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:
parent
25bed8b073
commit
180010a0d0
9 changed files with 69 additions and 9 deletions
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue