client/securedrop_client/state/state.py (74 lines of code) (raw):

# SPDX-License-Identifier: AGPL-3.0-or-later # Copyright © 2022‒2023 The Freedom of the Press Foundation. """ Stores and provides read/write access to the internal state of the SecureDrop Client. Note: the Graphical User Interface MUST NOT write state, except in QActions. """ from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot from securedrop_client.database import Database from .domain import ConversationId, File, FileId, SourceId class State(QObject): """Stores and provides read/write access to the internal state of the SecureDrop Client. Note: the Graphical User Interface SHOULD NOT write state, except in QActions. """ selected_conversation_files_changed = pyqtSignal() def __init__(self, database: Database | None = None) -> None: super().__init__() self._files: dict[FileId, File] = {} self._conversation_files: dict[ConversationId, list[File]] = {} self._selected_conversation: ConversationId | None = None if database is not None: self._initialize_from_database(database) def _initialize_from_database(self, database: Database) -> None: persisted_files = database.get_files() for persisted_file in persisted_files: conversation_id = ConversationId(persisted_file.source.uuid) file_id = FileId(persisted_file.uuid) self.add_file(conversation_id, file_id) if persisted_file.is_downloaded: known_file = self.file(file_id) if known_file is not None: known_file.is_downloaded = True def add_file(self, cid: ConversationId, fid: FileId) -> None: file = File(fid) # store references to the same object if fid not in self._files: self._files[fid] = file if cid not in self._conversation_files: self._conversation_files[cid] = [] file_is_known = False for known_file in self._conversation_files[cid]: if fid == known_file.id: file_is_known = True if not file_is_known: self._conversation_files[cid].append(file) if cid == self._selected_conversation: self.selected_conversation_files_changed.emit() def remove_conversation_files(self, id: ConversationId) -> None: self._conversation_files[id] = [] if id == self._selected_conversation: self.selected_conversation_files_changed.emit() def conversation_files(self, id: ConversationId) -> list[File]: default: list[File] = [] return self._conversation_files.get(id, default) def file(self, id: FileId) -> File | None: return self._files.get(id, None) def record_file_download(self, id: FileId) -> None: if id not in self._files: pass else: self._files[id].is_downloaded = True self.selected_conversation_files_changed.emit() @property def selected_conversation(self) -> ConversationId | None: """The identifier of the currently selected conversation, or None""" return self._selected_conversation @selected_conversation.setter def selected_conversation(self, id: ConversationId | None) -> None: self._selected_conversation = id self.selected_conversation_files_changed.emit() @property def selected_conversation_has_downloadable_files(self) -> bool: """Whether the selected conversation has any files that are not already downloaded""" selected_conversation_id = self._selected_conversation if selected_conversation_id is None: return False default: list[File] = [] for f in self._conversation_files.get(selected_conversation_id, default): if not f.is_downloaded: return True return False @pyqtSlot(SourceId) def set_selected_conversation_for_source(self, source_id: SourceId) -> None: self.selected_conversation = ConversationId(str(source_id)) @pyqtSlot() def clear_selected_conversation(self) -> None: self.selected_conversation = None