Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package org.siloserver.silo.common.network

import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.siloserver.silo.network.AndroidServerRegistry
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue

@RunWith(RobolectricTestRunner::class)
class AndroidServerIdentityTest {

@Test
fun matchesSchemeAndHostCaseAndDefaultPorts() {
val phone = wireId("https://Media.Example.test/library")
val tv = wireId("HTTPS://media.example.test:443/library/")

assertFalse(phone == tv, "persisted registry keys remain unchanged")
assertTrue(AndroidServerRegistry.serverIdsMatch(phone, tv))
assertTrue(
AndroidServerRegistry.serverIdsMatch(
wireId("http://MEDIA.example.test:80/library"),
wireId("http://media.example.test/library"),
),
)
}

@Test
fun preservesCredentialsPathQueryFragmentAndNonDefaultPort() {
val canonical = wireId("https://User:Pass@MEDIA.example.test:8443/Library?mode=A#Top")

assertTrue(
AndroidServerRegistry.serverIdsMatch(
canonical,
wireId("HTTPS://User:Pass@media.example.test:8443/Library?mode=A#Top"),
),
)
assertFalse(
AndroidServerRegistry.serverIdsMatch(
canonical,
wireId("https://user:Pass@media.example.test:8443/Library?mode=A#Top"),
),
)
assertFalse(
AndroidServerRegistry.serverIdsMatch(
canonical,
wireId("https://User:Pass@media.example.test:443/Library?mode=A#Top"),
),
)
assertFalse(
AndroidServerRegistry.serverIdsMatch(
canonical,
wireId("https://User:Pass@media.example.test:8443/library?mode=A#Top"),
),
)
assertFalse(
AndroidServerRegistry.serverIdsMatch(
canonical,
wireId("https://User:Pass@media.example.test:8443/Library?mode=a#Top"),
),
)
assertFalse(
AndroidServerRegistry.serverIdsMatch(
canonical,
wireId("https://User:Pass@media.example.test:8443/Library?mode=A#top"),
),
)
assertTrue(
AndroidServerRegistry.serverIdsMatch(
wireId("https://User:Pass@MÉDIA.example.test:443/Library"),
wireId("HTTPS://User:Pass@média.example.test/Library"),
),
)
}

@Test
fun decoderRequiresAnExactRoundTrippingHttpUrl() {
val original = "https://Média.example.test:443/silo?mode=A#top"
val serverId = wireId(original)

assertEquals(original, AndroidServerRegistry.urlForServerId(serverId))
assertNull(AndroidServerRegistry.urlForServerId("not-a-registry-id"))
assertNull(AndroidServerRegistry.urlForServerId(wireId("file:///tmp/silo")))
assertNull(
AndroidServerRegistry.urlForServerId(
AndroidServerRegistry.idFor("https://media.example.test/"),
),
)
assertNull(AndroidServerRegistry.urlForServerId("$serverId="))
}

@Test
fun exactUnknownIdsMatchButMissingOrDistinctIdsDoNot() {
assertTrue(AndroidServerRegistry.serverIdsMatch("future-format", "future-format"))
assertFalse(AndroidServerRegistry.serverIdsMatch("future-format-a", "future-format-b"))
assertFalse(AndroidServerRegistry.serverIdsMatch(null, "future-format"))
assertFalse(AndroidServerRegistry.serverIdsMatch("", ""))
}

private fun wireId(url: String): String =
AndroidServerRegistry.idFor(url.trim().trimEnd('/'))
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import android.content.res.Configuration
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
Expand All @@ -31,6 +32,7 @@ import androidx.lifecycle.lifecycleScope
import org.siloserver.silo.common.diagnostics.DiagnosticsLifecycleLogger
import org.siloserver.silo.android.downloads.LEGACY_PUBLIC_DOWNLOAD_PERMISSION
import org.siloserver.silo.android.downloads.hasLegacyPublicDownloadPermission
import org.siloserver.silo.android.cast.SiloCastController
import org.siloserver.silo.android.push.PushNotificationPresenter
import org.siloserver.silo.android.ui.navigation.AppNavigation
import org.siloserver.silo.android.ui.navigation.ExternalRouteRequest
Expand Down Expand Up @@ -217,6 +219,29 @@ class MainActivity : ComponentActivity() {
}
}

/**
* While the full Remote Control owns volume, consume both halves of each
* hardware-key event so Android neither changes local volume nor shows its
* volume HUD. Repeated ACTION_DOWN events intentionally remain individual
* remote steps when the user holds a button.
*/
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val step = when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> 1
KeyEvent.KEYCODE_VOLUME_DOWN -> -1
else -> return super.dispatchKeyEvent(event)
}
val controller = get<SiloCastController>(SiloCastController::class.java)
return when (event.action) {
KeyEvent.ACTION_DOWN -> {
controller.stepVolumeOptimistic(step) || super.dispatchKeyEvent(event)
}
else -> {
controller.shouldInterceptHardwareVolumeKeys() || super.dispatchKeyEvent(event)
}
}
}

override fun onStart() {
super.onStart()
DiagnosticsLifecycleLogger.state("foreground")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package org.siloserver.silo.android.cast

import java.util.ArrayDeque
import kotlin.math.abs

/**
* Holds locally requested absolute volume levels until the TV acknowledges
* them in order.
*
* SiloCast sends absolute values and the TV answers every command with a full
* state frame. During a burst, an older reply must not rewind the optimistic
* level used by the next hardware-button step. Tracking the ordered requests
* also handles reversals such as `0.5 -> 0.5625 -> 0.5`: a pre-command `0.5`
* snapshot cannot acknowledge the second request while the first is pending.
*/
internal class RemoteVolumeReconciler {
private data class PendingRequest(
val volume: Double,
val requestedAtMs: Long,
)

private val pending = ArrayDeque<PendingRequest>()

fun requested(volume: Double, atMs: Long) {
pending.addLast(PendingRequest(volume = volume, requestedAtMs = atMs))
}

fun clear() {
pending.clear()
}

fun reconcile(inbound: Double, atMs: Long): Double {
val latest = pending.peekLast() ?: return inbound
if (atMs - latest.requestedAtMs >= WINDOW_MS) {
pending.clear()
return inbound
}

val earliest = pending.peekFirst()
if (earliest != null && abs(inbound - earliest.volume) < TOLERANCE) {
pending.removeFirst()
return pending.peekLast()?.volume ?: inbound
}

return latest.volume
}

private companion object {
const val WINDOW_MS = 4_000L
const val TOLERANCE = 0.001
}
}
Loading
Loading