From ef99478defc89adef221f676881e84c0e0451493 Mon Sep 17 00:00:00 2001 From: tylxr59 <102394635+tylxr59@users.noreply.github.com> Date: Sun, 14 Dec 2025 11:13:11 -0500 Subject: [PATCH 1/2] Add SMS message skill --- app/src/main/AndroidManifest.xml | 3 + .../org/stypox/dicio/eval/SkillHandler.kt | 2 + .../dicio/skills/sms/AskMessageOutput.kt | 64 ++++++++++++++ .../dicio/skills/sms/ConfirmSmsOutput.kt | 57 ++++++++++++ .../stypox/dicio/skills/sms/SentSmsOutput.kt | 44 ++++++++++ .../skills/sms/SmsContactChooserIndex.kt | 48 ++++++++++ .../dicio/skills/sms/SmsContactChooserName.kt | 51 +++++++++++ .../org/stypox/dicio/skills/sms/SmsInfo.kt | 39 ++++++++ .../org/stypox/dicio/skills/sms/SmsOutput.kt | 88 +++++++++++++++++++ .../org/stypox/dicio/skills/sms/SmsSkill.kt | 79 +++++++++++++++++ .../org/stypox/dicio/util/PermissionUtils.kt | 4 + app/src/main/res/values/strings.xml | 9 ++ app/src/main/sentences/en/sms.yml | 7 ++ app/src/main/sentences/skill_definitions.yml | 10 +++ 14 files changed, 505 insertions(+) create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/sms/AskMessageOutput.kt create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/sms/ConfirmSmsOutput.kt create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/sms/SentSmsOutput.kt create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/sms/SmsContactChooserIndex.kt create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/sms/SmsContactChooserName.kt create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/sms/SmsInfo.kt create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/sms/SmsOutput.kt create mode 100644 app/src/main/kotlin/org/stypox/dicio/skills/sms/SmsSkill.kt create mode 100644 app/src/main/sentences/en/sms.yml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6355382b..300bcb13 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,6 +26,9 @@ + + + (SmsInfo, Specificity.HIGH) { + override fun score( + ctx: SkillContext, + input: String + ): Pair { + val trimmedInput = input.trim() + return Pair( + if (trimmedInput.isEmpty()) AlwaysWorstScore else AlwaysBestScore, + trimmedInput + ) + } + + override suspend fun generateOutput( + ctx: SkillContext, + inputData: String + ): SkillOutput { + return ConfirmSmsOutput(name, number, inputData) + } + } + + return InteractionPlan.StartSubInteraction( + reopenMicrophone = true, + nextSkills = listOf(messageSkill), + ) + } + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + Column { + Headline(text = getSpeechOutput(ctx)) + Spacer(modifier = Modifier.height(4.dp)) + Body(text = "$name ($number)") + } + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/sms/ConfirmSmsOutput.kt b/app/src/main/kotlin/org/stypox/dicio/skills/sms/ConfirmSmsOutput.kt new file mode 100644 index 00000000..634279d5 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/sms/ConfirmSmsOutput.kt @@ -0,0 +1,57 @@ +package org.stypox.dicio.skills.sms + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.InteractionPlan +import org.dicio.skill.skill.SkillOutput +import org.stypox.dicio.R +import org.stypox.dicio.io.graphical.Body +import org.stypox.dicio.io.graphical.Headline +import org.stypox.dicio.sentences.Sentences +import org.stypox.dicio.util.RecognizeYesNoSkill +import org.stypox.dicio.util.getString + +class ConfirmSmsOutput( + private val name: String, + private val number: String, + private val message: String +) : SkillOutput { + override fun getSpeechOutput(ctx: SkillContext): String = + ctx.getString(R.string.skill_sms_confirm, message, name) + + override fun getInteractionPlan(ctx: SkillContext): InteractionPlan { + val yesNoSentences = Sentences.UtilYesNo[ctx.sentencesLanguage]!! + val confirmYesNoSkill = object : RecognizeYesNoSkill(SmsInfo, yesNoSentences) { + override suspend fun generateOutput( + ctx: SkillContext, + inputData: Boolean + ): SkillOutput { + return if (inputData) { + SmsSkill.sendSms(number, message) + SentSmsOutput(name, number, message) + } else { + SentSmsOutput(null, null, null) + } + } + } + + return InteractionPlan.ReplaceSubInteraction( + reopenMicrophone = true, + nextSkills = listOf(confirmYesNoSkill), + ) + } + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + Column { + Headline(text = getSpeechOutput(ctx)) + Spacer(modifier = Modifier.height(4.dp)) + Body(text = "$name ($number)") + } + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/sms/SentSmsOutput.kt b/app/src/main/kotlin/org/stypox/dicio/skills/sms/SentSmsOutput.kt new file mode 100644 index 00000000..4f372d17 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/sms/SentSmsOutput.kt @@ -0,0 +1,44 @@ +package org.stypox.dicio.skills.sms + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.SkillOutput +import org.stypox.dicio.R +import org.stypox.dicio.io.graphical.Body +import org.stypox.dicio.io.graphical.Headline +import org.stypox.dicio.util.getString + +class SentSmsOutput( + private val name: String?, + private val number: String?, + private val message: String? +) : SkillOutput { + override fun getSpeechOutput(ctx: SkillContext): String = if (name == null) { + ctx.getString(R.string.skill_sms_not_sending) + } else { + "" // do not speak anything since the message was sent + } + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + if (name == null) { + Headline(text = stringResource(R.string.skill_sms_not_sending)) + } else { + Column { + Headline(text = stringResource(R.string.skill_sms_sent, name)) + Spacer(modifier = Modifier.height(4.dp)) + Body(text = number ?: "") + if (message != null) { + Spacer(modifier = Modifier.height(8.dp)) + Body(text = "\"$message\"") + } + } + } + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/sms/SmsContactChooserIndex.kt b/app/src/main/kotlin/org/stypox/dicio/skills/sms/SmsContactChooserIndex.kt new file mode 100644 index 00000000..8b087004 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/sms/SmsContactChooserIndex.kt @@ -0,0 +1,48 @@ +package org.stypox.dicio.skills.sms + +import org.dicio.numbers.unit.Number +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.AlwaysBestScore +import org.dicio.skill.skill.AlwaysWorstScore +import org.dicio.skill.skill.Score +import org.dicio.skill.skill.Skill +import org.dicio.skill.skill.SkillOutput +import org.dicio.skill.skill.Specificity + +class SmsContactChooserIndex internal constructor( + private val contacts: List>, + private val messageText: String? +) : Skill(SmsInfo, Specificity.HIGH) { + + override fun score( + ctx: SkillContext, + input: String + ): Pair { + val index = ctx.parserFormatter!! + .extractNumber(input) + .preferOrdinal(true) + .mixedWithText + .asSequence() + .filter { obj -> (obj as? Number)?.isInteger == true } + .map { obj -> (obj as Number).integerValue().toInt() } + .firstOrNull() ?: 0 + return Pair( + if (index <= 0 || index > contacts.size) AlwaysWorstScore else AlwaysBestScore, + index + ) + } + + override suspend fun generateOutput(ctx: SkillContext, inputData: Int): SkillOutput { + if (inputData > 0 && inputData <= contacts.size) { + val contact = contacts[inputData - 1] + return if (messageText == null) { + AskMessageOutput(contact.first, contact.second) + } else { + ConfirmSmsOutput(contact.first, contact.second, messageText) + } + } else { + // impossible situation + return SentSmsOutput(null, null, null) + } + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/sms/SmsContactChooserName.kt b/app/src/main/kotlin/org/stypox/dicio/skills/sms/SmsContactChooserName.kt new file mode 100644 index 00000000..ead6a99f --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/sms/SmsContactChooserName.kt @@ -0,0 +1,51 @@ +package org.stypox.dicio.skills.sms + +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.AlwaysBestScore +import org.dicio.skill.skill.AlwaysWorstScore +import org.dicio.skill.skill.Score +import org.dicio.skill.skill.Skill +import org.dicio.skill.skill.SkillOutput +import org.dicio.skill.skill.Specificity +import org.stypox.dicio.util.StringUtils + +class SmsContactChooserName internal constructor( + private val contacts: List>, + private val messageText: String? +) : Skill?>(SmsInfo, Specificity.LOW) { + + override fun score( + ctx: SkillContext, + input: String + ): Pair?> { + val trimmedInput = input.trim { it <= ' ' } + + val bestContact = contacts + .map { nameNumberPair -> + Pair( + nameNumberPair, + StringUtils.contactStringDistance(trimmedInput, nameNumberPair.first) + ) + } + .filter { pair -> pair.second < -7 } + .minByOrNull { a -> a.second } + ?.first + + return Pair( + if (bestContact == null) AlwaysWorstScore else AlwaysBestScore, + bestContact + ) + } + + override suspend fun generateOutput(ctx: SkillContext, inputData: Pair?): SkillOutput { + return inputData?.let { + if (messageText == null) { + AskMessageOutput(it.first, it.second) + } else { + ConfirmSmsOutput(it.first, it.second, messageText) + } + } + // impossible situation + ?: SentSmsOutput(null, null, null) + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/sms/SmsInfo.kt b/app/src/main/kotlin/org/stypox/dicio/skills/sms/SmsInfo.kt new file mode 100644 index 00000000..b61a2288 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/sms/SmsInfo.kt @@ -0,0 +1,39 @@ +package org.stypox.dicio.skills.sms + +import android.content.Context +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Message +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.Permission +import org.dicio.skill.skill.Skill +import org.dicio.skill.skill.SkillInfo +import org.stypox.dicio.R +import org.stypox.dicio.sentences.Sentences +import org.stypox.dicio.util.PERMISSION_READ_CONTACTS +import org.stypox.dicio.util.PERMISSION_SEND_SMS + +object SmsInfo : SkillInfo("sms") { + override fun name(context: Context) = + context.getString(R.string.skill_name_sms) + + override fun sentenceExample(context: Context) = + context.getString(R.string.skill_sentence_example_sms) + + @Composable + override fun icon() = + rememberVectorPainter(Icons.Default.Message) + + override val neededPermissions: List + = listOf(PERMISSION_READ_CONTACTS, PERMISSION_SEND_SMS) + + override fun isAvailable(ctx: SkillContext): Boolean { + return Sentences.Sms[ctx.sentencesLanguage] != null && + Sentences.UtilYesNo[ctx.sentencesLanguage] != null + } + + override fun build(ctx: SkillContext): Skill<*> { + return SmsSkill(SmsInfo, Sentences.Sms[ctx.sentencesLanguage]!!) + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/sms/SmsOutput.kt b/app/src/main/kotlin/org/stypox/dicio/skills/sms/SmsOutput.kt new file mode 100644 index 00000000..88f40ede --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/sms/SmsOutput.kt @@ -0,0 +1,88 @@ +package org.stypox.dicio.skills.sms + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.InteractionPlan +import org.dicio.skill.skill.Skill +import org.dicio.skill.skill.SkillOutput +import org.stypox.dicio.R +import org.stypox.dicio.io.graphical.Headline +import org.stypox.dicio.skills.telephone.ContactChooserIndex +import org.stypox.dicio.skills.telephone.ContactChooserName +import org.stypox.dicio.util.getString + +class SmsOutput( + private val contacts: List>>, + private val messageText: String?, +) : SkillOutput { + override fun getSpeechOutput(ctx: SkillContext): String = if (contacts.isEmpty()) { + ctx.getString(R.string.skill_sms_unknown_contact) + } else { + ctx.getString(R.string.skill_sms_found_contacts, contacts.size) + } + + override fun getInteractionPlan(ctx: SkillContext): InteractionPlan { + val nextSkills = mutableListOf>( + SmsContactChooserName( + // when saying the name, there is no way to distinguish between + // different numbers, so just use the first one + contacts.map { Pair(it.first, it.second[0]) }, + messageText + ) + ) + + if (ctx.parserFormatter != null) { + nextSkills.add( + SmsContactChooserIndex( + contacts.flatMap { contact -> + contact.second.map { number -> + Pair(contact.first, number) + } + }, + messageText + ) + ) + } + + return InteractionPlan.StartSubInteraction( + reopenMicrophone = true, + nextSkills = nextSkills, + ) + } + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + if (contacts.isEmpty()) { + Headline(text = getSpeechOutput(ctx)) + } else { + Column { + for (i in contacts.indices) { + if (i != 0) { + Spacer(modifier = Modifier.height(16.dp)) + } + + Column(modifier = Modifier.padding(vertical = 4.dp)) { + Text( + text = "${i + 1}. ${contacts[i].first}", + style = MaterialTheme.typography.titleMedium, + ) + for (number in contacts[i].second) { + Text( + text = number, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } + } + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/sms/SmsSkill.kt b/app/src/main/kotlin/org/stypox/dicio/skills/sms/SmsSkill.kt new file mode 100644 index 00000000..b690c840 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/sms/SmsSkill.kt @@ -0,0 +1,79 @@ +package org.stypox.dicio.skills.sms + +import android.telephony.SmsManager +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.SkillInfo +import org.dicio.skill.skill.SkillOutput +import org.dicio.skill.standard.StandardRecognizerData +import org.dicio.skill.standard.StandardRecognizerSkill +import org.stypox.dicio.sentences.Sentences.Sms +import org.stypox.dicio.skills.telephone.Contact + +class SmsSkill(correspondingSkillInfo: SkillInfo, data: StandardRecognizerData) : + StandardRecognizerSkill(correspondingSkillInfo, data) { + + override suspend fun generateOutput(ctx: SkillContext, inputData: Sms): SkillOutput { + val contentResolver = ctx.android.contentResolver + val (userContactName, messageText) = when (inputData) { + is Sms.Send -> Pair( + inputData.who?.trim { it <= ' ' } ?: "", + inputData.what?.trim() + ) + } + + val contacts = Contact.getFilteredSortedContacts(contentResolver, userContactName) + val validContacts = ArrayList>>() + + var i = 0 + while (validContacts.size < 5 && i < contacts.size) { + val contact = contacts[i] + val numbers = contact.getNumbers(contentResolver) + if (numbers.isEmpty()) { + ++i + continue + } + if (validContacts.isEmpty() + && contact.distance < 3 + && numbers.size == 1 // it has just one number + && (contacts.size <= i + 1 // the next contact has a distance higher by 3+ + || contacts[i + 1].distance - 2 > contact.distance) + ) { + // very close match with just one number and without distance ties + return if (messageText == null) { + // ask for the message + AskMessageOutput(contact.name, numbers[0]) + } else { + // we have everything, confirm sending + ConfirmSmsOutput(contact.name, numbers[0], messageText) + } + } + validContacts.add(Pair(contact.name, numbers)) + ++i + } + + if (validContacts.size == 1 // there is exactly one valid contact and ... + // ... either it has exactly one number, or we would be forced (because no number parser + // is available) to use ContactChooserName, which only uses the first phone number + // anyway + && (validContacts[0].second.size == 1 || ctx.parserFormatter == null) + ) { + // not a good enough match, but since we have only this, use it + val contact = validContacts[0] + return if (messageText == null) { + AskMessageOutput(contact.first, contact.second[0]) + } else { + ConfirmSmsOutput(contact.first, contact.second[0], messageText) + } + } + + // this point will not be reached if a very close match was found + return SmsOutput(validContacts, messageText) + } + + companion object { + fun sendSms(number: String, message: String) { + val smsManager = SmsManager.getDefault() + smsManager.sendTextMessage(number, null, message, null, null) + } + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/util/PermissionUtils.kt b/app/src/main/kotlin/org/stypox/dicio/util/PermissionUtils.kt index f06456cd..f887a980 100644 --- a/app/src/main/kotlin/org/stypox/dicio/util/PermissionUtils.kt +++ b/app/src/main/kotlin/org/stypox/dicio/util/PermissionUtils.kt @@ -27,6 +27,10 @@ val PERMISSION_CALL_PHONE = Permission.NormalPermission( name = R.string.perm_call_phone, id = Manifest.permission.CALL_PHONE, ) +val PERMISSION_SEND_SMS = Permission.NormalPermission( + name = R.string.perm_send_sms, + id = Manifest.permission.SEND_SMS, +) /** * @param context the Android context diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8230af59..311efb91 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -118,6 +118,7 @@ Do not play sound read your contacts directly call phone numbers + send SMS messages %1$s — %2$s The skill \"%1$s\" needs these permissions to work: %2$s Could not evaluate your request @@ -148,6 +149,8 @@ Navigating to %1$s Telephone Call Tom + SMS + Send a message to Tom Timer Set a timer for five minutes Current time @@ -177,6 +180,12 @@ Should I call %1$s? Calling %1$s… OK, I am not calling anyone + No contact found, try again + I found %1$d contacts, which one shall I message? + What do you want the message to say? + Should I send \"%1$s\" to %2$s? + Message sent to %1$s + OK, I am not sending any message Right now, it is %1$s How much time should the timer last? Are you sure you want to cancel all timers? diff --git a/app/src/main/sentences/en/sms.yml b/app/src/main/sentences/en/sms.yml new file mode 100644 index 00000000..702916b8 --- /dev/null +++ b/app/src/main/sentences/en/sms.yml @@ -0,0 +1,7 @@ +send: + - send a?n? (message|text|sms) to .who. saying .what. + - send a?n? (message|text|sms) to .who. that says .what. + - (message|text|sms) .who. saying .what. + - (message|text|sms) .who. that says .what. + - send a?n? (message|text|sms) to .who. + - (message|text|sms) .who. diff --git a/app/src/main/sentences/skill_definitions.yml b/app/src/main/sentences/skill_definitions.yml index 7aa23f72..11909ad7 100644 --- a/app/src/main/sentences/skill_definitions.yml +++ b/app/src/main/sentences/skill_definitions.yml @@ -123,3 +123,13 @@ skills: type: string - id: target type: string + + - id: sms + specificity: low + sentences: + - id: send + captures: + - id: who + type: string + - id: what + type: string \ No newline at end of file From 47db3b6b0dcf026e23b6631ebfc28684a1c6cb91 Mon Sep 17 00:00:00 2001 From: tylxr <102394635+tylxr59@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:25:06 -0500 Subject: [PATCH 2/2] Update README and Fastlane --- README.md | 1 + fastlane/metadata/android/en-US/full_description.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index cb3b8fe0..0e247095 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Currently Dicio answers questions about: - **open**: opens an app on your device - _Open NewPipe_ - **calculator**: evaluates basic calculations - _What is four thousand and two times three minus a million divided by three hundred?_ - **telephone**: view and call contacts - _Call Tom_ +- **sms**: send SMS messages to contacts - _Send a text to Jenna saying I got us reservations for dinner_ - **timer**: set, query and cancel timers - _Set a timer for five minutes_ - **current time**: query current time - _What time is it?_ - **navigation**: opens the navigation app at the requested position - _Take me to New York, fifteenth avenue_ diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index f572451f..179e3fb4 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -8,6 +8,7 @@ Dicio answers questions about:
  • open: opens an app on your device - Open NewPipe
  • calculator: evaluates basic calculations - What is four thousand and two times three minus a million divided by three hundred?
  • telephone: view and call contacts - Call Tom
  • +
  • sms: send SMS messages to contacts - Send a text to Jenna saying I got us reservations for dinner
  • timer: set, query and cancel timers - Set a timer for eleven minutes
  • current time: query current time - What time is it?
  • navigation: opens the navigation app at the requested position - Take me to New York, fifteenth avenue