Added initial support for sending and receiving SMS messages

This commit is contained in:
Juha Heinanen
2026-05-17 14:56:20 +03:00
parent a6981d121b
commit 3af0f39a8c
14 changed files with 435 additions and 37 deletions

View File

@ -36,6 +36,12 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.SEND_SMS"
tools:ignore="SmsAndCallLogPolicy" />
<uses-permission android:name="android.permission.RECEIVE_SMS"
tools:ignore="SmsAndCallLogPolicy" />
<uses-permission android:name="android.permission.READ_SMS"
tools:ignore="SmsAndCallLogPolicy" />
<uses-permission android:name="android.permission.RECEIVE_MMS"
tools:ignore="SmsAndCallLogPolicy" />
<uses-feature
android:name="android.hardware.telephony"
@ -78,10 +84,28 @@
<action android:name="android.intent.action.VIEW" />
<action android:name="android.intent.action.DIAL" />
<action android:name="android.intent.action.CALL" />
<action android:name="android.intent.action.SENDTO" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="tel" />
<data android:scheme="sip" />
<data android:scheme="smsto" />
</intent-filter>
</activity>
<activity
android:name=".ComposeActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:exported="true" >
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SENDTO" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="sms" />
<data android:scheme="smsto" />
<data android:scheme="mms" />
<data android:scheme="mmsto" />
</intent-filter>
</activity>
@ -121,6 +145,42 @@
</intent-filter>
</service>
<service
android:name=".HeadlessSmsSendService"
android:enabled="true"
android:exported="true"
android:permission="android.permission.SEND_RESPOND_VIA_MESSAGE" >
<intent-filter>
<action android:name="android.intent.action.RESPOND_VIA_MESSAGE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="sms" />
<data android:scheme="smsto" />
<data android:scheme="mms" />
<data android:scheme="mmsto" />
</intent-filter>
</service>
<receiver
android:name=".SmsReceiver"
android:enabled="true"
android:exported="true"
android:permission="android.permission.BROADCAST_SMS" >
<intent-filter>
<action android:name="android.provider.Telephony.SMS_DELIVER" />
</intent-filter>
</receiver>
<receiver
android:name=".MmsReceiver"
android:enabled="true"
android:exported="true"
android:permission="android.permission.BROADCAST_WAP_PUSH" >
<intent-filter>
<action android:name="android.provider.Telephony.WAP_PUSH_DELIVER" />
<data android:mimeType="application/vnd.wap.mms-message" />
</intent-filter>
</receiver>
<receiver
android:name=".BootCompletedReceiver"
android:enabled="true"

View File

@ -1279,10 +1279,29 @@ class BaresipService: Service() {
error
}
val timeStamp = System.currentTimeMillis()
val timeStampString = timeStamp.toString()
Log.d(TAG, "Message event for $uap from $peerUri at $timeStampString")
Message(ua.account.aor, peerUri, text, timeStamp, MESSAGE_DOWN, 0, "", true).add()
handleIncomingMessage(uap, peerUri, text, System.currentTimeMillis())
}
@SuppressLint("UnspecifiedImmutableFlag")
fun handleIncomingMessage(uap: Long, peerUri: String, text: String, timeStamp: Long) {
val ua = UserAgent.ofUap(uap)
if (ua == null) {
Log.w(TAG, "handleIncomingMessage did not find ua $uap")
return
}
val aor = ua.account.aor
// Check for duplicates
val lastMsg = messages.lastOrNull { m -> m.aor == aor }
if (lastMsg != null && lastMsg.timeStamp == timeStamp && lastMsg.peerUri == peerUri && lastMsg.message == text) {
Log.d(TAG, "Omit duplicate message from $peerUri")
return
}
Log.d(TAG, "Message event for $uap from $peerUri at $timeStamp")
Message(aor, peerUri, text, timeStamp, MESSAGE_DOWN, 0, "", true).add()
ua.account.unreadMessages = true
if (!Utils.isVisible()) {
@ -1339,7 +1358,7 @@ class BaresipService: Service() {
val saveIntent = Intent(this, BaresipService::class.java)
saveIntent.action = "Message Save"
saveIntent.putExtra("uap", uap).putExtra("time", timeStampString)
saveIntent.putExtra("uap", uap).putExtra("time", timeStamp.toString())
val savePendingIntent = PendingIntent.getService(this, SAVE_REQ_CODE, saveIntent, piFlags)
val saveAction = NotificationCompat.Action.Builder(
R.drawable.ic_notification_save,
@ -1349,7 +1368,7 @@ class BaresipService: Service() {
val deleteIntent = Intent(this, BaresipService::class.java)
deleteIntent.action = "Message Delete"
deleteIntent.putExtra("uap", uap).putExtra("time", timeStampString)
deleteIntent.putExtra("uap", uap).putExtra("time", timeStamp.toString())
val deletePendingIntent = PendingIntent.getService(this, DELETE_REQ_CODE, deleteIntent, piFlags)
val deleteAction = NotificationCompat.Action.Builder(
R.drawable.ic_notification_delete,

View File

@ -349,9 +349,16 @@ private fun Calls(
lastButtonText.value = ctx.getString(R.string.send_message)
lastAction.value = {
if (account.isMobile) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = ctx.getString(R.string.no_sms_messaging)
showAlert.value = true
if (!Utils.isDefaultSmsApp(ctx)) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = ctx.getString(R.string.enable_default_messaging)
showAlert.value = true
} else {
if (ua != null) {
handleIntent(ctx, viewModel, intent, "message")
navController.navigateUp()
}
}
}
else
if (ua != null) {

View File

@ -0,0 +1,37 @@
package com.tutpro.baresip
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.core.content.IntentSanitizer
/**
* Skeletal activity for SEND/SENDTO intents, required for Default SMS App eligibility.
* Redirects to MainActivity for processing.
*/
class ComposeActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Sanitize incoming intent to prevent intent redirection attacks
val sanitizedIntent = IntentSanitizer.Builder()
.allowAction(Intent.ACTION_SEND)
.allowAction(Intent.ACTION_SENDTO)
.allowType { it.startsWith("text/") }
.allowData { uri ->
uri.scheme in listOf("sms", "smsto", "mms", "mmsto")
}
.allowExtra(Intent.EXTRA_TEXT) { it is String || it is CharSequence }
.allowExtra("sms_body") { it is String || it is CharSequence }
.allowExtra("address") { it is String }
.allowExtra(Intent.EXTRA_STREAM) { true }
.allowExtra("exit_on_sent") { it is Boolean }
.build()
.sanitizeByFiltering(intent)
// Redirect to MainActivity which handles dialer/chat UI
sanitizedIntent.setClass(this, MainActivity::class.java)
sanitizedIntent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT)
startActivity(sanitizedIntent)
finish()
}
}

View File

@ -530,9 +530,14 @@ private fun ContactsContent(
lastAction.value = {
if (ua != null) {
if (ua.account.isMobile) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = ctx.getString(R.string.no_sms_messaging)
showAlert.value = true
if (!Utils.isDefaultSmsApp(ctx)) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = ctx.getString(R.string.enable_default_messaging)
showAlert.value = true
} else {
handleIntent(ctx, viewModel, intent, "message")
navController.navigateUp()
}
}
else {
handleIntent(ctx, viewModel, intent, "message")

View File

@ -0,0 +1,12 @@
package com.tutpro.baresip
import android.app.Service
import android.content.Intent
import android.os.IBinder
/**
* Skeletal headless SMS send service required for Default SMS App eligibility.
*/
class HeadlessSmsSendService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
}

View File

@ -6,6 +6,7 @@ import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
import android.app.Activity
import android.app.Activity.RESULT_OK
import android.app.KeyguardManager
import android.app.role.RoleManager
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
@ -808,17 +809,26 @@ private fun BottomBar(ctx: Context, viewModel: ViewModel, navController: NavCont
IconButton(
enabled = aor.isNotEmpty(),
onClick = {
if (isMobile) {
if (!Utils.isDefaultSmsApp(ctx)) {
alertTitle.value = ctx.getString(R.string.notice)
alertMessage.value = ctx.getString(R.string.enable_default_messaging)
showAlert.value = true
return@IconButton
}
}
navController.navigate("chats/$aor")
},
modifier = Modifier
.weight(1f)
.size(buttonSize)
modifier = Modifier.weight(1f).size(buttonSize)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Chat,
contentDescription = null,
Modifier.size(buttonSize),
tint = if (hasUnreadMessages) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.secondary
tint = if (hasUnreadMessages)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.secondary
)
}

View File

@ -0,0 +1,127 @@
package com.tutpro.baresip
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.provider.Telephony
import androidx.core.net.toUri
/**
* MMS receiver that extracts text components from incoming MMS messages.
*/
class MmsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Telephony.Sms.Intents.WAP_PUSH_DELIVER_ACTION) {
val contentType = intent.type
if (contentType == "application/vnd.wap.mms-message") {
Log.d(TAG, "Received MMS WAP Push Deliver")
// For a robust implementation, we'd need to parse the PDU.
// However, since we are the default SMS app, the OS will also
// save the MMS to the system provider. We can query it.
// Note: WAP_PUSH_DELIVER usually triggers before or during the save.
// We might need a small delay or use a ContentObserver if the query fails.
extractTextFromProvider(context)
}
}
}
private fun extractTextFromProvider(context: Context) {
// Query the most recent MMS message
val uri = "content://mms".toUri()
val cursor = context.contentResolver.query(uri, null, null, null, "date DESC LIMIT 1")
cursor?.use { c ->
if (c.moveToFirst()) {
val id = c.getString(c.getColumnIndexOrThrow("_id"))
val date = c.getLong(c.getColumnIndexOrThrow("date")) * 1000 // mms date is in seconds
// Get sender address
val address = getMmsAddr(context, id) ?: "unknown"
// Get text parts
val body = getMmsText(context, id)
if (body.isNotEmpty()) {
Log.d(TAG, "Extracted MMS text from $address: $body")
val mobileUa = BaresipService.uas.value.find { it.account.isMobile }
if (mobileUa != null) {
// Notify Service for history update, notification, and alert sound
if (BaresipService.isServiceRunning) {
BaresipService.instance?.handleIncomingMessage(mobileUa.uap, "tel:$address", body, date)
} else {
// Service not running, at least save to history
val aor = mobileUa.account.aor
// Check if this message was already added (simple deduplication by timestamp)
val lastMsg = Message.messages().lastOrNull { m -> m.aor == aor }
if (lastMsg == null || lastMsg.timeStamp != date || lastMsg.peerUri != "tel:$address") {
Message(aor, "tel:$address", body, date, MESSAGE_DOWN, 0, "", true).add()
mobileUa.account.unreadMessages = true
}
}
}
}
}
}
}
private fun getMmsAddr(context: Context, id: String): String? {
val uri = "content://mms/$id/addr".toUri()
val cursor = context.contentResolver.query(uri, null, "msg_id=$id", null, null)
var addr: String? = null
cursor?.use {
if (it.moveToFirst()) {
do {
val type = it.getInt(it.getColumnIndexOrThrow("type"))
if (type == 137) { // PDU_FROM
addr = it.getString(it.getColumnIndexOrThrow("address"))
break
}
} while (it.moveToNext())
}
}
return addr
}
private fun getMmsText(context: Context, id: String): String {
val selectionPart = "mid=$id"
val uri = "content://mms/part".toUri()
val cursor = context.contentResolver.query(uri, null, selectionPart, null, null)
val sb = StringBuilder()
cursor?.use {
while (it.moveToNext()) {
val type = it.getString(it.getColumnIndexOrThrow("ct"))
if (type == "text/plain") {
val data = it.getString(it.getColumnIndexOrThrow("_data"))
val body = if (data != null) {
getPartText(context, it.getString(it.getColumnIndexOrThrow("_id")))
} else {
it.getString(it.getColumnIndexOrThrow("text"))
}
if (body != null) sb.append(body)
}
}
}
return sb.toString()
}
private fun getPartText(context: Context, partId: String): String? {
val partUri = "content://mms/part/$partId".toUri()
return try {
context.contentResolver.openInputStream(partUri)?.use { isStream ->
isStream.bufferedReader().use { it.readText() }
}
} catch (e: Exception) {
Log.e(TAG, "Failed to read MMS part $partId", e)
null
}
}
companion object {
private const val TAG = "MmsReceiver"
}
}

View File

@ -1064,6 +1064,7 @@ private fun SettingsContent(
if (!roleManager.isRoleHeld(RoleManager.ROLE_DIALER))
dialerRoleRequest.launch(roleManager.createRequestRoleIntent(RoleManager.ROLE_DIALER))
} else {
viewModel.defaultMessaging.value = false
try {
dialerRoleRequest.launch(Intent("android.settings.MANAGE_DEFAULT_APPS_SETTINGS"))
} catch (e: ActivityNotFoundException) {
@ -1075,6 +1076,64 @@ private fun SettingsContent(
}
}
@RequiresApi(29)
@Composable
fun DefaultMessaging() {
val defaultMessagingAppTitle = stringResource(R.string.default_messaging_app)
val defaultMessagingAppHelp = stringResource(R.string.default_messaging_app_help)
val messagingRoleNotAvailableMessage = stringResource(R.string.messaging_role_not_available)
Row(
Modifier
.fillMaxWidth()
.padding(end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
val ctx = LocalContext.current
Text(text = defaultMessagingAppTitle,
modifier = Modifier
.weight(1f)
.clickable {
alertTitle.value = defaultMessagingAppTitle
alertMessage.value = defaultMessagingAppHelp
showAlert.value = true
},
fontSize = 18.sp
)
val defaultMessaging by viewModel.defaultMessaging.collectAsState()
val roleManager = ctx.getSystemService(ROLE_SERVICE) as RoleManager
val messagingRoleRequest = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { _ ->
val isHeld = roleManager.isRoleHeld(RoleManager.ROLE_SMS)
viewModel.defaultMessaging.value = isHeld
}
Switch(
checked = defaultMessaging,
onCheckedChange = {
viewModel.defaultMessaging.value = it
if (it) {
if (!roleManager.isRoleAvailable(RoleManager.ROLE_SMS)) {
alertTitle.value = alertTitleText
alertMessage.value = messagingRoleNotAvailableMessage
showAlert.value = true
}
else
if (!roleManager.isRoleHeld(RoleManager.ROLE_SMS))
messagingRoleRequest.launch(roleManager.createRequestRoleIntent(RoleManager.ROLE_SMS))
} else {
try {
messagingRoleRequest.launch(Intent("android.settings.MANAGE_DEFAULT_APPS_SETTINGS"))
} catch (e: ActivityNotFoundException) {
Log.e(TAG, "ActivityNotFound exception: ${e.message}")
}
}
}
)
}
}
@Composable
fun Debug() {
val debugTitle = stringResource(R.string.debug)
@ -1199,8 +1258,12 @@ private fun SettingsContent(
UserAgent()
UniqueContactUri()
AudioSettings(navController)
if (VERSION.SDK_INT >= 29)
if (VERSION.SDK_INT >= 29) {
DefaultDialer()
val defaultDialer by viewModel.defaultDialer.collectAsState()
if (defaultDialer)
DefaultMessaging()
}
BatteryOptimizations()
DarkTheme()
if (VERSION.SDK_INT >= 31)

View File

@ -30,6 +30,7 @@ class SettingsViewModel: ViewModel() {
val colorblind = MutableStateFlow(false)
val proximitySensing = MutableStateFlow(false)
val defaultDialer = MutableStateFlow(false)
val defaultMessaging = MutableStateFlow(false)
val debug = MutableStateFlow(false)
val sipTrace = MutableStateFlow(false)
@ -76,6 +77,7 @@ class SettingsViewModel: ViewModel() {
if (Build.VERSION.SDK_INT >= 29) {
val roleManager = ctx.getSystemService(ROLE_SERVICE) as RoleManager
defaultDialer.value = roleManager.isRoleHeld(RoleManager.ROLE_DIALER)
defaultMessaging.value = roleManager.isRoleHeld(RoleManager.ROLE_SMS)
}
debug.value = Config.variable("log_level") == "0"

View File

@ -0,0 +1,38 @@
package com.tutpro.baresip
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.provider.Telephony
class SmsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Telephony.Sms.Intents.SMS_DELIVER_ACTION) {
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent)
if (messages.isEmpty()) return
val sender = messages[0].displayOriginatingAddress ?: return
val body = messages.joinToString("") { it.displayMessageBody ?: "" }
val timestamp = messages[0].timestampMillis
Log.d(TAG, "Received SMS from $sender: $body")
val mobileUa = BaresipService.uas.value.find { it.account.isMobile }
if (mobileUa != null) {
// Notify Service for history update, notification, and alert sound
if (BaresipService.isServiceRunning) {
BaresipService.instance?.handleIncomingMessage(mobileUa.uap, "tel:$sender", body, timestamp)
} else {
// Service not running, at least save to history
val aor = mobileUa.account.aor
Message(aor, "tel:$sender", body, timestamp, MESSAGE_DOWN, 0, "", true).add()
mobileUa.account.unreadMessages = true
}
}
}
}
companion object {
private const val TAG = "SmsReceiver"
}
}

View File

@ -1404,6 +1404,32 @@ object Utils {
return null
}
fun sendSms(ctx: Context, destination: String, message: String): Boolean {
return try {
val smsManager = if (Build.VERSION.SDK_INT >= 31) {
ctx.getSystemService(android.telephony.SmsManager::class.java)
} else {
@Suppress("DEPRECATION")
android.telephony.SmsManager.getDefault()
}
smsManager.sendTextMessage(destination, null, message, null, null)
true
} catch (e: Exception) {
Log.e(TAG, "Failed to send SMS: ${e.message}")
false
}
}
fun isDefaultSmsApp(ctx: Context): Boolean {
return if (Build.VERSION.SDK_INT >= 29) {
val roleManager = ctx.getSystemService(ROLE_SERVICE) as RoleManager
roleManager.isRoleHeld(RoleManager.ROLE_SMS)
} else {
@Suppress("DEPRECATION")
android.provider.Telephony.Sms.getDefaultSmsPackage(ctx) == ctx.packageName
}
}
@Suppress("unused")
fun listFilesInDirectory(directoryPath: String): List<File> {
val directory = File(directoryPath)
@ -1439,21 +1465,4 @@ object Utils {
}
Log.e(TAG, "--------------------------------------")
}
fun sendSms(ctx: Context, destination: String, message: String): Boolean {
return try {
val smsManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ctx.getSystemService(android.telephony.SmsManager::class.java)
} else {
@Suppress("DEPRECATION")
android.telephony.SmsManager.getDefault()
}
smsManager.sendTextMessage(destination, null, message, null, null)
true
} catch (e: Exception) {
Log.e(TAG, "Failed to send SMS: ${e.message}")
false
}
}
}

View File

@ -294,7 +294,6 @@
%2$s puheluhistoriasta\?
</string>
<string name="calls_delete_question">Haluatko poistaa \'%1$s\' %2$s puheluhistoriasta\?</string>
<string name="no_sms_messaging">SMS-tekstiviestitystä ei ole vielä toteutettu.</string>
<string name="disable_history">Poista käytöstä</string>
<string name="enable_history">Ota käyttöön</string>
<string name="delete_history_alert">Haluatko tyhjentää tilin \'%1$s\' puheluhistorian\?</string>
@ -343,7 +342,14 @@
ja pääsyä verkkoon.</string>
<string name="default_phone_app">Oletus puhelusovellus</string>
<string name="dialer_role_not_available">Oletus puhelusovellusrooli ei ole saatavana</string>
<string name="default_phone_app_help">Jos merkitty, baresip on oletus puhelusovellus.</string>
<string name="default_phone_app_help">Jos merkitty, baresip on oletus puhelusovellus</string>
<string name="default_messaging_app">Oletus tekstiviestisovellus</string>
<string name="default_messaging_app_help">Jos merkitty, baresip on tekstiviestien
oletussovellus</string>
<string name="messaging_role_not_available">Oletus tekstiviestisovellusrooli ei ole
saatavana</string>
<string name="enable_default_messaging">Ole hyvä ja merkitse \'Oletus tekstiviestisovellus\'
baresip asetuksissa</string>
<string name="listen_address">Kuunteluosoite</string>
<string name="listen_address_help">IP-osoite ja portti muotoa
\'osoite:portti\', missä baresip kuuntelee sisään tulevia

View File

@ -280,7 +280,6 @@
<string name="calls_add_delete_question">Do you want to add \'%1$s\' to contacts or delete
%2$s from call history\?</string>
<string name="calls_delete_question">Do you want to delete \'%1$s\' %2$s from call history\?</string>
<string name="no_sms_messaging">Sorry, no SMS messaging yet.</string>
<string name="disable_history">Disable</string>
<string name="enable_history">Enable</string>
<string name="delete_history_alert">Do you want to delete call history of account \'%1$s\'\?</string>
@ -328,6 +327,10 @@
<string name="default_phone_app">Default Phone App</string>
<string name="dialer_role_not_available">Default phone app role is not available</string>
<string name="default_phone_app_help">If checked, baresip is the default phone app.</string>
<string name="default_messaging_app">Default Messaging App</string>
<string name="default_messaging_app_help">If checked, baresip is the default messaging app for SMS/MMS.</string>
<string name="messaging_role_not_available">Default messaging app role is not available</string>
<string name="enable_default_messaging">Please enable Default Messaging App in Settings</string>
<string name="listen_address">Listen Address</string>
<string name="listen_address_help">IP address and port of form \'address:port\' at which baresip listens
for incoming SIP requests. If IP address is an IPv6 address, it must be written inside