From 220dd85ed3bd8264676f0d12d6f6216f4a6c535a Mon Sep 17 00:00:00 2001 From: avi Date: Thu, 6 Aug 2026 16:24:28 -0500 Subject: [PATCH] checkpoint: Lost My Phone app - SMS triggers, DND override, mediaPlayback FGS --- README.md | 150 ++++++ app/build.gradle.kts | 46 ++ app/proguard-rules.pro | 3 + app/src/main/AndroidManifest.xml | 71 +++ .../com/example/lostmyphone/MainActivity.kt | 167 +++++++ .../com/example/lostmyphone/RingService.kt | 445 ++++++++++++++++++ .../com/example/lostmyphone/SmsReceiver.kt | 84 ++++ app/src/main/res/drawable/ic_launcher.xml | 15 + app/src/main/res/layout/activity_main.xml | 95 ++++ app/src/main/res/values/colors.xml | 6 + app/src/main/res/values/strings.xml | 32 ++ app/src/main/res/values/themes.xml | 8 + build.gradle.kts | 4 + gradle.properties | 4 + gradle/wrapper/gradle-wrapper.properties | 7 + settings.gradle.kts | 10 + 16 files changed, 1147 insertions(+) create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/example/lostmyphone/MainActivity.kt create mode 100644 app/src/main/java/com/example/lostmyphone/RingService.kt create mode 100644 app/src/main/java/com/example/lostmyphone/SmsReceiver.kt create mode 100644 app/src/main/res/drawable/ic_launcher.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 settings.gradle.kts diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ac80b3 --- /dev/null +++ b/README.md @@ -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 ` | 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. \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..ce79f83 --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..554499e --- /dev/null +++ b/app/proguard-rules.pro @@ -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.** { *; } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2a21e67 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/lostmyphone/MainActivity.kt b/app/src/main/java/com/example/lostmyphone/MainActivity.kt new file mode 100644 index 0000000..c5d3f33 --- /dev/null +++ b/app/src/main/java/com/example/lostmyphone/MainActivity.kt @@ -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() + 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) + } + } +} diff --git a/app/src/main/java/com/example/lostmyphone/RingService.kt b/app/src/main/java/com/example/lostmyphone/RingService.kt new file mode 100644 index 0000000..8bb0f1b --- /dev/null +++ b/app/src/main/java/com/example/lostmyphone/RingService.kt @@ -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? = 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 +} diff --git a/app/src/main/java/com/example/lostmyphone/SmsReceiver.kt b/app/src/main/java/com/example/lostmyphone/SmsReceiver.kt new file mode 100644 index 0000000..d1126d8 --- /dev/null +++ b/app/src/main/java/com/example/lostmyphone/SmsReceiver.kt @@ -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 -> 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? { + 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..a85c172 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..6b4df51 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + +