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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
+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>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["android"],
|
||||
"android": {
|
||||
"modules": ["tech.chrisedwards.musicshare.nowplaying.NowPlayingReaderModule"]
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NativeModule, requireOptionalNativeModule } from 'expo';
|
||||
|
||||
import type { NativePlayback, NowPlayingReaderModuleEvents } from './NowPlayingReader.types';
|
||||
|
||||
declare class NowPlayingReaderModule extends NativeModule<NowPlayingReaderModuleEvents> {
|
||||
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<NowPlayingReaderModule>('NowPlayingReader');
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -6,7 +6,10 @@ export default function TabsLayout() {
|
||||
<NativeTabs
|
||||
backgroundColor={colors.bg}
|
||||
indicatorColor={colors.surface2}
|
||||
labelStyle={{ selected: { color: colors.text } }}
|
||||
// Android defaults icons and labels to black, which vanishes on the dark bar.
|
||||
iconColor={{ default: colors.muted, selected: colors.text }}
|
||||
labelStyle={{ default: { color: colors.muted }, selected: { color: colors.text } }}
|
||||
labelVisibilityMode="labeled"
|
||||
tintColor={colors.accent}>
|
||||
<NativeTabs.Trigger name="index">
|
||||
<NativeTabs.Trigger.Label>Feed</NativeTabs.Trigger.Label>
|
||||
|
||||
@@ -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<ProfileDTO>(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={<RefreshControl refreshing={refreshing} onRefresh={refresh} tintColor={colors.muted} />}
|
||||
ListHeaderComponent={
|
||||
<View style={styles.header}>
|
||||
<SharingControls compact />
|
||||
{reader.available && !reader.granted && (
|
||||
<Card>
|
||||
<Body>Share what you’re playing in Spotify, YouTube Music and other apps. One tap, no accounts.</Body>
|
||||
<Button small variant="primary" title="Turn on" onPress={() => router.push('/share-setup')} style={{ alignSelf: 'flex-start' }} />
|
||||
</Card>
|
||||
)}
|
||||
{mine?.nowPlaying && (
|
||||
<View style={{ gap: space.sm }}>
|
||||
<Text style={styles.sectionLabel}>YOU</Text>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as Linking from 'expo-linking';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { Image } from 'expo-image';
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import useSWR from 'swr';
|
||||
@@ -13,9 +14,11 @@ import {
|
||||
type TrackSearchResultDTO,
|
||||
} from '@musicshare/shared';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { SharingControls } from '@/components/SharingControls';
|
||||
import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
|
||||
import { useDebounced } from '@/lib/useDebounced';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
@@ -25,7 +28,9 @@ export default function SettingsScreen() {
|
||||
<Screen>
|
||||
<Title>Settings</Title>
|
||||
<ProfileSection />
|
||||
<SharingControls />
|
||||
<PreferredService />
|
||||
<ThisPhone />
|
||||
<Connections />
|
||||
<FeaturedPicker />
|
||||
<Account />
|
||||
@@ -80,6 +85,35 @@ function PreferredService() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Android: share from any music app on this phone (no-op elsewhere). */
|
||||
function ThisPhone() {
|
||||
const { available, granted, playback } = useReader();
|
||||
if (!available) return null;
|
||||
return (
|
||||
<Card>
|
||||
<Heading>This phone</Heading>
|
||||
{granted ? (
|
||||
<Body muted style={styles.small}>
|
||||
{playback?.title
|
||||
? `Sharing ${playback.title}${playback.artist ? ` by ${playback.artist}` : ''} from ${playback.appName ?? 'a music app'}.`
|
||||
: 'On. Anything you play in a music app is shared.'}
|
||||
</Body>
|
||||
) : (
|
||||
<Body muted style={styles.small}>
|
||||
Share what you play in Spotify, YouTube Music, Apple Music and other music apps. No accounts needed.
|
||||
</Body>
|
||||
)}
|
||||
<Button
|
||||
small
|
||||
variant={granted ? 'secondary' : 'primary'}
|
||||
title={granted ? 'Turn off in Android settings' : 'Turn on'}
|
||||
onPress={granted ? openReaderSettings : () => router.push('/share-setup')}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Connections() {
|
||||
const { data: connections = [], mutate } = useSWR<ConnectionDTO[]>('/connections');
|
||||
const byProvider = new Map(connections.map((c) => [c.provider, c]));
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AppState, StyleSheet, View } from 'react-native';
|
||||
import { SWRConfig } from 'swr';
|
||||
import { Body, Button, Title } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { syncDevice } from '@/lib/nowPlayingReader';
|
||||
import { SessionProvider, useSession } from '@/lib/session';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
|
||||
@@ -35,6 +36,11 @@ function AppNavigator() {
|
||||
if (status !== 'loading') SplashScreen.hideAsync();
|
||||
}, [status]);
|
||||
|
||||
// Keep the Android listener pointed at this account (no-op elsewhere).
|
||||
useEffect(() => {
|
||||
if (signedIn) syncDevice().catch(() => {});
|
||||
}, [signedIn]);
|
||||
|
||||
if (status === 'loading') return null;
|
||||
if (status === 'offline') {
|
||||
return (
|
||||
@@ -69,6 +75,7 @@ function AppNavigator() {
|
||||
<Stack.Protected guard={signedIn}>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="u/[username]" options={{ title: '' }} />
|
||||
<Stack.Screen name="share-setup" options={{ headerShown: false, presentation: 'modal' }} />
|
||||
</Stack.Protected>
|
||||
<Stack.Protected guard={!signedIn}>
|
||||
<Stack.Screen name="login" options={{ headerShown: false }} />
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { Body, Button, Card, Title } from '@/components/ui';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { markSetupSeen, openReaderSettings, syncDevice, useReader } from '@/lib/nowPlayingReader';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
|
||||
/**
|
||||
* One-time onboarding on Android. This doubles as the prominent disclosure Google
|
||||
* Play requires before sending users to the notification-access screen.
|
||||
*/
|
||||
export default function ShareSetupScreen() {
|
||||
const { granted, playback } = useReader();
|
||||
const [synced, setSynced] = useState(false);
|
||||
const finishing = granted && !synced;
|
||||
|
||||
useEffect(() => {
|
||||
markSetupSeen();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!granted) return;
|
||||
syncDevice()
|
||||
.catch((err) => showError(err, "Couldn't finish setup"))
|
||||
.finally(() => setSynced(true));
|
||||
}, [granted]);
|
||||
|
||||
const close = () => (router.canGoBack() ? router.back() : router.replace('/'));
|
||||
|
||||
return (
|
||||
<Screen edges={['top', 'bottom']} contentContainerStyle={styles.content}>
|
||||
<View style={{ gap: space.md }}>
|
||||
<Title>{granted ? "You're all set" : 'Share what you’re playing'}</Title>
|
||||
{granted ? (
|
||||
<Body muted>
|
||||
{playback?.title
|
||||
? `Friends can see you're listening to ${playback.title}${playback.artist ? ` by ${playback.artist}` : ''}.`
|
||||
: 'Play something in Spotify, YouTube Music, Apple Music or any music app and your friends will see it.'}
|
||||
</Body>
|
||||
) : (
|
||||
<Body muted>
|
||||
Works with Spotify, YouTube Music, Apple Music, TIDAL, Deezer, SoundCloud and more. No accounts to link.
|
||||
</Body>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{!granted && (
|
||||
<Card>
|
||||
<Body style={styles.point}>• We read the song, artist and album from music apps, and nothing else.</Body>
|
||||
<Body style={styles.point}>• We never see your notifications, messages or other apps.</Body>
|
||||
<Body style={styles.point}>• Android will call this “notification access”. That’s the only way it lets apps see what’s playing.</Body>
|
||||
<Body style={styles.point}>• Turn it off any time in Settings.</Body>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<View style={{ gap: space.sm }}>
|
||||
{granted ? (
|
||||
<Button variant="primary" title="Done" loading={finishing} onPress={close} />
|
||||
) : (
|
||||
<>
|
||||
<Button variant="primary" title="Turn on" onPress={openReaderSettings} />
|
||||
<Button title="Not now" onPress={close} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: { flexGrow: 1, justifyContent: 'center', gap: space.xl },
|
||||
point: { color: colors.text, fontSize: 14 },
|
||||
});
|
||||
@@ -34,7 +34,7 @@ export function NowPlayingCard({
|
||||
{np.isPlaying && <View style={styles.dot} />}
|
||||
<Text style={[styles.status, np.isPlaying && { color: colors.live }]} numberOfLines={1}>
|
||||
{np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`}
|
||||
<Text style={styles.via}> · {SERVICE_LABELS[np.source]}</Text>
|
||||
<Text style={styles.via}> · {np.sourceApp ?? SERVICE_LABELS[np.source]}</Text>
|
||||
</Text>
|
||||
{onTogglePin && (
|
||||
<Pressable
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from 'react';
|
||||
import { pausedUntilLabel } from '@musicshare/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { resendCurrentPlayback } from '@/lib/nowPlayingReader';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Body, Button, Card, Heading, Row } from './ui';
|
||||
|
||||
/** Pause/resume sharing. Compact mode is the banner at the top of the feed. */
|
||||
export function SharingControls({ compact }: { compact?: boolean }) {
|
||||
const { me, setMe } = useSession();
|
||||
const [busy, setBusy] = useState(false);
|
||||
if (!me) return null;
|
||||
|
||||
const set = async (pause: '1h' | '8h' | 'indefinitely' | null) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setMe(await api.setSharing(pause));
|
||||
if (pause === null) resendCurrentPlayback();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
if (!me.sharingPausedUntil) return null;
|
||||
return (
|
||||
<Card>
|
||||
<Body>Sharing is paused {pausedUntilLabel(me.sharingPausedUntil)}.</Body>
|
||||
<Button small variant="primary" title="Resume" loading={busy} onPress={() => set(null)} style={{ alignSelf: 'flex-start' }} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Heading>Sharing</Heading>
|
||||
{me.sharingPausedUntil ? (
|
||||
<>
|
||||
<Body muted style={{ fontSize: 13 }}>
|
||||
Paused {pausedUntilLabel(me.sharingPausedUntil)}. Friends don’t see what you play, and nothing is added to your history.
|
||||
</Body>
|
||||
<Button small variant="primary" title="Resume sharing" loading={busy} onPress={() => set(null)} style={{ alignSelf: 'flex-start' }} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Body muted style={{ fontSize: 13 }}>
|
||||
Friends see what you’re playing. Pause any time.
|
||||
</Body>
|
||||
<Row>
|
||||
<Button small title="1 hour" disabled={busy} onPress={() => set('1h')} />
|
||||
<Button small title="Until tomorrow" disabled={busy} onPress={() => set('8h')} />
|
||||
<Button small title="Until I turn it back on" disabled={busy} onPress={() => set('indefinitely')} />
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import * as Device from 'expo-device';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AppState } from 'react-native';
|
||||
import { ANDROID_MUSIC_APPS } from '@musicshare/shared';
|
||||
import NowPlayingReader, { type NativePlayback } from '@/modules/now-playing-reader';
|
||||
import { api } from './api';
|
||||
import { API_URL } from './config';
|
||||
|
||||
const DEVICE_KEY = 'musicshare.device';
|
||||
const SETUP_SEEN_KEY = 'musicshare.shareSetupSeen';
|
||||
|
||||
/** True on Android builds that include the native module (not iOS, web, or Expo Go). */
|
||||
export const readerAvailable = NowPlayingReader !== null;
|
||||
|
||||
/** Dev-only escape hatch for testing with apps outside the music allowlist, e.g. "com.android.chrome". */
|
||||
const extraApps = String(process.env.EXPO_PUBLIC_EXTRA_MEDIA_APPS ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const allowedApps = [...Object.keys(ANDROID_MUSIC_APPS), ...extraApps];
|
||||
|
||||
type StoredDevice = { id: string; token: string };
|
||||
|
||||
async function storedDevice(): Promise<StoredDevice | null> {
|
||||
const raw = await SecureStore.getItemAsync(DEVICE_KEY);
|
||||
return raw ? (JSON.parse(raw) as StoredDevice) : null;
|
||||
}
|
||||
|
||||
let syncing: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Make sure this phone is registered and the native listener knows where to
|
||||
* report. Cheap to call repeatedly (app start, returning from settings).
|
||||
* Single-flight: several screens react to the same permission change, and
|
||||
* concurrent calls would each register a device and race to store a token.
|
||||
*/
|
||||
export function syncDevice(): Promise<void> {
|
||||
syncing ??= doSyncDevice().finally(() => {
|
||||
syncing = null;
|
||||
});
|
||||
return syncing;
|
||||
}
|
||||
|
||||
async function doSyncDevice(): Promise<void> {
|
||||
if (!NowPlayingReader || !NowPlayingReader.isPermissionGranted()) return;
|
||||
let device = await storedDevice();
|
||||
if (!device || NowPlayingReader.needsReregistration()) {
|
||||
const res = await api.registerDevice({ platform: 'android', name: Device.modelName ?? 'Android phone' });
|
||||
device = { id: res.device.id, token: res.token };
|
||||
await SecureStore.setItemAsync(DEVICE_KEY, JSON.stringify(device));
|
||||
}
|
||||
NowPlayingReader.configure(API_URL, device.token, allowedApps);
|
||||
}
|
||||
|
||||
/** Sign-out: stop reporting and revoke this phone's token (while the session is still valid). */
|
||||
export async function unregisterDevice(): Promise<void> {
|
||||
NowPlayingReader?.clear();
|
||||
const device = await storedDevice();
|
||||
if (device) await api.removeDevice(device.id).catch(() => {});
|
||||
await SecureStore.deleteItemAsync(DEVICE_KEY);
|
||||
}
|
||||
|
||||
export const openReaderSettings = () => NowPlayingReader?.openPermissionSettings();
|
||||
|
||||
/** After resuming sharing, re-send what's playing so friends see it immediately. */
|
||||
export const resendCurrentPlayback = () => NowPlayingReader?.resend();
|
||||
|
||||
export async function shouldOfferSetup(): Promise<boolean> {
|
||||
if (!NowPlayingReader || NowPlayingReader.isPermissionGranted()) return false;
|
||||
return (await SecureStore.getItemAsync(SETUP_SEEN_KEY)) === null;
|
||||
}
|
||||
export const markSetupSeen = () => SecureStore.setItemAsync(SETUP_SEEN_KEY, '1');
|
||||
|
||||
/** Live permission state and what the listener currently sees. Re-checks when the app returns to the foreground. */
|
||||
export function useReader() {
|
||||
const [granted, setGranted] = useState(() => NowPlayingReader?.isPermissionGranted() ?? false);
|
||||
const [playback, setPlayback] = useState<NativePlayback | null>(() => NowPlayingReader?.getCurrent() ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!NowPlayingReader) return;
|
||||
const reader = NowPlayingReader;
|
||||
const appState = AppState.addEventListener('change', (state) => {
|
||||
if (state !== 'active') return;
|
||||
const now = reader.isPermissionGranted();
|
||||
setGranted(now);
|
||||
if (now) syncDevice().catch(() => {});
|
||||
});
|
||||
const playbackSub = reader.addListener('onPlaybackChange', (e) => setPlayback(e.playback));
|
||||
const statusSub = reader.addListener('onStatusChange', () => syncDevice().catch(() => {}));
|
||||
return () => {
|
||||
appState.remove();
|
||||
playbackSub.remove();
|
||||
statusSub.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { available: readerAvailable, granted, playback };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import * as SecureStore from 'expo-secure-store';
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { ApiError, type AuthResponse, type MeDTO } from '@musicshare/shared';
|
||||
import { api, setApiToken } from './api';
|
||||
import { unregisterDevice } from './nowPlayingReader';
|
||||
|
||||
const TOKEN_KEY = 'musicshare.session';
|
||||
|
||||
@@ -59,6 +60,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await unregisterDevice().catch(() => {});
|
||||
await api.logout().catch(() => {});
|
||||
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
||||
setApiToken(null);
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
],
|
||||
"@/assets/*": [
|
||||
"./assets/*"
|
||||
],
|
||||
"@/modules/*": [
|
||||
"./modules/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts"
|
||||
"**/*.tsx"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user