You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
koreader/frontend/device/android/device.lua

515 lines
20 KiB
Lua

local FFIUtil = require("ffi/util")
local Generic = require("device/generic/device")
local A, android = pcall(require, "android") -- luacheck: ignore
local Geom = require("ui/geometry")
local ffi = require("ffi")
local C = ffi.C
local lfs = require("libs/libkoreader-lfs")
local logger = require("logger")
local util = require("util")
local _ = require("gettext")
local T = FFIUtil.template
local function yes() return true end
local function no() return false end
local function getCodename()
local api = android.app.activity.sdkVersion
local codename = ""
if api > 30 then
codename = "S"
elseif api == 30 then
codename = "R"
elseif api == 29 then
codename = "Q"
elseif api == 28 then
codename = "Pie"
elseif api == 27 or api == 26 then
codename = "Oreo"
elseif api == 25 or api == 24 then
codename = "Nougat"
elseif api == 23 then
codename = "Marshmallow"
elseif api == 22 or api == 21 then
codename = "Lollipop"
elseif api == 19 then
codename = "KitKat"
elseif api < 19 and api >= 16 then
codename = "Jelly Bean"
elseif api < 16 and api >= 14 then
codename = "Ice Cream Sandwich"
end
return codename
end
-- thirdparty app support
local external = require("device/thirdparty"):new{
dicts = {
{ "Aard2", "Aard2", false, "itkach.aard2", "aard2" },
{ "Alpus", "Alpus", false, "com.ngcomputing.fora.android", "search" },
{ "ColorDict", "ColorDict", false, "com.socialnmobile.colordict", "colordict" },
{ "Eudic", "Eudic", false, "com.eusoft.eudic", "send" },
{ "EudicPlay", "Eudic (Google Play)", false, "com.qianyan.eudic", "send" },
{ "Fora", "Fora Dict", false, "com.ngc.fora", "search" },
{ "ForaPro", "Fora Dict Pro", false, "com.ngc.fora.android", "search" },
{ "GoldenFree", "GoldenDict Free", false, "mobi.goldendict.android.free", "send" },
{ "GoldenPro", "GoldenDict Pro", false, "mobi.goldendict.android", "send" },
{ "Kiwix", "Kiwix", false, "org.kiwix.kiwixmobile", "text" },
{ "LookUp", "Look Up", false, "gaurav.lookup", "send" },
{ "LookUpPro", "Look Up Pro", false, "gaurav.lookuppro", "send" },
{ "Mdict", "Mdict", false, "cn.mdict", "send" },
{ "QuickDic", "QuickDic", false, "de.reimardoeffinger.quickdic", "quickdic" },
},
check = function(self, app)
return android.isPackageEnabled(app)
end,
}
local Device = Generic:new{
isAndroid = yes,
model = android.prop.product,
hasKeys = yes,
hasDPad = no,
hasExitOptions = no,
hasEinkScreen = function() return android.isEink() end,
hasColorScreen = function() return not android.isEink() end,
hasFrontlight = android.hasLights,
hasNaturalLight = android.isWarmthDevice,
canRestart = no,
canSuspend = no,
firmware_rev = android.app.activity.sdkVersion,
home_dir = android.getExternalStoragePath(),
display_dpi = android.lib.AConfiguration_getDensity(android.app.config),
isHapticFeedbackEnabled = yes,
hasClipboard = yes,
hasOTAUpdates = android.ota.isEnabled,
hasOTARunning = function() return android.ota.isRunning end,
hasFastWifiStatusQuery = yes,
hasSystemFonts = yes,
canOpenLink = yes,
openLink = function(self, link)
if not link or type(link) ~= "string" then return end
return android.openLink(link)
end,
canImportFiles = function() return android.app.activity.sdkVersion >= 19 end,
hasExternalSD = function() return android.getExternalSdPath() end,
importFile = function(path) android.importFile(path) end,
canShareText = yes,
doShareText = function(text) android.sendText(text) end,
canExternalDictLookup = yes,
getExternalDictLookupList = function() return external.dicts end,
doExternalDictLookup = function (self, text, method, callback)
external.when_back_callback = callback
local _, app, action = external:checkMethod("dict", method)
if action then
android.dictLookup(text, app, action)
end
end,
-- Android is very finicky, and the LuaJIT allocator has a tremendously hard time getting the system
-- to allocate memory for it in the very specific memory range it requires to store generated code (mcode).
-- Failure to do so at runtime (mcode_alloc) will *tank* performance
-- (much worse than if the JIT were simply disabled).
-- The first line of defense is left to android-luajit-launcher,
-- which will try to grab the full mcode region in one go right at startup.
-- The second line of defense is *this* flag, which disables the JIT in a few code-hungry modules,
-- but not necessarily performance critical ones, to hopefully limit the amount of mcode memory
-- required for those modules where it *really* matters (e.g., ffi/blitbuffer.lua).
-- This is also why we try to actually avoid entering actual loops in the Lua blitter on Android,
-- and instead attempt to do most of everything via the C implementation.
-- NOTE: Since https://github.com/koreader/android-luajit-launcher/pull/283, we've patched LuaJIT
-- to ensure that the initial mcode alloc works, and sticks around, which is why this is no longer enabled.
should_restrict_JIT = false,
}
function Device:init()
self.screen = require("ffi/framebuffer_android"):new{device = self, debug = logger.dbg}
self.powerd = require("device/android/powerd"):new{device = self}
self.input = require("device/input"):new{
device = self,
event_map = require("device/android/event_map"),
handleMiscEv = function(this, ev)
local Event = require("ui/event")
local UIManager = require("ui/uimanager")
logger.dbg("Android application event", ev.code)
if ev.code == C.APP_CMD_SAVE_STATE then
UIManager:broadcastEvent(Event:new("FlushSettings"))
elseif ev.code == C.APP_CMD_DESTROY then
UIManager:quit()
elseif ev.code == C.APP_CMD_GAINED_FOCUS
or ev.code == C.APP_CMD_INIT_WINDOW
or ev.code == C.APP_CMD_WINDOW_REDRAW_NEEDED then
this.device.screen:_updateWindow()
elseif ev.code == C.APP_CMD_LOST_FOCUS
or ev.code == C.APP_CMD_TERM_WINDOW then
this.device.input:resetState()
elseif ev.code == C.APP_CMD_CONFIG_CHANGED then
-- orientation and size changes
if android.screen.width ~= android.getScreenWidth()
or android.screen.height ~= android.getScreenHeight() then
this.device.screen:resize()
local new_size = this.device.screen:getSize()
logger.info("Resizing screen to", new_size)
local FileManager = require("apps/filemanager/filemanager")
UIManager:broadcastEvent(Event:new("SetDimensions", new_size))
UIManager:broadcastEvent(Event:new("ScreenResize", new_size))
UIManager:broadcastEvent(Event:new("RedrawCurrentPage"))
if FileManager.instance then
FileManager.instance:reinit(FileManager.instance.path,
FileManager.instance.focused_file)
UIManager:setDirty(FileManager.instance.banner, function()
return "ui", FileManager.instance.banner.dimen
end)
end
end
-- to-do: keyboard connected, disconnected
elseif ev.code == C.APP_CMD_RESUME then
if not android.prop.brokenLifecycle then
UIManager:broadcastEvent(Event:new("Resume"))
end
if external.when_back_callback then
external.when_back_callback()
external.when_back_callback = nil
end
if android.ota.isPending then
UIManager:scheduleIn(0.1, self.install)
else
local new_file = android.getIntent()
if new_file ~= nil and lfs.attributes(new_file, "mode") == "file" then
-- we cannot blit to a window here since we have no focus yet.
local InfoMessage = require("ui/widget/infomessage")
local BD = require("ui/bidi")
UIManager:scheduleIn(0.1, function()
UIManager:show(InfoMessage:new{
text = T(_("Opening file '%1'."), BD.filepath(new_file)),
timeout = 0.0,
})
end)
UIManager:scheduleIn(0.2, function()
require("apps/reader/readerui"):doShowReader(new_file)
end)
else
-- check if we're resuming from importing content.
local content_path = android.getLastImportedPath()
if content_path ~= nil then
local FileManager = require("apps/filemanager/filemanager")
UIManager:scheduleIn(0.5, function()
if FileManager.instance then
FileManager.instance:onRefresh()
else
FileManager:showFiles(content_path)
end
end)
end
end
end
elseif ev.code == C.APP_CMD_PAUSE then
if not android.prop.brokenLifecycle then
UIManager:broadcastEvent(Event:new("Suspend"))
end
elseif ev.code == C.AEVENT_POWER_CONNECTED then
UIManager:broadcastEvent(Event:new("Charging"))
elseif ev.code == C.AEVENT_POWER_DISCONNECTED then
UIManager:broadcastEvent(Event:new("NotCharging"))
elseif ev.code == C.AEVENT_DOWNLOAD_COMPLETE then
android.ota.isRunning = false
if android.isResumed() then
self:install()
else
android.ota.isPending = true
end
end
end,
hasClipboardText = function()
return android.hasClipboardText()
end,
getClipboardText = function()
return android.getClipboardText()
end,
setClipboardText = function(text)
return android.setClipboardText(text)
end,
}
-- check if we have a keyboard
if android.lib.AConfiguration_getKeyboard(android.app.config)
== C.ACONFIGURATION_KEYBOARD_QWERTY
then
self.hasKeyboard = yes
end
-- check if we have a touchscreen
if android.lib.AConfiguration_getTouchscreen(android.app.config)
~= C.ACONFIGURATION_TOUCHSCREEN_NOTOUCH
then
self.isTouchDevice = yes
end
-- check if we use custom timeouts
if android.needsWakelocks() then
android.timeout.set(C.AKEEP_SCREEN_ON_ENABLED)
else
local timeout = G_reader_settings:readSetting("android_screen_timeout")
if timeout then
if timeout == C.AKEEP_SCREEN_ON_ENABLED
or timeout > C.AKEEP_SCREEN_ON_DISABLED
and android.settings.hasPermission("settings")
then
android.timeout.set(timeout)
end
end
end
-- check if we disable fullscreen support
if G_reader_settings:isTrue("disable_android_fullscreen") then
self:toggleFullscreen()
end
-- check if we allow haptic feedback in spite of system settings
if G_reader_settings:isTrue("haptic_feedback_override") then
android.setHapticOverride(true)
end
-- check if we ignore volume keys and then they're forwarded to system services.
if G_reader_settings:isTrue("android_ignore_volume_keys") then
android.setVolumeKeysIgnored(true)
end
-- check if we ignore the back button completely
if G_reader_settings:isTrue("android_ignore_back_button") then
android.setBackButtonIgnored(true)
end
Generic.init(self)
end
function Device:initNetworkManager(NetworkMgr)
function NetworkMgr:turnOnWifi(complete_callback)
android.openWifiSettings()
end
function NetworkMgr:turnOffWifi(complete_callback)
android.openWifiSettings()
end
function NetworkMgr:openSettings()
android.openWifiSettings()
end
function NetworkMgr:isWifiOn()
local ok = android.getNetworkInfo()
ok = tonumber(ok)
if not ok then return false end
return ok == 1
end
end
function Device:performHapticFeedback(type)
android.hapticFeedback(C["AHAPTIC_"..type])
end
function Device:setIgnoreInput(enable)
android.setIgnoreInput(enable)
end
function Device:retrieveNetworkInfo()
local ok, type = android.getNetworkInfo()
ok, type = tonumber(ok), tonumber(type)
if not ok or not type or type == C.ANETWORK_NONE then
return _("Not connected")
else
if type == C.ANETWORK_WIFI then
return _("Connected to Wi-Fi")
elseif type == C.ANETWORK_MOBILE then
return _("Connected to mobile data network")
elseif type == C.ANETWORK_ETHERNET then
return _("Connected to Ethernet")
elseif type == C.ANETWORK_BLUETOOTH then
return _("Connected to Bluetooth")
elseif type == C.ANETWORK_VPN then
return _("Connected to VPN")
end
return _("Unknown connection")
end
end
function Device:setViewport(x,y,w,h)
logger.info(string.format("Switching viewport to new geometry [x=%d,y=%d,w=%d,h=%d]",x, y, w, h))
local viewport = Geom:new{x=x, y=y, w=w, h=h}
self.screen:setViewport(viewport)
end
function Device:toggleFullscreen()
local api = android.app.activity.sdkVersion
if api >= 19 then
logger.dbg("ignoring fullscreen toggle, reason: always in immersive mode")
elseif api < 19 and api >= 17 then
local width = android.getScreenWidth()
local height = android.getScreenHeight()
-- NOTE: Since we don't do HW rotation here, this should always match width
local available_width = android.getScreenAvailableWidth()
local available_height = android.getScreenAvailableHeight()
local is_fullscreen = android.isFullscreen()
android.setFullscreen(not is_fullscreen)
G_reader_settings:saveSetting("disable_android_fullscreen", is_fullscreen)
is_fullscreen = android.isFullscreen()
if is_fullscreen then
self:setViewport(0, 0, width, height)
else
self:setViewport(0, 0, available_width, available_height)
end
else
logger.dbg("ignoring fullscreen toggle, reason: legacy api " .. api)
end
end
function Device:info()
local is_eink, eink_platform = android.isEink()
local product_type = android.getPlatformName()
local common_text = T(_("%1\n\nOS: Android %2, api %3\nBuild flavor: %4\n"),
android.prop.product, getCodename(), Device.firmware_rev, android.prop.flavor)
local platform_text = ""
if product_type ~= "android" then
platform_text = "\n" .. T(_("Device type: %1"), product_type) .. "\n"
end
local eink_text = ""
if is_eink then
eink_text = "\n" .. T(_("E-ink display supported.\nPlatform: %1"), eink_platform) .. "\n"
end
local wakelocks_text = ""
if android.needsWakelocks() then
wakelocks_text = "\n" .. _("This device needs CPU, screen and touchscreen always on.\nScreen timeout will be ignored while the app is in the foreground!") .. "\n"
end
return common_text..platform_text..eink_text..wakelocks_text
end
function Device:test()
android.runTest()
end
function Device:exit()
android.LOGI(string.format("Stopping %s main activity", android.prop.name));
android.lib.ANativeActivity_finish(android.app.activity)
end
function Device:canExecuteScript(file)
local file_ext = string.lower(util.getFileNameSuffix(file))
if android.prop.flavor ~= "fdroid" and file_ext == "sh" then
return true
end
end
function Device:isValidPath(path)
-- the fast check
if android.isPathInsideSandbox(path) then
return true
end
-- the thorough check
local real_ext_storage = FFIUtil.realpath(android.getExternalStoragePath())
local real_path = FFIUtil.realpath(path)
if real_path then
return real_path:sub(1, #real_ext_storage) == real_ext_storage
else
return false
end
end
function Device:showLightDialog()
-- Delay it until next tick so that the event loop gets a chance to drain the input queue,
-- and consume the APP_CMD_LOST_FOCUS event.
-- This helps prevent ANRs on Tolino (c.f., #6583 & #7552).
local UIManager = require("ui/uimanager")
UIManager:nextTick(function() self:_showLightDialog() end)
end
function Device:_showLightDialog()
local title = android.isEink() and _("Frontlight settings") or _("Light settings")
android.lights.showDialog(title, _("Brightness"), _("Warmth"), _("OK"), _("Cancel"))
local action = android.lights.dialogState()
if action == C.ALIGHTS_DIALOG_OK then
self.powerd.fl_intensity = self.powerd:frontlightIntensityHW()
logger.dbg("Dialog OK, brightness: " .. self.powerd.fl_intensity)
if android.isWarmthDevice() then
self.powerd.fl_warmth = self.powerd:getWarmth()
logger.dbg("Dialog OK, warmth: " .. self.powerd.fl_warmth)
end
local Event = require("ui/event")
local UIManager = require("ui/uimanager")
UIManager:broadcastEvent(Event:new("FrontlightStateChanged"))
elseif action == C.ALIGHTS_DIALOG_CANCEL then
logger.dbg("Dialog Cancel, brightness: " .. self.powerd.fl_intensity)
self.powerd:setIntensityHW(self.powerd.fl_intensity)
if android.isWarmthDevice() then
logger.dbg("Dialog Cancel, warmth: " .. self.powerd.fl_warmth)
self.powerd:setWarmth(self.powerd.fl_warmth)
end
end
end
function Device:untar(archive, extract_to)
return android.untar(archive, extract_to)
end
function Device:download(link, name, ok_text)
local ConfirmBox = require("ui/widget/confirmbox")
local InfoMessage = require("ui/widget/infomessage")
local UIManager = require("ui/uimanager")
local ok = android.download(link, name)
if ok == C.ADOWNLOAD_EXISTS then
self:install()
elseif ok == C.ADOWNLOAD_OK then
android.ota.isRunning = true
UIManager:show(InfoMessage:new{
text = ok_text,
timeout = 3,
})
elseif ok == C.ADOWNLOAD_FAILED then
UIManager:show(ConfirmBox:new{
text = _("Your device seems to be unable to download packages.\nRetry using the browser?"),
ok_text = _("Retry"),
ok_callback = function() self:openLink(link) end,
})
end
end
function Device:install()
local ConfirmBox = require("ui/widget/confirmbox")
local Event = require("ui/event")
local UIManager = require("ui/uimanager")
UIManager:show(ConfirmBox:new{
text = _("Update is ready. Install it now?"),
ok_text = _("Install"),
ok_callback = function()
UIManager:broadcastEvent(Event:new("FlushSettings"))
UIManager:tickAfterNext(function()
android.ota.install()
android.ota.isPending = false
end)
end,
})
end
-- todo: Wouldn't we like an android.deviceIdentifier() method, so we can use better default paths?
function Device:getDefaultCoverPath()
if android.prop.product == "ntx_6sl" then -- Tolino HD4 and other
return android.getExternalStoragePath() .. "/suspend_others.jpg"
else
return android.getExternalStoragePath() .. "/cover.jpg"
end
end
android.LOGI(string.format("Android %s - %s (API %d) - flavor: %s",
android.prop.version, getCodename(), Device.firmware_rev, android.prop.flavor))
return Device