feat(promo): save the remote config's launch banners, read a dev config in dev builds and write strings off the main thread

This commit is contained in:
Nguyễn Đức Tuấn Minh 2026-10-10 19:36:22 +07:00
parent 25bed8b073
commit 180010a0d0
No known key found for this signature in database
9 changed files with 69 additions and 9 deletions

View file

@ -5,6 +5,8 @@ data class AppIdentity(
val applicationId: String,
val versionName: String,
val platform: String,
/** Debug builds and Gradle runs. They read the test remote config and keep their cached copy of it apart. */
val isDevBuild: Boolean = false,
) {
val userAgent: String
get() = "SimpMusic/$versionName ($applicationId; $platform)"

View file

@ -188,8 +188,15 @@ internal class DataStoreManagerImpl(
key: String,
value: String,
) {
settingsDataStore.edit { settings ->
settings[stringPreferencesKey(key)] = value
// Off the caller's thread, like every other write here. DataStore runs an edit's transform in
// the caller's context, so a write asked for from the main thread needs the main thread to
// finish. When the player's runBlocking holds the main thread waiting on a later write, the two
// wait on each other for good: an ANR at launch on 2026-10-10, when the remote config's
// promos block was saved from the main-thread service scope as playback started.
withContext(Dispatchers.IO) {
settingsDataStore.edit { settings ->
settings[stringPreferencesKey(key)] = value
}
}
}

View file

@ -1,5 +1,6 @@
package com.maxrave.data.di
import com.maxrave.common.AppIdentity
import com.maxrave.common.Config.SERVICE_SCOPE
import com.maxrave.data.io.fileDir
import com.maxrave.data.repository.AccountRepositoryImpl
@ -57,7 +58,7 @@ val repositoryModule =
single<CommonRepository>(createdAtStart = true) {
CommonRepositoryImpl(get(named(SERVICE_SCOPE)), get(), get(), get(), get(), get()).apply {
this.init("${fileDir()}/ytdlp-cookie.txt", get())
this.init("${fileDir()}/ytdlp-cookie.txt", get(), get<AppIdentity>().isDevBuild)
}
}

View file

@ -7,6 +7,7 @@ import com.maxrave.domain.data.entities.NotificationEntity
import com.maxrave.domain.data.model.cookie.CookieItem
import com.maxrave.domain.data.model.library.LibraryCollectionPreview
import com.maxrave.domain.data.model.library.LibraryOverview
import com.maxrave.domain.data.model.promo.Promo
import com.maxrave.domain.data.type.RecentlyType
import com.maxrave.domain.manager.DataStoreManager
import com.maxrave.domain.repository.CommonRepository
@ -47,6 +48,7 @@ internal class CommonRepositoryImpl(
override fun init(
cookiePath: String,
dataStoreManager: DataStoreManager,
isDevBuild: Boolean,
) {
youTube.cookiePath = cookiePath.toPath()
coroutineScope.launch {
@ -186,7 +188,7 @@ internal class CommonRepositoryImpl(
val tidalRemoteConfigJob =
launch {
youTube
.getTidalRemoteConfig()
.getTidalRemoteConfig(dev = isDevBuild)
.onSuccess { config ->
// Persist only non-blank fields so a malformed/partial file never
// wipes a previously cached value. No need to diff against the current
@ -198,6 +200,14 @@ internal class CommonRepositoryImpl(
config.tidalClientSecret
?.takeIf { it.isNotBlank() }
?.let { dataStoreManager.setTidalClientSecret(it) }
// Home decides the banner from whatever block is saved when its turn
// comes: a fetch that lands first serves this launch, a slower one the
// next. No block means no campaign, so "[]" clears it; an unchanged block
// costs nothing, DataStore skips equal writes.
dataStoreManager.putString(
Promo.cacheKey(isDevBuild),
config.promos?.toString() ?: "[]",
)
}.onFailure {
Logger.e("RemoteConfig", "TIDAL remote config fetch failed: ${it.message}")
}

View file

@ -0,0 +1,30 @@
package com.maxrave.domain.data.model.promo
import kotlinx.serialization.Serializable
/**
* A launch banner from the remote config's `promos` block. Every field has a default, so an entry
* missing one still decodes and is then skipped by the app; the app decodes entry by entry, so one
* written with a wrong type drops alone instead of taking the whole list down.
*/
@Serializable
data class Promo(
/** Names the banner, so the app can tell which ones it has shown. Required. */
val id: String? = null,
/** Language code to portrait banner URL. "en" stands in for any language without its own. */
val image: Map<String, String> = emptyMap(),
/** A simpmusic:// deep link or an https:// page. */
val link: String? = null,
/** Lowest app version that understands [link], e.g. "2.3.0". */
val minVersion: String? = null,
/** Conditions the app must meet. One a build does not know hides the banner from that build. */
val requires: List<String> = emptyList(),
) {
companion object {
/** The last fetched `promos` block, kept for the next launch. Dev builds keep their own. */
fun cacheKey(isDevBuild: Boolean) = if (isDevBuild) "remote_promos_dev" else "remote_promos"
/** The banners this app version has shown: each banner once per version. */
fun shownKey(isDevBuild: Boolean) = if (isDevBuild) "promo_shown_dev" else "promo_shown"
}
}

View file

@ -8,7 +8,7 @@ import com.maxrave.domain.manager.DataStoreManager
import kotlinx.coroutines.flow.Flow
interface CommonRepository {
fun init(cookiePath: String, dataStoreManager: DataStoreManager)
fun init(cookiePath: String, dataStoreManager: DataStoreManager, isDevBuild: Boolean = false)
// Database
fun closeDatabase()

View file

@ -2156,9 +2156,9 @@ class YouTube {
* Fetch the remote app config (TIDAL credentials) from GitHub raw.
* Returns a [Result] so callers can fall back silently when the fetch/parse fails.
*/
suspend fun getTidalRemoteConfig(): Result<RemoteConfig> =
suspend fun getTidalRemoteConfig(dev: Boolean = false): Result<RemoteConfig> =
runCatching {
ytMusic.getTidalRemoteConfig()
ytMusic.getTidalRemoteConfig(dev)
}
/**

View file

@ -1346,10 +1346,10 @@ class Ytmusic {
* ContentNegotiation will not auto-deserialize the body. We read it as text and parse
* it explicitly with [normalJson] (which ignores unknown keys for forward-compat).
*/
suspend fun getTidalRemoteConfig(): RemoteConfig {
suspend fun getTidalRemoteConfig(dev: Boolean = false): RemoteConfig {
val text =
httpClient
.get(TIDAL_REMOTE_CONFIG_URL) {
.get(if (dev) REMOTE_CONFIG_DEV_URL else TIDAL_REMOTE_CONFIG_URL) {
accept(ContentType.Application.Json)
}.bodyAsText()
return normalJson.decodeFromString(RemoteConfig.serializer(), text)
@ -1364,6 +1364,10 @@ class Ytmusic {
// kept in a separate repo (simpmusic-files) so the main repo stays credential-free.
const val TIDAL_REMOTE_CONFIG_URL =
"https://raw.githubusercontent.com/maxrave-dev/simpmusic-files/refs/heads/main/remote-config.json"
// Read by dev builds only, so a banner can be tried out without any real user seeing it.
const val REMOTE_CONFIG_DEV_URL =
"https://raw.githubusercontent.com/maxrave-dev/simpmusic-files/refs/heads/main/remote-config-dev.json"
}
}

View file

@ -2,6 +2,7 @@ package com.maxrave.kotlinytmusicscraper.models.response
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Remote app config fetched from GitHub raw on each app launch.
@ -16,4 +17,9 @@ data class RemoteConfig(
val tidalClientId: String? = null,
@SerialName("tidalClientSecret")
val tidalClientSecret: String? = null,
// Launch banners. Kept as raw JSON, so a block of the wrong shape cannot fail the TIDAL fields
// above; the app decodes it entry by entry and drops what it cannot read. A JSON syntax error
// anywhere in the file still fails the whole file, TIDAL included.
@SerialName("promos")
val promos: JsonElement? = null,
)