checkpoint: Lost My Phone app - SMS triggers, DND override, mediaPlayback FGS
This commit is contained in:
commit
220dd85ed3
16 changed files with 1147 additions and 0 deletions
150
README.md
Normal file
150
README.md
Normal 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
46
app/build.gradle.kts
Normal 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
3
app/proguard-rules.pro
vendored
Normal 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.** { *; }
|
||||||
71
app/src/main/AndroidManifest.xml
Normal file
71
app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
167
app/src/main/java/com/example/lostmyphone/MainActivity.kt
Normal file
167
app/src/main/java/com/example/lostmyphone/MainActivity.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
445
app/src/main/java/com/example/lostmyphone/RingService.kt
Normal file
445
app/src/main/java/com/example/lostmyphone/RingService.kt
Normal 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
|
||||||
|
}
|
||||||
84
app/src/main/java/com/example/lostmyphone/SmsReceiver.kt
Normal file
84
app/src/main/java/com/example/lostmyphone/SmsReceiver.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/src/main/res/drawable/ic_launcher.xml
Normal file
15
app/src/main/res/drawable/ic_launcher.xml
Normal 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>
|
||||||
95
app/src/main/res/layout/activity_main.xml
Normal file
95
app/src/main/res/layout/activity_main.xml
Normal 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 <Name>\" to play a matching local track."
|
||||||
|
android:textSize="13sp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
6
app/src/main/res/values/colors.xml
Normal file
6
app/src/main/res/values/colors.xml
Normal 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>
|
||||||
32
app/src/main/res/values/strings.xml
Normal file
32
app/src/main/res/values/strings.xml
Normal 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 & 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>
|
||||||
8
app/src/main/res/values/themes.xml
Normal file
8
app/src/main/res/values/themes.xml
Normal 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
4
build.gradle.kts
Normal 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
4
gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
kotlin.code.style=official
|
||||||
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
10
settings.gradle.kts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "LostMyPhone"
|
||||||
|
include(":app")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue