Files
koreader/frontend/ui/network/manager.lua
NiLuJe 34ba2fab30 NetworkMgr: Handle non-blocking turnOnWifi implementations better (#10863)
* Device: Add a `hasSeamlessWifiToggle` devcap to complement `hasWifiToggle`, to denote platforms where we can toggle WiFi without losing focus, as this has obvious UX impacts, and less obvious technical impacts on some of the NetworkMgr innards...
* Android: Mark as `!hasSeamlessWifiToggle`, as it requires losing focus to the system settings. Moreover, `turnOnWifi` returns *immediately* and we *still* run in the background during that time, for extra spiciness...
* NetworkMgr: Ensure only *one* call to `turnOnWifi` will actually go on when stuff gets re-scheduled by the `beforeWifiAction` framework.
* NetworkMgr: Ensure the `beforeWifiAction` framework will not re-schedule the same thing *ad vitam aeternam* if a previous connection attempt is still ongoing. (i.e., previously, on Android, if you backed out of the system settings, you entered the Benny Hill dimension, as NetworkMgr would keep throwing you back into the system settings ;p). This has a few implications on callbacks requested by subsequent connection attempts, though. Generally, we'll try to honor *explicitly interactive* callbacks, but `beforeWifiAction` stuff will be dropped (only the original cb is preserved). That's what prevents the aforementioned infinite loop, as the `beforeWifiAction` framework was based on the assumption that `turnOnWifi` somewhat guaranteed `isConnected` to be true on return, something which is only actually true on `hasWifiManager` platforms.
* NetworkMgr: In `prompt` mode, the above implies that the prompt will not even be shown for concurrent attempts, as it's otherwise extremely confusing (KOSync on Android being a prime example, as it has a pair of Suspend/Resume handlers, so the initial attempt trips those two because of the focus switch >_<").
* NetworkMgr: Don't attempt to kill wifi when aborting a connection attempt on `!hasSeamlessWifiToggle` (because, again, it'll break UX, and also because it might run at very awkward times (e.g., I managed to go back to KOReader *between* a FM/Reader switch at one point, which promptly caused `UIManager` to exit because there was nothing to show ;p).
* NetworkMgr: Don't drop the connectivity callback when `beforeWifiAction` is set to prompt and the target happens to use a connectivity check in its `turnOnWifi` implementation (e.g., on Kindle).
* Android: Add an `"ignore"` `beforeWifiAction` mode, that'll do nothing but schedule the connectivity check with its callback (with the intent being the system will eventually enable wifi on its own Soon(TM)). If you're already online, the callback will run immediately, obviously. If you followed the early discussions on this PR, this closely matches what happens on `!hasWifiToggle` platforms (as flagging Android that way was one of the possible approaches here).
* NetworkMgr: Bail out early in `goOnlineToRun` if `beforeWifiAction` isn't `"turn_on"`. Prompt cannot work there, and while ignore technically could, it would serve very little purpose given its intended use case.
* KOSync: Neuter the Resume/Suspend handlers early on `CloseDocument`, as this is how focus switches are handled on Android, and if `beforeWifiAction` is `turn_on` and you were offline at the time, we'd trip them because of the swap to system settings to enable wifi.
* KOSync: Allow `auto_sync` to be enabled regardless of the `beforeWifiAction` mode on `!hasSeamlessWifiToggle` platforms. Prompt is still a terrible idea, but given that `goOnlineToRun` now aborts early if the mode is not supported, it's less of a problem.
2023-09-21 18:21:09 +02:00

1145 lines
45 KiB
Lua
Raw Blame History

local BD = require("ui/bidi")
local ConfirmBox = require("ui/widget/confirmbox")
local DataStorage = require("datastorage")
local Device = require("device")
local Event = require("ui/event")
local InfoMessage = require("ui/widget/infomessage")
local LuaSettings = require("luasettings")
local MultiConfirmBox = require("ui/widget/multiconfirmbox")
local UIManager = require("ui/uimanager")
local ffi = require("ffi")
local ffiutil = require("ffi/util")
local logger = require("logger")
local time = require("ui/time")
local util = require("util")
local _ = require("gettext")
local C = ffi.C
local T = ffiutil.template
-- We'll need a bunch of stuff for getifaddrs in NetworkMgr:ifHasAnAddress
require("ffi/posix_h")
-- We unfortunately don't have that one in ffi/posix_h :/
local EBUSY = 16
local NetworkMgr = {
is_wifi_on = false,
is_connected = false,
interface = nil,
pending_connectivity_check = false,
pending_connection = false,
_before_action_tripped = nil,
}
function NetworkMgr:readNWSettings()
self.nw_settings = LuaSettings:open(DataStorage:getSettingsDir().."/network.lua")
end
-- Common chunk of stuff we have to do when aborting a connection attempt
function NetworkMgr:_abortWifiConnection()
-- Cancel any pending connectivity check, because it wouldn't achieve anything
self:unscheduleConnectivityCheck()
self.wifi_was_on = false
G_reader_settings:makeFalse("wifi_was_on")
-- Murder Wi-Fi and the async script (if any) first...
if Device:hasWifiRestore() and not Device:isKindle() then
os.execute("pkill -TERM restore-wifi-async.sh 2>/dev/null")
end
-- We were never connected to begin with, so, no disconnecting broadcast required
if Device:hasSeamlessWifiToggle() then
-- We only want to actually kill the WiFi on platforms where we can do that seamlessly.
self:turnOffWifi()
end
-- We're obviously done with this connection attempt
self.pending_connection = false
end
-- Attempt to deal with platforms that don't guarantee isConnected when turnOnWifi returns,
-- so that we only attempt to connect to WiFi *once* when using the beforeWifiAction framework...
function NetworkMgr:requestToTurnOnWifi(wifi_cb, interactive)
if self.pending_connection then
-- We've already enabled WiFi, don't try again until the earlier attempt succeeds or fails...
return EBUSY
end
-- Connecting will take a few seconds, broadcast that information so affected modules/plugins can react.
UIManager:broadcastEvent(Event:new("NetworkConnecting"))
self.pending_connection = true
return self:turnOnWifi(wifi_cb, interactive)
end
-- Used after restoreWifiAsync() and the turn_on beforeWifiAction to make sure we eventually send a NetworkConnected event,
-- as quite a few things rely on it (KOSync, c.f. #5109; the network activity check, c.f., #6424).
function NetworkMgr:connectivityCheck(iter, callback, widget)
-- Give up after a while (restoreWifiAsync can take over 45s, so, try to cover that)...
if iter >= 180 then
logger.info("Failed to restore Wi-Fi (after", iter * 0.25, "seconds)!")
self:_abortWifiConnection()
-- Handle the UI warning if it's from a beforeWifiAction...
if widget then
UIManager:close(widget)
UIManager:show(InfoMessage:new{ text = _("Error connecting to the network") })
end
return
end
self:queryNetworkState()
if self.is_wifi_on and self.is_connected then
self.wifi_was_on = true
G_reader_settings:makeTrue("wifi_was_on")
logger.info("Wi-Fi successfully restored (after", iter * 0.25, "seconds)!")
UIManager:broadcastEvent(Event:new("NetworkConnected"))
-- Handle the UI & callback if it's from a beforeWifiAction...
if widget then
UIManager:close(widget)
end
if callback then
callback()
else
-- If this trickled down from a turn_onbeforeWifiAction and there is no callback,
-- mention that the action needs to be retried manually.
if widget then
UIManager:show(InfoMessage:new{
text = _("You can now retry the action that required network access"),
timeout = 3,
})
end
end
self.pending_connectivity_check = false
-- We're done, so we can stop blocking concurrent connection attempts
self.pending_connection = false
else
UIManager:scheduleIn(0.25, self.connectivityCheck, self, iter + 1, callback, widget)
end
end
function NetworkMgr:scheduleConnectivityCheck(callback, widget)
self.pending_connectivity_check = true
UIManager:scheduleIn(0.25, self.connectivityCheck, self, 1, callback, widget)
end
function NetworkMgr:unscheduleConnectivityCheck()
UIManager:unschedule(self.connectivityCheck)
self.pending_connectivity_check = false
end
function NetworkMgr:init()
Device:initNetworkManager(self)
self.interface = self:getNetworkInterfaceName()
self:queryNetworkState()
self.wifi_was_on = G_reader_settings:isTrue("wifi_was_on")
-- Trigger an initial NetworkConnected event if WiFi was already up when we were launched
if self.is_connected then
-- NOTE: This needs to be delayed because we run on require, while NetworkListener gets spun up sliiightly later on FM/ReaderUI init...
UIManager:nextTick(UIManager.broadcastEvent, UIManager, Event:new("NetworkConnected"))
else
-- Attempt to restore wifi in the background if necessary
if Device:hasWifiRestore() and self.wifi_was_on and G_reader_settings:isTrue("auto_restore_wifi") then
logger.dbg("NetworkMgr: init will restore Wi-Fi in the background")
self:restoreWifiAsync()
self:scheduleConnectivityCheck()
end
end
return self
end
-- The following methods are Device specific, and need to be initialized in Device:initNetworkManager.
-- Some of them can be set by calling NetworkMgr:setWirelessBackend
-- NOTE: The interactive flag is set by callers when the toggle was a *direct* user prompt (i.e., Menu or Gesture),
-- as opposed to an indirect one (like the beforeWifiAction framework).
-- It allows the backend to skip UI prompts for non-interactive use-cases.
-- NOTE: May optionally return a boolean, e.g., return false if the backend can guarantee the connection failed.
function NetworkMgr:turnOnWifi(complete_callback, interactive) end
function NetworkMgr:turnOffWifi(complete_callback) end
-- This function returns the current status of the WiFi radio
-- NOTE: On !hasWifiToggle platforms, we assume networking is always available,
-- so as not to confuse the whole beforeWifiAction framework
-- (and let it fail with network errors when offline, instead of looping on unimplemented stuff...).
function NetworkMgr:isWifiOn()
if not Device:hasWifiToggle() then
return true
end
end
function NetworkMgr:isConnected()
if not Device:hasWifiToggle() then
return true
end
end
function NetworkMgr:getNetworkInterfaceName() end
function NetworkMgr:getNetworkList() end
function NetworkMgr:getCurrentNetwork() end
function NetworkMgr:authenticateNetwork() end
function NetworkMgr:disconnectNetwork() end
-- NOTE: This is currently only called on hasWifiManager platforms!
function NetworkMgr:obtainIP() end
function NetworkMgr:releaseIP() end
-- This function should call both turnOnWifi() and obtainIP() in a non-blocking manner.
function NetworkMgr:restoreWifiAsync() end
-- End of device specific methods
-- Helper functions for devices that use sysfs entries to check connectivity.
function NetworkMgr:sysfsWifiOn()
-- Network interface directory only exists as long as the Wi-Fi module is loaded
return util.pathExists("/sys/class/net/".. self.interface)
end
function NetworkMgr:sysfsCarrierConnected()
-- Read carrier state from sysfs.
-- NOTE: We can afford to use CLOEXEC, as devices too old for it don't support Wi-Fi anyway ;)
local out
local file = io.open("/sys/class/net/" .. self.interface .. "/carrier", "re")
-- File only exists while the Wi-Fi module is loaded, but may fail to read until the interface is brought up.
if file then
-- 0 means the interface is down, 1 that it's up
-- (technically, it reflects the state of the physical link (e.g., plugged in or not for Ethernet))
-- This does *NOT* represent network association state for Wi-Fi (it'll return 1 as soon as ifup)!
out = file:read("*number")
file:close()
end
return out == 1
end
function NetworkMgr:sysfsInterfaceOperational()
-- Reads the interface's RFC2863 operational state from sysfs, and wait for it to be up
-- (For Wi-Fi, that means associated & successfully authenticated)
local out
local file = io.open("/sys/class/net/" .. self.interface .. "/operstate", "re")
-- Possible values: "unknown", "notpresent", "down", "lowerlayerdown", "testing", "dormant", "up"
-- (c.f., Linux's <Documentation/ABI/testing/sysfs-class-net>)
-- We're *assuming* all the drivers we care about implement this properly, so we can just rely on checking for "up".
-- On unsupported drivers, this would be stuck on "unknown" (c.f., Linux's <Documentation/networking/operstates.rst>)
-- NOTE: This does *NOT* mean the interface has been assigned an IP!
if file then
out = file:read("*l")
file:close()
end
return out == "up"
end
-- This relies on the BSD API instead of the Linux ioctls (netdevice(7)), because handling IPv6 is slightly less painful this way...
function NetworkMgr:ifHasAnAddress()
-- If the interface isn't operationally up, no need to go any further
if not self:sysfsInterfaceOperational() then
logger.dbg("NetworkMgr: interface is not operational yet")
return false
end
-- It's up, do the getifaddrs dance to see if it was assigned an IP yet...
-- c.f., getifaddrs(3)
local ifaddr = ffi.new("struct ifaddrs *[1]")
if C.getifaddrs(ifaddr) == -1 then
local errno = ffi.errno()
logger.err("NetworkMgr: getifaddrs:", ffi.string(C.strerror(errno)))
return false
end
local ok
local ifa = ifaddr[0]
while ifa ~= nil do
if ifa.ifa_addr ~= nil and C.strcmp(ifa.ifa_name, self.interface) == 0 then
local family = ifa.ifa_addr.sa_family
if family == C.AF_INET or family == C.AF_INET6 then
local host = ffi.new("char[?]", C.NI_MAXHOST)
local s = C.getnameinfo(ifa.ifa_addr,
family == C.AF_INET and ffi.sizeof("struct sockaddr_in") or ffi.sizeof("struct sockaddr_in6"),
host, C.NI_MAXHOST,
nil, 0,
C.NI_NUMERICHOST)
if s ~= 0 then
logger.err("NetworkMgr: getnameinfo:", ffi.string(C.gai_strerror(s)))
ok = false
else
logger.dbg("NetworkMgr: interface", self.interface, "is up @", ffi.string(host))
ok = true
end
-- Regardless of failure, we only check a single if, so we're done
break
end
end
ifa = ifa.ifa_next
end
C.freeifaddrs(ifaddr[0])
return ok
end
-- Wrappers around turnOnWifi & turnOffWifi with proper Event signaling
function NetworkMgr:enableWifi(wifi_cb, connectivity_cb, connectivity_widget, interactive)
local status = self:requestToTurnOnWifi(wifi_cb, interactive)
-- If turnOnWifi failed, abort early
if status == false then
logger.warn("NetworkMgr:enableWifi: Connection failed!")
self:_abortWifiConnection()
return false
elseif status == EBUSY then
logger.warn("NetworkMgr:enableWifi: A previous connection attempt is still ongoing!")
-- We warn, but do keep going on with scheduling the callback iff it was interactive.
-- If it wasn't, it might have been from a beforeWifiAction, and, much like in turnOnWifiAndWaitForConnection,
-- we don't want to risk rescheduling the same thing over and over again.
if interactive then
-- Unlike the next branch, turnOnWifi was *not* called, so we don't need the extra checks.
self:scheduleConnectivityCheck(connectivity_cb, connectivity_widget)
else
-- No connectivity check to handle that for us, so close the widget *now*
if connectivity_widget then
UIManager:close(connectivity_widget)
end
end
return
else
-- Some turnOnWifi implementations may fire a connectivity check,
-- but we *need* our own, because of the callback & widget passing,
-- as we might have been called by the "prompt" beforeWifiAction...
-- NOTE: We *could* arguably have multiple connectivity checks running concurrently,
-- but only having a single one running makes things somewhat easier to follow...
-- NOTE: Also, most of the platforms that use a connectivity check in turnOnWifi do it to handle wifi_cb,
-- so we'll want to preserve it by wrapping both possible callbacks in a single function...
local wrapped_cb
if self.pending_connectivity_check then
self:unscheduleConnectivityCheck()
wrapped_cb = function()
if wifi_cb then
wifi_cb()
end
if connectivity_cb then
connectivity_cb()
end
end
else
-- If the turnOnWifi implementation didn't rely on a connectivity check, assume wifi_cb was already run.
wrapped_cb = connectivity_cb
end
-- This will handle sending the proper Event, manage wifi_was_on, as well as tearing down Wi-Fi in case of failures.
self:scheduleConnectivityCheck(wrapped_cb, connectivity_widget)
end
return true
end
function NetworkMgr:disableWifi(cb, interactive)
local complete_callback = function()
UIManager:broadcastEvent(Event:new("NetworkDisconnected"))
if cb then
cb()
end
end
UIManager:broadcastEvent(Event:new("NetworkDisconnecting"))
self:turnOffWifi(complete_callback)
if interactive then
self.wifi_was_on = false
G_reader_settings:makeFalse("wifi_was_on")
end
end
function NetworkMgr:toggleWifiOn(complete_callback, long_press, interactive)
local toggle_im = InfoMessage:new{
text = _("Turning on Wi-Fi…"),
}
UIManager:show(toggle_im)
UIManager:forceRePaint()
self.wifi_toggle_long_press = long_press
self:enableWifi(complete_callback, nil, nil, interactive)
UIManager:close(toggle_im)
end
function NetworkMgr:toggleWifiOff(complete_callback, interactive)
local toggle_im = InfoMessage:new{
text = _("Turning off Wi-Fi…"),
}
UIManager:show(toggle_im)
UIManager:forceRePaint()
self:disableWifi(complete_callback, interactive)
UIManager:close(toggle_im)
end
-- NOTE: Only used by the beforeWifiAction framework, so, can never be flagged as "interactive" ;).
function NetworkMgr:promptWifiOn(complete_callback)
-- If there's already an ongoing connection attempt, don't even display the ConfirmBox,
-- as that's just confusing, especially on Android, because you might have seen the one you tapped "Turn on" on disappear,
-- and be surprised by new ones that popped up out of focus while the system settings were opened...
if self.pending_connection then
-- Like other beforeWifiAction backends, the callback is forfeit anyway
logger.warn("NetworkMgr:promptWifiOn: A previous connection attempt is still ongoing!")
return
end
UIManager:show(ConfirmBox:new{
text = _("Do you want to turn on Wi-Fi?"),
ok_text = _("Turn on"),
ok_callback = function()
self:toggleWifiOn(complete_callback)
end,
})
end
function NetworkMgr:promptWifiOff(complete_callback)
UIManager:show(ConfirmBox:new{
text = _("Do you want to turn off Wi-Fi?"),
ok_text = _("Turn off"),
ok_callback = function()
self:toggleWifiOff(complete_callback)
end,
})
end
-- NOTE: Currently only has a single caller, the Menu entry, so it's always flagged as interactive
function NetworkMgr:promptWifi(complete_callback, long_press, interactive)
UIManager:show(MultiConfirmBox:new{
text = _("Wi-Fi is enabled, but you're currently not connected to a network.\nHow would you like to proceed?"),
choice1_text = _("Turn Wi-Fi off"),
choice1_callback = function()
self:toggleWifiOff(complete_callback, interactive)
end,
choice2_text = _("Connect"),
choice2_callback = function()
self:toggleWifiOn(complete_callback, long_press, interactive)
end,
})
end
function NetworkMgr:turnOnWifiAndWaitForConnection(callback)
-- Just run the callback if WiFi is already up...
if self:isWifiOn() and self:isConnected() then
--- @note: beforeWifiAction only guarantees isConnected, not isOnline.
-- In the rare cases we're isConnected but !isOnline, if we're called via a *runWhenOnline wrapper,
-- we don't get a callback at all to avoid infinite recursion, so we need to check it.
if callback then
callback()
end
return
end
local info = InfoMessage:new{ text = _("Connecting to Wi-Fi…") }
UIManager:show(info)
UIManager:forceRePaint()
-- NOTE: This is a slightly tweaked variant of enableWifi, because of our peculiar connectivityCheck usage...
-- Some implementations (usually, hasWifiManager) can report whether they were successfull
local status = self:requestToTurnOnWifi()
-- If turnOnWifi failed, abort early
if status == false then
logger.warn("NetworkMgr:turnOnWifiAndWaitForConnection: Connection failed!")
self:_abortWifiConnection()
UIManager:close(info)
return false
elseif status == EBUSY then
logger.warn("NetworkMgr:turnOnWifiAndWaitForConnection: A previous connection attempt is still ongoing!")
-- We might lose a callback in case the previous attempt wasn't from the same action,
-- but it's just plain saner to just abort here, as we'd risk calling the same thing over and over...
UIManager:close(info)
return
else
-- Some turnOnWifi implementations may fire a connectivity check,
-- but we *need* our own, because of the callback & widget passing.
if self.pending_connectivity_check then
self:unscheduleConnectivityCheck()
end
end
self:scheduleConnectivityCheck(callback, info)
return info
end
-- This is only used on Android, the intent being we assume the system will eventually turn on WiFi on its own in the background...
function NetworkMgr:doNothingAndWaitForConnection(callback)
if self:isWifiOn() and self:isConnected() then
if callback then
callback()
end
return
end
self:scheduleConnectivityCheck(callback)
end
--- This quirky internal flag is used for the rare beforeWifiAction -> afterWifiAction brackets.
function NetworkMgr:clearBeforeActionFlag()
self._before_action_tripped = nil
end
function NetworkMgr:setBeforeActionFlag()
self._before_action_tripped = true
end
function NetworkMgr:getBeforeActionFlag()
return self._before_action_tripped
end
--- @note: The callback will only run *after* a *succesful* network connection.
--- The only guarantee it provides is isConnected (i.e., an IP & a local gateway),
--- *NOT* isOnline (i.e., WAN), se be careful with recursive callbacks!
--- Should only return false on *explicit* failures,
--- in which case the backend will already have called _abortWifiConnection
function NetworkMgr:beforeWifiAction(callback)
-- Remember that we ran, for afterWifiAction...
self:setBeforeActionFlag()
local wifi_enable_action = G_reader_settings:readSetting("wifi_enable_action")
if wifi_enable_action == "turn_on" then
return self:turnOnWifiAndWaitForConnection(callback)
elseif wifi_enable_action == "ignore" then
return self:doNothingAndWaitForConnection(callback)
else
return self:promptWifiOn(callback)
end
end
-- NOTE: This is actually used very sparingly (newsdownloader/send2ebook),
-- because bracketing a single action in a connect/disconnect session doesn't necessarily make much sense...
function NetworkMgr:afterWifiAction(callback)
-- Don't do anything if beforeWifiAction never actually ran...
if not self:getBeforeActionFlag() then
return
end
self:clearBeforeActionFlag()
local wifi_disable_action = G_reader_settings:readSetting("wifi_disable_action")
if wifi_disable_action == "leave_on" then
-- NOP :)
if callback then
callback()
end
elseif wifi_disable_action == "turn_off" then
self:disableWifi(callback)
else
self:promptWifiOff(callback)
end
end
function NetworkMgr:isOnline()
-- For the same reasons as isWifiOn and isConnected above, bypass this on !hasWifiToggle platforms.
if not Device:hasWifiToggle() then
return true
end
local socket = require("socket")
-- Microsoft uses `dns.msftncsi.com` for Windows, see
-- <https://technet.microsoft.com/en-us/library/ee126135#BKMK_How> for
-- more information. They also check whether <http://www.msftncsi.com/ncsi.txt>
-- returns `Microsoft NCSI`.
return socket.dns.toip("dns.msftncsi.com") ~= nil
end
-- Update our cached network status
function NetworkMgr:queryNetworkState()
self.is_wifi_on = self:isWifiOn()
self.is_connected = self.is_wifi_on and self:isConnected()
end
-- These do not call the actual Device methods, but what we, NetworkMgr, think the state is based on our own behavior.
function NetworkMgr:getWifiState()
return self.is_wifi_on
end
function NetworkMgr:setWifiState(bool)
self.is_wifi_on = bool
end
function NetworkMgr:getConnectionState()
return self.is_connected
end
function NetworkMgr:setConnectionState(bool)
self.is_connected = bool
end
function NetworkMgr:isNetworkInfoAvailable()
if Device:isAndroid() then
-- always available
return true
else
return self:isConnected()
end
end
function NetworkMgr:setHTTPProxy(proxy)
local http = require("socket.http")
http.PROXY = proxy
if proxy then
G_reader_settings:saveSetting("http_proxy", proxy)
G_reader_settings:makeTrue("http_proxy_enabled")
else
G_reader_settings:makeFalse("http_proxy_enabled")
end
end
-- Helper functions to hide the quirks of using beforeWifiAction properly ;).
-- Run callback *now* if you're currently online (ie., isOnline),
-- or attempt to go online and run it *ASAP* without any more user interaction.
-- NOTE: If you're currently connected but without Internet access (i.e., isConnected and not isOnline),
-- it will just attempt to re-connect, *without* running the callback.
-- c.f., ReaderWikipedia:onShowWikipediaLookup @ frontend/apps/reader/modules/readerwikipedia.lua
function NetworkMgr:runWhenOnline(callback)
if self:isOnline() then
callback()
else
--- @note: Avoid infinite recursion, beforeWifiAction only guarantees isConnected, not isOnline.
if not self:isConnected() then
self:beforeWifiAction(callback)
else
self:beforeWifiAction()
end
end
end
-- This one is for callbacks that only require isConnected, and since that's guaranteed by beforeWifiAction,
-- you also have a guarantee that the callback *will* run.
function NetworkMgr:runWhenConnected(callback)
if self:isConnected() then
callback()
else
self:beforeWifiAction(callback)
end
end
-- Mild variants that are used for recursive calls at the beginning of a complex function call.
-- Returns true when not yet online, in which case you should *abort* (i.e., return) the initial call,
-- and otherwise, go-on as planned.
-- NOTE: If you're currently connected but without Internet access (i.e., isConnected and not isOnline),
-- it will just attempt to re-connect, *without* running the callback.
-- c.f., ReaderWikipedia:lookupWikipedia @ frontend/apps/reader/modules/readerwikipedia.lua
function NetworkMgr:willRerunWhenOnline(callback)
if not self:isOnline() then
--- @note: Avoid infinite recursion, beforeWifiAction only guarantees isConnected, not isOnline.
if not self:isConnected() then
self:beforeWifiAction(callback)
else
self:beforeWifiAction()
end
return true
end
return false
end
-- This one is for callbacks that only require isConnected, and since that's guaranteed by beforeWifiAction,
-- you also have a guarantee that the callback *will* run.
function NetworkMgr:willRerunWhenConnected(callback)
if not self:isConnected() then
self:beforeWifiAction(callback)
return true
end
return false
end
-- And this one is for when you absolutely *need* to block until we're online to run something (e.g., because it runs in a finalizer).
function NetworkMgr:goOnlineToRun(callback)
if self:isOnline() then
callback()
return true
end
-- If beforeWifiAction isn't turn_on, we're done.
-- We don't want to go behind the user's back by enforcing "turn_on" behavior,
-- and we *cannot* use prompt, as we'll block before handling the popup input...
-- NOTE: Ignore *technically* works, but unlike doNothingAndWaitForConnection, we *would* be displaying an InfoMessage
-- (and block/wait for input as usual). The only difference with turn_on would be the fact that we wouldn't *ever* even try to call turnOnWifi.
-- Given that "ignore" is supposed to be silent, and that you wouldn't actually be able to enable WiFi yourself at that point
-- (because that requires user input, which would cancel the whole thing), there's probably not much to gain by allowing "ignore" here...
if G_reader_settings:readSetting("wifi_enable_action") ~= "turn_on" then
logger.warn("NetworkMgr:goOnlineToRun: Cannot run callback because device is offline and wifi_enable_action is not turn_on")
return false
end
-- We'll do terrible things with this later...
local Input = Device.input
-- In case we abort before the beforeWifiAction, we won't pass it the callback, but run it ourselves,
-- to avoid it firing too late (or at the very least being pinned for too long).
local info = self:beforeWifiAction()
-- NOTE: Unlike turnOnWifiAndWaitForConnection, we're not reentrant,
-- so if there's already a connection attempt pending,
-- we can afford to *try* to wait for its success,
-- especially since we can be cancelled.
-- The following call *will* murder any and all pending callbacks though,
-- which is a *slightly* different behavior than turnOnWifiAndWaitForConnection,
-- but a necessity to ensure sane lifecycles...
-- We'll basically do the same but in a blocking manner...
-- NOTE: Since UIManager won't tick, they wouldn't really have a chance to run anyway...
-- Given the constraints of our callers, they would very likely affect dead/dying objects anyway,
-- so it's much saner to just drop them.
self:unscheduleConnectivityCheck()
-- If connecting just plain failed, we're done
if info == false then
return false
end
-- Throw in a connectivity check now, for the sake of hasWifiManager platforms,
-- where we manage Wi-Fi ourselves, meaning turnOnWifi, and as such beforeWifiAction,
-- is *blocking*, so if all went well, we'll already have blocked a while,
-- but the connection will be up already.
self:queryNetworkState()
local iter = 0
local success = true
while not self.is_connected do
if iter == 0 then
-- Display a slightly more accurate IM while we wait...
if info then
UIManager:close(info)
end
info = InfoMessage:new{ text = _("Waiting for network connectivity…") }
UIManager:show(info)
UIManager:forceRePaint()
end
iter = iter + 1
if iter >= 120 then
logger.warn("NetworkMgr:goOnlineToRun: Timed out!")
success = false
break
end
-- NOTE: Here be dragons! We want to be able to abort on user input, so,
-- handle the 250ms chunks of waiting via our actual input polling...
-- We don't actually let the actual UI loop tick, so `now` will never change,
-- which is good, we don't want to disturb the task queue handling.
-- (And we actually want a fixed 250ms select anyway).
-- NOTE: This *does* mean that multiple bursts of input events *will*
-- make this loop run for less than 120 * 250ms, as select could return early.
-- Assuming we don't actually abort *because* of said input (e.g., not taps) ;).
local now = UIManager:getTime()
local input_events = Input:waitEvent(now, now + time.ms(250))
if input_events then
for __, ev in ipairs(input_events) do
-- We'll want to abort on actual single taps only, in case there's extra noise from stuff like a gyro or something...
if ev.handler == "onGesture" then
local args = unpack(ev.args, 1, ev.args.n)
if args.ges == "tap" then
logger.warn("NetworkMgr:goOnlineToRun: Aborted by user input!")
success = false
-- No need to check further args
break
end
end
end
-- Break out of the actual loop on abort
if not success then
break
end
end
self:queryNetworkState()
end
-- To make our previous input shenanigans slightly less crazy, reset the whole input state.
Input:resetState()
-- Close the initial "Connecting..." InfoMessage from turnOnWifiAndWaitForConnection via beforeWifiAction,
-- or our own "Waiting for network connectivity" one.
if info then
UIManager:close(info)
end
-- Check whether we connected successfully...
if success then
-- We're finally connected!
logger.info("Successfully connected to Wi-Fi (after", iter * 0.25, "seconds)!")
self.wifi_was_on = true
G_reader_settings:makeTrue("wifi_was_on")
callback()
-- Delay this so it won't fire for dead/dying instances in case we're called by a finalizer...
UIManager:scheduleIn(2, function()
UIManager:broadcastEvent(Event:new("NetworkConnected"))
end)
else
-- We're not connected :(
logger.info("Failed to connect to Wi-Fi after", iter * 0.25, "seconds, giving up!")
self:_abortWifiConnection()
UIManager:show(InfoMessage:new{ text = _("Error connecting to the network") })
end
-- We're done, reset the pending connection flag, as we don't have any scheduled connectivity check to do it for us.
self.pending_connection = false
return success
end
function NetworkMgr:getWifiMenuTable()
if Device:isAndroid() then
return {
text = _("Wi-Fi settings"),
callback = function() self:openSettings() end,
}
else
return self:getWifiToggleMenuTable()
end
end
function NetworkMgr:getWifiToggleMenuTable()
local toggleCallback = function(touchmenu_instance, long_press)
self:queryNetworkState()
local fully_connected = self.is_wifi_on and self.is_connected
local complete_callback = function()
-- Notify TouchMenu to update item check state
touchmenu_instance:updateItems()
end -- complete_callback()
if fully_connected then
self:toggleWifiOff(complete_callback, true)
elseif self.is_wifi_on and not self.is_connected then
-- ask whether user wants to connect or turn off wifi
self:promptWifi(complete_callback, long_press, true)
else -- if not connected at all
self:toggleWifiOn(complete_callback, long_press, true)
end
end -- toggleCallback()
return {
text = _("Wi-Fi connection"),
enabled_func = function() return Device:hasWifiToggle() end,
checked_func = function() return self:isWifiOn() end,
callback = toggleCallback,
hold_callback = function(touchmenu_instance)
toggleCallback(touchmenu_instance, true)
end,
}
end
function NetworkMgr:getProxyMenuTable()
local proxy_enabled = function()
return G_reader_settings:readSetting("http_proxy_enabled")
end
local proxy = function()
return G_reader_settings:readSetting("http_proxy")
end
return {
text_func = function()
return T(_("HTTP proxy %1"), (proxy_enabled() and BD.url(proxy()) or ""))
end,
checked_func = function() return proxy_enabled() end,
callback = function()
if not proxy_enabled() and proxy() then
self:setHTTPProxy(proxy())
elseif proxy_enabled() then
self:setHTTPProxy(nil)
end
if not proxy() then
UIManager:show(InfoMessage:new{
text = _("Tip:\nLong press on this menu entry to configure HTTP proxy."),
})
end
end,
hold_input = {
title = _("Enter proxy address"),
type = "text",
hint = proxy() or "",
callback = function(input)
if input ~= "" then
self:setHTTPProxy(input)
end
end,
}
}
end
function NetworkMgr:getPowersaveMenuTable()
return {
text = _("Disable Wi-Fi connection when inactive"),
help_text = Device:isKindle() and _([[This is unlikely to function properly on a stock Kindle, given how much network activity the framework generates.]]) or
_([[This will automatically turn Wi-Fi off after a generous period of network inactivity, without disrupting workflows that require a network connection, so you can just keep reading without worrying about battery drain.]]),
checked_func = function() return G_reader_settings:isTrue("auto_disable_wifi") end,
callback = function()
G_reader_settings:flipNilOrFalse("auto_disable_wifi")
-- NOTE: Well, not exactly, but the activity check wouldn't be (un)scheduled until the next Network(Dis)Connected event...
UIManager:askForRestart()
end,
}
end
function NetworkMgr:getRestoreMenuTable()
return {
text = _("Restore Wi-Fi connection on resume"),
-- i.e., *everything* flips wifi_was_on true, but only direct user interaction (i.e., Menu & Gestures) will flip it off.
help_text = _([[This will attempt to automatically and silently re-connect to Wi-Fi on startup or on resume if Wi-Fi used to be enabled the last time you used KOReader, and you did not explicitly disable it.]]),
checked_func = function() return G_reader_settings:isTrue("auto_restore_wifi") end,
enabled_func = function() return Device:hasWifiRestore() end,
callback = function() G_reader_settings:flipNilOrFalse("auto_restore_wifi") end,
}
end
function NetworkMgr:getInfoMenuTable()
return {
text = _("Network info"),
keep_menu_open = true,
enabled_func = function() return self:isNetworkInfoAvailable() end,
callback = function()
UIManager:broadcastEvent(Event:new("ShowNetworkInfo"))
end
}
end
function NetworkMgr:getBeforeWifiActionMenuTable()
local wifi_enable_action_setting = G_reader_settings:readSetting("wifi_enable_action") or "prompt"
local wifi_enable_actions = {
turn_on = {_("turn on"), _("Turn on")},
prompt = {_("prompt"), _("Prompt")},
}
if Device:isAndroid() then
wifi_enable_actions.ignore = {_("ignore"), _("Ignore")}
end
local action_table = function(wifi_enable_action)
return {
text = wifi_enable_actions[wifi_enable_action][2],
checked_func = function()
return wifi_enable_action_setting == wifi_enable_action
end,
callback = function()
wifi_enable_action_setting = wifi_enable_action
G_reader_settings:saveSetting("wifi_enable_action", wifi_enable_action)
end,
}
end
local t = {
text_func = function()
return T(_("Action when Wi-Fi is off: %1"),
wifi_enable_actions[wifi_enable_action_setting][1]
)
end,
sub_item_table = {
action_table("turn_on"),
action_table("prompt"),
}
}
if Device:isAndroid() then
table.insert(t.sub_item_table, action_table("ignore"))
end
return t
end
function NetworkMgr:getAfterWifiActionMenuTable()
local wifi_disable_action_setting = G_reader_settings:readSetting("wifi_disable_action") or "prompt"
local wifi_disable_actions = {
leave_on = {_("leave on"), _("Leave on")},
turn_off = {_("turn off"), _("Turn off")},
prompt = {_("prompt"), _("Prompt")},
}
local action_table = function(wifi_disable_action)
return {
text = wifi_disable_actions[wifi_disable_action][2],
checked_func = function()
return wifi_disable_action_setting == wifi_disable_action
end,
callback = function()
wifi_disable_action_setting = wifi_disable_action
G_reader_settings:saveSetting("wifi_disable_action", wifi_disable_action)
end,
}
end
return {
text_func = function()
return T(_("Action when done with Wi-Fi: %1"),
wifi_disable_actions[wifi_disable_action_setting][1]
)
end,
sub_item_table = {
action_table("leave_on"),
action_table("turn_off"),
action_table("prompt"),
}
}
end
function NetworkMgr:getDismissScanMenuTable()
return {
text = _("Dismiss Wi-Fi scan popup after connection"),
checked_func = function() return G_reader_settings:nilOrTrue("auto_dismiss_wifi_scan") end,
enabled_func = function() return Device:hasWifiManager() and not Device:isEmulator() end,
callback = function() G_reader_settings:flipNilOrTrue("auto_dismiss_wifi_scan") end,
}
end
function NetworkMgr:getMenuTable(common_settings)
if Device:hasWifiToggle() then
common_settings.network_wifi = self:getWifiMenuTable()
end
common_settings.network_proxy = self:getProxyMenuTable()
common_settings.network_info = self:getInfoMenuTable()
-- Allow auto_disable_wifi on devices where the net sysfs entry is exposed.
if self:getNetworkInterfaceName() then
common_settings.network_powersave = self:getPowersaveMenuTable()
end
if Device:hasWifiRestore() or Device:isEmulator() then
common_settings.network_restore = self:getRestoreMenuTable()
end
if Device:hasWifiManager() or Device:isEmulator() then
common_settings.network_dismiss_scan = self:getDismissScanMenuTable()
end
if Device:hasWifiToggle() then
common_settings.network_before_wifi_action = self:getBeforeWifiActionMenuTable()
common_settings.network_after_wifi_action = self:getAfterWifiActionMenuTable()
end
end
function NetworkMgr:reconnectOrShowNetworkMenu(complete_callback, interactive)
local info = InfoMessage:new{text = _("Scanning for networks…")}
UIManager:show(info)
UIManager:forceRePaint()
local network_list, err = self:getNetworkList()
UIManager:close(info)
if network_list == nil then
UIManager:show(InfoMessage:new{text = err})
return
end
-- NOTE: Fairly hackish workaround for #4387,
-- rescan if the first scan appeared to yield an empty list.
--- @fixme This *might* be an issue better handled in lj-wpaclient...
if #network_list == 0 then
logger.warn("Initial Wi-Fi scan yielded no results, rescanning")
network_list, err = self:getNetworkList()
if network_list == nil then
UIManager:show(InfoMessage:new{text = err})
return
end
end
table.sort(network_list,
function(l, r) return l.signal_quality > r.signal_quality end)
local success = false
if self.wifi_toggle_long_press then
self.wifi_toggle_long_press = nil
else
local ssid
-- We need to do two passes, as we may have *both* an already connected network (from the global wpa config),
-- *and* preferred networks, and if the prferred networks have a better signal quality,
-- they'll be sorted *earlier*, which would cause us to try to associate to a different AP than
-- what wpa_supplicant is already trying to do...
for dummy, network in ipairs(network_list) do
if network.connected then
-- On platforms where we use wpa_supplicant (if we're calling this, we are),
-- the invocation will check its global config, and if an AP configured there is reachable,
-- it'll already have connected to it on its own.
success = true
ssid = network.ssid
break
end
end
-- Next, look for our own prferred networks...
local err_msg = _("Connection failed")
if not success then
for dummy, network in ipairs(network_list) do
if network.password then
-- If we hit a preferred network and we're not already connected,
-- attempt to connect to said preferred network....
success, err_msg = self:authenticateNetwork(network)
if success then
ssid = network.ssid
break
end
end
end
end
if success then
self:obtainIP()
if complete_callback then
complete_callback()
end
UIManager:show(InfoMessage:new{
tag = "NetworkMgr", -- for crazy KOSync purposes
text = T(_("Connected to network %1"), BD.wrap(self.decodeSSID(ssid))),
timeout = 3,
})
else
UIManager:show(InfoMessage:new{
text = err_msg,
timeout = 3,
})
end
end
if not success then
-- NOTE: Also supports a disconnect_callback, should we use it for something?
-- Tearing down Wi-Fi completely when tapping "disconnect" would feel a bit harsh, though...
if interactive then
-- We don't want to display the AP list for non-interactive callers (e.g., beforeWifiAction framework)...
UIManager:show(require("ui/widget/networksetting"):new{
network_list = network_list,
connect_callback = complete_callback,
})
end
end
return success
end
function NetworkMgr:saveNetwork(setting)
if not self.nw_settings then self:readNWSettings() end
self.nw_settings:saveSetting(setting.ssid, {
ssid = setting.ssid,
password = setting.password,
psk = setting.psk,
flags = setting.flags,
})
self.nw_settings:flush()
end
function NetworkMgr:deleteNetwork(setting)
if not self.nw_settings then self:readNWSettings() end
self.nw_settings:delSetting(setting.ssid)
self.nw_settings:flush()
end
function NetworkMgr:getAllSavedNetworks()
if not self.nw_settings then self:readNWSettings() end
return self.nw_settings
end
function NetworkMgr:setWirelessBackend(name, options)
require("ui/network/"..name).init(self, options)
end
function NetworkMgr.decodeSSID(text)
local decode = function(b)
local c = string.char(tonumber(b, 16))
-- This is a hack that allows us to make sure that any decoded backslash
-- does not get replaced in the step that replaces double backslashes.
if c == "\\" then
return "\\\\"
else
return c
end
end
local decoded = text:gsub("%f[\\]\\x(%x%x)", decode)
decoded = decoded:gsub("\\\\", "\\")
return util.fixUtf8(decoded, "<EFBFBD>")
end
-- set network proxy if global variable G_defaults:readSetting("NETWORK_PROXY") is defined
if G_defaults:readSetting("NETWORK_PROXY") then
NetworkMgr:setHTTPProxy(G_defaults:readSetting("NETWORK_PROXY"))
end
return NetworkMgr:init()