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:
avi 2026-08-07 13:31:29 -05:00
commit 63791b9f5e
10 changed files with 560 additions and 134 deletions

View file

@ -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 (`5300`, default **30**), then
the previous volume is restored.
---

View file

@ -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"

View file

@ -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 {

View 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)
}
}

View file

@ -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.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

View 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()
}
}

View file

@ -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"
}
}

View file

@ -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" />

View 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>

View file

@ -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 &lt;Name&gt;" 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>