checkpoint: Lost My Phone app - SMS triggers, DND override, mediaPlayback FGS

This commit is contained in:
avi 2026-08-06 16:24:28 -05:00
commit 220dd85ed3
16 changed files with 1147 additions and 0 deletions

150
README.md Normal file
View file

@ -0,0 +1,150 @@
# Lost My Phone
An Android app that listens for SMS commands to trigger a loud ring — overriding
"Do Not Disturb" (DND) — so you can find your phone.
- **Package:** `com.example.lostmyphone`
- **No Google Play Services.** Pure Android SDK.
- **Languages:** Kotlin, Material Components (AndroidX).
---
## SMS Trigger Commands
| 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. |
Playback always runs for exactly **30 seconds**, then the previous volume is
restored.
> Send the SMS to your phone from any other phone. The command is matched
> case-insensitively.
---
## How Do Not Disturb Override Works (read this)
Android does **not** allow any app to silently bypass DND. The only supported path
is the **"Do Not Disturb access"** special app setting. **Without it the app will
refuse to play any sound.** This is enforced at two points:
1. **`SmsReceiver`** passes whether access is granted to the service.
2. **`RingService.onStartCommand`** re-verifies
`NotificationManager.isNotificationPolicyAccessGranted` as the **source of
truth**. If it is `false` the service:
- does **not** start playback,
- shows an ongoing "Action Required: DND Access" notification,
- and launches the system DND-access settings screen.
Only after the user flips that toggle will a trigger actually ring.
When access **is** granted, `RingService`:
- saves the current music volume,
- forces `AudioManager.STREAM_MUSIC` to max,
- requests `AUDIOFOCUS_GAIN_TRANSIENT` using `USAGE_NOTIFICATION_RINGTONE` and
sets `AudioManager.mode = MODE_RINGTONE` so the sound cuts through DND,
- plays for 30 s, then restores the volume and abandons focus.
---
## Build & Install
### Prerequisites
- JDK 17
- Android SDK with `compileSdk = 34` (platform 34 + build tools) — see
`app/build.gradle.kts`.
### Steps
```bash
cd /path/to/Lost_My_Phone
# 1) Point Gradle at your Android SDK (if not already in $ANDROID_HOME)
# create a local.properties file:
# echo "sdk.dir=/path/to/Android/Sdk" > local.properties
# 2) Generate the Gradle wrapper (optional if you already have gradle 8.6)
gradle wrapper
# 3) Build the debug APK
./gradlew :app:assembleDebug
# 4) The APK is at app/build/outputs/apk/debug/app-debug.apk
# 5) Install with adb
adb install app/build/outputs/apk/debug/app-debug.apk
```
---
## First-Launch Permission Setup
On install you must grant **four** things, in this order:
### 1. SMS permission (required — SMS receiver will not fire without it)
Launch the app → tap **"Grant SMS & Media Permissions"** → **Allow**.
- On Android 13+ this also asks for `READ_MEDIA_AUDIO` (needed for `PLAY_SONG`).
### 2. Notification permission (Android 13+)
Tap **"Grant Notification Permission"** → **Allow**.
(Needed so the foreground-service notification shows.)
### 3. Do Not Disturb Access (CRITICAL)
The only way the app can legally override DND. Do **one** of:
- **(A)** Tap **"Open Do Not Disturb Access Settings"** in the app, **or**
- **(B)** Navigate manually:
```
Settings > Apps > (All / See all N apps) > Lost My Phone
> Special access > "Do Not Disturb access" > ALLOW / turn ON
```
> Some OEMs label it "Do Not Disturb access", some "Notification policy access".
> Older Android versions: `Settings > Apps > Advanced > Special app access`.
> Exact wording varies by manufacturer/Android version; search Settings for
> **"Do Not Disturb access"**.
Once enabled, return to the app — the status card should read **"Do Not Disturb
Access: GRANTED"**.
### 4. (Optional) Manual test
Tap **"Test Ring Now (30s)"**. Must have completed step 3 or it will warn and open
settings instead.
---
## Architecture
```
app/src/main/java/com/example/lostmyphone/
├── MainActivity.kt # UI: status, permission requests, DND-access launcher, manual test
├── SmsReceiver.kt # BroadcastReceiver: parses SMS -> detects RING_NOW / PLAY_SONG
└── RingService.kt # ForegroundService: DND gate, volume override, focus + playback, 30s timer
```
- **`SmsReceiver`** listens for `android.provider.Telephony.SMS_RECEIVED` and starts
`RingService` as a foreground service (`foregroundServiceType="mediaPlayback"`).
- **`RingService`** encapsulates all playback and DND logic so the ring continues
with the screen off.
---
## Permissions declared (`AndroidManifest.xml`)
- `ACCESS_NOTIFICATION_POLICY` — enables DND override once user grants special access.
- `RECEIVE_SMS` — to detect the trigger commands.
- `FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_MEDIA_PLAYBACK` — media foreground service.
- `READ_MEDIA_AUDIO` (13+) / `READ_EXTERNAL_STORAGE` (≤ 12) — MediaStore song search.
- `POST_NOTIFICATIONS` (13+) — foreground / status notifications.
- `WAKE_LOCK`, `VIBRATE` — ensure the ring is audible/prominent.
---
## Privacy & Safety
- The app **never** plays audio unless (a) the user has granted DND access **and**
(b) a valid trigger was sent or the manual test was tapped.
- Permit to Media and SMS are only used for the described purpose. No data leaves
the device, no analytics, no network calls, no GMS/Firebase.

46
app/build.gradle.kts Normal file
View file

@ -0,0 +1,46 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.lostmyphone"
compileSdk = 34
defaultConfig {
applicationId = "com.example.lostmyphone"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.activity:activity-ktx:1.8.2")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.lifecycle:lifecycle-service:2.7.0")
implementation("androidx.lifecycle:lifecycle-process:2.7.0")
}

3
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,3 @@
# Add project specific ProGuard rules here.
# Keep the SMS receiver and service since they are referenced by the manifest.
-keep class com.example.lostmyphone.** { *; }

View file

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Requested special access: grants the app the ability to MUTE notifications
but importantly allows bypassing Do Not Disturb restrictions for audio. -->
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<!-- Required to intercept SMS broadcasts (the app's primary trigger mechanism). -->
<uses-permission
android:name="android.permission.RECEIVE_SMS"
android:protectionLevel="dangerous" />
<!-- Required for the foreground trailing service and foreground service permission checks. -->
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE"
tools:targetApi="34" />
<!-- While starting a mediaPlayback foreground service on Android 14+ you need this. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- Required to read the audio MediaStore for the "PLAY_SONG" trigger. -->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.LostMyPhone"
tools:targetApi="34">
<!-- Main activity: UI, permission status, and launching DND access settings. -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/Theme.LostMyPhone">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- BroadcastReceiver: intercepts incoming SMS to detect RING_NOW / PLAY_SONG. -->
<receiver
android:name=".SmsReceiver"
android:exported="true">
<intent-filter android:priority="999">
<action android:name="android.provider.Telephony.SMS_RECEIVED" />
</intent-filter>
</receiver>
<!-- Foreground service: performs volume override + DND-aware playback. -->
<service
android:name=".RingService"
android:exported="false"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="com.example.lostmyphone.action.RING" />
</intent-filter>
</service>
</application>
</manifest>

View file

@ -0,0 +1,167 @@
package com.example.lostmyphone
import android.Manifest
import android.app.NotificationManager
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.widget.Button
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
class MainActivity : AppCompatActivity() {
private lateinit var tvStatus: TextView
private lateinit var tvDnd: TextView
private lateinit var btnSmsSettings: Button
private lateinit var btnGrantNotifications: Button
private lateinit var btnDndSettings: Button
private lateinit var btnTestRing: Button
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
refreshPermissionUi()
}
private val openDndSettingsLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
refreshPermissionUi()
Toast.makeText(this, R.string.toast_dnd_checked, Toast.LENGTH_SHORT).show()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
tvStatus = findViewById(R.id.tvStatus)
tvDnd = findViewById(R.id.tvDnd)
btnSmsSettings = findViewById(R.id.btnSmsSettings)
btnGrantNotifications = findViewById(R.id.btnGrantNotifications)
btnDndSettings = findViewById(R.id.btnDndSettings)
btnTestRing = findViewById(R.id.btnTestRing)
btnSmsSettings.setOnClickListener { requestSmsAndMediaPermissions() }
btnGrantNotifications.setOnClickListener { requestNotificationPermission() }
btnDndSettings.setOnClickListener { openDndSettings() }
btnTestRing.setOnClickListener {
if (isDndGranted()) {
startRingService(null)
} else {
Toast.makeText(this, R.string.toast_dnd_required, Toast.LENGTH_LONG).show()
openDndSettings()
}
}
// Refresh permission state whenever we return to the foreground (e.g. the
// user just left the DND access settings screen).
lifecycle.addObserver(LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) refreshPermissionUi()
})
}
// -----------------------------------------------------------------------
// Permission checks
// -----------------------------------------------------------------------
private fun isDndGranted(): Boolean {
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
return nm.isNotificationPolicyAccessGranted
}
private fun isSmsGranted(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.RECEIVE_SMS) ==
PackageManager.PERMISSION_GRANTED
private fun isAudioGranted(): Boolean {
val perm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_AUDIO
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
return ContextCompat.checkSelfPermission(this, perm) ==
PackageManager.PERMISSION_GRANTED
}
private fun isNotificationGranted(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
return ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
}
// -----------------------------------------------------------------------
// Permission requests
// -----------------------------------------------------------------------
private fun requestSmsAndMediaPermissions() {
val perms = mutableListOf<String>()
if (!isSmsGranted()) perms.add(Manifest.permission.RECEIVE_SMS)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !isAudioGranted()) {
perms.add(Manifest.permission.READ_MEDIA_AUDIO)
}
if (perms.isNotEmpty()) {
requestPermissionLauncher.launch(perms.toTypedArray())
} else {
Toast.makeText(this, R.string.toast_permissions_already, Toast.LENGTH_SHORT).show()
}
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !isNotificationGranted()) {
requestPermissionLauncher.launch(arrayOf(Manifest.permission.POST_NOTIFICATIONS))
}
}
/**
* The ONLY way the app can bypass DND: the user must manually flip the toggle
* in the system settings screen opened here. If they haven't, the app will
* never play a sound on a trigger.
*/
private fun openDndSettings() {
openDndSettingsLauncher.launch(
Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS)
)
}
private fun startRingService(songName: String?) {
val intent = RingService.buildIntent(this, isDndGranted(), songName)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
}
// -----------------------------------------------------------------------
// UI refresh
// -----------------------------------------------------------------------
private fun refreshPermissionUi() {
val dndGranted = isDndGranted()
tvDnd.text = if (dndGranted) {
getString(R.string.dnd_status_granted)
} else {
getString(R.string.dnd_status_denied)
}
tvDnd.setTextColor(if (dndGranted) Color.parseColor("#4CAF50") else Color.parseColor("#F44336"))
tvStatus.text = buildString {
append("RECEIVE_SMS: ${if (isSmsGranted()) "granted" else "NOT granted"}\n")
append("READ_MEDIA_AUDIO: ${if (isAudioGranted()) "granted" else "NOT granted"}\n")
append("POST_NOTIFICATIONS: ${if (isNotificationGranted()) "granted" else "NOT granted"}")
}
btnDndSettings.text = if (dndGranted) {
getString(R.string.btn_dnd_granted)
} else {
getString(R.string.btn_dnd_settings)
}
}
}

View file

@ -0,0 +1,445 @@
package com.example.lostmyphone
import android.app.NotificationManager
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
import android.media.AudioFocusRequest
import android.media.AudioManager
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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Foreground service responsible for the core "Lost My Phone" behaviour:
*
* 1. Verify the user has granted Do Not Disturb access
* (NotificationManager.isNotificationPolicyAccessGranted). If not, it must
* NOT play any sound. Instead it surfaces the settings intent and a message.
* 2. If granted: force STREAM_MUSIC volume to max, request transient audio
* focus with MODE_RINGTONE semantics so playback can cut through DND,
* play the requested audio for exactly 30 seconds, then restore the
* previous volume level and stop.
*
* Privacy note: no sound is ever played unless DND access has been explicitly
* granted by the user AND a valid SMS trigger was received.
*/
class RingService : Service() {
companion object {
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 {
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
}
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private lateinit var audioManager: AudioManager
private lateinit var notificationManager: NotificationManager
private lateinit var mediaPlayer: MediaPlayer
private var originalVolume = -1
private var previousFocusMode = AudioManager.MODE_NORMAL
private var hasFocus = false
private var isRestoring = false
private var audioFocusRequest: AudioFocusRequest? = null
override fun onCreate() {
super.onCreate()
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null) {
stopSelf()
return START_NOT_STICKY
}
// ---- CRITICAL: DND permission gate --------------------------------
// The SMS receiver already communicates whether permission was granted,
// but we ALWAYS re-verify server-side here because it is the source of truth.
val dndGranted = notificationManager.isNotificationPolicyAccessGranted
if (!dndGranted) {
Log.w(TAG, "DND access not granted. Refusing to play sound.")
// Publish a persistent, ongoing "permission required" notification so
// the user is clearly informed, then open the DND access settings.
startForeground(NOTIFICATION_ID, buildDeniedNotification().build())
showPermissionRequiredAction()
// Keep the service alive (with the persistent notification) until the
// user grants access and re-triggers, at which point onStartCommand
// fires again and playback proceeds. No sound was played.
return START_STICKY
}
// 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())
handleRing(songName)
return START_STICKY
}
// -----------------------------------------------------------------------
// 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
}
if (uri == null) {
Log.e(TAG, "No playable URI. Aborting.")
restoreAndStop()
return
}
scope.launch {
if (!beginPlayback(uri)) {
restoreAndStop()
return@launch
}
// Ring for exactly 30 seconds.
delay(RING_DURATION_MS)
restoreAndStop()
}
}
/**
* Bypass-DND sequence. Ordering matters:
* 1. Capture the current music volume so we can restore it later.
* 2. Force the music stream to max volume.
* 3. Put the audio manager into MODE_RINGTONE so the device treats the
* audio with the "ringtone" semantics used by incoming calls - this is
* what lets the sound punch through Do Not Disturb. On modern Android
* this is reinforced by requesting focus with USAGE_NOTIFICATION_RINGTONE.
* 4. Prepare and start playback.
*
* @return true if playback started successfully.
*/
private suspend fun beginPlayback(uri: Uri): Boolean {
return try {
// Step 1: save current volume.
originalVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
// Step 2: force max volume.
val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
audioManager.setStreamVolume(
AudioManager.STREAM_MUSIC,
maxVolume,
0 // flags = 0 so we do NOT show the system volume UI.
)
// Step 3: request focus. Use USAGE_NOTIFICATION_RINGTONE so the
// system lets it play even during DND, and set MODE_RINGTONE.
previousFocusMode = audioManager.mode
requestAudioFocus()
// Prepare the player on the main thread (MediaPlayer creation is
// safe on main; prepare is async).
mediaPlayer = MediaPlayer()
mediaPlayer.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
)
mediaPlayer.setDataSource(this, uri)
mediaPlayer.prepare()
mediaPlayer.setOnCompletionListener { /* restore handled by timer */ }
mediaPlayer.start()
Log.i(
TAG,
"Playback started. volume=$maxVolume mode=${audioManager.mode} " +
"dnd=${notificationManager.isNotificationPolicyAccessGranted}"
)
true
} catch (e: Exception) {
Log.e(TAG, "Failed to start playback", e)
restoreAndStop()
false
}
}
private fun requestAudioFocus() {
val listener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
hasFocus = true
try {
audioManager.mode = AudioManager.MODE_RINGTONE
if (::mediaPlayer.isInitialized && !mediaPlayer.isPlaying) {
mediaPlayer.start()
}
} catch (e: Exception) {
Log.e(TAG, "Focus gain handling failed", e)
}
}
AudioManager.AUDIOFOCUS_LOSS,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
// We only have a transient burst; don't fight for focus,
// just restore cleanly.
restoreAndStop()
}
}
}
val attributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
val result: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
.setAudioAttributes(attributes)
.setOnAudioFocusChangeListener(listener)
.setWillPauseWhenDucked(false)
.build()
audioManager.requestAudioFocus(audioFocusRequest!!)
} else {
@Suppress("DEPRECATION")
audioManager.requestAudioFocus(
listener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
)
}
hasFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
if (hasFocus) {
audioManager.mode = AudioManager.MODE_RINGTONE
}
}
/**
* 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
// -----------------------------------------------------------------------
private fun restoreAndStop() {
if (isRestoring) return
isRestoring = true
try {
// Stop playback.
if (::mediaPlayer.isInitialized) {
try {
mediaPlayer.stop()
} catch (_: Exception) {
}
mediaPlayer.release()
}
// Abandon audio focus.
if (hasFocus) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && audioFocusRequest != null) {
audioManager.abandonAudioFocusRequest(audioFocusRequest!!)
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus(null)
}
}
// Restore the previous audio mode.
if (audioManager.mode == AudioManager.MODE_RINGTONE) {
audioManager.mode = previousFocusMode
}
// Restore the previous volume.
if (originalVolume >= 0) {
audioManager.setStreamVolume(
AudioManager.STREAM_MUSIC,
originalVolume,
0
)
Log.i(TAG, "Restored volume to $originalVolume")
}
} catch (e: Exception) {
Log.e(TAG, "Cleanup error", e)
}
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
// -----------------------------------------------------------------------
// Notifications / user messaging
// -----------------------------------------------------------------------
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.channel_name),
NotificationManager.IMPORTANCE_HIGH
).apply {
description = getString(R.string.channel_description)
setSound(null, null) // silent channel; audio is played by MediaPlayer
enableVibration(false)
}
notificationManager.createNotificationChannel(channel)
}
}
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)
}
val openIntent = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle(getString(R.string.notif_ringing_title))
.setContentText(content)
.setContentIntent(openIntent)
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_ALARM)
}
private fun buildDeniedNotification(): NotificationCompat.Builder {
val content = getString(R.string.dnd_permission_required)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_lock_lock)
.setContentTitle(getString(R.string.notif_permission_denied_title))
.setContentText(content)
.setContentIntent(buildPermissionSettingsPendingIntent())
.setOngoing(true)
}
private fun buildPermissionSettingsPendingIntent(): PendingIntent {
val settingsIntent = Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS)
settingsIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
return PendingIntent.getActivity(
this, 1, settingsIntent,
PendingIntent.FLAG_IMMUTABLE
)
}
/**
* Called when DND permission is missing. We launch the system settings
* screen so the user can grant access - the only way this app can legally
* bypass DND.
*/
private fun showPermissionRequiredAction() {
val settingsIntent = Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS)
settingsIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
startActivity(settingsIntent)
} catch (e: Exception) {
Log.e(TAG, "Could not open DND settings", e)
}
}
override fun onDestroy() {
super.onDestroy()
scope.cancel()
restoreAndStop()
}
override fun onBind(intent: Intent?): IBinder? = null
}

View file

@ -0,0 +1,84 @@
package com.example.lostmyphone
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.Telephony
import android.util.Log
import androidx.core.content.ContextCompat
/**
* BroadcastReceiver that intercepts incoming SMS messages and looks for the
* trigger commands:
*
* - RING_NOW -> play the default system ringtone
* - PLAY_SONG <Name> -> play a matching local MediaStore song, else fallback
*
* On Android 6.0+ this receiver only fires if the user has granted the
* RECEIVE_SMS runtime permission (which MainActivity requests on first launch).
*/
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.
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)
}
}
}
}
/**
* Extracts the concatenated SMS body from the multi-part Telephony intent.
*/
private fun parseMessages(intent: Intent): Pair<String, String>? {
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent)
if (messages.isEmpty()) return null
val body = StringBuilder()
var sender: String? = null
for (msg in messages) {
body.append(msg.messageBody ?: "")
if (sender == null) sender = msg.originatingAddress
}
if (body.isEmpty()) return null
return Pair(body.toString().trim(), sender ?: "unknown")
}
private fun handleTrigger(context: Context, songName: String?) {
Log.i(TAG, "Trigger matched. song='$songName'")
// 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)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ContextCompat.startForegroundService(context, serviceIntent)
} else {
context.startService(serviceIntent)
}
}
}

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#202124"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M54,24c-12,0-22,9-22,21v10l-4,4v3h52v-3l-4-4v-10c0-12-10-21-22-21z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M54,84c-4,0-7-3-8-6h16c-1,3-4,6-8,6z" />
</vector>

View file

@ -0,0 +1,95 @@
<?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:id="@+id/tvStatusHeader"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="Permission Status"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="15sp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:background="#44000000" />
<TextView
android:id="@+id/tvDndHeader"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="Do Not Disturb Override (Critical)"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvDnd"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/dnd_explanation"
android:textSize="14sp" />
<Button
android:id="@+id/btnDndSettings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/btn_dnd_settings" />
<Button
android:id="@+id/btnSmsSettings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/btn_sms_settings" />
<Button
android:id="@+id/btnGrantNotifications"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/btn_notifications" />
<Button
android:id="@+id/btnTestRing"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:backgroundTint="#C62828"
android:text="@string/btn_test_ring" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Commands: send \"RING_NOW\" to this device for the default ringtone, or \"PLAY_SONG &lt;Name&gt;\" to play a matching local track."
android:textSize="13sp" />
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#1A73E8</color>
<color name="primary_variant">#174EA6</color>
<color name="white">#FFFFFF</color>
</resources>

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Lost My Phone</string>
<string name="channel_name">Ring 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_ringtone">Playing default ringtone for 30 seconds</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="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_status_granted">Do Not Disturb Access: GRANTED</string>
<string name="dnd_status_denied">Do Not Disturb Access: DENIED</string>
<string name="btn_dnd_settings">Open Do Not Disturb Access Settings</string>
<string name="btn_dnd_granted">Do Not Disturb Access — Granted</string>
<string name="btn_sms_settings">Grant SMS &amp; Media Permissions</string>
<string name="btn_notifications">Grant Notification Permission</string>
<string name="btn_test_ring">Test Ring Now (30s)</string>
<string name="toast_dnd_checked">DND access checked. If you enabled it, the app is ready.</string>
<string name="toast_dnd_required">Do Not Disturb Access is required before ringing.</string>
<string name="toast_dnd_already">Do Not Disturb Access is already granted.</string>
<string name="toast_permissions_already">All SMS/media permissions already granted.</string>
<string name="toast_sms_required">SMS permission is required to detect ring commands.</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>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.LostMyPhone" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryVariant">@color/primary_variant</item>
<item name="colorOnPrimary">@color/white</item>
</style>
</resources>

4
build.gradle.kts Normal file
View file

@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
}

4
gradle.properties Normal file
View file

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

10
settings.gradle.kts Normal file
View file

@ -0,0 +1,10 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = "LostMyPhone"
include(":app")