From 3f19a2a05e2035c5cf37e3a33582c162dfbaea6f Mon Sep 17 00:00:00 2001 From: Linus045 Date: Thu, 19 Jun 2025 10:15:40 +0200 Subject: [PATCH] feat: Adds progress bar to cloud storage downloads (#13650) --- frontend/apps/cloudstorage/cloudstorage.lua | 31 ++-- frontend/apps/cloudstorage/dropbox.lua | 4 +- frontend/apps/cloudstorage/dropboxapi.lua | 10 +- frontend/apps/cloudstorage/ftp.lua | 9 +- frontend/apps/cloudstorage/webdav.lua | 11 +- frontend/apps/cloudstorage/webdavapi.lua | 10 +- frontend/socketutil.lua | 18 ++ frontend/ui/widget/progressbardialog.lua | 174 ++++++++++++++++++++ 8 files changed, 246 insertions(+), 21 deletions(-) create mode 100644 frontend/ui/widget/progressbardialog.lua diff --git a/frontend/apps/cloudstorage/cloudstorage.lua b/frontend/apps/cloudstorage/cloudstorage.lua index b13ddf7c6..0c918b4cc 100644 --- a/frontend/apps/cloudstorage/cloudstorage.lua +++ b/frontend/apps/cloudstorage/cloudstorage.lua @@ -12,6 +12,7 @@ local LuaSettings = require("luasettings") local Menu = require("ui/widget/menu") local NetworkMgr = require("ui/network/manager") local PathChooser = require("ui/widget/pathchooser") +local ProgressbarDialog = require("ui/widget/progressbardialog") local UIManager = require("ui/uimanager") local WebDav = require("apps/cloudstorage/webdav") local lfs = require("libs/libkoreader-lfs") @@ -213,19 +214,29 @@ end function CloudStorage:downloadFile(item) local function startDownloadFile(unit_item, address, username, password, path_dir, callback_close) + local progressbar_dialog = ProgressbarDialog:new { + title = _("Downloading…"), + subtitle = unit_item.text, + progress_max = unit_item.filesize, + } + UIManager:scheduleIn(1, function() - if self.type == "dropbox" then - DropBox:downloadFile(unit_item, password, path_dir, callback_close) - elseif self.type == "ftp" then - Ftp:downloadFile(unit_item, address, username, password, path_dir, callback_close) - elseif self.type == "webdav" then - WebDav:downloadFile(unit_item, address, username, password, path_dir, callback_close) + local progress_callback = function(progress) + progressbar_dialog:reportProgress(progress) end + + if self.type == "dropbox" then + DropBox:downloadFile(unit_item, password, path_dir, callback_close, progress_callback) + elseif self.type == "ftp" then + Ftp:downloadFile(unit_item, address, username, password, path_dir, callback_close, nil) + elseif self.type == "webdav" then + WebDav:downloadFile(unit_item, address, username, password, path_dir, callback_close, progress_callback) + end + + progressbar_dialog:close() end) - UIManager:show(InfoMessage:new{ - text = _("Downloading. This might take a moment."), - timeout = 1, - }) + + progressbar_dialog:show() end local function createTitle(filename_orig, filesize, filename, path) -- title for ButtonDialog diff --git a/frontend/apps/cloudstorage/dropbox.lua b/frontend/apps/cloudstorage/dropbox.lua index c3c3d8968..6d6eaf01e 100644 --- a/frontend/apps/cloudstorage/dropbox.lua +++ b/frontend/apps/cloudstorage/dropbox.lua @@ -24,8 +24,8 @@ function DropBox:showFiles(url, password) return DropBoxApi:showFiles(url, password) end -function DropBox:downloadFile(item, password, path, callback_close) - local code_response = DropBoxApi:downloadFile(item.url, password, path) +function DropBox:downloadFile(item, password, path, callback_close, progress_callback) + local code_response = DropBoxApi:downloadFile(item.url, password, path, progress_callback) if code_response == 200 then local __, filename = util.splitFilePathName(path) if G_reader_settings:isTrue("show_unsupported") and not DocumentRegistry:hasProvider(filename) then diff --git a/frontend/apps/cloudstorage/dropboxapi.lua b/frontend/apps/cloudstorage/dropboxapi.lua index 6f06da304..0364b594f 100644 --- a/frontend/apps/cloudstorage/dropboxapi.lua +++ b/frontend/apps/cloudstorage/dropboxapi.lua @@ -106,9 +106,13 @@ function DropBoxApi:fetchListFolders(path, token) logger.warn("DropBoxApi: error:", result_response) end -function DropBoxApi:downloadFile(path, token, local_path) +function DropBoxApi:downloadFile(path, token, local_path, progress_callback) local data1 = "{\"path\": \"" .. path .. "\"}" socketutil:set_timeout(socketutil.FILE_BLOCK_TIMEOUT, socketutil.FILE_TOTAL_TIMEOUT) + + local handle = ltn12.sink.file(io.open(local_path, "w")) + handle = socketutil.chainSinkWithProgressCallback(handle, progress_callback) + local code, headers, status = socket.skip(1, http.request{ url = API_DOWNLOAD_FILE, method = "GET", @@ -116,7 +120,7 @@ function DropBoxApi:downloadFile(path, token, local_path) ["Authorization"] = "Bearer ".. token, ["Dropbox-API-Arg"] = data1, }, - sink = ltn12.sink.file(io.open(local_path, "w")), + sink = handle, }) socketutil:reset_timeout() if code ~= 200 then @@ -195,6 +199,7 @@ function DropBoxApi:listFolder(path, token, folder_mode) table.insert(dropbox_file, { text = text, mandatory = util.getFriendlySize(files.size), + filesize = files.size, url = files.path_display, type = tag, }) @@ -221,6 +226,7 @@ function DropBoxApi:listFolder(path, token, folder_mode) mandatory = files.mandatory, url = files.url, type = files.type, + filesize = files.filesize, }) end return dropbox_list diff --git a/frontend/apps/cloudstorage/ftp.lua b/frontend/apps/cloudstorage/ftp.lua index 5cb47c5e2..06239521e 100644 --- a/frontend/apps/cloudstorage/ftp.lua +++ b/frontend/apps/cloudstorage/ftp.lua @@ -8,6 +8,7 @@ local ReaderUI = require("apps/reader/readerui") local UIManager = require("ui/uimanager") local ltn12 = require("ltn12") local logger = require("logger") +local socketutil = require("socketutil") local util = require("util") local _ = require("gettext") local T = require("ffi/util").template @@ -19,7 +20,7 @@ function Ftp:run(address, user, pass, path) return FtpApi:listFolder(url, path) end -function Ftp:downloadFile(item, address, user, pass, path, callback_close) +function Ftp:downloadFile(item, address, user, pass, path, callback_close, progress_callback) local url = FtpApi:generateUrl(address, util.urlEncode(user), util.urlEncode(pass)) .. item.url logger.dbg("downloadFile url", url) path = util.fixUtf8(path, "_") @@ -30,7 +31,11 @@ function Ftp:downloadFile(item, address, user, pass, path, callback_close) }) return end - local response = FtpApi:ftpGet(url, "retr", ltn12.sink.file(file)) + + local handle = ltn12.sink.file(file) + handle = socketutil.chainSinkWithProgressCallback(handle, progress_callback) + + local response = FtpApi:ftpGet(url, "retr", handle) if response ~= nil then local __, filename = util.splitFilePathName(path) if G_reader_settings:isTrue("show_unsupported") and not DocumentRegistry:hasProvider(filename) then diff --git a/frontend/apps/cloudstorage/webdav.lua b/frontend/apps/cloudstorage/webdav.lua index be2a23e85..69ce6c808 100644 --- a/frontend/apps/cloudstorage/webdav.lua +++ b/frontend/apps/cloudstorage/webdav.lua @@ -17,8 +17,15 @@ function WebDav:run(address, user, pass, path, folder_mode) return WebDavApi:listFolder(address, user, pass, path, folder_mode) end -function WebDav:downloadFile(item, address, username, password, local_path, callback_close) - local code_response = WebDavApi:downloadFile(WebDavApi:getJoinedPath(address, item.url), username, password, local_path) +function WebDav:downloadFile(item, address, username, password, local_path, callback_close, progress_callback) + local code_response = WebDavApi:downloadFile( + WebDavApi:getJoinedPath(address, item.url), + username, + password, + local_path, + progress_callback + ) + if code_response == 200 then local __, filename = util.splitFilePathName(local_path) if G_reader_settings:isTrue("show_unsupported") and not DocumentRegistry:hasProvider(filename) then diff --git a/frontend/apps/cloudstorage/webdavapi.lua b/frontend/apps/cloudstorage/webdavapi.lua index 3865d6163..5eff429bc 100644 --- a/frontend/apps/cloudstorage/webdavapi.lua +++ b/frontend/apps/cloudstorage/webdavapi.lua @@ -162,13 +162,17 @@ function WebDavApi:listFolder(address, user, pass, folder_path, folder_mode) return webdav_list end -function WebDavApi:downloadFile(file_url, user, pass, local_path) +function WebDavApi:downloadFile(file_url, user, pass, local_path, progress_callback) socketutil:set_timeout(socketutil.FILE_BLOCK_TIMEOUT, socketutil.FILE_TOTAL_TIMEOUT) logger.dbg("WebDavApi: downloading file: ", file_url) - local code, headers, status = socket.skip(1, http.request{ + + local handle = ltn12.sink.file(io.open(local_path, "w")) + handle = socketutil.chainSinkWithProgressCallback(handle, progress_callback) + + local code, headers, status = socket.skip(1, http.request { url = file_url, method = "GET", - sink = ltn12.sink.file(io.open(local_path, "w")), + sink = handle, user = user, password = pass, }) diff --git a/frontend/socketutil.lua b/frontend/socketutil.lua index e11745ae1..1c2c338b2 100644 --- a/frontend/socketutil.lua +++ b/frontend/socketutil.lua @@ -172,4 +172,22 @@ function socketutil.redact_request(request) return safe_request end +function socketutil.chainSinkWithProgressCallback(sink, progressCallback) + if sink == nil or progressCallback == nil then + return sink + end + + local downloaded_bytes = 0 + local progress_reporter_filter = function(chunk, err) + if chunk ~= nil then + -- accumulate the downloaded bytes so we don't need to check the actual file every time + downloaded_bytes = downloaded_bytes + chunk:len() + progressCallback(downloaded_bytes) + end + return chunk, err + end + + return ltn12.sink.chain(progress_reporter_filter, sink) +end + return socketutil diff --git a/frontend/ui/widget/progressbardialog.lua b/frontend/ui/widget/progressbardialog.lua new file mode 100644 index 000000000..ca58f8994 --- /dev/null +++ b/frontend/ui/widget/progressbardialog.lua @@ -0,0 +1,174 @@ +--[[-- +A dialog that shows a progress bar with a title and subtitle. + +@usage +local progressbar_dialog = ProgressbarDialog:new { + title = nil, + subtitle = nil, + progress_max = nil + refresh_time_seconds = 3, +} +Note: provide at least one of title, subtitle or progress_max +@param title string the title of the dialog +@param subtitle string the subtitle of the dialog +@param progress_max number the maximum progress (e.g. size of the file in bytes for file downloads) + reportProgress() should be called with the current + progress (value between 0-progress_max) to update the progress bar + optional: if `progress_max` is nil, the progress bar will be hidden +@param refresh_time_seconds number refresh time in seconds + +-- Attach progress callback and call show() +progressbar_dialog:show() + +-- Call close() when download is done +progressbar_dialog:close() + +-- To report progress, you can either: +-- manually call reportProgress with the current progress (value between 0-progress_max) +progressbar_dialog:reportProgress( ) + +-- or when using luasocket sinks, chain the callback: +local sink = ltn12.sink.file(io.open(local_path, "w")) +sink = socketutil.chainSinkWithProgressCallback(sink, function(progress) + progressbar_dialog:reportProgress(progress) +end) +--]] + +local Blitbuffer = require("ffi/blitbuffer") +local Device = require("device") +local Font = require("ui/font") +local FrameContainer = require("ui/widget/container/framecontainer") +local ProgressWidget = require("ui/widget/progresswidget") +local Size = require("ui/size") +local TextWidget = require("ui/widget/textwidget") +local UIManager = require("ui/uimanager") +local VerticalGroup = require("ui/widget/verticalgroup") +local WidgetContainer = require("ui/widget/container/widgetcontainer") +local dbg = require("dbg") +local time = require("ui/time") +local Screen = Device.screen + +local ProgressbarDialog = WidgetContainer:extend { + refresh_time_seconds = 3, +} + +function ProgressbarDialog:init() + self.align = "center" + self.dimen = Screen:getSize() + + self.progress_bar_visible = self.progress_max ~= nil and self.progress_max > 0 + + -- used for internal state + self.last_redraw_time_ms = 0 + + -- create the dialog + local progress_bar_width = Screen:getWidth() - Screen:scaleBySize(80) + local progress_bar_height = Screen:scaleBySize(18) + + -- only add relevant widgets + local vertical_group = VerticalGroup:new {} + if self.title then + vertical_group[#vertical_group + 1] = TextWidget:new { + text = self.title or "", + face = Font:getFace("ffont"), + bold = true, + max_width = progress_bar_width, + } + end + if self.subtitle then + vertical_group[#vertical_group + 1] = TextWidget:new { + text = self.subtitle or "", + face = Font:getFace("smallffont"), + max_width = progress_bar_width, + } + end + if self.progress_bar_visible then + self.progress_bar = ProgressWidget:new { + width = progress_bar_width, + height = progress_bar_height, + padding = Size.padding.large, + margin = Size.margin.tiny, + percentage = 0, + } + vertical_group[#vertical_group + 1] = self.progress_bar + end + + self[1] = FrameContainer:new { + radius = Size.radius.window, + bordersize = Size.border.window, + padding = Size.padding.large, + background = Blitbuffer.COLOR_WHITE, + vertical_group + } +end + +dbg:guard(ProgressbarDialog, "init", + nil, + function(self) + assert(self.progress_max == nil or + (type(self.progress_max) == "number" and self.progress_max > 0), + "Wrong self.progress_max type (expected nil or number greater than 0), value was: " .. + tostring(self.progress_max)) + assert(type(self.refresh_time_seconds) == "number" and self.refresh_time_seconds > 0, + "Wrong self.refresh_time_seconds type (expected number greater than 0), value was: " .. + tostring(self.refresh_time_seconds)) + assert(self.title == nil or type(self.title) == "string", + "Wrong title type (expected nil or string), value was of type: " .. type(self.title)) + assert(self.subtitle == nil or type(self.subtitle) == "string", + "Wrong subtitle type (expected nil or string), value was of type: " .. type(self.subtitle)) + assert(self.title or self.subtitle or self.progress_max, + "No values defined, dialog would be empty. Please provide at least one of title, subtitle or progress_max") + end) + +--- Updates the UI to show the current percentage of the progress bar when needed. +function ProgressbarDialog:redrawProgressbarIfNeeded() + -- grab the current percentage from the progress bar + local current_percentage = self.progress_bar.percentage + + -- if we are at 100% always redraw + if current_percentage >= 1 then + self:redrawProgressbar() + return + end + + -- check if enough time has passed + local current_time_ms = time.now() + local time_delta_ms = current_time_ms - self.last_redraw_time_ms + local refresh_time_ms = self.refresh_time_seconds * 1000 * 1000 + if time_delta_ms >= refresh_time_ms then + self.last_redraw_time_ms = current_time_ms + self:redrawProgressbar() + end +end + +function ProgressbarDialog:redrawProgressbar() + --UI is not updating during file download so force an update + UIManager:setDirty(self, function() return "fast", self.progress_bar.dimen end) + UIManager:forceRePaint() +end + +--- Used to notify about a progress update. +-- @param progress number the current progress (e.g. size of the file in bytes for file downloads) +function ProgressbarDialog:reportProgress(progress) + if not self.progress_bar_visible then + return + end + + -- set percentage of progress bar internally, this does not yet update the screen element + self.progress_bar:setPercentage(progress / self.progress_max) + + -- actually draw the progress bar update + self:redrawProgressbarIfNeeded() +end + +--- Opens dialog. +function ProgressbarDialog:show() + UIManager:show(self, "ui") +end + +---- Closes dialog. +function ProgressbarDialog:close() + UIManager:close(self, "ui") +end + +return ProgressbarDialog