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:
2026-10-03 17:37:30 -04:00
co-authored by Claude Opus 5.5
parent 23faffc251
commit 15ab815df6
26 changed files with 865 additions and 8 deletions
@@ -0,0 +1,23 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'tech.chrisedwards.musicshare.nowplaying'
version = '0.1.0'
android {
namespace "tech.chrisedwards.musicshare.nowplaying"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
lintOptions {
abortOnError false
}
}
dependencies {
// Delivers reports even if the app process is killed, and retries when the network returns.
implementation "androidx.work:work-runtime-ktx:2.10.5"
}
@@ -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>
@@ -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
}
}
@@ -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) }
}
}
@@ -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()
}
}
}
@@ -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)
}
}
@@ -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()
}
@@ -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>