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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).

Expand Down
15 changes: 14 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
android:required="true" />

<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.MANAGE_SUBSCRIPTION_USER_ASSOCIATION" />

<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
Expand Down Expand Up @@ -92,6 +93,18 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<!-- FileProvider used to hand MMS PDUs to SmsManager and to serve
attachment bytes back to webhook consumers. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>

</manifest>
156 changes: 156 additions & 0 deletions app/src/main/assets/api/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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"
}
Comment thread
capcom6 marked this conversation as resolved.
},
"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"
Comment on lines +66 to +90

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce data XOR url for smsgateway.MmsAttachment.

The schema text says “exactly one,” but current validation allows both or neither. That creates client/server contract drift and ambiguous runtime behavior.

Suggested fix
"smsgateway.MmsAttachment": {
  "properties": {
    ...
  },
+ "oneOf": [
+   { "required": ["data"] },
+   { "required": ["url"] }
+ ],
+ "not": {
+   "required": ["data", "url"]
+ },
  "required": ["contentType"],
  "type": "object"
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"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"
"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"
}
},
"oneOf": [
{ "required": ["data"] },
{ "required": ["url"] }
],
"not": {
"required": ["data", "url"]
},
"required": ["contentType"],
"type": "object"
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/assets/api/swagger.json` around lines 66 - 90, The
smsgateway.MmsAttachment schema currently describes data and url as mutually
exclusive, but the schema definition does not enforce this constraint—both
properties are optional with no validation ensuring exactly one is provided. Add
OpenAPI schema constraint logic (such as oneOf composition) to the MmsAttachment
object that enforces exactly one of the data or url fields must be present, not
both and not neither. This will align the schema validation with the documented
contract and eliminate runtime ambiguity.

},
"MmsDownloadedPayload": {
"allOf": [
{
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/me/capcom/smsgateway/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -44,6 +45,7 @@ class App: Application() {
dbModule,
logsModule,
notificationsModule,
mmsModule,
messagesModule,
incomingModule,
receiverModule,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import java.util.Date
enum class MessageType {
Text,
Data,
Mms,
}

@Entity(
Expand Down Expand Up @@ -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,
)
Expand All @@ -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
Expand Down
18 changes: 17 additions & 1 deletion app/src/main/java/me/capcom/smsgateway/domain/MessageContent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,20 @@ sealed class MessageContent {
return "$data:$port"
}
}
}

data class Mms(
val subject: String?,
val text: String?,
val attachments: List<Attachment>,
) : 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})"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Attachment>?,
) : MessageContent() {
val attachments: List<Attachment>
get() = _attachments.orEmpty()

class Attachment(
val contentType: String,
val name: String?,
val data: String,
)
}
}

data class Message(
Expand All @@ -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<String>,
val simNumber: Int?,
val withDeliveryReport: Boolean?,
Expand All @@ -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")
}
Expand Down
Loading
Loading