Skip to content

Commit adfa171

Browse files
Added PDU generation via android-smsmms and MMS sender/handler
1 parent b35f60d commit adfa171

4 files changed

Lines changed: 183 additions & 0 deletions

File tree

android/app/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ dependencies {
6767
implementation 'com.google.android.material:material:1.12.0'
6868
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
6969
implementation 'com.googlecode.libphonenumber:libphonenumber:9.0.4'
70+
implementation 'com.klinkerapps:android-smsmms:5.2.6'
7071
testImplementation 'junit:junit:4.13.2'
7172
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
7273
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'

android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ import com.google.firebase.messaging.RemoteMessage
99
import com.httpsms.SentReceiver.FailedMessageWorker
1010
import timber.log.Timber
1111

12+
import com.google.android.mms.pdu.CharacterSets
13+
import com.google.android.mms.pdu.EncodedStringValue
14+
import com.google.android.mms.pdu.PduBody
15+
import com.google.android.mms.pdu.PduComposer
16+
import com.google.android.mms.pdu.PduPart
17+
import com.google.android.mms.pdu.SendReq
18+
1219
class MyFirebaseMessagingService : FirebaseMessagingService() {
1320
// [START receive_message]
1421
override fun onMessageReceived(remoteMessage: RemoteMessage) {
@@ -158,13 +165,119 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
158165
}
159166

160167
Receiver.register(applicationContext)
168+
169+
if (message.attachments != null && message.attachments.isNotEmpty()) {
170+
return handleMmsMessage(message)
171+
}
172+
161173
val parts = getMessageParts(applicationContext, message)
162174
if (parts.size == 1) {
163175
return handleSingleMessage(message, parts.first())
164176
}
165177
return handleMultipartMessage(message, parts)
166178
}
167179

180+
private fun handleMmsMessage(message: Message): Result {
181+
Timber.d("Processing MMS for message ID [${message.id}]")
182+
val apiService = HttpSmsApiService.create(applicationContext)
183+
184+
val downloadedFiles = mutableListOf<java.io.File>()
185+
186+
try {
187+
for ((index, attachment) in message.attachments!!.withIndex()) {
188+
val file = apiService.downloadAttachment(applicationContext, attachment.url, message.id, index)
189+
if (file == null) {
190+
handleFailed(applicationContext, message.id, "Failed to download attachment or file size exceeded 1.5MB.")
191+
return Result.failure()
192+
}
193+
downloadedFiles.add(file)
194+
}
195+
196+
val sendReq = SendReq()
197+
198+
val encodedContact = EncodedStringValue(message.contact)
199+
sendReq.to = arrayOf(encodedContact)
200+
201+
val pduBody = PduBody()
202+
203+
if (message.content.isNotEmpty()) {
204+
val textPart = PduPart()
205+
textPart.setCharset(CharacterSets.UTF_8)
206+
textPart.contentType = "text/plain".toByteArray()
207+
textPart.name = "text".toByteArray()
208+
textPart.contentId = "text".toByteArray()
209+
textPart.contentLocation = "text".toByteArray()
210+
211+
var messageBody = message.content
212+
val encryptionKey = Settings.getEncryptionKey(applicationContext)
213+
if (message.encrypted && !encryptionKey.isNullOrEmpty()) {
214+
messageBody = Encrypter.decrypt(encryptionKey, messageBody)
215+
}
216+
textPart.data = messageBody.toByteArray(Charsets.UTF_8)
217+
218+
pduBody.addPart(textPart)
219+
}
220+
221+
for ((index, file) in downloadedFiles.withIndex()) {
222+
val attachment = message.attachments[index]
223+
val fileBytes = file.readBytes()
224+
225+
val mediaPart = PduPart()
226+
mediaPart.contentType = attachment.contentType.toByteArray()
227+
228+
val fileName = "attachment_$index".toByteArray()
229+
mediaPart.name = fileName
230+
mediaPart.contentId = fileName
231+
mediaPart.contentLocation = fileName
232+
mediaPart.data = fileBytes
233+
234+
pduBody.addPart(mediaPart)
235+
}
236+
237+
sendReq.body = pduBody
238+
239+
val pduComposer = PduComposer(applicationContext, sendReq)
240+
val pduBytes = pduComposer.make()
241+
242+
if (pduBytes == null) {
243+
Timber.e("PduComposer failed to generate PDU byte array")
244+
handleFailed(applicationContext, message.id, "Failed to compose MMS PDU.")
245+
return Result.failure()
246+
}
247+
248+
val mmsDir = java.io.File(applicationContext.cacheDir, "mms_attachments")
249+
if (!mmsDir.exists()) {
250+
mmsDir.mkdirs()
251+
}
252+
253+
val pduFile = java.io.File(mmsDir, "pdu_${message.id}.dat")
254+
java.io.FileOutputStream(pduFile).use { it.write(pduBytes) }
255+
256+
val pduUri = androidx.core.content.FileProvider.getUriForFile(
257+
applicationContext,
258+
"${BuildConfig.APPLICATION_ID}.fileprovider",
259+
pduFile
260+
)
261+
262+
val sentIntent = createPendingIntent(message.id, SmsManagerService.sentAction())
263+
SmsManagerService().sendMultimediaMessage(applicationContext, pduUri, message.sim, sentIntent)
264+
265+
Timber.d("Successfully dispatched MMS for message ID [${message.id}]")
266+
return Result.success()
267+
268+
} catch (e: Exception) {
269+
Timber.e(e, "Failed to send MMS for message ID [${message.id}]")
270+
handleFailed(applicationContext, message.id, e.message ?: "Internal error while building or sending MMS.")
271+
return Result.failure()
272+
} finally {
273+
downloadedFiles.forEach { file ->
274+
if (file.exists()) {
275+
file.delete()
276+
}
277+
}
278+
}
279+
}
280+
168281
private fun handleMultipartMessage(message:Message, parts: ArrayList<String>): Result {
169282
Timber.d("sending multipart SMS for message with ID [${message.id}]")
170283
return try {

android/app/src/main/java/com/httpsms/HttpSmsApiService.kt

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import java.net.URI
1111
import java.net.URL
1212
import java.util.logging.Level
1313
import java.util.logging.Logger.getLogger
14+
import java.io.File
15+
import java.io.FileOutputStream
1416

1517

1618
class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) {
@@ -156,6 +158,51 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) {
156158
return true
157159
}
158160

161+
fun downloadAttachment(context: Context, urlString: String, messageId: String, attachmentIndex: Int): File? {
162+
val request = Request.Builder().url(urlString).build()
163+
164+
try {
165+
val response = client.newCall(request).execute()
166+
if (!response.isSuccessful) {
167+
Timber.e("Failed to download attachment: ${response.code}")
168+
response.close()
169+
return null
170+
}
171+
172+
val maxSizeBytes = 1.5 * 1024 * 1024 // most (modern?) carriers have a 2MB limit, so targetting 1.5MB should be safe
173+
val contentLength = response.body?.contentLength() ?: -1L
174+
if (contentLength > maxSizeBytes) {
175+
Timber.e("Attachment is too large ($contentLength bytes).")
176+
response.close()
177+
return null
178+
}
179+
180+
val mmsDir = File(context.cacheDir, "mms_attachments")
181+
if (!mmsDir.exists()) {
182+
mmsDir.mkdirs()
183+
}
184+
185+
val tempFile = File(mmsDir, "mms_${messageId}_$attachmentIndex")
186+
val inputStream = response.body?.byteStream()
187+
val outputStream = FileOutputStream(tempFile)
188+
inputStream?.copyTo(outputStream)
189+
190+
outputStream.close()
191+
inputStream?.close()
192+
response.close()
193+
194+
if (tempFile.length() > maxSizeBytes) {
195+
tempFile.delete()
196+
Timber.e("Downloaded file exceeded 1.5MB limit.")
197+
return null
198+
}
199+
200+
return tempFile
201+
} catch (e: Exception) {
202+
Timber.e(e, "Exception while downloading attachment")
203+
return null
204+
}
205+
}
159206

160207
private fun sendEvent(messageId: String, event: String, timestamp: String, reason: String? = null): Boolean {
161208
var reasonString = "null"

android/app/src/main/java/com/httpsms/SentReceiver.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import timber.log.Timber
1717

1818
internal class SentReceiver : BroadcastReceiver() {
1919
override fun onReceive(context: Context, intent: Intent) {
20+
val messageId = intent.getStringExtra(Constants.KEY_MESSAGE_ID)
21+
cleanupPduFile(context, messageId)
2022
when (resultCode) {
2123
Activity.RESULT_OK -> handleMessageSent(context, intent.getStringExtra(Constants.KEY_MESSAGE_ID))
2224
SmsManager.RESULT_ERROR_GENERIC_FAILURE -> handleMessageFailed(context, intent.getStringExtra(Constants.KEY_MESSAGE_ID), "GENERIC_FAILURE")
@@ -27,6 +29,26 @@ internal class SentReceiver : BroadcastReceiver() {
2729
}
2830
}
2931

32+
private fun cleanupPduFile(context: Context, messageId: String?) {
33+
if (messageId == null) return
34+
35+
try {
36+
val baseMessageId = messageId.substringBefore(".")
37+
val mmsDir = File(context.cacheDir, "mms_attachments")
38+
val pduFile = File(mmsDir, "pdu_$baseMessageId.dat")
39+
40+
if (pduFile.exists()) {
41+
if (pduFile.delete()) {
42+
Timber.d("Cleaned up PDU file for message ID [$baseMessageId]")
43+
} else {
44+
Timber.w("Failed to delete PDU file for message ID [$baseMessageId]")
45+
}
46+
}
47+
} catch (e: Exception) {
48+
Timber.e(e, "Error cleaning up PDU file for message ID [$messageId]")
49+
}
50+
}
51+
3052
private fun handleMessageSent(context: Context, messageId: String?) {
3153
if (!Receiver.isValid(context, messageId)) {
3254
return

0 commit comments

Comments
 (0)