Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Display download progress for file downloads #2327

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
11 changes: 7 additions & 4 deletions client/securedrop_client/api_jobs/downloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from securedrop_client.api_jobs.base import SingleObjectApiJob
from securedrop_client.crypto import CryptoError, GpgHelper
from securedrop_client.db import DownloadError, DownloadErrorCodes, File, Message, Reply
from securedrop_client.sdk import API, BaseError
from securedrop_client.sdk import API, BaseError, ProgressProxy
from securedrop_client.sdk import Reply as SdkReply
from securedrop_client.sdk import Submission as SdkSubmission
from securedrop_client.storage import (
Expand Down Expand Up @@ -56,6 +56,7 @@ class DownloadJob(SingleObjectApiJob):
def __init__(self, data_dir: str, uuid: str) -> None:
super().__init__(uuid)
self.data_dir = data_dir
self.progress: ProgressProxy | None = None

def _get_realistic_timeout(self, size_in_bytes: int) -> int:
"""
Expand Down Expand Up @@ -177,6 +178,8 @@ def _decrypt(self, filepath: str, db_object: File | Message | Reply, session: Se
"""
Decrypt the file located at the given filepath and mark it as decrypted.
"""
if self.progress:
self.progress.set_decypting()
try:
original_filename = self.call_decrypt(filepath, session)
db_object.download_error = None
Expand Down Expand Up @@ -260,7 +263,7 @@ def call_download_api(self, api: API, db_object: Reply) -> tuple[str, str]:
# will want to pass the default request timeout to download_reply instead of setting it on
# the api object directly.
api.default_request_timeout = 20
return api.download_reply(sdk_object)
return api.download_reply(sdk_object, progress=self.progress)

def call_decrypt(self, filepath: str, session: Session | None = None) -> str:
"""
Expand Down Expand Up @@ -316,7 +319,7 @@ def call_download_api(self, api: API, db_object: Message) -> tuple[str, str]:
sdk_object.source_uuid = db_object.source.uuid
sdk_object.filename = db_object.filename
return api.download_submission(
sdk_object, timeout=self._get_realistic_timeout(db_object.size)
sdk_object, timeout=self._get_realistic_timeout(db_object.size), progress=self.progress
)

def call_decrypt(self, filepath: str, session: Session | None = None) -> str:
Expand Down Expand Up @@ -375,7 +378,7 @@ def call_download_api(self, api: API, db_object: File) -> tuple[str, str]:
sdk_object.source_uuid = db_object.source.uuid
sdk_object.filename = db_object.filename
return api.download_submission(
sdk_object, timeout=self._get_realistic_timeout(db_object.size)
sdk_object, timeout=self._get_realistic_timeout(db_object.size), progress=self.progress
)

def call_decrypt(self, filepath: str, session: Session | None = None) -> str:
Expand Down
2 changes: 2 additions & 0 deletions client/securedrop_client/gui/base/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@
SvgPushButton,
SvgToggleButton,
)
from securedrop_client.gui.base.progress import FileDownloadProgressBar

__all__ = [
"FileDownloadProgressBar",
"ModalDialog",
"PasswordEdit",
"SDPushButton",
Expand Down
102 changes: 102 additions & 0 deletions client/securedrop_client/gui/base/progress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import math
import time

from PyQt5.QtCore import QTimer, pyqtSignal
from PyQt5.QtWidgets import QProgressBar

from securedrop_client.sdk import ProgressProxy
from securedrop_client.utils import humanize_speed

SMOOTHING_FACTOR = 0.3


class FileDownloadProgressBar(QProgressBar):
"""
A progress bar for file downloads.

It receives progress updates from the SDK and updates the total % downloaded,
as well as calculating the current speed.

We use an exponential moving average to smooth out the speed as suggested by
https://stackoverflow.com/a/3841706; the reported speed is 30% of the current
speed and 70% of the previous speed. It is updated every 100ms.
"""

# One of:
# {"size": int}
# {"decrypting": True}
signal = pyqtSignal(dict)

def __init__(self, file_size: int) -> None:
super().__init__()
self.setObjectName("FileDownloadProgressBar")
self.setMaximum(file_size)
# n.b. we only update the bar's value and let the text get updated by
# the timer in update_speed
self.signal.connect(self.handle)
self.timer = QTimer(self)
self.timer.setInterval(100)
self.timer.timeout.connect(self.update_speed)
self.timer.start()
# The most recently calculated speed
self.speed = 0.0
# The last time we updated the speed
self.last_total_time = 0.0
# The number of bytes downloaded the last time we updated the speed
self.last_total_bytes = 0

def handle(self, data: dict) -> None:
if data.get("decrypting"):
# Stop the speed timer and then switch to an indeterminate progress bar
self.timer.stop()
self.setMaximum(0)
self.setValue(0)
return
else:
self.setValue(data["size"])

def update_display(self) -> None:
"""Update the text displayed in the progress bar."""
# Use math.floor so we don't show 100% until we're actually done
maximum = self.maximum()
if maximum == 0:
# Race condition: we've likely switched to the indeterminate progress bar
# which has a maximum of 0. Treat it like 100% even though it won't show up
# just to avoid the DivisionByZero error.
percentage = 100
else:
percentage = math.floor((self.value() / maximum) * 100)
formatted_speed = humanize_speed(self.speed)
# TODO: localize this?
if percentage in (0, 100):
# If haven't started or have finished, don't display a 0B/s speed
self.setFormat(f"{percentage}%")
else:
self.setFormat(f"{percentage}% | {formatted_speed}")

def update_speed(self) -> None:
"""Calculate the new speed and trigger updating the display."""
now = time.monotonic()
value = self.value()

# If this is the first update we report the speed as 0
if self.last_total_time == 0:
self.last_total_time = now
self.last_total_bytes = value
self.speed = 0
return

time_diff = now - self.last_total_time
bytes_diff = value - self.last_total_bytes
if time_diff > 0:
self.speed = (
1 - SMOOTHING_FACTOR
) * self.speed + SMOOTHING_FACTOR * bytes_diff / time_diff

self.last_total_time = now
self.last_total_bytes = value
self.update_display()

def proxy(self) -> ProgressProxy:
"""Get a proxy that updates this widget."""
return ProgressProxy(self.signal)
33 changes: 20 additions & 13 deletions client/securedrop_client/gui/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,13 @@
ExportConversationTranscriptAction,
PrintConversationAction,
)
from securedrop_client.gui.base import SecureQLabel, SvgLabel, SvgPushButton, SvgToggleButton
from securedrop_client.gui.base import (
FileDownloadProgressBar,
SecureQLabel,
SvgLabel,
SvgPushButton,
SvgToggleButton,
)
from securedrop_client.gui.conversation import DeleteConversationDialog
from securedrop_client.gui.datetime_helpers import format_datetime_local
from securedrop_client.gui.shortcuts import Shortcuts
Expand Down Expand Up @@ -2579,7 +2585,8 @@ def __init__(
self.download_button.setIcon(load_icon("download_file.svg"))
self.download_button.setFont(self.file_buttons_font)
self.download_button.setCursor(QCursor(Qt.PointingHandCursor))
self.download_animation = load_movie("download_file.gif")
self.download_progress = FileDownloadProgressBar(self.file.size)
self.download_progress.hide()
self.export_button = QPushButton(_("EXPORT"))
self.export_button.setObjectName("FileWidget_export_print")
self.export_button.setFont(self.file_buttons_font)
Expand All @@ -2590,6 +2597,9 @@ def __init__(
self.print_button.setFont(self.file_buttons_font)
self.print_button.setCursor(QCursor(Qt.PointingHandCursor))
file_options_layout.addWidget(self.download_button)
file_options_layout.addWidget(self.download_progress)
# Add a bit of padding after the progress bar
file_options_layout.addSpacing(5)
file_options_layout.addWidget(self.export_button)
file_options_layout.addWidget(self.middot)
file_options_layout.addWidget(self.print_button)
Expand Down Expand Up @@ -2675,6 +2685,7 @@ def _set_file_state(self) -> None:
logger.debug(f"Changing file {self.uuid} state to decrypted/downloaded")
self._set_file_name()
self.download_button.hide()
self.download_progress.hide()
self.no_file_name.hide()
self.export_button.show()
self.middot.show()
Expand All @@ -2693,6 +2704,7 @@ def _set_file_state(self) -> None:

self.download_button.setFont(self.file_buttons_font)
self.download_button.show()
self.download_progress.hide()

# Reset stylesheet
self.download_button.setStyleSheet("")
Expand Down Expand Up @@ -2793,34 +2805,29 @@ def _on_left_click(self) -> None:
if self.controller.api:
self.start_button_animation()
# Download the file.
self.controller.on_submission_download(File, self.uuid)
self.controller.on_submission_download(
File, self.uuid, self.download_progress.proxy()
)

def start_button_animation(self) -> None:
"""
Update the download button to the animated "downloading" state.
"""
self.downloading = True
self.download_animation.frameChanged.connect(self.set_button_animation_frame)
self.download_animation.start()
self.download_progress.setValue(0)
self.download_progress.show()
self.download_button.setText(_(" DOWNLOADING "))

# Reset widget stylesheet
self.download_button.setStyleSheet("")
self.download_button.setObjectName("FileWidget_download_button_animating")
self.download_button.setStyleSheet(self.DOWNLOAD_BUTTON_CSS)

def set_button_animation_frame(self, frame_number: int) -> None:
"""
Sets the download button's icon to the current frame of the spinner
animation.
"""
self.download_button.setIcon(QIcon(self.download_animation.currentPixmap()))

def stop_button_animation(self) -> None:
"""
Stops the download animation and restores the button to its default state.
"""
self.download_animation.stop()
self.download_progress.hide()
file = self.controller.get_file(self.file.uuid)
if file is None:
self.deleteLater()
Expand Down
20 changes: 16 additions & 4 deletions client/securedrop_client/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,12 @@
)
from securedrop_client.crypto import GpgHelper
from securedrop_client.queue import ApiJobQueue
from securedrop_client.sdk import AuthError, RequestTimeoutError, ServerConnectionError
from securedrop_client.sdk import (
AuthError,
ProgressProxy,
RequestTimeoutError,
ServerConnectionError,
)
from securedrop_client.sync import ApiSync
from securedrop_client.utils import check_dir_permissions

Expand Down Expand Up @@ -825,7 +830,10 @@ def set_status(self, message: str, duration: int = 5000) -> None:

@login_required
def _submit_download_job(
self, object_type: type[db.Reply] | type[db.Message] | type[db.File], uuid: str
self,
object_type: type[db.Reply] | type[db.Message] | type[db.File],
uuid: str,
progress: ProgressProxy | None = None,
) -> None:
if object_type == db.Reply:
job: ReplyDownloadJob | MessageDownloadJob | FileDownloadJob = ReplyDownloadJob(
Expand All @@ -842,6 +850,7 @@ def _submit_download_job(
job.success_signal.connect(self.on_file_download_success)
job.failure_signal.connect(self.on_file_download_failure)

job.progress = progress
self.add_job.emit(job)

def download_new_messages(self) -> None:
Expand Down Expand Up @@ -974,12 +983,15 @@ def on_file_open(self, file: db.File) -> None:

@login_required
def on_submission_download(
self, submission_type: type[db.File] | type[db.Message], submission_uuid: str
self,
submission_type: type[db.File] | type[db.Message],
submission_uuid: str,
progress: ProgressProxy | None = None,
) -> None:
"""
Download the file associated with the Submission (which may be a File or Message).
"""
self._submit_download_job(submission_type, submission_uuid)
self._submit_download_job(submission_type, submission_uuid, progress)

def on_file_download_success(self, uuid: str) -> None:
"""
Expand Down
Loading
Loading