feat: configurable ring settings screen
Adds a Settings screen (SettingsActivity) where the user configures: - the SMS trigger phrase (replaces hardcoded RING_NOW/PLAY_SONG) - the sound: system ringtone (with picker) or a picked audio file - the play duration (5-300s, replaces the fixed 30s) Values persist via RingConfig (SharedPreferences). SmsReceiver matches the configured phrase; RingService plays the stored Uri for the configured length. Removes the old MediaStore by-name song search and its hardcoded duration.
This commit is contained in:
parent
6d05e22259
commit
63791b9f5e
10 changed files with 560 additions and 134 deletions
26
README.md
26
README.md
|
|
@ -9,18 +9,26 @@ An Android app that listens for SMS commands to trigger a loud ring — overridi
|
|||
|
||||
---
|
||||
|
||||
## SMS Trigger Commands
|
||||
## SMS Trigger
|
||||
|
||||
| Command | Behaviour |
|
||||
|--------------------|----------------------------------------------------------------------------------|
|
||||
| `RING_NOW` | Plays the default system ringtone at max volume. |
|
||||
| `PLAY_SONG <name>` | Searches local `MediaStore` for a matching song. Plays it, or falls back to the default ringtone if not found. |
|
||||
The app listens for a **configurable trigger phrase**. When an incoming SMS body
|
||||
matches it exactly (case-insensitive, trimmed), the app rings.
|
||||
|
||||
Playback always runs for exactly **30 seconds**, then the previous volume is
|
||||
restored.
|
||||
> Both the phrase, the sound it plays, and the duration are configurable from the
|
||||
> in-app **Ring Settings** screen (launched from the **"Ring Settings"** button).
|
||||
> Send the configured phrase to your phone from any other phone to trigger it.
|
||||
|
||||
> Send the SMS to your phone from any other phone. The command is matched
|
||||
> case-insensitively.
|
||||
### Trigger Phrase (default: `RING_NOW`)
|
||||
Configured in **Settings**. Any SMS whose body equals the phrase triggers the ring.
|
||||
|
||||
### Sound
|
||||
Choose between:
|
||||
- **System ringtone** (or pick a specific one), or
|
||||
- **Custom song** — pick any audio file on the device.
|
||||
|
||||
### Duration
|
||||
Playback runs for the configured number of seconds (`5–300`, default **30**), then
|
||||
the previous volume is restored.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,13 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Settings: trigger phrase, sound selection, and duration. -->
|
||||
<activity
|
||||
android:name=".SettingsActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/settings_header"
|
||||
android:parentActivityName=".MainActivity" />
|
||||
|
||||
<!-- BroadcastReceiver: intercepts incoming SMS to detect RING_NOW / PLAY_SONG. -->
|
||||
<receiver
|
||||
android:name=".SmsReceiver"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ class MainActivity : AppCompatActivity() {
|
|||
private lateinit var btnSmsSettings: Button
|
||||
private lateinit var btnGrantNotifications: Button
|
||||
private lateinit var btnDndSettings: Button
|
||||
private lateinit var btnSettings: Button
|
||||
private lateinit var btnTestRing: Button
|
||||
|
||||
private val requestPermissionLauncher =
|
||||
|
|
@ -46,6 +47,7 @@ class MainActivity : AppCompatActivity() {
|
|||
btnSmsSettings = findViewById(R.id.btnSmsSettings)
|
||||
btnGrantNotifications = findViewById(R.id.btnGrantNotifications)
|
||||
btnDndSettings = findViewById(R.id.btnDndSettings)
|
||||
btnSettings = findViewById(R.id.btnSettings)
|
||||
btnTestRing = findViewById(R.id.btnTestRing)
|
||||
|
||||
btnSmsSettings.setOnClickListener { requestSmsAndMediaPermissions() }
|
||||
|
|
@ -53,12 +55,15 @@ class MainActivity : AppCompatActivity() {
|
|||
btnDndSettings.setOnClickListener { openDndSettings() }
|
||||
btnTestRing.setOnClickListener {
|
||||
if (isDndGranted()) {
|
||||
startRingService(null)
|
||||
startRingService()
|
||||
} else {
|
||||
Toast.makeText(this, R.string.toast_dnd_required, Toast.LENGTH_LONG).show()
|
||||
openDndSettings()
|
||||
}
|
||||
}
|
||||
btnSettings.setOnClickListener {
|
||||
startActivity(Intent(this, SettingsActivity::class.java))
|
||||
}
|
||||
|
||||
// Refresh permission state whenever we return to the foreground (e.g. the
|
||||
// user just left the DND access settings screen).
|
||||
|
|
@ -130,8 +135,8 @@ class MainActivity : AppCompatActivity() {
|
|||
)
|
||||
}
|
||||
|
||||
private fun startRingService(songName: String?) {
|
||||
val intent = RingService.buildIntent(this, isDndGranted(), songName)
|
||||
private fun startRingService() {
|
||||
val intent = RingService.buildIntent(this, isDndGranted())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(intent)
|
||||
} else {
|
||||
|
|
|
|||
102
app/src/main/java/com/example/lostmyphone/RingConfig.kt
Normal file
102
app/src/main/java/com/example/lostmyphone/RingConfig.kt
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package com.example.lostmyphone
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
|
||||
/**
|
||||
* Persisted configuration for how the app reacts to a trigger SMS:
|
||||
* - the exact text to listen for (case-insensitive),
|
||||
* - which sound to play (system ringtone vs. a user-picked audio Uri),
|
||||
* - how long playback should last.
|
||||
*
|
||||
* Stored in SharedPreferences. Reads are cheap and safe to call from the
|
||||
* SmsReceiver on the main thread.
|
||||
*/
|
||||
object RingConfig {
|
||||
|
||||
private const val PREFS = "ring_config"
|
||||
|
||||
private const val KEY_TRIGGER = "trigger_text"
|
||||
private const val KEY_SOUND_MODE = "sound_mode" // "ringtone" | "song"
|
||||
private const val KEY_RINGTONE_URI = "ringtone_uri"
|
||||
private const val KEY_SONG_URI = "song_uri"
|
||||
private const val KEY_DURATION_SEC = "duration_sec"
|
||||
|
||||
const val SOUND_RINGTONE = "ringtone"
|
||||
const val SOUND_SONG = "song"
|
||||
|
||||
val DEFAULT_TRIGGER: String = "RING_NOW"
|
||||
const val DEFAULT_DURATION_SEC = 30
|
||||
const val MIN_DURATION_SEC = 5
|
||||
const val MAX_DURATION_SEC = 300
|
||||
|
||||
private fun prefs(context: Context): SharedPreferences {
|
||||
val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
return sp
|
||||
}
|
||||
|
||||
fun triggerText(context: Context): String =
|
||||
prefs(context).getString(KEY_TRIGGER, DEFAULT_TRIGGER) ?: DEFAULT_TRIGGER
|
||||
|
||||
fun setTriggerText(context: Context, value: String): Boolean =
|
||||
prefs(context).edit().putString(KEY_TRIGGER, value.trim()).commit()
|
||||
|
||||
fun soundMode(context: Context): String =
|
||||
prefs(context).getString(KEY_SOUND_MODE, SOUND_RINGTONE) ?: SOUND_RINGTONE
|
||||
|
||||
fun setSoundMode(context: Context, mode: String): Boolean =
|
||||
prefs(context).edit().putString(KEY_SOUND_MODE, mode).commit()
|
||||
|
||||
fun songUri(context: Context): Uri? {
|
||||
val raw = prefs(context).getString(KEY_SONG_URI, null) ?: return null
|
||||
return try {
|
||||
Uri.parse(raw)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun setSongUri(context: Context, uri: Uri?): Boolean =
|
||||
prefs(context).edit().putString(KEY_SONG_URI, uri?.toString()).commit()
|
||||
|
||||
fun ringtoneUri(context: Context): Uri? {
|
||||
val raw = prefs(context).getString(KEY_RINGTONE_URI, null) ?: return null
|
||||
return try {
|
||||
Uri.parse(raw)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun setRingtoneUri(context: Context, uri: Uri?): Boolean =
|
||||
prefs(context).edit().putString(KEY_RINGTONE_URI, uri?.toString()).commit()
|
||||
|
||||
fun durationSeconds(context: Context): Int =
|
||||
prefs(context).getInt(KEY_DURATION_SEC, DEFAULT_DURATION_SEC).coerceIn(
|
||||
MIN_DURATION_SEC,
|
||||
MAX_DURATION_SEC
|
||||
)
|
||||
|
||||
fun setDurationSeconds(context: Context, seconds: Int): Boolean =
|
||||
prefs(context).edit().putInt(KEY_DURATION_SEC, seconds.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC)).commit()
|
||||
|
||||
/**
|
||||
* The Uri the RingService should actually play.
|
||||
* - ringtone mode -> the system default ringtone,
|
||||
* - song mode -> the user-picked song, else fall back to the ringtone.
|
||||
*/
|
||||
fun playableUri(context: Context): Uri? {
|
||||
return when (soundMode(context)) {
|
||||
SOUND_SONG -> songUri(context) ?: Settings.System.DEFAULT_RINGTONE_URI
|
||||
else -> ringtoneUri(context) ?: Settings.System.DEFAULT_RINGTONE_URI
|
||||
}
|
||||
}
|
||||
|
||||
fun isConfiguredTrigger(context: Context, body: String): Boolean {
|
||||
if (body.isBlank()) return false
|
||||
// Put the persisted text on the left so it is the caller-provided value.
|
||||
return body.trim().equals(triggerText(context), ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import android.app.NotificationManager
|
|||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import androidx.core.app.NotificationCompat
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.AudioAttributes
|
||||
|
|
@ -15,7 +14,6 @@ import android.media.MediaPlayer
|
|||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.provider.MediaStore
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -45,22 +43,14 @@ class RingService : Service() {
|
|||
private const val TAG = "RingService"
|
||||
|
||||
const val ACTION_RING = "com.example.lostmyphone.action.RING"
|
||||
const val ACTION_PLAY_SONG = "com.example.lostmyphone.action.PLAY_SONG"
|
||||
const val EXTRA_SONG_NAME = "extra_song_name"
|
||||
const val EXTRA_DND_GRANTED = "extra_dnd_granted"
|
||||
|
||||
private const val CHANNEL_ID = "ring_channel"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val RING_DURATION_MS = 30_000L
|
||||
|
||||
fun buildIntent(context: Context, dndGranted: Boolean, songName: String?): Intent {
|
||||
fun buildIntent(context: Context, dndGranted: Boolean): Intent {
|
||||
val intent = Intent(context, RingService::class.java)
|
||||
if (songName.isNullOrBlank()) {
|
||||
intent.action = ACTION_RING
|
||||
} else {
|
||||
intent.action = ACTION_PLAY_SONG
|
||||
intent.putExtra(EXTRA_SONG_NAME, songName)
|
||||
}
|
||||
intent.action = ACTION_RING
|
||||
intent.putExtra(EXTRA_DND_GRANTED, dndGranted)
|
||||
return intent
|
||||
}
|
||||
|
|
@ -110,11 +100,10 @@ class RingService : Service() {
|
|||
}
|
||||
|
||||
// Explicitly granted: proceed with the ring.
|
||||
val songName = intent.getStringExtra(EXTRA_SONG_NAME)
|
||||
Log.i(TAG, "DND access granted. Ringing with song='$songName'")
|
||||
startForeground(NOTIFICATION_ID, buildRingingNotification(songName).build())
|
||||
Log.i(TAG, "DND access granted. Ringing.")
|
||||
startForeground(NOTIFICATION_ID, buildRingingNotification().build())
|
||||
|
||||
handleRing(songName)
|
||||
handleRing()
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
|
|
@ -122,14 +111,10 @@ class RingService : Service() {
|
|||
// Ring logic
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun handleRing(songName: String?) {
|
||||
val uri: Uri? = if (songName.isNullOrBlank()) {
|
||||
// RING_NOW -> default system ringtone
|
||||
Settings.System.DEFAULT_RINGTONE_URI
|
||||
} else {
|
||||
// PLAY_SONG -> try to find a matching local track, else fallback.
|
||||
querySongUri(songName) ?: Settings.System.DEFAULT_RINGTONE_URI
|
||||
}
|
||||
private fun handleRing() {
|
||||
// Sound + duration come from the user's settings.
|
||||
val uri: Uri? = RingConfig.playableUri(this)
|
||||
val durationMs = RingConfig.durationSeconds(this) * 1_000L
|
||||
|
||||
if (uri == null) {
|
||||
Log.e(TAG, "No playable URI. Aborting.")
|
||||
|
|
@ -142,8 +127,8 @@ class RingService : Service() {
|
|||
restoreAndStop()
|
||||
return@launch
|
||||
}
|
||||
// Ring for exactly 30 seconds.
|
||||
delay(RING_DURATION_MS)
|
||||
// Ring for the configured duration.
|
||||
delay(durationMs)
|
||||
restoreAndStop()
|
||||
}
|
||||
}
|
||||
|
|
@ -256,67 +241,6 @@ class RingService : Service() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries MediaStore for a song whose title (or display name) contains the
|
||||
* requested name, case-insensitively.
|
||||
*/
|
||||
private fun querySongUri(songName: String): Uri? {
|
||||
val needle = songName.trim()
|
||||
if (needle.isEmpty()) return null
|
||||
|
||||
val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
} else {
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||
}
|
||||
|
||||
val projection = arrayOf(
|
||||
MediaStore.Audio.Media._ID,
|
||||
MediaStore.Audio.Media.TITLE,
|
||||
MediaStore.Audio.Media.DISPLAY_NAME
|
||||
)
|
||||
|
||||
val selection = "${MediaStore.Audio.Media.IS_MUSIC} != 0"
|
||||
val selectionArgs: Array<String>? = null
|
||||
val sortOrder: String? = null
|
||||
|
||||
return try {
|
||||
val cursor = contentResolver.query(
|
||||
collection,
|
||||
projection,
|
||||
selection,
|
||||
selectionArgs,
|
||||
sortOrder
|
||||
) ?: return null
|
||||
|
||||
var best: Uri? = null
|
||||
cursor.use {
|
||||
if (it.moveToFirst()) {
|
||||
do {
|
||||
val title =
|
||||
it.getString(it.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE))
|
||||
?: ""
|
||||
val display =
|
||||
it.getString(it.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME))
|
||||
?: ""
|
||||
if (title.contains(needle, ignoreCase = true) ||
|
||||
display.contains(needle, ignoreCase = true)
|
||||
) {
|
||||
val id = it.getLong(it.getColumnIndexOrThrow(MediaStore.Audio.Media._ID))
|
||||
best = ContentUris.withAppendedId(collection, id)
|
||||
break
|
||||
}
|
||||
} while (it.moveToNext())
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "Query '$needle' -> ${best?.lastPathSegment ?: "not found"}")
|
||||
best
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "MediaStore query failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Cleanup
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -384,12 +308,12 @@ class RingService : Service() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun buildRingingNotification(songName: String?): NotificationCompat.Builder {
|
||||
val content = if (songName.isNullOrBlank()) {
|
||||
getString(R.string.notif_ringing_ringtone)
|
||||
} else {
|
||||
getString(R.string.notif_ringing_song, songName)
|
||||
}
|
||||
private fun buildRingingNotification(): NotificationCompat.Builder {
|
||||
val content = getString(
|
||||
R.string.notif_ringing,
|
||||
RingConfig.durationSeconds(this),
|
||||
RingConfig.soundMode(this)
|
||||
)
|
||||
val openIntent = PendingIntent.getActivity(
|
||||
this, 0, Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
|
|
|
|||
199
app/src/main/java/com/example/lostmyphone/SettingsActivity.kt
Normal file
199
app/src/main/java/com/example/lostmyphone/SettingsActivity.kt
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
package com.example.lostmyphone
|
||||
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.OpenableColumns
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.RadioButton
|
||||
import android.widget.RadioGroup
|
||||
import android.widget.SeekBar
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import android.media.RingtoneManager
|
||||
|
||||
/**
|
||||
* Settings screen for the ring behaviour:
|
||||
* - the SMS trigger phrase,
|
||||
* - the sound to play (system ringtone or a picked audio file),
|
||||
* - how long playback lasts.
|
||||
*
|
||||
* Values are written to RingConfig only when the user taps Save.
|
||||
*/
|
||||
class SettingsActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var etTrigger: EditText
|
||||
private lateinit var rgSound: RadioGroup
|
||||
private lateinit var rdbRingtone: RadioButton
|
||||
private lateinit var rdbSong: RadioButton
|
||||
private lateinit var btnPickRingtone: Button
|
||||
private lateinit var btnPickSong: Button
|
||||
private lateinit var tvSelectedSound: TextView
|
||||
private lateinit var sbDuration: SeekBar
|
||||
private lateinit var tvDurationValue: TextView
|
||||
private lateinit var btnSave: Button
|
||||
|
||||
private var pickedRingtoneUri: Uri? = null
|
||||
private var pickedSongUri: Uri? = null
|
||||
|
||||
private val pickRingtoneLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
val data = result.data
|
||||
pickedRingtoneUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
|
||||
}
|
||||
if (pickedRingtoneUri == null) {
|
||||
// User may have chosen "silent"/default; treat null as default ringtone.
|
||||
Toast.makeText(this, R.string.toast_ringtone_default, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
updateSoundSummary()
|
||||
}
|
||||
}
|
||||
|
||||
private val pickSongLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri != null) {
|
||||
try {
|
||||
contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
pickedSongUri = uri
|
||||
updateSoundSummary()
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(this, R.string.toast_song_failed, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_settings)
|
||||
|
||||
etTrigger = findViewById(R.id.etTrigger)
|
||||
rgSound = findViewById(R.id.rgSound)
|
||||
rdbRingtone = findViewById(R.id.rdbRingtone)
|
||||
rdbSong = findViewById(R.id.rdbSong)
|
||||
btnPickRingtone = findViewById(R.id.btnPickRingtone)
|
||||
btnPickSong = findViewById(R.id.btnPickSong)
|
||||
tvSelectedSound = findViewById(R.id.tvSelectedSound)
|
||||
sbDuration = findViewById(R.id.sbDuration)
|
||||
tvDurationValue = findViewById(R.id.tvDurationValue)
|
||||
btnSave = findViewById(R.id.btnSave)
|
||||
|
||||
loadCurrentValues()
|
||||
setupDurationSeekBar()
|
||||
setupPickers()
|
||||
|
||||
btnSave.setOnClickListener { saveSettings() }
|
||||
}
|
||||
|
||||
private fun loadCurrentValues() {
|
||||
etTrigger.setText(RingConfig.triggerText(this))
|
||||
|
||||
val mode = RingConfig.soundMode(this)
|
||||
if (mode == RingConfig.SOUND_SONG) rdbSong.isChecked = true else rdbRingtone.isChecked = true
|
||||
|
||||
pickedRingtoneUri = RingConfig.ringtoneUri(this)
|
||||
pickedSongUri = RingConfig.songUri(this)
|
||||
|
||||
sbDuration.progress = RingConfig.durationSeconds(this) - RingConfig.MIN_DURATION_SEC
|
||||
updateDurationLabel()
|
||||
updateSoundSummary()
|
||||
}
|
||||
|
||||
private fun setupDurationSeekBar() {
|
||||
val span = RingConfig.MAX_DURATION_SEC - RingConfig.MIN_DURATION_SEC
|
||||
sbDuration.max = span
|
||||
sbDuration.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
|
||||
updateDurationLabel()
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar) {}
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar) {}
|
||||
})
|
||||
}
|
||||
|
||||
private fun updateDurationLabel() {
|
||||
val seconds = sbDuration.progress + RingConfig.MIN_DURATION_SEC
|
||||
tvDurationValue.text = getString(R.string.duration_value, seconds)
|
||||
}
|
||||
|
||||
private fun setupPickers() {
|
||||
btnPickRingtone.setOnClickListener {
|
||||
val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {
|
||||
putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, getString(R.string.ringtone_picker_title))
|
||||
putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, false)
|
||||
putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
|
||||
putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_RINGTONE)
|
||||
putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, pickedRingtoneUri)
|
||||
}
|
||||
pickRingtoneLauncher.launch(intent)
|
||||
}
|
||||
|
||||
btnPickSong.setOnClickListener {
|
||||
pickSongLauncher.launch(arrayOf("audio/*"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSoundSummary() {
|
||||
val mode = if (rdbSong.isChecked) RingConfig.SOUND_SONG else RingConfig.SOUND_RINGTONE
|
||||
tvSelectedSound.text = when (mode) {
|
||||
RingConfig.SOUND_SONG -> {
|
||||
val uri = pickedSongUri
|
||||
if (uri == null) getString(R.string.sound_summary_song_none)
|
||||
else getString(R.string.sound_summary_song, displayNameFor(uri))
|
||||
}
|
||||
else -> {
|
||||
val uri = pickedRingtoneUri
|
||||
if (uri == null) getString(R.string.sound_summary_ringtone_default)
|
||||
else getString(R.string.sound_summary_ringtone, displayNameFor(uri))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayNameFor(uri: Uri): String {
|
||||
return try {
|
||||
val cursor: Cursor? = contentResolver.query(uri, null, null, null, null)
|
||||
cursor?.use {
|
||||
if (it.moveToFirst()) {
|
||||
val idx = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (idx >= 0) it.getString(idx)?.let { name -> return name }
|
||||
}
|
||||
}
|
||||
uri.lastPathSegment ?: uri.toString()
|
||||
} catch (e: Exception) {
|
||||
uri.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveSettings() {
|
||||
val trigger = etTrigger.text.toString().trim()
|
||||
if (trigger.isEmpty()) {
|
||||
etTrigger.error = getString(R.string.error_trigger_empty)
|
||||
return
|
||||
}
|
||||
|
||||
val mode = if (rdbSong.isChecked) RingConfig.SOUND_SONG else RingConfig.SOUND_RINGTONE
|
||||
val seconds = sbDuration.progress + RingConfig.MIN_DURATION_SEC
|
||||
|
||||
RingConfig.setTriggerText(this, trigger)
|
||||
RingConfig.setSoundMode(this, mode)
|
||||
RingConfig.setRingtoneUri(this, pickedRingtoneUri)
|
||||
RingConfig.setSongUri(this, pickedSongUri)
|
||||
RingConfig.setDurationSeconds(this, seconds)
|
||||
|
||||
Toast.makeText(this, R.string.toast_saved, Toast.LENGTH_SHORT).show()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
|
@ -10,43 +10,33 @@ import androidx.core.content.ContextCompat
|
|||
|
||||
/**
|
||||
* BroadcastReceiver that intercepts incoming SMS messages and looks for the
|
||||
* trigger commands:
|
||||
* user-configured trigger phrase (RingConfig.triggerText, e.g. "RING_NOW").
|
||||
*
|
||||
* - RING_NOW -> play the default system ringtone
|
||||
* - PLAY_SONG <Name> -> play a matching local MediaStore song, else fallback
|
||||
* When a matching message is received, RingService is started as a foreground
|
||||
* service, which plays the configured sound (Ringtone or a picked song) for the
|
||||
* configured duration.
|
||||
*
|
||||
* On Android 6.0+ this receiver only fires if the user has granted the
|
||||
* RECEIVE_SMS runtime permission (which MainActivity requests on first launch).
|
||||
* RECEIVE_SMS runtime permission (granted via MainActivity/Settings).
|
||||
*/
|
||||
class SmsReceiver : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SmsReceiver"
|
||||
|
||||
val TRIGGER_RING_NOW: Regex = Regex("^\\s*RING_NOW\\s*$", RegexOption.IGNORE_CASE)
|
||||
val TRIGGER_PLAY_SONG: Regex =
|
||||
Regex("^\\s*PLAY_SONG\\s+(.+?)\\s*$", RegexOption.IGNORE_CASE)
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) {
|
||||
return
|
||||
}
|
||||
|
||||
// We only act on a trigger and ignore others, but aborting a broadcast
|
||||
// that we did not handle would break other apps, so we do NOT call abort.
|
||||
// We only act on a trigger and ignore others; we do NOT call abort() so
|
||||
// other applications are not affected by broadcasts we do not handle.
|
||||
val (body, sender) = parseMessages(intent) ?: return
|
||||
Log.i(TAG, "SMS from '$sender': '$body'")
|
||||
|
||||
when {
|
||||
TRIGGER_RING_NOW.matches(body) -> handleTrigger(context, null)
|
||||
else -> {
|
||||
TRIGGER_PLAY_SONG.find(body)?.let { match ->
|
||||
val songName = match.groupValues[1].trim()
|
||||
handleTrigger(context, songName)
|
||||
}
|
||||
}
|
||||
if (!RingConfig.isConfiguredTrigger(context, body)) {
|
||||
Log.i(TAG, "Not a trigger. Configured text='${RingConfig.triggerText(context)}'")
|
||||
return
|
||||
}
|
||||
|
||||
handleTrigger(context, sender)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -66,19 +56,23 @@ class SmsReceiver : BroadcastReceiver() {
|
|||
return Pair(body.toString().trim(), sender ?: "unknown")
|
||||
}
|
||||
|
||||
private fun handleTrigger(context: Context, songName: String?) {
|
||||
Log.i(TAG, "Trigger matched. song='$songName'")
|
||||
private fun handleTrigger(context: Context, sender: String) {
|
||||
Log.i(TAG, "Trigger matched from '$sender'.")
|
||||
|
||||
// Determine the current DND access state so the service can short-circuit
|
||||
// if needed. The service re-verifies this at runtime as the source of truth.
|
||||
val dndGranted = (context.getSystemService(Context.NOTIFICATION_SERVICE)
|
||||
as android.app.NotificationManager).isNotificationPolicyAccessGranted
|
||||
|
||||
val serviceIntent = RingService.buildIntent(context, dndGranted, songName)
|
||||
val serviceIntent = RingService.buildIntent(context, dndGranted)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ContextCompat.startForegroundService(context, serviceIntent)
|
||||
} else {
|
||||
context.startService(serviceIntent)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SmsReceiver"
|
||||
}
|
||||
}
|
||||
|
|
@ -77,10 +77,17 @@
|
|||
android:text="@string/btn_notifications" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnTestRing"
|
||||
android:id="@+id/btnSettings"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="@string/btn_settings" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnTestRing"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:backgroundTint="#C62828"
|
||||
android:text="@string/btn_test_ring" />
|
||||
|
||||
|
|
|
|||
149
app/src/main/res/layout/activity_settings.xml
Normal file
149
app/src/main/res/layout/activity_settings.xml
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="@string/settings_header"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="@string/settings_subtitle"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<!-- Trigger phrase -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="6dp"
|
||||
android:text="@string/label_trigger"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/etTrigger"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:hint="@string/hint_trigger"
|
||||
android:maxLines="1" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/label_trigger_note"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_marginBottom="20dp"
|
||||
android:background="#44000000" />
|
||||
|
||||
<!-- Sound -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="6dp"
|
||||
android:text="@string/label_sound"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/rgSound"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/rdbRingtone"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/rdb_ringtone" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/rdbSong"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/rdb_song" />
|
||||
</RadioGroup>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnPickRingtone"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/btn_pick_ringtone" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnPickSong"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/btn_pick_song" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSelectedSound"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_marginBottom="20dp"
|
||||
android:background="#44000000" />
|
||||
|
||||
<!-- Duration -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="6dp"
|
||||
android:text="@string/label_duration"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvDurationValue"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<SeekBar
|
||||
android:id="@+id/sbDuration"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/label_duration_range"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnSave"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="@string/btn_save_settings" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
|
@ -6,8 +6,7 @@
|
|||
<string name="channel_description">Media playback channel for ring-alert playback</string>
|
||||
|
||||
<string name="notif_ringing_title">Lost My Phone — Ringing</string>
|
||||
<string name="notif_ringing_ringtone">Playing default ringtone for 30 seconds</string>
|
||||
<string name="notif_ringing_song">Playing "%1$s" for 30 seconds</string>
|
||||
<string name="notif_ringing">Ringing for %1$d seconds (sound: %2$s)</string>
|
||||
|
||||
<string name="notif_permission_denied_title">Action Required: DND Access</string>
|
||||
<string name="dnd_permission_required">Permission Required: Please open settings and enable "Do Not Disturb Access" for "Lost My Phone" to ring your device.</string>
|
||||
|
|
@ -30,5 +29,37 @@
|
|||
<string name="status_help">Status: %1$s</string>
|
||||
<string name="dnd_explanation">Enabling Do Not Disturb Access lets the app cut through DND to play the ring. It is only ever used after a valid SMS trigger or a manual test.</string>
|
||||
|
||||
<string name="commands_help">Commands: send "RING_NOW" to this device for the default ringtone, or "PLAY_SONG <Name>" to play a matching local track.</string>
|
||||
<string name="commands_help">Send the trigger phrase below to this device as an SMS to ring it. You can change the phrase, the sound, and the duration in Settings.</string>
|
||||
|
||||
<string name="btn_settings">Ring Settings</string>
|
||||
|
||||
<string name="settings_header">Ring Settings</string>
|
||||
<string name="settings_subtitle">Control the SMS trigger, the sound it plays, and how long it rings.</string>
|
||||
|
||||
<string name="label_trigger">Trigger phrase (SMS content)</string>
|
||||
<string name="hint_trigger">e.g. RING_NOW</string>
|
||||
<string name="label_trigger_note">An incoming SMS containing exactly this text (case-insensitive, trimmed) triggers the ring.</string>
|
||||
|
||||
<string name="label_sound">Sound to play</string>
|
||||
<string name="rdb_ringtone">System ringtone</string>
|
||||
<string name="rdb_song">Custom song</string>
|
||||
<string name="btn_pick_ringtone">Choose ringtone…</string>
|
||||
<string name="btn_pick_song">Choose audio file…</string>
|
||||
<string name="ringtone_picker_title">Pick ringtone</string>
|
||||
|
||||
<string name="sound_summary_ringtone_default">Sound: default system ringtone</string>
|
||||
<string name="sound_summary_ringtone">Sound: ringtone "%1$s"</string>
|
||||
<string name="sound_summary_song_none">Sound: custom song (none chosen — will use default ringtone)</string>
|
||||
<string name="sound_summary_song">Sound: song "%1$s"</string>
|
||||
|
||||
<string name="label_duration">Play duration</string>
|
||||
<string name="duration_value">%1$d seconds</string>
|
||||
<string name="label_duration_range">Between 5 and 300 seconds.</string>
|
||||
|
||||
<string name="btn_save_settings">Save Settings</string>
|
||||
|
||||
<string name="toast_ringtone_default">Using default system ringtone.</string>
|
||||
<string name="toast_song_failed">Could not use that audio file.</string>
|
||||
<string name="toast_saved">Settings saved.</string>
|
||||
<string name="error_trigger_empty">Trigger phrase cannot be empty.</string>
|
||||
</resources>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue