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

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>