diff --git a/.gitignore b/.gitignore index 628376a0..84462928 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,9 @@ build/ # Local /Video + +# Local clean-room reference (never committed) +/.sidespot/ + +# Rust build output +native/**/target/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0ddff4aa..50b76285 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -37,6 +37,7 @@ fun versionCodeFromGit() = runCatching { gitVersionCode.get() }.getOrDefault(fal fun versionNameFromGit() = runCatching { gitVersionName.get() }.getOrDefault(fallbackVersionName) android { + ndkVersion = "27.1.12297006" namespace = "hrln.interactive.sidekit" compileSdk { version = release(37) @@ -93,6 +94,7 @@ android { targetCompatibility = JavaVersion.VERSION_11 } packaging { resources.merges += setOf("META-INF/NOTICE.md", "META-INF/LICENSE.md", "META-INF/LICENSE.txt", "META-INF/NOTICE.txt") } + sourceSets["main"].jniLibs.directories.add(layout.buildDirectory.dir("generated/spotifyJniLibs").get().asFile.absolutePath) buildFeatures { compose = true } @@ -107,6 +109,52 @@ android { } } +// Sideplay' librespot bridge. The generated ABI directory is a normal jniLibs source, so an +// unsupported ABI simply has no .so and the Kotlin bridge reports it as unavailable. +val spotifyJniLibs = layout.buildDirectory.dir("generated/spotifyJniLibs") +val cargo = listOf( + File(System.getProperty("user.home"), ".cargo/bin/cargo"), + File("/usr/bin/cargo"), + File("/usr/local/bin/cargo") +).firstOrNull { it.canExecute() } + +// Resolve the NDK only when Cargo is present. CI has no Rust toolchain, and asking Gradle for +// the NDK directory eagerly would fail configuration even though the task is disabled. +val ndk = if (cargo == null) { + null +} else { + runCatching { androidComponents.sdkComponents.ndkDirectory.get().asFile }.getOrNull() +} + +val buildSpotifyNative = tasks.register("buildSpotifyNative") { + description = "Build libsidekit_spotify.so for arm64-v8a and x86_64." + group = "native" + enabled = cargo != null && ndk != null + when { + cargo == null -> logger.warn("Sideplay: cargo is unavailable; libsidekit_spotify.so will not be packaged.") + ndk == null -> logger.warn("Sideplay: the Android NDK is unavailable; libsidekit_spotify.so will not be packaged.") + } + workingDir = rootProject.file("native/sidekit-spotify") + commandLine( + cargo?.absolutePath.orEmpty(), + "ndk", + "-t", "arm64-v8a", + "-t", "x86_64", + "-o", spotifyJniLibs.get().asFile.absolutePath, + "build", "--release" + ) + if (ndk != null) environment("ANDROID_NDK_HOME", ndk.absolutePath) + inputs.dir(rootProject.file("native/sidekit-spotify/src")) + inputs.files(rootProject.file("native/sidekit-spotify/Cargo.toml"), rootProject.file("native/sidekit-spotify/Cargo.lock")) + outputs.dir(spotifyJniLibs) +} + +tasks.matching { + it.name.startsWith("merge") && (it.name.endsWith("NativeLibs") || it.name.endsWith("JniLibFolders")) +}.configureEach { + dependsOn(buildSpotifyNative) +} + dependencies { implementation(project(":sidekit")) implementation("com.sun.mail:android-mail:1.6.8") diff --git a/app/screenshots/sideplay_album.png b/app/screenshots/sideplay_album.png new file mode 100644 index 00000000..fd44f15b Binary files /dev/null and b/app/screenshots/sideplay_album.png differ diff --git a/app/screenshots/sideplay_artist.png b/app/screenshots/sideplay_artist.png new file mode 100644 index 00000000..5d7f5c94 Binary files /dev/null and b/app/screenshots/sideplay_artist.png differ diff --git a/app/screenshots/sideplay_home.png b/app/screenshots/sideplay_home.png new file mode 100644 index 00000000..f67de657 Binary files /dev/null and b/app/screenshots/sideplay_home.png differ diff --git a/app/screenshots/sideplay_library_albums.png b/app/screenshots/sideplay_library_albums.png new file mode 100644 index 00000000..36588465 Binary files /dev/null and b/app/screenshots/sideplay_library_albums.png differ diff --git a/app/screenshots/sideplay_now_playing.png b/app/screenshots/sideplay_now_playing.png new file mode 100644 index 00000000..e332e193 Binary files /dev/null and b/app/screenshots/sideplay_now_playing.png differ diff --git a/app/screenshots/sideplay_queue.png b/app/screenshots/sideplay_queue.png new file mode 100644 index 00000000..bddfb4d7 Binary files /dev/null and b/app/screenshots/sideplay_queue.png differ diff --git a/app/screenshots/sideplay_search.png b/app/screenshots/sideplay_search.png new file mode 100644 index 00000000..43f58192 Binary files /dev/null and b/app/screenshots/sideplay_search.png differ diff --git a/app/screenshots/sideplay_settings.png b/app/screenshots/sideplay_settings.png new file mode 100644 index 00000000..27d4b0e4 Binary files /dev/null and b/app/screenshots/sideplay_settings.png differ diff --git a/app/screenshots/sideplay_sign_in.png b/app/screenshots/sideplay_sign_in.png new file mode 100644 index 00000000..6a665364 Binary files /dev/null and b/app/screenshots/sideplay_sign_in.png differ diff --git a/app/src/androidTest/java/hrln/interactive/sidekit/apps/spotify/SpotifyPlaybackInstrumentedTest.kt b/app/src/androidTest/java/hrln/interactive/sidekit/apps/spotify/SpotifyPlaybackInstrumentedTest.kt new file mode 100644 index 00000000..8443b4b3 --- /dev/null +++ b/app/src/androidTest/java/hrln/interactive/sidekit/apps/spotify/SpotifyPlaybackInstrumentedTest.kt @@ -0,0 +1,152 @@ +package hrln.interactive.sidekit.apps.spotify + +import android.content.ComponentName +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.media.MediaMetadata +import android.media.session.MediaController +import android.media.session.PlaybackState +import android.os.SystemClock +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import hrln.interactive.sidekit.apps.media.MediaSessionOwner +import hrln.interactive.sidekit.apps.media.MediaSessionRegistry +import hrln.interactive.sidekit.apps.spotify.data.SpotifyGateway +import hrln.interactive.sidekit.apps.spotify.playback.SpotifyPlayback +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SpotifyPlaybackInstrumentedTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val context: Context = ApplicationProvider.getApplicationContext() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + /** Keeps playback tests from starting the real foreground service. */ + private val silentContext = object : ContextWrapper(context) { + override fun startForegroundService(service: Intent): ComponentName? = null + } + + @Test + fun mediaSessionPublishesMetadataAndPauseState() { + val gateway = FakeGateway() + val playback = SpotifyPlayback(gateway, scope, MediaSessionRegistry(), context = silentContext) + onMain { + playback.setNowPlaying(track("Session track"), 12_000L) + playback.updateState(true, 12_000L) + } + + val controller = MediaController(silentContext, requireNotNull(playback.sessionToken)) + + waitUntil { controller.playbackState?.state == PlaybackState.STATE_PLAYING } + assertEquals("Session track", controller.metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)) + assertTrue((controller.playbackState?.position ?: -1L) >= 12_000L) + + onMain { playback.pause() } + + waitUntil { controller.playbackState?.state == PlaybackState.STATE_PAUSED } + assertFalse(playback.isActive) + assertTrue(gateway.paused) + onMain { playback.setNowPlaying(null) } + } + + @Test + fun activatingAnotherMediaOwnerPausesSideplay() { + val gateway = FakeGateway() + val registry = MediaSessionRegistry() + val playback = SpotifyPlayback(gateway, scope, registry, context = silentContext) + val music = FakeOwner("music") + registry.register(music) + onMain { + playback.register() + playback.setNowPlaying(track("Handoff"), 0L) + playback.updateState(true, 0L) + } + assertTrue(playback.isActive) + + onMain { + music.isActive = true + registry.activate(music) + } + + waitUntil { !playback.isActive } + assertSame(music, registry.active) + assertTrue(gateway.paused) + onMain { playback.setNowPlaying(null) } + } + + @Test + fun headphoneUnplugPausesPlayback() { + val gateway = FakeGateway() + val playback = SpotifyPlayback(gateway, scope, MediaSessionRegistry(), context = silentContext) + onMain { + playback.setNowPlaying(track("Unplugged"), 0L) + playback.updateState(true, 0L) + } + assertTrue(playback.isActive) + + // The platform protects ACTION_AUDIO_BECOMING_NOISY, so invoke the receiver's callback. + onMain { playback.onAudioBecomingNoisy() } + + waitUntil { !playback.isActive } + assertTrue(gateway.paused) + onMain { playback.setNowPlaying(null) } + } + + private fun onMain(block: () -> Unit) = instrumentation.runOnMainSync(block) + + private fun waitUntil(condition: () -> Boolean) { + val deadline = SystemClock.elapsedRealtime() + 5_000L + while (!condition()) { + if (SystemClock.elapsedRealtime() > deadline) throw AssertionError("Timed out") + Thread.sleep(20L) + } + } + + private fun track(title: String) = SpotifyTrack( + id = "track-${title.hashCode()}", + title = title, + artist = "Artist", + artistId = "artist", + albumId = "album", + artRes = 0, + durationMs = 180_000L, + uri = "spotify:track:test" + ) + + private class FakeGateway : SpotifyGateway { + @Volatile var paused = false + + override suspend fun connect(accessToken: String) = Unit + override suspend fun disconnect() = Unit + override suspend fun load(uri: String, positionMs: Long, autoplay: Boolean) = Unit + override suspend fun play() = Unit + override suspend fun pause() { + paused = true + } + + override suspend fun stop() = Unit + override suspend fun seek(positionMs: Long) = Unit + } + + private class FakeOwner(override val sessionId: String) : MediaSessionOwner { + override val notificationChannelId = "channel-$sessionId" + override val notificationChannelName = "Channel $sessionId" + override val notificationId = sessionId.hashCode() + override var isActive = false + + override fun pause() { + isActive = false + } + } +} diff --git a/app/src/androidTest/java/hrln/interactive/sidekit/apps/spotify/SpotifyPlaybackServiceInstrumentedTest.kt b/app/src/androidTest/java/hrln/interactive/sidekit/apps/spotify/SpotifyPlaybackServiceInstrumentedTest.kt new file mode 100644 index 00000000..c8848ac8 --- /dev/null +++ b/app/src/androidTest/java/hrln/interactive/sidekit/apps/spotify/SpotifyPlaybackServiceInstrumentedTest.kt @@ -0,0 +1,96 @@ +package hrln.interactive.sidekit.apps.spotify + +import android.Manifest +import android.app.Notification +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.SystemClock +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import hrln.interactive.sidekit.MainActivity +import hrln.interactive.sidekit.apps.spotify.data.SpotifySessions +import hrln.interactive.sidekit.apps.spotify.playback.SpotifyPlaybackService +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SpotifyPlaybackServiceInstrumentedTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val context: Context = ApplicationProvider.getApplicationContext() + private val session get() = SpotifySessions.get(context) + private val manager get() = context.getSystemService(NotificationManager::class.java) + private val notificationId get() = session.playback.notificationId + + @Before + fun grantNotifications() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + instrumentation.uiAutomation.grantRuntimePermission( + context.packageName, + Manifest.permission.POST_NOTIFICATIONS + ) + } + } + + @After + fun cleanUp() { + onMain { + session.playback.updateState(false, 0L) + session.playback.setNowPlaying(null) + } + context.stopService(Intent(context, SpotifyPlaybackService::class.java)) + manager.cancel(notificationId) + } + + @Test + fun playingTrackStartsTheForegroundServiceAndPublishesTransportActions() { + ActivityScenario.launch(MainActivity::class.java).use { + onMain { + session.playback.setNowPlaying(track) + session.playback.updateState(true, 0L) + } + + val notification = waitForNotification() + assertEquals("sideplay", notification.channelId) + assertEquals("Service track", notification.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()) + assertEquals("Artist", notification.extras.getCharSequence(Notification.EXTRA_TEXT)?.toString()) + assertEquals(listOf("Previous", "Pause", "Next"), notification.actions.map { it.title.toString() }) + assertNotNull(notification.contentIntent) + assertTrue(notification.flags and Notification.FLAG_ONGOING_EVENT != 0) + assertTrue(manager.getNotificationChannel("sideplay") != null) + } + } + + private fun waitForNotification(): Notification { + val deadline = SystemClock.elapsedRealtime() + 5_000L + while (true) { + manager.activeNotifications.firstOrNull { it.id == notificationId }?.let { return it.notification } + if (SystemClock.elapsedRealtime() > deadline) throw AssertionError("Notification was not posted") + Thread.sleep(20L) + } + } + + private fun onMain(block: () -> Unit) = instrumentation.runOnMainSync(block) + + private val track = SpotifyTrack( + id = "service-track", + title = "Service track", + artist = "Artist", + artistId = "artist", + albumId = "album", + artRes = 0, + durationMs = 180_000L, + uri = "spotify:track:service" + ) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 53fd9dfb..5fcf9b45 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -228,6 +228,11 @@ android:name=".apps.music.MusicService" android:exported="false" android:foregroundServiceType="mediaPlayback" /> + + diff --git a/app/src/main/java/hrln/interactive/sidekit/MainActivity.kt b/app/src/main/java/hrln/interactive/sidekit/MainActivity.kt index b4bda5c8..a3226df2 100644 --- a/app/src/main/java/hrln/interactive/sidekit/MainActivity.kt +++ b/app/src/main/java/hrln/interactive/sidekit/MainActivity.kt @@ -18,6 +18,7 @@ import hrln.interactive.sidekit.apps.settings.rememberCaseColour import hrln.interactive.sidekit.apps.settings.rememberCompactLayout import hrln.interactive.sidekit.apps.sidemessage.IncomingLinks import hrln.interactive.sidekit.apps.sideport.SideportLinks +import hrln.interactive.sidekit.apps.spotify.SideplayLinks import hrln.interactive.sidekit.design.action.ChromeState import hrln.interactive.sidekit.design.canvas.SidekitCanvas import hrln.interactive.sidekit.design.input.Keypad @@ -40,8 +41,10 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Opened from an sms: link or a message notification: go to that conversation. - if (savedInstanceState == null && !SideportLinks.handle(intent)) IncomingLinks.handle(intent) + // Opened from an sms: link, a message notification, or the playback notification. + if (savedInstanceState == null && !SideportLinks.handle(intent) && !IncomingLinks.handle(intent)) { + SideplayLinks.handle(intent) + } enableEdgeToEdge() // SideKit uses the whole display; the launcher header carries the clock. WindowCompat.getInsetsController(window, window.decorView).apply { @@ -110,8 +113,8 @@ class MainActivity : ComponentActivity() { // singleTask: pressing Home while an app is open delivers the HOME intent here. if (intent.hasCategory(Intent.CATEGORY_HOME)) { homeRequests.intValue++ - } else if (!SideportLinks.handle(intent)) { - IncomingLinks.handle(intent) + } else if (!SideportLinks.handle(intent) && !IncomingLinks.handle(intent)) { + SideplayLinks.handle(intent) } } } diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/banking/BankingRepository.kt b/app/src/main/java/hrln/interactive/sidekit/apps/banking/BankingRepository.kt index aa4a786f..ef4b65b2 100644 --- a/app/src/main/java/hrln/interactive/sidekit/apps/banking/BankingRepository.kt +++ b/app/src/main/java/hrln/interactive/sidekit/apps/banking/BankingRepository.kt @@ -1,9 +1,15 @@ package hrln.interactive.sidekit.apps.banking import android.content.Context -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties import androidx.core.util.AtomicFile +import hrln.interactive.sidekit.apps.integrations.KeystoreCipher +import hrln.interactive.sidekit.apps.integrations.SecretCipher +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import java.security.GeneralSecurityException +import java.security.KeyStore +import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -14,16 +20,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.io.File -import java.io.FileNotFoundException -import java.io.IOException -import java.security.GeneralSecurityException -import java.security.KeyStore -import java.util.concurrent.atomic.AtomicReference -import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec /** Connector metadata for the editable manual book. Up bank snapshots use [UpBankConnector] * separately so imported balances cannot be changed by local ledger entries. */ @@ -43,55 +39,8 @@ object ManualTracker : BankConnector { override suspend fun sync(): BankBook? = null } -/** Encrypts the Banking file. */ -interface BookCipher { - fun encrypt(plain: ByteArray): ByteArray - fun decrypt(sealed: ByteArray): ByteArray -} - -/** - * AES-256-GCM with a key generated inside the Android Keystore: the key never leaves secure - * hardware (or the keystore daemon), so a copied file is unreadable off this phone. Output is - * the 12-byte IV followed by ciphertext and tag. - * - * The key isn't bound to user authentication: Banking asks for the screen lock before it reads - * the file instead (see [BankingGate]); binding the key would make every background save fail - * once the auth window lapsed. - */ -class KeystoreCipher(private val alias: String = "sidekit_banking") : BookCipher { - private fun key(): SecretKey { - val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - (store.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } - val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") - generator.init( - KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .setKeySize(256) - .build(), - ) - return generator.generateKey() - } - - override fun encrypt(plain: ByteArray): ByteArray { - val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.ENCRYPT_MODE, key()) - val iv = cipher.iv - return iv + cipher.doFinal(plain) - } - - override fun decrypt(sealed: ByteArray): ByteArray { - if (sealed.size <= IV_BYTES) throw GeneralSecurityException("Truncated") - val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, sealed, 0, IV_BYTES)) - return cipher.doFinal(sealed, IV_BYTES, sealed.size - IV_BYTES) - } - - private companion object { - const val TRANSFORMATION = "AES/GCM/NoPadding" - const val IV_BYTES = 12 - } -} +/** Banking's view of the shared cipher. */ +typealias BookCipher = SecretCipher /** Where the book lives. Called off the main thread only. */ interface BankStore { @@ -145,7 +94,7 @@ class BankingRepository( scope: CoroutineScope, private val io: CoroutineDispatcher = Dispatchers.IO, private val clock: () -> Long = System::currentTimeMillis, - val connector: BankConnector = ManualTracker, + val connector: BankConnector = ManualTracker ) { sealed interface Load { data object Locked : Load @@ -241,7 +190,7 @@ class BankingRepository( b.copy( accounts = accounts, transactions = b.transactions.filter { it.accountId != id }, - selectedAccountId = if (b.selectedAccountId == id) accounts.firstOrNull()?.id else b.selectedAccountId, + selectedAccountId = if (b.selectedAccountId == id) accounts.firstOrNull()?.id else b.selectedAccountId ) } return removed @@ -302,7 +251,7 @@ object Banking { fun repository(context: Context): BankingRepository = repository ?: synchronized(this) { repository ?: BankingRepository( EncryptedFileBankStore(File(context.applicationContext.filesDir, "banking.sealed"), KeystoreCipher()), - CoroutineScope(SupervisorJob()), + CoroutineScope(SupervisorJob()) ).also { repository = it } } } diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/integrations/PrivateSettings.kt b/app/src/main/java/hrln/interactive/sidekit/apps/integrations/PrivateSettings.kt index c38188a9..ffaee45b 100644 --- a/app/src/main/java/hrln/interactive/sidekit/apps/integrations/PrivateSettings.kt +++ b/app/src/main/java/hrln/interactive/sidekit/apps/integrations/PrivateSettings.kt @@ -2,20 +2,31 @@ package hrln.interactive.sidekit.apps.integrations import android.content.Context import androidx.core.util.AtomicFile -import hrln.interactive.sidekit.apps.banking.KeystoreCipher -import org.json.JSONObject import java.io.File +import org.json.JSONObject /** Credentials stay in app-private storage, encrypted with a non-exportable Android Keystore key. */ class PrivateSettings(context: Context, name: String) { private val file = AtomicFile(File(context.noBackupFilesDir, "$name.sealed")) private val cipher = KeystoreCipher("sidekit_$name") - @Synchronized fun read(): JSONObject = if (!file.baseFile.exists()) JSONObject() else + + @Synchronized fun read(): JSONObject = if (!file.baseFile.exists()) { + JSONObject() + } else { JSONObject(String(cipher.decrypt(file.readFully()), Charsets.UTF_8)) + } + @Synchronized fun write(value: JSONObject) { val bytes = cipher.encrypt(value.toString().toByteArray(Charsets.UTF_8)) val out = file.startWrite() - try { out.write(bytes); file.finishWrite(out) } catch (e: Exception) { file.failWrite(out); throw e } + try { + out.write(bytes) + file.finishWrite(out) + } catch (e: Exception) { + file.failWrite(out) + throw e + } } + @Synchronized fun clear() = file.delete() } diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/integrations/SecretCipher.kt b/app/src/main/java/hrln/interactive/sidekit/apps/integrations/SecretCipher.kt new file mode 100644 index 00000000..dfaad268 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/integrations/SecretCipher.kt @@ -0,0 +1,63 @@ +package hrln.interactive.sidekit.apps.integrations + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.GeneralSecurityException +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** Encrypts one app's private file. Implementations must be safe to call from any thread. */ +interface SecretCipher { + fun encrypt(plain: ByteArray): ByteArray + fun decrypt(sealed: ByteArray): ByteArray +} + +/** + * AES-256-GCM with a key generated inside the Android Keystore: the key never leaves secure + * hardware (or the keystore daemon), so a copied file is unreadable off this phone. Output is + * the 12-byte IV followed by ciphertext and tag. + * + * The key isn't bound to user authentication: callers that need a screen lock ask for it before + * reading the file (see Banking's gate). Binding the key would make every background save fail + * once the auth window lapsed. + * + * The default alias is Banking's historical key so existing sealed files keep opening; every + * other app passes its own alias. + */ +class KeystoreCipher(private val alias: String = "sidekit_banking") : SecretCipher { + private fun key(): SecretKey { + val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (store.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + generator.init( + KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build() + ) + return generator.generateKey() + } + + override fun encrypt(plain: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, key()) + val iv = cipher.iv + return iv + cipher.doFinal(plain) + } + + override fun decrypt(sealed: ByteArray): ByteArray { + if (sealed.size <= IV_BYTES) throw GeneralSecurityException("Truncated") + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, sealed, 0, IV_BYTES)) + return cipher.doFinal(sealed, IV_BYTES, sealed.size - IV_BYTES) + } + + private companion object { + const val TRANSFORMATION = "AES/GCM/NoPadding" + const val IV_BYTES = 12 + } +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/media/MediaSessionOwner.kt b/app/src/main/java/hrln/interactive/sidekit/apps/media/MediaSessionOwner.kt new file mode 100644 index 00000000..d240ae78 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/media/MediaSessionOwner.kt @@ -0,0 +1,82 @@ +package hrln.interactive.sidekit.apps.media + +/** + * Anything in the process that can hold the platform media session: local Music today, a remote + * provider later. + * + * Deliberately knows nothing about tracks, queues or media sources — only what the process needs + * to keep two players from talking over each other: an identity, the notification slot it uses, + * whether it is currently playing, and how to make it stop. + * + * Main thread only, like the players that implement it. + */ +interface MediaSessionOwner { + /** Stable identity, e.g. "music". Unique within the process. */ + val sessionId: String + + /** Notification channel this owner publishes on. Distinct per owner. */ + val notificationChannelId: String + + /** User-visible name of [notificationChannelId], used when the channel is created. */ + val notificationChannelName: String + + /** Notification id this owner publishes on. Distinct per owner. */ + val notificationId: Int + + /** True while this owner is actually producing audio. */ + val isActive: Boolean + + /** Stops playback without clearing the queue, so the user can resume where they were. */ + fun pause() +} + +/** + * The process's media sessions. Only one owner is active at a time: activating one pauses the + * others. Registration is explicit so an owner that never plays is still known (and can be + * paused) the first time another takes over. + * + * A plain class rather than an object so tests can hold their own registry; [MediaSessions] is + * the process-wide one. + * + * Main thread only. + */ +open class MediaSessionRegistry { + private val owners = LinkedHashSet() + + /** The owner currently allowed to play, or null. */ + var active: MediaSessionOwner? = null + private set + + val registered: List get() = owners.toList() + + fun register(owner: MediaSessionOwner) { + owners += owner + } + + fun unregister(owner: MediaSessionOwner) { + owners -= owner + if (active === owner) active = null + } + + /** + * Makes [owner] the active session. Every other owner that is playing is paused first, so + * starting one provider stops the other rather than mixing them. + */ + fun activate(owner: MediaSessionOwner) { + if (active === owner) return + // Claim first: a paused owner calls deactivate() from inside pause(), which must not + // clear the owner that just took over. + active = owner + owners.toList() + .filter { it !== owner && it.isActive } + .forEach { it.pause() } + } + + /** Clears [owner] if it is still the active session. */ + fun deactivate(owner: MediaSessionOwner) { + if (active === owner) active = null + } +} + +/** The one registry for the process. Main thread only. */ +object MediaSessions : MediaSessionRegistry() diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/media/RemoteArtwork.kt b/app/src/main/java/hrln/interactive/sidekit/apps/media/RemoteArtwork.kt new file mode 100644 index 00000000..6ac641b4 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/media/RemoteArtwork.kt @@ -0,0 +1,291 @@ +package hrln.interactive.sidekit.apps.media + +import android.app.ActivityManager +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.LruCache +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.core.util.AtomicFile +import hrln.interactive.sidekit.design.components.SidekitIconImage +import hrln.interactive.sidekit.design.icons.SidekitIcon +import java.io.ByteArrayOutputStream +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.security.MessageDigest +import kotlin.math.roundToInt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.withContext + +/** + * Artwork that lives on the network, for remote libraries (Spotify, podcast feeds). + * + * Local media keeps using [Thumbnails], which asks MediaProvider for a cached thumbnail. There is + * no provider here, so this object does the same job with a bounded HTTP fetch: + * + * - memory: the same 1/32-of-heap LRU as [Thumbnails], shared across sizes; + * - disk: `cacheDir/artwork`, one file per url+size, trimmed to [MAX_DISK_BYTES] oldest-first, so + * scrolling the same list after a restart does not refetch; + * - network: at most two fetches at once, 10 s timeouts, an 8 MB cap, and cancellation that + * disconnects the connection when the row leaves composition; + * - decode: bounds first, `inSampleSize` to roughly the drawn size, then [Thumbnails.cropTo] in + * RGB_565, so a 104 px tile never holds a full-resolution cover. + * + * Failures return null and are not cached, so a later scroll retries. + */ +object RemoteArtwork { + internal const val MAX_DISK_BYTES = 16L * 1024 * 1024 + private const val MAX_DOWNLOAD_BYTES = 8 * 1024 * 1024 + private const val TIMEOUT_MS = 10_000 + + @Volatile private var memory: LruCache? = null + + @OptIn(ExperimentalCoroutinesApi::class) + private val fetch = Dispatchers.IO.limitedParallelism(2) + + private fun memory(context: Context): LruCache = memory ?: synchronized(this) { + memory ?: run { + val memoryClassMb = context.getSystemService(ActivityManager::class.java)?.memoryClass ?: 128 + object : LruCache(memoryClassMb * 1024 * 1024 / 32) { + override fun sizeOf(key: String, value: Bitmap) = value.allocationByteCount + }.also { memory = it } + } + } + + private fun key(url: String, w: Int, h: Int) = "$url@${w}x$h" + + private fun cacheDir(context: Context) = File(context.cacheDir, "artwork").apply { mkdirs() } + + /** Stable file name for a url+size. Hashed so URLs with query strings stay filesystem-safe. */ + internal fun fileName(url: String, w: Int, h: Int): String { + val digest = MessageDigest.getInstance("SHA-256").digest(key(url, w, h).toByteArray(Charsets.UTF_8)) + return digest.joinToString("") { "%02x".format(it) } + ".img" + } + + fun cached(context: Context, url: String, w: Int, h: Int): Bitmap? = memory(context)[key(url, w, h)] + + /** Loads [url] cropped to [w] x [h] px. Null while offline, on a bad response, or undecodable. */ + suspend fun load(context: Context, url: String, w: Int, h: Int): Bitmap? { + if (w <= 0 || h <= 0 || url.isBlank()) return null + val cache = memory(context) + val key = key(url, w, h) + cache[key]?.let { return it } + return withContext(fetch) { + cache[key]?.let { return@withContext it } + val bitmap = readDisk(context, url, w, h) ?: download(context, url, w, h) ?: return@withContext null + cache.put(key, bitmap) + bitmap + } + } + + /** Drops the in-memory bitmaps; the disk cache stays until evicted or [clear]. */ + fun clearMemory() { + memory?.evictAll() + } + + /** Removes memory and disk copies (sign-out, "clear cache"). */ + fun clear(context: Context) { + clearMemory() + cacheDir(context).deleteRecursively() + } + + private fun readDisk(context: Context, url: String, w: Int, h: Int): Bitmap? = try { + val file = File(cacheDir(context), fileName(url, w, h)) + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, bounds) + val options = BitmapFactory.Options().apply { + inPreferredConfig = Bitmap.Config.RGB_565 + inSampleSize = sampleSize(bounds.outWidth, bounds.outHeight, w, h) + } + BitmapFactory.decodeFile(file.absolutePath, options) + } catch (_: Exception) { + null + } catch (_: OutOfMemoryError) { + null + } + + private suspend fun download(context: Context, url: String, w: Int, h: Int): Bitmap? { + val bytes = get(url) ?: return null + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + val options = BitmapFactory.Options().apply { + inPreferredConfig = Bitmap.Config.RGB_565 + inSampleSize = sampleSize(bounds.outWidth, bounds.outHeight, w, h) + } + val decoded = try { + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) + } catch (_: OutOfMemoryError) { + null + } ?: return null + // cropTo recycles the source; store the cropped result so the next restart decodes it directly. + val bitmap = try { + Thumbnails.cropTo(decoded, w, h) + } catch (_: OutOfMemoryError) { + decoded.recycle() + return null + } + writeDisk(context, url, w, h, bitmap) + return bitmap + } + + private suspend fun get(url: String): ByteArray? { + val connection = try { + (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + instanceFollowRedirects = true + setRequestProperty("Accept", "image/*") + } + } catch (_: Exception) { + return null + } + // A row that leaves composition cancels this coroutine; drop the socket rather than + // finishing a download nobody wants. + val cancel = currentCoroutineContext()[Job]?.invokeOnCompletion { connection.disconnect() } + return try { + if (connection.responseCode !in 200..299) return null + val declared = connection.contentLengthLong + if (declared > MAX_DOWNLOAD_BYTES) return null + connection.inputStream.use { input -> + val out = ByteArrayOutputStream(declared.takeIf { it in 1..MAX_DOWNLOAD_BYTES }?.toInt() ?: 8192) + val buffer = ByteArray(16 * 1024) + var total = 0 + while (true) { + val read = input.read(buffer) + if (read < 0) break + total += read + if (total > MAX_DOWNLOAD_BYTES) return null + out.write(buffer, 0, read) + } + out.toByteArray() + } + } catch (_: Exception) { + null + } finally { + cancel?.dispose() + connection.disconnect() + } + } + + private fun writeDisk(context: Context, url: String, w: Int, h: Int, bitmap: Bitmap) { + val file = File(cacheDir(context), fileName(url, w, h)) + val atomic = AtomicFile(file) + try { + val out = atomic.startWrite() + if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)) { + atomic.failWrite(out) + return + } + atomic.finishWrite(out) + } catch (_: Exception) { + atomic.failWrite(null) + return + } + trim(cacheDir(context)) + } + + /** Keeps the disk cache under [MAX_DISK_BYTES], dropping least-recently-used files first. */ + internal fun trim(dir: File, maxBytes: Long = MAX_DISK_BYTES) { + val files = dir.listFiles()?.sortedBy { it.lastModified() } ?: return + var total = files.sumOf { it.length() } + for (file in files) { + if (total <= maxBytes) break + total -= file.length() + file.delete() + } + } + + /** Power of two that keeps the decode near [w] x [h] instead of full resolution. */ + internal fun sampleSize(width: Int, height: Int, w: Int, h: Int): Int { + var sample = 1 + while (width / (sample * 2) >= w && height / (sample * 2) >= h) sample *= 2 + return sample + } +} + +/** + * Draws [url] filling the space this composable is given, with a Sidekit icon until it arrives + * (or if it never does). Requests cancel when the composable leaves composition, and a hit in the + * memory cache paints without a recomposition of the row. + */ +@Composable +fun RemoteArtworkImage( + url: String?, + modifier: Modifier = Modifier, + placeholder: SidekitIcon = SidekitIcon.Album, + contentDescription: String? = null +) { + BoxWithConstraints(modifier) { + RemoteArtworkImage( + url = url, + width = maxWidth.value.coerceIn(1f, 1024f).dp, + height = maxHeight.value.coerceIn(1f, 1024f).dp, + modifier = Modifier.fillMaxSize(), + placeholder = placeholder, + contentDescription = contentDescription + ) + } +} + +@Composable +fun RemoteArtworkImage( + url: String?, + width: Dp, + height: Dp, + modifier: Modifier = Modifier, + placeholder: SidekitIcon = SidekitIcon.Album, + contentDescription: String? = null +) { + val context = LocalContext.current.applicationContext + val density = LocalDensity.current + val w = with(density) { width.roundToPx() } + val h = with(density) { height.roundToPx() } + val inspection = LocalInspectionMode.current + var image by remember(url, w, h) { + mutableStateOf(url?.let { RemoteArtwork.cached(context, it, w, h)?.asImageBitmap() }) + } + if (url != null && image == null && !inspection) { + LaunchedEffect(url, w, h) { + image = RemoteArtwork.load(context, url, w, h)?.asImageBitmap() + } + } + Box(modifier.drawBehind { image?.let { drawRemoteArtwork(it) } }) { + if (image == null) { + SidekitIconImage( + icon = placeholder, + modifier = Modifier.align(Alignment.Center), + contentDescription = contentDescription + ) + } + } +} + +private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawRemoteArtwork(image: ImageBitmap) { + val dst = IntSize(size.width.roundToInt(), size.height.roundToInt()) + drawImage(image, srcOffset = IntOffset.Zero, srcSize = IntSize(image.width, image.height), dstSize = dst) +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/music/MusicPlayback.kt b/app/src/main/java/hrln/interactive/sidekit/apps/music/MusicPlayback.kt index 63508588..072e21ca 100644 --- a/app/src/main/java/hrln/interactive/sidekit/apps/music/MusicPlayback.kt +++ b/app/src/main/java/hrln/interactive/sidekit/apps/music/MusicPlayback.kt @@ -16,6 +16,8 @@ import android.os.SystemClock import androidx.compose.runtime.Immutable import hrln.interactive.sidekit.apps.media.AudioFocus import hrln.interactive.sidekit.apps.media.MediaPlayerCore +import hrln.interactive.sidekit.apps.media.MediaSessionOwner +import hrln.interactive.sidekit.apps.media.MediaSessions import hrln.interactive.sidekit.apps.media.Thumbnails import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -36,7 +38,7 @@ data class PlaybackState( val isPlaying: Boolean = false, val positionMs: Long = 0, val anchorAt: Long = 0, - val durationMs: Long = 0, + val durationMs: Long = 0 ) { val track: Track? get() = queue.current @@ -76,7 +78,15 @@ interface MusicPlayer { * * Main thread only. */ -class MusicPlayback private constructor(private val context: Context) : MusicPlayer { +class MusicPlayback private constructor(private val context: Context) : + MusicPlayer, + MediaSessionOwner { + override val sessionId = "music" + override val notificationChannelId = "music" + override val notificationChannelName = "Music playback" + override val notificationId = 0x6D75 // "mu" + override val isActive: Boolean get() = _state.value.isPlaying + private val _state = MutableStateFlow(PlaybackState()) override val state: StateFlow = _state.asStateFlow() @@ -133,7 +143,7 @@ class MusicPlayback private constructor(private val context: Context) : MusicPla publish(s.copy(isPlaying = true, positionMs = c.positionMs, anchorAt = now())) } - fun pause() { + override fun pause() { val s = _state.value if (!s.isPlaying) return val position = s.positionAt(now()) @@ -145,6 +155,7 @@ class MusicPlayback private constructor(private val context: Context) : MusicPla /** Ends playback and drops the queue: notification and session go away. */ fun stop() { + MediaSessions.deactivate(this) main.removeCallbacks(releaseIdle) releasePlayer() focus.abandon() @@ -258,6 +269,9 @@ class MusicPlayback private constructor(private val context: Context) : MusicPla private fun publish(next: PlaybackState) { _state.value = next + // Claim (or release) the process media session before anyone reacts to the state, so a + // second provider pauses this one — and this one pauses others — exactly once. + if (next.isPlaying) MediaSessions.activate(this) else MediaSessions.deactivate(this) updateNoisy(next.isPlaying) if (!next.queue.isEmpty) updateSession(next) MusicService.sync(context, next) @@ -274,14 +288,17 @@ class MusicPlayback private constructor(private val context: Context) : MusicPla } private fun session(): MediaSession = session ?: MediaSession(context, "SideKit Music").also { s -> - s.setCallback(object : MediaSession.Callback() { - override fun onPlay() = play() - override fun onPause() = pause() - override fun onSkipToNext() = next() - override fun onSkipToPrevious() = previous() - override fun onSeekTo(pos: Long) = seekTo(pos) - override fun onStop() = stop() - }, main) + s.setCallback( + object : MediaSession.Callback() { + override fun onPlay() = play() + override fun onPause() = pause() + override fun onSkipToNext() = next() + override fun onSkipToPrevious() = previous() + override fun onSeekTo(pos: Long) = seekTo(pos) + override fun onStop() = stop() + }, + main + ) s.isActive = true session = s } @@ -300,7 +317,7 @@ class MusicPlayback private constructor(private val context: Context) : MusicPla .putString(MediaMetadata.METADATA_KEY_ALBUM, track.album) .putLong(MediaMetadata.METADATA_KEY_DURATION, s.durationMs.takeIf { it > 0 } ?: track.durationMs) .apply { art?.let { putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, it) } } - .build(), + .build() ) } session.setPlaybackState( @@ -308,10 +325,10 @@ class MusicPlayback private constructor(private val context: Context) : MusicPla .setActions( SessionState.ACTION_PLAY or SessionState.ACTION_PAUSE or SessionState.ACTION_PLAY_PAUSE or SessionState.ACTION_SKIP_TO_NEXT or SessionState.ACTION_SKIP_TO_PREVIOUS or - SessionState.ACTION_SEEK_TO or SessionState.ACTION_STOP, + SessionState.ACTION_SEEK_TO or SessionState.ACTION_STOP ) .setState(if (s.isPlaying) SessionState.STATE_PLAYING else SessionState.STATE_PAUSED, s.positionMs, if (s.isPlaying) 1f else 0f, s.anchorAt) - .build(), + .build() ) } @@ -340,7 +357,10 @@ class MusicPlayback private constructor(private val context: Context) : MusicPla @Volatile private var instance: MusicPlayback? = null fun get(context: Context): MusicPlayback = instance ?: synchronized(this) { - instance ?: MusicPlayback(context.applicationContext).also { instance = it } + instance ?: MusicPlayback(context.applicationContext).also { + instance = it + MediaSessions.register(it) + } } /** The player if music has been used in this process; never creates one. */ diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/music/MusicService.kt b/app/src/main/java/hrln/interactive/sidekit/apps/music/MusicService.kt index 18b0f41a..d8246ff4 100644 --- a/app/src/main/java/hrln/interactive/sidekit/apps/music/MusicService.kt +++ b/app/src/main/java/hrln/interactive/sidekit/apps/music/MusicService.kt @@ -28,12 +28,13 @@ class MusicService : Service() { override fun onCreate() { super.onCreate() running = this + val playback = MusicPlayback.get(this) val manager = getSystemService(NotificationManager::class.java) - if (manager.getNotificationChannel(CHANNEL) == null) { + if (manager.getNotificationChannel(playback.notificationChannelId) == null) { manager.createNotificationChannel( - NotificationChannel(CHANNEL, "Music playback", NotificationManager.IMPORTANCE_LOW).apply { + NotificationChannel(playback.notificationChannelId, playback.notificationChannelName, NotificationManager.IMPORTANCE_LOW).apply { setShowBadge(false) - }, + } ) } } @@ -42,7 +43,7 @@ class MusicService : Service() { val playback = MusicPlayback.get(this) // Started with startForegroundService: must enter the foreground before anything else. // (Even when about to stop: a service started that way that never does is a crash.) - startForeground(NOTIFICATION_ID, notification(playback, playback.state.value), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) + startForeground(playback.notificationId, notification(playback, playback.state.value), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) when (intent?.action) { ACTION_PLAY_PAUSE -> playback.togglePlayPause() ACTION_NEXT -> playback.next() @@ -65,9 +66,11 @@ class MusicService : Service() { stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } - state.isPlaying -> startForeground(NOTIFICATION_ID, notification(playback, state), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) + + state.isPlaying -> startForeground(playback.notificationId, notification(playback, state), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) + else -> { - getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(playback, state)) + getSystemService(NotificationManager::class.java).notify(playback.notificationId, notification(playback, state)) stopForeground(STOP_FOREGROUND_DETACH) } } @@ -79,9 +82,9 @@ class MusicService : Service() { this, 0, Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - PendingIntent.FLAG_IMMUTABLE, + PendingIntent.FLAG_IMMUTABLE ) - return Notification.Builder(this, CHANNEL) + return Notification.Builder(this, playback.notificationChannelId) .setSmallIcon(SidekitIcon.MusicNote2.drawable) .setContentTitle(track?.title.orEmpty()) .setContentText(track?.artist.orEmpty()) @@ -96,8 +99,8 @@ class MusicService : Service() { Notification.Action.Builder( Icon.createWithResource(this, if (state.isPlaying) SidekitIcon.Pause.drawable else SidekitIcon.PlayArrow.drawable), if (state.isPlaying) "Pause" else "Play", - action(ACTION_PLAY_PAUSE), - ).build(), + action(ACTION_PLAY_PAUSE) + ).build() ) .addAction(Notification.Action.Builder(Icon.createWithResource(this, SidekitIcon.SkipNext.drawable), "Next", action(ACTION_NEXT)).build()) .setStyle(Notification.MediaStyle().setMediaSession(playback.sessionToken).setShowActionsInCompactView(0, 1, 2)) @@ -109,12 +112,10 @@ class MusicService : Service() { this, action.hashCode(), Intent(this, MusicService::class.java).setAction(action), - PendingIntent.FLAG_IMMUTABLE, + PendingIntent.FLAG_IMMUTABLE ) companion object { - private const val CHANNEL = "music" - private const val NOTIFICATION_ID = 0x6D75 // "mu" private const val ACTION_PLAY_PAUSE = "hrln.interactive.sidekit.music.PLAY_PAUSE" private const val ACTION_NEXT = "hrln.interactive.sidekit.music.NEXT" private const val ACTION_PREVIOUS = "hrln.interactive.sidekit.music.PREVIOUS" diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SideplayDebug.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SideplayDebug.kt new file mode 100644 index 00000000..c5eb7880 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SideplayDebug.kt @@ -0,0 +1,25 @@ +package hrln.interactive.sidekit.apps.spotify + +import android.util.Log + +/** + * Temporary live-debug logging for the Sideplay playback path. + * + * Enable on device with: + * adb shell setprop log.tag.SideplayDebug DEBUG + * + * The tag is checked at every call, so the property can be toggled without reinstalling. + */ +internal object SideplayDebug { + const val TAG = "SideplayDebug" + + val enabled: Boolean get() = Log.isLoggable(TAG, Log.DEBUG) + + fun log(message: String) { + if (enabled) Log.d(TAG, message) + } + + fun log(message: String, error: Throwable) { + if (enabled) Log.d(TAG, message, error) + } +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifyComponents.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifyComponents.kt new file mode 100644 index 00000000..cb16a3b8 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifyComponents.kt @@ -0,0 +1,467 @@ +package hrln.interactive.sidekit.apps.spotify + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.setProgress +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import hrln.interactive.sidekit.apps.media.RemoteArtworkImage +import hrln.interactive.sidekit.apps.media.formatDuration +import hrln.interactive.sidekit.design.action.ActionMotion +import hrln.interactive.sidekit.design.action.ActionTone +import hrln.interactive.sidekit.design.action.SideAction +import hrln.interactive.sidekit.design.action.undoBar +import hrln.interactive.sidekit.design.components.SidekitIconImage +import hrln.interactive.sidekit.design.components.SidekitIconItem +import hrln.interactive.sidekit.design.components.SidekitItem +import hrln.interactive.sidekit.design.components.SidekitItemRow +import hrln.interactive.sidekit.design.components.SidekitText +import hrln.interactive.sidekit.design.components.SidekitTile +import hrln.interactive.sidekit.design.icons.SidekitIcon +import hrln.interactive.sidekit.design.theme.SidekitTheme + +/** Square artwork: remote when the catalog has a URL, otherwise a local fixture or icon. */ +@Composable +fun SpotifyCover( + @DrawableRes artRes: Int, + modifier: Modifier = Modifier, + shape: androidx.compose.ui.graphics.Shape = SidekitTheme.shapes.control, + bordered: Boolean = false, + contentDescription: String? = null, + artUrl: String? = null, + onClick: (() -> Unit)? = null, + onClickLabel: String? = null +) { + val colors = SidekitTheme.colors + Box( + modifier + .clip(shape) + .background(colors.surfaceA2) + .then(if (bordered) Modifier.border(1.dp, colors.light, shape) else Modifier) + .then(if (onClick != null) Modifier.clickable(onClickLabel = onClickLabel, onClick = onClick) else Modifier) + ) { + when { + artUrl != null -> RemoteArtworkImage( + artUrl, + Modifier.fillMaxSize(), + placeholder = SidekitIcon.Album, + contentDescription = contentDescription + ) + + artRes != 0 -> Image( + painter = painterResource(artRes), + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + + else -> SidekitIconImage( + SidekitIcon.Album, + Modifier.align(Alignment.Center), + contentDescription = contentDescription + ) + } + } +} + +@Composable +fun SectionLabel(text: String, modifier: Modifier = Modifier) { + SidekitText( + text.uppercase(), + modifier.padding(start = 18.dp, top = 7.dp, bottom = 1.dp), + style = SidekitTheme.typography.small, + color = SidekitTheme.colors.surfaceA6 + ) +} + +@Composable +fun SpotifyMediaRow( + item: SpotifyMediaItem, + modifier: Modifier = Modifier, + index: Int? = null, + showArt: Boolean = true, + secondary: String = item.creator, + trailing: String? = formatDuration(item.durationMs), + onClick: () -> Unit +) { + val colors = SidekitTheme.colors + SidekitTile( + modifier = modifier.fillMaxWidth(), + color = if (item.playable) colors.surfaceA3 else colors.surfaceA2, + onClick = onClick + ) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 15.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(13.dp) + ) { + if (showArt) { + SpotifyCover(item.artRes, Modifier.size(68.dp), artUrl = item.artUrl) + } else if (index != null) { + Box(Modifier.size(30.dp), contentAlignment = Alignment.Center) { + SidekitText("${index + 1}", style = SidekitTheme.typography.label, color = colors.surfaceA6) + } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + SidekitText(item.title, style = SidekitTheme.typography.bodySmall, maxLines = 1) + SidekitText( + secondary + if (item.explicit) " · E" else "", + style = SidekitTheme.typography.caption, + color = colors.surfaceA6, + maxLines = 1 + ) + } + if (trailing != null) { + SidekitText( + trailing, + style = SidekitTheme.typography.caption, + color = colors.surfaceA6 + ) + } + } + } +} + +@Composable +fun RowScope.SpotifyIconTile( + icon: SidekitIcon, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tone: SpotifyTileTone = SpotifyTileTone.Neutral, + weight: Float = 1f +) { + val colors = SidekitTheme.colors + SidekitIconItem( + icon, + label, + modifier.weight(weight), + color = tone.colour(colors), + iconSize = SidekitTheme.spacing.tileIconSize, + onClick = onClick + ) +} + +enum class SpotifyTileTone { + Neutral, + Play, + On, + Muted + ; + + fun colour(colors: hrln.interactive.sidekit.design.theme.SidekitColors): Color = when (this) { + Neutral -> colors.surfaceA3 + Play -> colors.emphasisA1 + On -> colors.surfaceA5 + Muted -> colors.surfaceA2 + } +} + +@Composable +fun NowPlayingTile( + current: SpotifyMediaItem?, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val colors = SidekitTheme.colors + SidekitTile(modifier, onClick = onClick, onClickLabel = "Now Playing") { + Row( + Modifier.fillMaxWidth().padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(13.dp) + ) { + if (current == null) { + Box(Modifier.size(72.dp).clip(SidekitTheme.shapes.control).background(colors.surfaceA2), contentAlignment = Alignment.Center) { + SidekitIconImage(SidekitIcon.MusicNote2, size = 36.dp) + } + } else { + SpotifyCover(current.artRes, Modifier.size(72.dp), artUrl = current.artUrl) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + SidekitText( + if (current == null) "NOTHING PLAYING" else "PLAYING", + style = SidekitTheme.typography.small, + color = colors.surfaceA6 + ) + SidekitText(current?.title ?: "Pick a track", style = SidekitTheme.typography.bodySmall, maxLines = 2) + } + } + } +} + +@Composable +fun LibraryTile(onClick: () -> Unit, modifier: Modifier = Modifier) { + SidekitTile( + modifier, + color = SidekitTheme.colors.emphasisA1, + onClick = onClick, + onClickLabel = "Library" + ) { + SidekitIconImage( + SidekitIcon.Album, + size = SidekitTheme.spacing.tileIconSize, + contentDescription = "Library" + ) + } +} + +@Composable +fun ArtworkTile(@DrawableRes artRes: Int, label: String, modifier: Modifier = Modifier, artUrl: String? = null) { + SidekitTile(modifier) { + SpotifyCover(artRes, Modifier.fillMaxSize(), shape = SidekitTheme.shapes.item, contentDescription = label, artUrl = artUrl) + } +} + +@Composable +fun EmptyMessage(title: String, modifier: Modifier = Modifier, hint: String? = null) { + Column( + modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + SidekitText(title, style = SidekitTheme.typography.bodySmall, color = SidekitTheme.colors.surfaceA6) + if (hint != null) SidekitText(hint, style = SidekitTheme.typography.caption, color = SidekitTheme.colors.surfaceA6) + } +} + +@Composable +fun TerminalRow(label: String, secondary: String? = null, onClick: (() -> Unit)? = null) { + SidekitItem( + label, + secondary = secondary, + showLinkArrow = onClick != null, + colour = hrln.interactive.sidekit.design.components.ItemColour.Secondary, + onClick = onClick + ) +} + +/** + * Loads the next page when the footer enters composition. Lazy lists only compose visible rows + * (plus a small prefetch window), so this keeps both network and rendering work proportional to + * what the user is actually looking at. + */ +@Composable +fun PagingFooter(loading: Boolean, onLoadMore: () -> Unit) { + LaunchedEffect(loading) { + if (!loading) onLoadMore() + } + EmptyMessage(if (loading) "Loading more…" else "More", hint = null) +} + +fun withNotice(notice: String?, onDismiss: () -> Unit, actions: List): List = if (notice == null) { + actions +} else { + listOf( + SideAction( + "notice", + notice, + tone = ActionTone.Muted, + dense = true, + motion = ActionMotion.Up, + onClick = onDismiss + ) + ) +} + +fun spotifyBack(label: String, onBack: () -> Unit) = listOf(SideAction.back("back", label, onBack)) + +/** + * The action bar for screens that can change saved state. While an Undo offer is live it takes the + * right slot; otherwise the bar shows the screen's normal actions and any notice. + */ +fun spotifyBar( + state: SpotifyUiState, + onDismissNotice: () -> Unit, + onUndo: () -> Unit, + continueWith: SideAction, + extra: List = emptyList() +): List { + val undo = state.undo + return if (undo != null) { + undoBar(continueWith, SideAction.undo(contentDescription = undo.contentDescription, onClick = onUndo)) + } else { + withNotice(state.notice, onDismissNotice, listOf(continueWith) + extra) + } +} + +fun repeatLabel(repeat: RepeatMode) = repeat.label + +/** + * The seek row: tap or drag, or step with D-pad left/right. It mirrors Music's seek row so the + * transport feels the same in both apps. + */ +@Composable +fun SpotifySeek(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit, modifier: Modifier = Modifier) { + val seekable = durationMs > 0 + val fraction = if (seekable) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f + // While the finger is down the bar follows the finger locally. The player is only asked to + // seek once, on release: issuing a seek for every drag delta floods the native player and + // makes the bar take seconds to settle on the final position. + var dragFraction by remember { mutableStateOf(null) } + val shownFraction = dragFraction ?: fraction + val shownPositionMs = if (dragFraction != null) (shownFraction * durationMs).toLong() else positionMs + val trackColor = SidekitTheme.colors.surfaceA4 + val progressColor = SidekitTheme.colors.light + val labelStyle = SidekitTheme.typography.label + val timeStyle = remember(labelStyle) { labelStyle.copy(fontFeatureSettings = "tnum") } + val widest = remember(durationMs) { formatDuration(durationMs).replace(Regex("\\d"), "0") } + val interaction = remember { MutableInteractionSource() } + fun seekTo(target: Float) { + if (seekable) onSeek((target.coerceIn(0f, 1f) * durationMs).toLong()) + } + val step = if (seekable) 5_000f / durationMs else 0f + Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(contentAlignment = Alignment.CenterEnd) { + SidekitText(widest, Modifier.alpha(0f).clearAndSetSemantics {}, style = timeStyle) + SidekitText(formatDuration(shownPositionMs), style = timeStyle) + } + Box( + Modifier + .weight(1f) + .height(36.dp) + .onKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onKeyEvent false + when (event.key) { + Key.DirectionLeft -> { + seekTo(fraction - step) + true + } + + Key.DirectionRight -> { + seekTo(fraction + step) + true + } + + else -> false + } + } + .indication(interaction, LocalIndication.current) + .focusable(interactionSource = interaction) + .pointerInput(durationMs) { detectTapGestures { offset -> seekTo(offset.x / size.width) } } + .pointerInput(durationMs) { + detectHorizontalDragGestures( + onDragStart = { offset -> + if (seekable) dragFraction = (offset.x / size.width).coerceIn(0f, 1f) + }, + onHorizontalDrag = { change, _ -> + if (seekable) dragFraction = (change.position.x / size.width).coerceIn(0f, 1f) + }, + onDragEnd = { + val target = dragFraction + dragFraction = null + if (target != null) seekTo(target) + }, + onDragCancel = { dragFraction = null } + ) + } + .semantics { + if (seekable) { + contentDescription = "Seek" + progressBarRangeInfo = ProgressBarRangeInfo(shownFraction, 0f..1f) + setProgress { target -> + seekTo(target) + true + } + } + } + .drawBehind { + val h = 6.dp.toPx() + val y = (size.height - h) / 2 + drawRoundRect(trackColor, Offset(0f, y), Size(size.width, h), CornerRadius(h / 2)) + if (shownFraction > 0f) { + val filled = size.width * shownFraction + drawRoundRect(progressColor, Offset(0f, y), Size(filled, h), CornerRadius(h / 2)) + drawCircle(progressColor, 9.dp.toPx(), Offset(filled, size.height / 2)) + } + } + ) + SidekitText(formatDuration(durationMs), style = timeStyle) + } +} + +@Composable +fun PlayTile(onClick: () -> Unit, modifier: Modifier = Modifier) { + SidekitIconItem( + SidekitIcon.PlayArrow, + "Play", + modifier, + color = SidekitTheme.colors.emphasisA1, + iconSize = SidekitTheme.spacing.tileIconSize, + onClick = onClick + ) +} + +@Composable +fun SpotifyLibraryRow( + @DrawableRes artRes: Int, + title: String, + secondary: String, + modifier: Modifier = Modifier, + artUrl: String? = null, + onClick: () -> Unit +) { + val colors = SidekitTheme.colors + SidekitTile(modifier.fillMaxWidth(), color = colors.surfaceA3, onClick = onClick) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 15.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(13.dp) + ) { + SpotifyCover(artRes, Modifier.size(68.dp), artUrl = artUrl) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + SidekitText(title, style = SidekitTheme.typography.bodySmall, maxLines = 1) + SidekitText(secondary, style = SidekitTheme.typography.caption, color = colors.surfaceA6, maxLines = 1) + } + SidekitIconImage(SidekitIcon.ArrowRightAlt, size = 30.dp, contentDescription = null) + } + } +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifyGraph.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifyGraph.kt new file mode 100644 index 00000000..ecf48c90 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifyGraph.kt @@ -0,0 +1,347 @@ +package hrln.interactive.sidekit.apps.spotify + +import android.content.Intent +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.compose.navigation +import androidx.navigation.navArgument +import hrln.interactive.sidekit.apps.media.RemoteArtwork +import hrln.interactive.sidekit.apps.spotify.data.SpotifySessions +import hrln.interactive.sidekit.design.components.SidekitScaffold +import hrln.interactive.sidekit.navigation.LAUNCHER_ROUTE +import hrln.interactive.sidekit.navigation.SubApp +import hrln.interactive.sidekit.ui.UndoWindowLifecycle +import kotlinx.coroutines.flow.MutableStateFlow + +private val ROOT = SubApp.Sideplay.route +private val HOME = "$ROOT/home" +private val LIBRARY = "$ROOT/library" +private val COLLECTION = "$ROOT/collection/{type}/{id}" +private val ARTIST = "$ROOT/artist/{id}" +private val SEARCH = "$ROOT/search" +private val NOW_PLAYING = "$ROOT/now-playing" +private val QUEUE = "$ROOT/queue" +private val SETTINGS = "$ROOT/settings" +private val SIGN_IN = "$ROOT/sign-in" + +private fun collectionRoute(type: SpotifyCollectionType, id: String) = "$ROOT/collection/${type.route}/$id" +private fun artistRoute(id: String) = "$ROOT/artist/$id" + +/** + * A tap on the playback notification opens Sideplay at Now Playing instead of the launcher. + * The activity records the request here; the nav host consumes it once it is composed. + */ +object SideplayLinks { + const val ACTION_OPEN_NOW_PLAYING = "hrln.interactive.sidekit.action.SIDEPLAY_NOW_PLAYING" + + private val pending = MutableStateFlow(false) + + fun handle(intent: Intent?): Boolean { + if (intent?.action != ACTION_OPEN_NOW_PLAYING) return false + pending.value = true + return true + } + + /** Test seam: a unit test must not leave a pending navigation for the next activity. */ + internal fun reset() { + pending.value = false + } + + @Composable + fun Follow(nav: NavHostController) { + val open by pending.collectAsState() + LaunchedEffect(open) { + if (!open) return@LaunchedEffect + pending.value = false + nav.navigate(ROOT) { popUpTo(LAUNCHER_ROUTE) } + nav.navigate(NOW_PLAYING) { launchSingleTop = true } + } + } +} + +private val collectionArgs = listOf( + navArgument("type") { type = NavType.StringType }, + navArgument("id") { type = NavType.StringType } +) +private val artistArgs = listOf(navArgument("id") { type = NavType.StringType }) + +/** Registers Sideplay as one Sidekit sub-app. All screens share one mock account state holder. */ +fun NavGraphBuilder.sideplayGraph(nav: NavHostController) { + navigation(startDestination = HOME, route = ROOT) { + composable(HOME) { entry -> + val vm = entry.spotifyViewModel(nav) + val state by vm.state.collectAsStateWithLifecycle() + LaunchedEffect(state.signedIn) { + if (!state.signedIn) nav.navigate(SIGN_IN) { launchSingleTop = true } + } + SideplayHomeScreen( + state, + onNowPlaying = { nav.navigate(NOW_PLAYING) }, + onLibrary = { nav.navigate(LIBRARY) }, + onSearch = { nav.navigate(SEARCH) }, + onSettings = { nav.navigate(SETTINGS) }, + onRefresh = vm::refresh, + onPlay = vm::playLibraryTrack, + onQueue = { nav.navigate(QUEUE) }, + onQueueTrack = vm::addToQueue, + onDismissNotice = vm::dismissNotice + ) + } + composable(LIBRARY) { entry -> + val vm = entry.spotifyViewModel(nav) + val state by vm.state.collectAsStateWithLifecycle() + LibraryScreen( + state, + onBack = { nav.popBackStack() }, + onSearch = { nav.navigate(SEARCH) }, + onFilter = vm::setFilter, + onTrack = { + vm.playItem(it) + nav.navigate(NOW_PLAYING) + }, + onQueue = vm::addToQueue, + onPlaylist = { nav.navigate(collectionRoute(SpotifyCollectionType.Playlist, it.id)) }, + onAlbum = { nav.navigate(collectionRoute(SpotifyCollectionType.Album, it.id)) }, + onArtist = { nav.navigate(artistRoute(it.id)) }, + onShow = { nav.navigate(collectionRoute(SpotifyCollectionType.Show, it.id)) }, + onLoadMore = vm::loadMoreLibrary, + onDismissNotice = vm::dismissNotice + ) + } + composable(COLLECTION, arguments = collectionArgs) { entry -> + val vm = entry.spotifyViewModel(nav) + val state by vm.state.collectAsStateWithLifecycle() + UndoWindowLifecycle(vm.undoWindow, vm::commitUndo) + val type = SpotifyCollectionType.entries.firstOrNull { it.route == entry.arguments?.getString("type") } + val id = entry.arguments?.getString("id").orEmpty() + LaunchedEffect(type, id) { type?.let { vm.ensureCollection(it, id) } } + val collection = type?.let { vm.collection(it, id) } + if (collection == null) { + MissingCollectionScreen(onBack = { nav.popBackStack() }, loading = type?.let { "${it.route}:$id" in state.pendingCollections } == true) + } else { + CollectionScreen( + collection, + state, + onBack = { nav.popBackStack() }, + onPlay = { vm.playCollection(collection, 0) }, + onItem = { vm.playCollection(collection, it) }, + onSave = { vm.toggleSaved(collection) }, + onArtist = { + collection.artistId?.let { nav.navigate(artistRoute(it)) } ?: vm.notice("This collection has no artist page") + }, + onQueue = { collection.items.firstOrNull()?.let(vm::addToQueue) }, + onQueueItem = vm::addToQueue, + onNowPlaying = { nav.navigate(NOW_PLAYING) }, + onUndo = vm::undo, + onLoadMore = { type?.let { vm.loadMoreCollection(it, id) } }, + loadingMore = "${type?.route}:$id" in state.collectionMorePending, + onDismissNotice = vm::dismissNotice + ) + } + } + composable(ARTIST, arguments = artistArgs) { entry -> + val vm = entry.spotifyViewModel(nav) + val state by vm.state.collectAsStateWithLifecycle() + UndoWindowLifecycle(vm.undoWindow, vm::commitUndo) + val artistId = entry.arguments?.getString("id").orEmpty() + LaunchedEffect(artistId) { vm.ensureArtist(artistId) } + val artist = vm.artist(artistId) + if (artist == null) { + MissingCollectionScreen(onBack = { nav.popBackStack() }, loading = artistId in state.pendingArtists) + } else { + val albums = vm.albumsBy(artist) + val tracks = vm.topTracks(artist) + ArtistScreen( + artist, + albums, + tracks, + state, + onBack = { nav.popBackStack() }, + onPlay = { vm.playArtist(artist) }, + onTrack = { vm.play(tracks, tracks.indexOf(it)) }, + onQueue = vm::addToQueue, + onAlbum = { nav.navigate(collectionRoute(SpotifyCollectionType.Album, it.id)) }, + onFollow = { vm.toggleFollow(artist) }, + onNowPlaying = { nav.navigate(NOW_PLAYING) }, + onUndo = vm::undo, + onLoadMoreAlbums = { vm.loadMoreArtistAlbums(artist) }, + onLoadMoreTracks = { vm.loadMoreArtistTracks(artist) }, + albumHasMore = state.artistAlbumHasMore[artist.id] == true, + trackHasMore = state.artistTrackHasMore[artist.id] == true, + loadingMore = artist.id in state.artistMorePending, + onDismissNotice = vm::dismissNotice + ) + } + } + composable(SEARCH) { entry -> + val vm = entry.spotifyViewModel(nav) + val state by vm.state.collectAsStateWithLifecycle() + SpotifySearchScreen( + state, + onBack = { nav.popBackStack() }, + onQuery = vm::setQuery, + onClear = vm::clearQuery, + onToggleScope = vm::toggleScope, + onTrack = vm::playItem, + onQueue = vm::addToQueue, + onArtist = { nav.navigate(artistRoute(it.id)) }, + onAlbum = { nav.navigate(collectionRoute(SpotifyCollectionType.Album, it.id)) }, + onPlaylist = { nav.navigate(collectionRoute(SpotifyCollectionType.Playlist, it.id)) }, + onShow = { nav.navigate(collectionRoute(SpotifyCollectionType.Show, it.id)) }, + onLoadMore = vm::loadMoreSearch, + onDismissNotice = vm::dismissNotice + ) + } + composable(NOW_PLAYING) { entry -> + val vm = entry.spotifyViewModel(nav) + val state by vm.state.collectAsStateWithLifecycle() + UndoWindowLifecycle(vm.undoWindow, vm::commitUndo) + SpotifyNowPlayingScreen( + state, + onBack = { nav.popBackStack() }, + onPlayPause = vm::togglePlayPause, + onPrevious = vm::previous, + onNext = vm::next, + onSeek = vm::seekTo, + onShuffle = vm::toggleShuffle, + onRepeat = vm::cycleRepeat, + onSave = vm::toggleSaveCurrent, + onQueue = { nav.navigate(QUEUE) }, + onAlbum = { + (state.current as? SpotifyTrack)?.let { track -> + nav.navigate(collectionRoute(SpotifyCollectionType.Album, track.albumId)) + } + }, + onShow = { + (state.current as? SpotifyEpisode)?.let { episode -> + nav.navigate(collectionRoute(SpotifyCollectionType.Show, episode.showId)) + } + }, + onArtist = { + state.current?.artistId?.let { nav.navigate(artistRoute(it)) } + }, + onUndo = vm::undo, + onDismissNotice = vm::dismissNotice + ) + } + composable(QUEUE) { entry -> + val vm = entry.spotifyViewModel(nav) + val state by vm.state.collectAsStateWithLifecycle() + QueueScreen( + state, + onBack = { nav.popBackStack() }, + onNowPlaying = { + if (!nav.popBackStack(NOW_PLAYING, inclusive = false)) { + nav.navigate(NOW_PLAYING) { launchSingleTop = true } + } + }, + onJump = vm::jumpTo, + onPlayNext = vm::moveToNext, + onRemove = vm::removeUpcoming, + onRemoveHistory = vm::removeHistory, + onClearUpcoming = vm::clearUpcoming, + onRestoreUpcoming = vm::restoreUpcoming, + onShuffle = vm::toggleShuffle, + onDismissNotice = vm::dismissNotice + ) + } + composable(SETTINGS) { entry -> + val vm = entry.spotifyViewModel(nav) + val state by vm.state.collectAsStateWithLifecycle() + SpotifySettingsScreen( + state, + onBack = { nav.popBackStack() }, + onAudioQuality = vm::cycleAudioQuality, + onAutoplay = vm::toggleAutoplay, + onClearCache = vm::clearCache, + onClearHistory = vm::clearHistory, + onSignOut = { + vm.signOut() + nav.navigate(SIGN_IN) { launchSingleTop = true } + }, + onNotice = vm::notice, + onDismissNotice = vm::dismissNotice + ) + } + composable(SIGN_IN) { entry -> + val vm = entry.spotifyViewModel(nav) + val state by vm.state.collectAsStateWithLifecycle() + LaunchedEffect(state.signedIn) { + if (state.signedIn) { + nav.navigate(HOME) { + popUpTo(HOME) { inclusive = false } + launchSingleTop = true + } + } + } + SpotifySignInScreen( + state, + onBack = { nav.popBackStack(LAUNCHER_ROUTE, inclusive = false) }, + onLogIn = vm::signIn, + onDismissNotice = vm::dismissNotice + ) + } + } +} + +@Composable +private fun MissingCollectionScreen(onBack: () -> Unit, loading: Boolean = false) { + SidekitScaffold(title = "Sideplay", actions = spotifyBack("Back", onBack)) { padding -> + androidx.compose.foundation.layout.Box( + androidx.compose.ui.Modifier.padding(padding) + ) { + if (loading) { + EmptyMessage("Loading…", hint = "Fetching from Spotify") + } else { + EmptyMessage("This collection isn't available", hint = "Go back and choose another") + } + } + } +} + +@Composable +private fun NavBackStackEntry.spotifyViewModel(nav: NavHostController): SpotifyViewModel { + val parent = remember(this) { nav.getBackStackEntry(ROOT) } + val context = LocalContext.current.applicationContext + val session = remember(context) { SpotifySessions.get(context) } + val openBrowser: suspend (String) -> Unit = remember(context) { + { url -> + context.startActivity( + Intent(Intent.ACTION_VIEW, url.toUri()).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + } + return viewModel( + parent, + factory = remember(session, openBrowser, context) { + viewModelFactory { + initializer { + SpotifyViewModel( + catalog = session.catalog, + writer = session.writer, + queue = session.queue, + history = session.history, + session = session, + openBrowser = openBrowser, + clearArtworkCache = { RemoteArtwork.clear(context) } + ) + } + } + } + ) +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifyLibraryScreens.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifyLibraryScreens.kt new file mode 100644 index 00000000..93f23634 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifyLibraryScreens.kt @@ -0,0 +1,347 @@ +package hrln.interactive.sidekit.apps.spotify + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.unit.dp +import hrln.interactive.sidekit.design.action.ActionMotion +import hrln.interactive.sidekit.design.action.ActionTone +import hrln.interactive.sidekit.design.action.SideAction +import hrln.interactive.sidekit.design.components.SidekitItem +import hrln.interactive.sidekit.design.components.SidekitItemRow +import hrln.interactive.sidekit.design.components.SidekitList +import hrln.interactive.sidekit.design.components.SidekitScaffold +import hrln.interactive.sidekit.design.components.SidekitSwipeRow +import hrln.interactive.sidekit.design.components.SidekitText +import hrln.interactive.sidekit.design.components.SwipeAction +import hrln.interactive.sidekit.design.components.listFade +import hrln.interactive.sidekit.design.components.rememberSwipeRevealGroup +import hrln.interactive.sidekit.design.icons.SidekitIcon +import hrln.interactive.sidekit.design.theme.SidekitTheme + +@Composable +fun SideplayHomeScreen( + state: SpotifyUiState, + onNowPlaying: () -> Unit, + onLibrary: () -> Unit, + onSearch: () -> Unit, + onSettings: () -> Unit, + onRefresh: () -> Unit, + onPlay: (SpotifyTrack) -> Unit, + onQueue: () -> Unit, + onQueueTrack: (SpotifyTrack) -> Unit, + onDismissNotice: () -> Unit +) { + val recent = state.recent.take(10) + val bar = withNotice( + state.notice, + onDismissNotice, + listOf( + SideAction("queue", "Queue", SidekitIcon.QueueMusic, onClick = onQueue), + SideAction("search", "Search", SidekitIcon.Search, motion = ActionMotion.Forward, onClick = onSearch) + ) + ) + SidekitScaffold( + title = "Sideplay", + actions = bar, + options = listOf( + SideAction("refresh", "Refresh", SidekitIcon.Undo, onClick = onRefresh), + SideAction("settings", "Settings", SidekitIcon.Settings, onClick = onSettings) + ) + ) { padding -> + val listState = rememberLazyListState() + val swipes = rememberSwipeRevealGroup() + SidekitList(padding, state = listState) { + item(key = "tiles") { + SidekitItemRow(Modifier.listFade(listState, 0)) { + NowPlayingTile(state.current, onNowPlaying, Modifier.weight(1f)) + LibraryTile(onLibrary, Modifier.width(SidekitTheme.spacing.itemHeight)) + } + } + item(key = "recentLabel") { SectionLabel("Recently played") } + if (recent.isEmpty()) { + item(key = "empty") { EmptyMessage("Nothing played yet", hint = "Search to find your first track") } + } else { + itemsIndexed(recent, key = { index, track -> "$index:${track.id}" }) { index, track -> + SidekitSwipeRow( + key = track.id, + group = swipes, + modifier = Modifier.listFade(listState, index + 2), + action = queueAction(track, onQueueTrack) + ) { + SpotifyMediaRow( + track, + Modifier, + secondary = track.artist, + onClick = { onPlay(track) } + ) + } + } + } + } + } +} + +@Composable +private fun queueAction(item: T, onQueue: (T) -> Unit) = SwipeAction( + label = "Add ${item.title} to queue", + icon = SidekitIcon.QueueMusic, + color = SidekitTheme.colors.primaryA1, + armedColor = SidekitTheme.colors.primaryA2, + onClick = { onQueue(item) } +) + +@Composable +fun LibraryScreen( + state: SpotifyUiState, + onBack: () -> Unit, + onSearch: () -> Unit, + onFilter: (LibraryFilter) -> Unit, + onTrack: (SpotifyTrack) -> Unit, + onQueue: (SpotifyTrack) -> Unit, + onPlaylist: (SpotifyPlaylist) -> Unit, + onAlbum: (SpotifyAlbum) -> Unit, + onArtist: (SpotifyArtist) -> Unit, + onShow: (SpotifyShow) -> Unit, + onLoadMore: () -> Unit, + onDismissNotice: () -> Unit +) { + val actions = withNotice( + state.notice, + onDismissNotice, + listOf(spotifyBack("Sideplay", onBack).first(), SideAction("search", "Search", SidekitIcon.Search, motion = ActionMotion.Forward, onClick = onSearch)) + ) + val filters = LibraryFilter.entries.map { filter -> + SideAction( + "filter.${filter.name}", + filter.label, + tone = if (filter == state.filter) ActionTone.Primary else ActionTone.Muted, + onClick = { onFilter(filter) } + ) + } + SidekitScaffold( + title = "Library", + actions = actions, + options = filters + ) { padding -> + val listState = rememberLazyListState() + val swipes = rememberSwipeRevealGroup() + val first = remember { FocusRequester() } + LaunchedEffect(state.filter) { runCatching { first.requestFocus() } } + SidekitList(padding, state = listState) { + val items = state.libraryItems + if (items.isEmpty()) { + item(key = "empty") { EmptyMessage(state.filter.empty, hint = "Search to find something to save") } + } + itemsIndexed(items, key = { index, item -> "$index:${libraryKey(item)}" }) { index, item -> + val modifier = Modifier.listFade(listState, index).then(if (index == 0) Modifier.focusRequester(first) else Modifier) + when (item) { + is SpotifyTrack -> SidekitSwipeRow( + key = item.id, + group = swipes, + modifier = modifier, + action = queueAction(item, onQueue) + ) { + SpotifyMediaRow(item, Modifier, secondary = item.artist, onClick = { onTrack(item) }) + } + + is SpotifyPlaylist -> SpotifyLibraryRow(item.artRes, item.name, "${item.owner} · ${item.trackCount} tracks", modifier, artUrl = item.artUrl) { onPlaylist(item) } + + is SpotifyAlbum -> SpotifyLibraryRow(item.artRes, item.title, "${item.artist} · ${item.year}", modifier, artUrl = item.artUrl) { onAlbum(item) } + + is SpotifyArtist -> SpotifyLibraryRow(item.artRes, item.name, "Artist", modifier, artUrl = item.artUrl) { onArtist(item) } + + is SpotifyShow -> SpotifyLibraryRow(item.artRes, item.name, item.publisher, modifier, artUrl = item.artUrl) { onShow(item) } + + else -> Unit + } + } + if (state.libraryHasMore) { + item(key = "more") { + PagingFooter(state.libraryMorePending, onLoadMore) + } + } + } + } +} + +@Composable +fun CollectionScreen( + collection: SpotifyCollection, + state: SpotifyUiState, + onBack: () -> Unit, + onPlay: () -> Unit, + onItem: (Int) -> Unit, + onSave: () -> Unit, + onArtist: () -> Unit, + onQueue: () -> Unit, + onQueueItem: (SpotifyMediaItem) -> Unit, + onNowPlaying: () -> Unit, + onUndo: () -> Unit, + onLoadMore: () -> Unit, + loadingMore: Boolean, + onDismissNotice: () -> Unit +) { + val colors = SidekitTheme.colors + val saved = "${collection.type.route}:${collection.id}" in state.savedCollectionKeys + val actions = spotifyBar(state, onDismissNotice, onUndo, spotifyBack("Back", onBack).first()) + val options = buildList { + add( + SideAction( + "save", + if (saved) "Saved" else "Save", + if (saved) SidekitIcon.Check else SidekitIcon.Bookmark, + tone = if (saved) ActionTone.Secondary else ActionTone.Primary, + onClick = onSave + ) + ) + if (collection.artistId != null) add(SideAction("artist", "Artist", SidekitIcon.Artist, onClick = onArtist)) + collection.items.firstOrNull()?.let { add(SideAction("queue", "Add to queue", SidekitIcon.QueueMusic, onClick = onQueue)) } + add(SideAction("nowPlaying", "Go to now playing", SidekitIcon.MusicNote2, onClick = onNowPlaying)) + } + SidekitScaffold(title = collection.title, actions = actions, options = options) { padding -> + val listState = rememberLazyListState() + val swipes = rememberSwipeRevealGroup() + val playFocus = remember { FocusRequester() } + LaunchedEffect(collection.type, collection.id) { runCatching { playFocus.requestFocus() } } + SidekitList(padding, state = listState) { + item(key = "tiles") { + SidekitItemRow(Modifier.listFade(listState, 0)) { + ArtworkTile(collection.artRes, "${collection.title} artwork", Modifier.weight(1f), collection.artUrl) + PlayTile(onPlay, Modifier.weight(1f).focusRequester(playFocus)) + SpotifyIconTile( + if (saved) SidekitIcon.Check else SidekitIcon.Bookmark, + if (saved) "Saved" else "Save", + onSave, + tone = if (saved) SpotifyTileTone.On else SpotifyTileTone.Neutral + ) + if (collection.artistId != null) { + SpotifyIconTile(SidekitIcon.Artist, "Artist", onArtist) + } + } + } + item(key = "meta") { + SidekitText( + collection.subtitle, + Modifier.padding(start = 18.dp, end = 18.dp), + style = SidekitTheme.typography.caption, + color = colors.surfaceA6, + maxLines = 1 + ) + } + itemsIndexed(collection.items, key = { index, item -> "$index:${item.id}" }) { index, item -> + SidekitSwipeRow( + key = item.id, + group = swipes, + modifier = Modifier.listFade(listState, index + 2), + action = queueAction(item, onQueueItem) + ) { + SpotifyMediaRow( + item, + Modifier, + index = index, + showArt = false, + secondary = if (item is SpotifyEpisode) formatPublishedDate(item.published) else item.creator, + onClick = { onItem(index) } + ) + } + } + if (collection.hasMore) { + item(key = "more") { + PagingFooter(loadingMore, onLoadMore) + } + } + } + } +} + +@Composable +fun ArtistScreen( + artist: SpotifyArtist, + albums: List, + tracks: List, + state: SpotifyUiState, + onBack: () -> Unit, + onPlay: () -> Unit, + onTrack: (SpotifyTrack) -> Unit, + onQueue: (SpotifyTrack) -> Unit, + onAlbum: (SpotifyAlbum) -> Unit, + onFollow: () -> Unit, + onNowPlaying: () -> Unit, + onUndo: () -> Unit, + onLoadMoreAlbums: () -> Unit, + onLoadMoreTracks: () -> Unit, + albumHasMore: Boolean, + trackHasMore: Boolean, + loadingMore: Boolean, + onDismissNotice: () -> Unit +) { + val followed = artist.id in state.followedArtistIds + val actions = spotifyBar(state, onDismissNotice, onUndo, spotifyBack("Back", onBack).first()) + SidekitScaffold( + title = artist.name, + actions = actions, + options = listOf( + SideAction("follow", if (followed) "Following" else "Follow", tone = if (followed) ActionTone.Secondary else ActionTone.Primary, onClick = onFollow), + SideAction("nowPlaying", "Go to now playing", SidekitIcon.MusicNote2, onClick = onNowPlaying) + ) + ) { padding -> + val listState = rememberLazyListState() + val swipes = rememberSwipeRevealGroup() + SidekitList(padding, state = listState) { + item(key = "tiles") { + SidekitItemRow(Modifier.listFade(listState, 0)) { + PlayTile(onPlay, Modifier.weight(1f)) + ArtworkTile(artist.artRes, "${artist.name} artwork", Modifier.weight(1f), artist.artUrl) + } + } + item(key = "topLabel") { SectionLabel("Top tracks") } + itemsIndexed(tracks, key = { index, track -> "$index:${track.id}" }) { index, track -> + SidekitSwipeRow( + key = track.id, + group = swipes, + modifier = Modifier.listFade(listState, index + 2), + action = queueAction(track, onQueue) + ) { + SpotifyMediaRow(track, Modifier, secondary = track.artist, onClick = { onTrack(track) }) + } + } + if (trackHasMore) { + item(key = "tracksMore") { PagingFooter(loadingMore, onLoadMoreTracks) } + } + item(key = "albumsLabel") { SectionLabel("Albums") } + itemsIndexed(albums, key = { index, album -> "$index:${album.id}" }) { index, album -> + SpotifyLibraryRow( + album.artRes, + album.title, + "${album.year}" + if (album.trackCount > 0) " · ${album.trackCount} tracks" else "", + Modifier.listFade(listState, index + tracks.size + 3), + artUrl = album.artUrl + ) { onAlbum(album) } + } + if (albumHasMore) { + item(key = "albumsMore") { PagingFooter(loadingMore, onLoadMoreAlbums) } + } + } + } +} + +private fun libraryKey(item: Any): String = when (item) { + is SpotifyTrack -> "t${item.id}" + is SpotifyPlaylist -> "p${item.id}" + is SpotifyAlbum -> "a${item.id}" + is SpotifyArtist -> "r${item.id}" + is SpotifyShow -> "s${item.id}" + else -> item.toString() +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifyModels.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifyModels.kt new file mode 100644 index 00000000..ee3f5b63 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifyModels.kt @@ -0,0 +1,201 @@ +package hrln.interactive.sidekit.apps.spotify + +import androidx.annotation.DrawableRes + +/** A saved album, playlist or show. Collections share one screen, so they share one shape. */ +enum class SpotifyCollectionType(val route: String) { + Album("album"), + Playlist("playlist"), + Show("show") +} + +/** + * Everything that can be played. Albums and playlists hold [TrackItem]s, shows hold + * [EpisodeItem]s; the queue and Now Playing never need to know which screen it came from. + */ +sealed interface SpotifyMediaItem { + val id: String + val title: String + val creator: String + val artistId: String? + val artRes: Int + val durationMs: Long + val playable: Boolean + val explicit: Boolean + + /** Canonical Spotify URI for the real catalog; null for the local test fixtures. */ + val uri: String? get() = null + + /** Remote cover art for the real catalog; null for local fixtures and placeholder art. */ + val artUrl: String? get() = null +} + +data class SpotifyTrack( + override val id: String, + override val title: String, + val artist: String, + override val artistId: String, + val albumId: String, + override val artRes: Int, + override val durationMs: Long, + override val explicit: Boolean = false, + override val playable: Boolean = true, + override val uri: String? = null, + override val artUrl: String? = null +) : SpotifyMediaItem { + override val creator: String get() = artist +} + +data class SpotifyEpisode( + override val id: String, + override val title: String, + val show: String, + val showId: String, + val published: String, + override val artRes: Int, + override val durationMs: Long, + val video: Boolean = false, + override val uri: String? = null, + override val artUrl: String? = null +) : SpotifyMediaItem { + override val creator: String get() = show + override val artistId: String? get() = null + override val playable: Boolean get() = !video + override val explicit: Boolean get() = false +} + +data class SpotifyAlbum( + val id: String, + val title: String, + val artist: String, + val artistId: String, + val year: Int, + @param:DrawableRes val artRes: Int, + val tracks: List, + val artUrl: String? = null, + val trackCount: Int = tracks.size +) + +data class SpotifyPlaylist( + val id: String, + val name: String, + val owner: String, + val ownerId: String?, + @param:DrawableRes val artRes: Int, + val tracks: List, + val artUrl: String? = null, + val trackCount: Int = tracks.size +) + +data class SpotifyShow( + val id: String, + val name: String, + val publisher: String, + @param:DrawableRes val artRes: Int, + val episodes: List, + val artUrl: String? = null, + val episodeCount: Int = episodes.size +) + +data class SpotifyArtist( + val id: String, + val name: String, + @param:DrawableRes val artRes: Int, + val albumIds: List, + val artUrl: String? = null, + val albumCount: Int = albumIds.size +) + +/** One screen's worth of a collection, already resolved by the gateway. */ +data class SpotifyCollection( + val type: SpotifyCollectionType, + val id: String, + val title: String, + val subtitle: String, + val artRes: Int, + val items: List, + val artistId: String? = null, + val artUrl: String? = null, + val totalItems: Int = items.size, + val hasMore: Boolean = false +) + +/** How many rows a catalog request asks for. Kept small for the Sidephone's weak CPU. */ +const val SPOTIFY_PAGE_SIZE = 50 + +/** One page of a catalog list. [total] is null when the endpoint doesn't report it. */ +data class SpotifyPage( + val items: List = emptyList(), + val total: Int? = null, + val hasMore: Boolean = false +) { + companion object { + fun of(items: List, offset: Int, limit: Int, total: Int? = null): SpotifyPage = SpotifyPage( + items = items, + total = total, + hasMore = if (total != null) offset + items.size < total else items.size >= limit + ) + } +} + +data class SpotifySearchResults( + val tracks: List = emptyList(), + val artists: List = emptyList(), + val albums: List = emptyList(), + val playlists: List = emptyList(), + val shows: List = emptyList() +) { + val isEmpty: Boolean get() = tracks.isEmpty() && artists.isEmpty() && albums.isEmpty() && playlists.isEmpty() && shows.isEmpty() +} + +enum class LibraryFilter(val label: String, val empty: String) { + LikedSongs("Liked songs", "No liked songs"), + Playlists("Playlists", "No saved playlists"), + Albums("Albums", "No saved albums"), + Artists("Artists", "No followed artists"), + Shows("Shows", "No saved shows") +} + +enum class SearchScope(val label: String) { + Tracks("Tracks"), + Artists("Artists"), + Albums("Albums"), + Playlists("Playlists"), + Shows("Shows") +} + +enum class RepeatMode(val label: String) { + Off("Repeat: off"), + Context("Repeat: context"), + Track("Repeat: track") + ; + + fun next(): RepeatMode = when (this) { + Off -> Context + Context -> Track + Track -> Off + } +} + +enum class AudioQuality(val label: String, val bitrateKbps: Int) { + Normal("Normal", 96), + High("High", 160), + VeryHigh("Very high", 320) + ; + + fun next(): AudioQuality = entries[(ordinal + 1) % entries.size] +} + +data class SpotifyQueueItem( + val item: SpotifyMediaItem, + val isCurrent: Boolean = false +) + +/** Spotify metadata dates are ISO calendar dates; show them the way Sidekit shows release dates. */ +fun formatPublishedDate(value: String): String { + val date = value.substringBefore('T').substringBefore(' ') + if (date.length != 10 || date[4] != '-' || date[7] != '-') return value + return runCatching { + java.time.LocalDate.parse(date).format(java.time.format.DateTimeFormatter.ofPattern("d MMM uuuu")) + }.getOrDefault(value) +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifyNowPlayingScreens.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifyNowPlayingScreens.kt new file mode 100644 index 00000000..f9b14bc1 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifyNowPlayingScreens.kt @@ -0,0 +1,284 @@ +package hrln.interactive.sidekit.apps.spotify + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import hrln.interactive.sidekit.design.action.ActionMotion +import hrln.interactive.sidekit.design.action.ActionTone +import hrln.interactive.sidekit.design.action.SideAction +import hrln.interactive.sidekit.design.action.undoBar +import hrln.interactive.sidekit.design.components.SidekitItemRow +import hrln.interactive.sidekit.design.components.SidekitList +import hrln.interactive.sidekit.design.components.SidekitScaffold +import hrln.interactive.sidekit.design.components.SidekitSwipeRow +import hrln.interactive.sidekit.design.components.SidekitText +import hrln.interactive.sidekit.design.components.SwipeAction +import hrln.interactive.sidekit.design.components.listFade +import hrln.interactive.sidekit.design.components.rememberSwipeRevealGroup +import hrln.interactive.sidekit.design.icons.SidekitIcon +import hrln.interactive.sidekit.design.theme.SidekitTheme +import kotlinx.coroutines.delay + +@Composable +fun SpotifyNowPlayingScreen( + state: SpotifyUiState, + onBack: () -> Unit, + onPlayPause: () -> Unit, + onPrevious: () -> Unit, + onNext: () -> Unit, + onSeek: (Long) -> Unit, + onShuffle: () -> Unit, + onRepeat: () -> Unit, + onSave: () -> Unit, + onQueue: () -> Unit, + onAlbum: () -> Unit, + onShow: () -> Unit, + onArtist: () -> Unit, + onUndo: () -> Unit, + onDismissNotice: () -> Unit +) { + val current = state.current + // Episodes have no saved-track endpoint, so Save is a track-only action. + val save = (current as? SpotifyTrack)?.let { + SideAction.square( + "save", + if (state.savedCurrent) SidekitIcon.Bookmark else SidekitIcon.BookmarkBorder, + if (state.savedCurrent) "Saved" else "Save", + onSave, + tone = if (state.savedCurrent) ActionTone.Secondary else ActionTone.Muted, + motion = ActionMotion.Up + ) + } + val actions = spotifyBar( + state, + onDismissNotice, + onUndo, + spotifyBack("Back", onBack).first(), + listOfNotNull(save) + ) + val options = if (current == null) { + emptyList() + } else { + buildList { + add(SideAction("queue", "Queue", SidekitIcon.QueueMusic, onClick = onQueue)) + if (current is SpotifyTrack) add(SideAction("album", "Album", SidekitIcon.Album, onClick = onAlbum)) + } + } + SidekitScaffold(title = null, actions = actions, options = options) { padding -> + if (current == null) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + EmptyMessage("Nothing playing", hint = "Pick something from your library") + } + } else { + Column( + Modifier.fillMaxSize().padding(padding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + BoxWithConstraints(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + val art = minOf(maxWidth, maxHeight) + SpotifyCover( + current.artRes, + Modifier.size(art), + shape = SidekitTheme.shapes.item, + bordered = true, + contentDescription = "${current.title} artwork", + artUrl = current.artUrl, + onClick = when (current) { + is SpotifyTrack -> onAlbum + is SpotifyEpisode -> onShow + else -> null + }, + onClickLabel = when (current) { + is SpotifyTrack -> "Open album" + is SpotifyEpisode -> "Open show" + else -> null + } + ) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + SidekitText( + current.title, + Modifier.fillMaxWidth(), + style = SidekitTheme.typography.headline, + textAlign = TextAlign.Center, + maxLines = 2 + ) + if (current.creator.isNotBlank()) { + SidekitText( + current.creator, + Modifier.fillMaxWidth().clickable(enabled = current.artistId != null, onClickLabel = "Open artist", onClick = onArtist), + style = SidekitTheme.typography.label, + color = SidekitTheme.colors.surfaceA6, + textAlign = TextAlign.Center + ) + } + } + SpotifySeek(state.positionMs, current.durationMs, onSeek) + SidekitItemRow { + SpotifyIconTile( + SidekitIcon.Shuffle, + if (state.shuffle) "Shuffle on" else "Shuffle off", + onShuffle, + tone = if (state.shuffle) SpotifyTileTone.On else SpotifyTileTone.Neutral, + weight = 0.85f + ) + SpotifyIconTile(SidekitIcon.SkipPrevious, "Previous", onPrevious, weight = 0.85f) + SpotifyIconTile( + if (state.isPlaying) SidekitIcon.Pause else SidekitIcon.PlayArrow, + if (state.isPlaying) "Pause" else "Play", + onPlayPause, + tone = SpotifyTileTone.Play, + weight = 1.2f + ) + SpotifyIconTile(SidekitIcon.SkipNext, "Next", onNext, weight = 0.85f) + SpotifyIconTile( + SidekitIcon.Undo, + state.repeat.label, + onRepeat, + tone = if (state.repeat == RepeatMode.Off) SpotifyTileTone.Neutral else SpotifyTileTone.On, + weight = 0.85f + ) + } + } + } + } +} + +@Composable +private fun removeAction(label: String, onClick: () -> Unit) = SwipeAction( + label = label, + icon = SidekitIcon.DeleteForever, + color = SidekitTheme.colors.dangerA1, + armedColor = SidekitTheme.colors.dangerA2, + destructive = true, + onClick = onClick +) + +@Composable +fun QueueScreen( + state: SpotifyUiState, + onBack: () -> Unit, + onNowPlaying: () -> Unit, + onJump: (String) -> Unit, + onPlayNext: (SpotifyMediaItem) -> Unit, + onRemove: (SpotifyMediaItem) -> Unit, + onRemoveHistory: (SpotifyMediaItem) -> Unit, + onClearUpcoming: () -> List, + onRestoreUpcoming: (List) -> Unit, + onShuffle: () -> Unit, + onDismissNotice: () -> Unit +) { + var focusedId by remember { mutableStateOf(state.current?.id) } + var pendingUndo by remember { mutableStateOf?>(null) } + LaunchedEffect(pendingUndo) { + if (pendingUndo != null) { + delay(5_000L) + pendingUndo = null + } + } + val back = spotifyBack("Back", onBack).first() + val normal = withNotice( + state.notice, + onDismissNotice, + listOf(back, SideAction("nowPlaying", "Now playing", SidekitIcon.MusicNote2, onClick = onNowPlaying)) + ) + val actions = if (pendingUndo != null) { + undoBar( + back, + SideAction.undo("undoClear", "Undo", "Undo clear upcoming") { + onRestoreUpcoming(pendingUndo.orEmpty()) + pendingUndo = null + } + ) + } else { + normal + } + val focused = (listOfNotNull(state.current) + state.upcoming + state.history).firstOrNull { it.id == focusedId } + val options = buildList { + if (focused != null && focused.id != state.current?.id && focused.id !in state.history.map { it.id }) { + add(SideAction("playNext", "Play next", SidekitIcon.ArrowUpward, onClick = { onPlayNext(focused) })) + add(SideAction("remove", "Remove", SidekitIcon.DeleteForever, tone = ActionTone.Destructive, onClick = { onRemove(focused) })) + } + add(SideAction("clear", "Clear upcoming", SidekitIcon.DeleteForever, tone = ActionTone.Destructive, onClick = { pendingUndo = onClearUpcoming() })) + add(SideAction("shuffle", if (state.shuffle) "Shuffle on" else "Shuffle off", SidekitIcon.Shuffle, onClick = onShuffle)) + } + SidekitScaffold(title = "Queue", actions = actions, options = options) { padding -> + val listState = rememberLazyListState() + val swipes = rememberSwipeRevealGroup() + val currentFocus = remember { FocusRequester() } + LaunchedEffect(state.current?.id) { if (focusedId == null) runCatching { currentFocus.requestFocus() } } + SidekitList(padding, state = listState) { + val current = state.current + if (current == null) { + item(key = "empty") { EmptyMessage("Nothing queued", hint = "Search to find something to play") } + } else { + item(key = "current") { + SpotifyMediaRow( + current, + Modifier.listFade(listState, 0).focusRequester(currentFocus).onFocusChanged { if (it.isFocused) focusedId = current.id }, + secondary = "Now playing", + onClick = { onJump(current.id) } + ) + } + itemsIndexed(state.upcoming, key = { _, item -> "u${item.id}" }) { index, item -> + SidekitSwipeRow( + key = "u${item.id}", + group = swipes, + modifier = Modifier.listFade(listState, index + 1), + action = removeAction("Remove ${item.title} from queue") { onRemove(item) } + ) { + SpotifyMediaRow( + item, + Modifier.onFocusChanged { if (it.isFocused) focusedId = item.id }, + secondary = item.creator, + onClick = { onJump(item.id) } + ) + } + } + if (state.history.isNotEmpty()) { + item(key = "historyLabel") { SectionLabel("History") } + itemsIndexed(state.history.take(20), key = { _, item -> "h${item.id}" }) { index, item -> + SidekitSwipeRow( + key = "h${item.id}", + group = swipes, + modifier = Modifier.listFade(listState, index + 2 + state.upcoming.size), + action = removeAction("Remove ${item.title} from history") { onRemoveHistory(item) } + ) { + SpotifyMediaRow( + item, + Modifier, + secondary = item.creator, + onClick = { onJump(item.id) } + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifySearchScreen.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifySearchScreen.kt new file mode 100644 index 00000000..7c2d1ca1 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifySearchScreen.kt @@ -0,0 +1,216 @@ +package hrln.interactive.sidekit.apps.spotify + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import hrln.interactive.sidekit.design.action.ActionMotion +import hrln.interactive.sidekit.design.action.ActionTone +import hrln.interactive.sidekit.design.action.SideAction +import hrln.interactive.sidekit.design.components.SidekitList +import hrln.interactive.sidekit.design.components.SidekitScaffold +import hrln.interactive.sidekit.design.components.SidekitSwipeRow +import hrln.interactive.sidekit.design.components.SidekitTextField +import hrln.interactive.sidekit.design.components.SwipeAction +import hrln.interactive.sidekit.design.components.listFade +import hrln.interactive.sidekit.design.components.rememberSwipeRevealGroup +import hrln.interactive.sidekit.design.icons.SidekitIcon +import hrln.interactive.sidekit.design.theme.SidekitTheme + +private const val SEARCH_SECTION_PAGE = 10 + +@Composable +fun SpotifySearchScreen( + state: SpotifyUiState, + onBack: () -> Unit, + onQuery: (String) -> Unit, + onClear: () -> Unit, + onToggleScope: (SearchScope) -> Unit, + onTrack: (SpotifyTrack) -> Unit, + onQueue: (SpotifyTrack) -> Unit, + onArtist: (SpotifyArtist) -> Unit, + onAlbum: (SpotifyAlbum) -> Unit, + onPlaylist: (SpotifyPlaylist) -> Unit, + onShow: (SpotifyShow) -> Unit, + onLoadMore: (SearchScope) -> Unit, + onDismissNotice: () -> Unit +) { + var expanded by remember(state.query) { mutableStateOf(emptyMap()) } + val results = state.searchResults + fun visible(scope: SearchScope, total: Int): Int = minOf(expanded[scope] ?: SEARCH_SECTION_PAGE, total) + fun showMore(scope: SearchScope, total: Int) { + val current = visible(scope, total) + expanded = expanded + (scope to (current + SEARCH_SECTION_PAGE)) + if (current >= total && scope in state.searchHasMore) onLoadMore(scope) + } + val actions = withNotice( + state.notice, + onDismissNotice, + buildList { + add(spotifyBack("Back", onBack).first()) + if (state.query.isNotEmpty()) add(SideAction("clear", "Clear", SidekitIcon.Close, tone = ActionTone.Muted, onClick = onClear)) + } + ) + val scopes = SearchScope.entries.map { scope -> + SideAction( + "scope.${scope.name}", + scope.label, + tone = if (scope in state.searchScopes) ActionTone.Primary else ActionTone.Muted, + onClick = { onToggleScope(scope) } + ) + } + val focus = remember { FocusRequester() } + SidekitScaffold( + title = null, + header = { + Box( + Modifier.fillMaxWidth().height(SidekitTheme.spacing.headerHeight) + .padding(horizontal = SidekitTheme.spacing.itemPadding).padding(top = SidekitTheme.spacing.headerPaddingTop) + ) { + SidekitTextField( + state.query, + onQuery, + Modifier.fillMaxWidth().semantics { contentDescription = "Search Spotify" }, + textStyle = SidekitTheme.typography.title, + placeholder = "Search Spotify", + focusRequester = focus, + focusOnKeypad = true, + singleLine = true, + autoFocus = true + ) + } + }, + actions = actions, + options = scopes + ) { padding -> + val listState = rememberLazyListState() + val swipes = rememberSwipeRevealGroup() + SidekitList(padding, state = listState) { + when { + state.query.isBlank() -> item(key = "idle") { + EmptyMessage("Search your library and Spotify", hint = "Tracks, artists, albums, playlists and shows") + } + + state.query.trim().length < 2 -> item(key = "short") { + EmptyMessage("Keep typing", hint = "Type at least two letters to search") + } + + state.searchPending && results.isEmpty -> item(key = "loading") { + EmptyMessage("Searching…", hint = "Spotify is looking for matches") + } + + results.isEmpty -> item(key = "noResults") { + EmptyMessage("No results for \"${state.query.trim()}\"", hint = "Try another spelling or turn on more scopes in Options") + } + + else -> { + var slot = 0 + if (results.tracks.isNotEmpty()) { + item(key = "tracksLabel") { SectionLabel("Tracks") } + val shown = results.tracks.take(visible(SearchScope.Tracks, results.tracks.size)) + itemsIndexed(shown, key = { _, track -> "t${track.id}" }) { index, track -> + SidekitSwipeRow( + key = track.id, + group = swipes, + modifier = Modifier.listFade(listState, slot + index), + action = SwipeAction( + label = "Add ${track.title} to queue", + icon = SidekitIcon.QueueMusic, + color = SidekitTheme.colors.primaryA1, + armedColor = SidekitTheme.colors.primaryA2, + onClick = { onQueue(track) } + ) + ) { + SpotifyMediaRow(track, Modifier, secondary = track.artist) { onTrack(track) } + } + } + slot += shown.size + if (shown.size < results.tracks.size || SearchScope.Tracks in state.searchHasMore) { + item(key = "moreTracks") { + SearchMoreRow(SearchScope.Tracks, "More tracks", state) { showMore(SearchScope.Tracks, results.tracks.size) } + } + } + } + if (results.artists.isNotEmpty()) { + item(key = "artistsLabel") { SectionLabel("Artists") } + val shown = results.artists.take(visible(SearchScope.Artists, results.artists.size)) + itemsIndexed(shown, key = { _, artist -> "r${artist.id}" }) { index, artist -> + SpotifyLibraryRow(artist.artRes, artist.name, "Artist", Modifier.listFade(listState, slot + index), artUrl = artist.artUrl) { onArtist(artist) } + } + slot += shown.size + if (shown.size < results.artists.size || SearchScope.Artists in state.searchHasMore) { + item(key = "moreArtists") { + SearchMoreRow(SearchScope.Artists, "More artists", state) { showMore(SearchScope.Artists, results.artists.size) } + } + } + } + if (results.albums.isNotEmpty()) { + item(key = "albumsLabel") { SectionLabel("Albums") } + val shown = results.albums.take(visible(SearchScope.Albums, results.albums.size)) + itemsIndexed(shown, key = { _, album -> "a${album.id}" }) { index, album -> + SpotifyLibraryRow(album.artRes, album.title, "${album.artist} · ${album.year}", Modifier.listFade(listState, slot + index), artUrl = album.artUrl) { onAlbum(album) } + } + slot += shown.size + if (shown.size < results.albums.size || SearchScope.Albums in state.searchHasMore) { + item(key = "moreAlbums") { + SearchMoreRow(SearchScope.Albums, "More albums", state) { showMore(SearchScope.Albums, results.albums.size) } + } + } + } + if (results.playlists.isNotEmpty()) { + item(key = "playlistsLabel") { SectionLabel("Playlists") } + val shown = results.playlists.take(visible(SearchScope.Playlists, results.playlists.size)) + itemsIndexed(shown, key = { _, playlist -> "p${playlist.id}" }) { index, playlist -> + SpotifyLibraryRow(playlist.artRes, playlist.name, "${playlist.owner} · ${playlist.trackCount} tracks", Modifier.listFade(listState, slot + index), artUrl = playlist.artUrl) { onPlaylist(playlist) } + } + slot += shown.size + if (shown.size < results.playlists.size || SearchScope.Playlists in state.searchHasMore) { + item(key = "morePlaylists") { + SearchMoreRow(SearchScope.Playlists, "More playlists", state) { showMore(SearchScope.Playlists, results.playlists.size) } + } + } + } + if (results.shows.isNotEmpty()) { + item(key = "showsLabel") { SectionLabel("Shows") } + val shown = results.shows.take(visible(SearchScope.Shows, results.shows.size)) + itemsIndexed(shown, key = { _, show -> "s${show.id}" }) { index, show -> + SpotifyLibraryRow(show.artRes, show.name, show.publisher, Modifier.listFade(listState, slot + index), artUrl = show.artUrl) { onShow(show) } + } + if (shown.size < results.shows.size || SearchScope.Shows in state.searchHasMore) { + item(key = "moreShows") { + SearchMoreRow(SearchScope.Shows, "More shows", state) { showMore(SearchScope.Shows, results.shows.size) } + } + } + } + } + } + } + } +} + +@Composable +private fun SearchMoreRow( + scope: SearchScope, + label: String, + state: SpotifyUiState, + onMore: () -> Unit +) { + val loading = scope in state.searchMorePending + TerminalRow( + label, + secondary = if (loading) "Loading…" else null, + onClick = if (loading) null else onMore + ) +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifySettingsScreens.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifySettingsScreens.kt new file mode 100644 index 00000000..67b31616 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifySettingsScreens.kt @@ -0,0 +1,201 @@ +package hrln.interactive.sidekit.apps.spotify + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.unit.dp +import hrln.interactive.sidekit.design.action.ActionMotion +import hrln.interactive.sidekit.design.action.ActionTone +import hrln.interactive.sidekit.design.action.SideAction +import hrln.interactive.sidekit.design.components.ItemColour +import hrln.interactive.sidekit.design.components.SidekitCard +import hrln.interactive.sidekit.design.components.SidekitItem +import hrln.interactive.sidekit.design.components.SidekitList +import hrln.interactive.sidekit.design.components.SidekitScaffold +import hrln.interactive.sidekit.design.components.SidekitText +import hrln.interactive.sidekit.design.components.listFade +import hrln.interactive.sidekit.design.icons.SidekitIcon +import hrln.interactive.sidekit.design.theme.SidekitTheme + +private enum class SettingsConfirmation { SignOut, ClearHistory } + +@Composable +fun SpotifySettingsScreen( + state: SpotifyUiState, + onBack: () -> Unit, + onAudioQuality: () -> Unit, + onAutoplay: () -> Unit, + onClearCache: () -> Unit, + onClearHistory: () -> Unit, + onSignOut: () -> Unit, + onNotice: (String) -> Unit, + onDismissNotice: () -> Unit +) { + var confirmation by remember { mutableStateOf(null) } + val back = spotifyBack("Back", onBack).first() + val normal = withNotice( + state.notice, + onDismissNotice, + buildList { + add(back) + if (state.signedIn) { + add(SideAction.delete("signOut", "Sign out", onClick = { confirmation = SettingsConfirmation.SignOut })) + } + } + ) + val actions = when (confirmation) { + SettingsConfirmation.SignOut -> listOf( + SideAction.cancel("cancelSignOut", "Cancel") { confirmation = null }, + SideAction.delete("confirmSignOut", "Sign out", onClick = { + confirmation = null + onSignOut() + }) + ) + + SettingsConfirmation.ClearHistory -> listOf( + SideAction.cancel("cancelClear", "Cancel") { confirmation = null }, + SideAction.delete("confirmClear", "Clear", onClick = { + confirmation = null + onClearHistory() + }) + ) + + null -> normal + } + SidekitScaffold( + title = "Settings", + actions = actions, + options = listOf( + SideAction("about", "About", SidekitIcon.Docs, onClick = { onNotice("Sideplay · a Sidekit sub-app") }), + SideAction("cache", "Clear cache", SidekitIcon.DeleteForever, onClick = onClearCache) + ) + ) { padding -> + val listState = rememberLazyListState() + val first = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { first.requestFocus() } } + SidekitList(padding, state = listState) { + item(key = "quality") { + SidekitItem( + "Audio quality", + Modifier.listFade(listState, 0).focusRequester(first), + secondary = state.audioQuality.label, + secondaryToEnd = true, + showLinkArrow = true, + showLinkArrowWithSecondary = true, + onClick = onAudioQuality + ) + } + item(key = "autoplay") { + SidekitItem( + "Autoplay", + Modifier.listFade(listState, 1), + secondary = if (state.autoplay) "On" else "Off", + secondaryToEnd = true, + showLinkArrow = true, + showLinkArrowWithSecondary = true, + onClick = onAutoplay + ) + } + item(key = "cache") { + SidekitItem( + "Cache", + Modifier.listFade(listState, 2), + secondary = "1 GB", + secondaryToEnd = true, + showLinkArrow = true, + showLinkArrowWithSecondary = true, + onClick = onClearCache + ) + } + item(key = "history") { + SidekitItem( + "Clear history", + Modifier.listFade(listState, 3), + secondary = "${state.history.size} tracks", + secondaryToEnd = true, + showLinkArrow = true, + showLinkArrowWithSecondary = true, + onClick = { confirmation = SettingsConfirmation.ClearHistory } + ) + } + item(key = "account") { + SidekitItem( + "Account", + Modifier.listFade(listState, 4), + secondary = if (state.signedIn) "Signed in" else "Not signed in", + secondaryToEnd = true + ) + } + } + } +} + +@Composable +fun SpotifySignInScreen( + state: SpotifyUiState, + onBack: () -> Unit, + onLogIn: () -> Unit, + onDismissNotice: () -> Unit +) { + val colors = SidekitTheme.colors + val actions = withNotice( + state.notice, + onDismissNotice, + listOf(backToLauncher(onBack), SideAction.forward("login", "Log in", onLogIn)) + ) + SidekitScaffold( + title = "Sign in", + actions = actions + ) { padding -> + val listState = rememberLazyListState() + SidekitList(padding, state = listState) { + item(key = "explain") { + SidekitCard(Modifier.listFade(listState, 0).fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SidekitText("Sideplay plays from your Spotify account.", style = SidekitTheme.typography.bodySmall, maxLines = 2) + SidekitText( + "Your password is entered in the browser, never here.", + style = SidekitTheme.typography.caption, + color = colors.surfaceA6, + maxLines = 2 + ) + } + } + } + item(key = "login") { + SidekitItem( + "Log in with Spotify", + Modifier.listFade(listState, 1), + showLinkArrow = true, + onClick = onLogIn + ) + } + item(key = "status") { + SidekitItem( + "Status", + Modifier.listFade(listState, 2), + secondary = when { + state.authPending -> "Waiting for browser" + state.signedIn -> "Connected" + else -> "Waiting" + }, + secondaryToEnd = true, + colour = ItemColour.Muted + ) + } + } + } +} + +private fun backToLauncher(onBack: () -> Unit) = SideAction.back("back", "Launcher", onBack) diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifyViewModel.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifyViewModel.kt new file mode 100644 index 00000000..f5836e67 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/SpotifyViewModel.kt @@ -0,0 +1,1315 @@ +package hrln.interactive.sidekit.apps.spotify + +import android.os.SystemClock +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import hrln.interactive.sidekit.apps.spotify.data.SpotifyCatalog +import hrln.interactive.sidekit.apps.spotify.data.SpotifyFailure +import hrln.interactive.sidekit.apps.spotify.data.SpotifyGatewayException +import hrln.interactive.sidekit.apps.spotify.data.SpotifyHistoryRepository +import hrln.interactive.sidekit.apps.spotify.data.SpotifyLibraryWriter +import hrln.interactive.sidekit.apps.spotify.data.SpotifyQueueRepository +import hrln.interactive.sidekit.apps.spotify.data.SpotifySession +import hrln.interactive.sidekit.ui.UndoWindow +import kotlin.math.abs +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject + +/** The last save/follow change, offered back by the action bar for a few seconds. */ +sealed interface SpotifyUndo { + val contentDescription: String + + data class Track(val id: String, val label: String?) : SpotifyUndo { + override val contentDescription: String = if (label == null) "Undo like change" else "Undo save to $label" + } + + data class Collection(val type: SpotifyCollectionType, val id: String) : SpotifyUndo { + override val contentDescription: String = "Undo library change" + } + + data class Artist(val id: String, val name: String) : SpotifyUndo { + override val contentDescription: String = "Undo follow change" + } +} + +data class SpotifyUiState( + val signedIn: Boolean = false, + val current: SpotifyMediaItem? = null, + val upcoming: List = emptyList(), + val history: List = emptyList(), + val recent: List = emptyList(), + val libraryItems: List = emptyList(), + val searchResults: SpotifySearchResults = SpotifySearchResults(), + val searchPending: Boolean = false, + val authPending: Boolean = false, + val libraryLoading: Boolean = false, + val libraryHasMore: Boolean = false, + val libraryMorePending: Boolean = false, + val searchHasMore: Set = emptySet(), + val searchMorePending: Set = emptySet(), + val collectionMorePending: Set = emptySet(), + val artistAlbumHasMore: Map = emptyMap(), + val artistTrackHasMore: Map = emptyMap(), + val artistMorePending: Set = emptySet(), + val isPlaying: Boolean = false, + val positionMs: Long = 0L, + val shuffle: Boolean = false, + val repeat: RepeatMode = RepeatMode.Off, + val savedTrackIds: Set = emptySet(), + val savedCollectionKeys: Set = emptySet(), + val followedArtistIds: Set = emptySet(), + val collections: Map = emptyMap(), + val artists: Map = emptyMap(), + val artistAlbums: Map> = emptyMap(), + val artistTracks: Map> = emptyMap(), + val pendingCollections: Set = emptySet(), + val pendingArtists: Set = emptySet(), + val filter: LibraryFilter = LibraryFilter.LikedSongs, + val searchScopes: Set = SearchScope.entries.toSet(), + val query: String = "", + val audioQuality: AudioQuality = AudioQuality.High, + val autoplay: Boolean = true, + val notice: String? = null, + val undo: SpotifyUndo? = null +) { + val savedCurrent: Boolean get() = current?.let { it.id in savedTrackIds } ?: false +} + +/** + * State holder for the Sideplay UI. Catalog reads, queue ordering and local history live in + * their own domain objects; this class only maps them to the single immutable screen state and + * implements the optimistic save/follow behaviour the action bars expect. + */ +class SpotifyViewModel( + private val catalog: SpotifyCatalog, + private val writer: SpotifyLibraryWriter? = null, + private val queue: SpotifyQueueRepository = SpotifyQueueRepository(), + private val history: SpotifyHistoryRepository = SpotifyHistoryRepository(), + private val session: SpotifySession? = null, + private val openBrowser: (suspend (String) -> Unit)? = null, + private val clearArtworkCache: () -> Unit = {} +) : ViewModel() { + private val _state = MutableStateFlow(SpotifyUiState(signedIn = session?.signedIn?.value ?: false)) + val state: StateFlow = _state.asStateFlow() + + /** The five-second offer; leaving the screen commits the change instead of carrying Undo away. */ + val undoWindow = UndoWindow(viewModelScope) { _state.update { it.copy(undo = null) } } + + private var searchJob: Job? = null + private var autoplayJob: Job? = null + private val savedTrackTouched = mutableSetOf() + private val savedCollectionTouched = mutableSetOf() + private val followedArtistTouched = mutableSetOf() + private val nextHandler = { next() } + private val previousHandler = { previous() } + private val seekHandler = { position: Long -> seekTo(position) } + + /** + * Smooth-position anchor. librespot emits position events on its own cadence (and they can + * arrive late), so the UI interpolates from the last known position with the monotonic clock. + * When an AudioTrack exists, its playback head is used instead: that is the real audio clock. + */ + private var positionAnchorMs = 0L + private var positionAnchorAt = 0L + private var lastSeekAt = 0L + + init { + session?.let { + viewModelScope.launch { + it.signedIn.collect { signed -> + _state.update { state -> state.copy(signedIn = signed) } + if (signed) loadAccount() else clearAccount() + } + } + viewModelScope.launch { + it.settings.collect { settings -> + _state.update { state -> + state.copy(audioQuality = settings.audioQuality, autoplay = settings.autoplay) + } + } + } + it.playback.onNext = nextHandler + it.playback.onPrevious = previousHandler + it.playback.onSeek = seekHandler + } + restorePlaybackState() + if (session == null || !session.nativeAvailable) { + // Local fixtures and builds without the native library still need a moving progress bar. + viewModelScope.launch { + while (true) { + delay(1_000L) + tick() + } + } + } else { + viewModelScope.launch { + while (true) { + delay(300L) + val raw = withContext(Dispatchers.IO) { session.pollPlaybackEvent() } ?: continue + handlePlaybackEvent(raw) + } + } + viewModelScope.launch { + while (true) { + delay(100L) + updatePositionTick() + } + } + } + } + + /** Adopts the process-wide player and queue after the Sideplay screen was recreated. */ + private fun restorePlaybackState() { + val session = session ?: return + val playback = session.playback + val snapshot = queue.snapshot + val current = playback.current ?: snapshot.current + if (current != null && snapshot.current?.id != current.id) { + queue.restore(snapshot.copy(current = current)) + } + val restored = queue.snapshot + anchorPosition(playback.position) + _state.update { + it.copy( + current = current, + upcoming = restored.upcoming, + history = restored.history, + shuffle = restored.shuffle, + repeat = restored.repeat, + isPlaying = playback.isActive, + positionMs = playback.position, + recent = history.items().filterIsInstance() + ) + } + } + + override fun onCleared() { + session?.playback?.let { + if (it.onNext === nextHandler) it.onNext = null + if (it.onPrevious === previousHandler) it.onPrevious = null + if (it.onSeek === seekHandler) it.onSeek = null + } + super.onCleared() + } + + fun dismissNotice() = _state.update { it.copy(notice = null) } + + fun refresh() = loadAccount() + + fun notice(text: String) = _state.update { it.copy(notice = text) } + + fun collection(type: SpotifyCollectionType, id: String): SpotifyCollection? = _state.value.collections[collectionKey(type, id)] + + fun artist(id: String): SpotifyArtist? = _state.value.artists[id] + + fun albumsBy(artist: SpotifyArtist): List = _state.value.artistAlbums[artist.id].orEmpty() + + fun topTracks(artist: SpotifyArtist): List = _state.value.artistTracks[artist.id].orEmpty() + + fun ensureCollection(type: SpotifyCollectionType, id: String) { + val key = collectionKey(type, id) + val snapshot = _state.value + if (key in snapshot.collections || key in snapshot.pendingCollections) return + _state.update { it.copy(pendingCollections = it.pendingCollections + key) } + viewModelScope.launch { + try { + val collection = catalog.collection(type, id, 0, SPOTIFY_PAGE_SIZE) + val saved = if (collection == null) null else writer?.isSaved(type, id) + _state.update { + val keys = it.savedCollectionKeys.toMutableSet() + if (saved != null && key !in savedCollectionTouched) { + if (saved) keys.add(key) else keys.remove(key) + } + it.copy( + collections = if (collection == null) it.collections else it.collections + (key to collection), + savedCollectionKeys = keys, + pendingCollections = it.pendingCollections - key + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _state.update { + it.copy(pendingCollections = it.pendingCollections - key, notice = playbackNotice(e)) + } + } + } + } + + fun loadMoreCollection(type: SpotifyCollectionType, id: String) { + val key = collectionKey(type, id) + val snapshot = _state.value + val collection = snapshot.collections[key] ?: return + if (!collection.hasMore || key in snapshot.collectionMorePending) return + _state.update { it.copy(collectionMorePending = it.collectionMorePending + key) } + viewModelScope.launch { + try { + val next = catalog.collection(type, id, collection.items.size, SPOTIFY_PAGE_SIZE) + _state.update { state -> + val current = state.collections[key] ?: return@update state + if (next == null) { + state.copy( + collectionMorePending = state.collectionMorePending - key, + collections = state.collections + (key to current.copy(hasMore = false)) + ) + } else { + state.copy( + collectionMorePending = state.collectionMorePending - key, + collections = state.collections + (key to current.append(next)) + ) + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _state.update { + val current = it.collections[key] + it.copy( + collectionMorePending = it.collectionMorePending - key, + collections = if (current == null) it.collections else it.collections + (key to current.copy(hasMore = false)), + notice = playbackNotice(e) + ) + } + } + } + } + + fun loadMoreArtistAlbums(artist: SpotifyArtist) { + val id = artist.id + val snapshot = _state.value + if (snapshot.artistAlbumHasMore[id] != true || id in snapshot.artistMorePending) return + _state.update { it.copy(artistMorePending = it.artistMorePending + id) } + viewModelScope.launch { + try { + val page = catalog.albumsBy(artist, snapshot.artistAlbums[id].orEmpty().size, SPOTIFY_PAGE_SIZE) + _state.update { + it.copy( + artistAlbums = it.artistAlbums + (id to (it.artistAlbums[id].orEmpty() + page.items).distinctBy { album -> album.id }), + artistAlbumHasMore = it.artistAlbumHasMore + (id to page.hasMore), + artistMorePending = it.artistMorePending - id + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _state.update { + it.copy( + artistAlbumHasMore = it.artistAlbumHasMore + (id to false), + artistMorePending = it.artistMorePending - id, + notice = playbackNotice(e) + ) + } + } + } + } + + fun loadMoreArtistTracks(artist: SpotifyArtist) { + val id = artist.id + val snapshot = _state.value + if (snapshot.artistTrackHasMore[id] != true || id in snapshot.artistMorePending) return + _state.update { it.copy(artistMorePending = it.artistMorePending + id) } + viewModelScope.launch { + try { + val page = catalog.topTracks(artist, snapshot.artistTracks[id].orEmpty().size, SPOTIFY_PAGE_SIZE) + _state.update { + it.copy( + artistTracks = it.artistTracks + (id to (it.artistTracks[id].orEmpty() + page.items).distinctBy { track -> track.id }), + artistTrackHasMore = it.artistTrackHasMore + (id to page.hasMore), + artistMorePending = it.artistMorePending - id + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _state.update { + it.copy( + artistTrackHasMore = it.artistTrackHasMore + (id to false), + artistMorePending = it.artistMorePending - id, + notice = playbackNotice(e) + ) + } + } + } + } + + private suspend fun loadAllCollection(collection: SpotifyCollection): SpotifyCollection { + var current = collection + while (current.hasMore) { + val next = catalog.collection(current.type, current.id, current.items.size, SPOTIFY_PAGE_SIZE) ?: break + current = current.append(next) + _state.update { + it.copy(collections = it.collections + (collectionKey(current.type, current.id) to current)) + } + } + return current + } + + private fun SpotifyCollection.append(next: SpotifyCollection): SpotifyCollection = copy( + items = items + next.items, + totalItems = next.totalItems, + hasMore = next.hasMore + ) + + fun ensureArtist(id: String) { + val snapshot = _state.value + if (id in snapshot.artists || id in snapshot.pendingArtists) return + _state.update { it.copy(pendingArtists = it.pendingArtists + id) } + viewModelScope.launch { + try { + val artist = catalog.artist(id) + if (artist == null) { + _state.update { it.copy(pendingArtists = it.pendingArtists - id) } + return@launch + } + val albums = catalog.albumsBy(artist, 0, SPOTIFY_PAGE_SIZE) + val tracks = catalog.topTracks(artist, 0, SPOTIFY_PAGE_SIZE) + val followed = writer?.isArtistFollowed(id) + _state.update { + val ids = it.followedArtistIds.toMutableSet() + if (followed != null && id !in followedArtistTouched) { + if (followed) ids.add(id) else ids.remove(id) + } + it.copy( + artists = it.artists + (id to artist), + artistAlbums = it.artistAlbums + (id to albums.items.distinctBy { album -> album.id }), + artistTracks = it.artistTracks + (id to tracks.items.distinctBy { track -> track.id }), + artistAlbumHasMore = it.artistAlbumHasMore + (id to albums.hasMore), + artistTrackHasMore = it.artistTrackHasMore + (id to tracks.hasMore), + followedArtistIds = ids, + pendingArtists = it.pendingArtists - id + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _state.update { + it.copy(pendingArtists = it.pendingArtists - id, notice = playbackNotice(e)) + } + } + } + } + + fun play(items: List, index: Int) { + if (index !in items.indices) return + val item = items[index] + if (!item.playable) { + notice("Not available on this account") + return + } + if (_state.value.current?.id == item.id) { + if (!_state.value.isPlaying) togglePlayPause() + _state.update { it.copy(notice = null) } + return + } + val snapshot = queue.play(items, index) + if (item is SpotifyTrack) ensureTrackSaved(item.id) + recorded(item) + applyQueue(snapshot, positionMs = 0L, playing = true) + _state.update { it.copy(notice = null) } + startRemote(item, 0L) + } + + fun playItem(item: SpotifyMediaItem) = play(listOf(item), 0) + + fun playCollection(collection: SpotifyCollection, index: Int) { + if (!collection.hasMore) { + play(collection.items, index) + return + } + val key = collectionKey(collection.type, collection.id) + _state.update { it.copy(collectionMorePending = it.collectionMorePending + key) } + viewModelScope.launch { + try { + play(loadAllCollection(collection).items, index) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _state.update { it.copy(notice = playbackNotice(e)) } + } finally { + _state.update { it.copy(collectionMorePending = it.collectionMorePending - key) } + } + } + } + + fun playArtist(artist: SpotifyArtist) = play(topTracks(artist), 0) + + fun playLibraryTrack(track: SpotifyTrack) { + val recent = _state.value.recent + val at = recent.indexOfFirst { it.id == track.id } + if (at >= 0) play(recent, at) else playItem(track) + } + + fun togglePlayPause() { + val s = _state.value + val item = s.current ?: return + val play = !s.isPlaying + anchorPosition(s.positionMs) + _state.update { it.copy(isPlaying = play) } + if (item.uri == null || session == null) return + viewModelScope.launch { + try { + if (play && session.playback.current == null) { + // A restored queue outlives the native player. Start the current item instead + // of resuming a source that no longer exists. + startRemote(item, 0L) + } else if (play) { + if (!session.resume()) { + _state.update { it.copy(isPlaying = false) } + return@launch + } + } else { + session.pause() + } + session.playback.updateState(play, if (play) _state.value.positionMs else s.positionMs) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _state.update { it.copy(isPlaying = false, notice = playbackNotice(e)) } + } + } + } + + fun next() { + val before = _state.value.current + val snapshot = queue.next(auto = false) + if (snapshot.current !== before) { + snapshot.current?.let(::recorded) + applyQueue(snapshot, positionMs = 0L, playing = snapshot.current?.playable == true) + snapshot.current?.let { startRemote(it, 0L) } + } + } + + fun previous() { + val s = _state.value + if (s.current == null) return + if (s.positionMs > 3_000L) { + _state.update { it.copy(positionMs = 0L) } + s.current?.uri?.let { uri -> + session?.let { session -> + viewModelScope.launch { runCatching { session.seek(0L) } } + } + } + return + } + val snapshot = queue.previous(s.positionMs) + if (snapshot.current !== s.current) { + snapshot.current?.let(::recorded) + applyQueue(snapshot, positionMs = 0L, playing = snapshot.current?.playable == true) + snapshot.current?.let { startRemote(it, 0L) } + } else { + _state.update { it.copy(positionMs = 0L) } + s.current?.uri?.let { uri -> + session?.let { session -> + viewModelScope.launch { runCatching { session.seek(0L) } } + } + } + } + } + + fun seekTo(positionMs: Long) { + val current = _state.value.current ?: return + val target = positionMs.coerceIn(0L, current.durationMs) + lastSeekAt = SystemClock.elapsedRealtime() + anchorPosition(target) + _state.update { it.copy(positionMs = target) } + if (current.uri != null && session != null) { + viewModelScope.launch { runCatching { session.seek(target) } } + } + } + + fun toggleShuffle() = applyQueue(queue.toggleShuffle(), syncNowPlaying = false) + + fun cycleRepeat() = applyQueue(queue.cycleRepeat(), syncNowPlaying = false) + + fun toggleSaveCurrent() { + val item = _state.value.current ?: return + // A like is a simple toggle: no notice and no Undo offer. + toggleTrackSaved(item.id, null, offerUndo = false) + } + + fun toggleSaved(track: SpotifyTrack) = toggleTrackSaved(track.id, null) + + private fun toggleTrackSaved(id: String, label: String?, offerUndo: Boolean = true) { + val wasSaved = id in _state.value.savedTrackIds + val saved = !wasSaved + val action = SpotifyUndo.Track(id, label) + savedTrackTouched += id + _state.update { + val ids = it.savedTrackIds.toMutableSet() + if (saved) ids.add(id) else ids.remove(id) + it.copy( + savedTrackIds = ids, + notice = label?.let { name -> if (saved) "Saved to $name" else "Removed from $name" }, + undo = if (offerUndo) action else it.undo + ) + } + if (offerUndo) undoWindow.start() + val writer = writer ?: return + viewModelScope.launch { + try { + writer.setTrackSaved(id, saved) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + clearUndo(action) + _state.update { + val ids = it.savedTrackIds.toMutableSet() + if (wasSaved) ids.add(id) else ids.remove(id) + it.copy(savedTrackIds = ids, notice = playbackNotice(e)) + } + } + } + } + + fun savedCollection(collection: SpotifyCollection): Boolean = collectionKey(collection.type, collection.id) in _state.value.savedCollectionKeys + + fun toggleSaved(collection: SpotifyCollection) = toggleSaved(collection.type, collection.id) + + private fun toggleSaved(type: SpotifyCollectionType, id: String, offerUndo: Boolean = true) { + val key = collectionKey(type, id) + val wasSaved = key in _state.value.savedCollectionKeys + val saved = !wasSaved + val action = SpotifyUndo.Collection(type, id) + savedCollectionTouched += key + _state.update { + val keys = it.savedCollectionKeys.toMutableSet() + if (saved) keys.add(key) else keys.remove(key) + it.copy( + savedCollectionKeys = keys, + notice = if (saved) "Saved to Library" else "Removed from Library", + undo = if (offerUndo) action else it.undo + ) + } + if (offerUndo) undoWindow.start() + val writer = writer ?: return + viewModelScope.launch { + try { + writer.setSaved(type, id, saved) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + clearUndo(action) + _state.update { + val keys = it.savedCollectionKeys.toMutableSet() + if (wasSaved) keys.add(key) else keys.remove(key) + it.copy(savedCollectionKeys = keys, notice = playbackNotice(e)) + } + } + } + } + + fun followed(artist: SpotifyArtist): Boolean = artist.id in _state.value.followedArtistIds + + fun toggleFollow(artist: SpotifyArtist) = toggleFollow(artist.id, artist.name) + + private fun toggleFollow(id: String, name: String, offerUndo: Boolean = true) { + val wasFollowing = id in _state.value.followedArtistIds + val following = !wasFollowing + val action = SpotifyUndo.Artist(id, name) + followedArtistTouched += id + _state.update { + val ids = it.followedArtistIds.toMutableSet() + if (following) ids.add(id) else ids.remove(id) + it.copy( + followedArtistIds = ids, + notice = if (following) "Following $name" else "Unfollowed $name", + undo = if (offerUndo) action else it.undo + ) + } + if (offerUndo) undoWindow.start() + val writer = writer ?: return + viewModelScope.launch { + try { + writer.setArtistFollowed(id, following) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + clearUndo(action) + _state.update { + val ids = it.followedArtistIds.toMutableSet() + if (wasFollowing) ids.add(id) else ids.remove(id) + it.copy(followedArtistIds = ids, notice = playbackNotice(e)) + } + } + } + } + + fun undo() { + val action = _state.value.undo ?: return + undoWindow.cancel() + _state.update { it.copy(undo = null) } + when (action) { + is SpotifyUndo.Track -> toggleTrackSaved(action.id, action.label, offerUndo = false) + is SpotifyUndo.Collection -> toggleSaved(action.type, action.id, offerUndo = false) + is SpotifyUndo.Artist -> toggleFollow(action.id, action.name, offerUndo = false) + } + } + + /** Leaving the screen finalises the change instead of offering Undo when you come back. */ + fun commitUndo() { + undoWindow.cancel() + _state.update { it.copy(undo = null) } + } + + private fun clearUndo(action: SpotifyUndo) { + if (_state.value.undo == action) { + undoWindow.cancel() + _state.update { it.copy(undo = null) } + } + } + + private fun ensureTrackSaved(id: String) { + val writer = writer ?: return + if (id in _state.value.savedTrackIds || id in savedTrackTouched) return + viewModelScope.launch { + val saved = runCatching { writer.isTrackSaved(id) }.getOrNull() ?: return@launch + if (saved && id !in savedTrackTouched) { + _state.update { it.copy(savedTrackIds = it.savedTrackIds + id) } + } + } + } + + fun setFilter(filter: LibraryFilter) { + _state.update { it.copy(filter = filter) } + loadLibrary(filter) + } + + fun setQuery(query: String) { + _state.update { it.copy(query = query) } + search(debounceMs = 250L) + } + + fun clearQuery() { + searchJob?.cancel() + _state.update { + it.copy( + query = "", + searchResults = SpotifySearchResults(), + searchPending = false, + searchHasMore = emptySet(), + searchMorePending = emptySet() + ) + } + } + + fun toggleScope(scope: SearchScope) { + _state.update { + val scopes = if (scope in it.searchScopes) it.searchScopes - scope else it.searchScopes + scope + it.copy(searchScopes = scopes) + } + search(debounceMs = 0L) + } + + fun cycleAudioQuality() { + val next = _state.value.audioQuality.next() + _state.update { it.copy(audioQuality = next) } + session?.setAudioQuality(next) + } + + fun toggleAutoplay() { + val enabled = !_state.value.autoplay + _state.update { it.copy(autoplay = enabled) } + session?.setAutoplay(enabled) + } + + fun clearHistory() { + history.clear() + val snapshot = queue.clearHistory() + applyQueue(snapshot, syncNowPlaying = false) + _state.update { it.copy(recent = emptyList(), notice = "Listening history cleared") } + } + + fun clearCache() { + clearArtworkCache() + session?.clearAudioCache() + _state.update { it.copy(notice = "Cache cleared") } + } + + fun moveToNext(item: SpotifyMediaItem) { + applyQueue(queue.playNext(item), syncNowPlaying = false) + } + + fun removeUpcoming(item: SpotifyMediaItem) { + applyQueue(queue.removeUpcoming(item), syncNowPlaying = false) + } + + fun removeHistory(item: SpotifyMediaItem) { + history.remove(item) + val snapshot = queue.removeHistory(item) + applyQueue(snapshot, syncNowPlaying = false) + _state.update { + it.copy( + recent = history.items().filterIsInstance(), + notice = "${item.title} removed from history" + ) + } + } + + fun addToQueue(item: SpotifyMediaItem) { + applyQueue(queue.addToQueue(item), syncNowPlaying = false) + } + + fun clearUpcoming(): List { + val removed = queue.clearUpcoming() + applyQueue(queue.snapshot, syncNowPlaying = false) + return removed + } + + fun restoreUpcoming(items: List) = applyQueue(queue.restoreUpcoming(items), syncNowPlaying = false) + + fun jumpTo(id: String) { + val s = _state.value + if (s.current?.id == id) { + _state.update { it.copy(positionMs = 0L, isPlaying = true) } + return + } + val snapshot = queue.jumpTo(id) + if (snapshot.current !== s.current) { + snapshot.current?.let(::recorded) + applyQueue(snapshot, positionMs = 0L, playing = snapshot.current?.playable == true) + snapshot.current?.let { startRemote(it, 0L) } + } + } + + fun signOut() { + session?.signOut() + queue.stop() + _state.update { SpotifyUiState(signedIn = false, recent = history.items().filterIsInstance()) } + } + + fun signIn() { + val session = session + val openBrowser = openBrowser + if (session == null || openBrowser == null) { + _state.update { it.copy(notice = "Sign-in is unavailable in this build") } + return + } + if (_state.value.authPending) return + _state.update { it.copy(authPending = true, notice = null) } + viewModelScope.launch { + try { + session.signIn(openBrowser) + _state.update { it.copy(signedIn = true, authPending = false) } + loadAccount() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _state.update { + it.copy(authPending = false, notice = e.message ?: "Sign-in failed") + } + } + } + } + + private fun loadAccount() { + if (!_state.value.signedIn) return + _state.update { it.copy(libraryLoading = true) } + viewModelScope.launch { + try { + val filter = _state.value.filter + val library = catalog.library(filter, 0, SPOTIFY_PAGE_SIZE) + _state.update { + it.withLibrarySaved(filter, library.items).copy( + // Recently played is local-only: no first-party history endpoint, no Web API quota. + recent = history.items().filterIsInstance(), + libraryItems = library.items, + libraryHasMore = library.hasMore, + libraryMorePending = false, + libraryLoading = false + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _state.update { it.copy(libraryLoading = false, notice = playbackNotice(e)) } + } + } + } + + private fun loadLibrary(filter: LibraryFilter) { + if (!_state.value.signedIn) return + _state.update { it.copy(libraryLoading = true, libraryMorePending = false) } + viewModelScope.launch { + try { + val page = catalog.library(filter, 0, SPOTIFY_PAGE_SIZE) + _state.update { + if (it.filter == filter) { + it.withLibrarySaved(filter, page.items).copy( + libraryItems = page.items, + libraryHasMore = page.hasMore, + libraryLoading = false + ) + } else { + it + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _state.update { it.copy(libraryLoading = false, notice = playbackNotice(e)) } + } + } + } + + fun loadMoreLibrary() { + val snapshot = _state.value + if (!snapshot.libraryHasMore || snapshot.libraryMorePending || snapshot.libraryLoading) return + val filter = snapshot.filter + val offset = snapshot.libraryItems.size + _state.update { it.copy(libraryMorePending = true) } + viewModelScope.launch { + try { + val page = catalog.library(filter, offset, SPOTIFY_PAGE_SIZE) + _state.update { + if (it.filter != filter) { + it.copy(libraryMorePending = false) + } else { + val merged = (it.libraryItems + page.items).distinctBy(::libraryItemKey) + it.withLibrarySaved(filter, merged).copy( + libraryItems = merged, + libraryHasMore = page.hasMore, + libraryMorePending = false + ) + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _state.update { + it.copy(libraryMorePending = false, libraryHasMore = false, notice = playbackNotice(e)) + } + } + } + } + + private fun clearAccount() { + undoWindow.cancel() + queue.stop() + history.clear() + savedTrackTouched.clear() + savedCollectionTouched.clear() + followedArtistTouched.clear() + _state.update { + it.copy( + recent = emptyList(), + libraryItems = emptyList(), + libraryHasMore = false, + libraryMorePending = false, + searchResults = SpotifySearchResults(), + searchHasMore = emptySet(), + searchMorePending = emptySet(), + collections = emptyMap(), + collectionMorePending = emptySet(), + artistAlbumHasMore = emptyMap(), + artistTrackHasMore = emptyMap(), + artistMorePending = emptySet(), + artists = emptyMap(), + artistAlbums = emptyMap(), + artistTracks = emptyMap(), + savedTrackIds = emptySet(), + savedCollectionKeys = emptySet(), + followedArtistIds = emptySet() + ) + } + } + + private fun SpotifyUiState.withLibrarySaved(filter: LibraryFilter, items: List): SpotifyUiState = when (filter) { + LibraryFilter.LikedSongs -> copy(savedTrackIds = items.filterIsInstance().map { it.id }.toSet()) + + LibraryFilter.Albums -> copy( + savedCollectionKeys = savedCollectionKeys.withoutPrefix("${SpotifyCollectionType.Album.route}:") + + items.filterIsInstance().map { collectionKey(SpotifyCollectionType.Album, it.id) } + ) + + LibraryFilter.Playlists -> copy( + savedCollectionKeys = savedCollectionKeys.withoutPrefix("${SpotifyCollectionType.Playlist.route}:") + + items.filterIsInstance().map { collectionKey(SpotifyCollectionType.Playlist, it.id) } + ) + + LibraryFilter.Shows -> copy( + savedCollectionKeys = savedCollectionKeys.withoutPrefix("${SpotifyCollectionType.Show.route}:") + + items.filterIsInstance().map { collectionKey(SpotifyCollectionType.Show, it.id) } + ) + + LibraryFilter.Artists -> copy(followedArtistIds = items.filterIsInstance().map { it.id }.toSet()) + } + + private fun Set.withoutPrefix(prefix: String): Set = filterNot { it.startsWith(prefix) }.toSet() + + private fun applyQueue( + snapshot: SpotifyQueueRepository.Snapshot, + positionMs: Long? = null, + playing: Boolean? = null, + syncNowPlaying: Boolean = true + ) { + if (positionMs != null) anchorPosition(positionMs) + _state.update { + it.copy( + current = snapshot.current, + upcoming = snapshot.upcoming, + history = snapshot.history, + shuffle = snapshot.shuffle, + repeat = snapshot.repeat, + positionMs = positionMs ?: it.positionMs, + isPlaying = playing ?: it.isPlaying + ) + } + if (syncNowPlaying) { + session?.playback?.setNowPlaying(snapshot.current, positionMs ?: _state.value.positionMs) + } + } + + private fun recorded(item: SpotifyMediaItem) { + if (item !is SpotifyTrack) return + history.record(item, System.currentTimeMillis()) + _state.update { it.copy(recent = history.items().filterIsInstance()) } + } + + private fun search(debounceMs: Long) { + searchJob?.cancel() + val query = _state.value.query + if (query.trim().length < 2) { + _state.update { + it.copy( + searchResults = SpotifySearchResults(), + searchPending = false, + searchHasMore = emptySet(), + searchMorePending = emptySet() + ) + } + return + } + _state.update { it.copy(searchPending = true, searchHasMore = emptySet(), searchMorePending = emptySet()) } + searchJob = viewModelScope.launch { + delay(debounceMs) + val scopes = _state.value.searchScopes + val results = try { + if (_state.value.signedIn) catalog.search(query, scopes, 0, SPOTIFY_PAGE_SIZE) else SpotifySearchResults() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _state.update { it.copy(notice = playbackNotice(e)) } + SpotifySearchResults() + } + _state.update { + if (it.query == query && it.searchScopes == scopes) { + it.copy( + searchResults = results, + searchPending = false, + searchHasMore = scopes.filterTo(mutableSetOf()) { scope -> results.sizeFor(scope) >= SPOTIFY_PAGE_SIZE } + ) + } else { + it + } + } + } + } + + fun loadMoreSearch(scope: SearchScope) { + val snapshot = _state.value + val query = snapshot.query + if (scope !in snapshot.searchHasMore || scope in snapshot.searchMorePending) return + val offset = snapshot.searchResults.sizeFor(scope) + _state.update { it.copy(searchMorePending = it.searchMorePending + scope) } + viewModelScope.launch { + try { + val more = catalog.search(query, setOf(scope), offset, SPOTIFY_PAGE_SIZE) + _state.update { + if (it.query != query) { + it.copy(searchMorePending = it.searchMorePending - scope) + } else { + val hasMore = if (more.sizeFor(scope) >= SPOTIFY_PAGE_SIZE) { + it.searchHasMore + scope + } else { + it.searchHasMore - scope + } + it.copy( + searchResults = it.searchResults.merge(scope, more), + searchHasMore = hasMore, + searchMorePending = it.searchMorePending - scope + ) + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _state.update { + it.copy(searchMorePending = it.searchMorePending - scope, notice = playbackNotice(e)) + } + } + } + } + + private fun SpotifySearchResults.sizeFor(scope: SearchScope): Int = when (scope) { + SearchScope.Tracks -> tracks.size + SearchScope.Artists -> artists.size + SearchScope.Albums -> albums.size + SearchScope.Playlists -> playlists.size + SearchScope.Shows -> shows.size + } + + private fun SpotifySearchResults.merge(scope: SearchScope, next: SpotifySearchResults): SpotifySearchResults = when (scope) { + SearchScope.Tracks -> copy(tracks = (tracks + next.tracks).distinctBy { it.id }) + SearchScope.Artists -> copy(artists = (artists + next.artists).distinctBy { it.id }) + SearchScope.Albums -> copy(albums = (albums + next.albums).distinctBy { it.id }) + SearchScope.Playlists -> copy(playlists = (playlists + next.playlists).distinctBy { it.id }) + SearchScope.Shows -> copy(shows = (shows + next.shows).distinctBy { it.id }) + } + + /** + * librespot is the source of truth while it is playing: its position events keep the seek bar + * honest through buffering and seeks, and EndOfTrack advances the Sidekit queue exactly once. + */ + private fun handlePlaybackEvent(raw: String) { + val event = runCatching { JSONObject(raw) }.getOrNull() ?: return + val position = event.optLong("positionMs", _state.value.positionMs) + SideplayDebug.log("event type=${event.optString("type")} uri=${event.optString("uri")} positionMs=$position") + when (event.optString("type")) { + "loading" -> if (matchesCurrent(event)) { + anchorPosition(position) + session?.playback?.updatePosition(position) + _state.update { it.copy(positionMs = position) } + } + + "playing" -> if (matchesCurrent(event)) { + val playback = session?.playback + // Ignore a playing event that belongs to a command the user already superseded + // with a pause. Otherwise rapid taps leave the UI showing play with no sound. + if (playback?.shouldApplyEvent(true) != false) { + // Keep the UI clock where it is. librespot's event position is the decoder + // position, which is ahead of the speaker by the PCM buffer; adopting it + // would make the bar jump forward on resume. + val current = _state.value.positionMs + anchorPosition(current) + playback?.updateState(true, current) + _state.update { it.copy(isPlaying = true, positionMs = current) } + } + } + + "paused" -> if (matchesCurrent(event)) { + val playback = session?.playback + // Ignore a paused event that belongs to a command the user already superseded + // with a resume. Otherwise the UI flickers back to paused after a resume. + if (playback?.shouldApplyEvent(false) != false) { + val current = _state.value.positionMs + anchorPosition(current) + playback?.updateState(false, current) + _state.update { it.copy(isPlaying = false, positionMs = current) } + } + } + + "position" -> { + // The 100 ms ticker owns the seek bar. Only adopt an event position when it is + // far enough away to be a real seek from another client, not the decoder's + // normal lead over the speaker. + val current = _state.value.positionMs + // Ignore the decoder's stale position events while a local seek is settling. + val settling = SystemClock.elapsedRealtime() - lastSeekAt < 1_500L + if (!settling && abs(position - current) > 2_000L) { + anchorPosition(position) + session?.playback?.updatePosition(position) + _state.update { it.copy(positionMs = position) } + } + } + + // librespot 0.8 has no shuffle/repeat setters, but it emits these when another + // client changes the player. Adopt them so the UI does not drift from the service. + "shuffle" -> applyQueue(queue.setShuffle(event.optBoolean("shuffle")), syncNowPlaying = false) + + "repeat" -> { + val mode = when { + event.optBoolean("track") -> RepeatMode.Track + event.optBoolean("context") -> RepeatMode.Context + else -> RepeatMode.Off + } + applyQueue(queue.setRepeat(mode), syncNowPlaying = false) + } + + "ended" -> if (matchesCurrent(event)) onPlaybackEnded() + + "stopped" -> if (matchesCurrent(event)) { + // Stop from the notification or lock screen: clear both the UI and the queue so + // reopening Sideplay cannot restore the track that was stopped. + session?.playback?.updateState(false, 0L) + applyQueue(queue.stop(), positionMs = 0L, playing = false) + } + + "unavailable" -> if (matchesCurrent(event)) { + _state.update { it.copy(isPlaying = false, notice = "This track isn't available") } + } + } + } + + private fun matchesCurrent(event: JSONObject): Boolean { + val uri = event.optString("uri").takeIf { it.isNotEmpty() } ?: return true + return _state.value.current?.uri?.equals(uri, ignoreCase = true) ?: true + } + + private fun onPlaybackEnded() { + val snapshot = _state.value + val current = snapshot.current ?: return + if (snapshot.repeat == RepeatMode.Track) { + _state.update { it.copy(positionMs = 0L, isPlaying = true) } + startRemote(current, 0L) + } else { + advance(auto = true, forceContextLoop = snapshot.repeat == RepeatMode.Context) + } + } + + private fun anchorPosition(positionMs: Long) { + positionAnchorMs = positionMs + positionAnchorAt = SystemClock.elapsedRealtime() + } + + /** + * Advances the seek bar from a monotonic clock anchored to the last known position. The + * AudioTrack playback head is not stable across pause/resume on this device, so it is not + * used for the UI; the system clock advances at the same 1.0x rate and never jumps. + */ + private fun updatePositionTick() { + val s = _state.value + if (!s.isPlaying) return + val current = s.current ?: return + val next = positionAnchorMs + (SystemClock.elapsedRealtime() - positionAnchorAt) + val clamped = next.coerceIn(0L, current.durationMs) + if (clamped != s.positionMs) { + session?.playback?.updatePosition(clamped) + _state.update { it.copy(positionMs = clamped) } + } + } + + private fun tick() { + val s = _state.value + val current = s.current ?: return + if (!s.isPlaying) return + val nextPosition = s.positionMs + 1_000L + if (nextPosition < current.durationMs) { + _state.update { it.copy(positionMs = nextPosition) } + } else { + when (s.repeat) { + RepeatMode.Track -> _state.update { it.copy(positionMs = 0L) } + RepeatMode.Context -> advance(auto = true, forceContextLoop = true) + RepeatMode.Off -> advance(auto = true, forceContextLoop = false) + } + } + } + + private fun advance(auto: Boolean, forceContextLoop: Boolean) { + val s = _state.value + val current = s.current ?: return + val hasNext = s.upcoming.isNotEmpty() + if (!hasNext && forceContextLoop) { + val snapshot = queue.next(auto = true) + snapshot.current?.let(::recorded) + applyQueue(snapshot, positionMs = 0L, playing = snapshot.current?.playable == true) + snapshot.current?.let { startRemote(it, 0L) } + return + } + if (!hasNext && !auto) return + if (!hasNext) { + if (_state.value.autoplay) autoplaySimilar(current) else stopAtQueueEnd() + return + } + val snapshot = queue.next(auto = true) + if (snapshot.current !== current) { + snapshot.current?.let(::recorded) + applyQueue(snapshot, positionMs = 0L, playing = snapshot.current?.playable == true) + snapshot.current?.let { startRemote(it, 0L) } + } else { + _state.update { it.copy(isPlaying = false, positionMs = 0L) } + } + } + + /** + * The queue ran out with Autoplay on. Ask the catalog for the current artist's top tracks, + * drop anything already in the queue or history, and let the normal advance path start the + * first one. The guard keeps a slow request from hijacking a track the user started meanwhile. + */ + private fun autoplaySimilar(current: SpotifyMediaItem) { + if (autoplayJob?.isActive == true) return + val track = current as? SpotifyTrack + val artistId = track?.artistId?.takeIf { it.isNotBlank() } + if (track == null || artistId == null) { + stopAtQueueEnd() + return + } + autoplayJob = viewModelScope.launch { + try { + val artist = SpotifyArtist(artistId, track.artist, 0, emptyList()) + val page = catalog.topTracks(artist, 0, SPOTIFY_PAGE_SIZE) + if (_state.value.current?.id != track.id || !_state.value.autoplay) return@launch + val known = buildSet { + add(track.id) + addAll(_state.value.history.map { it.id }) + addAll(_state.value.upcoming.map { it.id }) + } + val similar = page.items.filter { it.id !in known && it.playable } + if (similar.isEmpty()) { + stopAtQueueEnd() + return@launch + } + val tail = if (_state.value.shuffle) similar.shuffled() else similar + applyQueue(queue.addToQueue(tail), syncNowPlaying = false) + advance(auto = true, forceContextLoop = false) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (_state.value.current?.id == track.id) stopAtQueueEnd() + } + } + } + + private fun stopAtQueueEnd() { + _state.update { it.copy(isPlaying = false, positionMs = 0L) } + } + + /** Starts a real Spotify item; local test fixtures have no URI and keep the local simulation. */ + private fun startRemote(item: SpotifyMediaItem, positionMs: Long) { + val uri = item.uri ?: return + val session = session ?: return + SideplayDebug.log("viewModel.startRemote uri=$uri positionMs=$positionMs") + anchorPosition(positionMs) + session.playback.setNowPlaying(item, positionMs) + viewModelScope.launch { + try { + session.play(uri, positionMs) + SideplayDebug.log("viewModel.startRemote returned") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + SideplayDebug.log("viewModel.startRemote failed", e) + _state.update { it.copy(isPlaying = false, notice = playbackNotice(e)) } + } + } + } + + private fun playbackNotice(error: Exception): String = when ((error as? SpotifyGatewayException)?.failure) { + SpotifyFailure.Unavailable -> "Spotify playback is unavailable in this build" + SpotifyFailure.NotConnected -> "Playback is not ready — try again" + SpotifyFailure.AuthenticationExpired -> "Spotify session expired — sign in again" + SpotifyFailure.Network -> "Couldn't reach Spotify" + SpotifyFailure.RateLimited -> "Spotify is rate-limiting requests — try again shortly" + SpotifyFailure.TrackUnavailable -> "This track isn't available" + SpotifyFailure.Unknown, null -> error.message ?: "Playback failed" + } + + private fun collectionKey(type: SpotifyCollectionType, id: String) = "${type.route}:$id" + + private fun libraryItemKey(item: Any): String = when (item) { + is SpotifyTrack -> "t${item.id}" + is SpotifyPlaylist -> "p${item.id}" + is SpotifyAlbum -> "a${item.id}" + is SpotifyArtist -> "r${item.id}" + is SpotifyShow -> "s${item.id}" + else -> item.toString() + } +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/auth/EncryptedSpotifyCredentialStore.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/auth/EncryptedSpotifyCredentialStore.kt new file mode 100644 index 00000000..8073e6f3 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/auth/EncryptedSpotifyCredentialStore.kt @@ -0,0 +1,50 @@ +package hrln.interactive.sidekit.apps.spotify.auth + +import android.content.Context +import hrln.interactive.sidekit.apps.integrations.PrivateSettings +import org.json.JSONObject + +/** + * Token storage backed by Sidekit's [PrivateSettings]: an atomic app-private file encrypted with a + * non-exportable Android Keystore key. Tokens must never go into plain SharedPreferences. + */ +class EncryptedSpotifyCredentialStore( + context: Context, + // Legacy name kept so existing dev installs keep their Spotify session. + name: String = "sidetracks_spotify" +) : SpotifyCredentialStore { + private val settings = PrivateSettings(context.applicationContext, name) + + override fun read(): SpotifyCredentials? { + val value = settings.read() + val access = value.optString(KEY_ACCESS).takeIf { it.isNotEmpty() } ?: return null + return SpotifyCredentials( + accessToken = access, + refreshToken = value.optString(KEY_REFRESH).takeIf { it.isNotEmpty() }, + expiresAtMs = value.optLong(KEY_EXPIRES_AT, 0L), + scope = value.optString(KEY_SCOPE), + tokenType = value.optString(KEY_TOKEN_TYPE, "Bearer") + ) + } + + override fun save(credentials: SpotifyCredentials) { + settings.write( + JSONObject() + .put(KEY_ACCESS, credentials.accessToken) + .put(KEY_REFRESH, credentials.refreshToken ?: "") + .put(KEY_EXPIRES_AT, credentials.expiresAtMs) + .put(KEY_SCOPE, credentials.scope) + .put(KEY_TOKEN_TYPE, credentials.tokenType) + ) + } + + override fun clear() = settings.clear() + + private companion object { + const val KEY_ACCESS = "access_token" + const val KEY_REFRESH = "refresh_token" + const val KEY_EXPIRES_AT = "expires_at_ms" + const val KEY_SCOPE = "scope" + const val KEY_TOKEN_TYPE = "token_type" + } +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/auth/SpotifyAuth.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/auth/SpotifyAuth.kt new file mode 100644 index 00000000..4dc80cfa --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/auth/SpotifyAuth.kt @@ -0,0 +1,160 @@ +package hrln.interactive.sidekit.apps.spotify.auth + +import java.io.IOException +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketTimeoutException +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** + * Spotify's OAuth authorization-code flow with PKCE and a loopback callback. The app opens the + * system browser; the browser returns to a short-lived `127.0.0.1` listener, so there is no deep + * link and the user's password never enters Sidekit. + * + * The client id is Spotify's desktop client id, matching the one librespot presents at login5. + * Keep the two in step: Spotify rejects stored credentials minted by a different client. + */ +class SpotifyAuth( + private val store: SpotifyCredentialStore, + private val tokens: SpotifyTokenClient = HttpSpotifyTokenClient(), + private val nowMs: () -> Long = System::currentTimeMillis +) { + private val refreshLock = Mutex() + + fun hasCredentials(): Boolean = store.read() != null + + /** + * Runs the browser round trip and stores the resulting tokens. Throws with a user-facing + * message when Spotify denies the request, the state does not match, or the callback times out. + */ + suspend fun signIn(openBrowser: suspend (String) -> Unit): SpotifyCredentials { + val verifier = SpotifyPkce.verifier() + val state = SpotifyPkce.state() + + ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")).use { server -> + server.soTimeout = CALLBACK_TIMEOUT_MS + val redirectUri = "http://127.0.0.1:${server.localPort}$REDIRECT_PATH" + val url = SpotifyPkce.authorizationUrl( + clientId = CLIENT_ID, + redirectUri = redirectUri, + challenge = SpotifyPkce.challenge(verifier), + state = state, + scopes = SCOPES + ) + openBrowser(url) + val code = awaitCode(server, state) + val credentials = tokens.exchange(CLIENT_ID, code, verifier, redirectUri) + store.save(credentials) + return credentials + } + } + + /** Returns a usable access token, refreshing it first when it is inside the expiry window. */ + suspend fun accessToken(): String? = refreshLock.withLock { + val credentials = store.read() ?: return@withLock null + if (!credentials.isExpired(nowMs())) return@withLock credentials.accessToken + val refreshToken = credentials.refreshToken ?: return@withLock null + runCatching { tokens.refresh(CLIENT_ID, refreshToken) } + .getOrNull() + ?.let { it.copy(scope = it.scope.ifEmpty { credentials.scope }) } + ?.also(store::save) + ?.accessToken + } + + fun clear() = store.clear() + + private suspend fun awaitCode(server: ServerSocket, expectedState: String): String { + val closeOnCancel = coroutineContext[Job]?.invokeOnCompletion { + runCatching { server.close() } + } + try { + return withContext(Dispatchers.IO) { + while (true) { + val socket = try { + server.accept() + } catch (e: SocketTimeoutException) { + throw IOException("Sign-in timed out — please try again", e) + } catch (e: IOException) { + coroutineContext.ensureActive() + throw e + } + socket.use { + when (val result = readCallback(it, expectedState)) { + is SpotifyPkce.Callback.Code -> return@withContext result.code + is SpotifyPkce.Callback.Failure -> throw IOException(result.message) + SpotifyPkce.Callback.Ignored -> Unit + } + } + } + @Suppress("UNREACHABLE_CODE") + error("unreachable") + } + } catch (e: CancellationException) { + throw e + } finally { + closeOnCancel?.dispose() + } + } + + private fun readCallback(socket: Socket, expectedState: String): SpotifyPkce.Callback { + val requestLine = socket.getInputStream().bufferedReader().readLine() ?: return SpotifyPkce.Callback.Ignored + val target = requestLine.split(' ').getOrNull(1) ?: return SpotifyPkce.Callback.Ignored + val result = SpotifyPkce.parseCallback(target, expectedState) + val message = when (result) { + is SpotifyPkce.Callback.Code -> "Signed in to Sideplay. You can close this page." + is SpotifyPkce.Callback.Failure -> "Sign-in did not complete. You can close this page." + SpotifyPkce.Callback.Ignored -> "Waiting for Spotify…" + } + respond(socket, message) + return result + } + + private fun respond(socket: Socket, message: String) { + val body = """ + + + Sideplay + +

$message

+ """.trimIndent() + val bytes = body.toByteArray(Charsets.UTF_8) + val headers = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n" + + "Content-Length: ${bytes.size}\r\nConnection: close\r\n\r\n" + runCatching { + socket.getOutputStream().apply { + write(headers.toByteArray(Charsets.US_ASCII)) + write(bytes) + flush() + } + } + } + + private companion object { + /** + * Spotify's desktop ("keymaster") client id. This is the id librespot's default + * SessionConfig presents, and the loopback path is registered for it. + */ + const val CLIENT_ID = "65b708073fc0480ea92a077233ca87bd" + const val REDIRECT_PATH = "/login" + const val CALLBACK_TIMEOUT_MS = 5 * 60 * 1_000 + + const val SCOPES = + "app-remote-control playlist-modify playlist-modify-private " + + "playlist-modify-public playlist-read playlist-read-collaborative " + + "playlist-read-private streaming ugc-image-upload user-follow-modify " + + "user-follow-read user-library-modify user-library-read user-modify " + + "user-modify-playback-state user-modify-private user-personalized " + + "user-read-birthdate user-read-currently-playing user-read-email " + + "user-read-play-history user-read-playback-position " + + "user-read-playback-state user-read-private " + + "user-top-read" + } +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/auth/SpotifyCredentials.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/auth/SpotifyCredentials.kt new file mode 100644 index 00000000..b2fe69fb --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/auth/SpotifyCredentials.kt @@ -0,0 +1,39 @@ +package hrln.interactive.sidekit.apps.spotify.auth + +/** + * The minimum material needed to reconnect to Spotify later. The password never reaches the app; + * only the OAuth tokens are kept, encrypted by [SpotifyCredentialStore]. + */ +data class SpotifyCredentials( + val accessToken: String, + val refreshToken: String?, + val expiresAtMs: Long, + val scope: String, + val tokenType: String = "Bearer" +) { + /** Refresh a minute early so a request never races the expiry. */ + fun isExpired(nowMs: Long = System.currentTimeMillis()): Boolean = expiresAtMs <= nowMs + REFRESH_WINDOW_MS + + fun withTokenResponse( + accessToken: String, + refreshToken: String?, + expiresInSeconds: Long, + scope: String? + ): SpotifyCredentials = copy( + accessToken = accessToken, + // Spotify omits the refresh token when the grant did not rotate it. + refreshToken = refreshToken ?: this.refreshToken, + expiresAtMs = System.currentTimeMillis() + expiresInSeconds.coerceAtLeast(0L) * 1_000L, + scope = scope ?: this.scope + ) + + companion object { + const val REFRESH_WINDOW_MS = 60_000L + } +} + +interface SpotifyCredentialStore { + fun read(): SpotifyCredentials? + fun save(credentials: SpotifyCredentials) + fun clear() +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/auth/SpotifyPkce.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/auth/SpotifyPkce.kt new file mode 100644 index 00000000..f03bb663 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/auth/SpotifyPkce.kt @@ -0,0 +1,85 @@ +package hrln.interactive.sidekit.apps.spotify.auth + +import java.net.URLDecoder +import java.net.URLEncoder +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 + +/** + * Pure OAuth PKCE helpers. Kept free of Android types so the security-sensitive parts can be + * verified by ordinary JVM tests. + */ +object SpotifyPkce { + private const val REDIRECT_PATH = "/login" + + fun verifier(random: SecureRandom = SecureRandom()): String { + val bytes = ByteArray(64) + random.nextBytes(bytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + } + + fun challenge(verifier: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.US_ASCII)) + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest) + } + + fun state(random: SecureRandom = SecureRandom()): String = verifier(random) + + fun authorizationUrl( + clientId: String, + redirectUri: String, + challenge: String, + state: String, + scopes: String + ): String = buildString { + append("https://accounts.spotify.com/authorize") + append("?client_id=").append(encode(clientId)) + append("&response_type=code") + append("&redirect_uri=").append(encode(redirectUri)) + append("&scope=").append(encode(scopes)) + append("&state=").append(encode(state)) + append("&code_challenge_method=S256") + append("&code_challenge=").append(encode(challenge)) + } + + fun isCallback(target: String): Boolean = target.substringBefore('?').substringBefore('#') == REDIRECT_PATH + + /** Parses the loopback request target (`/login?code=…`) after validating the CSRF state. */ + fun parseCallback(target: String, expectedState: String): Callback { + if (!isCallback(target)) return Callback.Ignored + val query = target.substringAfter('?', "").substringBefore('#') + val params = query.split('&') + .filter { it.isNotEmpty() } + .associate { + val key = decode(it.substringBefore('=')) + val value = decode(it.substringAfter('=', "")) + key to value + } + + params["error"]?.let { error -> + return Callback.Failure( + if (error == "access_denied") "Sign-in was cancelled" else "Spotify rejected the sign-in: $error" + ) + } + if (params["state"] != expectedState) { + return Callback.Failure("Sign-in could not be verified — please try again") + } + val code = params["code"].orEmpty() + return if (code.isEmpty()) { + Callback.Failure("Spotify did not return an authorization code") + } else { + Callback.Code(code) + } + } + + private fun encode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name()) + + private fun decode(value: String): String = URLDecoder.decode(value, Charsets.UTF_8.name()) + + sealed interface Callback { + data class Code(val code: String) : Callback + data class Failure(val message: String) : Callback + data object Ignored : Callback + } +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/auth/SpotifyTokenClient.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/auth/SpotifyTokenClient.kt new file mode 100644 index 00000000..b689dd7d --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/auth/SpotifyTokenClient.kt @@ -0,0 +1,95 @@ +package hrln.interactive.sidekit.apps.spotify.auth + +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject + +/** Swappable so token parsing and refresh logic can be tested without the network. */ +interface SpotifyTokenClient { + suspend fun exchange( + clientId: String, + code: String, + verifier: String, + redirectUri: String + ): SpotifyCredentials + + suspend fun refresh(clientId: String, refreshToken: String): SpotifyCredentials +} + +/** + * Minimal form-encoded client for Spotify's token endpoint. Authorization-code exchange is the + * only place the PKCE verifier leaves the device; refresh tokens are rotated when Spotify returns + * a new one. + */ +class HttpSpotifyTokenClient : SpotifyTokenClient { + override suspend fun exchange( + clientId: String, + code: String, + verifier: String, + redirectUri: String + ): SpotifyCredentials = request( + mapOf( + "grant_type" to "authorization_code", + "code" to code, + "redirect_uri" to redirectUri, + "client_id" to clientId, + "code_verifier" to verifier + ), + previousRefresh = null + ) + + override suspend fun refresh(clientId: String, refreshToken: String): SpotifyCredentials = request( + mapOf( + "grant_type" to "refresh_token", + "refresh_token" to refreshToken, + "client_id" to clientId + ), + previousRefresh = refreshToken + ) + + private suspend fun request(form: Map, previousRefresh: String?): SpotifyCredentials = withContext(Dispatchers.IO) { + val body = form.entries.joinToString("&") { (key, value) -> "${encode(key)}=${encode(value)}" } + val connection = (URL(TOKEN_URL).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + doOutput = true + setRequestProperty("Content-Type", "application/x-www-form-urlencoded") + setRequestProperty("Accept", "application/json") + } + try { + connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } + val status = connection.responseCode + val stream = if (status in 200..299) connection.inputStream else connection.errorStream + val text = stream?.bufferedReader()?.use { it.readText() }.orEmpty() + val json = runCatching { JSONObject(text) }.getOrElse { + throw IOException("Spotify returned an unreadable token response (HTTP $status)") + } + if (status !in 200..299) { + throw IOException(json.optString("error_description").ifEmpty { "Spotify token request failed (HTTP $status)" }) + } + val access = json.optString("access_token") + if (access.isEmpty()) throw IOException("Spotify did not return an access token") + val expiresIn = json.optLong("expires_in", 0L) + SpotifyCredentials( + accessToken = access, + refreshToken = json.optString("refresh_token").takeIf { it.isNotEmpty() } ?: previousRefresh, + expiresAtMs = System.currentTimeMillis() + expiresIn.coerceAtLeast(0L) * 1_000L, + scope = json.optString("scope"), + tokenType = json.optString("token_type", "Bearer") + ) + } finally { + connection.disconnect() + } + } + + private fun encode(value: String): String = java.net.URLEncoder.encode(value, Charsets.UTF_8.name()) + + private companion object { + const val TOKEN_URL = "https://accounts.spotify.com/api/token" + const val TIMEOUT_MS = 15_000 + } +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/bridge/NativeBridge.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/bridge/NativeBridge.kt new file mode 100644 index 00000000..eb2de255 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/bridge/NativeBridge.kt @@ -0,0 +1,114 @@ +package hrln.interactive.sidekit.apps.spotify.bridge + +import hrln.interactive.sidekit.apps.spotify.SideplayDebug +import hrln.interactive.sidekit.system.NativeLibraries + +/** + * Guarded entry point to `libsidekit_spotify.so`. Never calls `System.loadLibrary` from an + * initializer: a build or emulator without the ABI is a normal state, not a crash. + */ +object NativeBridge : SpotifyNative { + const val LIBRARY = "sidekit_spotify" + + override val isAvailable: Boolean get() = NativeLibraries.load(LIBRARY) + + override fun initialize(): Boolean = isAvailable && runCatching { nativeInit() }.getOrDefault(false) + + override fun setTmpDir(path: String) { + if (isAvailable) runCatching { nativeSetTmpDir(path) } + } + + override fun setCacheDir(path: String, sizeLimitBytes: Long) { + if (isAvailable) runCatching { nativeSetCacheDir(path, sizeLimitBytes) } + } + + override fun connect(accessToken: String): Boolean = isAvailable && runCatching { nativeConnect(accessToken) }.getOrDefault(false) + + override fun setAccessToken(accessToken: String): Boolean = isAvailable && runCatching { nativeSetAccessToken(accessToken) }.getOrDefault(false) + + override fun disconnect() { + if (isAvailable) runCatching { nativeDisconnect() } + } + + override fun createPlayer(bitrateKbps: Int): Boolean = isAvailable && runCatching { nativeCreatePlayer(bitrateKbps) }.getOrDefault(false) + + override fun load(uri: String, positionMs: Long, autoplay: Boolean): Boolean { + if (!isAvailable) { + SideplayDebug.log("native.load unavailable") + return false + } + val started = System.nanoTime() + return runCatching { nativeLoad(uri, positionMs, autoplay) } + .onFailure { SideplayDebug.log("native.load threw", it) } + .getOrDefault(false) + .also { + val elapsedMs = (System.nanoTime() - started) / 1_000_000 + SideplayDebug.log("native.load returned ok=$it elapsedMs=$elapsedMs") + } + } + + override fun play(): Boolean = isAvailable && runCatching { nativePlay() }.getOrDefault(false) + + override fun pause(): Boolean = isAvailable && runCatching { nativePause() }.getOrDefault(false) + + override fun stop(): Boolean = isAvailable && runCatching { nativeStop() }.getOrDefault(false) + + override fun seek(positionMs: Long): Boolean = isAvailable && runCatching { nativeSeek(positionMs) }.getOrDefault(false) + + override fun readAudio(timeoutMs: Long): ByteArray? = if (isAvailable) runCatching { nativeReadAudio(timeoutMs) }.getOrNull() else null + + override fun drainAudio() { + if (isAvailable) runCatching { nativeDrainAudio() } + } + + override fun audioFormat(): String? = if (isAvailable) runCatching { nativeAudioFormat() }.getOrNull() else null + + override fun pollEvent(): String? = if (isAvailable) runCatching { nativePollEvent() }.getOrNull() else null + + override fun catalogLibrary(filter: String, offset: Long, limit: Long): String? = if (isAvailable) runCatching { nativeCatalogLibrary(filter, offset, limit) }.getOrNull() else null + + override fun catalogCollection(kind: String, id: String, offset: Long, limit: Long): String? = if (isAvailable) runCatching { nativeCatalogCollection(kind, id, offset, limit) }.getOrNull() else null + + override fun catalogArtist(id: String): String? = if (isAvailable) runCatching { nativeCatalogArtist(id) }.getOrNull() else null + + override fun catalogAlbumsBy(id: String, offset: Long, limit: Long): String? = if (isAvailable) runCatching { nativeCatalogAlbumsBy(id, offset, limit) }.getOrNull() else null + + override fun catalogTopTracks(id: String, offset: Long, limit: Long): String? = if (isAvailable) runCatching { nativeCatalogTopTracks(id, offset, limit) }.getOrNull() else null + + override fun catalogSearch(query: String, scopes: String, offset: Long, limit: Long): String? = if (isAvailable) runCatching { nativeCatalogSearch(query, scopes, offset, limit) }.getOrNull() else null + + override fun collectionContains(set: String, uris: String): String? = if (isAvailable) runCatching { nativeCollectionContains(set, uris) }.getOrNull() else null + + override fun collectionWrite(set: String, uris: String, removed: Boolean): Boolean = isAvailable && runCatching { nativeCollectionWrite(set, uris, removed) }.getOrDefault(false) + + override fun playlistSaved(id: String): String? = if (isAvailable) runCatching { nativePlaylistSaved(id) }.getOrNull() else null + + override fun playlistSave(id: String, save: Boolean): Boolean = isAvailable && runCatching { nativePlaylistSave(id, save) }.getOrDefault(false) + + private external fun nativeInit(): Boolean + private external fun nativeSetTmpDir(path: String) + private external fun nativeSetCacheDir(path: String, sizeLimitBytes: Long) + private external fun nativeConnect(accessToken: String): Boolean + private external fun nativeSetAccessToken(accessToken: String): Boolean + private external fun nativeDisconnect() + private external fun nativeCreatePlayer(bitrateKbps: Int): Boolean + private external fun nativeLoad(uri: String, positionMs: Long, autoplay: Boolean): Boolean + private external fun nativePlay(): Boolean + private external fun nativePause(): Boolean + private external fun nativeStop(): Boolean + private external fun nativeSeek(positionMs: Long): Boolean + private external fun nativeReadAudio(timeoutMs: Long): ByteArray? + private external fun nativeDrainAudio() + private external fun nativeAudioFormat(): String? + private external fun nativePollEvent(): String? + private external fun nativeCatalogLibrary(filter: String, offset: Long, limit: Long): String? + private external fun nativeCatalogCollection(kind: String, id: String, offset: Long, limit: Long): String? + private external fun nativeCatalogArtist(id: String): String? + private external fun nativeCatalogAlbumsBy(id: String, offset: Long, limit: Long): String? + private external fun nativeCatalogTopTracks(id: String, offset: Long, limit: Long): String? + private external fun nativeCatalogSearch(query: String, scopes: String, offset: Long, limit: Long): String? + private external fun nativeCollectionContains(set: String, uris: String): String? + private external fun nativeCollectionWrite(set: String, uris: String, removed: Boolean): Boolean + private external fun nativePlaylistSaved(id: String): String? + private external fun nativePlaylistSave(id: String, save: Boolean): Boolean +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/bridge/SpotifyNative.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/bridge/SpotifyNative.kt new file mode 100644 index 00000000..554f245e --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/bridge/SpotifyNative.kt @@ -0,0 +1,37 @@ +package hrln.interactive.sidekit.apps.spotify.bridge + +/** + * Testable edge of the JNI library. Calls are blocking; `NativeSpotifyGateway` moves them to IO. + */ +interface SpotifyNative { + val isAvailable: Boolean + + fun initialize(): Boolean + fun setTmpDir(path: String) + fun setCacheDir(path: String, sizeLimitBytes: Long) + fun connect(accessToken: String): Boolean + fun setAccessToken(accessToken: String): Boolean + fun disconnect() + fun createPlayer(bitrateKbps: Int): Boolean + fun load(uri: String, positionMs: Long, autoplay: Boolean): Boolean + fun play(): Boolean + fun pause(): Boolean + fun stop(): Boolean + fun seek(positionMs: Long): Boolean + fun readAudio(timeoutMs: Long): ByteArray? + fun drainAudio() + fun audioFormat(): String? + fun pollEvent(): String? + + fun catalogLibrary(filter: String, offset: Long, limit: Long): String? + fun catalogCollection(kind: String, id: String, offset: Long, limit: Long): String? + fun catalogArtist(id: String): String? + fun catalogAlbumsBy(id: String, offset: Long, limit: Long): String? + fun catalogTopTracks(id: String, offset: Long, limit: Long): String? + fun catalogSearch(query: String, scopes: String, offset: Long, limit: Long): String? + + fun collectionContains(set: String, uris: String): String? + fun collectionWrite(set: String, uris: String, removed: Boolean): Boolean + fun playlistSaved(id: String): String? + fun playlistSave(id: String, save: Boolean): Boolean +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/NativeSpotifyCatalog.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/NativeSpotifyCatalog.kt new file mode 100644 index 00000000..94a5d373 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/NativeSpotifyCatalog.kt @@ -0,0 +1,319 @@ +package hrln.interactive.sidekit.apps.spotify.data + +import hrln.interactive.sidekit.apps.spotify.LibraryFilter +import hrln.interactive.sidekit.apps.spotify.SearchScope +import hrln.interactive.sidekit.apps.spotify.SpotifyAlbum +import hrln.interactive.sidekit.apps.spotify.SpotifyArtist +import hrln.interactive.sidekit.apps.spotify.SpotifyCollection +import hrln.interactive.sidekit.apps.spotify.SpotifyCollectionType +import hrln.interactive.sidekit.apps.spotify.SpotifyEpisode +import hrln.interactive.sidekit.apps.spotify.SpotifyPage +import hrln.interactive.sidekit.apps.spotify.SpotifyPlaylist +import hrln.interactive.sidekit.apps.spotify.SpotifySearchResults +import hrln.interactive.sidekit.apps.spotify.SpotifyShow +import hrln.interactive.sidekit.apps.spotify.SpotifyTrack +import hrln.interactive.sidekit.apps.spotify.bridge.SpotifyNative +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject + +/** + * [SpotifyCatalog] over librespot's authenticated first-party endpoints. + * + * The public Web API shares a quota that ordinary desktop-client credentials exhaust quickly. + * These calls use the same session that plays audio, so they avoid that quota entirely. + */ +class NativeSpotifyCatalog( + private val native: SpotifyNative, + private val ensureSession: suspend () -> Unit +) : SpotifyCatalog, + SpotifyLibraryWriter { + override suspend fun library(filter: LibraryFilter, offset: Int, limit: Int): SpotifyPage { + val array = parseArray(callText { native.catalogLibrary(filter.name, offset.toLong(), limit.toLong()) }) + val items = when (filter) { + LibraryFilter.LikedSongs -> array.tracks() + LibraryFilter.Playlists -> array.playlists() + LibraryFilter.Albums -> array.albums() + LibraryFilter.Artists -> array.artists() + LibraryFilter.Shows -> array.shows() + } + return SpotifyPage.of(items, offset, limit) + } + + override suspend fun collection( + type: SpotifyCollectionType, + id: String, + offset: Int, + limit: Int + ): SpotifyCollection? { + val json = call { native.catalogCollection(type.name, id, offset.toLong(), limit.toLong()) } + return runCatching { json.collection(type) }.getOrNull() + } + + override suspend fun artist(id: String): SpotifyArtist? { + val json = call { native.catalogArtist(id) } + return runCatching { artistFromNative(json) }.getOrNull() + } + + override suspend fun albumsBy(artist: SpotifyArtist, offset: Int, limit: Int): SpotifyPage { + val array = parseArray(callText { native.catalogAlbumsBy(artist.id, offset.toLong(), limit.toLong()) }) + return SpotifyPage.of(array.albums(), offset, limit) + } + + override suspend fun topTracks(artist: SpotifyArtist, offset: Int, limit: Int): SpotifyPage { + val array = parseArray(callText { native.catalogTopTracks(artist.id, offset.toLong(), limit.toLong()) }) + return SpotifyPage.of(array.tracks(), offset, limit) + } + + override suspend fun search( + query: String, + scopes: Set, + offset: Int, + limit: Int + ): SpotifySearchResults { + val scopeJson = JSONArray(scopes.map { it.name }).toString() + val json = call { native.catalogSearch(query.trim(), scopeJson, offset.toLong(), limit.toLong()) } + return SpotifySearchResults( + tracks = json.optJSONArray("tracks").tracks(), + artists = json.optJSONArray("artists").artists(), + albums = json.optJSONArray("albums").albums(), + playlists = json.optJSONArray("playlists").playlists(), + shows = json.optJSONArray("shows").shows() + ) + } + + override suspend fun isTrackSaved(id: String): Boolean? = contains("collection", "spotify:track:$id") + + override suspend fun isSaved(type: SpotifyCollectionType, id: String): Boolean? = when (type) { + SpotifyCollectionType.Album -> contains("collection", "spotify:album:$id") + SpotifyCollectionType.Show -> contains("show", "spotify:show:$id") + SpotifyCollectionType.Playlist -> playlistSaved(id) + } + + override suspend fun isArtistFollowed(id: String): Boolean? = contains("artist", "spotify:artist:$id") + + override suspend fun setTrackSaved(id: String, saved: Boolean) = writeCollection("collection", "spotify:track:$id", saved) + + override suspend fun setSaved(type: SpotifyCollectionType, id: String, saved: Boolean) { + when (type) { + SpotifyCollectionType.Album -> writeCollection("collection", "spotify:album:$id", saved) + SpotifyCollectionType.Show -> writeCollection("show", "spotify:show:$id", saved) + SpotifyCollectionType.Playlist -> writePlaylist(id, saved) + } + } + + override suspend fun setArtistFollowed(id: String, followed: Boolean) = writeCollection("artist", "spotify:artist:$id", followed) + + private suspend fun contains(set: String, uri: String): Boolean? { + val text = callText { + native.collectionContains(set, JSONArray(listOf(uri)).toString()) + } + return runCatching { JSONArray(text).optBoolean(0) }.getOrNull() + } + + private suspend fun playlistSaved(id: String): Boolean? { + val text = callText { native.playlistSaved(id) } + return runCatching { text.toBooleanStrict() }.getOrNull() + } + + private suspend fun writeCollection(set: String, uri: String, saved: Boolean) { + withContext(Dispatchers.IO) { + ensureSession() + if (!native.collectionWrite(set, JSONArray(listOf(uri)).toString(), !saved)) { + fail(SpotifyFailure.Network, "Spotify didn't accept the library change") + } + } + } + + private suspend fun writePlaylist(id: String, saved: Boolean) { + withContext(Dispatchers.IO) { + ensureSession() + if (!native.playlistSave(id, saved)) { + fail(SpotifyFailure.Network, "Spotify didn't accept the library change") + } + } + } + + private suspend fun call(block: () -> String?): JSONObject { + val text = callText(block) + return runCatching { JSONObject(text) }.getOrElse { + fail(SpotifyFailure.Unknown, "Spotify returned an unreadable catalog response") + } + } + + private suspend fun callText(block: () -> String?): String = withContext(Dispatchers.IO) { + ensureSession() + val text = block() ?: fail(SpotifyFailure.Network, "Spotify didn't return a catalog response") + if (text.startsWith("{")) { + val error = runCatching { JSONObject(text).optString("error") }.getOrDefault("") + if (error.isNotEmpty()) { + android.util.Log.w("SideplayCatalog", error) + val failure = if (error.contains("SESSION", ignoreCase = true)) { + SpotifyFailure.AuthenticationExpired + } else { + SpotifyFailure.Network + } + fail(failure, error) + } + } + text + } + + private fun parseArray(text: String): JSONArray = runCatching { JSONArray(text) }.getOrElse { + fail(SpotifyFailure.Unknown, "Spotify returned an unreadable catalog response") + } + + private fun fail(failure: SpotifyFailure, message: String): Nothing = throw SpotifyGatewayException(failure, message) +} + +private fun JSONArray?.tracks(): List = buildList { + if (this@tracks == null) return@buildList + for (i in 0 until this@tracks.length()) { + this@tracks.optJSONObject(i)?.let(::trackFromNative)?.let(::add) + } +} + +private fun JSONArray?.albums(): List = buildList { + if (this@albums == null) return@buildList + for (i in 0 until this@albums.length()) { + this@albums.optJSONObject(i)?.let(::albumFromNative)?.let(::add) + } +} + +private fun JSONArray?.artists(): List = buildList { + if (this@artists == null) return@buildList + for (i in 0 until this@artists.length()) { + this@artists.optJSONObject(i)?.let(::artistFromNative)?.let(::add) + } +} + +private fun JSONArray?.playlists(): List = buildList { + if (this@playlists == null) return@buildList + for (i in 0 until this@playlists.length()) { + this@playlists.optJSONObject(i)?.let(::playlistFromNative)?.let(::add) + } +} + +private fun JSONArray?.shows(): List = buildList { + if (this@shows == null) return@buildList + for (i in 0 until this@shows.length()) { + this@shows.optJSONObject(i)?.let(::showFromNative)?.let(::add) + } +} + +private fun JSONObject.collection(type: SpotifyCollectionType): SpotifyCollection? { + val id = optString("id").takeIf { it.isNotEmpty() } ?: return null + val items = when (type) { + SpotifyCollectionType.Show -> optJSONArray("items").episodes(null) + SpotifyCollectionType.Album, SpotifyCollectionType.Playlist -> optJSONArray("items").tracks() + } + return SpotifyCollection( + type = type, + id = id, + title = optString("title", "Unknown"), + subtitle = optString("subtitle"), + artRes = 0, + items = items, + artistId = optString("artistId").takeIf { it.isNotEmpty() }, + artUrl = optString("artUrl").takeIf { it.isNotEmpty() }, + totalItems = optInt("totalItems", items.size), + hasMore = optBoolean("hasMore", false) + ) +} + +private fun trackFromNative(json: JSONObject): SpotifyTrack? { + val id = json.optString("id").takeIf { it.isNotEmpty() } ?: return null + return SpotifyTrack( + id = id, + title = json.optString("title", "Unknown track"), + artist = json.optString("artist", "Unknown artist"), + artistId = json.optString("artistId"), + albumId = json.optString("albumId"), + artRes = 0, + durationMs = json.optLong("durationMs", 0L), + explicit = json.optBoolean("explicit", false), + playable = json.optBoolean("playable", true), + uri = json.optString("uri").takeIf { it.isNotEmpty() }, + artUrl = json.optString("artUrl").takeIf { it.isNotEmpty() } + ) +} + +private fun albumFromNative(json: JSONObject): SpotifyAlbum? { + val id = json.optString("id").takeIf { it.isNotEmpty() } ?: return null + return SpotifyAlbum( + id = id, + title = json.optString("title", "Unknown album"), + artist = json.optString("artist", "Unknown artist"), + artistId = json.optString("artistId"), + year = json.optInt("year", 0), + artRes = 0, + tracks = json.optJSONArray("tracks").tracks(), + artUrl = json.optString("artUrl").takeIf { it.isNotEmpty() }, + trackCount = json.optInt("trackCount", 0) + ) +} + +private fun artistFromNative(json: JSONObject): SpotifyArtist? { + val id = json.optString("id").takeIf { it.isNotEmpty() } ?: return null + val albumIds = json.optJSONArray("albumIds") ?: JSONArray() + return SpotifyArtist( + id = id, + name = json.optString("name", "Unknown artist"), + artRes = 0, + albumIds = buildList { + for (i in 0 until albumIds.length()) albumIds.optString(i).takeIf { it.isNotEmpty() }?.let(::add) + }, + artUrl = json.optString("artUrl").takeIf { it.isNotEmpty() }, + albumCount = json.optInt("albumCount", albumIds.length()) + ) +} + +private fun playlistFromNative(json: JSONObject): SpotifyPlaylist? { + val id = json.optString("id").takeIf { it.isNotEmpty() } ?: return null + return SpotifyPlaylist( + id = id, + name = json.optString("name", "Unknown playlist"), + owner = json.optString("owner", "Spotify"), + ownerId = json.optString("ownerId").takeIf { it.isNotEmpty() }, + artRes = 0, + tracks = json.optJSONArray("tracks").tracks(), + artUrl = json.optString("artUrl").takeIf { it.isNotEmpty() }, + trackCount = json.optInt("trackCount", 0) + ) +} + +private fun showFromNative(json: JSONObject): SpotifyShow? { + val id = json.optString("id").takeIf { it.isNotEmpty() } ?: return null + return SpotifyShow( + id = id, + name = json.optString("name", "Unknown show"), + publisher = json.optString("publisher", "Podcast"), + artRes = 0, + episodes = json.optJSONArray("episodes").episodes(null), + artUrl = json.optString("artUrl").takeIf { it.isNotEmpty() }, + episodeCount = json.optInt("episodeCount", 0) + ) +} + +private fun JSONArray?.episodes(show: SpotifyShow?): List = buildList { + if (this@episodes == null) return@buildList + for (i in 0 until this@episodes.length()) { + val json = this@episodes.optJSONObject(i) ?: continue + val id = json.optString("id").takeIf { it.isNotEmpty() } ?: continue + add( + SpotifyEpisode( + id = id, + title = json.optString("title", "Unknown episode"), + show = json.optString("show").ifEmpty { show?.name ?: "Podcast" }, + showId = json.optString("showId").ifEmpty { show?.id.orEmpty() }, + published = json.optString("published"), + artRes = 0, + durationMs = json.optLong("durationMs", 0L), + video = json.optBoolean("video", false), + uri = json.optString("uri").takeIf { it.isNotEmpty() }, + artUrl = json.optString("artUrl").takeIf { it.isNotEmpty() } + ) + ) + } +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/NativeSpotifyGateway.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/NativeSpotifyGateway.kt new file mode 100644 index 00000000..766df448 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/NativeSpotifyGateway.kt @@ -0,0 +1,155 @@ +package hrln.interactive.sidekit.apps.spotify.data + +import hrln.interactive.sidekit.apps.spotify.SideplayDebug +import hrln.interactive.sidekit.apps.spotify.bridge.SpotifyNative +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * [SpotifyGateway] backed by librespot. Every JNI call is blocking and therefore runs on IO. + * Native code returns only success/failure; stable failure mapping happens here. + */ +class NativeSpotifyGateway( + private val native: SpotifyNative, + private val settings: () -> SpotifySettings = { SpotifySettings() }, + private val accessToken: suspend () -> String? +) : SpotifyGateway { + @Volatile + private var connected = false + + @Volatile + private var sessionConnected = false + + @Volatile + private var sessionToken: String? = null + + override suspend fun connect(accessToken: String) = onIo { + requireNative() + if (connected) return@onIo + connectSession(accessToken) + createPlayer() + } + + override suspend fun disconnect() = onIo { + connected = false + sessionConnected = false + sessionToken = null + if (native.isAvailable) native.disconnect() + } + + override suspend fun load(uri: String, positionMs: Long, autoplay: Boolean) = onIo { + requireNative() + SideplayDebug.log("gateway.load begin uri=$uri positionMs=$positionMs autoplay=$autoplay") + val started = System.nanoTime() + val ok = native.load(uri, positionMs, autoplay) + val elapsedMs = (System.nanoTime() - started) / 1_000_000 + SideplayDebug.log("gateway.load native returned ok=$ok elapsedMs=$elapsedMs") + if (!ok) fail(SpotifyFailure.TrackUnavailable, "Track unavailable") + } + + override suspend fun play() = onIo { + requireNative() + if (!native.play()) fail(SpotifyFailure.NotConnected, "Playback is not ready") + } + + override suspend fun pause() = onIo { + requireNative() + if (!native.pause()) fail(SpotifyFailure.NotConnected, "Playback is not ready") + } + + override suspend fun stop() = onIo { + requireNative() + if (!native.stop()) fail(SpotifyFailure.NotConnected, "Playback is not ready") + } + + override suspend fun seek(positionMs: Long) = onIo { + requireNative() + if (!native.seek(positionMs)) fail(SpotifyFailure.NotConnected, "Playback is not ready") + } + + /** Forgets the native session after it has been torn down outside this gateway. */ + fun reset() { + connected = false + sessionConnected = false + sessionToken = null + } + + /** + * Connects with the current stored token, refreshing it first when needed. + * + * `setAccessToken` is called on every attempt, not only when the token changed: librespot can + * lose its access-point connection while the OAuth token is still valid, and a session that + * has gone invalid must be rebuilt or playback fails for every track. The native call is a + * cheap state update, so the extra round trip on each play is worth the liveness check. + */ + suspend fun ensureConnected(): Boolean { + val token = accessToken() ?: fail(SpotifyFailure.AuthenticationExpired, "Sign in again") + SideplayDebug.log("ensureConnected connected=$connected sessionConnected=$sessionConnected tokenLength=${token.length}") + if (!connected) { + SideplayDebug.log("ensureConnected: no player, connecting") + connect(token) + return true + } + val tokenAccepted = withContext(Dispatchers.IO) { native.setAccessToken(token) } + SideplayDebug.log("ensureConnected: setAccessToken accepted=$tokenAccepted") + if (tokenAccepted) { + sessionToken = token + return false + } + SideplayDebug.log("ensureConnected: session rejected token, rebuilding") + connected = false + sessionConnected = false + sessionToken = null + connect(token) + return true + } + + /** Connects the librespot session without creating an audio player. Used by catalog reads. */ + suspend fun ensureSession() = onIo { + requireNative() + val token = accessToken() ?: fail(SpotifyFailure.AuthenticationExpired, "Sign in again") + if (!sessionConnected) { + connectSession(token) + } else if (!native.setAccessToken(token)) { + // The native session is gone; reconnect from scratch rather than keep a stale token. + sessionConnected = false + sessionToken = null + connectSession(token) + } else { + sessionToken = token + } + } + + private fun connectSession(token: String) { + if (sessionConnected) return + SideplayDebug.log("connectSession begin") + if (!native.connect(token)) fail(SpotifyFailure.AuthenticationExpired, "Could not connect") + SideplayDebug.log("connectSession connected") + sessionConnected = true + sessionToken = token + } + + private fun createPlayer() { + val current = settings() + SideplayDebug.log("createPlayer bitrateKbps=${current.bitrateKbps}") + if (!native.createPlayer(current.bitrateKbps)) { + SideplayDebug.log("createPlayer failed") + native.disconnect() + sessionConnected = false + sessionToken = null + fail(SpotifyFailure.Unknown, "Could not create the player") + } + SideplayDebug.log("createPlayer created") + connected = true + } + + private fun requireNative() { + if (!native.isAvailable) fail(SpotifyFailure.Unavailable, "Spotify playback is unavailable in this build") + } + + private suspend fun onIo(block: suspend () -> Unit) { + withContext(Dispatchers.IO) { block() } + } + + private fun fail(failure: SpotifyFailure, message: String): Nothing = throw SpotifyGatewayException(failure, message) +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifyCatalog.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifyCatalog.kt new file mode 100644 index 00000000..29077a10 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifyCatalog.kt @@ -0,0 +1,64 @@ +package hrln.interactive.sidekit.apps.spotify.data + +import hrln.interactive.sidekit.apps.spotify.LibraryFilter +import hrln.interactive.sidekit.apps.spotify.SPOTIFY_PAGE_SIZE +import hrln.interactive.sidekit.apps.spotify.SearchScope +import hrln.interactive.sidekit.apps.spotify.SpotifyAlbum +import hrln.interactive.sidekit.apps.spotify.SpotifyArtist +import hrln.interactive.sidekit.apps.spotify.SpotifyCollection +import hrln.interactive.sidekit.apps.spotify.SpotifyCollectionType +import hrln.interactive.sidekit.apps.spotify.SpotifyPage +import hrln.interactive.sidekit.apps.spotify.SpotifySearchResults +import hrln.interactive.sidekit.apps.spotify.SpotifyTrack + +/** + * Read side of the Spotify account, as the UI needs it. The production implementation is the Web + * API; tests supply their own catalog. No screen or ViewModel may call an endpoint directly. + */ +interface SpotifyCatalog { + suspend fun library( + filter: LibraryFilter, + offset: Int = 0, + limit: Int = SPOTIFY_PAGE_SIZE + ): SpotifyPage + + suspend fun collection( + type: SpotifyCollectionType, + id: String, + offset: Int = 0, + limit: Int = SPOTIFY_PAGE_SIZE + ): SpotifyCollection? + + suspend fun artist(id: String): SpotifyArtist? + suspend fun albumsBy( + artist: SpotifyArtist, + offset: Int = 0, + limit: Int = SPOTIFY_PAGE_SIZE + ): SpotifyPage + + suspend fun topTracks( + artist: SpotifyArtist, + offset: Int = 0, + limit: Int = SPOTIFY_PAGE_SIZE + ): SpotifyPage + + suspend fun search( + query: String, + scopes: Set, + offset: Int = 0, + limit: Int = SPOTIFY_PAGE_SIZE + ): SpotifySearchResults +} + +/** + * Write side of the user's Spotify library. Kept separate from [SpotifyCatalog] so read-only + * fallbacks and test catalogs do not have to pretend they can mutate an account. + */ +interface SpotifyLibraryWriter { + suspend fun isTrackSaved(id: String): Boolean? + suspend fun isSaved(type: SpotifyCollectionType, id: String): Boolean? + suspend fun isArtistFollowed(id: String): Boolean? + suspend fun setTrackSaved(id: String, saved: Boolean) + suspend fun setSaved(type: SpotifyCollectionType, id: String, saved: Boolean) + suspend fun setArtistFollowed(id: String, followed: Boolean) +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifyGateway.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifyGateway.kt new file mode 100644 index 00000000..4d0f16e6 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifyGateway.kt @@ -0,0 +1,33 @@ +package hrln.interactive.sidekit.apps.spotify.data + +/** + * Playback side of the Spotify connection. Catalog reads stay on [SpotifyCatalog]; this interface + * is deliberately transport-free so a fake can drive UI/playback tests and + * `NativeSpotifyGateway` can wrap JNI behind the same contract. + */ +interface SpotifyGateway { + suspend fun connect(accessToken: String) + suspend fun disconnect() + suspend fun load(uri: String, positionMs: Long, autoplay: Boolean) + suspend fun play() + suspend fun pause() + suspend fun stop() + suspend fun seek(positionMs: Long) +} + +/** Stable gateway failures. Raw librespot strings must not reach the UI. */ +enum class SpotifyFailure { + Unavailable, + NotConnected, + AuthenticationExpired, + Network, + RateLimited, + TrackUnavailable, + Unknown +} + +class SpotifyGatewayException( + val failure: SpotifyFailure, + message: String, + cause: Throwable? = null +) : Exception(message, cause) diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifyHistoryRepository.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifyHistoryRepository.kt new file mode 100644 index 00000000..cb2a86f2 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifyHistoryRepository.kt @@ -0,0 +1,62 @@ +package hrln.interactive.sidekit.apps.spotify.data + +import hrln.interactive.sidekit.apps.spotify.SpotifyMediaItem + +/** A played item and when it last started. Timestamps are local and never uploaded. */ +data class SpotifyHistoryEntry( + val item: SpotifyMediaItem, + val playedAtMs: Long +) + +/** + * Recently played history for Home. Deduplicated by item id, most-recent-first, and bounded so + * a long-lived account cannot grow the file without limit. + * + * Persistence is injected as a pair of lambdas so JVM tests use memory and the app can use the + * same atomic-file convention as the rest of Sidekit. + */ +class SpotifyHistoryRepository( + private val limit: Int = DEFAULT_LIMIT, + private val load: () -> List = { emptyList() }, + private val save: (List) -> Unit = {} +) { + private val entries = ArrayDeque() + + init { + // Older builds could persist the same item more than once. Home renders this list with + // the item id as its LazyColumn key, so a duplicate would crash the draw pass. + val seen = HashSet() + load().asSequence() + .filter { seen.add(it.item.id) } + .take(limit) + .forEach(entries::addLast) + } + + fun snapshot(): List = entries.toList() + + fun items(): List = entries.map { it.item } + + /** Marks [item] as most recently played and persists the new bounded list. */ + fun record(item: SpotifyMediaItem, playedAtMs: Long): List { + entries.removeAll { it.item.id == item.id } + entries.addFirst(SpotifyHistoryEntry(item, playedAtMs)) + while (entries.size > limit) entries.removeLast() + save(entries.toList()) + return snapshot() + } + + fun remove(item: SpotifyMediaItem): List { + if (entries.removeAll { it.item.id == item.id }) save(entries.toList()) + return snapshot() + } + + fun clear(): List { + entries.clear() + save(emptyList()) + return emptyList() + } + + private companion object { + const val DEFAULT_LIMIT = 200 + } +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifyQueueRepository.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifyQueueRepository.kt new file mode 100644 index 00000000..22ab250d --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifyQueueRepository.kt @@ -0,0 +1,216 @@ +package hrln.interactive.sidekit.apps.spotify.data + +import hrln.interactive.sidekit.apps.spotify.RepeatMode +import hrln.interactive.sidekit.apps.spotify.SpotifyMediaItem +import kotlin.random.Random + +/** + * The queue is owned by Sideplay, not librespot: librespot plays one item at a time and this + * repository decides what comes next. librespot 0.8 exposes shuffle/repeat events but has no + * setters, so this repository is authoritative for both; native events are adopted when another + * client changes them. + * + * History and upcoming are plain ordered lists. Shuffle is a view over upcoming only, so the + * current item and history never move. The repository is deliberately free of Android and + * Compose types; playback, persistence and the UI observe it through [snapshot]. + */ +class SpotifyQueueRepository( + private val random: Random = Random.Default, + private val load: () -> Snapshot = { Snapshot() }, + private val save: (Snapshot) -> Unit = {} +) { + data class Snapshot( + val current: SpotifyMediaItem? = null, + val upcoming: List = emptyList(), + val history: List = emptyList(), + val shuffle: Boolean = false, + val repeat: RepeatMode = RepeatMode.Off, + val context: List = emptyList(), + /** Upcoming order before shuffle, kept so shuffle can be turned off after process death. */ + val shuffleBackup: List? = null + ) + + var snapshot: Snapshot = runCatching(load).getOrDefault(Snapshot()) + private set + + /** Replaces the queue, used when a new ViewModel adopts the process-wide playback state. */ + fun restore(snapshot: Snapshot): Snapshot = commit(snapshot) + + /** + * Starts playback from [index] within [items], replacing the queue. The rows after the + * chosen item become upcoming; the item that was playing moves into history. + */ + fun play(items: List, index: Int): Snapshot { + if (index !in items.indices) return snapshot + val item = items[index] + if (!item.playable) return snapshot + + val previous = snapshot.current + val nextUpcoming = items.drop(index + 1).let { if (snapshot.shuffle) it.shuffled(random) else it } + return commit( + snapshot.copy( + current = item, + upcoming = nextUpcoming, + history = if (previous == null || previous.id == item.id) { + snapshot.history + } else { + (listOf(previous) + snapshot.history).take(HISTORY_LIMIT) + }, + context = items, + shuffleBackup = if (snapshot.shuffle) items.drop(index + 1) else null + ) + ) + } + + fun playItem(item: SpotifyMediaItem): Snapshot = play(listOf(item), 0) + + /** Stops and clears the queue. */ + fun stop(): Snapshot = commit( + snapshot.copy( + current = null, + upcoming = emptyList(), + history = emptyList(), + context = emptyList(), + shuffleBackup = null + ) + ) + + fun toggleShuffle(): Snapshot = setShuffle(!snapshot.shuffle) + + /** Adopts a shuffle change from the native player without re-shuffling an already shuffled tail. */ + fun setShuffle(enabled: Boolean): Snapshot = when { + enabled == snapshot.shuffle -> snapshot + + enabled -> commit( + snapshot.copy( + shuffle = true, + upcoming = snapshot.upcoming.shuffled(random), + shuffleBackup = snapshot.upcoming + ) + ) + + else -> commit( + snapshot.copy( + shuffle = false, + upcoming = snapshot.shuffleBackup ?: snapshot.upcoming, + shuffleBackup = null + ) + ) + } + + fun clearHistory(): Snapshot = commit(snapshot.copy(history = emptyList())) + + fun removeHistory(item: SpotifyMediaItem): Snapshot = commit(snapshot.copy(history = snapshot.history.filterNot { it.id == item.id })) + + fun cycleRepeat(): Snapshot = commit(snapshot.copy(repeat = snapshot.repeat.next())) + + fun setRepeat(mode: RepeatMode): Snapshot = commit(snapshot.copy(repeat = mode)) + + /** + * Moves to the next upcoming item. [auto] distinguishes the end of a track from the user + * pressing Next: an empty queue only stops automatically. + */ + fun next(auto: Boolean): Snapshot { + val current = snapshot.current ?: return snapshot + if (auto && snapshot.repeat == RepeatMode.Track) return snapshot + val next = snapshot.upcoming.firstOrNull() + if (next != null) { + return commit( + snapshot.copy( + current = next, + upcoming = snapshot.upcoming.drop(1), + history = (listOf(current) + snapshot.history).take(HISTORY_LIMIT) + ) + ) + } + if (!auto) return snapshot + return when (snapshot.repeat) { + RepeatMode.Context -> replayContext() + RepeatMode.Track -> snapshot + RepeatMode.Off -> commit(snapshot.copy(current = null, history = snapshot.history)) + } + } + + /** User pressed Previous. Restarts when past the 3 s threshold. */ + fun previous(positionMs: Long): Snapshot { + if (snapshot.current == null) return snapshot + if (positionMs > RESTART_THRESHOLD_MS) return snapshot + val previous = snapshot.history.firstOrNull() ?: return snapshot + return commit( + snapshot.copy( + current = previous, + upcoming = listOfNotNull(snapshot.current) + snapshot.upcoming, + history = snapshot.history.drop(1) + ) + ) + } + + fun jumpTo(id: String): Snapshot { + val current = snapshot.current ?: return snapshot + if (current.id == id) return snapshot + val at = snapshot.upcoming.indexOfFirst { it.id == id } + if (at >= 0) { + val skipped = snapshot.upcoming.take(at) + val target = snapshot.upcoming[at] + return commit( + snapshot.copy( + current = target, + upcoming = snapshot.upcoming.drop(at + 1), + history = (listOf(current) + skipped + snapshot.history).take(HISTORY_LIMIT) + ) + ) + } + snapshot.history.firstOrNull { it.id == id }?.let { playItem(it) } + return snapshot + } + + /** Inserts directly after the current item; an id already upcoming is moved, not duplicated. */ + fun playNext(item: SpotifyMediaItem): Snapshot { + if (item.id == snapshot.current?.id) return snapshot + val remaining = snapshot.upcoming.filterNot { it.id == item.id } + return commit(snapshot.copy(upcoming = listOf(item) + remaining)) + } + + fun addToQueue(item: SpotifyMediaItem): Snapshot = addToQueue(listOf(item)) + + fun addToQueue(items: List): Snapshot = commit(snapshot.copy(upcoming = snapshot.upcoming + items)) + + fun removeUpcoming(item: SpotifyMediaItem): Snapshot = commit(snapshot.copy(upcoming = snapshot.upcoming.filterNot { it.id == item.id })) + + /** Removes every upcoming item and returns them so the caller can offer Undo. */ + fun clearUpcoming(): List { + val removed = snapshot.upcoming + commit(snapshot.copy(upcoming = emptyList(), shuffleBackup = null)) + return removed + } + + /** Restores a cleared tail in front of whatever was appended in the meantime. */ + fun restoreUpcoming(items: List): Snapshot = commit(snapshot.copy(upcoming = items + snapshot.upcoming)) + + private fun replayContext(): Snapshot { + val items = snapshot.context + if (items.isEmpty()) return commit(snapshot.copy(current = null)) + val first = items.first() + return commit( + snapshot.copy( + current = first, + upcoming = items.drop(1).let { if (snapshot.shuffle) it.shuffled(random) else it }, + history = snapshot.history, + shuffleBackup = if (snapshot.shuffle) items.drop(1) else null + ) + ) + } + + private fun commit(next: Snapshot): Snapshot { + if (next != snapshot) { + snapshot = next + runCatching { save(next) } + } + return snapshot + } + + private companion object { + const val HISTORY_LIMIT = 20 + const val RESTART_THRESHOLD_MS = 3_000L + } +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifySession.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifySession.kt new file mode 100644 index 00000000..c6388400 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifySession.kt @@ -0,0 +1,256 @@ +package hrln.interactive.sidekit.apps.spotify.data + +import android.content.Context +import hrln.interactive.sidekit.apps.spotify.AudioQuality +import hrln.interactive.sidekit.apps.spotify.SideplayDebug +import hrln.interactive.sidekit.apps.spotify.SpotifyMediaItem +import hrln.interactive.sidekit.apps.spotify.SpotifyTrack +import hrln.interactive.sidekit.apps.spotify.auth.EncryptedSpotifyCredentialStore +import hrln.interactive.sidekit.apps.spotify.auth.SpotifyAuth +import hrln.interactive.sidekit.apps.spotify.bridge.NativeBridge +import hrln.interactive.sidekit.apps.spotify.playback.SpotifyAudioSink +import hrln.interactive.sidekit.apps.spotify.playback.SpotifyPlayback +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Process-wide Spotify identity. It owns the credential store and token refresh, not the UI; the + * ViewModel observes [signedIn] and asks for an access token when a real gateway is connected. + */ +class SpotifySession( + private val auth: SpotifyAuth = SpotifyAuth(NoopCredentialStore), + private val tmpDir: String? = null, + context: Context? = null +) { + private val _signedIn = MutableStateFlow(auth.hasCredentials()) + val signedIn: StateFlow = _signedIn.asStateFlow() + + private val appContext = context?.applicationContext + private val audioCacheDir = appContext?.let { File(it.cacheDir, "spotify-audio") } + private val settingsStore = appContext?.let { SpotifySettingsStore(it) } + + // Legacy file names kept so existing dev installs keep their queue and history. + private val queueStore = appContext?.let { FileSpotifyQueueStore(File(it.filesDir, "sidetracks-queue.bin")) } + private val historyStore = appContext?.let { FileSpotifyHistoryStore(File(it.filesDir, "sidetracks-history.bin")) } + private val _settings = MutableStateFlow(settingsStore?.read() ?: SpotifySettings()) + val settings: StateFlow = _settings.asStateFlow() + + val nativeAvailable: Boolean get() = NativeBridge.isAvailable + + private val nativeGateway by lazy { + NativeSpotifyGateway(NativeBridge, { _settings.value }, { accessToken() }) + } + val gateway: SpotifyGateway get() = nativeGateway + + private val webApi by lazy { SpotifyWebApi { accessToken() } } + val catalog: SpotifyCatalog by lazy { + if (nativeAvailable) NativeSpotifyCatalog(NativeBridge) { nativeGateway.ensureSession() } else SpotifyWebCatalog(webApi) + } + val writer: SpotifyLibraryWriter? get() = catalog as? SpotifyLibraryWriter + + /** Process-wide queue and history so leaving Sideplay does not forget what is playing. */ + val queue = SpotifyQueueRepository( + load = { queueStore?.read() ?: SpotifyQueueRepository.Snapshot() }, + save = { queueStore?.write(it) } + ) + val history = SpotifyHistoryRepository( + load = { historyStore?.read().orEmpty() }, + save = { historyStore?.write(it) } + ) + + private val playbackScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + val playback: SpotifyPlayback by lazy { + SpotifyPlayback( + gateway = nativeGateway, + scope = playbackScope, + audio = SpotifyAudioSink(NativeBridge, playbackScope), + context = appContext + ).also { player -> + // Adopt transport actions when no ViewModel is attached. The ViewModel installs its + // own callbacks while it exists and clears them in onCleared(). + player.fallbackNext = { skipNext() } + player.fallbackPrevious = { skipPrevious() } + player.onStopped = { queue.stop() } + } + } + + init { + if (nativeAvailable) { + tmpDir?.let(NativeBridge::setTmpDir) + audioCacheDir?.let { NativeBridge.setCacheDir(it.absolutePath, AUDIO_CACHE_LIMIT_BYTES) } + NativeBridge.initialize() + } + } + + suspend fun signIn(openBrowser: suspend (String) -> Unit) { + auth.signIn(openBrowser) + _signedIn.value = true + } + + fun signOut() { + queue.stop() + history.clear() + if (nativeAvailable) { + playback.setNowPlaying(null) + playback.pause() + // Stop the decoder immediately; waiting for the coroutine below leaves a window in + // which the current track keeps playing after the credentials have been cleared. + NativeBridge.stop() + playbackScope.launch { runCatching { playback.disconnect() } } + } else { + nativeGateway.reset() + NativeBridge.disconnect() + } + auth.clear() + _signedIn.value = false + } + + /** + * Connects librespot with the current access token, creating the one player on first use. + * Returns true when the native session had to be rebuilt, which discards the loaded track. + */ + suspend fun ensurePlaybackConnected(): Boolean = nativeGateway.ensureConnected() + + /** Bounded so a stalled access-point handshake cannot leave the play button stuck forever. */ + private suspend fun connectForPlayback(): Boolean { + val rebuilt = withTimeoutOrNull(CONNECT_TIMEOUT_MS) { nativeGateway.ensureConnected() } + if (rebuilt == null) { + SideplayDebug.log("connectForPlayback timed out after ${CONNECT_TIMEOUT_MS}ms") + throw SpotifyGatewayException(SpotifyFailure.NotConnected, "Playback connection timed out") + } + SideplayDebug.log("connectForPlayback rebuilt=$rebuilt") + return rebuilt + } + + suspend fun play(uri: String, positionMs: Long = 0L) { + SideplayDebug.log("session.play begin uri=$uri positionMs=$positionMs") + connectForPlayback() + SideplayDebug.log("session.play connected; starting playback") + withContext(Dispatchers.Main.immediate) { playback.start(uri, positionMs) } + SideplayDebug.log("session.play returned") + } + + suspend fun resume(): Boolean { + val rebuilt = connectForPlayback() + // A rebuilt session starts from an empty native player. Reload the current item instead of + // asking the player to resume a track it no longer has. + val uri = playback.current?.uri + return if (rebuilt && uri != null) { + withContext(Dispatchers.Main.immediate) { playback.start(uri, playback.position) } + true + } else { + withContext(Dispatchers.Main.immediate) { playback.resume() } + } + } + + suspend fun seek(positionMs: Long) = playback.seek(positionMs) + + fun pause() = playback.pause() + + suspend fun accessToken(): String? = auth.accessToken() + + fun setAudioQuality(quality: AudioQuality) { + _settings.update { it.copy(audioQuality = quality) } + settingsStore?.write(_settings.value) + } + + fun setAutoplay(enabled: Boolean) { + _settings.update { it.copy(autoplay = enabled) } + settingsStore?.write(_settings.value) + } + + fun togglePlayback() { + if (playback.isActive) { + playback.pause() + } else { + playbackScope.launch { runCatching { resume() } } + } + } + + fun stopPlayback() { + queue.stop() + playbackScope.launch { runCatching { playback.stop() } } + } + + /** + * Advances the queue when the notification/lock screen has no ViewModel to call back into. + * The ViewModel follows the same queue semantics, so the UI adopts this state on return. + */ + fun skipNext() { + val before = queue.snapshot.current ?: return + val snapshot = queue.next(auto = false) + if (snapshot.current !== before) snapshot.current?.let(::startFromTransport) + } + + /** Restarts the current item past the 3 s threshold, otherwise returns to queue history. */ + fun skipPrevious() { + val current = queue.snapshot.current ?: return + if (playback.position > 3_000L) { + playbackScope.launch { runCatching { seek(0L) } } + return + } + val snapshot = queue.previous(playback.position) + if (snapshot.current !== current) { + snapshot.current?.let(::startFromTransport) + } else { + playbackScope.launch { runCatching { seek(0L) } } + } + } + + private fun startFromTransport(item: SpotifyMediaItem) { + if (item is SpotifyTrack) history.record(item, System.currentTimeMillis()) + playback.setNowPlaying(item, 0L) + val uri = item.uri ?: return + playbackScope.launch { + runCatching { play(uri, 0L) }.onFailure { + playback.pause() + playback.updateState(false, 0L) + } + } + } + + /** One librespot player event, or null when nothing has happened since the last poll. */ + fun pollPlaybackEvent(): String? = if (nativeAvailable) NativeBridge.pollEvent() else null + + /** Drops the on-disk audio cache. librespot re-fetches anything it still needs. */ + fun clearAudioCache() { + audioCacheDir?.deleteRecursively() + } + + private companion object { + /** Slightly longer than the native connect timeout, so its specific error wins. */ + const val CONNECT_TIMEOUT_MS = 25_000L + + /** Upper bound for the audio cache; librespot evicts least-recently-used entries. */ + const val AUDIO_CACHE_LIMIT_BYTES = 1L shl 30 + } + + private object NoopCredentialStore : hrln.interactive.sidekit.apps.spotify.auth.SpotifyCredentialStore { + override fun read(): hrln.interactive.sidekit.apps.spotify.auth.SpotifyCredentials? = null + override fun save(credentials: hrln.interactive.sidekit.apps.spotify.auth.SpotifyCredentials) = Unit + override fun clear() = Unit + } +} + +/** Lazily creates the one session for the process, matching how Sidekit owns local playback. */ +object SpotifySessions { + @Volatile + private var instance: SpotifySession? = null + + fun get(context: Context): SpotifySession = instance ?: synchronized(this) { + instance ?: SpotifySession( + SpotifyAuth(EncryptedSpotifyCredentialStore(context.applicationContext)), + context.applicationContext.cacheDir.absolutePath, + context.applicationContext + ).also { instance = it } + } +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifySettings.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifySettings.kt new file mode 100644 index 00000000..8c7b24f4 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifySettings.kt @@ -0,0 +1,52 @@ +package hrln.interactive.sidekit.apps.spotify.data + +import android.content.Context +import hrln.interactive.sidekit.apps.integrations.PrivateSettings +import hrln.interactive.sidekit.apps.spotify.AudioQuality + +/** User preferences that survive process death and apply before the native player is created. */ +data class SpotifySettings( + val audioQuality: AudioQuality = AudioQuality.High, + /** Keep playing similar tracks when the queue runs out. */ + val autoplay: Boolean = true +) { + val bitrateKbps: Int get() = audioQuality.bitrateKbps +} + +/** + * Settings live in the same encrypted, atomic store as credentials. They are not secrets, but the + * store already exists, needs no extra dependency and keeps all Sideplay state in one place. + */ +class SpotifySettingsStore( + context: Context, + // Legacy name kept so existing dev installs keep their audio-quality setting. + name: String = "sidetracks_settings" +) { + private val settings = PrivateSettings(context.applicationContext, name) + + @Synchronized + fun read(): SpotifySettings { + val value = settings.read() + val quality = AudioQuality.entries + .firstOrNull { it.name == value.optString(KEY_QUALITY) } + ?: AudioQuality.High + return SpotifySettings( + audioQuality = quality, + autoplay = value.optBoolean(KEY_AUTOPLAY, true) + ) + } + + @Synchronized + fun write(value: SpotifySettings) { + settings.write( + org.json.JSONObject() + .put(KEY_QUALITY, value.audioQuality.name) + .put(KEY_AUTOPLAY, value.autoplay) + ) + } + + private companion object { + const val KEY_QUALITY = "audio_quality" + const val KEY_AUTOPLAY = "autoplay" + } +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifyStateStores.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifyStateStores.kt new file mode 100644 index 00000000..697b7a2e --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifyStateStores.kt @@ -0,0 +1,234 @@ +package hrln.interactive.sidekit.apps.spotify.data + +import androidx.core.util.AtomicFile +import hrln.interactive.sidekit.apps.spotify.RepeatMode +import hrln.interactive.sidekit.apps.spotify.SpotifyEpisode +import hrln.interactive.sidekit.apps.spotify.SpotifyMediaItem +import hrln.interactive.sidekit.apps.spotify.SpotifyTrack +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.File +import java.io.IOException + +/** Persists the process-wide queue, including its history and the pre-shuffle order. */ +interface SpotifyQueueStore { + fun read(): SpotifyQueueRepository.Snapshot + fun write(snapshot: SpotifyQueueRepository.Snapshot) +} + +/** Persists the recently played list shown on Home. */ +interface SpotifyHistoryStore { + fun read(): List + fun write(entries: List) +} + +/** + * Small binary files replaced atomically ([AtomicFile]), matching playlists, reminders and + * Sidenotes. A corrupt or missing file falls back to an empty state instead of blocking startup. + */ +class FileSpotifyQueueStore(file: File) : SpotifyQueueStore { + private val file = AtomicFile(file) + + override fun read(): SpotifyQueueRepository.Snapshot { + val stream = try { + file.openRead() + } catch (_: IOException) { + return SpotifyQueueRepository.Snapshot() + } + return try { + DataInputStream(BufferedInputStream(stream)).use { input -> + if (input.readInt() != MAGIC || input.readInt() != VERSION) return SpotifyQueueRepository.Snapshot() + val shuffle = input.readBoolean() + val repeat = RepeatMode.entries.getOrNull(input.readInt()) ?: RepeatMode.Off + SpotifyQueueRepository.Snapshot( + current = input.readItemOrNull(), + upcoming = input.readItems(), + history = input.readItems(), + shuffle = shuffle, + repeat = repeat, + context = input.readItems(), + shuffleBackup = input.readItemsOrNull() + ) + } + } catch (_: Exception) { + SpotifyQueueRepository.Snapshot() + } + } + + override fun write(snapshot: SpotifyQueueRepository.Snapshot) { + val out = file.startWrite() + try { + val data = DataOutputStream(BufferedOutputStream(out)) + data.writeInt(MAGIC) + data.writeInt(VERSION) + data.writeBoolean(snapshot.shuffle) + data.writeInt(snapshot.repeat.ordinal) + data.writeItemOrNull(snapshot.current) + data.writeItems(snapshot.upcoming) + data.writeItems(snapshot.history) + data.writeItems(snapshot.context) + data.writeItemsOrNull(snapshot.shuffleBackup) + data.flush() + file.finishWrite(out) + } catch (e: Exception) { + file.failWrite(out) + throw e + } + } + + private companion object { + const val MAGIC = 0x534B5451 // "SKTQ" + const val VERSION = 1 + } +} + +class FileSpotifyHistoryStore(file: File) : SpotifyHistoryStore { + private val file = AtomicFile(file) + + override fun read(): List { + val stream = try { + file.openRead() + } catch (_: IOException) { + return emptyList() + } + return try { + DataInputStream(BufferedInputStream(stream)).use { input -> + if (input.readInt() != MAGIC || input.readInt() != VERSION) return emptyList() + List(input.readCount(MAX_HISTORY)) { + SpotifyHistoryEntry(input.readItem(), input.readLong()) + } + } + } catch (_: Exception) { + emptyList() + } + } + + override fun write(entries: List) { + val out = file.startWrite() + try { + val data = DataOutputStream(BufferedOutputStream(out)) + data.writeInt(MAGIC) + data.writeInt(VERSION) + data.writeInt(entries.size) + entries.forEach { entry -> + data.writeItem(entry.item) + data.writeLong(entry.playedAtMs) + } + data.flush() + file.finishWrite(out) + } catch (e: Exception) { + file.failWrite(out) + throw e + } + } + + private companion object { + const val MAGIC = 0x534B5448 // "SKTH" + const val VERSION = 1 + const val MAX_HISTORY = 10_000 + } +} + +private const val TYPE_TRACK = 1 +private const val TYPE_EPISODE = 2 +private const val MAX_ITEMS = 100_000 + +private fun DataOutputStream.writeItems(items: List) { + writeInt(items.size) + items.forEach(::writeItem) +} + +private fun DataOutputStream.writeItemsOrNull(items: List?) { + writeBoolean(items != null) + items?.let(::writeItems) +} + +private fun DataOutputStream.writeItemOrNull(item: SpotifyMediaItem?) { + writeBoolean(item != null) + item?.let(::writeItem) +} + +private fun DataOutputStream.writeItem(item: SpotifyMediaItem) { + when (item) { + is SpotifyTrack -> { + writeByte(TYPE_TRACK) + writeUTF(item.id) + writeUTF(item.title) + writeUTF(item.artist) + writeUTF(item.artistId) + writeUTF(item.albumId) + writeInt(item.artRes) + writeLong(item.durationMs) + writeBoolean(item.explicit) + writeBoolean(item.playable) + writeNullableString(item.uri) + writeNullableString(item.artUrl) + } + + is SpotifyEpisode -> { + writeByte(TYPE_EPISODE) + writeUTF(item.id) + writeUTF(item.title) + writeUTF(item.show) + writeUTF(item.showId) + writeUTF(item.published) + writeInt(item.artRes) + writeLong(item.durationMs) + writeBoolean(item.video) + writeNullableString(item.uri) + writeNullableString(item.artUrl) + } + } +} + +private fun DataInputStream.readItems(): List = List(readCount(MAX_ITEMS)) { readItem() } + +private fun DataInputStream.readItemsOrNull(): List? = if (readBoolean()) readItems() else null + +private fun DataInputStream.readItemOrNull(): SpotifyMediaItem? = if (readBoolean()) readItem() else null + +private fun DataInputStream.readItem(): SpotifyMediaItem = when (val type = readUnsignedByte()) { + TYPE_TRACK -> SpotifyTrack( + id = readUTF(), + title = readUTF(), + artist = readUTF(), + artistId = readUTF(), + albumId = readUTF(), + artRes = readInt(), + durationMs = readLong(), + explicit = readBoolean(), + playable = readBoolean(), + uri = readNullableString(), + artUrl = readNullableString() + ) + + TYPE_EPISODE -> SpotifyEpisode( + id = readUTF(), + title = readUTF(), + show = readUTF(), + showId = readUTF(), + published = readUTF(), + artRes = readInt(), + durationMs = readLong(), + video = readBoolean(), + uri = readNullableString(), + artUrl = readNullableString() + ) + + else -> throw IOException("Unknown Sideplay media item type $type") +} + +private fun DataInputStream.readCount(max: Int): Int { + val count = readInt() + if (count !in 0..max) throw IOException("Invalid Sideplay item count $count") + return count +} + +private fun DataOutputStream.writeNullableString(value: String?) { + writeBoolean(value != null) + if (value != null) writeUTF(value) +} + +private fun DataInputStream.readNullableString(): String? = if (readBoolean()) readUTF() else null diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifyWebApi.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifyWebApi.kt new file mode 100644 index 00000000..cda3043b --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifyWebApi.kt @@ -0,0 +1,435 @@ +package hrln.interactive.sidekit.apps.spotify.data + +import hrln.interactive.sidekit.apps.spotify.LibraryFilter +import hrln.interactive.sidekit.apps.spotify.SPOTIFY_PAGE_SIZE +import hrln.interactive.sidekit.apps.spotify.SearchScope +import hrln.interactive.sidekit.apps.spotify.SpotifyAlbum +import hrln.interactive.sidekit.apps.spotify.SpotifyArtist +import hrln.interactive.sidekit.apps.spotify.SpotifyCollection +import hrln.interactive.sidekit.apps.spotify.SpotifyCollectionType +import hrln.interactive.sidekit.apps.spotify.SpotifyEpisode +import hrln.interactive.sidekit.apps.spotify.SpotifyPage +import hrln.interactive.sidekit.apps.spotify.SpotifyPlaylist +import hrln.interactive.sidekit.apps.spotify.SpotifySearchResults +import hrln.interactive.sidekit.apps.spotify.SpotifyShow +import hrln.interactive.sidekit.apps.spotify.SpotifyTrack +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject + +/** + * The Spotify Web API reads the app needs. It deliberately returns UI models, not raw JSON: the + * screens never learn an endpoint or a field name. + */ +class SpotifyWebApi(private val accessToken: suspend () -> String?) { + suspend fun library( + filter: LibraryFilter, + offset: Int = 0, + limit: Int = SPOTIFY_PAGE_SIZE + ): SpotifyPage { + val page = { items: List, total: Int? -> SpotifyPage.of(items, offset, limit, total) } + return when (filter) { + LibraryFilter.LikedSongs -> { + val json = get("$ME_TRACKS?limit=$limit&offset=$offset", token()) + page(tracks(json), json.optInt("total").takeIf { it > 0 }) + } + + LibraryFilter.Playlists -> { + val json = get("$ME_PLAYLISTS?limit=$limit&offset=$offset", token()) + page(json.optJSONArray("items").playlists(), json.optInt("total").takeIf { it > 0 }) + } + + LibraryFilter.Albums -> savedAlbums(offset, limit) + + LibraryFilter.Artists -> followedArtists(offset, limit) + + LibraryFilter.Shows -> savedShows(offset, limit) + } + } + + suspend fun collection( + type: SpotifyCollectionType, + id: String, + offset: Int = 0, + limit: Int = SPOTIFY_PAGE_SIZE + ): SpotifyCollection? = when (type) { + SpotifyCollectionType.Album -> runCatching { albumCollection(id, offset, limit) }.getOrNull() + SpotifyCollectionType.Playlist -> runCatching { playlistCollection(id, offset, limit) }.getOrNull() + SpotifyCollectionType.Show -> runCatching { showCollection(id, offset, limit) }.getOrNull() + } + + suspend fun search( + query: String, + scopes: Set, + offset: Int = 0, + limit: Int = SPOTIFY_PAGE_SIZE + ): SpotifySearchResults { + if (query.trim().length < 2) return SpotifySearchResults() + val types = buildList { + if (SearchScope.Tracks in scopes) add("track") + if (SearchScope.Artists in scopes) add("artist") + if (SearchScope.Albums in scopes) add("album") + if (SearchScope.Playlists in scopes) add("playlist") + if (SearchScope.Shows in scopes) add("show") + } + if (types.isEmpty()) return SpotifySearchResults() + val json = get( + "$SEARCH?q=${enc(query.trim())}&type=${types.joinToString(",")}&limit=${limit.coerceIn(1, 50)}&offset=$offset&market=from_token", + token() + ) + return SpotifySearchResults( + tracks = json.optJSONObject("tracks")?.optJSONArray("items").tracks(), + artists = json.optJSONObject("artists")?.optJSONArray("items").artists(), + albums = json.optJSONObject("albums")?.optJSONArray("items").albums(), + playlists = json.optJSONObject("playlists")?.optJSONArray("items").playlists(), + shows = json.optJSONObject("shows")?.optJSONArray("items").shows() + ) + } + + suspend fun artist(id: String): SpotifyArtist? = runCatching { + artist(get("$ARTISTS/${enc(id)}", token())) + }.getOrNull() + + suspend fun albumsBy( + artist: SpotifyArtist, + offset: Int = 0, + limit: Int = SPOTIFY_PAGE_SIZE + ): SpotifyPage { + val json = runCatching { + get( + "$ARTISTS/${enc(artist.id)}/albums?include_groups=album,single&limit=$limit&offset=$offset&market=from_token", + token() + ) + }.getOrNull() + val albums = json?.optJSONArray("items").albums().orEmpty() + return SpotifyPage.of(albums, offset, limit, json?.optInt("total")?.takeIf { it > 0 }) + } + + suspend fun topTracks(artist: SpotifyArtist, offset: Int = 0, limit: Int = SPOTIFY_PAGE_SIZE): SpotifyPage { + val json = runCatching { + get("$ARTISTS/${enc(artist.id)}/top-tracks?market=from_token", token()) + }.getOrNull() + val tracks = json?.optJSONArray("tracks").tracks().orEmpty().drop(offset).take(limit) + return SpotifyPage.of(tracks, offset, limit) + } + + /** Visible for tests: maps Spotify's search envelope without touching the network. */ + internal fun parseSearchTracks(json: JSONObject): List = json.optJSONObject("tracks")?.optJSONArray("items").tracks() + + private fun tracks(json: JSONObject): List { + val items = json.optJSONArray("items") ?: return emptyList() + return buildList(items.length()) { + for (i in 0 until items.length()) { + items.optJSONObject(i)?.optJSONObject("track")?.let(::track)?.let(::add) + } + } + } + + private suspend fun savedAlbums(offset: Int, limit: Int): SpotifyPage { + val json = get("$ME_ALBUMS?limit=$limit&offset=$offset", token()) + val items = json.optJSONArray("items") ?: JSONArray() + val albums = buildList(items.length()) { + for (i in 0 until items.length()) { + items.optJSONObject(i)?.optJSONObject("album")?.let(::album)?.let(::add) + } + } + return SpotifyPage.of(albums, offset, limit, json.optInt("total").takeIf { it > 0 }) + } + + private suspend fun followedArtists(offset: Int, limit: Int): SpotifyPage { + val json = get("$ME_FOLLOWING?type=artist&limit=$limit&offset=$offset", token()) + val page = json.optJSONObject("artists") + val artists = page?.optJSONArray("items").artists().orEmpty() + return SpotifyPage.of(artists, offset, limit, page?.optInt("total")?.takeIf { it > 0 }) + } + + private suspend fun savedShows(offset: Int, limit: Int): SpotifyPage { + val json = get("$ME_SHOWS?limit=$limit&offset=$offset", token()) + val items = json.optJSONArray("items") ?: JSONArray() + val shows = buildList(items.length()) { + for (i in 0 until items.length()) { + items.optJSONObject(i)?.optJSONObject("show")?.let(::show)?.let(::add) + } + } + return SpotifyPage.of(shows, offset, limit, json.optInt("total").takeIf { it > 0 }) + } + + private suspend fun albumCollection(id: String, offset: Int, limit: Int): SpotifyCollection? { + val json = get("$ALBUMS/${enc(id)}", token()) + val album = album(json) ?: return null + val page = get("$ALBUMS/${enc(id)}/tracks?limit=$limit&offset=$offset", token()) + val tracks = page.optJSONArray("items").tracks() + return SpotifyCollection( + type = SpotifyCollectionType.Album, + id = album.id, + title = album.title, + subtitle = "${album.artist} · ${album.year} · ${album.trackCount} tracks", + artRes = 0, + items = tracks, + artistId = album.artistId, + artUrl = album.artUrl, + totalItems = album.trackCount, + hasMore = offset + tracks.size < album.trackCount + ) + } + + private suspend fun playlistCollection(id: String, offset: Int, limit: Int): SpotifyCollection? { + val json = get("$PLAYLISTS/${enc(id)}", token()) + val playlist = playlist(json) ?: return null + val page = get("$PLAYLISTS/${enc(id)}/tracks?limit=$limit&offset=$offset&market=from_token", token()) + val tracks = tracks(page) + return SpotifyCollection( + type = SpotifyCollectionType.Playlist, + id = playlist.id, + title = playlist.name, + subtitle = "${playlist.owner} · ${playlist.trackCount} tracks", + artRes = 0, + items = tracks, + artistId = playlist.ownerId, + artUrl = playlist.artUrl, + totalItems = playlist.trackCount, + hasMore = offset + tracks.size < playlist.trackCount + ) + } + + private suspend fun showCollection(id: String, offset: Int, limit: Int): SpotifyCollection? { + val json = get("$SHOWS/${enc(id)}", token()) + val show = show(json) ?: return null + val page = get("$SHOWS/${enc(id)}/episodes?limit=$limit&offset=$offset&market=from_token", token()) + val items = page.optJSONArray("items") ?: JSONArray() + val episodes = buildList(items.length()) { + for (i in 0 until items.length()) { + items.optJSONObject(i)?.let { episode(it, show) }?.let(::add) + } + } + return SpotifyCollection( + type = SpotifyCollectionType.Show, + id = show.id, + title = show.name, + subtitle = "${show.publisher} · ${show.episodeCount} episodes", + artRes = 0, + items = episodes, + artUrl = show.artUrl, + totalItems = show.episodeCount, + hasMore = offset + episodes.size < show.episodeCount + ) + } + + private fun track(json: JSONObject): SpotifyTrack? { + val id = json.optString("id").takeIf { it.isNotEmpty() } ?: return null + val album = json.optJSONObject("album") + val artist = json.optJSONArray("artists")?.optJSONObject(0) + return SpotifyTrack( + id = id, + title = json.optString("name", "Unknown track"), + artist = artist?.optString("name").orEmpty().ifEmpty { "Unknown artist" }, + artistId = artist?.optString("id").orEmpty(), + albumId = album?.optString("id").orEmpty(), + artRes = 0, + durationMs = json.optLong("duration_ms", 0L), + explicit = json.optBoolean("explicit", false), + playable = json.optBoolean("is_playable", true), + uri = json.optString("uri").takeIf { it.isNotEmpty() }, + artUrl = imageUrl(album) + ) + } + + private fun episode(json: JSONObject, show: SpotifyShow? = null): SpotifyEpisode? { + val id = json.optString("id").takeIf { it.isNotEmpty() } ?: return null + val images = imageUrl(json) ?: show?.artUrl + return SpotifyEpisode( + id = id, + title = json.optString("name", "Unknown episode"), + show = show?.name ?: json.optString("show").ifEmpty { "Podcast" }, + showId = show?.id ?: json.optJSONObject("show")?.optString("id").orEmpty(), + published = json.optString("release_date").ifEmpty { "Podcast episode" }, + artRes = 0, + durationMs = json.optLong("duration_ms", 0L), + video = false, + uri = json.optString("uri").takeIf { it.isNotEmpty() }, + artUrl = images + ) + } + + private fun album(json: JSONObject): SpotifyAlbum? { + val id = json.optString("id").takeIf { it.isNotEmpty() } ?: return null + val artist = json.optJSONArray("artists")?.optJSONObject(0) + val tracks = json.optJSONObject("tracks")?.optJSONArray("items").tracks() + return SpotifyAlbum( + id = id, + title = json.optString("name", "Unknown album"), + artist = artist?.optString("name").orEmpty().ifEmpty { "Unknown artist" }, + artistId = artist?.optString("id").orEmpty(), + year = json.optString("release_date").take(4).toIntOrNull() ?: 0, + artRes = 0, + tracks = tracks, + artUrl = imageUrl(json), + trackCount = json.optJSONObject("tracks")?.optInt("total", tracks.size) ?: tracks.size + ) + } + + private fun playlist(json: JSONObject): SpotifyPlaylist? { + val id = json.optString("id").takeIf { it.isNotEmpty() } ?: return null + val owner = json.optJSONObject("owner") + return SpotifyPlaylist( + id = id, + name = json.optString("name", "Unknown playlist"), + owner = owner?.optString("display_name").orEmpty().ifEmpty { "Spotify" }, + ownerId = owner?.optString("id"), + artRes = 0, + tracks = emptyList(), + artUrl = imageUrl(json), + trackCount = json.optJSONObject("tracks")?.optInt("total", 0) ?: 0 + ) + } + + private fun show(json: JSONObject): SpotifyShow? { + val id = json.optString("id").takeIf { it.isNotEmpty() } ?: return null + val artUrl = imageUrl(json) + val base = SpotifyShow( + id = id, + name = json.optString("name", "Unknown show"), + publisher = json.optString("publisher").ifEmpty { "Podcast" }, + artRes = 0, + episodes = emptyList(), + artUrl = artUrl + ) + val episodes = json.optJSONObject("episodes")?.optJSONArray("items").episodes(this, base) + return base.copy( + episodes = episodes, + episodeCount = json.optJSONObject("episodes")?.optInt("total", episodes.size) ?: episodes.size + ) + } + + private fun artist(json: JSONObject): SpotifyArtist? { + val id = json.optString("id").takeIf { it.isNotEmpty() } ?: return null + return SpotifyArtist( + id = id, + name = json.optString("name", "Unknown artist"), + artRes = 0, + albumIds = emptyList(), + artUrl = imageUrl(json) + ) + } + + private fun JSONArray?.tracks(): List = buildList { + if (this@tracks == null) return@buildList + for (i in 0 until this@tracks.length()) { + this@tracks.optJSONObject(i)?.let(::track)?.let(::add) + } + } + + private fun JSONArray?.albums(): List = buildList { + if (this@albums == null) return@buildList + for (i in 0 until this@albums.length()) { + this@albums.optJSONObject(i)?.let(::album)?.let(::add) + } + } + + private fun JSONArray?.artists(): List = buildList { + if (this@artists == null) return@buildList + for (i in 0 until this@artists.length()) { + this@artists.optJSONObject(i)?.let(::artist)?.let(::add) + } + } + + private fun JSONArray?.playlists(): List = buildList { + if (this@playlists == null) return@buildList + for (i in 0 until this@playlists.length()) { + this@playlists.optJSONObject(i)?.let(::playlist)?.let(::add) + } + } + + private fun JSONArray?.shows(): List = buildList { + if (this@shows == null) return@buildList + for (i in 0 until this@shows.length()) { + this@shows.optJSONObject(i)?.let(::show)?.let(::add) + } + } + + private fun JSONArray?.episodes(api: SpotifyWebApi, show: SpotifyShow): List = buildList { + if (this@episodes == null) return@buildList + for (i in 0 until this@episodes.length()) { + this@episodes.optJSONObject(i)?.let { api.episode(it, show) }?.let(::add) + } + } + + private fun imageUrl(json: JSONObject?): String? { + val images = json?.optJSONArray("images") ?: return null + var best: String? = null + var bestWidth = Int.MAX_VALUE + for (i in 0 until images.length()) { + val image = images.optJSONObject(i) ?: continue + val url = image.optString("url").takeIf { it.isNotEmpty() } ?: continue + val width = image.optInt("width", Int.MAX_VALUE) + if (width >= 64 && width < bestWidth) { + best = url + bestWidth = width + } + } + return best ?: images.optJSONObject(0)?.optString("url")?.takeIf { it.isNotEmpty() } + } + + private suspend fun token(): String = accessToken() ?: fail(SpotifyFailure.AuthenticationExpired, "Sign in again") + + private suspend fun get(url: String, token: String): JSONObject = withContext(Dispatchers.IO) { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + setRequestProperty("Authorization", "Bearer $token") + setRequestProperty("Accept", "application/json") + } + try { + val status = connection.responseCode + val text = (if (status in 200..299) connection.inputStream else connection.errorStream) + ?.bufferedReader()?.use { it.readText() }.orEmpty() + when { + status == 401 -> fail(SpotifyFailure.AuthenticationExpired, "Sign in again") + + status == 429 -> { + android.util.Log.w("SideplayApi", "$url -> 429 retryAfter=${connection.getHeaderField("Retry-After")} ${text.take(300)}") + fail(SpotifyFailure.RateLimited, "Spotify is rate-limiting requests") + } + + status !in 200..299 -> { + android.util.Log.w("SideplayApi", "$url -> $status ${text.take(300)}") + fail(SpotifyFailure.Network, "Spotify returned HTTP $status") + } + + else -> runCatching { JSONObject(text) } + .getOrElse { fail(SpotifyFailure.Unknown, "Spotify returned an unreadable response") } + } + } catch (e: SpotifyGatewayException) { + throw e + } catch (e: IOException) { + throw SpotifyGatewayException(SpotifyFailure.Network, "Couldn't reach Spotify", e) + } finally { + connection.disconnect() + } + } + + private fun enc(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name()) + + private fun fail(failure: SpotifyFailure, message: String): Nothing = throw SpotifyGatewayException(failure, message) + + private companion object { + const val API = "https://api.spotify.com/v1" + const val SEARCH = "$API/search" + const val ME_TRACKS = "$API/me/tracks" + const val ME_PLAYLISTS = "$API/me/playlists" + const val ME_ALBUMS = "$API/me/albums" + const val ME_FOLLOWING = "$API/me/following" + const val ME_SHOWS = "$API/me/shows" + const val ALBUMS = "$API/albums" + const val ARTISTS = "$API/artists" + const val PLAYLISTS = "$API/playlists" + const val SHOWS = "$API/shows" + const val TIMEOUT_MS = 15_000 + } +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifyWebCatalog.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifyWebCatalog.kt new file mode 100644 index 00000000..f94a2e82 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/data/SpotifyWebCatalog.kt @@ -0,0 +1,48 @@ +package hrln.interactive.sidekit.apps.spotify.data + +import hrln.interactive.sidekit.apps.spotify.LibraryFilter +import hrln.interactive.sidekit.apps.spotify.SearchScope +import hrln.interactive.sidekit.apps.spotify.SpotifyAlbum +import hrln.interactive.sidekit.apps.spotify.SpotifyArtist +import hrln.interactive.sidekit.apps.spotify.SpotifyCollection +import hrln.interactive.sidekit.apps.spotify.SpotifyCollectionType +import hrln.interactive.sidekit.apps.spotify.SpotifyPage +import hrln.interactive.sidekit.apps.spotify.SpotifySearchResults +import hrln.interactive.sidekit.apps.spotify.SpotifyTrack + +/** [SpotifyCatalog] over the Web API. */ +class SpotifyWebCatalog(private val api: SpotifyWebApi) : SpotifyCatalog { + override suspend fun library( + filter: LibraryFilter, + offset: Int, + limit: Int + ): SpotifyPage = api.library(filter, offset, limit) + + override suspend fun collection( + type: SpotifyCollectionType, + id: String, + offset: Int, + limit: Int + ): SpotifyCollection? = api.collection(type, id, offset, limit) + + override suspend fun artist(id: String): SpotifyArtist? = api.artist(id) + + override suspend fun albumsBy( + artist: SpotifyArtist, + offset: Int, + limit: Int + ): SpotifyPage = api.albumsBy(artist, offset, limit) + + override suspend fun topTracks( + artist: SpotifyArtist, + offset: Int, + limit: Int + ): SpotifyPage = api.topTracks(artist, offset, limit) + + override suspend fun search( + query: String, + scopes: Set, + offset: Int, + limit: Int + ): SpotifySearchResults = api.search(query, scopes, offset, limit) +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/playback/SpotifyAudioSink.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/playback/SpotifyAudioSink.kt new file mode 100644 index 00000000..01138488 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/playback/SpotifyAudioSink.kt @@ -0,0 +1,275 @@ +package hrln.interactive.sidekit.apps.spotify.playback + +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioTrack +import hrln.interactive.sidekit.apps.spotify.SideplayDebug +import hrln.interactive.sidekit.apps.spotify.bridge.NativeBridge +import hrln.interactive.sidekit.apps.spotify.bridge.SpotifyNative +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.json.JSONObject + +/** + * Drains PCM from librespot into a streaming [AudioTrack]. + * + * The native sink uses a bounded channel and blocks when this consumer stops. That backpressure + * is what keeps librespot's decode position tied to the speaker instead of racing through the + * track. The important consequence is that the player thread can only process pause/play/load + * commands while this drain is running, so pause/stop must keep draining until the player has + * acknowledged the command. + * + * Pause keeps the [AudioTrack] alive and captures the PCM still queued in the native channel. + * On resume the track's own buffer plays first, then the captured PCM, then new PCM from the + * player. Nothing is discarded, so the audio continues exactly where it stopped. + */ +class SpotifyAudioSink( + private val native: SpotifyNative = NativeBridge, + private val scope: CoroutineScope, + private val onFailure: (Throwable) -> Unit = {} +) { + private var track: AudioTrack? = null + private var drain: Job? = null + + /** + * Serializes track lifecycle operations. The drain job writes to the AudioTrack on its own + * coroutine; releasing or flushing the track while that write is in flight crashes inside + * libaudioclient. Every public operation takes this lock and stops the drain first. + */ + private val lock = Mutex() + + /** PCM read from the native channel after the AudioTrack was paused. */ + private val pendingPcm = ArrayDeque() + + /** Position of the first frame written to the current AudioTrack. */ + private var startPositionMs = 0L + private var startHeadFrames = 0L + private var lastHeadFrames = 0L + private var headWrapOffset = 0L + + val isRunning: Boolean get() = drain?.isActive == true + + /** Starts a new track. Discards any audio left over from the previous one. */ + suspend fun start(positionMs: Long = 0L) = lock.withLock { + startLocked(positionMs) + } + + private suspend fun startLocked(positionMs: Long) { + stopDrain() + pendingPcm.clear() + releaseTrack() + // Unblock a player thread that may still be waiting in PcmSink::write for the previous + // track. The load command cannot be processed until that thread is free. + native.drainAudio() + + val format = readFormat() + SideplayDebug.log("audio.start format=${format.sampleRate}Hz channels=${format.channels}") + val output = createTrack(format) + if (output == null) { + SideplayDebug.log("audio.start aborted: AudioTrack creation failed") + return + } + track = output + startPositionMs = positionMs + startHeadFrames = 0L + lastHeadFrames = 0L + headWrapOffset = 0L + output.play() + SideplayDebug.log("audio.start AudioTrack playing positionMs=$positionMs") + startDrain(output) + } + + /** + * Resumes the paused track, replaying the PCM captured at pause before new PCM. + * + * @return true when the existing AudioTrack (and therefore its buffered audio) was reused. + * false means the buffer was lost and the player must be realigned with [positionMs]. + */ + suspend fun resume(positionMs: Long): Boolean = lock.withLock { + val output = track + if (output == null || output.playState != AudioTrack.PLAYSTATE_PAUSED) { + startLocked(positionMs) + return@withLock false + } + SideplayDebug.log("audio.resume pending=${pendingPcm.size}") + output.play() + startDrain(output) + true + } + + /** + * Pauses the track without releasing it. The AudioTrack buffer and the PCM still queued in + * the native channel are both preserved, so resume is sample-continuous. + */ + suspend fun pause() = lock.withLock { + stopDrain() + capturePendingPcm() + runCatching { track?.pause() } + SideplayDebug.log("audio.pause pending=${pendingPcm.size}") + } + + /** Stops and releases the track. Used when changing tracks or stopping playback. */ + suspend fun stop() = lock.withLock { + stopDrain() + pendingPcm.clear() + releaseTrack() + native.drainAudio() + } + + /** + * The actual audio position, taken from the AudioTrack playback head. This is the hardware + * clock, so the UI can follow it without guessing from librespot's event cadence. + */ + fun currentPositionMs(): Long? { + val output = track ?: return null + val head = runCatching { output.playbackHeadPosition.toLong() }.getOrNull() ?: return null + if (head < lastHeadFrames) headWrapOffset += 1L shl 32 + lastHeadFrames = head + val frames = head + headWrapOffset - startHeadFrames + return startPositionMs + frames * 1_000L / output.sampleRate + } + + /** Re-bases the playback-head clock after a seek without recreating the AudioTrack. */ + suspend fun seek(positionMs: Long) = lock.withLock { + val output = track ?: return@withLock + val wasPlaying = output.playState == AudioTrack.PLAYSTATE_PLAYING + stopDrain() + pendingPcm.clear() + // Discard PCM decoded for the old position and unblock the player so it can process the + // seek command. + native.drainAudio() + runCatching { output.pause() } + runCatching { output.flush() } + val head = runCatching { output.playbackHeadPosition.toLong() }.getOrDefault(0L) + startPositionMs = positionMs + startHeadFrames = head + lastHeadFrames = head + headWrapOffset = 0L + if (wasPlaying) { + output.play() + startDrain(output) + } + } + + private suspend fun startDrain(output: AudioTrack) { + stopDrain() + drain = scope.launch(Dispatchers.IO) { + var reads = 0 + var bytes = 0L + try { + // Replay PCM captured at pause before reading anything new from the player. + while (pendingPcm.isNotEmpty()) { + val pcm = pendingPcm.removeFirst() + writeBlocking(output, pcm) + reads++ + bytes += pcm.size + } + while (isActive) { + val pcm = native.readAudio(READ_TIMEOUT_MS) ?: continue + if (pcm.isEmpty()) continue + if (!isActive) { + // Cancellation raced with the read. Keep this buffer for the next resume + // instead of losing it. + pendingPcm.addFirst(pcm) + break + } + if (reads == 0) SideplayDebug.log("audio first PCM read bytes=${pcm.size}") + writeBlocking(output, pcm) + reads++ + bytes += pcm.size + if (reads % 100 == 0) SideplayDebug.log("audio drained reads=$reads bytes=$bytes") + } + } catch (t: Throwable) { + SideplayDebug.log("audio drain failed reads=$reads bytes=$bytes", t) + if (isActive) onFailure(t) + } finally { + SideplayDebug.log("audio drain ended reads=$reads bytes=$bytes") + } + } + } + + private fun writeBlocking(output: AudioTrack, pcm: ByteArray) { + val written = output.write(pcm, 0, pcm.size, AudioTrack.WRITE_BLOCKING) + if (written < 0) throw IllegalStateException("AudioTrack write failed: $written") + } + + private suspend fun stopDrain() { + drain?.cancelAndJoin() + drain = null + } + + private fun capturePendingPcm() { + while (true) { + val pcm = native.readAudio(0) ?: break + if (pcm.isNotEmpty()) pendingPcm.addLast(pcm) + } + } + + private fun releaseTrack() { + track?.let { + runCatching { it.pause() } + runCatching { it.flush() } + runCatching { it.release() } + } + track = null + } + + private fun readFormat(): Format { + val raw = native.audioFormat() ?: return Format() + return runCatching { + val json = JSONObject(raw) + Format( + sampleRate = json.optInt("sampleRate", 44_100), + channels = json.optInt("channels", 2) + ) + }.getOrDefault(Format()) + } + + private fun createTrack(format: Format): AudioTrack? { + val channelMask = if (format.channels == 1) { + AudioFormat.CHANNEL_OUT_MONO + } else { + AudioFormat.CHANNEL_OUT_STEREO + } + val minBuffer = AudioTrack.getMinBufferSize( + format.sampleRate, + channelMask, + AudioFormat.ENCODING_PCM_16BIT + ) + if (minBuffer <= 0) { + onFailure(IllegalStateException("AudioTrack rejected the PCM format")) + return null + } + return runCatching { + AudioTrack.Builder() + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build() + ) + .setAudioFormat( + AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setSampleRate(format.sampleRate) + .setChannelMask(channelMask) + .build() + ) + .setBufferSizeInBytes(minBuffer * 2) + .setTransferMode(AudioTrack.MODE_STREAM) + .build() + }.onFailure(onFailure).getOrNull() + } + + private data class Format(val sampleRate: Int = 44_100, val channels: Int = 2) + + private companion object { + const val READ_TIMEOUT_MS = 100L + } +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/playback/SpotifyPlayback.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/playback/SpotifyPlayback.kt new file mode 100644 index 00000000..8d16ef76 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/playback/SpotifyPlayback.kt @@ -0,0 +1,424 @@ +package hrln.interactive.sidekit.apps.spotify.playback + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.Bitmap +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.MediaMetadata +import android.media.session.MediaSession +import android.media.session.PlaybackState as SessionState +import android.os.SystemClock +import hrln.interactive.sidekit.apps.media.AudioFocus +import hrln.interactive.sidekit.apps.media.MediaSessionOwner +import hrln.interactive.sidekit.apps.media.MediaSessionRegistry +import hrln.interactive.sidekit.apps.media.MediaSessions +import hrln.interactive.sidekit.apps.media.RemoteArtwork +import hrln.interactive.sidekit.apps.spotify.SideplayDebug +import hrln.interactive.sidekit.apps.spotify.SpotifyMediaItem +import hrln.interactive.sidekit.apps.spotify.data.SpotifyGateway +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Process-scoped playback owner. The activity and screens may come and go; this object holds the + * native player lifetime, the platform media session and the foreground notification. + * + * Main-thread only, like [MediaSessionOwner]. Long-running gateway work is moved onto [scope]. + */ +class SpotifyPlayback( + private val gateway: SpotifyGateway, + private val scope: CoroutineScope, + private val registry: MediaSessionRegistry = MediaSessions, + private val audio: SpotifyAudioSink? = null, + private val context: Context? = null +) : MediaSessionOwner { + override val sessionId = "sideplay" + override val notificationChannelId = "sideplay" + override val notificationChannelName = "Sideplay playback" + override val notificationId = 0x6D76 + + @Volatile + private var playing = false + + @Volatile + private var positionMs = 0L + + @Volatile + var current: SpotifyMediaItem? = null + private set + + val position: Long get() = positionMs + + @Volatile + var notificationArt: Bitmap? = null + private set + + override val isActive: Boolean get() = playing + + /** + * Queue ops normally live in the ViewModel so its screen state stays in sync. When the + * ViewModel is gone, [fallbackNext]/[fallbackPrevious] keep the notification and lock-screen + * transport working by advancing the process-wide queue instead. + * + * librespot 0.8 has no shuffle/repeat setters. Sideplay's queue repository owns those, and + * native shuffle/repeat events are adopted by the ViewModel when another client changes them. + */ + var onNext: (() -> Unit)? = null + var onPrevious: (() -> Unit)? = null + var onSeek: ((Long) -> Unit)? = null + var fallbackNext: (() -> Unit)? = null + var fallbackPrevious: (() -> Unit)? = null + + /** Lets the process owner clear queue state whenever playback is stopped from any control. */ + var onStopped: (() -> Unit)? = null + + private var mediaSession: MediaSession? = null + private var artJob: Job? = null + private var lastSessionUpdate = 0L + private var noisyRegistered = false + + /** + * Invalidates a pending command that is still waiting for the player to acknowledge it. A + * newer play/pause/start/stop bumps this so an older coroutine cannot apply its result. + */ + private var pauseGeneration = 0 + + /** + * The last play/pause intent. librespot events can arrive out of order after rapid taps; + * this lets the UI ignore an event that belongs to a superseded command. + */ + @Volatile + private var lastCommandWasPause = false + + @Volatile + private var lastCommandAt = 0L + + private val noisy = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) = onAudioBecomingNoisy() + } + + /** Headphones unplugged: pause rather than play out loud. */ + fun onAudioBecomingNoisy() = pause() + + private val focus = context?.let { + AudioFocus( + it, + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build(), + onPause = { pauseInternal(abandonFocus = false) }, + onResume = { scope.launch { runCatching { resume() } } } + ) + } + + val sessionToken: MediaSession.Token? get() = session()?.sessionToken + + /** Registers once so starting local Music can pause this owner even before the first track. */ + fun register() = registry.register(this) + + fun setNowPlaying(item: SpotifyMediaItem?, position: Long = 0L) { + current = item + positionMs = position + artJob?.cancel() + notificationArt = null + if (context != null) updateMetadata() + publish() + if (item?.artUrl != null && context != null) { + artJob = scope.launch { + val bitmap = RemoteArtwork.load(context, item.artUrl!!, ART_PX, ART_PX) + if (current?.id == item.id) { + notificationArt = bitmap + lastSessionUpdate = 0L + updateMetadata() + publish() + } + } + } + } + + /** Mirrors native player events into the media session and notification. */ + fun updateState(isPlaying: Boolean, position: Long) { + playing = isPlaying + positionMs = position + publish() + } + + /** Keeps the playback owner's position in step with the UI clock without publishing. */ + fun updatePosition(position: Long) { + positionMs = position + } + + /** + * Whether a native playing/paused event still matches the user's latest intent. Events that + * arrive within the command window but contradict the last command are stale. After the + * window, events from other clients are accepted again. + */ + fun shouldApplyEvent(isPlaying: Boolean): Boolean { + val age = SystemClock.elapsedRealtime() - lastCommandAt + if (age > COMMAND_EVENT_WINDOW_MS) return true + return if (isPlaying) !lastCommandWasPause else lastCommandWasPause + } + + suspend fun start(uri: String, positionMs: Long) { + SideplayDebug.log("start uri=$uri positionMs=$positionMs audio=${audio != null}") + val generation = ++pauseGeneration + lastCommandWasPause = false + lastCommandAt = SystemClock.elapsedRealtime() + register() + if (focus?.request() == false) { + SideplayDebug.log("start aborted: audio focus denied") + return + } + if (generation != pauseGeneration) return + registry.activate(this) + playing = true + this.positionMs = positionMs + // Release the previous AudioTrack and drain the native channel before loading the next + // item. Draining also unblocks a player thread that may still be waiting in + // PcmSink::write, so the load command can be processed. + audio?.stop() + if (generation != pauseGeneration) return + publish() + try { + SideplayDebug.log("start calling gateway.load") + gateway.load(uri, positionMs, autoplay = true) + if (generation != pauseGeneration) return + SideplayDebug.log("start gateway.load returned; starting audio sink") + audio?.start(positionMs) + SideplayDebug.log("start audio sink started") + } catch (t: Throwable) { + SideplayDebug.log("start failed", t) + playing = false + audio?.stop() + focus?.abandon() + registry.deactivate(this) + publish() + throw t + } + } + + suspend fun resume(): Boolean { + SideplayDebug.log("resume audio=${audio != null}") + val generation = ++pauseGeneration + lastCommandWasPause = false + lastCommandAt = SystemClock.elapsedRealtime() + register() + if (focus?.request() == false) { + SideplayDebug.log("resume aborted: audio focus denied") + return false + } + if (generation != pauseGeneration) return false + registry.activate(this) + playing = true + val reused = audio?.resume(positionMs) ?: false + if (generation != pauseGeneration) return false + publish() + if (!reused) { + // The AudioTrack buffer was lost (for example after a long pause). Realign the + // player with the UI position before playing, otherwise it resumes from its decoder + // position and skips the buffered audio. + SideplayDebug.log("resume audio buffer lost; seeking to $positionMs") + runCatching { gateway.seek(positionMs) } + } + if (generation != pauseGeneration) return false + SideplayDebug.log("resume calling gateway.play") + gateway.play() + SideplayDebug.log("resume gateway.play returned") + return true + } + + fun togglePlayPause() { + if (playing) pause() else scope.launch { runCatching { resume() } } + } + + override fun pause() = pauseInternal(abandonFocus = true) + + /** + * Transient audio-focus loss keeps the focus request held so [AudioFocus] can resume us when + * the call or notification ends. An explicit pause gives it back. + */ + private fun pauseInternal(abandonFocus: Boolean) { + SideplayDebug.log("pauseInternal abandonFocus=$abandonFocus") + // The UI clock owns the position; the decoder's `paused` event is ahead by the PCM + // buffer and must not be used as the resume point. + playing = false + lastCommandWasPause = true + lastCommandAt = SystemClock.elapsedRealtime() + if (abandonFocus) focus?.abandon() + registry.deactivate(this) + publish() + val generation = ++pauseGeneration + scope.launch { + // Queue the native pause while the AudioTrack drain is still running. The drain is + // the real-time clock and also unblocks a player thread waiting in PcmSink::write, + // so the player can process the pause command. Stopping the drain first would wedge + // that thread and leave the UI showing a stale playing state. + if (generation != pauseGeneration) return@launch + runCatching { gateway.pause() } + delay(PAUSE_DRAIN_GRACE_MS) + if (generation == pauseGeneration && !playing) audio?.pause() + } + } + + suspend fun seek(positionMs: Long) { + this.positionMs = positionMs + publish() + gateway.seek(positionMs) + audio?.seek(positionMs) + } + + fun requestSeek(positionMs: Long) { + val callback = onSeek + if (callback != null) callback(positionMs) else scope.launch { runCatching { seek(positionMs) } } + } + + fun requestNext() = (onNext ?: fallbackNext)?.invoke() + + fun requestPrevious() = (onPrevious ?: fallbackPrevious)?.invoke() + + suspend fun stop() { + playing = false + lastCommandWasPause = true + lastCommandAt = SystemClock.elapsedRealtime() + val generation = ++pauseGeneration + focus?.abandon() + registry.deactivate(this) + // Same ordering as pause: let the player process stop while the drain is still alive. + if (generation != pauseGeneration) return + runCatching { gateway.stop() } + delay(PAUSE_DRAIN_GRACE_MS) + if (generation != pauseGeneration) return + audio?.stop() + mediaSession?.let { + it.setPlaybackState( + SessionState.Builder() + .setState(SessionState.STATE_STOPPED, 0L, 0f, SystemClock.elapsedRealtime()) + .build() + ) + } + current = null + notificationArt = null + artJob?.cancel() + onStopped?.invoke() + publish() + } + + suspend fun disconnect() { + stop() + gateway.disconnect() + mediaSession?.release() + mediaSession = null + } + + private fun publish() { + SideplayDebug.log("publish playing=$playing positionMs=$positionMs current=${current?.uri}") + session()?.let { session -> + val now = SystemClock.elapsedRealtime() + if (current != null) updateMetadata(session, now) + session.setPlaybackState( + SessionState.Builder() + .setActions( + SessionState.ACTION_PLAY or SessionState.ACTION_PAUSE or + SessionState.ACTION_PLAY_PAUSE or SessionState.ACTION_SKIP_TO_NEXT or + SessionState.ACTION_SKIP_TO_PREVIOUS or SessionState.ACTION_SEEK_TO or + SessionState.ACTION_STOP + ) + .setState( + when { + current == null -> SessionState.STATE_STOPPED + playing -> SessionState.STATE_PLAYING + else -> SessionState.STATE_PAUSED + }, + positionMs, + if (playing) 1f else 0f, + now + ) + .build() + ) + } + updateNoisy() + context?.let { SpotifyPlaybackService.sync(it, this) } + } + + private fun updateNoisy() { + val context = context ?: return + if (playing == noisyRegistered) return + noisyRegistered = playing + if (playing) { + context.registerReceiver(noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)) + } else { + runCatching { context.unregisterReceiver(noisy) } + } + } + + private fun updateMetadata(session: MediaSession? = session(), now: Long = SystemClock.elapsedRealtime()) { + val item = current ?: return + session ?: return + if (lastSessionUpdate != 0L && now - lastSessionUpdate < 250L) return + lastSessionUpdate = now + session.setMetadata( + MediaMetadata.Builder() + .putString(MediaMetadata.METADATA_KEY_TITLE, item.title) + .putString(MediaMetadata.METADATA_KEY_ARTIST, item.creator) + .putString(MediaMetadata.METADATA_KEY_ALBUM, item.creator) + .putLong(MediaMetadata.METADATA_KEY_DURATION, item.durationMs) + .apply { notificationArt?.let { putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, it) } } + .build() + ) + } + + private fun session(): MediaSession? { + val context = context ?: return null + return mediaSession ?: MediaSession(context, "SideKit Sideplay").also { session -> + session.setCallback( + object : MediaSession.Callback() { + override fun onPlay() { + scope.launch { runCatching { resume() } } + } + + override fun onPause() { + pause() + } + + override fun onSkipToNext() { + requestNext() + } + + override fun onSkipToPrevious() { + requestPrevious() + } + + override fun onSeekTo(pos: Long) { + requestSeek(pos) + } + + override fun onStop() { + scope.launch { runCatching { stop() } } + } + } + ) + session.isActive = true + mediaSession = session + } + } + + private companion object { + const val ART_PX = 256 + + /** + * How long the AudioTrack drain keeps running after a pause/stop is queued. One PCM + * buffer is ~23 ms, so this is enough for a player thread blocked in PcmSink::write to + * wake, process the command, and stop producing audio. + */ + const val PAUSE_DRAIN_GRACE_MS = 60L + + /** How long after a command a contradicting native event is treated as stale. */ + const val COMMAND_EVENT_WINDOW_MS = 2_000L + } +} diff --git a/app/src/main/java/hrln/interactive/sidekit/apps/spotify/playback/SpotifyPlaybackService.kt b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/playback/SpotifyPlaybackService.kt new file mode 100644 index 00000000..e240072b --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/apps/spotify/playback/SpotifyPlaybackService.kt @@ -0,0 +1,168 @@ +package hrln.interactive.sidekit.apps.spotify.playback + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.graphics.drawable.Icon +import android.os.IBinder +import hrln.interactive.sidekit.MainActivity +import hrln.interactive.sidekit.apps.spotify.SideplayLinks +import hrln.interactive.sidekit.apps.spotify.data.SpotifySessions +import hrln.interactive.sidekit.design.icons.SidekitIcon + +/** + * Keeps Sideplay playing when SideKit is not in front. It mirrors [SpotifyPlayback]'s media + * session in a platform media-style notification and forwards transport actions back to the + * process-wide player. + */ +class SpotifyPlaybackService : Service() { + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + running = this + val playback = SpotifySessions.get(this).playback + val manager = getSystemService(NotificationManager::class.java) + if (manager.getNotificationChannel(playback.notificationChannelId) == null) { + manager.createNotificationChannel( + NotificationChannel( + playback.notificationChannelId, + playback.notificationChannelName, + NotificationManager.IMPORTANCE_LOW + ).apply { setShowBadge(false) } + ) + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val session = SpotifySessions.get(this) + val playback = session.playback + startForeground( + playback.notificationId, + notification(playback, playing = playback.isActive), + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + ) + when (intent?.action) { + ACTION_PLAY_PAUSE -> session.togglePlayback() + ACTION_NEXT -> playback.requestNext() + ACTION_PREVIOUS -> playback.requestPrevious() + ACTION_STOP -> session.stopPlayback() + } + update(playback) + return START_NOT_STICKY + } + + override fun onDestroy() { + if (running === this) running = null + super.onDestroy() + } + + private fun update(playback: SpotifyPlayback) { + when { + playback.current == null -> { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + playback.isActive -> startForeground( + playback.notificationId, + notification(playback, playing = true), + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + ) + + else -> { + getSystemService(NotificationManager::class.java) + .notify(playback.notificationId, notification(playback, playing = false)) + stopForeground(STOP_FOREGROUND_DETACH) + } + } + } + + private fun notification(playback: SpotifyPlayback, playing: Boolean): Notification { + val open = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java) + .setAction(SideplayLinks.ACTION_OPEN_NOW_PLAYING) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + PendingIntent.FLAG_IMMUTABLE + ) + return Notification.Builder(this, playback.notificationChannelId) + .setSmallIcon(SidekitIcon.MusicNote2.drawable) + .setContentTitle(playback.current?.title ?: "Sideplay") + .setContentText(playback.current?.creator.orEmpty()) + .setLargeIcon(playback.notificationArt) + .setContentIntent(open) + .setDeleteIntent(action(ACTION_STOP)) + .setOngoing(playing) + .setShowWhen(false) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .addAction( + Notification.Action.Builder( + Icon.createWithResource(this, SidekitIcon.SkipPrevious.drawable), + "Previous", + action(ACTION_PREVIOUS) + ).build() + ) + .addAction( + Notification.Action.Builder( + Icon.createWithResource( + this, + if (playing) SidekitIcon.Pause.drawable else SidekitIcon.PlayArrow.drawable + ), + if (playing) "Pause" else "Play", + action(ACTION_PLAY_PAUSE) + ).build() + ) + .addAction( + Notification.Action.Builder( + Icon.createWithResource(this, SidekitIcon.SkipNext.drawable), + "Next", + action(ACTION_NEXT) + ).build() + ) + .setStyle( + Notification.MediaStyle() + .setMediaSession(playback.sessionToken) + .setShowActionsInCompactView(0, 1, 2) + ) + .build() + } + + private fun action(action: String): PendingIntent = PendingIntent.getForegroundService( + this, + action.hashCode(), + Intent(this, SpotifyPlaybackService::class.java).setAction(action), + PendingIntent.FLAG_IMMUTABLE + ) + + companion object { + private const val ACTION_PLAY_PAUSE = "hrln.interactive.sidekit.sideplay.PLAY_PAUSE" + private const val ACTION_NEXT = "hrln.interactive.sidekit.sideplay.NEXT" + private const val ACTION_PREVIOUS = "hrln.interactive.sidekit.sideplay.PREVIOUS" + private const val ACTION_STOP = "hrln.interactive.sidekit.sideplay.STOP" + + private var running: SpotifyPlaybackService? = null + + /** Brings the service in line with the player. Main thread only, like the player. */ + fun sync(context: Context, playback: SpotifyPlayback) { + val service = running + if (service != null) { + service.update(playback) + return + } + if (!playback.isActive || playback.current == null) return + try { + context.startForegroundService(Intent(context, SpotifyPlaybackService::class.java)) + } catch (_: IllegalStateException) { + // Not allowed from the background. Playback still works, just without the + // foreground-service protection on this edge. + } + } + } +} diff --git a/app/src/main/java/hrln/interactive/sidekit/navigation/SidekitNavHost.kt b/app/src/main/java/hrln/interactive/sidekit/navigation/SidekitNavHost.kt index e7abef14..3ec53e5a 100644 --- a/app/src/main/java/hrln/interactive/sidekit/navigation/SidekitNavHost.kt +++ b/app/src/main/java/hrln/interactive/sidekit/navigation/SidekitNavHost.kt @@ -9,12 +9,11 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import hrln.interactive.sidekit.apps.apps.appsGraph -import hrln.interactive.sidekit.apps.vault.vaultGraph import hrln.interactive.sidekit.apps.banking.bankingGraph import hrln.interactive.sidekit.apps.calculator.calculatorGraph import hrln.interactive.sidekit.apps.calendar.calendarGraph -import hrln.interactive.sidekit.apps.clock.clockGraph import hrln.interactive.sidekit.apps.camera.cameraGraph +import hrln.interactive.sidekit.apps.clock.clockGraph import hrln.interactive.sidekit.apps.compass.compassGraph import hrln.interactive.sidekit.apps.files.filesGraph import hrln.interactive.sidekit.apps.health.healthGraph @@ -33,8 +32,11 @@ import hrln.interactive.sidekit.apps.sideport.openPorted import hrln.interactive.sidekit.apps.sideport.sideportGraph import hrln.interactive.sidekit.apps.sideticks.sideticksGraph import hrln.interactive.sidekit.apps.sideweather.sideweatherGraph +import hrln.interactive.sidekit.apps.spotify.SideplayLinks +import hrln.interactive.sidekit.apps.spotify.sideplayGraph import hrln.interactive.sidekit.apps.translate.translateGraph import hrln.interactive.sidekit.apps.tripplanning.tripPlanningGraph +import hrln.interactive.sidekit.apps.vault.vaultGraph import hrln.interactive.sidekit.apps.video.videoGraph import hrln.interactive.sidekit.apps.webbrowser.webBrowserGraph import hrln.interactive.sidekit.launcher.LauncherRoute @@ -55,6 +57,8 @@ fun SidekitNavHost(navController: NavHostController = rememberNavController(), h IncomingLinks.Follow(navController) // A projected app's "Messages and media" opens its Sideport hub. SideportLinks.Follow(navController) + // Tapping the Sideplay playback notification opens Now Playing. + SideplayLinks.Follow(navController) // No screen transition. Every screen is the same frame (title, body, action bar) and the // title and bar are shared chrome that morph on their own, so navigating swaps only the body, // which plays its own entrance (rows rising in). Nothing moves as a whole screen, and only one @@ -65,14 +69,14 @@ fun SidekitNavHost(navController: NavHostController = rememberNavController(), h enterTransition = { EnterTransition.None }, exitTransition = { ExitTransition.None }, popEnterTransition = { EnterTransition.None }, - popExitTransition = { ExitTransition.None }, + popExitTransition = { ExitTransition.None } ) { composable(LAUNCHER_ROUTE) { val context = androidx.compose.ui.platform.LocalContext.current LauncherRoute( onOpenApp = { app -> navController.navigate(app.route) { launchSingleTop = true } }, // Ported apps open straight into their projection; if that can't start, Sideport explains why. - onOpenPorted = { app -> if (openPorted(context, app) != null) navController.navigate(SubApp.Sideport.route) { launchSingleTop = true } }, + onOpenPorted = { app -> if (openPorted(context, app) != null) navController.navigate(SubApp.Sideport.route) { launchSingleTop = true } } ) } // Core SideKit apps @@ -84,6 +88,7 @@ fun SidekitNavHost(navController: NavHostController = rememberNavController(), h translateGraph(navController) calculatorGraph(navController) musicGraph(navController) + sideplayGraph(navController) cameraGraph(navController) sideweatherGraph(navController) // Sidephone everyday apps (96:419) diff --git a/app/src/main/java/hrln/interactive/sidekit/navigation/SubApp.kt b/app/src/main/java/hrln/interactive/sidekit/navigation/SubApp.kt index 003d789d..f1d0cb4c 100644 --- a/app/src/main/java/hrln/interactive/sidekit/navigation/SubApp.kt +++ b/app/src/main/java/hrln/interactive/sidekit/navigation/SubApp.kt @@ -21,6 +21,7 @@ enum class SubApp(val label: String, val icon: SidekitIcon, val designed: Boolea Files("Files", SidekitIcon.FolderCopy), Maps("Maps", SidekitIcon.Map), Music("Music", SidekitIcon.MusicNote2), + Sideplay("Sideplay", SidekitIcon.QueueMusic), Clock("Clock", SidekitIcon.Alarm), Video("Video", SidekitIcon.SmartDisplay), Vault("Vault", SidekitIcon.Encrypted), @@ -34,7 +35,7 @@ enum class SubApp(val label: String, val icon: SidekitIcon, val designed: Boolea Calendar("Calendar", SidekitIcon.CalendarMonth), Compass("Compass", SidekitIcon.Explore), Health("Health", SidekitIcon.EcgHeart), - Sideport("Sideport", SidekitIcon.DeployedCode), + Sideport("Sideport", SidekitIcon.DeployedCode) ; /** Root navigation route of the app. Nested screens use "$route/...". */ diff --git a/app/src/main/java/hrln/interactive/sidekit/system/NativeLibraries.kt b/app/src/main/java/hrln/interactive/sidekit/system/NativeLibraries.kt new file mode 100644 index 00000000..0be3c0a2 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/system/NativeLibraries.kt @@ -0,0 +1,34 @@ +package hrln.interactive.sidekit.system + +/** + * Loads the app's native libraries without taking the process down when one is missing. + * + * A build only carries the ABIs it was compiled for, so `System.loadLibrary` can legitimately + * fail on an emulator or a split APK. Callers ask once and show a "not available in this build" + * state instead of crashing from an object initialiser. + * + * Results are cached, including failures: a library that is not packaged will not appear later. + * Thread-safe; loading is idempotent per name. + */ +object NativeLibraries { + private val loaded = HashSet() + private val failed = HashSet() + + /** True if [name] (without the `lib` prefix and `.so` suffix) was loaded. */ + @Synchronized + fun load(name: String): Boolean { + if (name in loaded) return true + if (name in failed) return false + return try { + System.loadLibrary(name) + loaded += name + true + } catch (_: UnsatisfiedLinkError) { + failed += name + false + } + } + + @Synchronized + fun isAvailable(name: String): Boolean = name in loaded +} diff --git a/app/src/main/java/hrln/interactive/sidekit/ui/UndoWindow.kt b/app/src/main/java/hrln/interactive/sidekit/ui/UndoWindow.kt new file mode 100644 index 00000000..01ca3c69 --- /dev/null +++ b/app/src/main/java/hrln/interactive/sidekit/ui/UndoWindow.kt @@ -0,0 +1,128 @@ +package hrln.interactive.sidekit.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** How long a deletion can be taken back, counted only while its screen is visible. */ +const val UNDO_WINDOW_MS = 5_000L + +/** + * One undo offer, measured in visible time. + * + * The five seconds are a budget of attention, not of wall clock: [pause] when the screen leaves + * the foreground and [resume] when it comes back, so leaving Sidekit mid-window does not silently + * burn the offer, and returning shows the time that is actually left. Leaving the screen for good + * is a commit, not a pause: cancel the window and finalise the deletion ([cancel]). + */ +class UndoWindow( + private val scope: CoroutineScope, + private val durationMs: Long = UNDO_WINDOW_MS, + // nanoTime is monotonic on both Android and the JVM, so plain unit tests need no Android stub. + private val elapsed: () -> Long = { System.nanoTime() / 1_000_000L }, + private val onExpire: () -> Unit +) { + private var job: Job? = null + private var remainingMs = durationMs + private var deadline = 0L + private var running = false + private var armed = false + + /** True while a deletion can still be taken back (running or paused). */ + val isPending: Boolean get() = armed + + /** Offers a new deletion, replacing any earlier offer and restarting the five seconds. */ + fun start() { + job?.cancel() + armed = true + remainingMs = durationMs + run() + } + + /** Stops the clock while the screen is not visible; [resume] continues with what is left. */ + fun pause() { + if (!running) return + remainingMs = (deadline - elapsed()).coerceAtLeast(0L) + job?.cancel() + job = null + running = false + } + + /** Continues a paused offer, or expires it if its time ran out at the moment it was paused. */ + fun resume() { + if (!armed || running) return + if (remainingMs <= 0L) expire() else run() + } + + /** Drops the offer without restoring anything. Callers finalise the deletion first. */ + fun cancel() { + job?.cancel() + job = null + running = false + armed = false + remainingMs = durationMs + } + + private fun run() { + running = true + deadline = elapsed() + remainingMs + val wait = remainingMs + job = scope.launch { + delay(wait) + job = null + expire() + } + } + + private fun expire() { + running = false + armed = false + remainingMs = 0L + onExpire() + } +} + +/** + * Binds an [UndoWindow] to the screen that owns it. + * + * Backgrounding pauses the clock and returning resumes it; disposing the screen commits, so the + * offer never follows the user to another conversation, tab or app. + */ +@Composable +fun UndoWindowLifecycle(window: UndoWindow, commit: () -> Unit) { + val lifecycle = LocalLifecycleOwner.current.lifecycle + val latestCommit by rememberUpdatedState(commit) + DisposableEffect(lifecycle, window) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_STOP -> window.pause() + Lifecycle.Event.ON_START -> window.resume() + else -> Unit + } + } + lifecycle.addObserver(observer) + onDispose { + lifecycle.removeObserver(observer) + window.cancel() + latestCommit() + } + } +} + +/** A window for screens that keep their pending undo in composition state rather than a ViewModel. */ +@Composable +fun rememberUndoWindow(onExpire: () -> Unit): UndoWindow { + val scope = rememberCoroutineScope() + val latestExpire by rememberUpdatedState(onExpire) + return remember { UndoWindow(scope) { latestExpire() } } +} diff --git a/app/src/main/res/drawable-nodpi/cover_aurora_vale.png b/app/src/main/res/drawable-nodpi/cover_aurora_vale.png new file mode 100644 index 00000000..7455fc71 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/cover_aurora_vale.png differ diff --git a/app/src/main/res/drawable-nodpi/cover_coastline.png b/app/src/main/res/drawable-nodpi/cover_coastline.png new file mode 100644 index 00000000..a208893a Binary files /dev/null and b/app/src/main/res/drawable-nodpi/cover_coastline.png differ diff --git a/app/src/main/res/drawable-nodpi/cover_focus_no_words.png b/app/src/main/res/drawable-nodpi/cover_focus_no_words.png new file mode 100644 index 00000000..57cb8370 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/cover_focus_no_words.png differ diff --git a/app/src/main/res/drawable-nodpi/cover_glass_hours.png b/app/src/main/res/drawable-nodpi/cover_glass_hours.png new file mode 100644 index 00000000..0ba8ff26 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/cover_glass_hours.png differ diff --git a/app/src/main/res/drawable-nodpi/cover_golden_hour.png b/app/src/main/res/drawable-nodpi/cover_golden_hour.png new file mode 100644 index 00000000..899ec958 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/cover_golden_hour.png differ diff --git a/app/src/main/res/drawable-nodpi/cover_ivy_ash.png b/app/src/main/res/drawable-nodpi/cover_ivy_ash.png new file mode 100644 index 00000000..d5c92906 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/cover_ivy_ash.png differ diff --git a/app/src/main/res/drawable-nodpi/cover_kitchen_radio.png b/app/src/main/res/drawable-nodpi/cover_kitchen_radio.png new file mode 100644 index 00000000..9a24f04b Binary files /dev/null and b/app/src/main/res/drawable-nodpi/cover_kitchen_radio.png differ diff --git a/app/src/main/res/drawable-nodpi/cover_late_night_drive.png b/app/src/main/res/drawable-nodpi/cover_late_night_drive.png new file mode 100644 index 00000000..87bb83b1 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/cover_late_night_drive.png differ diff --git a/app/src/main/res/drawable-nodpi/cover_long_way_home.png b/app/src/main/res/drawable-nodpi/cover_long_way_home.png new file mode 100644 index 00000000..25e05074 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/cover_long_way_home.png differ diff --git a/app/src/main/res/drawable-nodpi/cover_lumen_park.png b/app/src/main/res/drawable-nodpi/cover_lumen_park.png new file mode 100644 index 00000000..77e02963 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/cover_lumen_park.png differ diff --git a/app/src/main/res/drawable-nodpi/cover_mira_sol.png b/app/src/main/res/drawable-nodpi/cover_mira_sol.png new file mode 100644 index 00000000..dbbfb1be Binary files /dev/null and b/app/src/main/res/drawable-nodpi/cover_mira_sol.png differ diff --git a/app/src/main/res/drawable-nodpi/cover_night_signals.png b/app/src/main/res/drawable-nodpi/cover_night_signals.png new file mode 100644 index 00000000..39be3006 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/cover_night_signals.png differ diff --git a/app/src/main/res/drawable-nodpi/cover_rooms_we_left.png b/app/src/main/res/drawable-nodpi/cover_rooms_we_left.png new file mode 100644 index 00000000..8683b36e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/cover_rooms_we_left.png differ diff --git a/app/src/main/res/drawable-nodpi/cover_small_hours.png b/app/src/main/res/drawable-nodpi/cover_small_hours.png new file mode 100644 index 00000000..b314bcaa Binary files /dev/null and b/app/src/main/res/drawable-nodpi/cover_small_hours.png differ diff --git a/app/src/main/res/drawable-nodpi/cover_soft_focus.png b/app/src/main/res/drawable-nodpi/cover_soft_focus.png new file mode 100644 index 00000000..d773446e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/cover_soft_focus.png differ diff --git a/app/src/main/res/drawable-nodpi/cover_static_coast.png b/app/src/main/res/drawable-nodpi/cover_static_coast.png new file mode 100644 index 00000000..b688fbc4 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/cover_static_coast.png differ diff --git a/app/src/main/res/drawable-nodpi/cover_sunday_morning.png b/app/src/main/res/drawable-nodpi/cover_sunday_morning.png new file mode 100644 index 00000000..4ede9ae5 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/cover_sunday_morning.png differ diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/media/MediaSessionRegistryTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/media/MediaSessionRegistryTest.kt new file mode 100644 index 00000000..43f1c831 --- /dev/null +++ b/app/src/test/java/hrln/interactive/sidekit/apps/media/MediaSessionRegistryTest.kt @@ -0,0 +1,119 @@ +package hrln.interactive.sidekit.apps.media + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +class MediaSessionRegistryTest { + private class FakeOwner(override val sessionId: String) : MediaSessionOwner { + override val notificationChannelId = "channel-$sessionId" + override val notificationChannelName = "Channel $sessionId" + override val notificationId = sessionId.hashCode() + override var isActive = false + var pauses = 0 + + override fun pause() { + pauses++ + isActive = false + } + } + + @Test + fun `activating an owner pauses every other active owner`() { + val registry = MediaSessionRegistry() + val music = FakeOwner("music").apply { isActive = true } + val remote = FakeOwner("remote") + registry.register(music) + registry.register(remote) + + registry.activate(remote) + + assertEquals(1, music.pauses) + assertEquals(0, remote.pauses) + assertSame(remote, registry.active) + } + + @Test + fun `activating twice does not pause the owner again`() { + val registry = MediaSessionRegistry() + val music = FakeOwner("music").apply { isActive = true } + registry.register(music) + + registry.activate(music) + registry.activate(music) + + assertEquals(0, music.pauses) + assertSame(music, registry.active) + } + + @Test + fun `owners that are already paused are left alone`() { + val registry = MediaSessionRegistry() + val music = FakeOwner("music") + registry.register(music) + + val remote = FakeOwner("remote") + registry.register(remote) + + registry.activate(remote) + + assertEquals(0, music.pauses) + assertNotSame(music, registry.active) + } + + @Test + fun `deactivating a non-active owner keeps the active session`() { + val registry = MediaSessionRegistry() + val music = FakeOwner("music").apply { isActive = true } + val remote = FakeOwner("remote").apply { isActive = true } + registry.register(music) + registry.register(remote) + registry.activate(remote) + + registry.deactivate(music) + + assertSame(remote, registry.active) + } + + @Test + fun `deactivating the active owner clears it`() { + val registry = MediaSessionRegistry() + val music = FakeOwner("music").apply { isActive = true } + registry.register(music) + registry.activate(music) + + registry.deactivate(music) + + assertNull(registry.active) + } + + @Test + fun `unregistering the active owner clears it`() { + val registry = MediaSessionRegistry() + val music = FakeOwner("music") + registry.register(music) + registry.activate(music) + + registry.unregister(music) + + assertNull(registry.active) + assertEquals(emptyList(), registry.registered) + } + + @Test + fun `release order is irrelevant - whoever activates last wins`() { + val registry = MediaSessionRegistry() + val a = FakeOwner("a").apply { isActive = true } + val b = FakeOwner("b").apply { isActive = true } + registry.register(a) + registry.register(b) + + registry.activate(a) + registry.activate(b) + + assertEquals(1, a.pauses) + assertSame(b, registry.active) + } +} diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/media/RemoteArtworkTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/media/RemoteArtworkTest.kt new file mode 100644 index 00000000..746228e4 --- /dev/null +++ b/app/src/test/java/hrln/interactive/sidekit/apps/media/RemoteArtworkTest.kt @@ -0,0 +1,72 @@ +package hrln.interactive.sidekit.apps.media + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class RemoteArtworkTest { + @get:Rule val temp = TemporaryFolder() + + @Test + fun `file name is stable for the same url and size`() { + val url = "https://i.scdn.co/image/ab67616d00001e02000000000000000000000000" + assertEquals(RemoteArtwork.fileName(url, 104, 104), RemoteArtwork.fileName(url, 104, 104)) + } + + @Test + fun `file name separates sizes and urls`() { + val url = "https://i.scdn.co/image/abc" + assertNotEquals(RemoteArtwork.fileName(url, 104, 104), RemoteArtwork.fileName(url, 208, 208)) + assertNotEquals(RemoteArtwork.fileName(url, 104, 104), RemoteArtwork.fileName("$url?v=2", 104, 104)) + } + + @Test + fun `file name is filesystem safe`() { + val name = RemoteArtwork.fileName("https://example.com/a b?x=1&y=2#frag", 48, 48) + assertTrue(name, name.matches(Regex("[0-9a-f]{64}\\.img"))) + } + + @Test + fun `sample size keeps the decode near the drawn size`() { + assertEquals(1, RemoteArtwork.sampleSize(300, 300, 300, 300)) + assertEquals(4, RemoteArtwork.sampleSize(640, 640, 104, 104)) + assertEquals(16, RemoteArtwork.sampleSize(2560, 2560, 104, 104)) + assertEquals(1, RemoteArtwork.sampleSize(10, 10, 104, 104)) + } + + @Test + fun `trim drops least recently used files until under the cap`() { + val dir = temp.newFolder("artwork") + val files = (0 until 5).map { i -> + File(dir, "file$i.img").apply { + writeBytes(ByteArray(100)) + setLastModified(1_000L + i) + } + } + + RemoteArtwork.trim(dir, maxBytes = 250) + + assertEquals(2, dir.listFiles()!!.size) + assertTrue(!files[0].exists() && !files[1].exists() && !files[2].exists()) + assertTrue(files[3].exists() && files[4].exists()) + } + + @Test + fun `trim leaves a cache under the cap alone`() { + val dir = temp.newFolder("artwork") + File(dir, "one.img").apply { writeBytes(ByteArray(10)) } + + RemoteArtwork.trim(dir, maxBytes = 100) + + assertTrue(File(dir, "one.img").exists()) + } + + @Test + fun `trim tolerates a missing directory`() { + RemoteArtwork.trim(File(temp.root, "nope"), maxBytes = 100) + } +} diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/spotify/MockSpotifyDataSource.kt b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/MockSpotifyDataSource.kt new file mode 100644 index 00000000..7322f963 --- /dev/null +++ b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/MockSpotifyDataSource.kt @@ -0,0 +1,267 @@ +package hrln.interactive.sidekit.apps.spotify + +import hrln.interactive.sidekit.R +import hrln.interactive.sidekit.apps.spotify.data.SpotifyCatalog + +/** + * The screenshot suite's account. Everything here is local, fixed data shaped like the gateway + * response: the UI can be reviewed without a Spotify login, a native library, or a network. + */ +object MockSpotifyDataSource : SpotifyCatalog { + private fun t( + id: String, + title: String, + artist: String, + artistId: String, + albumId: String, + art: Int, + seconds: Int, + explicit: Boolean = false, + playable: Boolean = true + ) = SpotifyTrack(id, title, artist, artistId, albumId, art, seconds * 1000L, explicit, playable) + + private val nightSignals = listOf( + t("ns1", "Afterglow", "Aurora Vale", "aurora", "night_signals", R.drawable.cover_night_signals, 227), + t("ns2", "Static Bloom", "Aurora Vale", "aurora", "night_signals", R.drawable.cover_night_signals, 194), + t("ns3", "Midnight Radio", "Aurora Vale", "aurora", "night_signals", R.drawable.cover_night_signals, 243), + t("ns4", "Paper Moon", "Aurora Vale", "aurora", "night_signals", R.drawable.cover_night_signals, 211), + t("ns5", "Low Tide", "Aurora Vale", "aurora", "night_signals", R.drawable.cover_night_signals, 258, explicit = true), + t("ns6", "Neon Rooms", "Aurora Vale", "aurora", "night_signals", R.drawable.cover_night_signals, 202), + t("ns7", "Slow Motion", "Aurora Vale", "aurora", "night_signals", R.drawable.cover_night_signals, 236), + t("ns8", "Satellite", "Aurora Vale", "aurora", "night_signals", R.drawable.cover_night_signals, 188), + t("ns9", "Wide Awake", "Aurora Vale", "aurora", "night_signals", R.drawable.cover_night_signals, 219), + t("ns10", "Last Light", "Aurora Vale", "aurora", "night_signals", R.drawable.cover_night_signals, 271) + ) + + private val glassHours = listOf( + t("gh1", "Glass Hours", "Aurora Vale", "aurora", "glass_hours", R.drawable.cover_glass_hours, 245), + t("gh2", "Amber", "Aurora Vale", "aurora", "glass_hours", R.drawable.cover_glass_hours, 201), + t("gh3", "Held Open", "Aurora Vale", "aurora", "glass_hours", R.drawable.cover_glass_hours, 230), + t("gh4", "The Quiet Part", "Aurora Vale", "aurora", "glass_hours", R.drawable.cover_glass_hours, 218), + t("gh5", "Half a Mile", "Aurora Vale", "aurora", "glass_hours", R.drawable.cover_glass_hours, 256), + t("gh6", "Window Seat", "Aurora Vale", "aurora", "glass_hours", R.drawable.cover_glass_hours, 192), + t("gh7", "Soft Landing", "Aurora Vale", "aurora", "glass_hours", R.drawable.cover_glass_hours, 274), + t("gh8", "Glass Hours (Reprise)", "Aurora Vale", "aurora", "glass_hours", R.drawable.cover_glass_hours, 133) + ) + + private val coastline = listOf( + t("co1", "Coastline", "Mira Sol", "mira", "coastline", R.drawable.cover_coastline, 232), + t("co2", "Saltwater", "Mira Sol", "mira", "coastline", R.drawable.cover_coastline, 207), + t("co3", "Blue Hour", "Mira Sol", "mira", "coastline", R.drawable.cover_coastline, 248), + t("co4", "North Wind", "Mira Sol", "mira", "coastline", R.drawable.cover_coastline, 196), + t("co5", "Open Water", "Mira Sol", "mira", "coastline", R.drawable.cover_coastline, 263), + t("co6", "Every Harbour", "Mira Sol", "mira", "coastline", R.drawable.cover_coastline, 224), + t("co7", "Drift", "Mira Sol", "mira", "coastline", R.drawable.cover_coastline, 181), + t("co8", "Coming Home", "Mira Sol", "mira", "coastline", R.drawable.cover_coastline, 286) + ) + + private val roomsWeLeft = listOf( + t("rw1", "Rooms We Left", "The Static Coast", "static", "rooms_we_left", R.drawable.cover_rooms_we_left, 239), + t("rw2", "Hallway Light", "The Static Coast", "static", "rooms_we_left", R.drawable.cover_rooms_we_left, 204), + t("rw3", "Furniture", "The Static Coast", "static", "rooms_we_left", R.drawable.cover_rooms_we_left, 217, explicit = true), + t("rw4", "A Door, Ajar", "The Static Coast", "static", "rooms_we_left", R.drawable.cover_rooms_we_left, 251), + t("rw5", "Everything We Kept", "The Static Coast", "static", "rooms_we_left", R.drawable.cover_rooms_we_left, 268), + t("rw6", "Keys", "The Static Coast", "static", "rooms_we_left", R.drawable.cover_rooms_we_left, 185), + t("rw7", "After the Lease", "The Static Coast", "static", "rooms_we_left", R.drawable.cover_rooms_we_left, 233), + t("rw8", "Empty House", "The Static Coast", "static", "rooms_we_left", R.drawable.cover_rooms_we_left, 297) + ) + + private val goldenHour = listOf( + t("go1", "Golden Hour", "Ivy & Ash", "ivy", "golden_hour", R.drawable.cover_golden_hour, 221), + t("go2", "Marigold", "Ivy & Ash", "ivy", "golden_hour", R.drawable.cover_golden_hour, 198), + t("go3", "Long Shadows", "Ivy & Ash", "ivy", "golden_hour", R.drawable.cover_golden_hour, 242), + t("go4", "Honey", "Ivy & Ash", "ivy", "golden_hour", R.drawable.cover_golden_hour, 209), + t("go5", "Porch Song", "Ivy & Ash", "ivy", "golden_hour", R.drawable.cover_golden_hour, 257), + t("go6", "Warm Front", "Ivy & Ash", "ivy", "golden_hour", R.drawable.cover_golden_hour, 188), + t("go7", "Before the Streetlights", "Ivy & Ash", "ivy", "golden_hour", R.drawable.cover_golden_hour, 236), + t("go8", "Still Daylight", "Ivy & Ash", "ivy", "golden_hour", R.drawable.cover_golden_hour, 265) + ) + + private val softFocus = listOf( + t("sf1", "Soft Focus", "Lumen Park", "lumen", "soft_focus", R.drawable.cover_soft_focus, 214), + t("sf2", "Pastel Static", "Lumen Park", "lumen", "soft_focus", R.drawable.cover_soft_focus, 199), + t("sf3", "Lavender", "Lumen Park", "lumen", "soft_focus", R.drawable.cover_soft_focus, 238), + t("sf4", "Low Resolution", "Lumen Park", "lumen", "soft_focus", R.drawable.cover_soft_focus, 226), + t("sf5", "Silver Lining", "Lumen Park", "lumen", "soft_focus", R.drawable.cover_soft_focus, 247), + t("sf6", "A Little Blur", "Lumen Park", "lumen", "soft_focus", R.drawable.cover_soft_focus, 183), + t("sf7", "Daylight Savings", "Lumen Park", "lumen", "soft_focus", R.drawable.cover_soft_focus, 252), + t("sf8", "Out of Frame", "Lumen Park", "lumen", "soft_focus", R.drawable.cover_soft_focus, 269) + ) + + val artists: List = listOf( + SpotifyArtist("aurora", "Aurora Vale", R.drawable.cover_aurora_vale, listOf("night_signals", "glass_hours")), + SpotifyArtist("mira", "Mira Sol", R.drawable.cover_mira_sol, listOf("coastline")), + SpotifyArtist("static", "The Static Coast", R.drawable.cover_static_coast, listOf("rooms_we_left")), + SpotifyArtist("ivy", "Ivy & Ash", R.drawable.cover_ivy_ash, listOf("golden_hour")), + SpotifyArtist("lumen", "Lumen Park", R.drawable.cover_lumen_park, listOf("soft_focus")) + ) + + val albums: List = listOf( + SpotifyAlbum("night_signals", "Night Signals", "Aurora Vale", "aurora", 2024, R.drawable.cover_night_signals, nightSignals), + SpotifyAlbum("glass_hours", "Glass Hours", "Aurora Vale", "aurora", 2022, R.drawable.cover_glass_hours, glassHours), + SpotifyAlbum("coastline", "Coastline", "Mira Sol", "mira", 2023, R.drawable.cover_coastline, coastline), + SpotifyAlbum("rooms_we_left", "Rooms We Left", "The Static Coast", "static", 2024, R.drawable.cover_rooms_we_left, roomsWeLeft), + SpotifyAlbum("golden_hour", "Golden Hour", "Ivy & Ash", "ivy", 2021, R.drawable.cover_golden_hour, goldenHour), + SpotifyAlbum("soft_focus", "Soft Focus", "Lumen Park", "lumen", 2025, R.drawable.cover_soft_focus, softFocus) + ) + + val playlists: List = listOf( + SpotifyPlaylist( + "late_night", + "Late Night Drive", + "You", + null, + R.drawable.cover_late_night_drive, + listOf(nightSignals[0], nightSignals[5], coastline[2], roomsWeLeft[1], softFocus[3], nightSignals[7], glassHours[5], coastline[6]) + ), + SpotifyPlaylist( + "sunday_morning", + "Sunday Morning", + "You", + null, + R.drawable.cover_sunday_morning, + listOf(goldenHour[0], goldenHour[3], glassHours[1], coastline[0], goldenHour[6], softFocus[4], glassHours[6]) + ), + SpotifyPlaylist( + "focus", + "Focus / No Words", + "You", + null, + R.drawable.cover_focus_no_words, + listOf(softFocus[0], softFocus[1], softFocus[5], glassHours[7], coastline[6], roomsWeLeft[5], nightSignals[3]) + ), + SpotifyPlaylist( + "kitchen_radio", + "Kitchen Radio", + "You", + null, + R.drawable.cover_kitchen_radio, + listOf(goldenHour[1], coastline[1], nightSignals[8], goldenHour[4], glassHours[2], coastline[4], roomsWeLeft[0]) + ) + ) + + val shows: List = listOf( + SpotifyShow( + "long_way_home", + "The Long Way Home", + "Northline Audio", + R.drawable.cover_long_way_home, + listOf( + SpotifyEpisode("lw1", "The Road Out of Town", "The Long Way Home", "long_way_home", "12 Mar 2025", R.drawable.cover_long_way_home, 2_442_000L), + SpotifyEpisode("lw2", "A Room with One Window", "The Long Way Home", "long_way_home", "19 Mar 2025", R.drawable.cover_long_way_home, 2_118_000L), + SpotifyEpisode("lw3", "The Ferry at Dusk", "The Long Way Home", "long_way_home", "26 Mar 2025", R.drawable.cover_long_way_home, 2_676_000L), + SpotifyEpisode("lw4", "Notes from the Coast Road", "The Long Way Home", "long_way_home", "2 Apr 2025", R.drawable.cover_long_way_home, 2_307_000L), + SpotifyEpisode("lw5", "The Last Bus", "The Long Way Home", "long_way_home", "9 Apr 2025", R.drawable.cover_long_way_home, 2_529_000L) + ) + ), + SpotifyShow( + "small_hours", + "Small Hours", + "Northline Audio", + R.drawable.cover_small_hours, + listOf( + SpotifyEpisode("sh1", "3:14 AM", "Small Hours", "small_hours", "4 Feb 2025", R.drawable.cover_small_hours, 1_842_000L), + SpotifyEpisode("sh2", "The Night Shift", "Small Hours", "small_hours", "11 Feb 2025", R.drawable.cover_small_hours, 1_976_000L), + SpotifyEpisode("sh3", "A Film We Nearly Made", "Small Hours", "small_hours", "18 Feb 2025", R.drawable.cover_small_hours, 2_035_000L), + SpotifyEpisode("sh4", "Room Tone", "Small Hours", "small_hours", "25 Feb 2025", R.drawable.cover_small_hours, 1_788_000L) + ) + ) + ) + + /** The default Liked Songs set, already saved in the mock account. */ + val liked: List = listOf( + nightSignals[0], nightSignals[3], coastline[0], coastline[2], roomsWeLeft[1], + goldenHour[0], goldenHour[5], softFocus[2], glassHours[3], nightSignals[7] + ) + + val recentlyPlayed: List = listOf( + nightSignals[0], coastline[2], roomsWeLeft[1], goldenHour[0], softFocus[2], + nightSignals[5], glassHours[1], coastline[6], roomsWeLeft[5] + ) + + fun album(id: String): SpotifyAlbum? = albums.firstOrNull { it.id == id } + fun playlist(id: String): SpotifyPlaylist? = playlists.firstOrNull { it.id == id } + fun show(id: String): SpotifyShow? = shows.firstOrNull { it.id == id } + override suspend fun artist(id: String): SpotifyArtist? = artists.firstOrNull { it.id == id } + fun track(id: String): SpotifyTrack? = albums.flatMap { it.tracks }.firstOrNull { it.id == id } + + override suspend fun collection( + type: SpotifyCollectionType, + id: String, + offset: Int, + limit: Int + ): SpotifyCollection? = when (type) { + SpotifyCollectionType.Album -> album(id)?.let { + val items = it.tracks.drop(offset).take(limit) + SpotifyCollection( + type, id, it.title, "${it.artist} · ${it.year} · ${it.tracks.size} tracks", it.artRes, items, it.artistId, + totalItems = it.tracks.size, hasMore = offset + items.size < it.tracks.size + ) + } + + SpotifyCollectionType.Playlist -> playlist(id)?.let { + val items = it.tracks.drop(offset).take(limit) + SpotifyCollection( + type, id, it.name, "${it.owner} · ${it.tracks.size} tracks", it.artRes, items, it.ownerId, + totalItems = it.tracks.size, hasMore = offset + items.size < it.tracks.size + ) + } + + SpotifyCollectionType.Show -> show(id)?.let { + val items = it.episodes.drop(offset).take(limit) + SpotifyCollection( + type, + id, + it.name, + "${it.publisher} · ${it.episodes.size} episodes", + it.artRes, + items, + totalItems = it.episodes.size, + hasMore = offset + items.size < it.episodes.size + ) + } + } + + override suspend fun topTracks(artist: SpotifyArtist, offset: Int, limit: Int): SpotifyPage { + val all = albums.filter { it.id in artist.albumIds }.flatMap { it.tracks } + return SpotifyPage.of(all.drop(offset).take(limit), offset, limit, all.size) + } + + fun topTracks(artist: SpotifyArtist, limit: Int): List = albums.filter { it.id in artist.albumIds }.flatMap { it.tracks }.take(limit) + + override suspend fun albumsBy(artist: SpotifyArtist, offset: Int, limit: Int): SpotifyPage { + val all = artist.albumIds.mapNotNull(::album) + return SpotifyPage.of(all.drop(offset).take(limit), offset, limit, all.size) + } + + override suspend fun library(filter: LibraryFilter, offset: Int, limit: Int): SpotifyPage { + val all: List = when (filter) { + LibraryFilter.LikedSongs -> liked + LibraryFilter.Playlists -> playlists + LibraryFilter.Albums -> albums + LibraryFilter.Artists -> artists + LibraryFilter.Shows -> shows + } + return SpotifyPage.of(all.drop(offset).take(limit), offset, limit, all.size) + } + + override suspend fun search( + query: String, + scopes: Set, + offset: Int, + limit: Int + ): SpotifySearchResults { + val q = query.trim() + if (q.length < 2) return SpotifySearchResults() + fun match(vararg text: String) = text.any { it.contains(q, ignoreCase = true) } + val allTracks = albums.flatMap { it.tracks } + fun page(items: List): List = items.drop(offset).take(limit) + return SpotifySearchResults( + tracks = if (SearchScope.Tracks in scopes) page(allTracks.filter { match(it.title, it.artist) }) else emptyList(), + artists = if (SearchScope.Artists in scopes) page(artists.filter { match(it.name) }) else emptyList(), + albums = if (SearchScope.Albums in scopes) page(albums.filter { match(it.title, it.artist) }) else emptyList(), + playlists = if (SearchScope.Playlists in scopes) page(playlists.filter { match(it.name, it.owner) }) else emptyList(), + shows = if (SearchScope.Shows in scopes) page(shows.filter { match(it.name, it.publisher) }) else emptyList() + ) + } +} diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/spotify/NativeSpotifyCatalogTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/NativeSpotifyCatalogTest.kt new file mode 100644 index 00000000..44eb6650 --- /dev/null +++ b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/NativeSpotifyCatalogTest.kt @@ -0,0 +1,187 @@ +package hrln.interactive.sidekit.apps.spotify + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import hrln.interactive.sidekit.apps.spotify.bridge.SpotifyNative +import hrln.interactive.sidekit.apps.spotify.data.NativeSpotifyCatalog +import hrln.interactive.sidekit.apps.spotify.data.SpotifyFailure +import hrln.interactive.sidekit.apps.spotify.data.SpotifyGatewayException +import kotlinx.coroutines.test.runTest +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class NativeSpotifyCatalogTest { + @Test fun `track save writes the collection set`() = runTest { + val native = FakeNative() + val catalog = NativeSpotifyCatalog(native) {} + + catalog.setTrackSaved("abc", true) + + assertEquals("collection", native.set) + assertEquals("[\"spotify:track:abc\"]", native.uris) + assertFalse(native.removed) + } + + @Test fun `album and show saves use their collection sets`() = runTest { + val native = FakeNative() + val catalog = NativeSpotifyCatalog(native) {} + + catalog.setSaved(SpotifyCollectionType.Album, "album1", false) + assertEquals("collection", native.set) + assertTrue(native.removed) + + catalog.setSaved(SpotifyCollectionType.Show, "show1", true) + assertEquals("show", native.set) + assertFalse(native.removed) + } + + @Test fun `playlist save uses the rootlist endpoint`() = runTest { + val native = FakeNative() + val catalog = NativeSpotifyCatalog(native) {} + + catalog.setSaved(SpotifyCollectionType.Playlist, "playlist1", true) + + assertEquals("playlist1", native.playlistId) + assertTrue(native.playlistSave) + } + + @Test fun `contains responses seed saved state`() = runTest { + val native = FakeNative().apply { + contains = "[true]" + playlistSaved = "true" + } + val catalog = NativeSpotifyCatalog(native) {} + + assertTrue(catalog.isTrackSaved("abc") == true) + assertTrue(catalog.isSaved(SpotifyCollectionType.Playlist, "playlist1") == true) + } + + @Test fun `library forwards paging and reports another page`() = runTest { + val tracks = JSONArray().apply { + repeat(50) { put(JSONObject().put("id", "t$it")) } + } + val native = FakeNative().apply { libraryResult = tracks.toString() } + val catalog = NativeSpotifyCatalog(native) {} + + val page = catalog.library(LibraryFilter.LikedSongs, offset = 50, limit = 50) + + assertEquals(50, page.items.size) + assertTrue(page.hasMore) + assertEquals(50L, native.libraryOffset) + assertEquals(50L, native.libraryLimit) + } + + @Test fun `collection preserves total and page state`() = runTest { + val native = FakeNative().apply { + collectionResult = JSONObject() + .put("id", "p1") + .put("title", "Long playlist") + .put("subtitle", "You · 202 tracks") + .put("items", JSONArray()) + .put("totalItems", 202) + .put("hasMore", true) + .toString() + } + val catalog = NativeSpotifyCatalog(native) {} + + val collection = catalog.collection(SpotifyCollectionType.Playlist, "p1", offset = 50, limit = 50) + + assertEquals(202, collection?.totalItems) + assertTrue(collection?.hasMore == true) + assertEquals(50L, native.collectionOffset) + assertEquals(50L, native.collectionLimit) + } + + @Test fun `search forwards its offset and limit`() = runTest { + val native = FakeNative().apply { searchResult = "{\"tracks\":[]}" } + val catalog = NativeSpotifyCatalog(native) {} + + catalog.search("night", setOf(SearchScope.Tracks), offset = 50, limit = 50) + + assertEquals(50L, native.searchOffset) + assertEquals(50L, native.searchLimit) + } + + @Test fun `failed write surfaces a network failure`() = runTest { + val native = FakeNative().apply { writeOk = false } + val catalog = NativeSpotifyCatalog(native) {} + + val error = runCatching { catalog.setTrackSaved("abc", true) }.exceptionOrNull() + + assertTrue(error is SpotifyGatewayException) + assertEquals(SpotifyFailure.Network, (error as SpotifyGatewayException).failure) + } + + private class FakeNative : SpotifyNative { + override val isAvailable = true + var set: String? = null + var uris: String? = null + var removed = false + var writeOk = true + var contains = "[false]" + var playlistSaved = "false" + var playlistId: String? = null + var playlistSave = false + var libraryResult = "[]" + var libraryOffset = -1L + var libraryLimit = -1L + var collectionResult = "{}" + var collectionOffset = -1L + var collectionLimit = -1L + var searchResult = "{}" + var searchOffset = -1L + var searchLimit = -1L + + override fun initialize() = true + override fun setTmpDir(path: String) = Unit + override fun setCacheDir(path: String, sizeLimitBytes: Long) = Unit + override fun connect(accessToken: String) = true + override fun setAccessToken(accessToken: String) = true + override fun disconnect() = Unit + override fun createPlayer(bitrateKbps: Int) = true + override fun load(uri: String, positionMs: Long, autoplay: Boolean) = true + override fun play() = true + override fun pause() = true + override fun stop() = true + override fun seek(positionMs: Long) = true + override fun readAudio(timeoutMs: Long): ByteArray? = null + override fun audioFormat(): String? = null + override fun pollEvent(): String? = null + override fun catalogLibrary(filter: String, offset: Long, limit: Long): String { + libraryOffset = offset + libraryLimit = limit + return libraryResult + } + override fun catalogCollection(kind: String, id: String, offset: Long, limit: Long): String { + collectionOffset = offset + collectionLimit = limit + return collectionResult + } + override fun catalogArtist(id: String): String? = null + override fun catalogAlbumsBy(id: String, offset: Long, limit: Long): String? = null + override fun catalogTopTracks(id: String, offset: Long, limit: Long): String? = null + override fun catalogSearch(query: String, scopes: String, offset: Long, limit: Long): String { + searchOffset = offset + searchLimit = limit + return searchResult + } + override fun collectionContains(set: String, uris: String): String? = contains + override fun collectionWrite(set: String, uris: String, removed: Boolean): Boolean { + this.set = set + this.uris = uris + this.removed = removed + return writeOk + } + override fun playlistSaved(id: String): String? = playlistSaved + override fun playlistSave(id: String, save: Boolean): Boolean { + playlistId = id + playlistSave = save + return writeOk + } + } +} diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/spotify/NativeSpotifyGatewayTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/NativeSpotifyGatewayTest.kt new file mode 100644 index 00000000..3f723be8 --- /dev/null +++ b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/NativeSpotifyGatewayTest.kt @@ -0,0 +1,145 @@ +package hrln.interactive.sidekit.apps.spotify + +import hrln.interactive.sidekit.apps.spotify.bridge.SpotifyNative +import hrln.interactive.sidekit.apps.spotify.data.NativeSpotifyGateway +import hrln.interactive.sidekit.apps.spotify.data.SpotifyFailure +import hrln.interactive.sidekit.apps.spotify.data.SpotifyGatewayException +import hrln.interactive.sidekit.apps.spotify.data.SpotifySettings +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class NativeSpotifyGatewayTest { + @Test fun `unavailable library maps to Unavailable`() = runTest { + val gateway = NativeSpotifyGateway(FakeNative(isAvailable = false)) { "token" } + + val failure = runCatching { gateway.connect("token") }.exceptionOrNull() + + assertTrue(failure is SpotifyGatewayException) + assertEquals(SpotifyFailure.Unavailable, (failure as SpotifyGatewayException).failure) + } + + @Test fun `connect creates the player`() = runTest { + val native = FakeNative() + val gateway = NativeSpotifyGateway(native) { "token" } + + gateway.connect("token") + + assertEquals("token", native.token) + assertTrue(native.playerCreated) + } + + @Test fun `failed load maps to TrackUnavailable`() = runTest { + val native = FakeNative().apply { loadOk = false } + val gateway = NativeSpotifyGateway(native) { "token" } + + val failure = runCatching { gateway.load("spotify:track:a", 0, true) }.exceptionOrNull() + + assertEquals(SpotifyFailure.TrackUnavailable, (failure as SpotifyGatewayException).failure) + } + + @Test fun `ensureConnected uses the stored token`() = runTest { + val native = FakeNative() + val gateway = NativeSpotifyGateway(native) { "stored" } + + gateway.ensureConnected() + + assertEquals("stored", native.token) + } + + @Test fun `player receives persisted bitrate`() = runTest { + val native = FakeNative() + val gateway = NativeSpotifyGateway( + native, + { SpotifySettings(audioQuality = hrln.interactive.sidekit.apps.spotify.AudioQuality.Normal) } + ) { "token" } + + gateway.connect("token") + + assertEquals(96, native.bitrateKbps) + } + + @Test fun `rotated token is pushed into the live session`() = runTest { + val native = FakeNative() + var token = "first" + val gateway = NativeSpotifyGateway(native) { token } + gateway.connect(token) + token = "second" + + gateway.ensureConnected() + + assertEquals("second", native.token) + assertEquals(1, native.connects) + } + + @Test fun `dead native session reconnects even when the token has not changed`() = runTest { + val native = FakeNative().apply { setTokenOk = false } + val gateway = NativeSpotifyGateway(native) { "same" } + gateway.connect("same") + + gateway.ensureConnected() + + assertEquals("same", native.token) + assertEquals(2, native.connects) + } + + @Test fun `dead native session reconnects from scratch`() = runTest { + val native = FakeNative().apply { setTokenOk = false } + var token = "first" + val gateway = NativeSpotifyGateway(native) { token } + gateway.connect(token) + token = "second" + + gateway.ensureConnected() + + assertEquals("second", native.token) + assertEquals(2, native.connects) + } + + private class FakeNative(override val isAvailable: Boolean = true) : SpotifyNative { + var token: String? = null + var playerCreated = false + var loadOk = true + var setTokenOk = true + var bitrateKbps: Int? = null + var connects = 0 + + override fun initialize() = isAvailable + override fun setTmpDir(path: String) = Unit + override fun setCacheDir(path: String, sizeLimitBytes: Long) = Unit + override fun connect(accessToken: String): Boolean { + connects++ + token = accessToken + return isAvailable + } + override fun setAccessToken(accessToken: String): Boolean { + if (setTokenOk) token = accessToken + return setTokenOk && isAvailable + } + override fun disconnect() = Unit + override fun createPlayer(bitrateKbps: Int): Boolean { + playerCreated = true + this.bitrateKbps = bitrateKbps + return isAvailable + } + override fun load(uri: String, positionMs: Long, autoplay: Boolean) = loadOk + override fun play() = isAvailable + override fun pause() = isAvailable + override fun stop() = isAvailable + override fun seek(positionMs: Long) = isAvailable + override fun readAudio(timeoutMs: Long): ByteArray? = null + override fun audioFormat(): String? = null + override fun pollEvent(): String? = null + override fun catalogLibrary(filter: String, offset: Long, limit: Long): String? = null + override fun catalogCollection(kind: String, id: String, offset: Long, limit: Long): String? = null + override fun catalogArtist(id: String): String? = null + override fun catalogAlbumsBy(id: String, offset: Long, limit: Long): String? = null + override fun catalogTopTracks(id: String, offset: Long, limit: Long): String? = null + override fun catalogSearch(query: String, scopes: String, offset: Long, limit: Long): String? = null + override fun collectionContains(set: String, uris: String): String? = null + override fun collectionWrite(set: String, uris: String, removed: Boolean): Boolean = true + override fun playlistSaved(id: String): String? = null + override fun playlistSave(id: String, save: Boolean): Boolean = true + } +} diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SideplayLinksTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SideplayLinksTest.kt new file mode 100644 index 00000000..a563c637 --- /dev/null +++ b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SideplayLinksTest.kt @@ -0,0 +1,23 @@ +package hrln.interactive.sidekit.apps.spotify + +import android.content.Intent +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SideplayLinksTest { + @After fun clearPendingNavigation() = SideplayLinks.reset() + + @Test fun `the playback notification action asks for now playing`() { + assertTrue(SideplayLinks.handle(Intent(SideplayLinks.ACTION_OPEN_NOW_PLAYING))) + } + + @Test fun `unrelated intents are ignored`() { + assertFalse(SideplayLinks.handle(Intent(Intent.ACTION_VIEW))) + assertFalse(SideplayLinks.handle(null)) + } +} diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SideplayScreenshotTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SideplayScreenshotTest.kt new file mode 100644 index 00000000..c00f47f1 --- /dev/null +++ b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SideplayScreenshotTest.kt @@ -0,0 +1,191 @@ +package hrln.interactive.sidekit.apps.spotify + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.captureRoboImage +import hrln.interactive.sidekit.ui.SidekitPreviewFrame +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class SideplayScreenshotTest { + @get:Rule val compose = createComposeRule() + + private fun shoot(name: String, content: @Composable () -> Unit) { + compose.setContent { SidekitPreviewFrame(content) } + compose.onRoot().captureRoboImage("screenshots/$name.png") + } + + private val recent = MockSpotifyDataSource.recentlyPlayed + private val current = MockSpotifyDataSource.recentlyPlayed.first() + private val state = SpotifyUiState( + current = current, + upcoming = recent.drop(1), + history = recent.drop(3), + recent = recent, + libraryItems = runBlocking { MockSpotifyDataSource.library(LibraryFilter.Albums).items }, + searchResults = runBlocking { MockSpotifyDataSource.search("night", SearchScope.entries.toSet()) }, + isPlaying = true, + positionMs = 68_000L, + savedTrackIds = setOf(current.id), + filter = LibraryFilter.Albums + ) + + @Test fun home() = shoot("sideplay_home") { + SideplayHomeScreen( + state, + onNowPlaying = {}, + onLibrary = {}, + onSearch = {}, + onSettings = {}, + onRefresh = {}, + onPlay = {}, + onQueue = {}, + onQueueTrack = {}, + onDismissNotice = {} + ) + } + + @Test fun library() = shoot("sideplay_library_albums") { + LibraryScreen( + state, + onBack = {}, + onSearch = {}, + onFilter = {}, + onTrack = {}, + onQueue = {}, + onPlaylist = {}, + onAlbum = {}, + onArtist = {}, + onShow = {}, + onLoadMore = {}, + onDismissNotice = {} + ) + } + + @Test fun album() = shoot("sideplay_album") { + CollectionScreen( + runBlocking { MockSpotifyDataSource.collection(SpotifyCollectionType.Album, "night_signals") }!!, + state, + onBack = {}, + onPlay = {}, + onItem = {}, + onSave = {}, + onArtist = {}, + onQueue = {}, + onQueueItem = {}, + onNowPlaying = {}, + onUndo = {}, + onLoadMore = {}, + loadingMore = false, + onDismissNotice = {} + ) + } + + @Test fun artist() = shoot("sideplay_artist") { + val artist = MockSpotifyDataSource.artists.first() + ArtistScreen( + artist, + runBlocking { MockSpotifyDataSource.albumsBy(artist).items }, + runBlocking { MockSpotifyDataSource.topTracks(artist).items }, + state, + onBack = {}, + onPlay = {}, + onTrack = {}, + onQueue = {}, + onAlbum = {}, + onFollow = {}, + onNowPlaying = {}, + onUndo = {}, + onLoadMoreAlbums = {}, + onLoadMoreTracks = {}, + albumHasMore = false, + trackHasMore = false, + loadingMore = false, + onDismissNotice = {} + ) + } + + @Test fun search() = shoot("sideplay_search") { + SpotifySearchScreen( + state.copy(query = "night"), + onBack = {}, + onQuery = {}, + onClear = {}, + onToggleScope = {}, + onTrack = {}, + onQueue = {}, + onArtist = {}, + onAlbum = {}, + onPlaylist = {}, + onShow = {}, + onLoadMore = {}, + onDismissNotice = {} + ) + } + + @Test fun nowPlaying() = shoot("sideplay_now_playing") { + SpotifyNowPlayingScreen( + state, + onBack = {}, + onPlayPause = {}, + onPrevious = {}, + onNext = {}, + onSeek = {}, + onShuffle = {}, + onRepeat = {}, + onSave = {}, + onQueue = {}, + onAlbum = {}, + onShow = {}, + onArtist = {}, + onUndo = {}, + onDismissNotice = {} + ) + } + + @Test fun queue() = shoot("sideplay_queue") { + QueueScreen( + state, + onBack = {}, + onNowPlaying = {}, + onJump = {}, + onPlayNext = {}, + onRemove = {}, + onRemoveHistory = {}, + onClearUpcoming = { emptyList() }, + onRestoreUpcoming = {}, + onShuffle = {}, + onDismissNotice = {} + ) + } + + @Test fun settings() = shoot("sideplay_settings") { + SpotifySettingsScreen( + state, + onBack = {}, + onAudioQuality = {}, + onAutoplay = {}, + onClearCache = {}, + onClearHistory = {}, + onSignOut = {}, + onNotice = {}, + onDismissNotice = {} + ) + } + + @Test fun signIn() = shoot("sideplay_sign_in") { + SpotifySignInScreen( + SpotifyUiState(signedIn = false), + onBack = {}, + onLogIn = {}, + onDismissNotice = {} + ) + } +} diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyAuthTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyAuthTest.kt new file mode 100644 index 00000000..21d5ba7f --- /dev/null +++ b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyAuthTest.kt @@ -0,0 +1,140 @@ +package hrln.interactive.sidekit.apps.spotify + +import hrln.interactive.sidekit.apps.spotify.auth.SpotifyAuth +import hrln.interactive.sidekit.apps.spotify.auth.SpotifyCredentialStore +import hrln.interactive.sidekit.apps.spotify.auth.SpotifyCredentials +import hrln.interactive.sidekit.apps.spotify.auth.SpotifyTokenClient +import java.net.URI +import java.net.URL +import java.net.URLDecoder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SpotifyAuthTest { + private val now = 1_000_000L + + @Test fun `valid token is returned without refreshing`() = runTest { + val store = MemoryStore(credentials(expiresAt = now + 180_000L)) + val tokens = FakeTokens() + val auth = SpotifyAuth(store, tokens, nowMs = { now }) + + assertEquals("old-access", auth.accessToken()) + assertEquals(0, tokens.refreshCalls) + } + + @Test fun `expired token is refreshed and saved`() = runTest { + val store = MemoryStore(credentials(expiresAt = now + 10_000L)) + val tokens = FakeTokens(refreshed = credentials(access = "new-access", expiresAt = now + 3_600_000L)) + val auth = SpotifyAuth(store, tokens, nowMs = { now }) + + assertEquals("new-access", auth.accessToken()) + assertEquals(1, tokens.refreshCalls) + assertEquals("new-access", store.saved?.accessToken) + } + + @Test fun `refresh failure leaves the store untouched and returns null`() = runTest { + val original = credentials(expiresAt = now + 10_000L) + val store = MemoryStore(original) + val auth = SpotifyAuth(store, FakeTokens(refreshFails = true), nowMs = { now }) + + assertNull(auth.accessToken()) + assertEquals(original, store.saved) + } + + @Test fun `sign in completes against the loopback callback`() = runTest { + val store = MemoryStore(null) + val tokens = object : SpotifyTokenClient { + override suspend fun exchange( + clientId: String, + code: String, + verifier: String, + redirectUri: String + ): SpotifyCredentials { + assertEquals("test-code", code) + assertTrue(clientId.isNotEmpty()) + assertTrue(verifier.isNotEmpty()) + return credentials(access = "fresh-access") + } + + override suspend fun refresh(clientId: String, refreshToken: String): SpotifyCredentials = credentials(access = "refreshed") + } + val auth = SpotifyAuth(store, tokens) + val io = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + val result = auth.signIn { url -> + val params = URI(url).rawQuery.split('&').associate { + URLDecoder.decode(it.substringBefore('='), Charsets.UTF_8.name()) to + URLDecoder.decode(it.substringAfter('='), Charsets.UTF_8.name()) + } + val redirect = requireNotNull(params["redirect_uri"]) + val state = requireNotNull(params["state"]) + io.launch { + delay(50) + URL("$redirect?code=test-code&state=$state").openStream().use { it.readBytes() } + } + } + + assertEquals("fresh-access", result.accessToken) + assertEquals("fresh-access", store.saved?.accessToken) + } + + @Test fun `sign out clears credentials`() { + val store = MemoryStore(credentials()) + val session = hrln.interactive.sidekit.apps.spotify.data.SpotifySession(SpotifyAuth(store, FakeTokens())) + + session.signOut() + + assertNull(store.saved) + assertTrue(!session.signedIn.value) + } + + private fun credentials( + access: String = "old-access", + refresh: String? = "refresh", + expiresAt: Long = now + 180_000L + ) = SpotifyCredentials( + accessToken = access, + refreshToken = refresh, + expiresAtMs = expiresAt, + scope = "streaming" + ) + + private class MemoryStore(var saved: SpotifyCredentials?) : SpotifyCredentialStore { + override fun read(): SpotifyCredentials? = saved + override fun save(credentials: SpotifyCredentials) { + saved = credentials + } + + override fun clear() { + saved = null + } + } + + private class FakeTokens( + private val refreshed: SpotifyCredentials? = null, + private val refreshFails: Boolean = false + ) : SpotifyTokenClient { + var refreshCalls = 0 + + override suspend fun exchange( + clientId: String, + code: String, + verifier: String, + redirectUri: String + ): SpotifyCredentials = error("not used") + + override suspend fun refresh(clientId: String, refreshToken: String): SpotifyCredentials { + refreshCalls++ + if (refreshFails) error("refresh failed") + return requireNotNull(refreshed) + } + } +} diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyHistoryRepositoryTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyHistoryRepositoryTest.kt new file mode 100644 index 00000000..bc995efa --- /dev/null +++ b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyHistoryRepositoryTest.kt @@ -0,0 +1,71 @@ +package hrln.interactive.sidekit.apps.spotify + +import hrln.interactive.sidekit.apps.spotify.data.SpotifyHistoryEntry +import hrln.interactive.sidekit.apps.spotify.data.SpotifyHistoryRepository +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SpotifyHistoryRepositoryTest { + @Test fun `most recent item is first and duplicates move up`() { + val saved = mutableListOf>() + val history = SpotifyHistoryRepository(save = { saved += it }) + + history.record(track("a"), 1) + history.record(track("b"), 2) + history.record(track("a"), 3) + + assertEquals(listOf("a", "b"), history.items().map { it.id }) + assertEquals(3, saved.last().first().playedAtMs) + } + + @Test fun `history is bounded and drops the oldest entry`() { + val history = SpotifyHistoryRepository(limit = 3) + + listOf("a", "b", "c", "d").forEachIndexed { index, id -> history.record(track(id), index.toLong()) } + + assertEquals(listOf("d", "c", "b"), history.items().map { it.id }) + } + + @Test fun `clear empties memory and persists the empty list`() { + var saved: List? = null + val history = SpotifyHistoryRepository(save = { saved = it }) + history.record(track("a"), 1) + + history.clear() + + assertTrue(history.items().isEmpty()) + assertTrue(saved!!.isEmpty()) + } + + @Test fun `load seeds the repository in order`() { + val stored = listOf( + SpotifyHistoryEntry(track("a"), 2), + SpotifyHistoryEntry(track("b"), 1) + ) + val history = SpotifyHistoryRepository(load = { stored }) + + assertEquals(listOf("a", "b"), history.items().map { it.id }) + } + + @Test fun `load drops duplicate ids from older files`() { + val stored = listOf( + SpotifyHistoryEntry(track("a"), 3), + SpotifyHistoryEntry(track("b"), 2), + SpotifyHistoryEntry(track("a"), 1) + ) + val history = SpotifyHistoryRepository(load = { stored }) + + assertEquals(listOf("a", "b"), history.items().map { it.id }) + } + + private fun track(id: String) = SpotifyTrack( + id = id, + title = "Track $id", + artist = "Artist", + artistId = "artist", + albumId = "album", + artRes = 0, + durationMs = 180_000L + ) +} diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyPaginationTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyPaginationTest.kt new file mode 100644 index 00000000..cef229e5 --- /dev/null +++ b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyPaginationTest.kt @@ -0,0 +1,52 @@ +package hrln.interactive.sidekit.apps.spotify + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SpotifyPaginationTest { + @Test fun `collection pages continue without gaps`() = runTest { + val full = MockSpotifyDataSource.playlist("late_night")!!.tracks + val first = MockSpotifyDataSource.collection(SpotifyCollectionType.Playlist, "late_night", 0, 3)!! + val second = MockSpotifyDataSource.collection(SpotifyCollectionType.Playlist, "late_night", 3, 3)!! + + assertEquals(full.take(3), first.items) + assertEquals(full.drop(3).take(3), second.items) + assertEquals(full.size, first.totalItems) + assertTrue(first.hasMore) + } + + @Test fun `last collection page reports no more`() = runTest { + val full = MockSpotifyDataSource.playlist("late_night")!!.tracks + val last = MockSpotifyDataSource.collection( + SpotifyCollectionType.Playlist, + "late_night", + full.size - 2, + 3 + )!! + + assertEquals(full.takeLast(2), last.items) + assertFalse(last.hasMore) + } + + @Test fun `library page reports total and more`() = runTest { + val page = MockSpotifyDataSource.library(LibraryFilter.LikedSongs, 0, 4) + + assertEquals(4, page.items.size) + assertEquals(MockSpotifyDataSource.liked.size, page.total) + assertTrue(page.hasMore) + } + + @Test fun `search offset advances each section`() = runTest { + val first = MockSpotifyDataSource.search("night", SearchScope.entries.toSet(), 0, 2) + val second = MockSpotifyDataSource.search("night", SearchScope.entries.toSet(), 2, 2) + + assertTrue(first.tracks.isNotEmpty()) + assertTrue((first.tracks + second.tracks).distinctBy { it.id }.size >= first.tracks.size) + } +} diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyPersistenceTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyPersistenceTest.kt new file mode 100644 index 00000000..82d6449a --- /dev/null +++ b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyPersistenceTest.kt @@ -0,0 +1,97 @@ +package hrln.interactive.sidekit.apps.spotify + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import hrln.interactive.sidekit.apps.spotify.data.FileSpotifyHistoryStore +import hrln.interactive.sidekit.apps.spotify.data.FileSpotifyQueueStore +import hrln.interactive.sidekit.apps.spotify.data.SpotifyHistoryRepository +import hrln.interactive.sidekit.apps.spotify.data.SpotifyQueueRepository +import java.io.File +import kotlin.random.Random +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SpotifyPersistenceTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private val tracks = listOf( + track("a"), + track("b"), + track("c"), + track("d") + ) + + @Test fun `queue survives a process restart with shuffle and history`() { + val store = FileSpotifyQueueStore(tempFile("queue")) + val first = SpotifyQueueRepository(Random(1), load = store::read, save = store::write) + first.play(tracks, 0) + first.play(tracks, 1) + first.toggleShuffle() + first.addToQueue(episode("e")) + + val second = SpotifyQueueRepository(Random(2), load = store::read, save = store::write) + + assertEquals(first.snapshot, second.snapshot) + assertEquals("b", second.snapshot.current?.id) + assertEquals(listOf("a"), second.snapshot.history.map { it.id }) + assertTrue(second.snapshot.shuffle) + assertEquals(listOf("c", "d"), second.snapshot.shuffleBackup?.map { it.id }) + assertTrue(second.snapshot.upcoming.last() is SpotifyEpisode) + } + + @Test fun `recently played history survives a process restart`() { + val store = FileSpotifyHistoryStore(tempFile("history")) + val first = SpotifyHistoryRepository(load = store::read, save = store::write) + first.record(track("a"), 10) + first.record(episode("b"), 20) + + val second = SpotifyHistoryRepository(load = store::read, save = store::write) + + assertEquals(listOf("b", "a"), second.items().map { it.id }) + assertEquals(20L, second.snapshot().first().playedAtMs) + } + + @Test fun `removing a history entry persists the shorter list`() { + val store = FileSpotifyHistoryStore(tempFile("history-remove")) + val history = SpotifyHistoryRepository(load = store::read, save = store::write) + history.record(track("a"), 10) + history.record(track("b"), 20) + + history.remove(track("b")) + + assertEquals(listOf("a"), SpotifyHistoryRepository(load = store::read).items().map { it.id }) + } + + @Test fun `a corrupt queue file falls back to an empty queue`() { + val file = tempFile("corrupt").also { it.writeBytes(byteArrayOf(1, 2, 3, 4)) } + + val snapshot = FileSpotifyQueueStore(file).read() + + assertEquals(SpotifyQueueRepository.Snapshot(), snapshot) + } + + private fun tempFile(name: String) = File(context.cacheDir, "sideplay-$name-${System.nanoTime()}.bin").also { it.delete() } + + private fun track(id: String) = SpotifyTrack( + id = id, + title = "Track $id", + artist = "Artist", + artistId = "artist", + albumId = "album", + artRes = 0, + durationMs = 180_000L + ) + + private fun episode(id: String) = SpotifyEpisode( + id = id, + title = "Episode $id", + show = "Show", + showId = "show", + published = "Today", + artRes = 0, + durationMs = 1_800_000L + ) +} diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyPkceTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyPkceTest.kt new file mode 100644 index 00000000..5256d790 --- /dev/null +++ b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyPkceTest.kt @@ -0,0 +1,61 @@ +package hrln.interactive.sidekit.apps.spotify + +import hrln.interactive.sidekit.apps.spotify.auth.SpotifyPkce +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SpotifyPkceTest { + @Test fun `challenge matches the RFC 7636 example`() { + assertEquals( + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + SpotifyPkce.challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") + ) + } + + @Test fun `verifiers are URL safe and long enough`() { + val verifier = SpotifyPkce.verifier() + + assertTrue(verifier.length in 43..128) + assertTrue(verifier.all { it.isLetterOrDigit() || it == '-' || it == '_' }) + } + + @Test fun `authorization URL contains the PKCE fields`() { + val url = SpotifyPkce.authorizationUrl( + clientId = "client", + redirectUri = "http://127.0.0.1:4321/login", + challenge = "challenge", + state = "state", + scopes = "streaming user-read-private" + ) + + assertTrue(url.startsWith("https://accounts.spotify.com/authorize?")) + assertTrue(url.contains("code_challenge_method=S256")) + assertTrue(url.contains("code_challenge=challenge")) + assertTrue(url.contains("state=state")) + assertTrue(url.contains("scope=streaming+user-read-private")) + } + + @Test fun `callback accepts only the matching state`() { + assertEquals( + SpotifyPkce.Callback.Code("abc"), + SpotifyPkce.parseCallback("/login?code=abc&state=expected", "expected") + ) + assertTrue( + SpotifyPkce.parseCallback("/login?code=abc&state=wrong", "expected") is SpotifyPkce.Callback.Failure + ) + } + + @Test fun `denied callback becomes a readable failure`() { + assertEquals( + SpotifyPkce.Callback.Failure("Sign-in was cancelled"), + SpotifyPkce.parseCallback("/login?error=access_denied&state=expected", "expected") + ) + } + + @Test fun `favicon and other requests are ignored`() { + assertFalse(SpotifyPkce.isCallback("/favicon.ico")) + assertEquals(SpotifyPkce.Callback.Ignored, SpotifyPkce.parseCallback("/favicon.ico", "expected")) + } +} diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyPlaybackTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyPlaybackTest.kt new file mode 100644 index 00000000..cf520389 --- /dev/null +++ b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyPlaybackTest.kt @@ -0,0 +1,150 @@ +package hrln.interactive.sidekit.apps.spotify + +import hrln.interactive.sidekit.apps.media.MediaSessionOwner +import hrln.interactive.sidekit.apps.media.MediaSessionRegistry +import hrln.interactive.sidekit.apps.spotify.data.SpotifyFailure +import hrln.interactive.sidekit.apps.spotify.data.SpotifyGateway +import hrln.interactive.sidekit.apps.spotify.data.SpotifyGatewayException +import hrln.interactive.sidekit.apps.spotify.playback.SpotifyPlayback +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class SpotifyPlaybackTest { + @Test fun `start claims the session and loads the track`() = runTest { + val gateway = FakeGateway() + val registry = MediaSessionRegistry() + val playback = SpotifyPlayback(gateway, CoroutineScope(StandardTestDispatcher(testScheduler)), registry) + + playback.start("spotify:track:a", 0L) + + assertSame(playback, registry.active) + assertTrue(playback.isActive) + assertEquals("spotify:track:a", gateway.loadedUri) + assertTrue(gateway.autoplay) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `starting another owner pauses Sideplay`() = runTest { + val gateway = FakeGateway() + val registry = MediaSessionRegistry() + val playback = SpotifyPlayback(gateway, CoroutineScope(StandardTestDispatcher(testScheduler)), registry) + val other = FakeOwner() + registry.register(other) + playback.start("spotify:track:a", 0L) + + registry.activate(other) + runCurrent() + + assertFalse(playback.isActive) + assertTrue(gateway.paused) + assertSame(other, registry.active) + } + + @Test fun `failed load releases the session`() = runTest { + val gateway = FakeGateway(failLoad = true) + val registry = MediaSessionRegistry() + val playback = SpotifyPlayback(gateway, CoroutineScope(StandardTestDispatcher(testScheduler)), registry) + + runCatching { playback.start("spotify:track:a", 0L) } + + assertFalse(playback.isActive) + assertNull(registry.active) + } + + @Test fun `stop deactivates and tells the gateway`() = runTest { + val gateway = FakeGateway() + val registry = MediaSessionRegistry() + val playback = SpotifyPlayback(gateway, CoroutineScope(StandardTestDispatcher(testScheduler)), registry) + var stopped = false + playback.onStopped = { stopped = true } + playback.start("spotify:track:a", 0L) + + playback.stop() + + assertFalse(playback.isActive) + assertNull(registry.active) + assertTrue(gateway.stopped) + assertTrue(stopped) + } + + @Test fun `requestNext prefers the attached UI owner`() { + val playback = SpotifyPlayback( + FakeGateway(), + CoroutineScope(StandardTestDispatcher()), + MediaSessionRegistry() + ) + var uiCalls = 0 + var fallbackCalls = 0 + playback.onNext = { uiCalls++ } + playback.fallbackNext = { fallbackCalls++ } + + playback.requestNext() + + assertEquals(1, uiCalls) + assertEquals(0, fallbackCalls) + } + + @Test fun `requestNext falls back when the UI owner is gone`() { + val playback = SpotifyPlayback( + FakeGateway(), + CoroutineScope(StandardTestDispatcher()), + MediaSessionRegistry() + ) + var fallbackCalls = 0 + playback.fallbackNext = { fallbackCalls++ } + + playback.requestNext() + + assertEquals(1, fallbackCalls) + } + + private class FakeGateway(private val failLoad: Boolean = false) : SpotifyGateway { + var loadedUri: String? = null + var autoplay = false + var paused = false + var stopped = false + + override suspend fun connect(accessToken: String) = Unit + override suspend fun disconnect() = Unit + + override suspend fun load(uri: String, positionMs: Long, autoplay: Boolean) { + if (failLoad) throw SpotifyGatewayException(SpotifyFailure.TrackUnavailable, "missing") + loadedUri = uri + this.autoplay = autoplay + } + + override suspend fun play() = Unit + override suspend fun pause() { + paused = true + } + + override suspend fun stop() { + stopped = true + } + + override suspend fun seek(positionMs: Long) = Unit + } + + private class FakeOwner : MediaSessionOwner { + override val sessionId = "other" + override val notificationChannelId = "other" + override val notificationChannelName = "Other" + override val notificationId = 42 + override var isActive = true + private set + + override fun pause() { + isActive = false + } + } +} diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyQueueRepositoryTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyQueueRepositoryTest.kt new file mode 100644 index 00000000..bfa7f7e5 --- /dev/null +++ b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyQueueRepositoryTest.kt @@ -0,0 +1,208 @@ +package hrln.interactive.sidekit.apps.spotify + +import hrln.interactive.sidekit.apps.spotify.data.SpotifyQueueRepository +import kotlin.random.Random +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SpotifyQueueRepositoryTest { + private val tracks = listOf( + track("a"), + track("b"), + track("c"), + track("d") + ) + + @Test fun `play from a row replaces the queue and records the previous item`() { + val queue = SpotifyQueueRepository(Random(1)) + queue.play(tracks, 0) + queue.play(tracks, 2) + + assertEquals("c", queue.snapshot.current?.id) + assertEquals(listOf("d"), queue.snapshot.upcoming.map { it.id }) + assertEquals(listOf("a"), queue.snapshot.history.map { it.id }) + } + + @Test fun `replaying the current item does not duplicate history`() { + val queue = SpotifyQueueRepository(Random(1)) + queue.play(tracks, 1) + queue.play(tracks, 1) + + assertEquals("b", queue.snapshot.current?.id) + assertTrue(queue.snapshot.history.isEmpty()) + } + + @Test fun `play next moves an existing upcoming item instead of duplicating it`() { + val queue = SpotifyQueueRepository(Random(1)) + queue.play(tracks, 0) + queue.playNext(tracks[2]) + + assertEquals(listOf("c", "b", "d"), queue.snapshot.upcoming.map { it.id }) + } + + @Test fun `restore adopts a process-wide snapshot`() { + val queue = SpotifyQueueRepository(Random(1)) + val snapshot = queue.play(tracks, 1) + + val restored = SpotifyQueueRepository(Random(2)).restore(snapshot) + + assertEquals("b", restored.current?.id) + assertEquals(listOf("c", "d"), restored.upcoming.map { it.id }) + } + + @Test fun `add to queue appends`() { + val queue = SpotifyQueueRepository(Random(1)) + queue.play(tracks, 0) + queue.addToQueue(track("z")) + + assertEquals(listOf("b", "c", "d", "z"), queue.snapshot.upcoming.map { it.id }) + } + + @Test fun `adding several items appends them in order`() { + val queue = SpotifyQueueRepository(Random(1)) + queue.play(tracks, 0) + queue.addToQueue(listOf(track("x"), track("y"))) + + assertEquals(listOf("b", "c", "d", "x", "y"), queue.snapshot.upcoming.map { it.id }) + } + + @Test fun `next moves the current item into history`() { + val queue = SpotifyQueueRepository(Random(1)) + queue.play(tracks, 0) + + queue.next(auto = false) + + assertEquals("b", queue.snapshot.current?.id) + assertEquals(listOf("a"), queue.snapshot.history.map { it.id }) + assertEquals(listOf("c", "d"), queue.snapshot.upcoming.map { it.id }) + } + + @Test fun `previous restarts inside the threshold and rewinds outside it`() { + val queue = SpotifyQueueRepository(Random(1)) + queue.play(tracks, 0) + queue.next(auto = false) + assertEquals("b", queue.snapshot.current?.id) + + queue.previous(4_000L) + assertEquals("b", queue.snapshot.current?.id) + + queue.previous(1_000L) + assertEquals("a", queue.snapshot.current?.id) + assertEquals(listOf("b", "c", "d"), queue.snapshot.upcoming.map { it.id }) + assertTrue(queue.snapshot.history.isEmpty()) + } + + @Test fun `shuffle only reorders upcoming and restores it when switched off`() { + val queue = SpotifyQueueRepository(Random(7)) + queue.play(tracks, 0) + val original = queue.snapshot.upcoming.map { it.id } + + queue.toggleShuffle() + assertTrue(queue.snapshot.shuffle) + assertEquals("a", queue.snapshot.current?.id) + assertEquals(original.toSet(), queue.snapshot.upcoming.map { it.id }.toSet()) + + queue.toggleShuffle() + assertEquals(original, queue.snapshot.upcoming.map { it.id }) + } + + @Test fun `adopting the same shuffle state does not reorder the tail`() { + val queue = SpotifyQueueRepository(Random(7)) + queue.play(tracks, 0) + + queue.setShuffle(true) + val shuffled = queue.snapshot.upcoming.map { it.id } + queue.setShuffle(true) + + assertEquals(shuffled, queue.snapshot.upcoming.map { it.id }) + } + + @Test fun `adopting shuffle off restores the original tail`() { + val queue = SpotifyQueueRepository(Random(7)) + queue.play(tracks, 0) + val original = queue.snapshot.upcoming.map { it.id } + + queue.setShuffle(true) + queue.setShuffle(false) + + assertEquals(original, queue.snapshot.upcoming.map { it.id }) + } + + @Test fun `repeat off stops at the end of the queue`() { + val queue = SpotifyQueueRepository(Random(1)) + queue.play(tracks.take(2), 0) + + queue.next(auto = true) + queue.next(auto = true) + + assertNull(queue.snapshot.current) + } + + @Test fun `repeat context replays the collection when the queue runs out`() { + val queue = SpotifyQueueRepository(Random(1)) + queue.play(tracks.take(2), 0) + queue.setRepeat(RepeatMode.Context) + + queue.next(auto = true) + queue.next(auto = true) + + assertEquals("a", queue.snapshot.current?.id) + assertEquals(listOf("b"), queue.snapshot.upcoming.map { it.id }) + } + + @Test fun `repeat track leaves the current item in place`() { + val queue = SpotifyQueueRepository(Random(1)) + queue.play(tracks, 0) + queue.setRepeat(RepeatMode.Track) + + queue.next(auto = true) + + assertEquals("a", queue.snapshot.current?.id) + assertEquals(listOf("b", "c", "d"), queue.snapshot.upcoming.map { it.id }) + } + + @Test fun `jump to an upcoming item moves the skipped items into history`() { + val queue = SpotifyQueueRepository(Random(1)) + queue.play(tracks, 0) + + queue.jumpTo("c") + + assertEquals("c", queue.snapshot.current?.id) + assertEquals(listOf("a", "b"), queue.snapshot.history.map { it.id }) + assertEquals(listOf("d"), queue.snapshot.upcoming.map { it.id }) + } + + @Test fun `removing history only drops the chosen item`() { + val queue = SpotifyQueueRepository(Random(1)) + queue.play(tracks, 0) + queue.next(auto = false) + queue.next(auto = false) + + queue.removeHistory(track("a")) + + assertEquals(listOf("b"), queue.snapshot.history.map { it.id }) + } + + @Test fun `clearing upcoming can be undone`() { + val queue = SpotifyQueueRepository(Random(1)) + queue.play(tracks, 0) + + val removed = queue.clearUpcoming() + assertTrue(queue.snapshot.upcoming.isEmpty()) + + queue.restoreUpcoming(removed) + assertEquals(listOf("b", "c", "d"), queue.snapshot.upcoming.map { it.id }) + } + + private fun track(id: String) = SpotifyTrack( + id = id, + title = "Track $id", + artist = "Artist", + artistId = "artist", + albumId = "album", + artRes = 0, + durationMs = 180_000L + ) +} diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyWebApiTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyWebApiTest.kt new file mode 100644 index 00000000..0673072f --- /dev/null +++ b/app/src/test/java/hrln/interactive/sidekit/apps/spotify/SpotifyWebApiTest.kt @@ -0,0 +1,65 @@ +package hrln.interactive.sidekit.apps.spotify + +import hrln.interactive.sidekit.apps.spotify.data.SpotifyWebApi +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SpotifyWebApiTest { + private val api = SpotifyWebApi { null } + + @Test + fun `maps a track search result to the player shape`() { + val track = api.parseSearchTracks(JSONObject(RESULT)).single() + + assertEquals("4uLU6hMCjMI75M1A2tKUQC", track.id) + assertEquals("Never Gonna Give You Up", track.title) + assertEquals("Rick Astley", track.artist) + assertEquals("0gxyHStUsqpMadRV0Di1Qt", track.artistId) + assertEquals("6XhjNHCyCDyyGJRM5mg40G", track.albumId) + assertEquals(213_573L, track.durationMs) + assertFalse(track.explicit) + assertEquals("spotify:track:4uLU6hMCjMI75M1A2tKUQC", track.uri) + assertTrue(track.playable) + } + + @Test + fun `an empty envelope yields no rows`() { + assertTrue(api.parseSearchTracks(JSONObject("""{"tracks":{"items":[]}}""")).isEmpty()) + assertTrue(api.parseSearchTracks(JSONObject("{}")).isEmpty()) + } + + @Test + fun `a row without an id is skipped`() { + val json = JSONObject( + """{"tracks":{"items":[{"name":"No id"},{"id":"abc","name":"Kept","uri":"spotify:track:abc"}]}}""" + ) + + assertEquals(listOf("abc"), api.parseSearchTracks(json).map { it.id }) + } + + private companion object { + const val RESULT = """ + { + "tracks": { + "items": [ + { + "id": "4uLU6hMCjMI75M1A2tKUQC", + "name": "Never Gonna Give You Up", + "duration_ms": 213573, + "explicit": false, + "uri": "spotify:track:4uLU6hMCjMI75M1A2tKUQC", + "artists": [{"id": "0gxyHStUsqpMadRV0Di1Qt", "name": "Rick Astley"}], + "album": {"id": "6XhjNHCyCDyyGJRM5mg40G"} + } + ] + } + } + """ + } +} diff --git a/app/src/test/java/hrln/interactive/sidekit/system/NativeLibrariesTest.kt b/app/src/test/java/hrln/interactive/sidekit/system/NativeLibrariesTest.kt new file mode 100644 index 00000000..3bac4786 --- /dev/null +++ b/app/src/test/java/hrln/interactive/sidekit/system/NativeLibrariesTest.kt @@ -0,0 +1,18 @@ +package hrln.interactive.sidekit.system + +import org.junit.Assert.assertFalse +import org.junit.Test + +class NativeLibrariesTest { + @Test + fun `a missing library reports unavailable instead of throwing`() { + assertFalse(NativeLibraries.load("sidekit_no_such_library")) + assertFalse(NativeLibraries.isAvailable("sidekit_no_such_library")) + } + + @Test + fun `repeated loads of a missing library stay false`() { + assertFalse(NativeLibraries.load("sidekit_no_such_library")) + assertFalse(NativeLibraries.load("sidekit_no_such_library")) + } +} diff --git a/app/src/test/java/hrln/interactive/sidekit/ui/KeypadKeysTest.kt b/app/src/test/java/hrln/interactive/sidekit/ui/KeypadKeysTest.kt index 09438b1e..242f18be 100644 --- a/app/src/test/java/hrln/interactive/sidekit/ui/KeypadKeysTest.kt +++ b/app/src/test/java/hrln/interactive/sidekit/ui/KeypadKeysTest.kt @@ -1,10 +1,12 @@ package hrln.interactive.sidekit.ui import android.view.KeyEvent +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasContentDescription -import androidx.compose.ui.test.onLast +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onLast import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -16,15 +18,25 @@ import org.junit.Test import org.junit.runner.RunWith /** The keypad's top keys: top-right (ENDCALL) is Options, top-left (CALL) is Back. */ +@OptIn(ExperimentalTestApi::class) @RunWith(AndroidJUnit4::class) class KeypadKeysTest { - @get:Rule(order = 0) val completedSetup = object : org.junit.rules.ExternalResource() { + @get:Rule(order = 0) + val completedSetup = object : org.junit.rules.ExternalResource() { override fun before() { val context = androidx.test.core.app.ApplicationProvider.getApplicationContext() - hrln.interactive.sidekit.apps.settings.SidekitPreferences(context).setupComplete = true + hrln.interactive.sidekit.apps.settings.SidekitPreferences(context).apply { + setupComplete = true + // Earlier tests may persist launcher state; these assertions use grid tiles. + listLayout = false + hiddenApps = emptySet() + } + hrln.interactive.sidekit.home.HomePreferences(context).reset() } } - @get:Rule(order = 1) val compose = createAndroidComposeRule() + + @get:Rule(order = 1) + val compose = createAndroidComposeRule() private fun press(code: Int): Boolean { var handled = false @@ -62,7 +74,9 @@ class KeypadKeysTest { @Test fun screensWithoutOptionsSaySo() { // The dial pad has no options until a number is entered. - compose.onAllNodes(hasContentDescription("Phonebook")).onLast().performClick() + val phonebook = hasContentDescription("Phonebook") or hasText("Phonebook") + compose.waitUntilAtLeastOneExists(phonebook, 15_000) + compose.onAllNodes(phonebook).onLast().performClick() compose.onNodeWithContentDescription("Dial pad").performClick() press(KeyEvent.KEYCODE_MENU) compose.onNodeWithText("No options here").assertIsDisplayed() @@ -75,13 +89,15 @@ class KeypadKeysTest { val keypad = hrln.interactive.sidekit.design.input.Keypad.forTesting( kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined), hrln.interactive.sidekit.design.input.T9Words.Empty, - mapper = hrln.interactive.sidekit.design.input.KeyMapper.create( - compose.activity.getSharedPreferences("sidekit_keypad", android.content.Context.MODE_PRIVATE)), + mapper = hrln.interactive.sidekit.design.input.KeyMapper.create(compose.activity.getSharedPreferences("sidekit_keypad", android.content.Context.MODE_PRIVATE)) ) keypad.useCustomMapping(hrln.interactive.sidekit.design.input.KeypadModule.T9) - keypad.mapper.bind(hrln.interactive.sidekit.design.input.KeypadModule.T9, KeyEvent.KEYCODE_ENDCALL, + keypad.mapper.bind( + hrln.interactive.sidekit.design.input.KeypadModule.T9, + KeyEvent.KEYCODE_ENDCALL, // Test events come from the virtual keyboard device. - hrln.interactive.sidekit.design.input.PhysicalKey("Virtual", KeyEvent.KEYCODE_F1)) + hrln.interactive.sidekit.design.input.PhysicalKey("Virtual", KeyEvent.KEYCODE_F1) + ) } assertTrue(press(KeyEvent.KEYCODE_F1)) compose.onNodeWithContentDescription("Close options").assertIsDisplayed() diff --git a/app/src/test/java/hrln/interactive/sidekit/ui/UndoWindowTest.kt b/app/src/test/java/hrln/interactive/sidekit/ui/UndoWindowTest.kt new file mode 100644 index 00000000..051b775c --- /dev/null +++ b/app/src/test/java/hrln/interactive/sidekit/ui/UndoWindowTest.kt @@ -0,0 +1,100 @@ +package hrln.interactive.sidekit.ui + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class UndoWindowTest { + @Test + fun `expires after five seconds of visible time`() = runTest { + var expired = 0 + val window = UndoWindow(this, elapsed = { testScheduler.currentTime }) { expired++ } + window.start() + assertTrue(window.isPending) + advanceTimeBy(4_999) + assertEquals(0, expired) + advanceTimeBy(1) + runCurrent() + assertEquals(1, expired) + assertFalse(window.isPending) + } + + @Test + fun `pausing stops the clock and resuming spends only the time that was left`() = runTest { + var expired = 0 + val window = UndoWindow(this, elapsed = { testScheduler.currentTime }) { expired++ } + window.start() + advanceTimeBy(3_000) + window.pause() + advanceTimeBy(60_000) + assertEquals(0, expired) + assertTrue(window.isPending) + window.resume() + advanceTimeBy(1_999) + assertEquals(0, expired) + advanceTimeBy(1) + runCurrent() + assertEquals(1, expired) + } + + @Test + fun `every pause resumes with the same remaining time`() = runTest { + var expired = 0 + val window = UndoWindow(this, elapsed = { testScheduler.currentTime }) { expired++ } + window.start() + advanceTimeBy(2_000) + window.pause() + window.resume() + advanceTimeBy(1_000) + window.pause() + advanceTimeBy(10_000) + window.resume() + advanceTimeBy(1_999) + assertEquals(0, expired) + advanceTimeBy(1) + runCurrent() + assertEquals(1, expired) + } + + @Test + fun `cancelling drops the offer without expiring it`() = runTest { + var expired = 0 + val window = UndoWindow(this, elapsed = { testScheduler.currentTime }) { expired++ } + window.start() + advanceTimeBy(1_000) + window.cancel() + advanceTimeBy(10_000) + assertEquals(0, expired) + assertFalse(window.isPending) + } + + @Test + fun `a second deletion replaces the first`() = runTest { + var expired = 0 + val window = UndoWindow(this, elapsed = { testScheduler.currentTime }) { expired++ } + window.start() + advanceTimeBy(3_000) + window.start() + advanceTimeBy(3_000) + assertEquals(0, expired) + advanceTimeBy(2_000) + runCurrent() + assertEquals(1, expired) + } + + @Test + fun `resume without an offer does nothing`() = runTest { + var expired = 0 + val window = UndoWindow(this, elapsed = { testScheduler.currentTime }) { expired++ } + window.resume() + advanceTimeBy(10_000) + assertEquals(0, expired) + assertFalse(window.isPending) + } +} diff --git a/docs/spotify/SIDEPLAY.md b/docs/spotify/SIDEPLAY.md new file mode 100644 index 00000000..22dec2a7 --- /dev/null +++ b/docs/spotify/SIDEPLAY.md @@ -0,0 +1,207 @@ +# Sideplay + +**Updated:** 2026-11-09 +**Branch:** `integration` +**Phase:** PR review; P0 device verification and P1 parity decisions remain + +Sideplay is Sidekit's Spotify sub-app: one account's library, search, playback, and queue, +drawn with Sidekit's chrome and operated keypad-first on a 480 × 640 phone. It is not a Spotify +client reproduction and not a web wrapper. + +The implementation has moved past the porting/mockup stage. This document is the single source +of truth for what exists, what must not regress, and what happens next. Older clean-room specs +and status logs were folded into it; the full pre-consolidation text remains in Git history. + +--- + +## 1. Guardrails + +These are constraints, not preferences. A change that violates them is wrong even if it matches +Spotify. + +- **Chrome belongs to the shell.** `SidekitScaffold` draws title, body, and action bar. No app + bottom bar, FAB, tabs, mini-player, sheets, dialogs, toasts, or snackbars. +- **Action bar grammar.** Left = continuation (primary action, else Back), right = next/undo. + Secondary actions live in keypad Options. Destructive actions swap the bar for confirmation. +- **The body is the control surface.** Play, save, and transport are tiles/rows; the bar navigates. +- **One column, explicit pagination.** No carousels, infinite scroll, or algorithmic shelves. + Long lists end with a "Load more" row. +- **Keypad first.** Every interactive row is focusable, has a visible focus ring, and a 48 dp + minimum target. Do not fade the focused element. +- **Content never wears the theme colour.** Selection, progress, and saved state use emphasis; + the case colour belongs to primary actions. +- **No new dependencies** for images, DI, persistence, or media. Reuse `RemoteArtwork`, + `PrivateSettings`, `NativeLibraries`, `MediaSessions`, plain repositories, and manual wiring. +- **No new icons** unless design decides; v1 needed none. +- **Explicit non-goal:** Spotify feature parity. Sidekit parity is the bar. + +Rejected on purpose: algorithmic home feed, horizontal shelves, persistent mini-player, bottom +navigation, full-screen drag-dismiss player, queue bottom sheet, Spotify Connect device switching, +lyrics/Canvas/video, social features, account management, crossfade/EQ, and track-seeded radio. + +--- + +## 2. Shipped + +**Architecture** + +- Playback: `SpotifySession` → `NativeSpotifyGateway` → `native/sidekit-spotify` (librespot 0.8.0) + → `SpotifyAudioSink` (streaming `AudioTrack`). +- Catalog: `NativeSpotifyCatalog` reads through the authenticated librespot session + (SpClient/Pathfinder). `SpotifyWebCatalog` remains a fallback when native is unavailable. +- UI: `SpotifyViewModel` maps catalog responses, queue/history repositories, and native player + events into one immutable `SpotifyUiState`. +- Screens: Home, Library, Collection (album/playlist/show), Artist, Search, Now Playing, Queue, + Settings, Sign in. Mock data is confined to the screenshot-test source set. + +**Behaviour** + +- Library, collection, artist, and grouped search reads use first-party endpoints, avoiding the + public Web API quota and the old HTTP 429 failures. +- Native events (`loading`, `playing`, `paused`, `position`, `ended`, `unavailable`) drive Now + Playing and advance the local queue exactly once. The one-second simulation runs only when + native is absent. +- Library write-back is optimistic with rollback: like/unlike, save/remove album and show, + follow/unfollow artist, add/remove playlist. Saves and follows offer Undo for five visible + seconds; queue actions are silent. +- Playback is process-wide. Queue and local history live on `SpotifySession`; a recreated + ViewModel adopts the live item, position, and playing state. Starting a new item releases the + previous `AudioTrack`. +- Queue, queue history, shuffle order, repeat mode, and recently played persist across process + death in atomic binary files (`sidetracks-queue.bin`, `sidetracks-history.bin`; legacy names + kept so existing dev installs keep their data). Playback + itself does not resume on launch. +- Foreground `mediaPlayback` service and platform `MediaSession` keep librespot alive with the + screen off. Notification shows artwork, title, artist, and previous/play-pause/next. When the + ViewModel is gone, next/previous fall back to advancing the process-wide queue directly. + Tapping the notification opens Sideplay directly at Now Playing rather than the launcher. + A native `stopped` event clears Now Playing and the queue so notification/lock-screen stop + cannot leave stale UI or restore the stopped track. +- Sideplay and local Music pause each other through `MediaSessions`; Sideplay takes and + abandons `AUDIOFOCUS_GAIN`, including transient loss and headphone changes. +- OAuth tokens rotate into an existing native session. Every play and resume revalidates that + session: librespot marks it invalid when the access-point connection or its keep-alive dies, + so an invalid session is rebuilt from the stored token and the current item reloads at its + position. A connection attempt is bounded (20 s native, 25 s Kotlin) so a stalled handshake + reports an error instead of leaving the play button hanging. Tearing down the old session runs + off the native state lock, because `Session::shutdown` can block behind a stalled network task; + a shutdown that cannot complete leaks one thread instead of wedging playback. Sign-out stops + the decoder synchronously, releases the media session, removes the notification, then clears + credentials. +- Audio quality persists encrypted across process death and maps to 96/160/320 kbps. Playback + volume is the device output volume; there is no separate attenuation. +- Artwork is memory-safe on device: 300 px URL preference, `RGB_565` + `inSampleSize`, 1024 px + cap, `OutOfMemoryError` treated as a cache miss. +- Home's action bar opens the queue; track rows across the app expose Add to queue on left swipe. + Upcoming and History rows in the queue swipe left to remove; removing queue history also drops + the item from recently played. Library uses the standard single-line header; Settings has one + sign-out action in the bar. +- Catalog lists page in 50-row chunks. Library, collection, artist, and search surfaces load the + next page as their footer becomes visible, so a 202-track playlist reaches its last track without + composing every row at once. + +**Verified** + +- `spotlessCheck`, `:app:lintDebug`, `:app:testDebugUnitTest`, `:app:assembleDebug` pass. +- APK contains `arm64-v8a` and `x86_64` `libsidekit_spotify.so`. +- On `SP01GE260600886` (480 × 640): libraries load real data, search returns Pathfinder tracks, + playback advances/pauses correctly, show episodes format dates, and artwork no longer crashes. +- On `SP01GE260600886`: instrumented tests cover the foreground-service notification and actions, + media-session metadata/state, media-owner hand-off, and the headphone-unplug pause path. +- On `SP01GE260600886`: dropping the network long enough to close the librespot access point + leaves libraries browsable (HTTP catalog) but makes playback fail until the session is rebuilt. + After the fix, the next play reconnects (`Connecting to AP` / `Authenticated as …`) and loads + the track instead of reporting every item as unavailable. +- JVM tests cover catalog page offsets, collection totals, and page boundaries. + +--- + +## 3. Known limitations + +1. **Recently played is local-only.** There is no safe first-party history endpoint, and the + Web API quota is not worth spending on a non-critical list. Home shows Sideplay's local + history; it is not cross-device. +2. **Sideplay owns queue, shuffle, and repeat.** librespot 0.8 plays one item at a time and has + no shuffle/repeat setters. `SpotifyQueueRepository` is authoritative; native shuffle/repeat + events are adopted when another client changes them. Queue, queue history, and recently + played persist; playback does not resume automatically after process death. +3. **Playlist owner names.** Your own playlists show **You**; other users' playlists show their + Spotify username. Display-name lookup is not worth a per-owner Web API call. + +--- + +## 4. What's next + +Ordered by user-visible impact. Each item names its seam so it does not become another +cross-cutting refactor. + +### P0 — device verification for daily use + +- Verify token rotation on a session older than one hour; record the result in the device + checklist. +- Verify library write-back on device: like/unlike, save/remove album and show, follow/unfollow + artist, add/remove playlist; confirm in another Spotify client and after restart. +- Run the existing device checklist for lock screen, notification transport, audio-focus hand-off + with local Music, and headphone unplug. + +### P1 — parity decisions (settled) + +1. **Recently played: local-only.** No first-party endpoint, no Web API quota. The local history + is the product feature; cross-device history is a later opt-in, not a merge blocker. +2. **Queue/shuffle/repeat: Sideplay owns them.** librespot 0.8 has no setters, so mirroring is + not possible. `SpotifyQueueRepository` is the single authority; native shuffle/repeat events + are adopted when another client changes them. Now Playing follows the queue, and the native + player follows the item Sideplay starts. +3. **Playlist owner names: authenticated identity, no per-owner lookup.** Your playlists show + **You**; other users show their Spotify username. This avoids a Web API call per collection. +4. **Remaining test coverage.** + - `NativeSpotifyCatalog` JSON parsing and error mapping (auth, network, malformed response). + - `NativeSpotifyCatalog`/`SpotifyViewModel` event state machine, including `stopped`. + - Device smoke checklist for sign-in, library filters, search, playback, queue, lock screen, + and token expiry. + - Acceptance: JVM tests run in CI; device checklist stays manual. + +### P2 — polish + +- Clear artwork and credential caches on sign-out; expose cache size in Settings. +- Accessibility/focus review for long playlist/show collections and the Library Options filter. +- APK-size review once the vendored librespot patch can be dropped. + +--- + +## 5. Native build, provenance, and licensing + +**Native bridge.** `native/sidekit-spotify` links librespot 0.8.0 and exposes the JNI surface +declared in `apps/spotify/bridge/NativeBridge.kt`. + +- Toolchain: Rust stable, `cargo-ndk` 4.1.2, NDK 27.1.12297006, targets + `aarch64-linux-android` and `x86_64-linux-android`. +- Gradle builds it with `:app:buildSpotifyNative` as part of `assembleDebug`/`assembleRelease`. + If Rust or the NDK is missing, the task is skipped, the APK has no library, and + `NativeLibraries.load("sidekit_spotify")` returns false. +- `Cargo.lock` pins the full graph. `vergen` is held at 9.0.6; do not update it without rebuilding + the Android targets. +- Playback temp files use Android `cacheDir` (`NativeBridge.setTmpDir`). + +**librespot patch.** One minimal in-tree patch is applied via +`native/vendor/librespot-core` and `[patch.crates-io]`: + +- `src/config.rs`: `OS = "linux"` instead of `std::env::consts::OS`. +- Reason: the stored OAuth credential comes from Spotify's desktop/keymaster client id. Login5 + and client-token requests bind to the platform-implied client id; on Android librespot + otherwise reports `INVALID_CREDENTIALS`/`BAD_REQUEST`. +- Scope: one constant. No protocol, playback, audio, or auth logic changes. +- Removal: delete the patch once librespot supports overriding platform/client identity, or once + Sideplay performs its own Android-client OAuth. +- Verify with `diff -rq ~/.cargo/registry/src/index.crates.io-*/librespot-core-0.8.0 + native/vendor/librespot-core`; only `src/config.rs` (plus registry bookkeeping) should differ. + +**Licensing.** Sidekit and librespot are MIT; librespot's notice and `Cargo.lock` are retained. +Sidespot is GPLv3 and remains reference-only at the untracked `.sidespot/` path: never committed, +linked, vendored, or used as a build dependency. No Sidespot source, identifiers, comments, +assets, or documentation enter Sidekit. + +**Clean-room boundary.** The implementation was written from Sidekit's design system, public +Spotify/librespot documentation, and observable behaviour. Before merge, run the similarity +review: no `sidespot` identifiers in committed code, no copied strings/assets/symbols, and no +structure that only matches the reference implementation. Record the reviewer and date here. diff --git a/native/sidekit-spotify/.cargo/config.toml b/native/sidekit-spotify/.cargo/config.toml new file mode 100644 index 00000000..e32c5929 --- /dev/null +++ b/native/sidekit-spotify/.cargo/config.toml @@ -0,0 +1,6 @@ +# Android 15+ devices require 16 KB-aligned native library segments. +[target.aarch64-linux-android] +rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] + +[target.x86_64-linux-android] +rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] diff --git a/native/sidekit-spotify/Cargo.lock b/native/sidekit-spotify/Cargo.lock new file mode 100644 index 00000000..54cedd38 --- /dev/null +++ b/native/sidekit-spotify/Cargo.lock @@ -0,0 +1,3555 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b07e8e73d720a1f2e4b6014766e6039fd2e96a4fa44e2a78d0e1fa2ff49826" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "encoding_rs" +version = "0.8.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "futures-sink", + "futures-timer", + "futures-util", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "smallvec", + "spinning_top", + "web-time", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "headers" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc2700e3fe555c3310aa7286cac6167449f2c87e0eb58769c9208a1c58a1d106" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-proxy2" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9043b7b23fb0bc4a1c7014c27b50a4fc42cc76206f71d34fc0dfe5b28ddc3faf" +dependencies = [ + "bytes", + "futures-util", + "headers", + "http", + "hyper", + "hyper-rustls 0.26.0", + "hyper-util", + "pin-project-lite", + "tokio", + "tokio-rustls 0.25.0", + "tower-service", + "webpki", + "webpki-roots 0.26.11", +] + +[[package]] +name = "hyper-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c" +dependencies = [ + "futures-util", + "http", + "hyper", + "hyper-util", + "log", + "rustls 0.22.4", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.25.0", + "tower-service", + "webpki-roots 0.26.11", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls 0.23.46", + "tokio", + "tokio-rustls 0.26.6", + "tower-service", + "webpki-roots 1.0.11", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "librespot-audio" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3fe76acb49f58165484303edf0e7bd778f0e6d96f5c59e9d6b6fde1a90d36ff" +dependencies = [ + "aes", + "bytes", + "ctr", + "futures-util", + "http-body-util", + "hyper", + "hyper-util", + "librespot-core", + "log", + "tempfile", + "thiserror 2.0.21", + "tokio", +] + +[[package]] +name = "librespot-core" +version = "0.8.0" +dependencies = [ + "aes", + "base64 0.22.1", + "byteorder", + "bytes", + "data-encoding", + "flate2", + "form_urlencoded", + "futures-core", + "futures-util", + "governor", + "hmac", + "http", + "http-body-util", + "httparse", + "hyper", + "hyper-proxy2", + "hyper-rustls 0.27.10", + "hyper-util", + "librespot-oauth", + "librespot-protocol", + "log", + "nonzero_ext", + "num-bigint", + "num-derive", + "num-integer", + "num-traits", + "pbkdf2", + "pin-project-lite", + "priority-queue", + "protobuf", + "protobuf-json-mapping", + "quick-xml", + "rand 0.9.5", + "rand_distr", + "rsa", + "serde", + "serde_json", + "sha1", + "shannon", + "sysinfo", + "thiserror 2.0.21", + "time", + "tokio", + "tokio-stream", + "tokio-tungstenite", + "tokio-util", + "url", + "uuid", + "vergen-gitcl", +] + +[[package]] +name = "librespot-metadata" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a9c688aa2acd3ed2498e31a95d6f2be49c0f18128db8958450ffd628aa88532" +dependencies = [ + "async-trait", + "bytes", + "librespot-core", + "librespot-protocol", + "log", + "protobuf", + "serde", + "serde_json", + "thiserror 2.0.21", + "uuid", +] + +[[package]] +name = "librespot-oauth" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d686417d49c9d2c363392ffe28d6e469daca20a82dc414740930e078f5829661" +dependencies = [ + "log", + "oauth2", + "open", + "reqwest", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "librespot-playback" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88258620bf3e6808ea1fadd11639648d77c06280b9f5a4c9d14ea79f6f998af6" +dependencies = [ + "form_urlencoded", + "futures-util", + "librespot-audio", + "librespot-core", + "librespot-metadata", + "log", + "portable-atomic", + "rand 0.9.5", + "rand_distr", + "shell-words", + "symphonia", + "thiserror 2.0.21", + "tokio", + "zerocopy", +] + +[[package]] +name = "librespot-protocol" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e01f0b2d39f83fa162eb91d4a16313bcf99e77daf258abe8f7b7bcb1160b084" +dependencies = [ + "protobuf", + "protobuf-codegen", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5848f5a9e8117cf94f2f90faac24a87ff81600ff8628c1e3deaab572e76bfa1" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_threads" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" +dependencies = [ + "libc", +] + +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.8", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "priority-queue" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96" +dependencies = [ + "equivalent", + "indexmap", + "serde", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-codegen" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d3976825c0014bbd2f3b34f0001876604fe87e0c86cd8fa54251530f1544ace" +dependencies = [ + "anyhow", + "once_cell", + "protobuf", + "protobuf-parse", + "regex", + "tempfile", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-json-mapping" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0d6e4be637b310d8a5c02fa195243328e2d97fa7df1127a27281ef1187fcb1d" +dependencies = [ + "protobuf", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-parse" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4aeaa1f2460f1d348eeaeed86aea999ce98c1bded6f089ff8514c9d9dbdc973" +dependencies = [ + "anyhow", + "indexmap", + "log", + "protobuf", + "protobuf-support", + "tempfile", + "thiserror 1.0.69", + "which", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.46", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls 0.23.46", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_distr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +dependencies = [ + "num-traits", + "rand 0.9.5", +] + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls 0.27.10", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.46", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.6", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.11", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.16", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shannon" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ea5b41c9427b56caa7b808cb548a04fb50bb5b9e98590b53f28064ff4174561" +dependencies = [ + "byteorder", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "sidekit-spotify" +version = "0.1.0" +dependencies = [ + "android_logger", + "bytes", + "futures-util", + "http", + "jni", + "librespot-core", + "librespot-metadata", + "librespot-playback", + "librespot-protocol", + "log", + "protobuf", + "protobuf-json-mapping", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149309498a5f7334b12ddae0cad8aba81ce785fe91755068867e017cf98fee46" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spinning_top" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf02766a1eb909bc7653557c66b384e44edc3ad979e6257c6173c65ea51bc7d" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-ogg", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "sysinfo" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls 0.23.46", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls 0.23.46", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.6", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls 0.23.46", + "rustls-pki-types", + "sha1", + "thiserror 2.0.21", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "uuid" +version = "1.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a06f08a856b009bc7bbaf7d3e79808595d032dd19074c0c2ef26e49e03e6bdc" +dependencies = [ + "getrandom 0.4.3", +] + +[[package]] +name = "vergen" +version = "9.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b2bf58be11fc9414104c6d3a2e464163db5ef74b12296bda593cac37b6e4777" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen-lib", +] + +[[package]] +name = "vergen-gitcl" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9dfc1de6eb2e08a4ddf152f1b179529638bedc0ea95e6d667c014506377aefe" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib", +] + +[[package]] +name = "vergen-lib" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b07e6010c0f3e59fcb164e0163834597da68d1f864e2b8ca49f74de01e9c166" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.11", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/native/sidekit-spotify/Cargo.toml b/native/sidekit-spotify/Cargo.toml new file mode 100644 index 00000000..6733d1b5 --- /dev/null +++ b/native/sidekit-spotify/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "sidekit-spotify" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +name = "sidekit_spotify" +crate-type = ["cdylib"] + +[dependencies] +jni = "0.21" +librespot-core = { version = "0.8.0", default-features = false, features = ["rustls-tls-webpki-roots"] } +librespot-metadata = { version = "0.8.0", default-features = false, features = ["rustls-tls-webpki-roots"] } +librespot-playback = { version = "0.8.0", default-features = false, features = ["rustls-tls-webpki-roots"] } +librespot-protocol = "0.8.0" +protobuf = "3.7" +protobuf-json-mapping = "3.7" +bytes = "1" +futures-util = "0.3" +http = "1" +log = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] } + +[target.'cfg(target_os = "android")'.dependencies] +android_logger = "0.14" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" + +[patch.crates-io] +librespot-core = { path = "../vendor/librespot-core" } diff --git a/native/sidekit-spotify/LICENSE-librespot b/native/sidekit-spotify/LICENSE-librespot new file mode 100644 index 00000000..b06ecfb2 --- /dev/null +++ b/native/sidekit-spotify/LICENSE-librespot @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Paul Lietar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/native/sidekit-spotify/README.md b/native/sidekit-spotify/README.md new file mode 100644 index 00000000..35dbfb17 --- /dev/null +++ b/native/sidekit-spotify/README.md @@ -0,0 +1,40 @@ +# libsidekit_spotify + +The Sideplay native bridge. It links librespot 0.8.0 and exposes the JNI surface declared in +`apps/spotify/bridge/NativeBridge.kt`. + +## Toolchain + +- Rust stable. The first working build used 1.99.1. +- Android targets: `aarch64-linux-android`, `x86_64-linux-android`. +- `cargo-ndk` 4.1.2. +- Android NDK 27.1.12297006 (the version pinned by the app module). + +## Build + +Normally Gradle builds it as part of `:app:assembleDebug` / `:app:assembleRelease`: + +```sh +./gradlew :app:buildSpotifyNative +``` + +To run Cargo directly: + +```sh +export ANDROID_NDK_HOME="$HOME/Android/Sdk/ndk/27.1.12297006" +cargo ndk -t arm64-v8a -t x86_64 \ + -o ../../app/build/generated/spotifyJniLibs build --release +``` + +The output directory is a `jniLibs` source set. If Cargo or the NDK is unavailable, Gradle skips +the task and the APK has no library; `NativeLibraries.load("sidekit_spotify")` then returns false. + +## Dependency pins + +`Cargo.lock` pins librespot and the full graph. `vergen` is held at 9.0.6 because +`vergen-gitcl` 1.0.8 depends on `vergen-lib` 0.1.6 while `vergen` 9.1.0 pulled a second +incompatible `vergen-lib`. Do not update that crate without rebuilding Android targets. + +One minimal librespot-core patch is applied through `native/vendor/librespot-core` (platform +constant set to `"linux"` so Login5/client-token requests match the desktop client id). It is +documented in `docs/spotify/SIDEPLAY.md` §6. Upstream MIT licence: `LICENSE-librespot`. diff --git a/native/sidekit-spotify/src/audio.rs b/native/sidekit-spotify/src/audio.rs new file mode 100644 index 00000000..88fb9805 --- /dev/null +++ b/native/sidekit-spotify/src/audio.rs @@ -0,0 +1,67 @@ +//! PCM sink. Decoded S16LE stereo is queued for the Kotlin AudioTrack drainer. +//! +//! The bounded channel is deliberate: the consumer (AudioTrack) is the real-time clock. When +//! Kotlin stops draining, `send` blocks the librespot player thread, which is what keeps the +//! decode position tied to playback instead of racing through the track. Kotlin must keep +//! draining until the player has processed a pause/stop command; otherwise the player thread +//! cannot process that command. + +use librespot_playback::audio_backend::{Open, Sink, SinkResult}; +use librespot_playback::config::AudioFormat; +use librespot_playback::convert::Converter; +use librespot_playback::decoder::AudioPacket; +use std::sync::mpsc::SyncSender; + +pub const CHANNELS: u16 = 2; +pub const SAMPLE_RATE: u32 = 44_100; + +pub struct PcmSink { + tx: SyncSender>, + writes: u64, +} + +impl PcmSink { + pub fn new(tx: SyncSender>) -> Self { + Self { tx, writes: 0 } + } +} + +impl Open for PcmSink { + fn open(_device: Option, _format: AudioFormat) -> Self { + unreachable!("PcmSink is constructed with its channel") + } +} + +impl Sink for PcmSink { + fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> SinkResult<()> { + let pcm = match packet { + AudioPacket::Samples(samples) => { + let s16 = converter.f64_to_s16(&samples); + let mut bytes = Vec::with_capacity(s16.len() * 2); + for sample in s16 { + bytes.extend_from_slice(&sample.to_le_bytes()); + } + bytes + } + AudioPacket::Raw(bytes) => bytes, + }; + let bytes = pcm.len(); + if self.writes == 0 { + log::info!("pcm first write bytes={bytes}"); + } + // Blocking send is the pacing mechanism: the Kotlin drainer writes to AudioTrack with + // WRITE_BLOCKING, so the hardware clock controls how fast this channel is consumed. + // The player thread is blocked here only while the consumer is behind; it wakes as soon + // as one buffer is drained, which is also when it can process a queued pause/play/load. + match self.tx.send(pcm) { + Ok(()) => { + self.writes += 1; + if self.writes % 100 == 0 { + log::info!("pcm writes={} last_bytes={bytes}", self.writes); + } + } + Err(_) => log::warn!("pcm send failed: receiver disconnected"), + } + Ok(()) + } +} diff --git a/native/sidekit-spotify/src/catalog.rs b/native/sidekit-spotify/src/catalog.rs new file mode 100644 index 00000000..7713d436 --- /dev/null +++ b/native/sidekit-spotify/src/catalog.rs @@ -0,0 +1,1667 @@ +//! Catalog reads over librespot's authenticated session (spclient + pathfinder). +//! +//! The public Web API has a shared quota that is exhausted by ordinary desktop-client +//! credentials. The first-party endpoints used by the Spotify clients do not share that quota, so +//! Sideplay reads its library, search, artist and collection data through them instead. + +use std::collections::HashSet; + +use bytes::Bytes; +use futures_util::future::join_all; +use http::header::{HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE}; +use http::Method; +use librespot_core::spclient::SpClientError; +use librespot_core::Session; +use librespot_core::SpotifyUri; +use librespot_metadata::image::Images; +use librespot_metadata::{Album, Artist, Episode, Metadata, Playlist, Show, Track}; +use librespot_protocol::context::Context; +use librespot_protocol::extended_metadata::{ + BatchedEntityRequest, BatchedExtensionResponse, EntityRequest, ExtensionQuery, +}; +use librespot_protocol::extension_kind::ExtensionKind; +use librespot_protocol::playlist4_external::{ + Add, Delta, Item as PlaylistItem, ListChanges, Op, Rem, SelectedListContent, +}; +use protobuf::{EnumOrUnknown, Message, MessageField}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const PATHFINDER_BASE: &str = "https://api-partner.spotify.com"; +const SEARCH_QUERY_HASH: &str = "d9f785900f0710b31c07818d617f4f7600c1e21217e80f5b043d1e78d74e6026"; +const COLLECTION_PAGE: i32 = 100; +const COLLECTION_PAGES: usize = 100; +const MAX_PAGE: usize = 200; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct TrackJson { + id: String, + title: String, + artist: String, + artist_id: String, + album_id: String, + art_res: i32, + duration_ms: i64, + explicit: bool, + playable: bool, + uri: String, + art_url: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AlbumJson { + id: String, + title: String, + artist: String, + artist_id: String, + year: i32, + art_res: i32, + tracks: Vec, + art_url: Option, + track_count: i32, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct PlaylistJson { + id: String, + name: String, + owner: String, + owner_id: Option, + art_res: i32, + tracks: Vec, + art_url: Option, + track_count: i32, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ArtistJson { + id: String, + name: String, + art_res: i32, + album_ids: Vec, + art_url: Option, + album_count: i32, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct EpisodeJson { + id: String, + title: String, + show: String, + show_id: String, + published: String, + art_res: i32, + duration_ms: i64, + video: bool, + uri: String, + art_url: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ShowJson { + id: String, + name: String, + publisher: String, + art_res: i32, + episodes: Vec, + art_url: Option, + episode_count: i32, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CollectionJson { + #[serde(rename = "type")] + kind: String, + id: String, + title: String, + subtitle: String, + art_res: i32, + items: Vec, + artist_id: Option, + art_url: Option, + total_items: i32, + has_more: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SearchJson { + tracks: Vec, + artists: Vec, + albums: Vec, + playlists: Vec, + shows: Vec, +} + +fn id_of(uri: &SpotifyUri) -> String { + uri.to_id().unwrap_or_default() +} + +fn uri_of(id: &str, kind: &str) -> String { + if id.starts_with("spotify:") { + id.to_string() + } else { + format!("spotify:{kind}:{id}") + } +} + +fn spotify_uri(id: &str, kind: &str) -> Result { + SpotifyUri::from_uri(&uri_of(id, kind)).map_err(|e| format!("invalid URI: {e}")) +} + +fn image_url(images: &Images) -> Option { + let preferred = [ + librespot_metadata::image::ImageSize::DEFAULT, + librespot_metadata::image::ImageSize::SMALL, + librespot_metadata::image::ImageSize::LARGE, + ]; + for size in preferred { + if let Some(image) = images.iter().find(|image| image.size == size) { + return image + .id + .to_base16() + .ok() + .map(|id| format!("https://i.scdn.co/image/{id}")); + } + } + images + .first() + .and_then(|image| image.id.to_base16().ok()) + .map(|id| format!("https://i.scdn.co/image/{id}")) +} + +fn artist_name_and_id(artist: Option<&librespot_metadata::Artist>) -> (String, String) { + match artist { + Some(artist) => (artist.name.clone(), id_of(&artist.id)), + None => ("Unknown artist".to_string(), String::new()), + } +} + +fn track_json(track: &Track) -> TrackJson { + let (artist, artist_id) = artist_name_and_id(track.artists.first()); + TrackJson { + id: id_of(&track.id), + title: track.name.clone(), + artist, + artist_id, + album_id: id_of(&track.album.id), + art_res: 0, + duration_ms: track.duration as i64, + explicit: track.is_explicit, + playable: track.restrictions.is_empty(), + uri: uri_of(&id_of(&track.id), "track"), + art_url: image_url(&track.album.covers), + } +} + +fn album_json(album: &Album, tracks: Vec) -> AlbumJson { + let (artist, artist_id) = artist_name_and_id(album.artists.first()); + AlbumJson { + id: id_of(&album.id), + title: album.name.clone(), + artist, + artist_id, + year: album.date.year() as i32, + art_res: 0, + track_count: album.tracks().count() as i32, + tracks, + art_url: image_url(&album.covers), + } +} + +fn playlist_owner(playlist: &Playlist) -> (String, Option) { + match &playlist.id { + SpotifyUri::Playlist { user, .. } => { + let owner = user.clone().unwrap_or_default(); + let id = if owner.is_empty() { + None + } else { + Some(owner.clone()) + }; + ( + if owner.is_empty() { + "Spotify".to_string() + } else { + owner + }, + id, + ) + } + _ => ("Spotify".to_string(), None), + } +} + +fn owner_display(owner: &str, current_user: &str) -> String { + if owner.is_empty() { + "Spotify".to_string() + } else if owner == current_user { + "You".to_string() + } else { + owner.to_string() + } +} + +fn playlist_art(playlist: &Playlist) -> Option { + playlist + .attributes + .picture_sizes + .iter() + .find(|size| !size.url.is_empty()) + .map(|size| size.url.clone()) +} + +fn playlist_json(playlist: &Playlist, tracks: Vec, current_user: &str) -> PlaylistJson { + let (owner, owner_id) = playlist_owner(playlist); + PlaylistJson { + id: id_of(&playlist.id), + name: playlist.name().to_string(), + owner: owner_display(&owner, current_user), + owner_id, + art_res: 0, + tracks, + art_url: playlist_art(playlist), + track_count: playlist.length.max(0), + } +} + +fn artist_json(artist: &Artist, album_ids: Vec) -> ArtistJson { + ArtistJson { + id: id_of(&artist.id), + name: artist.name.clone(), + art_res: 0, + album_count: album_ids.len() as i32, + album_ids, + art_url: image_url(&artist.portraits).or_else(|| image_url(&artist.portrait_group)), + } +} + +fn episode_json_with( + episode: &Episode, + show_name: &str, + show_id: &str, + show_art_url: Option, +) -> EpisodeJson { + EpisodeJson { + id: id_of(&episode.id), + title: episode.name.clone(), + show: if episode.show_name.is_empty() { + show_name.to_string() + } else { + episode.show_name.clone() + }, + show_id: show_id.to_string(), + published: { + let date = episode.publish_time.as_utc(); + format!( + "{:04}-{:02}-{:02}", + date.year(), + u8::from(date.month()), + date.day() + ) + }, + art_res: 0, + duration_ms: episode.duration as i64, + video: !episode.videos.is_empty(), + uri: uri_of(&id_of(&episode.id), "episode"), + art_url: image_url(&episode.covers).or(show_art_url), + } +} + +fn show_json(show: &Show, episodes: Vec) -> ShowJson { + ShowJson { + id: id_of(&show.id), + name: show.name.clone(), + publisher: if show.publisher.is_empty() { + "Podcast".to_string() + } else { + show.publisher.clone() + }, + art_res: 0, + episode_count: show.episodes.len() as i32, + episodes, + art_url: image_url(&show.covers), + } +} + +async fn http_request( + session: &Session, + token: &str, + method: Method, + url: &str, + content_type: Option<&str>, + body: &[u8], +) -> Result { + let mut builder = http::Request::builder() + .method(method) + .uri(url) + .header(AUTHORIZATION, format!("Bearer {token}")); + if let Some(content_type) = content_type { + builder = builder.header(CONTENT_TYPE, content_type); + } + let request = builder + .body(Bytes::copy_from_slice(body)) + .map_err(|e| e.to_string())?; + session + .http_client() + .request_body(request) + .await + .map_err(|e| e.to_string()) +} + +async fn sp_request( + session: &Session, + token: &str, + method: Method, + endpoint: &str, + content_type: Option<&str>, + body: &[u8], +) -> Result { + let (host, port) = session + .spclient() + .get_accesspoint() + .await + .map_err(|e| e.to_string())?; + let url = if port == 443 { + format!("https://{host}{endpoint}") + } else { + format!("https://{host}:{port}{endpoint}") + }; + http_request(session, token, method, &url, content_type, body).await +} + +async fn metadata_bytes_retry( + session: &Session, + token: &str, + kind: ExtensionKind, + uri: &SpotifyUri, +) -> Result { + match metadata_bytes(session, token, kind, uri).await { + Ok(bytes) => Ok(bytes), + Err(first) => { + // Spotify's extended-metadata endpoint occasionally answers the first request for a + // podcast show with a 502. A second request for the same entity succeeds. + log::warn!("metadata request failed, retrying: {first}"); + metadata_bytes(session, token, kind, uri).await + } + } +} + +async fn metadata_bytes( + session: &Session, + token: &str, + kind: ExtensionKind, + uri: &SpotifyUri, +) -> Result { + let request = BatchedEntityRequest { + entity_request: vec![EntityRequest { + entity_uri: uri.to_uri().map_err(|e| e.to_string())?, + query: vec![ExtensionQuery { + extension_kind: EnumOrUnknown::new(kind), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }; + let body = request.write_to_bytes().map_err(|e| e.to_string())?; + let bytes = sp_request( + session, + token, + Method::POST, + "/extended-metadata/v0/extended-metadata", + Some("application/x-protobuf"), + &body, + ) + .await?; + let mut response = + BatchedExtensionResponse::parse_from_bytes(&bytes).map_err(|e| e.to_string())?; + let mut metadata = response + .extended_metadata + .pop() + .ok_or_else(|| SpClientError::ExpectedEntry("extended_metadata").to_string())?; + let mut data = metadata + .extension_data + .pop() + .ok_or_else(|| SpClientError::ExpectedEntry("extension_data").to_string())?; + data.extension_data + .take() + .map(|data| Bytes::from(data.value)) + .ok_or_else(|| SpClientError::ExpectedEntry("data").to_string()) +} + +async fn track_get(session: &Session, token: &str, uri: &SpotifyUri) -> Result { + let bytes = metadata_bytes(session, token, ExtensionKind::TRACK_V4, uri).await?; + let message = + librespot_protocol::metadata::Track::parse_from_bytes(&bytes).map_err(|e| e.to_string())?; + Track::parse(&message, uri).map_err(|e| e.to_string()) +} + +async fn album_get(session: &Session, token: &str, uri: &SpotifyUri) -> Result { + let bytes = metadata_bytes(session, token, ExtensionKind::ALBUM_V4, uri).await?; + let message = + librespot_protocol::metadata::Album::parse_from_bytes(&bytes).map_err(|e| e.to_string())?; + Album::parse(&message, uri).map_err(|e| e.to_string()) +} + +async fn artist_get(session: &Session, token: &str, uri: &SpotifyUri) -> Result { + let bytes = metadata_bytes(session, token, ExtensionKind::ARTIST_V4, uri).await?; + let message = librespot_protocol::metadata::Artist::parse_from_bytes(&bytes) + .map_err(|e| e.to_string())?; + Artist::parse(&message, uri).map_err(|e| e.to_string()) +} + +async fn show_get(session: &Session, token: &str, uri: &SpotifyUri) -> Result { + let bytes = metadata_bytes_retry(session, token, ExtensionKind::SHOW_V4, uri).await?; + let message = + librespot_protocol::metadata::Show::parse_from_bytes(&bytes).map_err(|e| e.to_string())?; + Show::parse(&message, uri).map_err(|e| e.to_string()) +} + +async fn episode_get(session: &Session, token: &str, uri: &SpotifyUri) -> Result { + let bytes = metadata_bytes_retry(session, token, ExtensionKind::EPISODE_V4, uri).await?; + let message = librespot_protocol::metadata::Episode::parse_from_bytes(&bytes) + .map_err(|e| e.to_string())?; + Episode::parse(&message, uri).map_err(|e| e.to_string()) +} + +async fn playlist_get( + session: &Session, + token: &str, + uri: &SpotifyUri, +) -> Result { + let id = uri.to_id().map_err(|e| e.to_string())?; + let bytes = sp_request( + session, + token, + Method::GET, + &format!("/playlist/v2/playlist/{id}"), + None, + &[], + ) + .await?; + let content = SelectedListContent::parse_from_bytes(&bytes).map_err(|e| e.to_string())?; + Playlist::parse(&content, uri).map_err(|e| e.to_string()) +} + +async fn context_get(session: &Session, token: &str, uri: &str) -> Result { + let bytes = sp_request( + session, + token, + Method::GET, + &format!("/context-resolve/v1/{uri}"), + None, + &[], + ) + .await?; + let text = String::from_utf8(bytes.to_vec()).map_err(|e| e.to_string())?; + protobuf_json_mapping::parse_from_str::(&text).map_err(|e| e.to_string()) +} + +fn context_episode_uris(context: &Context) -> Vec { + let mut seen = HashSet::new(); + let mut uris = Vec::new(); + for page in context.pages.iter() { + for track in page.tracks.iter() { + let Some(uri) = track.uri.as_deref() else { + continue; + }; + if !uri.starts_with("spotify:episode:") || !seen.insert(uri.to_string()) { + continue; + } + if let Ok(uri) = SpotifyUri::from_uri(uri) { + uris.push(uri); + } + } + } + uris +} + +async fn fetch_tracks( + session: &Session, + token: &str, + uris: &[SpotifyUri], + limit: usize, +) -> Vec { + let futures = uris.iter().take(limit).map(|uri| { + let session = session.clone(); + let token = token.to_string(); + let uri = uri.clone(); + async move { + track_get(&session, &token, &uri) + .await + .ok() + .map(|track| track_json(&track)) + } + }); + join_all(futures).await.into_iter().flatten().collect() +} + +async fn fetch_albums( + session: &Session, + token: &str, + uris: &[SpotifyUri], + with_tracks: bool, +) -> Vec { + let futures = uris.iter().map(|uri| { + let session = session.clone(); + let token = token.to_string(); + let uri = uri.clone(); + async move { album_get(&session, &token, &uri).await.ok() } + }); + let mut albums = Vec::new(); + for album in join_all(futures).await.into_iter().flatten() { + let tracks = if with_tracks { + let uris: Vec = album.tracks().cloned().collect(); + let fetched = fetch_tracks(session, token, &uris, 100).await; + fetched.into_iter().collect() + } else { + Vec::new() + }; + albums.push(album_json(&album, tracks)); + } + albums +} + +fn encode(input: &str) -> String { + let mut out = String::with_capacity(input.len() * 2); + for byte in input.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(byte as char) + } + _ => { + out.push('%'); + out.push(char::from(b"0123456789ABCDEF"[(byte >> 4) as usize])); + out.push(char::from(b"0123456789ABCDEF"[(byte & 0x0F) as usize])); + } + } + } + out +} + +fn pf_str(node: &Value, pointer: &str) -> String { + node.pointer(pointer) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() +} + +fn pf_image(node: Option<&Value>) -> Option { + let sources = node?.as_array()?; + let mut best: Option<(i64, String)> = None; + for source in sources { + let Some(url) = source.get("url").and_then(Value::as_str) else { + continue; + }; + let width = source.get("width").and_then(Value::as_i64).unwrap_or(0); + if width >= 200 + && best + .as_ref() + .is_none_or(|(best_width, _)| width < *best_width) + { + best = Some((width, url.to_string())); + } + } + best.map(|(_, url)| url).or_else(|| { + sources.iter().find_map(|source| { + source + .get("url") + .and_then(Value::as_str) + .map(str::to_string) + }) + }) +} + +fn pf_artists(data: &Value) -> (String, String) { + let artist = data.pointer("/artists/items/0"); + let name = artist + .map(|a| pf_str(a, "/profile/name")) + .unwrap_or_default(); + let id = artist.map(|a| pf_str(a, "/uri")).unwrap_or_default(); + ( + name, + if id.is_empty() { + String::new() + } else { + id.rsplit(':').next().unwrap_or_default().to_string() + }, + ) +} + +fn pf_track(data: &Value) -> Option { + let uri = data.get("uri")?.as_str()?.to_string(); + let id = uri.rsplit(':').next().unwrap_or_default().to_string(); + let (artist, artist_id) = pf_artists(data); + let album_uri = pf_str(data, "/albumOfTrack/uri"); + Some(TrackJson { + id, + title: pf_str(data, "/name"), + artist: if artist.is_empty() { + "Unknown artist".to_string() + } else { + artist + }, + artist_id, + album_id: album_uri.rsplit(':').next().unwrap_or_default().to_string(), + art_res: 0, + duration_ms: data + .pointer("/duration/totalMilliseconds") + .and_then(Value::as_i64) + .unwrap_or(0), + explicit: pf_str(data, "/contentRating/label") == "EXPLICIT", + playable: true, + uri, + art_url: pf_image(data.pointer("/albumOfTrack/coverArt/sources")), + }) +} + +fn pf_album(data: &Value) -> Option { + let uri = data.get("uri")?.as_str()?; + let (artist, artist_id) = pf_artists(data); + Some(AlbumJson { + id: uri.rsplit(':').next().unwrap_or_default().to_string(), + title: pf_str(data, "/name"), + artist: if artist.is_empty() { + "Unknown artist".to_string() + } else { + artist + }, + artist_id, + year: pf_str(data, "/date/year").parse().unwrap_or(0), + art_res: 0, + tracks: Vec::new(), + art_url: pf_image(data.pointer("/coverArt/sources")), + track_count: data + .pointer("/tracks/totalCount") + .and_then(Value::as_i64) + .unwrap_or(0) as i32, + }) +} + +fn pf_artist(data: &Value) -> Option { + let uri = data.get("uri")?.as_str()?; + Some(ArtistJson { + id: uri.rsplit(':').next().unwrap_or_default().to_string(), + name: pf_str(data, "/profile/name"), + art_res: 0, + album_ids: Vec::new(), + album_count: 0, + art_url: pf_image(data.pointer("/visuals/avatarImage/sources")), + }) +} + +fn pf_playlist(data: &Value, current_user: &str) -> Option { + let uri = data.get("uri")?.as_str()?; + let owner = data.pointer("/ownerV2/data"); + let owner_name = owner.map(|o| pf_str(o, "/name")).unwrap_or_default(); + Some(PlaylistJson { + id: uri.rsplit(':').next().unwrap_or_default().to_string(), + name: pf_str(data, "/name"), + owner: owner_display(&owner_name, current_user), + owner_id: owner + .and_then(|o| o.get("uri")) + .and_then(Value::as_str) + .map(|u| u.rsplit(':').next().unwrap_or_default().to_string()), + art_res: 0, + tracks: Vec::new(), + art_url: pf_image(data.pointer("/images/items/0/sources")), + track_count: data + .pointer("/tracks/totalCount") + .and_then(Value::as_i64) + .unwrap_or(0) as i32, + }) +} + +fn pf_show(data: &Value) -> Option { + let uri = data.get("uri")?.as_str()?; + Some(ShowJson { + id: uri.rsplit(':').next().unwrap_or_default().to_string(), + name: pf_str(data, "/name"), + publisher: if pf_str(data, "/publisher/name").is_empty() { + "Podcast".to_string() + } else { + pf_str(data, "/publisher/name") + }, + art_res: 0, + episodes: Vec::new(), + art_url: pf_image(data.pointer("/coverArt/sources")), + episode_count: data + .pointer("/episodes/totalCount") + .and_then(Value::as_i64) + .unwrap_or(0) as i32, + }) +} + +fn section_items<'a>(root: &'a Value, name: &str) -> &'a [Value] { + root.pointer(&format!("/data/searchV2/{name}/items")) + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or(&[]) +} + +async fn pathfinder_search( + session: &Session, + token: &str, + query: &str, + offset: i32, + limit: i32, +) -> Result { + let variables = json!({ + "searchTerm": query, + "offset": offset, + "limit": limit, + "numberOfTopResults": 5, + "includeAudiobooks": false, + }); + let extensions = json!({ + "persistedQuery": {"version": 1, "sha256Hash": SEARCH_QUERY_HASH}, + }); + let url = format!( + "{PATHFINDER_BASE}/pathfinder/v1/query?operationName=searchDesktop&variables={}&extensions={}", + encode(&variables.to_string()), + encode(&extensions.to_string()) + ); + let request = http::Request::builder() + .method(Method::GET) + .uri(url) + .header(AUTHORIZATION, format!("Bearer {token}")) + .header(ACCEPT, HeaderValue::from_static("application/json")) + .header("app-platform", HeaderValue::from_static("WebPlayer")) + .body(Bytes::new()) + .map_err(|e| format!("request: {e}"))?; + let body = session + .http_client() + .request_body(request) + .await + .map_err(|e| format!("pathfinder: {e}"))?; + let value: Value = serde_json::from_slice(&body).map_err(|e| format!("json: {e}"))?; + if let Some(errors) = value.get("errors") { + return Err(format!("pathfinder error: {errors}")); + } + if value.pointer("/data/searchV2").is_none() { + return Err("pathfinder returned no searchV2".to_string()); + } + Ok(value) +} + +pub async fn search( + session: &Session, + token: &str, + query: &str, + offset: i32, + limit: i32, + scopes: &[String], +) -> Result { + let want = |scope: &str| { + scopes.is_empty() || scopes.iter().any(|value| value.eq_ignore_ascii_case(scope)) + }; + match pathfinder_search(session, token, query, offset.max(0), limit.clamp(1, 50)).await { + Ok(value) => { + let tracks = if want("Tracks") { + section_items(&value, "tracksV2") + .iter() + .filter_map(|item| item.pointer("/item/data")) + .filter_map(pf_track) + .collect() + } else { + Vec::new() + }; + let artists = if want("Artists") { + section_items(&value, "artists") + .iter() + .filter_map(|item| item.get("data")) + .filter_map(pf_artist) + .collect() + } else { + Vec::new() + }; + let albums = if want("Albums") { + section_items(&value, "albumsV2") + .iter() + .filter_map(|item| item.get("data")) + .filter_map(pf_album) + .collect() + } else { + Vec::new() + }; + let playlists = if want("Playlists") { + section_items(&value, "playlists") + .iter() + .filter_map(|item| item.get("data")) + .filter_map(|data| pf_playlist(data, &session.username())) + .collect() + } else { + Vec::new() + }; + let shows = if want("Shows") { + section_items(&value, "podcasts") + .iter() + .filter_map(|item| item.get("data")) + .filter_map(pf_show) + .collect() + } else { + Vec::new() + }; + let results = SearchJson { + tracks, + artists, + albums, + playlists, + shows, + }; + Ok(serde_json::to_string(&results).map_err(|e| e.to_string())?) + } + Err(error) => { + log::warn!("Pathfinder search failed, using context fallback: {error}"); + let context_uri = format!("spotify:search:{}", query.replace(' ', "+")); + let context = context_get(session, token, &context_uri) + .await + .map_err(|e| format!("search fallback: {e}"))?; + let mut uris = Vec::new(); + let mut seen = HashSet::new(); + for page in context.pages.iter() { + for track in page.tracks.iter() { + let uri = track.uri(); + if uri.starts_with("spotify:track:") && seen.insert(uri.to_string()) { + uris.push(SpotifyUri::from_uri(uri).map_err(|e| e.to_string())?); + } + } + } + let page: Vec = uris + .into_iter() + .skip(offset.max(0) as usize) + .take(limit.clamp(1, 50) as usize) + .collect(); + let tracks = fetch_tracks(session, token, &page, page.len()).await; + let results = SearchJson { + tracks, + artists: Vec::new(), + albums: Vec::new(), + playlists: Vec::new(), + shows: Vec::new(), + }; + Ok(serde_json::to_string(&results).map_err(|e| e.to_string())?) + } + } +} + +#[derive(Debug, Clone)] +struct CollectionItem { + uri: String, + added_at: i32, + is_removed: bool, +} + +fn put_varint(out: &mut Vec, mut value: u64) { + while value >= 0x80 { + out.push((value as u8) | 0x80); + value >>= 7; + } + out.push(value as u8); +} + +fn put_string(out: &mut Vec, field: u32, value: &str) { + put_varint(out, ((field << 3) | 2) as u64); + put_varint(out, value.len() as u64); + out.extend_from_slice(value.as_bytes()); +} + +fn read_varint(bytes: &[u8], index: &mut usize) -> Option { + let mut value = 0u64; + let mut shift = 0; + while *index < bytes.len() { + let byte = bytes[*index]; + *index += 1; + value |= ((byte & 0x7f) as u64) << shift; + if byte & 0x80 == 0 { + return Some(value); + } + shift += 7; + } + None +} + +fn parse_collection_item(bytes: &[u8]) -> CollectionItem { + let mut item = CollectionItem { + uri: String::new(), + added_at: 0, + is_removed: false, + }; + let mut index = 0; + while index < bytes.len() { + let Some(key) = read_varint(bytes, &mut index) else { + break; + }; + let field = (key >> 3) as u32; + let wire = (key & 7) as u8; + match (field, wire) { + (1, 2) => { + let Some(len) = read_varint(bytes, &mut index) else { + break; + }; + let end = index + len as usize; + if end > bytes.len() { + break; + } + item.uri = String::from_utf8_lossy(&bytes[index..end]).into_owned(); + index = end; + } + (2, 0) => { + let Some(value) = read_varint(bytes, &mut index) else { + break; + }; + item.added_at = value as i32; + } + (3, 0) => { + let Some(value) = read_varint(bytes, &mut index) else { + break; + }; + item.is_removed = value != 0; + } + (_, 0) => { + let _ = read_varint(bytes, &mut index); + } + (_, 1) => { + index += 8; + } + (_, 2) => { + let Some(len) = read_varint(bytes, &mut index) else { + break; + }; + index += len as usize; + } + (_, 5) => { + index += 4; + } + _ => break, + } + } + item +} + +fn parse_collection_page(bytes: &[u8]) -> (Vec, String) { + let mut items = Vec::new(); + let mut next_page_token = String::new(); + let mut index = 0; + while index < bytes.len() { + let Some(key) = read_varint(bytes, &mut index) else { + break; + }; + let field = (key >> 3) as u32; + let wire = (key & 7) as u8; + match (field, wire) { + (1, 2) => { + let Some(len) = read_varint(bytes, &mut index) else { + break; + }; + let end = index + len as usize; + if end > bytes.len() { + break; + } + items.push(parse_collection_item(&bytes[index..end])); + index = end; + } + (2, 2) => { + let Some(len) = read_varint(bytes, &mut index) else { + break; + }; + let end = index + len as usize; + if end > bytes.len() { + break; + } + next_page_token = String::from_utf8_lossy(&bytes[index..end]).into_owned(); + index = end; + } + (_, 0) => { + let _ = read_varint(bytes, &mut index); + } + (_, 1) => { + index += 8; + } + (_, 2) => { + let Some(len) = read_varint(bytes, &mut index) else { + break; + }; + index += len as usize; + } + (_, 5) => { + index += 4; + } + _ => break, + } + } + (items, next_page_token) +} + +async fn collection_page( + session: &Session, + token: &str, + set: &str, + offset: usize, + limit: usize, +) -> Result, String> { + let username = session.username(); + let mut items = Vec::new(); + let mut skipped = 0usize; + let mut page_token = String::new(); + let mut seen = HashSet::new(); + for _ in 0..COLLECTION_PAGES { + let mut body = Vec::new(); + put_string(&mut body, 1, &username); + put_string(&mut body, 2, set); + put_string(&mut body, 3, &page_token); + put_varint(&mut body, (4 << 3) as u64); + put_varint(&mut body, COLLECTION_PAGE as u64); + let bytes = sp_request( + session, + token, + Method::POST, + "/collection/v2/paging", + Some("application/vnd.collection-v2.spotify.proto"), + &body, + ) + .await + .map_err(|e| format!("collection {set}: {e}"))?; + let (page_items, next) = parse_collection_page(&bytes); + let got = page_items.len(); + for item in page_items.into_iter().filter(|item| !item.is_removed) { + if skipped < offset { + skipped += 1; + continue; + } + items.push(item); + if items.len() >= limit { + return Ok(items); + } + } + if next.is_empty() || got == 0 || !seen.insert(next.clone()) { + break; + } + page_token = next; + } + Ok(items) +} + +fn collection_uris(items: &[CollectionItem], prefix: &str) -> Vec { + items + .iter() + .filter(|item| item.uri.starts_with(prefix)) + .filter_map(|item| SpotifyUri::from_uri(&item.uri).ok()) + .collect() +} + +async fn rootlist_playlists( + session: &Session, + token: &str, + offset: usize, + limit: usize, +) -> Result, String> { + let mut seen = 0usize; + let mut skipped = 0usize; + let mut playlists = Vec::new(); + for _ in 0..COLLECTION_PAGES { + let user = session.username(); + let endpoint = format!( + "/playlist/v2/user/{user}/rootlist?decorate=revision,attributes,length,owner,capabilities,status_code&from={seen}&length=500" + ); + let bytes = sp_request(session, token, Method::GET, &endpoint, None, &[]) + .await + .map_err(|e| format!("rootlist: {e}"))?; + let content = SelectedListContent::parse_from_bytes(&bytes).map_err(|e| e.to_string())?; + let items = &content.contents.items; + let meta = &content.contents.meta_items; + if items.is_empty() { + break; + } + if content.contents.pos() as usize != seen { + break; + } + for (index, item) in items.iter().enumerate() { + let uri = item.uri(); + if !uri.starts_with("spotify:playlist:") { + continue; + } + let metadata = meta.get(index); + let attributes = metadata.and_then(|m| m.attributes.as_ref()); + let name = attributes.map(|a| a.name().to_string()).unwrap_or_default(); + // A few stale rootlist entries carry no name; they cannot be navigated usefully. + if name.trim().is_empty() { + continue; + } + if skipped < offset { + skipped += 1; + continue; + } + let owner = metadata + .map(|m| m.owner_username().to_string()) + .unwrap_or_default(); + let art_url = attributes + .and_then(|a| a.picture_size.iter().find(|size| !size.url().is_empty())) + .map(|size| size.url().to_string()); + let owner_id = if owner.is_empty() { + None + } else { + Some(owner.clone()) + }; + playlists.push(PlaylistJson { + id: uri.rsplit(':').next().unwrap_or_default().to_string(), + name, + owner: owner_display(&owner, &user), + owner_id, + art_res: 0, + tracks: Vec::new(), + art_url, + track_count: metadata.map(|m| m.length()).unwrap_or(0), + }); + if playlists.len() >= limit { + return Ok(playlists); + } + } + seen += items.len(); + if seen >= content.length().max(0) as usize { + break; + } + } + Ok(playlists) +} + +fn collection_item_bytes(uri: &str, added_at: i32, removed: bool) -> Vec { + let mut item = Vec::new(); + put_string(&mut item, 1, uri); + if added_at > 0 { + put_varint(&mut item, (2 << 3) as u64); + put_varint(&mut item, added_at as u64); + } + if removed { + put_varint(&mut item, (3 << 3) as u64); + put_varint(&mut item, 1); + } + item +} + +fn collection_body(username: &str, set: &str, uris: &[String], removed: bool) -> Vec { + let mut body = Vec::new(); + put_string(&mut body, 1, username); + put_string(&mut body, 2, set); + let added_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs() as i32) + .unwrap_or(0); + for uri in uris { + let item = collection_item_bytes(uri, if removed { 0 } else { added_at }, removed); + put_varint(&mut body, ((3 << 3) | 2) as u64); + put_varint(&mut body, item.len() as u64); + body.extend_from_slice(&item); + } + body +} + +fn parse_contains(bytes: &[u8]) -> Vec { + let mut found = Vec::new(); + let mut index = 0; + while index < bytes.len() { + let Some(key) = read_varint(bytes, &mut index) else { + break; + }; + let field = (key >> 3) as u32; + let wire = (key & 7) as u8; + match (field, wire) { + (1, 0) => { + let Some(value) = read_varint(bytes, &mut index) else { + break; + }; + found.push(value != 0); + } + (_, 0) => { + let _ = read_varint(bytes, &mut index); + } + (_, 1) => index += 8, + (_, 2) => { + let Some(len) = read_varint(bytes, &mut index) else { + break; + }; + index += len as usize; + } + (_, 5) => index += 4, + _ => break, + } + } + found +} + +/// Answers in the order of `uris`; used to seed the optimistic save state before a toggle. +pub async fn collection_contains( + session: &Session, + token: &str, + set: &str, + uris: &[String], +) -> Result { + let body = collection_body(&session.username(), set, uris, false); + let bytes = sp_request( + session, + token, + Method::POST, + "/collection/v2/contains", + Some("application/vnd.collection-v2.spotify.proto"), + &body, + ) + .await?; + let found = parse_contains(&bytes); + if found.len() != uris.len() { + return Err("collection contains returned the wrong number of results".to_string()); + } + serde_json::to_string(&found).map_err(|e| e.to_string()) +} + +/// Adds or removes items from a collection set. `is_removed` marks a removal. +pub async fn collection_write( + session: &Session, + token: &str, + set: &str, + uris: &[String], + removed: bool, +) -> Result<(), String> { + let body = collection_body(&session.username(), set, uris, removed); + sp_request( + session, + token, + Method::POST, + "/collection/v2/write", + Some("application/vnd.collection-v2.spotify.proto"), + &body, + ) + .await?; + Ok(()) +} + +async fn rootlist_content(session: &Session, token: &str) -> Result { + let user = session.username(); + let endpoint = format!( + "/playlist/v2/user/{user}/rootlist?decorate=revision,attributes,length,owner,capabilities,status_code&from=0&length=500" + ); + let bytes = sp_request(session, token, Method::GET, &endpoint, None, &[]) + .await + .map_err(|e| format!("rootlist: {e}"))?; + SelectedListContent::parse_from_bytes(&bytes).map_err(|e| e.to_string()) +} + +pub async fn playlist_saved(session: &Session, token: &str, id: &str) -> Result { + let uri = uri_of(id, "playlist"); + let content = rootlist_content(session, token).await?; + Ok(content + .contents + .as_ref() + .is_some_and(|items| items.items.iter().any(|item| item.uri() == uri))) +} + +/// Adds or removes a playlist from the user's rootlist (the "Your Library" playlist list). +pub async fn playlist_save( + session: &Session, + token: &str, + id: &str, + save: bool, +) -> Result<(), String> { + let uri = uri_of(id, "playlist"); + let content = rootlist_content(session, token).await?; + let index = content + .contents + .as_ref() + .and_then(|items| items.items.iter().position(|item| item.uri() == uri)); + if save == index.is_some() { + return Ok(()); + } + + let mut changes = ListChanges::new(); + changes.base_revision = content.revision.clone(); + let mut delta = Delta::new(); + if save { + let item = PlaylistItem { + uri: Some(uri), + ..Default::default() + }; + let add = Add { + items: vec![item], + add_last: Some(true), + ..Default::default() + }; + delta.ops.push(Op { + kind: Some(EnumOrUnknown::new( + librespot_protocol::playlist4_external::op::Kind::ADD, + )), + add: MessageField::some(add), + ..Default::default() + }); + } else { + let rem = Rem { + from_index: Some(index.unwrap_or(0) as i32), + length: Some(1), + ..Default::default() + }; + delta.ops.push(Op { + kind: Some(EnumOrUnknown::new( + librespot_protocol::playlist4_external::op::Kind::REM, + )), + rem: MessageField::some(rem), + ..Default::default() + }); + } + changes.deltas.push(delta); + let body = changes.write_to_bytes().map_err(|e| e.to_string())?; + let user = session.username(); + sp_request( + session, + token, + Method::POST, + &format!("/playlist/v2/user/{user}/rootlist/changes"), + Some("application/x-www-form-urlencoded"), + &body, + ) + .await?; + Ok(()) +} + +pub async fn library( + session: &Session, + token: &str, + filter: &str, + offset: usize, + limit: usize, +) -> Result { + let limit = limit.clamp(1, MAX_PAGE); + match filter { + "LikedSongs" => { + let items = collection_page(session, token, "collection", offset, limit).await?; + let uris = collection_uris(&items, "spotify:track:"); + let tracks = fetch_tracks(session, token, &uris, uris.len()).await; + Ok(serde_json::to_string(&tracks).map_err(|e| e.to_string())?) + } + "Playlists" => Ok(serde_json::to_string( + &rootlist_playlists(session, token, offset, limit).await?, + ) + .map_err(|e| e.to_string())?), + "Albums" => { + let items = collection_page(session, token, "collection", offset, limit).await?; + let uris = collection_uris(&items, "spotify:album:"); + let albums = fetch_albums(session, token, &uris, false).await; + Ok(serde_json::to_string(&albums).map_err(|e| e.to_string())?) + } + "Artists" => { + let mut items = collection_page(session, token, "artist", offset, limit).await?; + if items.is_empty() { + items = collection_page(session, token, "collection", offset, limit) + .await? + .into_iter() + .filter(|item| item.uri.starts_with("spotify:artist:")) + .collect(); + } + let uris = collection_uris(&items, "spotify:artist:"); + let futures = uris.iter().map(|uri| { + let session = session.clone(); + let uri = uri.clone(); + async move { + Artist::get(&session, &uri) + .await + .ok() + .map(|artist| artist_json(&artist, Vec::new())) + } + }); + let artists: Vec = join_all(futures).await.into_iter().flatten().collect(); + Ok(serde_json::to_string(&artists).map_err(|e| e.to_string())?) + } + "Shows" => { + let items = collection_page(session, token, "show", offset, limit).await?; + let uris = collection_uris(&items, "spotify:show:"); + let futures = uris.iter().map(|uri| { + let session = session.clone(); + let uri = uri.clone(); + async move { + Show::get(&session, &uri) + .await + .ok() + .map(|show| show_json(&show, Vec::new())) + } + }); + let shows: Vec = join_all(futures).await.into_iter().flatten().collect(); + Ok(serde_json::to_string(&shows).map_err(|e| e.to_string())?) + } + other => Err(format!("unknown library filter: {other}")), + } +} + +pub async fn collection( + session: &Session, + token: &str, + kind: &str, + id: &str, + offset: usize, + limit: usize, +) -> Result { + let limit = limit.clamp(1, MAX_PAGE); + match kind { + "Album" => { + let album = album_get(session, token, &spotify_uri(id, "album")?) + .await + .map_err(|e| e.to_string())?; + let all_uris: Vec = album.tracks().cloned().collect(); + let total = all_uris.len(); + let uris: Vec = all_uris.into_iter().skip(offset).take(limit).collect(); + let tracks = fetch_tracks(session, token, &uris, uris.len()).await; + let album_json = album_json(&album, tracks); + let subtitle = format!( + "{} · {} · {} tracks", + album_json.artist, album_json.year, album_json.track_count + ); + let collection = CollectionJson { + kind: "Album".to_string(), + id: album_json.id.clone(), + title: album_json.title.clone(), + subtitle, + art_res: 0, + items: album_json + .tracks + .iter() + .map(|track| serde_json::to_value(track).unwrap_or(Value::Null)) + .collect(), + artist_id: Some(album_json.artist_id.clone()).filter(|value| !value.is_empty()), + art_url: album_json.art_url.clone(), + total_items: total as i32, + has_more: offset + album_json.tracks.len() < total, + }; + Ok(serde_json::to_string(&collection).map_err(|e| e.to_string())?) + } + "Playlist" => { + let playlist = playlist_get(session, token, &spotify_uri(id, "playlist")?) + .await + .map_err(|e| e.to_string())?; + let all_uris: Vec = playlist.tracks().cloned().collect(); + let total = (playlist.length.max(0) as usize).max(all_uris.len()); + let uris: Vec = all_uris.into_iter().skip(offset).take(limit).collect(); + let tracks = fetch_tracks(session, token, &uris, uris.len()).await; + let playlist_json = playlist_json(&playlist, tracks, &session.username()); + let subtitle = format!( + "{} · {} tracks", + playlist_json.owner, playlist_json.track_count + ); + let collection = CollectionJson { + kind: "Playlist".to_string(), + id: playlist_json.id.clone(), + title: playlist_json.name.clone(), + subtitle, + art_res: 0, + items: playlist_json + .tracks + .iter() + .map(|track| serde_json::to_value(track).unwrap_or(Value::Null)) + .collect(), + artist_id: playlist_json.owner_id.clone(), + art_url: playlist_json.art_url.clone(), + total_items: total as i32, + has_more: offset + playlist_json.tracks.len() < total, + }; + Ok(serde_json::to_string(&collection).map_err(|e| e.to_string())?) + } + "Show" => { + let show_uri = spotify_uri(id, "show")?; + let show = show_get(session, token, &show_uri).await; + let (title, publisher, art_url, all_uris) = match show { + Ok(show) => { + let mut uris: Vec = show.episodes.iter().cloned().collect(); + if uris.is_empty() { + // Some shows return no episode list in their metadata. The context + // resolver still knows the full episode order. + uris = context_get(session, token, &uri_of(id, "show")) + .await + .map(|context| context_episode_uris(&context)) + .unwrap_or_default(); + } + ( + show.name.clone(), + if show.publisher.is_empty() { + "Podcast".to_string() + } else { + show.publisher.clone() + }, + image_url(&show.covers), + uris, + ) + } + Err(error) => { + log::warn!("show metadata failed, using context: {error}"); + let context = context_get(session, token, &uri_of(id, "show")).await?; + let uris = context_episode_uris(&context); + let title = context + .metadata + .get("title") + .cloned() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "Show".to_string()); + let publisher = context + .metadata + .get("publisher") + .cloned() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "Podcast".to_string()); + let art_url = context + .metadata + .get("image_url") + .cloned() + .filter(|value| !value.is_empty()); + (title, publisher, art_url, uris) + } + }; + let total = all_uris.len(); + let uris: Vec = all_uris.into_iter().skip(offset).take(limit).collect(); + let futures = uris.iter().map(|uri| { + let session = session.clone(); + let token = token.to_string(); + let uri = uri.clone(); + let title = title.clone(); + let show_id = id.to_string(); + let art_url = art_url.clone(); + async move { + episode_get(&session, &token, &uri) + .await + .ok() + .map(|episode| episode_json_with(&episode, &title, &show_id, art_url)) + } + }); + let episodes: Vec = + join_all(futures).await.into_iter().flatten().collect(); + let subtitle = format!("{publisher} · {total} episodes"); + let collection = CollectionJson { + kind: "Show".to_string(), + id: id.to_string(), + title, + subtitle, + art_res: 0, + items: episodes + .iter() + .map(|episode| serde_json::to_value(episode).unwrap_or(Value::Null)) + .collect(), + artist_id: None, + art_url, + total_items: total as i32, + has_more: offset + episodes.len() < total, + }; + Ok(serde_json::to_string(&collection).map_err(|e| e.to_string())?) + } + other => Err(format!("unknown collection type: {other}")), + } +} + +pub async fn artist(session: &Session, token: &str, id: &str) -> Result { + let artist = artist_get(session, token, &spotify_uri(id, "artist")?) + .await + .map_err(|e| e.to_string())?; + let album_ids: Vec = artist + .albums_current() + .chain(artist.singles_current()) + .map(|uri| uri_of(&id_of(uri), "album")) + .collect(); + Ok(serde_json::to_string(&artist_json(&artist, album_ids)).map_err(|e| e.to_string())?) +} + +pub async fn albums_by( + session: &Session, + token: &str, + id: &str, + offset: usize, + limit: usize, +) -> Result { + let artist = artist_get(session, token, &spotify_uri(id, "artist")?) + .await + .map_err(|e| e.to_string())?; + let uris: Vec = artist + .albums_current() + .chain(artist.singles_current()) + .skip(offset) + .take(limit.clamp(1, MAX_PAGE)) + .cloned() + .collect(); + let mut albums = fetch_albums(session, token, &uris, false).await; + albums.sort_by(|a, b| b.year.cmp(&a.year).then_with(|| a.title.cmp(&b.title))); + Ok(serde_json::to_string(&albums).map_err(|e| e.to_string())?) +} + +pub async fn top_tracks( + session: &Session, + token: &str, + id: &str, + offset: usize, + limit: usize, +) -> Result { + let artist = artist_get(session, token, &spotify_uri(id, "artist")?) + .await + .map_err(|e| e.to_string())?; + let country = session.country(); + let uris: Vec = artist + .top_tracks + .for_country(&country) + .iter() + .skip(offset) + .take(limit.clamp(1, MAX_PAGE)) + .cloned() + .collect(); + let tracks = fetch_tracks(session, token, &uris, uris.len()).await; + Ok(serde_json::to_string(&tracks).map_err(|e| e.to_string())?) +} + +#[cfg(test)] +mod tests { + use super::owner_display; + + #[test] + fn owner_display_marks_the_signed_in_user() { + assert_eq!(owner_display("alice", "alice"), "You"); + assert_eq!(owner_display("bob", "alice"), "bob"); + assert_eq!(owner_display("", "alice"), "Spotify"); + } +} diff --git a/native/sidekit-spotify/src/engine.rs b/native/sidekit-spotify/src/engine.rs new file mode 100644 index 00000000..05f773ba --- /dev/null +++ b/native/sidekit-spotify/src/engine.rs @@ -0,0 +1,401 @@ +//! One process-wide librespot session and player, guarded independently of the JNI callers. + +use crate::audio::{PcmSink, CHANNELS, SAMPLE_RATE}; +use librespot_core::authentication::Credentials; +use librespot_core::cache::Cache; +use librespot_core::config::SessionConfig; +use librespot_core::session::Session; +use librespot_core::SpotifyUri; +use librespot_playback::config::{Bitrate, PlayerConfig}; +use librespot_playback::mixer::VolumeGetter; +use librespot_playback::player::{Player, PlayerEventChannel}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU16, Ordering}; +use std::sync::mpsc::{sync_channel, Receiver, SyncSender}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +struct Volume(AtomicU16); + +struct SharedVolume(Arc); + +impl VolumeGetter for SharedVolume { + fn attenuation_factor(&self) -> f64 { + let volume = self.0 .0.load(Ordering::Relaxed).min(65_535) as f64 / 65_535.0; + volume * volume + } +} + +struct State { + session: Option, + access_token: Option, + player: Option>, + events: Option, + volume: Arc, +} + +impl Default for State { + fn default() -> Self { + Self { + session: None, + access_token: None, + player: None, + events: None, + volume: Arc::new(Volume(AtomicU16::new(65_535))), + } + } +} + +/// Upper bound for one librespot access-point connection attempt. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); + +static RUNTIME: OnceLock = OnceLock::new(); +static STATE: OnceLock> = OnceLock::new(); +static AUDIO_RX: OnceLock>>> = OnceLock::new(); + +pub(crate) fn runtime() -> &'static tokio::runtime::Runtime { + RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .worker_threads(2) + .build() + .expect("tokio runtime") + }) +} + +fn state() -> &'static Mutex { + STATE.get_or_init(|| Mutex::new(State::default())) +} + +fn audio_rx() -> &'static Mutex>> { + AUDIO_RX.get_or_init(|| { + let (tx, rx) = sync_channel::>(64); + AUDIO_TX.set(tx).ok(); + Mutex::new(rx) + }) +} + +static AUDIO_TX: OnceLock>> = OnceLock::new(); +static TMP_DIR: OnceLock>> = OnceLock::new(); +static CACHE: OnceLock>> = OnceLock::new(); + +pub fn set_tmp_dir(path: &str) { + let slot = TMP_DIR.get_or_init(|| Mutex::new(None)); + if let Ok(mut guard) = slot.lock() { + *guard = Some(PathBuf::from(path)); + } +} + +/// Enables librespot's on-disk audio cache. The cache stores the encrypted CDN blobs and is +/// bounded by `size_limit` bytes; librespot evicts least-recently-used entries past the limit. +pub fn set_cache_dir(path: &str, size_limit: u64) { + let slot = CACHE.get_or_init(|| Mutex::new(None)); + if let Ok(mut guard) = slot.lock() { + *guard = Some((PathBuf::from(path), size_limit)); + } +} + +fn cache() -> Option { + let (path, size_limit) = CACHE + .get() + .and_then(|slot| slot.lock().ok().and_then(|guard| guard.clone()))?; + match Cache::new::(None, None, Some(path), Some(size_limit)) { + Ok(cache) => Some(cache), + Err(error) => { + log::warn!("audio cache unavailable: {error}"); + None + } + } +} + +fn tmp_dir() -> PathBuf { + TMP_DIR + .get() + .and_then(|slot| slot.lock().ok().and_then(|guard| guard.clone())) + .unwrap_or_else(std::env::temp_dir) +} + +pub fn init() -> Result<(), String> { + // Force the audio channel (and its sender) to exist before the player starts. + let _ = audio_rx(); + Ok(()) +} + +pub(crate) fn run(future: F) -> F::Output { + runtime().block_on(future) +} + +pub(crate) fn with_session_and_token( + f: impl FnOnce(&Session, &str) -> Result, +) -> Result { + // Clone out of the lock: catalog calls may make several network round trips, and the audio + // event poller must never wait behind them. + let (session, token) = { + let guard = state().lock().map_err(|_| "STATE_LOCK".to_string())?; + let session = guard + .session + .clone() + .ok_or_else(|| "SESSION_NOT_CONNECTED".to_string())?; + let token = guard + .access_token + .clone() + .ok_or_else(|| "SESSION_NOT_CONNECTED".to_string())?; + (session, token) + }; + f(&session, &token) +} + +pub fn connect(access_token: &str) -> Result<(), String> { + disconnect(); + let rt = runtime(); + let _guard = rt.enter(); + // The OAuth token comes from the desktop/keymaster client. librespot defaults to the Android + // client id on this target, which would make Login5 reject the stored credential. + let config = SessionConfig { + client_id: "65b708073fc0480ea92a077233ca87bd".to_string(), + tmp_dir: tmp_dir(), + ..Default::default() + }; + let session = Session::new(config, cache()); + // A fresh TCP handshake has no deadline of its own, and `Session::connect` may walk up to six + // access points. Bound it so a bad network cannot wedge the caller (and the UI waiting on it) + // indefinitely; the caller retries with a new session on the next play. + let connect = session.connect(Credentials::with_access_token(access_token), true); + match rt.block_on(tokio::time::timeout(CONNECT_TIMEOUT, connect)) { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(format!("SESSION_CONNECT_FAILED: {e}")), + Err(_) => return Err("SESSION_CONNECT_TIMEOUT".to_string()), + } + + let mut guard = state().lock().map_err(|_| "STATE_LOCK".to_string())?; + guard.session = Some(session); + guard.access_token = Some(access_token.to_string()); + Ok(()) +} + +pub fn set_access_token(access_token: &str) -> Result<(), String> { + let mut guard = state().lock().map_err(|_| "STATE_LOCK".to_string())?; + let Some(session) = guard.session.as_ref() else { + return Err("SESSION_NOT_CONNECTED".to_string()); + }; + // librespot marks the session invalid when the access point connection closes or the + // keep-alive times out. The stored `Session` handle stays usable, so without this check a + // dead session would accept token updates forever and every later track would load with no + // audio key. Reporting the invalid state lets the Kotlin side reconnect from scratch. + if session.is_invalid() { + return Err("SESSION_INVALID".to_string()); + } + guard.access_token = Some(access_token.to_string()); + Ok(()) +} + +pub fn disconnect() { + // Take the old handles out and release `STATE` before touching them. `Session::shutdown` + // waits on librespot's component mutexes; when the access-point connection has stalled, a + // network task can be holding one of them, so the call may not return. Holding `STATE` + // across it would wedge every native call (including the reconnect that follows). + let (player, session) = match state().lock() { + Ok(mut guard) => { + let player = guard.player.take(); + let session = guard.session.take(); + guard.events = None; + guard.access_token = None; + (player, session) + } + Err(_) => return, + }; + + if let Some(player) = player { + player.stop(); + } + + if let Some(session) = session { + // Reconnect must not wait for the dead session's teardown. If the shutdown cannot + // complete it leaks one thread for the lifetime of the process; that is preferable to + // blocking playback until the app is restarted. + std::thread::spawn(move || session.shutdown()); + } +} + +pub fn create_player(bitrate_kbps: u16) -> Result<(), String> { + let mut guard = state().lock().map_err(|_| "STATE_LOCK".to_string())?; + if guard.player.is_some() { + return Ok(()); + } + let session = guard + .session + .clone() + .ok_or_else(|| "SESSION_NOT_CONNECTED".to_string())?; + + let _ = audio_rx(); + let tx = AUDIO_TX + .get() + .ok_or_else(|| "AUDIO_UNINITIALISED".to_string())? + .clone(); + let volume = guard.volume.clone(); + let bitrate = match bitrate_kbps { + 0..=96 => Bitrate::Bitrate96, + 97..=160 => Bitrate::Bitrate160, + _ => Bitrate::Bitrate320, + }; + let config = PlayerConfig { + bitrate, + position_update_interval: Some(Duration::from_millis(500)), + ..PlayerConfig::default() + }; + let player = Player::new(config, session, Box::new(SharedVolume(volume)), move || { + Box::new(PcmSink::new(tx)) + }); + guard.events = Some(player.get_player_event_channel()); + guard.player = Some(player); + log::info!("engine.create_player created bitrate={bitrate_kbps}"); + Ok(()) +} + +pub fn load(uri: &str, position_ms: u32, autoplay: bool) -> Result<(), String> { + log::info!("engine.load begin uri={uri} position_ms={position_ms} autoplay={autoplay}"); + let uri = SpotifyUri::from_uri(uri).map_err(|e| format!("INVALID_URI: {e}"))?; + if !uri.is_playable() { + log::warn!("engine.load rejected: not playable"); + return Err("NOT_PLAYABLE".to_string()); + } + let player = { + let guard = state().lock().map_err(|_| "STATE_LOCK".to_string())?; + guard + .player + .clone() + .ok_or_else(|| "PLAYER_NOT_CREATED".to_string())? + }; + player.load(uri, autoplay, position_ms); + log::info!("engine.load command queued"); + Ok(()) +} + +pub fn play() -> Result<(), String> { + log::info!("engine.play"); + player()?.play(); + Ok(()) +} + +pub fn pause() -> Result<(), String> { + log::info!("engine.pause"); + player()?.pause(); + Ok(()) +} + +pub fn stop() -> Result<(), String> { + player()?.stop(); + Ok(()) +} + +pub fn seek(position_ms: u32) -> Result<(), String> { + player()?.seek(position_ms); + Ok(()) +} + +pub fn read_audio(timeout: Duration) -> Option> { + audio_rx().lock().ok()?.recv_timeout(timeout).ok() +} + +/// Discards queued PCM. Called when Kotlin stops draining (pause) and before starting a new +/// track, so stale audio from the previous position is not played. `PcmSink::write` no longer +/// blocks indefinitely, so this is a cleanup/safety net rather than the only way to unblock the +/// player thread. +pub fn drain_audio() { + if let Ok(rx) = audio_rx().lock() { + let mut drained = 0usize; + while rx.try_recv().is_ok() { + drained += 1; + } + if drained > 0 { + log::info!("audio drained {drained} queued buffers"); + } + } +} + +pub fn audio_format() -> String { + format!("{{\"format\":\"s16le\",\"sampleRate\":{SAMPLE_RATE},\"channels\":{CHANNELS}}}") +} + +pub fn poll_event() -> Option { + use librespot_playback::player::PlayerEvent; + use serde_json::json; + + let mut guard = state().lock().ok()?; + let events = guard.events.as_mut()?; + let event = events.try_recv().ok()?; + let value = match event { + PlayerEvent::Stopped { track_id, .. } => json!({"type": "stopped", "uri": uri(&track_id)}), + PlayerEvent::Loading { + track_id, + position_ms, + .. + } => { + json!({"type": "loading", "uri": uri(&track_id), "positionMs": position_ms}) + } + PlayerEvent::Playing { + track_id, + position_ms, + .. + } => { + json!({"type": "playing", "uri": uri(&track_id), "positionMs": position_ms}) + } + PlayerEvent::Paused { + track_id, + position_ms, + .. + } => { + json!({"type": "paused", "uri": uri(&track_id), "positionMs": position_ms}) + } + PlayerEvent::EndOfTrack { track_id, .. } => { + json!({"type": "ended", "uri": uri(&track_id)}) + } + PlayerEvent::Unavailable { track_id, .. } => { + json!({"type": "unavailable", "uri": uri(&track_id)}) + } + PlayerEvent::PositionChanged { position_ms, .. } + | PlayerEvent::PositionCorrection { position_ms, .. } + | PlayerEvent::Seeked { position_ms, .. } => { + json!({"type": "position", "positionMs": position_ms}) + } + PlayerEvent::VolumeChanged { volume } => json!({"type": "volume", "volume": volume}), + PlayerEvent::ShuffleChanged { shuffle } => json!({"type": "shuffle", "shuffle": shuffle}), + PlayerEvent::RepeatChanged { context, track } => { + json!({"type": "repeat", "context": context, "track": track}) + } + PlayerEvent::AutoPlayChanged { auto_play } => { + json!({"type": "autoplay", "autoplay": auto_play}) + } + _ => json!({"type": "other"}), + }; + Some(value.to_string()) +} + +fn uri(value: &SpotifyUri) -> String { + format!( + "spotify:{}:{}", + value.item_type(), + value.to_id().unwrap_or_default() + ) +} + +fn player() -> Result, String> { + let guard = state().lock().map_err(|_| "STATE_LOCK".to_string())?; + guard + .player + .clone() + .ok_or_else(|| "PLAYER_NOT_CREATED".to_string()) +} + +#[cfg(test)] +mod tests { + use super::audio_format; + + #[test] + fn audio_format_is_interleaved_s16_stereo() { + let format = audio_format(); + assert!(format.contains("\"format\":\"s16le\"")); + assert!(format.contains("\"sampleRate\":44100")); + assert!(format.contains("\"channels\":2")); + } +} diff --git a/native/sidekit-spotify/src/lib.rs b/native/sidekit-spotify/src/lib.rs new file mode 100644 index 00000000..37ba640e --- /dev/null +++ b/native/sidekit-spotify/src/lib.rs @@ -0,0 +1,427 @@ +//! JNI surface for Sideplay. All state lives in `engine`; these functions only translate. + +mod audio; +mod catalog; +mod engine; + +use jni::objects::{JObject, JString}; +use jni::sys::{jboolean, jbyteArray, jlong, JNI_FALSE, JNI_TRUE}; +use jni::JNIEnv; +use std::time::Duration; + +fn string(env: &mut JNIEnv, value: &JString) -> Option { + env.get_string(value).ok().map(Into::into) +} + +fn catalog_result(env: &mut JNIEnv, result: Result) -> jni::sys::jstring { + let value = match result { + Ok(value) => value, + Err(error) => serde_json::json!({ "error": error }).to_string(), + }; + env.new_string(value) + .map(|value| value.into_raw()) + .unwrap_or(std::ptr::null_mut()) +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativeInit( + _env: JNIEnv, + _this: JObject, +) -> jboolean { + #[cfg(target_os = "android")] + android_logger::init_once( + android_logger::Config::default().with_max_level(log::LevelFilter::Info), + ); + if engine::init().is_ok() { + JNI_TRUE + } else { + JNI_FALSE + } +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativeSetTmpDir( + mut env: JNIEnv, + _this: JObject, + path: JString, +) { + if let Some(path) = string(&mut env, &path) { + engine::set_tmp_dir(&path); + } +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativeSetCacheDir( + mut env: JNIEnv, + _this: JObject, + path: JString, + size_limit: jlong, +) { + if let Some(path) = string(&mut env, &path) { + engine::set_cache_dir(&path, size_limit.max(0) as u64); + } +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativeConnect( + mut env: JNIEnv, + _this: JObject, + token: JString, +) -> jboolean { + match string(&mut env, &token).map(|token| engine::connect(&token)) { + Some(Ok(())) => JNI_TRUE, + _ => JNI_FALSE, + } +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativeSetAccessToken( + mut env: JNIEnv, + _this: JObject, + token: JString, +) -> jni::sys::jboolean { + let Some(token) = string(&mut env, &token) else { + return JNI_FALSE; + }; + bool_result(engine::set_access_token(&token)) +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativeDisconnect( + _env: JNIEnv, + _this: JObject, +) { + engine::disconnect(); +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativeCreatePlayer( + _env: JNIEnv, + _this: JObject, + bitrate_kbps: jlong, +) -> jboolean { + if engine::create_player(bitrate_kbps.clamp(0, u16::MAX as i64) as u16).is_ok() { + JNI_TRUE + } else { + JNI_FALSE + } +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativeLoad( + mut env: JNIEnv, + _this: JObject, + uri: JString, + position_ms: jlong, + autoplay: jboolean, +) -> jboolean { + match string(&mut env, &uri) + .map(|uri| engine::load(&uri, position_ms.max(0) as u32, autoplay == JNI_TRUE)) + { + Some(Ok(())) => JNI_TRUE, + _ => JNI_FALSE, + } +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativePlay( + _env: JNIEnv, + _this: JObject, +) -> jboolean { + bool_result(engine::play()) +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativePause( + _env: JNIEnv, + _this: JObject, +) -> jboolean { + bool_result(engine::pause()) +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativeStop( + _env: JNIEnv, + _this: JObject, +) -> jboolean { + bool_result(engine::stop()) +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativeSeek( + _env: JNIEnv, + _this: JObject, + position_ms: jlong, +) -> jboolean { + bool_result(engine::seek(position_ms.max(0) as u32)) +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativeReadAudio( + env: JNIEnv, + _this: JObject, + timeout_ms: jlong, +) -> jbyteArray { + let timeout = Duration::from_millis(timeout_ms.max(0) as u64); + match engine::read_audio(timeout) { + Some(bytes) => match env.byte_array_from_slice(&bytes) { + Ok(array) => array.into_raw(), + Err(_) => std::ptr::null_mut(), + }, + None => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativeDrainAudio( + _env: JNIEnv, + _this: JObject, +) { + engine::drain_audio(); +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativeAudioFormat( + env: JNIEnv, + _this: JObject, +) -> jni::sys::jstring { + match env.new_string(engine::audio_format()) { + Ok(value) => value.into_raw(), + Err(_) => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativePollEvent( + env: JNIEnv, + _this: JObject, +) -> jni::sys::jstring { + match engine::poll_event() { + Some(event) => match env.new_string(event) { + Ok(value) => value.into_raw(), + Err(_) => std::ptr::null_mut(), + }, + None => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativeCatalogLibrary( + mut env: JNIEnv, + _this: JObject, + filter: JString, + offset: jlong, + limit: jlong, +) -> jni::sys::jstring { + let Some(filter) = string(&mut env, &filter) else { + return std::ptr::null_mut(); + }; + let result = engine::with_session_and_token(|session, token| { + engine::run(catalog::library( + session, + token, + &filter, + offset.max(0) as usize, + limit.max(1) as usize, + )) + }); + catalog_result(&mut env, result) +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativeCatalogCollection( + mut env: JNIEnv, + _this: JObject, + kind: JString, + id: JString, + offset: jlong, + limit: jlong, +) -> jni::sys::jstring { + let (Some(kind), Some(id)) = (string(&mut env, &kind), string(&mut env, &id)) else { + return std::ptr::null_mut(); + }; + let result = engine::with_session_and_token(|session, token| { + engine::run(catalog::collection( + session, + token, + &kind, + &id, + offset.max(0) as usize, + limit.max(1) as usize, + )) + }); + catalog_result(&mut env, result) +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativeCatalogArtist( + mut env: JNIEnv, + _this: JObject, + id: JString, +) -> jni::sys::jstring { + let Some(id) = string(&mut env, &id) else { + return std::ptr::null_mut(); + }; + let result = engine::with_session_and_token(|session, token| { + engine::run(catalog::artist(session, token, &id)) + }); + catalog_result(&mut env, result) +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativeCatalogAlbumsBy( + mut env: JNIEnv, + _this: JObject, + id: JString, + offset: jlong, + limit: jlong, +) -> jni::sys::jstring { + let Some(id) = string(&mut env, &id) else { + return std::ptr::null_mut(); + }; + let result = engine::with_session_and_token(|session, token| { + engine::run(catalog::albums_by( + session, + token, + &id, + offset.max(0) as usize, + limit.max(1) as usize, + )) + }); + catalog_result(&mut env, result) +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativeCatalogTopTracks( + mut env: JNIEnv, + _this: JObject, + id: JString, + offset: jlong, + limit: jlong, +) -> jni::sys::jstring { + let Some(id) = string(&mut env, &id) else { + return std::ptr::null_mut(); + }; + let result = engine::with_session_and_token(|session, token| { + engine::run(catalog::top_tracks( + session, + token, + &id, + offset.max(0) as usize, + limit.max(1) as usize, + )) + }); + catalog_result(&mut env, result) +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativeCatalogSearch( + mut env: JNIEnv, + _this: JObject, + query: JString, + scopes: JString, + offset: jlong, + limit: jlong, +) -> jni::sys::jstring { + let (Some(query), Some(scopes)) = (string(&mut env, &query), string(&mut env, &scopes)) else { + return std::ptr::null_mut(); + }; + let scopes: Vec = serde_json::from_str(&scopes).unwrap_or_default(); + let result = engine::with_session_and_token(|session, token| { + engine::run(catalog::search( + session, + token, + &query, + offset.max(0) as i32, + limit as i32, + &scopes, + )) + }); + catalog_result(&mut env, result) +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativeCollectionContains( + mut env: JNIEnv, + _this: JObject, + set: JString, + uris: JString, +) -> jni::sys::jstring { + let (Some(set), Some(uris)) = (string(&mut env, &set), string(&mut env, &uris)) else { + return std::ptr::null_mut(); + }; + let uris: Vec = serde_json::from_str(&uris).unwrap_or_default(); + let result = engine::with_session_and_token(|session, token| { + engine::run(catalog::collection_contains(session, token, &set, &uris)) + }); + catalog_result(&mut env, result) +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativeCollectionWrite( + mut env: JNIEnv, + _this: JObject, + set: JString, + uris: JString, + removed: jboolean, +) -> jboolean { + let (Some(set), Some(uris)) = (string(&mut env, &set), string(&mut env, &uris)) else { + return JNI_FALSE; + }; + let uris: Vec = serde_json::from_str(&uris).unwrap_or_default(); + let result = engine::with_session_and_token(|session, token| { + engine::run(catalog::collection_write( + session, + token, + &set, + &uris, + removed == JNI_TRUE, + )) + }); + bool_result(result) +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativePlaylistSaved( + mut env: JNIEnv, + _this: JObject, + id: JString, +) -> jni::sys::jstring { + let Some(id) = string(&mut env, &id) else { + return std::ptr::null_mut(); + }; + let result = engine::with_session_and_token(|session, token| { + engine::run(catalog::playlist_saved(session, token, &id)) + .and_then(|saved| serde_json::to_string(&saved).map_err(|e| e.to_string())) + }); + catalog_result(&mut env, result) +} + +#[no_mangle] +pub extern "system" fn Java_hrln_interactive_sidekit_apps_spotify_bridge_NativeBridge_nativePlaylistSave( + mut env: JNIEnv, + _this: JObject, + id: JString, + save: jboolean, +) -> jboolean { + let Some(id) = string(&mut env, &id) else { + return JNI_FALSE; + }; + let result = engine::with_session_and_token(|session, token| { + engine::run(catalog::playlist_save( + session, + token, + &id, + save == JNI_TRUE, + )) + }); + bool_result(result) +} + +fn bool_result(result: Result<(), String>) -> jboolean { + if result.is_ok() { + JNI_TRUE + } else { + JNI_FALSE + } +} diff --git a/native/vendor/librespot-core/Cargo.toml b/native/vendor/librespot-core/Cargo.toml new file mode 100644 index 00000000..4de310c1 --- /dev/null +++ b/native/vendor/librespot-core/Cargo.toml @@ -0,0 +1,275 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +rust-version = "1.85" +name = "librespot-core" +version = "0.8.0" +authors = ["Paul Lietar "] +build = "build.rs" +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "The core functionality provided by librespot" +readme = false +license = "MIT" +repository = "https://github.com/librespot-org/librespot" + +[features] +__rustls = [] +default = ["native-tls"] +native-tls = [ + "dep:hyper-tls", + "hyper-proxy2/tls", + "librespot-oauth/native-tls", + "tokio-tungstenite/native-tls", +] +rustls-tls-native-roots = [ + "__rustls", + "hyper-proxy2/rustls", + "hyper-rustls/native-tokio", + "librespot-oauth/rustls-tls-native-roots", + "tokio-tungstenite/rustls-tls-native-roots", +] +rustls-tls-webpki-roots = [ + "__rustls", + "hyper-proxy2/rustls-webpki", + "hyper-rustls/webpki-tokio", + "librespot-oauth/rustls-tls-webpki-roots", + "tokio-tungstenite/rustls-tls-webpki-roots", +] + +[lib] +name = "librespot_core" +path = "src/lib.rs" + +[[test]] +name = "connect" +path = "tests/connect.rs" + +[dependencies.aes] +version = "0.8" + +[dependencies.base64] +version = "0.22" + +[dependencies.byteorder] +version = "1.5" + +[dependencies.bytes] +version = "1" + +[dependencies.data-encoding] +version = "2.9" + +[dependencies.flate2] +version = "1.1" + +[dependencies.form_urlencoded] +version = "1.2" + +[dependencies.futures-core] +version = "0.3" + +[dependencies.futures-util] +version = "0.3" +features = [ + "alloc", + "bilock", + "unstable", +] +default-features = false + +[dependencies.governor] +version = "0.10" +features = ["std"] +default-features = false + +[dependencies.hmac] +version = "0.12" + +[dependencies.http] +version = "1.3" + +[dependencies.http-body-util] +version = "0.1" + +[dependencies.httparse] +version = "1.10" + +[dependencies.hyper] +version = "1.6" +features = [ + "http1", + "http2", +] + +[dependencies.hyper-proxy2] +version = "0.1" +default-features = false + +[dependencies.hyper-rustls] +version = "0.27" +features = [ + "http1", + "http2", + "ring", +] +optional = true +default-features = false + +[dependencies.hyper-tls] +version = "0.6" +optional = true + +[dependencies.hyper-util] +version = "0.1" +features = [ + "client", + "http1", + "http2", +] +default-features = false + +[dependencies.librespot-oauth] +version = "0.8.0" +default-features = false + +[dependencies.librespot-protocol] +version = "0.8.0" +default-features = false + +[dependencies.log] +version = "0.4" + +[dependencies.nonzero_ext] +version = "0.3" + +[dependencies.num-bigint] +version = "0.4" + +[dependencies.num-derive] +version = "0.4" + +[dependencies.num-integer] +version = "0.1" + +[dependencies.num-traits] +version = "0.2" + +[dependencies.pbkdf2] +version = "0.12" +features = ["hmac"] +default-features = false + +[dependencies.pin-project-lite] +version = "0.2" + +[dependencies.priority-queue] +version = "2.5" + +[dependencies.protobuf] +version = "3.7" + +[dependencies.protobuf-json-mapping] +version = "3.7" + +[dependencies.quick-xml] +version = "0.38" +features = ["serialize"] + +[dependencies.rand] +version = "0.9" +features = ["thread_rng"] +default-features = false + +[dependencies.rsa] +version = "0.9" + +[dependencies.serde] +version = "1.0" +features = ["derive"] + +[dependencies.serde_json] +version = "1.0" + +[dependencies.sha1] +version = "0.10" +features = ["oid"] + +[dependencies.shannon] +version = "0.2" + +[dependencies.sysinfo] +version = "0.36" +features = ["system"] +default-features = false + +[dependencies.thiserror] +version = "2" + +[dependencies.time] +version = "0.3" +features = [ + "formatting", + "parsing", +] + +[dependencies.tokio] +version = "1" +features = [ + "io-util", + "macros", + "net", + "rt", + "sync", + "time", +] + +[dependencies.tokio-stream] +version = "0.1" +default-features = false + +[dependencies.tokio-tungstenite] +version = "0.28" +default-features = false + +[dependencies.tokio-util] +version = "0.7" +default-features = false + +[dependencies.url] +version = "2" + +[dependencies.uuid] +version = "1" +features = ["v4"] +default-features = false + +[dev-dependencies.tokio] +version = "1" +features = ["macros"] + +[build-dependencies.rand] +version = "0.9" +features = ["thread_rng"] +default-features = false + +[build-dependencies.rand_distr] +version = "0.5" + +[build-dependencies.vergen-gitcl] +version = "1.0" +features = ["build"] +default-features = false diff --git a/native/vendor/librespot-core/Cargo.toml.orig b/native/vendor/librespot-core/Cargo.toml.orig new file mode 100644 index 00000000..ffe7c108 --- /dev/null +++ b/native/vendor/librespot-core/Cargo.toml.orig @@ -0,0 +1,120 @@ +[package] +name = "librespot-core" +version = "0.8.0" +rust-version.workspace = true +authors = ["Paul Lietar "] +license.workspace = true +description = "The core functionality provided by librespot" +repository.workspace = true +edition.workspace = true +build = "build.rs" + +[features] +# Refer to the workspace Cargo.toml for the list of features +default = ["native-tls"] + +# TLS backends (mutually exclusive - see oauth/src/lib.rs for compile-time checks) +# Note: Validation is in oauth since it's compiled first in the dependency tree. +native-tls = [ + "dep:hyper-tls", + "hyper-proxy2/tls", + "librespot-oauth/native-tls", + "tokio-tungstenite/native-tls", +] +rustls-tls-native-roots = [ + "__rustls", + "hyper-proxy2/rustls", + "hyper-rustls/native-tokio", + "librespot-oauth/rustls-tls-native-roots", + "tokio-tungstenite/rustls-tls-native-roots", +] +rustls-tls-webpki-roots = [ + "__rustls", + "hyper-proxy2/rustls-webpki", + "hyper-rustls/webpki-tokio", + "librespot-oauth/rustls-tls-webpki-roots", + "tokio-tungstenite/rustls-tls-webpki-roots", +] + +# Internal features - these are not meant to be used by end users +__rustls = [] + +[dependencies] +librespot-oauth = { version = "0.8.0", path = "../oauth", default-features = false } +librespot-protocol = { version = "0.8.0", path = "../protocol", default-features = false } + +aes = "0.8" +base64 = "0.22" +byteorder = "1.5" +bytes = "1" +data-encoding = "2.9" +flate2 = "1.1" +form_urlencoded = "1.2" +futures-core = "0.3" +futures-util = { version = "0.3", default-features = false, features = [ + "alloc", + "bilock", + "unstable", +] } +governor = { version = "0.10", default-features = false, features = ["std"] } +hmac = "0.12" +httparse = "1.10" +http = "1.3" +http-body-util = "0.1" +hyper = { version = "1.6", features = ["http1", "http2"] } +hyper-proxy2 = { version = "0.1", default-features = false } +hyper-rustls = { version = "0.27", default-features = false, features = [ + "http1", + "http2", + "ring", +], optional = true } +hyper-tls = { version = "0.6", optional = true } +hyper-util = { version = "0.1", default-features = false, features = [ + "client", + "http1", + "http2", +] } +log = "0.4" +nonzero_ext = "0.3" +num-bigint = "0.4" +num-derive = "0.4" +num-integer = "0.1" +num-traits = "0.2" +pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } +pin-project-lite = "0.2" +priority-queue = "2.5" +protobuf = "3.7" +protobuf-json-mapping = "3.7" +quick-xml = { version = "0.38", features = ["serialize"] } +rand = { version = "0.9", default-features = false, features = ["thread_rng"] } +rsa = "0.9" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha1 = { version = "0.10", features = ["oid"] } +shannon = "0.2" +sysinfo = { version = "0.36", default-features = false, features = ["system"] } +thiserror = "2" +time = { version = "0.3", features = ["formatting", "parsing"] } +tokio = { version = "1", features = [ + "io-util", + "macros", + "net", + "rt", + "sync", + "time", +] } +tokio-stream = { version = "0.1", default-features = false } +tokio-tungstenite = { version = "0.28", default-features = false } +tokio-util = { version = "0.7", default-features = false } +url = "2" +uuid = { version = "1", default-features = false, features = ["v4"] } + +[build-dependencies] +rand = { version = "0.9", default-features = false, features = ["thread_rng"] } +rand_distr = "0.5" +vergen-gitcl = { version = "1.0", default-features = false, features = [ + "build", +] } + +[dev-dependencies] +tokio = { version = "1", features = ["macros"] } diff --git a/native/vendor/librespot-core/LICENSE b/native/vendor/librespot-core/LICENSE new file mode 100644 index 00000000..b06ecfb2 --- /dev/null +++ b/native/vendor/librespot-core/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Paul Lietar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/native/vendor/librespot-core/build.rs b/native/vendor/librespot-core/build.rs new file mode 100644 index 00000000..4413d2f3 --- /dev/null +++ b/native/vendor/librespot-core/build.rs @@ -0,0 +1,31 @@ +use rand::Rng; +use rand_distr::Alphanumeric; +use vergen_gitcl::{BuildBuilder, Emitter, GitclBuilder}; + +fn main() -> Result<(), Box> { + let gitcl = GitclBuilder::default() + .sha(true) // outputs 'VERGEN_GIT_SHA', and sets the 'short' flag true + .commit_date(true) // outputs 'VERGEN_GIT_COMMIT_DATE' + .build()?; + + let build = BuildBuilder::default() + .build_date(true) // outputs 'VERGEN_BUILD_DATE' + .build()?; + + Emitter::default() + .add_instructions(&build)? + .add_instructions(&gitcl)? + .emit() + .expect("Unable to generate the cargo keys!"); + let build_id = match std::env::var("SOURCE_DATE_EPOCH") { + Ok(val) => val, + Err(_) => rand::rng() + .sample_iter(Alphanumeric) + .take(8) + .map(char::from) + .collect(), + }; + + println!("cargo:rustc-env=LIBRESPOT_BUILD_ID={build_id}"); + Ok(()) +} diff --git a/native/vendor/librespot-core/src/apresolve.rs b/native/vendor/librespot-core/src/apresolve.rs new file mode 100644 index 00000000..38dde797 --- /dev/null +++ b/native/vendor/librespot-core/src/apresolve.rs @@ -0,0 +1,153 @@ +use std::collections::VecDeque; + +use bytes::Bytes; +use hyper::{Method, Request}; +use serde::Deserialize; + +use crate::Error; + +pub type SocketAddress = (String, u16); + +#[derive(Default)] +pub struct AccessPoints { + accesspoint: VecDeque, + dealer: VecDeque, + spclient: VecDeque, +} + +#[derive(Deserialize, Default)] +pub struct ApResolveData { + accesspoint: Vec, + dealer: Vec, + spclient: Vec, +} + +impl ApResolveData { + // These addresses probably do some geo-location based traffic management or at least DNS-based + // load balancing. They are known to fail when the normal resolvers are up, so that's why they + // should only be used as fallback. + fn fallback() -> Self { + Self { + accesspoint: vec![String::from("ap.spotify.com:443")], + dealer: vec![String::from("dealer.spotify.com:443")], + spclient: vec![String::from("spclient.wg.spotify.com:443")], + } + } +} + +impl AccessPoints { + fn is_any_empty(&self) -> bool { + self.accesspoint.is_empty() || self.dealer.is_empty() || self.spclient.is_empty() + } +} + +component! { + ApResolver : ApResolverInner { + data: AccessPoints = AccessPoints::default(), + } +} + +impl ApResolver { + // return a port if a proxy URL and/or a proxy port was specified. This is useful even when + // there is no proxy, but firewalls only allow certain ports (e.g. 443 and not 4070). + pub fn port_config(&self) -> Option { + if self.session().config().proxy.is_some() || self.session().config().ap_port.is_some() { + Some(self.session().config().ap_port.unwrap_or(443)) + } else { + None + } + } + + fn process_ap_strings(&self, data: Vec) -> VecDeque { + let filter_port = self.port_config(); + data.into_iter() + .filter_map(|ap| { + let mut split = ap.rsplitn(2, ':'); + let port = split.next()?; + let port: u16 = port.parse().ok()?; + let host = split.next()?.to_owned(); + match filter_port { + Some(filter_port) if filter_port != port => None, + _ => Some((host, port)), + } + }) + .collect() + } + + fn parse_resolve_to_access_points(&self, resolve: ApResolveData) -> AccessPoints { + AccessPoints { + accesspoint: self.process_ap_strings(resolve.accesspoint), + dealer: self.process_ap_strings(resolve.dealer), + spclient: self.process_ap_strings(resolve.spclient), + } + } + + pub async fn try_apresolve(&self) -> Result { + let req = Request::builder() + .method(Method::GET) + .uri("https://apresolve.spotify.com/?type=accesspoint&type=dealer&type=spclient") + .body(Bytes::new())?; + + let body = self.session().http_client().request_body(req).await?; + let data: ApResolveData = serde_json::from_slice(body.as_ref())?; + + Ok(data) + } + + async fn apresolve(&self) { + let result = self.try_apresolve().await; + + self.lock(|inner| { + let (data, error) = match result { + Ok(data) => (data, None), + Err(e) => (ApResolveData::default(), Some(e)), + }; + + inner.data = self.parse_resolve_to_access_points(data); + + if inner.data.is_any_empty() { + warn!("Failed to resolve all access points, using fallbacks"); + if let Some(error) = error { + warn!("Resolve access points error: {error}"); + } + + let fallback = self.parse_resolve_to_access_points(ApResolveData::fallback()); + inner.data.accesspoint.extend(fallback.accesspoint); + inner.data.dealer.extend(fallback.dealer); + inner.data.spclient.extend(fallback.spclient); + } + }) + } + + fn is_any_empty(&self) -> bool { + self.lock(|inner| inner.data.is_any_empty()) + } + + pub async fn resolve(&self, endpoint: &str) -> Result { + if self.is_any_empty() { + self.apresolve().await; + } + + self.lock(|inner| { + let access_point = match endpoint { + // take the first position instead of the last with `pop`, because Spotify returns + // access points with ports 4070, 443 and 80 in order of preference from highest + // to lowest. + "accesspoint" => inner.data.accesspoint.pop_front(), + "dealer" => inner.data.dealer.pop_front(), + "spclient" => inner.data.spclient.pop_front(), + _ => { + return Err(Error::unimplemented(format!( + "No implementation to resolve access point {endpoint}" + ))); + } + }; + + let access_point = access_point.ok_or_else(|| { + Error::unavailable(format!("No access point available for endpoint {endpoint}")) + })?; + + Ok(access_point) + }) + } +} diff --git a/native/vendor/librespot-core/src/audio_key.rs b/native/vendor/librespot-core/src/audio_key.rs new file mode 100644 index 00000000..42cad43c --- /dev/null +++ b/native/vendor/librespot-core/src/audio_key.rs @@ -0,0 +1,110 @@ +use std::{collections::HashMap, io::Write, time::Duration}; + +use byteorder::{BigEndian, ByteOrder, WriteBytesExt}; +use bytes::Bytes; +use thiserror::Error; +use tokio::sync::oneshot; + +use crate::{Error, FileId, SpotifyId, packet::PacketType, util::SeqGenerator}; + +#[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)] +pub struct AudioKey(pub [u8; 16]); + +#[derive(Debug, Error)] +pub enum AudioKeyError { + #[error("audio key error")] + AesKey, + #[error("other end of channel disconnected")] + Channel, + #[error("unexpected packet type {0}")] + Packet(u8), + #[error("sequence {0} not pending")] + Sequence(u32), + #[error("audio key response timeout")] + Timeout, +} + +impl From for Error { + fn from(err: AudioKeyError) -> Self { + match err { + AudioKeyError::AesKey => Error::unavailable(err), + AudioKeyError::Channel => Error::aborted(err), + AudioKeyError::Sequence(_) => Error::aborted(err), + AudioKeyError::Packet(_) => Error::unimplemented(err), + AudioKeyError::Timeout => Error::aborted(err), + } + } +} + +component! { + AudioKeyManager : AudioKeyManagerInner { + sequence: SeqGenerator = SeqGenerator::new(0), + pending: HashMap>> = HashMap::new(), + } +} + +impl AudioKeyManager { + pub(crate) fn dispatch(&self, cmd: PacketType, mut data: Bytes) -> Result<(), Error> { + let seq = BigEndian::read_u32(data.split_to(4).as_ref()); + + let sender = self + .lock(|inner| inner.pending.remove(&seq)) + .ok_or(AudioKeyError::Sequence(seq))?; + + match cmd { + PacketType::AesKey => { + let mut key = [0u8; 16]; + key.copy_from_slice(data.as_ref()); + sender + .send(Ok(AudioKey(key))) + .map_err(|_| AudioKeyError::Channel)? + } + PacketType::AesKeyError => { + error!( + "error audio key {:x} {:x}", + data.as_ref()[0], + data.as_ref()[1] + ); + sender + .send(Err(AudioKeyError::AesKey.into())) + .map_err(|_| AudioKeyError::Channel)? + } + _ => { + trace!("Did not expect {cmd:?} AES key packet with data {data:#?}"); + return Err(AudioKeyError::Packet(cmd as u8).into()); + } + } + + Ok(()) + } + + pub async fn request(&self, track: SpotifyId, file: FileId) -> Result { + let (tx, rx) = oneshot::channel(); + + let seq = self.lock(move |inner| { + let seq = inner.sequence.get(); + inner.pending.insert(seq, tx); + seq + }); + + self.send_key_request(seq, track, file)?; + const KEY_RESPONSE_TIMEOUT: Duration = Duration::from_millis(1500); + match tokio::time::timeout(KEY_RESPONSE_TIMEOUT, rx).await { + Err(_) => { + error!("Audio key response timeout"); + Err(AudioKeyError::Timeout.into()) + } + Ok(k) => k?, + } + } + + fn send_key_request(&self, seq: u32, track: SpotifyId, file: FileId) -> Result<(), Error> { + let mut data: Vec = Vec::new(); + data.write_all(&file.0)?; + data.write_all(&track.to_raw())?; + data.write_u32::(seq)?; + data.write_u16::(0x0000)?; + + self.session().send_packet(PacketType::RequestKey, data) + } +} diff --git a/native/vendor/librespot-core/src/authentication.rs b/native/vendor/librespot-core/src/authentication.rs new file mode 100644 index 00000000..62bab6a8 --- /dev/null +++ b/native/vendor/librespot-core/src/authentication.rs @@ -0,0 +1,189 @@ +use std::io::{self, Read}; + +use aes::Aes192; +use base64::engine::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use byteorder::{BigEndian, ByteOrder}; +use pbkdf2::pbkdf2_hmac; +use protobuf::Enum; +use serde::{Deserialize, Serialize}; +use sha1::{Digest, Sha1}; +use thiserror::Error; + +use crate::{Error, protocol::authentication::AuthenticationType}; + +#[derive(Debug, Error)] +pub enum AuthenticationError { + #[error("unknown authentication type {0}")] + AuthType(u32), + #[error("invalid key")] + Key, +} + +impl From for Error { + fn from(err: AuthenticationError) -> Self { + Error::invalid_argument(err) + } +} + +/// The credentials are used to log into the Spotify API. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct Credentials { + pub username: Option, + + #[serde(serialize_with = "serialize_protobuf_enum")] + #[serde(deserialize_with = "deserialize_protobuf_enum")] + pub auth_type: AuthenticationType, + + #[serde(alias = "encoded_auth_blob")] + #[serde(serialize_with = "serialize_base64")] + #[serde(deserialize_with = "deserialize_base64")] + pub auth_data: Vec, +} + +impl Credentials { + /// Intialize these credentials from a username and a password. + /// + /// ### Example + /// ```rust + /// use librespot_core::authentication::Credentials; + /// + /// let creds = Credentials::with_password("my account", "my password"); + /// ``` + pub fn with_password(username: impl Into, password: impl Into) -> Self { + Self { + username: Some(username.into()), + auth_type: AuthenticationType::AUTHENTICATION_USER_PASS, + auth_data: password.into().into_bytes(), + } + } + + pub fn with_access_token(token: impl Into) -> Self { + Self { + username: None, + auth_type: AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN, + auth_data: token.into().into_bytes(), + } + } + + #[expect(deprecated)] + pub fn with_blob( + username: impl Into, + encrypted_blob: impl AsRef<[u8]>, + device_id: impl AsRef<[u8]>, + ) -> Result { + fn read_u8(stream: &mut R) -> io::Result { + let mut data = [0u8]; + stream.read_exact(&mut data)?; + Ok(data[0]) + } + + fn read_int(stream: &mut R) -> io::Result { + let lo = read_u8(stream)? as u32; + if lo & 0x80 == 0 { + return Ok(lo); + } + + let hi = read_u8(stream)? as u32; + Ok(lo & 0x7f | (hi << 7)) + } + + fn read_bytes(stream: &mut R) -> io::Result> { + let length = read_int(stream)?; + let mut data = vec![0u8; length as usize]; + stream.read_exact(&mut data)?; + + Ok(data) + } + + let username = username.into(); + + let secret = Sha1::digest(device_id.as_ref()); + + let key = { + let mut key = [0u8; 24]; + if key.len() < 20 { + return Err(AuthenticationError::Key.into()); + } + + pbkdf2_hmac::(&secret, username.as_bytes(), 0x100, &mut key[0..20]); + + let hash = &Sha1::digest(&key[..20]); + key[..20].copy_from_slice(hash); + BigEndian::write_u32(&mut key[20..], 20); + key + }; + + // decrypt data using ECB mode without padding + let blob = { + use aes::cipher::generic_array::GenericArray; + use aes::cipher::{BlockDecrypt, BlockSizeUser, KeyInit}; + + let mut data = BASE64.decode(encrypted_blob)?; + let cipher = Aes192::new(GenericArray::from_slice(&key)); + let block_size = Aes192::block_size(); + + for chunk in data.chunks_exact_mut(block_size) { + cipher.decrypt_block(GenericArray::from_mut_slice(chunk)); + } + + let l = data.len(); + for i in 0..l - 0x10 { + data[l - i - 1] ^= data[l - i - 0x11]; + } + + data + }; + + let mut cursor = io::Cursor::new(blob.as_slice()); + read_u8(&mut cursor)?; + read_bytes(&mut cursor)?; + read_u8(&mut cursor)?; + let auth_type = read_int(&mut cursor)?; + let auth_type = AuthenticationType::from_i32(auth_type as i32) + .ok_or(AuthenticationError::AuthType(auth_type))?; + read_u8(&mut cursor)?; + let auth_data = read_bytes(&mut cursor)?; + + Ok(Self { + username: Some(username), + auth_type, + auth_data, + }) + } +} + +fn serialize_protobuf_enum(v: &T, ser: S) -> Result +where + T: Enum, + S: serde::Serializer, +{ + serde::Serialize::serialize(&v.value(), ser) +} + +fn deserialize_protobuf_enum<'de, T, D>(de: D) -> Result +where + T: Enum, + D: serde::Deserializer<'de>, +{ + let v: i32 = serde::Deserialize::deserialize(de)?; + T::from_i32(v).ok_or_else(|| serde::de::Error::custom("Invalid enum value")) +} + +fn serialize_base64(v: &T, ser: S) -> Result +where + T: AsRef<[u8]>, + S: serde::Serializer, +{ + serde::Serialize::serialize(&BASE64.encode(v.as_ref()), ser) +} + +fn deserialize_base64<'de, D>(de: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let v: String = serde::Deserialize::deserialize(de)?; + BASE64 + .decode(v) + .map_err(|e| serde::de::Error::custom(e.to_string())) +} diff --git a/native/vendor/librespot-core/src/cache.rs b/native/vendor/librespot-core/src/cache.rs new file mode 100644 index 00000000..15e35d21 --- /dev/null +++ b/native/vendor/librespot-core/src/cache.rs @@ -0,0 +1,494 @@ +use std::{ + cmp::Reverse, + collections::HashMap, + fs::{self, File}, + io::{self, Read, Write}, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::SystemTime, +}; + +use priority_queue::PriorityQueue; +use thiserror::Error; + +use crate::{Error, FileId, authentication::Credentials, error::ErrorKind}; + +const CACHE_LIMITER_POISON_MSG: &str = "cache limiter mutex should not be poisoned"; + +#[derive(Debug, Error)] +pub enum CacheError { + #[error("audio cache location is not configured")] + Path, +} + +impl From for Error { + fn from(err: CacheError) -> Self { + Error::failed_precondition(err) + } +} + +/// Some kind of data structure that holds some paths, the size of these files and a timestamp. +/// It keeps track of the file sizes and is able to pop the path with the oldest timestamp if +/// a given limit is exceeded. +struct SizeLimiter { + queue: PriorityQueue>, + sizes: HashMap, + size_limit: u64, + in_use: u64, +} + +impl SizeLimiter { + /// Creates a new instance with the given size limit. + fn new(limit: u64) -> Self { + Self { + queue: PriorityQueue::new(), + sizes: HashMap::new(), + size_limit: limit, + in_use: 0, + } + } + + /// Adds an entry to this data structure. + /// + /// If this file is already contained, it will be updated accordingly. + fn add(&mut self, file: &Path, size: u64, accessed: SystemTime) { + self.in_use += size; + self.queue.push(file.to_owned(), Reverse(accessed)); + if let Some(old_size) = self.sizes.insert(file.to_owned(), size) { + // It's important that decreasing happens after + // increasing the size, to prevent an overflow. + self.in_use -= old_size; + } + } + + /// Returns true if the limit is exceeded. + fn exceeds_limit(&self) -> bool { + self.in_use > self.size_limit + } + + /// Returns the least recently accessed file if the size of the cache exceeds + /// the limit. + /// + /// The entry is removed from the data structure, but the caller is responsible + /// to delete the file in the file system. + fn pop(&mut self) -> Option { + if self.exceeds_limit() { + if let Some((next, _)) = self.queue.pop() { + if let Some(size) = self.sizes.remove(&next) { + self.in_use -= size; + } else { + error!("`queue` and `sizes` should have the same keys."); + } + Some(next) + } else { + error!("in_use was > 0, so the queue should have contained an item."); + None + } + } else { + None + } + } + + /// Updates the timestamp of an existing element. Returns `true` if the item did exist. + fn update(&mut self, file: &Path, access_time: SystemTime) -> bool { + self.queue + .change_priority(file, Reverse(access_time)) + .is_some() + } + + /// Removes an element with the specified path. Returns `true` if the item did exist. + fn remove(&mut self, file: &Path) -> bool { + if self.queue.remove(file).is_none() { + return false; + } + + if let Some(size) = self.sizes.remove(file) { + self.in_use -= size; + } else { + error!("`queue` and `sizes` should have the same keys."); + } + + true + } +} + +struct FsSizeLimiter { + limiter: Mutex, +} + +impl FsSizeLimiter { + /// Returns access time and file size of a given path. + fn get_metadata(file: &Path) -> io::Result<(SystemTime, u64)> { + let metadata = file.metadata()?; + + // The first of the following timestamps which is available will be chosen as access time: + // 1. Access time + // 2. Modification time + // 3. Creation time + // 4. Current time + let access_time = metadata + .accessed() + .or_else(|_| metadata.modified()) + .or_else(|_| metadata.created()) + .unwrap_or_else(|_| SystemTime::now()); + + let size = metadata.len(); + + Ok((access_time, size)) + } + + /// Recursively search a directory for files and add them to the `limiter` struct. + fn init_dir(limiter: &mut SizeLimiter, path: &Path) { + let list_dir = match fs::read_dir(path) { + Ok(list_dir) => list_dir, + Err(e) => { + warn!("Could not read directory {path:?} in cache dir: {e}"); + return; + } + }; + + for entry in list_dir { + let entry = match entry { + Ok(entry) => entry, + Err(e) => { + warn!("Could not directory {path:?} in cache dir: {e}"); + return; + } + }; + + match entry.file_type() { + Ok(file_type) if file_type.is_dir() || file_type.is_symlink() => { + Self::init_dir(limiter, &entry.path()) + } + Ok(file_type) if file_type.is_file() => { + let path = entry.path(); + match Self::get_metadata(&path) { + Ok((access_time, size)) => { + limiter.add(&path, size, access_time); + } + Err(e) => { + warn!("Could not read file {path:?} in cache dir: {e}") + } + } + } + Ok(ft) => { + warn!( + "File {:?} in cache dir has unsupported type {:?}", + entry.path(), + ft + ) + } + Err(e) => { + warn!( + "Could not get type of file {:?} in cache dir: {}", + entry.path(), + e + ) + } + }; + } + } + + fn add(&self, file: &Path, size: u64) { + self.limiter + .lock() + .expect(CACHE_LIMITER_POISON_MSG) + .add(file, size, SystemTime::now()) + } + + fn touch(&self, file: &Path) -> bool { + self.limiter + .lock() + .expect(CACHE_LIMITER_POISON_MSG) + .update(file, SystemTime::now()) + } + + fn remove(&self, file: &Path) -> bool { + self.limiter + .lock() + .expect(CACHE_LIMITER_POISON_MSG) + .remove(file) + } + + fn prune_internal Option>(mut pop: F) -> Result<(), Error> { + let mut first = true; + let mut count = 0; + let mut last_error = None; + + while let Some(file) = pop() { + if first { + debug!("Cache dir exceeds limit, removing least recently used files."); + first = false; + } + + let res = fs::remove_file(&file); + if let Err(e) = res { + warn!("Could not remove file {file:?} from cache dir: {e}"); + last_error = Some(e); + } else { + count += 1; + } + } + + if count > 0 { + info!("Removed {count} cache files."); + } + + if let Some(err) = last_error { + Err(err.into()) + } else { + Ok(()) + } + } + + fn prune(&self) -> Result<(), Error> { + Self::prune_internal(|| self.limiter.lock().expect(CACHE_LIMITER_POISON_MSG).pop()) + } + + fn new(path: &Path, limit: u64) -> Result { + let mut limiter = SizeLimiter::new(limit); + + Self::init_dir(&mut limiter, path); + Self::prune_internal(|| limiter.pop())?; + + Ok(Self { + limiter: Mutex::new(limiter), + }) + } +} + +/// A cache for volume, credentials and audio files. +#[derive(Clone)] +pub struct Cache { + credentials_location: Option, + volume_location: Option, + audio_location: Option, + size_limiter: Option>, +} + +impl Cache { + pub fn new>( + credentials_path: Option

, + volume_path: Option

, + audio_path: Option

, + size_limit: Option, + ) -> Result { + let mut size_limiter = None; + + if let Some(location) = &credentials_path { + fs::create_dir_all(location)?; + } + + let credentials_location = credentials_path + .as_ref() + .map(|p| p.as_ref().join("credentials.json")); + + if let Some(location) = &volume_path { + fs::create_dir_all(location)?; + } + + let volume_location = volume_path.as_ref().map(|p| p.as_ref().join("volume")); + + if let Some(location) = &audio_path { + fs::create_dir_all(location)?; + + if let Some(limit) = size_limit { + let limiter = FsSizeLimiter::new(location.as_ref(), limit)?; + size_limiter = Some(Arc::new(limiter)); + } + } + + let audio_location = audio_path.map(|p| p.as_ref().to_owned()); + + let cache = Cache { + credentials_location, + volume_location, + audio_location, + size_limiter, + }; + + Ok(cache) + } + + pub fn credentials(&self) -> Option { + let location = self.credentials_location.as_ref()?; + + // This closure is just convencience to enable the question mark operator + let read = || -> Result { + let mut file = File::open(location)?; + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + Ok(serde_json::from_str(&contents)?) + }; + + match read() { + Ok(c) => Some(c), + Err(e) => { + // If the file did not exist, the file was probably not written + // before. Otherwise, log the error. + if e.kind != ErrorKind::NotFound { + warn!("Error reading credentials from cache: {e}"); + } + None + } + } + } + + pub fn save_credentials(&self, cred: &Credentials) { + if let Some(location) = &self.credentials_location { + let result = File::create(location).and_then(|mut file| { + let data = serde_json::to_string(cred)?; + write!(file, "{data}") + }); + + if let Err(e) = result { + warn!("Cannot save credentials to cache: {e}") + } + } + } + + pub fn volume(&self) -> Option { + let location = self.volume_location.as_ref()?; + + let read = || -> Result { + let mut file = File::open(location)?; + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + Ok(contents.parse()?) + }; + + match read() { + Ok(v) => Some(v), + Err(e) => { + if e.kind != ErrorKind::NotFound { + warn!("Error reading volume from cache: {e}"); + } + None + } + } + } + + pub fn save_volume(&self, volume: u16) { + if let Some(ref location) = self.volume_location { + let result = File::create(location).and_then(|mut file| write!(file, "{volume}")); + if let Err(e) = result { + warn!("Cannot save volume to cache: {e}"); + } + } + } + + pub fn file_path(&self, file: FileId) -> Option { + match file.to_base16() { + Ok(name) => self.audio_location.as_ref().map(|location| { + let mut path = location.join(&name[0..2]); + path.push(&name[2..]); + path + }), + Err(e) => { + warn!("Invalid FileId: {e}"); + None + } + } + } + + pub fn file(&self, file: FileId) -> Option { + let path = self.file_path(file)?; + match File::open(&path) { + Ok(file) => { + if let Some(limiter) = self.size_limiter.as_deref() { + if !limiter.touch(&path) { + error!("limiter could not touch {path:?}"); + } + } + Some(file) + } + Err(e) => { + if e.kind() != io::ErrorKind::NotFound { + warn!("Error reading file from cache: {e}") + } + None + } + } + } + + pub fn save_file(&self, file: FileId, contents: &mut F) -> Result { + if let Some(path) = self.file_path(file) { + if let Some(parent) = path.parent() { + if let Ok(size) = fs::create_dir_all(parent) + .and_then(|_| File::create(&path)) + .and_then(|mut file| io::copy(contents, &mut file)) + { + if let Some(limiter) = self.size_limiter.as_deref() { + limiter.add(&path, size); + limiter.prune()?; + } + return Ok(path); + } + } + } + Err(CacheError::Path.into()) + } + + pub fn remove_file(&self, file: FileId) -> Result<(), Error> { + let path = self.file_path(file).ok_or(CacheError::Path)?; + + fs::remove_file(&path)?; + if let Some(limiter) = self.size_limiter.as_deref() { + limiter.remove(&path); + } + + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use std::time::Duration; + + fn ordered_time(v: u64) -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_secs(v) + } + + #[test] + fn test_size_limiter() { + let mut limiter = SizeLimiter::new(1000); + + limiter.add(Path::new("a"), 500, ordered_time(2)); + limiter.add(Path::new("b"), 500, ordered_time(1)); + + // b (500) -> a (500) => sum: 1000 <= 1000 + assert!(!limiter.exceeds_limit()); + assert_eq!(limiter.pop(), None); + + limiter.add(Path::new("c"), 1000, ordered_time(3)); + + // b (500) -> a (500) -> c (1000) => sum: 2000 > 1000 + assert!(limiter.exceeds_limit()); + assert_eq!(limiter.pop().as_deref(), Some(Path::new("b"))); + // a (500) -> c (1000) => sum: 1500 > 1000 + assert_eq!(limiter.pop().as_deref(), Some(Path::new("a"))); + // c (1000) => sum: 1000 <= 1000 + assert_eq!(limiter.pop().as_deref(), None); + + limiter.add(Path::new("d"), 5, ordered_time(2)); + // d (5) -> c (1000) => sum: 1005 > 1000 + assert_eq!(limiter.pop().as_deref(), Some(Path::new("d"))); + // c (1000) => sum: 1000 <= 1000 + assert_eq!(limiter.pop().as_deref(), None); + + // Test updating + + limiter.add(Path::new("e"), 500, ordered_time(3)); + // c (1000) -> e (500) => sum: 1500 > 1000 + assert!(limiter.update(Path::new("c"), ordered_time(4))); + // e (500) -> c (1000) => sum: 1500 > 1000 + assert_eq!(limiter.pop().as_deref(), Some(Path::new("e"))); + // c (1000) => sum: 1000 <= 1000 + + // Test removing + limiter.add(Path::new("f"), 500, ordered_time(2)); + assert!(limiter.remove(Path::new("c"))); + assert!(!limiter.exceeds_limit()); + } +} diff --git a/native/vendor/librespot-core/src/cdn_url.rs b/native/vendor/librespot-core/src/cdn_url.rs new file mode 100644 index 00000000..818f68f5 --- /dev/null +++ b/native/vendor/librespot-core/src/cdn_url.rs @@ -0,0 +1,259 @@ +use std::ops::{Deref, DerefMut}; + +use protobuf::Message; +use thiserror::Error; +use time::Duration; +use url::Url; + +use super::{Error, FileId, Session, date::Date}; + +use librespot_protocol as protocol; +use protocol::storage_resolve::StorageResolveResponse as CdnUrlMessage; +use protocol::storage_resolve::storage_resolve_response::Result as StorageResolveResponse_Result; + +#[derive(Debug, Clone)] +pub struct MaybeExpiringUrl(pub String, pub Option); + +const CDN_URL_EXPIRY_MARGIN: Duration = Duration::seconds(5 * 60); + +#[derive(Debug, Clone)] +pub struct MaybeExpiringUrls(pub Vec); + +impl Deref for MaybeExpiringUrls { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for MaybeExpiringUrls { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +#[derive(Debug, Error)] +pub enum CdnUrlError { + #[error("all URLs expired")] + Expired, + #[error("resolved storage is not for CDN")] + Storage, + #[error("no URLs resolved")] + Unresolved, +} + +impl From for Error { + fn from(err: CdnUrlError) -> Self { + match err { + CdnUrlError::Expired => Error::deadline_exceeded(err), + CdnUrlError::Storage | CdnUrlError::Unresolved => Error::unavailable(err), + } + } +} + +#[derive(Debug, Clone)] +pub struct CdnUrl { + pub file_id: FileId, + urls: MaybeExpiringUrls, +} + +impl CdnUrl { + pub fn new(file_id: FileId) -> Self { + Self { + file_id, + urls: MaybeExpiringUrls(Vec::new()), + } + } + + pub async fn resolve_audio(&self, session: &Session) -> Result { + let file_id = self.file_id; + let response = session.spclient().get_audio_storage(&file_id).await?; + let msg = CdnUrlMessage::parse_from_bytes(&response)?; + let urls = MaybeExpiringUrls::try_from(msg)?; + + let cdn_url = Self { file_id, urls }; + + trace!("Resolved CDN storage: {cdn_url:#?}"); + + Ok(cdn_url) + } + + #[deprecated = "This function only returns the first valid URL. Use try_get_urls instead, which allows for fallback logic."] + pub fn try_get_url(&self) -> Result<&str, Error> { + if self.urls.is_empty() { + return Err(CdnUrlError::Unresolved.into()); + } + + let now = Date::now_utc(); + let url = self.urls.iter().find(|url| match url.1 { + Some(expiry) => now < expiry, + None => true, + }); + + if let Some(url) = url { + Ok(&url.0) + } else { + Err(CdnUrlError::Expired.into()) + } + } + + pub fn try_get_urls(&self) -> Result, Error> { + if self.urls.is_empty() { + return Err(CdnUrlError::Unresolved.into()); + } + + let now = Date::now_utc(); + let urls: Vec<&str> = self + .urls + .iter() + .filter_map(|MaybeExpiringUrl(url, expiry)| match *expiry { + Some(expiry) => { + if now < expiry { + Some(url.as_str()) + } else { + None + } + } + None => Some(url.as_str()), + }) + .collect(); + + if urls.is_empty() { + Err(CdnUrlError::Expired.into()) + } else { + Ok(urls) + } + } +} + +impl TryFrom for MaybeExpiringUrls { + type Error = crate::Error; + fn try_from(msg: CdnUrlMessage) -> Result { + if !matches!( + msg.result.enum_value_or_default(), + StorageResolveResponse_Result::CDN + ) { + return Err(CdnUrlError::Storage.into()); + } + + let is_expiring = !msg.fileid.is_empty(); + + let result = msg + .cdnurl + .iter() + .map(|cdn_url| { + let url = Url::parse(cdn_url)?; + let mut expiry: Option = None; + + if is_expiring { + let mut expiry_str: Option = None; + if let Some(token) = url + .query_pairs() + .into_iter() + .find(|(key, _value)| key == "verify") + { + // https://audio-cf.spotifycdn.com/audio/844ecdb297a87ebfee4399f28892ef85d9ba725f?verify=1750549951-4R3I2w2q7OfNkR%2FGH8qH7xtIKUPlDxywBuADY%2BsvMeU%3D + if let Some((expiry_str_candidate, _)) = token.1.split_once('-') { + expiry_str = Some(expiry_str_candidate.to_string()); + } + } else if let Some(token) = url + .query_pairs() + .into_iter() + .find(|(key, _value)| key == "__token__") + { + //"https://audio-ak-spotify-com.akamaized.net/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?__token__=exp=1688165560~hmac=4e661527574fab5793adb99cf04e1c2ce12294c71fe1d39ffbfabdcfe8ce3b41", + if let Some(mut start) = token.1.find("exp=") { + start += 4; + if token.1.len() >= start { + let slice = &token.1[start..]; + if let Some(end) = slice.find('~') { + // this is the only valid invariant for akamaized.net + expiry_str = Some(String::from(&slice[..end])); + } else { + expiry_str = Some(String::from(slice)); + } + } + } + } else if let Some(token) = url + .query_pairs() + .into_iter() + .find(|(key, _value)| key == "Expires") + { + //"https://audio-gm-off.spotifycdn.com/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?Expires=1688165560~FullPath~hmac=IIZA28qptl8cuGLq15-SjHKHtLoxzpy_6r_JpAU4MfM=", + if let Some(end) = token.1.find('~') { + // this is the only valid invariant for spotifycdn.com + let slice = &token.1[..end]; + expiry_str = Some(String::from(&slice[..end])); + } + } else if let Some(query) = url.query() { + //"https://audio4-fa.scdn.co/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?1688165560_0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4=", + let mut items = query.split('_'); + if let Some(first) = items.next() { + // this is the only valid invariant for scdn.co + expiry_str = Some(String::from(first)); + } + } + + if let Some(exp_str) = expiry_str { + if let Ok(expiry_parsed) = exp_str.parse::() { + if let Ok(expiry_at) = Date::from_timestamp_ms(expiry_parsed * 1_000) { + let with_margin = expiry_at.saturating_sub(CDN_URL_EXPIRY_MARGIN); + expiry = Some(Date::from(with_margin)); + } + } else { + warn!( + "Cannot parse CDN URL expiry timestamp '{exp_str}' from '{cdn_url}'" + ); + } + } else { + warn!("Unknown CDN URL format: {cdn_url}"); + } + } + Ok(MaybeExpiringUrl(cdn_url.to_owned(), expiry)) + }) + .collect::, Error>>()?; + + Ok(Self(result)) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_maybe_expiring_urls() { + let timestamp = 1688165560; + let mut msg = CdnUrlMessage::new(); + msg.result = StorageResolveResponse_Result::CDN.into(); + msg.cdnurl = vec![ + format!( + "https://audio-cf.spotifycdn.com/audio/844ecdb297a87ebfee4399f28892ef85d9ba725f?verify={timestamp}-4R3I2w2q7OfNkR%2FGH8qH7xtIKUPlDxywBuADY%2BsvMeU%3D" + ), + format!( + "https://audio-ak-spotify-com.akamaized.net/audio/foo?__token__=exp={timestamp}~hmac=4e661527574fab5793adb99cf04e1c2ce12294c71fe1d39ffbfabdcfe8ce3b41" + ), + format!( + "https://audio-gm-off.spotifycdn.com/audio/foo?Expires={timestamp}~FullPath~hmac=IIZA28qptl8cuGLq15-SjHKHtLoxzpy_6r_JpAU4MfM=" + ), + format!( + "https://audio4-fa.scdn.co/audio/foo?{timestamp}_0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4=" + ), + "https://audio4-fa.scdn.co/foo?baz".to_string(), + ]; + msg.fileid = vec![0]; + + let urls = MaybeExpiringUrls::try_from(msg).expect("valid urls"); + assert_eq!(urls.len(), 5); + assert!(urls[0].1.is_some()); + assert!(urls[1].1.is_some()); + assert!(urls[2].1.is_some()); + assert!(urls[3].1.is_some()); + assert!(urls[4].1.is_none()); + let timestamp_margin = Duration::seconds(timestamp) - CDN_URL_EXPIRY_MARGIN; + assert_eq!( + urls[0].1.unwrap().as_timestamp_ms() as i128, + timestamp_margin.whole_milliseconds() + ); + } +} diff --git a/native/vendor/librespot-core/src/channel.rs b/native/vendor/librespot-core/src/channel.rs new file mode 100644 index 00000000..dfdc4dc2 --- /dev/null +++ b/native/vendor/librespot-core/src/channel.rs @@ -0,0 +1,229 @@ +use std::{ + collections::HashMap, + fmt, + pin::Pin, + task::{Context, Poll}, + time::{Duration, Instant}, +}; + +use byteorder::{BigEndian, ByteOrder}; +use bytes::Bytes; +use futures_core::Stream; +use futures_util::{StreamExt, lock::BiLock, ready}; +use num_traits::FromPrimitive; +use thiserror::Error; +use tokio::sync::mpsc; + +use crate::{Error, packet::PacketType, util::SeqGenerator}; + +component! { + ChannelManager : ChannelManagerInner { + sequence: SeqGenerator = SeqGenerator::new(0), + channels: HashMap> = HashMap::new(), + download_rate_estimate: usize = 0, + download_measurement_start: Option = None, + download_measurement_bytes: usize = 0, + invalid: bool = false, + } +} + +const ONE_SECOND: Duration = Duration::from_secs(1); + +#[derive(Debug, Error, Hash, PartialEq, Eq, Copy, Clone)] +pub struct ChannelError; + +impl From for Error { + fn from(err: ChannelError) -> Self { + Error::aborted(err) + } +} + +impl fmt::Display for ChannelError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "channel error") + } +} + +pub struct Channel { + receiver: mpsc::UnboundedReceiver<(u8, Bytes)>, + state: ChannelState, +} + +pub struct ChannelHeaders(BiLock); +pub struct ChannelData(BiLock); + +pub enum ChannelEvent { + Header(u8, Vec), + Data(Bytes), +} + +#[derive(Clone)] +enum ChannelState { + Header(Bytes), + Data, + Closed, +} + +impl ChannelManager { + pub fn allocate(&self) -> (u16, Channel) { + let (tx, rx) = mpsc::unbounded_channel(); + + let seq = self.lock(|inner| { + let seq = inner.sequence.get(); + if !inner.invalid { + inner.channels.insert(seq, tx); + } + seq + }); + + let channel = Channel { + receiver: rx, + state: ChannelState::Header(Bytes::new()), + }; + + (seq, channel) + } + + pub(crate) fn dispatch(&self, cmd: PacketType, mut data: Bytes) -> Result<(), Error> { + use std::collections::hash_map::Entry; + + let id: u16 = BigEndian::read_u16(data.split_to(2).as_ref()); + + self.lock(|inner| { + let current_time = Instant::now(); + if let Some(download_measurement_start) = inner.download_measurement_start { + if (current_time - download_measurement_start) > ONE_SECOND { + inner.download_rate_estimate = ONE_SECOND.as_millis() as usize + * inner.download_measurement_bytes + / (current_time - download_measurement_start).as_millis() as usize; + inner.download_measurement_start = Some(current_time); + inner.download_measurement_bytes = 0; + } + } else { + inner.download_measurement_start = Some(current_time); + } + + inner.download_measurement_bytes += data.len(); + + if let Entry::Occupied(entry) = inner.channels.entry(id) { + entry + .get() + .send((cmd as u8, data)) + .map_err(|_| ChannelError)?; + } + + Ok(()) + }) + } + + pub fn get_download_rate_estimate(&self) -> usize { + self.lock(|inner| inner.download_rate_estimate) + } + + pub(crate) fn shutdown(&self) { + self.lock(|inner| { + inner.invalid = true; + // destroy the sending halves of the channels to signal everyone who is waiting for something. + inner.channels.clear(); + }); + } +} + +impl Channel { + fn recv_packet(&mut self, cx: &mut Context<'_>) -> Poll> { + let (cmd, packet) = ready!(self.receiver.poll_recv(cx)).ok_or(ChannelError)?; + + let packet_type = FromPrimitive::from_u8(cmd); + if let Some(PacketType::ChannelError) = packet_type { + let code = BigEndian::read_u16(&packet.as_ref()[..2]); + error!("channel error: {} {}", packet.len(), code); + + self.state = ChannelState::Closed; + + Poll::Ready(Err(ChannelError)) + } else { + Poll::Ready(Ok(packet)) + } + } + + pub fn split(self) -> (ChannelHeaders, ChannelData) { + let (headers, data) = BiLock::new(self); + + (ChannelHeaders(headers), ChannelData(data)) + } +} + +impl Stream for Channel { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + loop { + match self.state.clone() { + ChannelState::Closed => { + error!("Polling already terminated channel"); + return Poll::Ready(None); + } + + ChannelState::Header(mut data) => { + if data.is_empty() { + data = ready!(self.recv_packet(cx))?; + } + + let length = BigEndian::read_u16(data.split_to(2).as_ref()) as usize; + if length == 0 { + self.state = ChannelState::Data; + } else { + let header_id = data.split_to(1).as_ref()[0]; + let header_data = data.split_to(length - 1).as_ref().to_owned(); + + self.state = ChannelState::Header(data); + + let event = ChannelEvent::Header(header_id, header_data); + return Poll::Ready(Some(Ok(event))); + } + } + + ChannelState::Data => { + let data = ready!(self.recv_packet(cx))?; + if data.is_empty() { + self.receiver.close(); + self.state = ChannelState::Closed; + return Poll::Ready(None); + } else { + let event = ChannelEvent::Data(data); + return Poll::Ready(Some(Ok(event))); + } + } + } + } + } +} + +impl Stream for ChannelData { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut channel = ready!(self.0.poll_lock(cx)); + + loop { + match ready!(channel.poll_next_unpin(cx)?) { + Some(ChannelEvent::Header(..)) => (), + Some(ChannelEvent::Data(data)) => return Poll::Ready(Some(Ok(data))), + None => return Poll::Ready(None), + } + } + } +} + +impl Stream for ChannelHeaders { + type Item = Result<(u8, Vec), ChannelError>; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut channel = ready!(self.0.poll_lock(cx)); + + match ready!(channel.poll_next_unpin(cx)?) { + Some(ChannelEvent::Header(id, data)) => Poll::Ready(Some(Ok((id, data)))), + _ => Poll::Ready(None), + } + } +} diff --git a/native/vendor/librespot-core/src/component.rs b/native/vendor/librespot-core/src/component.rs new file mode 100644 index 00000000..75387ae5 --- /dev/null +++ b/native/vendor/librespot-core/src/component.rs @@ -0,0 +1,40 @@ +pub(crate) const COMPONENT_POISON_MSG: &str = "component mutex should not be poisoned"; + +macro_rules! component { + ($name:ident : $inner:ident { $($key:ident : $ty:ty = $value:expr,)* }) => { + #[derive(Clone)] + pub struct $name(::std::sync::Arc<($crate::session::SessionWeak, ::std::sync::Mutex<$inner>)>); + impl $name { + #[allow(dead_code)] + pub(crate) fn new(session: $crate::session::SessionWeak) -> $name { + debug!(target:"librespot::component", "new {}", stringify!($name)); + + $name(::std::sync::Arc::new((session, ::std::sync::Mutex::new($inner { + $($key : $value,)* + })))) + } + + #[allow(dead_code)] + fn lock R, R>(&self, f: F) -> R { + let mut inner = (self.0).1.lock() + .expect($crate::component::COMPONENT_POISON_MSG); + f(&mut inner) + } + + #[allow(dead_code)] + fn session(&self) -> $crate::session::Session { + (self.0).0.upgrade() + } + } + + struct $inner { + $($key : $ty,)* + } + + impl Drop for $inner { + fn drop(&mut self) { + debug!(target:"librespot::component", "drop {}", stringify!($name)); + } + } + } +} diff --git a/native/vendor/librespot-core/src/config.rs b/native/vendor/librespot-core/src/config.rs new file mode 100644 index 00000000..6c7a3484 --- /dev/null +++ b/native/vendor/librespot-core/src/config.rs @@ -0,0 +1,170 @@ +use std::{fmt, path::PathBuf, str::FromStr}; + +use librespot_protocol::devices::DeviceType as ProtoDeviceType; +use url::Url; + +pub(crate) const KEYMASTER_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; +pub(crate) const ANDROID_CLIENT_ID: &str = "9a8d2f0ce77a4e248bb71fefcb557637"; +pub(crate) const IOS_CLIENT_ID: &str = "58bd3c95768941ea9eb4350aaa033eb3"; + +// Sidetracks patch: the OAuth token comes from the keymaster/desktop client id, and +// Login5/client-token requests bind to the client id implied by this platform string. +// Treating the Android build as desktop keeps those requests consistent with the token. +pub const OS: &str = "linux"; + +// valid versions for some os: +// 'android': 30 +// 'ios': 17 +/// See [sysinfo::System::os_version] +pub fn os_version() -> String { + sysinfo::System::os_version().unwrap_or("0".into()) +} + +#[derive(Clone, Debug)] +pub struct SessionConfig { + pub client_id: String, + pub device_id: String, + pub proxy: Option, + pub ap_port: Option, + pub tmp_dir: PathBuf, + pub autoplay: Option, +} + +impl SessionConfig { + pub(crate) fn default_for_os(os: &str) -> Self { + let device_id = uuid::Uuid::new_v4().as_hyphenated().to_string(); + let client_id = match os { + "android" => ANDROID_CLIENT_ID, + "ios" => IOS_CLIENT_ID, + _ => KEYMASTER_CLIENT_ID, + } + .to_owned(); + + Self { + client_id, + device_id, + proxy: None, + ap_port: None, + tmp_dir: std::env::temp_dir(), + autoplay: None, + } + } +} + +impl Default for SessionConfig { + fn default() -> Self { + Self::default_for_os(OS) + } +} + +#[derive(Clone, Copy, Debug, Hash, PartialOrd, Ord, PartialEq, Eq, Default)] +pub enum DeviceType { + Unknown = 0, + Computer = 1, + Tablet = 2, + Smartphone = 3, + #[default] + Speaker = 4, + Tv = 5, + Avr = 6, + Stb = 7, + AudioDongle = 8, + GameConsole = 9, + CastAudio = 10, + CastVideo = 11, + Automobile = 12, + Smartwatch = 13, + Chromebook = 14, + UnknownSpotify = 100, + CarThing = 101, + Observer = 102, +} + +impl FromStr for DeviceType { + type Err = (); + fn from_str(s: &str) -> Result { + use self::DeviceType::*; + match s.to_lowercase().as_ref() { + "computer" => Ok(Computer), + "tablet" => Ok(Tablet), + "smartphone" => Ok(Smartphone), + "speaker" => Ok(Speaker), + "tv" => Ok(Tv), + "avr" => Ok(Avr), + "stb" => Ok(Stb), + "audiodongle" => Ok(AudioDongle), + "gameconsole" => Ok(GameConsole), + "castaudio" => Ok(CastAudio), + "castvideo" => Ok(CastVideo), + "automobile" => Ok(Automobile), + "smartwatch" => Ok(Smartwatch), + "chromebook" => Ok(Chromebook), + "carthing" => Ok(CarThing), + _ => Err(()), + } + } +} + +impl From<&DeviceType> for &str { + fn from(d: &DeviceType) -> &'static str { + use self::DeviceType::*; + match d { + Unknown => "Unknown", + Computer => "Computer", + Tablet => "Tablet", + Smartphone => "Smartphone", + Speaker => "Speaker", + Tv => "TV", + Avr => "AVR", + Stb => "STB", + AudioDongle => "AudioDongle", + GameConsole => "GameConsole", + CastAudio => "CastAudio", + CastVideo => "CastVideo", + Automobile => "Automobile", + Smartwatch => "Smartwatch", + Chromebook => "Chromebook", + UnknownSpotify => "UnknownSpotify", + CarThing => "CarThing", + Observer => "Observer", + } + } +} + +impl From for &str { + fn from(d: DeviceType) -> &'static str { + (&d).into() + } +} + +impl fmt::Display for DeviceType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let str: &str = self.into(); + f.write_str(str) + } +} + +impl From for ProtoDeviceType { + fn from(value: DeviceType) -> Self { + match value { + DeviceType::Unknown => ProtoDeviceType::UNKNOWN, + DeviceType::Computer => ProtoDeviceType::COMPUTER, + DeviceType::Tablet => ProtoDeviceType::TABLET, + DeviceType::Smartphone => ProtoDeviceType::SMARTPHONE, + DeviceType::Speaker => ProtoDeviceType::SPEAKER, + DeviceType::Tv => ProtoDeviceType::TV, + DeviceType::Avr => ProtoDeviceType::AVR, + DeviceType::Stb => ProtoDeviceType::STB, + DeviceType::AudioDongle => ProtoDeviceType::AUDIO_DONGLE, + DeviceType::GameConsole => ProtoDeviceType::GAME_CONSOLE, + DeviceType::CastAudio => ProtoDeviceType::CAST_VIDEO, + DeviceType::CastVideo => ProtoDeviceType::CAST_AUDIO, + DeviceType::Automobile => ProtoDeviceType::AUTOMOBILE, + DeviceType::Smartwatch => ProtoDeviceType::SMARTWATCH, + DeviceType::Chromebook => ProtoDeviceType::CHROMEBOOK, + DeviceType::UnknownSpotify => ProtoDeviceType::UNKNOWN_SPOTIFY, + DeviceType::CarThing => ProtoDeviceType::CAR_THING, + DeviceType::Observer => ProtoDeviceType::OBSERVER, + } + } +} diff --git a/native/vendor/librespot-core/src/connection/codec.rs b/native/vendor/librespot-core/src/connection/codec.rs new file mode 100644 index 00000000..826839c6 --- /dev/null +++ b/native/vendor/librespot-core/src/connection/codec.rs @@ -0,0 +1,111 @@ +use std::io; + +use byteorder::{BigEndian, ByteOrder}; +use bytes::{BufMut, Bytes, BytesMut}; +use shannon::Shannon; +use thiserror::Error; +use tokio_util::codec::{Decoder, Encoder}; + +const HEADER_SIZE: usize = 3; +const MAC_SIZE: usize = 4; + +#[derive(Debug, Error)] +pub enum ApCodecError { + #[error("payload was malformed")] + Payload, +} + +#[derive(Debug)] +enum DecodeState { + Header, + Payload(u8, usize), +} + +pub struct ApCodec { + encode_nonce: u32, + encode_cipher: Shannon, + + decode_nonce: u32, + decode_cipher: Shannon, + decode_state: DecodeState, +} + +impl ApCodec { + pub fn new(send_key: &[u8], recv_key: &[u8]) -> ApCodec { + ApCodec { + encode_nonce: 0, + encode_cipher: Shannon::new(send_key), + + decode_nonce: 0, + decode_cipher: Shannon::new(recv_key), + decode_state: DecodeState::Header, + } + } +} + +impl Encoder<(u8, Vec)> for ApCodec { + type Error = io::Error; + + fn encode(&mut self, item: (u8, Vec), buf: &mut BytesMut) -> io::Result<()> { + let (cmd, payload) = item; + let offset = buf.len(); + + buf.reserve(3 + payload.len()); + buf.put_u8(cmd); + buf.put_u16(payload.len() as u16); + buf.extend_from_slice(&payload); + + self.encode_cipher.nonce_u32(self.encode_nonce); + self.encode_nonce += 1; + + self.encode_cipher.encrypt(&mut buf[offset..]); + + let mut mac = [0u8; MAC_SIZE]; + self.encode_cipher.finish(&mut mac); + buf.extend_from_slice(&mac); + + Ok(()) + } +} + +impl Decoder for ApCodec { + type Item = (u8, Bytes); + type Error = io::Error; + + fn decode(&mut self, buf: &mut BytesMut) -> io::Result> { + if let DecodeState::Header = self.decode_state { + if buf.len() >= HEADER_SIZE { + let mut header = [0u8; HEADER_SIZE]; + header.copy_from_slice(buf.split_to(HEADER_SIZE).as_ref()); + + self.decode_cipher.nonce_u32(self.decode_nonce); + self.decode_nonce += 1; + + self.decode_cipher.decrypt(&mut header); + + let cmd = header[0]; + let size = BigEndian::read_u16(&header[1..]) as usize; + self.decode_state = DecodeState::Payload(cmd, size); + } + } + + if let DecodeState::Payload(cmd, size) = self.decode_state { + if buf.len() >= size + MAC_SIZE { + self.decode_state = DecodeState::Header; + + let mut payload = buf.split_to(size + MAC_SIZE); + + self.decode_cipher + .decrypt(payload.get_mut(..size).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, ApCodecError::Payload) + })?); + let mac = payload.split_off(size); + self.decode_cipher.check_mac(mac.as_ref())?; + + return Ok(Some((cmd, payload.freeze()))); + } + } + + Ok(None) + } +} diff --git a/native/vendor/librespot-core/src/connection/handshake.rs b/native/vendor/librespot-core/src/connection/handshake.rs new file mode 100644 index 00000000..1dea18ac --- /dev/null +++ b/native/vendor/librespot-core/src/connection/handshake.rs @@ -0,0 +1,270 @@ +use std::{env::consts::ARCH, io}; + +use byteorder::{BigEndian, ByteOrder, WriteBytesExt}; +use hmac::{Hmac, Mac}; +use protobuf::Message; +use rand::RngCore; +use rsa::{BigUint, Pkcs1v15Sign, RsaPublicKey}; +use sha1::{Digest, Sha1}; +use thiserror::Error; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio_util::codec::{Decoder, Framed}; + +use super::codec::ApCodec; + +use crate::{diffie_hellman::DhLocalKeys, version}; + +use crate::protocol; +use crate::protocol::keyexchange::{ + APResponseMessage, ClientHello, ClientResponsePlaintext, Platform, ProductFlags, +}; + +const SERVER_KEY: [u8; 256] = [ + 0xac, 0xe0, 0x46, 0x0b, 0xff, 0xc2, 0x30, 0xaf, 0xf4, 0x6b, 0xfe, 0xc3, 0xbf, 0xbf, 0x86, 0x3d, + 0xa1, 0x91, 0xc6, 0xcc, 0x33, 0x6c, 0x93, 0xa1, 0x4f, 0xb3, 0xb0, 0x16, 0x12, 0xac, 0xac, 0x6a, + 0xf1, 0x80, 0xe7, 0xf6, 0x14, 0xd9, 0x42, 0x9d, 0xbe, 0x2e, 0x34, 0x66, 0x43, 0xe3, 0x62, 0xd2, + 0x32, 0x7a, 0x1a, 0x0d, 0x92, 0x3b, 0xae, 0xdd, 0x14, 0x02, 0xb1, 0x81, 0x55, 0x05, 0x61, 0x04, + 0xd5, 0x2c, 0x96, 0xa4, 0x4c, 0x1e, 0xcc, 0x02, 0x4a, 0xd4, 0xb2, 0x0c, 0x00, 0x1f, 0x17, 0xed, + 0xc2, 0x2f, 0xc4, 0x35, 0x21, 0xc8, 0xf0, 0xcb, 0xae, 0xd2, 0xad, 0xd7, 0x2b, 0x0f, 0x9d, 0xb3, + 0xc5, 0x32, 0x1a, 0x2a, 0xfe, 0x59, 0xf3, 0x5a, 0x0d, 0xac, 0x68, 0xf1, 0xfa, 0x62, 0x1e, 0xfb, + 0x2c, 0x8d, 0x0c, 0xb7, 0x39, 0x2d, 0x92, 0x47, 0xe3, 0xd7, 0x35, 0x1a, 0x6d, 0xbd, 0x24, 0xc2, + 0xae, 0x25, 0x5b, 0x88, 0xff, 0xab, 0x73, 0x29, 0x8a, 0x0b, 0xcc, 0xcd, 0x0c, 0x58, 0x67, 0x31, + 0x89, 0xe8, 0xbd, 0x34, 0x80, 0x78, 0x4a, 0x5f, 0xc9, 0x6b, 0x89, 0x9d, 0x95, 0x6b, 0xfc, 0x86, + 0xd7, 0x4f, 0x33, 0xa6, 0x78, 0x17, 0x96, 0xc9, 0xc3, 0x2d, 0x0d, 0x32, 0xa5, 0xab, 0xcd, 0x05, + 0x27, 0xe2, 0xf7, 0x10, 0xa3, 0x96, 0x13, 0xc4, 0x2f, 0x99, 0xc0, 0x27, 0xbf, 0xed, 0x04, 0x9c, + 0x3c, 0x27, 0x58, 0x04, 0xb6, 0xb2, 0x19, 0xf9, 0xc1, 0x2f, 0x02, 0xe9, 0x48, 0x63, 0xec, 0xa1, + 0xb6, 0x42, 0xa0, 0x9d, 0x48, 0x25, 0xf8, 0xb3, 0x9d, 0xd0, 0xe8, 0x6a, 0xf9, 0x48, 0x4d, 0xa1, + 0xc2, 0xba, 0x86, 0x30, 0x42, 0xea, 0x9d, 0xb3, 0x08, 0x6c, 0x19, 0x0e, 0x48, 0xb3, 0x9d, 0x66, + 0xeb, 0x00, 0x06, 0xa2, 0x5a, 0xee, 0xa1, 0x1b, 0x13, 0x87, 0x3c, 0xd7, 0x19, 0xe6, 0x55, 0xbd, +]; + +#[derive(Debug, Error)] +pub enum HandshakeError { + #[error("invalid key length")] + InvalidLength, + #[error("server key verification failed")] + VerificationFailed, +} + +pub async fn handshake( + mut connection: T, +) -> io::Result> { + let local_keys = DhLocalKeys::random(&mut rand::rng()); + let gc = local_keys.public_key(); + let mut accumulator = client_hello(&mut connection, gc).await?; + let message: APResponseMessage = recv_packet(&mut connection, &mut accumulator).await?; + let remote_key = message + .challenge + .get_or_default() + .login_crypto_challenge + .get_or_default() + .diffie_hellman + .get_or_default() + .gs() + .to_owned(); + let remote_signature = message + .challenge + .get_or_default() + .login_crypto_challenge + .get_or_default() + .diffie_hellman + .get_or_default() + .gs_signature() + .to_owned(); + + // Prevent man-in-the-middle attacks: check server signature + let n = BigUint::from_bytes_be(&SERVER_KEY); + let e = BigUint::new(vec![65537]); + let public_key = RsaPublicKey::new(n, e).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + HandshakeError::VerificationFailed, + ) + })?; + + let hash = Sha1::digest(&remote_key); + let padding = Pkcs1v15Sign::new::(); + public_key + .verify(padding, &hash, &remote_signature) + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + HandshakeError::VerificationFailed, + ) + })?; + + // OK to proceed + let shared_secret = local_keys.shared_secret(&remote_key); + let (challenge, send_key, recv_key) = compute_keys(&shared_secret, &accumulator)?; + let codec = ApCodec::new(&send_key, &recv_key); + + client_response(&mut connection, challenge).await?; + + Ok(codec.framed(connection)) +} + +async fn client_hello(connection: &mut T, gc: Vec) -> io::Result> +where + T: AsyncWrite + Unpin, +{ + let mut client_nonce = vec![0; 0x10]; + rand::rng().fill_bytes(&mut client_nonce); + + let platform = match crate::config::OS { + "freebsd" | "netbsd" | "openbsd" => match ARCH { + "x86_64" => Platform::PLATFORM_FREEBSD_X86_64, + _ => Platform::PLATFORM_FREEBSD_X86, + }, + "ios" => match ARCH { + "aarch64" => Platform::PLATFORM_IPHONE_ARM64, + _ => Platform::PLATFORM_IPHONE_ARM, + }, + // Rather than sending `Platform::PLATFORM_ANDROID_ARM` for "android", + // we are spoofing "android" as "linux", as otherwise during Session::connect + // all APs will reject the client with TryAnotherAP, no matter the credentials + // used was obtained via OAuth using KEYMASTER or ANDROID's client ID or + // Login5Manager::login + "linux" | "android" => match ARCH { + "arm" | "aarch64" => Platform::PLATFORM_LINUX_ARM, + "blackfin" => Platform::PLATFORM_LINUX_BLACKFIN, + "mips" => Platform::PLATFORM_LINUX_MIPS, + "sh" => Platform::PLATFORM_LINUX_SH, + "x86_64" => Platform::PLATFORM_LINUX_X86_64, + _ => Platform::PLATFORM_LINUX_X86, + }, + "macos" => match ARCH { + "ppc" | "ppc64" => Platform::PLATFORM_OSX_PPC, + "x86_64" => Platform::PLATFORM_OSX_X86_64, + _ => Platform::PLATFORM_OSX_X86, + }, + "windows" => match ARCH { + "arm" | "aarch64" => Platform::PLATFORM_WINDOWS_CE_ARM, + "x86_64" => Platform::PLATFORM_WIN32_X86_64, + _ => Platform::PLATFORM_WIN32_X86, + }, + _ => Platform::PLATFORM_LINUX_X86, + }; + + #[cfg(debug_assertions)] + const PRODUCT_FLAGS: ProductFlags = ProductFlags::PRODUCT_FLAG_DEV_BUILD; + #[cfg(not(debug_assertions))] + const PRODUCT_FLAGS: ProductFlags = ProductFlags::PRODUCT_FLAG_NONE; + + let mut packet = ClientHello::new(); + packet + .build_info + .mut_or_insert_default() + // ProductInfo won't push autoplay and perhaps other settings + // when set to anything else than PRODUCT_CLIENT + .set_product(protocol::keyexchange::Product::PRODUCT_CLIENT); + packet + .build_info + .mut_or_insert_default() + .product_flags + .push(PRODUCT_FLAGS.into()); + packet + .build_info + .mut_or_insert_default() + .set_platform(platform); + packet + .build_info + .mut_or_insert_default() + .set_version(version::SPOTIFY_VERSION); + packet + .cryptosuites_supported + .push(protocol::keyexchange::Cryptosuite::CRYPTO_SUITE_SHANNON.into()); + packet + .login_crypto_hello + .mut_or_insert_default() + .diffie_hellman + .mut_or_insert_default() + .set_gc(gc); + packet + .login_crypto_hello + .mut_or_insert_default() + .diffie_hellman + .mut_or_insert_default() + .set_server_keys_known(1); + packet.set_client_nonce(client_nonce); + packet.set_padding(vec![0x1e]); + + let mut buffer = vec![0, 4]; + let size = 2 + 4 + packet.compute_size(); + as WriteBytesExt>::write_u32::(&mut buffer, size.try_into().unwrap())?; + packet.write_to_vec(&mut buffer)?; + + connection.write_all(&buffer[..]).await?; + Ok(buffer) +} + +async fn client_response(connection: &mut T, challenge: Vec) -> io::Result<()> +where + T: AsyncWrite + Unpin, +{ + let mut packet = ClientResponsePlaintext::new(); + packet + .login_crypto_response + .mut_or_insert_default() + .diffie_hellman + .mut_or_insert_default() + .set_hmac(challenge); + + packet.pow_response.mut_or_insert_default(); + packet.crypto_response.mut_or_insert_default(); + + let mut buffer = vec![]; + let size = 4 + packet.compute_size(); + as WriteBytesExt>::write_u32::(&mut buffer, size.try_into().unwrap())?; + packet.write_to_vec(&mut buffer)?; + + connection.write_all(&buffer[..]).await?; + Ok(()) +} + +async fn recv_packet(connection: &mut T, acc: &mut Vec) -> io::Result +where + T: AsyncRead + Unpin, + M: Message, +{ + let header = read_into_accumulator(connection, 4, acc).await?; + let size = BigEndian::read_u32(header) as usize; + let data = read_into_accumulator(connection, size - 4, acc).await?; + let message = M::parse_from_bytes(data)?; + Ok(message) +} + +async fn read_into_accumulator<'b, T: AsyncRead + Unpin>( + connection: &mut T, + size: usize, + acc: &'b mut Vec, +) -> io::Result<&'b mut [u8]> { + let offset = acc.len(); + acc.resize(offset + size, 0); + + connection.read_exact(&mut acc[offset..]).await?; + Ok(&mut acc[offset..]) +} + +fn compute_keys(shared_secret: &[u8], packets: &[u8]) -> io::Result<(Vec, Vec, Vec)> { + type HmacSha1 = Hmac; + + let mut data = Vec::with_capacity(0x64); + for i in 1..6 { + let mut mac = HmacSha1::new_from_slice(shared_secret).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, HandshakeError::InvalidLength) + })?; + mac.update(packets); + mac.update(&[i]); + data.extend_from_slice(&mac.finalize().into_bytes()); + } + + let mut mac = HmacSha1::new_from_slice(&data[..0x14]) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, HandshakeError::InvalidLength))?; + mac.update(packets); + + Ok(( + mac.finalize().into_bytes().to_vec(), + data[0x14..0x34].to_vec(), + data[0x34..0x54].to_vec(), + )) +} diff --git a/native/vendor/librespot-core/src/connection/mod.rs b/native/vendor/librespot-core/src/connection/mod.rs new file mode 100644 index 00000000..83017189 --- /dev/null +++ b/native/vendor/librespot-core/src/connection/mod.rs @@ -0,0 +1,194 @@ +mod codec; +mod handshake; + +pub use self::{codec::ApCodec, handshake::handshake}; + +use std::{io, time::Duration}; + +use futures_util::{SinkExt, StreamExt}; +use num_traits::FromPrimitive; +use protobuf::Message; +use thiserror::Error; +use tokio::net::TcpStream; +use tokio_util::codec::Framed; +use url::Url; + +use crate::{Error, authentication::Credentials, packet::PacketType, version}; + +use crate::protocol::keyexchange::{APLoginFailed, ErrorCode}; + +pub type Transport = Framed; + +fn login_error_message(code: &ErrorCode) -> &'static str { + pub use ErrorCode::*; + match code { + ProtocolError => "Protocol error", + TryAnotherAP => "Try another access point", + BadConnectionId => "Bad connection ID", + TravelRestriction => "Travel restriction", + PremiumAccountRequired => "Premium account required", + BadCredentials => "Bad credentials", + CouldNotValidateCredentials => "Could not validate credentials", + AccountExists => "Account exists", + ExtraVerificationRequired => "Extra verification required", + InvalidAppKey => "Invalid app key", + ApplicationBanned => "Application banned", + } +} + +#[derive(Debug, Error)] +pub enum AuthenticationError { + #[error("Login failed with reason: {}", login_error_message(.0))] + LoginFailed(ErrorCode), + #[error("invalid packet {0}")] + Packet(u8), + #[error("transport returned no data")] + Transport, +} + +impl From for Error { + fn from(err: AuthenticationError) -> Self { + match err { + AuthenticationError::LoginFailed(_) => Error::permission_denied(err), + AuthenticationError::Packet(_) => Error::unimplemented(err), + AuthenticationError::Transport => Error::unavailable(err), + } + } +} + +impl From for AuthenticationError { + fn from(login_failure: APLoginFailed) -> Self { + Self::LoginFailed(login_failure.error_code()) + } +} + +pub async fn connect(host: &str, port: u16, proxy: Option<&Url>) -> io::Result { + const TIMEOUT: Duration = Duration::from_secs(5); + tokio::time::timeout(TIMEOUT, { + let socket = crate::socket::connect(host, port, proxy).await?; + debug!("Connection to AP established."); + handshake(socket) + }) + .await? +} + +pub async fn connect_with_retry( + host: &str, + port: u16, + proxy: Option<&Url>, + max_retries: u8, +) -> io::Result { + let mut num_retries = 0; + loop { + match connect(host, port, proxy).await { + Ok(f) => return Ok(f), + Err(e) => { + debug!("Connection to \"{host}:{port}\" failed: {e}"); + if num_retries < max_retries { + num_retries += 1; + debug!("Retry access point..."); + continue; + } + return Err(e); + } + } + } +} + +pub async fn authenticate( + transport: &mut Transport, + credentials: Credentials, + device_id: &str, +) -> Result { + use crate::protocol::authentication::{APWelcome, ClientResponseEncrypted, CpuFamily, Os}; + + let cpu_family = match std::env::consts::ARCH { + "blackfin" => CpuFamily::CPU_BLACKFIN, + "arm" | "aarch64" => CpuFamily::CPU_ARM, + "ia64" => CpuFamily::CPU_IA64, + "mips" => CpuFamily::CPU_MIPS, + "ppc" => CpuFamily::CPU_PPC, + "ppc64" => CpuFamily::CPU_PPC_64, + "sh" => CpuFamily::CPU_SH, + "x86" => CpuFamily::CPU_X86, + "x86_64" => CpuFamily::CPU_X86_64, + _ => CpuFamily::CPU_UNKNOWN, + }; + + let os = match crate::config::OS { + "android" => Os::OS_ANDROID, + "freebsd" | "netbsd" | "openbsd" => Os::OS_FREEBSD, + "ios" => Os::OS_IPHONE, + "linux" => Os::OS_LINUX, + "macos" => Os::OS_OSX, + "windows" => Os::OS_WINDOWS, + _ => Os::OS_UNKNOWN, + }; + + let mut packet = ClientResponseEncrypted::new(); + if let Some(username) = credentials.username { + packet + .login_credentials + .mut_or_insert_default() + .set_username(username); + } + packet + .login_credentials + .mut_or_insert_default() + .set_typ(credentials.auth_type); + packet + .login_credentials + .mut_or_insert_default() + .set_auth_data(credentials.auth_data); + packet + .system_info + .mut_or_insert_default() + .set_cpu_family(cpu_family); + packet.system_info.mut_or_insert_default().set_os(os); + packet + .system_info + .mut_or_insert_default() + .set_system_information_string(format!( + "librespot-{}-{}", + version::SHA_SHORT, + version::BUILD_ID + )); + packet + .system_info + .mut_or_insert_default() + .set_device_id(device_id.to_string()); + packet.set_version_string(format!("librespot {}", version::SEMVER)); + + let cmd = PacketType::Login; + let data = packet.write_to_bytes()?; + + debug!("Authenticating with AP using {:?}", credentials.auth_type); + transport.send((cmd as u8, data)).await?; + let (cmd, data) = transport + .next() + .await + .ok_or(AuthenticationError::Transport)??; + let packet_type = FromPrimitive::from_u8(cmd); + let result = match packet_type { + Some(PacketType::APWelcome) => { + let welcome_data = APWelcome::parse_from_bytes(data.as_ref())?; + + let reusable_credentials = Credentials { + username: Some(welcome_data.canonical_username().to_owned()), + auth_type: welcome_data.reusable_auth_credentials_type(), + auth_data: welcome_data.reusable_auth_credentials().to_owned(), + }; + + Ok(reusable_credentials) + } + Some(PacketType::AuthFailure) => { + let error_data = APLoginFailed::parse_from_bytes(data.as_ref())?; + Err(error_data.into()) + } + _ => { + trace!("Did not expect {cmd:?} AES key packet with data {data:#?}"); + Err(AuthenticationError::Packet(cmd)) + } + }; + Ok(result?) +} diff --git a/native/vendor/librespot-core/src/date.rs b/native/vendor/librespot-core/src/date.rs new file mode 100644 index 00000000..e77cf5ef --- /dev/null +++ b/native/vendor/librespot-core/src/date.rs @@ -0,0 +1,81 @@ +use std::{fmt::Debug, ops::Deref}; + +use time::{ + Date as _Date, OffsetDateTime, PrimitiveDateTime, Time, error::ComponentRange, + format_description::well_known::Iso8601, +}; + +use crate::Error; + +use librespot_protocol as protocol; +use protocol::metadata::Date as DateMessage; + +impl From for Error { + fn from(err: ComponentRange) -> Self { + Error::out_of_range(err) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Date(pub OffsetDateTime); + +impl Deref for Date { + type Target = OffsetDateTime; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Date { + pub fn as_timestamp_ms(&self) -> i64 { + (self.0.unix_timestamp_nanos() / 1_000_000) as i64 + } + + pub fn from_timestamp_ms(timestamp: i64) -> Result { + let date_time = OffsetDateTime::from_unix_timestamp_nanos(timestamp as i128 * 1_000_000)?; + Ok(Self(date_time)) + } + + pub fn as_utc(&self) -> OffsetDateTime { + self.0 + } + + pub fn from_utc(date_time: PrimitiveDateTime) -> Self { + Self(date_time.assume_utc()) + } + + pub fn now_utc() -> Self { + Self(OffsetDateTime::now_utc()) + } + + pub fn from_iso8601(input: &str) -> Result { + let date_time = OffsetDateTime::parse(input, &Iso8601::DEFAULT)?; + Ok(Self(date_time)) + } +} + +impl TryFrom<&DateMessage> for Date { + type Error = crate::Error; + fn try_from(msg: &DateMessage) -> Result { + // Some metadata contains a year, but no month. In that case just set January. + let month = if msg.has_month() { + msg.month() as u8 + } else { + 1 + }; + + // Having no day will work, but may be unexpected: it will imply the last day + // of the month before. So prevent that, and just set day 1. + let day = if msg.has_day() { msg.day() as u8 } else { 1 }; + + let date = _Date::from_calendar_date(msg.year(), month.try_into()?, day)?; + let time = Time::from_hms(msg.hour() as u8, msg.minute() as u8, 0)?; + Ok(Self::from_utc(PrimitiveDateTime::new(date, time))) + } +} + +impl From for Date { + fn from(datetime: OffsetDateTime) -> Self { + Self(datetime) + } +} diff --git a/native/vendor/librespot-core/src/dealer/manager.rs b/native/vendor/librespot-core/src/dealer/manager.rs new file mode 100644 index 00000000..98ea0265 --- /dev/null +++ b/native/vendor/librespot-core/src/dealer/manager.rs @@ -0,0 +1,174 @@ +use futures_core::Stream; +use futures_util::StreamExt; +use std::{pin::Pin, str::FromStr, sync::OnceLock}; +use thiserror::Error; +use tokio::sync::mpsc; +use tokio_stream::wrappers::UnboundedReceiverStream; +use url::Url; + +use super::{ + Builder, Dealer, GetUrlResult, Request, RequestHandler, Responder, Response, Subscription, + protocol::Message, +}; +use crate::{Error, Session}; + +component! { + DealerManager: DealerManagerInner { + builder: OnceLock = OnceLock::from(Builder::new()), + dealer: OnceLock = OnceLock::new(), + } +} + +pub type BoxedStream = Pin + Send>>; +pub type BoxedStreamResult = BoxedStream>; + +#[derive(Error, Debug)] +enum DealerError { + #[error("Builder wasn't available")] + BuilderNotAvailable, + #[error("Websocket couldn't be started because: {0}")] + LaunchFailure(Error), + #[error("Failed to set dealer")] + CouldNotSetDealer, +} + +impl From for Error { + fn from(err: DealerError) -> Self { + Error::failed_precondition(err) + } +} + +#[derive(Debug)] +pub enum Reply { + Success, + Failure, + Unanswered, +} + +pub type RequestReply = (Request, mpsc::UnboundedSender); +type RequestReceiver = mpsc::UnboundedReceiver; +type RequestSender = mpsc::UnboundedSender; + +struct DealerRequestHandler(RequestSender); + +impl DealerRequestHandler { + pub fn new() -> (Self, RequestReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + (DealerRequestHandler(tx), rx) + } +} + +impl RequestHandler for DealerRequestHandler { + fn handle_request(&self, request: Request, responder: Responder) { + let (tx, mut rx) = mpsc::unbounded_channel(); + + if let Err(why) = self.0.send((request, tx)) { + error!("failed sending dealer request {why}"); + responder.send(Response { success: false }); + return; + } + + tokio::spawn(async move { + let reply = rx.recv().await.unwrap_or(Reply::Failure); + debug!("replying to ws request: {reply:?}"); + match reply { + Reply::Unanswered => responder.force_unanswered(), + Reply::Success | Reply::Failure => responder.send(Response { + success: matches!(reply, Reply::Success), + }), + } + }); + } +} + +impl DealerManager { + async fn get_url(session: Session) -> GetUrlResult { + let (host, port) = session.apresolver().resolve("dealer").await?; + let token = session.login5().auth_token().await?.access_token; + let url = format!("wss://{host}:{port}/?access_token={token}"); + let url = Url::from_str(&url)?; + Ok(url) + } + + pub fn add_listen_for(&self, url: impl Into) -> Result { + let url = url.into(); + self.lock(|inner| { + if let Some(dealer) = inner.dealer.get() { + dealer.subscribe(&[&url]) + } else if let Some(builder) = inner.builder.get_mut() { + builder.subscribe(&[&url]) + } else { + Err(DealerError::BuilderNotAvailable.into()) + } + }) + } + + pub fn listen_for( + &self, + uri: impl Into, + t: impl Fn(Message) -> Result + Send + 'static, + ) -> Result, Error> { + Ok(Box::pin(self.add_listen_for(uri)?.map(t))) + } + + pub fn add_handle_for(&self, url: impl Into) -> Result { + let url = url.into(); + + let (handler, receiver) = DealerRequestHandler::new(); + self.lock(|inner| { + if let Some(dealer) = inner.dealer.get() { + dealer.add_handler(&url, handler).map(|_| receiver) + } else if let Some(builder) = inner.builder.get_mut() { + builder.add_handler(&url, handler).map(|_| receiver) + } else { + Err(DealerError::BuilderNotAvailable.into()) + } + }) + } + + pub fn handle_for(&self, uri: impl Into) -> Result, Error> { + Ok(Box::pin( + self.add_handle_for(uri).map(UnboundedReceiverStream::new)?, + )) + } + + pub fn handles(&self, uri: &str) -> bool { + self.lock(|inner| { + if let Some(dealer) = inner.dealer.get() { + dealer.handles(uri) + } else if let Some(builder) = inner.builder.get() { + builder.handles(uri) + } else { + false + } + }) + } + + pub async fn start(&self) -> Result<(), Error> { + debug!("Launching dealer"); + + let session = self.session(); + // the url has to be a function that can retrieve a new url, + // otherwise when we later try to reconnect with the initial url/token + // and the token is expired we will just get 401 error + let get_url = move || Self::get_url(session.clone()); + + let dealer = self + .lock(move |inner| inner.builder.take()) + .ok_or(DealerError::BuilderNotAvailable)? + .launch(get_url, None) + .await + .map_err(DealerError::LaunchFailure)?; + + self.lock(|inner| inner.dealer.set(dealer)) + .map_err(|_| DealerError::CouldNotSetDealer)?; + + Ok(()) + } + + pub async fn close(&self) { + if let Some(dealer) = self.lock(|inner| inner.dealer.take()) { + dealer.close().await + } + } +} diff --git a/native/vendor/librespot-core/src/dealer/maps.rs b/native/vendor/librespot-core/src/dealer/maps.rs new file mode 100644 index 00000000..23d21a11 --- /dev/null +++ b/native/vendor/librespot-core/src/dealer/maps.rs @@ -0,0 +1,155 @@ +use std::collections::HashMap; + +use crate::Error; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum HandlerMapError { + #[error("request was already handled")] + AlreadyHandled, +} + +impl From for Error { + fn from(err: HandlerMapError) -> Self { + Error::aborted(err) + } +} + +pub enum HandlerMap { + Leaf(T), + Branch(HashMap>), +} + +impl Default for HandlerMap { + fn default() -> Self { + Self::Branch(HashMap::new()) + } +} + +impl HandlerMap { + pub fn contains(&self, path: &str) -> bool { + matches!(self, HandlerMap::Branch(map) if map.contains_key(path)) + } + + pub fn insert<'a>( + &mut self, + mut path: impl Iterator, + handler: T, + ) -> Result<(), Error> { + match self { + Self::Leaf(_) => Err(HandlerMapError::AlreadyHandled.into()), + Self::Branch(children) => { + if let Some(component) = path.next() { + let node = children.entry(component.to_owned()).or_default(); + node.insert(path, handler) + } else if children.is_empty() { + *self = Self::Leaf(handler); + Ok(()) + } else { + Err(HandlerMapError::AlreadyHandled.into()) + } + } + } + } + + pub fn get<'a>(&self, mut path: impl Iterator) -> Option<&T> { + match self { + Self::Leaf(t) => Some(t), + Self::Branch(m) => { + let component = path.next()?; + m.get(component)?.get(path) + } + } + } + + pub fn remove<'a>(&mut self, mut path: impl Iterator) -> Option { + match self { + Self::Leaf(_) => match std::mem::take(self) { + Self::Leaf(t) => Some(t), + _ => unreachable!(), + }, + Self::Branch(map) => { + let component = path.next()?; + let next = map.get_mut(component)?; + let result = next.remove(path); + match &*next { + Self::Branch(b) if b.is_empty() => { + map.remove(component); + } + _ => (), + } + result + } + } + } +} + +pub struct SubscriberMap { + subscribed: Vec, + children: HashMap>, +} + +impl Default for SubscriberMap { + fn default() -> Self { + Self { + subscribed: Vec::new(), + children: HashMap::new(), + } + } +} + +impl SubscriberMap { + pub fn insert<'a>(&mut self, mut path: impl Iterator, handler: T) { + if let Some(component) = path.next() { + self.children + .entry(component.to_owned()) + .or_default() + .insert(path, handler); + } else { + self.subscribed.push(handler); + } + } + + pub fn contains<'a>(&self, mut path: impl Iterator) -> bool { + if !self.subscribed.is_empty() { + return true; + } + + if let Some(next) = path.next() { + if let Some(next_map) = self.children.get(next) { + return next_map.contains(path); + } + } else { + return !self.is_empty(); + } + + false + } + + pub fn is_empty(&self) -> bool { + self.children.is_empty() && self.subscribed.is_empty() + } + + pub fn retain<'a>( + &mut self, + mut path: impl Iterator, + fun: &mut impl FnMut(&T) -> bool, + ) -> bool { + let mut handled_by_any = false; + self.subscribed.retain(|x| { + handled_by_any = true; + fun(x) + }); + + if let Some(next) = path.next() { + if let Some(y) = self.children.get_mut(next) { + handled_by_any = handled_by_any || y.retain(path, fun); + if y.is_empty() { + self.children.remove(next); + } + } + } + + handled_by_any + } +} diff --git a/native/vendor/librespot-core/src/dealer/mod.rs b/native/vendor/librespot-core/src/dealer/mod.rs new file mode 100644 index 00000000..63ee6e72 --- /dev/null +++ b/native/vendor/librespot-core/src/dealer/mod.rs @@ -0,0 +1,724 @@ +pub mod manager; +mod maps; +pub mod protocol; + +use std::{ + iter, + pin::Pin, + sync::{ + Arc, Mutex, + atomic::{self, AtomicBool}, + }, + task::Poll, + time::Duration, +}; + +use futures_core::{Future, Stream}; +use futures_util::{SinkExt, StreamExt, future::join_all}; +use thiserror::Error; +use tokio::{ + select, + sync::{ + Semaphore, + mpsc::{self, UnboundedReceiver}, + }, + task::JoinHandle, +}; +use tokio_tungstenite::tungstenite; +use tungstenite::error::UrlError; +use url::Url; + +use self::{ + maps::*, + protocol::{Message, MessageOrRequest, Request, WebsocketMessage, WebsocketRequest}, +}; + +use crate::{ + Error, socket, + util::{CancelOnDrop, TimeoutOnDrop, keep_flushing}, +}; + +type WsMessage = tungstenite::Message; +type WsError = tungstenite::Error; +type WsResult = Result; +type GetUrlResult = Result; + +impl From for Error { + fn from(err: WsError) -> Self { + Error::failed_precondition(err) + } +} + +const WEBSOCKET_CLOSE_TIMEOUT: Duration = Duration::from_secs(3); + +const PING_INTERVAL: Duration = Duration::from_secs(30); +const PING_TIMEOUT: Duration = Duration::from_secs(3); + +const RECONNECT_INTERVAL: Duration = Duration::from_secs(10); + +const DEALER_REQUEST_HANDLERS_POISON_MSG: &str = + "dealer request handlers mutex should not be poisoned"; +const DEALER_MESSAGE_HANDLERS_POISON_MSG: &str = + "dealer message handlers mutex should not be poisoned"; + +struct Response { + pub success: bool, +} + +struct Responder { + key: String, + tx: mpsc::UnboundedSender, + sent: bool, +} + +impl Responder { + fn new(key: String, tx: mpsc::UnboundedSender) -> Self { + Self { + key, + tx, + sent: false, + } + } + + // Should only be called once + fn send_internal(&mut self, response: Response) { + let response = serde_json::json!({ + "type": "reply", + "key": &self.key, + "payload": { + "success": response.success, + } + }) + .to_string(); + + if let Err(e) = self.tx.send(WsMessage::Text(response.into())) { + warn!("Wasn't able to reply to dealer request: {e}"); + } + } + + pub fn send(mut self, response: Response) { + self.send_internal(response); + self.sent = true; + } + + pub fn force_unanswered(mut self) { + self.sent = true; + } +} + +impl Drop for Responder { + fn drop(&mut self) { + if !self.sent { + self.send_internal(Response { success: false }); + } + } +} + +trait IntoResponse { + fn respond(self, responder: Responder); +} + +impl IntoResponse for Response { + fn respond(self, responder: Responder) { + responder.send(self) + } +} + +impl IntoResponse for F +where + F: Future + Send + 'static, +{ + fn respond(self, responder: Responder) { + tokio::spawn(async move { + responder.send(self.await); + }); + } +} + +impl RequestHandler for F +where + F: (Fn(Request) -> R) + Send + 'static, + R: IntoResponse, +{ + fn handle_request(&self, request: Request, responder: Responder) { + self(request).respond(responder); + } +} + +trait RequestHandler: Send + 'static { + fn handle_request(&self, request: Request, responder: Responder); +} + +type MessageHandler = mpsc::UnboundedSender; + +// TODO: Maybe it's possible to unregister subscription directly when they +// are dropped instead of on next failed attempt. +pub struct Subscription(UnboundedReceiver); + +impl Stream for Subscription { + type Item = Message; + + fn poll_next( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + self.0.poll_recv(cx) + } +} + +fn split_uri(s: &str) -> Option> { + let (scheme, sep, rest) = if let Some(rest) = s.strip_prefix("hm://") { + ("hm", '/', rest) + } else if let Some(rest) = s.strip_prefix("spotify:") { + ("spotify", ':', rest) + } else if s.contains('/') { + ("", '/', s) + } else { + return None; + }; + + let rest = rest.trim_end_matches(sep); + let split = rest.split(sep); + + Some(iter::once(scheme).chain(split)) +} + +#[derive(Debug, Clone, Error)] +enum AddHandlerError { + #[error("There is already a handler for the given uri")] + AlreadyHandled, + #[error("The specified uri {0} is invalid")] + InvalidUri(String), +} + +impl From for Error { + fn from(err: AddHandlerError) -> Self { + match err { + AddHandlerError::AlreadyHandled => Error::aborted(err), + AddHandlerError::InvalidUri(_) => Error::invalid_argument(err), + } + } +} + +#[derive(Debug, Clone, Error)] +enum SubscriptionError { + #[error("The specified uri is invalid")] + InvalidUri(String), +} + +impl From for Error { + fn from(err: SubscriptionError) -> Self { + Error::invalid_argument(err) + } +} + +fn add_handler( + map: &mut HandlerMap>, + uri: &str, + handler: impl RequestHandler, +) -> Result<(), Error> { + let split = split_uri(uri).ok_or_else(|| AddHandlerError::InvalidUri(uri.to_string()))?; + map.insert(split, Box::new(handler)) +} + +fn remove_handler(map: &mut HandlerMap, uri: &str) -> Option { + map.remove(split_uri(uri)?) +} + +fn subscribe( + map: &mut SubscriberMap, + uris: &[&str], +) -> Result { + let (tx, rx) = mpsc::unbounded_channel(); + + for &uri in uris { + let split = split_uri(uri).ok_or_else(|| SubscriptionError::InvalidUri(uri.to_string()))?; + map.insert(split, tx.clone()); + } + + Ok(Subscription(rx)) +} + +fn handles( + req_map: &HandlerMap>, + msg_map: &SubscriberMap, + uri: &str, +) -> bool { + if req_map.contains(uri) { + return true; + } + + match split_uri(uri) { + None => false, + Some(mut split) => msg_map.contains(&mut split), + } +} + +#[derive(Default)] +struct Builder { + message_handlers: SubscriberMap, + request_handlers: HandlerMap>, +} + +macro_rules! create_dealer { + ($builder:expr, $shared:ident -> $body:expr) => { + match $builder { + builder => { + let shared = Arc::new(DealerShared { + message_handlers: Mutex::new(builder.message_handlers), + request_handlers: Mutex::new(builder.request_handlers), + notify_drop: Semaphore::new(0), + }); + + let handle = { + let $shared = Arc::clone(&shared); + tokio::spawn($body) + }; + + Dealer { + shared, + handle: TimeoutOnDrop::new(handle, WEBSOCKET_CLOSE_TIMEOUT), + } + } + } + }; +} + +impl Builder { + pub fn new() -> Self { + Self::default() + } + + pub fn add_handler(&mut self, uri: &str, handler: impl RequestHandler) -> Result<(), Error> { + add_handler(&mut self.request_handlers, uri, handler) + } + + pub fn subscribe(&mut self, uris: &[&str]) -> Result { + subscribe(&mut self.message_handlers, uris) + } + + pub fn handles(&self, uri: &str) -> bool { + handles(&self.request_handlers, &self.message_handlers, uri) + } + + pub fn launch_in_background(self, get_url: F, proxy: Option) -> Dealer + where + Fut: Future + Send + 'static, + F: (Fn() -> Fut) + Send + 'static, + { + create_dealer!(self, shared -> run(shared, None, get_url, proxy)) + } + + pub async fn launch(self, get_url: F, proxy: Option) -> WsResult + where + Fut: Future + Send + 'static, + F: (Fn() -> Fut) + Send + 'static, + { + let dealer = create_dealer!(self, shared -> { + // Try to connect. + let url = get_url().await?; + let tasks = connect(&url, proxy.as_ref(), &shared).await?; + + // If a connection is established, continue in a background task. + run(shared, Some(tasks), get_url, proxy) + }); + + Ok(dealer) + } +} + +struct DealerShared { + message_handlers: Mutex>, + request_handlers: Mutex>>, + + // Semaphore with 0 permits. By closing this semaphore, we indicate + // that the actual Dealer struct has been dropped. + notify_drop: Semaphore, +} + +impl DealerShared { + fn dispatch_message(&self, mut msg: WebsocketMessage) { + let msg = match msg.handle_payload() { + Ok(value) => Message { + headers: msg.headers, + payload: value, + uri: msg.uri, + }, + Err(why) => { + warn!("failure during data parsing for {}: {why}", msg.uri); + return; + } + }; + + if let Some(split) = split_uri(&msg.uri) { + if self + .message_handlers + .lock() + .expect(DEALER_MESSAGE_HANDLERS_POISON_MSG) + .retain(split, &mut |tx| tx.send(msg.clone()).is_ok()) + { + return; + } + } + + debug!("No subscriber for msg.uri: {}", msg.uri); + } + + fn dispatch_request( + &self, + request: WebsocketRequest, + send_tx: &mpsc::UnboundedSender, + ) { + trace!("dealer request {}", &request.message_ident); + + let payload_request = match request.handle_payload() { + Ok(payload) => payload, + Err(why) => { + warn!("request payload handling failed because of {why}"); + return; + } + }; + + // ResponseSender will automatically send "success: false" if it is dropped without an answer. + let responder = Responder::new(request.key.clone(), send_tx.clone()); + + let split = if let Some(split) = split_uri(&request.message_ident) { + split + } else { + warn!( + "Dealer request with invalid message_ident: {}", + &request.message_ident + ); + return; + }; + + let handler_map = self + .request_handlers + .lock() + .expect(DEALER_REQUEST_HANDLERS_POISON_MSG); + + if let Some(handler) = handler_map.get(split) { + handler.handle_request(payload_request, responder); + return; + } + + warn!("No handler for message_ident: {}", &request.message_ident); + } + + fn dispatch(&self, m: MessageOrRequest, send_tx: &mpsc::UnboundedSender) { + match m { + MessageOrRequest::Message(m) => self.dispatch_message(m), + MessageOrRequest::Request(r) => self.dispatch_request(r, send_tx), + } + } + + async fn closed(&self) { + if self.notify_drop.acquire().await.is_ok() { + error!("should never have gotten a permit"); + } + } + + fn is_closed(&self) -> bool { + self.notify_drop.is_closed() + } +} + +struct Dealer { + shared: Arc, + handle: TimeoutOnDrop>, +} + +impl Dealer { + pub fn add_handler(&self, uri: &str, handler: H) -> Result<(), Error> + where + H: RequestHandler, + { + add_handler( + &mut self + .shared + .request_handlers + .lock() + .expect(DEALER_REQUEST_HANDLERS_POISON_MSG), + uri, + handler, + ) + } + + pub fn remove_handler(&self, uri: &str) -> Option> { + remove_handler( + &mut self + .shared + .request_handlers + .lock() + .expect(DEALER_REQUEST_HANDLERS_POISON_MSG), + uri, + ) + } + + pub fn subscribe(&self, uris: &[&str]) -> Result { + subscribe( + &mut self + .shared + .message_handlers + .lock() + .expect(DEALER_MESSAGE_HANDLERS_POISON_MSG), + uris, + ) + } + + pub fn handles(&self, uri: &str) -> bool { + handles( + &self + .shared + .request_handlers + .lock() + .expect(DEALER_REQUEST_HANDLERS_POISON_MSG), + &self + .shared + .message_handlers + .lock() + .expect(DEALER_MESSAGE_HANDLERS_POISON_MSG), + uri, + ) + } + + pub async fn close(mut self) { + debug!("closing dealer"); + + self.shared.notify_drop.close(); + + if let Some(handle) = self.handle.take() { + if let Err(e) = CancelOnDrop(handle).await { + error!("error aborting dealer operations: {e}"); + } + } + } +} + +/// Initializes a connection and returns futures that will finish when the connection is closed/lost. +async fn connect( + address: &Url, + proxy: Option<&Url>, + shared: &Arc, +) -> WsResult<(JoinHandle<()>, JoinHandle<()>)> { + let host = address + .host_str() + .ok_or(WsError::Url(UrlError::NoHostName))?; + + let default_port = match address.scheme() { + "ws" => 80, + "wss" => 443, + _ => return Err(WsError::Url(UrlError::UnsupportedUrlScheme).into()), + }; + + let port = address.port().unwrap_or(default_port); + + let stream = socket::connect(host, port, proxy).await?; + + let (mut ws_tx, ws_rx) = tokio_tungstenite::client_async_tls(address.as_str(), stream) + .await? + .0 + .split(); + + let (send_tx, mut send_rx) = mpsc::unbounded_channel::(); + + // Spawn a task that will forward messages from the channel to the websocket. + let send_task = { + let shared = Arc::clone(shared); + + tokio::spawn(async move { + let result = loop { + select! { + biased; + () = shared.closed() => { + break Ok(None); + } + msg = send_rx.recv() => { + if let Some(msg) = msg { + // New message arrived through channel + if let WsMessage::Close(close_frame) = msg { + break Ok(close_frame); + } + + if let Err(e) = ws_tx.feed(msg).await { + break Err(e); + } + } else { + break Ok(None); + } + }, + e = keep_flushing(&mut ws_tx) => { + break Err(e) + } + else => (), + } + }; + + send_rx.close(); + + // I don't trust in tokio_tungstenite's implementation of Sink::close. + let result = match result { + Ok(close_frame) => ws_tx.send(WsMessage::Close(close_frame)).await, + Err(WsError::AlreadyClosed) | Err(WsError::ConnectionClosed) => ws_tx.flush().await, + Err(e) => { + warn!("Dealer finished with an error: {e}"); + ws_tx.send(WsMessage::Close(None)).await + } + }; + + if let Err(e) = result { + warn!("Error while closing websocket: {e}"); + } + + debug!("Dropping send task"); + }) + }; + + let shared = Arc::clone(shared); + + // A task that receives messages from the web socket. + let receive_task = tokio::spawn(async { + let pong_received = AtomicBool::new(true); + let send_tx = send_tx; + let shared = shared; + + let receive_task = async { + let mut ws_rx = ws_rx; + + loop { + match ws_rx.next().await { + Some(Ok(msg)) => match msg { + WsMessage::Text(t) => match serde_json::from_str(&t) { + Ok(m) => shared.dispatch(m, &send_tx), + Err(e) => warn!("Message couldn't be parsed: {e}. Message was {t}"), + }, + WsMessage::Binary(_) => { + info!("Received invalid binary message"); + } + WsMessage::Pong(_) => { + trace!("Received pong"); + pong_received.store(true, atomic::Ordering::Relaxed); + } + _ => (), // tungstenite handles Close and Ping automatically + }, + Some(Err(e)) => { + warn!("Websocket connection failed: {e}"); + break; + } + None => { + debug!("Websocket connection closed."); + break; + } + } + } + }; + + // Sends pings and checks whether a pong comes back. + let ping_task = async { + use tokio::time::{interval, sleep}; + + let mut timer = interval(PING_INTERVAL); + + loop { + timer.tick().await; + + pong_received.store(false, atomic::Ordering::Relaxed); + if send_tx + .send(WsMessage::Ping(bytes::Bytes::default())) + .is_err() + { + // The sender is closed. + break; + } + + trace!("Sent ping"); + + sleep(PING_TIMEOUT).await; + + if !pong_received.load(atomic::Ordering::SeqCst) { + // No response + warn!("Websocket peer does not respond."); + break; + } + } + }; + + // Exit this task as soon as one our subtasks fails. + // In both cases the connection is probably lost. + select! { + () = ping_task => (), + () = receive_task => () + } + + // Try to take send_task down with us, in case it's still alive. + let _ = send_tx.send(WsMessage::Close(None)); + + debug!("Dropping receive task"); + }); + + Ok((send_task, receive_task)) +} + +/// The main background task for `Dealer`, which coordinates reconnecting. +async fn run( + shared: Arc, + initial_tasks: Option<(JoinHandle<()>, JoinHandle<()>)>, + mut get_url: F, + proxy: Option, +) -> Result<(), Error> +where + Fut: Future + Send + 'static, + F: (FnMut() -> Fut) + Send + 'static, +{ + let init_task = |t| Some(TimeoutOnDrop::new(t, WEBSOCKET_CLOSE_TIMEOUT)); + + let mut tasks = if let Some((s, r)) = initial_tasks { + (init_task(s), init_task(r)) + } else { + (None, None) + }; + + while !shared.is_closed() { + match &mut tasks { + (Some(t0), Some(t1)) => { + select! { + () = shared.closed() => break, + r = t0 => { + if let Err(e) = r { + error!("timeout on task 0: {e}"); + } + tasks.0.take(); + }, + r = t1 => { + if let Err(e) = r { + error!("timeout on task 1: {e}"); + } + tasks.1.take(); + } + } + } + _ => { + let url = select! { + () = shared.closed() => { + break + }, + e = get_url() => e + }?; + + match connect(&url, proxy.as_ref(), &shared).await { + Ok((s, r)) => tasks = (init_task(s), init_task(r)), + Err(e) => { + error!("Error while connecting: {e}"); + tokio::time::sleep(RECONNECT_INTERVAL).await; + } + } + } + } + } + + let tasks = tasks.0.into_iter().chain(tasks.1); + + let _ = join_all(tasks).await; + + Ok(()) +} diff --git a/native/vendor/librespot-core/src/dealer/protocol.rs b/native/vendor/librespot-core/src/dealer/protocol.rs new file mode 100644 index 00000000..803fe300 --- /dev/null +++ b/native/vendor/librespot-core/src/dealer/protocol.rs @@ -0,0 +1,196 @@ +pub mod request; + +pub use request::*; + +use std::collections::HashMap; +use std::io::{Error as IoError, Read}; + +use crate::{Error, deserialize_with::json_proto}; +use base64::{DecodeError, Engine, prelude::BASE64_STANDARD}; +use flate2::read::GzDecoder; +use log::LevelFilter; +use serde::Deserialize; +use serde_json::Error as SerdeError; +use thiserror::Error; + +const IGNORE_UNKNOWN: protobuf_json_mapping::ParseOptions = protobuf_json_mapping::ParseOptions { + ignore_unknown_fields: true, + _future_options: (), +}; + +type JsonValue = serde_json::Value; + +#[derive(Debug, Error)] +enum ProtocolError { + #[error("base64 decoding failed: {0}")] + Base64(DecodeError), + #[error("gzip decoding failed: {0}")] + GZip(IoError), + #[error("deserialization failed: {0}")] + Deserialization(SerdeError), + #[error("payload had more then one value. had {0} values")] + MoreThenOneValue(usize), + #[error("received unexpected data {0:#?}")] + UnexpectedData(PayloadValue), + #[error("payload was empty")] + Empty, +} + +impl From for Error { + fn from(err: ProtocolError) -> Self { + match err { + ProtocolError::UnexpectedData(_) => Error::unavailable(err), + _ => Error::failed_precondition(err), + } + } +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct Payload { + pub compressed: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct WebsocketRequest { + #[serde(default)] + pub headers: HashMap, + pub message_ident: String, + pub key: String, + pub payload: Payload, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct WebsocketMessage { + #[serde(default)] + pub headers: HashMap, + pub method: Option, + #[serde(default)] + pub payloads: Vec, + pub uri: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub enum MessagePayloadValue { + String(String), + Bytes(Vec), + Json(JsonValue), +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(super) enum MessageOrRequest { + Message(WebsocketMessage), + Request(WebsocketRequest), +} + +#[derive(Clone, Debug)] +pub enum PayloadValue { + Empty, + Raw(Vec), + Json(String), +} + +#[derive(Clone, Debug)] +pub struct Message { + pub headers: HashMap, + pub payload: PayloadValue, + pub uri: String, +} + +#[derive(Deserialize)] +#[serde(untagged)] +pub enum FallbackWrapper { + Inner(#[serde(deserialize_with = "json_proto")] T), + Fallback(JsonValue), +} + +impl Message { + pub fn try_from_json( + value: Self, + ) -> Result, Error> { + match value.payload { + PayloadValue::Json(json) => Ok(serde_json::from_str(&json)?), + other => Err(ProtocolError::UnexpectedData(other).into()), + } + } + + pub fn from_raw(value: Self) -> Result { + match value.payload { + PayloadValue::Raw(bytes) => { + M::parse_from_bytes(&bytes).map_err(Error::failed_precondition) + } + other => Err(ProtocolError::UnexpectedData(other).into()), + } + } +} + +impl WebsocketMessage { + pub fn handle_payload(&mut self) -> Result { + if self.payloads.is_empty() { + return Ok(PayloadValue::Empty); + } else if self.payloads.len() > 1 { + return Err(ProtocolError::MoreThenOneValue(self.payloads.len()).into()); + } + + let payload = self.payloads.pop().ok_or(ProtocolError::Empty)?; + let bytes = match payload { + MessagePayloadValue::String(string) => BASE64_STANDARD + .decode(string) + .map_err(ProtocolError::Base64)?, + MessagePayloadValue::Bytes(bytes) => bytes, + MessagePayloadValue::Json(json) => return Ok(PayloadValue::Json(json.to_string())), + }; + + handle_transfer_encoding(&self.headers, bytes).map(PayloadValue::Raw) + } +} + +impl WebsocketRequest { + pub fn handle_payload(&self) -> Result { + let payload_bytes = BASE64_STANDARD + .decode(&self.payload.compressed) + .map_err(ProtocolError::Base64)?; + + let payload = handle_transfer_encoding(&self.headers, payload_bytes)?; + let payload = String::from_utf8(payload)?; + + if log::max_level() >= LevelFilter::Trace { + if let Ok(json) = serde_json::from_str::(&payload) { + trace!("websocket request: {json:#?}"); + } else { + trace!("websocket request: {payload}"); + } + } + + serde_json::from_str(&payload) + .map_err(ProtocolError::Deserialization) + .map_err(Into::into) + } +} + +fn handle_transfer_encoding( + headers: &HashMap, + data: Vec, +) -> Result, Error> { + let encoding = headers.get("Transfer-Encoding").map(String::as_str); + if let Some(encoding) = encoding { + trace!("message was sent with {encoding} encoding "); + } else { + trace!("message was sent with no encoding "); + } + + if !matches!(encoding, Some("gzip")) { + return Ok(data); + } + + let mut gz = GzDecoder::new(&data[..]); + let mut bytes = vec![]; + match gz.read_to_end(&mut bytes) { + Ok(i) if i == bytes.len() => Ok(bytes), + Ok(_) => Err(Error::failed_precondition( + "read bytes mismatched with expected bytes", + )), + Err(why) => Err(ProtocolError::GZip(why).into()), + } +} diff --git a/native/vendor/librespot-core/src/dealer/protocol/request.rs b/native/vendor/librespot-core/src/dealer/protocol/request.rs new file mode 100644 index 00000000..8125750c --- /dev/null +++ b/native/vendor/librespot-core/src/dealer/protocol/request.rs @@ -0,0 +1,217 @@ +use crate::{ + deserialize_with::*, + protocol::{ + context::Context, + context_player_options::ContextPlayerOptionOverrides, + player::{PlayOrigin, ProvidedTrack}, + transfer_state::TransferState, + }, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::fmt::{Display, Formatter}; + +#[derive(Clone, Debug, Deserialize)] +pub struct Request { + pub message_id: u32, + // todo: did only send target_alias_id: null so far, maybe we just ignore it, will see + // pub target_alias_id: Option<()>, + pub sent_by_device_id: String, + pub command: Command, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "endpoint", rename_all = "snake_case")] +pub enum Command { + Transfer(TransferCommand), + #[serde(deserialize_with = "boxed")] + Play(Box), + Pause(PauseCommand), + SeekTo(SeekToCommand), + SetShufflingContext(SetValueCommand), + SetRepeatingTrack(SetValueCommand), + SetRepeatingContext(SetValueCommand), + AddToQueue(AddToQueueCommand), + SetQueue(SetQueueCommand), + SetOptions(SetOptionsCommand), + UpdateContext(UpdateContextCommand), + SkipNext(SkipNextCommand), + // commands that don't send any context (at least not usually...) + SkipPrev(GenericCommand), + Resume(GenericCommand), + // catch unknown commands, so that we can implement them later + #[serde(untagged)] + Unknown(Value), +} + +impl Display for Command { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + use Command::*; + + write!( + f, + "endpoint: {}{}", + matches!(self, Unknown(_)) + .then_some("unknown ") + .unwrap_or_default(), + match self { + Transfer(_) => "transfer", + Play(_) => "play", + Pause(_) => "pause", + SeekTo(_) => "seek_to", + SetShufflingContext(_) => "set_shuffling_context", + SetRepeatingContext(_) => "set_repeating_context", + SetRepeatingTrack(_) => "set_repeating_track", + AddToQueue(_) => "add_to_queue", + SetQueue(_) => "set_queue", + SetOptions(_) => "set_options", + UpdateContext(_) => "update_context", + SkipNext(_) => "skip_next", + SkipPrev(_) => "skip_prev", + Resume(_) => "resume", + Unknown(json) => { + json.as_object() + .and_then(|obj| obj.get("endpoint").map(|v| v.as_str())) + .flatten() + .unwrap_or("???") + } + } + ) + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct TransferCommand { + #[serde(default, deserialize_with = "base64_proto")] + pub data: Option, + pub options: TransferOptions, + pub from_device_identifier: String, + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct PlayCommand { + #[serde(deserialize_with = "json_proto")] + pub context: Context, + #[serde(deserialize_with = "json_proto")] + pub play_origin: PlayOrigin, + pub options: PlayOptions, + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct PauseCommand { + // does send options with it, but seems to be empty, investigate which options are send here + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SeekToCommand { + pub value: u32, + pub position: u32, + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SkipNextCommand { + #[serde(default, deserialize_with = "option_json_proto")] + pub track: Option, + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SetValueCommand { + pub value: bool, + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AddToQueueCommand { + #[serde(deserialize_with = "json_proto")] + pub track: ProvidedTrack, + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SetQueueCommand { + #[serde(deserialize_with = "vec_json_proto")] + pub next_tracks: Vec, + #[serde(deserialize_with = "vec_json_proto")] + pub prev_tracks: Vec, + // this queue revision is actually the last revision, so using it will not update the web ui + // might be that internally they use the last revision to create the next revision + pub queue_revision: String, + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SetOptionsCommand { + pub shuffling_context: Option, + pub repeating_context: Option, + pub repeating_track: Option, + pub options: Option, + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct UpdateContextCommand { + #[serde(deserialize_with = "json_proto")] + pub context: Context, + pub session_id: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct GenericCommand { + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct TransferOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub restore_paused: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub restore_position: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub restore_track: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub retain_session: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct PlayOptions { + pub skip_to: Option, + #[serde(default, deserialize_with = "option_json_proto")] + pub player_options_override: Option, + pub license: Option, + // possible to send wie web-api + pub seek_to: Option, + // mobile + pub always_play_something: Option, + pub audio_stream: Option, + pub initially_paused: Option, + pub prefetch_level: Option, + pub system_initiated: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct OptionsOptions { + only_for_local_device: bool, + override_restrictions: bool, + system_initiated: bool, +} + +#[derive(Clone, Debug, Deserialize, Default)] +pub struct SkipTo { + pub track_uid: Option, + pub track_uri: Option, + pub track_index: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct LoggingParams { + pub interaction_ids: Option>, + pub device_identifier: Option, + pub command_initiated_time: Option, + pub page_instance_ids: Option>, + pub command_id: Option, +} diff --git a/native/vendor/librespot-core/src/deserialize_with.rs b/native/vendor/librespot-core/src/deserialize_with.rs new file mode 100644 index 00000000..22fc24a6 --- /dev/null +++ b/native/vendor/librespot-core/src/deserialize_with.rs @@ -0,0 +1,90 @@ +use base64::Engine; +use base64::prelude::BASE64_STANDARD; +use protobuf::MessageFull; +use serde::de::{Error, Unexpected}; +use serde::{Deserialize, Deserializer}; +use serde_json::Value; + +const IGNORE_UNKNOWN: protobuf_json_mapping::ParseOptions = protobuf_json_mapping::ParseOptions { + ignore_unknown_fields: true, + _future_options: (), +}; + +fn parse_value_to_msg( + value: &Value, +) -> Result { + protobuf_json_mapping::parse_from_str_with_options::(&value.to_string(), &IGNORE_UNKNOWN) +} + +pub fn base64_proto<'de, T, D>(de: D) -> Result, D::Error> +where + T: MessageFull, + D: Deserializer<'de>, +{ + let v: String = Deserialize::deserialize(de)?; + let bytes = BASE64_STANDARD + .decode(v) + .map_err(|e| Error::custom(e.to_string()))?; + + T::parse_from_bytes(&bytes).map(Some).map_err(Error::custom) +} + +pub fn json_proto<'de, T, D>(de: D) -> Result +where + T: MessageFull, + D: Deserializer<'de>, +{ + let v: Value = Deserialize::deserialize(de)?; + parse_value_to_msg(&v).map_err(Error::custom) +} + +pub fn option_json_proto<'de, T, D>(de: D) -> Result, D::Error> +where + T: MessageFull, + D: Deserializer<'de>, +{ + let v: Value = Deserialize::deserialize(de)?; + parse_value_to_msg(&v).map(Some).map_err(Error::custom) +} + +pub fn vec_json_proto<'de, T, D>(de: D) -> Result, D::Error> +where + T: MessageFull, + D: Deserializer<'de>, +{ + let v: Value = Deserialize::deserialize(de)?; + let array = match v { + Value::Array(array) => array, + _ => return Err(Error::custom("the value wasn't an array")), + }; + + let res = array + .iter() + .flat_map(parse_value_to_msg) + .collect::>(); + + Ok(res) +} + +pub fn boxed<'de, T, D>(de: D) -> Result, D::Error> +where + T: Deserialize<'de>, + D: Deserializer<'de>, +{ + let v: T = Deserialize::deserialize(de)?; + Ok(Box::new(v)) +} + +pub fn bool_from_string<'de, D>(de: D) -> Result +where + D: Deserializer<'de>, +{ + match String::deserialize(de)?.as_ref() { + "true" => Ok(true), + "false" => Ok(false), + other => Err(Error::invalid_value( + Unexpected::Str(other), + &"true or false", + )), + } +} diff --git a/native/vendor/librespot-core/src/diffie_hellman.rs b/native/vendor/librespot-core/src/diffie_hellman.rs new file mode 100644 index 00000000..283aa026 --- /dev/null +++ b/native/vendor/librespot-core/src/diffie_hellman.rs @@ -0,0 +1,67 @@ +use std::sync::LazyLock; + +use num_bigint::BigUint; +use num_integer::Integer; +use num_traits::{One, Zero}; +use rand::{CryptoRng, Rng}; + +static DH_GENERATOR: LazyLock = LazyLock::new(|| BigUint::from_bytes_be(&[0x02])); +static DH_PRIME: LazyLock = LazyLock::new(|| { + BigUint::from_bytes_be(&[ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc9, 0x0f, 0xda, 0xa2, 0x21, 0x68, 0xc2, + 0x34, 0xc4, 0xc6, 0x62, 0x8b, 0x80, 0xdc, 0x1c, 0xd1, 0x29, 0x02, 0x4e, 0x08, 0x8a, 0x67, + 0xcc, 0x74, 0x02, 0x0b, 0xbe, 0xa6, 0x3b, 0x13, 0x9b, 0x22, 0x51, 0x4a, 0x08, 0x79, 0x8e, + 0x34, 0x04, 0xdd, 0xef, 0x95, 0x19, 0xb3, 0xcd, 0x3a, 0x43, 0x1b, 0x30, 0x2b, 0x0a, 0x6d, + 0xf2, 0x5f, 0x14, 0x37, 0x4f, 0xe1, 0x35, 0x6d, 0x6d, 0x51, 0xc2, 0x45, 0xe4, 0x85, 0xb5, + 0x76, 0x62, 0x5e, 0x7e, 0xc6, 0xf4, 0x4c, 0x42, 0xe9, 0xa6, 0x3a, 0x36, 0x20, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + ]) +}); + +fn powm(base: &BigUint, exp: &BigUint, modulus: &BigUint) -> BigUint { + let mut base = base.clone(); + let mut exp = exp.clone(); + let mut result: BigUint = One::one(); + + while !exp.is_zero() { + if exp.is_odd() { + result = (result * &base) % modulus; + } + exp >>= 1; + base = (&base * &base) % modulus; + } + + result +} + +pub struct DhLocalKeys { + private_key: BigUint, + public_key: BigUint, +} + +impl DhLocalKeys { + pub fn random(rng: &mut R) -> DhLocalKeys { + let mut bytes = [0u8; 95]; + rng.fill_bytes(&mut bytes); + let private_key = BigUint::from_bytes_le(&bytes); + let public_key = powm(&DH_GENERATOR, &private_key, &DH_PRIME); + + DhLocalKeys { + private_key, + public_key, + } + } + + pub fn public_key(&self) -> Vec { + self.public_key.to_bytes_be() + } + + pub fn shared_secret(&self, remote_key: &[u8]) -> Vec { + let shared_key = powm( + &BigUint::from_bytes_be(remote_key), + &self.private_key, + &DH_PRIME, + ); + shared_key.to_bytes_be() + } +} diff --git a/native/vendor/librespot-core/src/error.rs b/native/vendor/librespot-core/src/error.rs new file mode 100644 index 00000000..7f299579 --- /dev/null +++ b/native/vendor/librespot-core/src/error.rs @@ -0,0 +1,507 @@ +use std::{ + error, fmt, + num::{ParseIntError, TryFromIntError}, + str::Utf8Error, + string::FromUtf8Error, +}; + +use base64::DecodeError; +use http::{ + header::{InvalidHeaderName, InvalidHeaderValue, ToStrError}, + method::InvalidMethod, + status::InvalidStatusCode, + uri::{InvalidUri, InvalidUriParts}, +}; +use protobuf::Error as ProtobufError; +use thiserror::Error; +use tokio::sync::{ + AcquireError, TryAcquireError, mpsc::error::SendError, oneshot::error::RecvError, +}; +use url::ParseError; + +use librespot_oauth::OAuthError; + +#[derive(Debug)] +pub struct Error { + pub kind: ErrorKind, + pub error: Box, +} + +#[derive(Clone, Copy, Debug, Eq, Error, Hash, Ord, PartialEq, PartialOrd)] +pub enum ErrorKind { + #[error("The operation was cancelled by the caller")] + Cancelled = 1, + + #[error("Unknown error")] + Unknown = 2, + + #[error("Client specified an invalid argument")] + InvalidArgument = 3, + + #[error("Deadline expired before operation could complete")] + DeadlineExceeded = 4, + + #[error("Requested entity was not found")] + NotFound = 5, + + #[error("Attempt to create entity that already exists")] + AlreadyExists = 6, + + #[error("Permission denied")] + PermissionDenied = 7, + + #[error("No valid authentication credentials")] + Unauthenticated = 16, + + #[error("Resource has been exhausted")] + ResourceExhausted = 8, + + #[error("Invalid state")] + FailedPrecondition = 9, + + #[error("Operation aborted")] + Aborted = 10, + + #[error("Operation attempted past the valid range")] + OutOfRange = 11, + + #[error("Not implemented")] + Unimplemented = 12, + + #[error("Internal error")] + Internal = 13, + + #[error("Service unavailable")] + Unavailable = 14, + + #[error("Unrecoverable data loss or corruption")] + DataLoss = 15, + + #[error("Operation must not be used")] + DoNotUse = -1, +} + +#[derive(Debug, Error)] +struct ErrorMessage(String); + +impl fmt::Display for ErrorMessage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Error { + pub fn new(kind: ErrorKind, error: E) -> Error + where + E: Into>, + { + Self { + kind, + error: error.into(), + } + } + + pub fn aborted(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::Aborted, + error: error.into(), + } + } + + pub fn already_exists(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::AlreadyExists, + error: error.into(), + } + } + + pub fn cancelled(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::Cancelled, + error: error.into(), + } + } + + pub fn data_loss(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::DataLoss, + error: error.into(), + } + } + + pub fn deadline_exceeded(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::DeadlineExceeded, + error: error.into(), + } + } + + pub fn do_not_use(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::DoNotUse, + error: error.into(), + } + } + + pub fn failed_precondition(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::FailedPrecondition, + error: error.into(), + } + } + + pub fn internal(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::Internal, + error: error.into(), + } + } + + pub fn invalid_argument(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::InvalidArgument, + error: error.into(), + } + } + + pub fn not_found(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::NotFound, + error: error.into(), + } + } + + pub fn out_of_range(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::OutOfRange, + error: error.into(), + } + } + + pub fn permission_denied(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::PermissionDenied, + error: error.into(), + } + } + + pub fn resource_exhausted(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::ResourceExhausted, + error: error.into(), + } + } + + pub fn unauthenticated(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::Unauthenticated, + error: error.into(), + } + } + + pub fn unavailable(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::Unavailable, + error: error.into(), + } + } + + pub fn unimplemented(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::Unimplemented, + error: error.into(), + } + } + + pub fn unknown(error: E) -> Error + where + E: Into>, + { + Self { + kind: ErrorKind::Unknown, + error: error.into(), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.error.source() + } +} + +impl fmt::Display for Error { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(fmt, "{} {{ ", self.kind)?; + self.error.fmt(fmt)?; + write!(fmt, " }}") + } +} + +impl From for Error { + fn from(err: OAuthError) -> Self { + use OAuthError::*; + match err { + AuthCodeBadUri { .. } + | AuthCodeNotFound { .. } + | AuthCodeListenerRead + | AuthCodeListenerParse => Error::unavailable(err), + AuthCodeStdinRead + | AuthCodeListenerBind { .. } + | AuthCodeListenerTerminated + | AuthCodeListenerWrite + | Recv + | ExchangeCode { .. } => Error::internal(err), + _ => Error::failed_precondition(err), + } + } +} + +impl From for Error { + fn from(err: DecodeError) -> Self { + Self::new(ErrorKind::FailedPrecondition, err) + } +} + +impl From for Error { + fn from(err: http::Error) -> Self { + if err.is::() + || err.is::() + || err.is::() + || err.is::() + || err.is::() + { + return Self::new(ErrorKind::InvalidArgument, err); + } + + if err.is::() { + return Self::new(ErrorKind::FailedPrecondition, err); + } + + Self::new(ErrorKind::Unknown, err) + } +} + +impl From for Error { + fn from(err: hyper::Error) -> Self { + if err.is_parse() || err.is_parse_status() || err.is_user() { + return Self::new(ErrorKind::Internal, err); + } + + if err.is_canceled() { + return Self::new(ErrorKind::Cancelled, err); + } + + if err.is_incomplete_message() { + return Self::new(ErrorKind::DataLoss, err); + } + + if err.is_body_write_aborted() || err.is_closed() { + return Self::new(ErrorKind::Aborted, err); + } + + if err.is_timeout() { + return Self::new(ErrorKind::DeadlineExceeded, err); + } + + Self::new(ErrorKind::Unknown, err) + } +} + +impl From for Error { + fn from(err: hyper_util::client::legacy::Error) -> Self { + if err.is_connect() { + return Self::new(ErrorKind::Unavailable, err); + } + + Self::new(ErrorKind::Unknown, err) + } +} + +impl From for Error { + fn from(err: time::error::Parse) -> Self { + Self::new(ErrorKind::FailedPrecondition, err) + } +} + +impl From for Error { + fn from(err: quick_xml::Error) -> Self { + Self::new(ErrorKind::FailedPrecondition, err) + } +} + +impl From for Error { + fn from(err: serde_json::Error) -> Self { + Self::new(ErrorKind::FailedPrecondition, err) + } +} + +impl From for Error { + fn from(err: std::io::Error) -> Self { + use std::io::ErrorKind as IoErrorKind; + match err.kind() { + IoErrorKind::NotFound => Self::new(ErrorKind::NotFound, err), + IoErrorKind::PermissionDenied => Self::new(ErrorKind::PermissionDenied, err), + IoErrorKind::AddrInUse | IoErrorKind::AlreadyExists => { + Self::new(ErrorKind::AlreadyExists, err) + } + IoErrorKind::AddrNotAvailable + | IoErrorKind::ConnectionRefused + | IoErrorKind::NotConnected => Self::new(ErrorKind::Unavailable, err), + IoErrorKind::BrokenPipe + | IoErrorKind::ConnectionReset + | IoErrorKind::ConnectionAborted => Self::new(ErrorKind::Aborted, err), + IoErrorKind::Interrupted | IoErrorKind::WouldBlock => { + Self::new(ErrorKind::Cancelled, err) + } + IoErrorKind::InvalidData | IoErrorKind::UnexpectedEof => { + Self::new(ErrorKind::FailedPrecondition, err) + } + IoErrorKind::TimedOut => Self::new(ErrorKind::DeadlineExceeded, err), + IoErrorKind::InvalidInput => Self::new(ErrorKind::InvalidArgument, err), + IoErrorKind::WriteZero => Self::new(ErrorKind::ResourceExhausted, err), + _ => Self::new(ErrorKind::Unknown, err), + } + } +} + +impl From for Error { + fn from(err: FromUtf8Error) -> Self { + Self::new(ErrorKind::FailedPrecondition, err) + } +} + +impl From for Error { + fn from(err: InvalidHeaderValue) -> Self { + Self::new(ErrorKind::InvalidArgument, err) + } +} + +impl From for Error { + fn from(err: InvalidUri) -> Self { + Self::new(ErrorKind::InvalidArgument, err) + } +} + +impl From for Error { + fn from(err: ParseError) -> Self { + Self::new(ErrorKind::FailedPrecondition, err) + } +} + +impl From for Error { + fn from(err: ParseIntError) -> Self { + Self::new(ErrorKind::FailedPrecondition, err) + } +} + +impl From for Error { + fn from(err: TryFromIntError) -> Self { + Self::new(ErrorKind::FailedPrecondition, err) + } +} + +impl From for Error { + fn from(err: ProtobufError) -> Self { + Self::new(ErrorKind::FailedPrecondition, err) + } +} + +impl From for Error { + fn from(err: RecvError) -> Self { + Self::new(ErrorKind::Internal, err) + } +} + +impl From> for Error { + fn from(err: SendError) -> Self { + Self { + kind: ErrorKind::Internal, + error: ErrorMessage(err.to_string()).into(), + } + } +} + +impl From for Error { + fn from(err: AcquireError) -> Self { + Self { + kind: ErrorKind::ResourceExhausted, + error: ErrorMessage(err.to_string()).into(), + } + } +} + +impl From for Error { + fn from(err: TryAcquireError) -> Self { + Self { + kind: ErrorKind::ResourceExhausted, + error: ErrorMessage(err.to_string()).into(), + } + } +} + +impl From for Error { + fn from(err: ToStrError) -> Self { + Self::new(ErrorKind::FailedPrecondition, err) + } +} + +impl From for Error { + fn from(err: Utf8Error) -> Self { + Self::new(ErrorKind::FailedPrecondition, err) + } +} + +impl From for Error { + fn from(err: protobuf_json_mapping::ParseError) -> Self { + Self::failed_precondition(err) + } +} diff --git a/native/vendor/librespot-core/src/file_id.rs b/native/vendor/librespot-core/src/file_id.rs new file mode 100644 index 00000000..e513d97d --- /dev/null +++ b/native/vendor/librespot-core/src/file_id.rs @@ -0,0 +1,62 @@ +use std::fmt; + +use librespot_protocol as protocol; + +use crate::{Error, spotify_id::to_base16}; + +const RAW_LEN: usize = 20; + +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct FileId(pub [u8; RAW_LEN]); + +impl FileId { + pub fn from_raw(src: &[u8]) -> FileId { + let mut dst = [0u8; RAW_LEN]; + let len = src.len(); + // some tracks return 16 instead of 20 bytes: #1188 + if len <= RAW_LEN { + dst[..len].clone_from_slice(src); + } + FileId(dst) + } + + #[allow(clippy::wrong_self_convention)] + pub fn to_base16(&self) -> Result { + to_base16(&self.0, &mut [0u8; 40]) + } +} + +impl fmt::Debug for FileId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("FileId").field(&self.to_base16()).finish() + } +} + +impl fmt::Display for FileId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_base16().unwrap_or_default()) + } +} + +impl From<&[u8]> for FileId { + fn from(src: &[u8]) -> Self { + Self::from_raw(src) + } +} +impl From<&protocol::metadata::Image> for FileId { + fn from(image: &protocol::metadata::Image) -> Self { + Self::from(image.file_id()) + } +} + +impl From<&protocol::metadata::AudioFile> for FileId { + fn from(file: &protocol::metadata::AudioFile) -> Self { + Self::from(file.file_id()) + } +} + +impl From<&protocol::metadata::VideoFile> for FileId { + fn from(video: &protocol::metadata::VideoFile) -> Self { + Self::from(video.file_id()) + } +} diff --git a/native/vendor/librespot-core/src/http_client.rs b/native/vendor/librespot-core/src/http_client.rs new file mode 100644 index 00000000..bbd63838 --- /dev/null +++ b/native/vendor/librespot-core/src/http_client.rs @@ -0,0 +1,304 @@ +use std::{ + sync::OnceLock, + time::{Duration, Instant}, +}; + +use bytes::Bytes; +use futures_util::{FutureExt, future::IntoStream}; +use governor::{ + Quota, RateLimiter, clock::MonotonicClock, middleware::NoOpMiddleware, + state::keyed::DefaultKeyedStateStore, +}; +use http::{Uri, header::HeaderValue}; +use http_body_util::{BodyExt, Full}; +use hyper::{HeaderMap, Request, Response, StatusCode, body::Incoming, header::USER_AGENT}; +use hyper_proxy2::{Intercept, Proxy, ProxyConnector}; +use hyper_util::{ + client::legacy::{Client, ResponseFuture, connect::HttpConnector}, + rt::TokioExecutor, +}; +use nonzero_ext::nonzero; +use thiserror::Error; +use url::Url; + +#[cfg(all(feature = "__rustls", not(feature = "native-tls")))] +use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder}; +#[cfg(all(feature = "native-tls", not(feature = "__rustls")))] +use hyper_tls::HttpsConnector; + +use crate::{ + Error, + config::{OS, os_version}, + date::Date, + version::{FALLBACK_USER_AGENT, VERSION_STRING, spotify_version}, +}; + +// The 30 seconds interval is documented by Spotify, but the calls per interval +// is a guesstimate and probably subject to licensing (purchasing extra calls) +// and may change at any time. +pub const RATE_LIMIT_INTERVAL: Duration = Duration::from_secs(30); +pub const RATE_LIMIT_MAX_WAIT: Duration = Duration::from_secs(10); +pub const RATE_LIMIT_CALLS_PER_INTERVAL: u32 = 300; + +#[derive(Debug, Error)] +pub enum HttpClientError { + #[error("Response status code: {0}")] + StatusCode(hyper::StatusCode), +} + +impl From for Error { + fn from(err: HttpClientError) -> Self { + match err { + HttpClientError::StatusCode(code) => { + // not exhaustive, but what reasonably could be expected + match code { + StatusCode::GATEWAY_TIMEOUT | StatusCode::REQUEST_TIMEOUT => { + Error::deadline_exceeded(err) + } + StatusCode::GONE + | StatusCode::NOT_FOUND + | StatusCode::MOVED_PERMANENTLY + | StatusCode::PERMANENT_REDIRECT + | StatusCode::TEMPORARY_REDIRECT => Error::not_found(err), + StatusCode::FORBIDDEN | StatusCode::PAYMENT_REQUIRED => { + Error::permission_denied(err) + } + StatusCode::NETWORK_AUTHENTICATION_REQUIRED + | StatusCode::PROXY_AUTHENTICATION_REQUIRED + | StatusCode::UNAUTHORIZED => Error::unauthenticated(err), + StatusCode::EXPECTATION_FAILED + | StatusCode::PRECONDITION_FAILED + | StatusCode::PRECONDITION_REQUIRED => Error::failed_precondition(err), + StatusCode::RANGE_NOT_SATISFIABLE => Error::out_of_range(err), + StatusCode::INTERNAL_SERVER_ERROR + | StatusCode::MISDIRECTED_REQUEST + | StatusCode::SERVICE_UNAVAILABLE + | StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS => Error::unavailable(err), + StatusCode::BAD_REQUEST + | StatusCode::HTTP_VERSION_NOT_SUPPORTED + | StatusCode::LENGTH_REQUIRED + | StatusCode::METHOD_NOT_ALLOWED + | StatusCode::NOT_ACCEPTABLE + | StatusCode::PAYLOAD_TOO_LARGE + | StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE + | StatusCode::UNSUPPORTED_MEDIA_TYPE + | StatusCode::URI_TOO_LONG => Error::invalid_argument(err), + StatusCode::TOO_MANY_REQUESTS => Error::resource_exhausted(err), + StatusCode::NOT_IMPLEMENTED => Error::unimplemented(err), + _ => Error::unknown(err), + } + } + } + } +} + +type HyperClient = Client>, Full>; + +pub struct HttpClient { + user_agent: HeaderValue, + proxy_url: Option, + hyper_client: OnceLock, + + rate_limiter: + RateLimiter, MonotonicClock, NoOpMiddleware>, +} + +impl HttpClient { + pub fn new(proxy_url: Option<&Url>) -> Self { + let zero_str = String::from("0"); + let os_version = os_version(); + + let (spotify_platform, os_version) = match OS { + "android" => ("Android", os_version), + "ios" => ("iOS", os_version), + "macos" => ("OSX", zero_str), + "windows" => ("Win32", zero_str), + _ => ("Linux", zero_str), + }; + + let user_agent_str = &format!( + "Spotify/{} {}/{} ({})", + spotify_version(), + spotify_platform, + os_version, + VERSION_STRING + ); + + let user_agent = HeaderValue::from_str(user_agent_str).unwrap_or_else(|err| { + error!("Invalid user agent <{user_agent_str}>: {err}"); + HeaderValue::from_static(FALLBACK_USER_AGENT) + }); + + let replenish_interval_ns = + RATE_LIMIT_INTERVAL.as_nanos() / RATE_LIMIT_CALLS_PER_INTERVAL as u128; + let quota = Quota::with_period(Duration::from_nanos(replenish_interval_ns as u64)) + .expect("replenish interval should be valid") + .allow_burst(nonzero![RATE_LIMIT_CALLS_PER_INTERVAL]); + let rate_limiter = RateLimiter::keyed(quota); + + Self { + user_agent, + proxy_url: proxy_url.cloned(), + hyper_client: OnceLock::new(), + rate_limiter, + } + } + + fn try_create_hyper_client(proxy_url: Option<&Url>) -> Result { + // configuring TLS is expensive and should be done once per process + + #[cfg(all(feature = "__rustls", not(feature = "native-tls")))] + let https_connector = { + #[cfg(feature = "rustls-tls-native-roots")] + let tls = HttpsConnectorBuilder::new().with_native_roots()?; + #[cfg(feature = "rustls-tls-webpki-roots")] + let tls = HttpsConnectorBuilder::new().with_webpki_roots(); + tls.https_or_http().enable_http1().enable_http2().build() + }; + + #[cfg(all(feature = "native-tls", not(feature = "__rustls")))] + let https_connector = HttpsConnector::new(); + + // When not using a proxy a dummy proxy is configured that will not intercept any traffic. + // This prevents needing to carry the Client Connector generics through the whole project + let proxy = match &proxy_url { + Some(proxy_url) => Proxy::new(Intercept::All, proxy_url.to_string().parse()?), + None => Proxy::new(Intercept::None, Uri::from_static("0.0.0.0")), + }; + let proxy_connector = ProxyConnector::from_proxy(https_connector, proxy)?; + + let client = Client::builder(TokioExecutor::new()) + .http2_adaptive_window(true) + .build(proxy_connector); + Ok(client) + } + + fn hyper_client(&self) -> &HyperClient { + self.hyper_client + .get_or_init(|| Self::try_create_hyper_client(self.proxy_url.as_ref()).unwrap()) + } + + pub async fn request(&self, req: Request) -> Result, Error> { + debug!("Requesting {}", req.uri()); + + // `Request` does not implement `Clone` because its `Body` may be a single-shot stream. + // As correct as that may be technically, we now need all this boilerplate to clone it + // ourselves, as any `Request` is moved in the loop. + let (parts, body_as_bytes) = req.into_parts(); + + loop { + let mut req = Request::builder() + .method(parts.method.clone()) + .uri(parts.uri.clone()) + .version(parts.version) + .body(body_as_bytes.clone())?; + *req.headers_mut() = parts.headers.clone(); + + let request = self.request_fut(req)?; + let response = request.await; + + if let Ok(response) = &response { + let code = response.status(); + + if code == StatusCode::TOO_MANY_REQUESTS { + if let Some(duration) = Self::get_retry_after(response.headers()) { + warn!( + "Rate limited by service, retrying in {} seconds...", + duration.as_secs() + ); + tokio::time::sleep(duration).await; + continue; + } + } + + if !code.is_success() { + return Err(HttpClientError::StatusCode(code).into()); + } + } + + let response = response?; + return Ok(response); + } + } + + pub async fn request_body(&self, req: Request) -> Result { + let response = self.request(req).await?; + Ok(response.into_body().collect().await?.to_bytes()) + } + + pub fn request_stream(&self, req: Request) -> Result, Error> { + Ok(self.request_fut(req)?.into_stream()) + } + + pub fn request_fut(&self, mut req: Request) -> Result { + let headers_mut = req.headers_mut(); + headers_mut.insert(USER_AGENT, self.user_agent.clone()); + + // For rate limiting we cannot *just* depend on Spotify sending us HTTP/429 + // Retry-After headers. For example, when there is a service interruption + // and HTTP/500 is returned, we don't want to DoS the Spotify infrastructure. + let domain = match req.uri().host() { + Some(host) => { + // strip the prefix from *.domain.tld (assume rate limit is per domain, not subdomain) + let mut parts = host + .split('.') + .map(|s| s.to_string()) + .collect::>(); + let n = parts.len().saturating_sub(2); + parts.drain(n..).collect() + } + None => String::from(""), + }; + self.rate_limiter.check_key(&domain).map_err(|e| { + Error::resource_exhausted(format!( + "rate limited for at least another {} seconds", + e.wait_time_from(Instant::now()).as_secs() + )) + })?; + + Ok(self.hyper_client().request(req.map(Full::new))) + } + + pub fn get_retry_after(headers: &HeaderMap) -> Option { + let now = Date::now_utc().as_timestamp_ms(); + + let mut retry_after_ms = None; + if let Some(header_val) = headers.get("X-RateLimit-Next") { + // *.akamaized.net (Akamai) + if let Ok(date_str) = header_val.to_str() { + if let Ok(target) = Date::from_iso8601(date_str) { + retry_after_ms = Some(target.as_timestamp_ms().saturating_sub(now)) + } + } + } else if let Some(header_val) = headers.get("Fastly-RateLimit-Reset") { + // *.scdn.co (Fastly) + if let Ok(timestamp) = header_val.to_str() { + if let Ok(target) = timestamp.parse::() { + retry_after_ms = Some(target.saturating_sub(now)) + } + } + } else if let Some(header_val) = headers.get("Retry-After") { + // Generic RFC compliant (including *.spotify.com) + if let Ok(retry_after) = header_val.to_str() { + if let Ok(duration) = retry_after.parse::() { + retry_after_ms = Some(duration * 1000) + } + } + } + + if let Some(retry_after) = retry_after_ms { + let duration = Duration::from_millis(retry_after as u64); + if duration <= RATE_LIMIT_MAX_WAIT { + return Some(duration); + } else { + debug!( + "Waiting {} seconds would exceed {} second limit", + duration.as_secs(), + RATE_LIMIT_MAX_WAIT.as_secs() + ); + } + } + + None + } +} diff --git a/native/vendor/librespot-core/src/lib.rs b/native/vendor/librespot-core/src/lib.rs new file mode 100644 index 00000000..f4ead234 --- /dev/null +++ b/native/vendor/librespot-core/src/lib.rs @@ -0,0 +1,46 @@ +#[macro_use] +extern crate log; + +use librespot_protocol as protocol; + +#[macro_use] +mod component; + +pub mod apresolve; +pub mod audio_key; +pub mod authentication; +pub mod cache; +pub mod cdn_url; +pub mod channel; +pub mod config; +mod connection; +pub mod date; +#[allow(dead_code)] +pub mod dealer; +pub mod deserialize_with; +#[doc(hidden)] +pub mod diffie_hellman; +pub mod error; +pub mod file_id; +pub mod http_client; +pub mod login5; +pub mod mercury; +pub mod packet; +mod proxytunnel; +pub mod session; +mod socket; +#[allow(dead_code)] +pub mod spclient; +pub mod spotify_id; +pub mod spotify_uri; +pub mod token; +#[doc(hidden)] +pub mod util; +pub mod version; + +pub use config::SessionConfig; +pub use error::Error; +pub use file_id::FileId; +pub use session::Session; +pub use spotify_id::SpotifyId; +pub use spotify_uri::SpotifyUri; diff --git a/native/vendor/librespot-core/src/login5.rs b/native/vendor/librespot-core/src/login5.rs new file mode 100644 index 00000000..9cf71338 --- /dev/null +++ b/native/vendor/librespot-core/src/login5.rs @@ -0,0 +1,270 @@ +use crate::config::OS; +use crate::spclient::CLIENT_TOKEN; +use crate::token::Token; +use crate::{Error, SessionConfig, util}; +use bytes::Bytes; +use http::{HeaderValue, Method, Request, header::ACCEPT}; +use librespot_protocol::login5::login_response::Response; +use librespot_protocol::{ + client_info::ClientInfo, + credentials::{Password, StoredCredential}, + hashcash::HashcashSolution, + login5::{ + ChallengeSolution, LoginError, LoginOk, LoginRequest, LoginResponse, + login_request::Login_method, + }, +}; +use protobuf::well_known_types::duration::Duration as ProtoDuration; +use protobuf::{Message, MessageField}; +use std::time::{Duration, SystemTime}; +use thiserror::Error; +use tokio::time::sleep; + +const MAX_LOGIN_TRIES: u8 = 3; +const LOGIN_TIMEOUT: Duration = Duration::from_secs(3); + +component! { + Login5Manager : Login5ManagerInner { + auth_token: Option = None, + } +} + +#[derive(Debug, Error)] +enum Login5Error { + #[error("Login request was denied: {0:?}")] + FaultyRequest(LoginError), + #[error("Code challenge is not supported")] + CodeChallenge, + #[error("Tried to acquire token without stored credentials")] + NoStoredCredentials, + #[error("Couldn't successfully authenticate after {0} times")] + RetriesFailed(u8), + #[error("Login via login5 is only allowed for android or ios")] + OnlyForMobile, +} + +impl From for Error { + fn from(err: Login5Error) -> Self { + match err { + Login5Error::NoStoredCredentials | Login5Error::OnlyForMobile => { + Error::unavailable(err) + } + Login5Error::RetriesFailed(_) | Login5Error::FaultyRequest(_) => { + Error::failed_precondition(err) + } + Login5Error::CodeChallenge => Error::unimplemented(err), + } + } +} + +impl Login5Manager { + async fn request(&self, message: &LoginRequest) -> Result { + let client_token = self.session().spclient().client_token().await?; + let body = message.write_to_bytes()?; + + let request = Request::builder() + .method(&Method::POST) + .uri("https://login5.spotify.com/v3/login") + .header(ACCEPT, HeaderValue::from_static("application/x-protobuf")) + .header(CLIENT_TOKEN, HeaderValue::from_str(&client_token)?) + .body(body.into())?; + + self.session().http_client().request_body(request).await + } + + async fn login5_request(&self, login: Login_method) -> Result { + let client_id = match OS { + "macos" | "windows" => self.session().client_id(), + // StoredCredential is used to get an access_token from Session credentials. + // Using the session client_id allows user to use Keymaster on Android/IOS + // if their Credentials::with_access_token was obtained there, assuming + // they have overriden the SessionConfig::client_id with the Keymaster's. + _ if matches!(login, Login_method::StoredCredential(_)) => self.session().client_id(), + _ => SessionConfig::default().client_id, + }; + + let mut login_request = LoginRequest { + client_info: MessageField::some(ClientInfo { + client_id, + device_id: self.session().device_id().to_string(), + special_fields: Default::default(), + }), + login_method: Some(login), + ..Default::default() + }; + + let mut response = self.request(&login_request).await?; + let mut count = 0; + + loop { + count += 1; + + let message = LoginResponse::parse_from_bytes(&response)?; + if let Some(Response::Ok(ok)) = message.response { + break Ok(ok); + } + + if message.has_error() { + match message.error() { + LoginError::TIMEOUT | LoginError::TOO_MANY_ATTEMPTS => { + sleep(LOGIN_TIMEOUT).await + } + others => return Err(Login5Error::FaultyRequest(others).into()), + } + } + + if message.has_challenges() { + // handles the challenges, and updates the login context with the response + Self::handle_challenges(&mut login_request, message)?; + } + + if count < MAX_LOGIN_TRIES { + response = self.request(&login_request).await?; + } else { + return Err(Login5Error::RetriesFailed(MAX_LOGIN_TRIES).into()); + } + } + } + + /// Login for android and ios + /// + /// This request doesn't require a connected session as it is the entrypoint for android or ios + /// + /// This request will only work when: + /// - client_id => android or ios | can be easily adjusted in [SessionConfig::default_for_os] + /// - user-agent => android or ios | has to be adjusted in [HttpClient::new](crate::http_client::HttpClient::new) + pub async fn login( + &self, + id: impl Into, + password: impl Into, + ) -> Result<(Token, Vec), Error> { + if !matches!(OS, "android" | "ios") { + // by manipulating the user-agent and client-id it can be also used/tested on desktop + return Err(Login5Error::OnlyForMobile.into()); + } + + let method = Login_method::Password(Password { + id: id.into(), + password: password.into(), + ..Default::default() + }); + + let token_response = self.login5_request(method).await?; + let auth_token = Self::token_from_login( + token_response.access_token, + token_response.access_token_expires_in, + ); + + Ok((auth_token, token_response.stored_credential)) + } + + /// Retrieve the access_token via login5 + /// + /// This request will only work when the store credentials match the client-id. Meaning that + /// stored credentials generated with the keymaster client-id will not work, for example, with + /// the android client-id. + pub async fn auth_token(&self) -> Result { + let auth_data = self.session().auth_data(); + if auth_data.is_empty() { + return Err(Login5Error::NoStoredCredentials.into()); + } + + let auth_token = self.lock(|inner| { + if let Some(token) = &inner.auth_token { + if token.is_expired() { + inner.auth_token = None; + } + } + inner.auth_token.clone() + }); + + if let Some(auth_token) = auth_token { + return Ok(auth_token); + } + + let method = Login_method::StoredCredential(StoredCredential { + username: self.session().username().to_string(), + data: auth_data, + ..Default::default() + }); + + let token_response = self.login5_request(method).await?; + let auth_token = Self::token_from_login( + token_response.access_token, + token_response.access_token_expires_in, + ); + + let token = self.lock(|inner| { + inner.auth_token = Some(auth_token.clone()); + inner.auth_token.clone() + }); + + trace!("Got auth token: {auth_token:?}"); + + token.ok_or(Login5Error::NoStoredCredentials.into()) + } + + fn handle_challenges( + login_request: &mut LoginRequest, + message: LoginResponse, + ) -> Result<(), Error> { + let challenges = message.challenges(); + debug!( + "Received {} challenges, solving...", + challenges.challenges.len() + ); + + for challenge in &challenges.challenges { + if challenge.has_code() { + return Err(Login5Error::CodeChallenge.into()); + } else if !challenge.has_hashcash() { + debug!("Challenge was empty, skipping..."); + continue; + } + + let hash_cash_challenge = challenge.hashcash(); + + let mut suffix = [0u8; 0x10]; + let duration = util::solve_hash_cash( + &message.login_context, + &hash_cash_challenge.prefix, + hash_cash_challenge.length, + &mut suffix, + )?; + + let (seconds, nanos) = (duration.as_secs() as i64, duration.subsec_nanos() as i32); + debug!("Solving hashcash took {seconds}s {nanos}ns"); + + let mut solution = ChallengeSolution::new(); + solution.set_hashcash(HashcashSolution { + suffix: Vec::from(suffix), + duration: MessageField::some(ProtoDuration { + seconds, + nanos, + ..Default::default() + }), + ..Default::default() + }); + + login_request + .challenge_solutions + .mut_or_insert_default() + .solutions + .push(solution); + } + + login_request.login_context = message.login_context; + + Ok(()) + } + + fn token_from_login(token: String, expires_in: i32) -> Token { + Token { + access_token: token, + expires_in: Duration::from_secs(expires_in.try_into().unwrap_or(3600)), + token_type: "Bearer".to_string(), + scopes: vec![], + timestamp: SystemTime::now(), + } + } +} diff --git a/native/vendor/librespot-core/src/mercury/mod.rs b/native/vendor/librespot-core/src/mercury/mod.rs new file mode 100644 index 00000000..b32aeb11 --- /dev/null +++ b/native/vendor/librespot-core/src/mercury/mod.rs @@ -0,0 +1,306 @@ +use std::{ + collections::HashMap, + future::Future, + pin::Pin, + task::{Context, Poll}, +}; + +use byteorder::{BigEndian, ByteOrder}; +use bytes::Bytes; +use futures_util::FutureExt; +use protobuf::Message; +use tokio::sync::{mpsc, oneshot}; + +use crate::{Error, packet::PacketType, protocol, util::SeqGenerator}; + +mod types; +pub use self::types::*; + +mod sender; +pub use self::sender::MercurySender; + +component! { + MercuryManager : MercuryManagerInner { + sequence: SeqGenerator = SeqGenerator::new(0), + pending: HashMap, MercuryPending> = HashMap::new(), + subscriptions: Vec<(String, mpsc::UnboundedSender)> = Vec::new(), + invalid: bool = false, + } +} + +pub struct MercuryPending { + parts: Vec>, + partial: Option>, + callback: Option>>, +} + +pub struct MercuryFuture { + receiver: oneshot::Receiver>, +} + +impl Future for MercuryFuture { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.receiver.poll_unpin(cx)? + } +} + +impl MercuryManager { + fn next_seq(&self) -> Vec { + let mut seq = vec![0u8; 8]; + BigEndian::write_u64(&mut seq, self.lock(|inner| inner.sequence.get())); + seq + } + + fn request(&self, req: MercuryRequest) -> Result, Error> { + let (tx, rx) = oneshot::channel(); + + let pending = MercuryPending { + parts: Vec::new(), + partial: None, + callback: Some(tx), + }; + + let seq = self.next_seq(); + self.lock(|inner| { + if !inner.invalid { + inner.pending.insert(seq.clone(), pending); + } + }); + + let cmd = req.method.command(); + let data = req.encode(&seq)?; + + self.session().send_packet(cmd, data)?; + Ok(MercuryFuture { receiver: rx }) + } + + pub fn get>(&self, uri: T) -> Result, Error> { + self.request(MercuryRequest { + method: MercuryMethod::Get, + uri: uri.into(), + content_type: None, + payload: Vec::new(), + }) + } + + pub fn send>( + &self, + uri: T, + data: Vec, + ) -> Result, Error> { + self.request(MercuryRequest { + method: MercuryMethod::Send, + uri: uri.into(), + content_type: None, + payload: vec![data], + }) + } + + pub fn sender>(&self, uri: T) -> MercurySender { + MercurySender::new(self.clone(), uri.into()) + } + + pub fn subscribe>( + &self, + uri: T, + ) -> impl Future, Error>> + 'static + { + let uri = uri.into(); + let request = self.request(MercuryRequest { + method: MercuryMethod::Sub, + uri: uri.clone(), + content_type: None, + payload: Vec::new(), + }); + + let manager = self.clone(); + async move { + let response = request?.await?; + + let (tx, rx) = mpsc::unbounded_channel(); + + manager.lock(move |inner| { + if !inner.invalid { + debug!("subscribed uri={} count={}", uri, response.payload.len()); + if !response.payload.is_empty() { + // Old subscription protocol, watch the provided list of URIs + for sub in response.payload { + match protocol::pubsub::Subscription::parse_from_bytes(&sub) { + Ok(mut sub) => { + let sub_uri = sub.take_uri(); + + debug!("subscribed sub_uri={sub_uri}"); + + inner.subscriptions.push((sub_uri, tx.clone())); + } + Err(e) => { + error!("could not subscribe to {uri}: {e}"); + } + } + } + } else { + // New subscription protocol, watch the requested URI + inner.subscriptions.push((uri, tx)); + } + } + }); + + Ok(rx) + } + } + + pub fn listen_for>( + &self, + uri: T, + ) -> impl Future> + 'static { + let uri = uri.into(); + + let manager = self.clone(); + async move { + let (tx, rx) = mpsc::unbounded_channel(); + + manager.lock(move |inner| { + if !inner.invalid { + debug!("listening to uri={uri}"); + inner.subscriptions.push((uri, tx)); + } + }); + + rx + } + } + + pub(crate) fn dispatch(&self, cmd: PacketType, mut data: Bytes) -> Result<(), Error> { + let seq_len = BigEndian::read_u16(data.split_to(2).as_ref()) as usize; + let seq = data.split_to(seq_len).as_ref().to_owned(); + + let flags = data.split_to(1).as_ref()[0]; + let count = BigEndian::read_u16(data.split_to(2).as_ref()) as usize; + + let pending = self.lock(|inner| inner.pending.remove(&seq)); + + let mut pending = match pending { + Some(pending) => pending, + None => { + if let PacketType::MercuryEvent = cmd { + MercuryPending { + parts: Vec::new(), + partial: None, + callback: None, + } + } else { + warn!("Ignore seq {:?} cmd {:x}", seq, cmd as u8); + return Err(MercuryError::Command(cmd).into()); + } + } + }; + + for i in 0..count { + let mut part = Self::parse_part(&mut data); + if let Some(mut partial) = pending.partial.take() { + partial.extend_from_slice(&part); + part = partial; + } + + if i == count - 1 && (flags == 2) { + pending.partial = Some(part) + } else { + pending.parts.push(part); + } + } + + if flags == 0x1 { + self.complete_request(cmd, pending)?; + } else { + self.lock(move |inner| inner.pending.insert(seq, pending)); + } + + Ok(()) + } + + fn parse_part(data: &mut Bytes) -> Vec { + let size = BigEndian::read_u16(data.split_to(2).as_ref()) as usize; + data.split_to(size).as_ref().to_owned() + } + + fn complete_request(&self, cmd: PacketType, mut pending: MercuryPending) -> Result<(), Error> { + let header_data = pending.parts.remove(0); + let header = protocol::mercury::Header::parse_from_bytes(&header_data)?; + + let response = MercuryResponse { + uri: header.uri().to_string(), + status_code: header.status_code(), + payload: pending.parts, + }; + + let status_code = response.status_code; + if status_code >= 500 { + error!("error {} for uri {}", status_code, &response.uri); + Err(MercuryError::Response(response).into()) + } else if status_code >= 400 { + error!("error {} for uri {}", status_code, &response.uri); + if let Some(cb) = pending.callback { + cb.send(Err(MercuryError::Response(response.clone()).into())) + .map_err(|_| MercuryError::Channel)?; + } + Err(MercuryError::Response(response).into()) + } else if let PacketType::MercuryEvent = cmd { + // TODO: This is just a workaround to make utf-8 encoded usernames work. + // A better solution would be to use an uri struct and urlencode it directly + // before sending while saving the subscription under its unencoded form. + let mut uri_split = response.uri.split('/'); + + let encoded_uri = std::iter::once(uri_split.next().unwrap_or_default().to_string()) + .chain(uri_split.map(|component| { + form_urlencoded::byte_serialize(component.as_bytes()).collect::() + })) + .collect::>() + .join("/"); + + let mut found = false; + + self.lock(|inner| { + inner.subscriptions.retain(|(prefix, sub)| { + if encoded_uri.starts_with(prefix) { + found = true; + + // if send fails, remove from list of subs + // TODO: send unsub message + sub.send(response.clone()).is_ok() + } else { + // URI doesn't match + true + } + }); + }); + + if found { + Ok(()) + } else if self.session().dealer().handles(&response.uri) { + trace!("mercury response <{}> is handled by dealer", response.uri); + Ok(()) + } else { + debug!("unknown subscription uri={}", &response.uri); + trace!("response pushed over Mercury: {response:?}"); + Err(MercuryError::Response(response).into()) + } + } else if let Some(cb) = pending.callback { + cb.send(Ok(response)).map_err(|_| MercuryError::Channel)?; + Ok(()) + } else { + error!("can't handle Mercury response: {response:?}"); + Err(MercuryError::Response(response).into()) + } + } + + pub(crate) fn shutdown(&self) { + self.lock(|inner| { + inner.invalid = true; + // destroy the sending halves of the channels to signal everyone who is waiting for something. + inner.pending.clear(); + inner.subscriptions.clear(); + }); + } +} diff --git a/native/vendor/librespot-core/src/mercury/sender.rs b/native/vendor/librespot-core/src/mercury/sender.rs new file mode 100644 index 00000000..31409e88 --- /dev/null +++ b/native/vendor/librespot-core/src/mercury/sender.rs @@ -0,0 +1,56 @@ +use std::collections::VecDeque; + +use super::{MercuryFuture, MercuryManager, MercuryResponse}; + +use crate::Error; + +pub struct MercurySender { + mercury: MercuryManager, + uri: String, + pending: VecDeque>, + buffered_future: Option>, +} + +impl MercurySender { + pub(crate) fn new(mercury: MercuryManager, uri: String) -> MercurySender { + MercurySender { + mercury, + uri, + pending: VecDeque::new(), + buffered_future: None, + } + } + + pub fn is_flushed(&self) -> bool { + self.buffered_future.is_none() && self.pending.is_empty() + } + + pub fn send(&mut self, item: Vec) -> Result<(), Error> { + let task = self.mercury.send(self.uri.clone(), item)?; + self.pending.push_back(task); + Ok(()) + } + + pub async fn flush(&mut self) -> Result<(), Error> { + if self.buffered_future.is_none() { + self.buffered_future = self.pending.pop_front(); + } + + while let Some(fut) = self.buffered_future.as_mut() { + fut.await?; + self.buffered_future = self.pending.pop_front(); + } + Ok(()) + } +} + +impl Clone for MercurySender { + fn clone(&self) -> MercurySender { + MercurySender { + mercury: self.mercury.clone(), + uri: self.uri.clone(), + pending: VecDeque::new(), + buffered_future: None, + } + } +} diff --git a/native/vendor/librespot-core/src/mercury/types.rs b/native/vendor/librespot-core/src/mercury/types.rs new file mode 100644 index 00000000..3cc8d8bc --- /dev/null +++ b/native/vendor/librespot-core/src/mercury/types.rs @@ -0,0 +1,101 @@ +use std::io::Write; + +use byteorder::{BigEndian, WriteBytesExt}; +use protobuf::Message; +use thiserror::Error; + +use crate::{Error, packet::PacketType, protocol}; + +#[derive(Debug, PartialEq, Eq)] +pub enum MercuryMethod { + Get, + Sub, + Unsub, + Send, +} + +#[derive(Debug)] +pub struct MercuryRequest { + pub method: MercuryMethod, + pub uri: String, + pub content_type: Option, + pub payload: Vec>, +} + +#[derive(Debug, Clone)] +pub struct MercuryResponse { + pub uri: String, + pub status_code: i32, + pub payload: Vec>, +} + +#[derive(Debug, Error)] +pub enum MercuryError { + #[error("callback receiver was disconnected")] + Channel, + #[error("error handling packet type: {0:?}")] + Command(PacketType), + #[error("error handling Mercury response: {0:?}")] + Response(MercuryResponse), +} + +impl From for Error { + fn from(err: MercuryError) -> Self { + match err { + MercuryError::Channel => Error::aborted(err), + MercuryError::Command(_) => Error::unimplemented(err), + MercuryError::Response(_) => Error::unavailable(err), + } + } +} + +impl std::fmt::Display for MercuryMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match *self { + MercuryMethod::Get => "GET", + MercuryMethod::Sub => "SUB", + MercuryMethod::Unsub => "UNSUB", + MercuryMethod::Send => "SEND", + }; + write!(f, "{s}") + } +} + +impl MercuryMethod { + pub fn command(&self) -> PacketType { + use PacketType::*; + match *self { + MercuryMethod::Get | MercuryMethod::Send => MercuryReq, + MercuryMethod::Sub => MercurySub, + MercuryMethod::Unsub => MercuryUnsub, + } + } +} + +impl MercuryRequest { + pub fn encode(&self, seq: &[u8]) -> Result, Error> { + let mut packet = Vec::new(); + packet.write_u16::(seq.len() as u16)?; + packet.write_all(seq)?; + packet.write_u8(1)?; // Flags: FINAL + packet.write_u16::(1 + self.payload.len() as u16)?; // Part count + + let mut header = protocol::mercury::Header::new(); + header.set_uri(self.uri.clone()); + header.set_method(self.method.to_string()); + + if let Some(ref content_type) = self.content_type { + header.set_content_type(content_type.clone()); + } + + packet.write_u16::(header.compute_size() as u16)?; + header.write_to_writer(&mut packet)?; + + for p in &self.payload { + packet.write_u16::(p.len() as u16)?; + packet.write_all(p)?; + } + + Ok(packet) + } +} diff --git a/native/vendor/librespot-core/src/packet.rs b/native/vendor/librespot-core/src/packet.rs new file mode 100644 index 00000000..2f50d158 --- /dev/null +++ b/native/vendor/librespot-core/src/packet.rs @@ -0,0 +1,41 @@ +// Ported from librespot-java. Relicensed under MIT with permission. + +use num_derive::{FromPrimitive, ToPrimitive}; + +#[derive(Debug, Copy, Clone, FromPrimitive, ToPrimitive)] +pub enum PacketType { + SecretBlock = 0x02, + Ping = 0x04, + StreamChunk = 0x08, + StreamChunkRes = 0x09, + ChannelError = 0x0a, + ChannelAbort = 0x0b, + RequestKey = 0x0c, + AesKey = 0x0d, + AesKeyError = 0x0e, + Image = 0x19, + CountryCode = 0x1b, + Pong = 0x49, + PongAck = 0x4a, + Pause = 0x4b, + ProductInfo = 0x50, + LegacyWelcome = 0x69, + LicenseVersion = 0x76, + Login = 0xab, + APWelcome = 0xac, + AuthFailure = 0xad, + MercuryReq = 0xb2, + MercurySub = 0xb3, + MercuryUnsub = 0xb4, + MercuryEvent = 0xb5, + TrackEndedTime = 0x82, + UnknownDataAllZeros = 0x1f, + PreferredLocale = 0x74, + Unknown0x0f = 0x0f, + Unknown0x10 = 0x10, + Unknown0x4f = 0x4f, + + // TODO - occurs when subscribing with an empty URI. Maybe a MercuryError? + // Payload: b"\0\x08\0\0\0\0\0\0\0\0\x01\0\x01\0\x03 \xb0\x06" + Unknown0xb6 = 0xb6, +} diff --git a/native/vendor/librespot-core/src/proxytunnel.rs b/native/vendor/librespot-core/src/proxytunnel.rs new file mode 100644 index 00000000..9e8cd3b7 --- /dev/null +++ b/native/vendor/librespot-core/src/proxytunnel.rs @@ -0,0 +1,52 @@ +use std::io; + +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +pub async fn proxy_connect( + mut proxy_connection: T, + connect_host: &str, + connect_port: &str, +) -> io::Result { + let mut buffer = Vec::new(); + buffer.extend_from_slice(b"CONNECT "); + buffer.extend_from_slice(connect_host.as_bytes()); + buffer.push(b':'); + buffer.extend_from_slice(connect_port.as_bytes()); + buffer.extend_from_slice(b" HTTP/1.1\r\n\r\n"); + + proxy_connection.write_all(buffer.as_ref()).await?; + + buffer.resize(buffer.capacity(), 0); + + let mut offset = 0; + loop { + let bytes_read = proxy_connection.read(&mut buffer[offset..]).await?; + if bytes_read == 0 { + return Err(io::Error::other("Early EOF from proxy")); + } + offset += bytes_read; + + let mut headers = [httparse::EMPTY_HEADER; 16]; + let mut response = httparse::Response::new(&mut headers); + + let status = response + .parse(&buffer[..offset]) + .map_err(io::Error::other)?; + + if status.is_complete() { + return match response.code { + Some(200) => Ok(proxy_connection), // Proxy says all is well + Some(code) => { + let reason = response.reason.unwrap_or("no reason"); + let msg = format!("Proxy responded with {code}: {reason}"); + Err(io::Error::other(msg)) + } + None => Err(io::Error::other("Malformed response from proxy")), + }; + } + + if offset >= buffer.len() { + buffer.resize(buffer.len() + 100, 0); + } + } +} diff --git a/native/vendor/librespot-core/src/session.rs b/native/vendor/librespot-core/src/session.rs new file mode 100644 index 00000000..333678fd --- /dev/null +++ b/native/vendor/librespot-core/src/session.rs @@ -0,0 +1,957 @@ +use std::{ + collections::HashMap, + future::Future, + io, + pin::Pin, + process::exit, + sync::{Arc, OnceLock, RwLock, Weak}, + task::{Context, Poll}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use crate::dealer::manager::DealerManager; +use crate::{ + Error, + apresolve::{ApResolver, SocketAddress}, + audio_key::AudioKeyManager, + authentication::Credentials, + cache::Cache, + channel::ChannelManager, + config::SessionConfig, + connection::{self, AuthenticationError, Transport}, + http_client::HttpClient, + login5::Login5Manager, + mercury::MercuryManager, + packet::PacketType, + protocol::keyexchange::ErrorCode, + spclient::SpClient, + token::TokenProvider, +}; +use byteorder::{BigEndian, ByteOrder}; +use bytes::Bytes; +use futures_core::TryStream; +use futures_util::StreamExt; +use librespot_protocol::authentication::AuthenticationType; +use num_traits::FromPrimitive; +use pin_project_lite::pin_project; +use quick_xml::events::Event; +use thiserror::Error; +use tokio::{ + sync::mpsc, + time::{Duration as TokioDuration, Instant as TokioInstant, Sleep, sleep}, +}; +use tokio_stream::wrappers::UnboundedReceiverStream; +use uuid::Uuid; + +const SESSION_DATA_POISON_MSG: &str = "session data rwlock should not be poisoned"; + +#[derive(Debug, Error)] +pub enum SessionError { + #[error(transparent)] + AuthenticationError(#[from] AuthenticationError), + #[error("Cannot create session: {0}")] + IoError(#[from] io::Error), + #[error("Session is not connected")] + NotConnected, + #[error("packet {0} unknown")] + Packet(u8), +} + +impl From for Error { + fn from(err: SessionError) -> Self { + match err { + SessionError::AuthenticationError(_) => Error::unauthenticated(err), + SessionError::IoError(_) => Error::unavailable(err), + SessionError::NotConnected => Error::unavailable(err), + SessionError::Packet(_) => Error::unimplemented(err), + } + } +} + +impl From for Error { + fn from(err: quick_xml::encoding::EncodingError) -> Self { + Error::invalid_argument(err) + } +} + +pub type UserAttributes = HashMap; + +#[derive(Debug, Clone, Default)] +pub struct UserData { + pub country: String, + pub canonical_username: String, + pub attributes: UserAttributes, +} + +#[derive(Debug, Clone, Default)] +struct SessionData { + session_id: String, + client_id: String, + client_name: String, + client_brand_name: String, + client_model_name: String, + connection_id: String, + auth_data: Vec, + time_delta: i64, + invalid: bool, + user_data: UserData, +} + +struct SessionInternal { + config: SessionConfig, + data: RwLock, + + http_client: HttpClient, + tx_connection: OnceLock)>>, + + apresolver: OnceLock, + audio_key: OnceLock, + channel: OnceLock, + mercury: OnceLock, + dealer: OnceLock, + spclient: OnceLock, + token_provider: OnceLock, + login5: OnceLock, + cache: Option>, + + handle: tokio::runtime::Handle, +} + +/// A shared reference to a Spotify session. +/// +/// After instantiating, you need to login via [Session::connect]. +/// You can either implement the whole playback logic yourself by using +/// this structs interface directly or hand it to a +/// `Player`. +/// +/// *Note*: [Session] instances cannot yet be reused once invalidated. After +/// an unexpectedly closed connection, you'll need to create a new [Session]. +#[derive(Clone)] +pub struct Session(Arc); + +impl Session { + pub fn new(config: SessionConfig, cache: Option) -> Self { + let http_client = HttpClient::new(config.proxy.as_ref()); + + debug!("new Session"); + + let session_data = SessionData { + client_id: config.client_id.clone(), + // can be any guid, doesn't need to be simple + session_id: Uuid::new_v4().as_simple().to_string(), + ..SessionData::default() + }; + + Self(Arc::new(SessionInternal { + config, + data: RwLock::new(session_data), + http_client, + tx_connection: OnceLock::new(), + cache: cache.map(Arc::new), + apresolver: OnceLock::new(), + audio_key: OnceLock::new(), + channel: OnceLock::new(), + mercury: OnceLock::new(), + dealer: OnceLock::new(), + spclient: OnceLock::new(), + token_provider: OnceLock::new(), + login5: OnceLock::new(), + handle: tokio::runtime::Handle::current(), + })) + } + + async fn connect_inner( + &self, + access_point: &SocketAddress, + credentials: Credentials, + ) -> Result<(Credentials, Transport), Error> { + const MAX_RETRIES: u8 = 1; + let mut transport = connection::connect_with_retry( + &access_point.0, + access_point.1, + self.config().proxy.as_ref(), + MAX_RETRIES, + ) + .await?; + let mut reusable_credentials = connection::authenticate( + &mut transport, + credentials.clone(), + &self.config().device_id, + ) + .await?; + + // Might be able to remove this once keymaster is replaced with login5. + if credentials.auth_type == AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN { + trace!( + "Reconnect using stored credentials as token authed sessions cannot use keymaster." + ); + transport = connection::connect_with_retry( + &access_point.0, + access_point.1, + self.config().proxy.as_ref(), + MAX_RETRIES, + ) + .await?; + reusable_credentials = connection::authenticate( + &mut transport, + reusable_credentials.clone(), + &self.config().device_id, + ) + .await?; + } + + Ok((reusable_credentials, transport)) + } + + pub async fn connect( + &self, + credentials: Credentials, + store_credentials: bool, + ) -> Result<(), Error> { + // There currently happen to be 6 APs but anything will do to avoid an infinite loop. + const MAX_AP_TRIES: u8 = 6; + let mut num_ap_tries = 0; + let (reusable_credentials, transport) = loop { + let ap = self.apresolver().resolve("accesspoint").await?; + info!("Connecting to AP \"{}:{}\"", ap.0, ap.1); + match self.connect_inner(&ap, credentials.clone()).await { + Ok(ct) => break ct, + Err(e) => { + num_ap_tries += 1; + if MAX_AP_TRIES == num_ap_tries { + error!("Tried too many access points"); + return Err(e); + } + if let Some(AuthenticationError::LoginFailed(ErrorCode::TryAnotherAP)) = + e.error.downcast_ref::() + { + warn!("Instructed to try another access point..."); + continue; + } else if let Some(AuthenticationError::LoginFailed(..)) = + e.error.downcast_ref::() + { + return Err(e); + } else { + warn!("Try another access point..."); + continue; + } + } + } + }; + + let username = reusable_credentials + .username + .as_ref() + .map_or("UNKNOWN", |s| s.as_str()); + info!("Authenticated as '{username}' !"); + self.set_username(username); + self.set_auth_data(&reusable_credentials.auth_data); + if let Some(cache) = self.cache() { + if store_credentials { + let cred_changed = cache + .credentials() + .map(|c| c != reusable_credentials) + .unwrap_or(true); + if cred_changed { + cache.save_credentials(&reusable_credentials); + } + } + } + + // This channel serves as a buffer for packets and serializes access to the TcpStream, such + // that `self.send_packet` can return immediately and needs no additional synchronization. + let (tx_connection, rx_connection) = mpsc::unbounded_channel(); + self.0 + .tx_connection + .set(tx_connection) + .map_err(|_| SessionError::NotConnected)?; + + let (sink, stream) = transport.split(); + let sender_task = UnboundedReceiverStream::new(rx_connection) + .map(Ok) + .forward(sink); + let session_weak = self.weak(); + tokio::spawn(async move { + if let Err(e) = sender_task.await { + error!("{e}"); + if let Some(session) = session_weak.try_upgrade() { + if !session.is_invalid() { + session.shutdown(); + } + } + } + }); + + tokio::spawn(DispatchTask::new(self.weak(), stream)); + + Ok(()) + } + + pub fn apresolver(&self) -> &ApResolver { + self.0 + .apresolver + .get_or_init(|| ApResolver::new(self.weak())) + } + + pub fn audio_key(&self) -> &AudioKeyManager { + self.0 + .audio_key + .get_or_init(|| AudioKeyManager::new(self.weak())) + } + + pub fn channel(&self) -> &ChannelManager { + self.0 + .channel + .get_or_init(|| ChannelManager::new(self.weak())) + } + + pub fn http_client(&self) -> &HttpClient { + &self.0.http_client + } + + pub fn mercury(&self) -> &MercuryManager { + self.0 + .mercury + .get_or_init(|| MercuryManager::new(self.weak())) + } + + pub fn dealer(&self) -> &DealerManager { + self.0 + .dealer + .get_or_init(|| DealerManager::new(self.weak())) + } + + pub fn spclient(&self) -> &SpClient { + self.0.spclient.get_or_init(|| SpClient::new(self.weak())) + } + + pub fn token_provider(&self) -> &TokenProvider { + self.0 + .token_provider + .get_or_init(|| TokenProvider::new(self.weak())) + } + + pub fn login5(&self) -> &Login5Manager { + self.0 + .login5 + .get_or_init(|| Login5Manager::new(self.weak())) + } + + pub fn time_delta(&self) -> i64 { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .time_delta + } + + pub fn spawn(&self, task: T) + where + T: Future + Send + 'static, + T::Output: Send + 'static, + { + self.0.handle.spawn(task); + } + + fn debug_info(&self) { + debug!( + "Session strong={} weak={}", + Arc::strong_count(&self.0), + Arc::weak_count(&self.0) + ); + } + + fn check_catalogue(attributes: &UserAttributes) { + if let Some(account_type) = attributes.get("type") { + if account_type != "premium" { + error!("librespot does not support {account_type:?} accounts."); + info!("Please support Spotify and your artists and sign up for a premium account."); + + // TODO: logout instead of exiting + exit(1); + } + } + } + + pub fn send_packet(&self, cmd: PacketType, data: Vec) -> Result<(), Error> { + match self.0.tx_connection.get() { + Some(tx) => Ok(tx.send((cmd as u8, data))?), + None => Err(SessionError::NotConnected.into()), + } + } + + pub fn cache(&self) -> Option<&Arc> { + self.0.cache.as_ref() + } + + pub fn config(&self) -> &SessionConfig { + &self.0.config + } + + // This clones a fairly large struct, so use a specific getter or setter unless + // you need more fields at once, in which case this can spare multiple `read` + // locks. + pub fn user_data(&self) -> UserData { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .user_data + .clone() + } + + pub fn session_id(&self) -> String { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .session_id + .clone() + } + + pub fn set_session_id(&self, session_id: &str) { + session_id.clone_into( + &mut self + .0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .session_id, + ); + } + + pub fn device_id(&self) -> &str { + &self.config().device_id + } + + pub fn client_id(&self) -> String { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .client_id + .clone() + } + + pub fn set_client_id(&self, client_id: &str) { + client_id.clone_into( + &mut self + .0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .client_id, + ); + } + + pub fn client_name(&self) -> String { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .client_name + .clone() + } + + pub fn set_client_name(&self, client_name: &str) { + client_name.clone_into( + &mut self + .0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .client_name, + ); + } + + pub fn client_brand_name(&self) -> String { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .client_brand_name + .clone() + } + + pub fn set_client_brand_name(&self, client_brand_name: &str) { + client_brand_name.clone_into( + &mut self + .0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .client_brand_name, + ); + } + + pub fn client_model_name(&self) -> String { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .client_model_name + .clone() + } + + pub fn set_client_model_name(&self, client_model_name: &str) { + client_model_name.clone_into( + &mut self + .0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .client_model_name, + ); + } + + pub fn connection_id(&self) -> String { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .connection_id + .clone() + } + + pub fn set_connection_id(&self, connection_id: &str) { + connection_id.clone_into( + &mut self + .0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .connection_id, + ); + } + + pub fn username(&self) -> String { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .user_data + .canonical_username + .clone() + } + + pub fn set_username(&self, username: &str) { + username.clone_into( + &mut self + .0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .user_data + .canonical_username, + ); + } + + pub fn auth_data(&self) -> Vec { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .auth_data + .clone() + } + + pub fn set_auth_data(&self, auth_data: &[u8]) { + auth_data.clone_into( + &mut self + .0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .auth_data, + ); + } + + pub fn country(&self) -> String { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .user_data + .country + .clone() + } + + pub fn filter_explicit_content(&self) -> bool { + match self.get_user_attribute("filter-explicit-content") { + Some(value) => matches!(&*value, "1"), + None => false, + } + } + + pub fn autoplay(&self) -> bool { + if let Some(overide) = self.config().autoplay { + return overide; + } + + match self.get_user_attribute("autoplay") { + Some(value) => matches!(&*value, "1"), + None => false, + } + } + + pub fn set_user_attribute(&self, key: &str, value: &str) -> Option { + let mut dummy_attributes = UserAttributes::new(); + dummy_attributes.insert(key.to_owned(), value.to_owned()); + Self::check_catalogue(&dummy_attributes); + + self.0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .user_data + .attributes + .insert(key.to_owned(), value.to_owned()) + } + + pub fn set_user_attributes(&self, attributes: UserAttributes) { + Self::check_catalogue(&attributes); + + self.0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .user_data + .attributes + .extend(attributes) + } + + pub fn get_user_attribute(&self, key: &str) -> Option { + self.0 + .data + .read() + .expect(SESSION_DATA_POISON_MSG) + .user_data + .attributes + .get(key) + .cloned() + } + + fn weak(&self) -> SessionWeak { + SessionWeak(Arc::downgrade(&self.0)) + } + + pub fn shutdown(&self) { + debug!("Shutdown: Invalidating session"); + self.0.data.write().expect(SESSION_DATA_POISON_MSG).invalid = true; + self.mercury().shutdown(); + self.channel().shutdown(); + } + + pub fn is_invalid(&self) -> bool { + self.0.data.read().expect(SESSION_DATA_POISON_MSG).invalid + } +} + +#[derive(Clone)] +pub struct SessionWeak(Weak); + +impl SessionWeak { + fn try_upgrade(&self) -> Option { + self.0.upgrade().map(Session) + } + + pub(crate) fn upgrade(&self) -> Session { + self.try_upgrade() + .expect("session was dropped and so should have this component") + } +} + +impl Drop for SessionInternal { + fn drop(&mut self) { + debug!("drop Session"); + } +} + +#[derive(Clone, Copy, Default, Debug, PartialEq)] +enum KeepAliveState { + #[default] + // Expecting a Ping from the server, either after startup or after a PongAck. + ExpectingPing, + + // We need to send a Pong at the given time. + PendingPong, + + // We just sent a Pong and wait for it be ACK'd. + ExpectingPongAck, +} + +const INITIAL_PING_TIMEOUT: TokioDuration = TokioDuration::from_secs(20); +const PING_TIMEOUT: TokioDuration = TokioDuration::from_secs(80); // 60s expected + 20s buffer +const PONG_DELAY: TokioDuration = TokioDuration::from_secs(60); +const PONG_ACK_TIMEOUT: TokioDuration = TokioDuration::from_secs(20); + +impl KeepAliveState { + fn debug(&self, sleep: &Sleep) { + let delay = sleep + .deadline() + .checked_duration_since(TokioInstant::now()) + .map(|t| t.as_secs_f64()) + .unwrap_or(f64::INFINITY); + + trace!("keep-alive state: {self:?}, timeout in {delay:.1}"); + } +} + +pin_project! { + struct DispatchTask + where + S: TryStream + { + session: SessionWeak, + keep_alive_state: KeepAliveState, + #[pin] + stream: S, + #[pin] + timeout: Sleep, + } + + impl PinnedDrop for DispatchTask + where + S: TryStream + { + fn drop(_this: Pin<&mut Self>) { + debug!("drop Dispatch"); + } + } +} + +impl DispatchTask +where + S: TryStream, +{ + fn new(session: SessionWeak, stream: S) -> Self { + Self { + session, + keep_alive_state: KeepAliveState::ExpectingPing, + stream, + timeout: sleep(INITIAL_PING_TIMEOUT), + } + } + + fn dispatch( + mut self: Pin<&mut Self>, + session: &Session, + cmd: u8, + data: Bytes, + ) -> Result<(), Error> { + use KeepAliveState::*; + use PacketType::*; + + let packet_type = FromPrimitive::from_u8(cmd); + let cmd = match packet_type { + Some(cmd) => cmd, + None => { + trace!("Ignoring unknown packet {cmd:x}"); + return Err(SessionError::Packet(cmd).into()); + } + }; + + match packet_type { + Some(Ping) => { + trace!("Received Ping"); + if self.keep_alive_state != ExpectingPing { + warn!("Received unexpected Ping from server") + } + let mut this = self.as_mut().project(); + *this.keep_alive_state = PendingPong; + this.timeout + .as_mut() + .reset(TokioInstant::now() + PONG_DELAY); + this.keep_alive_state.debug(&this.timeout); + + let server_timestamp = BigEndian::read_u32(data.as_ref()) as i64; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::ZERO) + .as_secs() as i64; + { + let mut data = session.0.data.write().expect(SESSION_DATA_POISON_MSG); + data.time_delta = server_timestamp.saturating_sub(timestamp); + } + + session.debug_info(); + + Ok(()) + } + Some(PongAck) => { + trace!("Received PongAck"); + if self.keep_alive_state != ExpectingPongAck { + warn!("Received unexpected PongAck from server") + } + let mut this = self.as_mut().project(); + *this.keep_alive_state = ExpectingPing; + this.timeout + .as_mut() + .reset(TokioInstant::now() + PING_TIMEOUT); + this.keep_alive_state.debug(&this.timeout); + + Ok(()) + } + Some(CountryCode) => { + let country = String::from_utf8(data.as_ref().to_owned())?; + info!("Country: {country:?}"); + session + .0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .user_data + .country = country; + Ok(()) + } + Some(StreamChunkRes) | Some(ChannelError) => session.channel().dispatch(cmd, data), + Some(AesKey) | Some(AesKeyError) => session.audio_key().dispatch(cmd, data), + Some(MercuryReq) | Some(MercurySub) | Some(MercuryUnsub) | Some(MercuryEvent) => { + session.mercury().dispatch(cmd, data) + } + Some(ProductInfo) => { + let data = std::str::from_utf8(&data)?; + let mut reader = quick_xml::Reader::from_str(data); + + let mut buf = Vec::new(); + let mut current_element = String::new(); + let mut user_attributes: UserAttributes = HashMap::new(); + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref element)) => { + std::str::from_utf8(element)?.clone_into(&mut current_element) + } + Ok(Event::End(_)) => { + current_element = String::new(); + } + Ok(Event::Text(ref value)) => { + if !current_element.is_empty() { + let _ = user_attributes.insert( + current_element.clone(), + value.xml_content()?.to_string(), + ); + } + } + Ok(Event::Eof) => break, + Ok(_) => (), + Err(e) => warn!( + "Error parsing XML at position {}: {:?}", + reader.buffer_position(), + e + ), + } + } + + trace!("Received product info: {user_attributes:#?}"); + Session::check_catalogue(&user_attributes); + + session + .0 + .data + .write() + .expect(SESSION_DATA_POISON_MSG) + .user_data + .attributes = user_attributes; + Ok(()) + } + Some(SecretBlock) + | Some(LegacyWelcome) + | Some(UnknownDataAllZeros) + | Some(LicenseVersion) => Ok(()), + _ => { + trace!("Ignoring {cmd:?} packet with data {data:#?}"); + Err(SessionError::Packet(cmd as u8).into()) + } + } + } +} + +impl Future for DispatchTask +where + S: TryStream, + ::Ok: std::fmt::Debug, +{ + type Output = Result<(), S::Error>; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + use KeepAliveState::*; + + let session = match self.session.try_upgrade() { + Some(session) => session, + None => return Poll::Ready(Ok(())), + }; + + // Process all messages that are immediately ready + loop { + match self.as_mut().project().stream.try_poll_next(cx) { + Poll::Ready(Some(Ok((cmd, data)))) => { + let result = self.as_mut().dispatch(&session, cmd, data); + if let Err(e) = result { + debug!("could not dispatch command: {e}"); + } + } + Poll::Ready(None) => { + warn!("Connection to server closed."); + session.shutdown(); + return Poll::Ready(Ok(())); + } + Poll::Ready(Some(Err(e))) => { + error!("Connection to server closed."); + session.shutdown(); + return Poll::Ready(Err(e)); + } + Poll::Pending => break, + } + } + + // Handle the keep-alive sequence, returning an error when we haven't received a + // Ping/PongAck for too long. + // + // The expected keepalive sequence is + // - Server: Ping + // - wait 60s + // - Client: Pong + // - Server: PongAck + // - wait 60s + // - repeat + // + // This means that we silently lost connection to Spotify servers if + // - we don't receive Ping immediately after connecting, + // - we don't receive a Ping 60s after the last PongAck or + // - we don't receive a PongAck immediately after our Pong. + // + // Currently, we add a safety margin of 20s to these expected deadlines. + let mut this = self.as_mut().project(); + if let Poll::Ready(()) = this.timeout.as_mut().poll(cx) { + match this.keep_alive_state { + ExpectingPing | ExpectingPongAck => { + if !session.is_invalid() { + session.shutdown(); + } + // TODO: Optionally reconnect (with cached/last credentials?) + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::TimedOut, + format!( + "session lost connection to server ({:?})", + this.keep_alive_state + ), + ))); + } + PendingPong => { + trace!("Sending Pong"); + // TODO: Ideally, this should flush the `Framed as Sink` + // before starting the timeout. + let _ = session.send_packet(PacketType::Pong, vec![0, 0, 0, 0]); + *this.keep_alive_state = ExpectingPongAck; + this.timeout + .as_mut() + .reset(TokioInstant::now() + PONG_ACK_TIMEOUT); + this.keep_alive_state.debug(&this.timeout); + } + } + } + + Poll::Pending + } +} diff --git a/native/vendor/librespot-core/src/socket.rs b/native/vendor/librespot-core/src/socket.rs new file mode 100644 index 00000000..71b0d6b9 --- /dev/null +++ b/native/vendor/librespot-core/src/socket.rs @@ -0,0 +1,34 @@ +use std::{io, net::ToSocketAddrs}; + +use tokio::net::TcpStream; +use url::Url; + +use crate::proxytunnel; + +pub async fn connect(host: &str, port: u16, proxy: Option<&Url>) -> io::Result { + let socket = if let Some(proxy_url) = proxy { + info!("Using proxy \"{proxy_url}\""); + + let socket_addr = proxy_url.socket_addrs(|| None).and_then(|addrs| { + addrs.into_iter().next().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "Can't resolve proxy server address", + ) + }) + })?; + let socket = TcpStream::connect(&socket_addr).await?; + + proxytunnel::proxy_connect(socket, host, &port.to_string()).await? + } else { + let socket_addr = (host, port).to_socket_addrs()?.next().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "Can't resolve access point address", + ) + })?; + + TcpStream::connect(&socket_addr).await? + }; + Ok(socket) +} diff --git a/native/vendor/librespot-core/src/spclient.rs b/native/vendor/librespot-core/src/spclient.rs new file mode 100644 index 00000000..6eaa33cb --- /dev/null +++ b/native/vendor/librespot-core/src/spclient.rs @@ -0,0 +1,960 @@ +use std::{ + fmt::Write, + time::{Duration, SystemTime}, +}; + +use crate::config::{OS, os_version}; +use crate::{ + Error, FileId, SpotifyId, SpotifyUri, + apresolve::SocketAddress, + config::SessionConfig, + dealer::protocol::TransferOptions, + error::ErrorKind, + protocol::{ + autoplay_context_request::AutoplayContextRequest, + clienttoken_http::{ + ChallengeAnswer, ChallengeType, ClientTokenRequest, ClientTokenRequestType, + ClientTokenResponse, ClientTokenResponseType, + }, + connect::PutStateRequest, + context::Context, + extended_metadata::BatchedEntityRequest, + extended_metadata::{BatchedExtensionResponse, EntityRequest, ExtensionQuery}, + extension_kind::ExtensionKind, + }, + token::Token, + util, + version::spotify_semantic_version, +}; +use bytes::Bytes; +use data_encoding::HEXUPPER_PERMISSIVE; +use futures_util::future::IntoStream; +use http::{Uri, header::HeaderValue}; +use hyper::{ + HeaderMap, Method, Request, + header::{ACCEPT, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, HeaderName, RANGE}, +}; +use hyper_util::client::legacy::ResponseFuture; +use protobuf::{Enum, EnumOrUnknown, Message, MessageFull}; +use rand::RngCore; +use serde::Serialize; +use sysinfo::System; +use thiserror::Error; + +component! { + SpClient : SpClientInner { + accesspoint: Option = None, + strategy: RequestStrategy = RequestStrategy::default(), + client_token: Option = None, + } +} + +pub type SpClientResult = Result; + +#[allow(clippy::declare_interior_mutable_const)] +pub const CLIENT_TOKEN: HeaderName = HeaderName::from_static("client-token"); +#[allow(clippy::declare_interior_mutable_const)] +const CONNECTION_ID: HeaderName = HeaderName::from_static("x-spotify-connection-id"); + +const NO_METRICS_AND_SALT: RequestOptions = RequestOptions { + metrics: false, + salt: false, + base_url: None, +}; + +#[derive(Debug, Error)] +pub enum SpClientError { + #[error("missing attribute {0}")] + Attribute(String), + #[error("expected data but received none")] + NoData, + #[error("expected an entry to exist in {0}")] + ExpectedEntry(&'static str), +} + +impl From for Error { + fn from(err: SpClientError) -> Self { + Self::failed_precondition(err) + } +} + +#[derive(Copy, Clone, Debug)] +pub enum RequestStrategy { + TryTimes(usize), + Infinitely, +} + +impl Default for RequestStrategy { + fn default() -> Self { + RequestStrategy::TryTimes(10) + } +} + +pub struct RequestOptions { + metrics: bool, + salt: bool, + base_url: Option<&'static str>, +} + +impl Default for RequestOptions { + fn default() -> Self { + Self { + metrics: true, + salt: true, + base_url: None, + } + } +} + +#[derive(Debug, Serialize)] +pub struct TransferRequest { + pub transfer_options: TransferOptions, +} + +impl SpClient { + pub fn set_strategy(&self, strategy: RequestStrategy) { + self.lock(|inner| inner.strategy = strategy) + } + + pub async fn flush_accesspoint(&self) { + self.lock(|inner| inner.accesspoint = None) + } + + pub async fn get_accesspoint(&self) -> Result { + // Memoize the current access point. + let ap = self.lock(|inner| inner.accesspoint.clone()); + let tuple = match ap { + Some(tuple) => tuple, + None => { + let tuple = self.session().apresolver().resolve("spclient").await?; + self.lock(|inner| inner.accesspoint = Some(tuple.clone())); + info!( + "Resolved \"{}:{}\" as spclient access point", + tuple.0, tuple.1 + ); + tuple + } + }; + Ok(tuple) + } + + pub async fn base_url(&self) -> Result { + let ap = self.get_accesspoint().await?; + Ok(format!("https://{}:{}", ap.0, ap.1)) + } + + async fn client_token_request(&self, message: &M) -> Result { + let body = message.write_to_bytes()?; + + let request = Request::builder() + .method(&Method::POST) + .uri("https://clienttoken.spotify.com/v1/clienttoken") + .header(ACCEPT, HeaderValue::from_static("application/x-protobuf")) + .body(body.into())?; + + self.session().http_client().request_body(request).await + } + + pub async fn client_token(&self) -> Result { + let client_token = self.lock(|inner| { + if let Some(token) = &inner.client_token { + if token.is_expired() { + inner.client_token = None; + } + } + inner.client_token.clone() + }); + + if let Some(client_token) = client_token { + return Ok(client_token.access_token); + } + + debug!("Client token unavailable or expired, requesting new token."); + + let mut request = ClientTokenRequest::new(); + request.request_type = ClientTokenRequestType::REQUEST_CLIENT_DATA_REQUEST.into(); + + let client_data = request.mut_client_data(); + + client_data.client_version = spotify_semantic_version(); + + // Current state of affairs: keymaster ID works on all tested platforms, but may be phased out, + // so it seems a good idea to mimick the real clients. `self.session().client_id()` returns the + // ID of the client that last connected, but requesting a client token with this ID only works + // on macOS and Windows. On Android and iOS we can send a platform-specific client ID and are + // then presented with a hash cash challenge. On Linux, we have to pass the old keymaster ID. + // We delegate most of this logic to `SessionConfig`. + let os = OS; + let client_id = match os { + "macos" | "windows" => self.session().client_id(), + os => SessionConfig::default_for_os(os).client_id, + }; + client_data.client_id = client_id; + + let connectivity_data = client_data.mut_connectivity_sdk_data(); + connectivity_data.device_id = self.session().device_id().to_string(); + + let platform_data = connectivity_data + .platform_specific_data + .mut_or_insert_default(); + + let os_version = os_version(); + let kernel_version = System::kernel_version().unwrap_or_else(|| String::from("0")); + + match os { + "windows" => { + let os_version = os_version.parse::().unwrap_or(10.) as i32; + let kernel_version = kernel_version.parse::().unwrap_or(21370); + + let (pe, image_file) = match std::env::consts::ARCH { + "arm" => (448, 452), + "aarch64" => (43620, 452), + "x86_64" => (34404, 34404), + _ => (332, 332), // x86 + }; + + let windows_data = platform_data.mut_desktop_windows(); + windows_data.os_version = os_version; + windows_data.os_build = kernel_version; + windows_data.platform_id = 2; + windows_data.unknown_value_6 = 9; + windows_data.image_file_machine = image_file; + windows_data.pe_machine = pe; + windows_data.unknown_value_10 = true; + } + "ios" => { + let ios_data = platform_data.mut_ios(); + ios_data.user_interface_idiom = 0; + ios_data.target_iphone_simulator = false; + ios_data.hw_machine = "iPhone14,5".to_string(); + ios_data.system_version = os_version; + } + "android" => { + let android_data = platform_data.mut_android(); + android_data.android_version = os_version; + android_data.api_version = 31; + "Pixel".clone_into(&mut android_data.device_name); + "GF5KQ".clone_into(&mut android_data.model_str); + "Google".clone_into(&mut android_data.vendor); + } + "macos" => { + let macos_data = platform_data.mut_desktop_macos(); + macos_data.system_version = os_version; + macos_data.hw_model = "iMac21,1".to_string(); + macos_data.compiled_cpu_type = std::env::consts::ARCH.to_string(); + } + _ => { + let linux_data = platform_data.mut_desktop_linux(); + linux_data.system_name = "Linux".to_string(); + linux_data.system_release = kernel_version; + linux_data.system_version = os_version; + linux_data.hardware = std::env::consts::ARCH.to_string(); + } + } + + let mut response = self.client_token_request(&request).await?; + let mut count = 0; + const MAX_TRIES: u8 = 3; + + let token_response = loop { + count += 1; + + let message = ClientTokenResponse::parse_from_bytes(&response)?; + + match ClientTokenResponseType::from_i32(message.response_type.value()) { + // depending on the platform, you're either given a token immediately + // or are presented a hash cash challenge to solve first + Some(ClientTokenResponseType::RESPONSE_GRANTED_TOKEN_RESPONSE) => { + debug!("Received a granted token"); + break message; + } + Some(ClientTokenResponseType::RESPONSE_CHALLENGES_RESPONSE) => { + debug!("Received a hash cash challenge, solving..."); + + let challenges = message.challenges().clone(); + let state = challenges.state; + if let Some(challenge) = challenges.challenges.first() { + let hash_cash_challenge = challenge.evaluate_hashcash_parameters(); + + let ctx = vec![]; + let prefix = HEXUPPER_PERMISSIVE + .decode(hash_cash_challenge.prefix.as_bytes()) + .map_err(|e| { + Error::failed_precondition(format!( + "Unable to decode hash cash challenge: {e}" + )) + })?; + let length = hash_cash_challenge.length; + + let mut suffix = [0u8; 0x10]; + let answer = util::solve_hash_cash(&ctx, &prefix, length, &mut suffix); + + match answer { + Ok(_) => { + // the suffix must be in uppercase + let suffix = HEXUPPER_PERMISSIVE.encode(&suffix); + + let mut answer_message = ClientTokenRequest::new(); + answer_message.request_type = + ClientTokenRequestType::REQUEST_CHALLENGE_ANSWERS_REQUEST + .into(); + + let challenge_answers = answer_message.mut_challenge_answers(); + + let mut challenge_answer = ChallengeAnswer::new(); + challenge_answer.mut_hash_cash().suffix = suffix; + challenge_answer.ChallengeType = + ChallengeType::CHALLENGE_HASH_CASH.into(); + + challenge_answers.state = state.to_string(); + challenge_answers.answers.push(challenge_answer); + + trace!("Answering hash cash challenge"); + match self.client_token_request(&answer_message).await { + Ok(token) => { + response = token; + continue; + } + Err(e) => { + trace!("Answer not accepted {count}/{MAX_TRIES}: {e}"); + } + } + } + Err(e) => trace!( + "Unable to solve hash cash challenge {count}/{MAX_TRIES}: {e}" + ), + } + + if count < MAX_TRIES { + response = self.client_token_request(&request).await?; + } else { + return Err(Error::failed_precondition(format!( + "Unable to solve any of {MAX_TRIES} hash cash challenges" + ))); + } + } else { + return Err(Error::failed_precondition("No challenges found")); + } + } + + Some(unknown) => { + return Err(Error::unimplemented(format!( + "Unknown client token response type: {unknown:?}" + ))); + } + None => return Err(Error::failed_precondition("No client token response type")), + } + }; + + let granted_token = token_response.granted_token(); + let access_token = granted_token.token.to_owned(); + + self.lock(|inner| { + let client_token = Token { + access_token: access_token.clone(), + expires_in: Duration::from_secs( + granted_token + .refresh_after_seconds + .try_into() + .unwrap_or(7200), + ), + token_type: "client-token".to_string(), + scopes: granted_token + .domains + .iter() + .map(|d| d.domain.clone()) + .collect(), + timestamp: SystemTime::now(), + }; + + inner.client_token = Some(client_token); + }); + + trace!("Got client token: {granted_token:?}"); + + Ok(access_token) + } + + pub async fn request_with_protobuf( + &self, + method: &Method, + endpoint: &str, + headers: Option, + message: &M, + ) -> SpClientResult { + self.request_with_protobuf_and_options( + method, + endpoint, + headers, + message, + &Default::default(), + ) + .await + } + + pub async fn request_with_protobuf_and_options( + &self, + method: &Method, + endpoint: &str, + headers: Option, + message: &M, + options: &RequestOptions, + ) -> SpClientResult { + let body = message.write_to_bytes()?; + + let mut headers = headers.unwrap_or_default(); + headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("application/x-protobuf"), + ); + + self.request_with_options(method, endpoint, Some(headers), Some(&body), options) + .await + } + + pub async fn request_as_json( + &self, + method: &Method, + endpoint: &str, + headers: Option, + body: Option<&str>, + ) -> SpClientResult { + let mut headers = headers.unwrap_or_default(); + headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + + self.request(method, endpoint, Some(headers), body.map(|s| s.as_bytes())) + .await + } + + pub async fn request( + &self, + method: &Method, + endpoint: &str, + headers: Option, + body: Option<&[u8]>, + ) -> SpClientResult { + self.request_with_options(method, endpoint, headers, body, &Default::default()) + .await + } + + pub async fn request_with_options( + &self, + method: &Method, + endpoint: &str, + headers: Option, + body: Option<&[u8]>, + options: &RequestOptions, + ) -> SpClientResult { + let mut tries: usize = 0; + let mut last_response; + + let body = body.unwrap_or_default(); + + loop { + tries += 1; + + // Reconnection logic: retrieve the endpoint every iteration, so we can try + // another access point when we are experiencing network issues (see below). + let mut url = match options.base_url { + Some(base_url) => base_url.to_string(), + None => self.base_url().await?, + }; + url.push_str(endpoint); + + // Add metrics. There is also an optional `partner` key with a value like + // `vodafone-uk` but we've yet to discover how we can find that value. + // For the sake of documentation you could also do "product=free" but + // we only support premium anyway. + if options.metrics && !url.contains("product=0") { + let _ = write!( + url, + "{}product=0&country={}", + util::get_next_query_separator(&url), + self.session().country() + ); + } + + // Defeat caches. Spotify-generated URLs already contain this. + if options.salt && !url.contains("salt=") { + let _ = write!( + url, + "{}salt={}", + util::get_next_query_separator(&url), + rand::rng().next_u32() + ); + } + + let mut request = Request::builder() + .method(method) + .uri(url) + .header(CONTENT_LENGTH, body.len()) + .body(Bytes::copy_from_slice(body))?; + + // Reconnection logic: keep getting (cached) tokens because they might have expired. + let token = self.session().login5().auth_token().await?; + + let headers_mut = request.headers_mut(); + if let Some(ref headers) = headers { + for (name, value) in headers { + headers_mut.insert(name, value.clone()); + } + } + + headers_mut.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("{} {}", token.token_type, token.access_token,))?, + ); + + match self.client_token().await { + Ok(client_token) => { + let _ = headers_mut.insert(CLIENT_TOKEN, HeaderValue::from_str(&client_token)?); + } + Err(e) => { + // currently these endpoints seem to work fine without it + warn!("Unable to get client token: {e} Trying to continue without...") + } + } + + last_response = self.session().http_client().request_body(request).await; + + if last_response.is_ok() { + return last_response; + } + + // Break before the reconnection logic below, so that the current access point + // is retained when max_tries == 1. Leave it up to the caller when to flush. + if let RequestStrategy::TryTimes(max_tries) = self.lock(|inner| inner.strategy) { + if tries >= max_tries { + break; + } + } + + // Reconnection logic: drop the current access point if we are experiencing issues. + // This will cause the next call to base_url() to resolve a new one. + if let Err(ref network_error) = last_response { + match network_error.kind { + ErrorKind::Unavailable | ErrorKind::DeadlineExceeded => { + // Keep trying the current access point three times before dropping it. + if tries % 3 == 0 { + self.flush_accesspoint().await + } + } + _ => break, // if we can't build the request now, then we won't ever + } + } + + debug!("Error was: {last_response:?}"); + } + + last_response + } + + pub async fn put_connect_state_request(&self, state: &PutStateRequest) -> SpClientResult { + let endpoint = format!("/connect-state/v1/devices/{}", self.session().device_id()); + + let mut headers = HeaderMap::new(); + headers.insert(CONNECTION_ID, self.session().connection_id().parse()?); + + self.request_with_protobuf(&Method::PUT, &endpoint, Some(headers), state) + .await + } + + pub async fn delete_connect_state_request(&self) -> SpClientResult { + let endpoint = format!("/connect-state/v1/devices/{}", self.session().device_id()); + self.request(&Method::DELETE, &endpoint, None, None).await + } + + pub async fn put_connect_state_inactive(&self, notify: bool) -> SpClientResult { + let endpoint = format!( + "/connect-state/v1/devices/{}/inactive?notify={notify}", + self.session().device_id() + ); + + let mut headers = HeaderMap::new(); + headers.insert(CONNECTION_ID, self.session().connection_id().parse()?); + + self.request(&Method::PUT, &endpoint, Some(headers), None) + .await + } + + pub async fn get_extended_metadata( + &self, + request: BatchedEntityRequest, + ) -> Result { + let res = self + .request_with_protobuf( + &Method::POST, + "/extended-metadata/v0/extended-metadata", + None, + &request, + ) + .await?; + Ok(BatchedExtensionResponse::parse_from_bytes(&res)?) + } + + pub async fn get_metadata(&self, kind: ExtensionKind, id: &SpotifyUri) -> SpClientResult { + let req = BatchedEntityRequest { + entity_request: vec![EntityRequest { + entity_uri: id.to_uri()?, + query: vec![ExtensionQuery { + extension_kind: EnumOrUnknown::new(kind), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }; + + let mut res = self.get_extended_metadata(req).await?; + let mut extended_metadata = res + .extended_metadata + .pop() + .ok_or(SpClientError::ExpectedEntry("extended_metadata"))?; + + let mut data = extended_metadata + .extension_data + .pop() + .ok_or(SpClientError::ExpectedEntry("extension_data"))?; + + match data.extension_data.take() { + None => Err(SpClientError::ExpectedEntry("data").into()), + Some(data) => Ok(Bytes::from(data.value)), + } + } + + pub async fn get_track_metadata(&self, track_uri: &SpotifyUri) -> SpClientResult { + self.get_metadata(ExtensionKind::TRACK_V4, track_uri).await + } + + pub async fn get_episode_metadata(&self, episode_uri: &SpotifyUri) -> SpClientResult { + self.get_metadata(ExtensionKind::EPISODE_V4, episode_uri) + .await + } + + pub async fn get_album_metadata(&self, album_uri: &SpotifyUri) -> SpClientResult { + self.get_metadata(ExtensionKind::ALBUM_V4, album_uri).await + } + + pub async fn get_artist_metadata(&self, artist_uri: &SpotifyUri) -> SpClientResult { + self.get_metadata(ExtensionKind::ARTIST_V4, artist_uri) + .await + } + + pub async fn get_show_metadata(&self, show_uri: &SpotifyUri) -> SpClientResult { + self.get_metadata(ExtensionKind::SHOW_V4, show_uri).await + } + + pub async fn get_lyrics(&self, track_id: &SpotifyId) -> SpClientResult { + let endpoint = format!("/color-lyrics/v2/track/{}", track_id.to_base62()?); + + self.request_as_json(&Method::GET, &endpoint, None, None) + .await + } + + pub async fn get_lyrics_for_image( + &self, + track_id: &SpotifyId, + image_id: &FileId, + ) -> SpClientResult { + let endpoint = format!( + "/color-lyrics/v2/track/{}/image/spotify:image:{}", + track_id.to_base62()?, + image_id + ); + + self.request_as_json(&Method::GET, &endpoint, None, None) + .await + } + + pub async fn get_playlist(&self, playlist_id: &SpotifyId) -> SpClientResult { + let endpoint = format!("/playlist/v2/playlist/{}", playlist_id.to_base62()?); + + self.request(&Method::GET, &endpoint, None, None).await + } + + pub async fn get_user_profile( + &self, + username: &str, + playlist_limit: Option, + artist_limit: Option, + ) -> SpClientResult { + let mut endpoint = format!("/user-profile-view/v3/profile/{username}"); + + if playlist_limit.is_some() || artist_limit.is_some() { + let _ = write!(endpoint, "?"); + + if let Some(limit) = playlist_limit { + let _ = write!(endpoint, "playlist_limit={limit}"); + if artist_limit.is_some() { + let _ = write!(endpoint, "&"); + } + } + + if let Some(limit) = artist_limit { + let _ = write!(endpoint, "artist_limit={limit}"); + } + } + + self.request_as_json(&Method::GET, &endpoint, None, None) + .await + } + + pub async fn get_user_followers(&self, username: &str) -> SpClientResult { + let endpoint = format!("/user-profile-view/v3/profile/{username}/followers"); + + self.request_as_json(&Method::GET, &endpoint, None, None) + .await + } + + pub async fn get_user_following(&self, username: &str) -> SpClientResult { + let endpoint = format!("/user-profile-view/v3/profile/{username}/following"); + + self.request_as_json(&Method::GET, &endpoint, None, None) + .await + } + + pub async fn get_radio_for_track(&self, track_uri: &SpotifyUri) -> SpClientResult { + let endpoint = format!( + "/inspiredby-mix/v2/seed_to_playlist/{}?response-format=json", + track_uri.to_uri()? + ); + + self.request_as_json(&Method::GET, &endpoint, None, None) + .await + } + + // Known working scopes: stations, tracks + // For others see: https://gist.github.com/roderickvd/62df5b74d2179a12de6817a37bb474f9 + // + // Seen-in-the-wild but unimplemented query parameters: + // - image_style=gradient_overlay + // - excludeClusters=true + // - language=en + // - count_tracks=0 + // - market=from_token + pub async fn get_apollo_station( + &self, + scope: &str, + context_uri: &str, + count: Option, + previous_tracks: Vec, + autoplay: bool, + ) -> SpClientResult { + let mut endpoint = format!("/radio-apollo/v3/{scope}/{context_uri}?autoplay={autoplay}"); + + // Spotify has a default of 50 + if let Some(count) = count { + let _ = write!(endpoint, "&count={count}"); + } + + let previous_track_str = previous_tracks + .iter() + .map(|track| track.to_base62()) + .collect::, _>>()? + .join(","); + // better than checking `previous_tracks.len() > 0` because the `filter_map` could still return 0 items + if !previous_track_str.is_empty() { + let _ = write!(endpoint, "&prev_tracks={previous_track_str}"); + } + + self.request_as_json(&Method::GET, &endpoint, None, None) + .await + } + + pub async fn get_next_page(&self, next_page_uri: &str) -> SpClientResult { + let endpoint = next_page_uri.trim_start_matches("hm:/"); + self.request_as_json(&Method::GET, endpoint, None, None) + .await + } + + // TODO: Seen-in-the-wild but unimplemented endpoints + // - /presence-view/v1/buddylist + + pub async fn get_audio_storage(&self, file_id: &FileId) -> SpClientResult { + let endpoint = format!( + "/storage-resolve/files/audio/interactive/{}", + file_id.to_base16()? + ); + self.request(&Method::GET, &endpoint, None, None).await + } + + pub fn stream_from_cdn( + &self, + cdn_url: U, + offset: usize, + length: usize, + ) -> Result, Error> + where + U: TryInto, + >::Error: Into, + { + let req = Request::builder() + .method(&Method::GET) + .uri(cdn_url) + .header( + RANGE, + HeaderValue::from_str(&format!("bytes={}-{}", offset, offset + length - 1))?, + ) + .body(Bytes::new())?; + + let stream = self.session().http_client().request_stream(req)?; + + Ok(stream) + } + + pub async fn request_url(&self, url: &str) -> SpClientResult { + let request = Request::builder() + .method(&Method::GET) + .uri(url) + .body(Bytes::new())?; + + self.session().http_client().request_body(request).await + } + + // Audio preview in 96 kbps MP3, unencrypted + pub async fn get_audio_preview(&self, preview_id: &FileId) -> SpClientResult { + const ATTRIBUTE: &str = "audio-preview-url-template"; + let template = self + .session() + .get_user_attribute(ATTRIBUTE) + .ok_or_else(|| SpClientError::Attribute(ATTRIBUTE.to_string()))?; + + let mut url = template.replace("{id}", &preview_id.to_base16()?); + let separator = match url.find('?') { + Some(_) => "&", + None => "?", + }; + let _ = write!(url, "{}cid={}", separator, self.session().client_id()); + + self.request_url(&url).await + } + + // The first 128 kB of a track, unencrypted + pub async fn get_head_file(&self, file_id: &FileId) -> SpClientResult { + const ATTRIBUTE: &str = "head-files-url"; + let template = self + .session() + .get_user_attribute(ATTRIBUTE) + .ok_or_else(|| SpClientError::Attribute(ATTRIBUTE.to_string()))?; + + let url = template.replace("{file_id}", &file_id.to_base16()?); + + self.request_url(&url).await + } + + pub async fn get_image(&self, image_id: &FileId) -> SpClientResult { + const ATTRIBUTE: &str = "image-url"; + let template = self + .session() + .get_user_attribute(ATTRIBUTE) + .ok_or_else(|| SpClientError::Attribute(ATTRIBUTE.to_string()))?; + let url = template.replace("{file_id}", &image_id.to_base16()?); + + self.request_url(&url).await + } + + /// Request the context for an uri + /// + /// All [SpotifyId] uris are supported in addition to the following special uris: + /// - liked songs: + /// - all: `spotify:user::collection` + /// - of artist: `spotify:user::collection:artist:` + /// - search: `spotify:search:` (whitespaces are replaced with `+`) + /// + /// ## Query params found in the wild: + /// - include_video=true + /// + /// ## Known results of uri types: + /// - uris of type `track` + /// - returns a single page with a single track + /// - when requesting a single track with a query in the request, the returned track uri + /// **will** contain the query + /// - uris of type `artist` + /// - returns 2 pages with tracks: 10 most popular tracks and latest/popular album + /// - remaining pages are artist albums sorted by popularity (only provided as page_url) + /// - uris of type `search` + /// - is massively influenced by the provided query + /// - the query result shown by the search expects no query at all + /// - uri looks like `spotify:search:never+gonna` + pub async fn get_context(&self, uri: &str) -> Result { + let uri = format!("/context-resolve/v1/{uri}"); + + let res = self + .request_with_options(&Method::GET, &uri, None, None, &NO_METRICS_AND_SALT) + .await?; + let ctx_json = String::from_utf8(res.to_vec())?; + if ctx_json.is_empty() { + Err(SpClientError::NoData)? + } + + let ctx = protobuf_json_mapping::parse_from_str::(&ctx_json); + + if ctx.is_err() { + trace!("failed parsing context: {ctx_json}") + } + + Ok(ctx?) + } + + pub async fn get_autoplay_context( + &self, + context_request: &AutoplayContextRequest, + ) -> Result { + let res = self + .request_with_protobuf_and_options( + &Method::POST, + "/context-resolve/v1/autoplay", + None, + context_request, + &NO_METRICS_AND_SALT, + ) + .await?; + + let ctx_json = String::from_utf8(res.to_vec())?; + if ctx_json.is_empty() { + Err(SpClientError::NoData)? + } + + let ctx = protobuf_json_mapping::parse_from_str::(&ctx_json); + + if ctx.is_err() { + trace!("failed parsing context: {ctx_json}") + } + + Ok(ctx?) + } + + pub async fn get_rootlist(&self, from: usize, length: Option) -> SpClientResult { + let length = length.unwrap_or(120); + let user = self.session().username(); + let endpoint = format!( + "/playlist/v2/user/{user}/rootlist?decorate=revision,attributes,length,owner,capabilities,status_code&from={from}&length={length}" + ); + + self.request(&Method::GET, &endpoint, None, None).await + } + + /// Triggers the transfers of the playback from one device to another + /// + /// Using the same `device_id` for `from_device_id` and `to_device_id`, initiates the transfer + /// from the currently active device. + pub async fn transfer( + &self, + from_device_id: &str, + to_device_id: &str, + transfer_request: Option<&TransferRequest>, + ) -> SpClientResult { + let body = transfer_request.map(serde_json::to_string).transpose()?; + + let endpoint = + format!("/connect-state/v1/connect/transfer/from/{from_device_id}/to/{to_device_id}"); + self.request_with_options( + &Method::POST, + &endpoint, + None, + body.as_deref().map(|s| s.as_bytes()), + &NO_METRICS_AND_SALT, + ) + .await + } +} diff --git a/native/vendor/librespot-core/src/spotify_id.rs b/native/vendor/librespot-core/src/spotify_id.rs new file mode 100644 index 00000000..c627b551 --- /dev/null +++ b/native/vendor/librespot-core/src/spotify_id.rs @@ -0,0 +1,387 @@ +use std::fmt; + +use thiserror::Error; + +use crate::{Error, SpotifyUri}; + +// re-export FileId for historic reasons, when it was part of this mod +pub use crate::FileId; + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct SpotifyId { + pub id: u128, +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum SpotifyIdError { + #[error("ID cannot be parsed")] + InvalidId, + #[error("not a valid Spotify ID")] + InvalidFormat, +} + +impl From for Error { + fn from(err: SpotifyIdError) -> Self { + Error::invalid_argument(err) + } +} + +pub type SpotifyIdResult = Result; + +const BASE62_DIGITS: &[u8; 62] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const BASE16_DIGITS: &[u8; 16] = b"0123456789abcdef"; + +impl SpotifyId { + const SIZE: usize = 16; + const SIZE_BASE16: usize = 32; + const SIZE_BASE62: usize = 22; + + /// Parses a base16 (hex) encoded [Spotify ID] into a `SpotifyId`. + /// + /// `src` is expected to be 32 bytes long and encoded using valid characters. + /// + /// [Spotify ID]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids + pub fn from_base16(src: &str) -> SpotifyIdResult { + if src.len() != 32 { + return Err(SpotifyIdError::InvalidId.into()); + } + let mut dst: u128 = 0; + + for c in src.as_bytes() { + let p = match c { + b'0'..=b'9' => c - b'0', + b'a'..=b'f' => c - b'a' + 10, + _ => return Err(SpotifyIdError::InvalidId.into()), + } as u128; + + dst <<= 4; + dst += p; + } + + Ok(Self { id: dst }) + } + + /// Parses a base62 encoded [Spotify ID] into a `u128`. + /// + /// `src` is expected to be 22 bytes long and encoded using valid characters. + /// + /// [Spotify ID]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids + pub fn from_base62(src: &str) -> SpotifyIdResult { + if src.len() != Self::SIZE_BASE62 { + return Err(SpotifyIdError::InvalidId.into()); + } + let mut dst: u128 = 0; + + for c in src.as_bytes() { + let p = match c { + b'0'..=b'9' => c - b'0', + b'a'..=b'z' => c - b'a' + 10, + b'A'..=b'Z' => c - b'A' + 36, + _ => return Err(SpotifyIdError::InvalidId.into()), + } as u128; + + dst = dst.checked_mul(62).ok_or(SpotifyIdError::InvalidId)?; + dst = dst.checked_add(p).ok_or(SpotifyIdError::InvalidId)?; + } + + Ok(Self { id: dst }) + } + + /// Creates a `u128` from a copy of `SpotifyId::SIZE` (16) bytes in big-endian order. + /// + /// The resulting `SpotifyId` will default to a `SpotifyItemType::Unknown`. + pub fn from_raw(src: &[u8]) -> SpotifyIdResult { + match src.try_into() { + Ok(dst) => Ok(Self { + id: u128::from_be_bytes(dst), + }), + Err(_) => Err(SpotifyIdError::InvalidId.into()), + } + } + + /// Returns the `SpotifyId` as a base16 (hex) encoded, `SpotifyId::SIZE_BASE16` (32) + /// character long `String`. + #[allow(clippy::wrong_self_convention)] + pub fn to_base16(&self) -> Result { + to_base16(&self.to_raw(), &mut [0u8; Self::SIZE_BASE16]) + } + + /// Returns the `SpotifyId` as a [canonically] base62 encoded, `SpotifyId::SIZE_BASE62` (22) + /// character long `String`. + /// + /// [canonically]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids + #[allow(clippy::wrong_self_convention)] + pub fn to_base62(&self) -> Result { + let mut dst = [0u8; 22]; + let mut i = 0; + let n = self.id; + + // The algorithm is based on: + // https://github.com/trezor/trezor-crypto/blob/c316e775a2152db255ace96b6b65ac0f20525ec0/base58.c + // + // We are not using naive division of self.id as it is an u128 and div + mod are software + // emulated at runtime (and unoptimized into mul + shift) on non-128bit platforms, + // making them very expensive. + // + // Trezor's algorithm allows us to stick to arithmetic on native registers making this + // an order of magnitude faster. Additionally, as our sizes are known, instead of + // dealing with the ID on a byte by byte basis, we decompose it into four u32s and + // use 64-bit arithmetic on them for an additional speedup. + for shift in &[96, 64, 32, 0] { + let mut carry = (n >> shift) as u32 as u64; + + for b in &mut dst[..i] { + carry += (*b as u64) << 32; + *b = (carry % 62) as u8; + carry /= 62; + } + + while carry > 0 { + dst[i] = (carry % 62) as u8; + carry /= 62; + i += 1; + } + } + + for b in &mut dst { + *b = BASE62_DIGITS[*b as usize]; + } + + dst.reverse(); + + String::from_utf8(dst.to_vec()).map_err(|_| SpotifyIdError::InvalidId.into()) + } + + /// Returns a copy of the `SpotifyId` as an array of `SpotifyId::SIZE` (16) bytes in + /// big-endian order. + #[allow(clippy::wrong_self_convention)] + pub fn to_raw(&self) -> [u8; Self::SIZE] { + self.id.to_be_bytes() + } +} + +impl fmt::Debug for SpotifyId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("SpotifyId") + .field(&self.to_base62().unwrap_or_else(|_| "invalid uri".into())) + .finish() + } +} + +impl fmt::Display for SpotifyId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_base62().unwrap_or_else(|_| "invalid uri".into())) + } +} + +impl TryFrom<&[u8]> for SpotifyId { + type Error = crate::Error; + fn try_from(src: &[u8]) -> Result { + Self::from_raw(src) + } +} + +impl TryFrom<&str> for SpotifyId { + type Error = crate::Error; + fn try_from(src: &str) -> Result { + Self::from_base62(src) + } +} + +impl TryFrom for SpotifyId { + type Error = crate::Error; + fn try_from(src: String) -> Result { + Self::try_from(src.as_str()) + } +} + +impl TryFrom<&Vec> for SpotifyId { + type Error = crate::Error; + fn try_from(src: &Vec) -> Result { + Self::try_from(src.as_slice()) + } +} + +impl TryFrom<&SpotifyUri> for SpotifyId { + type Error = crate::Error; + fn try_from(value: &SpotifyUri) -> Result { + match value { + SpotifyUri::Album { id } + | SpotifyUri::Artist { id } + | SpotifyUri::Episode { id } + | SpotifyUri::Playlist { id, .. } + | SpotifyUri::Show { id } + | SpotifyUri::Track { id } => Ok(*id), + SpotifyUri::Local { .. } | SpotifyUri::Unknown { .. } => { + Err(SpotifyIdError::InvalidFormat.into()) + } + } + } +} + +pub fn to_base16(src: &[u8], buf: &mut [u8]) -> Result { + let mut i = 0; + for v in src { + buf[i] = BASE16_DIGITS[(v >> 4) as usize]; + buf[i + 1] = BASE16_DIGITS[(v & 0x0f) as usize]; + i += 2; + } + + String::from_utf8(buf.to_vec()).map_err(|_| SpotifyIdError::InvalidId.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct ConversionCase { + id: u128, + base16: &'static str, + base62: &'static str, + raw: &'static [u8], + } + + static CONV_VALID: [ConversionCase; 5] = [ + ConversionCase { + id: 238762092608182713602505436543891614649, + base16: "b39fe8081e1f4c54be38e8d6f9f12bb9", + base62: "5sWHDYs0csV6RS48xBl0tH", + raw: &[ + 179, 159, 232, 8, 30, 31, 76, 84, 190, 56, 232, 214, 249, 241, 43, 185, + ], + }, + ConversionCase { + id: 204841891221366092811751085145916697048, + base16: "9a1b1cfbc6f244569ae0356c77bbe9d8", + base62: "4GNcXTGWmnZ3ySrqvol3o4", + raw: &[ + 154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216, + ], + }, + ConversionCase { + id: 204841891221366092811751085145916697048, + base16: "9a1b1cfbc6f244569ae0356c77bbe9d8", + base62: "4GNcXTGWmnZ3ySrqvol3o4", + raw: &[ + 154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216, + ], + }, + ConversionCase { + id: 204841891221366092811751085145916697048, + base16: "9a1b1cfbc6f244569ae0356c77bbe9d8", + base62: "4GNcXTGWmnZ3ySrqvol3o4", + raw: &[ + 154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 53, 108, 119, 187, 233, 216, + ], + }, + ConversionCase { + id: 0, + base16: "00000000000000000000000000000000", + base62: "0000000000000000000000", + raw: &[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, + ]; + + static CONV_INVALID: [ConversionCase; 5] = [ + ConversionCase { + id: 0, + base16: "ZZZZZ8081e1f4c54be38e8d6f9f12bb9", + base62: "!!!!!Ys0csV6RS48xBl0tH", + raw: &[ + // Invalid length. + 154, 27, 28, 251, 198, 242, 68, 86, 154, 224, 5, 3, 108, 119, 187, 233, 216, 255, + ], + }, + ConversionCase { + id: 0, + base16: "--------------------", + base62: "....................", + raw: &[ + // Invalid length. + 154, 27, 28, 251, + ], + }, + ConversionCase { + id: 0, + // too long, should return error but not panic overflow + base16: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + // too long, should return error but not panic overflow + base62: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + raw: &[ + // Invalid length. + 154, 27, 28, 251, + ], + }, + ConversionCase { + id: 0, + base16: "--------------------", + // too short to encode a 128 bits int + base62: "aa", + raw: &[ + // Invalid length. + 154, 27, 28, 251, + ], + }, + ConversionCase { + id: 0, + base16: "--------------------", + // too high of a value, this would need a 132 bits int + base62: "ZZZZZZZZZZZZZZZZZZZZZZ", + raw: &[ + // Invalid length. + 154, 27, 28, 251, + ], + }, + ]; + + #[test] + fn from_base62() { + for c in &CONV_VALID { + assert_eq!(SpotifyId::from_base62(c.base62).unwrap().id, c.id); + } + + for c in &CONV_INVALID { + assert!(SpotifyId::from_base62(c.base62).is_err(),); + } + } + + #[test] + fn to_base62() { + for c in &CONV_VALID { + let id = SpotifyId { id: c.id }; + + assert_eq!(id.to_base62().unwrap(), c.base62); + } + } + + #[test] + fn from_base16() { + for c in &CONV_VALID { + assert_eq!(SpotifyId::from_base16(c.base16).unwrap().id, c.id); + } + + for c in &CONV_INVALID { + assert!(SpotifyId::from_base16(c.base16).is_err(),); + } + } + + #[test] + fn to_base16() { + for c in &CONV_VALID { + let id = SpotifyId { id: c.id }; + + assert_eq!(id.to_base16().unwrap(), c.base16); + } + } + + #[test] + fn from_raw() { + for c in &CONV_VALID { + assert_eq!(SpotifyId::from_raw(c.raw).unwrap().id, c.id); + } + + for c in &CONV_INVALID { + assert!(SpotifyId::from_raw(c.raw).is_err()); + } + } +} diff --git a/native/vendor/librespot-core/src/spotify_uri.rs b/native/vendor/librespot-core/src/spotify_uri.rs new file mode 100644 index 00000000..f92f2d3b --- /dev/null +++ b/native/vendor/librespot-core/src/spotify_uri.rs @@ -0,0 +1,615 @@ +use crate::{Error, SpotifyId}; +use std::{borrow::Cow, fmt, str::FromStr, time::Duration}; +use thiserror::Error; + +use librespot_protocol as protocol; + +const SPOTIFY_ITEM_TYPE_ALBUM: &str = "album"; +const SPOTIFY_ITEM_TYPE_ARTIST: &str = "artist"; +const SPOTIFY_ITEM_TYPE_EPISODE: &str = "episode"; +const SPOTIFY_ITEM_TYPE_PLAYLIST: &str = "playlist"; +const SPOTIFY_ITEM_TYPE_SHOW: &str = "show"; +const SPOTIFY_ITEM_TYPE_TRACK: &str = "track"; +const SPOTIFY_ITEM_TYPE_LOCAL: &str = "local"; +const SPOTIFY_ITEM_TYPE_UNKNOWN: &str = "unknown"; + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum SpotifyUriError { + #[error("not a valid Spotify URI")] + InvalidFormat, + #[error("URI does not belong to Spotify")] + InvalidRoot, +} + +impl From for Error { + fn from(err: SpotifyUriError) -> Self { + Error::invalid_argument(err) + } +} + +pub type SpotifyUriResult = Result; + +#[derive(Clone, PartialEq, Eq, Hash)] +pub enum SpotifyUri { + Album { + id: SpotifyId, + }, + Artist { + id: SpotifyId, + }, + Episode { + id: SpotifyId, + }, + Playlist { + user: Option, + id: SpotifyId, + }, + Show { + id: SpotifyId, + }, + Track { + id: SpotifyId, + }, + Local { + artist: String, + album_title: String, + track_title: String, + duration: std::time::Duration, + }, + Unknown { + kind: Cow<'static, str>, + id: String, + }, +} + +impl SpotifyUri { + /// Returns whether this `SpotifyUri` is for a playable audio item, if known. + pub fn is_playable(&self) -> bool { + matches!( + self, + SpotifyUri::Episode { .. } | SpotifyUri::Track { .. } | SpotifyUri::Local { .. } + ) + } + + /// Gets the item type of this URI as a static string + pub fn item_type(&self) -> &'static str { + match &self { + SpotifyUri::Album { .. } => SPOTIFY_ITEM_TYPE_ALBUM, + SpotifyUri::Artist { .. } => SPOTIFY_ITEM_TYPE_ARTIST, + SpotifyUri::Episode { .. } => SPOTIFY_ITEM_TYPE_EPISODE, + SpotifyUri::Playlist { .. } => SPOTIFY_ITEM_TYPE_PLAYLIST, + SpotifyUri::Show { .. } => SPOTIFY_ITEM_TYPE_SHOW, + SpotifyUri::Track { .. } => SPOTIFY_ITEM_TYPE_TRACK, + SpotifyUri::Local { .. } => SPOTIFY_ITEM_TYPE_LOCAL, + SpotifyUri::Unknown { .. } => SPOTIFY_ITEM_TYPE_UNKNOWN, + } + } + + /// Gets the ID of this URI. The resource ID is the component of the URI that identifies + /// the resource after its type label. If `self` is a named ID, the user will be omitted. + pub fn to_id(&self) -> Result { + match &self { + SpotifyUri::Album { id } + | SpotifyUri::Artist { id } + | SpotifyUri::Episode { id } + | SpotifyUri::Playlist { id, .. } + | SpotifyUri::Show { id } + | SpotifyUri::Track { id } => id.to_base62(), + SpotifyUri::Local { + artist, + album_title, + track_title, + duration, + } => { + let duration_secs = duration.as_secs(); + Ok(format!( + "{artist}:{album_title}:{track_title}:{duration_secs}" + )) + } + SpotifyUri::Unknown { id, .. } => Ok(id.clone()), + } + } + + /// Parses a [Spotify URI] into a `SpotifyUri`. + /// + /// `uri` is expected to be in the canonical form `spotify:{type}:{id}`, where `{type}` + /// can be arbitrary while `{id}` is in a format that varies based on the `{type}`: + /// + /// - For most item types, a 22-character long, base62 encoded Spotify ID is expected. + /// - For local files, an arbitrary length string with the fields + /// `{artist}:{album_title}:{track_title}:{duration_in_seconds}` is expected. + /// + /// Spotify URI: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids + pub fn from_uri(src: &str) -> SpotifyUriResult { + // Basic: `spotify:{type}:{id}` + // Named: `spotify:user:{user}:{type}:{id}` + // Local: `spotify:local:{artist}:{album_title}:{track_title}:{duration_in_seconds}` + let mut parts = src.split(':'); + + let scheme = parts.next().ok_or(SpotifyUriError::InvalidFormat)?; + + if scheme != "spotify" { + return Err(SpotifyUriError::InvalidRoot.into()); + } + + let mut username: Option = None; + + let item_type = { + let next = parts.next().ok_or(SpotifyUriError::InvalidFormat)?; + if next == "user" { + username.replace( + parts + .next() + .ok_or(SpotifyUriError::InvalidFormat)? + .to_owned(), + ); + parts.next().ok_or(SpotifyUriError::InvalidFormat)? + } else { + next + } + }; + + let name = parts.next().ok_or(SpotifyUriError::InvalidFormat)?; + + match item_type { + SPOTIFY_ITEM_TYPE_ALBUM => Ok(Self::Album { + id: SpotifyId::from_base62(name)?, + }), + SPOTIFY_ITEM_TYPE_ARTIST => Ok(Self::Artist { + id: SpotifyId::from_base62(name)?, + }), + SPOTIFY_ITEM_TYPE_EPISODE => Ok(Self::Episode { + id: SpotifyId::from_base62(name)?, + }), + SPOTIFY_ITEM_TYPE_PLAYLIST => Ok(Self::Playlist { + id: SpotifyId::from_base62(name)?, + user: username, + }), + SPOTIFY_ITEM_TYPE_SHOW => Ok(Self::Show { + id: SpotifyId::from_base62(name)?, + }), + SPOTIFY_ITEM_TYPE_TRACK => Ok(Self::Track { + id: SpotifyId::from_base62(name)?, + }), + SPOTIFY_ITEM_TYPE_LOCAL => { + let artist = name; + let album_title = parts.next().ok_or(SpotifyUriError::InvalidFormat)?; + let track_title = parts.next().ok_or(SpotifyUriError::InvalidFormat)?; + let duration_secs = parts + .next() + .and_then(|f| u64::from_str(f).ok()) + .ok_or(SpotifyUriError::InvalidFormat)?; + + Ok(Self::Local { + artist: artist.to_owned(), + album_title: album_title.to_owned(), + track_title: track_title.to_owned(), + duration: Duration::from_secs(duration_secs), + }) + } + _ => Ok(Self::Unknown { + kind: item_type.to_owned().into(), + id: name.to_owned(), + }), + } + } + + /// Returns the `SpotifyUri` as a [Spotify URI] in the canonical form `spotify:{type}:{id}`, + /// where `{type}` is an arbitrary string and `{id}` is a 22-character long, base62 encoded + /// Spotify ID. + /// + /// If the `SpotifyUri` has an associated type unrecognized by the library, `{type}` will + /// be encoded as `unknown`. + /// + /// If the `SpotifyUri` is named, it will be returned in the form + /// `spotify:user:{user}:{type}:{id}`. + /// + /// [Spotify URI]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids + pub fn to_uri(&self) -> Result { + let item_type = self.item_type(); + let name = self.to_id()?; + + if let SpotifyUri::Playlist { + id, + user: Some(user), + } = self + { + Ok(format!("spotify:user:{user}:{item_type}:{id}")) + } else { + Ok(format!("spotify:{item_type}:{name}")) + } + } + + /// Gets the name of this URI. The resource name is the component of the URI that identifies + /// the resource after its type label. If `self` is a named ID, the user will be omitted. + /// + /// Deprecated: not all IDs can be represented in Base62, so this function has been renamed to + /// [SpotifyUri::to_id], which this implementation forwards to. + #[deprecated(since = "0.8.0", note = "use to_name instead")] + pub fn to_base62(&self) -> Result { + self.to_id() + } +} + +impl fmt::Debug for SpotifyUri { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("SpotifyUri") + .field(&self.to_uri().unwrap_or_else(|_| "invalid uri".into())) + .finish() + } +} + +impl fmt::Display for SpotifyUri { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_uri().unwrap_or_else(|_| "invalid uri".into())) + } +} + +impl TryFrom<&protocol::metadata::Album> for SpotifyUri { + type Error = crate::Error; + fn try_from(album: &protocol::metadata::Album) -> Result { + Ok(Self::Album { + id: SpotifyId::from_raw(album.gid())?, + }) + } +} + +impl TryFrom<&protocol::metadata::Artist> for SpotifyUri { + type Error = crate::Error; + fn try_from(artist: &protocol::metadata::Artist) -> Result { + Ok(Self::Artist { + id: SpotifyId::from_raw(artist.gid())?, + }) + } +} + +impl TryFrom<&protocol::metadata::Episode> for SpotifyUri { + type Error = crate::Error; + fn try_from(episode: &protocol::metadata::Episode) -> Result { + Ok(Self::Episode { + id: SpotifyId::from_raw(episode.gid())?, + }) + } +} + +impl TryFrom<&protocol::metadata::Track> for SpotifyUri { + type Error = crate::Error; + fn try_from(track: &protocol::metadata::Track) -> Result { + Ok(Self::Track { + id: SpotifyId::from_raw(track.gid())?, + }) + } +} + +impl TryFrom<&protocol::metadata::Show> for SpotifyUri { + type Error = crate::Error; + fn try_from(show: &protocol::metadata::Show) -> Result { + Ok(Self::Show { + id: SpotifyId::from_raw(show.gid())?, + }) + } +} + +impl TryFrom<&protocol::metadata::ArtistWithRole> for SpotifyUri { + type Error = crate::Error; + fn try_from(artist: &protocol::metadata::ArtistWithRole) -> Result { + Ok(Self::Artist { + id: SpotifyId::from_raw(artist.artist_gid())?, + }) + } +} + +impl TryFrom<&protocol::playlist4_external::Item> for SpotifyUri { + type Error = crate::Error; + fn try_from(item: &protocol::playlist4_external::Item) -> Result { + Self::from_uri(item.uri()) + } +} + +// Note that this is the unique revision of an item's metadata on a playlist, +// not the ID of that item or playlist. +impl TryFrom<&protocol::playlist4_external::MetaItem> for SpotifyUri { + type Error = crate::Error; + fn try_from(item: &protocol::playlist4_external::MetaItem) -> Result { + Ok(Self::Unknown { + kind: "MetaItem".into(), + id: SpotifyId::try_from(item.revision())?.to_base62()?, + }) + } +} + +// Note that this is the unique revision of a playlist, not the ID of that playlist. +impl TryFrom<&protocol::playlist4_external::SelectedListContent> for SpotifyUri { + type Error = crate::Error; + fn try_from( + playlist: &protocol::playlist4_external::SelectedListContent, + ) -> Result { + Ok(Self::Unknown { + kind: "SelectedListContent".into(), + id: SpotifyId::try_from(playlist.revision())?.to_base62()?, + }) + } +} + +// TODO: check meaning and format of this field in the wild. This might be a FileId, +// which is why we now don't create a separate `Playlist` enum value yet and choose +// to discard any item type. +impl TryFrom<&protocol::playlist_annotate3::TranscodedPicture> for SpotifyUri { + type Error = crate::Error; + fn try_from( + picture: &protocol::playlist_annotate3::TranscodedPicture, + ) -> Result { + Ok(Self::Unknown { + kind: "TranscodedPicture".into(), + id: picture.uri().to_owned(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct ConversionCase { + parsed: SpotifyUri, + uri: &'static str, + base62: &'static str, + } + + static CONV_VALID: [ConversionCase; 4] = [ + ConversionCase { + parsed: SpotifyUri::Track { + id: SpotifyId { + id: 238762092608182713602505436543891614649, + }, + }, + uri: "spotify:track:5sWHDYs0csV6RS48xBl0tH", + base62: "5sWHDYs0csV6RS48xBl0tH", + }, + ConversionCase { + parsed: SpotifyUri::Track { + id: SpotifyId { + id: 204841891221366092811751085145916697048, + }, + }, + uri: "spotify:track:4GNcXTGWmnZ3ySrqvol3o4", + base62: "4GNcXTGWmnZ3ySrqvol3o4", + }, + ConversionCase { + parsed: SpotifyUri::Episode { + id: SpotifyId { + id: 204841891221366092811751085145916697048, + }, + }, + uri: "spotify:episode:4GNcXTGWmnZ3ySrqvol3o4", + base62: "4GNcXTGWmnZ3ySrqvol3o4", + }, + ConversionCase { + parsed: SpotifyUri::Show { + id: SpotifyId { + id: 204841891221366092811751085145916697048, + }, + }, + uri: "spotify:show:4GNcXTGWmnZ3ySrqvol3o4", + base62: "4GNcXTGWmnZ3ySrqvol3o4", + }, + ]; + + static CONV_INVALID: [ConversionCase; 5] = [ + ConversionCase { + parsed: SpotifyUri::Track { + id: SpotifyId { id: 0 }, + }, + // Invalid ID in the URI. + uri: "spotify:track:5sWHDYs0Bl0tH", + base62: "!!!!!Ys0csV6RS48xBl0tH", + }, + ConversionCase { + parsed: SpotifyUri::Track { + id: SpotifyId { id: 0 }, + }, + // Missing colon between ID and type. + uri: "spotify:arbitrarywhatever5sWHDYs0csV6RS48xBl0tH", + base62: "....................", + }, + ConversionCase { + parsed: SpotifyUri::Track { + id: SpotifyId { id: 0 }, + }, + // Uri too short + uri: "spotify:track:aRS48xBl0tH", + // too long, should return error but not panic overflow + base62: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + ConversionCase { + parsed: SpotifyUri::Track { + id: SpotifyId { id: 0 }, + }, + // Uri too short + uri: "spotify:track:aRS48xBl0tH", + // too short to encode a 128 bits int + base62: "aa", + }, + ConversionCase { + parsed: SpotifyUri::Track { + id: SpotifyId { id: 0 }, + }, + uri: "cleary invalid uri", + // too high of a value, this would need a 132 bits int + base62: "ZZZZZZZZZZZZZZZZZZZZZZ", + }, + ]; + + struct ItemTypeCase { + uri: SpotifyUri, + expected_type: &'static str, + } + + static ITEM_TYPES: [ItemTypeCase; 6] = [ + ItemTypeCase { + uri: SpotifyUri::Album { + id: SpotifyId { id: 0 }, + }, + expected_type: "album", + }, + ItemTypeCase { + uri: SpotifyUri::Artist { + id: SpotifyId { id: 0 }, + }, + expected_type: "artist", + }, + ItemTypeCase { + uri: SpotifyUri::Episode { + id: SpotifyId { id: 0 }, + }, + expected_type: "episode", + }, + ItemTypeCase { + uri: SpotifyUri::Playlist { + user: None, + id: SpotifyId { id: 0 }, + }, + expected_type: "playlist", + }, + ItemTypeCase { + uri: SpotifyUri::Show { + id: SpotifyId { id: 0 }, + }, + expected_type: "show", + }, + ItemTypeCase { + uri: SpotifyUri::Track { + id: SpotifyId { id: 0 }, + }, + expected_type: "track", + }, + ]; + + #[test] + fn to_id() { + for c in &CONV_VALID { + assert_eq!(c.parsed.to_id().unwrap(), c.base62); + } + } + + #[test] + fn item_type() { + for i in &ITEM_TYPES { + assert_eq!(i.uri.item_type(), i.expected_type); + } + + // These need to use methods that can't be used in the static context like to_owned() and + // into(). + + let local_file = SpotifyUri::Local { + artist: "".to_owned(), + album_title: "".to_owned(), + track_title: "".to_owned(), + duration: Default::default(), + }; + + assert_eq!(local_file.item_type(), "local"); + + let unknown = SpotifyUri::Unknown { + kind: "not used".into(), + id: "".to_owned(), + }; + + assert_eq!(unknown.item_type(), "unknown"); + } + + #[test] + fn from_uri() { + for c in &CONV_VALID { + let actual = SpotifyUri::from_uri(c.uri).unwrap(); + + assert_eq!(actual, c.parsed); + } + + for c in &CONV_INVALID { + assert!(SpotifyUri::from_uri(c.uri).is_err()); + } + } + + #[test] + fn from_invalid_type_uri() { + let actual = + SpotifyUri::from_uri("spotify:arbitrarywhatever:5sWHDYs0csV6RS48xBl0tH").unwrap(); + + assert_eq!( + actual, + SpotifyUri::Unknown { + kind: "arbitrarywhatever".into(), + id: "5sWHDYs0csV6RS48xBl0tH".to_owned() + } + ) + } + + #[test] + fn from_local_uri() { + let actual = SpotifyUri::from_uri( + "spotify:local:David+Wise:Donkey+Kong+Country%3A+Tropical+Freeze:Snomads+Island:127", + ) + .unwrap(); + + assert_eq!( + actual, + SpotifyUri::Local { + artist: "David+Wise".to_owned(), + album_title: "Donkey+Kong+Country%3A+Tropical+Freeze".to_owned(), + track_title: "Snomads+Island".to_owned(), + duration: Duration::from_secs(127), + } + ); + } + + #[test] + fn from_local_uri_missing_fields() { + let actual = SpotifyUri::from_uri("spotify:local:::Snomads+Island:127").unwrap(); + + assert_eq!( + actual, + SpotifyUri::Local { + artist: "".to_owned(), + album_title: "".to_owned(), + track_title: "Snomads+Island".to_owned(), + duration: Duration::from_secs(127), + } + ); + } + + #[test] + fn from_named_uri() { + let actual = + SpotifyUri::from_uri("spotify:user:spotify:playlist:37i9dQZF1DWSw8liJZcPOI").unwrap(); + + let SpotifyUri::Playlist { ref user, id } = actual else { + panic!("wrong id type"); + }; + + assert_eq!(*user, Some("spotify".to_owned())); + assert_eq!( + id, + SpotifyId { + id: 136159921382084734723401526672209703396 + }, + ); + } + + #[test] + fn to_uri() { + for c in &CONV_VALID { + assert_eq!(c.parsed.to_uri().unwrap(), c.uri); + } + } + + #[test] + fn to_named_uri() { + let string = "spotify:user:spotify:playlist:37i9dQZF1DWSw8liJZcPOI"; + + let actual = + SpotifyUri::from_uri("spotify:user:spotify:playlist:37i9dQZF1DWSw8liJZcPOI").unwrap(); + + assert_eq!(actual.to_uri().unwrap(), string); + } +} diff --git a/native/vendor/librespot-core/src/token.rs b/native/vendor/librespot-core/src/token.rs new file mode 100644 index 00000000..913edf48 --- /dev/null +++ b/native/vendor/librespot-core/src/token.rs @@ -0,0 +1,143 @@ +// Ported from librespot-java. Relicensed under MIT with permission. + +// Known scopes: +// ugc-image-upload, playlist-read-collaborative, playlist-modify-private, +// playlist-modify-public, playlist-read-private, user-read-playback-position, +// user-read-recently-played, user-top-read, user-modify-playback-state, +// user-read-currently-playing, user-read-playback-state, user-read-private, user-read-email, +// user-library-modify, user-library-read, user-follow-modify, user-follow-read, streaming, +// app-remote-control + +use std::time::{Duration, SystemTime}; + +use serde::Deserialize; +use thiserror::Error; + +use crate::Error; + +component! { + TokenProvider : TokenProviderInner { + tokens: Vec = vec![], + } +} + +#[derive(Debug, Error)] +pub enum TokenError { + #[error("no tokens available")] + Empty, +} + +impl From for Error { + fn from(err: TokenError) -> Self { + Error::unavailable(err) + } +} + +#[derive(Clone, Debug)] +pub struct Token { + pub access_token: String, + pub expires_in: Duration, + pub token_type: String, + pub scopes: Vec, + pub timestamp: SystemTime, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct TokenData { + access_token: String, + expires_in: u64, + token_type: String, + scope: Vec, +} + +impl TokenProvider { + fn find_token(&self, scopes: Vec<&str>) -> Option { + self.lock(|inner| { + (0..inner.tokens.len()).find(|&i| inner.tokens[i].in_scopes(scopes.clone())) + }) + } + + // Not all combinations of scopes and client ID are allowed. + // Depending on the client ID currently used, the function may return an error for specific scopes. + // In this case get_token_with_client_id() can be used, where an appropriate client ID can be provided. + // scopes must be comma-separated + pub async fn get_token(&self, scopes: &str) -> Result { + let client_id = self.session().client_id(); + self.get_token_with_client_id(scopes, &client_id).await + } + + pub async fn get_token_with_client_id( + &self, + scopes: &str, + client_id: &str, + ) -> Result { + if client_id.is_empty() { + return Err(Error::invalid_argument("Client ID cannot be empty")); + } + + if let Some(index) = self.find_token(scopes.split(',').collect()) { + let cached_token = self.lock(|inner| inner.tokens[index].clone()); + if cached_token.is_expired() { + self.lock(|inner| inner.tokens.remove(index)); + } else { + return Ok(cached_token); + } + } + + trace!( + "Requested token in scopes {scopes:?} unavailable or expired, requesting new token." + ); + + let query_uri = format!( + "hm://keymaster/token/authenticated?scope={}&client_id={}&device_id={}", + scopes, + client_id, + self.session().device_id(), + ); + let request = self.session().mercury().get(query_uri)?; + let response = request.await?; + let data = response.payload.first().ok_or(TokenError::Empty)?.to_vec(); + let token = Token::from_json(String::from_utf8(data)?)?; + trace!("Got token: {token:#?}"); + self.lock(|inner| inner.tokens.push(token.clone())); + Ok(token) + } +} + +impl Token { + const EXPIRY_THRESHOLD: Duration = Duration::from_secs(10); + + pub fn from_json(body: String) -> Result { + let data: TokenData = serde_json::from_slice(body.as_ref())?; + Ok(Self { + access_token: data.access_token, + expires_in: Duration::from_secs(data.expires_in), + token_type: data.token_type, + scopes: data.scope, + timestamp: SystemTime::now(), + }) + } + + pub fn is_expired(&self) -> bool { + self.timestamp + self.expires_in.saturating_sub(Self::EXPIRY_THRESHOLD) < SystemTime::now() + } + + pub fn in_scope(&self, scope: &str) -> bool { + for s in &self.scopes { + if *s == scope { + return true; + } + } + false + } + + pub fn in_scopes(&self, scopes: Vec<&str>) -> bool { + for s in scopes { + if !self.in_scope(s) { + return false; + } + } + true + } +} diff --git a/native/vendor/librespot-core/src/util.rs b/native/vendor/librespot-core/src/util.rs new file mode 100644 index 00000000..04a33668 --- /dev/null +++ b/native/vendor/librespot-core/src/util.rs @@ -0,0 +1,175 @@ +use crate::Error; +use byteorder::{BigEndian, ByteOrder}; +use futures_core::ready; +use futures_util::{FutureExt, Sink, SinkExt, future}; +use hmac::digest::Digest; +use sha1::Sha1; +use std::time::{Duration, Instant}; +use std::{ + future::Future, + mem, + pin::Pin, + task::{Context, Poll}, +}; +use tokio::{task::JoinHandle, time::timeout}; + +/// Returns a future that will flush the sink, even if flushing is temporarily completed. +/// Finishes only if the sink throws an error. +pub(crate) fn keep_flushing<'a, T, S: Sink + Unpin + 'a>( + mut s: S, +) -> impl Future + 'a { + future::poll_fn(move |cx| match s.poll_flush_unpin(cx) { + Poll::Ready(Err(e)) => Poll::Ready(e), + _ => Poll::Pending, + }) +} + +pub struct CancelOnDrop(pub JoinHandle); + +impl Future for CancelOnDrop { + type Output = as Future>::Output; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.0.poll_unpin(cx) + } +} + +impl Drop for CancelOnDrop { + fn drop(&mut self) { + self.0.abort(); + } +} + +pub struct TimeoutOnDrop { + handle: Option>, + timeout: tokio::time::Duration, +} + +impl TimeoutOnDrop { + pub fn new(handle: JoinHandle, timeout: tokio::time::Duration) -> Self { + Self { + handle: Some(handle), + timeout, + } + } + + pub fn take(&mut self) -> Option> { + self.handle.take() + } +} + +impl Future for TimeoutOnDrop { + type Output = as Future>::Output; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let r = ready!( + self.handle + .as_mut() + .expect("Polled after ready") + .poll_unpin(cx) + ); + self.handle = None; + Poll::Ready(r) + } +} + +impl Drop for TimeoutOnDrop { + fn drop(&mut self) { + let mut handle = if let Some(handle) = self.handle.take() { + handle + } else { + return; + }; + + if (&mut handle).now_or_never().is_some() { + // Already finished + return; + } + + match tokio::runtime::Handle::try_current() { + Ok(h) => { + h.spawn(timeout(self.timeout, CancelOnDrop(handle))); + } + Err(_) => { + // Not in tokio context, can't spawn + handle.abort(); + } + } + } +} + +pub trait Seq { + fn next(&self) -> Self; +} + +macro_rules! impl_seq { + ($($ty:ty)*) => { $( + impl Seq for $ty { + fn next(&self) -> Self { (*self).wrapping_add(1) } + } + )* } +} + +impl_seq!(u8 u16 u32 u64 usize); + +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, Default)] +pub struct SeqGenerator(T); + +impl SeqGenerator { + pub fn new(value: T) -> Self { + SeqGenerator(value) + } + + pub fn get(&mut self) -> T { + let value = self.0.next(); + mem::replace(&mut self.0, value) + } +} + +pub fn solve_hash_cash( + ctx: &[u8], + prefix: &[u8], + length: i32, + dst: &mut [u8], +) -> Result { + // after a certain number of seconds, the challenge expires + const TIMEOUT: u64 = 5; // seconds + let now = Instant::now(); + + let md = Sha1::digest(ctx); + + let mut counter: i64 = 0; + let target: i64 = BigEndian::read_i64(&md[12..20]); + + let suffix = loop { + if now.elapsed().as_secs() >= TIMEOUT { + return Err(Error::deadline_exceeded(format!( + "{TIMEOUT} seconds expired" + ))); + } + + let suffix = [(target + counter).to_be_bytes(), counter.to_be_bytes()].concat(); + + let mut hasher = Sha1::new(); + hasher.update(prefix); + hasher.update(&suffix); + let md = hasher.finalize(); + + if BigEndian::read_i64(&md[12..20]).trailing_zeros() >= (length as u32) { + break suffix; + } + + counter += 1; + }; + + dst.copy_from_slice(&suffix); + + Ok(now.elapsed()) +} + +pub fn get_next_query_separator(url: &str) -> &'static str { + match url.find('?') { + Some(_) => "&", + None => "?", + } +} diff --git a/native/vendor/librespot-core/src/version.rs b/native/vendor/librespot-core/src/version.rs new file mode 100644 index 00000000..0e48a47a --- /dev/null +++ b/native/vendor/librespot-core/src/version.rs @@ -0,0 +1,53 @@ +/// Version string of the form "librespot-\" +pub const VERSION_STRING: &str = concat!("librespot-", env!("VERGEN_GIT_SHA")); + +/// Generate a timestamp string representing the build date (UTC). +pub const BUILD_DATE: &str = env!("VERGEN_BUILD_DATE"); + +/// Short sha of the latest git commit. +pub const SHA_SHORT: &str = env!("VERGEN_GIT_SHA"); + +/// Date of the latest git commit. +pub const COMMIT_DATE: &str = env!("VERGEN_GIT_COMMIT_DATE"); + +/// Librespot crate version. +pub const SEMVER: &str = env!("CARGO_PKG_VERSION"); + +/// A random build id. +pub const BUILD_ID: &str = env!("LIBRESPOT_BUILD_ID"); + +/// The protocol version of the Spotify desktop client. +pub const SPOTIFY_VERSION: u64 = 124200290; + +/// The semantic version of the Spotify desktop client. +pub const SPOTIFY_SEMANTIC_VERSION: &str = "1.2.52.442"; + +/// `property_set_id` related to desktop version 1.2.52.442 +pub const SPOTIFY_PROPERTY_SET_ID: &str = "b4c7e4b5835079ed94391b2e65fca0fdba65eb50"; + +/// The protocol version of the Spotify mobile app. +pub const SPOTIFY_MOBILE_VERSION: &str = "8.9.82.620"; + +/// `property_set_id` related to mobile version 8.9.82.620 +pub const SPOTIFY_MOBILE_PROPERTY_SET_ID: &str = + "5ec87c2cc32e7c509703582cfaaa3c7ad253129d5701127c1f5eab5c9531736c"; + +/// The general spirc version +pub const SPOTIFY_SPIRC_VERSION: &str = "3.2.6"; + +/// The user agent to fall back to, if one could not be determined dynamically. +pub const FALLBACK_USER_AGENT: &str = "Spotify/124200290 Linux/0 (librespot)"; + +pub fn spotify_version() -> String { + match crate::config::OS { + "android" | "ios" => SPOTIFY_MOBILE_VERSION.to_owned(), + _ => SPOTIFY_VERSION.to_string(), + } +} + +pub fn spotify_semantic_version() -> String { + match crate::config::OS { + "android" | "ios" => SPOTIFY_MOBILE_VERSION.to_owned(), + _ => SPOTIFY_SEMANTIC_VERSION.to_string(), + } +} diff --git a/native/vendor/librespot-core/tests/connect.rs b/native/vendor/librespot-core/tests/connect.rs new file mode 100644 index 00000000..91679f91 --- /dev/null +++ b/native/vendor/librespot-core/tests/connect.rs @@ -0,0 +1,21 @@ +use std::time::Duration; + +use tokio::time::timeout; + +use librespot_core::{authentication::Credentials, config::SessionConfig, session::Session}; + +#[tokio::test] +async fn test_connection() { + timeout(Duration::from_secs(30), async { + let result = Session::new(SessionConfig::default(), None) + .connect(Credentials::with_password("test", "test"), false) + .await; + + match result { + Ok(_) => panic!("Authentication succeeded despite of bad credentials."), + Err(e) => assert!(!e.to_string().is_empty()), // there should be some error message + } + }) + .await + .unwrap(); +} diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/action/SideAction.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/action/SideAction.kt index 541cfa87..e6ac2ac7 100644 --- a/sidekit/src/main/java/hrln/interactive/sidekit/design/action/SideAction.kt +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/action/SideAction.kt @@ -59,7 +59,7 @@ enum class ActionMotion { Up, /** Scales in on the spot. */ - None, + None } /** @@ -91,7 +91,7 @@ class SideAction( val dense: Boolean = false, val artKey: Any? = null, val art: (@Composable () -> Unit)? = null, - val onClick: () -> Unit, + val onClick: () -> Unit ) { init { require(label != null || icon != null || art != null) { "SideAction '$key' needs a label, icon or art" } @@ -99,25 +99,59 @@ class SideAction( } /** What the user can see; a change here plays the content swap. Click handlers are excluded. */ - internal fun sameContent(other: SideAction): Boolean = - label == other.label && icon == other.icon && iconAtEnd == other.iconAtEnd && - dense == other.dense && (art == null) == (other.art == null) && artKey == other.artKey + internal fun sameContent(other: SideAction): Boolean = label == other.label && icon == other.icon && iconAtEnd == other.iconAtEnd && + dense == other.dense && (art == null) == (other.art == null) && artKey == other.artKey /** Everything the bar draws or animates is the same; only click handlers (and art lambdas for the same [artKey]) may differ. */ - internal fun sameLook(other: SideAction): Boolean = - sameContent(other) && tone == other.tone && weight == other.weight && square == other.square && - motion == other.motion && contentDescription == other.contentDescription + internal fun sameLook(other: SideAction): Boolean = sameContent(other) && tone == other.tone && weight == other.weight && square == other.square && + motion == other.motion && contentDescription == other.contentDescription internal val description: String? get() = contentDescription ?: label companion object { /** Wide button that navigates deeper: label then a right arrow. */ - fun forward(key: String, label: String, onClick: () -> Unit, icon: SidekitIcon = SidekitIcon.ArrowRightAlt) = - SideAction(key, label, icon, iconAtEnd = true, motion = ActionMotion.Forward, onClick = onClick) + fun forward(key: String, label: String, onClick: () -> Unit, icon: SidekitIcon = SidekitIcon.ArrowRightAlt) = SideAction(key, label, icon, iconAtEnd = true, motion = ActionMotion.Forward, onClick = onClick) /** Wide button that returns: left arrow then label. */ - fun back(key: String, label: String, onClick: () -> Unit) = - SideAction(key, label, SidekitIcon.ArrowLeftAlt, motion = ActionMotion.Back, onClick = onClick) + fun back(key: String, label: String, onClick: () -> Unit) = SideAction(key, label, SidekitIcon.ArrowLeftAlt, motion = ActionMotion.Back, onClick = onClick) + + /** + * Commit: green, check at the end, rises from below. The screen names the act, the role + * owns the colour — saving looks the same in every sub-app. + */ + fun save(key: String, label: String = "Save", onClick: () -> Unit) = SideAction(key, label, SidekitIcon.Check, iconAtEnd = true, tone = ActionTone.Secondary, motion = ActionMotion.Up, onClick = onClick) + + /** Commit that leaves the screen: green, arrow at the end, enters from the right. */ + fun send(key: String, label: String = "Send", onClick: () -> Unit) = SideAction(key, label, SidekitIcon.ArrowRightAlt, iconAtEnd = true, tone = ActionTone.Secondary, motion = ActionMotion.Forward, onClick = onClick) + + /** + * Take it back: grey, no promise to advance. Cancel and Undo wear the same colour because + * they make the same promise — nothing happened. [icon] and [motion] default to the + * reversing pair (left arrow, enters from the left); a themed confirm passes + * `icon = null, motion = ActionMotion.Up` to stay grey while it dismisses in place. + */ + fun cancel( + key: String = "back", + label: String = "Cancel", + icon: SidekitIcon? = SidekitIcon.ArrowLeftAlt, + motion: ActionMotion = ActionMotion.Back, + contentDescription: String? = null, + onClick: () -> Unit + ) = SideAction(key, label, icon, tone = ActionTone.Muted, motion = motion, contentDescription = contentDescription, onClick = onClick) + + /** + * Take a delete back: grey, rises in place, and describes what it restores so the button + * reads as "Undo delete Barclays" rather than a bare "Undo". + */ + fun undo( + key: String = "undo", + label: String = "Undo", + contentDescription: String? = null, + onClick: () -> Unit + ) = SideAction(key, label, SidekitIcon.Undo, tone = ActionTone.Muted, motion = ActionMotion.Up, contentDescription = contentDescription, onClick = onClick) + + /** Destroy: red, wherever it appears (bar, confirm, Options). [icon] stays optional. */ + fun delete(key: String, label: String = "Delete", icon: SidekitIcon? = null, onClick: () -> Unit) = SideAction(key, label, icon, tone = ActionTone.Destructive, onClick = onClick) /** Square icon-only button. */ fun square( @@ -126,7 +160,7 @@ class SideAction( contentDescription: String, onClick: () -> Unit, tone: ActionTone = ActionTone.Primary, - motion: ActionMotion = ActionMotion.None, + motion: ActionMotion = ActionMotion.None ) = SideAction(key, icon = icon, tone = tone, square = true, motion = motion, contentDescription = contentDescription, onClick = onClick) } } @@ -139,7 +173,7 @@ class SideAction( class HeaderSpec( val title: String, val align: TextAlign = TextAlign.Start, - val modifier: Modifier = Modifier, + val modifier: Modifier = Modifier ) /** @@ -264,7 +298,7 @@ internal class ChromePublisher { opts.map { o -> SideAction( "options.${o.key}", o.label, o.icon, o.iconAtEnd, o.tone, o.weight, o.square, - ActionMotion.Up, o.contentDescription, dense = o.dense || opts.size > 2, artKey = o.artKey, art = o.art, + ActionMotion.Up, o.contentDescription, dense = o.dense || opts.size > 2, artKey = o.artKey, art = o.art ) { close() o.onClick() diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/action/UndoBar.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/action/UndoBar.kt new file mode 100644 index 00000000..c371b68b --- /dev/null +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/action/UndoBar.kt @@ -0,0 +1,15 @@ +package hrln.interactive.sidekit.design.action + +/** + * The bar while a delete can still be taken back. + * + * Undo is an offer, not a mode, so it never removes the thing the screen is for: the bar keeps two + * slots, the left one holds the continuation ([continueWith]) and the right one — which is empty + * until something can be undone — shows [undo] for as long as the window lasts. The undo button is + * only ever added, never substituted, and always lands on the same side. + * + * When a screen has both a way back and a primary action the primary wins the left slot, so the + * exit button steps aside for the window; the system back still leaves, and the exit returns with + * the primary. + */ +fun undoBar(continueWith: SideAction, undo: SideAction? = null): List = if (undo == null) listOf(continueWith) else listOf(continueWith, undo) diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/components/Item.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/components/Item.kt index f8bdb921..9c639a01 100644 --- a/sidekit/src/main/java/hrln/interactive/sidekit/design/components/Item.kt +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/components/Item.kt @@ -13,16 +13,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import hrln.interactive.sidekit.design.theme.contentColorFor import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.Dp import hrln.interactive.sidekit.design.icons.SidekitIcon import hrln.interactive.sidekit.design.theme.SidekitTheme +import hrln.interactive.sidekit.design.theme.contentColorFor /** Item "Colour Variant" from the Item component set (62:950). */ enum class ItemColour { Primary, Secondary, Destructive, Muted } @@ -46,7 +47,7 @@ fun SidekitTile( onClick: (() -> Unit)? = null, onClickLabel: String? = null, contentAlignment: Alignment = Alignment.Center, - content: @Composable BoxScope.() -> Unit, + content: @Composable BoxScope.() -> Unit ) { Box( modifier = modifier @@ -54,13 +55,16 @@ fun SidekitTile( .clip(SidekitTheme.shapes.item) .background(color) .then( - if (onClick != null) Modifier.clickable(onClickLabel = onClickLabel, role = Role.Button, onClick = onClick) - else Modifier, + if (onClick != null) { + Modifier.clickable(onClickLabel = onClickLabel, role = Role.Button, onClick = onClick) + } else { + Modifier + } ), contentAlignment = contentAlignment, content = { CompositionLocalProvider(LocalSidekitContentColor provides SidekitTheme.colors.contentColorFor(color)) { content() } - }, + } ) } @@ -73,31 +77,49 @@ fun SidekitItem( title: String, modifier: Modifier = Modifier, secondary: String? = null, + /** Put the secondary value at the row's trailing edge (toggles and compact state rows). */ + secondaryToEnd: Boolean = false, icon: SidekitIcon? = null, showLinkArrow: Boolean = false, + /** + * Keep [showLinkArrow] when [secondary] is present too. Off by default so rows that show a + * value keep their existing look; settings rows that open another page opt in. + */ + showLinkArrowWithSecondary: Boolean = false, colour: ItemColour = ItemColour.Primary, titleDecoration: TextDecoration? = null, onClick: (() -> Unit)? = null, - leading: (@Composable RowScope.() -> Unit)? = null, + leading: (@Composable RowScope.() -> Unit)? = null ) { SidekitTile( modifier = modifier.fillMaxWidth(), color = colour.container(), onClick = onClick, - contentAlignment = Alignment.CenterStart, + contentAlignment = Alignment.CenterStart ) { Row( modifier = Modifier.padding(horizontal = SidekitTheme.spacing.itemPadding), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(SidekitTheme.spacing.itemContentGap), + horizontalArrangement = Arrangement.spacedBy(SidekitTheme.spacing.itemContentGap) ) { leading?.invoke(this) if (icon != null) SidekitIconImage(icon) SidekitText(title, textDecoration = titleDecoration) if (secondary != null) { - SidekitText(secondary, Modifier.weight(1f), color = SidekitTheme.colors.surfaceA6) - } else if (showLinkArrow) { - Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { + SidekitText( + secondary, + Modifier.weight(1f), + color = SidekitTheme.colors.surfaceA6, + textAlign = if (secondaryToEnd) TextAlign.End else null + ) + } + // The arrow marks a row that opens another page. With a secondary value it must be + // requested explicitly, so existing rows keep the arrow off. + if (showLinkArrow && (secondary == null || showLinkArrowWithSecondary)) { + Box( + if (secondary == null) Modifier.weight(1f) else Modifier, + contentAlignment = Alignment.CenterEnd + ) { SidekitIconImage(SidekitIcon.ArrowRightAlt) } } @@ -114,7 +136,7 @@ fun SidekitIconItem( colour: ItemColour = ItemColour.Primary, color: Color = colour.container(), iconSize: Dp = SidekitTheme.spacing.iconSize, - onClick: (() -> Unit)? = null, + onClick: (() -> Unit)? = null ) { SidekitTile(modifier = modifier, color = color, onClick = onClick, onClickLabel = contentDescription) { SidekitIconImage(icon, size = iconSize, contentDescription = contentDescription) @@ -127,7 +149,7 @@ fun SidekitItemRow(modifier: Modifier = Modifier, content: @Composable RowScope. Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(SidekitTheme.spacing.itemOuterGap), - content = content, + content = content ) } @@ -137,7 +159,7 @@ fun SidekitCard( modifier: Modifier = Modifier, color: Color = SidekitTheme.colors.surfaceA3, onClick: (() -> Unit)? = null, - content: @Composable BoxScope.() -> Unit, + content: @Composable BoxScope.() -> Unit ) { Box( modifier = modifier @@ -147,6 +169,6 @@ fun SidekitCard( .padding(SidekitTheme.spacing.itemPadding), content = { CompositionLocalProvider(LocalSidekitContentColor provides SidekitTheme.colors.contentColorFor(color)) { content() } - }, + } ) } diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/components/SwipeReveal.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/components/SwipeReveal.kt index 31a023f1..05e916fd 100644 --- a/sidekit/src/main/java/hrln/interactive/sidekit/design/components/SwipeReveal.kt +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/components/SwipeReveal.kt @@ -24,9 +24,11 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.graphicsLayer @@ -42,15 +44,24 @@ import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import hrln.interactive.sidekit.design.icons.SidekitIcon import hrln.interactive.sidekit.design.motion.SidekitMotion import hrln.interactive.sidekit.design.theme.SidekitTheme +import kotlin.math.min import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import kotlin.math.min + +/** The action revealed by a left swipe. Delete is the default; other actions pass their own. */ +data class SwipeAction( + val label: String, + val icon: SidekitIcon, + val color: Color, + val armedColor: Color = color, + val destructive: Boolean = false, + val onClick: () -> Unit +) /** Keeps at most one row of a list swiped open, like iOS. */ @Stable @@ -72,11 +83,12 @@ private class SwipeMetrics { } /** - * A list row that swipes left to reveal a delete button (iOS style). + * A list row that swipes left to reveal an action button (iOS style). Delete is the default; + * pass [action] for a non-destructive action such as Add to queue. * - * - Swipe past half the button and release to leave it open; tap it to delete. + * - Swipe past half the button and release to leave it open; tap it to run the action. * - Keep dragging past [FULL_SWIPE_FRACTION] of the row and the button stretches to fill it - * (with a haptic tick); release there to delete without the extra tap. + * (with a haptic tick); release there to run the action without the extra tap. * - Tapping the row while open closes it instead of opening the item. * - Opening one row closes any other row in [group]. * @@ -88,10 +100,11 @@ private class SwipeMetrics { fun SidekitSwipeRow( key: Any, group: SwipeRevealGroup, - onDelete: () -> Unit, + onDelete: () -> Unit = {}, modifier: Modifier = Modifier, deleteLabel: String = "Delete", - content: @Composable () -> Unit, + action: SwipeAction? = null, + content: @Composable () -> Unit ) { val density = LocalDensity.current val colors = SidekitTheme.colors @@ -99,6 +112,14 @@ fun SidekitSwipeRow( val view = LocalView.current val scope = rememberCoroutineScope() + val swipeAction = action ?: SwipeAction( + label = deleteLabel, + icon = SidekitIcon.DeleteForever, + color = colors.dangerA1, + armedColor = colors.dangerA2, + destructive = true, + onClick = onDelete + ) val buttonPx = with(density) { spacing.itemHeight.toPx() } val gapPx = with(density) { spacing.itemOuterGap.toPx() } val radiusPx = with(density) { 36.dp.toPx() } @@ -122,9 +143,9 @@ fun SidekitSwipeRow( } } - fun delete() { + fun performAction() { group.closeAll() - onDelete() + swipeAction.onClick() } LaunchedEffect(armed) { @@ -132,7 +153,11 @@ fun SidekitSwipeRow( arm.animateTo(if (armed) 1f else 0f, SidekitMotion.settle) } LaunchedEffect(isOpen) { - if (isOpen) group.openKey = key else if (group.openKey == key && !dragging) group.openKey = null + if (isOpen) { + group.openKey = key + } else if (group.openKey == key && !dragging) { + group.openKey = null + } } LaunchedEffect(group, key) { snapshotFlow { group.openKey }.collect { open -> if (open != key && !dragging && offset != 0f) settle(0f) } @@ -149,7 +174,7 @@ fun SidekitSwipeRow( } } - val trash = rememberVectorPainter(ImageVector.vectorResource(SidekitIcon.DeleteForever.drawable)) + val actionIcon = rememberVectorPainter(ImageVector.vectorResource(swipeAction.icon.drawable)) val tint = remember(colors.light) { ColorFilter.tint(colors.light) } Box( @@ -162,18 +187,21 @@ fun SidekitSwipeRow( val left = size.width - revealed val r = min(radiusPx, min(revealed, size.height) / 2f) drawRoundRect( - color = lerp(colors.dangerA1, colors.dangerA2, a), + color = lerp(swipeAction.color, swipeAction.armedColor, a), topLeft = Offset(left, 0f), size = Size(revealed, size.height), - cornerRadius = CornerRadius(r, r), + cornerRadius = CornerRadius(r, r) ) // Icon sits centred in the button, then jumps to the leading edge once armed. - val cx = if (revealed <= buttonPx) left + revealed / 2f - else lerp(size.width - buttonPx / 2f, left + buttonPx / 2f, a) + val cx = if (revealed <= buttonPx) { + left + revealed / 2f + } else { + lerp(size.width - buttonPx / 2f, left + buttonPx / 2f, a) + } val iconAlpha = ((revealed - iconPx * 0.5f) / iconPx).coerceIn(0f, 1f) val s = iconPx * lerp(0.6f, 1f, iconAlpha) * (1f + 0.15f * a) translate(cx - s / 2f, (size.height - s) / 2f) { - with(trash) { draw(Size(s, s), alpha = iconAlpha, colorFilter = tint) } + with(actionIcon) { draw(Size(s, s), alpha = iconAlpha, colorFilter = tint) } } } .draggable( @@ -188,19 +216,26 @@ fun SidekitSwipeRow( dragging = false val width = metrics.width.toFloat() when { - armed -> settle(-width, velocity) { delete() } + armed -> settle(-width, velocity) { performAction() } + velocity < -FLING_VELOCITY || (offset < openOffset / 2f && velocity < FLING_VELOCITY) -> settle(openOffset, velocity) + else -> { settle(0f, velocity) if (group.openKey == key) group.closeAll() } } - }, + } ) .semantics { - customActions = listOf(CustomAccessibilityAction(deleteLabel) { delete(); true }) - }, + customActions = listOf( + CustomAccessibilityAction(swipeAction.label) { + performAction() + true + } + ) + } ) { Box(Modifier.graphicsLayer { translationX = offset }) { content() } if (isOpen) { @@ -209,7 +244,12 @@ fun SidekitSwipeRow( Modifier .matchParentSize() .graphicsLayer { translationX = offset } - .pointerInput(Unit) { detectTapGestures { settle(0f); group.closeAll() } }, + .pointerInput(Unit) { + detectTapGestures { + settle(0f) + group.closeAll() + } + } ) // matchParentSize: rows sit in a lazy list (unbounded height), so the tap target takes // the row's measured height rather than fillMaxHeight's (which would be 0). @@ -218,10 +258,12 @@ fun SidekitSwipeRow( Modifier .width(spacing.itemHeight) .fillMaxHeight() - .clickable(role = Role.Button, onClickLabel = deleteLabel, indication = null, interactionSource = null) { - view.performHapticFeedback(HapticFeedbackConstants.REJECT) - delete() - }, + .clickable(role = Role.Button, onClickLabel = swipeAction.label, indication = null, interactionSource = null) { + view.performHapticFeedback( + if (swipeAction.destructive) HapticFeedbackConstants.REJECT else HapticFeedbackConstants.CONTEXT_CLICK + ) + performAction() + } ) } } diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/icons/SidekitIcon.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/icons/SidekitIcon.kt index 7e5beb63..f325b027 100644 --- a/sidekit/src/main/java/hrln/interactive/sidekit/design/icons/SidekitIcon.kt +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/icons/SidekitIcon.kt @@ -22,6 +22,7 @@ enum class SidekitIcon(@DrawableRes val drawable: Int) { Bedtime(R.drawable.sk_ic_bedtime), Bluetooth(R.drawable.sk_ic_bluetooth), Bookmark(R.drawable.sk_ic_bookmark), + BookmarkBorder(R.drawable.sk_ic_bookmark_border), BrandAwareness(R.drawable.sk_ic_brand_awareness), BusMapPin(R.drawable.sk_ic_bus_map_pin), Calculate(R.drawable.sk_ic_calculate), @@ -68,5 +69,5 @@ enum class SidekitIcon(@DrawableRes val drawable: Int) { Undo(R.drawable.sk_ic_undo), Travel(R.drawable.sk_ic_travel), TravelExplore(R.drawable.sk_ic_travel_explore), - Videocam(R.drawable.sk_ic_videocam), + Videocam(R.drawable.sk_ic_videocam) } diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/theme/SidekitColors.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/theme/SidekitColors.kt index eb7e3a67..35f649d5 100644 --- a/sidekit/src/main/java/hrln/interactive/sidekit/design/theme/SidekitColors.kt +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/theme/SidekitColors.kt @@ -32,16 +32,26 @@ data class SidekitColors( val surfaceA5: Color = Color(0xFF717171), val surfaceA6: Color = Color(0xFF8B8B8B), val dark: Color = Color(0xFF000000), - val light: Color = Color(0xFFFFFFFF), -) + val light: Color = Color(0xFFFFFFFF) +) { + /** + * Content emphasis: the selected day, a progress fill, a route, your own message, a marker. + * + * Deliberately the same dark green a commit action wears, and deliberately *not* [primaryA1]: + * primary is the case colour and changes with the hardware, so content painted in it would + * change meaning per skin. Emphasis is fixed; only actions and the colour picker may use + * primary. + */ + val emphasisA1: Color get() = successA1 + val emphasisA2: Color get() = successA2 + val emphasisA3: Color get() = successA3 +} /** Luminance above which content on a fill should switch from white to black. */ private const val CONTENT_LUMINANCE = 0.3f /** Black or white, whichever reads against an arbitrary [background] (e.g. a colour swatch). */ -fun readableContentOn(background: Color): Color = - if (background.luminance() > CONTENT_LUMINANCE) Color(0xFF000000) else Color(0xFFFFFFFF) +fun readableContentOn(background: Color): Color = if (background.luminance() > CONTENT_LUMINANCE) Color(0xFF000000) else Color(0xFFFFFFFF) /** Keep labels readable when a light case colour replaces the default blue. */ -fun SidekitColors.contentColorFor(background: Color): Color = - if (background in listOf(primaryA1, primaryA2, primaryA3)) readableContentOn(background) else light +fun SidekitColors.contentColorFor(background: Color): Color = if (background in listOf(primaryA1, primaryA2, primaryA3)) readableContentOn(background) else light diff --git a/sidekit/src/main/res/drawable/sk_ic_bookmark_border.xml b/sidekit/src/main/res/drawable/sk_ic_bookmark_border.xml new file mode 100644 index 00000000..3a55e50e --- /dev/null +++ b/sidekit/src/main/res/drawable/sk_ic_bookmark_border.xml @@ -0,0 +1,7 @@ + + + + +