fix(koreader): open the dashboard offline without waiting for wi-fi

Open the cached dashboard at once from the menu, gesture and Dashboard
button, then ask for a connection once through KOReader's Wi-Fi setting
and refresh in place. With no cache, show the unavailable state and Retry.

Fixes #1741
This commit is contained in:
neon
2026-10-11 05:43:07 +00:00
parent 7e11d39724
commit 62cb4f4411
5 changed files with 263 additions and 24 deletions
@@ -307,9 +307,7 @@ function BookOrbitCatalog:init()
self.paths = self.stack self.paths = self.stack
UIManager:nextTick(function() UIManager:nextTick(function()
self:hydrateOnDeviceMaps() self:hydrateOnDeviceMaps()
if self:shouldRefreshDashboardOnOpen() then self:refreshDashboardOnOpen()
self:loadDashboardRoot(true, { invisible = true })
end
-- One-off cache migration, queued behind the refresh so its directory -- One-off cache migration, queued behind the refresh so its directory
-- walk cannot delay the first dashboard request. -- walk cannot delay the first dashboard request.
UIManager:scheduleIn(1.0, function() UIManager:scheduleIn(1.0, function()
@@ -475,10 +473,15 @@ function BookOrbitCatalog:markStackDirty()
end end
end end
-- Going home shows the cached dashboard at once and refreshes it behind the
-- redraw, like opening the catalog does, so it never waits on a connection.
function BookOrbitCatalog:goDashboard() function BookOrbitCatalog:goDashboard()
self.stack = {} self.stack = {}
self:updateReturnPath() local items, context = self:initialDashboardContext()
self:loadDashboardRoot(true) self:switchTo(context.title or self.title, items, context, false)
UIManager:nextTick(function()
self:refreshDashboardOnOpen()
end)
end end
function BookOrbitCatalog:sectionSubtitle(page, q, has_next) function BookOrbitCatalog:sectionSubtitle(page, q, has_next)
@@ -2421,11 +2424,9 @@ function BookOrbitCatalog:onReturn()
if self.current_context.kind == "dashboard" then if self.current_context.kind == "dashboard" then
self:scheduleThumbnailDownloads(self.dashboardBooks(self.current_context.dashboard)) self:scheduleThumbnailDownloads(self.dashboardBooks(self.current_context.dashboard))
end end
if self:shouldRefreshDashboardOnOpen() then UIManager:nextTick(function()
UIManager:nextTick(function() self:refreshDashboardOnOpen()
self:loadDashboardRoot(true, { invisible = true }) end)
end)
end
end end
self:updateLeftIcon() self:updateLeftIcon()
if dirty then if dirty then
@@ -178,22 +178,22 @@ function CatalogDashboard:cachedDashboardContext(cached, opts)
end end
-- What the catalog widget opens on, built without touching the network: the -- What the catalog widget opens on, built without touching the network: the
-- cached dashboard when there is one, otherwise a placeholder the pending -- cached dashboard when there is one, otherwise a placeholder the refresh
-- refresh replaces in place. -- replaces in place. Offline the placeholder says the dashboard could not be
-- loaded, because a connection may never come.
function CatalogDashboard:initialDashboardContext() function CatalogDashboard:initialDashboardContext()
local cached = self:dashboardCache() local cached = self:dashboardCache()
if cached then if cached then
return self:cachedDashboardContext(cached) return self:cachedDashboardContext(cached)
end end
if self:shouldRefreshDashboardOnOpen() then if NetworkMgr:isConnected() then
return self:dashboardContext(nil, { stale = true, loading = true }) return self:dashboardContext(nil, { stale = true, loading = true })
end end
return self:dashboardContext(nil, { stale = true, unavailable = true }) return self:dashboardContext(nil, { stale = true, unavailable = true })
end end
-- An offline-tolerant open (auto-open, offline browse) never asks for a -- An offline-tolerant catalog never asks for a connection on its own. An
-- connection on its own; only an explicit browse goes through the connected -- explicit open is not offline-tolerant until its first refresh has asked.
-- gate.
function CatalogDashboard:shouldRefreshDashboardOnOpen() function CatalogDashboard:shouldRefreshDashboardOnOpen()
if self.prefer_cached_dashboard and not NetworkMgr:isConnected() then if self.prefer_cached_dashboard and not NetworkMgr:isConnected() then
return false return false
@@ -201,6 +201,17 @@ function CatalogDashboard:shouldRefreshDashboardOnOpen()
return true return true
end end
-- The refresh behind a dashboard that has just been shown. An explicit open
-- asks for a connection once, through the user's Wi-Fi policy, while the
-- cached dashboard is already on screen. Returning to the dashboard later
-- keeps the cache while offline rather than asking again.
function CatalogDashboard:refreshDashboardOnOpen()
if self:shouldRefreshDashboardOnOpen() then
self:loadDashboardRoot(true, { invisible = true })
end
self.prefer_cached_dashboard = true
end
function CatalogDashboard:dashboardRoot(opts) function CatalogDashboard:dashboardRoot(opts)
local function requestIsCurrent() local function requestIsCurrent()
return not opts or not opts.is_current or opts.is_current() return not opts or not opts.is_current or opts.is_current()
@@ -325,6 +336,11 @@ function CatalogDashboard:loadDashboardRoot(replace, opts)
and self.current_context == expected_context and self.current_context == expected_context
end end
self:runConnected(function() self:runConnected(function()
-- The screen said the dashboard could not be loaded while this waited
-- for a connection, so the fetch shows that it is now loading.
if expected_context and expected_context.unavailable then
request_opts.invisible = nil
end
local items, context = self:dashboardRoot(request_opts) local items, context = self:dashboardRoot(request_opts)
if self.catalog_closed or request_generation ~= self.dashboard_request_generation if self.catalog_closed or request_generation ~= self.dashboard_request_generation
or self.current_context ~= expected_context then or self.current_context ~= expected_context then
+5 -8
View File
@@ -1113,19 +1113,16 @@ function BookOrbit:openCatalogBrowser(prefer_cached_dashboard)
UIManager:show(self.catalog_browser) UIManager:show(self.catalog_browser)
end end
function BookOrbit:browseCatalog(allow_offline) -- Opening never waits for a connection: the catalog shows the cached dashboard
-- at once and asks for one itself when it refreshes. An offline-tolerant open
-- (the auto-open) does not ask at all.
function BookOrbit:browseCatalog(prefer_cached_dashboard)
if not self:isLoggedIn() then if not self:isLoggedIn() then
promptLogin() promptLogin()
return return
end end
if allow_offline then self:openCatalogBrowser(prefer_cached_dashboard == true)
self:openCatalogBrowser(true)
else
NetworkMgr:runWhenConnected(function()
self:openCatalogBrowser(false)
end)
end
end end
-- Manual sync triggers -- Manual sync triggers
@@ -192,4 +192,97 @@ assertEqual(last_fetch_opts.invisible, true, "automatic dashboard refresh is non
assertEqual(switches, 0, "stale dashboard response cannot replace a navigated view") assertEqual(switches, 0, "stale dashboard response cannot replace a navigated view")
assertEqual(catalog.current_context, navigated_context, "navigation context remains current") assertEqual(catalog.current_context, navigated_context, "navigation context remains current")
-- An explicit open while offline shows the cache at once and asks for a
-- connection a single time, through the user's Wi-Fi policy. Returning to the
-- dashboard while still offline keeps the cache rather than asking again.
local function openedCatalog(opts)
local opened = newCatalog(opts)
local _, initial = opened:initialDashboardContext()
opened.current_context = initial
opened.connection_requests = {}
function opened:runConnected(fn)
table.insert(self.connection_requests, fn)
end
function opened:switchTo(_, _, next_context)
self.current_context = next_context
end
return opened
end
connected = false
catalog = openedCatalog{ cache = cached_dashboard }
assertEqual(catalog.current_context.dashboard, cached_dashboard, "an offline explicit open shows the cache at once")
catalog:refreshDashboardOnOpen()
assertEqual(#catalog.connection_requests, 1, "an offline explicit open asks for a connection")
catalog:refreshDashboardOnOpen()
assertEqual(#catalog.connection_requests, 1, "returning to the dashboard offline does not ask again")
-- The connection arrives: the cached dashboard is refreshed in place, quietly.
connected = true
requests = 0
catalog.connection_requests[1]()
assertEqual(requests, 1, "the refresh runs once the connection arrives")
assertEqual(last_fetch_opts.invisible, true, "a refresh behind the cached dashboard stays non-modal")
assertEqual(catalog.current_context.stale, false, "the cached dashboard is replaced by fresh data")
catalog:refreshDashboardOnOpen()
assertEqual(#catalog.connection_requests, 2, "returning to the dashboard online still refreshes")
-- With no cache an offline explicit open says the dashboard could not be
-- loaded rather than promising a load that may never come, and still asks.
connected = false
catalog = openedCatalog{}
assertEqual(catalog.current_context.unavailable, true, "an offline open with no cache reports unavailable")
assertEqual(catalog.current_context.loading, false, "an offline open with no cache does not claim to be loading")
catalog:refreshDashboardOnOpen()
assertEqual(#catalog.connection_requests, 1, "an offline open with no cache still asks for a connection")
connected = true
catalog.connection_requests[1]()
assertEqual(last_fetch_opts.invisible, nil, "once connected, the fetch shows that the dashboard is loading")
assertEqual(catalog.current_context.unavailable, false, "the unavailable placeholder is replaced")
-- The auto-open never asks for a connection, offline or on later returns.
connected = false
catalog = openedCatalog{ prefer_cached_dashboard = true, cache = cached_dashboard }
catalog:refreshDashboardOnOpen()
catalog:refreshDashboardOnOpen()
assertEqual(#catalog.connection_requests, 0, "an offline auto-open never asks for a connection")
connected = true
-- Going home from deep in the catalog (the Dashboard button, hold Return)
-- switches to the cached dashboard at once instead of waiting on a connection.
-- bookorbit_catalog.lua cannot be loaded outside KOReader, so the method is
-- lifted out and run against the real dashboard methods.
local catalog_source = assert(io.open("koreader-plugin/bookorbit.koplugin/bookorbit_catalog.lua", "rb"))
local go_dashboard_source = assert(catalog_source:read("*a"):match("\n(function BookOrbitCatalog:goDashboard%(.-\nend)\n"),
"goDashboard is defined in bookorbit_catalog.lua")
catalog_source:close()
local next_ticks = {}
local go_dashboard_env = {
BookOrbitCatalog = {},
UIManager = {
nextTick = function(_, fn)
table.insert(next_ticks, fn)
end,
},
}
local go_dashboard_chunk = assert(loadstring(go_dashboard_source, "goDashboard"))
setfenv(go_dashboard_chunk, go_dashboard_env)
go_dashboard_chunk()
connected = false
catalog = openedCatalog{ cache = cached_dashboard }
catalog:refreshDashboardOnOpen()
local detail_context = { kind = "detail" }
catalog.current_context = detail_context
catalog.stack = { { context = catalog.current_context } }
catalog.goDashboard = go_dashboard_env.BookOrbitCatalog.goDashboard
catalog.updateReturnPath = function() end
catalog:goDashboard()
assertEqual(catalog.current_context.kind, "dashboard", "going home offline shows the dashboard at once")
assertEqual(catalog.current_context.dashboard, cached_dashboard, "going home offline shows the cached dashboard")
assertEqual(#catalog.stack, 0, "going home clears the navigation stack")
assertEqual(#next_ticks, 1, "the refresh is queued behind the redraw")
next_ticks[1]()
assertEqual(#catalog.connection_requests, 1, "going home offline does not ask for a connection again")
print("bookorbit_catalog_dashboard_startup_test.lua: ok") print("bookorbit_catalog_dashboard_startup_test.lua: ok")
@@ -0,0 +1,132 @@
-- Issue 1741: opening the dashboard must never wait for a connection. Every
-- entry point opens the catalog at once; only the auto-open is offline-tolerant
-- from the start, and asking for a connection is left to the catalog's refresh.
package.loaded["gettext"] = function(text)
return text
end
package.loaded["ffi/util"] = {
template = function(text)
return text
end,
}
package.loaded["ui/uimanager"] = {}
package.loaded["device"] = { model = "test-device" }
package.loaded["ui/widget/infomessage"] = { new = function(_, opts) return opts end }
package.loaded["ui/widget/inputdialog"] = {}
package.loaded["ui/widget/multiinputdialog"] = {}
package.loaded["ui/widget/notification"] = {}
package.loaded["ffi/sha2"] = { md5 = function(value) return value end }
package.loaded["util"] = {
trim = function(value)
return tostring(value or ""):match("^%s*(.-)%s*$")
end,
}
package.loaded["bookorbit_api"] = {}
package.loaded["bookorbit_highlight_diagnostics"] = {}
package.loaded["bookorbit_sweep"] = {}
local waits = 0
local function waitForConnection()
waits = waits + 1
return true
end
local NetworkMgr = {
isConnected = function()
return false
end,
runWhenConnected = waitForConnection,
willRerunWhenConnected = waitForConnection,
beforeWifiAction = waitForConnection,
}
package.loaded["ui/network/manager"] = NetworkMgr
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local MainMenu = require("bookorbit_main_menu")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
local function read(path)
local file = assert(io.open(path, "rb"))
local content = file:read("*a")
file:close()
return content
end
-- The entry points live in main.lua, which cannot be loaded whole outside
-- KOReader, so each one is lifted out and run against stubs.
local main = read("koreader-plugin/bookorbit.koplugin/main.lua")
local prompted_login = 0
local env = {
BookOrbit = {},
NetworkMgr = NetworkMgr,
promptLogin = function()
prompted_login = prompted_login + 1
end,
}
for _, name in ipairs({ "isLoggedIn", "browseCatalog", "onBookOrbitOpenDashboard", "maybeAutoOpenCatalog" }) do
local source = assert(main:match("\n(function BookOrbit:" .. name .. "%(.-\nend)\n"), name .. " is defined in main.lua")
local chunk = assert(loadstring(source, name))
setfenv(chunk, env)
chunk()
end
local opened = {}
local plugin = {
SYNC_STRATEGY = { PROMPT = 1, SILENT = 2, DISABLE = 3 },
settings = { server_url = "http://bookorbit.test/api/v1", username = "reader", userkey = "key" },
}
MainMenu.install(plugin)
for name, fn in pairs(env.BookOrbit) do
plugin[name] = fn
end
plugin.settingsMenu = function()
return {}
end
plugin.openCatalogBrowser = function(_, prefer_cached_dashboard)
table.insert(opened, prefer_cached_dashboard)
end
local function openDashboardMenuItem()
local menu_items = {}
plugin:addToMainMenu(menu_items)
for _, item in ipairs(menu_items.bookorbit.sub_item_table) do
if item.id == "open_dashboard" then return item end
end
error("the BookOrbit menu has an Open dashboard item")
end
-- Tools > BookOrbit > Open dashboard while offline.
openDashboardMenuItem().callback()
assertEqual(#opened, 1, "the menu opens the dashboard while offline")
assertEqual(opened[1], false, "the menu open is explicit, so its refresh may ask for a connection")
assertEqual(waits, 0, "the menu does not wait for a connection before opening")
-- The gesture and dispatcher action.
opened = {}
plugin:onBookOrbitOpenDashboard()
assertEqual(#opened, 1, "the dispatcher action opens the dashboard while offline")
assertEqual(opened[1], false, "the dispatcher action is an explicit open")
assertEqual(waits, 0, "the dispatcher action does not wait for a connection")
-- The auto-open stays offline-tolerant: it never asks for a connection.
opened = {}
plugin.ui = { file_chooser = {} }
plugin:maybeAutoOpenCatalog("filemanager")
assertEqual(#opened, 1, "the auto-open opens the dashboard")
assertEqual(opened[1], true, "the auto-open prefers the cached dashboard")
assertEqual(waits, 0, "the auto-open does not wait for a connection")
-- Logged out, nothing opens and the user is told to log in.
opened = {}
plugin.settings = {}
plugin:browseCatalog()
assertEqual(#opened, 0, "a logged-out open does not open the catalog")
assertEqual(prompted_login, 1, "a logged-out open asks the user to log in")
print("bookorbit_dashboard_offline_open_test.lua: ok")