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
1 change: 1 addition & 0 deletions client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ windows-sys = { version = "0.52", features = [
"Win32_System_LibraryLoader",
"Win32_System_Threading",
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Input_KeyboardAndMouse",
] }
windows = { version = "0.61", features = [
"Win32_Media_Audio",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,28 @@ import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CountDownLatch
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference

@RunWith(AndroidJUnit4::class)
class ViewerSessionTest {
@Test fun secureEnrollmentDisplaysHtmlAndClearsUnassignedCache() {
@Test fun secureEnrollmentDisplaysHtmlAndClearsUnassignedCache() = exerciseEnrollment(false)
@Test fun demoUsesNormalPlaybackAndExitsWithoutServerDeletion() = exerciseEnrollment(true)

@Test fun demoRetriesAfterTemporaryEnrollmentFailure() = exerciseEnrollment(true, true)

private fun exerciseEnrollment(demo: Boolean, retryDemo: Boolean = false) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val directory = File(context.cacheDir, "session-test-${System.nanoTime()}").apply { mkdirs() }
val isolated = object : ContextWrapper(context) {
override fun getNoBackupFilesDir(): File = directory
override fun getApplicationContext(): Context = this
}
val demoRequested = AtomicBoolean(false)
val demoConfirmed = AtomicBoolean(false)
val demoAttempts = AtomicInteger()
val unassigned = AtomicBoolean(false)
val profile = AtomicReference<String?>(null)
val profileRejected = LinkedBlockingQueue<JSONObject>()
Expand Down Expand Up @@ -64,12 +73,23 @@ class ViewerSessionTest {
val body = JSONObject(request.body.readUtf8())
check(body.getBoolean("secure_claim")) { "Secure enrollment was not requested" }
check(body.getJSONArray("capabilities").toString().contains("android-viewer")) { "Android capabilities missing" }
json("""{"code":"ABCD12","polling_secret":"test-poll-secret","poll_after_ms":1000}""")
json("""{"code":"ABCD12","polling_secret":"test-poll-secret","poll_after_ms":1000,"allowDemo":$demo}""")
}
"/api/pair/demo" -> {
check(demo)
val body = JSONObject(request.body.readUtf8())
check(body.getString("code") == "ABCD12" && body.getString("polling_secret") == "test-poll-secret")
if (demoAttempts.incrementAndGet() == 1 && retryDemo) json("{}", 503)
else {
demoConfirmed.set(true)
json("{}")
}
}
"/api/pair/claim", "/api/pair/ack" -> {
val body = JSONObject(request.body.readUtf8())
check(body.getString("code") == "ABCD12" && body.getString("polling_secret") == "test-poll-secret") { "Enrollment polling secret was not preserved" }
if (path.endsWith("claim")) json("""{"status":"claimed","kiosk_id":"1","kiosk_key":"test-device-key","encrypt_key":"0000000000000000000000000000000000000000000000000000000000000000"}""")
if (path.endsWith("claim") && demo && !demoConfirmed.get()) json("""{"status":"pending"}""", 202)
else if (path.endsWith("claim")) json("""{"status":"claimed","demo":$demo,"kiosk_id":"1","kiosk_key":"test-device-key","encrypt_key":"0000000000000000000000000000000000000000000000000000000000000000"}""")
else {
// The secure device identity must already be durable before acknowledging.
check(ProtectedStore(isolated).read().getJSONObject("identity").getString("kiosk_key") == "test-device-key") { "Acknowledged before saving identity" }
Expand Down Expand Up @@ -109,9 +129,16 @@ class ViewerSessionTest {
session.set(ViewerSession(isolated, object : ViewerSession.Listener {
override fun onStatus(message: String) {
statuses.add(message)
if (retryDemo && message.startsWith("Demo enrollment failed")) {
if (!session.get().allowDemo || !ProtectedStore(isolated).read().getJSONObject("pending").optBoolean("allowDemo")) {
failures.add("Transient failure disabled the pending demo session")
}
session.get().enterDemo()
}
if (message.contains("connection unavailable")) offlineRetained.countDown()
}
override fun onPairing(code: String) {
if (code.isNotBlank() && demo && session.get().allowDemo && demoRequested.compareAndSet(false, true)) session.get().enterDemo()
if (code.isBlank() && clearing.get()) enrollmentCleared.countDown()
}
override fun onPlan(plan: JSONObject) {
Expand Down Expand Up @@ -146,6 +173,9 @@ class ViewerSessionTest {
assertTrue(ProtectedStore(isolated).read().has("bundle"))
assertEquals("android-viewer-v1", ProtectedStore(isolated).read().getString("bundle_profile"))
assertTrue(requests.contains("/api/pair/ack"))
assertEquals(demo, session.get().isDemo)
assertEquals(demo, ProtectedStore(isolated).read().getJSONObject("identity").optBoolean("demo"))
assertEquals(if (retryDemo) 2 else if (demo) 1 else 0, requests.count { it == "/api/pair/demo" })
val fetchedBeforeDowngrade = requests.count { it == "/api/kiosk/bundle" }
interruptHeartbeat.set(true)
instrumentation.runOnMainSync { session.get().refresh() }
Expand All @@ -169,6 +199,8 @@ class ViewerSessionTest {
instrumentation.runOnMainSync { session.get().unpair() }
assertTrue("Unpair did not finish browser/cache cleanup", enrollmentCleared.await(10, TimeUnit.SECONDS))
assertEquals(0, ProtectedStore(isolated).read().length())
assertFalse(session.get().isDemo)
assertFalse(requests.any { it.contains("delete") || it.contains("unpair") })
} finally {
instrumentation.runOnMainSync { session.get()?.close() }
server.shutdown()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,25 @@ class MainActivity : Activity(), ViewerSession.Listener {

private fun kioskRoot() = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) }

private fun updateDemoButton() {
val existing = root.findViewWithTag<Button>("demo-control")
val exit = session.isDemo
val visible = exit || (session.allowDemo && pairingCode.isNotBlank())
val label = if (exit) "Exit demo" else "Demo"
// Pairing polls update this screen repeatedly; retain remote focus.
if (visible && existing?.text?.toString() == label) return
existing?.let { root.removeView(it) }
if (!visible) return
val control = button(label) {
if (exit) resetEnrollment(session.serverUrl) else session.enterDemo()
}.apply { tag = "demo-control" }
root.addView(control, FrameLayout.LayoutParams(-2, -2, Gravity.TOP or Gravity.START).apply {
topMargin = dp(8); marginStart = dp(8)
})
}

private fun addMenu() {
updateDemoButton()
val menu = button("⋮") { showKioskMenu() }.apply {
contentDescription = getString(R.string.kiosk_menu)
textSize = 24f
Expand Down Expand Up @@ -420,6 +438,7 @@ class MainActivity : Activity(), ViewerSession.Listener {
if (displayVisible || setupView == null) showSetup(code)
else setupView?.showPairing(code)
pairingCode = code
updateDemoButton()
if (code.isBlank()) resetRequested = false
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,7 @@ class ViewerSession internal constructor(context: Context, private val listener:
}

fun unpair(nextServer: String? = null) {
allowDemo = false
if (closed || clearingEnrollment) return
// Validate before changing enrollment; retain the choice through activity recreation.
val target = try { nextServer?.let { ServerAddress.parse(it).toString().trimEnd('/') } }
Expand Down Expand Up @@ -629,6 +630,23 @@ class ViewerSession internal constructor(context: Context, private val listener:
return JSONObject(String(bytes, Charsets.UTF_8))
}

@Volatile var allowDemo = false
private set
val isDemo: Boolean get() = state.optJSONObject("identity")?.optBoolean("demo", false) == true

fun enterDemo() = enqueue {
val pending = state.optJSONObject("pending") ?: return@enqueue
if (kioskKey.isNotBlank() || !allowDemo) return@enqueue
try {
request("/api/pair/demo", claimBody(pending), false).use { requireSuccessful(it) }
nextPairPoll = 0L
} catch (_: Exception) {
// A failed POST does not change the server's advertised availability.
// Keep the same device-bound session retryable (including lost replies).
status("Demo enrollment failed. Choose Demo to retry, or continue normal pairing.")
}
}

private fun pair() {
val now = System.currentTimeMillis()
if (now < nextPairPoll) return
Expand All @@ -644,6 +662,7 @@ class ViewerSession internal constructor(context: Context, private val listener:
}
}
val session = pending!!
allowDemo = session.optBoolean("allowDemo", false)
ui(activeEpoch) { listener.onPairing(session.getString("code")) }
nextPairPoll = now + session.optLong("poll_after_ms", 2000).coerceIn(1000, 60_000)
request("/api/pair/claim", claimBody(session), false).use {
Expand All @@ -657,6 +676,7 @@ class ViewerSession internal constructor(context: Context, private val listener:
state.put("identity", claim).put("blocked", false)
persist() // Durable identity BEFORE acknowledgement or first bundle fetch.
kioskKey = claim.getString("kiosk_key")
allowDemo = false
nextSync = 0L
ui(activeEpoch) { listener.onPairing("") }
status("Paired — loading assigned display")
Expand Down
21 changes: 21 additions & 0 deletions client/core/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ pub fn validate_discovery_redirect(

#[derive(Clone, Deserialize, Serialize)]
pub struct PairInitiateResponse {
#[serde(default, rename = "allowDemo")]
pub allow_demo: bool,
pub code: String,
pub expires_at: String,
pub expires_in_seconds: Option<u64>,
Expand Down Expand Up @@ -115,6 +117,8 @@ pub fn poll_delay(milliseconds: Option<u64>) -> Duration {

#[derive(Clone, Deserialize, Serialize)]
pub struct DeviceIdentity {
#[serde(default)]
pub demo: bool,
pub version: u32,
pub server_url: String,
pub kiosk_id: String,
Expand All @@ -134,6 +138,7 @@ impl DeviceIdentity {
) -> Result<Self, String> {
let identity = Self {
version: 1,
demo: claim.demo,
server_url: server.to_string(),
kiosk_id: match claim.kiosk_id {
Some(Value::String(id)) => id,
Expand Down Expand Up @@ -174,6 +179,8 @@ impl DeviceIdentity {

#[derive(Deserialize)]
pub struct PairClaimResponse {
#[serde(default)]
pub demo: bool,
pub status: String,
pub expires_in_seconds: Option<u64>,
pub poll_after_ms: Option<u64>,
Expand Down Expand Up @@ -227,6 +234,20 @@ mod tests {
assert_eq!(modern.poll_delay(), Duration::from_secs(1));
}

#[test]
fn demo_availability_requires_explicit_true_and_identity_survives_restart() {
for (field, expected) in [("", false), (",\"allowDemo\":false", false), (",\"allowDemo\":true", true)] {
let session: PairInitiateResponse = serde_json::from_str(&format!(r#"{{"code":"ABC123","expires_at":"invalid"{field}}}"#)).unwrap();
assert_eq!(session.allow_demo, expected);
let claim: PairClaimResponse = serde_json::from_str(r#"{"status":"claimed","demo":true,"kiosk_id":"demo-kiosk","kiosk_key":"unique-device-key","encrypt_key":"encryption-material"}"#).unwrap();
let identity = DeviceIdentity::from_claim("https://example.com", &session, claim).unwrap();
let restored: DeviceIdentity = serde_json::from_slice(&serde_json::to_vec(&identity).unwrap()).unwrap();
assert!(restored.demo);
}
let old: crate::state::ClientState = serde_json::from_str(r#"{"server_url":"https://example.com"}"#).unwrap();
assert!(!old.demo && !old.allow_demo);
}

#[test]
fn incomplete_claim_cannot_be_saved_as_paired() {
let session: PairInitiateResponse =
Expand Down
4 changes: 4 additions & 0 deletions client/core/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ClientState {
#[serde(default)]
pub demo: bool,
#[serde(default)]
pub allow_demo: bool,
pub server_url: String,
pub kiosk_key: Option<String>,
#[serde(default)]
Expand Down
17 changes: 17 additions & 0 deletions client/src/platform/linux/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,23 @@ fn remove_pairing_state_files(dir: &PathBuf) {

/// Wipe all kiosk state and exit. Systemd restarts the service,
/// kiosk boots fresh with a new pairing code.
pub fn demo_mode() -> bool { load_identity().map(|identity| identity.demo).unwrap_or(false) }

pub fn demo_session() -> Option<(String, PairInitiateResponse)> {
let bytes = crate::at_rest::read_maybe_encrypted(&state_dir().join("pairing.json"))?;
let pending: (String, PairInitiateResponse) = serde_json::from_slice(&bytes).ok()?;
(pending.1.allow_demo && !is_paired()).then_some(pending)
}

pub fn enter_demo() -> Result<(), String> {
let (origin, session) = demo_session().ok_or("Demo unavailable")?;
let response = pairing_client()?.post(format!("{origin}/api/pair/demo"))
.json(&crate::core::protocol::claim_body(&session.code, session.polling_secret.as_deref()))
.send().map_err(|error| error.to_string())?;
if !response.status().is_success() { return Err("Demo unavailable; continue normal pairing or restart to retry".into()); }
Ok(())
}

pub fn reset_pairing_and_restart(reason: &str) -> ! {
tracing::warn!("{reason}; wiping kiosk pairing state and restarting");

Expand Down
Loading
Loading