From 7db7e02b419c0a3bcbe6f34d5142f38aa2a4cbe4 Mon Sep 17 00:00:00 2001 From: Gavyn Caldwell Date: Mon, 2 Nov 2026 09:32:06 -0700 Subject: [PATCH 1/3] refactor(playback): let the SDK own playback through LightAudioQueueSessions Drop Kelp's own ExoPlayer controller and the strict Media3 1.5.0 pins in app and server. The server module now consumes the SDK client streaming API: Kelp keeps TIDAL stream resolution (playbackinfopostpaywall, BTS/clear-DASH) and the similar-tracks continuation, while the SDK media service owns the player, queue, transitions, and background lifecycle. Requires the companion SDK fork that carries LightAudioQueueSessions. --- AGENTS.md | 8 +- README.md | 15 +- app/build.gradle.kts | 17 - docs/PRODUCT.md | 4 +- docs/TIDAL_ARCHITECTURE.md | 18 +- server/build.gradle.kts | 18 +- .../kelp/server/KelpPlayerController.kt | 464 ++++++------------ .../kelp/server/KelpServiceMethods.kt | 68 +-- .../kelp/server/TidalStreamResolver.kt | 38 +- 9 files changed, 225 insertions(+), 425 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 010526b..5bb5e60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,9 +17,9 @@ Distribution lane: **sideload-only, experimental/local.** Not eligible for Light - **`:app` never imports TIDAL SDK, okhttp, Media3, or `:server` types.** Everything crosses the Light binder via `KelpRemoteMethod` IDs (`com.loosewire.kelp.*`). Add a feature by extending `:protocol` contracts, dispatch in `KelpServiceMethods`, and a client method in `KelpClient.kt`. - **`:protocol` stays Android/TIDAL-free.** Serializable models only, no platform imports. - **Two TIDAL sessions** (`KelpRuntime`): developer-app auth3 login for the v2 Public API (catalog) + `TidalStreamingAuth` first-party PKCE for playback. Catalog endpoints only accept the developer token; full-length streams only resolve with the first-party token. Do not collapse them. -- **Playback is Kelp-owned**: `TidalStreamResolver` (private `playbackinfopostpaywall`, BTS/clear-DASH) + Media3 ExoPlayer in `KelpPlayerController`. The official TIDAL Player SDK was removed on purpose — do not reintroduce it. -- **`KelpPlayerController` threading**: queue state under `stateLock`; ExoPlayer calls only on the main thread via `onPlayer{}`; never call `onPlayer` (blocking post) while holding `stateLock`; playback liveness in `@Volatile` fields. -- **Media3 is strict-pinned to 1.5.0** in both `app` and `server` (Light SDK resolves a newer version). Keep both pins in sync. +- **The Light SDK owns playback** through `LightAudioQueueSessions` and its detached `MediaSessionService`. Kelp owns TIDAL stream resolution (`playbackinfopostpaywall`, BTS/clear-DASH) and catalog recommendations. Do not create a second player or reintroduce the official TIDAL Player SDK. +- **Playback threading**: the SDK queue is main-thread confined. TIDAL resolver callbacks use IO. Synchronous playback RPCs must stay off Main. Never hold `catalogLock` while waiting for the SDK. Playback commands and sign-out serialize under `playerLock`. +- **The SDK owns Media3 versions and decoder dependencies**, including DASH. The old app/server 1.5.0 pins are removed. Both Kelp modules exclude desktop Tink from their SDK-client dependency because authentication requires `tink-android`. - **`lighttool.toml` is the identity source** (app id, version, capabilities, `serverPackage = "com.loosewire.kelp"`). Do not hand-write what the plugin generates. - **Secrets**: `local.properties` holds the developer-app client id and is git-ignored. Never embed it. The first-party client id in `TidalStreamingAuth` is intentionally embedded (public, shared with orpheusdl/phono; rotation procedure in `docs/TIDAL_ARCHITECTURE.md` and README). @@ -34,6 +34,8 @@ scripts/build-release.sh # tests + minified release APK in dist/ (handl Verification for any change: the narrowest useful unit tests first (`:protocol:test`, `:app:testDebugUnitTest`, `:server:testDebugUnitTest`), then `:app:assembleDebug`; run `:app:assembleRelease` for anything touching server code (R8 path) — or just run `scripts/build-release.sh`. Install: `adb -s install -r app/build/outputs/apk/debug/app-debug.apk`; pick an explicit serial when devices are ambiguous. +Playback changes also require the companion SDK's `:sdk:client:testDebugUnitTest` and a device lifecycle check. Verify screen-off playback across track transitions after the Activity and controller are released. A stock emulator cannot prove LP3-specific power behavior. + ## CI / releases - `.github/workflows/build.yml`: tests + debug assemble on push/PR. CI checks out the companion `LooseWireDev/light-sdk` (branch `codex/kelp-official-sdk`) next to the repo; locally that checkout must sit at `../light-sdk` (override with `-Ptide.sdkPath=...`). diff --git a/README.md b/README.md index 2ed6df9..21f2769 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ ## What is it? -Kelp streams TIDAL the way you actually use it — your saved songs, albums, artists, playlists, daily mixes, and search — squeezed into a calm, text-first interface that fits a minimal phone. Under the hood it runs the Light SDK and plays real, full-length tracks through its own Media3/ExoPlayer pipeline, using the same proven technique as [phono](https://github.com/jonathancaudill/phono) and the other community clients (orpheusdl, streamrip). +Kelp provides saved songs, albums, artists, playlists, daily mixes, and search in a monochrome interface. Kelp resolves full-length TIDAL streams with first-party credentials, using the approach of [phono](https://github.com/jonathancaudill/phono). The Light SDK owns the audio player and its background service. **You need a paid TIDAL subscription.** Like phono, kelp never works around that and never will. @@ -41,11 +41,12 @@ Kelp is **sideload-only** today. It can't go through Light's hosted tool builder - Full catalog search with per-section paging - Queue with shuffle, repeat, seek, and "keep playing similar songs" continuous playback - LOSSLESS streaming by default (falls back to lower quality if needed) +- SDK-owned background playback, track transitions, and system media controls - Follows LightOS theming and hardware-button navigation ## Build it yourself -You'll need JDK 17, an Android SDK (platform 36), and the companion Light SDK checkout at `../light-sdk` (branch `codex/kelp-official-sdk`, based on upstream v0.1.1 plus kelp's small compatibility patches): +Builds require JDK 17, Android SDK platform 36, and the companion Light SDK fork at `../light-sdk`. The fork must include both the `codex/kelp-official-sdk` compatibility changes and the `LightAudioQueueSessions` streaming API. Kelp and its companion SDK changes must ship together. ```bash ./gradlew :app:assembleDebug @@ -62,6 +63,16 @@ You'll also want your own TIDAL developer credentials in `local.properties` (see CI runs tests + assemble on every push; pushing a matching `v*` tag publishes a signed release automatically. +### Background playback + +`detached-audio` declares the SDK media service and its required permissions. Kelp starts a service-owned streaming queue through `LightAudioQueueSessions`. The service resolves each track when needed and loads recommendations without a screen or controller. + +Closing Kelp disconnects the UI without stopping playback. Reopening Kelp reads the existing queue and position. Sign-out clears the queue and cancels pending playback work. An abandoned paused session stops after 60 seconds. Queue restoration after service or process destruction is not supported. + +The SDK owns Media3 and the DASH decoder dependency. Kelp no longer pins the older Media3 runtime used by its separate player. + +For device verification, play several tracks with the screen off for at least 15 minutes. Verify automatic transitions, system pause/resume and next controls, reconnection, and sign-out. JVM tests cannot establish LightOS power-management behavior. + ## Legal / disclaimers Unofficial project, unaffiliated with TIDAL or Light. A paid TIDAL subscription is required for everything to work. If TIDAL rotates their first-party client id (you'll notice as playback suddenly failing), the fix procedure is documented in [docs/TIDAL_ARCHITECTURE.md](docs/TIDAL_ARCHITECTURE.md). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bf76314..594e9b4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -87,23 +87,6 @@ dependencies { } implementation(libs.kotlinx.coroutines) - // Kelp's own ExoPlayer-backed TIDAL playback pins Media3 1.5.0; strict, - // allow-listed dependencies keep the merged APK on one Media3 runtime - // even though Light SDK depends on a newer version. - listOf( - "media3-common", - "media3-exoplayer", - "media3-session", - "media3-exoplayer-dash", - "media3-exoplayer-hls", - "media3-datasource-okhttp", - ).forEach { module -> - implementation("androidx.media3:$module") { - version { strictly("1.5.0") } - because("TIDAL Player 0.0.71 requires Media3 1.5.0 binary APIs") - } - } - testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 7716621..1fc742b 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -54,7 +54,7 @@ Future settings include download-over-Wi-Fi-only, storage usage, crossfade if th 1. **Shell:** authentication, five-icon catalog navigation, search entry, persistent local playback settings, and Light-native styling. 2. **Catalog:** album details and tracks, playlists, artists, saved tracks, and live search results. -3. **Player:** TIDAL stream resolution, Light detached-audio queue, Now Playing, queue, quality application, and media-state reconnection. +3. **Player:** TIDAL stream resolution through an SDK-owned detached queue, Now Playing, queue controls, and media-state reconnection. Quality application remains separate work. 4. **Offline:** downloads and storage management, only after online playback is reliable. -The current implementation includes the shell, paginated collection reads, artist/album/playlist details, grouped search, and basic online playback from every song list. Offline controls must only appear as active actions when their backing behavior is connected. +The current implementation includes the shell, paginated collection reads, artist/album/playlist details, grouped search, and SDK-owned online playback from song lists. The audio service owns background track transitions and continuous playback. Offline controls must only appear as active actions when their backing behavior is connected. diff --git a/docs/TIDAL_ARCHITECTURE.md b/docs/TIDAL_ARCHITECTURE.md index e2ae655..6c48a67 100644 --- a/docs/TIDAL_ARCHITECTURE.md +++ b/docs/TIDAL_ARCHITECTURE.md @@ -4,10 +4,10 @@ This document records the TIDAL Developer Platform constraints that shape Kelp. ## Boundaries -- The Light SDK owns the tool lifecycle, screen stack, system typography, monochrome UI primitives, LightOS icons, binder transport, and detached-audio capability declaration. +- The Light SDK owns the tool lifecycle, UI primitives, binder transport, and detached audio service. Its service owns the player, queue, media session, and playback jobs. - The official TIDAL Android Auth module owns developer-app OAuth PKCE credentials and refresh for catalog calls. Kelp requests credentials from its `CredentialsProvider` for API work; it never copies tokens into the app protocol or preferences. A second, phono-style first-party PKCE sign-in (`TidalStreamingAuth`) stores separate streaming tokens in a private SharedPreferences file on the server side only. - The official TIDAL API module owns generated catalog calls. Kelp maps generated JSON:API resource objects into small protocol-owned models before they cross the Light binder. A narrow Retrofit adapter covers only the current Search route while the latest Android release still carries the preceding generated shape. -- Kelp owns playback. Developer-app tokens only resolve 30-second previews, so Kelp resolves streams just-in-time against the private `playbackinfopostpaywall` endpoint with first-party credentials (phono's recipe) and plays them with Media3 ExoPlayer. The official TIDAL Player module is no longer used. +- Kelp resolves full-length streams through the private `playbackinfopostpaywall` endpoint with first-party credentials. Developer-app tokens only resolve previews. The SDK plays these sources. The official TIDAL Player module is not used. - The app module never imports TIDAL SDK types. The merged server module is the integration boundary and exposes typed, versioned Kelp RPC methods. ## Public API map @@ -36,13 +36,19 @@ The public API does not expose a third-party playback-history resource, and `pla ## Playback and quality -Playback is Kelp-owned end to end, following phono (github.com/jonathancaudill/phono; also orpheusdl/python-tidal). Stream entitlement is keyed on the OAuth client: tokens issued to registered developer apps only resolve 30-second previews, so Kelp performs a second PKCE sign-in with TIDAL's first-party Android client id (the clear hi-res tier; no Widevine CDM needed). The login screen chains both sign-ins on one WebView — the developer-app login for the catalog, then the first-party login for playback — and shared cookies usually carry the session into step two without re-entry. +Stream entitlement depends on the OAuth client. Developer-app tokens only resolve 30-second previews. Kelp therefore uses a second PKCE sign-in with TIDAL's first-party Android client ID. This client supports clear streams without a Widevine CDM. The login screen chains catalog and playback sign-ins in one WebView. -`TidalStreamResolver` resolves each track just-in-time: base64 BTS manifests yield progressive signed URLs; clear DASH manifests are sanitized (Media3 crashes on TIDAL's non-numeric `AdaptationSet group="main"`) and written to a temp MPD. ExoPlayer plays the result; the queue, shuffle/repeat, and continuous playback remain Kelp queue policy in `KelpPlayerController`. +`TidalStreamResolver` resolves each track when the SDK requests it. BTS manifests provide signed progressive URLs. Clear DASH manifests become local MPD files after invalid `group="main"` attributes are removed. Kelp supplies the file and its MIME type to the SDK. The SDK provides the DASH decoder. -The Light SDK's detached-audio capability remains declared so LightOS can support the tool's background audio lifecycle. +`KelpPlayerController` is a server-side adapter to `LightAudioQueueSessions`, not an audio player. The SDK service owns resolution jobs, shuffle, repeat, seek, source-error recovery, and queue transitions. A continuation callback asks Kelp's catalog for similar tracks. Neither callback depends on a screen ViewModel. -Continuous playback is queue policy, not a looping toggle. When enabled and the chosen queue approaches its end, Kelp requests `similarTracks` for the last seed track, removes duplicates and explicit tracks when required by settings, then appends a bounded page to the queue. +The SDK publishes the logical queue and track metadata through a platform media session. Temporary controllers release after commands, but the started foreground service retains playback. During source resolution, the session reports buffering and holds a partial wake lock. ExoPlayer manages playback wake locks and audio focus. + +The `detached-audio` capability generates the media service, foreground-service permissions, and wake-lock permission. The declaration alone does not attach another player to that service. + +Sign-out stops the SDK queue before Kelp clears either authentication session. Generation checks reject stale resolution and recommendation results. Reopening the UI reads the live SDK snapshot. An abandoned paused service stops after 60 seconds. Queue state does not survive service or process destruction. + +Continuous playback is queue policy, not a looping toggle. At the end of the chosen queue, the SDK asks Kelp for `similarTracks` based on the last seed track. Duplicate tracks are removed before the returned page extends the queue. ## Offline downloads diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 9881c9c..ee145f6 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -89,6 +89,11 @@ kotlin { dependencies { implementation(project(":protocol")) implementation(libs.sdk.server) + implementation(libs.sdk.client) { + exclude(group = "com.google.mlkit") + exclude(group = "androidx.camera") + exclude(group = "com.google.crypto.tink", module = "tink") + } implementation(libs.compose.activity) implementation(libs.androidx.lifecycle.runtime) implementation(libs.kotlinx.coroutines) @@ -101,18 +106,5 @@ dependencies { exclude(group = "com.google.crypto.tink", module = "tink-android") } implementation("com.squareup.okhttp3:okhttp:4.12.0") - // Playback runs through Kelp's own first-party playbackinfo resolution + - // Media3 ExoPlayer (phono's recipe): the official TIDAL Player module only - // plays 30-second previews for unapproved developer apps. - listOf( - "media3-common", - "media3-exoplayer", - "media3-exoplayer-dash", - ).forEach { module -> - implementation("androidx.media3:$module") { - version { strictly("1.5.0") } - because("Match the Media3 1.5.0 runtime pinned by the app module") - } - } testImplementation(libs.kotlin.test) } diff --git a/server/src/main/kotlin/com/loosewire/kelp/server/KelpPlayerController.kt b/server/src/main/kotlin/com/loosewire/kelp/server/KelpPlayerController.kt index 8cc7582..54c0309 100644 --- a/server/src/main/kotlin/com/loosewire/kelp/server/KelpPlayerController.kt +++ b/server/src/main/kotlin/com/loosewire/kelp/server/KelpPlayerController.kt @@ -1,358 +1,182 @@ package com.loosewire.kelp.server -import android.annotation.SuppressLint import android.content.Context -import android.os.Handler import android.os.Looper -import androidx.media3.common.AudioAttributes -import androidx.media3.common.C -import androidx.media3.common.PlaybackException -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.DefaultLoadControl -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import com.loosewire.kelp.protocol.PlaybackSnapshot import com.loosewire.kelp.protocol.PlayerCommand import com.loosewire.kelp.protocol.RepeatMode import com.loosewire.kelp.protocol.StartPlaybackRequest import com.loosewire.kelp.protocol.TrackSummary -import java.util.concurrent.CompletableFuture -import java.util.concurrent.TimeUnit -import java.util.logging.Logger -import kotlinx.coroutines.CoroutineScope +import com.thelightphone.sdk.audio.LightAudioQueueCommand +import com.thelightphone.sdk.audio.LightAudioQueueItem +import com.thelightphone.sdk.audio.LightAudioQueueResolver +import com.thelightphone.sdk.audio.LightAudioQueueSessions +import com.thelightphone.sdk.audio.LightAudioQueueSnapshot +import com.thelightphone.sdk.audio.LightAudioRepeatMode +import com.thelightphone.sdk.audio.LightAudioResolvedSource +import com.thelightphone.sdk.audio.LightAudioSource +import com.thelightphone.sdk.audio.LightMediaMetadata +import java.io.File +import java.util.WeakHashMap +import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout /** - * Queue and playback state for Kelp. Streams resolve via [TidalStreamResolver] - * (first-party playbackinfo) and play through a Media3 [ExoPlayer] — Kelp owns - * the player because the official TIDAL Player module only plays 30-second - * previews for unapproved developer apps (phono runs the same recipe). - * - * Threading: binder RPCs arrive on arbitrary threads. Queue/queue-index state - * lives under [stateLock]; ExoPlayer calls are marshaled to the main thread - * via [onPlayer], whose blocking-post must never run while [stateLock] is - * held (the main-thread block briefly takes it itself). Playback liveness - * (isPlaying/position/duration) lives in @Volatile fields maintained by the - * player listener and a main-thread ticker. + * Server-only TIDAL adapter. The SDK service owns the queue, player and resolution jobs; + * this controller can disappear with an RPC client without interrupting playback. + * Synchronous mutations are binder-thread entry points, never main-thread waits. */ -@UnstableApi -@SuppressLint("UnsafeOptInUsageError") -internal class KelpPlayerController( - context: Context, - streamingAuth: TidalStreamingAuth, - private val continuousLoader: suspend (TrackSummary) -> List, -) { +internal class KelpPlayerController(context: Context) { private val appContext = context.applicationContext - private val resolver = TidalStreamResolver(streamingAuth) - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private val mainHandler = Handler(Looper.getMainLooper()) - private val logger = Logger.getLogger(KelpPlayerController::class.java.name) - @Volatile - private var player: ExoPlayer? = null - - private val stateLock = Any() - private var queue = emptyList() - private var currentIndex = -1 - private var sourceName: String? = null - private var shuffle = false - private var repeatMode = RepeatMode.Off - private var continuousPlayback = true - private var loadGeneration = 0 - private var rescuedGeneration = 0 - - @Volatile - private var isPlaying = false - - @Volatile - private var positionMs = 0L - - @Volatile - private var durationMs = 0L - - private val playerListener = object : Player.Listener { - override fun onPlaybackStateChanged(playbackState: Int) { - when (playbackState) { - // REPEAT_MODE_ONE loops inside ExoPlayer, so ENDED means "queue step". - Player.STATE_ENDED -> scope.launch { advance() } - Player.STATE_IDLE -> isPlaying = false - else -> Unit - } - } - - override fun onIsPlayingChanged(playing: Boolean) { - isPlaying = playing - } - - override fun onPlayerError(error: PlaybackException) { - val detail = (error.message ?: error.javaClass.simpleName).take(200) - logger.warning("TIDAL playback error ${error.errorCodeName}: $detail") - val step = synchronized(stateLock) { - queue.getOrNull(currentIndex)?.let { it to loadGeneration } - } ?: return - if (rescuedGeneration == step.second) { - isPlaying = false - return - } - // Signed CDN URLs expire; a stale resolve is the common failure, - // so re-resolve the current track once before giving up. - rescuedGeneration = step.second - beginLoad(step.first, step.second, play = true) - } - } - - /** Keeps snapshot positions moving between player callbacks. */ - private val positionTicker = object : Runnable { - override fun run() { - try { - player?.let { p -> - if (p.playbackState == Player.STATE_READY || p.playbackState == Player.STATE_BUFFERING) { - positionMs = p.currentPosition.coerceAtLeast(0L) - p.duration.takeIf { it != C.TIME_UNSET && it > 0 }?.let { durationMs = it } - } - } - } finally { - mainHandler.postDelayed(this, POSITION_POLL_MS) - } - } - } - - // --- public API (binder threads) ------------------------------------------ - - fun snapshot(): PlaybackSnapshot = synchronized(stateLock) { snapshotLocked() } + fun snapshot(): PlaybackSnapshot = LightAudioQueueSessions.state.value.toPlaybackSnapshot() fun start(request: StartPlaybackRequest): PlaybackSnapshot { require(request.tracks.isNotEmpty()) { "Playback needs at least one song" } require(request.startIndex in request.tracks.indices) { "Invalid starting song" } - val step = synchronized(stateLock) { - queue = request.tracks.distinctBy { it.id } - val requestedId = request.tracks[request.startIndex].id - currentIndex = queue.indexOfFirst { it.id == requestedId }.coerceAtLeast(0) - sourceName = request.sourceName - continuousPlayback = request.continuousPlayback - isPlaying = true - val generation = ++loadGeneration - queue[currentIndex] to generation - } - beginLoad(step.first, step.second, play = true) - return snapshot() - } - - suspend fun control(command: PlayerCommand, positionMs: Long? = null): PlaybackSnapshot { - when (command) { - PlayerCommand.TogglePlayPause -> togglePlayPause() - PlayerCommand.Previous -> previous() - PlayerCommand.Next -> advance() - PlayerCommand.ToggleShuffle -> toggleShuffle() - PlayerCommand.CycleRepeat -> cycleRepeat() - PlayerCommand.Seek -> seek(positionMs) + requireBinderThread() + val tracks = request.tracks.distinctBy(TrackSummary::id) + val requestedId = request.tracks[request.startIndex].id + val items = queueItems(tracks, nextGeneration.incrementAndGet().toString()) + return runBlocking { + withTimeout(PLAYER_TIMEOUT_MS) { + LightAudioQueueSessions.start( + context = appContext, + items = items, + startIndex = tracks.indexOfFirst { it.id == requestedId }, + sourceName = request.sourceName, + continuousPlayback = request.continuousPlayback, + ).toPlaybackSnapshot() + } } - return snapshot() } - /** Stop playback and release the ExoPlayer instance (sign out / full reset). */ + suspend fun control(command: PlayerCommand, positionMs: Long? = null): PlaybackSnapshot = + LightAudioQueueSessions.control( + context = appContext, + command = when (command) { + PlayerCommand.TogglePlayPause -> LightAudioQueueCommand.TogglePlayPause + PlayerCommand.Previous -> LightAudioQueueCommand.Previous + PlayerCommand.Next -> LightAudioQueueCommand.Next + PlayerCommand.ToggleShuffle -> LightAudioQueueCommand.ToggleShuffle + PlayerCommand.CycleRepeat -> LightAudioQueueCommand.CycleRepeat + PlayerCommand.Seek -> LightAudioQueueCommand.Seek + }, + positionMs = positionMs, + ).toPlaybackSnapshot() + + /** Sign-out stops the service and cancels resolution before credentials are cleared. */ fun release() { - synchronized(stateLock) { - loadGeneration += 1 - queue = emptyList() - currentIndex = -1 - sourceName = null - isPlaying = false - positionMs = 0L - durationMs = 0L - } - mainHandler.post { - mainHandler.removeCallbacks(positionTicker) - player?.release() - player = null - } - } - - // --- commands ---------------------------------------------------------------- - - private fun togglePlayPause() { - onPlayer { p -> - if (p.isPlaying) p.pause() else if (p.playbackState != Player.STATE_IDLE) p.play() - } + requireBinderThread() + runBlocking { LightAudioQueueSessions.stop() } } - private fun previous() { - val restartPositionMs = onPlayer { it.currentPosition } - val step = synchronized(stateLock) { - if (queue.isEmpty()) return@synchronized null - if (restartPositionMs > PreviousRestartThresholdMs || currentIndex <= 0) { - null - } else { - currentIndex -= 1 - val generation = ++loadGeneration - queue[currentIndex] to generation + companion object { + private const val PLAYER_TIMEOUT_MS = 10_000L + private val nextGeneration = AtomicLong() + private val tracksLock = Any() + + // The SDK retains these exact items for snapshots and resolver callbacks. Weak keys + // retain metadata while any old snapshot is in flight, not for the process lifetime. + // Generation-qualified ids keep replacement queues with the same track ids distinct. + private val tracksByItem = WeakHashMap() + + fun initialize( + context: Context, + continuousLoader: suspend (TrackSummary) -> List, + ) { + val appContext = context.applicationContext + val streams = TidalStreamResolver { + checkNotNull(KelpRuntime.streamingAuth()) { "TIDAL playback session is unavailable" } } - } - if (step == null) { - seek(0L) - } else { - beginLoad(step.first, step.second, play = true) - } - } - - private suspend fun advance() { - val needsMore = synchronized(stateLock) { - queue.isNotEmpty() && currentIndex >= queue.lastIndex && - repeatMode == RepeatMode.Off && continuousPlayback - } - if (needsMore) appendContinuousTracks() - - val step = synchronized(stateLock) { - if (queue.isEmpty()) return@synchronized null - when { - currentIndex < queue.lastIndex -> currentIndex += 1 - repeatMode == RepeatMode.All -> currentIndex = 0 - repeatMode == RepeatMode.One -> Unit - else -> { - isPlaying = false - return@synchronized null - } - } - val generation = ++loadGeneration - queue[currentIndex] to generation - } - if (step == null) { - onPlayer { it.pause() } - return - } - isPlaying = true - beginLoad(step.first, step.second, play = true) - } - - private suspend fun appendContinuousTracks() { - val seed = synchronized(stateLock) { queue.getOrNull(currentIndex) } ?: return - val additions = continuousLoader(seed) - synchronized(stateLock) { - val existing = queue.asSequence().map(TrackSummary::id).toHashSet() - queue = queue + additions.filter { existing.add(it.id) } - } - } - - private fun seek(positionMs: Long?) { - val targetMs = positionMs?.coerceAtLeast(0L) ?: return - val duration = durationMs - if (duration <= 0L) return - val clamped = targetMs.coerceAtMost(duration) - onPlayer { p -> - if (p.playbackState != Player.STATE_IDLE) p.seekTo(clamped) - } - this.positionMs = clamped - } + LightAudioQueueSessions.configure(object : LightAudioQueueResolver { + override suspend fun resolve(item: LightAudioQueueItem): LightAudioResolvedSource = + withContext(Dispatchers.IO) { + val track = trackFor(item) + RecentTracksStore.record(track) + when (val resolved = streams.resolve(track.id)) { + is TidalStreamResolver.ResolvedStream.Progressive -> + LightAudioResolvedSource(LightAudioSource.UrlSource(resolved.url)) + is TidalStreamResolver.ResolvedStream.ClearDash -> { + val directory = File(appContext.cacheDir, "tidal_mpd").apply { mkdirs() } + val file = File( + directory, + "${item.id.substringBefore(':')}_${track.id}_${resolved.audioQuality}.mpd", + ) + file.writeText(resolved.mpdXml) + LightAudioResolvedSource( + LightAudioSource.FileSource(file), + mimeType = "application/dash+xml", + ) + } + } + } - private fun toggleShuffle() = synchronized(stateLock) { - val current = queue.getOrNull(currentIndex) ?: return@synchronized - shuffle = !shuffle - if (shuffle) { - queue = listOf(current) + queue.filterNot { it.id == current.id }.shuffled() - currentIndex = 0 + override suspend fun continuation(seed: LightAudioQueueItem): List = + withContext(Dispatchers.IO) { + val additions = continuousLoader(trackFor(seed)) + val currentItems = LightAudioQueueSessions.state.value.items + if (currentItems.none { it.id == seed.id }) return@withContext emptyList() + val generation = seed.id.substringBefore(':') + val existing = currentItems.mapTo(HashSet()) { it.id } + // Never replace a live weak key with an equal recommendation key. + // The engine also deduplicates; filter here to preserve its metadata. + queueItems( + additions.filter { existing.add("$generation:${it.id}") }, + generation, + ) + } + }) } - } - private fun cycleRepeat() { - val next = synchronized(stateLock) { - repeatMode = when (repeatMode) { - RepeatMode.Off -> RepeatMode.All - RepeatMode.All -> RepeatMode.One - RepeatMode.One -> RepeatMode.Off + fun requireBinderThread() { + check(Looper.myLooper() != Looper.getMainLooper()) { + "Synchronous playback RPCs must run off the main thread" } - repeatMode } - onPlayer { p -> - p.repeatMode = if (next == RepeatMode.One) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF - } - } - // --- loading ------------------------------------------------------------------ - - /** - * Resolve [track] on IO, then install it on the player on the main thread. - * A stale [generation] (user already moved on) short-circuits both phases. - */ - private fun beginLoad(track: TrackSummary, generation: Int, play: Boolean) { - RecentTracksStore.record(track) - scope.launch { - val mediaItem = try { - resolver.resolveMediaItem(appContext, track) - } catch (error: Exception) { - logger.warning( - "TIDAL stream resolve failed for ${track.id}: ${error.javaClass.simpleName}: ${error.message?.take(200)}", - ) - synchronized(stateLock) { - if (generation == loadGeneration) isPlaying = false + private fun queueItems(tracks: List, generation: String): List = + synchronized(tracksLock) { + tracks.map { track -> + LightAudioQueueItem( + id = "$generation:${track.id}", + metadata = LightMediaMetadata( + title = track.title, + artist = track.artistName, + album = track.albumTitle, + durationMs = track.durationMs, + ), + ).also { tracksByItem[it] = track } } - return@launch - } - if (synchronized(stateLock) { generation != loadGeneration }) return@launch - onPlayer { p -> - if (synchronized(stateLock) { generation != loadGeneration }) return@onPlayer - positionMs = 0L - durationMs = track.durationMs - p.setMediaItem(mediaItem) - p.prepare() - if (play) p.play() else p.pause() } - } - } - // --- ExoPlayer main-thread bridge ---------------------------------------------- - - private fun buildPlayer(): ExoPlayer = ExoPlayer.Builder(appContext) - .setMediaSourceFactory(DefaultMediaSourceFactory(appContext)) - .setLoadControl( - DefaultLoadControl.Builder() - .setBufferDurationsMs( - /* minBufferMs = */ 15_000, - /* maxBufferMs = */ 90_000, - /* bufferForPlaybackMs = */ 2_500, - /* bufferForPlaybackAfterRebufferMs = */ 5_000, - ) - .build(), - ) - .setAudioAttributes(AudioAttributes.DEFAULT, /* handleAudioFocus = */ true) - .setHandleAudioBecomingNoisy(true) - .build() - .also { - it.addListener(playerListener) - mainHandler.postDelayed(positionTicker, POSITION_POLL_MS) - } - - /** Run [block] on the main thread against the (lazily built) player. */ - private fun onPlayer(block: (ExoPlayer) -> T): T { - if (Looper.myLooper() == Looper.getMainLooper()) { - return block(player ?: buildPlayer().also { player = it }) - } - val future = CompletableFuture>() - mainHandler.post { - future.complete(runCatching { block(player ?: buildPlayer().also { player = it }) }) + private fun trackFor(item: LightAudioQueueItem): TrackSummary = synchronized(tracksLock) { + checkNotNull(tracksByItem[item]) { "TIDAL queue metadata is unavailable" } + } + + private fun LightAudioQueueSnapshot.toPlaybackSnapshot(): PlaybackSnapshot { + // The protocol has no playback-error field. Surface an RPC error instead of + // returning the last playing snapshot after a resolver or service failure. + check(error == null) { "SDK audio playback failed: ${error?.diagnostic}" } + val tracks = items.map(::trackFor) + return PlaybackSnapshot( + current = tracks.getOrNull(currentIndex), + sourceName = sourceName, + queue = tracks, + currentIndex = currentIndex, + positionMs = positionMs, + durationMs = durationMs, + isPlaying = isPlaying, + shuffle = shuffle, + repeatMode = when (repeatMode) { + LightAudioRepeatMode.Off -> RepeatMode.Off + LightAudioRepeatMode.All -> RepeatMode.All + LightAudioRepeatMode.One -> RepeatMode.One + }, + ) } - return future.get(ON_PLAYER_TIMEOUT_MS, TimeUnit.MILLISECONDS).getOrThrow() - } - - // --- snapshot -------------------------------------------------------------------- - - private fun snapshotLocked(): PlaybackSnapshot = PlaybackSnapshot( - current = queue.getOrNull(currentIndex), - sourceName = sourceName, - queue = queue, - currentIndex = currentIndex, - positionMs = positionMs, - durationMs = durationMs, - isPlaying = isPlaying, - shuffle = shuffle, - repeatMode = repeatMode, - ) - - private companion object { - const val ON_PLAYER_TIMEOUT_MS = 5_000L - const val POSITION_POLL_MS = 500L - const val PreviousRestartThresholdMs = 5_000L } } diff --git a/server/src/main/kotlin/com/loosewire/kelp/server/KelpServiceMethods.kt b/server/src/main/kotlin/com/loosewire/kelp/server/KelpServiceMethods.kt index ceef866..8328ef5 100644 --- a/server/src/main/kotlin/com/loosewire/kelp/server/KelpServiceMethods.kt +++ b/server/src/main/kotlin/com/loosewire/kelp/server/KelpServiceMethods.kt @@ -30,6 +30,7 @@ object KelpServiceMethods { private var loginActivityComponent = "com.loosewire.kelp/.server.LoginActivity" private lateinit var applicationContext: Context private val catalogLock = Any() + private val playerLock = Any() @Volatile private var catalog: Catalog? = null @Volatile @@ -38,6 +39,9 @@ object KelpServiceMethods { fun initialize(context: Context) { applicationContext = context.applicationContext RecentTracksStore.initialize(applicationContext) + KelpPlayerController.initialize(applicationContext) { track -> + currentCatalog()?.similarTracks(track).orEmpty() + } loginActivityComponent = ComponentName( context.applicationContext, LoginActivity::class.java, @@ -65,11 +69,12 @@ object KelpServiceMethods { ) KelpRemoteMethod.Logout.id -> { - runBlocking { KelpRuntime.logout() } - synchronized(catalogLock) { + KelpPlayerController.requireBinderThread() + synchronized(playerLock) { playerController?.release() + runBlocking { KelpRuntime.logout() } playerController = null - catalog = null + synchronized(catalogLock) { catalog = null } } LightResult.Success( KelpRemoteMethod.Logout.encodeResponse(KelpRuntime.currentAuthSnapshot()), @@ -229,16 +234,17 @@ object KelpServiceMethods { private fun handleStartPlayback(request: StartPlaybackRequest): LightResult = withPlayer { controller -> - runCatching { controller.start(request) }.fold( - onSuccess = { LightResult.Success(KelpRemoteMethod.StartPlayback.encodeResponse(it)) }, - onFailure = { - failure( - KelpErrorCategory.Protocol, - "Choose a valid song to start playback.", - LightResult.ErrorCode.InvalidParameters, - ) - }, - ) + if (request.tracks.isEmpty() || request.startIndex !in request.tracks.indices) { + failure( + KelpErrorCategory.Protocol, + "Choose a valid song to start playback.", + LightResult.ErrorCode.InvalidParameters, + ) + } else { + LightResult.Success( + KelpRemoteMethod.StartPlayback.encodeResponse(controller.start(request)), + ) + } } private fun handlePlayerCommand(request: PlayerCommandRequest): LightResult = @@ -254,19 +260,22 @@ object KelpServiceMethods { private fun withPlayer( block: (KelpPlayerController) -> LightResult, ): LightResult { - val controller = currentPlayer() ?: return failure( - KelpErrorCategory.Unavailable, - "The TIDAL player is not available yet.", - ) - return try { - block(controller) - } catch (_: TimeoutCancellationException) { - failure(KelpErrorCategory.Timeout, "The player took too long to respond. Please retry.") - } catch (error: TidalCatalogException) { - failure(error.category, error.safeMessage) - } catch (error: Exception) { - logger.warning("TIDAL player request failed: ${error.javaClass.simpleName}") - failure(KelpErrorCategory.Unavailable, "The TIDAL player could not complete that action.") + KelpPlayerController.requireBinderThread() + return synchronized(playerLock) { + val controller = currentPlayer() ?: return failure( + KelpErrorCategory.Unavailable, + "The TIDAL player is not available yet.", + ) + try { + block(controller) + } catch (_: TimeoutCancellationException) { + failure(KelpErrorCategory.Timeout, "The player took too long to respond. Please retry.") + } catch (error: TidalCatalogException) { + failure(error.category, error.safeMessage) + } catch (error: Exception) { + logger.warning("TIDAL player request failed: ${error.javaClass.simpleName}") + failure(KelpErrorCategory.Unavailable, "The TIDAL player could not complete that action.") + } } } @@ -323,12 +332,7 @@ object KelpServiceMethods { private fun currentPlayer(): KelpPlayerController? { playerController?.let { return it } if (!::applicationContext.isInitialized) return null - val streamingAuth = KelpRuntime.streamingAuth() ?: return null - return synchronized(catalogLock) { - playerController ?: KelpPlayerController(applicationContext, streamingAuth) { track -> - currentCatalog()?.similarTracks(track).orEmpty() - }.also { playerController = it } - } + return KelpPlayerController(applicationContext).also { playerController = it } } private fun failure( diff --git a/server/src/main/kotlin/com/loosewire/kelp/server/TidalStreamResolver.kt b/server/src/main/kotlin/com/loosewire/kelp/server/TidalStreamResolver.kt index 5706cf9..eb98a71 100644 --- a/server/src/main/kotlin/com/loosewire/kelp/server/TidalStreamResolver.kt +++ b/server/src/main/kotlin/com/loosewire/kelp/server/TidalStreamResolver.kt @@ -1,11 +1,5 @@ package com.loosewire.kelp.server -import android.content.Context -import android.net.Uri -import androidx.media3.common.MediaItem -import androidx.media3.common.MimeTypes -import com.loosewire.kelp.protocol.TrackSummary -import java.io.File import java.io.IOException import java.util.concurrent.TimeUnit import kotlinx.serialization.Serializable @@ -21,13 +15,12 @@ import okhttp3.Request * * Responses carry a base64 manifest: either BTS (progressive signed URLs) * or clear DASH. Clear DASH MPDs use `group="main"` on AdaptationSets, which - * crashes Media3's DashManifestParser (spec wants unsigned int) — the - * attribute is stripped before the MPD is handed to ExoPlayer, the same - * treatment high-kelp and mopidy-tidal give it. + * violates the DASH schema (the attribute must be an unsigned integer). + * Strip it before handing the MPD to the SDK, as high-kelp and mopidy-tidal do. * * All methods block; call from a background thread. */ -internal class TidalStreamResolver(private val auth: TidalStreamingAuth) { +internal class TidalStreamResolver(private val auth: () -> TidalStreamingAuth) { private val json = Json { ignoreUnknownKeys = true; isLenient = true } private val client = OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) @@ -48,27 +41,12 @@ internal class TidalStreamResolver(private val auth: TidalStreamingAuth) { ) : ResolvedStream } - fun resolveMediaItem(context: Context, track: TrackSummary): MediaItem = - when (val resolved = resolve(track.id)) { - is ResolvedStream.Progressive -> MediaItem.Builder() - .setUri(resolved.url) - .build() - is ResolvedStream.ClearDash -> { - val dir = File(context.cacheDir, "tidal_mpd").apply { mkdirs() } - val file = File(dir, "${track.id}_${resolved.audioQuality}.mpd") - file.writeText(resolved.mpdXml) - MediaItem.Builder() - .setUri(Uri.fromFile(file)) - .setMimeType(MimeTypes.APPLICATION_MPD) - .build() - } - } - fun resolve(trackId: String): ResolvedStream { + val auth = auth() var lastError: Exception? = null for (quality in QUALITY_FALLBACK_LADDER) { try { - val info = playbackInfo(trackId, quality) + val info = playbackInfo(trackId, quality, auth) val mime = info.manifestMimeType val decoded = String(java.util.Base64.getDecoder().decode(info.manifest.trim())) when { @@ -102,7 +80,7 @@ internal class TidalStreamResolver(private val auth: TidalStreamingAuth) { throw lastError ?: IOException("TIDAL stream resolution failed") } - private fun playbackInfo(trackId: String, quality: String): PlaybackInfo { + private fun playbackInfo(trackId: String, quality: String, auth: TidalStreamingAuth): PlaybackInfo { var refreshRetried = false while (true) { val url = "https://api.tidal.com/v1/tracks/$trackId/playbackinfopostpaywall" @@ -139,8 +117,8 @@ internal class TidalStreamResolver(private val auth: TidalStreamingAuth) { } /** - * Strip non-numeric `group="main"` AdaptationSet attributes Media3 cannot - * parse, plus inert ContentProtection stubs on clear streams. + * Strip non-numeric `group="main"` AdaptationSet attributes forbidden by + * the DASH schema, plus inert ContentProtection stubs on clear streams. */ private fun sanitizeMpd(mpdXml: String): String { var s = mpdXml From b55b9f38d85dfad0fadf732fe00ab23427039f67 Mon Sep 17 00:00:00 2001 From: Gavyn Caldwell Date: Mon, 2 Nov 2026 10:08:42 -0700 Subject: [PATCH 2/3] fix: build against SDK main's cert-override hook and dedupe Netty metadata SDK main carries the cert override as checkCertOverride, consulted by the installed-caller filter instead of an assignable checkCert. The rebased SDK server also brings a Netty/ktor stack whose jars share META-INF indexes and version manifests, so the merged APK drops the legacy index and picks the first version manifest. --- app/build.gradle.kts | 9 +++++++++ .../com/loosewire/kelp/server/KelpBootstrapProvider.kt | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 594e9b4..4f8a84b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -66,6 +66,15 @@ android { error += "RestrictedApi" } + packaging { + resources { + // Legacy jar indexes and version manifests from the SDK server's + // Netty/ktor stack; nothing reads them at runtime. + excludes += "META-INF/INDEX.LIST" + pickFirsts += "META-INF/io.netty.versions.properties" + } + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/server/src/main/kotlin/com/loosewire/kelp/server/KelpBootstrapProvider.kt b/server/src/main/kotlin/com/loosewire/kelp/server/KelpBootstrapProvider.kt index 4b2ae1b..6461c6c 100644 --- a/server/src/main/kotlin/com/loosewire/kelp/server/KelpBootstrapProvider.kt +++ b/server/src/main/kotlin/com/loosewire/kelp/server/KelpBootstrapProvider.kt @@ -13,7 +13,7 @@ class KelpBootstrapProvider : ContentProvider() { val appContext = context?.applicationContext ?: return false with(LightSdkServer) { defaultClientFilterLevel = ClientFilterLevel.AllowLightSignedApks - checkCert = { packageName -> + checkCertOverride = { packageName -> if (packageName == appContext.packageName) { ClientCertType.LightSdkSignedUnverified } else { From c7980635af94442410ba78aac704d4531cdce83f Mon Sep 17 00:00:00 2001 From: Gavyn Caldwell Date: Mon, 2 Nov 2026 10:10:57 -0700 Subject: [PATCH 3/3] build: pin CI to the consolidated SDK main and commit the companion opt-in CI was checking out the codex/kelp-official-sdk branch of the old SDK; everything Kelp needs now lives on gavdevs/light-sdk main, pinned here at 251237e. The serverPackage guard's opt-in moves from ad-hoc -D flags into gradle.properties so scripts, CI, and local builds agree. --- .github/workflows/build.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- AGENTS.md | 4 ++-- README.md | 2 +- gradle.properties | 5 +++++ 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7a9514e..0caaea1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,8 +17,8 @@ jobs: - name: Checkout companion light-sdk uses: actions/checkout@v4 with: - repository: LooseWireDev/light-sdk - ref: codex/kelp-official-sdk + repository: gavdevs/light-sdk + ref: 251237ef3778c907c5dbd89e84685532c9d99a28 path: light-sdk - name: Set up JDK 17 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1bdcea8..1c8d309 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,8 +18,8 @@ jobs: - name: Checkout companion light-sdk uses: actions/checkout@v4 with: - repository: LooseWireDev/light-sdk - ref: codex/kelp-official-sdk + repository: gavdevs/light-sdk + ref: 251237ef3778c907c5dbd89e84685532c9d99a28 path: light-sdk - name: Set up JDK 17 diff --git a/AGENTS.md b/AGENTS.md index a549efe..55bd287 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Kelp – agent notes -Light Phone III TIDAL client built on `lightphone/light-sdk` (composite build from `../light-sdk`, branch `codex/kelp-official-sdk`, based on official v0.1.1). Kotlin + Compose throughout. +Light Phone III TIDAL client built on the `gavdevs/light-sdk` fork (composite build from `../light-sdk`, pinned to `main` at `251237e`). Kotlin + Compose throughout. ## Module map @@ -39,7 +39,7 @@ Playback changes also require the companion SDK's `:sdk:client:testDebugUnitTest ## CI / releases -- `.github/workflows/build.yml`: tests + debug assemble on push/PR. CI checks out the companion `LooseWireDev/light-sdk` (branch `codex/kelp-official-sdk`) next to the repo; locally that checkout must sit at `../light-sdk` (override with `-Ptide.sdkPath=...`). +- `.github/workflows/build.yml`: tests + debug assemble on push/PR. CI checks out the companion `gavdevs/light-sdk` (pinned revision on `main`) next to the repo; locally that checkout must sit at `../light-sdk` (override with `-Ptide.sdkPath=...`). - `.github/workflows/release.yml`: tag-triggered (`v*`) signed release. Tag must match `versionName` in `app/lighttool.toml`; bump `versionCode` each release. Signing uses Actions secrets `TIDE_RELEASE_*`; the keystore backup lives at `~/.tide-keys/tide-release.jks` (storepass in `tide-release.storepass`, 600), outside the repo. - GitHub Release APKs are signed with the release key; dev-key-signed installs must be uninstalled first (signature mismatch on upgrade). diff --git a/README.md b/README.md index f3be9f3..03421ea 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Background playback is not persistent playback recovery: force-stop, reboot, or ## Build it yourself -Builds require JDK 17, Android SDK platform 36, and the companion Light SDK fork at `../light-sdk`. The fork must include both the `codex/kelp-official-sdk` compatibility changes and the `LightAudioQueueSessions` streaming API. Kelp and its companion SDK changes must ship together. +Builds require JDK 17, Android SDK platform 36, and the companion `gavdevs/light-sdk` fork at `../light-sdk` on `main`. The fork carries the `LightAudioQueueSessions` streaming API and the companion cert override Kelp needs; Kelp and its companion SDK changes must ship together. ```bash ./gradlew :app:assembleDebug diff --git a/gradle.properties b/gradle.properties index ca0aed2..b55ddf2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,3 +9,8 @@ kotlin.code.style=official # Consumed by the light-sdk tool plugin for the generated manifest (manifestPlaceholders). sdkVersion=0.1.1 + +# Kelp is its own companion server (serverPackage = "com.loosewire.kelp"), so the +# SDK's serverPackage guard needs an explicit opt-in. Committed here so local +# builds, scripts, and CI all see it. +systemProp.lightSdk.allowAltServerPackage=true