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
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue