diff --git a/README.md b/README.md index b6a406667..d29b4f6a1 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ SMS Gateway turns your Android smartphone into an SMS gateway. It's a lightweigh - 🔔 **Real-time incoming message notifications:** Receive instant SMS and MMS notifications via webhooks. - 📖 **Read received messages:** Access [previously received messages](https://docs.sms-gate.app/features/reading-messages/) via the same webhooks used for real-time notifications. - 📎 **MMS download notifications:** Receive webhook notifications when MMS messages are fully downloaded, including message body and attachments. +- 🖼️ **Send and receive MMS with payloads:** Send images, audio, video, and other media as MMS with inline or URL-referenced attachments. Received attachments are persisted locally and exposed via the API. See [`docs/MMS.md`](docs/MMS.md). 🔒 Security and Privacy: @@ -158,6 +159,7 @@ To use the application, you need to grant the following permissions: - **READ_SMS**: This permission is optional. If you want to read previous SMS messages, you need to grant this permission. - **RECEIVE_SMS**: This permission is optional. If you want to receive webhooks on incoming SMS, you need to grant this permission. - **RECEIVE_MMS**, **RECEIVE_WAP_PUSH**: This permissions are optional. If you want to receive webhooks on incoming MMS messages, you need to grant these permissions. +- **Default SMS app role** (optional but recommended): required to actively download inbound MMS and for reliable MMS sending on carriers such as Verizon. Grant from the app's Settings → Default SMS app. See [`docs/MMS.md`](docs/MMS.md) for details. ### Installation from APK @@ -250,8 +252,8 @@ Use webhooks to receive notifications for messaging events (e.g., incoming SMS a | `sms:delivered` | Triggered when an SMS message is delivered | | `sms:failed` | Triggered when an SMS message fails to send | | `sms:data-received` | Triggered when a data SMS is received | -| `mms:received` | Triggered when an MMS notification is received (before download) | -| `mms:downloaded` | Triggered when an MMS message is fully downloaded with body and attachments | +| `mms:received` | Triggered when an MMS notification is received (before download). See [`docs/MMS.md`](docs/MMS.md). | +| `mms:downloaded` | Triggered when an MMS message is fully downloaded with body and attachments. Attachments are served both inline (base64) and at `/inbox/{id}/attachments/{partId}`. | | `system:ping` | Periodic heartbeat event | #### Setting Up Webhooks @@ -309,6 +311,7 @@ For cloud mode the process is similar, simply change the URL to https://api.sms- - [ ] Implement region-based restrictions to prevent international SMS. - [ ] Provide an API endpoint to retrieve the list of available SIM cards on the device. - [x] Include detailed error messages in responses and logs. +- [x] Send and receive MMS with attachments (images, audio, video, etc.) — see [`docs/MMS.md`](docs/MMS.md). See the [open issues](https://github.com/capcom6/android-sms-gateway/issues) for a full list of proposed features (and known issues). diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 684e7cdb2..8cc7a5f4f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,9 +7,10 @@ android:required="true" /> + - + @@ -92,6 +93,18 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/assets/api/swagger.json b/app/src/main/assets/api/swagger.json index 37b2c5a69..babae2eee 100644 --- a/app/src/main/assets/api/swagger.json +++ b/app/src/main/assets/api/swagger.json @@ -27,6 +27,11 @@ "description": "Size in bytes, 0 if unknown.", "nullable": true, "type": "integer" + }, + "url": { + "description": "Relative URL path that serves these bytes on the same host as this API, e.g. /inbox/{messageId}/attachments/{partId}. Authenticate the same way as the rest of the API.", + "nullable": true, + "type": "string" } }, "required": [ @@ -35,6 +40,55 @@ ], "type": "object" }, + "smsgateway.MmsMessage": { + "description": "MMS payload for POST /message. Requires the app to be the default SMS app for reliable delivery on most carriers.", + "properties": { + "subject": { + "description": "Optional MMS subject line.", + "nullable": true, + "type": "string" + }, + "text": { + "description": "Optional text/plain body to include alongside attachments.", + "nullable": true, + "type": "string" + }, + "attachments": { + "description": "One or more media parts. Each attachment must provide exactly one of `data` (inline base64) or `url` (fetched by the device at send time).", + "items": { + "$ref": "#/components/schemas/smsgateway.MmsAttachment" + }, + "type": "array" + } + }, + "type": "object" + }, + "smsgateway.MmsAttachment": { + "properties": { + "contentType": { + "description": "MIME type. Common values: image/jpeg, image/png, image/gif, video/mp4, audio/amr, audio/mpeg, text/plain.", + "type": "string" + }, + "name": { + "description": "Optional filename for the part (used as Content-Location).", + "nullable": true, + "type": "string" + }, + "data": { + "description": "Base64-encoded bytes. Mutually exclusive with `url`.", + "format": "byte", + "nullable": true, + "type": "string" + }, + "url": { + "description": "HTTP(S) URL the device will fetch immediately before sending. Mutually exclusive with `data`.", + "nullable": true, + "type": "string" + } + }, + "required": ["contentType"], + "type": "object" + }, "MmsDownloadedPayload": { "allOf": [ { @@ -355,6 +409,15 @@ "description": "Present only when `includeContent=true` and the message type is data.", "type": "object" }, + "mmsMessage": { + "allOf": [ + { + "$ref": "#/components/schemas/smsgateway.MmsMessage" + } + ], + "description": "Present only when `includeContent=true` and the message type is MMS.", + "type": "object" + }, "deviceId": { "description": "Device ID", "example": "PyDmBQZZXYmyxMwED8Fzy", @@ -753,6 +816,15 @@ "description": "Data message", "type": "object" }, + "mmsMessage": { + "allOf": [ + { + "$ref": "#/components/schemas/smsgateway.MmsMessage" + } + ], + "description": "MMS message (multimedia: images, audio, video, etc.)", + "type": "object" + }, "deviceId": { "description": "Optional device ID for explicit selection", "example": "PyDmBQZZXYmyxMwED8Fzy", @@ -890,6 +962,15 @@ "description": "Present only when `includeContent=true` and the message type is data.", "type": "object" }, + "mmsMessage": { + "allOf": [ + { + "$ref": "#/components/schemas/smsgateway.MmsMessage" + } + ], + "description": "Present only when `includeContent=true` and the message type is MMS.", + "type": "object" + }, "deviceId": { "description": "Device ID", "example": "PyDmBQZZXYmyxMwED8Fzy", @@ -2131,6 +2212,81 @@ ] } }, + "/inbox/{id}/attachments/{partId}": { + "get": { + "summary": "Download MMS attachment", + "description": "Downloads a stored MMS attachment by message ID and part ID.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Inbox message ID" + }, + { + "name": "partId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "description": "Attachment part ID" + } + ], + "responses": { + "200": { + "description": "Attachment bytes", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/smsgateway.ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/smsgateway.ErrorResponse" + } + } + } + }, + "404": { + "description": "Attachment not found" + } + }, + "security": [ + { + "ApiAuth": [] + }, + { + "JWTAuth": [] + } + ], + "tags": [ + "User", + "Inbox" + ] + } + }, "/inbox/refresh": { "post": { "description": "Refreshes inbox messages. Webhooks are triggered when triggerWebhooks is true.", diff --git a/app/src/main/java/me/capcom/smsgateway/App.kt b/app/src/main/java/me/capcom/smsgateway/App.kt index 53e6a226c..04a49224f 100644 --- a/app/src/main/java/me/capcom/smsgateway/App.kt +++ b/app/src/main/java/me/capcom/smsgateway/App.kt @@ -13,6 +13,7 @@ import me.capcom.smsgateway.modules.incoming.incomingModule import me.capcom.smsgateway.modules.localserver.localserverModule import me.capcom.smsgateway.modules.logs.logsModule import me.capcom.smsgateway.modules.messages.messagesModule +import me.capcom.smsgateway.modules.mms.mmsModule import me.capcom.smsgateway.modules.notifications.notificationsModule import me.capcom.smsgateway.modules.orchestrator.OrchestratorService import me.capcom.smsgateway.modules.orchestrator.orchestratorModule @@ -44,6 +45,7 @@ class App: Application() { dbModule, logsModule, notificationsModule, + mmsModule, messagesModule, incomingModule, receiverModule, diff --git a/app/src/main/java/me/capcom/smsgateway/data/entities/Message.kt b/app/src/main/java/me/capcom/smsgateway/data/entities/Message.kt index 638276156..e9acdfcbf 100644 --- a/app/src/main/java/me/capcom/smsgateway/data/entities/Message.kt +++ b/app/src/main/java/me/capcom/smsgateway/data/entities/Message.kt @@ -12,6 +12,7 @@ import java.util.Date enum class MessageType { Text, Data, + Mms, } @Entity( @@ -80,6 +81,7 @@ data class Message constructor( type = when (content) { is MessageContent.Text -> MessageType.Text is MessageContent.Data -> MessageType.Data + is MessageContent.Mms -> MessageType.Mms }, createdAt = createdAt, ) @@ -96,6 +98,12 @@ data class Message constructor( else -> null } + val mmsContent: MessageContent.Mms? + get() = when (type) { + MessageType.Mms -> gson.fromJson(content, MessageContent.Mms::class.java) + else -> null + } + companion object { const val PRIORITY_MIN: Byte = Byte.MIN_VALUE const val PRIORITY_DEFAULT: Byte = 0 diff --git a/app/src/main/java/me/capcom/smsgateway/domain/MessageContent.kt b/app/src/main/java/me/capcom/smsgateway/domain/MessageContent.kt index 42aefc12e..19567e4b3 100644 --- a/app/src/main/java/me/capcom/smsgateway/domain/MessageContent.kt +++ b/app/src/main/java/me/capcom/smsgateway/domain/MessageContent.kt @@ -12,4 +12,20 @@ sealed class MessageContent { return "$data:$port" } } -} \ No newline at end of file + + data class Mms( + val subject: String?, + val text: String?, + val attachments: List, + ) : MessageContent() { + data class Attachment( + val contentType: String, + val name: String?, + val data: String, + ) + + override fun toString(): String { + return "mms(subject.len=${subject?.length ?: 0}, text.len=${text?.length ?: 0}, attachments=${attachments.size})" + } + } +} diff --git a/app/src/main/java/me/capcom/smsgateway/modules/gateway/GatewayApi.kt b/app/src/main/java/me/capcom/smsgateway/modules/gateway/GatewayApi.kt index dddf2ebc7..1c2850a66 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/gateway/GatewayApi.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/gateway/GatewayApi.kt @@ -185,6 +185,25 @@ class GatewayApi( val data: String, val port: UShort, ) : MessageContent() + + class Mms( + val subject: String?, + val text: String?, + // Gson can leave this null when the field is omitted (text-only + // MMS), despite the non-null Kotlin type. Normalize via the + // public accessor below so callers never see null. + @SerializedName("attachments") + private val _attachments: List?, + ) : MessageContent() { + val attachments: List + get() = _attachments.orEmpty() + + class Attachment( + val contentType: String, + val name: String?, + val data: String, + ) + } } data class Message( @@ -193,6 +212,8 @@ class GatewayApi( val _textMessage: MessageContent.Text?, @SerializedName("dataMessage") val _dataMessage: MessageContent.Data?, + @SerializedName("mmsMessage") + val _mmsMessage: MessageContent.Mms?, val phoneNumbers: List, val simNumber: Int?, val withDeliveryReport: Boolean?, @@ -208,6 +229,7 @@ class GatewayApi( val content: MessageContent get() = this._dataMessage ?: this._textMessage + ?: this._mmsMessage ?: _message?.let { MessageContent.Text(it) } ?: throw RuntimeException("Invalid message content") } diff --git a/app/src/main/java/me/capcom/smsgateway/modules/gateway/GatewayService.kt b/app/src/main/java/me/capcom/smsgateway/modules/gateway/GatewayService.kt index 232984ad1..7c94b59b1 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/gateway/GatewayService.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/gateway/GatewayService.kt @@ -223,12 +223,14 @@ class GatewayService( try { processMessage(context, message) } catch (th: Throwable) { + val contentForLog = runCatching { message.content }.getOrNull() logsService.insert( LogEntry.Priority.ERROR, MODULE_NAME, "Failed to process message", mapOf( - "message" to message, + "messageId" to message.id, + "messageType" to (contentForLog?.let { it::class.simpleName } ?: "Invalid"), "exception" to th.stackTraceToString(), ) ) @@ -254,6 +256,17 @@ class GatewayService( content.data, content.port ) + is GatewayApi.MessageContent.Mms -> MessageContent.Mms( + subject = content.subject, + text = content.text, + attachments = content.attachments.map { + MessageContent.Mms.Attachment( + contentType = it.contentType, + name = it.name, + data = it.data, + ) + } + ) }, message.phoneNumbers, message.isEncrypted ?: false, diff --git a/app/src/main/java/me/capcom/smsgateway/modules/incoming/IncomingMessagesService.kt b/app/src/main/java/me/capcom/smsgateway/modules/incoming/IncomingMessagesService.kt index 04226b638..5d30945ab 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/incoming/IncomingMessagesService.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/incoming/IncomingMessagesService.kt @@ -1,17 +1,20 @@ package me.capcom.smsgateway.modules.incoming import android.content.Context +import android.util.Base64 import me.capcom.smsgateway.helpers.SubscriptionsHelper import me.capcom.smsgateway.modules.incoming.db.IncomingMessage import me.capcom.smsgateway.modules.incoming.db.IncomingMessageType import me.capcom.smsgateway.modules.incoming.repositories.IncomingMessagesRepository import me.capcom.smsgateway.modules.logs.LogsService import me.capcom.smsgateway.modules.logs.db.LogEntry +import me.capcom.smsgateway.modules.mms.MmsAttachmentStorage import me.capcom.smsgateway.modules.receiver.data.InboxMessage class IncomingMessagesService( private val context: Context, private val repository: IncomingMessagesRepository, + private val attachmentStorage: MmsAttachmentStorage, private val logsService: LogsService, ) { fun save(message: InboxMessage): IncomingMessage { @@ -42,6 +45,9 @@ class IncomingMessagesService( ).also { try { repository.insert(it) + if (message is InboxMessage.MMS) { + persistMmsAttachments(message) + } } catch (e: Exception) { logsService.insert( LogEntry.Priority.ERROR, @@ -56,6 +62,50 @@ class IncomingMessagesService( } } + private fun persistMmsAttachments(message: InboxMessage.MMS) { + message.attachments.forEach { att -> + val decoded = att.data?.let { + runCatching { + Base64.decode(it, Base64.DEFAULT) + }.onFailure { e -> + logsService.insert( + LogEntry.Priority.WARN, + MODULE_NAME, + "Failed to decode MMS attachment data", + mapOf( + "messageId" to message.messageId, + "partId" to att.partId, + "name" to (att.name ?: ""), + "error" to (e.message ?: e.toString()), + ) + ) + }.getOrNull() + } + if (decoded != null) { + try { + attachmentStorage.store( + message.messageId, + att.partId, + att.name, + att.contentType, + decoded, + ) + } catch (e: Exception) { + logsService.insert( + LogEntry.Priority.WARN, + me.capcom.smsgateway.modules.receiver.MODULE_NAME, + "Failed to persist MMS attachment", + mapOf( + "messageId" to message.messageId, + "partId" to att.partId, + "error" to (e.message ?: e.toString()), + ) + ) + } + } + } + } + suspend fun count(type: IncomingMessageType?, from: Long, to: Long): Int { return repository.count(type, from, to) } 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 21572c4f7..49cf180eb 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 @@ -181,7 +181,7 @@ class WebService : Service() { it.register(this) } } - InboxRoutes(applicationContext, get(), get(), get()).let { + InboxRoutes(applicationContext, get(), get(), get(), get()).let { route("/inbox") { it.register(this) } diff --git a/app/src/main/java/me/capcom/smsgateway/modules/localserver/domain/messages/Message.kt b/app/src/main/java/me/capcom/smsgateway/modules/localserver/domain/messages/Message.kt index 3ee201487..9d1acbddd 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/localserver/domain/messages/Message.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/localserver/domain/messages/Message.kt @@ -12,6 +12,7 @@ open class Message( val scheduleAt: Date?, val textMessage: TextMessage?, val dataMessage: DataMessage?, + val mmsMessage: MmsMessage?, val hashedMessage: HashedMessage?, val recipients: List, val states: Map, diff --git a/app/src/main/java/me/capcom/smsgateway/modules/localserver/domain/messages/MmsMessage.kt b/app/src/main/java/me/capcom/smsgateway/modules/localserver/domain/messages/MmsMessage.kt new file mode 100644 index 000000000..79bfda74a --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/localserver/domain/messages/MmsMessage.kt @@ -0,0 +1,22 @@ +package me.capcom.smsgateway.modules.localserver.domain.messages + +data class MmsMessage( + val subject: String? = null, + val text: String? = null, + val attachments: List = emptyList(), +) { + data class Attachment( + val contentType: String, + val data: String, + val name: String? = null, + ) { + fun validate(index: Int) { + if (contentType.isBlank()) { + throw IllegalArgumentException("Attachment $index: contentType is required") + } + if (data.isBlank()) { + throw IllegalArgumentException("Attachment $index: must provide data") + } + } + } +} diff --git a/app/src/main/java/me/capcom/smsgateway/modules/localserver/domain/messages/PostMessageRequest.kt b/app/src/main/java/me/capcom/smsgateway/modules/localserver/domain/messages/PostMessageRequest.kt index 2d869d50a..f1dda989b 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/localserver/domain/messages/PostMessageRequest.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/localserver/domain/messages/PostMessageRequest.kt @@ -16,6 +16,7 @@ data class PostMessageRequest( val textMessage: TextMessage? = null, val dataMessage: DataMessage? = null, + val mmsMessage: MmsMessage? = null, val deviceId: String? = null, @@ -43,9 +44,9 @@ data class PostMessageRequest( fun validate(): PostMessageRequest { val messageTypes = - listOfNotNull(textMessage, dataMessage, message) + listOfNotNull(textMessage, dataMessage, mmsMessage, message) when { - messageTypes.isEmpty() -> throw IllegalArgumentException("Must specify exactly one of: textMessage, dataMessage, or message") + messageTypes.isEmpty() -> throw IllegalArgumentException("Must specify exactly one of: textMessage, dataMessage, mmsMessage, or message") messageTypes.size > 1 -> throw IllegalArgumentException("Cannot specify multiple message types simultaneously") } @@ -72,6 +73,15 @@ data class PostMessageRequest( throw IllegalArgumentException("Text message is empty") } + mmsMessage?.let { mms -> + val hasBody = !mms.text.isNullOrBlank() + if (!hasBody && mms.attachments.isEmpty()) { + throw IllegalArgumentException("MMS must have text or at least one attachment") + } + + mms.attachments.forEachIndexed { i, att -> att.validate(i) } + } + if (phoneNumbers.isEmpty()) { throw IllegalArgumentException("Empty phone numbers list") } diff --git a/app/src/main/java/me/capcom/smsgateway/modules/localserver/routes/InboxRoutes.kt b/app/src/main/java/me/capcom/smsgateway/modules/localserver/routes/InboxRoutes.kt index 32c383d6b..34f5cce8f 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/localserver/routes/InboxRoutes.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/localserver/routes/InboxRoutes.kt @@ -1,9 +1,12 @@ package me.capcom.smsgateway.modules.localserver.routes import android.content.Context +import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.server.application.call +import io.ktor.server.http.content.LocalFileContent import io.ktor.server.request.receive +import io.ktor.server.response.header import io.ktor.server.response.respond import io.ktor.server.routing.Route import io.ktor.server.routing.get @@ -17,6 +20,7 @@ import me.capcom.smsgateway.modules.localserver.LocalServerSettings import me.capcom.smsgateway.modules.localserver.auth.AuthScopes import me.capcom.smsgateway.modules.localserver.auth.requireScope import me.capcom.smsgateway.modules.localserver.domain.InboxRefreshRequest +import me.capcom.smsgateway.modules.mms.MmsAttachmentStorage import me.capcom.smsgateway.modules.receiver.ReceiverService import java.util.Date @@ -24,6 +28,7 @@ class InboxRoutes( private val context: Context, private val incomingMessagesService: IncomingMessagesService, private val receiverService: ReceiverService, + private val attachmentStorage: MmsAttachmentStorage, private val settings: LocalServerSettings, ) { fun register(routing: Route) { @@ -44,6 +49,15 @@ class InboxRoutes( ) return@get } + val includeAttachments = call.request.queryParameters["includeAttachments"]?.let { + it.toBooleanStrictOrNull() ?: run { + call.respond( + HttpStatusCode.BadRequest, + mapOf("message" to "includeAttachments must be true or false") + ) + return@get + } + } ?: false val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 50 val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0 @@ -133,26 +147,34 @@ class InboxRoutes( call.response.headers.append("X-Total-Count", total.toString()) - call.respond(messages.map { it.toDomain() } as GetIncomingMessagesResponse) + call.respond(messages.map { it.toDomain(includeAttachments) } as GetIncomingMessagesResponse) } -// get("{id}") { -// if (!requireScope(AuthScopes.InboxRead)) return@get -// val id = call.parameters["id"] -// ?: return@get call.respond(HttpStatusCode.BadRequest) -// -// val message = try { -// incomingMessagesService.getById(id) -// ?: return@get call.respond(HttpStatusCode.NotFound) -// } catch (e: Throwable) { -// return@get call.respond( -// HttpStatusCode.InternalServerError, -// mapOf("message" to e.message) -// ) -// } -// -// call.respond(message.toDomain()) -// } + get("{id}/attachments/{partId}") { + if (!requireScope(AuthScopes.InboxRead)) return@get + val id = call.parameters["id"] + ?: return@get call.respond(HttpStatusCode.BadRequest) + val partId = call.parameters["partId"]?.toLongOrNull() + ?: return@get call.respond( + HttpStatusCode.BadRequest, + mapOf("message" to "partId must be a number") + ) + + val attachment = attachmentStorage.find(id, partId) + ?: return@get call.respond(HttpStatusCode.NotFound) + val file = attachment.file + if (!file.exists()) { + return@get call.respond(HttpStatusCode.NotFound) + } + + val safeName = attachment.displayName + .replace(Regex("[\\r\\n\"\\\\]"), "_") + call.response.header( + "Content-Disposition", + "attachment; filename=\"${safeName}\"" + ) + call.respond(LocalFileContent(file, parseContentType(attachment.contentType))) + } post("refresh") { if (!requireScope(AuthScopes.InboxRefresh)) return@post @@ -197,9 +219,33 @@ class InboxRoutes( val simNumber: Int?, val contentPreview: String, val createdAt: Date, + val attachments: List = emptyList(), + ) + + data class AttachmentRef( + val partId: Long, + val name: String, + val size: Long, + val contentType: String, ) - private fun IncomingMessage.toDomain() = InboxMessage( + private fun listAttachmentRefs(messageId: String): List { + return attachmentStorage.list(messageId).map { attachment -> + AttachmentRef( + partId = attachment.partId, + name = attachment.displayName, + size = attachment.file.length(), + contentType = attachment.contentType, + ) + }.sortedBy { it.partId } + } + + private fun parseContentType(raw: String): ContentType { + return runCatching { ContentType.parse(raw) } + .getOrDefault(ContentType.Application.OctetStream) + } + + private fun IncomingMessage.toDomain(includeAttachments: Boolean = false) = InboxMessage( id = id, type = type, sender = sender, @@ -207,7 +253,8 @@ class InboxRoutes( simNumber = simNumber, contentPreview = contentPreview, createdAt = Date(createdAt), + attachments = if (includeAttachments) listAttachmentRefs(id) else emptyList(), ) } -typealias GetIncomingMessagesResponse = List \ No newline at end of file +typealias GetIncomingMessagesResponse = List 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 25a69679b..14834f824 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 @@ -21,6 +21,7 @@ import me.capcom.smsgateway.modules.localserver.auth.AuthScopes import me.capcom.smsgateway.modules.localserver.auth.requireScope import me.capcom.smsgateway.modules.localserver.domain.InboxRefreshRequest import me.capcom.smsgateway.modules.localserver.domain.messages.DataMessage +import me.capcom.smsgateway.modules.localserver.domain.messages.MmsMessage import me.capcom.smsgateway.modules.localserver.domain.messages.PostMessageRequest import me.capcom.smsgateway.modules.localserver.domain.messages.TextMessage import me.capcom.smsgateway.modules.messages.MessagesService @@ -154,6 +155,20 @@ class MessagesRoutes( ) } + request.mmsMessage != null -> { + MessageContent.Mms( + subject = request.mmsMessage.subject, + text = request.mmsMessage.text, + attachments = request.mmsMessage.attachments.map { + MessageContent.Mms.Attachment( + contentType = it.contentType, + name = it.name, + data = it.data, + ) + } + ) + } + else -> { // This case should be caught by validation, but just in case throw IllegalStateException("Unknown message type") @@ -267,6 +282,23 @@ class MessagesRoutes( else -> null }, + mmsMessage = when (includeContent) { + true -> message.mmsContent?.let { mms -> + MmsMessage( + subject = mms.subject, + text = mms.text, + attachments = mms.attachments.map { + MmsMessage.Attachment( + contentType = it.contentType, + name = it.name, + data = it.data, + ) + } + ) + } + + else -> null + }, hashedMessage = null, recipients = recipients.map { me.capcom.smsgateway.modules.localserver.domain.messages.Message.Recipient( diff --git a/app/src/main/java/me/capcom/smsgateway/modules/messages/MessagesRepository.kt b/app/src/main/java/me/capcom/smsgateway/modules/messages/MessagesRepository.kt index f1ee5eaf4..c4f825167 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/messages/MessagesRepository.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/messages/MessagesRepository.kt @@ -96,6 +96,11 @@ class MessagesRepository(private val dao: MessagesDao) { message.message.content, MessageContent.Data::class.java ) + + MessageType.Mms -> gson.fromJson( + message.message.content, + MessageContent.Mms::class.java + ) }, phoneNumbers = message.recipients.filter { it.state == ProcessingState.Pending } .map { it.phoneNumber }, diff --git a/app/src/main/java/me/capcom/smsgateway/modules/messages/MessagesService.kt b/app/src/main/java/me/capcom/smsgateway/modules/messages/MessagesService.kt index 001273d08..1f3896c48 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/messages/MessagesService.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/messages/MessagesService.kt @@ -39,6 +39,7 @@ import me.capcom.smsgateway.modules.messages.events.MessageStateChangedEvent import me.capcom.smsgateway.modules.messages.exceptions.ConflictException import me.capcom.smsgateway.modules.messages.workers.LogTruncateWorker import me.capcom.smsgateway.modules.messages.workers.SendMessagesWorker +import me.capcom.smsgateway.modules.mms.MmsSender import me.capcom.smsgateway.receivers.EventsReceiver import java.util.Date import kotlin.coroutines.coroutineContext @@ -51,6 +52,7 @@ class MessagesService( private val encryptionService: EncryptionService, private val events: EventBus, private val logsService: LogsService, + private val mmsSender: MmsSender, ) { val processingOrder get() = settings.processingOrder @@ -170,6 +172,22 @@ class MessagesService( "pdu" to intent.extras?.getByteArray("pdu")?.joinToString("") { "%02x".format(it) }, ) ) + + if (intent.action == EventsReceiver.ACTION_MMS_SENT) { + val id = intent.getStringExtra(EventsReceiver.EXTRA_MESSAGE_ID) ?: return + val (mmsState, mmsError) = if (resultCode == Activity.RESULT_OK) { + ProcessingState.Sent to null + } else { + ProcessingState.Failed to "MMS send result: ${mmsErrorToMessage(resultCode)}" + } + try { + updateState(id, null, mmsState, mmsError) + } finally { + MmsSender.cleanup(context, id) + } + return + } + val (state, error) = when (intent.action) { EventsReceiver.ACTION_SENT -> when { resultCode != Activity.RESULT_OK -> ProcessingState.Failed to "Send result: " + this.resultToErrorMessage( @@ -281,7 +299,10 @@ class MessagesService( } try { - sendSMS(request) + when (request.message.content) { + is MessageContent.Mms -> sendMMS(request) + else -> sendSMS(request) + } return true } catch (e: Exception) { e.printStackTrace() @@ -296,6 +317,67 @@ class MessagesService( return false } + private suspend fun sendMMS(request: StoredSendRequest) { + val message = request.message + val id = message.id + val content = message.content as MessageContent.Mms + + val simNumber = selectSimNumber(request.id, request.params) + if (request.params.simNumber == null && simNumber != null) { + dao.updateSimNumber(id, simNumber + 1) + } + + // Resolve a subscription ID + MSISDN from the chosen SIM slot (if any). + // Verizon and some other US carriers silently drop MMS whose From + // header is the insert-address-token, so we pass an explicit MSISDN + // whenever the platform exposes one. + val subscriptionId = simNumber?.let { SubscriptionsHelper.getSubscriptionId(context, it) } + val fromMsisdn = simNumber?.let { SubscriptionsHelper.getPhoneNumber(context, it) } + ?: run { + // No SIM was explicitly selected; try the first active subscription. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1 + && SubscriptionsHelper.hasPhoneStatePermission(context) + ) { + @Suppress("DEPRECATION", "MissingPermission") + SubscriptionsHelper.getSubscriptionsManager(context) + ?.activeSubscriptionInfoList + ?.firstOrNull() + ?.number + ?.takeIf { it.isNotBlank() } + } else null + } + + val decryptedContent = if (message.isEncrypted) { + MessageContent.Mms( + subject = content.subject?.let { encryptionService.decrypt(it) }, + text = content.text?.let { encryptionService.decrypt(it) }, + attachments = content.attachments.map { + it.copy( + data = encryptionService.decrypt(it.data) + ) + } + ) + } else content + + val recipients = message.phoneNumbers.map { source -> + decryptAndNormalizePhone(source, message.isEncrypted, request.params.skipPhoneValidation) + } + + dao.updatePartsCount(id, 1 + decryptedContent.attachments.size) + + // Don't set fromMsisdn — insert-address-token lets the MMSC fill + // the From field, which matches how Google Messages composes its + // PDUs and is what Verizon's MMSC expects. + mmsSender.send(id, recipients, decryptedContent, subscriptionId, fromMsisdn) + + // Move all recipients out of Pending immediately so the worker loop + // does not re-pull this message while we wait for ACTION_MMS_SENT. + // State transitions to Sent/Failed are driven by processStateIntent. + message.phoneNumbers.forEach { src -> + updateState(id, src, ProcessingState.Processed) + } + } + private suspend fun updateState( id: String, phone: String?, @@ -386,6 +468,9 @@ class MessagesService( } } + is MessageContent.Mms -> + throw IllegalStateException("MMS content must be handled via sendMMS") + is MessageContent.Data -> { val data = when (message.isEncrypted) { true -> encryptionService.decrypt(content.data) @@ -445,14 +530,9 @@ class MessagesService( } try { - val phoneNumber = when (message.isEncrypted) { - true -> encryptionService.decrypt(sourcePhoneNumber) - false -> sourcePhoneNumber - } - val normalizedPhoneNumber = when (request.params.skipPhoneValidation) { - true -> phoneNumber.filter { it.isDigit() || it == '+' || it == '*' || it == '#' } - false -> PhoneHelper.filterPhoneNumber(phoneNumber, countryCode ?: "RU") - } + val normalizedPhoneNumber = decryptAndNormalizePhone( + sourcePhoneNumber, message.isEncrypted, request.params.skipPhoneValidation, + ) sendFn(normalizedPhoneNumber, sentIntent, deliveredIntent) @@ -512,6 +592,34 @@ class MessagesService( } } + private fun mmsErrorToMessage(resultCode: Int): String { + return when (resultCode) { + Activity.RESULT_OK -> "OK" + SmsManager.MMS_ERROR_UNSPECIFIED -> "MMS_ERROR_UNSPECIFIED (An unspecified error occurred)" + SmsManager.MMS_ERROR_INVALID_APN -> "MMS_ERROR_INVALID_APN (The APN is invalid)" + SmsManager.MMS_ERROR_UNABLE_CONNECT_MMS -> "MMS_ERROR_UNABLE_CONNECT_MMS (Unable to connect to the MMS service)" + SmsManager.MMS_ERROR_HTTP_FAILURE -> "MMS_ERROR_HTTP_FAILURE (HTTP failure during MMS transfer)" + SmsManager.MMS_ERROR_IO_ERROR -> "MMS_ERROR_IO_ERROR (An I/O error occurred)" + SmsManager.MMS_ERROR_RETRY -> "MMS_ERROR_RETRY (The carrier indicated a retry is needed)" + SmsManager.MMS_ERROR_CONFIGURATION_ERROR -> "MMS_ERROR_CONFIGURATION_ERROR (MMS configuration error)" + SmsManager.MMS_ERROR_NO_DATA_NETWORK -> "MMS_ERROR_NO_DATA_NETWORK (No data network available)" + else -> "Unknown MMS error: $resultCode" + } + } + + private fun decryptAndNormalizePhone( + source: String, + isEncrypted: Boolean, + skipValidation: Boolean, + ): String { + val phone = if (isEncrypted) encryptionService.decrypt(source) else source + return if (skipValidation) { + phone.filter { it.isDigit() || it == '+' || it == '*' || it == '#' } + } else { + PhoneHelper.filterPhoneNumber(phone, countryCode ?: "RU") + } + } + private fun resultToErrorMessage(resultCode: Int): String { return when (resultCode) { SmsManager.RESULT_ERROR_NONE -> "RESULT_ERROR_NONE (No error)" diff --git a/app/src/main/java/me/capcom/smsgateway/modules/mms/MmsAttachmentStorage.kt b/app/src/main/java/me/capcom/smsgateway/modules/mms/MmsAttachmentStorage.kt new file mode 100644 index 000000000..06738e20f --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/mms/MmsAttachmentStorage.kt @@ -0,0 +1,112 @@ +package me.capcom.smsgateway.modules.mms + +import android.content.Context +import com.google.gson.Gson +import java.io.File +import java.security.MessageDigest + +/** + * Persists MMS attachment bytes under the app's private files directory so + * they can be served back to webhook consumers and survive beyond + * the lifetime of the Android MMS provider entry. + * + * Layout: `/mms-in//` + * Metadata: `/mms-in//.metadata` (JSON) + */ +class MmsAttachmentStorage(private val context: Context) { + + private val gson = Gson() + + private val root: File + get() = File(context.filesDir, "mms-in").apply { mkdirs() } + + fun store( + messageId: String, + partId: Long, + name: String?, + contentType: String, + bytes: ByteArray, + ): StoredAttachment { + val dir = messageDir(messageId).apply { mkdirs() } + val file = File(dir, "$partId") + file.writeBytes(bytes) + val meta = AttachmentMetadata( + name = name, + contentType = contentType.ifBlank { DEFAULT_CONTENT_TYPE }, + ) + metadataFile(file).writeText(gson.toJson(meta)) + return StoredAttachment(partId, meta.displayName, meta.contentType, file) + } + + fun find(messageId: String, partId: Long): StoredAttachment? { + val dir = messageDir(messageId) + if (!dir.isDirectory) return null + val file = File(dir, "$partId") + if (!file.isFile) return null + val meta = readMetadata(file) + return StoredAttachment(partId, meta.displayName, meta.contentType, file) + } + + fun list(messageId: String): List { + val dir = messageDir(messageId) + if (!dir.isDirectory) return emptyList() + return dir.listFiles() + ?.filter { it.isFile && !it.name.endsWith(METADATA_SUFFIX) } + ?.mapNotNull { file -> + val partId = file.name.toLongOrNull() ?: return@mapNotNull null + val meta = readMetadata(file) + StoredAttachment(partId, meta.displayName, meta.contentType, file) + } + .orEmpty() + } + + fun remove(messageId: String) { + val dir = messageDir(messageId) + if (!dir.exists()) return + val removed = dir.deleteRecursively() + check(removed) { "Failed to remove MMS attachments directory: ${dir.absolutePath}" } + } + + private fun messageDir(messageId: String): File = File(root, digest(messageId)) + + private fun digest(s: String): String { + val bytes = MessageDigest.getInstance("SHA-256") + .digest(s.toByteArray(Charsets.UTF_8)) + val sb = StringBuilder(bytes.size * 2) + for (b in bytes) sb.append("%02x".format(b)) + return sb.toString() + } + + private fun readMetadata(file: File): AttachmentMetadata { + val metaFile = metadataFile(file) + if (!metaFile.isFile) return AttachmentMetadata() + return try { + gson.fromJson(metaFile.readText(), AttachmentMetadata::class.java) + ?: AttachmentMetadata() + } catch (_: Exception) { + AttachmentMetadata() + } + } + + private fun metadataFile(file: File): File = File(file.parentFile, file.name + METADATA_SUFFIX) + + private data class AttachmentMetadata( + val name: String? = null, + val contentType: String = DEFAULT_CONTENT_TYPE, + ) { + val displayName: String + get() = name?.takeIf { it.isNotBlank() } ?: "attachment" + } + + data class StoredAttachment( + val partId: Long, + val displayName: String, + val contentType: String, + val file: File, + ) + + companion object { + private const val DEFAULT_CONTENT_TYPE = "application/octet-stream" + private const val METADATA_SUFFIX = ".metadata" + } +} diff --git a/app/src/main/java/me/capcom/smsgateway/modules/mms/MmsSender.kt b/app/src/main/java/me/capcom/smsgateway/modules/mms/MmsSender.kt new file mode 100644 index 000000000..660eedead --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/mms/MmsSender.kt @@ -0,0 +1,202 @@ +package me.capcom.smsgateway.modules.mms + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.telephony.SmsManager +import android.util.Base64 +import android.webkit.MimeTypeMap +import androidx.core.content.FileProvider +import me.capcom.smsgateway.BuildConfig +import me.capcom.smsgateway.domain.MessageContent +import me.capcom.smsgateway.modules.mms.pdu.aosp.CharacterSets +import me.capcom.smsgateway.modules.mms.pdu.aosp.EncodedStringValue +import me.capcom.smsgateway.modules.mms.pdu.aosp.PduBody +import me.capcom.smsgateway.modules.mms.pdu.aosp.PduPart +import me.capcom.smsgateway.modules.mms.pdu.aosp.SendReq +import me.capcom.smsgateway.receivers.EventsReceiver +import java.io.File +import java.security.MessageDigest +import me.capcom.smsgateway.modules.mms.pdu.aosp.PduComposer as AospPduComposer + +class MmsSender( + private val context: Context, +) { + suspend fun send( + messageId: String, + phoneNumbers: List, + mms: MessageContent.Mms, + subscriptionId: Int?, + fromMsisdn: String? = null, + ) { + val req = buildSendReq(phoneNumbers, mms, fromMsisdn) + val pdu = AospPduComposer(context, req).make() + ?: throw RuntimeException("AOSP PduComposer returned null bytes") + + val file = writePduFile(messageId, pdu) + val uri = FileProvider.getUriForFile( + context, + BuildConfig.APPLICATION_ID + ".fileprovider", + file + ) + + context.grantUriPermission( + "com.android.phone", uri, Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + context.grantUriPermission( + "com.android.mms.service", uri, Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + val sentIntent = PendingIntent.getBroadcast( + context, + messageId.hashCode(), + Intent( + EventsReceiver.ACTION_MMS_SENT, + Uri.parse("mmsgw:$messageId"), + context, + EventsReceiver::class.java + ).apply { + putExtra(EventsReceiver.EXTRA_MESSAGE_ID, messageId) + putExtra(EventsReceiver.EXTRA_PDU_PATH, file.absolutePath) + flags = Intent.FLAG_RECEIVER_FOREGROUND + }, + pendingIntentFlags() + ) + + val manager = getSmsManager(subscriptionId) + try { + manager.sendMultimediaMessage( + context, + uri, + null, + null, + sentIntent + ) + } catch (e: Exception) { + file.delete() + throw e + } + } + + private fun buildSendReq( + phoneNumbers: List, + mms: MessageContent.Mms, + fromMsisdn: String?, + ): SendReq { + val req = SendReq() + + // Recipients + req.to = phoneNumbers.map { EncodedStringValue(it) }.toTypedArray() + + // Optional subject + mms.subject?.takeIf { it.isNotBlank() }?.let { + req.subject = EncodedStringValue(it) + } + + // Optional explicit From (Verizon accepts both insert-address-token + // default set by SendReq() and an explicit MSISDN). + if (!fromMsisdn.isNullOrBlank()) { + req.from = EncodedStringValue(fromMsisdn) + } + + val body = PduBody() + var index = 0 + mms.text?.takeIf { it.isNotBlank() }?.let { text -> + val part = PduPart() + part.contentType = "text/plain".toByteArray() + part.charset = CharacterSets.UTF_8 + val name = "text_${index++}.txt" + part.contentLocation = name.toByteArray() + part.contentId = "<${name}>".toByteArray() + part.name = name.toByteArray() + part.data = text.toByteArray(Charsets.UTF_8) + body.addPart(part) + } + + mms.attachments.forEach { att -> + val bytes = attachmentBytes(att) + val fallbackName = "part_${index++}${extensionFor(att.contentType)}" + val name = att.name?.takeIf { it.isNotBlank() } ?: fallbackName + val part = PduPart() + part.contentType = att.contentType.substringBefore(';').trim().toByteArray() + part.contentLocation = name.toByteArray() + part.contentId = "<${name}>".toByteArray() + part.name = name.toByteArray() + part.data = bytes + body.addPart(part) + } + + require(body.partsNum > 0) { "MMS must contain at least one part" } + + req.body = body + // Message size hint for the MMSC (AOSP composes this field optionally). + req.messageSize = (0 until body.partsNum) + .sumOf { body.getPart(it).dataLength.toLong() } + return req + } + + private fun attachmentBytes(att: MessageContent.Mms.Attachment): ByteArray { + return try { + Base64.decode(att.data, Base64.DEFAULT) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("Invalid base64 data for attachment", e) + } + } + + private fun extensionFor(contentType: String): String { + val mime = contentType.substringBefore(';').trim().lowercase() + val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) + return if (ext != null) ".$ext" else ".bin" + } + + private fun writePduFile(messageId: String, bytes: ByteArray): File { + val dir = File(context.filesDir, "mms-out").apply { mkdirs() } + val file = File(dir, "${pduFileName(messageId)}.pdu") + file.writeBytes(bytes) + return file + } + + private fun pendingIntentFlags(): Int { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + } + + @Suppress("DEPRECATION") + private fun getSmsManager(subscriptionId: Int?): SmsManager { + return when { + subscriptionId == null || subscriptionId < 0 -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.getSystemService(SmsManager::class.java) + } else { + SmsManager.getDefault() + } + } + + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> + context.getSystemService(SmsManager::class.java) + .createForSubscriptionId(subscriptionId) + + Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1 -> + SmsManager.getSmsManagerForSubscriptionId(subscriptionId) + + else -> SmsManager.getDefault() + } + } + + companion object { + /** Clean up any stale PDU files for the given message. */ + fun cleanup(context: Context, messageId: String) { + File(File(context.filesDir, "mms-out"), "${pduFileName(messageId)}.pdu").delete() + } + + private fun pduFileName(messageId: String): String = + MessageDigest.getInstance("SHA-256") + .digest(messageId.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + } +} diff --git a/app/src/main/java/me/capcom/smsgateway/modules/mms/Module.kt b/app/src/main/java/me/capcom/smsgateway/modules/mms/Module.kt new file mode 100644 index 000000000..d52cde1ec --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/mms/Module.kt @@ -0,0 +1,9 @@ +package me.capcom.smsgateway.modules.mms + +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module + +val mmsModule = module { + single { MmsSender(androidContext()) } + single { MmsAttachmentStorage(androidContext()) } +} diff --git a/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/CharacterSets.java b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/CharacterSets.java new file mode 100644 index 000000000..967b91b9b --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/CharacterSets.java @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2015 Jacob Klinker + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.capcom.smsgateway.modules.mms.pdu.aosp; + +import java.io.UnsupportedEncodingException; +import java.util.HashMap; +import java.util.Locale; + +public class CharacterSets { + /** + * IANA assigned MIB enum numbers. + * + * From wap-230-wsp-20010705-a.pdf + * Any-charset = + * Equivalent to the special RFC2616 charset value "*" + */ + public static final int ANY_CHARSET = 0x00; + public static final int US_ASCII = 0x03; + public static final int ISO_8859_1 = 0x04; + public static final int ISO_8859_2 = 0x05; + public static final int ISO_8859_3 = 0x06; + public static final int ISO_8859_4 = 0x07; + public static final int ISO_8859_5 = 0x08; + public static final int ISO_8859_6 = 0x09; + public static final int ISO_8859_7 = 0x0A; + public static final int ISO_8859_8 = 0x0B; + public static final int ISO_8859_9 = 0x0C; + public static final int SHIFT_JIS = 0x11; + public static final int UTF_8 = 0x6A; + public static final int BIG5 = 0x07EA; + public static final int UCS2 = 0x03E8; + public static final int UTF_16 = 0x03F7; + + /** + * If the encoding of given data is unsupported, use UTF_8 to decode it. + */ + public static final int DEFAULT_CHARSET = UTF_8; + + /** + * Array of MIB enum numbers. + */ + private static final int[] MIBENUM_NUMBERS = { + ANY_CHARSET, + US_ASCII, + ISO_8859_1, + ISO_8859_2, + ISO_8859_3, + ISO_8859_4, + ISO_8859_5, + ISO_8859_6, + ISO_8859_7, + ISO_8859_8, + ISO_8859_9, + SHIFT_JIS, + UTF_8, + BIG5, + UCS2, + UTF_16, + }; + + /** + * The Well-known-charset Mime name. + */ + public static final String MIMENAME_ANY_CHARSET = "*"; + public static final String MIMENAME_US_ASCII = "us-ascii"; + public static final String MIMENAME_ISO_8859_1 = "iso-8859-1"; + public static final String MIMENAME_ISO_8859_2 = "iso-8859-2"; + public static final String MIMENAME_ISO_8859_3 = "iso-8859-3"; + public static final String MIMENAME_ISO_8859_4 = "iso-8859-4"; + public static final String MIMENAME_ISO_8859_5 = "iso-8859-5"; + public static final String MIMENAME_ISO_8859_6 = "iso-8859-6"; + public static final String MIMENAME_ISO_8859_7 = "iso-8859-7"; + public static final String MIMENAME_ISO_8859_8 = "iso-8859-8"; + public static final String MIMENAME_ISO_8859_9 = "iso-8859-9"; + public static final String MIMENAME_SHIFT_JIS = "shift_JIS"; + public static final String MIMENAME_UTF_8 = "utf-8"; + public static final String MIMENAME_BIG5 = "big5"; + public static final String MIMENAME_UCS2 = "iso-10646-ucs-2"; + public static final String MIMENAME_UTF_16 = "utf-16"; + + public static final String DEFAULT_CHARSET_NAME = MIMENAME_UTF_8; + + /** + * Array of the names of character sets. + */ + private static final String[] MIME_NAMES = { + MIMENAME_ANY_CHARSET, + MIMENAME_US_ASCII, + MIMENAME_ISO_8859_1, + MIMENAME_ISO_8859_2, + MIMENAME_ISO_8859_3, + MIMENAME_ISO_8859_4, + MIMENAME_ISO_8859_5, + MIMENAME_ISO_8859_6, + MIMENAME_ISO_8859_7, + MIMENAME_ISO_8859_8, + MIMENAME_ISO_8859_9, + MIMENAME_SHIFT_JIS, + MIMENAME_UTF_8, + MIMENAME_BIG5, + MIMENAME_UCS2, + MIMENAME_UTF_16, + }; + + private static final HashMap MIBENUM_TO_NAME_MAP; + private static final HashMap NAME_TO_MIBENUM_MAP; + + static { + // Create the HashMaps. + MIBENUM_TO_NAME_MAP = new HashMap(); + NAME_TO_MIBENUM_MAP = new HashMap(); + assert(MIBENUM_NUMBERS.length == MIME_NAMES.length); + int count = MIBENUM_NUMBERS.length - 1; + for(int i = 0; i <= count; i++) { + MIBENUM_TO_NAME_MAP.put(MIBENUM_NUMBERS[i], MIME_NAMES[i]); + NAME_TO_MIBENUM_MAP.put(MIME_NAMES[i].toLowerCase(Locale.ROOT), MIBENUM_NUMBERS[i]); + } + } + + private CharacterSets() {} // Non-instantiatable + + /** + * Map an MIBEnum number to the name of the charset which this number + * is assigned to by IANA. + * + * @param mibEnumValue An IANA assigned MIBEnum number. + * @return The name string of the charset. + * @throws UnsupportedEncodingException + */ + public static String getMimeName(int mibEnumValue) + throws UnsupportedEncodingException { + String name = MIBENUM_TO_NAME_MAP.get(mibEnumValue); + if (name == null) { + throw new UnsupportedEncodingException(); + } + return name; + } + + /** + * Map a well-known charset name to its assigned MIBEnum number. + * + * @param mimeName The charset name. + * @return The MIBEnum number assigned by IANA for this charset. + * @throws UnsupportedEncodingException + */ + public static int getMibEnumValue(String mimeName) + throws UnsupportedEncodingException { + if(null == mimeName) { + return -1; + } + + Integer mibEnumValue = NAME_TO_MIBENUM_MAP.get(mimeName.trim().toLowerCase(Locale.ROOT)); + if (mibEnumValue == null) { + throw new UnsupportedEncodingException(); + } + return mibEnumValue; + } +} diff --git a/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/ContentType.java b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/ContentType.java new file mode 100644 index 000000000..c8fe07e57 --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/ContentType.java @@ -0,0 +1,241 @@ +/* + * Copyright (C) 2007-2008 Esmertec AG. + * Copyright (C) 2007-2008 The Android Open Source Project + * Copyright (c) 2013, The Linux Foundation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.capcom.smsgateway.modules.mms.pdu.aosp; + +import java.util.ArrayList; + +public class ContentType { + public static final String MMS_MESSAGE = "application/vnd.wap.mms-message"; + // The phony content type for generic PDUs (e.g. ReadOrig.ind, + // Notification.ind, Delivery.ind). + public static final String MMS_GENERIC = "application/vnd.wap.mms-generic"; + public static final String MULTIPART_MIXED = "application/vnd.wap.multipart.mixed"; + public static final String MULTIPART_RELATED = "application/vnd.wap.multipart.related"; + public static final String MULTIPART_ALTERNATIVE = "application/vnd.wap.multipart.alternative"; + public static final String MULTIPART_SIGNED = "multipart/signed"; + + public static final String TEXT_PLAIN = "text/plain"; + public static final String TEXT_HTML = "text/html"; + public static final String TEXT_VCALENDAR = "text/x-vCalendar"; + public static final String TEXT_VCARD = "text/x-vCard"; + + public static final String IMAGE_UNSPECIFIED = "image/*"; + public static final String IMAGE_JPEG = "image/jpeg"; + public static final String IMAGE_JPG = "image/jpg"; + public static final String IMAGE_GIF = "image/gif"; + public static final String IMAGE_WBMP = "image/vnd.wap.wbmp"; + public static final String IMAGE_PNG = "image/png"; + public static final String IMAGE_X_MS_BMP = "image/x-ms-bmp"; + + public static final String AUDIO_UNSPECIFIED = "audio/*"; + public static final String AUDIO_AAC = "audio/aac"; + public static final String AUDIO_AAC_MP4 = "audio/aac_mp4"; + public static final String AUDIO_QCELP = "audio/qcelp"; + public static final String AUDIO_EVRC = "audio/evrc"; + public static final String AUDIO_AMR = "audio/amr"; + public static final String AUDIO_IMELODY = "audio/imelody"; + public static final String AUDIO_MID = "audio/mid"; + public static final String AUDIO_MIDI = "audio/midi"; + public static final String AUDIO_MP3 = "audio/mp3"; + public static final String AUDIO_MPEG3 = "audio/mpeg3"; + public static final String AUDIO_MPEG = "audio/mpeg"; + public static final String AUDIO_MPG = "audio/mpg"; + public static final String AUDIO_MP4 = "audio/mp4"; + public static final String AUDIO_X_MID = "audio/x-mid"; + public static final String AUDIO_X_MIDI = "audio/x-midi"; + public static final String AUDIO_X_MP3 = "audio/x-mp3"; + public static final String AUDIO_X_MPEG3 = "audio/x-mpeg3"; + public static final String AUDIO_X_MPEG = "audio/x-mpeg"; + public static final String AUDIO_X_MPG = "audio/x-mpg"; + public static final String AUDIO_3GPP = "audio/3gpp"; + public static final String AUDIO_X_WAV = "audio/x-wav"; + public static final String AUDIO_OGG = "application/ogg"; + + public static final String VIDEO_UNSPECIFIED = "video/*"; + public static final String VIDEO_3GPP = "video/3gpp"; + public static final String VIDEO_3G2 = "video/3gpp2"; + public static final String VIDEO_H263 = "video/h263"; + public static final String VIDEO_MP4 = "video/mp4"; + + public static final String APP_SMIL = "application/smil"; + public static final String APP_WAP_XHTML = "application/vnd.wap.xhtml+xml"; + public static final String APP_XHTML = "application/xhtml+xml"; + + public static final String APP_DRM_CONTENT = "application/vnd.oma.drm.content"; + public static final String APP_DRM_MESSAGE = "application/vnd.oma.drm.message"; + + private static final ArrayList sSupportedContentTypes = new ArrayList(); + private static final ArrayList sSupportedImageTypes = new ArrayList(); + private static final ArrayList sSupportedAudioTypes = new ArrayList(); + private static final ArrayList sSupportedVideoTypes = new ArrayList(); + + static { + sSupportedContentTypes.add(TEXT_PLAIN); + sSupportedContentTypes.add(TEXT_HTML); + sSupportedContentTypes.add(TEXT_VCALENDAR); + sSupportedContentTypes.add(TEXT_VCARD); + + sSupportedContentTypes.add(IMAGE_JPEG); + sSupportedContentTypes.add(IMAGE_GIF); + sSupportedContentTypes.add(IMAGE_WBMP); + sSupportedContentTypes.add(IMAGE_PNG); + sSupportedContentTypes.add(IMAGE_JPG); + sSupportedContentTypes.add(IMAGE_X_MS_BMP); + //supportedContentTypes.add(IMAGE_SVG); not yet supported. + + sSupportedContentTypes.add(AUDIO_AAC); + sSupportedContentTypes.add(AUDIO_AAC_MP4); + sSupportedContentTypes.add(AUDIO_QCELP); + sSupportedContentTypes.add(AUDIO_EVRC); + sSupportedContentTypes.add(AUDIO_AMR); + sSupportedContentTypes.add(AUDIO_IMELODY); + sSupportedContentTypes.add(AUDIO_MID); + sSupportedContentTypes.add(AUDIO_MIDI); + sSupportedContentTypes.add(AUDIO_MP3); + sSupportedContentTypes.add(AUDIO_MP4); + sSupportedContentTypes.add(AUDIO_MPEG3); + sSupportedContentTypes.add(AUDIO_MPEG); + sSupportedContentTypes.add(AUDIO_MPG); + sSupportedContentTypes.add(AUDIO_X_MID); + sSupportedContentTypes.add(AUDIO_X_MIDI); + sSupportedContentTypes.add(AUDIO_X_MP3); + sSupportedContentTypes.add(AUDIO_X_MPEG3); + sSupportedContentTypes.add(AUDIO_X_MPEG); + sSupportedContentTypes.add(AUDIO_X_MPG); + sSupportedContentTypes.add(AUDIO_X_WAV); + sSupportedContentTypes.add(AUDIO_3GPP); + sSupportedContentTypes.add(AUDIO_OGG); + + sSupportedContentTypes.add(VIDEO_3GPP); + sSupportedContentTypes.add(VIDEO_3G2); + sSupportedContentTypes.add(VIDEO_H263); + sSupportedContentTypes.add(VIDEO_MP4); + + sSupportedContentTypes.add(APP_SMIL); + sSupportedContentTypes.add(APP_WAP_XHTML); + sSupportedContentTypes.add(APP_XHTML); + + sSupportedContentTypes.add(APP_DRM_CONTENT); + sSupportedContentTypes.add(APP_DRM_MESSAGE); + + // add supported image types + sSupportedImageTypes.add(IMAGE_JPEG); + sSupportedImageTypes.add(IMAGE_GIF); + sSupportedImageTypes.add(IMAGE_WBMP); + sSupportedImageTypes.add(IMAGE_PNG); + sSupportedImageTypes.add(IMAGE_JPG); + sSupportedImageTypes.add(IMAGE_X_MS_BMP); + + // add supported audio types + sSupportedAudioTypes.add(AUDIO_AAC); + sSupportedAudioTypes.add(AUDIO_AAC_MP4); + sSupportedAudioTypes.add(AUDIO_QCELP); + sSupportedAudioTypes.add(AUDIO_EVRC); + sSupportedAudioTypes.add(AUDIO_AMR); + sSupportedAudioTypes.add(AUDIO_IMELODY); + sSupportedAudioTypes.add(AUDIO_MID); + sSupportedAudioTypes.add(AUDIO_MIDI); + sSupportedAudioTypes.add(AUDIO_MP3); + sSupportedAudioTypes.add(AUDIO_MPEG3); + sSupportedAudioTypes.add(AUDIO_MPEG); + sSupportedAudioTypes.add(AUDIO_MPG); + sSupportedAudioTypes.add(AUDIO_MP4); + sSupportedAudioTypes.add(AUDIO_X_MID); + sSupportedAudioTypes.add(AUDIO_X_MIDI); + sSupportedAudioTypes.add(AUDIO_X_MP3); + sSupportedAudioTypes.add(AUDIO_X_MPEG3); + sSupportedAudioTypes.add(AUDIO_X_MPEG); + sSupportedAudioTypes.add(AUDIO_X_MPG); + sSupportedAudioTypes.add(AUDIO_X_WAV); + sSupportedAudioTypes.add(AUDIO_3GPP); + sSupportedAudioTypes.add(AUDIO_OGG); + + // add supported video types + sSupportedVideoTypes.add(VIDEO_3GPP); + sSupportedVideoTypes.add(VIDEO_3G2); + sSupportedVideoTypes.add(VIDEO_H263); + sSupportedVideoTypes.add(VIDEO_MP4); + } + + // This class should never be instantiated. + private ContentType() { + } + + public static boolean isSupportedType(String contentType) { + return (null != contentType) && sSupportedContentTypes.contains(contentType); + } + + public static boolean isSupportedImageType(String contentType) { + return isImageType(contentType) && isSupportedType(contentType); + } + + public static boolean isSupportedAudioType(String contentType) { + return isAudioType(contentType) && isSupportedType(contentType); + } + + public static boolean isSupportedVideoType(String contentType) { + return isVideoType(contentType) && isSupportedType(contentType); + } + + public static boolean isTextType(String contentType) { + return (null != contentType) && contentType.startsWith("text/"); + } + + public static boolean isImageType(String contentType) { + return (null != contentType) && contentType.startsWith("image/"); + } + + public static boolean isAudioType(String contentType) { + return (null != contentType) && contentType.startsWith("audio/"); + } + + public static boolean isVideoType(String contentType) { + return (null != contentType) && contentType.startsWith("video/"); + } + + public static boolean isDrmType(String contentType) { + return (null != contentType) + && (contentType.equals(APP_DRM_CONTENT) + || contentType.equals(APP_DRM_MESSAGE)); + } + + public static boolean isUnspecified(String contentType) { + return (null != contentType) && contentType.endsWith("*"); + } + + @SuppressWarnings("unchecked") + public static ArrayList getImageTypes() { + return (ArrayList) sSupportedImageTypes.clone(); + } + + @SuppressWarnings("unchecked") + public static ArrayList getAudioTypes() { + return (ArrayList) sSupportedAudioTypes.clone(); + } + + @SuppressWarnings("unchecked") + public static ArrayList getVideoTypes() { + return (ArrayList) sSupportedVideoTypes.clone(); + } + + @SuppressWarnings("unchecked") + public static ArrayList getSupportedTypes() { + return (ArrayList) sSupportedContentTypes.clone(); + } +} diff --git a/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/EncodedStringValue.java b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/EncodedStringValue.java new file mode 100644 index 000000000..359a220a3 --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/EncodedStringValue.java @@ -0,0 +1,283 @@ +/* + * Copyright (C) 2015 Jacob Klinker + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.capcom.smsgateway.modules.mms.pdu.aosp; + +import android.util.Log; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; + +/** + * Encoded-string-value = Text-string | Value-length Char-set Text-string + */ +public class EncodedStringValue implements Cloneable { + private static final String TAG = "EncodedStringValue"; + private static final boolean DEBUG = false; + private static final boolean LOCAL_LOGV = false; + + /** + * The Char-set value. + */ + private int mCharacterSet; + + /** + * The Text-string value. + */ + private byte[] mData; + + /** + * Constructor. + * + * @param charset the Char-set value + * @param data the Text-string value + * @throws NullPointerException if Text-string value is null. + */ + public EncodedStringValue(int charset, byte[] data) { + // TODO: CharSet needs to be validated against MIBEnum. + if(null == data) { + throw new NullPointerException("EncodedStringValue: Text-string is null."); + } + + mCharacterSet = charset; + mData = new byte[data.length]; + System.arraycopy(data, 0, mData, 0, data.length); + } + + /** + * Constructor. + * + * @param data the Text-string value + * @throws NullPointerException if Text-string value is null. + */ + public EncodedStringValue(byte[] data) { + this(CharacterSets.DEFAULT_CHARSET, data); + } + + public EncodedStringValue(String data) { + try { + mData = data.getBytes(CharacterSets.DEFAULT_CHARSET_NAME); + mCharacterSet = CharacterSets.DEFAULT_CHARSET; + } catch (UnsupportedEncodingException e) { + Log.e(TAG, "Default encoding must be supported.", e); + } + } + + /** + * Get Char-set value. + * + * @return the value + */ + public int getCharacterSet() { + return mCharacterSet; + } + + /** + * Set Char-set value. + * + * @param charset the Char-set value + */ + public void setCharacterSet(int charset) { + // TODO: CharSet needs to be validated against MIBEnum. + mCharacterSet = charset; + } + + /** + * Get Text-string value. + * + * @return the value + */ + public byte[] getTextString() { + byte[] byteArray = new byte[mData.length]; + + System.arraycopy(mData, 0, byteArray, 0, mData.length); + return byteArray; + } + + /** + * Set Text-string value. + * + * @param textString the Text-string value + * @throws NullPointerException if Text-string value is null. + */ + public void setTextString(byte[] textString) { + if(null == textString) { + throw new NullPointerException("EncodedStringValue: Text-string is null."); + } + + mData = new byte[textString.length]; + System.arraycopy(textString, 0, mData, 0, textString.length); + } + + /** + * Convert this object to a {@link String}. If the encoding of + * the EncodedStringValue is null or unsupported, it will be + * treated as iso-8859-1 encoding. + * + * @return The decoded String. + */ + public String getString() { + if (CharacterSets.ANY_CHARSET == mCharacterSet) { + return new String(mData); // system default encoding. + } else { + try { + String name = CharacterSets.getMimeName(mCharacterSet); + return new String(mData, name); + } catch (UnsupportedEncodingException e) { + if (LOCAL_LOGV) { + Log.v(TAG, e.getMessage(), e); + } + try { + return new String(mData, CharacterSets.MIMENAME_ISO_8859_1); + } catch (UnsupportedEncodingException f) { + return new String(mData); // system default encoding. + } + } + } + } + + /** + * Append to Text-string. + * + * @param textString the textString to append + * @throws NullPointerException if the text String is null + * or an IOException occured. + */ + public void appendTextString(byte[] textString) { + if(null == textString) { + throw new NullPointerException("Text-string is null."); + } + + if(null == mData) { + mData = new byte[textString.length]; + System.arraycopy(textString, 0, mData, 0, textString.length); + } else { + ByteArrayOutputStream newTextString = new ByteArrayOutputStream(); + try { + newTextString.write(mData); + newTextString.write(textString); + } catch (IOException e) { + Log.e(TAG, "logging error", e); + e.printStackTrace(); + throw new NullPointerException( + "appendTextString: failed when write a new Text-string"); + } + + mData = newTextString.toByteArray(); + } + } + + /* + * (non-Javadoc) + * @see java.lang.Object#clone() + */ + @Override + public Object clone() throws CloneNotSupportedException { + super.clone(); + int len = mData.length; + byte[] dstBytes = new byte[len]; + System.arraycopy(mData, 0, dstBytes, 0, len); + + try { + return new EncodedStringValue(mCharacterSet, dstBytes); + } catch (Exception e) { + Log.e(TAG, "logging error", e); + e.printStackTrace(); + throw new CloneNotSupportedException(e.getMessage()); + } + } + + /** + * Split this encoded string around matches of the given pattern. + * + * @param pattern the delimiting pattern + * @return the array of encoded strings computed by splitting this encoded + * string around matches of the given pattern + */ + public EncodedStringValue[] split(String pattern) { + String[] temp = getString().split(pattern); + EncodedStringValue[] ret = new EncodedStringValue[temp.length]; + for (int i = 0; i < ret.length; ++i) { + try { + ret[i] = new EncodedStringValue(mCharacterSet, + temp[i].getBytes()); + } catch (NullPointerException e) { + // Can't arrive here + return null; + } + } + return ret; + } + + /** + * Extract an EncodedStringValue[] from a given String. + */ + public static EncodedStringValue[] extract(String src) { + String[] values = src.split(";"); + + ArrayList list = new ArrayList(); + for (int i = 0; i < values.length; i++) { + if (values[i].length() > 0) { + list.add(new EncodedStringValue(values[i])); + } + } + + int len = list.size(); + if (len > 0) { + return list.toArray(new EncodedStringValue[len]); + } else { + return null; + } + } + + /** + * Concatenate an EncodedStringValue[] into a single String. + */ + public static String concat(EncodedStringValue[] addr) { + StringBuilder sb = new StringBuilder(); + int maxIndex = addr.length - 1; + for (int i = 0; i <= maxIndex; i++) { + sb.append(addr[i].getString()); + if (i < maxIndex) { + sb.append(";"); + } + } + + return sb.toString(); + } + + public static EncodedStringValue copy(EncodedStringValue value) { + if (value == null) { + return null; + } + + return new EncodedStringValue(value.mCharacterSet, value.mData); + } + + public static EncodedStringValue[] encodeStrings(String[] array) { + int count = array.length; + if (count > 0) { + EncodedStringValue[] encodedArray = new EncodedStringValue[count]; + for (int i = 0; i < count; i++) { + encodedArray[i] = new EncodedStringValue(array[i]); + } + return encodedArray; + } + return null; + } +} diff --git a/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/GenericPdu.java b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/GenericPdu.java new file mode 100644 index 000000000..45756a0b4 --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/GenericPdu.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2015 Jacob Klinker + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.capcom.smsgateway.modules.mms.pdu.aosp; + +import me.capcom.smsgateway.modules.mms.pdu.aosp.InvalidHeaderValueException; + +public class GenericPdu { + /** + * The headers of pdu. + */ + PduHeaders mPduHeaders = null; + + /** + * Constructor. + */ + public GenericPdu() { + mPduHeaders = new PduHeaders(); + } + + /** + * Constructor. + * + * @param headers Headers for this PDU. + */ + GenericPdu(PduHeaders headers) { + mPduHeaders = headers; + } + + /** + * Get the headers of this PDU. + * + * @return A PduHeaders of this PDU. + */ + PduHeaders getPduHeaders() { + return mPduHeaders; + } + + /** + * Get X-Mms-Message-Type field value. + * + * @return the X-Mms-Report-Allowed value + */ + public int getMessageType() { + return mPduHeaders.getOctet(PduHeaders.MESSAGE_TYPE); + } + + /** + * Set X-Mms-Message-Type field value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + * RuntimeException if field's value is not Octet. + */ + public void setMessageType(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.MESSAGE_TYPE); + } + + /** + * Get X-Mms-MMS-Version field value. + * + * @return the X-Mms-MMS-Version value + */ + public int getMmsVersion() { + return mPduHeaders.getOctet(PduHeaders.MMS_VERSION); + } + + /** + * Set X-Mms-MMS-Version field value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + * RuntimeException if field's value is not Octet. + */ + public void setMmsVersion(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.MMS_VERSION); + } + + /** + * Get From value. + * From-value = Value-length + * (Address-present-token Encoded-string-value | Insert-address-token) + * + * @return the value + */ + public EncodedStringValue getFrom() { + return mPduHeaders.getEncodedStringValue(PduHeaders.FROM); + } + + /** + * Set From value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setFrom(EncodedStringValue value) { + mPduHeaders.setEncodedStringValue(value, PduHeaders.FROM); + } +} diff --git a/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/InvalidHeaderValueException.java b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/InvalidHeaderValueException.java new file mode 100644 index 000000000..147aa57af --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/InvalidHeaderValueException.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.capcom.smsgateway.modules.mms.pdu.aosp; + +/** + * Thrown when an invalid header value was set. + */ +public class InvalidHeaderValueException extends MmsException { + private static final long serialVersionUID = -2053384496042052262L; + + /** + * Constructs an InvalidHeaderValueException with no detailed message. + */ + public InvalidHeaderValueException() { + super(); + } + + /** + * Constructs an InvalidHeaderValueException with the specified detailed message. + * + * @param message the detailed message. + */ + public InvalidHeaderValueException(String message) { + super(message); + } +} diff --git a/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/MmsException.java b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/MmsException.java new file mode 100644 index 000000000..1605f7267 --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/MmsException.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2007 Esmertec AG. + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.capcom.smsgateway.modules.mms.pdu.aosp; + +/** + * A generic exception that is thrown by the Mms client. + */ +public class MmsException extends Exception { + private static final long serialVersionUID = -7323249827281485390L; + + /** + * Creates a new MmsException. + */ + public MmsException() { + super(); + } + + /** + * Creates a new MmsException with the specified detail message. + * + * @param message the detail message. + */ + public MmsException(String message) { + super(message); + } + + /** + * Creates a new MmsException with the specified cause. + * + * @param cause the cause. + */ + public MmsException(Throwable cause) { + super(cause); + } + + /** + * Creates a new MmsException with the specified detail message and cause. + * + * @param message the detail message. + * @param cause the cause. + */ + public MmsException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/MultimediaMessagePdu.java b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/MultimediaMessagePdu.java new file mode 100644 index 000000000..18e5376c1 --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/MultimediaMessagePdu.java @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2015 Jacob Klinker + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.capcom.smsgateway.modules.mms.pdu.aosp; + +import me.capcom.smsgateway.modules.mms.pdu.aosp.InvalidHeaderValueException; + +/** + * Multimedia message PDU. + */ +public class MultimediaMessagePdu extends GenericPdu{ + /** + * The body. + */ + private PduBody mMessageBody; + + /** + * Constructor. + */ + public MultimediaMessagePdu() { + super(); + } + + /** + * Constructor. + * + * @param header the header of this PDU + * @param body the body of this PDU + */ + public MultimediaMessagePdu(PduHeaders header, PduBody body) { + super(header); + mMessageBody = body; + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + MultimediaMessagePdu(PduHeaders headers) { + super(headers); + } + + /** + * Get body of the PDU. + * + * @return the body + */ + public PduBody getBody() { + return mMessageBody; + } + + /** + * Set body of the PDU. + * + * @param body the body + */ + public void setBody(PduBody body) { + mMessageBody = body; + } + + /** + * Get subject. + * + * @return the value + */ + public EncodedStringValue getSubject() { + return mPduHeaders.getEncodedStringValue(PduHeaders.SUBJECT); + } + + /** + * Set subject. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setSubject(EncodedStringValue value) { + mPduHeaders.setEncodedStringValue(value, PduHeaders.SUBJECT); + } + + /** + * Get To value. + * + * @return the value + */ + public EncodedStringValue[] getTo() { + return mPduHeaders.getEncodedStringValues(PduHeaders.TO); + } + + /** + * Add a "To" value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void addTo(EncodedStringValue value) { + mPduHeaders.appendEncodedStringValue(value, PduHeaders.TO); + } + + /** + * Get X-Mms-Priority value. + * + * @return the value + */ + public int getPriority() { + return mPduHeaders.getOctet(PduHeaders.PRIORITY); + } + + /** + * Set X-Mms-Priority value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setPriority(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.PRIORITY); + } + + /** + * Get Date value. + * + * @return the value + */ + public long getDate() { + return mPduHeaders.getLongInteger(PduHeaders.DATE); + } + + /** + * Set Date value in seconds. + * + * @param value the value + */ + public void setDate(long value) { + mPduHeaders.setLongInteger(value, PduHeaders.DATE); + } +} diff --git a/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/PduBody.java b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/PduBody.java new file mode 100644 index 000000000..933679fe4 --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/PduBody.java @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2015 Jacob Klinker + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.capcom.smsgateway.modules.mms.pdu.aosp; + +import java.util.HashMap; +import java.util.Map; +import java.util.Vector; + +public class PduBody { + private Vector mParts = null; + + private Map mPartMapByContentId = null; + private Map mPartMapByContentLocation = null; + private Map mPartMapByName = null; + private Map mPartMapByFileName = null; + + /** + * Constructor. + */ + public PduBody() { + mParts = new Vector(); + + mPartMapByContentId = new HashMap(); + mPartMapByContentLocation = new HashMap(); + mPartMapByName = new HashMap(); + mPartMapByFileName = new HashMap(); + } + + private void putPartToMaps(PduPart part) { + // Put part to mPartMapByContentId. + byte[] contentId = part.getContentId(); + if(null != contentId) { + mPartMapByContentId.put(new String(contentId), part); + } + + // Put part to mPartMapByContentLocation. + byte[] contentLocation = part.getContentLocation(); + if(null != contentLocation) { + String clc = new String(contentLocation); + mPartMapByContentLocation.put(clc, part); + } + + // Put part to mPartMapByName. + byte[] name = part.getName(); + if(null != name) { + String clc = new String(name); + mPartMapByName.put(clc, part); + } + + // Put part to mPartMapByFileName. + byte[] fileName = part.getFilename(); + if(null != fileName) { + String clc = new String(fileName); + mPartMapByFileName.put(clc, part); + } + } + + private void clearPartMaps() { + mPartMapByContentId.clear(); + mPartMapByContentLocation.clear(); + mPartMapByName.clear(); + mPartMapByFileName.clear(); + } + + private void rebuildPartMaps() { + clearPartMaps(); + for (PduPart part : mParts) { + putPartToMaps(part); + } + } + + /** + * Appends the specified part to the end of this body. + * + * @param part part to be appended + * @return true when success, false when fail + * @throws NullPointerException when part is null + */ + public boolean addPart(PduPart part) { + if(null == part) { + throw new NullPointerException(); + } + + putPartToMaps(part); + return mParts.add(part); + } + + /** + * Inserts the specified part at the specified position. + * + * @param index index at which the specified part is to be inserted + * @param part part to be inserted + * @throws NullPointerException when part is null + */ + public void addPart(int index, PduPart part) { + if(null == part) { + throw new NullPointerException(); + } + + mParts.add(index, part); + putPartToMaps(part); + } + + /** + * Removes the part at the specified position. + * + * @param index index of the part to return + * @return part at the specified index + */ + public PduPart removePart(int index) { + PduPart removed = mParts.remove(index); + rebuildPartMaps(); + return removed; + } + + /** + * Remove all of the parts. + */ + public void removeAll() { + mParts.clear(); + clearPartMaps(); + } + + /** + * Get the part at the specified position. + * + * @param index index of the part to return + * @return part at the specified index + */ + public PduPart getPart(int index) { + return mParts.get(index); + } + + /** + * Get the index of the specified part. + * + * @param part the part object + * @return index the index of the first occurrence of the part in this body + */ + public int getPartIndex(PduPart part) { + return mParts.indexOf(part); + } + + /** + * Get the number of parts. + * + * @return the number of parts + */ + public int getPartsNum() { + return mParts.size(); + } + + /** + * Get pdu part by content id. + * + * @param cid the value of content id. + * @return the pdu part. + */ + public PduPart getPartByContentId(String cid) { + return mPartMapByContentId.get(cid); + } + + /** + * Get pdu part by Content-Location. Content-Location of part is + * the same as filename and name(param of content-type). + * + * @param contentLocation the value of filename. + * @return the pdu part. + */ + public PduPart getPartByContentLocation(String contentLocation) { + return mPartMapByContentLocation.get(contentLocation); + } + + /** + * Get pdu part by name. + * + * @param name the value of filename. + * @return the pdu part. + */ + public PduPart getPartByName(String name) { + return mPartMapByName.get(name); + } + + /** + * Get pdu part by filename. + * + * @param filename the value of filename. + * @return the pdu part. + */ + public PduPart getPartByFileName(String filename) { + return mPartMapByFileName.get(filename); + } +} diff --git a/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/PduComposer.java b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/PduComposer.java new file mode 100644 index 000000000..938f7f07b --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/PduComposer.java @@ -0,0 +1,1187 @@ +/* + * Copyright (C) 2015 Jacob Klinker + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.capcom.smsgateway.modules.mms.pdu.aosp; + +import android.content.ContentResolver; +import android.content.Context; +import android.text.TextUtils; +import android.util.Log; + +import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.HashMap; + +public class PduComposer { + /** + * Address type. + */ + static private final int PDU_PHONE_NUMBER_ADDRESS_TYPE = 1; + static private final int PDU_EMAIL_ADDRESS_TYPE = 2; + static private final int PDU_IPV4_ADDRESS_TYPE = 3; + static private final int PDU_IPV6_ADDRESS_TYPE = 4; + static private final int PDU_UNKNOWN_ADDRESS_TYPE = 5; + + /** + * Address regular expression string. + */ + static final String REGEXP_PHONE_NUMBER_ADDRESS_TYPE = "\\+?[0-9|\\.|\\-]+"; + static final String REGEXP_EMAIL_ADDRESS_TYPE = "[a-zA-Z| ]*\\<{0,1}[a-zA-Z| ]+@{1}" + + "[a-zA-Z| ]+\\.{1}[a-zA-Z| ]+\\>{0,1}"; + static final String REGEXP_IPV6_ADDRESS_TYPE = + "[a-fA-F]{4}\\:{1}[a-fA-F0-9]{4}\\:{1}[a-fA-F0-9]{4}\\:{1}" + + "[a-fA-F0-9]{4}\\:{1}[a-fA-F0-9]{4}\\:{1}[a-fA-F0-9]{4}\\:{1}" + + "[a-fA-F0-9]{4}\\:{1}[a-fA-F0-9]{4}"; + static final String REGEXP_IPV4_ADDRESS_TYPE = "[0-9]{1,3}\\.{1}[0-9]{1,3}\\.{1}" + + "[0-9]{1,3}\\.{1}[0-9]{1,3}"; + + /** + * The postfix strings of address. + */ + static final String STRING_PHONE_NUMBER_ADDRESS_TYPE = "/TYPE=PLMN"; + static final String STRING_IPV4_ADDRESS_TYPE = "/TYPE=IPV4"; + static final String STRING_IPV6_ADDRESS_TYPE = "/TYPE=IPV6"; + + /** + * Error values. + */ + static private final int PDU_COMPOSE_SUCCESS = 0; + static private final int PDU_COMPOSE_CONTENT_ERROR = 1; + static private final int PDU_COMPOSE_FIELD_NOT_SET = 2; + static private final int PDU_COMPOSE_FIELD_NOT_SUPPORTED = 3; + + /** + * WAP values defined in WSP spec. + */ + static private final int QUOTED_STRING_FLAG = 34; + static private final int END_STRING_FLAG = 0; + static private final int LENGTH_QUOTE = 31; + static private final int TEXT_MAX = 127; + static private final int SHORT_INTEGER_MAX = 127; + static private final int LONG_INTEGER_LENGTH_MAX = 8; + + /** + * Block size when read data from InputStream. + */ + static private final int PDU_COMPOSER_BLOCK_SIZE = 1024; + private static final String TAG = "PduComposer"; + + /** + * The output message. + */ + protected ByteArrayOutputStream mMessage = null; + + /** + * The PDU. + */ + private GenericPdu mPdu = null; + + /** + * Current visiting position of the mMessage. + */ + protected int mPosition = 0; + + /** + * Message compose buffer stack. + */ + private BufferStack mStack = null; + + /** + * Content resolver. + */ + private final ContentResolver mResolver; + + /** + * Header of this pdu. + */ + private PduHeaders mPduHeader = null; + + /** + * Map of all content type + */ + private static HashMap mContentTypeMap = null; + + static { + mContentTypeMap = new HashMap(); + + int i; + for (i = 0; i < PduContentTypes.contentTypes.length; i++) { + mContentTypeMap.put(PduContentTypes.contentTypes[i], i); + } + } + + /** + * Constructor. + * + * @param context the context + * @param pdu the pdu to be composed + */ + public PduComposer(Context context, GenericPdu pdu) { + mPdu = pdu; + mResolver = context.getContentResolver(); + mPduHeader = pdu.getPduHeaders(); + mStack = new BufferStack(); + mMessage = new ByteArrayOutputStream(); + mPosition = 0; + } + + /** + * Make the message. No need to check whether mandatory fields are set, + * because the constructors of outgoing pdus are taking care of this. + * + * @return OutputStream of maked message. Return null if + * the PDU is invalid. + */ + public byte[] make() { + // Get Message-type. + int type = mPdu.getMessageType(); + + /* make the message */ + switch (type) { + case PduHeaders.MESSAGE_TYPE_SEND_REQ: + if (makeSendReqPdu() != PDU_COMPOSE_SUCCESS) { + return null; + } + break; + case PduHeaders.MESSAGE_TYPE_NOTIFYRESP_IND: + if (makeNotifyResp() != PDU_COMPOSE_SUCCESS) { + return null; + } + break; + case PduHeaders.MESSAGE_TYPE_ACKNOWLEDGE_IND: + if (makeAckInd() != PDU_COMPOSE_SUCCESS) { + return null; + } + break; + case PduHeaders.MESSAGE_TYPE_READ_REC_IND: + if (makeReadRecInd() != PDU_COMPOSE_SUCCESS) { + return null; + } + break; + default: + return null; + } + + return mMessage.toByteArray(); + } + + /** + * Copy buf to mMessage. + */ + protected void arraycopy(byte[] buf, int pos, int length) { + mMessage.write(buf, pos, length); + mPosition = mPosition + length; + } + + /** + * Append a byte to mMessage. + */ + protected void append(int value) { + mMessage.write(value); + mPosition ++; + } + + /** + * Append short integer value to mMessage. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendShortInteger(int value) { + /* + * From WAP-230-WSP-20010705-a: + * Short-integer = OCTET + * ; Integers in range 0-127 shall be encoded as a one octet value + * ; with the most significant bit set to one (1xxx xxxx) and with + * ; the value in the remaining least significant bits. + * In our implementation, only low 7 bits are stored and otherwise + * bits are ignored. + */ + append((value | 0x80) & 0xff); + } + + /** + * Append an octet number between 128 and 255 into mMessage. + * NOTE: + * A value between 0 and 127 should be appended by using appendShortInteger. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendOctet(int number) { + append(number); + } + + /** + * Append a short length into mMessage. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendShortLength(int value) { + /* + * From WAP-230-WSP-20010705-a: + * Short-length = + */ + append(value); + } + + /** + * Append long integer into mMessage. it's used for really long integers. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendLongInteger(long longInt) { + /* + * From WAP-230-WSP-20010705-a: + * Long-integer = Short-length Multi-octet-integer + * ; The Short-length indicates the length of the Multi-octet-integer + * Multi-octet-integer = 1*30 OCTET + * ; The content octets shall be an unsigned integer value with the + * ; most significant octet encoded first (big-endian representation). + * ; The minimum number of octets must be used to encode the value. + */ + int size; + long temp = longInt; + + // Count the length of the long integer. + for(size = 0; (temp != 0) && (size < LONG_INTEGER_LENGTH_MAX); size++) { + temp = (temp >>> 8); + } + + // Set Length. + appendShortLength(size); + + // Count and set the long integer. + int i; + int shift = (size -1) * 8; + + for (i = 0; i < size; i++) { + append((int)((longInt >>> shift) & 0xff)); + shift = shift - 8; + } + } + + /** + * Append text string into mMessage. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendTextString(byte[] text) { + /* + * From WAP-230-WSP-20010705-a: + * Text-string = [Quote] *TEXT End-of-string + * ; If the first character in the TEXT is in the range of 128-255, + * ; a Quote character must precede it. Otherwise the Quote character + * ;must be omitted. The Quote is not part of the contents. + */ + if (((text[0])&0xff) > TEXT_MAX) { // No need to check for <= 255 + append(TEXT_MAX); + } + + arraycopy(text, 0, text.length); + append(0); + } + + /** + * Append text string into mMessage. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendTextString(String str) { + /* + * From WAP-230-WSP-20010705-a: + * Text-string = [Quote] *TEXT End-of-string + * ; If the first character in the TEXT is in the range of 128-255, + * ; a Quote character must precede it. Otherwise the Quote character + * ;must be omitted. The Quote is not part of the contents. + */ + appendTextString(str.getBytes()); + } + + /** + * Append encoded string value to mMessage. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendEncodedString(EncodedStringValue enStr) { + /* + * From OMA-TS-MMS-ENC-V1_3-20050927-C: + * Encoded-string-value = Text-string | Value-length Char-set Text-string + */ + assert(enStr != null); + + int charset = enStr.getCharacterSet(); + byte[] textString = enStr.getTextString(); + if (null == textString) { + return; + } + + /* + * In the implementation of EncodedStringValue, the charset field will + * never be 0. It will always be composed as + * Encoded-string-value = Value-length Char-set Text-string + */ + mStack.newbuf(); + PositionMarker start = mStack.mark(); + + appendShortInteger(charset); + appendTextString(textString); + + int len = start.getLength(); + mStack.pop(); + appendValueLength(len); + mStack.copy(); + } + + /** + * Append uintvar integer into mMessage. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendUintvarInteger(long value) { + /* + * From WAP-230-WSP-20010705-a: + * To encode a large unsigned integer, split it into 7-bit fragments + * and place them in the payloads of multiple octets. The most significant + * bits are placed in the first octets with the least significant bits + * ending up in the last octet. All octets MUST set the Continue bit to 1 + * except the last octet, which MUST set the Continue bit to 0. + */ + int i; + long max = SHORT_INTEGER_MAX; + + for (i = 0; i < 5; i++) { + if (value < max) { + break; + } + + max = (max << 7) | 0x7fl; + } + + while(i > 0) { + long temp = value >>> (i * 7); + temp = temp & 0x7f; + + append((int)((temp | 0x80) & 0xff)); + + i--; + } + + append((int)(value & 0x7f)); + } + + /** + * Append date value into mMessage. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendDateValue(long date) { + /* + * From OMA-TS-MMS-ENC-V1_3-20050927-C: + * Date-value = Long-integer + */ + appendLongInteger(date); + } + + /** + * Append value length to mMessage. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendValueLength(long value) { + /* + * From WAP-230-WSP-20010705-a: + * Value-length = Short-length | (Length-quote Length) + * ; Value length is used to indicate the length of the value to follow + * Short-length = + * Length-quote = + * Length = Uintvar-integer + */ + if (value < LENGTH_QUOTE) { + appendShortLength((int) value); + return; + } + + append(LENGTH_QUOTE); + appendUintvarInteger(value); + } + + /** + * Append quoted string to mMessage. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendQuotedString(byte[] text) { + /* + * From WAP-230-WSP-20010705-a: + * Quoted-string = *TEXT End-of-string + * ;The TEXT encodes an RFC2616 Quoted-string with the enclosing + * ;quotation-marks <"> removed. + */ + append(QUOTED_STRING_FLAG); + arraycopy(text, 0, text.length); + append(END_STRING_FLAG); + } + + /** + * Append quoted string to mMessage. + * This implementation doesn't check the validity of parameter, since it + * assumes that the values are validated in the GenericPdu setter methods. + */ + protected void appendQuotedString(String str) { + /* + * From WAP-230-WSP-20010705-a: + * Quoted-string = *TEXT End-of-string + * ;The TEXT encodes an RFC2616 Quoted-string with the enclosing + * ;quotation-marks <"> removed. + */ + appendQuotedString(str.getBytes()); + } + + private EncodedStringValue appendAddressType(EncodedStringValue address) { + EncodedStringValue temp = null; + + try { + int addressType = checkAddressType(address.getString()); + temp = EncodedStringValue.copy(address); + if (PDU_PHONE_NUMBER_ADDRESS_TYPE == addressType) { + // Phone number. + temp.appendTextString(STRING_PHONE_NUMBER_ADDRESS_TYPE.getBytes()); + } else if (PDU_IPV4_ADDRESS_TYPE == addressType) { + // Ipv4 address. + temp.appendTextString(STRING_IPV4_ADDRESS_TYPE.getBytes()); + } else if (PDU_IPV6_ADDRESS_TYPE == addressType) { + // Ipv6 address. + temp.appendTextString(STRING_IPV6_ADDRESS_TYPE.getBytes()); + } + } catch (NullPointerException e) { + return null; + } + + return temp; + } + + /** + * Append header to mMessage. + */ + private int appendHeader(int field) { + switch (field) { + case PduHeaders.MMS_VERSION: + appendOctet(field); + + int version = mPduHeader.getOctet(field); + if (0 == version) { + appendShortInteger(PduHeaders.CURRENT_MMS_VERSION); + } else { + appendShortInteger(version); + } + + break; + + case PduHeaders.MESSAGE_ID: + case PduHeaders.TRANSACTION_ID: + byte[] textString = mPduHeader.getTextString(field); + if (null == textString) { + return PDU_COMPOSE_FIELD_NOT_SET; + } + + appendOctet(field); + appendTextString(textString); + break; + + case PduHeaders.TO: + case PduHeaders.BCC: + case PduHeaders.CC: + EncodedStringValue[] addr = mPduHeader.getEncodedStringValues(field); + + if (null == addr) { + return PDU_COMPOSE_FIELD_NOT_SET; + } + + EncodedStringValue temp; + for (int i = 0; i < addr.length; i++) { + temp = appendAddressType(addr[i]); + if (temp == null) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + appendOctet(field); + appendEncodedString(temp); + } + break; + + case PduHeaders.FROM: + // Value-length (Address-present-token Encoded-string-value | Insert-address-token) + appendOctet(field); + + EncodedStringValue from = mPduHeader.getEncodedStringValue(field); + if ((from == null) + || TextUtils.isEmpty(from.getString()) + || new String(from.getTextString()).equals( + PduHeaders.FROM_INSERT_ADDRESS_TOKEN_STR)) { + // Length of from = 1 + append(1); + // Insert-address-token = + append(PduHeaders.FROM_INSERT_ADDRESS_TOKEN); + } else { + mStack.newbuf(); + PositionMarker fstart = mStack.mark(); + + // Address-present-token = + append(PduHeaders.FROM_ADDRESS_PRESENT_TOKEN); + + temp = appendAddressType(from); + if (temp == null) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + appendEncodedString(temp); + + int flen = fstart.getLength(); + mStack.pop(); + appendValueLength(flen); + mStack.copy(); + } + break; + + case PduHeaders.READ_STATUS: + case PduHeaders.STATUS: + case PduHeaders.REPORT_ALLOWED: + case PduHeaders.PRIORITY: + case PduHeaders.DELIVERY_REPORT: + case PduHeaders.READ_REPORT: + int octet = mPduHeader.getOctet(field); + if (0 == octet) { + return PDU_COMPOSE_FIELD_NOT_SET; + } + + appendOctet(field); + appendOctet(octet); + break; + + case PduHeaders.DATE: + long date = mPduHeader.getLongInteger(field); + if (-1 == date) { + return PDU_COMPOSE_FIELD_NOT_SET; + } + + appendOctet(field); + appendDateValue(date); + break; + + case PduHeaders.SUBJECT: + EncodedStringValue enString = + mPduHeader.getEncodedStringValue(field); + if (null == enString) { + return PDU_COMPOSE_FIELD_NOT_SET; + } + + appendOctet(field); + appendEncodedString(enString); + break; + + case PduHeaders.MESSAGE_CLASS: + byte[] messageClass = mPduHeader.getTextString(field); + if (null == messageClass) { + return PDU_COMPOSE_FIELD_NOT_SET; + } + + appendOctet(field); + if (Arrays.equals(messageClass, + PduHeaders.MESSAGE_CLASS_ADVERTISEMENT_STR.getBytes())) { + appendOctet(PduHeaders.MESSAGE_CLASS_ADVERTISEMENT); + } else if (Arrays.equals(messageClass, + PduHeaders.MESSAGE_CLASS_AUTO_STR.getBytes())) { + appendOctet(PduHeaders.MESSAGE_CLASS_AUTO); + } else if (Arrays.equals(messageClass, + PduHeaders.MESSAGE_CLASS_PERSONAL_STR.getBytes())) { + appendOctet(PduHeaders.MESSAGE_CLASS_PERSONAL); + } else if (Arrays.equals(messageClass, + PduHeaders.MESSAGE_CLASS_INFORMATIONAL_STR.getBytes())) { + appendOctet(PduHeaders.MESSAGE_CLASS_INFORMATIONAL); + } else { + appendTextString(messageClass); + } + break; + + case PduHeaders.EXPIRY: + long expiry = mPduHeader.getLongInteger(field); + if (-1 == expiry) { + return PDU_COMPOSE_FIELD_NOT_SET; + } + + appendOctet(field); + + mStack.newbuf(); + PositionMarker expiryStart = mStack.mark(); + + append(PduHeaders.VALUE_RELATIVE_TOKEN); + appendLongInteger(expiry); + + int expiryLength = expiryStart.getLength(); + mStack.pop(); + appendValueLength(expiryLength); + mStack.copy(); + break; + + default: + return PDU_COMPOSE_FIELD_NOT_SUPPORTED; + } + + return PDU_COMPOSE_SUCCESS; + } + + /** + * Make ReadRec.Ind. + */ + private int makeReadRecInd() { + if (mMessage == null) { + mMessage = new ByteArrayOutputStream(); + mPosition = 0; + } + + // X-Mms-Message-Type + appendOctet(PduHeaders.MESSAGE_TYPE); + appendOctet(PduHeaders.MESSAGE_TYPE_READ_REC_IND); + + // X-Mms-MMS-Version + if (appendHeader(PduHeaders.MMS_VERSION) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // Message-ID + if (appendHeader(PduHeaders.MESSAGE_ID) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // To + if (appendHeader(PduHeaders.TO) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // From + if (appendHeader(PduHeaders.FROM) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // Date Optional + appendHeader(PduHeaders.DATE); + + // X-Mms-Read-Status + if (appendHeader(PduHeaders.READ_STATUS) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // X-Mms-Applic-ID Optional(not support) + // X-Mms-Reply-Applic-ID Optional(not support) + // X-Mms-Aux-Applic-Info Optional(not support) + + return PDU_COMPOSE_SUCCESS; + } + + /** + * Make NotifyResp.Ind. + */ + private int makeNotifyResp() { + if (mMessage == null) { + mMessage = new ByteArrayOutputStream(); + mPosition = 0; + } + + // X-Mms-Message-Type + appendOctet(PduHeaders.MESSAGE_TYPE); + appendOctet(PduHeaders.MESSAGE_TYPE_NOTIFYRESP_IND); + + // X-Mms-Transaction-ID + if (appendHeader(PduHeaders.TRANSACTION_ID) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // X-Mms-MMS-Version + if (appendHeader(PduHeaders.MMS_VERSION) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // X-Mms-Status + if (appendHeader(PduHeaders.STATUS) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // X-Mms-Report-Allowed Optional (not support) + return PDU_COMPOSE_SUCCESS; + } + + /** + * Make Acknowledge.Ind. + */ + private int makeAckInd() { + if (mMessage == null) { + mMessage = new ByteArrayOutputStream(); + mPosition = 0; + } + + // X-Mms-Message-Type + appendOctet(PduHeaders.MESSAGE_TYPE); + appendOctet(PduHeaders.MESSAGE_TYPE_ACKNOWLEDGE_IND); + + // X-Mms-Transaction-ID + if (appendHeader(PduHeaders.TRANSACTION_ID) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // X-Mms-MMS-Version + if (appendHeader(PduHeaders.MMS_VERSION) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // X-Mms-Report-Allowed Optional + appendHeader(PduHeaders.REPORT_ALLOWED); + + return PDU_COMPOSE_SUCCESS; + } + + /** + * Make Send.req. + */ + private int makeSendReqPdu() { + if (mMessage == null) { + mMessage = new ByteArrayOutputStream(); + mPosition = 0; + } + + // X-Mms-Message-Type + appendOctet(PduHeaders.MESSAGE_TYPE); + appendOctet(PduHeaders.MESSAGE_TYPE_SEND_REQ); + + // X-Mms-Transaction-ID + appendOctet(PduHeaders.TRANSACTION_ID); + + byte[] trid = mPduHeader.getTextString(PduHeaders.TRANSACTION_ID); + if (trid == null) { + // Transaction-ID should be set(by Transaction) before make(). + throw new IllegalArgumentException("Transaction-ID is null."); + } + appendTextString(trid); + + // X-Mms-MMS-Version + if (appendHeader(PduHeaders.MMS_VERSION) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // Date Date-value Optional. + appendHeader(PduHeaders.DATE); + + // From + if (appendHeader(PduHeaders.FROM) != PDU_COMPOSE_SUCCESS) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + boolean recipient = false; + + // To + if (appendHeader(PduHeaders.TO) != PDU_COMPOSE_CONTENT_ERROR) { + recipient = true; + } + + // Cc + if (appendHeader(PduHeaders.CC) != PDU_COMPOSE_CONTENT_ERROR) { + recipient = true; + } + + // Bcc + if (appendHeader(PduHeaders.BCC) != PDU_COMPOSE_CONTENT_ERROR) { + recipient = true; + } + + // Need at least one of "cc", "bcc" and "to". + if (false == recipient) { + return PDU_COMPOSE_CONTENT_ERROR; + } + + // Subject Optional + appendHeader(PduHeaders.SUBJECT); + + // X-Mms-Message-Class Optional + // Message-class-value = Class-identifier | Token-text + appendHeader(PduHeaders.MESSAGE_CLASS); + + // X-Mms-Expiry Optional + appendHeader(PduHeaders.EXPIRY); + + // X-Mms-Priority Optional + appendHeader(PduHeaders.PRIORITY); + + // X-Mms-Delivery-Report Optional + appendHeader(PduHeaders.DELIVERY_REPORT); + + // X-Mms-Read-Report Optional + appendHeader(PduHeaders.READ_REPORT); + + // Content-Type + appendOctet(PduHeaders.CONTENT_TYPE); + + // Message body + return makeMessageBody(); + } + + /** + * Make message body. + */ + private int makeMessageBody() { + // 1. add body informations + mStack.newbuf(); // Switching buffer because we need to + + PositionMarker ctStart = mStack.mark(); + + // This contentTypeIdentifier should be used for type of attachment... + String contentType = new String(mPduHeader.getTextString(PduHeaders.CONTENT_TYPE)); + Integer contentTypeIdentifier = mContentTypeMap.get(contentType); + if (contentTypeIdentifier == null) { + // content type is mandatory + return PDU_COMPOSE_CONTENT_ERROR; + } + + appendShortInteger(contentTypeIdentifier.intValue()); + + // content-type parameter: start + PduBody body = ((SendReq) mPdu).getBody(); + if (null == body || body.getPartsNum() == 0) { + // empty message + appendUintvarInteger(0); + mStack.pop(); + mStack.copy(); + return PDU_COMPOSE_SUCCESS; + } + + PduPart part; + try { + part = body.getPart(0); + + byte[] start = part.getContentId(); + if (start != null && start.length > 0) { + appendOctet(PduPart.P_DEP_START); + if (('<' == start[0]) && ('>' == start[start.length - 1])) { + appendTextString(start); + } else { + appendTextString("<" + new String(start) + ">"); + } + } + + // content-type parameter: type + appendOctet(PduPart.P_CT_MR_TYPE); + appendTextString(part.getContentType()); + } + catch (ArrayIndexOutOfBoundsException e){ + Log.e(TAG, "logging error", e); + e.printStackTrace(); + } + + int ctLength = ctStart.getLength(); + mStack.pop(); + appendValueLength(ctLength); + mStack.copy(); + + // 3. add content + int partNum = body.getPartsNum(); + appendUintvarInteger(partNum); + for (int i = 0; i < partNum; i++) { + part = body.getPart(i); + mStack.newbuf(); // Leaving space for header lengh and data length + PositionMarker attachment = mStack.mark(); + + mStack.newbuf(); // Leaving space for Content-Type length + PositionMarker contentTypeBegin = mStack.mark(); + + byte[] partContentType = part.getContentType(); + + if (partContentType == null) { + // content type is mandatory + return PDU_COMPOSE_CONTENT_ERROR; + } + + // content-type value + Integer partContentTypeIdentifier = + mContentTypeMap.get(new String(partContentType)); + if (partContentTypeIdentifier == null) { + appendTextString(partContentType); + } else { + appendShortInteger(partContentTypeIdentifier.intValue()); + } + + /* Content-type parameter : name. + * The value of name, filename, content-location is the same. + * Just one of them is enough for this PDU. + */ + byte[] name = part.getName(); + + if (null == name) { + name = part.getFilename(); + + if (null == name) { + name = part.getContentLocation(); + + if (null == name) { + /* at lease one of name, filename, Content-location + * should be available. + */ + return PDU_COMPOSE_CONTENT_ERROR; + } + } + } + appendOctet(PduPart.P_DEP_NAME); + appendTextString(name); + + // content-type parameter : charset + int charset = part.getCharset(); + if (charset != 0) { + appendOctet(PduPart.P_CHARSET); + appendShortInteger(charset); + } + + int contentTypeLength = contentTypeBegin.getLength(); + mStack.pop(); + appendValueLength(contentTypeLength); + mStack.copy(); + + // content id + byte[] contentId = part.getContentId(); + + if (null != contentId && contentId.length > 0) { + appendOctet(PduPart.P_CONTENT_ID); + if (('<' == contentId[0]) && ('>' == contentId[contentId.length - 1])) { + appendQuotedString(contentId); + } else { + appendQuotedString("<" + new String(contentId) + ">"); + } + } + + // content-location + byte[] contentLocation = part.getContentLocation(); + if (null != contentLocation) { + appendOctet(PduPart.P_CONTENT_LOCATION); + appendTextString(contentLocation); + } + + // content + int headerLength = attachment.getLength(); + + int dataLength = 0; // Just for safety... + byte[] partData = part.getData(); + + if (partData != null) { + arraycopy(partData, 0, partData.length); + dataLength = partData.length; + } else { + InputStream cr = null; + try { + byte[] buffer = new byte[PDU_COMPOSER_BLOCK_SIZE]; + cr = mResolver.openInputStream(part.getDataUri()); + int len = 0; + while ((len = cr.read(buffer)) != -1) { + mMessage.write(buffer, 0, len); + mPosition += len; + dataLength += len; + } + } catch (FileNotFoundException e) { + return PDU_COMPOSE_CONTENT_ERROR; + } catch (IOException e) { + return PDU_COMPOSE_CONTENT_ERROR; + } catch (RuntimeException e) { + return PDU_COMPOSE_CONTENT_ERROR; + } finally { + if (cr != null) { + try { + cr.close(); + } catch (IOException e) { + } + } + } + } + + if (dataLength != (attachment.getLength() - headerLength)) { + throw new RuntimeException("BUG: Length sanity check failed"); + } + + mStack.pop(); + appendUintvarInteger(headerLength); + appendUintvarInteger(dataLength); + mStack.copy(); + } + + return PDU_COMPOSE_SUCCESS; + } + + /** + * Record current message informations. + */ + static private class LengthRecordNode { + ByteArrayOutputStream currentMessage = null; + public int currentPosition = 0; + + public LengthRecordNode next = null; + } + + /** + * Mark current message position and stact size. + */ + private class PositionMarker { + private int c_pos; // Current position + private int currentStackSize; // Current stack size + + int getLength() { + // If these assert fails, likely that you are finding the + // size of buffer that is deep in BufferStack you can only + // find the length of the buffer that is on top + if (currentStackSize != mStack.stackSize) { + throw new RuntimeException("BUG: Invalid call to getLength()"); + } + + return mPosition - c_pos; + } + } + + /** + * This implementation can be OPTIMIZED to use only + * 2 buffers. This optimization involves changing BufferStack + * only... Its usage (interface) will not change. + */ + private class BufferStack { + private LengthRecordNode stack = null; + private LengthRecordNode toCopy = null; + + int stackSize = 0; + + /** + * Create a new message buffer and push it into the stack. + */ + void newbuf() { + // You can't create a new buff when toCopy != null + // That is after calling pop() and before calling copy() + // If you do, it is a bug + if (toCopy != null) { + throw new RuntimeException("BUG: Invalid newbuf() before copy()"); + } + + LengthRecordNode temp = new LengthRecordNode(); + + temp.currentMessage = mMessage; + temp.currentPosition = mPosition; + + temp.next = stack; + stack = temp; + + stackSize = stackSize + 1; + + mMessage = new ByteArrayOutputStream(); + mPosition = 0; + } + + /** + * Pop the message before and record current message in the stack. + */ + void pop() { + ByteArrayOutputStream currentMessage = mMessage; + int currentPosition = mPosition; + + mMessage = stack.currentMessage; + mPosition = stack.currentPosition; + + toCopy = stack; + // Re using the top element of the stack to avoid memory allocation + + stack = stack.next; + stackSize = stackSize - 1; + + toCopy.currentMessage = currentMessage; + toCopy.currentPosition = currentPosition; + } + + /** + * Append current message to the message before. + */ + void copy() { + arraycopy(toCopy.currentMessage.toByteArray(), 0, + toCopy.currentPosition); + + toCopy = null; + } + + /** + * Mark current message position + */ + PositionMarker mark() { + PositionMarker m = new PositionMarker(); + + m.c_pos = mPosition; + m.currentStackSize = stackSize; + + return m; + } + } + + /** + * Check address type. + * + * @param address address string without the postfix stinng type, + * such as "/TYPE=PLMN", "/TYPE=IPv6" and "/TYPE=IPv4" + * @return PDU_PHONE_NUMBER_ADDRESS_TYPE if it is phone number, + * PDU_EMAIL_ADDRESS_TYPE if it is email address, + * PDU_IPV4_ADDRESS_TYPE if it is ipv4 address, + * PDU_IPV6_ADDRESS_TYPE if it is ipv6 address, + * PDU_UNKNOWN_ADDRESS_TYPE if it is unknown. + */ + protected static int checkAddressType(String address) { + /** + * From OMA-TS-MMS-ENC-V1_3-20050927-C.pdf, section 8. + * address = ( e-mail / device-address / alphanum-shortcode / num-shortcode) + * e-mail = mailbox; to the definition of mailbox as described in + * section 3.4 of [RFC2822], but excluding the + * obsolete definitions as indicated by the "obs-" prefix. + * device-address = ( global-phone-number "/TYPE=PLMN" ) + * / ( ipv4 "/TYPE=IPv4" ) / ( ipv6 "/TYPE=IPv6" ) + * / ( escaped-value "/TYPE=" address-type ) + * + * global-phone-number = ["+"] 1*( DIGIT / written-sep ) + * written-sep =("-"/".") + * + * ipv4 = 1*3DIGIT 3( "." 1*3DIGIT ) ; IPv4 address value + * + * ipv6 = 4HEXDIG 7( ":" 4HEXDIG ) ; IPv6 address per RFC 2373 + */ + + if (null == address) { + return PDU_UNKNOWN_ADDRESS_TYPE; + } + + if (address.matches(REGEXP_IPV4_ADDRESS_TYPE)) { + // Ipv4 address. + return PDU_IPV4_ADDRESS_TYPE; + }else if (address.matches(REGEXP_PHONE_NUMBER_ADDRESS_TYPE)) { + // Phone number. + return PDU_PHONE_NUMBER_ADDRESS_TYPE; + } else if (address.matches(REGEXP_EMAIL_ADDRESS_TYPE)) { + // Email address. + return PDU_EMAIL_ADDRESS_TYPE; + } else if (address.matches(REGEXP_IPV6_ADDRESS_TYPE)) { + // Ipv6 address. + return PDU_IPV6_ADDRESS_TYPE; + } else { + // Unknown address. + return PDU_UNKNOWN_ADDRESS_TYPE; + } + } +} diff --git a/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/PduContentTypes.java b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/PduContentTypes.java new file mode 100644 index 000000000..adadadc3e --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/PduContentTypes.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2015 Jacob Klinker + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.capcom.smsgateway.modules.mms.pdu.aosp; + +public class PduContentTypes { + /** + * All content types. From: + * http://www.openmobilealliance.org/tech/omna/omna-wsp-content-type.htm + */ + static final String[] contentTypes = { + "*/*", /* 0x00 */ + "text/*", /* 0x01 */ + "text/html", /* 0x02 */ + "text/plain", /* 0x03 */ + "text/x-hdml", /* 0x04 */ + "text/x-ttml", /* 0x05 */ + "text/x-vCalendar", /* 0x06 */ + "text/x-vCard", /* 0x07 */ + "text/vnd.wap.wml", /* 0x08 */ + "text/vnd.wap.wmlscript", /* 0x09 */ + "text/vnd.wap.wta-event", /* 0x0A */ + "multipart/*", /* 0x0B */ + "multipart/mixed", /* 0x0C */ + "multipart/form-data", /* 0x0D */ + "multipart/byteranges", /* 0x0E */ + "multipart/alternative", /* 0x0F */ + "application/*", /* 0x10 */ + "application/java-vm", /* 0x11 */ + "application/x-www-form-urlencoded", /* 0x12 */ + "application/x-hdmlc", /* 0x13 */ + "application/vnd.wap.wmlc", /* 0x14 */ + "application/vnd.wap.wmlscriptc", /* 0x15 */ + "application/vnd.wap.wta-eventc", /* 0x16 */ + "application/vnd.wap.uaprof", /* 0x17 */ + "application/vnd.wap.wtls-ca-certificate", /* 0x18 */ + "application/vnd.wap.wtls-user-certificate", /* 0x19 */ + "application/x-x509-ca-cert", /* 0x1A */ + "application/x-x509-user-cert", /* 0x1B */ + "image/*", /* 0x1C */ + "image/gif", /* 0x1D */ + "image/jpeg", /* 0x1E */ + "image/tiff", /* 0x1F */ + "image/png", /* 0x20 */ + "image/vnd.wap.wbmp", /* 0x21 */ + "application/vnd.wap.multipart.*", /* 0x22 */ + "application/vnd.wap.multipart.mixed", /* 0x23 */ + "application/vnd.wap.multipart.form-data", /* 0x24 */ + "application/vnd.wap.multipart.byteranges", /* 0x25 */ + "application/vnd.wap.multipart.alternative", /* 0x26 */ + "application/xml", /* 0x27 */ + "text/xml", /* 0x28 */ + "application/vnd.wap.wbxml", /* 0x29 */ + "application/x-x968-cross-cert", /* 0x2A */ + "application/x-x968-ca-cert", /* 0x2B */ + "application/x-x968-user-cert", /* 0x2C */ + "text/vnd.wap.si", /* 0x2D */ + "application/vnd.wap.sic", /* 0x2E */ + "text/vnd.wap.sl", /* 0x2F */ + "application/vnd.wap.slc", /* 0x30 */ + "text/vnd.wap.co", /* 0x31 */ + "application/vnd.wap.coc", /* 0x32 */ + "application/vnd.wap.multipart.related", /* 0x33 */ + "application/vnd.wap.sia", /* 0x34 */ + "text/vnd.wap.connectivity-xml", /* 0x35 */ + "application/vnd.wap.connectivity-wbxml", /* 0x36 */ + "application/pkcs7-mime", /* 0x37 */ + "application/vnd.wap.hashed-certificate", /* 0x38 */ + "application/vnd.wap.signed-certificate", /* 0x39 */ + "application/vnd.wap.cert-response", /* 0x3A */ + "application/xhtml+xml", /* 0x3B */ + "application/wml+xml", /* 0x3C */ + "text/css", /* 0x3D */ + "application/vnd.wap.mms-message", /* 0x3E */ + "application/vnd.wap.rollover-certificate", /* 0x3F */ + "application/vnd.wap.locc+wbxml", /* 0x40 */ + "application/vnd.wap.loc+xml", /* 0x41 */ + "application/vnd.syncml.dm+wbxml", /* 0x42 */ + "application/vnd.syncml.dm+xml", /* 0x43 */ + "application/vnd.syncml.notification", /* 0x44 */ + "application/vnd.wap.xhtml+xml", /* 0x45 */ + "application/vnd.wv.csp.cir", /* 0x46 */ + "application/vnd.oma.dd+xml", /* 0x47 */ + "application/vnd.oma.drm.message", /* 0x48 */ + "application/vnd.oma.drm.content", /* 0x49 */ + "application/vnd.oma.drm.rights+xml", /* 0x4A */ + "application/vnd.oma.drm.rights+wbxml", /* 0x4B */ + "application/vnd.wv.csp+xml", /* 0x4C */ + "application/vnd.wv.csp+wbxml", /* 0x4D */ + "application/vnd.syncml.ds.notification", /* 0x4E */ + "audio/*", /* 0x4F */ + "video/*", /* 0x50 */ + "application/vnd.oma.dd2+xml", /* 0x51 */ + "application/mikey" /* 0x52 */ + }; +} diff --git a/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/PduHeaders.java b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/PduHeaders.java new file mode 100644 index 000000000..220dd14c7 --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/PduHeaders.java @@ -0,0 +1,720 @@ +/* + * Copyright (C) 2015 Jacob Klinker + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.capcom.smsgateway.modules.mms.pdu.aosp; + +import me.capcom.smsgateway.modules.mms.pdu.aosp.InvalidHeaderValueException; + +import java.util.ArrayList; +import java.util.HashMap; + +public class PduHeaders { + /** + * All pdu header fields. + */ + public static final int BCC = 0x81; + public static final int CC = 0x82; + public static final int CONTENT_LOCATION = 0x83; + public static final int CONTENT_TYPE = 0x84; + public static final int DATE = 0x85; + public static final int DELIVERY_REPORT = 0x86; + public static final int DELIVERY_TIME = 0x87; + public static final int EXPIRY = 0x88; + public static final int FROM = 0x89; + public static final int MESSAGE_CLASS = 0x8A; + public static final int MESSAGE_ID = 0x8B; + public static final int MESSAGE_TYPE = 0x8C; + public static final int MMS_VERSION = 0x8D; + public static final int MESSAGE_SIZE = 0x8E; + public static final int PRIORITY = 0x8F; + + public static final int READ_REPLY = 0x90; + public static final int READ_REPORT = 0x90; + public static final int REPORT_ALLOWED = 0x91; + public static final int RESPONSE_STATUS = 0x92; + public static final int RESPONSE_TEXT = 0x93; + public static final int SENDER_VISIBILITY = 0x94; + public static final int STATUS = 0x95; + public static final int SUBJECT = 0x96; + public static final int TO = 0x97; + public static final int TRANSACTION_ID = 0x98; + public static final int RETRIEVE_STATUS = 0x99; + public static final int RETRIEVE_TEXT = 0x9A; + public static final int READ_STATUS = 0x9B; + public static final int REPLY_CHARGING = 0x9C; + public static final int REPLY_CHARGING_DEADLINE = 0x9D; + public static final int REPLY_CHARGING_ID = 0x9E; + public static final int REPLY_CHARGING_SIZE = 0x9F; + + public static final int PREVIOUSLY_SENT_BY = 0xA0; + public static final int PREVIOUSLY_SENT_DATE = 0xA1; + public static final int STORE = 0xA2; + public static final int MM_STATE = 0xA3; + public static final int MM_FLAGS = 0xA4; + public static final int STORE_STATUS = 0xA5; + public static final int STORE_STATUS_TEXT = 0xA6; + public static final int STORED = 0xA7; + public static final int ATTRIBUTES = 0xA8; + public static final int TOTALS = 0xA9; + public static final int MBOX_TOTALS = 0xAA; + public static final int QUOTAS = 0xAB; + public static final int MBOX_QUOTAS = 0xAC; + public static final int MESSAGE_COUNT = 0xAD; + public static final int CONTENT = 0xAE; + public static final int START = 0xAF; + + public static final int ADDITIONAL_HEADERS = 0xB0; + public static final int DISTRIBUTION_INDICATOR = 0xB1; + public static final int ELEMENT_DESCRIPTOR = 0xB2; + public static final int LIMIT = 0xB3; + public static final int RECOMMENDED_RETRIEVAL_MODE = 0xB4; + public static final int RECOMMENDED_RETRIEVAL_MODE_TEXT = 0xB5; + public static final int STATUS_TEXT = 0xB6; + public static final int APPLIC_ID = 0xB7; + public static final int REPLY_APPLIC_ID = 0xB8; + public static final int AUX_APPLIC_ID = 0xB9; + public static final int CONTENT_CLASS = 0xBA; + public static final int DRM_CONTENT = 0xBB; + public static final int ADAPTATION_ALLOWED = 0xBC; + public static final int REPLACE_ID = 0xBD; + public static final int CANCEL_ID = 0xBE; + public static final int CANCEL_STATUS = 0xBF; + + /** + * X-Mms-Message-Type field types. + */ + public static final int MESSAGE_TYPE_SEND_REQ = 0x80; + public static final int MESSAGE_TYPE_SEND_CONF = 0x81; + public static final int MESSAGE_TYPE_NOTIFICATION_IND = 0x82; + public static final int MESSAGE_TYPE_NOTIFYRESP_IND = 0x83; + public static final int MESSAGE_TYPE_RETRIEVE_CONF = 0x84; + public static final int MESSAGE_TYPE_ACKNOWLEDGE_IND = 0x85; + public static final int MESSAGE_TYPE_DELIVERY_IND = 0x86; + public static final int MESSAGE_TYPE_READ_REC_IND = 0x87; + public static final int MESSAGE_TYPE_READ_ORIG_IND = 0x88; + public static final int MESSAGE_TYPE_FORWARD_REQ = 0x89; + public static final int MESSAGE_TYPE_FORWARD_CONF = 0x8A; + public static final int MESSAGE_TYPE_MBOX_STORE_REQ = 0x8B; + public static final int MESSAGE_TYPE_MBOX_STORE_CONF = 0x8C; + public static final int MESSAGE_TYPE_MBOX_VIEW_REQ = 0x8D; + public static final int MESSAGE_TYPE_MBOX_VIEW_CONF = 0x8E; + public static final int MESSAGE_TYPE_MBOX_UPLOAD_REQ = 0x8F; + public static final int MESSAGE_TYPE_MBOX_UPLOAD_CONF = 0x90; + public static final int MESSAGE_TYPE_MBOX_DELETE_REQ = 0x91; + public static final int MESSAGE_TYPE_MBOX_DELETE_CONF = 0x92; + public static final int MESSAGE_TYPE_MBOX_DESCR = 0x93; + public static final int MESSAGE_TYPE_DELETE_REQ = 0x94; + public static final int MESSAGE_TYPE_DELETE_CONF = 0x95; + public static final int MESSAGE_TYPE_CANCEL_REQ = 0x96; + public static final int MESSAGE_TYPE_CANCEL_CONF = 0x97; + + /** + * X-Mms-Delivery-Report | + * X-Mms-Read-Report | + * X-Mms-Report-Allowed | + * X-Mms-Sender-Visibility | + * X-Mms-Store | + * X-Mms-Stored | + * X-Mms-Totals | + * X-Mms-Quotas | + * X-Mms-Distribution-Indicator | + * X-Mms-DRM-Content | + * X-Mms-Adaptation-Allowed | + * field types. + */ + public static final int VALUE_YES = 0x80; + public static final int VALUE_NO = 0x81; + + /** + * Delivery-Time | + * Expiry and Reply-Charging-Deadline | + * field type components. + */ + public static final int VALUE_ABSOLUTE_TOKEN = 0x80; + public static final int VALUE_RELATIVE_TOKEN = 0x81; + + /** + * X-Mms-MMS-Version field types. + */ + public static final int MMS_VERSION_1_3 = ((1 << 4) | 3); + public static final int MMS_VERSION_1_2 = ((1 << 4) | 2); + public static final int MMS_VERSION_1_1 = ((1 << 4) | 1); + public static final int MMS_VERSION_1_0 = ((1 << 4) | 0); + + // Current version is 1.2. + public static final int CURRENT_MMS_VERSION = MMS_VERSION_1_2; + + /** + * From field type components. + */ + public static final int FROM_ADDRESS_PRESENT_TOKEN = 0x80; + public static final int FROM_INSERT_ADDRESS_TOKEN = 0x81; + + public static final String FROM_ADDRESS_PRESENT_TOKEN_STR = "address-present-token"; + public static final String FROM_INSERT_ADDRESS_TOKEN_STR = "insert-address-token"; + + /** + * X-Mms-Status Field. + */ + public static final int STATUS_EXPIRED = 0x80; + public static final int STATUS_RETRIEVED = 0x81; + public static final int STATUS_REJECTED = 0x82; + public static final int STATUS_DEFERRED = 0x83; + public static final int STATUS_UNRECOGNIZED = 0x84; + public static final int STATUS_INDETERMINATE = 0x85; + public static final int STATUS_FORWARDED = 0x86; + public static final int STATUS_UNREACHABLE = 0x87; + + /** + * MM-Flags field type components. + */ + public static final int MM_FLAGS_ADD_TOKEN = 0x80; + public static final int MM_FLAGS_REMOVE_TOKEN = 0x81; + public static final int MM_FLAGS_FILTER_TOKEN = 0x82; + + /** + * X-Mms-Message-Class field types. + */ + public static final int MESSAGE_CLASS_PERSONAL = 0x80; + public static final int MESSAGE_CLASS_ADVERTISEMENT = 0x81; + public static final int MESSAGE_CLASS_INFORMATIONAL = 0x82; + public static final int MESSAGE_CLASS_AUTO = 0x83; + + public static final String MESSAGE_CLASS_PERSONAL_STR = "personal"; + public static final String MESSAGE_CLASS_ADVERTISEMENT_STR = "advertisement"; + public static final String MESSAGE_CLASS_INFORMATIONAL_STR = "informational"; + public static final String MESSAGE_CLASS_AUTO_STR = "auto"; + + /** + * X-Mms-Priority field types. + */ + public static final int PRIORITY_LOW = 0x80; + public static final int PRIORITY_NORMAL = 0x81; + public static final int PRIORITY_HIGH = 0x82; + + /** + * X-Mms-Response-Status field types. + */ + public static final int RESPONSE_STATUS_OK = 0x80; + public static final int RESPONSE_STATUS_ERROR_UNSPECIFIED = 0x81; + public static final int RESPONSE_STATUS_ERROR_SERVICE_DENIED = 0x82; + + public static final int RESPONSE_STATUS_ERROR_MESSAGE_FORMAT_CORRUPT = 0x83; + public static final int RESPONSE_STATUS_ERROR_SENDING_ADDRESS_UNRESOLVED = 0x84; + + public static final int RESPONSE_STATUS_ERROR_MESSAGE_NOT_FOUND = 0x85; + public static final int RESPONSE_STATUS_ERROR_NETWORK_PROBLEM = 0x86; + public static final int RESPONSE_STATUS_ERROR_CONTENT_NOT_ACCEPTED = 0x87; + public static final int RESPONSE_STATUS_ERROR_UNSUPPORTED_MESSAGE = 0x88; + public static final int RESPONSE_STATUS_ERROR_TRANSIENT_FAILURE = 0xC0; + + public static final int RESPONSE_STATUS_ERROR_TRANSIENT_SENDNG_ADDRESS_UNRESOLVED = 0xC1; + public static final int RESPONSE_STATUS_ERROR_TRANSIENT_MESSAGE_NOT_FOUND = 0xC2; + public static final int RESPONSE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM = 0xC3; + public static final int RESPONSE_STATUS_ERROR_TRANSIENT_PARTIAL_SUCCESS = 0xC4; + + public static final int RESPONSE_STATUS_ERROR_PERMANENT_FAILURE = 0xE0; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_SERVICE_DENIED = 0xE1; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_MESSAGE_FORMAT_CORRUPT = 0xE2; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_SENDING_ADDRESS_UNRESOLVED = 0xE3; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_MESSAGE_NOT_FOUND = 0xE4; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_CONTENT_NOT_ACCEPTED = 0xE5; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_LIMITATIONS_NOT_MET = 0xE6; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_REQUEST_NOT_ACCEPTED = 0xE6; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_FORWARDING_DENIED = 0xE8; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_REPLY_CHARGING_NOT_SUPPORTED = 0xE9; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_ADDRESS_HIDING_NOT_SUPPORTED = 0xEA; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_LACK_OF_PREPAID = 0xEB; + public static final int RESPONSE_STATUS_ERROR_PERMANENT_END = 0xFF; + + /** + * X-Mms-Retrieve-Status field types. + */ + public static final int RETRIEVE_STATUS_OK = 0x80; + public static final int RETRIEVE_STATUS_ERROR_TRANSIENT_FAILURE = 0xC0; + public static final int RETRIEVE_STATUS_ERROR_TRANSIENT_MESSAGE_NOT_FOUND = 0xC1; + public static final int RETRIEVE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM = 0xC2; + public static final int RETRIEVE_STATUS_ERROR_PERMANENT_FAILURE = 0xE0; + public static final int RETRIEVE_STATUS_ERROR_PERMANENT_SERVICE_DENIED = 0xE1; + public static final int RETRIEVE_STATUS_ERROR_PERMANENT_MESSAGE_NOT_FOUND = 0xE2; + public static final int RETRIEVE_STATUS_ERROR_PERMANENT_CONTENT_UNSUPPORTED = 0xE3; + public static final int RETRIEVE_STATUS_ERROR_END = 0xFF; + + /** + * X-Mms-Sender-Visibility field types. + */ + public static final int SENDER_VISIBILITY_HIDE = 0x80; + public static final int SENDER_VISIBILITY_SHOW = 0x81; + + /** + * X-Mms-Read-Status field types. + */ + public static final int READ_STATUS_READ = 0x80; + public static final int READ_STATUS__DELETED_WITHOUT_BEING_READ = 0x81; + + /** + * X-Mms-Cancel-Status field types. + */ + public static final int CANCEL_STATUS_REQUEST_SUCCESSFULLY_RECEIVED = 0x80; + public static final int CANCEL_STATUS_REQUEST_CORRUPTED = 0x81; + + /** + * X-Mms-Reply-Charging field types. + */ + public static final int REPLY_CHARGING_REQUESTED = 0x80; + public static final int REPLY_CHARGING_REQUESTED_TEXT_ONLY = 0x81; + public static final int REPLY_CHARGING_ACCEPTED = 0x82; + public static final int REPLY_CHARGING_ACCEPTED_TEXT_ONLY = 0x83; + + /** + * X-Mms-MM-State field types. + */ + public static final int MM_STATE_DRAFT = 0x80; + public static final int MM_STATE_SENT = 0x81; + public static final int MM_STATE_NEW = 0x82; + public static final int MM_STATE_RETRIEVED = 0x83; + public static final int MM_STATE_FORWARDED = 0x84; + + /** + * X-Mms-Recommended-Retrieval-Mode field types. + */ + public static final int RECOMMENDED_RETRIEVAL_MODE_MANUAL = 0x80; + + /** + * X-Mms-Content-Class field types. + */ + public static final int CONTENT_CLASS_TEXT = 0x80; + public static final int CONTENT_CLASS_IMAGE_BASIC = 0x81; + public static final int CONTENT_CLASS_IMAGE_RICH = 0x82; + public static final int CONTENT_CLASS_VIDEO_BASIC = 0x83; + public static final int CONTENT_CLASS_VIDEO_RICH = 0x84; + public static final int CONTENT_CLASS_MEGAPIXEL = 0x85; + public static final int CONTENT_CLASS_CONTENT_BASIC = 0x86; + public static final int CONTENT_CLASS_CONTENT_RICH = 0x87; + + /** + * X-Mms-Store-Status field types. + */ + public static final int STORE_STATUS_SUCCESS = 0x80; + public static final int STORE_STATUS_ERROR_TRANSIENT_FAILURE = 0xC0; + public static final int STORE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM = 0xC1; + public static final int STORE_STATUS_ERROR_PERMANENT_FAILURE = 0xE0; + public static final int STORE_STATUS_ERROR_PERMANENT_SERVICE_DENIED = 0xE1; + public static final int STORE_STATUS_ERROR_PERMANENT_MESSAGE_FORMAT_CORRUPT = 0xE2; + public static final int STORE_STATUS_ERROR_PERMANENT_MESSAGE_NOT_FOUND = 0xE3; + public static final int STORE_STATUS_ERROR_PERMANENT_MMBOX_FULL = 0xE4; + public static final int STORE_STATUS_ERROR_END = 0xFF; + + /** + * The map contains the value of all headers. + */ + private HashMap mHeaderMap = null; + + /** + * Constructor of PduHeaders. + */ + public PduHeaders() { + mHeaderMap = new HashMap(); + } + + /** + * Get octet value by header field. + * + * @param field the field + * @return the octet value of the pdu header + * with specified header field. Return 0 if + * the value is not set. + */ + protected int getOctet(int field) { + Integer octet = (Integer) mHeaderMap.get(field); + if (null == octet) { + return 0; + } + + return octet; + } + + /** + * Set octet value to pdu header by header field. + * + * @param value the value + * @param field the field + * @throws InvalidHeaderValueException if the value is invalid. + */ + protected void setOctet(int value, int field) + throws InvalidHeaderValueException{ + /** + * Check whether this field can be set for specific + * header and check validity of the field. + */ + switch (field) { + case REPORT_ALLOWED: + case ADAPTATION_ALLOWED: + case DELIVERY_REPORT: + case DRM_CONTENT: + case DISTRIBUTION_INDICATOR: + case QUOTAS: + case READ_REPORT: + case STORE: + case STORED: + case TOTALS: + case SENDER_VISIBILITY: + if ((VALUE_YES != value) && (VALUE_NO != value)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case READ_STATUS: + if ((READ_STATUS_READ != value) && + (READ_STATUS__DELETED_WITHOUT_BEING_READ != value)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case CANCEL_STATUS: + if ((CANCEL_STATUS_REQUEST_SUCCESSFULLY_RECEIVED != value) && + (CANCEL_STATUS_REQUEST_CORRUPTED != value)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case PRIORITY: + if ((value < PRIORITY_LOW) || (value > PRIORITY_HIGH)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case STATUS: + if ((value < STATUS_EXPIRED) || (value > STATUS_UNREACHABLE)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case REPLY_CHARGING: + if ((value < REPLY_CHARGING_REQUESTED) + || (value > REPLY_CHARGING_ACCEPTED_TEXT_ONLY)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case MM_STATE: + if ((value < MM_STATE_DRAFT) || (value > MM_STATE_FORWARDED)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case RECOMMENDED_RETRIEVAL_MODE: + if (RECOMMENDED_RETRIEVAL_MODE_MANUAL != value) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case CONTENT_CLASS: + if ((value < CONTENT_CLASS_TEXT) + || (value > CONTENT_CLASS_CONTENT_RICH)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + case RETRIEVE_STATUS: + // According to oma-ts-mms-enc-v1_3, section 7.3.50, we modify the invalid value. + if ((value > RETRIEVE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM) && + (value < RETRIEVE_STATUS_ERROR_PERMANENT_FAILURE)) { + value = RETRIEVE_STATUS_ERROR_TRANSIENT_FAILURE; + } else if ((value > RETRIEVE_STATUS_ERROR_PERMANENT_CONTENT_UNSUPPORTED) && + (value <= RETRIEVE_STATUS_ERROR_END)) { + value = RETRIEVE_STATUS_ERROR_PERMANENT_FAILURE; + } else if ((value < RETRIEVE_STATUS_OK) || + ((value > RETRIEVE_STATUS_OK) && + (value < RETRIEVE_STATUS_ERROR_TRANSIENT_FAILURE)) || + (value > RETRIEVE_STATUS_ERROR_END)) { + value = RETRIEVE_STATUS_ERROR_PERMANENT_FAILURE; + } + break; + case STORE_STATUS: + // According to oma-ts-mms-enc-v1_3, section 7.3.58, we modify the invalid value. + if ((value > STORE_STATUS_ERROR_TRANSIENT_NETWORK_PROBLEM) && + (value < STORE_STATUS_ERROR_PERMANENT_FAILURE)) { + value = STORE_STATUS_ERROR_TRANSIENT_FAILURE; + } else if ((value > STORE_STATUS_ERROR_PERMANENT_MMBOX_FULL) && + (value <= STORE_STATUS_ERROR_END)) { + value = STORE_STATUS_ERROR_PERMANENT_FAILURE; + } else if ((value < STORE_STATUS_SUCCESS) || + ((value > STORE_STATUS_SUCCESS) && + (value < STORE_STATUS_ERROR_TRANSIENT_FAILURE)) || + (value > STORE_STATUS_ERROR_END)) { + value = STORE_STATUS_ERROR_PERMANENT_FAILURE; + } + break; + case RESPONSE_STATUS: + // According to oma-ts-mms-enc-v1_3, section 7.3.48, we modify the invalid value. + if ((value > RESPONSE_STATUS_ERROR_TRANSIENT_PARTIAL_SUCCESS) && + (value < RESPONSE_STATUS_ERROR_PERMANENT_FAILURE)) { + value = RESPONSE_STATUS_ERROR_TRANSIENT_FAILURE; + } else if (((value > RESPONSE_STATUS_ERROR_PERMANENT_LACK_OF_PREPAID) && + (value <= RESPONSE_STATUS_ERROR_PERMANENT_END)) || + (value < RESPONSE_STATUS_OK) || + ((value > RESPONSE_STATUS_ERROR_UNSUPPORTED_MESSAGE) && + (value < RESPONSE_STATUS_ERROR_TRANSIENT_FAILURE)) || + (value > RESPONSE_STATUS_ERROR_PERMANENT_END)) { + value = RESPONSE_STATUS_ERROR_PERMANENT_FAILURE; + } + break; + case MMS_VERSION: + if ((value < MMS_VERSION_1_0)|| (value > MMS_VERSION_1_3)) { + value = CURRENT_MMS_VERSION; // Current version is the default value. + } + break; + case MESSAGE_TYPE: + if ((value < MESSAGE_TYPE_SEND_REQ) || (value > MESSAGE_TYPE_CANCEL_CONF)) { + // Invalid value. + throw new InvalidHeaderValueException("Invalid Octet value!"); + } + break; + default: + // This header value should not be Octect. + throw new RuntimeException("Invalid header field!"); + } + mHeaderMap.put(field, value); + } + + /** + * Get TextString value by header field. + * + * @param field the field + * @return the TextString value of the pdu header + * with specified header field + */ + protected byte[] getTextString(int field) { + return (byte[]) mHeaderMap.get(field); + } + + /** + * Set TextString value to pdu header by header field. + * + * @param value the value + * @param field the field + * @return the TextString value of the pdu header + * with specified header field + * @throws NullPointerException if the value is null. + */ + protected void setTextString(byte[] value, int field) { + /** + * Check whether this field can be set for specific + * header and check validity of the field. + */ + if (null == value) { + throw new NullPointerException(); + } + + switch (field) { + case TRANSACTION_ID: + case REPLY_CHARGING_ID: + case AUX_APPLIC_ID: + case APPLIC_ID: + case REPLY_APPLIC_ID: + case MESSAGE_ID: + case REPLACE_ID: + case CANCEL_ID: + case CONTENT_LOCATION: + case MESSAGE_CLASS: + case CONTENT_TYPE: + break; + default: + // This header value should not be Text-String. + throw new RuntimeException("Invalid header field!"); + } + mHeaderMap.put(field, value); + } + + /** + * Get EncodedStringValue value by header field. + * + * @param field the field + * @return the EncodedStringValue value of the pdu header + * with specified header field + */ + protected EncodedStringValue getEncodedStringValue(int field) { + return (EncodedStringValue) mHeaderMap.get(field); + } + + /** + * Get TO, CC or BCC header value. + * + * @param field the field + * @return the EncodeStringValue array of the pdu header + * with specified header field + */ + protected EncodedStringValue[] getEncodedStringValues(int field) { + ArrayList list = + (ArrayList) mHeaderMap.get(field); + if (null == list) { + return null; + } + EncodedStringValue[] values = new EncodedStringValue[list.size()]; + return list.toArray(values); + } + + /** + * Set EncodedStringValue value to pdu header by header field. + * + * @param value the value + * @param field the field + * @return the EncodedStringValue value of the pdu header + * with specified header field + * @throws NullPointerException if the value is null. + */ + protected void setEncodedStringValue(EncodedStringValue value, int field) { + /** + * Check whether this field can be set for specific + * header and check validity of the field. + */ + if (null == value) { + throw new NullPointerException(); + } + + switch (field) { + case SUBJECT: + case RECOMMENDED_RETRIEVAL_MODE_TEXT: + case RETRIEVE_TEXT: + case STATUS_TEXT: + case STORE_STATUS_TEXT: + case RESPONSE_TEXT: + case FROM: + case PREVIOUSLY_SENT_BY: + case MM_FLAGS: + break; + default: + // This header value should not be Encoded-String-Value. + throw new RuntimeException("Invalid header field!"); + } + + mHeaderMap.put(field, value); + } + + /** + * Set TO, CC or BCC header value. + * + * @param value the value + * @param field the field + * @return the EncodedStringValue value array of the pdu header + * with specified header field + * @throws NullPointerException if the value is null. + */ + protected void setEncodedStringValues(EncodedStringValue[] value, int field) { + /** + * Check whether this field can be set for specific + * header and check validity of the field. + */ + if (null == value) { + throw new NullPointerException(); + } + + switch (field) { + case BCC: + case CC: + case TO: + break; + default: + // This header value should not be Encoded-String-Value. + throw new RuntimeException("Invalid header field!"); + } + + ArrayList list = new ArrayList(); + for (int i = 0; i < value.length; i++) { + list.add(value[i]); + } + mHeaderMap.put(field, list); + } + + /** + * Append one EncodedStringValue to another. + * + * @param value the EncodedStringValue to append + * @param field the field + * @throws NullPointerException if the value is null. + */ + protected void appendEncodedStringValue(EncodedStringValue value, + int field) { + if (null == value) { + throw new NullPointerException(); + } + + switch (field) { + case BCC: + case CC: + case TO: + break; + default: + throw new RuntimeException("Invalid header field!"); + } + + ArrayList list = + (ArrayList) mHeaderMap.get(field); + if (null == list) { + list = new ArrayList(); + } + list.add(value); + mHeaderMap.put(field, list); + } + + /** + * Get LongInteger value by header field. + * + * @param field the field + * @return the LongInteger value of the pdu header + * with specified header field. if return -1, the + * field is not existed in pdu header. + */ + protected long getLongInteger(int field) { + Long longInteger = (Long) mHeaderMap.get(field); + if (null == longInteger) { + return -1; + } + + return longInteger.longValue(); + } + + /** + * Set LongInteger value to pdu header by header field. + * + * @param value the value + * @param field the field + */ + protected void setLongInteger(long value, int field) { + /** + * Check whether this field can be set for specific + * header and check validity of the field. + */ + switch (field) { + case DATE: + case REPLY_CHARGING_SIZE: + case MESSAGE_SIZE: + case MESSAGE_COUNT: + case START: + case LIMIT: + case DELIVERY_TIME: + case EXPIRY: + case REPLY_CHARGING_DEADLINE: + case PREVIOUSLY_SENT_DATE: + break; + default: + // This header value should not be LongInteger. + throw new RuntimeException("Invalid header field!"); + } + mHeaderMap.put(field, value); + } +} diff --git a/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/PduPart.java b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/PduPart.java new file mode 100644 index 000000000..62067b3bf --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/PduPart.java @@ -0,0 +1,418 @@ +/* + * Copyright (C) 2015 Jacob Klinker + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.capcom.smsgateway.modules.mms.pdu.aosp; + +import android.net.Uri; + +import java.util.HashMap; +import java.util.Map; + +/** + * The pdu part. + */ +public class PduPart { + /** + * Well-Known Parameters. + */ + public static final int P_Q = 0x80; + public static final int P_CHARSET = 0x81; + public static final int P_LEVEL = 0x82; + public static final int P_TYPE = 0x83; + public static final int P_DEP_NAME = 0x85; + public static final int P_DEP_FILENAME = 0x86; + public static final int P_DIFFERENCES = 0x87; + public static final int P_PADDING = 0x88; + // This value of "TYPE" s used with Content-Type: multipart/related + public static final int P_CT_MR_TYPE = 0x89; + public static final int P_DEP_START = 0x8A; + public static final int P_DEP_START_INFO = 0x8B; + public static final int P_DEP_COMMENT = 0x8C; + public static final int P_DEP_DOMAIN = 0x8D; + public static final int P_MAX_AGE = 0x8E; + public static final int P_DEP_PATH = 0x8F; + public static final int P_SECURE = 0x90; + public static final int P_SEC = 0x91; + public static final int P_MAC = 0x92; + public static final int P_CREATION_DATE = 0x93; + public static final int P_MODIFICATION_DATE = 0x94; + public static final int P_READ_DATE = 0x95; + public static final int P_SIZE = 0x96; + public static final int P_NAME = 0x97; + public static final int P_FILENAME = 0x98; + public static final int P_START = 0x99; + public static final int P_START_INFO = 0x9A; + public static final int P_COMMENT = 0x9B; + public static final int P_DOMAIN = 0x9C; + public static final int P_PATH = 0x9D; + + /** + * Header field names. + */ + public static final int P_CONTENT_TYPE = 0x91; + public static final int P_CONTENT_LOCATION = 0x8E; + public static final int P_CONTENT_ID = 0xC0; + public static final int P_DEP_CONTENT_DISPOSITION = 0xAE; + public static final int P_CONTENT_DISPOSITION = 0xC5; + // The next header is unassigned header, use reserved header(0x48) value. + public static final int P_CONTENT_TRANSFER_ENCODING = 0xC8; + + /** + * Content=Transfer-Encoding string. + */ + public static final String CONTENT_TRANSFER_ENCODING = + "Content-Transfer-Encoding"; + + /** + * Value of Content-Transfer-Encoding. + */ + public static final String P_BINARY = "binary"; + public static final String P_7BIT = "7bit"; + public static final String P_8BIT = "8bit"; + public static final String P_BASE64 = "base64"; + public static final String P_QUOTED_PRINTABLE = "quoted-printable"; + + /** + * Value of disposition can be set to PduPart when the value is octet in + * the PDU. + * "from-data" instead of Form-data. + * "attachment" instead of Attachment. + * "inline" instead of Inline. + */ + static final byte[] DISPOSITION_FROM_DATA = "from-data".getBytes(); + static final byte[] DISPOSITION_ATTACHMENT = "attachment".getBytes(); + static final byte[] DISPOSITION_INLINE = "inline".getBytes(); + + /** + * Content-Disposition value. + */ + public static final int P_DISPOSITION_FROM_DATA = 0x80; + public static final int P_DISPOSITION_ATTACHMENT = 0x81; + public static final int P_DISPOSITION_INLINE = 0x82; + + /** + * Header of part. + */ + private Map mPartHeader = null; + + /** + * Data uri. + */ + private Uri mUri = null; + + /** + * Part data. + */ + private byte[] mPartData = null; + + private static final String TAG = "PduPart"; + + /** + * Empty Constructor. + */ + public PduPart() { + mPartHeader = new HashMap(); + } + + /** + * Set part data. The data are stored as byte array. + * + * @param data the data + */ + public void setData(byte[] data) { + if(data == null) { + mPartData = null; + return; + } + + mUri = null; + mPartData = new byte[data.length]; + System.arraycopy(data, 0, mPartData, 0, data.length); + } + + /** + * @return A copy of the part data or null if the data wasn't set or + * the data is stored as Uri. + * @see #getDataUri + */ + public byte[] getData() { + if(mPartData == null) { + return null; + } + + byte[] byteArray = new byte[mPartData.length]; + System.arraycopy(mPartData, 0, byteArray, 0, mPartData.length); + return byteArray; + } + + /** + * @return The length of the data, if this object have data, else 0. + */ + public int getDataLength() { + if(mPartData != null){ + return mPartData.length; + } else { + return 0; + } + } + + + /** + * Set data uri. The data are stored as Uri. + * + * @param uri the uri + */ + public void setDataUri(Uri uri) { + mUri = uri; + if (uri != null) { + mPartData = null; + } + } + + /** + * @return The Uri of the part data or null if the data wasn't set or + * the data is stored as byte array. + * @see #getData + */ + public Uri getDataUri() { + return mUri; + } + + /** + * Set Content-id value + * + * @param contentId the content-id value + * @throws NullPointerException if the value is null. + */ + public void setContentId(byte[] contentId) { + if((contentId == null) || (contentId.length == 0)) { + throw new IllegalArgumentException( + "Content-Id may not be null or empty."); + } + + if ((contentId.length > 1) + && ((char) contentId[0] == '<') + && ((char) contentId[contentId.length - 1] == '>')) { + mPartHeader.put(P_CONTENT_ID, contentId); + return; + } + + // Insert beginning '<' and trailing '>' for Content-Id. + byte[] buffer = new byte[contentId.length + 2]; + buffer[0] = (byte) (0xff & '<'); + buffer[buffer.length - 1] = (byte) (0xff & '>'); + System.arraycopy(contentId, 0, buffer, 1, contentId.length); + mPartHeader.put(P_CONTENT_ID, buffer); + } + + /** + * Get Content-id value. + * + * @return the value + */ + public byte[] getContentId() { + return (byte[]) mPartHeader.get(P_CONTENT_ID); + } + + /** + * Set Char-set value. + * + * @param charset the value + */ + public void setCharset(int charset) { + mPartHeader.put(P_CHARSET, charset); + } + + /** + * Get Char-set value + * + * @return the charset value. Return 0 if charset was not set. + */ + public int getCharset() { + Integer charset = (Integer) mPartHeader.get(P_CHARSET); + if(charset == null) { + return 0; + } else { + return charset.intValue(); + } + } + + /** + * Set Content-Location value. + * + * @param contentLocation the value + * @throws NullPointerException if the value is null. + */ + public void setContentLocation(byte[] contentLocation) { + if(contentLocation == null) { + throw new NullPointerException("null content-location"); + } + + mPartHeader.put(P_CONTENT_LOCATION, contentLocation); + } + + /** + * Get Content-Location value. + * + * @return the value + * return PduPart.disposition[0] instead of (Form-data). + * return PduPart.disposition[1] instead of (Attachment). + * return PduPart.disposition[2] instead of (Inline). + */ + public byte[] getContentLocation() { + return (byte[]) mPartHeader.get(P_CONTENT_LOCATION); + } + + /** + * Set Content-Disposition value. + * Use PduPart.disposition[0] instead of (Form-data). + * Use PduPart.disposition[1] instead of (Attachment). + * Use PduPart.disposition[2] instead of (Inline). + * + * @param contentDisposition the value + * @throws NullPointerException if the value is null. + */ + public void setContentDisposition(byte[] contentDisposition) { + if(contentDisposition == null) { + throw new NullPointerException("null content-disposition"); + } + + mPartHeader.put(P_CONTENT_DISPOSITION, contentDisposition); + } + + /** + * Get Content-Disposition value. + * + * @return the value + */ + public byte[] getContentDisposition() { + return (byte[]) mPartHeader.get(P_CONTENT_DISPOSITION); + } + + /** + * Set Content-Type value. + * + * @param contentType the value + * @throws NullPointerException if the value is null. + */ + public void setContentType(byte[] contentType) { + if(contentType == null) { + throw new NullPointerException("null content-type"); + } + + mPartHeader.put(P_CONTENT_TYPE, contentType); + } + + /** + * Get Content-Type value of part. + * + * @return the value + */ + public byte[] getContentType() { + return (byte[]) mPartHeader.get(P_CONTENT_TYPE); + } + + /** + * Set Content-Transfer-Encoding value + * + * @param contentTransferEncoding the content-id value + * @throws NullPointerException if the value is null. + */ + public void setContentTransferEncoding(byte[] contentTransferEncoding) { + if(contentTransferEncoding == null) { + throw new NullPointerException("null content-transfer-encoding"); + } + + mPartHeader.put(P_CONTENT_TRANSFER_ENCODING, contentTransferEncoding); + } + + /** + * Get Content-Transfer-Encoding value. + * + * @return the value + */ + public byte[] getContentTransferEncoding() { + return (byte[]) mPartHeader.get(P_CONTENT_TRANSFER_ENCODING); + } + + /** + * Set Content-type parameter: name. + * + * @param name the name value + * @throws NullPointerException if the value is null. + */ + public void setName(byte[] name) { + if(null == name) { + throw new NullPointerException("null content-id"); + } + + mPartHeader.put(P_NAME, name); + } + + /** + * Get content-type parameter: name. + * + * @return the name + */ + public byte[] getName() { + return (byte[]) mPartHeader.get(P_NAME); + } + + /** + * Get Content-disposition parameter: filename + * + * @param fileName the filename value + * @throws NullPointerException if the value is null. + */ + public void setFilename(byte[] fileName) { + if(null == fileName) { + throw new NullPointerException("null content-id"); + } + + mPartHeader.put(P_FILENAME, fileName); + } + + /** + * Set Content-disposition parameter: filename + * + * @return the filename + */ + public byte[] getFilename() { + return (byte[]) mPartHeader.get(P_FILENAME); + } + + public String generateLocation() { + // Assumption: At least one of the content-location / name / filename + // or content-id should be set. This is guaranteed by the PduParser + // for incoming messages and by MM composer for outgoing messages. + byte[] location = (byte[]) mPartHeader.get(P_NAME); + if(null == location) { + location = (byte[]) mPartHeader.get(P_FILENAME); + + if (null == location) { + location = (byte[]) mPartHeader.get(P_CONTENT_LOCATION); + } + } + + if (null == location) { + byte[] contentId = (byte[]) mPartHeader.get(P_CONTENT_ID); + return "cid:" + new String(contentId); + } else { + return new String(location); + } + } +} + diff --git a/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/SendReq.java b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/SendReq.java new file mode 100644 index 000000000..a2a0cd78d --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/mms/pdu/aosp/SendReq.java @@ -0,0 +1,364 @@ +/* + * Copyright (C) 2015 Jacob Klinker + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.capcom.smsgateway.modules.mms.pdu.aosp; + +import android.content.Context; +import android.text.TextUtils; + +import android.util.Log; + +import me.capcom.smsgateway.modules.mms.pdu.aosp.InvalidHeaderValueException; +// Utils.getMyPhoneNumberFromSubscription is supplied by the caller via +// prepareFromAddress(fromAddress) — the device MSISDN lookup used to live +// in klinker-apps' Utils class but we rely on the outer Kotlin sender to +// resolve it from SubscriptionsHelper. + +public class SendReq extends MultimediaMessagePdu { + private static final String TAG = "SendReq"; + + public SendReq() { + super(); + + try { + setMessageType(PduHeaders.MESSAGE_TYPE_SEND_REQ); + setMmsVersion(PduHeaders.CURRENT_MMS_VERSION); + // FIXME: Content-type must be decided according to whether + // SMIL part present. + setContentType("application/vnd.wap.multipart.related".getBytes()); + setFrom(new EncodedStringValue(PduHeaders.FROM_INSERT_ADDRESS_TOKEN_STR.getBytes())); + setTransactionId(generateTransactionId()); + } catch (InvalidHeaderValueException e) { + // Impossible to reach here since all headers we set above are valid. + Log.e(TAG, "Unexpected InvalidHeaderValueException.", e); + throw new RuntimeException(e); + } + } + + private byte[] generateTransactionId() { + String transactionId = "T" + Long.toHexString(System.currentTimeMillis()); + return transactionId.getBytes(); + } + + /** + * Constructor, used when composing a M-Send.req pdu. + * + * @param contentType the content type value + * @param from the from value + * @param mmsVersion current viersion of mms + * @param transactionId the transaction-id value + * @throws InvalidHeaderValueException if parameters are invalid. + * NullPointerException if contentType, form or transactionId is null. + */ + public SendReq(byte[] contentType, + EncodedStringValue from, + int mmsVersion, + byte[] transactionId) throws InvalidHeaderValueException { + super(); + setMessageType(PduHeaders.MESSAGE_TYPE_SEND_REQ); + setContentType(contentType); + setFrom(from); + setMmsVersion(mmsVersion); + setTransactionId(transactionId); + } + + /** + * Constructor with given headers. + * + * @param headers Headers for this PDU. + */ + SendReq(PduHeaders headers) { + super(headers); + } + + /** + * Constructor with given headers and body + * + * @param headers Headers for this PDU. + * @param body Body of this PDu. + */ + SendReq(PduHeaders headers, PduBody body) { + super(headers, body); + } + + /** + * Get Bcc value. + * + * @return the value + */ + public EncodedStringValue[] getBcc() { + return mPduHeaders.getEncodedStringValues(PduHeaders.BCC); + } + + /** + * Add a "BCC" value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void addBcc(EncodedStringValue value) { + mPduHeaders.appendEncodedStringValue(value, PduHeaders.BCC); + } + + /** + * Set "BCC" value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setBcc(EncodedStringValue[] value) { + mPduHeaders.setEncodedStringValues(value, PduHeaders.BCC); + } + + /** + * Get CC value. + * + * @return the value + */ + public EncodedStringValue[] getCc() { + return mPduHeaders.getEncodedStringValues(PduHeaders.CC); + } + + /** + * Add a "CC" value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void addCc(EncodedStringValue value) { + mPduHeaders.appendEncodedStringValue(value, PduHeaders.CC); + } + + /** + * Set "CC" value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setCc(EncodedStringValue[] value) { + mPduHeaders.setEncodedStringValues(value, PduHeaders.CC); + } + + /** + * Get Content-type value. + * + * @return the value + */ + public byte[] getContentType() { + return mPduHeaders.getTextString(PduHeaders.CONTENT_TYPE); + } + + /** + * Set Content-type value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setContentType(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.CONTENT_TYPE); + } + + /** + * Get X-Mms-Delivery-Report value. + * + * @return the value + */ + public int getDeliveryReport() { + return mPduHeaders.getOctet(PduHeaders.DELIVERY_REPORT); + } + + /** + * Set X-Mms-Delivery-Report value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setDeliveryReport(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.DELIVERY_REPORT); + } + + /** + * Get X-Mms-Expiry value. + * + * Expiry-value = Value-length + * (Absolute-token Date-value | Relative-token Delta-seconds-value) + * + * @return the value + */ + public long getExpiry() { + return mPduHeaders.getLongInteger(PduHeaders.EXPIRY); + } + + /** + * Set X-Mms-Expiry value. + * + * @param value the value + */ + public void setExpiry(long value) { + mPduHeaders.setLongInteger(value, PduHeaders.EXPIRY); + } + + /** + * Get X-Mms-MessageSize value. + * + * Expiry-value = size of message + * + * @return the value + */ + public long getMessageSize() { + return mPduHeaders.getLongInteger(PduHeaders.MESSAGE_SIZE); + } + + /** + * Set X-Mms-MessageSize value. + * + * @param value the value + */ + public void setMessageSize(long value) { + mPduHeaders.setLongInteger(value, PduHeaders.MESSAGE_SIZE); + } + + /** + * Get X-Mms-Message-Class value. + * Message-class-value = Class-identifier | Token-text + * Class-identifier = Personal | Advertisement | Informational | Auto + * + * @return the value + */ + public byte[] getMessageClass() { + return mPduHeaders.getTextString(PduHeaders.MESSAGE_CLASS); + } + + /** + * Set X-Mms-Message-Class value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setMessageClass(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.MESSAGE_CLASS); + } + + /** + * Get X-Mms-Read-Report value. + * + * @return the value + */ + public int getReadReport() { + return mPduHeaders.getOctet(PduHeaders.READ_REPORT); + } + + /** + * Set X-Mms-Read-Report value. + * + * @param value the value + * @throws InvalidHeaderValueException if the value is invalid. + */ + public void setReadReport(int value) throws InvalidHeaderValueException { + mPduHeaders.setOctet(value, PduHeaders.READ_REPORT); + } + + /** + * Set "To" value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setTo(EncodedStringValue[] value) { + mPduHeaders.setEncodedStringValues(value, PduHeaders.TO); + } + + /** + * Get X-Mms-Transaction-Id field value. + * + * @return the X-Mms-Report-Allowed value + */ + public byte[] getTransactionId() { + return mPduHeaders.getTextString(PduHeaders.TRANSACTION_ID); + } + + /** + * Set X-Mms-Transaction-Id field value. + * + * @param value the value + * @throws NullPointerException if the value is null. + */ + public void setTransactionId(byte[] value) { + mPduHeaders.setTextString(value, PduHeaders.TRANSACTION_ID); + } + + /** + * prepares and sets from address info in the request. + * + * @param context context + * @param fromAddress from address info from client + * @param subscriptionId subscription id to use + */ + public void prepareFromAddress(Context context, String fromAddress, int subscriptionId) { + if (!TextUtils.isEmpty(fromAddress)) { + setFrom(new EncodedStringValue(fromAddress)); + } + } + + /* + * Optional, not supported header fields: + * + * public byte getAdaptationAllowed() {return 0}; + * public void setAdaptationAllowed(btye value) {}; + * + * public byte[] getApplicId() {return null;} + * public void setApplicId(byte[] value) {} + * + * public byte[] getAuxApplicId() {return null;} + * public void getAuxApplicId(byte[] value) {} + * + * public byte getContentClass() {return 0x00;} + * public void setApplicId(byte value) {} + * + * public long getDeliveryTime() {return 0}; + * public void setDeliveryTime(long value) {}; + * + * public byte getDrmContent() {return 0x00;} + * public void setDrmContent(byte value) {} + * + * public MmFlagsValue getMmFlags() {return null;} + * public void setMmFlags(MmFlagsValue value) {} + * + * public MmStateValue getMmState() {return null;} + * public void getMmState(MmStateValue value) {} + * + * public byte[] getReplyApplicId() {return 0x00;} + * public void setReplyApplicId(byte[] value) {} + * + * public byte getReplyCharging() {return 0x00;} + * public void setReplyCharging(byte value) {} + * + * public byte getReplyChargingDeadline() {return 0x00;} + * public void setReplyChargingDeadline(byte value) {} + * + * public byte[] getReplyChargingId() {return 0x00;} + * public void setReplyChargingId(byte[] value) {} + * + * public long getReplyChargingSize() {return 0;} + * public void setReplyChargingSize(long value) {} + * + * public byte[] getReplyApplicId() {return 0x00;} + * public void setReplyApplicId(byte[] value) {} + * + * public byte getStore() {return 0x00;} + * public void setStore(byte value) {} + */ +} 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 5a3892095..83b7f9b03 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 @@ -36,12 +36,26 @@ class MessagesReceiver : BroadcastReceiver(), KoinComponent { SubscriptionsHelper.extractSubscriptionId(context, intent) ) - true -> InboxMessage.Data( - firstMessage.userData, - firstMessage.displayOriginatingAddress, - Date(firstMessage.timestampMillis), - SubscriptionsHelper.extractSubscriptionId(context, intent) - ) + true -> { + val userData = messages + .mapNotNull { it.userData } + .takeIf { it.isNotEmpty() } + ?.let { parts -> + ByteArray(parts.sumOf { it.size }).also { output -> + var offset = 0 + for (part in parts) { + part.copyInto(output, destinationOffset = offset) + offset += part.size + } + } + } + InboxMessage.Data( + userData, + firstMessage.displayOriginatingAddress, + Date(firstMessage.timestampMillis), + SubscriptionsHelper.extractSubscriptionId(context, intent) + ) + } } receiverSvc.process( @@ -63,7 +77,7 @@ class MessagesReceiver : BroadcastReceiver(), KoinComponent { val textFilter = IntentFilter().apply { addAction(Intents.SMS_RECEIVED_ACTION) } - appContext.registerReceiver( + context.registerReceiver( INSTANCE, textFilter ) diff --git a/app/src/main/java/me/capcom/smsgateway/modules/receiver/MmsContentObserver.kt b/app/src/main/java/me/capcom/smsgateway/modules/receiver/MmsContentObserver.kt index 489ac545b..c8b08ba07 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/receiver/MmsContentObserver.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/receiver/MmsContentObserver.kt @@ -1,10 +1,12 @@ package me.capcom.smsgateway.modules.receiver import android.content.Context +import android.content.pm.PackageManager import android.database.ContentObserver import android.net.Uri import android.os.Handler import android.os.HandlerThread +import androidx.core.content.ContextCompat import me.capcom.smsgateway.modules.logs.LogsService import me.capcom.smsgateway.modules.logs.db.LogEntry import me.capcom.smsgateway.modules.receiver.data.InboxMessage @@ -25,6 +27,15 @@ class MmsContentObserver : KoinComponent { return } + if (!canReadSms()) { + logsService.insert( + LogEntry.Priority.WARN, + MODULE_NAME, + "MMS inbox observer not started because READ_SMS is not granted", + ) + return + } + // Initialize high-water mark to current max ID if not set if (storage.mmsLastProcessedID == 0L) { storage.mmsLastProcessedID = queryMaxMmsId() @@ -32,8 +43,9 @@ class MmsContentObserver : KoinComponent { val thread = HandlerThread("MmsContentObserver").apply { start() } handlerThread = thread + val handler = Handler(thread.looper) - val obs = object : ContentObserver(Handler(thread.looper)) { + val obs = object : ContentObserver(handler) { override fun onChange(selfChange: Boolean) { super.onChange(selfChange) processNewMessages() @@ -44,8 +56,13 @@ class MmsContentObserver : KoinComponent { context.contentResolver.registerContentObserver( Uri.parse("content://mms"), true, - obs + obs, ) + + // Catch up rows that arrived while the app process was stopped or before + // READ_SMS was granted. ContentObserver callbacks are edge-triggered, so + // already-inserted MMS rows would otherwise remain pending forever. + handler.post { processNewMessages() } } fun stop() { @@ -56,12 +73,24 @@ class MmsContentObserver : KoinComponent { } private fun queryMaxMmsId(): Long { - val cursor = context.contentResolver.query( - Uri.parse("content://mms"), - arrayOf("_id"), - null, null, - "_id DESC LIMIT 1" - ) ?: return 0 + if (!canReadSms()) return 0 + + val cursor = try { + context.contentResolver.query( + Uri.parse("content://mms"), + arrayOf("_id"), + null, null, + "_id DESC LIMIT 1" + ) + } catch (e: SecurityException) { + logsService.insert( + LogEntry.Priority.WARN, + MODULE_NAME, + "Unable to initialize MMS inbox high-water mark because provider access was denied", + mapOf("error" to (e.message ?: e.toString())), + ) + return 0 + } ?: return 0 return cursor.use { c -> if (c.moveToFirst()) c.getLong(0) else 0 @@ -69,29 +98,50 @@ class MmsContentObserver : KoinComponent { } private fun processNewMessages() { + if (!canReadSms()) { + logsService.insert( + LogEntry.Priority.WARN, + MODULE_NAME, + "Skipping MMS inbox processing because READ_SMS is not granted", + ) + return + } + val mark = storage.mmsLastProcessedID // msg_type 132 = retrieve-conf (fully downloaded), msg_box 1 = inbox - val cursor = context.contentResolver.query( - Uri.parse("content://mms"), - arrayOf("_id"), - "_id > ? AND m_type = 132 AND msg_box = 1", - arrayOf(mark.toString()), - "_id ASC" - ) ?: return + val cursor = try { + context.contentResolver.query( + Uri.parse("content://mms"), + arrayOf("_id"), + "_id > ? AND m_type = 132 AND msg_box = 1", + arrayOf(mark.toString()), + "_id ASC" + ) + } catch (e: SecurityException) { + logsService.insert( + LogEntry.Priority.WARN, + MODULE_NAME, + "Skipping MMS inbox processing because provider access was denied", + mapOf("error" to (e.message ?: e.toString())), + ) + return + } ?: return cursor.use { c -> while (c.moveToNext()) { val mmsId = c.getLong(0) try { processMmsDownloaded(mmsId) + storage.mmsLastProcessedID = mmsId } catch (e: Exception) { logsService.insert( LogEntry.Priority.ERROR, MODULE_NAME, "Failed processing downloaded MMS (id=$mmsId)", - mapOf("mmsId" to mmsId) + mapOf("mmsId" to mmsId, "error" to (e.message ?: e.toString())), ) } + // Always advance mark to prevent one corrupt MMS from blocking the pipeline storage.mmsLastProcessedID = mmsId } } @@ -122,6 +172,11 @@ class MmsContentObserver : KoinComponent { ) } + private fun canReadSms(): Boolean = ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.READ_SMS, + ) == PackageManager.PERMISSION_GRANTED + companion object { private const val TAG = "MmsContentObserver" } diff --git a/app/src/main/java/me/capcom/smsgateway/modules/receiver/MmsReceiver.kt b/app/src/main/java/me/capcom/smsgateway/modules/receiver/MmsReceiver.kt index 4bf75c500..8ca449cd3 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/receiver/MmsReceiver.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/receiver/MmsReceiver.kt @@ -14,6 +14,7 @@ import me.capcom.smsgateway.modules.receiver.data.InboxMessage import me.capcom.smsgateway.modules.receiver.parsers.MMSParser import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import java.security.MessageDigest import java.util.Date class MmsReceiver : BroadcastReceiver(), KoinComponent { @@ -56,7 +57,10 @@ class MmsReceiver : BroadcastReceiver(), KoinComponent { "uri" to intent.extras?.getString("uri"), "header" to intent.extras?.getByteArray("header") ?.joinToString("") { "%02x".format(it) }, - "pdu" to pdu.joinToString("") { "%02x".format(it) }, + "pduSizeBytes" to pdu.size, + "pduSha256" to MessageDigest.getInstance("SHA-256") + .digest(pdu) + .joinToString("") { "%02x".format(it) }, ) ) 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 145e080fc..fff07c0d7 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 @@ -26,10 +26,10 @@ class ReceiverService : KoinComponent { private val logsService: LogsService by inject() private val incomingMessagesService: IncomingMessagesService by inject() private val receiverSettings: ReceiverSettings by inject() + private val attachmentStorage: MmsAttachmentStorage by inject() private val eventsReceiver by lazy { EventsReceiver() } private val mmsContentObserver by lazy { MmsContentObserver() } - private val smsContentObserver by lazy { SmsContentObserver() } fun start(context: Context) { MessagesReceiver.register(context) @@ -42,7 +42,6 @@ class ReceiverService : KoinComponent { } fun stop(context: Context) { - smsContentObserver.stop() mmsContentObserver.stop() eventsReceiver.stop() MmsReceiver.unregister(context) @@ -75,13 +74,16 @@ class ReceiverService : KoinComponent { ) } - fun process(context: Context, message: InboxMessage, triggerWebhooks: Boolean) { logsService.insert( LogEntry.Priority.DEBUG, MODULE_NAME, "ReceiverService::process - message received", - mapOf("message" to message) + mapOf( + "messageType" to message.javaClass.simpleName, + "date" to message.date, + "subscriptionId" to message.subscriptionId, + ) ) // Dedup safety net: skip if this exact message was already processed @@ -90,7 +92,11 @@ class ReceiverService : KoinComponent { LogEntry.Priority.DEBUG, MODULE_NAME, "ReceiverService::process - duplicate message, skipping", - mapOf("message" to message) + mapOf( + "messageType" to message.javaClass.simpleName, + "date" to message.date, + "subscriptionId" to message.subscriptionId, + ) ) return } @@ -137,24 +143,26 @@ class ReceiverService : KoinComponent { recipient = recipient, ) - is InboxMessage.MMS -> WebHookEvent.MmsDownloaded to MmsDownloadedPayload( - messageId = message.messageId, - sender = incoming.sender, - recipient = recipient, - simNumber = simNumber, - body = message.body, - subject = message.subject, - attachments = message.attachments.map { - MmsDownloadedPayload.Attachment( - partId = it.partId, - contentType = it.contentType, - name = it.name, - size = it.size, - data = it.data, - ) - }, - receivedAt = message.date, - ) + is InboxMessage.MMS -> { + WebHookEvent.MmsDownloaded to MmsDownloadedPayload( + messageId = message.messageId, + sender = incoming.sender, + recipient = recipient, + simNumber = simNumber, + body = message.body, + subject = message.subject, + attachments = message.attachments.map { + MmsDownloadedPayload.Attachment( + partId = it.partId, + contentType = it.contentType, + name = it.name, + size = it.size, + data = it.data, + ) + }, + receivedAt = message.date, + ) + } } webHooksService.emit(context, type, payload) diff --git a/app/src/main/java/me/capcom/smsgateway/modules/receiver/parsers/MMSParser.kt b/app/src/main/java/me/capcom/smsgateway/modules/receiver/parsers/MMSParser.kt index fa6a6cc08..db23f9366 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/receiver/parsers/MMSParser.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/receiver/parsers/MMSParser.kt @@ -62,7 +62,8 @@ object MMSParser { val from: String, val subject: String?, val messageSize: Long, - val contentClass: ContentClass? + val contentClass: ContentClass?, + val contentLocation: String?, ) fun parseMNotificationInd(pdu: ByteArray): MNotificationInd { @@ -119,7 +120,8 @@ object MMSParser { from = headers[HEADER_FROM] as String, subject = headers[HEADER_SUBJECT] as String?, messageSize = headers[HEADER_MESSAGE_SIZE] as Long, - contentClass = headers[HEADER_CONTENT_CLASS] as ContentClass? + contentClass = headers[HEADER_CONTENT_CLASS] as ContentClass?, + contentLocation = headers[HEADER_CONTENT_LOCATION] as String?, ) } diff --git a/app/src/main/java/me/capcom/smsgateway/modules/receiver/parsers/MMSRetrieveParser.kt b/app/src/main/java/me/capcom/smsgateway/modules/receiver/parsers/MMSRetrieveParser.kt new file mode 100644 index 000000000..eba785b66 --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/receiver/parsers/MMSRetrieveParser.kt @@ -0,0 +1,466 @@ +package me.capcom.smsgateway.modules.receiver.parsers + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.charset.Charset +import java.util.Date + +/** + * Minimal parser for the MMS M-Retrieve.conf PDU (message type 0x84 / + * 132 decimal). Extracts the headers we care about and the multipart + * body parts so we can persist and forward them via webhook. + * + * Reference: OMA-TS-MMS_ENC-V1_3 (WAP-209 encapsulation). + * This is NOT a full implementation — unknown headers are skipped + * defensively. + */ +object MMSRetrieveParser { + + data class MRetrieveConf( + val messageId: String?, + val transactionId: String?, + val from: String?, + val to: List, + val subject: String?, + val date: Date?, + val parts: List, + ) + + data class Part( + val partId: Long, + val contentType: String, + val name: String?, + val contentLocation: String?, + val contentId: String?, + val charset: String?, + val data: ByteArray, + ) + + // Header field codes (with high bit set). + private const val F_BCC = 0x81 + private const val F_CC = 0x82 + private const val F_CONTENT_LOCATION = 0x83 + private const val F_CONTENT_TYPE = 0x84 + private const val F_DATE = 0x85 + private const val F_DELIVERY_REPORT = 0x86 + private const val F_DELIVERY_TIME = 0x87 + private const val F_EXPIRY = 0x88 + private const val F_FROM = 0x89 + private const val F_MESSAGE_CLASS = 0x8A + private const val F_MESSAGE_ID = 0x8B + private const val F_MESSAGE_TYPE = 0x8C + private const val F_MMS_VERSION = 0x8D + private const val F_MESSAGE_SIZE = 0x8E + private const val F_PRIORITY = 0x8F + private const val F_READ_REPORT = 0x90 + private const val F_REPORT_ALLOWED = 0x91 + private const val F_RESPONSE_STATUS = 0x92 + private const val F_RESPONSE_TEXT = 0x93 + private const val F_SENDER_VISIBILITY = 0x94 + private const val F_STATUS = 0x95 + private const val F_SUBJECT = 0x96 + private const val F_TO = 0x97 + private const val F_TRANSACTION_ID = 0x98 + private const val F_RETRIEVE_STATUS = 0x99 + private const val F_RETRIEVE_TEXT = 0x9A + private const val F_READ_STATUS = 0x9B + private const val F_REPLY_CHARGING = 0x9C + private const val F_REPLY_CHARGING_DEADLINE = 0x9D + private const val F_REPLY_CHARGING_ID = 0x9E + private const val F_REPLY_CHARGING_SIZE = 0x9F + private const val F_PREVIOUSLY_SENT_BY = 0xA0 + private const val F_PREVIOUSLY_SENT_DATE = 0xA1 + + private const val MESSAGE_TYPE_RETRIEVE_CONF = 0x84 + + private val WELL_KNOWN_CONTENT_TYPES = mapOf( + 0x02 to "text/html", + 0x03 to "text/plain", + 0x0C to "multipart/mixed", + 0x0F to "multipart/alternative", + 0x1D to "image/gif", + 0x1E to "image/jpeg", + 0x1F to "image/tiff", + 0x20 to "image/png", + 0x21 to "image/vnd.wap.wbmp", + 0x23 to "application/vnd.wap.multipart.mixed", + 0x27 to "application/xml", + 0x28 to "text/xml", + 0x33 to "application/vnd.wap.multipart.related", + 0x3B to "application/xhtml+xml", + 0x3D to "text/css", + 0x5B to "application/smil", + ) + + fun parse(pdu: ByteArray): MRetrieveConf { + val buf = ByteBuffer.wrap(pdu).order(ByteOrder.BIG_ENDIAN) + + val to = mutableListOf() + var from: String? = null + var messageId: String? = null + var transactionId: String? = null + var subject: String? = null + var date: Date? = null + var contentTypeParsed: ContentTypeInfo? = null + var messageType: Int? = null + + // Parse headers until Content-Type is consumed; the body follows. + while (buf.hasRemaining()) { + val code = buf.get().toInt() and 0xFF + when (code) { + F_MESSAGE_TYPE -> messageType = readShortInt(buf) + F_TRANSACTION_ID -> transactionId = readTextString(buf) + F_MMS_VERSION -> readShortInt(buf) + F_DATE -> date = Date(readLongInt(buf) * 1000L) + F_FROM -> from = readFrom(buf) + F_TO -> to.add(stripAddrType(readEncodedString(buf))) + F_CC -> to.add(stripAddrType(readEncodedString(buf))) + F_BCC -> to.add(stripAddrType(readEncodedString(buf))) + F_SUBJECT -> subject = readEncodedString(buf) + F_MESSAGE_ID -> messageId = readTextString(buf) + F_MESSAGE_CLASS -> { + // Either short-integer or token-text. Peek first byte. + val mark = buf.position() + val b = buf.get().toInt() and 0xFF + buf.position(mark) + if (b >= 0x80) buf.get() else readTextString(buf) + } + F_CONTENT_TYPE -> { + contentTypeParsed = readContentType(buf) + break // body follows + } + F_DELIVERY_REPORT, F_READ_REPORT, F_PRIORITY, F_STATUS, + F_SENDER_VISIBILITY, F_REPORT_ALLOWED, F_RESPONSE_STATUS, + F_RETRIEVE_STATUS, F_READ_STATUS, F_REPLY_CHARGING, + F_REPLY_CHARGING_DEADLINE -> readShortInt(buf) + F_DELIVERY_TIME, F_EXPIRY -> readExpiry(buf) + F_MESSAGE_SIZE, F_REPLY_CHARGING_SIZE -> readLongInt(buf) + F_CONTENT_LOCATION, F_RESPONSE_TEXT, F_RETRIEVE_TEXT, + F_REPLY_CHARGING_ID, F_PREVIOUSLY_SENT_BY -> readTextString(buf) + F_PREVIOUSLY_SENT_DATE -> readLongInt(buf) + else -> { + // Unknown header: best-effort skip as text-string. + readTextString(buf) + } + } + } + + // 0x84 = M-Retrieve.conf. Reject other PDU types (notifications, + // status, error, etc.) so a misdirected callback can't be persisted + // as a downloaded MMS body. + require(messageType == MESSAGE_TYPE_RETRIEVE_CONF) { + "Expected M-Retrieve.conf (0x84), got ${ + messageType?.let { "0x%02X".format(it) } ?: "missing" + }" + } + + val parts = if (contentTypeParsed != null && buf.hasRemaining()) { + readMultipart(buf) + } else { + emptyList() + } + + return MRetrieveConf( + messageId = messageId, + transactionId = transactionId, + from = from?.let { stripAddrType(it) }, + to = to, + subject = subject, + date = date, + parts = parts, + ) + } + + // --- WSP primitives --- + + private fun readShortInt(buf: ByteBuffer): Int = buf.get().toInt() and 0xFF + + private fun readLongInt(buf: ByteBuffer): Long { + val length = readShortInt(buf) + var v = 0L + repeat(length.coerceAtMost(buf.remaining())) { + v = (v shl 8) or (buf.get().toLong() and 0xFF) + } + return v + } + + private fun readUintvar(buf: ByteBuffer): Long { + var v = 0L + while (true) { + val b = buf.get().toInt() and 0xFF + v = (v shl 7) or (b and 0x7F).toLong() + if ((b and 0x80) == 0) break + } + return v + } + + private fun readUintvarInt(buf: ByteBuffer, field: String): Int { + val value = readUintvar(buf) + require(value in 0..Int.MAX_VALUE) { "$field is out of range: $value" } + return value.toInt() + } + + private fun readTextString(buf: ByteBuffer): String { + return readTextString(buf, Charsets.UTF_8) + } + + private fun readTextString(buf: ByteBuffer, charset: Charset): String { + if (!buf.hasRemaining()) return "" + val mark = buf.position() + val first = buf.get().toInt() and 0xFF + if (first != 0x7F) { + buf.position(mark) + } + val bytes = ArrayList() + while (buf.hasRemaining()) { + val b = buf.get() + if (b == 0x00.toByte()) break + bytes.add(b) + } + return String(bytes.toByteArray(), charset) + } + + private fun readValueLength(buf: ByteBuffer): Int { + val b = readShortInt(buf) + return when { + b <= 30 -> b + b == 31 -> readUintvar(buf).toInt() + else -> { + // It was a short-integer itself — backtrack is impossible, caller should not call this for shorts. + b + } + } + } + + private fun readEncodedString(buf: ByteBuffer): String { + if (!buf.hasRemaining()) return "" + val mark = buf.position() + val b = buf.get().toInt() and 0xFF + buf.position(mark) + if (b <= 30 || b == 31) { + // Value-length + charset + text-string + val len = readValueLength(buf) + val bodyStart = buf.position() + require(len <= buf.remaining()) { + "Encoded-string length ($len) exceeds remaining PDU bytes (${buf.remaining()})" + } + val bodyEnd = bodyStart + len + val originalLimit = buf.limit() + try { + buf.limit(bodyEnd) + val charset = Charset.forName(charsetName(readIntegerValue(buf))) + return readTextString(buf, charset) + } catch (_: Exception) { + buf.position(bodyStart) + val fallbackCharset = runCatching { + charsetName(readIntegerValue(buf)) + }.getOrDefault("UTF-8") + return readTextString( + buf, + runCatching { Charset.forName(fallbackCharset) }.getOrDefault(Charsets.UTF_8), + ) + } finally { + buf.limit(originalLimit) + buf.position(bodyEnd) + } + } + return readTextString(buf) + } + + private fun readIntegerValue(buf: ByteBuffer): Int { + val first = buf.get().toInt() and 0xFF + if (first >= 0x80) return first and 0x7F + + var value = 0 + repeat(first.coerceAtMost(buf.remaining())) { + value = (value shl 8) or (buf.get().toInt() and 0xFF) + } + return value + } + + private fun readFrom(buf: ByteBuffer): String { + val mark = buf.position() + val valueLen = readValueLength(buf) + val start = buf.position() + val addrType = readShortInt(buf) + val addr = if (addrType == 0x80) readEncodedString(buf) else "" + // Ensure we don't overrun. + val end = start + valueLen + if (buf.position() < end) buf.position(end) + return addr + } + + private fun readExpiry(buf: ByteBuffer) { + val len = readValueLength(buf) + repeat(len) { if (buf.hasRemaining()) buf.get() } + } + + private data class ContentTypeInfo( + val type: String, + val params: Map, + ) + + private fun readContentType(buf: ByteBuffer): ContentTypeInfo { + val mark = buf.position() + val first = buf.get().toInt() and 0xFF + buf.position(mark) + if (first >= 0x80) { + // Constrained-media: single short-integer + val code = buf.get().toInt() and 0x7F + val name = WELL_KNOWN_CONTENT_TYPES[code] ?: "application/octet-stream" + return ContentTypeInfo(name, emptyMap()) + } + // Value-length + (well-known-media|extension-media) + *(Parameter) + val len = readValueLength(buf) + val innerStart = buf.position() + require(len in 1..buf.remaining()) { + "Content-Type length ($len) exceeds remaining PDU bytes (${buf.remaining()})" + } + val innerEnd = innerStart + len + + val second = buf.get().toInt() and 0xFF + val typeName = if (second >= 0x80) { + WELL_KNOWN_CONTENT_TYPES[second and 0x7F] ?: "application/octet-stream" + } else { + // Backtrack and read as text-string. + buf.position(buf.position() - 1) + readTextString(buf) + } + + val params = mutableMapOf() + while (buf.position() < innerEnd) { + val pToken = buf.get().toInt() and 0xFF + if (pToken >= 0x80) { + val pCode = pToken and 0x7F + val name = paramName(pCode) + val value = readParameterValue(buf, pCode) + if (name != null) params[name] = value + } else { + // Untyped-parameter (token-text + untyped-value) + buf.position(buf.position() - 1) + val name = readTextString(buf) + val value = readEncodedString(buf) + params[name] = value + } + } + buf.position(innerEnd) + return ContentTypeInfo(typeName, params) + } + + /** + * Maps WSP / MMS MIBEnum charset codes to IANA names that + * `Charset.forName` understands. Falls back to UTF-8 for unknown + * values — same behavior as the prior `Charset.forName` failure path + * downstream, but without the exception detour. + */ + private fun charsetName(mibEnum: Int): String = when (mibEnum) { + 3 -> "US-ASCII" + 4 -> "ISO-8859-1" + 106 -> "UTF-8" + 1015 -> "UTF-16" + 1013 -> "UTF-16BE" + 1014 -> "UTF-16LE" + else -> "UTF-8" + } + + private fun paramName(code: Int): String? = when (code) { + 0x01 -> "charset" + 0x05 -> "name" + 0x06 -> "filename" + 0x09 -> "type" + 0x0A -> "start" + 0x0E -> "start-info" + else -> null + } + + private fun readParameterValue(buf: ByteBuffer, pCode: Int): String { + // Mostly text-string for what we care about; charset is short-integer. + if (pCode == 0x01) { + val b = buf.get().toInt() and 0xFF + val mibEnum = if (b >= 0x80) { + b and 0x7F + } else { + // long-integer: first byte is length, then big-endian value. + var value = 0 + repeat(b.coerceAtMost(buf.remaining())) { + value = (value shl 8) or (buf.get().toInt() and 0xFF) + } + value + } + return charsetName(mibEnum) + } + if (pCode == 0x09) { + val mark = buf.position() + val b = buf.get().toInt() and 0xFF + buf.position(mark) + return if (b >= 0x80) { + val code = buf.get().toInt() and 0x7F + WELL_KNOWN_CONTENT_TYPES[code] ?: "application/octet-stream" + } else readTextString(buf) + } + return readTextString(buf) + } + + private fun readMultipart(buf: ByteBuffer): List { + val entries = readUintvarInt(buf, "multipart entry count") + val out = mutableListOf() + repeat(entries) { + val headersLen = readUintvarInt(buf, "multipart headers length") + val dataLen = readUintvarInt(buf, "multipart data length") + require(headersLen <= buf.remaining()) { + "Multipart headers length ($headersLen) exceeds remaining PDU bytes (${buf.remaining()})" + } + val headersEnd = buf.position() + headersLen + // Temporarily clip the buffer to the declared header slice so + // readContentType / readTextString cannot consume payload bytes + // when an inner length is malformed. + val originalLimit = buf.limit() + val ct: ContentTypeInfo + var contentLocation: String? = null + var contentId: String? = null + try { + buf.limit(headersEnd) + ct = readContentType(buf) + while (buf.hasRemaining()) { + val headerCode = buf.get().toInt() and 0xFF + when (headerCode) { + 0x8E -> contentLocation = readTextString(buf) // Content-Location + 0xC0 -> contentId = readTextString(buf) // Content-ID + else -> { + // Unknown, try skipping as text-string. + buf.position(buf.position() - 1) + readTextString(buf) // name + readTextString(buf) // value + } + } + } + } finally { + buf.limit(originalLimit) + } + buf.position(headersEnd) + + require(dataLen <= buf.remaining()) { + "Multipart data length ($dataLen) exceeds remaining PDU bytes (${buf.remaining()})" + } + val bytes = ByteArray(dataLen) + if (dataLen > 0) buf.get(bytes) + + out.add( + Part( + partId = (out.size + 1).toLong(), + contentType = ct.type, + name = ct.params["name"] ?: ct.params["filename"], + contentLocation = contentLocation, + contentId = contentId, + charset = ct.params["charset"], + data = bytes, + ) + ) + } + return out + } + + private fun stripAddrType(s: String): String { + return s.substringBefore('/') + } +} diff --git a/app/src/main/java/me/capcom/smsgateway/receivers/EventsReceiver.kt b/app/src/main/java/me/capcom/smsgateway/receivers/EventsReceiver.kt index 36420eb91..042a6f1a3 100644 --- a/app/src/main/java/me/capcom/smsgateway/receivers/EventsReceiver.kt +++ b/app/src/main/java/me/capcom/smsgateway/receivers/EventsReceiver.kt @@ -21,10 +21,15 @@ class EventsReceiver : BroadcastReceiver(), KoinComponent { private val logsService: LogsService by inject() override fun onReceive(context: Context, intent: Intent) { + val action = intent.action + val rc = resultCode + + if (action !in setOf(ACTION_SENT, ACTION_DELIVERED, ACTION_MMS_SENT)) return + + val pendingResult = goAsync() scope.launch { try { - messagesService - .processStateIntent(intent, resultCode) + messagesService.processStateIntent(intent, rc) } catch (e: Throwable) { logsService.insert( LogEntry.Priority.ERROR, @@ -32,8 +37,9 @@ class EventsReceiver : BroadcastReceiver(), KoinComponent { "Can't process message state intent", intent.toLogContext() + e.toLogContext() ) + } finally { + pendingResult.finish() } - } } @@ -45,6 +51,11 @@ class EventsReceiver : BroadcastReceiver(), KoinComponent { const val ACTION_SENT = "me.capcom.smsgateway.ACTION_SENT" const val ACTION_DELIVERED = "me.capcom.smsgateway.ACTION_DELIVERED" + const val ACTION_MMS_SENT = "me.capcom.smsgateway.ACTION_MMS_SENT" + + const val EXTRA_MESSAGE_ID = "messageId" + const val EXTRA_PDU_PATH = "pduPath" + const val EXTRA_SUBSCRIPTION_ID = "subscriptionId" private fun getInstance(): EventsReceiver { return INSTANCE ?: EventsReceiver().also { INSTANCE = it } @@ -53,8 +64,10 @@ class EventsReceiver : BroadcastReceiver(), KoinComponent { fun register(context: Context) { context.registerReceiver( getInstance(), - IntentFilter(ACTION_SENT) - .apply { addAction(ACTION_DELIVERED) } + IntentFilter(ACTION_SENT).apply { + addAction(ACTION_DELIVERED) + addAction(ACTION_MMS_SENT) + } ) } } diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 968206c16..9d8c3a2b5 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -3,9 +3,13 @@ API URL、私有令牌、凭据等 API URL 应用版本(构建号) - 电池优化已禁用 + 电池优化已经禁用 此设备不支持电池优化 电池优化 + 默认短信应用 + 主动下载 MMS 和可靠发送 MMS 所必需 + 此应用已是默认短信应用 + 此设备不支持默认短信应用角色 取消 继续 通过验证码 diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 000000000..a9b040cf7 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/xml/root_preferences.xml b/app/src/main/res/xml/root_preferences.xml index a1aa966e1..c80a74069 100644 --- a/app/src/main/res/xml/root_preferences.xml +++ b/app/src/main/res/xml/root_preferences.xml @@ -83,4 +83,4 @@ app:selectable="false" /> - \ No newline at end of file + diff --git a/docs/MMS.md b/docs/MMS.md new file mode 100644 index 000000000..e12fa0a72 --- /dev/null +++ b/docs/MMS.md @@ -0,0 +1,377 @@ +# MMS support + +The gateway can **send** and **receive** multimedia messages (MMS) carrying +images, audio, video, and arbitrary binary attachments, in addition to SMS +and Data SMS. + +This document covers: +- Enabling MMS on the device +- The `POST /message` shape for sending MMS +- Webhook events for received MMS +- Fetching attachments from a received MMS +- Limits, gotchas, and carrier notes + +## Contents + +- [MMS support](#mms-support) + - [Contents](#contents) + - [Requirements](#requirements) + - [Setup](#setup) + - [Grant the default SMS app role](#grant-the-default-sms-app-role) + - [Sending MMS](#sending-mms) + - [API](#api) + - [Attachment sources](#attachment-sources) + - [Example: send an image](#example-send-an-image) + - [Receiving MMS](#receiving-mms) + - [Flow](#flow) + - [Webhook: `mms:received`](#webhook-mmsreceived) + - [Webhook: `mms:downloaded`](#webhook-mmsdownloaded) + - [Fetching attachment bytes](#fetching-attachment-bytes) + - [Inbox API](#inbox-api) + - [Cloud mode](#cloud-mode) + - [Limits and carrier behavior](#limits-and-carrier-behavior) + - [Troubleshooting](#troubleshooting) + +## Requirements + +- Android 5.0 (API 21) or newer. Android 10+ is recommended. +- A SIM with an MMS-capable plan and a working MMS APN. +- `SEND_SMS`, `READ_PHONE_STATE`, `RECEIVE_MMS`, `RECEIVE_WAP_PUSH` permissions + granted. These are the same permissions the app already needs for SMS. +- To actively download inbound MMS, the app must be the **default SMS app** + (see below). + +## Setup + +### Grant the default SMS app role + +Sending MMS from a non-default app is allowed by the platform but many US +carriers (Verizon notably) silently drop those messages. The gateway should +be the default SMS app on any device that is expected to send or receive +real MMS traffic. + +From inside the app: + +1. Open the **Settings** tab. +2. Tap **Default SMS app**. +3. Accept the Android prompt to change the default. + +The same preference shows whether the role is currently held. To hand the +role back to another app (Google Messages, Verizon Messages, etc.) use +Android's own Default apps screen. + +Under the hood the app now declares the manifest receivers, activity, and +service required to qualify for the `android.app.role.SMS` role, so the +system can grant it without further configuration. + +## Sending MMS + +### API + +The existing `POST /message` endpoint gains a new mutually-exclusive +content field, `mmsMessage`. Exactly one of `textMessage`, `dataMessage`, +`mmsMessage`, or the deprecated `message` string must be present. + +```json +{ + "phoneNumbers": ["+15551234567"], + "mmsMessage": { + "subject": "Optional subject line", + "text": "Optional text body shown alongside attachments", + "attachments": [ + { + "contentType": "image/jpeg", + "name": "photo.jpg", + "data": "" + } + ] + }, + "simNumber": 1, + "withDeliveryReport": true, + "priority": 100 +} +``` + +Field reference: + +| Field | Type | Required | Notes | +| --------------------------- | -------------- | ----------- | ------------------------------------------------------------------------ | +| `phoneNumbers` | `string[]` | yes | Recipient MSISDNs. E.164 format strongly recommended. | +| `mmsMessage.subject` | `string` | no | Optional MMS subject. | +| `mmsMessage.text` | `string` | no | Optional text/plain body part. Can coexist with attachments. | +| `mmsMessage.attachments[]` | `Attachment[]` | conditional | At least one of `text` or `attachments` must be present. | +| `attachments[].contentType` | `string` | yes | e.g. `image/jpeg`, `image/png`, `audio/amr`, `video/mp4`. | +| `attachments[].name` | `string` | no | Suggested filename, forwarded as Content-Location + Content-Type `name`. | +| `attachments[].data` | `string` | yes | Base64-encoded bytes. | + +Other top-level message fields (`simNumber`, `withDeliveryReport`, +`priority`, `ttl`/`validUntil`, `isEncrypted`, `id`) behave the same way +they do for SMS. + +Response is the same `Accepted` shape as SMS, with the request echoed back +including the stored `mmsMessage` content: + +```json +{ + "id": "PyDmBQZZXYmyxMwED8Fzy", + "deviceId": "...", + "state": "Pending", + "isEncrypted": false, + "mmsMessage": { "...": "..." }, + "recipients": [ + { "phoneNumber": "+15551234567", "state": "Pending", "error": null } + ], + "states": {} +} +``` + +### Attachment sources + +Each attachment provides **exactly one** of `data` or `url`: +- `data`: inline base64 bytes. Larger payloads bloat the request body — prefer + `url` for anything over a few hundred KB. +- `url`: the device performs an HTTP GET at send time and embeds the + response body as the part. Useful for sending media already hosted on + your infrastructure without base64 overhead. + +If both are provided the request is rejected with `400 Bad Request`. + +### Example: send an image + +```sh +B64=$(base64 -w0 < photo.jpg) +curl -u ":" -H 'Content-Type: application/json' \ + -d "{ + \"phoneNumbers\":[\"+15551234567\"], + \"mmsMessage\":{ + \"text\":\"Say hi\", + \"attachments\":[{ + \"contentType\":\"image/jpeg\", + \"name\":\"photo.jpg\", + \"data\":\"$B64\" + }] + } + }" \ + http://:8080/message +``` + +## Receiving MMS + +### Flow + +1. The carrier delivers a WAP-push notification over SMS. The app + parses it and emits the `mms:received` webhook (metadata only — the + message body is not yet downloaded). +2. If the app is the default SMS app, it calls + `SmsManager.downloadMultimediaMessage()` to fetch the full PDU from the + carrier's MMSC over the dedicated MMS APN. +3. Once the PDU is downloaded and parsed, the gateway: + - Saves each non-SMIL attachment to private storage under + `/files/mms-in//-`. + - Inserts an `incoming_messages` row (type `MMS_DOWNLOADED`). + - Emits the `mms:downloaded` webhook carrying part metadata, inline + base64 bytes, and a relative URL to fetch the same bytes from the + gateway. + +If the app is *not* the default SMS app, step 2 is handled by whichever +app is default; once that app writes the message into `content://mms`, +the gateway's content observer picks it up and step 3 runs as usual. + +### Webhook: `mms:received` + +Fired on the incoming WAP-push notification, before the content has been +downloaded. + +```json +{ + "event": "mms:received", + "payload": { + "messageId": "hex-derived-id", + "sender": "+15550009999", + "recipient": "+15551234567", + "simNumber": 1, + "transactionId": "T0123abcd", + "subject": "Photos", + "size": 43210, + "contentClass": "IMAGE_BASIC", + "receivedAt": "2026-04-20T17:31:00.000+00:00" + } +} +``` + +### Webhook: `mms:downloaded` + +Fired once the content has been retrieved and stored. Attachments are +included inline as base64 (`data`) **and** as a URL path on the gateway's +local HTTP server (`url`) — consumers may use either. + +```json +{ + "event": "mms:downloaded", + "payload": { + "messageId": "hex-derived-id", + "sender": "+15550009999", + "recipient": "+15551234567", + "simNumber": 1, + "subject": "Photos", + "body": "Hi! Here's the photo.", + "attachments": [ + { + "partId": 3, + "contentType": "image/jpeg", + "name": "photo.jpg", + "size": 38421, + "data": "", + "url": "/inbox/hex-derived-id/attachments/3" + } + ], + "receivedAt": "2026-04-20T17:31:14.000+00:00" + } +} +``` + +### Fetching attachment bytes + +To avoid base64-inflated webhook payloads you can fetch attachments on +demand. Authenticate with the same credentials used for the rest of the +API. + +```sh +curl -u ":" \ + http://:8080/inbox//attachments/ \ + -o photo.jpg +``` + +`Content-Type` reflects the attachment's MIME type; `Content-Disposition` +carries the original filename when one was present. + +Required auth scope: `inbox:read`. + +## Inbox API + +`GET /inbox` — list received messages with optional attachment metadata. + +Query parameters: + +| Parameter | Type | Default | Description | +|----------------------|-----------|---------|------------------------------------------------------------------| +| `type` | `string` | — | Filter by message type (`SMS`, `MMS`, `MMS_DOWNLOADED`, etc.). | +| `limit` | `integer` | `50` | Max results (1–500). | +| `offset` | `integer` | `0` | Result offset for pagination. | +| `from` | `string` | epoch | ISO-8601 start of date range. | +| `to` | `string` | now | ISO-8601 end of date range. | +| `deviceId` | `string` | — | Must match the device's own ID if provided. | +| `includeAttachments` | `boolean` | `false` | When `true`, includes per-message attachment metadata (partId, name, size, contentType). | + +By default attachment metadata is omitted to avoid unnecessary I/O on large +result sets. Attachments are always available individually via the +dedicated endpoint below. + +Default response (attachments excluded): + +```json +[ + { + "id": "hex-derived-id", + "type": "MMS_DOWNLOADED", + "sender": "+15550009999", + "recipient": "+15551234567", + "simNumber": 1, + "contentPreview": "Hi! Here's the photo.", + "createdAt": "2026-04-20T17:31:14.000+00:00", + "attachments": [] + } +] +``` + +Pass `?includeAttachments=true` to include attachment metadata: + +```json +[ + { + "id": "hex-derived-id", + "type": "MMS_DOWNLOADED", + "sender": "+15550009999", + "recipient": "+15551234567", + "simNumber": 1, + "contentPreview": "Hi! Here's the photo.", + "createdAt": "2026-04-20T17:31:14.000+00:00", + "attachments": [ + { + "partId": 3, + "name": "photo.jpg", + "size": 38421, + "contentType": "image/jpeg" + } + ] + } +] +``` + +- `GET /inbox/{messageId}/attachments/{partId}` — raw attachment bytes. + +All endpoints are available in both Local and Cloud modes. + +## Cloud mode + +The cloud pull protocol (`GET /message`) accepts the same `mmsMessage` +object inside a `Message` payload. When the device pulls a cloud-queued +MMS, it composes the PDU locally and sends it via the same code path used +for local-server requests. State transitions (Processed / Sent / Failed) +are reported back to the cloud identically to SMS. + +## Limits and carrier behavior + +- **PDU format.** The gateway composes PDUs with AOSP's reference + `PduComposer` (ported from klinker-apps's `pdu_alt`, Apache-2.0), so + the output matches Google Messages' format and is accepted by every + MMSC we've tested. +- **Body type.** `multipart/related` is used automatically; Content-Type + parameters `type` and `start` reference the first text part so recipient + clients render text + attachments correctly even without SMIL. +- **Size.** Carriers impose per-message PDU limits, typically 300 KB to + 1.2 MB. Verizon accepts up to ~1.2 MB in practice; T-Mobile and AT&T + are similar. Large attachments (video) should be downscaled before + sending. +- **From.** The gateway uses the WSP `insert-address-token` so the MMSC + fills in the sender's MSISDN — the approach AOSP and Google Messages + both use. If your carrier rejects insert-address-token, pass an + explicit From via `SubscriptionsHelper.getPhoneNumber()` and the sender + will fall back to that MSISDN. +- **Self-send.** Some carriers (Verizon included) silently drop MMS + addressed to the sender's own MSISDN. Use a second number for + round-trip testing. +- **Recipient parser.** Once a recipient's messaging app rejects a + malformed PDU, some MMSCs enter a backoff window (5–60 min) before + re-pushing. If you're iterating on PDU issues, test against multiple + recipient numbers/carriers to avoid getting stuck on one backoff. + +## Troubleshooting + +- **Send reports `Sent` but recipient never gets the message.** The MMSC + accepted our HTTP POST (that's what `Sent` means) but may have dropped + the message later. Confirm the app is the default SMS app, the + recipient isn't the sender's own number, and the recipient hasn't + blocked the sender. +- **`MMS_ERROR_IO_ERROR` on send.** The MMS APN was not available or the + MMSC returned a non-200. Check mobile data is up and that the active + APN has type `mms`. The carrier's own SMS app will surface APN + misconfiguration errors that our app cannot — use it as a sanity + check first. +- **`MMS_ERROR_INVALID_APN` / `MMS_ERROR_NO_DATA_NETWORK`.** No + MMS-typed APN configured. Add one through Android's Mobile network → + Access point names settings, or install the carrier's config profile. +- **Inbound MMS shows as `mms:received` but never transitions to + `mms:downloaded`.** The app likely isn't the default SMS app, or the + device dropped the MMS APN while downloading. Check the app's log + screen for `MMS download failed` entries. +- **Inspecting the outgoing PDU for debugging.** With USB debugging + enabled: + ```sh + adb shell run-as me.capcom.smsgateway ls files/mms-out + adb shell run-as me.capcom.smsgateway cat files/mms-out/.pdu > out.pdu + xxd out.pdu | head + ``` + PDU files are deleted automatically after the `mms:sent` state fires; + to retain them across sends, flip the cleanup flag in + `MessagesService.processStateIntent`.