Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions android/app/src/main/java/com/httpsms/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,7 @@ class Constants {
const val SIM2 = "SIM2"

const val TIMESTAMP_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSS'000000'ZZZZZ"

const val MAX_MMS_ATTACHMENT_SIZE: Long = (3L * 1024 * 1024) / 2
}
}
60 changes: 41 additions & 19 deletions android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import com.google.android.mms.pdu_alt.PduBody
import com.google.android.mms.pdu_alt.PduComposer
import com.google.android.mms.pdu_alt.PduPart
import com.google.android.mms.pdu_alt.SendReq
import okhttp3.MediaType
import java.io.File

class MyFirebaseMessagingService : FirebaseMessagingService() {
// [START receive_message]
Expand Down Expand Up @@ -177,20 +179,35 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
return handleMultipartMessage(message, parts)
}

fun extractFileName(url: String, prefix: String, mimeType: String? = null): String {
val fileName = url.substringAfterLast("/")
.substringBefore("?")
.takeIf { it.isNotBlank() && it.contains(".") }
?: run {
val extension = mimeType?.let { mime ->
val ext = mime.substringAfterLast("/")
if (ext.isNotBlank()) ".$ext" else ".bin"
} ?: ""
"attachment$extension"
}

return "${prefix}_$fileName"
}

private fun handleMmsMessage(message: Message): Result {
Timber.d("Processing MMS for message ID [${message.id}]")
val apiService = HttpSmsApiService.create(applicationContext)

val downloadedFiles = mutableListOf<java.io.File>()
val downloadedFiles = mutableListOf<Pair<File, MediaType>>()

try {
for ((index, attachment) in message.attachments!!.withIndex()) {
val file = apiService.downloadAttachment(applicationContext, attachment.url, message.id, index)
if (file == null) {
val file = apiService.downloadAttachment(applicationContext, attachment, message.id, index)
if (file.first == null || file.second == null) {
handleFailed(applicationContext, message.id, "Failed to download attachment or file size exceeded 1.5MB.")
return Result.failure()
}
downloadedFiles.add(file)
downloadedFiles.add(Pair(file.first!!, file.second!!))
}

val sendReq = SendReq()
Expand All @@ -207,30 +224,32 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
textPart.name = "text".toByteArray()
textPart.contentId = "text".toByteArray()
textPart.contentLocation = "text".toByteArray()

var messageBody = message.content
val encryptionKey = Settings.getEncryptionKey(applicationContext)
if (message.encrypted && !encryptionKey.isNullOrEmpty()) {
messageBody = Encrypter.decrypt(encryptionKey, messageBody)
}
textPart.data = messageBody.toByteArray(Charsets.UTF_8)

pduBody.addPart(textPart)
}

for ((index, file) in downloadedFiles.withIndex()) {
val attachment = message.attachments[index]
val fileBytes = file.readBytes()
val fileBytes = file.first.readBytes()

val mediaPart = PduPart()
mediaPart.contentType = attachment.contentType.toByteArray()

val fileName = "attachment_$index".toByteArray()
mediaPart.name = fileName
mediaPart.contentId = fileName
mediaPart.contentLocation = fileName
mediaPart.contentType = file.second.toString().toByteArray()


val fileName = extractFileName(message.attachments[index], index.toString(), file.second.toString())
mediaPart.name = fileName.toByteArray()
mediaPart.contentId = fileName.toByteArray()
mediaPart.contentLocation = fileName.toByteArray()
mediaPart.data = fileBytes


Timber.d("Adding MMS attachment with name [$fileName] and size [${fileBytes.size}] and type [${file.second}]")

pduBody.addPart(mediaPart)
}

Expand All @@ -249,7 +268,7 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
if (!mmsDir.exists()) {
mmsDir.mkdirs()
}

val pduFile = java.io.File(mmsDir, "pdu_${message.id}.dat")
java.io.FileOutputStream(pduFile).use { it.write(pduBytes) }

Expand All @@ -272,15 +291,18 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
} finally {
// Clean up any downloaded temporary files
downloadedFiles.forEach { file ->
if (file.exists()) {
file.delete()
if (file.first.exists()) {
file.first.delete()
}
}

// Also clean up the MMS PDU file to avoid cache buildup in cases where
// sendMultimediaMessage fails before the sent broadcast is delivered.
try {
val pduFile = java.io.File(applicationContext.cacheDir, "pdu_${message.id}.dat")
// The PDU file is stored under the "mms_attachments" cache subdirectory;
// delete it from the same location to ensure cleanup is effective.
val pduDir = File(applicationContext.cacheDir, "mms_attachments")
val pduFile = File(pduDir, "pdu_${message.id}.dat")
if (pduFile.exists()) {
val deleted = pduFile.delete()
if (!deleted) {
Expand Down
43 changes: 19 additions & 24 deletions android/app/src/main/java/com/httpsms/HttpSmsApiService.kt
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
package com.httpsms

import android.content.Context
import com.httpsms.Constants.Companion.MAX_MMS_ATTACHMENT_SIZE
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.apache.commons.text.StringEscapeUtils
import timber.log.Timber
import java.net.URI
import java.net.URL
import java.util.logging.Level
import java.util.logging.Logger.getLogger
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.URI
import java.net.URL
import java.util.logging.Level
import java.util.logging.Logger.getLogger


class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) {
Expand Down Expand Up @@ -162,49 +164,42 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) {
}

fun InputStream.copyToWithLimit(
out: OutputStream,
limit: Long,
out: OutputStream,
limit: Long,
bufferSize: Int = DEFAULT_BUFFER_SIZE
): Long {
var bytesCopied: Long = 0
val buffer = ByteArray(bufferSize)
var bytes = read(buffer)

while (bytes >= 0) {
bytesCopied += bytes

if (bytesCopied > limit) {
throw IOException("Download aborted: File exceeded maximum allowed size of $limit bytes.")
}

out.write(buffer, 0, bytes)
bytes = read(buffer)
}
return bytesCopied
}

// Downloads the attachment URL content locally
fun downloadAttachment(context: Context, urlString: String, messageId: String, attachmentIndex: Int): File? {
fun downloadAttachment(context: Context, urlString: String, messageId: String, attachmentIndex: Int): Pair<File?, MediaType?> {
val request = Request.Builder().url(urlString).build()

try {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Timber.e("Failed to download attachment: ${response.code}")
return null
return Pair(null, null)
}

val body = response.body
if (body == null) {
Timber.e("Failed to download attachment: response body is null")
return null
}

val maxSizeBytes = 1.5 * 1024 * 1024 // most (modern?) carriers have a 2MB limit, so targetting 1.5MB should be safe
val contentLength = body.contentLength()
Comment thread
AchoArnold marked this conversation as resolved.
if (contentLength > maxSizeBytes) {
if (contentLength > MAX_MMS_ATTACHMENT_SIZE) {
Timber.e("Attachment is too large ($contentLength bytes).")
return null
return Pair(null, null)
}
Comment thread
AchoArnold marked this conversation as resolved.

val mmsDir = File(context.cacheDir, "mms_attachments")
Expand All @@ -216,15 +211,15 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) {
val inputStream = body.byteStream()
FileOutputStream(tempFile).use { outputStream ->
inputStream.use { input ->
input.copyToWithLimit(outputStream, maxSizeBytes.toLong())
input.copyToWithLimit(outputStream, MAX_MMS_ATTACHMENT_SIZE.toLong())
}
}

return tempFile
return Pair(tempFile, body.contentType())
}
} catch (e: Exception) {
Timber.e(e, "Exception while downloading attachment")
return null
return Pair(null, null)
}
}

Expand Down Expand Up @@ -257,7 +252,7 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) {
}

if (!response.isSuccessful) {
Timber.e("error response [${response.body?.string()}] with code [${response.code}] while sending [${event}] event [${body}] for message with ID [${messageId}]")
Timber.e("error response [${response.body.string()}] with code [${response.code}] while sending [${event}] event [${body}] for message with ID [${messageId}]")
response.close()
return false
}
Expand Down
10 changes: 1 addition & 9 deletions android/app/src/main/java/com/httpsms/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,6 @@ data class Phone (
val userID: String,
)

// mms attachment
data class Attachment (
@Json(name = "content_type")
val contentType: String,

val url: String
)

data class Message (
val contact: String,
val content: String,
Expand Down Expand Up @@ -78,5 +70,5 @@ data class Message (
@Json(name = "updated_at")
val updatedAt: String,

val attachments: List<Attachment>? = null
val attachments: List<String>? = null
)
43 changes: 11 additions & 32 deletions api/pkg/entities/message.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package entities

import (
"strings"
"time"

"github.com/google/uuid"
"github.com/lib/pq"
)

// MessageType is the type of message if it is incoming or outgoing
Expand Down Expand Up @@ -82,23 +82,18 @@ func (s SIM) String() string {
return string(s)
}

type MessageAttachment struct {
ContentType string `json:"content_type" example:"image/jpeg"`
URL string `json:"url" example:"https://example.com/image.jpg"`
}

// Message represents a message sent between 2 phone numbers
type Message struct {
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
RequestID *string `json:"request_id" example:"153554b5-ae44-44a0-8f4f-7bbac5657ad4" validate:"optional"`
Owner string `json:"owner" example:"+18005550199"`
UserID UserID `json:"user_id" gorm:"index:idx_messages__user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
Contact string `json:"contact" example:"+18005550100"`
Content string `json:"content" example:"This is a sample text message"`
Attachments []MessageAttachment `json:"attachments,omitempty" gorm:"type:json;serializer:json"`
Encrypted bool `json:"encrypted" example:"false" gorm:"default:false"`
Type MessageType `json:"type" example:"mobile-terminated"`
Status MessageStatus `json:"status" example:"pending"`
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
RequestID *string `json:"request_id" example:"153554b5-ae44-44a0-8f4f-7bbac5657ad4" validate:"optional"`
Owner string `json:"owner" example:"+18005550199"`
UserID UserID `json:"user_id" gorm:"index:idx_messages__user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
Contact string `json:"contact" example:"+18005550100"`
Content string `json:"content" example:"This is a sample text message"`
Attachments pq.StringArray `json:"attachments" gorm:"type:text[];column:attachments_new" swaggertype:"array,string"`
Encrypted bool `json:"encrypted" example:"false" gorm:"default:false"`
Type MessageType `json:"type" example:"mobile-terminated"`
Status MessageStatus `json:"status" example:"pending"`
// SIM is the SIM card to use to send the message
// * SMS1: use the SIM card in slot 1
// * SMS2: use the SIM card in slot 2
Expand Down Expand Up @@ -234,19 +229,3 @@ func (message *Message) updateOrderTimestamp(timestamp time.Time) {
message.OrderTimestamp = timestamp
}
}

func GetAttachmentContentType(url string) string {
// Since there's no easy way to set a type in the CSV, defaulting to octet-stream and then just checking the file extension in the URL
contentType := "application/octet-stream"
lowerURL := strings.ToLower(url)
if strings.HasSuffix(lowerURL, ".jpg") || strings.HasSuffix(lowerURL, ".jpeg") {
contentType = "image/jpeg"
} else if strings.HasSuffix(lowerURL, ".png") {
contentType = "image/png"
} else if strings.HasSuffix(lowerURL, ".gif") {
contentType = "image/gif"
} else if strings.HasSuffix(lowerURL, ".mp4") {
contentType = "video/mp4"
}
return contentType
}
24 changes: 12 additions & 12 deletions api/pkg/events/message_api_sent_event.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,16 @@ const EventTypeMessageAPISent = "message.api.sent"

// MessageAPISentPayload is the payload of the EventTypeMessageSent event
type MessageAPISentPayload struct {
MessageID uuid.UUID `json:"message_id"`
UserID entities.UserID `json:"user_id"`
Owner string `json:"owner"`
RequestID *string `json:"request_id"`
MaxSendAttempts uint `json:"max_send_attempts"`
Contact string `json:"contact"`
ScheduledSendTime *time.Time `json:"scheduled_send_time"`
RequestReceivedAt time.Time `json:"request_received_at"`
Content string `json:"content"`
Attachments []entities.MessageAttachment `json:"attachments"`
Encrypted bool `json:"encrypted"`
SIM entities.SIM `json:"sim"`
MessageID uuid.UUID `json:"message_id"`
UserID entities.UserID `json:"user_id"`
Owner string `json:"owner"`
RequestID *string `json:"request_id"`
MaxSendAttempts uint `json:"max_send_attempts"`
Contact string `json:"contact"`
ScheduledSendTime *time.Time `json:"scheduled_send_time"`
RequestReceivedAt time.Time `json:"request_received_at"`
Content string `json:"content"`
Attachments []string `json:"attachments"`
Encrypted bool `json:"encrypted"`
SIM entities.SIM `json:"sim"`
}
Loading
Loading