Share what's playing in any Android music app
A local Expo module listens to system media sessions (the only way Android exposes other apps' playback, gated by notification access; we never read notifications). It filters to known music apps, debounces and deduplicates changes, sends a heartbeat while playing, and delivers reports through WorkManager so they survive the app being closed. - One-screen onboarding that doubles as Google Play's prominent disclosure, opening straight to the app's toggle in Android settings - Only media notifications are pre-ticked on that settings screen - Device registration is single-flight (several screens react to the same permission change) and self-heals if the server revokes a token - Sign-out revokes the phone's token; resuming sharing re-sends the current track immediately - Pause controls in Settings and on the feed - Fix invisible tab icons and labels on Android - Keep Metro out of build output so it doesn't exhaust file watchers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application>
|
||||
<!--
|
||||
Holding notification access is the only way Android lets an app see other apps'
|
||||
media sessions. We never read notification contents; the service only uses its
|
||||
binding to call MediaSessionManager.getActiveSessions().
|
||||
-->
|
||||
<service
|
||||
android:name=".MediaListenerService"
|
||||
android:label="@string/now_playing_listener_label"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
<!-- Only media ("real-time") notifications are relevant, so the settings screen
|
||||
doesn't pre-tick Conversations etc. We still never read notification contents. -->
|
||||
<meta-data
|
||||
android:name="android.service.notification.default_filter_types"
|
||||
android:value="ongoing" />
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package tech.chrisedwards.musicshare.nowplaying
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.MediaMetadata
|
||||
import android.media.session.MediaController
|
||||
import android.media.session.MediaSessionManager
|
||||
import android.media.session.PlaybackState
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.service.notification.NotificationListenerService
|
||||
|
||||
/**
|
||||
* Bound by the system once the user grants notification access. We don't
|
||||
* override onNotificationPosted: notification access is only the key that
|
||||
* unlocks MediaSessionManager.getActiveSessions(), which tells us what music
|
||||
* apps are playing. Changes are debounced (apps publish title and artist in
|
||||
* separate updates), deduplicated, and handed to Reporter.
|
||||
*/
|
||||
class MediaListenerService : NotificationListenerService() {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val callbacks = mutableMapOf<MediaController, MediaController.Callback>()
|
||||
private var sessionManager: MediaSessionManager? = null
|
||||
private var lastReported: Playback? = null
|
||||
|
||||
private val component by lazy { ComponentName(this, MediaListenerService::class.java) }
|
||||
private val prefs by lazy { Prefs(this) }
|
||||
|
||||
private val sessionsChanged = MediaSessionManager.OnActiveSessionsChangedListener { controllers ->
|
||||
track(controllers.orEmpty())
|
||||
}
|
||||
private val evaluateRunnable = Runnable { evaluate() }
|
||||
private val heartbeatRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
// Re-send while playing so the server knows we're still here (it expires silent phones).
|
||||
lastReported?.takeIf { it.state == PlayState.PLAYING }?.let { Reporter.report(this@MediaListenerService, it) }
|
||||
handler.postDelayed(this, HEARTBEAT_MS)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onListenerConnected() {
|
||||
super.onListenerConnected()
|
||||
instance = this
|
||||
val manager = getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager
|
||||
sessionManager = manager
|
||||
try {
|
||||
manager.addOnActiveSessionsChangedListener(sessionsChanged, component, handler)
|
||||
track(manager.getActiveSessions(component))
|
||||
} catch (e: SecurityException) {
|
||||
// Access was revoked between binding and now.
|
||||
}
|
||||
handler.postDelayed(heartbeatRunnable, HEARTBEAT_MS)
|
||||
}
|
||||
|
||||
override fun onListenerDisconnected() {
|
||||
instance = null
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
sessionManager?.removeOnActiveSessionsChangedListener(sessionsChanged)
|
||||
untrackAll()
|
||||
NowPlayingBus.emitPlayback(null)
|
||||
super.onListenerDisconnected()
|
||||
}
|
||||
|
||||
/** Called when JS changes configuration (e.g. which apps to share). */
|
||||
internal fun refresh() {
|
||||
val manager = sessionManager ?: return
|
||||
try {
|
||||
track(manager.getActiveSessions(component))
|
||||
} catch (e: SecurityException) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private fun track(controllers: List<MediaController>) {
|
||||
untrackAll()
|
||||
for (controller in controllers) {
|
||||
val callback = object : MediaController.Callback() {
|
||||
override fun onPlaybackStateChanged(state: PlaybackState?) = scheduleEvaluate()
|
||||
override fun onMetadataChanged(metadata: MediaMetadata?) = scheduleEvaluate()
|
||||
override fun onSessionDestroyed() = scheduleEvaluate()
|
||||
}
|
||||
controller.registerCallback(callback, handler)
|
||||
callbacks[controller] = callback
|
||||
}
|
||||
scheduleEvaluate()
|
||||
}
|
||||
|
||||
private fun untrackAll() {
|
||||
callbacks.forEach { (controller, callback) -> controller.unregisterCallback(callback) }
|
||||
callbacks.clear()
|
||||
}
|
||||
|
||||
private fun scheduleEvaluate() {
|
||||
handler.removeCallbacks(evaluateRunnable)
|
||||
handler.postDelayed(evaluateRunnable, DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
private fun evaluate() {
|
||||
val allowed = prefs.allowedPackages
|
||||
val candidates = callbacks.keys
|
||||
.filter { it.packageName in allowed }
|
||||
.mapNotNull { snapshot(it) }
|
||||
// Prefer whatever is actually playing; otherwise a paused session in an allowed app.
|
||||
val current = candidates.firstOrNull { it.state == PlayState.PLAYING } ?: candidates.firstOrNull()
|
||||
NowPlayingBus.emitPlayback(current)
|
||||
|
||||
val next = when {
|
||||
current != null -> current
|
||||
// The app we were reporting went away entirely: tell the server it stopped.
|
||||
lastReported != null && lastReported!!.state != PlayState.STOPPED ->
|
||||
Playback.stopped(lastReported!!.packageName, lastReported!!.appName)
|
||||
else -> return
|
||||
}
|
||||
if (next.identity == lastReported?.identity) return
|
||||
lastReported = next
|
||||
if (prefs.isConfigured) Reporter.report(this, next)
|
||||
}
|
||||
|
||||
private fun snapshot(controller: MediaController): Playback? {
|
||||
val metadata = controller.metadata
|
||||
val title = metadata?.text(MediaMetadata.METADATA_KEY_TITLE) ?: metadata?.text(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)
|
||||
val artist = metadata?.text(MediaMetadata.METADATA_KEY_ARTIST)
|
||||
?: metadata?.text(MediaMetadata.METADATA_KEY_ALBUM_ARTIST)
|
||||
?: metadata?.text(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE)
|
||||
val playbackState = controller.playbackState
|
||||
val state = when (playbackState?.state) {
|
||||
PlaybackState.STATE_PLAYING, PlaybackState.STATE_BUFFERING,
|
||||
PlaybackState.STATE_FAST_FORWARDING, PlaybackState.STATE_REWINDING,
|
||||
PlaybackState.STATE_SKIPPING_TO_NEXT, PlaybackState.STATE_SKIPPING_TO_PREVIOUS,
|
||||
PlaybackState.STATE_CONNECTING -> PlayState.PLAYING
|
||||
PlaybackState.STATE_PAUSED -> PlayState.PAUSED
|
||||
else -> PlayState.STOPPED
|
||||
}
|
||||
if (title.isNullOrBlank() && state != PlayState.STOPPED) return null // between tracks / ads with no metadata
|
||||
return Playback(
|
||||
packageName = controller.packageName,
|
||||
appName = appLabel(controller.packageName),
|
||||
state = state,
|
||||
title = title,
|
||||
artist = artist,
|
||||
album = metadata?.text(MediaMetadata.METADATA_KEY_ALBUM),
|
||||
durationMs = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.takeIf { it > 0 },
|
||||
positionMs = playbackState?.position?.takeIf { it >= 0 },
|
||||
)
|
||||
}
|
||||
|
||||
private fun MediaMetadata.text(key: String): String? = getString(key)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun appLabel(packageName: String): String? = try {
|
||||
packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, 0)).toString()
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DEBOUNCE_MS = 1_500L
|
||||
private const val HEARTBEAT_MS = 4 * 60_000L
|
||||
|
||||
@Volatile internal var instance: MediaListenerService? = null
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package tech.chrisedwards.musicshare.nowplaying
|
||||
|
||||
/** Bridges the listener service (system-bound, no JS) to the JS module when the app is open. */
|
||||
internal object NowPlayingBus {
|
||||
@Volatile var current: Playback? = null
|
||||
private set
|
||||
|
||||
private val listeners = mutableSetOf<Listener>()
|
||||
|
||||
interface Listener {
|
||||
fun onPlayback(playback: Playback?)
|
||||
fun onStatus(status: String)
|
||||
}
|
||||
|
||||
@Synchronized fun add(listener: Listener) { listeners.add(listener) }
|
||||
@Synchronized fun remove(listener: Listener) { listeners.remove(listener) }
|
||||
|
||||
@Synchronized fun emitPlayback(playback: Playback?) {
|
||||
current = playback
|
||||
listeners.forEach { it.onPlayback(playback) }
|
||||
}
|
||||
|
||||
@Synchronized fun emitStatus(status: String) {
|
||||
listeners.forEach { it.onStatus(status) }
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package tech.chrisedwards.musicshare.nowplaying
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import expo.modules.kotlin.exception.Exceptions
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
class NowPlayingReaderModule : Module() {
|
||||
private val context get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
|
||||
|
||||
private val busListener = object : NowPlayingBus.Listener {
|
||||
override fun onPlayback(playback: Playback?) {
|
||||
sendEvent("onPlaybackChange", mapOf("playback" to playback?.toMap()))
|
||||
}
|
||||
override fun onStatus(status: String) {
|
||||
sendEvent("onStatusChange", mapOf("status" to status))
|
||||
}
|
||||
}
|
||||
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("NowPlayingReader")
|
||||
|
||||
Events("onPlaybackChange", "onStatusChange")
|
||||
|
||||
OnStartObserving { NowPlayingBus.add(busListener) }
|
||||
OnStopObserving { NowPlayingBus.remove(busListener) }
|
||||
|
||||
/** Whether the user has granted notification access (required to see media sessions). */
|
||||
Function("isPermissionGranted") {
|
||||
NotificationManagerCompat.getEnabledListenerPackages(context).contains(context.packageName)
|
||||
}
|
||||
|
||||
/** Opens the system screen with our toggle (directly on Android 11+, the list before that). */
|
||||
Function("openPermissionSettings") {
|
||||
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Intent(Settings.ACTION_NOTIFICATION_LISTENER_DETAIL_SETTINGS).putExtra(
|
||||
Settings.EXTRA_NOTIFICATION_LISTENER_COMPONENT_NAME,
|
||||
ComponentName(context, MediaListenerService::class.java).flattenToString(),
|
||||
)
|
||||
} else {
|
||||
Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
|
||||
}
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
try {
|
||||
context.startActivity(intent)
|
||||
} catch (e: android.content.ActivityNotFoundException) {
|
||||
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
}
|
||||
|
||||
/** Where and how to report, and which apps' sessions to share. */
|
||||
Function("configure") { apiUrl: String, deviceToken: String, allowedPackages: List<String> ->
|
||||
val prefs = Prefs(context)
|
||||
val tokenChanged = prefs.deviceToken != deviceToken
|
||||
prefs.apiUrl = apiUrl
|
||||
prefs.deviceToken = deviceToken
|
||||
prefs.allowedPackages = allowedPackages.toSet()
|
||||
if (tokenChanged) prefs.unauthorized = false
|
||||
MediaListenerService.instance?.refresh()
|
||||
}
|
||||
|
||||
/** Re-send what's playing now (after resuming sharing). */
|
||||
Function("resend") {
|
||||
// Use the last snapshot the listener published rather than the service instance,
|
||||
// which isn't reachable from here if the system rebound the service.
|
||||
val prefs = Prefs(context)
|
||||
NowPlayingBus.current?.let { if (prefs.isConfigured) Reporter.report(context, it) }
|
||||
}
|
||||
|
||||
/** Stop reporting (sign out). */
|
||||
Function("clear") {
|
||||
Prefs(context).clear()
|
||||
NowPlayingBus.emitPlayback(null)
|
||||
}
|
||||
|
||||
/** True when the server rejected this device's token; JS should register a new device. */
|
||||
Function("needsReregistration") {
|
||||
Prefs(context).unauthorized
|
||||
}
|
||||
|
||||
/** What the listener currently sees, for showing "Sharing: X in Spotify" in settings. */
|
||||
Function("getCurrent") {
|
||||
NowPlayingBus.current?.toMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package tech.chrisedwards.musicshare.nowplaying
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
internal enum class PlayState(val wire: String) { PLAYING("playing"), PAUSED("paused"), STOPPED("stopped") }
|
||||
|
||||
/** A snapshot of what one app is playing. */
|
||||
internal data class Playback(
|
||||
val packageName: String,
|
||||
val appName: String?,
|
||||
val state: PlayState,
|
||||
val title: String?,
|
||||
val artist: String?,
|
||||
val album: String?,
|
||||
val durationMs: Long?,
|
||||
val positionMs: Long?,
|
||||
) {
|
||||
/** Fields that matter for "did anything change": not position, which ticks constantly. */
|
||||
val identity: String get() = listOf(packageName, state.wire, title, artist).joinToString("\u0000")
|
||||
|
||||
fun toJson(): JSONObject = JSONObject().apply {
|
||||
put("app", packageName)
|
||||
appName?.let { put("appName", it) }
|
||||
put("state", state.wire)
|
||||
if (state != PlayState.STOPPED && !title.isNullOrBlank()) {
|
||||
put("track", JSONObject().apply {
|
||||
put("title", title)
|
||||
put("artist", artist ?: "")
|
||||
album?.let { put("album", it) }
|
||||
durationMs?.takeIf { it > 0 }?.let { put("durationMs", it) }
|
||||
})
|
||||
}
|
||||
positionMs?.takeIf { it >= 0 }?.let { put("positionMs", it) }
|
||||
}
|
||||
|
||||
fun toMap(): Map<String, Any?> = mapOf(
|
||||
"app" to packageName,
|
||||
"appName" to appName,
|
||||
"state" to state.wire,
|
||||
"title" to title,
|
||||
"artist" to artist,
|
||||
"album" to album,
|
||||
"durationMs" to durationMs?.toDouble(),
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun stopped(packageName: String, appName: String?) =
|
||||
Playback(packageName, appName, PlayState.STOPPED, null, null, null, null, null)
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package tech.chrisedwards.musicshare.nowplaying
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/** Configuration written by JS (configure) and read by the listener service, which may run without the app UI. */
|
||||
internal class Prefs(context: Context) {
|
||||
private val prefs = context.applicationContext.getSharedPreferences("musicshare.now_playing", Context.MODE_PRIVATE)
|
||||
|
||||
var apiUrl: String?
|
||||
get() = prefs.getString("api_url", null)
|
||||
set(value) = prefs.edit().putString("api_url", value).apply()
|
||||
|
||||
var deviceToken: String?
|
||||
get() = prefs.getString("device_token", null)
|
||||
set(value) = prefs.edit().putString("device_token", value).apply()
|
||||
|
||||
var allowedPackages: Set<String>
|
||||
get() = prefs.getStringSet("allowed_packages", emptySet()) ?: emptySet()
|
||||
set(value) = prefs.edit().putStringSet("allowed_packages", value).apply()
|
||||
|
||||
/** Set when the server rejects the token (device revoked); JS re-registers. */
|
||||
var unauthorized: Boolean
|
||||
get() = prefs.getBoolean("unauthorized", false)
|
||||
set(value) = prefs.edit().putBoolean("unauthorized", value).apply()
|
||||
|
||||
val isConfigured: Boolean
|
||||
get() = !apiUrl.isNullOrEmpty() && !deviceToken.isNullOrEmpty() && !unauthorized
|
||||
|
||||
fun clear() = prefs.edit().clear().apply()
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package tech.chrisedwards.musicshare.nowplaying
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
internal object Reporter {
|
||||
private const val WORK_NAME = "musicshare.now_playing.report"
|
||||
|
||||
/** Queue the latest state. REPLACE drops any older unsent report: only the newest state matters. */
|
||||
fun report(context: Context, playback: Playback) {
|
||||
val request = OneTimeWorkRequestBuilder<ReportWorker>()
|
||||
.setInputData(workDataOf(ReportWorker.KEY_BODY to playback.toJson().toString()))
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
}
|
||||
|
||||
internal class ReportWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||
val prefs = Prefs(applicationContext)
|
||||
val apiUrl = prefs.apiUrl
|
||||
val token = prefs.deviceToken
|
||||
if (!prefs.isConfigured || apiUrl == null || token == null) return@withContext Result.success()
|
||||
val body = inputData.getString(KEY_BODY) ?: return@withContext Result.success()
|
||||
|
||||
val connection = (URL("${apiUrl.trimEnd('/')}/devices/now-playing").openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
connectTimeout = 10_000
|
||||
readTimeout = 10_000
|
||||
doOutput = true
|
||||
instanceFollowRedirects = false
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
setRequestProperty("Authorization", "Bearer $token")
|
||||
}
|
||||
try {
|
||||
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
||||
when (val code = connection.responseCode) {
|
||||
in 200..299 -> Result.success()
|
||||
401 -> {
|
||||
// Device was revoked or the account deleted: stop reporting until JS re-registers.
|
||||
prefs.unauthorized = true
|
||||
NowPlayingBus.emitStatus("unauthorized")
|
||||
Result.failure()
|
||||
}
|
||||
429, in 500..599 -> Result.retry()
|
||||
else -> {
|
||||
android.util.Log.w(TAG, "report rejected: HTTP $code")
|
||||
Result.failure()
|
||||
}
|
||||
}
|
||||
} catch (e: java.io.IOException) {
|
||||
Result.retry()
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_BODY = "body"
|
||||
private const val TAG = "MusicshareNowPlaying"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="now_playing_listener_label">musicshare: share what you\'re playing</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user