diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3862d0c2..d643c1b4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,6 +36,12 @@ + + + + + + + + + + + + + + + + + + @@ -121,6 +145,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + 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, diff --git a/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt index 20aa0bcd..869b885d 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt @@ -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) { diff --git a/app/src/main/kotlin/com/tutpro/baresip/ComposeActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/ComposeActivity.kt new file mode 100644 index 00000000..08829f51 --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/ComposeActivity.kt @@ -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() + } +} diff --git a/app/src/main/kotlin/com/tutpro/baresip/ContactsScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/ContactsScreen.kt index 18f5ee3e..03e8a089 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/ContactsScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/ContactsScreen.kt @@ -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") diff --git a/app/src/main/kotlin/com/tutpro/baresip/HeadlessSmsSendService.kt b/app/src/main/kotlin/com/tutpro/baresip/HeadlessSmsSendService.kt new file mode 100644 index 00000000..ece96189 --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/HeadlessSmsSendService.kt @@ -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 +} diff --git a/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt index 19c0f008..92bcefb7 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/MainScreen.kt @@ -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 ) } diff --git a/app/src/main/kotlin/com/tutpro/baresip/MmsReceiver.kt b/app/src/main/kotlin/com/tutpro/baresip/MmsReceiver.kt new file mode 100644 index 00000000..93ca700f --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/MmsReceiver.kt @@ -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" + } +} diff --git a/app/src/main/kotlin/com/tutpro/baresip/SettingsScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/SettingsScreen.kt index faad55a8..0fc0e69a 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/SettingsScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/SettingsScreen.kt @@ -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) diff --git a/app/src/main/kotlin/com/tutpro/baresip/SettingsViewModel.kt b/app/src/main/kotlin/com/tutpro/baresip/SettingsViewModel.kt index 2df5af91..57a8b5cd 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/SettingsViewModel.kt @@ -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" diff --git a/app/src/main/kotlin/com/tutpro/baresip/SmsReceiver.kt b/app/src/main/kotlin/com/tutpro/baresip/SmsReceiver.kt new file mode 100644 index 00000000..2a98b0f2 --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/SmsReceiver.kt @@ -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" + } +} diff --git a/app/src/main/kotlin/com/tutpro/baresip/Utils.kt b/app/src/main/kotlin/com/tutpro/baresip/Utils.kt index 25d06cbc..3939fa02 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Utils.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Utils.kt @@ -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 { 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 - } - } - } diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index ea477a90..b8cf93e8 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -294,7 +294,6 @@ %2$s puheluhistoriasta\? Haluatko poistaa \'%1$s\' %2$s puheluhistoriasta\? - SMS-tekstiviestitystä ei ole vielä toteutettu. Poista käytöstä Ota käyttöön Haluatko tyhjentää tilin \'%1$s\' puheluhistorian\? @@ -343,7 +342,14 @@ ja pääsyä verkkoon. Oletus puhelusovellus Oletus puhelusovellusrooli ei ole saatavana - Jos merkitty, baresip on oletus puhelusovellus. + Jos merkitty, baresip on oletus puhelusovellus + Oletus tekstiviestisovellus + Jos merkitty, baresip on tekstiviestien + oletussovellus + Oletus tekstiviestisovellusrooli ei ole + saatavana + Ole hyvä ja merkitse \'Oletus tekstiviestisovellus\' + baresip asetuksissa Kuunteluosoite IP-osoite ja portti muotoa \'osoite:portti\', missä baresip kuuntelee sisään tulevia diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8eeb1dad..553cc64d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -280,7 +280,6 @@ Do you want to add \'%1$s\' to contacts or delete %2$s from call history\? Do you want to delete \'%1$s\' %2$s from call history\? - Sorry, no SMS messaging yet. Disable Enable Do you want to delete call history of account \'%1$s\'\? @@ -328,6 +327,10 @@ Default Phone App Default phone app role is not available If checked, baresip is the default phone app. + Default Messaging App + If checked, baresip is the default messaging app for SMS/MMS. + Default messaging app role is not available + Please enable Default Messaging App in Settings Listen Address 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