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#[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 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
94pub 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 #[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 #[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 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 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 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 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 #[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 #[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 #[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 #[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 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 #[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}