Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
4d38d9a
Give media playback one owner at a time
manuel-rtsch Nov 8, 2026
751beae
Move the Keystore cipher out of Banking
manuel-rtsch Nov 8, 2026
45e45ad
Report a missing native library instead of crashing
manuel-rtsch Nov 8, 2026
814a4ef
Format the files the seam work touched
manuel-rtsch Nov 8, 2026
73f030b
Cache remote artwork without adding an image library
manuel-rtsch Nov 8, 2026
432f986
Add the Sidetracks UI mockup
manuel-rtsch Nov 8, 2026
572756c
Document the Sidetracks mockup
manuel-rtsch Nov 8, 2026
64af6f3
Move the Sidetracks plan into the docs and record the reference policy
manuel-rtsch Nov 8, 2026
091a49f
Give Sidetracks a domain queue, history and catalog seam
manuel-rtsch Nov 8, 2026
b750e08
Add encrypted Spotify auth and a process-wide session
manuel-rtsch Nov 8, 2026
214143d
Add the playback gateway seam and session owner
manuel-rtsch Nov 8, 2026
0305d01
Keep the existing scope when Spotify omits it on refresh
manuel-rtsch Nov 8, 2026
b9e3dee
Build the librespot native bridge for Android
manuel-rtsch Nov 8, 2026
9c34242
Expose librespot through the Spotify gateway
manuel-rtsch Nov 8, 2026
1965bac
Drain librespot PCM into a streaming AudioTrack
manuel-rtsch Nov 8, 2026
634649b
Match the JNI declarations for init, load and seek
manuel-rtsch Nov 8, 2026
aa23246
Start Spotify playback on the main thread
manuel-rtsch Nov 8, 2026
630d5ba
Search Spotify and play real tracks
manuel-rtsch Nov 8, 2026
01fb5ba
feat(sidetracks): use authenticated catalog and harden playback
manuel-rtsch Nov 9, 2026
f2fa48b
feat(sidetracks): finish playback, library write-back and UI polish
manuel-rtsch Nov 9, 2026
5366feb
fix(sidetracks): restore shared design APIs and document merge follow…
manuel-rtsch Nov 9, 2026
1f93f29
fix(sidetracks): pin the NDK for the native build
manuel-rtsch Nov 9, 2026
4b786c9
docs(sidetracks): consolidate into one status and roadmap
manuel-rtsch Nov 9, 2026
ca07dff
fix(sidetracks): keep notification transport working without UI
manuel-rtsch Nov 9, 2026
2ec28b2
fix(sidetracks): open Now Playing from the media notification
manuel-rtsch Nov 9, 2026
7fbe9d6
fix(sidetracks): clear playback state on native stop
manuel-rtsch Nov 9, 2026
dd4c119
feat(sidetracks): persist queue and history and swipe to remove
manuel-rtsch Nov 9, 2026
66bde02
test(sidetracks): cover foreground service, media session, and unplug
manuel-rtsch Nov 9, 2026
171a6b2
feat(sidetracks): page catalog lists in small chunks
manuel-rtsch Nov 9, 2026
1bcb308
fix(sidetracks): load shows reliably and page search sections
manuel-rtsch Nov 9, 2026
70c32d6
refactor(sideplay): rename Sidetracks to Sideplay
manuel-rtsch Nov 9, 2026
0be9e42
feat(sideplay): settle parity ownership decisions
manuel-rtsch Nov 9, 2026
5934aeb
fix(build): skip the native task when the NDK is unavailable
manuel-rtsch Nov 9, 2026
10a4d61
fix(sideplay): address code-scanning lint findings
manuel-rtsch Nov 9, 2026
85f5fa1
fix(sidekit): keep link arrow opt-in when a row also has a secondary …
manuel-rtsch Nov 9, 2026
faf6c0c
fix(sideplay): rebuild a dead librespot session instead of failing ev…
manuel-rtsch Nov 9, 2026
95c1d0e
fix(sideplay): bound session reconnects and unblock teardown
manuel-rtsch Nov 9, 2026
0a4654c
fix(sidetracks): prevent duplicate lazy keys in Sideplay lists
manuel-rtsch Nov 10, 2026
9af6236
Merge branch 'feat/sidetracks' into pr/sideplay
manuel-rtsch Nov 10, 2026
90cd423
chore: keep screenshots out of the PR
manuel-rtsch Nov 10, 2026
7a59e7f
feat(sideplay): cache audio on disk and drop the streaming-only notice
manuel-rtsch Nov 10, 2026
6600024
feat(sideplay): add autoplay and tidy home controls
manuel-rtsch Nov 10, 2026
d99b2d7
fix(sideplay): inset the library label and move settings to options
manuel-rtsch Nov 10, 2026
0c416cf
fix(sideplay): make the library tile an emphasized icon
manuel-rtsch Nov 10, 2026
97cca02
chore(sideplay): refresh screenshots and apply formatting
manuel-rtsch Nov 10, 2026
916fe56
fix(sideplay): align now playing actions with playback
manuel-rtsch Nov 10, 2026
81837be
fix(sideplay): stabilise playback, seeking and now playing state
manuel-rtsch Nov 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,9 @@ build/

# Local
/Video

# Local clean-room reference (never committed)
/.sidespot/

# Rust build output
native/**/target/
48 changes: 48 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand All @@ -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<Exec>("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")
Expand Down
Binary file added app/screenshots/sideplay_album.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/screenshots/sideplay_artist.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/screenshots/sideplay_home.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/screenshots/sideplay_library_albums.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/screenshots/sideplay_now_playing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/screenshots/sideplay_queue.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/screenshots/sideplay_search.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/screenshots/sideplay_settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/screenshots/sideplay_sign_in.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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"
)
}
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,11 @@
android:name=".apps.music.MusicService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<!-- Sideplay uses its own notification slot so local Music and Spotify never share one. -->
<service
android:name=".apps.spotify.playback.SpotifyPlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<receiver android:name=".apps.health.StepRolloverReceiver" android:exported="false" />
</application>

Expand Down
11 changes: 7 additions & 4 deletions app/src/main/java/hrln/interactive/sidekit/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
}
}
Loading
Loading