diff --git a/core/main/src/bootstrap/start_fbgateway_step.rs b/core/main/src/bootstrap/start_fbgateway_step.rs index c3f6a73d1..253c2ab87 100644 --- a/core/main/src/bootstrap/start_fbgateway_step.rs +++ b/core/main/src/bootstrap/start_fbgateway_step.rs @@ -21,7 +21,8 @@ use crate::{ firebolt::{ firebolt_gateway::FireboltGateway, handlers::{ - accessory_rpc::AccessoryRippleProvider, advertising_rpc::AdvertisingRPCProvider, + accessory_rpc::AccessoryRippleProvider, actions_rpc::ActionsRPCProvider, + advertising_rpc::AdvertisingRPCProvider, audio_description_rpc::AudioDescriptionRPCProvider, capabilities_rpc::CapRPCProvider, closed_captions_rpc::ClosedcaptionsRPCProvider, device_rpc::DeviceRPCProvider, discovery_rpc::DiscoveryRPCProvider, internal_rpc::InternalProvider, @@ -71,6 +72,7 @@ impl FireboltGatewayStep { let _ = methods.merge(ParametersRPCProvider::provide_with_alias(state.clone())); let _ = methods.merge(AdvertisingRPCProvider::provide_with_alias(state.clone())); let _ = methods.merge(DiscoveryRPCProvider::provide_with_alias(state.clone())); + let _ = methods.merge(ActionsRPCProvider::provide_with_alias(state.clone())); let _ = methods.merge(AudioDescriptionRPCProvider::provide_with_alias( state.clone(), )); diff --git a/core/main/src/firebolt/handlers/actions_rpc.rs b/core/main/src/firebolt/handlers/actions_rpc.rs new file mode 100644 index 000000000..f8d03e26d --- /dev/null +++ b/core/main/src/firebolt/handlers/actions_rpc.rs @@ -0,0 +1,150 @@ +// Copyright 2023 Comcast Cable Communications Management, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// + +use crate::{ + firebolt::rpc::RippleRPCProvider, service::apps::app_events::AppEvents, + state::platform_state::PlatformState, +}; +use jsonrpsee::{ + core::{async_trait, RpcResult}, + proc_macros::rpc, + RpcModule, +}; +use ripple_sdk::api::{ + apps::ActionsIntentResponse, + firebolt::{ + fb_discovery::ACTIONS_EVENT_ON_INTENT, + fb_general::{ListenRequest, ListenerResponse}, + }, + gateway::rpc_gateway_api::CallContext, +}; +use serde_json::Value; + +use crate::utils::rpc_utils::rpc_err; + +#[rpc(server)] +pub trait Actions { + /// Actions.intent - Getter: returns the most recently received intent + /// for the calling app, as a JSON document with a monotonic intentId. + #[method(name = "actions.intent")] + async fn intent(&self, ctx: CallContext) -> RpcResult; + + /// Actions.onIntent - Event: subscribe to intent delivery events. + /// Listeners receive { intentId, intent } whenever a new intent is + /// delivered to the app by the platform. + #[method(name = "actions.onIntent")] + async fn on_intent( + &self, + ctx: CallContext, + request: ListenRequest, + ) -> RpcResult; +} + +pub struct ActionsImpl { + pub state: PlatformState, +} + +#[async_trait] +impl ActionsServer for ActionsImpl { + async fn intent(&self, ctx: CallContext) -> RpcResult { + match self + .state + .app_manager_state + .get_current_intent_with_id(&ctx.app_id) + { + Some((intent, intent_id)) => { + let response = ActionsIntentResponse { intent_id, intent }; + serde_json::to_value(response).map_err(|_| rpc_err("serialization error")) + } + None => Ok(serde_json::json!({ + "intentId": 0, + "intent": {} + })), + } + } + + async fn on_intent( + &self, + ctx: CallContext, + request: ListenRequest, + ) -> RpcResult { + let listen = request.listen; + + AppEvents::add_listener(&self.state, ACTIONS_EVENT_ON_INTENT.into(), ctx, request); + Ok(ListenerResponse { + listening: listen, + event: ACTIONS_EVENT_ON_INTENT.into(), + }) + } +} + +pub struct ActionsRPCProvider; +impl RippleRPCProvider for ActionsRPCProvider { + fn provide(state: PlatformState) -> RpcModule { + (ActionsImpl { state }).into_rpc() + } +} + +#[cfg(test)] +mod tests { + use ripple_sdk::api::{ + apps::ActionsIntentResponse, + device::entertainment_data::{HomeIntent, NavigationIntent, NavigationIntentStrict}, + firebolt::fb_discovery::DiscoveryContext, + }; + + #[test] + fn test_actions_intent_response_serialization() { + let home_intent = HomeIntent { + context: DiscoveryContext { + source: "voice".to_string(), + age_policy: None, + }, + }; + let response = ActionsIntentResponse { + intent_id: 42, + intent: NavigationIntent::NavigationIntentStrict(NavigationIntentStrict::Home( + home_intent, + )), + }; + + let json = serde_json::to_value(&response).unwrap(); + assert_eq!(json["intentId"], 42); + assert!(json["intent"].is_object()); + assert_eq!(json["intent"]["action"], "home"); + } + + #[test] + fn test_actions_intent_response_roundtrip() { + let home_intent = HomeIntent { + context: DiscoveryContext { + source: "remote".to_string(), + age_policy: None, + }, + }; + let original = ActionsIntentResponse { + intent_id: 7, + intent: NavigationIntent::NavigationIntentStrict(NavigationIntentStrict::Home( + home_intent, + )), + }; + + let json_str = serde_json::to_string(&original).unwrap(); + let deserialized: ActionsIntentResponse = serde_json::from_str(&json_str).unwrap(); + assert_eq!(deserialized.intent_id, 7); + } +} diff --git a/core/main/src/firebolt/mod.rs b/core/main/src/firebolt/mod.rs index 7154b6f59..66f87c392 100644 --- a/core/main/src/firebolt/mod.rs +++ b/core/main/src/firebolt/mod.rs @@ -19,6 +19,7 @@ //pub mod firebolt_gateway; pub mod handlers { pub mod accessory_rpc; + pub mod actions_rpc; pub mod advertising_rpc; pub mod audio_description_rpc; pub mod capabilities_rpc; diff --git a/core/main/src/service/apps/delegated_launcher_handler.rs b/core/main/src/service/apps/delegated_launcher_handler.rs index 952d3c7c3..1a69655c8 100644 --- a/core/main/src/service/apps/delegated_launcher_handler.rs +++ b/core/main/src/service/apps/delegated_launcher_handler.rs @@ -18,7 +18,10 @@ use std::{ collections::HashMap, env, fs, - sync::{Arc, RwLock}, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, RwLock, + }, }; use ripple_sdk::{ @@ -27,7 +30,7 @@ use ripple_sdk::{ device::{device_user_grants_data::EvaluateAt, entertainment_data::NavigationIntent}, firebolt::{ fb_capabilities::{DenyReason, DenyReasonWithCap, FireboltPermission}, - fb_discovery::DISCOVERY_EVENT_ON_NAVIGATE_TO, + fb_discovery::{ACTIONS_EVENT_ON_INTENT, DISCOVERY_EVENT_ON_NAVIGATE_TO}, fb_lifecycle::{ Lifecycle2_0AppEvent, Lifecycle2_0AppEventData, LifecycleManagerState, LifecycleState, LifecycleStateChangeEvent, @@ -102,6 +105,7 @@ pub struct App { pub app_id: String, pub app_metrics_version: Option, // Provided by app via call to Metrics.appInfo pub is_app_init_params_invoked: bool, + pub current_intent_id: u64, } #[derive(Debug, Clone)] @@ -118,6 +122,8 @@ pub struct AppManagerState { apps: Arc>>, // Very useful for internal launcher where the intent might get untagged intents: Arc>>, + // Global monotonic counter for intent IDs (Firebolt 9.0.0 Actions API) + intent_index: Arc, // This is a map app_title: Arc>>, app_title_persist_path: String, @@ -185,6 +191,7 @@ impl AppManagerState { AppManagerState { apps: Arc::new(RwLock::new(HashMap::new())), intents: Arc::new(RwLock::new(HashMap::new())), + intent_index: Arc::new(AtomicU64::new(0)), app_title: Arc::new(RwLock::new(persisted_app_titles)), app_title_persist_path, migrated_apps: Arc::new(RwLock::new(persisted_migrated_apps)), @@ -327,6 +334,7 @@ impl AppManagerState { } } + #[allow(dead_code)] fn set_session(&self, app_id: &str, session: AppSession) { let mut apps = self.apps.write().unwrap(); if let Some(app) = apps.get_mut(app_id) { @@ -389,6 +397,23 @@ impl AppManagerState { intents.remove(app_id) } + /// Allocate the next monotonic intent_id from the global counter. + pub fn next_intent_id(&self) -> u64 { + self.intent_index.fetch_add(1, Ordering::SeqCst) + 1 + } + + /// Returns the current intent and its monotonic intent_id for a given app, + /// read from the App's current_session and current_intent_id fields. + pub fn get_current_intent_with_id(&self, app_id: &str) -> Option<(NavigationIntent, u64)> { + let apps = self.apps.read().unwrap(); + if let Some(app) = apps.get(app_id) { + if let Some(intent) = app.current_session.launch.intent.clone() { + return Some((intent, app.current_intent_id)); + } + } + None + } + pub fn set_app_metrics_version(&self, app_id: &str, version: String) -> Result<(), AppError> { let mut apps = self.apps.write().unwrap(); if let Some(app) = apps.get_mut(app_id) { @@ -1103,6 +1128,43 @@ impl DelegatedLauncherHandler { .await } + /// Emit Actions.onIntent event for an app. + /// This event is always emitted when the intent is updated — no conditional guards. + async fn emit_actions_on_intent( + platform_state: &PlatformState, + app_id: String, + intent: &NavigationIntent, + intent_id: u64, + ) { + let actions_payload = serde_json::json!({ + "intentId": intent_id, + "intent": serde_json::to_value(intent).unwrap_or_default(), + }); + AppEvents::emit_to_app( + platform_state, + app_id, + ACTIONS_EVENT_ON_INTENT, + &actions_payload, + ) + .await; + } + + /// Emit Discovery.onNavigateTo event for an app (backward compatibility). + /// Callers are responsible for applying any conditional guards before calling. + async fn emit_discovery_on_navigate_to( + platform_state: &PlatformState, + app_id: String, + intent: &NavigationIntent, + ) { + AppEvents::emit_to_app( + platform_state, + app_id, + DISCOVERY_EVENT_ON_NAVIGATE_TO, + &serde_json::to_value(intent).unwrap_or_default(), + ) + .await; + } + /// Actually perform the transition of the session from inactive to active. /// Generate a new active_session_id. /// If this transition happened asynchronously, then emit the completed event @@ -1124,20 +1186,28 @@ impl DelegatedLauncherHandler { .app_manager_state .update_active_session(&app_id, Some(Uuid::new_v4().to_string())); } - platform_state - .app_manager_state - .set_session(&app_id, session.clone()); + // Assign a new monotonic intent_id when the session carries an intent. + let intent_id = if session.launch.intent.is_some() { + platform_state.app_manager_state.next_intent_id() + } else { + app.current_intent_id + }; + + // Update the session and intent_id atomically under a single write lock + // to prevent a concurrent Actions.intent call from observing a mismatched pair. + { + let mut apps = platform_state.app_manager_state.apps.write().unwrap(); + if let Some(app) = apps.get_mut(&app_id) { + app.current_session = session.clone(); + app.current_intent_id = intent_id; + } + } if emit_event { Self::emit_completed(platform_state, &app_id).await; } - if let Some(intent) = session.launch.intent { - AppEvents::emit_to_app( - platform_state, - app_id.clone(), - DISCOVERY_EVENT_ON_NAVIGATE_TO, - &serde_json::to_value(intent).unwrap_or_default(), - ) - .await; + if let Some(ref intent) = session.launch.intent { + Self::emit_actions_on_intent(platform_state, app_id.clone(), intent, intent_id).await; + Self::emit_discovery_on_navigate_to(platform_state, app_id.clone(), intent).await; } if let Some(ss) = session.launch.second_screen { @@ -1172,6 +1242,13 @@ impl DelegatedLauncherHandler { app_id ); + // Assign a monotonic intent_id if the session carries an intent. + let current_intent_id = if session.launch.intent.is_some() { + platform_state.app_manager_state.next_intent_id() + } else { + 0 + }; + let app = App { initial_session: session.clone(), current_session: session.clone(), @@ -1183,6 +1260,7 @@ impl DelegatedLauncherHandler { app_id: app_id.clone(), app_metrics_version: None, is_app_init_params_invoked: false, + current_intent_id, }; platform_state .app_manager_state @@ -1350,6 +1428,20 @@ impl DelegatedLauncherHandler { pub async fn send_app_init_events(&self, app_id: &str) { if let Some(app) = self.platform_state.app_manager_state.get(app_id) { + // Actions.onIntent is always emitted when the app has an intent. + if let Some(ref intent) = app.initial_session.launch.intent { + Self::emit_actions_on_intent( + &self.platform_state, + app_id.to_string(), + intent, + app.current_intent_id, + ) + .await; + } + + // Discovery.onNavigateTo and second-screen events are conditional + // on the legacy emit-on-init flag and Parameters.initialization not + // having been called yet. if self .platform_state .get_device_manifest() @@ -1357,12 +1449,11 @@ impl DelegatedLauncherHandler { .is_emit_event_on_app_init_enabled() && !app.is_app_init_params_invoked { - if let Some(intent) = app.initial_session.launch.intent.clone() { - AppEvents::emit_to_app( + if let Some(ref intent) = app.initial_session.launch.intent { + Self::emit_discovery_on_navigate_to( &self.platform_state, app_id.to_string(), - DISCOVERY_EVENT_ON_NAVIGATE_TO, - &serde_json::to_value(intent).unwrap_or_default(), + intent, ) .await; } @@ -1456,30 +1547,37 @@ impl DelegatedLauncherHandler { self.on_unloading(app_id).await.ok(); } - // Check if the device manifest is enabled with events to emit discovery.navigateTo - // if an app is coming back to active from Inactive. - // This is necessary as some apps do not run processes to update the navigation - // intent to conserve memory footprint - if self - .platform_state - .get_device_manifest() - .lifecycle - .is_emit_navigate_on_activate() - && previous_state == LifecycleState::Inactive + // When an app transitions from Inactive to an active state, emit intent events. + if previous_state == LifecycleState::Inactive && matches!( state, LifecycleState::Background | LifecycleState::Foreground ) { - let session = app.current_session.clone(); - if let Some(intent) = session.launch.intent { - AppEvents::emit_to_app( + if let Some(ref intent) = app.current_session.launch.intent { + // Actions.onIntent always fires on activation. + Self::emit_actions_on_intent( &self.platform_state, app_id.to_owned(), - DISCOVERY_EVENT_ON_NAVIGATE_TO, - &serde_json::to_value(intent).unwrap_or_default(), + intent, + app.current_intent_id, ) .await; + + // Discovery.onNavigateTo is conditional on the device manifest flag. + if self + .platform_state + .get_device_manifest() + .lifecycle + .is_emit_navigate_on_activate() + { + Self::emit_discovery_on_navigate_to( + &self.platform_state, + app_id.to_owned(), + intent, + ) + .await; + } } } Ok(AppManagerResponse::None) @@ -1871,4 +1969,110 @@ mod tests { LifecycleState::Initializing ),); } + + #[test] + fn test_next_intent_id_increments() { + let state = AppManagerState::default(); + assert_eq!(state.next_intent_id(), 1); + assert_eq!(state.next_intent_id(), 2); + assert_eq!(state.next_intent_id(), 3); + } + + #[test] + fn test_store_and_take_intent() { + let state = AppManagerState::default(); + + state.store_intent("app1", NavigationIntent::default()); + let taken = state.take_intent("app1"); + assert!(taken.is_some()); + + // Should be gone after take + assert!(state.take_intent("app1").is_none()); + } + + #[test] + fn test_take_intent_nonexistent_returns_none() { + let state = AppManagerState::default(); + assert!(state.take_intent("nonexistent").is_none()); + } + + #[test] + fn test_get_current_intent_with_id() { + let state = AppManagerState::default(); + + let session = AppSession { + app: ripple_sdk::api::apps::AppBasicInfo { + id: "app1".to_string(), + catalog: None, + url: None, + title: None, + }, + runtime: None, + launch: ripple_sdk::api::apps::AppLaunchInfo { + intent: Some(NavigationIntent::default()), + second_screen: None, + inactive: false, + }, + }; + + let app = App { + initial_session: session.clone(), + current_session: session, + session_id: "sess1".to_string(), + state: LifecycleState::Initializing, + loaded_session_id: "loaded1".to_string(), + active_session_id: None, + internal_state: None, + app_id: "app1".to_string(), + app_metrics_version: None, + is_app_init_params_invoked: false, + current_intent_id: 5, + }; + + state.insert("app1".to_string(), app); + + let result = state.get_current_intent_with_id("app1"); + assert!(result.is_some()); + let (_, intent_id) = result.unwrap(); + assert_eq!(intent_id, 5); + } + + #[test] + fn test_get_current_intent_with_id_no_intent() { + let state = AppManagerState::default(); + + let session = AppSession { + app: ripple_sdk::api::apps::AppBasicInfo { + id: "app1".to_string(), + catalog: None, + url: None, + title: None, + }, + runtime: None, + launch: ripple_sdk::api::apps::AppLaunchInfo { + intent: None, + second_screen: None, + inactive: false, + }, + }; + + let app = App { + initial_session: session.clone(), + current_session: session, + session_id: "sess1".to_string(), + state: LifecycleState::Initializing, + loaded_session_id: "loaded1".to_string(), + active_session_id: None, + internal_state: None, + app_id: "app1".to_string(), + app_metrics_version: None, + is_app_init_params_invoked: false, + current_intent_id: 0, + }; + + state.insert("app1".to_string(), app); + + // No intent on the session, should return None + assert!(state.get_current_intent_with_id("app1").is_none()); + } } diff --git a/core/sdk/src/api/apps.rs b/core/sdk/src/api/apps.rs index a25c5d14c..83a32ccb5 100644 --- a/core/sdk/src/api/apps.rs +++ b/core/sdk/src/api/apps.rs @@ -71,6 +71,15 @@ impl AppSession { } } +/// Response type for `Actions.intent` getter and `Actions.onIntent` event payload. +/// Contains a monotonic intentId and the NavigationIntent JSON document. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActionsIntentResponse { + pub intent_id: u64, + pub intent: NavigationIntent, +} + #[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Default)] pub struct AppBasicInfo { pub id: String, @@ -398,4 +407,33 @@ mod tests { let contract_type: RippleContract = RippleContract::AppEvents; test_extn_payload_provider(app_event_request, contract_type); } + + #[test] + fn test_actions_intent_response_camel_case_serialization() { + let home_intent = HomeIntent { + context: DiscoveryContext { + source: "voice".to_string(), + age_policy: None, + }, + }; + let response = ActionsIntentResponse { + intent_id: 99, + intent: NavigationIntent::NavigationIntentStrict(NavigationIntentStrict::Home( + home_intent, + )), + }; + + let json = serde_json::to_value(&response).unwrap(); + // Verify camelCase: "intentId" not "intent_id" + assert_eq!(json["intentId"], 99); + assert!(json.get("intent_id").is_none()); + assert!(json["intent"].is_object()); + } + + #[test] + fn test_actions_intent_response_deserialization() { + let json_str = r#"{"intentId":5,"intent":{"action":"home","context":{"source":"test"}}}"#; + let response: ActionsIntentResponse = serde_json::from_str(json_str).unwrap(); + assert_eq!(response.intent_id, 5); + } } diff --git a/core/sdk/src/api/firebolt/fb_discovery.rs b/core/sdk/src/api/firebolt/fb_discovery.rs index 0365cb058..70d578442 100644 --- a/core/sdk/src/api/firebolt/fb_discovery.rs +++ b/core/sdk/src/api/firebolt/fb_discovery.rs @@ -31,6 +31,7 @@ use async_trait::async_trait; use serde::Serializer; pub const DISCOVERY_EVENT_ON_NAVIGATE_TO: &str = "discovery.onNavigateTo"; +pub const ACTIONS_EVENT_ON_INTENT: &str = "actions.onIntent"; #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct DiscoveryContext { diff --git a/examples/reference-manifest/IpStb/firebolt-device-manifest.json b/examples/reference-manifest/IpStb/firebolt-device-manifest.json index 9e967434d..31caac04a 100644 --- a/examples/reference-manifest/IpStb/firebolt-device-manifest.json +++ b/examples/reference-manifest/IpStb/firebolt-device-manifest.json @@ -61,6 +61,9 @@ "lifecycle.close", "lifecycle.finished", "discovery.onNavigateTo", + "actions.intent", + "actions.onIntent", + "actions.start", "parameters.initialization", "capabilities.supported", "capabilities.available",