Skip to content

Commit 4667ede

Browse files
committed
Preparing for GPlay. Fixing WebRTC over public STUN. Don't disconnect when on background.
1 parent 798beb5 commit 4667ede

5 files changed

Lines changed: 91 additions & 79 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,4 @@ output.json
4949
obj/
5050
.externalNativeBuild
5151
composeApp/keystore.properties
52-
upload-keystore.jks
52+
composeApp/upload-keystore.jks

composeApp/build.gradle.kts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2+
import java.util.Properties
23

34
plugins {
45
alias(libs.plugins.kotlinMultiplatform)
@@ -110,17 +111,35 @@ android {
110111
applicationId = "io.music_assistant.client"
111112
minSdk { version = release(libs.versions.android.minSdk.get().toInt()) }
112113
targetSdk = libs.versions.android.targetSdk.get().toInt()
113-
versionCode = 2
114-
versionName = "0.1.0"
114+
versionCode = 3
115+
versionName = "0.2.0"
115116
}
116117
packaging {
117118
resources {
118119
excludes += "/META-INF/{AL2.0,LGPL2.1}"
119120
}
120121
}
122+
signingConfigs {
123+
create("release") {
124+
val props = Properties().apply {
125+
val file = project.file("keystore.properties")
126+
if (file.exists()) load(file.inputStream())
127+
}
128+
storeFile = file(props["storeFile"] as String)
129+
storePassword = props["storePassword"] as String
130+
keyAlias = props["keyAlias"] as String
131+
keyPassword = props["keyPassword"] as String
132+
}
133+
}
134+
121135
buildTypes {
122-
getByName("release") {
123-
isMinifyEnabled = false
136+
release {
137+
signingConfig = signingConfigs.getByName("release")
138+
isMinifyEnabled = false // Set to true to enable code shrinking and obfuscation
139+
proguardFiles(
140+
getDefaultProguardFile("proguard-android-optimize.txt"),
141+
"proguard-rules.pro"
142+
)
124143
}
125144
}
126145

composeApp/src/commonMain/kotlin/io/music_assistant/client/api/ServiceClient.kt

Lines changed: 32 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,8 @@ class ServiceClient(private val settings: SettingsRepository) : CoroutineScope,
8181
private var lastWebRTCConnection: WebRTCConnectionCache? = null
8282

8383
// --- Lifecycle / background state ---
84-
var isAnythingPlayingFlow: StateFlow<Boolean>? = null
8584
private var isInBackground = false
8685
private var hasActiveExternalConsumer = false
87-
private var backgroundPlaybackMonitorJob: Job? = null
8886

8987
private sealed class BackgroundedConnectionInfo {
9088
data class Direct(val connectionInfo: ConnectionInfo) : BackgroundedConnectionInfo()
@@ -163,14 +161,11 @@ class ServiceClient(private val settings: SettingsRepository) : CoroutineScope,
163161

164162
/**
165163
* Called when the app moves to the background.
166-
* If nothing is playing, disconnect to save resources. If something is playing,
167-
* keep the connection alive but monitor — if playback stops while still backgrounded,
168-
* disconnect at that point.
164+
* Connection stays alive — if the system kills it, we save info and reconnect on foreground.
169165
*/
170166
fun onAppBackground() {
171167
isInBackground = true
172168
logger.i { "App backgrounded" }
173-
evaluateBackgroundDisconnect()
174169
}
175170

176171
/**
@@ -201,73 +196,10 @@ class ServiceClient(private val settings: SettingsRepository) : CoroutineScope,
201196

202197
/**
203198
* Called when an external consumer (Android Auto / CarPlay) becomes inactive.
204-
* Re-evaluates whether a background disconnect is needed.
205199
*/
206200
fun onExternalConsumerInactive() {
207201
hasActiveExternalConsumer = false
208202
logger.i { "External consumer inactive" }
209-
210-
if (isInBackground) {
211-
evaluateBackgroundDisconnect()
212-
}
213-
}
214-
215-
/**
216-
* Evaluates whether to disconnect due to backgrounding.
217-
* Skipped when an external consumer (Android Auto / CarPlay) is active.
218-
*/
219-
private fun evaluateBackgroundDisconnect() {
220-
221-
if (hasActiveExternalConsumer) {
222-
logger.i { "External consumer active — skipping background disconnect" }
223-
return
224-
}
225-
226-
val currentState = _sessionState.value
227-
228-
// Only act if connected or reconnecting
229-
if (currentState !is SessionState.Connected && currentState !is SessionState.Reconnecting) {
230-
logger.d { "Not connected, nothing to do on background" }
231-
return
232-
}
233-
234-
if (isAnythingPlayingFlow?.value == true) {
235-
logger.i { "Audio is playing — keeping connection alive, monitoring playback" }
236-
backgroundPlaybackMonitorJob?.cancel()
237-
backgroundPlaybackMonitorJob = launch {
238-
isAnythingPlayingFlow?.collect { isPlaying ->
239-
if (!isPlaying && isInBackground && !hasActiveExternalConsumer) {
240-
logger.i { "Playback stopped while backgrounded — disconnecting now" }
241-
performBackgroundDisconnect()
242-
// Stop monitoring after disconnect (coroutine is still alive until next suspension)
243-
return@collect
244-
}
245-
}
246-
}
247-
return
248-
}
249-
250-
performBackgroundDisconnect()
251-
}
252-
253-
private fun performBackgroundDisconnect() {
254-
val currentState = _sessionState.value
255-
256-
// Save connection info for foreground reconnect
257-
backgroundedConnectionInfo = when (currentState) {
258-
is SessionState.Connected.Direct ->
259-
BackgroundedConnectionInfo.Direct(currentState.connectionInfo)
260-
is SessionState.Reconnecting.Direct ->
261-
BackgroundedConnectionInfo.Direct(currentState.connectionInfo)
262-
is SessionState.Connected.WebRTC ->
263-
BackgroundedConnectionInfo.WebRTC(currentState.remoteId)
264-
is SessionState.Reconnecting.WebRTC ->
265-
BackgroundedConnectionInfo.WebRTC(currentState.remoteId)
266-
else -> null
267-
}
268-
269-
logger.i { "Disconnecting (Backgrounded), saved=${backgroundedConnectionInfo != null}" }
270-
disconnect(SessionState.Disconnected.Backgrounded)
271203
}
272204

273205
/**
@@ -276,8 +208,6 @@ class ServiceClient(private val settings: SettingsRepository) : CoroutineScope,
276208
*/
277209
fun onAppForeground() {
278210
isInBackground = false
279-
backgroundPlaybackMonitorJob?.cancel()
280-
backgroundPlaybackMonitorJob = null
281211
logger.i { "App foregrounded" }
282212

283213
val savedInfo = backgroundedConnectionInfo ?: return
@@ -703,6 +633,14 @@ class ServiceClient(private val settings: SettingsRepository) : CoroutineScope,
703633
return@collect
704634
}
705635

636+
// If backgrounded without external consumer, save info and stop
637+
if (isInBackground && !hasActiveExternalConsumer) {
638+
logger.i { "App is backgrounded — saving WebRTC connection info instead of reconnecting" }
639+
backgroundedConnectionInfo = BackgroundedConnectionInfo.WebRTC(info.remoteId)
640+
disconnect(SessionState.Disconnected.Backgrounded)
641+
return@collect
642+
}
643+
706644
val source =
707645
if (currentState is SessionState.Connected.WebRTC) "current state" else "cache"
708646
logger
@@ -1016,6 +954,14 @@ class ServiceClient(private val settings: SettingsRepository) : CoroutineScope,
1016954
}
1017955
)
1018956

957+
// If backgrounded during reconnection, save info and disconnect instead of re-auth
958+
if (isInBackground && !hasActiveExternalConsumer && _sessionState.value is SessionState.Reconnecting.WebRTC) {
959+
logger.i { "App backgrounded during WebRTC reconnection — saving info and disconnecting" }
960+
backgroundedConnectionInfo = BackgroundedConnectionInfo.WebRTC(remoteId)
961+
disconnect(SessionState.Disconnected.Backgrounded)
962+
return
963+
}
964+
1019965
// WebRTC-specific: re-authenticate with saved token after successful reconnection
1020966
if (_sessionState.value is SessionState.Connected.WebRTC) {
1021967
logger
@@ -1111,6 +1057,14 @@ class ServiceClient(private val settings: SettingsRepository) : CoroutineScope,
11111057
val authProcessState = state.authProcessState
11121058
val wasAutoLogin = state.wasAutoLogin
11131059

1060+
// If backgrounded without external consumer, save info and disconnect
1061+
if (isInBackground && !hasActiveExternalConsumer) {
1062+
logger.i { "App is backgrounded — saving Direct connection info instead of reconnecting" }
1063+
backgroundedConnectionInfo = BackgroundedConnectionInfo.Direct(connectionInfo)
1064+
disconnect(SessionState.Disconnected.Backgrounded)
1065+
return
1066+
}
1067+
11141068
// Enter Reconnecting state (preserves server/user/auth state - no UI reload!)
11151069
_sessionState.update {
11161070
SessionState.Reconnecting.Direct(
@@ -1152,6 +1106,13 @@ class ServiceClient(private val settings: SettingsRepository) : CoroutineScope,
11521106
disconnect(SessionState.Disconnected.Error(Exception("Failed to reconnect after max attempts")))
11531107
}
11541108
)
1109+
1110+
// If backgrounded during reconnection, save info and disconnect
1111+
if (isInBackground && !hasActiveExternalConsumer && _sessionState.value is SessionState.Reconnecting.Direct) {
1112+
logger.i { "App backgrounded during Direct reconnection — saving info and disconnecting" }
1113+
backgroundedConnectionInfo = BackgroundedConnectionInfo.Direct(connectionInfo)
1114+
disconnect(SessionState.Disconnected.Backgrounded)
1115+
}
11551116
}
11561117

11571118
suspend fun sendRequest(request: Request): Result<Answer> = suspendCoroutine { continuation ->

composeApp/src/commonMain/kotlin/io/music_assistant/client/data/MainDataSource.kt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,6 @@ class MainDataSource(
182182
private var updateJob: Job? = null
183183

184184
init {
185-
// Wire isAnythingPlayingFlow so ServiceClient can check playback state on background
186-
apiClient.isAnythingPlayingFlow = isAnythingPlaying
187-
188185
// Position calculation loop - runs independently to provide smooth position updates
189186
launch {
190187
while (isActive) {

composeApp/src/commonMain/kotlin/io/music_assistant/client/webrtc/model/SignalingMessage.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
package io.music_assistant.client.webrtc.model
22

3+
import kotlinx.serialization.KSerializer
34
import kotlinx.serialization.SerialName
45
import kotlinx.serialization.Serializable
6+
import kotlinx.serialization.descriptors.SerialDescriptor
7+
import kotlinx.serialization.descriptors.listSerialDescriptor
8+
import kotlinx.serialization.descriptors.serialDescriptor
9+
import kotlinx.serialization.encoding.Decoder
10+
import kotlinx.serialization.encoding.Encoder
11+
import kotlinx.serialization.json.JsonArray
12+
import kotlinx.serialization.json.JsonDecoder
13+
import kotlinx.serialization.json.JsonPrimitive
14+
import kotlinx.serialization.json.jsonPrimitive
515

616
/**
717
* Messages exchanged with the WebRTC signaling server.
@@ -147,11 +157,36 @@ sealed interface SignalingMessage {
147157
) : SignalingMessage
148158
}
149159

160+
/**
161+
* Deserializes `urls` from either a JSON array or a single string.
162+
* The public signaling server sends a single string, while Nabu Casa sends an array.
163+
*/
164+
private object FlexibleUrlListSerializer : KSerializer<List<String>> {
165+
override val descriptor: SerialDescriptor = listSerialDescriptor(serialDescriptor<String>())
166+
167+
override fun deserialize(decoder: Decoder): List<String> {
168+
val jsonDecoder = decoder as JsonDecoder
169+
return when (val element = jsonDecoder.decodeJsonElement()) {
170+
is JsonArray -> element.map { it.jsonPrimitive.content }
171+
is JsonPrimitive -> listOf(element.content)
172+
else -> emptyList()
173+
}
174+
}
175+
176+
override fun serialize(encoder: Encoder, value: List<String>) {
177+
encoder.beginCollection(descriptor, value.size).apply {
178+
value.forEachIndexed { index, s -> encodeStringElement(descriptor, index, s) }
179+
endStructure(descriptor)
180+
}
181+
}
182+
}
183+
150184
/**
151185
* ICE server configuration for STUN/TURN.
152186
*/
153187
@Serializable
154188
data class IceServer(
189+
@Serializable(with = FlexibleUrlListSerializer::class)
155190
@SerialName("urls") val urls: List<String>,
156191
@SerialName("username") val username: String? = null,
157192
@SerialName("credential") val credential: String? = null

0 commit comments

Comments
 (0)