From 15ab815df64445b56df49e36eef575f36ad7530a Mon Sep 17 00:00:00 2001 From: chris Date: Sat, 3 Oct 2026 17:37:30 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8 --- apps/mobile/.gitignore | 9 + apps/mobile/metro.config.js | 20 +++ .../now-playing-reader/android/build.gradle | 23 +++ .../android/src/main/AndroidManifest.xml | 25 +++ .../nowplaying/MediaListenerService.kt | 162 ++++++++++++++++++ .../musicshare/nowplaying/NowPlayingBus.kt | 26 +++ .../nowplaying/NowPlayingReaderModule.kt | 90 ++++++++++ .../musicshare/nowplaying/Playback.kt | 50 ++++++ .../musicshare/nowplaying/Prefs.kt | 30 ++++ .../musicshare/nowplaying/Reporter.kt | 77 +++++++++ .../android/src/main/res/values/strings.xml | 3 + .../expo-module.config.json | 6 + .../modules/now-playing-reader/index.ts | 3 + .../src/NowPlayingReader.types.ts | 14 ++ .../src/NowPlayingReaderModule.ts | 19 ++ apps/mobile/package.json | 4 +- apps/mobile/src/app/(tabs)/_layout.tsx | 5 +- apps/mobile/src/app/(tabs)/index.tsx | 21 ++- apps/mobile/src/app/(tabs)/settings.tsx | 34 ++++ apps/mobile/src/app/_layout.tsx | 7 + apps/mobile/src/app/share-setup.tsx | 75 ++++++++ apps/mobile/src/components/NowPlayingCard.tsx | 2 +- .../mobile/src/components/SharingControls.tsx | 60 +++++++ apps/mobile/src/lib/nowPlayingReader.ts | 99 +++++++++++ apps/mobile/src/lib/session.tsx | 2 + apps/mobile/tsconfig.json | 7 +- 26 files changed, 865 insertions(+), 8 deletions(-) create mode 100644 apps/mobile/metro.config.js create mode 100644 apps/mobile/modules/now-playing-reader/android/build.gradle create mode 100644 apps/mobile/modules/now-playing-reader/android/src/main/AndroidManifest.xml create mode 100644 apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/MediaListenerService.kt create mode 100644 apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/NowPlayingBus.kt create mode 100644 apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/NowPlayingReaderModule.kt create mode 100644 apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Playback.kt create mode 100644 apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Prefs.kt create mode 100644 apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Reporter.kt create mode 100644 apps/mobile/modules/now-playing-reader/android/src/main/res/values/strings.xml create mode 100644 apps/mobile/modules/now-playing-reader/expo-module.config.json create mode 100644 apps/mobile/modules/now-playing-reader/index.ts create mode 100644 apps/mobile/modules/now-playing-reader/src/NowPlayingReader.types.ts create mode 100644 apps/mobile/modules/now-playing-reader/src/NowPlayingReaderModule.ts create mode 100644 apps/mobile/src/app/share-setup.tsx create mode 100644 apps/mobile/src/components/SharingControls.tsx create mode 100644 apps/mobile/src/lib/nowPlayingReader.ts diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore index 4b00baf..e9ab2b1 100644 --- a/apps/mobile/.gitignore +++ b/apps/mobile/.gitignore @@ -41,3 +41,12 @@ example # generated native folders /ios /android + +# Generated by `expo prebuild` (Continuous Native Generation); configure via app.json instead +/android +/ios + +# Gradle output inside local native modules +/modules/*/android/build +/modules/*/android/.gradle +/modules/*/android/.cxx diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js new file mode 100644 index 0000000..409233e --- /dev/null +++ b/apps/mobile/metro.config.js @@ -0,0 +1,20 @@ +// https://docs.expo.dev/guides/customizing-metro/ +const { getDefaultConfig } = require('expo/metro-config'); + +const config = getDefaultConfig(__dirname); + +// Metro watches the whole monorepo. Keep it out of build output (Gradle, Xcode, +// Next.js, tsup): those folders hold tens of thousands of files, and exhausting +// the OS file-watch limit makes Metro silently stop picking up code changes. +const buildOutput = [ + /[/\\]apps[/\\]mobile[/\\]android[/\\]/, + /[/\\]apps[/\\]mobile[/\\]ios[/\\]/, + /[/\\]modules[/\\][^/\\]+[/\\]android[/\\]build[/\\]/, + /[/\\]apps[/\\]web[/\\]\.next[/\\]/, + /[/\\]apps[/\\]api[/\\]dist[/\\]/, + /[/\\]\.turbo[/\\]/, +]; +const existing = config.resolver.blockList; +config.resolver.blockList = [...(Array.isArray(existing) ? existing : existing ? [existing] : []), ...buildOutput]; + +module.exports = config; diff --git a/apps/mobile/modules/now-playing-reader/android/build.gradle b/apps/mobile/modules/now-playing-reader/android/build.gradle new file mode 100644 index 0000000..c1596d9 --- /dev/null +++ b/apps/mobile/modules/now-playing-reader/android/build.gradle @@ -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" +} diff --git a/apps/mobile/modules/now-playing-reader/android/src/main/AndroidManifest.xml b/apps/mobile/modules/now-playing-reader/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8001090 --- /dev/null +++ b/apps/mobile/modules/now-playing-reader/android/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + diff --git a/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/MediaListenerService.kt b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/MediaListenerService.kt new file mode 100644 index 0000000..11cbde0 --- /dev/null +++ b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/MediaListenerService.kt @@ -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() + 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) { + 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 + } +} diff --git a/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/NowPlayingBus.kt b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/NowPlayingBus.kt new file mode 100644 index 0000000..2ad2d6b --- /dev/null +++ b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/NowPlayingBus.kt @@ -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() + + 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) } + } +} diff --git a/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/NowPlayingReaderModule.kt b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/NowPlayingReaderModule.kt new file mode 100644 index 0000000..0d74507 --- /dev/null +++ b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/NowPlayingReaderModule.kt @@ -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 -> + 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() + } + } +} diff --git a/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Playback.kt b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Playback.kt new file mode 100644 index 0000000..191a0b0 --- /dev/null +++ b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Playback.kt @@ -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 = 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) + } +} diff --git a/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Prefs.kt b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Prefs.kt new file mode 100644 index 0000000..6a0cabd --- /dev/null +++ b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Prefs.kt @@ -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 + 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() +} diff --git a/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Reporter.kt b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Reporter.kt new file mode 100644 index 0000000..a10a813 --- /dev/null +++ b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Reporter.kt @@ -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() + .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" + } +} diff --git a/apps/mobile/modules/now-playing-reader/android/src/main/res/values/strings.xml b/apps/mobile/modules/now-playing-reader/android/src/main/res/values/strings.xml new file mode 100644 index 0000000..b751cec --- /dev/null +++ b/apps/mobile/modules/now-playing-reader/android/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + musicshare: share what you\'re playing + diff --git a/apps/mobile/modules/now-playing-reader/expo-module.config.json b/apps/mobile/modules/now-playing-reader/expo-module.config.json new file mode 100644 index 0000000..af9f764 --- /dev/null +++ b/apps/mobile/modules/now-playing-reader/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["tech.chrisedwards.musicshare.nowplaying.NowPlayingReaderModule"] + } +} diff --git a/apps/mobile/modules/now-playing-reader/index.ts b/apps/mobile/modules/now-playing-reader/index.ts new file mode 100644 index 0000000..ea036a2 --- /dev/null +++ b/apps/mobile/modules/now-playing-reader/index.ts @@ -0,0 +1,3 @@ +// Android-only native module; the default export is null where it is unavailable. +export { default } from './src/NowPlayingReaderModule'; +export * from './src/NowPlayingReader.types'; diff --git a/apps/mobile/modules/now-playing-reader/src/NowPlayingReader.types.ts b/apps/mobile/modules/now-playing-reader/src/NowPlayingReader.types.ts new file mode 100644 index 0000000..f3d8f60 --- /dev/null +++ b/apps/mobile/modules/now-playing-reader/src/NowPlayingReader.types.ts @@ -0,0 +1,14 @@ +export type NativePlayback = { + app: string; + appName: string | null; + state: 'playing' | 'paused' | 'stopped'; + title: string | null; + artist: string | null; + album: string | null; + durationMs: number | null; +}; + +export type NowPlayingReaderModuleEvents = { + onPlaybackChange: (event: { playback: NativePlayback | null }) => void; + onStatusChange: (event: { status: 'unauthorized' }) => void; +}; diff --git a/apps/mobile/modules/now-playing-reader/src/NowPlayingReaderModule.ts b/apps/mobile/modules/now-playing-reader/src/NowPlayingReaderModule.ts new file mode 100644 index 0000000..c8961a0 --- /dev/null +++ b/apps/mobile/modules/now-playing-reader/src/NowPlayingReaderModule.ts @@ -0,0 +1,19 @@ +import { NativeModule, requireOptionalNativeModule } from 'expo'; + +import type { NativePlayback, NowPlayingReaderModuleEvents } from './NowPlayingReader.types'; + +declare class NowPlayingReaderModule extends NativeModule { + isPermissionGranted(): boolean; + openPermissionSettings(): void; + configure(apiUrl: string, deviceToken: string, allowedPackages: string[]): void; + clear(): void; + resend(): void; + needsReregistration(): boolean; + getCurrent(): NativePlayback | null; +} + +/** + * Android only. null on iOS, web, and in Expo Go (no custom native code there), + * so callers must feature-check. + */ +export default requireOptionalNativeModule('NowPlayingReader'); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index e815d6c..37b4fce 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -40,8 +40,8 @@ }, "scripts": { "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "typecheck": "tsc --noEmit", "lint": "expo lint" }, diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index 89cea62..a58f58f 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -6,7 +6,10 @@ export default function TabsLayout() { Feed diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index 77f3dc6..f58b26d 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -1,13 +1,15 @@ import { router } from 'expo-router'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import useSWR from 'swr'; import { sortFeed, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared'; import { NowPlayingCard } from '@/components/NowPlayingCard'; +import { SharingControls } from '@/components/SharingControls'; import { Body, Button, Card, Title } from '@/components/ui'; import { api } from '@/lib/api'; import { showError } from '@/lib/errors'; +import { shouldOfferSetup, useReader } from '@/lib/nowPlayingReader'; import { useRealtime } from '@/lib/useRealtime'; import { useSession } from '@/lib/session'; import { colors, space } from '@/lib/theme'; @@ -19,6 +21,16 @@ export default function FeedScreen() { const { data: mine, mutate: mutateMine } = useSWR(me ? `/users/${me.username}` : null); const [refreshing, setRefreshing] = useState(false); + const reader = useReader(); + + // First run on Android: open the one-tap setup once (deferred so navigation has mounted). + useEffect(() => { + const timer = setTimeout(() => { + shouldOfferSetup().then((offer) => offer && router.push('/share-setup')); + }, 400); + return () => clearTimeout(timer); + }, []); + useRealtime(true, (msg) => { if (msg.type !== 'now_playing') return; if (msg.data.user.id === me?.id) { @@ -61,6 +73,13 @@ export default function FeedScreen() { refreshControl={} ListHeaderComponent={ + + {reader.available && !reader.granted && ( + + Share what you’re playing in Spotify, YouTube Music and other apps. One tap, no accounts. +