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 |
|
The app listens for a **configurable trigger phrase**. When an incoming SMS body
|
||||||
|--------------------|----------------------------------------------------------------------------------|
|
matches it exactly (case-insensitive, trimmed), the app rings.
|
||||||
| `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. |
|
|
||||||
|
|
||||||
Playback always runs for exactly **30 seconds**, then the previous volume is
|
> Both the phrase, the sound it plays, and the duration are configurable from the
|
||||||
restored.
|
> 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
|
### Trigger Phrase (default: `RING_NOW`)
|
||||||
> case-insensitively.
|
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>
|
</intent-filter>
|
||||||
</activity>
|
</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. -->
|
<!-- BroadcastReceiver: intercepts incoming SMS to detect RING_NOW / PLAY_SONG. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".SmsReceiver"
|
android:name=".SmsReceiver"
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
private lateinit var btnSmsSettings: Button
|
private lateinit var btnSmsSettings: Button
|
||||||
private lateinit var btnGrantNotifications: Button
|
private lateinit var btnGrantNotifications: Button
|
||||||
private lateinit var btnDndSettings: Button
|
private lateinit var btnDndSettings: Button
|
||||||
|
private lateinit var btnSettings: Button
|
||||||
private lateinit var btnTestRing: Button
|
private lateinit var btnTestRing: Button
|
||||||
|
|
||||||
private val requestPermissionLauncher =
|
private val requestPermissionLauncher =
|
||||||
|
|
@ -46,6 +47,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
btnSmsSettings = findViewById(R.id.btnSmsSettings)
|
btnSmsSettings = findViewById(R.id.btnSmsSettings)
|
||||||
btnGrantNotifications = findViewById(R.id.btnGrantNotifications)
|
btnGrantNotifications = findViewById(R.id.btnGrantNotifications)
|
||||||
btnDndSettings = findViewById(R.id.btnDndSettings)
|
btnDndSettings = findViewById(R.id.btnDndSettings)
|
||||||
|
btnSettings = findViewById(R.id.btnSettings)
|
||||||
btnTestRing = findViewById(R.id.btnTestRing)
|
btnTestRing = findViewById(R.id.btnTestRing)
|
||||||
|
|
||||||
btnSmsSettings.setOnClickListener { requestSmsAndMediaPermissions() }
|
btnSmsSettings.setOnClickListener { requestSmsAndMediaPermissions() }
|
||||||
|
|
@ -53,12 +55,15 @@ class MainActivity : AppCompatActivity() {
|
||||||
btnDndSettings.setOnClickListener { openDndSettings() }
|
btnDndSettings.setOnClickListener { openDndSettings() }
|
||||||
btnTestRing.setOnClickListener {
|
btnTestRing.setOnClickListener {
|
||||||
if (isDndGranted()) {
|
if (isDndGranted()) {
|
||||||
startRingService(null)
|
startRingService()
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(this, R.string.toast_dnd_required, Toast.LENGTH_LONG).show()
|
Toast.makeText(this, R.string.toast_dnd_required, Toast.LENGTH_LONG).show()
|
||||||
openDndSettings()
|
openDndSettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
btnSettings.setOnClickListener {
|
||||||
|
startActivity(Intent(this, SettingsActivity::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh permission state whenever we return to the foreground (e.g. the
|
// Refresh permission state whenever we return to the foreground (e.g. the
|
||||||
// user just left the DND access settings screen).
|
// user just left the DND access settings screen).
|
||||||
|
|
@ -130,8 +135,8 @@ class MainActivity : AppCompatActivity() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startRingService(songName: String?) {
|
private fun startRingService() {
|
||||||
val intent = RingService.buildIntent(this, isDndGranted(), songName)
|
val intent = RingService.buildIntent(this, isDndGranted())
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
startForegroundService(intent)
|
startForegroundService(intent)
|
||||||
} else {
|
} 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.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import android.content.ContentUris
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
|
|
@ -15,7 +14,6 @@ import android.media.MediaPlayer
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.provider.MediaStore
|
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
@ -45,22 +43,14 @@ class RingService : Service() {
|
||||||
private const val TAG = "RingService"
|
private const val TAG = "RingService"
|
||||||
|
|
||||||
const val ACTION_RING = "com.example.lostmyphone.action.RING"
|
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"
|
const val EXTRA_DND_GRANTED = "extra_dnd_granted"
|
||||||
|
|
||||||
private const val CHANNEL_ID = "ring_channel"
|
private const val CHANNEL_ID = "ring_channel"
|
||||||
private const val NOTIFICATION_ID = 1001
|
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)
|
val intent = Intent(context, RingService::class.java)
|
||||||
if (songName.isNullOrBlank()) {
|
|
||||||
intent.action = ACTION_RING
|
intent.action = ACTION_RING
|
||||||
} else {
|
|
||||||
intent.action = ACTION_PLAY_SONG
|
|
||||||
intent.putExtra(EXTRA_SONG_NAME, songName)
|
|
||||||
}
|
|
||||||
intent.putExtra(EXTRA_DND_GRANTED, dndGranted)
|
intent.putExtra(EXTRA_DND_GRANTED, dndGranted)
|
||||||
return intent
|
return intent
|
||||||
}
|
}
|
||||||
|
|
@ -110,11 +100,10 @@ class RingService : Service() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explicitly granted: proceed with the ring.
|
// Explicitly granted: proceed with the ring.
|
||||||
val songName = intent.getStringExtra(EXTRA_SONG_NAME)
|
Log.i(TAG, "DND access granted. Ringing.")
|
||||||
Log.i(TAG, "DND access granted. Ringing with song='$songName'")
|
startForeground(NOTIFICATION_ID, buildRingingNotification().build())
|
||||||
startForeground(NOTIFICATION_ID, buildRingingNotification(songName).build())
|
|
||||||
|
|
||||||
handleRing(songName)
|
handleRing()
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,14 +111,10 @@ class RingService : Service() {
|
||||||
// Ring logic
|
// Ring logic
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
private fun handleRing(songName: String?) {
|
private fun handleRing() {
|
||||||
val uri: Uri? = if (songName.isNullOrBlank()) {
|
// Sound + duration come from the user's settings.
|
||||||
// RING_NOW -> default system ringtone
|
val uri: Uri? = RingConfig.playableUri(this)
|
||||||
Settings.System.DEFAULT_RINGTONE_URI
|
val durationMs = RingConfig.durationSeconds(this) * 1_000L
|
||||||
} else {
|
|
||||||
// PLAY_SONG -> try to find a matching local track, else fallback.
|
|
||||||
querySongUri(songName) ?: Settings.System.DEFAULT_RINGTONE_URI
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
Log.e(TAG, "No playable URI. Aborting.")
|
Log.e(TAG, "No playable URI. Aborting.")
|
||||||
|
|
@ -142,8 +127,8 @@ class RingService : Service() {
|
||||||
restoreAndStop()
|
restoreAndStop()
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
// Ring for exactly 30 seconds.
|
// Ring for the configured duration.
|
||||||
delay(RING_DURATION_MS)
|
delay(durationMs)
|
||||||
restoreAndStop()
|
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
|
// Cleanup
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
@ -384,12 +308,12 @@ class RingService : Service() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildRingingNotification(songName: String?): NotificationCompat.Builder {
|
private fun buildRingingNotification(): NotificationCompat.Builder {
|
||||||
val content = if (songName.isNullOrBlank()) {
|
val content = getString(
|
||||||
getString(R.string.notif_ringing_ringtone)
|
R.string.notif_ringing,
|
||||||
} else {
|
RingConfig.durationSeconds(this),
|
||||||
getString(R.string.notif_ringing_song, songName)
|
RingConfig.soundMode(this)
|
||||||
}
|
)
|
||||||
val openIntent = PendingIntent.getActivity(
|
val openIntent = PendingIntent.getActivity(
|
||||||
this, 0, Intent(this, MainActivity::class.java),
|
this, 0, Intent(this, MainActivity::class.java),
|
||||||
PendingIntent.FLAG_IMMUTABLE
|
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
|
* 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
|
* When a matching message is received, RingService is started as a foreground
|
||||||
* - PLAY_SONG <Name> -> play a matching local MediaStore song, else fallback
|
* 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
|
* 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() {
|
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) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) {
|
if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// We only act on a trigger and ignore others, but aborting a broadcast
|
// We only act on a trigger and ignore others; we do NOT call abort() so
|
||||||
// that we did not handle would break other apps, so we do NOT call abort.
|
// other applications are not affected by broadcasts we do not handle.
|
||||||
val (body, sender) = parseMessages(intent) ?: return
|
val (body, sender) = parseMessages(intent) ?: return
|
||||||
Log.i(TAG, "SMS from '$sender': '$body'")
|
Log.i(TAG, "SMS from '$sender': '$body'")
|
||||||
|
|
||||||
when {
|
if (!RingConfig.isConfiguredTrigger(context, body)) {
|
||||||
TRIGGER_RING_NOW.matches(body) -> handleTrigger(context, null)
|
Log.i(TAG, "Not a trigger. Configured text='${RingConfig.triggerText(context)}'")
|
||||||
else -> {
|
return
|
||||||
TRIGGER_PLAY_SONG.find(body)?.let { match ->
|
|
||||||
val songName = match.groupValues[1].trim()
|
|
||||||
handleTrigger(context, songName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleTrigger(context, sender)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -66,19 +56,23 @@ class SmsReceiver : BroadcastReceiver() {
|
||||||
return Pair(body.toString().trim(), sender ?: "unknown")
|
return Pair(body.toString().trim(), sender ?: "unknown")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleTrigger(context: Context, songName: String?) {
|
private fun handleTrigger(context: Context, sender: String) {
|
||||||
Log.i(TAG, "Trigger matched. song='$songName'")
|
Log.i(TAG, "Trigger matched from '$sender'.")
|
||||||
|
|
||||||
// Determine the current DND access state so the service can short-circuit
|
// 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.
|
// if needed. The service re-verifies this at runtime as the source of truth.
|
||||||
val dndGranted = (context.getSystemService(Context.NOTIFICATION_SERVICE)
|
val dndGranted = (context.getSystemService(Context.NOTIFICATION_SERVICE)
|
||||||
as android.app.NotificationManager).isNotificationPolicyAccessGranted
|
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) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
ContextCompat.startForegroundService(context, serviceIntent)
|
ContextCompat.startForegroundService(context, serviceIntent)
|
||||||
} else {
|
} else {
|
||||||
context.startService(serviceIntent)
|
context.startService(serviceIntent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SmsReceiver"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -77,10 +77,17 @@
|
||||||
android:text="@string/btn_notifications" />
|
android:text="@string/btn_notifications" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/btnTestRing"
|
android:id="@+id/btnSettings"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="24dp"
|
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:backgroundTint="#C62828"
|
||||||
android:text="@string/btn_test_ring" />
|
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="channel_description">Media playback channel for ring-alert playback</string>
|
||||||
|
|
||||||
<string name="notif_ringing_title">Lost My Phone — Ringing</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">Ringing for %1$d seconds (sound: %2$s)</string>
|
||||||
<string name="notif_ringing_song">Playing "%1$s" for 30 seconds</string>
|
|
||||||
|
|
||||||
<string name="notif_permission_denied_title">Action Required: DND Access</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>
|
<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="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="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>
|
</resources>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue