Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
104 changes: 1 addition & 103 deletions Logo_Radio-Capullo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 9 additions & 8 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ plugins {

android {
namespace = "tech.capullo.radio"
compileSdk = 36
compileSdk = 37

defaultConfig {
applicationId = "tech.capullo.radio"
minSdk = 23
targetSdk = 36
targetSdk = 37
versionCode = 1
versionName = "v0.5.7"

Expand All @@ -36,12 +36,6 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
freeCompilerArgs.add("-Xannotation-default-target=param-property")
}
}
buildFeatures {
compose = true
buildConfig = true
Expand All @@ -67,6 +61,13 @@ android {
}
}

kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
freeCompilerArgs.add("-Xannotation-default-target=param-property")
}
}

dependencies {

ksp(libs.hilt.android.compiler)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.StandardTestDispatcher
Expand All @@ -31,7 +29,7 @@ import tech.capullo.radio.snapcast.SnapclientProcess
import tech.capullo.radio.snapcast.SnapserverProcess

@RunWith(AndroidJUnit4::class)
class SnapcastControlClientInstrumentedTest {
class SnapcastControlClientTest {

private lateinit var radioRepository: RadioRepository
private lateinit var appContext: android.content.Context
Expand Down Expand Up @@ -121,6 +119,44 @@ class SnapcastControlClientInstrumentedTest {
}
}

@Test
fun serverOnLatencyChanged() = runTest {
// Setup
backgroundScope.launch(Dispatchers.IO) {
SnapserverProcess(radioRepository).start()
}

backgroundScope.launch(Dispatchers.IO) {
SnapclientProcess(appContext, radioRepository).start()
}

val snapcastControlClient = SnapcastControlClient(
"127.0.0.1",
ioDispatcher = StandardTestDispatcher(testScheduler),
)

// the control client will do a status query as part of its init routine
snapcastControlClient.initialize()

var notification = snapcastControlClient.notifications.first()
println("notification: $notification")
// Asserting the first notification we receive is from the control client's init routine
assert(notification is ServerGetStatusResponse)

// Second notification we receive is from the client connection, get the id
notification = snapcastControlClient.notifications.first()
println("notification: $notification")
assert(notification is ServerOnUpdate)
val client = (notification as ServerOnUpdate)
.params.server.groups.first().clients.first()
val clientId = client.id
val currLatency = client.config.latency

snapcastControlClient.sendSetLatency(clientId, currLatency + 25)
notification = snapcastControlClient.notifications.first()
println("notification: $notification")
}

@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun notificationCollectionDuringSnapserverShutdown() = runTest {
Expand Down Expand Up @@ -158,84 +194,6 @@ class SnapcastControlClientInstrumentedTest {
// ...therefore we count this test being able to finish as "passing"
}

@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun snapclientSuccessfulFirstTryConnection() = runTest {
// Setup
backgroundScope.launch(Dispatchers.IO) { SnapserverProcess(radioRepository).start() }

val snapclientProcess = SnapclientProcess(appContext, radioRepository)
backgroundScope.launch(Dispatchers.IO) { snapclientProcess.start() }

// Succesfull Connection on the first try
val snapclientProcessConnectionState = snapclientProcess.connectionState.drop(1).first()
assert(snapclientProcessConnectionState == SnapclientProcess.ConnectionState.CONNECTED)
}

@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun snapclientHandlesInvalidHostAddress() = runTest {
val snapclientProcess =
SnapclientProcess(appContext, radioRepository)

backgroundScope.launch(Dispatchers.IO) {
snapclientProcess.start(
snapserverAddress = "someHostAddressThatFailsToResolve",
)
}
val snapclientProcessConnectionState = snapclientProcess.connectionState.take(2).toList()
assert(snapclientProcessConnectionState[0] == SnapclientProcess.ConnectionState.STARTING)
assert(snapclientProcessConnectionState[1] == SnapclientProcess.ConnectionState.ERROR)
}

@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun snapclientRetryAndReconnectBehavior() = runTest {
// Setup
val snapclientProcess = SnapclientProcess(appContext, radioRepository)
backgroundScope.launch(Dispatchers.IO) { snapclientProcess.start() }

// connecting to a valid host (localhost) but no server is online yet
var snapclientProcessConnectionState = snapclientProcess.connectionState.drop(1).first()
assert(snapclientProcessConnectionState == SnapclientProcess.ConnectionState.ERROR)

// bring the snapserver online...
val serverJob = backgroundScope.launch(Dispatchers.IO) {
SnapserverProcess(radioRepository).start()
}
// ... snapclient process has retry mechanism
snapclientProcessConnectionState = snapclientProcess.connectionState.drop(1).first()
assert(snapclientProcessConnectionState == SnapclientProcess.ConnectionState.CONNECTED)

// plug the server off in the middle of a session
serverJob.cancel()

// trick to make the snapserver process stdout to print something
// causing the readLine() function call [SnapserverProcess.kt:45] to unblock
// and ensureActive to recognize the process has been canceled
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
// run it in a background scope since the control client has an auto retry mechanism
try {
val snapcastControlClient = SnapcastControlClient(
"127.0.0.1",
ioDispatcher = StandardTestDispatcher(testScheduler),
)
snapcastControlClient.initialize()
} catch (_: Exception) {}
}

serverJob.join()

snapclientProcessConnectionState = snapclientProcess.connectionState.first()
assert(snapclientProcessConnectionState == SnapclientProcess.ConnectionState.ERROR)

// make the server come back online, simulating connected -> disconected -> then reconnected
// automatically
backgroundScope.launch(Dispatchers.IO) { SnapserverProcess(radioRepository).start() }
snapclientProcessConnectionState = snapclientProcess.connectionState.drop(1).first()
assert(snapclientProcessConnectionState == SnapclientProcess.ConnectionState.CONNECTED)
}

@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun snapcastControlClientReconnect() {
Expand Down
116 changes: 116 additions & 0 deletions app/src/androidTest/java/tech/capullo/radio/SnapclientProcessTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package tech.capullo.radio

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import tech.capullo.radio.data.ConfFileDataSource
import tech.capullo.radio.data.RadioAdvertisingDataSource
import tech.capullo.radio.data.RadioRepository
import tech.capullo.radio.snapcast.SnapcastControlClient
import tech.capullo.radio.snapcast.SnapclientProcess
import tech.capullo.radio.snapcast.SnapserverProcess

@RunWith(AndroidJUnit4::class)
class SnapclientProcessTest {

private lateinit var radioRepository: RadioRepository
private lateinit var appContext: android.content.Context

@Before
fun setup() {
appContext = InstrumentationRegistry.getInstrumentation().targetContext
val confFileDataSource = ConfFileDataSource(appContext)
val radioAdvertisingDataSource = RadioAdvertisingDataSource(appContext)
radioRepository = RadioRepository(confFileDataSource, radioAdvertisingDataSource)
}

@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun snapclientSuccessfulFirstTryConnection() = runTest {
// Setup
backgroundScope.launch(Dispatchers.IO) { SnapserverProcess(radioRepository).start() }

val snapclientProcess = SnapclientProcess(appContext, radioRepository)
backgroundScope.launch(Dispatchers.IO) { snapclientProcess.start() }

// Succesfull Connection on the first try
val snapclientProcessConnectionState = snapclientProcess.connectionState.drop(1).first()
assert(snapclientProcessConnectionState == SnapclientProcess.ConnectionState.CONNECTED)
}

@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun snapclientHandlesInvalidHostAddress() = runTest {
val snapclientProcess =
SnapclientProcess(appContext, radioRepository)

backgroundScope.launch(Dispatchers.IO) {
snapclientProcess.start(
snapserverAddress = "someHostAddressThatFailsToResolve",
)
}
val snapclientProcessConnectionState = snapclientProcess.connectionState.take(2).toList()
assert(snapclientProcessConnectionState[0] == SnapclientProcess.ConnectionState.STARTING)
assert(snapclientProcessConnectionState[1] == SnapclientProcess.ConnectionState.ERROR)
}

@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun snapclientRetryAndReconnectBehavior() = runTest {
// Setup
val snapclientProcess = SnapclientProcess(appContext, radioRepository)
backgroundScope.launch(Dispatchers.IO) { snapclientProcess.start() }

// connecting to a valid host (localhost) but no server is online yet
var snapclientProcessConnectionState = snapclientProcess.connectionState.drop(1).first()
assert(snapclientProcessConnectionState == SnapclientProcess.ConnectionState.ERROR)

// bring the snapserver online...
val serverJob = backgroundScope.launch(Dispatchers.IO) {
SnapserverProcess(radioRepository).start()
}
// ... snapclient process has retry mechanism
snapclientProcessConnectionState = snapclientProcess.connectionState.drop(1).first()
assert(snapclientProcessConnectionState == SnapclientProcess.ConnectionState.CONNECTED)

// plug the server off in the middle of a session
serverJob.cancel()

// trick to make the snapserver process stdout to print something
// causing the readLine() function call [SnapserverProcess.kt:45] to unblock
// and ensureActive to recognize the process has been canceled
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
// run it in a background scope since the control client has an auto retry mechanism
try {
val snapcastControlClient = SnapcastControlClient(
"127.0.0.1",
ioDispatcher = StandardTestDispatcher(testScheduler),
)
snapcastControlClient.initialize()
} catch (_: Exception) {}
}

serverJob.join()

snapclientProcessConnectionState = snapclientProcess.connectionState.first()
assert(snapclientProcessConnectionState == SnapclientProcess.ConnectionState.ERROR)

// make the server come back online, simulating connected -> disconected -> then reconnected
// automatically
backgroundScope.launch(Dispatchers.IO) { SnapserverProcess(radioRepository).start() }
snapclientProcessConnectionState = snapclientProcess.connectionState.drop(1).first()
assert(snapclientProcessConnectionState == SnapclientProcess.ConnectionState.CONNECTED)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,16 @@ class SnapcastControlClient(
session?.sendSerialized(setVolume)
}

suspend fun sendSetLatency(clientId: String, latency: Int) {
val setLatency = ClientSetLatencyRequest(
id = requestIdCounter++,
params = LatencyParams(clientId, latency),
)

Log.d(TAG, "sendSetLatency: $setLatency")
session?.sendSerialized(setLatency)
}

companion object {
private val TAG = SnapcastControlClient::class.simpleName
}
Expand Down
31 changes: 29 additions & 2 deletions app/src/main/java/tech/capullo/radio/snapcast/SnapcastJSONRPC.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ import kotlinx.serialization.json.jsonObject

// Requests
// https://github.com/badaix/snapcast/blob/develop/doc/json_rpc_api/control.md#requests-1
val JSON_RPC_VERSION: String = "2.0"

@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class ServerGetStatusRequest(
val id: Int,
@EncodeDefault
val jsonrpc: String = "2.0",
val jsonrpc: String = JSON_RPC_VERSION,
@EncodeDefault
val method: String = "Server.GetStatus",
)
Expand Down Expand Up @@ -129,7 +131,7 @@ data class StreamQuery(
data class ClientSetVolumeRequest(
val id: Int,
@EncodeDefault
val jsonrpc: String = "2.0",
val jsonrpc: String = JSON_RPC_VERSION,
@EncodeDefault
val method: String = "Client.SetVolume",
val params: VolumeParams,
Expand All @@ -142,6 +144,23 @@ data class VolumeParams(
val volume: Volume,
)

@Serializable
data class ClientSetLatencyRequest(
val id: Int,
@EncodeDefault
val jsonrpc: String = JSON_RPC_VERSION,
@EncodeDefault
val method: String = "Client.SetLatency",
val params: LatencyParams,
)

@Serializable
data class LatencyParams(
@SerialName("id")
val clientId: String,
val latency: Int,
)

// Notifications
// https://github.com/badaix/snapcast/blob/develop/doc/json_rpc_api/control.md#notifications
@Serializable
Expand Down Expand Up @@ -178,6 +197,13 @@ data class ClientOnConnect(
val params: ClientParams,
) : Notification()

@Serializable
data class ClientOnLatencyChanged(
override val jsonrpc: String,
override val method: String,
val params: LatencyParams,
) : Notification()

@Serializable
data class ServerOnUpdate(
override val jsonrpc: String,
Expand All @@ -195,6 +221,7 @@ object NotificationSerializer : JsonContentPolymorphicSerializer<Notification>(
"\"Client.OnVolumeChanged\"" -> ClientOnVolumeChanged.serializer()
"\"Client.OnDisconnect\"" -> ClientOnDisconnect.serializer()
"\"Client.OnConnect\"" -> ClientOnConnect.serializer()
"\"Client.OnLatencyChanged\"" -> ClientOnLatencyChanged.serializer()
else -> GenericNotification.serializer()
}
}
Expand Down
Loading
Loading