cadmus_core/view/
ota.rs

1use super::dialog::Dialog;
2use super::input_field::InputField;
3use super::label::Label;
4use super::notification::Notification;
5use super::toggleable_keyboard::ToggleableKeyboard;
6use super::{
7    Align, Bus, EntryId, Event, Hub, Id, NotificationEvent, RenderData, RenderQueue, UpdateMode,
8    View, ViewId, ID_FEEDER,
9};
10use crate::color::WHITE;
11use crate::context::Context;
12use crate::device::CURRENT_DEVICE;
13use crate::font::{font_from_style, Fonts, NORMAL_STYLE};
14use crate::framebuffer::Framebuffer;
15use crate::geom::Rectangle;
16use crate::gesture::GestureEvent;
17use crate::ota::{OtaClient, OtaProgress};
18use crate::unit::scale_by_dpi;
19use crate::view::filler::Filler;
20use crate::view::BIG_BAR_HEIGHT;
21use secrecy::SecretString;
22use std::thread;
23use tracing::{error, info};
24
25#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq)]
26pub enum OtaViewId {
27    Main,
28    SourceSelection,
29    PrInput,
30}
31
32#[derive(Debug, Clone, Eq, PartialEq)]
33pub enum OtaEntryId {
34    DefaultBranch,
35    StableRelease,
36}
37
38/// Attempts to show the OTA update view with validation checks.
39///
40/// This function validates prerequisites before showing the OTA view:
41/// - Checks if WiFi is enabled
42///
43/// If validation fails, a notification is added to the view hierarchy instead.
44///
45/// # Arguments
46///
47/// * `view` - The parent view to add either OTA view or notification to
48/// * `hub` - Event hub for sending events
49/// * `rq` - Render queue for UI updates
50/// * `context` - Application context containing settings and WiFi state
51///
52/// # Returns
53///
54/// `true` if the OTA view was successfully shown, `false` if validation failed
55/// and a notification was shown instead.
56#[cfg_attr(
57    feature = "otel",
58    tracing::instrument(
59        skip_all, ret(level=tracing::Level::TRACE),
60        ret(level = tracing::Level::TRACE)
61    )
62)]
63pub fn show_ota_view(
64    view: &mut dyn View,
65    hub: &Hub,
66    rq: &mut RenderQueue,
67    context: &mut Context,
68) -> bool {
69    #[cfg(feature = "otel")]
70    tracing::trace!("showing ota view");
71
72    // TODO(ogkevin): This only checks if WiFi is enabled in settings, not if there's an actual
73    // connection or internet access. Should verify actual network connectivity.
74    // See: https://github.com/OGKevin/cadmus/issues/69
75    if !context.settings.wifi {
76        let notif = Notification::new(
77            None,
78            "WiFi must be enabled to check for updates.".to_string(),
79            false,
80            hub,
81            rq,
82            context,
83        );
84        view.children_mut().push(Box::new(notif) as Box<dyn View>);
85        return false;
86    }
87
88    let ota_view = OtaView::new(context.settings.ota.github_token.clone(), context);
89    view.children_mut()
90        .push(Box::new(ota_view) as Box<dyn View>);
91    true
92}
93
94/// UI view for downloading and installing OTA updates from GitHub.
95///
96/// Manages two screens:
97/// 1. Source selection dialog - asks where to download from
98///    (Stable Release, Main Branch, or PR Build)
99/// 2. PR input screen - prompts for PR number input (only for PR Build)
100///
101/// The view transitions between screens based on user selections.
102/// Selecting Stable Release or Main Branch starts the download immediately,
103/// while PR Build first shows the input screen for a PR number.
104///
105/// # Security
106///
107/// The GitHub token is securely stored using `SecretString` to prevent
108/// accidental exposure in logs or debug output.
109pub struct OtaView {
110    id: Id,
111    rect: Rectangle,
112    children: Vec<Box<dyn View>>,
113    view_id: ViewId,
114    github_token: Option<SecretString>,
115    keyboard_index: Option<usize>,
116}
117
118impl OtaView {
119    /// Creates a new OTA view with the configured GitHub token.
120    ///
121    /// Initially displays the source selection dialog asking where to
122    /// download updates from.
123    ///
124    /// # Arguments
125    ///
126    /// * `github_token` - Optional GitHub personal access token wrapped in `SecretString`
127    ///   for secure handling. Not required for stable release downloads.
128    /// * `context` - Application context containing fonts and device information
129    #[cfg_attr(feature = "otel", tracing::instrument(skip_all))]
130    pub fn new(github_token: Option<SecretString>, context: &mut Context) -> OtaView {
131        let id = ID_FEEDER.next();
132        let view_id = ViewId::Ota(OtaViewId::Main);
133        let (width, height) = CURRENT_DEVICE.dims;
134
135        let mut children: Vec<Box<dyn View>> = Vec::new();
136
137        children.push(Box::new(Filler::new(
138            rect![0, 0, width as i32, height as i32],
139            WHITE,
140        )));
141
142        let source_dialog = Self::build_source_selection_dialog(context);
143        children.push(Box::new(source_dialog));
144
145        OtaView {
146            id,
147            rect: rect![0, 0, width as i32, height as i32],
148            children,
149            view_id,
150            github_token,
151            keyboard_index: None,
152        }
153    }
154
155    /// Builds the source selection dialog.
156    #[inline]
157    fn build_source_selection_dialog(context: &mut Context) -> Dialog {
158        let builder = Dialog::builder(
159            ViewId::Ota(OtaViewId::Main),
160            "Where to check for updates?".to_string(),
161        );
162
163        #[cfg(not(feature = "test"))]
164        let mut builder = builder;
165
166        #[cfg(not(feature = "test"))]
167        {
168            builder = builder.add_button(
169                "Stable Release",
170                Event::Select(EntryId::Ota(OtaEntryId::StableRelease)),
171            );
172        }
173
174        builder
175            .add_button(
176                "Main Branch",
177                Event::Select(EntryId::Ota(OtaEntryId::DefaultBranch)),
178            )
179            .add_button("PR Build", Event::Show(ViewId::Ota(OtaViewId::PrInput)))
180            .build(context)
181    }
182
183    /// Builds the PR input screen with title, input field, and keyboard.
184    fn build_pr_input_screen(&mut self, context: &mut Context) {
185        let dpi = CURRENT_DEVICE.dpi;
186        let (width, height) = CURRENT_DEVICE.dims;
187
188        self.children.clear();
189
190        self.children.push(Box::new(Filler::new(
191            rect![0, 0, width as i32, height as i32],
192            WHITE,
193        )));
194
195        let font = font_from_style(&mut context.fonts, &NORMAL_STYLE, dpi);
196        let x_height = font.x_heights.0 as i32;
197        let padding = font.em() as i32;
198
199        let dialog_width = scale_by_dpi(width as f32, dpi) as i32;
200        let dialog_height = scale_by_dpi(BIG_BAR_HEIGHT, dpi) as i32;
201        let dx = (width as i32 - dialog_width) / 2;
202        let dy = (height as i32) / 3 - dialog_height / 2;
203        let rect = rect![dx, dy, dx + dialog_width, dy + dialog_height];
204
205        let title_rect = rect![
206            rect.min.x + padding,
207            rect.min.y + padding,
208            rect.max.x - padding,
209            rect.min.y + padding + 3 * x_height
210        ];
211        let title = Label::new(
212            title_rect,
213            "Download Build from PR".to_string(),
214            Align::Center,
215        );
216        self.children.push(Box::new(title));
217
218        let input_rect = rect![
219            rect.min.x + 2 * padding,
220            rect.min.y + padding + 4 * x_height,
221            rect.max.x - 2 * padding,
222            rect.min.y + padding + 8 * x_height
223        ];
224        let input = InputField::new(input_rect, ViewId::Ota(OtaViewId::PrInput));
225        self.children.push(Box::new(input));
226
227        let screen_rect = rect![0, 0, width as i32, height as i32];
228        let keyboard = ToggleableKeyboard::new(screen_rect, true);
229        self.children.push(Box::new(keyboard));
230        self.keyboard_index = Some(self.children.len() - 1);
231
232        self.rect = rect![0, 0, width as i32, height as i32];
233    }
234
235    /// Toggles keyboard visibility based on focus state.
236    ///
237    /// # Arguments
238    ///
239    /// * `visible` - Whether the keyboard should be visible
240    /// * `hub` - Event hub for sending events
241    /// * `rq` - Render queue for UI updates
242    /// * `context` - Application context
243    fn toggle_keyboard(
244        &mut self,
245        visible: bool,
246        hub: &Hub,
247        rq: &mut RenderQueue,
248        context: &mut Context,
249    ) {
250        if let Some(idx) = self.keyboard_index {
251            if let Some(keyboard) = self.children.get_mut(idx) {
252                if let Some(kb) = keyboard.downcast_mut::<ToggleableKeyboard>() {
253                    kb.set_visible(visible, hub, rq, context);
254                }
255            }
256        }
257    }
258
259    /// Handles submission of PR number from input field.
260    ///
261    /// Validates the input, initiates download if valid, and closes the view.
262    ///
263    /// # Arguments
264    ///
265    /// * `text` - The input text to parse as PR number
266    /// * `hub` - Event hub for sending notifications
267    fn handle_pr_submission(&mut self, text: &str, hub: &Hub) {
268        if let Ok(pr_number) = text.trim().parse::<u32>() {
269            hub.send(Event::Notification(NotificationEvent::Show(format!(
270                "Downloading PR #{} build...",
271                pr_number
272            ))))
273            .ok();
274            self.start_pr_download(pr_number, hub);
275            hub.send(Event::Close(self.view_id)).ok();
276        } else {
277            hub.send(Event::Notification(NotificationEvent::Show(
278                "Invalid PR number".to_string(),
279            )))
280            .ok();
281        }
282    }
283
284    /// Handles tap gesture outside the dialog and keyboard areas.
285    ///
286    /// Closes the view when user taps outside to dismiss.
287    ///
288    /// # Arguments
289    ///
290    /// * `tap_position` - The position where the tap occurred
291    /// * `context` - Application context containing keyboard rectangle
292    /// * `hub` - Event hub for sending close event
293    fn handle_outside_tap(&self, tap_position: crate::geom::Point, context: &Context, hub: &Hub) {
294        if !self.rect.includes(tap_position)
295            && !context.kb_rect.includes(tap_position)
296            && !context.kb_rect.is_empty()
297        {
298            hub.send(Event::Close(self.view_id)).ok();
299        }
300    }
301
302    /// Validates that a GitHub token is configured.
303    ///
304    /// Returns true if a token is present. If not, sends a notification
305    /// explaining that the token is required.
306    #[inline]
307    fn require_github_token(&self, hub: &Hub, check_source: &str) -> bool {
308        if self.github_token.is_none() {
309            hub.send(Event::Notification(NotificationEvent::Show(format!(
310                "GitHub token required for {}. Add [ota] github-token to Settings.toml",
311                check_source
312            ))))
313            .ok();
314            return false;
315        }
316        true
317    }
318
319    /// Initiates the download process in a background thread.
320    ///
321    /// Spawns a thread that:
322    /// 1. Creates an OTA client
323    /// 2. Downloads the artifact for the specified PR
324    /// 3. Extracts and deploys KoboRoot.tgz
325    /// 4. Sends notification events on success or failure
326    ///
327    /// # Arguments
328    ///
329    /// * `pr_number` - The GitHub pull request number to download
330    /// * `hub` - Event hub for sending notifications and status updates
331    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, hub)))]
332    fn start_pr_download(&mut self, pr_number: u32, hub: &Hub) {
333        let Some(github_token) = self.github_token.clone() else {
334            tracing::error!("GitHub token is missing when starting download, this code path should be unreachable due to prior validation");
335            return;
336        };
337
338        let hub2 = hub.clone();
339        let parent_span = tracing::Span::current();
340
341        thread::spawn(move || {
342            let _span =
343                tracing::info_span!(parent: &parent_span, "pr_download_async", pr_number).entered();
344            let client = match OtaClient::new(Some(github_token)) {
345                Ok(c) => c,
346                Err(e) => {
347                    error!("[OTA] Failed to create github client {:?}", e);
348                    let error_msg = format!("Failed to create client: {}", e);
349                    hub2.send(Event::Notification(NotificationEvent::Show(error_msg)))
350                        .ok();
351                    return;
352                }
353            };
354
355            let notify_id = ViewId::MessageNotif(ID_FEEDER.next());
356            hub2.send(Event::Notification(NotificationEvent::ShowPinned(
357                notify_id,
358                "Starting update download".to_string(),
359            )))
360            .ok();
361            hub2.send(Event::Notification(NotificationEvent::UpdateProgress(
362                notify_id, 0,
363            )))
364            .ok();
365
366            let download_result = client.download_pr_artifact(pr_number, |ota_progress| {
367                if let OtaProgress::DownloadingArtifact { downloaded, total } = ota_progress {
368                    let progress = (downloaded as f32 / total as f32) * 100.0;
369                    let msg = format!("Downloading update: {}%", progress as u8);
370                    hub2.send(Event::Notification(NotificationEvent::UpdateText(
371                        notify_id, msg,
372                    )))
373                    .ok();
374                    hub2.send(Event::Notification(NotificationEvent::UpdateProgress(
375                        notify_id,
376                        progress as u8,
377                    )))
378                    .ok();
379                }
380            });
381
382            hub2.send(Event::Close(notify_id)).ok();
383
384            match download_result {
385                Ok(zip_path) => {
386                    info!("[OTA] Download completed, starting extraction...");
387
388                    match client.extract_and_deploy(zip_path) {
389                        Ok(_) => {
390                            hub2.send(Event::Notification(NotificationEvent::Show(
391                                "Update installed! Reboot to apply.".to_string(),
392                            )))
393                            .ok();
394                        }
395                        Err(e) => {
396                            error!("[OTA] Deployment error: {:?}", e);
397                            let error_msg = format!("Deployment failed: {}", e);
398                            hub2.send(Event::Notification(NotificationEvent::Show(error_msg)))
399                                .ok();
400                        }
401                    }
402                }
403                Err(e) => {
404                    error!("[OTA] Download error: {:?}", e);
405                    let error_msg = format!("Download failed: {}", e);
406                    hub2.send(Event::Notification(NotificationEvent::Show(error_msg)))
407                        .ok();
408                }
409            }
410        });
411    }
412
413    /// Initiates the default branch download in a background thread.
414    ///
415    /// Spawns a thread that:
416    /// 1. Creates an OTA client
417    /// 2. Downloads the latest artifact from the default branch
418    /// 3. Extracts and deploys KoboRoot.tgz
419    /// 4. Sends notification events on success or failure
420    ///
421    /// # Arguments
422    ///
423    /// * `hub` - Event hub for sending notifications and status updates
424    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, hub)))]
425    fn start_default_branch_download(&mut self, hub: &Hub) {
426        let Some(github_token) = self.github_token.clone() else {
427            tracing::error!("GitHub token is missing when starting download, this code path should be unreachable due to prior validation");
428            return;
429        };
430
431        let hub2 = hub.clone();
432        let parent_span = tracing::Span::current();
433
434        thread::spawn(move || {
435            let _span = tracing::info_span!(parent: &parent_span, "default_branch_download_async")
436                .entered();
437            let client = match OtaClient::new(Some(github_token)) {
438                Ok(c) => c,
439                Err(e) => {
440                    error!(error = %e, "Failed to create OTA client");
441                    let error_msg = format!("Failed to create client: {}", e);
442                    hub2.send(Event::Notification(NotificationEvent::Show(error_msg)))
443                        .ok();
444                    return;
445                }
446            };
447
448            let notify_id = ViewId::MessageNotif(ID_FEEDER.next());
449            hub2.send(Event::Notification(NotificationEvent::ShowPinned(
450                notify_id,
451                "Starting main branch download".to_string(),
452            )))
453            .ok();
454            hub2.send(Event::Notification(NotificationEvent::UpdateProgress(
455                notify_id, 0,
456            )))
457            .ok();
458
459            let download_result = client.download_default_branch_artifact(|ota_progress| {
460                if let OtaProgress::DownloadingArtifact { downloaded, total } = ota_progress {
461                    let progress = (downloaded as f32 / total as f32) * 100.0;
462                    let msg = format!("Downloading update: {}%", progress as u8);
463                    hub2.send(Event::Notification(NotificationEvent::UpdateText(
464                        notify_id, msg,
465                    )))
466                    .ok();
467                    hub2.send(Event::Notification(NotificationEvent::UpdateProgress(
468                        notify_id,
469                        progress as u8,
470                    )))
471                    .ok();
472                }
473            });
474
475            hub2.send(Event::Close(notify_id)).ok();
476
477            match download_result {
478                Ok(zip_path) => {
479                    info!("Main branch download completed, starting extraction");
480
481                    match client.extract_and_deploy(zip_path) {
482                        Ok(_) => {
483                            hub2.send(Event::Notification(NotificationEvent::Show(
484                                "Update installed! Reboot to apply.".to_string(),
485                            )))
486                            .ok();
487                        }
488                        Err(e) => {
489                            error!(error = %e, "Deployment failed");
490                            let error_msg = format!("Deployment failed: {}", e);
491                            hub2.send(Event::Notification(NotificationEvent::Show(error_msg)))
492                                .ok();
493                        }
494                    }
495                }
496                Err(e) => {
497                    error!(error = %e, "Main branch download failed");
498                    let error_msg = format!("Download failed: {}", e);
499                    hub2.send(Event::Notification(NotificationEvent::Show(error_msg)))
500                        .ok();
501                }
502            }
503        });
504    }
505
506    /// Initiates the stable release download in a background thread.
507    ///
508    /// Spawns a thread that:
509    /// 1. Creates an OTA client
510    /// 2. Downloads the latest stable release asset
511    /// 3. Extracts and deploys KoboRoot.tgz
512    /// 4. Sends notification events on success or failure
513    ///
514    /// GitHub authentication is not required for this operation.
515    ///
516    /// # Arguments
517    ///
518    /// * `hub` - Event hub for sending notifications and status updates
519    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, hub)))]
520    fn start_stable_release_download(&mut self, hub: &Hub) {
521        let github_token = self.github_token.clone();
522        let hub2 = hub.clone();
523        let parent_span = tracing::Span::current();
524
525        thread::spawn(move || {
526            let _span = tracing::info_span!(parent: &parent_span, "stable_release_download_async")
527                .entered();
528            let client = match OtaClient::new(github_token) {
529                Ok(c) => c,
530                Err(e) => {
531                    error!(error = %e, "Failed to create OTA client");
532                    let error_msg = format!("Failed to create client: {}", e);
533                    hub2.send(Event::Notification(NotificationEvent::Show(error_msg)))
534                        .ok();
535                    return;
536                }
537            };
538
539            let notify_id = ViewId::MessageNotif(ID_FEEDER.next());
540            hub2.send(Event::Notification(NotificationEvent::ShowPinned(
541                notify_id,
542                "Starting stable release download".to_string(),
543            )))
544            .ok();
545            hub2.send(Event::Notification(NotificationEvent::UpdateProgress(
546                notify_id, 0,
547            )))
548            .ok();
549
550            let download_result = client.download_stable_release_artifact(|ota_progress| {
551                if let OtaProgress::DownloadingArtifact { downloaded, total } = ota_progress {
552                    let progress = (downloaded as f32 / total as f32) * 100.0;
553                    let msg = format!("Downloading update: {}%", progress as u8);
554                    hub2.send(Event::Notification(NotificationEvent::UpdateText(
555                        notify_id, msg,
556                    )))
557                    .ok();
558                    hub2.send(Event::Notification(NotificationEvent::UpdateProgress(
559                        notify_id,
560                        progress as u8,
561                    )))
562                    .ok();
563                }
564            });
565
566            hub2.send(Event::Close(notify_id)).ok();
567
568            match download_result {
569                Ok(asset_path) => {
570                    info!("Stable release download completed, deploying update");
571
572                    match client.deploy(asset_path) {
573                        Ok(_) => {
574                            hub2.send(Event::Notification(NotificationEvent::Show(
575                                "Update installed! Reboot to apply.".to_string(),
576                            )))
577                            .ok();
578                        }
579                        Err(e) => {
580                            error!(error = %e, "Deployment failed");
581                            let error_msg = format!("Deployment failed: {}", e);
582                            hub2.send(Event::Notification(NotificationEvent::Show(error_msg)))
583                                .ok();
584                        }
585                    }
586                }
587                Err(e) => {
588                    error!(error = %e, "Stable release download failed");
589                    let error_msg = format!("Download failed: {}", e);
590                    hub2.send(Event::Notification(NotificationEvent::Show(error_msg)))
591                        .ok();
592                }
593            }
594        });
595    }
596}
597
598impl View for OtaView {
599    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, hub, _bus, rq, context), fields(event = ?evt), ret(level=tracing::Level::TRACE)))]
600    fn handle_event(
601        &mut self,
602        evt: &Event,
603        hub: &Hub,
604        _bus: &mut Bus,
605        rq: &mut RenderQueue,
606        context: &mut Context,
607    ) -> bool {
608        match *evt {
609            Event::Select(EntryId::Ota(OtaEntryId::DefaultBranch)) => {
610                if !self.require_github_token(hub, "main branch builds") {
611                    return true;
612                }
613
614                hub.send(Event::Notification(NotificationEvent::Show(
615                    "Downloading latest main branch build...".to_string(),
616                )))
617                .ok();
618                self.start_default_branch_download(hub);
619                hub.send(Event::Close(self.view_id)).ok();
620                true
621            }
622            Event::Select(EntryId::Ota(OtaEntryId::StableRelease)) => {
623                hub.send(Event::Notification(NotificationEvent::Show(
624                    "Downloading latest stable release...".to_string(),
625                )))
626                .ok();
627                self.start_stable_release_download(hub);
628                hub.send(Event::Close(self.view_id)).ok();
629                true
630            }
631            Event::Show(ViewId::Ota(OtaViewId::PrInput)) => {
632                if !self.require_github_token(hub, "PR builds") {
633                    return true;
634                }
635
636                #[cfg(feature = "otel")]
637                tracing::trace!("Showing PR input screen");
638
639                self.build_pr_input_screen(context);
640                rq.add(RenderData::new(self.id, self.rect, UpdateMode::Gui));
641                self.toggle_keyboard(true, hub, rq, context);
642                hub.send(Event::Focus(Some(ViewId::Ota(OtaViewId::PrInput))))
643                    .ok();
644                true
645            }
646            Event::Focus(None) => {
647                self.toggle_keyboard(false, hub, rq, context);
648                true
649            }
650            Event::Focus(Some(ViewId::Ota(_))) => true,
651            Event::Submit(ViewId::Ota(OtaViewId::PrInput), ref text) => {
652                self.toggle_keyboard(false, hub, rq, context);
653                self.handle_pr_submission(text, hub);
654                true
655            }
656            Event::Gesture(GestureEvent::Tap(center)) => {
657                self.handle_outside_tap(center, context, hub);
658                true
659            }
660            _ => false,
661        }
662    }
663    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, _fb, _fonts, _rect), fields(rect = ?_rect)))]
664    fn render(&self, _fb: &mut dyn Framebuffer, _rect: Rectangle, _fonts: &mut Fonts) {}
665
666    fn rect(&self) -> &Rectangle {
667        &self.rect
668    }
669    fn rect_mut(&mut self) -> &mut Rectangle {
670        &mut self.rect
671    }
672    fn children(&self) -> &Vec<Box<dyn View>> {
673        &self.children
674    }
675    fn children_mut(&mut self) -> &mut Vec<Box<dyn View>> {
676        &mut self.children
677    }
678    fn id(&self) -> Id {
679        self.id
680    }
681    fn view_id(&self) -> Option<ViewId> {
682        Some(self.view_id)
683    }
684
685    fn resize(
686        &mut self,
687        _rect: Rectangle,
688        _hub: &Hub,
689        _rq: &mut RenderQueue,
690        _context: &mut Context,
691    ) {
692    }
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698    use crate::context::test_helpers::create_test_context;
699    use crate::view::handle_event;
700    use crate::view::keyboard::Keyboard;
701    use std::collections::VecDeque;
702    use std::sync::mpsc::channel;
703
704    fn create_ota_view(context: &mut Context) -> OtaView {
705        OtaView::new(Some(SecretString::from("test-token")), context)
706    }
707
708    /// A minimal parent view that mimics Home/Reader keyboard behavior.
709    ///
710    /// When it receives `Event::Focus(Some(_))`, it inserts a Keyboard
711    /// child — exactly like Home and Reader do. This lets us assert that
712    /// the OtaView prevents the focus event from reaching the parent.
713    struct FakeParentView {
714        id: Id,
715        rect: Rectangle,
716        children: Vec<Box<dyn View>>,
717    }
718
719    impl FakeParentView {
720        fn new(rect: Rectangle) -> Self {
721            FakeParentView {
722                id: ID_FEEDER.next(),
723                rect,
724                children: Vec::new(),
725            }
726        }
727
728        fn has_keyboard(&self) -> bool {
729            self.children
730                .iter()
731                .any(|c| c.downcast_ref::<Keyboard>().is_some())
732        }
733    }
734
735    impl View for FakeParentView {
736        fn handle_event(
737            &mut self,
738            evt: &Event,
739            _hub: &Hub,
740            _bus: &mut Bus,
741            _rq: &mut RenderQueue,
742            context: &mut Context,
743        ) -> bool {
744            match *evt {
745                Event::Focus(Some(_)) => {
746                    let mut kb_rect = rect![
747                        self.rect.min.x,
748                        self.rect.max.y - 300,
749                        self.rect.max.x,
750                        self.rect.max.y - 66
751                    ];
752                    let keyboard = Keyboard::new(&mut kb_rect, false, context);
753                    self.children.push(Box::new(keyboard) as Box<dyn View>);
754                    true
755                }
756                _ => false,
757            }
758        }
759
760        fn render(&self, _fb: &mut dyn Framebuffer, _rect: Rectangle, _fonts: &mut Fonts) {}
761
762        fn rect(&self) -> &Rectangle {
763            &self.rect
764        }
765        fn rect_mut(&mut self) -> &mut Rectangle {
766            &mut self.rect
767        }
768        fn children(&self) -> &Vec<Box<dyn View>> {
769            &self.children
770        }
771        fn children_mut(&mut self) -> &mut Vec<Box<dyn View>> {
772            &mut self.children
773        }
774        fn id(&self) -> Id {
775            self.id
776        }
777    }
778
779    #[test]
780    fn test_ota_view_consumes_own_focus_event() {
781        let mut context = create_test_context();
782        let mut ota = create_ota_view(&mut context);
783        let (hub, _rx) = channel();
784        let mut bus: Bus = VecDeque::new();
785        let mut rq = RenderQueue::new();
786
787        let focus_evt = Event::Focus(Some(ViewId::Ota(OtaViewId::PrInput)));
788        let handled = ota.handle_event(&focus_evt, &hub, &mut bus, &mut rq, &mut context);
789
790        assert!(
791            handled,
792            "OtaView must consume focus events for its own ViewIds"
793        );
794        assert!(bus.is_empty(), "Focus event must not leak to parent bus");
795    }
796
797    #[test]
798    fn test_ota_view_does_not_consume_foreign_focus_event() {
799        let mut context = create_test_context();
800        let mut ota = create_ota_view(&mut context);
801        let (hub, _rx) = channel();
802        let mut bus: Bus = VecDeque::new();
803        let mut rq = RenderQueue::new();
804
805        let focus_evt = Event::Focus(Some(ViewId::HomeSearchInput));
806        let handled = ota.handle_event(&focus_evt, &hub, &mut bus, &mut rq, &mut context);
807
808        assert!(
809            !handled,
810            "OtaView must not consume focus events for other ViewIds"
811        );
812    }
813
814    /// Simulates the full event dispatch chain when OtaView shows the PR
815    /// input screen.
816    ///
817    /// The `Event::Show` handler sends `Event::Focus(Some(Ota(PrInput)))`
818    /// to the hub. We drain the hub and dispatch each event through the
819    /// view tree — just like the main loop does — and assert that the
820    /// parent never inserts a keyboard child.
821    #[test]
822    fn test_parent_keyboard_not_shown_when_ota_focuses_input() {
823        let mut context = create_test_context();
824        context.load_keyboard_layouts();
825        context.load_dictionaries();
826        let (hub, rx) = channel();
827        let mut bus: Bus = VecDeque::new();
828        let mut rq = RenderQueue::new();
829
830        let mut parent = FakeParentView::new(rect![0, 0, 600, 800]);
831        let ota = create_ota_view(&mut context);
832        parent.children.push(Box::new(ota) as Box<dyn View>);
833
834        assert!(
835            !parent.has_keyboard(),
836            "Parent must not have keyboard before focus"
837        );
838
839        let show_evt = Event::Show(ViewId::Ota(OtaViewId::PrInput));
840        handle_event(
841            &mut parent,
842            &show_evt,
843            &hub,
844            &mut bus,
845            &mut rq,
846            &mut context,
847        );
848
849        while let Ok(evt) = rx.try_recv() {
850            handle_event(&mut parent, &evt, &hub, &mut bus, &mut rq, &mut context);
851        }
852
853        assert!(
854            !parent.has_keyboard(),
855            "Parent keyboard must not be shown — OtaView should consume its own focus event"
856        );
857    }
858}