require "import"
import "android.app.*"
import "android.app.Dialog"
import "android.os.*"
import "android.os.Looper"
import "android.widget.*"
import "android.view.*"
import "android.view.Window"
import "android.view.animation.*"
import "android.animation.*"
import "android.content.*"
import "android.net.*"
import "android.util.*"
import "android.graphics.*"
import "android.graphics.drawable.*"
import "android.text.*"
import "java.lang.*"
import "java.lang.Thread"
import "java.lang.Byte"
import "java.io.*"
import "java.net.*"
import "java.util.*"
import "java.util.zip.*"
import "java.util.concurrent.atomic.AtomicInteger"
import "java.util.concurrent.Executors"
import "javax.net.ssl.*"
import "org.json.JSONObject"
import "org.json.JSONArray"
import "com.liquidglass.java.miba.LiquidBottomTabs"

pcall(function()
    local Guard = require "crash_guard"
    if Guard and Guard.install then Guard.install(activity) end
end)

local _unpack = table.unpack or unpack
if type(_unpack) ~= "function" then
    _unpack = function(t, i, j) return unpack(t, i, j) end
end

local SPONSOR_RES_OVERRIDE = nil
local LUA_DIR = nil
pcall(function() LUA_DIR = tostring(activity.getLuaDir()) end)
if not LUA_DIR or LUA_DIR == "" then LUA_DIR = "." end

-- ★ FIX: 供 home1.lua 等模块读取
_G.LUA_DIR = LUA_DIR

-- ==================== 配置 ====================
local CFG = {
    CONNECT_TIMEOUT_MS      = 15000,
    READ_TIMEOUT_MS         = 60000,
    THEME_CHECK_INTERVAL_MS = 120 * 1000,
    HTTP_RETRY_TIMES        = 2,
    BANNER_INTERVAL_MS      = 3500,
    VISIBILITY_CACHE_MS     = 500,
    HOME_MAX_ITEMS          = 9,
    HOME_LOAD_TIMEOUT_MS    = 30000,
    NET_BMP_CACHE_MAX       = 20,
    LOCAL_BMP_CACHE_MAX     = 40,
    BMP_FAIL_RETRY_SEC      = 30,
    USER_AGENT              = "Mozilla/5.0 CityFileLib/1.2",
    API_PATH                = "/api.php",
    DEBUG                   = true,

    OFFICIAL_WEBSITE_URL    = "https://www.城辉.icu",
    PKG_WECHAT              = "com.tencent.mm",
    PKG_ALIPAY              = "com.eg.android.AlipayGphone",

    SPONSOR_WX_NAMES        = { "wechat.png", "wx.png", "weixin.png", "微信.png" },
    SPONSOR_ALI_NAMES       = { "alipay.png", "zfb.png", "zhifubao.png", "支付宝.png" },
    SPONSOR_WX_LOCAL        = "wechat.png",
    SPONSOR_ALI_LOCAL       = "alipay.png",
    SPONSOR_WX_IMG          = "https://www.城辉.icu/wechat.png",
    SPONSOR_ALI_IMG         = "https://www.城辉.icu/alipay.png",

    CATEGORY_LIST = {
        { id = "gongneng", name = "功能" },
        { id = "meihua",   name = "美化" },
        { id = "peizhi",   name = "配置" },
        { id = "ziyuan",   name = "资源" },
    },
    HOME_LATEST_CATS        = { "gongneng", "meihua", "peizhi" },
    HOME_STAT_CATS          = { "gongneng", "meihua", "peizhi", "ziyuan" },
    HOME_CACHE_KEY          = "__home_latest__",
    TAB_HOME                = 1,
    TAB_FILES               = 2,
    TAB_ZIYUAN              = 3,
    TAB_SETTINGS            = 4,

    SERVER_LINES = {
        { key = "main",   label = "主路线",   url = "https://chwjk.hsaxy.icu" },
        { key = "backup", label = "备用路线", url = "https://hsgj.hsaxy.icu" },
    },

    THEMES = {
        light = {
            BG = 0xfff2f2f7, CARD = 0xffffffff, CARD_STROKE = 0x22000000,
            TEXT = 0xff000000, TEXT_SUB = 0x99000000,
            ACCENT = 0xff007aff, ACCENT_SOFT_BG = 0x1a007aff,
            ACCENT_SOFT_BORDER = 0x33007aff,
            DANGER = 0xffff453a, SUCCESS = 0xff34c759,
        },
        dark = {
            BG = 0xff000000, CARD = 0xff1c1c1e, CARD_STROKE = 0x15ffffff,
            TEXT = 0xffffffff, TEXT_SUB = 0x99ffffff,
            ACCENT = 0xff0a84ff, ACCENT_SOFT_BG = 0x1a0a84ff,
            ACCENT_SOFT_BORDER = 0x330a84ff,
            DANGER = 0xffff453a, SUCCESS = 0xff30d158,
        },
    },

    EXT_EMOJI = {
        zip="️", rar="🗜️", ["7z"]="🗜️", tar="🗜️", gz="🗜️", bz2="️",
        png="🖼️", jpg="🖼️", jpeg="🖼️", gif="🖼️", webp="️", bmp="🖼️", svg="🖼️",
        apk="📱",
        mp4="🎬", avi="🎬", mkv="🎬", mov="🎬", wmv="🎬", flv="🎬", webm="🎬",
        mp3="🎵", wav="🎵", flac="🎵", aac="🎵", ogg="🎵", m4a="",
        pdf="📕", txt="📝", md="📝", log="📝", lua="🌙",
        doc="📘", docx="📘", xls="📗", xlsx="📗", ppt="📙", pptx="📙",
        iso="💿", json="📋", xml="📋", yml="📋", yaml="📋",
        html="🌐", htm="🌐", css="🎨", js="📜", sh="⌨️", bat="⌨️",
        link="🔗",
    },

    DEFAULT_FILE_DESC =
        "📦 本资源由「辉少工具箱」提供\n" ..
        "💡 详情信息仅供参考\n" ..
        "🔗 如需获取请点击链接查看",

    NETDISK_RULES = {
        { key="pan.baidu.com",    name="百度网盘",   icon="BD.png" },
        { key="yun.baidu.com",    name="百度网盘",   icon="BD.png" },
        { key="baidu.com",        name="百度网盘",   icon="BD.png" },
        { key="pan.xunlei.com",   name="迅雷网盘",   icon="XL.png" },
        { key="xunlei.com",       name="迅雷网盘",   icon="XL.png" },
        { key="pan.quark.cn",     name="夸克网盘",   icon="KK.png" },
        { key="quark.cn",         name="夸克网盘",   icon="KK.png" },
        { key="drive.uc.cn",      name="UC网盘",     icon="UC.png" },
        { key="uc.cn",            name="UC网盘",     icon="UC.png" },
        { key="123pan.com",       name="123网盘",    icon="123.png" },
        { key="123pan.cn",        name="123网盘",    icon="123.png" },
        { key="123684.com",       name="123网盘",    icon="123.png" },
        { key="123865.com",       name="123网盘",    icon="123.png" },
        { key="123912.com",       name="123网盘",    icon="123.png" },
        { key="xiaofeiji.net",    name="小飞机网盘", icon="XFJ.png" },
        { key="www.feijipan.com", name="小飞机网盘", icon="XFJ.png" },
        { key="xiaofeiji.com",    name="小飞机网盘", icon="XFJ.png" },
        { key="feijipan.com",     name="小飞机网盘", icon="XFJ.png" },
        { key="ctfile.com",       name="城通网盘",   icon="CT.png" },
        { key="lanzou",           name="蓝奏云",     icon="LZY.png" },
        { key="aliyundrive.com",  name="阿里云盘",   icon="AL.png" },
        { key="alipan.com",       name="阿里云盘",   icon="AL.png" },
        { key="cloud.189.cn",     name="天翼云盘",   icon="TY.png" },
        { key="115.com",          name="115网盘",    icon="115.png" },
        { key="weiyun.com",       name="腾讯微云",   icon="WY.png" },
        { key="caiyun.139.com",   name="移动云盘",   icon="YD.png" },
    },

    SUBTITLE_POOL = {
        "或许只需一滴露水，便能守护这绽放的花朵。",
        "每一次浏览，都是一次新的开始。",
        "愿你今天也能遇见心动的东西。",
        "把喜欢的都收进来，就是最好的收藏。",
        "简单一点，快乐一点。",
    },
}

-- ==================== 缓存 ====================
local CA = {
    bmp             = {},
    bmpOrder        = {},
    netdiskIcon     = {},
    resDirs         = nil,
    resIndex        = nil,
    netBmp          = {},
    netBmpOrder     = {},
    drawable        = {},
    cardPool        = {},
    cardPoolMax     = 40,
    clickCallbacks  = {},
    clickProxy      = nil,
    systemRef       = nil,
    bmpFail         = {},
    lastGoodColors  = nil,
    lastToastMsg    = "",
    lastToastTs     = 0,
    imgSem          = nil,
    sponsorImgSem   = nil,
    idnClass        = nil,
}

-- ==================== 防闪退 ====================
pcall(function()
    local ThreadClass = luajava.bindClass("java.lang.Thread")
    ThreadClass.setDefaultUncaughtExceptionHandler(
        luajava.createProxy("java.lang.Thread$UncaughtExceptionHandler", {
            uncaughtException = function(t, e)
                pcall(function() print("[Uncaught][Thread] " .. tostring(e)) end)
            end
        })
    )
end)

pcall(function()
    local LooperClass = luajava.bindClass("android.os.Looper")
    local mainLooper = LooperClass.getMainLooper()
    if mainLooper then
        local field = mainLooper.getClass():getDeclaredField("mUncaughtExceptionHandler")
        field.setAccessible(true)
        field.set(mainLooper, luajava.createProxy(
            "java.lang.Thread$UncaughtExceptionHandler", {
                uncaughtException = function(t, e)
                    pcall(function() print("[Uncaught][Main] " .. tostring(e)) end)
                end
            }))
    end
end)

local SCREEN_DENSITY = 1.0
local SCREEN_WIDTH_PX = 1080
pcall(function()
    local dm = activity.getResources().getDisplayMetrics()
    SCREEN_DENSITY = dm.density
    SCREEN_WIDTH_PX = dm.widthPixels
end)

local _dpCache = {}
local function dp(v)
    v = tonumber(v) or 0
    local c = _dpCache[v]
    if c then return c end
    local ok, r = pcall(function()
        return math.floor(v * SCREEN_DENSITY + 0.5)
    end)
    if ok and type(r) == "number" then
        _dpCache[v] = r
        return r
    end
    return 0
end

local SWIPE_CLASS = nil
local SWIPE_LISTENER_CLASS = nil
pcall(function()
    luajava.bindClass("androidx.swiperefreshlayout.widget.SwipeRefreshLayout")
    SWIPE_CLASS = "androidx.swiperefreshlayout.widget.SwipeRefreshLayout"
    SWIPE_LISTENER_CLASS = "androidx.swiperefreshlayout.widget.SwipeRefreshLayout$OnRefreshListener"
end)
if not SWIPE_CLASS then
    pcall(function()
        luajava.bindClass("android.support.v4.widget.SwipeRefreshLayout")
        SWIPE_CLASS = "android.support.v4.widget.SwipeRefreshLayout"
        SWIPE_LISTENER_CLASS = "android.support.v4.widget.SwipeRefreshLayout$OnRefreshListener"
    end)
end

local function LOG(tag, msg)
    if not CFG.DEBUG then return end
    pcall(function() print("[" .. tostring(tag) .. "] " .. tostring(msg)) end)
end

pcall(function()
    local Sys = luajava.bindClass("java.lang.System")
    Sys.setProperty("https.protocols", "TLSv1.2,TLSv1.1,TLSv1,TLSv1.3")
    Sys.setProperty("jdk.tls.client.protocols", "TLSv1.2,TLSv1.1,TLSv1,TLSv1.3")
end)

local function forceTls12()
    pcall(function()
        local Sys = luajava.bindClass("java.lang.System")
        Sys.setProperty("https.protocols", "TLSv1.2,TLSv1.1,TLSv1")
        Sys.setProperty("jdk.tls.client.protocols", "TLSv1.2,TLSv1.1,TLSv1")
    end)
end

local MAIN_HANDLER = nil
pcall(function() MAIN_HANDLER = Handler(Looper.getMainLooper()) end)
if not MAIN_HANDLER then pcall(function() MAIN_HANDLER = Handler() end) end

local function makeRunnable(fn)
    return luajava.createProxy("java.lang.Runnable", {
        run = function()
            local ok, err = xpcall(fn, function(e)
                return tostring(e) .. "\n" .. debug.traceback()
            end)
            if not ok then LOG("Runnable", tostring(err)) end
        end
    })
end

local function postUi(fn)
    if not MAIN_HANDLER then pcall(fn); return end
    pcall(function() MAIN_HANDLER.post(makeRunnable(fn)) end)
end

local function postDelayed(fn, delay)
    if not MAIN_HANDLER then return end
    pcall(function() MAIN_HANDLER.postDelayed(makeRunnable(fn), delay) end)
end

local THREAD_POOL = nil
local IMG_POOL = nil
local THREAD_POOL_CLOSED = false
pcall(function()
    local Executors = luajava.bindClass("java.util.concurrent.Executors")
    THREAD_POOL = Executors.newFixedThreadPool(3)
    IMG_POOL    = Executors.newFixedThreadPool(2)
end)

local function _submit(pool, fn)
    if not pool then return false end
    return pcall(function()
        pool.execute(luajava.createProxy("java.lang.Runnable", {
            run = function()
                local ok, err = xpcall(fn, function(e)
                    return tostring(e) .. "\n" .. debug.traceback()
                end)
                if not ok then LOG("Pool", tostring(err)) end
            end
        }))
    end)
end

local function runAsync(fn)
    if THREAD_POOL_CLOSED then return false end
    if THREAD_POOL then return _submit(THREAD_POOL, fn) end
    return pcall(function()
        local t = luajava.newInstance("java.lang.Thread",
            luajava.createProxy("java.lang.Runnable", { run = function() pcall(fn) end }))
        t.start()
    end)
end

local function runImgAsync(fn)
    if THREAD_POOL_CLOSED then return false end
    if IMG_POOL then return _submit(IMG_POOL, fn) end
    return runAsync(fn)
end

local function byteArrayLen(bytes)
    if not bytes then return 0 end
    local n = 0
    pcall(function() n = bytes.length end)
    if n == 0 then pcall(function() n = #bytes end) end
    return n or 0
end

local function newBAOS()
    local ok, o = pcall(function()
        return luajava.newInstance("java.io.ByteArrayOutputStream")
    end)
    return ok and o or nil
end

local function newGZIS(ins)
    local ok, o = pcall(function()
        return luajava.newInstance("java.util.zip.GZIPInputStream", ins)
    end)
    return ok and o or nil
end

pcall(function() CA.idnClass = luajava.bindClass("java.net.IDN") end)

local function _urlToAscii(raw)
    if not raw or raw == "" then return raw end
    local schemeEnd = raw:find("://", 1, true)
    if not schemeEnd then return raw end
    local rest = raw:sub(schemeEnd + 3)
    local hostEnd = #rest + 1
    for _, ch in ipairs({"/", "?", "#"}) do
        local p = rest:find(ch, 1, true)
        if p and p < hostEnd then hostEnd = p end
    end
    local hostPart = rest:sub(1, hostEnd - 1)
    local tail     = rest:sub(hostEnd)
    if not hostPart:find("[\128-\255]") then return raw end
    local userinfo = ""
    local at = hostPart:find("@", 1, true)
    if at then userinfo = hostPart:sub(1, at); hostPart = hostPart:sub(at + 1) end
    local port = ""
    local colon = hostPart:find(":", 1, true)
    if colon then port = hostPart:sub(colon); hostPart = hostPart:sub(1, colon - 1) end
    local asciiHost = hostPart
    if CA.idnClass then
        local ok, r = pcall(function() return tostring(CA.idnClass.toASCII(hostPart)) end)
        if ok and r and r ~= "" and r ~= "nil" then asciiHost = r end
    end
    return raw:sub(1, schemeEnd + 2) .. userinfo .. asciiHost .. port .. tail
end

local function newUrl(s)
    if not s or s == "" then return nil end
    local str = tostring(s)
    local ok, o = pcall(function()
        return luajava.newInstance("java.net.URL", str)
    end)
    if ok and o then return o end
    local ascii = _urlToAscii(str)
    if ascii and ascii ~= str then
        local ok2, o2 = pcall(function()
            return luajava.newInstance("java.net.URL", ascii)
        end)
        if ok2 and o2 then return o2 end
    end
    return nil
end

pcall(function()
    CA.imgSem = luajava.newInstance("java.util.concurrent.atomic.AtomicInteger", 2)
    CA.sponsorImgSem = luajava.newInstance("java.util.concurrent.atomic.AtomicInteger", 2)
end)

local function acquireImgSlot()
    if not CA.imgSem then return true end
    local v = CA.imgSem.decrementAndGet()
    if v >= 0 then return true end
    CA.imgSem.incrementAndGet()
    return false
end
local function releaseImgSlot()
    if CA.imgSem then CA.imgSem.incrementAndGet() end
end

local function acquireSponsorImgSlot()
    if not CA.sponsorImgSem then return true end
    local v = CA.sponsorImgSem.decrementAndGet()
    if v >= 0 then return true end
    CA.sponsorImgSem.incrementAndGet()
    return false
end
local function releaseSponsorImgSlot()
    if CA.sponsorImgSem then CA.sponsorImgSem.incrementAndGet() end
end

local SERVER_PREF_FILE = nil
pcall(function() SERVER_PREF_FILE = LUA_DIR .. "/server_line.txt" end)

local function readServerPref()
    if not SERVER_PREF_FILE then return "main" end
    local ok, content = pcall(function()
        local f = java.io.File(SERVER_PREF_FILE)
        if not f.exists() then return nil end
        local ins = java.io.FileInputStream(f)
        local baos = newBAOS()
        if not baos then return nil end
        local ByteType = luajava.bindClass("java.lang.Byte").TYPE
        local buf = luajava.newArray(ByteType, 256)
        while true do
            local n = ins.read(buf, 0, 256)
            if n == -1 then break end
            if n > 0 then baos.write(buf, 0, n) end
        end
        ins.close()
        return tostring(luajava.newInstance("java.lang.String", baos.toByteArray(), "UTF-8"))
    end)
    if ok and content then
        content = content:gsub("%s+", "")
        for _, line in ipairs(CFG.SERVER_LINES) do
            if line.key == content then return content end
        end
    end
    return "main"
end

local function writeServerPref(key)
    if not SERVER_PREF_FILE then return end
    pcall(function()
        local f = java.io.File(SERVER_PREF_FILE)
        local parent = f.getParentFile()
        if parent and not parent.exists() then parent.mkdirs() end
        local fos = java.io.FileOutputStream(f, false)
        fos.write(luajava.newInstance("java.lang.String", key):getBytes("UTF-8"))
        fos.flush()
        fos.close()
    end)
end

local S = {
    currentTab = CFG.TAB_HOME,
    currentCategory = nil,
    dataCache = {},
    isLoading = {},
    bannerData = nil,
    bannerLoading = false,
    noticeData = nil,
    noticeLoading = false,
    searchQuery = nil,
    searchResults = nil,
    searchLoading = false,
    searchGen = 0,
    isScrolling = false,
    ACTIVE_BANNER_TASKS = {},
    currentBannerTask = nil,
    cachedGreeting = nil,
    cachedSubtitle = nil,
    SERVER_LINE = readServerPref(),
    THEME_MODE = "default",
    _homeLatestGen = 0,
    _autoSwitchNotified = false,
    lastSwitchNotifyKey = nil,
    lastSwitchNotifyTs = 0,
    switchLock = false,
    subPage = nil,   -- ★ 当前子页面标记
}

local function getServerUrl(key)
    key = key or S.SERVER_LINE
    for _, line in ipairs(CFG.SERVER_LINES) do
        if line.key == key then return line.url end
    end
    return CFG.SERVER_LINES[1].url
end

local function getServerLabel(key)
    key = key or S.SERVER_LINE
    for _, line in ipairs(CFG.SERVER_LINES) do
        if line.key == key then return line.label end
    end
    return "主路线"
end

local function apiUrlFor(key, query)
    return getServerUrl(key) .. CFG.API_PATH .. (query or "")
end

local function resolveDefaultTheme()
    local hour = tonumber(os.date("%H")) or 0
    return (hour >= 6 and hour < 22) and "light" or "dark"
end

local function getEffectiveTheme()
    if S.THEME_MODE == "light" or S.THEME_MODE == "dark" then return S.THEME_MODE end
    return resolveDefaultTheme()
end

local function getColors()
    local ok, c = pcall(function() return CFG.THEMES[getEffectiveTheme()] end)
    if ok and type(c) == "table" and c.TEXT then
        CA.lastGoodColors = c
        return c
    end
    return CA.lastGoodColors or CFG.THEMES.light
end

local C = getColors()

local function applyThemeToSystemBars()
    pcall(function()
        activity.getWindow().setStatusBarColor(C.BG)
        activity.getWindow().setNavigationBarColor(C.BG)
    end)
end

local function detectNetdisk(link)
    if not link or link == "" then return nil, nil end
    local ok, lower = pcall(function() return tostring(link):lower() end)
    if not ok or not lower then return nil, nil end
    for _, rule in ipairs(CFG.NETDISK_RULES) do
        local found = false
        pcall(function() found = lower:find(rule.key, 1, true) ~= nil end)
        if found then return rule.name, rule.icon end
    end
    return nil, nil
end

local function isConnectionReset(err)
    local s = tostring(err or "")
    return s:find("Connection reset") or s:find("SocketException")
        or s:find("unexpected end of stream") or s:find("broken pipe")
        or s:find("EOFException") or s:find("SSLException")
        or s:find("SSLHandshake") or s:find("Connection closed")
end

local function isActivityAlive()
    local ok, finishing = pcall(function() return activity.isFinishing() end)
    if not ok then return false end
    return not finishing
end

local function runOnUiSafely(fn)
    if not isActivityAlive() then return end
    postUi(function()
        if not isActivityAlive() then return end
        local ok, err = pcall(fn)
        if not ok then LOG("UI", tostring(err)) end
    end)
end

pcall(function()
    local ab = activity.getActionBar()
    if ab then ab.hide() end
end)
pcall(function()
    local sab = activity.getSupportActionBar()
    if sab then sab.hide() end
end)
applyThemeToSystemBars()

local function toast(msg)
    local now = os.time()
    local s = tostring(msg)
    if s == CA.lastToastMsg and now - CA.lastToastTs < 1 then return end
    CA.lastToastMsg = s
    CA.lastToastTs = now
    runOnUiSafely(function()
        Toast.makeText(activity, s, Toast.LENGTH_SHORT).show()
    end)
end

local function openConn(url, method, headers, ctMs, rtMs)
    local u = newUrl(url)
    if not u then error("无效 URL: " .. tostring(url)) end
    local conn = u.openConnection()
    conn.setConnectTimeout(ctMs or CFG.CONNECT_TIMEOUT_MS)
    conn.setReadTimeout(rtMs or CFG.READ_TIMEOUT_MS)
    conn.setRequestMethod(method or "GET")
    conn.setRequestProperty("User-Agent", CFG.USER_AGENT)
    conn.setRequestProperty("Accept", "application/json, */*")
    conn.setRequestProperty("Accept-Encoding", "gzip")
    conn.setUseCaches(false)
    conn.setInstanceFollowRedirects(true)
    if headers then
        for k, v in pairs(headers) do conn.setRequestProperty(k, v) end
    end
    return conn
end

local function readAll(ins)
    local out = newBAOS()
    if not out then error("BAOS 不可用") end
    local ByteType = luajava.bindClass("java.lang.Byte").TYPE
    local buf = luajava.newArray(ByteType, 8192)
    while true do
        local n = ins.read(buf, 0, 8192)
        if n == -1 then break end
        if n > 0 then out.write(buf, 0, n) end
    end
    local ok, s = pcall(function()
        return luajava.newInstance("java.lang.String", out.toByteArray(), "UTF-8")
    end)
    return ok and tostring(s) or tostring(out.toString())
end

local function httpGetOnce(url, timeoutMs)
    local ct = timeoutMs or CFG.CONNECT_TIMEOUT_MS
    local conn = openConn(url, "GET", nil, ct, math.max(ct, CFG.READ_TIMEOUT_MS))
    local ok, res = pcall(function()
        local code = conn.getResponseCode()
        if code == 304 then return "" end
        if code ~= 200 then error("HTTP " .. code) end
        local cenc = conn.getHeaderField("Content-Encoding")
        local ins = conn.getInputStream()
        if cenc and cenc:lower():find("gzip") then
            local gz = newGZIS(ins)
            if gz then ins = gz end
        end
        local body = readAll(ins)
        pcall(function() ins.close() end)
        return body
    end)
    pcall(function() conn.disconnect() end)
    if not ok then error(res) end
    return res
end

local function httpGetWithRetry(url, retries)
    retries = retries or CFG.HTTP_RETRY_TIMES
    local lastErr
    local tlsDown = false
    for i = 1, retries + 1 do
        local ok, res = pcall(httpGetOnce, url)
        if ok then return res end
        lastErr = res
        if isConnectionReset(res) and not tlsDown then forceTls12(); tlsDown = true end
        if i <= retries then
            pcall(function() Thread.sleep(math.min(400 * (2 ^ (i - 1)), 3000)) end)
        end
    end
    error(lastErr)
end

local function httpGetSmart(query)
    local lastErr = nil
    local order = { S.SERVER_LINE }
    for _, line in ipairs(CFG.SERVER_LINES) do
        if line.key ~= S.SERVER_LINE then order[#order + 1] = line.key end
    end
    for _, key in ipairs(order) do
        local url = apiUrlFor(key, query)
        local ok, res = pcall(httpGetWithRetry, url)
        if ok then
            if key ~= S.SERVER_LINE and not S.switchLock then
                S.switchLock = true
                pcall(function()
                    S.SERVER_LINE = key
                    writeServerPref(key)
                    local nowTs = os.time()
                    if S.lastSwitchNotifyKey ~= key or (nowTs - S.lastSwitchNotifyTs) > 300 then
                        S.lastSwitchNotifyKey = key
                        S.lastSwitchNotifyTs = nowTs
                        runOnUiSafely(function()
                            toast("已自动切换到：" .. getServerLabel(key))
                        end)
                    end
                end)
                S.switchLock = false
            end
            return res
        else
            lastErr = res
        end
    end
    error(lastErr or "所有线路均无法访问")
end

local function parseJsonList(jsonStr)
    if not jsonStr or jsonStr == "" then return nil end
    local ok, result = pcall(function()
        local root = JSONObject(jsonStr)
        if root.optInt("code", 0) ~= 200 then return nil end
        local hasData = true
        pcall(function() hasData = root.has("data") end)
        if not hasData then return {} end
        local isNull = false
        pcall(function() isNull = root.isNull("data") end)
        if isNull then return {} end
        local arr = nil
        pcall(function() arr = root.optJSONArray("data") end)
        if not arr then
            local obj = nil
            pcall(function() obj = root.optJSONObject("data") end)
            if obj and obj.length() == 0 then return {} end
            return nil
        end
        local list = {}
        for i = 0, arr.length() - 1 do
            local obj = arr.optJSONObject(i)
            if obj then
                table.insert(list, {
                    name   = obj.optString("name", ""),
                    time   = obj.optString("time", ""),
                    url    = obj.optString("url", ""),
                    desc   = obj.optString("desc", ""),
                    link   = obj.optString("link", ""),
                    author = obj.optString("author", ""),
                    pinned = obj.optBoolean("pinned", false),
                    today  = obj.optBoolean("today", false),
                })
            end
        end
        return list
    end)
    if ok then return result end
    return nil
end

local function parseBannerList(body)
    if not body or body == "" then return {} end
    local ok, result = pcall(function()
        local root = JSONObject(body)
        if root.optInt("code", 0) ~= 200 then return {} end
        local hasData = true
        pcall(function() hasData = root.has("data") end)
        if not hasData then return {} end
        local arr = nil
        pcall(function() arr = root.optJSONArray("data") end)
        if not arr then return {} end
        local list = {}
        for i = 0, arr.length() - 1 do
            local obj = arr.optJSONObject(i)
            if obj then
                local img = obj.optString("img", "")
                if img == "" then img = obj.optString("image", "") end
                if img == "" then img = obj.optString("pic", "") end
                if img == "" then img = obj.optString("cover", "") end
                if img == "" then img = obj.optString("src", "") end
                img = img:gsub("\\/", "/")
                img = img:gsub("^%s+", ""):gsub("%s+$", "")
                if img ~= "" then
                    if not img:find("^http") and not img:find("^//") then
                        if img:sub(1, 1) == "/" then
                            img = getServerUrl() .. img
                        else
                            img = getServerUrl() .. "/" .. img
                        end
                    elseif img:find("^//") then
                        img = "https:" .. img
                    end
                    list[#list + 1] = {
                        img = img,
                        url = obj.optString("url", ""),
                        title = obj.optString("title", ""),
                    }
                end
            end
        end
        return list
    end)
    if ok then return result end
    return {}
end

local function fetchBannersData()
    return parseBannerList(httpGetSmart("?action=banners"))
end

local function fetchNoticeData()
    local body = httpGetSmart("?action=notice")
    if not body or body == "" then return nil end
    local ok, result = pcall(function()
        local root = JSONObject(body)
        if root.optInt("code", 0) ~= 200 then return nil end
        local obj = nil
        pcall(function() obj = root.optJSONObject("data") end)
        if not obj then return nil end
        return { text = obj.optString("text", ""), link = obj.optString("link", "") }
    end)
    if ok then return result end
    return nil
end

local function fetchListData(catId)
    return parseJsonList(httpGetSmart("?action=list&type=" .. catId))
end

-- ==================== 首页聚合 ====================
local function sortHomeItems(a, b)
    if a.pinned ~= b.pinned then return a.pinned and true or false end
    return tostring(a.time or "") > tostring(b.time or "")
end

local function fetchAllLatest(callback)
    local fetchCats = { "gongneng", "meihua", "peizhi" }
    local total = #fetchCats
    if total == 0 then callback(false, nil, "无分类"); return end
    S._homeLatestGen = S._homeLatestGen + 1
    local myGen = S._homeLatestGen
    local done = 0
    local merged = {}
    local anyOk = false
    local finished = false
    local lastError = nil

    local function tryFinish()
        if finished then return end
        if myGen ~= S._homeLatestGen then finished = true; return end
        done = done + 1
        if done < total then return end
        finished = true
        if anyOk then
            table.sort(merged, sortHomeItems)
            callback(true, merged, lastError)
        else
            callback(false, nil, lastError or "网络请求失败")
        end
    end

    for _, catId in ipairs(fetchCats) do
        local myCatId = catId
        runAsync(function()
            local ok, list = pcall(fetchListData, myCatId)
            local err = nil
            if not ok then err = tostring(list or "unknown") end
            postUi(function()
                if myGen ~= S._homeLatestGen then return end
                if ok and list then
                    anyOk = true
                    S.dataCache[myCatId] = list
                    for _, item in ipairs(list) do
                        item.__catId = myCatId
                        merged[#merged + 1] = item
                    end
                else
                    lastError = err
                end
                tryFinish()
            end)
        end)
    end
end

local function cachedRoundedBg(color, radius)
    local key = string.format("r_%08X_%d", color, radius)
    local d = CA.drawable[key]
    if d then return d end
    local ok, gd = pcall(function()
        local g = GradientDrawable()
        g.setColor(color); g.setCornerRadius(dp(radius))
        return g
    end)
    if not ok or not gd then return nil end
    CA.drawable[key] = gd
    return gd
end

local function cachedRoundedBgStroke(fillColor, strokeColor, radius, strokeWidth)
    local key = string.format("s_%08X_%08X_%d_%d", fillColor, strokeColor, radius, strokeWidth)
    local d = CA.drawable[key]
    if d then return d end
    local ok, gd = pcall(function()
        local g = GradientDrawable()
        g.setColor(fillColor); g.setCornerRadius(dp(radius))
        g.setStroke(dp(strokeWidth), strokeColor)
        return g
    end)
    if not ok or not gd then return nil end
    CA.drawable[key] = gd
    return gd
end

-- ==================== 资源目录 ====================
local FIXED_RES_DIR = "/storage/emulated/0/LuaAppX_Pro/project/辉少工具箱/res"

local function collectResDirs()
    if CA.resDirs then return CA.resDirs end
    local dirs = {}
    local seen = {}

    local function addDir(d)
        if not d or d == "" then return end
        d = tostring(d):gsub("//+", "/"):gsub("/+$", "")
        if d == "" or d == "." then return end
        if seen[d] then return end
        seen[d] = true
        dirs[#dirs + 1] = d
    end

    pcall(function()
        local d = activity.getLuaDir()
        if d then addDir(d) end
    end)

    pcall(function()
        local f = activity.getExternalFilesDir(nil)
        if f then addDir(tostring(f.getAbsolutePath())) end
    end)

    pcall(function() addDir(FIXED_RES_DIR) end)

    CA.resDirs = dirs
    print("[ResDirs] 共收集 " .. #dirs .. " 个候选目录")
    return dirs
end

local function buildResIndex()
    if CA.resIndex then return CA.resIndex end
    local index = {}
    local dirs = collectResDirs()

    local function scanFlat(dir)
        pcall(function()
            local f = java.io.File(dir)
            if not f.exists() or not f.isDirectory() then return end
            local files = f.listFiles()
            if not files then return end
            local list = luajava.astable(files)
            for _, cf in ipairs(list) do
                if cf and cf.isFile() then
                    local name = tostring(cf.getName())
                    if not index[name] then
                        index[name] = tostring(cf.getAbsolutePath())
                    end
                end
            end
        end)
    end

    for _, d in ipairs(dirs) do
        scanFlat(d)
        scanFlat(d .. "/res")
    end

    CA.resIndex = index
    local cnt = 0
    for _ in pairs(index) do cnt = cnt + 1 end
    print("[ResIndex] 建立索引 " .. cnt .. " 个文件")
    return index
end

local function resolveResPath(fileName)
    if not fileName or fileName == "" then return nil, nil end

    local okFixed, fixedPath = pcall(function()
        local f = java.io.File(FIXED_RES_DIR .. "/" .. fileName)
        if f.exists() and f.isFile() then
            return tostring(f.getAbsolutePath())
        end
        return nil
    end)
    if okFixed and fixedPath then return "file", fixedPath end

    local index = buildResIndex()
    local hit = index[fileName]
    if hit then return "file", hit end

    local dirs = collectResDirs()
    local fileCands = {}
    local added = {}
    local function addCand(p)
        if not p or p == "" then return end
        p = tostring(p):gsub("//+", "/")
        if added[p] then return end
        added[p] = true
        fileCands[#fileCands + 1] = p
    end

    addCand("/res/" .. fileName)
    addCand("/sdcard/res/" .. fileName)
    addCand("/storage/emulated/0/res/" .. fileName)

    local pkg = nil
    pcall(function() pkg = tostring(activity.getPackageName()) end)
    if pkg then
        addCand("/storage/emulated/0/Android/data/" .. pkg .. "/files/res/" .. fileName)
    end

    local subPaths = { "", "res/", "res/png/", "res/icon/", "res/netdisk/" }
    for _, d in ipairs(dirs) do
        for _, sub in ipairs(subPaths) do
            addCand(d .. "/" .. sub .. fileName)
        end
    end

    for _, p in ipairs(fileCands) do
        local ok, exists = pcall(function() return java.io.File(p).exists() end)
        if ok and exists then return "file", p end
    end

    local assetCands = {
        fileName, "res/" .. fileName, "res/png/" .. fileName,
        "res/icon/" .. fileName, "res/netdisk/" .. fileName,
    }
    for _, name in ipairs(assetCands) do
        local ok, exists = pcall(function()
            local ins = activity.getAssets().open(name)
            ins.close()
            return true
        end)
        if ok and exists then return "asset", name end
    end

    return nil, nil
end

local function calcSampleSize(w, h, targetPx)
    if w <= 0 or h <= 0 then return 1 end
    local sample = 1
    while (w / sample) > targetPx * 2 and (h / sample) > targetPx * 2 do
        sample = sample * 2
    end
    return sample
end

local function decodeSampledFile(path, targetPx)
    local opts = android.graphics.BitmapFactory.Options()
    opts.inJustDecodeBounds = true
    android.graphics.BitmapFactory.decodeFile(path, opts)
    local sample = calcSampleSize(opts.outWidth or 0, opts.outHeight or 0, targetPx)

    local opts2 = android.graphics.BitmapFactory.Options()
    opts2.inSampleSize = sample
    opts2.inPreferredConfig = android.graphics.Bitmap.Config.RGB_565
    return android.graphics.BitmapFactory.decodeFile(path, opts2)
end

local function decodeSampledAsset(assetPath, targetPx)
    local opts = android.graphics.BitmapFactory.Options()
    opts.inJustDecodeBounds = true
    local ins = activity.getAssets().open(assetPath)
    android.graphics.BitmapFactory.decodeStream(ins, nil, opts)
    ins.close()
    local sample = calcSampleSize(opts.outWidth or 0, opts.outHeight or 0, targetPx)

    local opts2 = android.graphics.BitmapFactory.Options()
    opts2.inSampleSize = sample
    opts2.inPreferredConfig = android.graphics.Bitmap.Config.RGB_565
    local ins2 = activity.getAssets().open(assetPath)
    local bmp = android.graphics.BitmapFactory.decodeStream(ins2, nil, opts2)
    ins2.close()
    return bmp
end

local function _cachePutLocal(key, bmp)
    if CA.bmp[key] then return end
    CA.bmp[key] = bmp
    CA.bmpOrder[#CA.bmpOrder + 1] = key
    while #CA.bmpOrder > CFG.LOCAL_BMP_CACHE_MAX do
        local old = table.remove(CA.bmpOrder, 1)
        CA.bmp[old] = nil
    end
end

local function loadLocalBitmap(fileName, targetPx)
    if not fileName or fileName == "" then return nil end
    targetPx = targetPx or 128
    local key = fileName .. "@" .. targetPx

    local cached = CA.bmp[key]
    if cached then return cached end

    local failTs = CA.bmpFail[key]
    if failTs and (os.time() - failTs) < CFG.BMP_FAIL_RETRY_SEC then
        return nil
    end

    local kind, path = resolveResPath(fileName)
    if not kind then
        CA.bmpFail[key] = os.time()
        return nil
    end

    local bmp = nil
    local ok = pcall(function()
        if kind == "file" then
            bmp = decodeSampledFile(path, targetPx)
        else
            bmp = decodeSampledAsset(path, targetPx)
        end
    end)

    if not ok or not bmp then
        CA.bmpFail[key] = os.time()
        return nil
    end

    _cachePutLocal(key, bmp)
    CA.bmpFail[key] = nil
    return bmp
end

-- ★ 补全：多候选名加载
local function loadLocalBitmapMulti(names, targetPx)
    if type(names) ~= "table" then return nil end
    for _, name in ipairs(names) do
        local bmp = loadLocalBitmap(name, targetPx)
        if bmp then return bmp end
    end
    return nil
end

-- ★ 补全：清空目录缓存
local function resetResDirsCache()
    CA.resDirs = nil
    CA.resIndex = nil
end

local function loadNetdiskIcon(iconFileName)
    if not iconFileName or iconFileName == "" then return nil end
    if CA.netdiskIcon[iconFileName] ~= nil then
        local c = CA.netdiskIcon[iconFileName]
        return c or nil
    end
    local bmp = loadLocalBitmap(iconFileName, 64)
    CA.netdiskIcon[iconFileName] = bmp or false
    return bmp
end

local function cacheGetBmp(url)
    local b = CA.netBmp[url]
    if b then
        for i, u in ipairs(CA.netBmpOrder) do
            if u == url then table.remove(CA.netBmpOrder, i); break end
        end
        CA.netBmpOrder[#CA.netBmpOrder + 1] = url
    end
    return b
end

local function cachePutBmp(url, bmp)
    runOnUiSafely(function()
        if CA.netBmp[url] then return end
        CA.netBmp[url] = bmp
        CA.netBmpOrder[#CA.netBmpOrder + 1] = url
        while #CA.netBmpOrder > CFG.NET_BMP_CACHE_MAX do
            local old = table.remove(CA.netBmpOrder, 1)
            CA.netBmp[old] = nil
        end
    end)
end

local function setTextBold(tv, bold)
    if not tv then return end
    pcall(function()
        tv.setTypeface(bold and Typeface.DEFAULT_BOLD or Typeface.DEFAULT)
    end)
end

pcall(function() CA.systemRef = luajava.bindClass("java.lang.System") end)

local CLICK_KEY_TAG = 0x7F0F0F01
local function _clickKey(v)
    if not v then return "nil" end
    local cached = nil
    pcall(function() cached = v.getTag(CLICK_KEY_TAG) end)
    if cached ~= nil then return tostring(cached) end
    local k = "nil"
    if CA.systemRef then
        local ok, r = pcall(function() return CA.systemRef.identityHashCode(v) end)
        if ok and r ~= nil then k = tostring(r) end
    else
        k = tostring(v)
    end
    pcall(function() v.setTag(CLICK_KEY_TAG, k) end)
    return k
end

local function getClickProxy()
    if CA.clickProxy then return CA.clickProxy end
    CA.clickProxy = luajava.createProxy("android.view.View$OnClickListener", {
        onClick = function(v)
            local cb = CA.clickCallbacks[_clickKey(v)]
            if type(cb) ~= "function" then return end
            pcall(function()
                local scale = ScaleAnimation(0.97, 1.0, 0.97, 1.0,
                    Animation.RELATIVE_TO_SELF, 0.5,
                    Animation.RELATIVE_TO_SELF, 0.5)
                scale.setDuration(120)
                v.startAnimation(scale)
            end)
            local ok, err = xpcall(cb, function(e)
                return tostring(e) .. "\n" .. debug.traceback()
            end, v)
            if not ok then LOG("onClick", tostring(err)) end
        end
    })
    return CA.clickProxy
end

local function setOnClick(view, callback)
    if not view then return end
    local key = _clickKey(view)
    if not callback then
        pcall(function()
            CA.clickCallbacks[key] = nil
            view.setOnClickListener(nil)
        end)
        return
    end
    CA.clickCallbacks[key] = callback
    pcall(function()
        view.setClickable(true)
        view.setOnClickListener(getClickProxy())
    end)
end

local function safeDismiss(dialog)
    if not dialog then return end
    pcall(function() if dialog.isShowing() then dialog.dismiss() end end)
end

local function openExternalUrl(url)
    if not url or url == "" then return end
    pcall(function()
        local intent = Intent(Intent.ACTION_VIEW)
        intent.setData(Uri.parse(url))
        activity.startActivity(intent)
    end)
end

local function openAppByPackage(pkgName)
    if not pkgName or pkgName == "" then return false end
    local opened = false
    pcall(function()
        local pm = activity.getPackageManager()
        local intent = pm.getLaunchIntentForPackage(pkgName)
        if intent then
            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
            activity.startActivity(intent)
            opened = true
        end
    end)
    return opened
end

local function newDialog(contentView, widthMarginDp, cancelable)
    if not isActivityAlive() then return nil end
    local dlg = nil
    local ok = pcall(function()
        dlg = Dialog(activity)
        dlg.requestWindowFeature(Window.FEATURE_NO_TITLE)
        dlg.setContentView(contentView)
        dlg.setCancelable(cancelable ~= false)
        local win = dlg.getWindow()
        if win then
            win.setBackgroundDrawable(ColorDrawable(0x00000000))
            pcall(function() win.setSoftInputMode(0x20) end)
            local lp = win.getAttributes()
            lp.width = SCREEN_WIDTH_PX - dp(widthMarginDp or 40)
            lp.dimAmount = 0.55
            win.setAttributes(lp)
        end
    end)
    if not ok then return nil end
    return dlg
end

local function fadeInView(view, duration, delay)
    if not view then return end
    pcall(function()
        view.setAlpha(0)
        view.animate().alpha(1)
            .setDuration(duration or 220)
            .setStartDelay(delay or 0)
            .start()
    end)
end

local function slideUpFadeIn(view, duration, delay)
    if not view then return end
    pcall(function()
        view.setAlpha(0)
        view.setTranslationY(dp(20))
        view.animate().alpha(1).translationY(0)
            .setDuration(duration or 260)
            .setStartDelay(delay or 0)
            .start()
    end)
end

-- ==================== 主布局 ====================
local rootLayout = LinearLayout(activity)
rootLayout.setOrientation(LinearLayout.VERTICAL)
rootLayout.setBackgroundColor(C.BG)

local headerLayout = LinearLayout(activity)
headerLayout.setOrientation(LinearLayout.HORIZONTAL)
headerLayout.setGravity(Gravity.CENTER_VERTICAL)
headerLayout.setPadding(dp(12), dp(40), dp(20), dp(10))

local titleText = TextView(activity)
titleText.setText("主要")
titleText.setTextColor(C.TEXT)
titleText.setTextSize(22)
setTextBold(titleText, true)
titleText.setLayoutParams(LinearLayout.LayoutParams(0,
    LinearLayout.LayoutParams.WRAP_CONTENT, 1.0))
headerLayout.addView(titleText)

local backBtn = TextView(activity)
backBtn.setText("‹")  -- ★ FIX: 左箭头
backBtn.setTextSize(30)
backBtn.setTextColor(C.ACCENT)
backBtn.setGravity(Gravity.CENTER)
backBtn.setPadding(dp(8), 0, dp(8), 0)
backBtn.setVisibility(View.GONE)
headerLayout.addView(backBtn)

rootLayout.addView(headerLayout)

local scrollView = ScrollView(activity)
scrollView.setVerticalScrollBarEnabled(false)
pcall(function() scrollView.setOverScrollMode(View.OVER_SCROLL_NEVER) end)
scrollView.setLayoutParams(LinearLayout.LayoutParams(
    LinearLayout.LayoutParams.MATCH_PARENT,
    LinearLayout.LayoutParams.MATCH_PARENT))

local contentLayout = LinearLayout(activity)
contentLayout.setOrientation(LinearLayout.VERTICAL)
contentLayout.setPadding(dp(18), dp(8), dp(18), dp(120))
scrollView.addView(contentLayout)

local swipeRefresh = nil
local scrollContainer = nil

if SWIPE_CLASS then
    local ok, view = pcall(function()
        return luajava.newInstance(SWIPE_CLASS, activity)
    end)
    if ok and view then
        swipeRefresh = view
        swipeRefresh.setLayoutParams(LinearLayout.LayoutParams(
            LinearLayout.LayoutParams.MATCH_PARENT, 0, 1.0))
        swipeRefresh.addView(scrollView)
        pcall(function()
            swipeRefresh.setColorSchemeColors(C.ACCENT)
            swipeRefresh.setProgressBackgroundColorSchemeColor(C.CARD)
            swipeRefresh.setDistanceToTriggerSync(dp(80))
        end)
        scrollContainer = swipeRefresh
    end
end

if not scrollContainer then
    scrollView.setLayoutParams(LinearLayout.LayoutParams(
        LinearLayout.LayoutParams.MATCH_PARENT, 0, 1.0))
    scrollContainer = scrollView
end
rootLayout.addView(scrollContainer)

local function setTitleVisible(visible)
    pcall(function()
        if titleText then
            titleText.setVisibility(visible and View.VISIBLE or View.GONE)
        end
    end)
end

-- ==================== 底部导航 ====================
local suppressTabCallback = false
local liquidTabs = nil

local function buildFallbackTabs()
    local container = LinearLayout(activity)
    container.setOrientation(LinearLayout.HORIZONTAL)
    container.setGravity(Gravity.CENTER_VERTICAL)
    local bg = cachedRoundedBg(C.CARD, 22)
    if bg then container.setBackground(bg) end
    container.setPadding(dp(6), dp(6), dp(6), dp(6))

    local navParams = LinearLayout.LayoutParams(
        LinearLayout.LayoutParams.MATCH_PARENT,
        LinearLayout.LayoutParams.WRAP_CONTENT)
    navParams.leftMargin = dp(16)
    navParams.rightMargin = dp(16)
    navParams.bottomMargin = dp(16)
    container.setLayoutParams(navParams)

    local defs = {
        { text = "主要", png = "zy.png" },
        { text = "文件", png = "wj.png" },
        { text = "资源", png = "ziyuan.png" },
        { text = "设置", png = "sz.png" },
    }
    local items = {}
    local selected = 1
    local listener = nil

    local function refresh()
        for i, it in ipairs(items) do
            if i == selected then
                pcall(function()
                    local bg2 = cachedRoundedBg(C.ACCENT_SOFT_BG, 16)
                    if bg2 then it.box.setBackground(bg2) end
                end)
                it.txt.setTextColor(C.ACCENT)
                setTextBold(it.txt, true)
            else
                pcall(function() it.box.setBackground(nil) end)
                it.txt.setTextColor(C.TEXT_SUB)
                setTextBold(it.txt, false)
            end
        end
    end

    for i, def in ipairs(defs) do
        local box = LinearLayout(activity)
        box.setOrientation(LinearLayout.VERTICAL)
        box.setGravity(Gravity.CENTER)
        box.setPadding(dp(4), dp(8), dp(4), dp(8))
        box.setLayoutParams(LinearLayout.LayoutParams(
            0, LinearLayout.LayoutParams.WRAP_CONTENT, 1.0))

        local iv = ImageView(activity)
        local ivLp = LinearLayout.LayoutParams(dp(20), dp(20))
        ivLp.gravity = Gravity.CENTER_HORIZONTAL
        iv.setLayoutParams(ivLp)
        iv.setScaleType(ImageView.ScaleType.FIT_CENTER)
        iv.setVisibility(View.INVISIBLE)
        box.addView(iv)
        do
            local iconName = def.png
            runImgAsync(function()
                local bmp = loadLocalBitmap(iconName, 48)
                if bmp then
                    runOnUiSafely(function()
                        pcall(function()
                            iv.setImageBitmap(bmp)
                            iv.setVisibility(View.VISIBLE)
                        end)
                    end)
                end
            end)
        end

        local txt = TextView(activity)
        txt.setText(def.text)
        txt.setTextSize(11)
        txt.setGravity(Gravity.CENTER)
        local txtLp = LinearLayout.LayoutParams(
            LinearLayout.LayoutParams.WRAP_CONTENT,
            LinearLayout.LayoutParams.WRAP_CONTENT)
        txtLp.topMargin = dp(3)
        txt.setLayoutParams(txtLp)
        box.addView(txt)

        setOnClick(box, function()
            if selected == i then return end
            selected = i
            refresh()
            if listener and listener.onTabSelected then
                pcall(function() listener.onTabSelected(container, i - 1) end)
            end
        end)

        container.addView(box)
        items[i] = { box = box, txt = txt }
    end
    refresh()

    container.setSelectedIndex = function(idx, animated)
        idx = idx + 1
        if idx < 1 then idx = 1 end
        if idx > #items then idx = #items end
        if selected == idx then return end
        selected = idx
        refresh()
    end

    container.setOnTabSelectedListener = function(l) listener = l end
    return container
end

local function buildLiquidTabs()
    local ok, tabs = pcall(function()
        local t = LiquidBottomTabs(activity)
        t.setTabs({
            { text = "主要", png = LUA_DIR .. "/res/zy.png",     iconSize = 24 },
            { text = "文件", png = LUA_DIR .. "/res/wj.png",     iconSize = 24 },
            { text = "资源", png = LUA_DIR .. "/res/ziyuan.png", iconSize = 24 },
            { text = "设置", png = LUA_DIR .. "/res/sz.png",     iconSize = 24 },
        })
        t.setSelectedIndex(0, true)
        local navParams = LinearLayout.LayoutParams(
            LinearLayout.LayoutParams.MATCH_PARENT,
            LinearLayout.LayoutParams.WRAP_CONTENT)
        navParams.leftMargin = dp(16)
        navParams.rightMargin = dp(16)
        navParams.bottomMargin = dp(16)
        t.setLayoutParams(navParams)
        return t
    end)
    if ok and tabs then return tabs end
    return buildFallbackTabs()
end

do
    local ok, r = pcall(buildLiquidTabs)
    if ok and r then
        liquidTabs = r
        pcall(function() rootLayout.addView(liquidTabs) end)
    end
end

local function setNavActive(idx)
    if not liquidTabs then return end
    suppressTabCallback = true
    pcall(function() liquidTabs.setSelectedIndex(idx - 1, true) end)
    suppressTabCallback = false
end

local function warmupDrawables()
    cachedRoundedBg(C.CARD, 14)
    cachedRoundedBg(C.CARD, 22)
    cachedRoundedBg(C.ACCENT, 11)
    cachedRoundedBg(C.ACCENT_SOFT_BG, 12)
    cachedRoundedBg(C.BG, 22)
    cachedRoundedBgStroke(C.CARD, C.CARD_STROKE, 14, 1)
    cachedRoundedBgStroke(C.BG, C.CARD_STROKE, 12, 1)
    cachedRoundedBgStroke(C.ACCENT_SOFT_BG, C.ACCENT_SOFT_BORDER, 10, 1)
end

local lastEffectiveTheme = nil

local function applyThemeToUI()
    local ok, err = xpcall(function()
        C = getColors()
        CA.drawable = {}
        warmupDrawables()
        applyThemeToSystemBars()
        if rootLayout then rootLayout.setBackgroundColor(C.BG) end
        if titleText then titleText.setTextColor(C.TEXT) end
        if backBtn then backBtn.setTextColor(C.ACCENT) end
        if swipeRefresh then
            swipeRefresh.setColorSchemeColors(C.ACCENT)
            swipeRefresh.setProgressBackgroundColorSchemeColor(C.CARD)
        end
        setNavActive(S.currentTab)
        lastEffectiveTheme = getEffectiveTheme()
    end, function(e) return tostring(e) .. "\n" .. debug.traceback() end)
    if not ok then LOG("applyThemeToUI", tostring(err)) end
end

pcall(function()
    if not scrollView then return end
    local token = 0
    local stopRunnable = nil
    scrollView.setOnScrollChangeListener(luajava.createProxy(
        "android.view.View$OnScrollChangeListener", {
        onScrollChange = function(v, sx, sy, osx, osy)
            S.isScrolling = true
            token = token + 1
            local myTok = token
            if not stopRunnable then
                stopRunnable = makeRunnable(function()
                    if myTok == token then S.isScrolling = false end
                end)
            end
            if MAIN_HANDLER then
                pcall(function()
                    MAIN_HANDLER.removeCallbacks(stopRunnable)
                    MAIN_HANDLER.postDelayed(stopRunnable, 200)
                end)
            end
        end
    }))
end)

-- ==================== 卡片池 ====================
-- ★ FIX: 按 tag 查找子视图（兼容 Java String / Lua string）
-- 原生 View.findViewWithTag 使用 Java 对象 == 比较，Lua string 传入会创建新实例导致匹配失败
local function findChildByTag(parent, tag)
    if not parent then return nil end
    local cnt = 0
    pcall(function() cnt = parent.getChildCount() end)
    for i = 0, cnt - 1 do
        local ch = nil
        pcall(function() ch = parent.getChildAt(i) end)
        if ch then
            local t = nil
            pcall(function() t = tostring(ch.getTag()) end)
            if t == tag then return ch end
        end
    end
    return nil
end

local function recycleCard(card)
    if not card then return end
    pcall(function()
        -- ★ FIX: 取消所有正在进行的属性动画
        card.animate().cancel()
        card.clearAnimation()
        card.setAlpha(1)
        card.setTranslationY(0)
        card.setScaleX(1)
        card.setScaleY(1)
        CA.clickCallbacks[_clickKey(card)] = nil
        card.setOnClickListener(nil)
        -- ★ FIX: 主动从父布局移除，避免残留
        local parent = card.getParent()
        if parent then parent.removeView(card) end
    end)
    if #CA.cardPool < CA.cardPoolMax then
        CA.cardPool[#CA.cardPool + 1] = card
    end
end

local function obtainCard()
    local n = #CA.cardPool
    if n == 0 then return nil end
    local c = CA.cardPool[n]
    CA.cardPool[n] = nil
    return c
end

local function makeCard(title, subtitle, onClick)
    local card = obtainCard()
    local left, right
    if card then
        -- ★ FIX: 用 findChildByTag 替代原生 findViewWithTag（后者 tag 相等判断失败）
        left  = findChildByTag(card, "left")
        right = findChildByTag(card, "right")
        if not left or not right then
            recycleCard(card)
            card = nil
        end
    end
    if not card then
        card = LinearLayout(activity)
        card.setOrientation(LinearLayout.HORIZONTAL)
        card.setPadding(dp(16), dp(16), dp(16), dp(16))
        card.setGravity(Gravity.CENTER_VERTICAL)

        left = TextView(activity)
        left.setTextSize(14)
        left.setLayoutParams(LinearLayout.LayoutParams(0,
            LinearLayout.LayoutParams.WRAP_CONTENT, 1.0))
        left.setMaxLines(1); left.setSingleLine(true)
        left.setEllipsize(TextUtils.TruncateAt.MIDDLE)
        left.setTag("left")
        card.addView(left)

        right = TextView(activity)
        right.setTextSize(11)
        right.setMaxLines(1); right.setSingleLine(true)
        right.setTag("right")
        card.addView(right)

        card.setTag("card")
    end

    -- ★ FIX: 复用前先清空旧文字，防止上一次内容残留
    pcall(function()
        if left  then left.setText("")  end
        if right then right.setText("") end
    end)

    -- ★ FIX: 每次强制重置 LayoutParams（关键！修复卡片重叠/串台）
    local lp = LinearLayout.LayoutParams(
        LinearLayout.LayoutParams.MATCH_PARENT,
        LinearLayout.LayoutParams.WRAP_CONTENT)
    lp.setMargins(0, 0, 0, dp(10))
    card.setLayoutParams(lp)

    -- ★ FIX: 重置视觉状态，防止残留动画
    pcall(function()
        card.animate().cancel()
        card.clearAnimation()
        card.setAlpha(1)
        card.setTranslationY(0)
        card.setScaleX(1)
        card.setScaleY(1)
    end)

    local bg = cachedRoundedBgStroke(C.CARD, C.CARD_STROKE, 14, 1)
    if bg then card.setBackground(bg) end
    left.setText(tostring(title or ""))
    left.setTextColor(C.TEXT)
    right.setText(tostring(subtitle or ""))
    right.setTextColor(C.TEXT_SUB)
    setOnClick(card, onClick)
    return card
end

local function recycleAllCards()
    for i = #S.ACTIVE_BANNER_TASKS, 1, -1 do
        local t = S.ACTIVE_BANNER_TASKS[i]
        if t then
            t.running = false
            if t.handler then
                pcall(function() t.handler.removeCallbacksAndMessages(nil) end)
            end
        end
        S.ACTIVE_BANNER_TASKS[i] = nil
    end
    S.currentBannerTask = nil
    if contentLayout then
        pcall(function()
            for i = contentLayout.getChildCount() - 1, 0, -1 do
                local v = contentLayout.getChildAt(i)
                if v then
                    -- ★ FIX: 用 tostring 兼容 Java String，修复"卡片串台"根因
                    local tag = nil
                    pcall(function() tag = tostring(v.getTag()) end)
                    if tag == "card" then
                        contentLayout.removeViewAt(i)
                        recycleCard(v)
                    end
                end
            end
        end)
    end
end

local function makeSectionTitle(text)
    local tv = TextView(activity)
    tv.setText(tostring(text))
    tv.setTextColor(C.TEXT_SUB)
    tv.setTextSize(11)
    setTextBold(tv, true)
    tv.setPadding(dp(4), dp(6), 0, dp(8))
    return tv
end

local function getGreeting()
    local hour = tonumber(os.date("%H")) or 0
    if hour < 6 then return "夜深了" end
    if hour < 12 then return "Hi 早上好！" end
    if hour < 14 then return "Hi 中午好！" end
    if hour < 18 then return "Hi 下午好！" end
    if hour < 23 then return "Hi 晚上好！" end
    return "夜深了"
end

math.randomseed(tonumber(os.time()) or 0)
local function getRandomSubtitle()
    return CFG.SUBTITLE_POOL[math.random(1, #CFG.SUBTITLE_POOL)]
end

local function findCatName(catId)
    for _, cat in ipairs(CFG.CATEGORY_LIST) do
        if cat.id == catId then return cat.name end
    end
    return tostring(catId or "")
end

local function getAppVersionName()
    local ok, info = pcall(function()
        return activity.getPackageManager().getPackageInfo(
            activity.getPackageName(), 0)
    end)
    if ok and info then
        local v = nil
        pcall(function() v = tostring(info.versionName) end)
        if v and v ~= "" then return v end
    end
    return "1.2.0"
end

-- ==================== 远程更新 ====================
local function compareVersion(v1, v2)
    local t1, t2 = {}, {}
    for num in v1:gmatch("%d+") do table.insert(t1, tonumber(num)) end
    for num in v2:gmatch("%d+") do table.insert(t2, tonumber(num)) end
    for i = 1, math.max(#t1, #t2) do
        local n1 = t1[i] or 0
        local n2 = t2[i] or 0
        if n2 > n1 then return true end
        if n2 < n1 then return false end
    end
    return false
end

local function checkUpdate(isManual)
    if not isActivityAlive() then return end

    runAsync(function()
        local ok, body = pcall(httpGetSmart, "?action=update")
        if not ok or not body then
            if isManual then postUi(function() toast("检查更新失败，请检查网络") end) end
            return
        end

        local data = nil
        pcall(function()
            local root = JSONObject(body)
            if root.optInt("code", 0) == 200 then
                data = root.optJSONObject("data")
            end
        end)

        if not data then
            if isManual then postUi(function() toast("获取更新信息失败") end) end
            return
        end

        local serverVersion = data.optString("version", "1.2.0")
        local downloadUrl   = data.optString("download_url", "")
        local updateLog     = data.optString("update_log", "")
        local forceUpdate   = data.optBoolean("force_update", false)
        local fileSize      = data.optString("file_size", "")
        local updateSwitch  = data.optBoolean("update_switch", true)
        local localVersion  = getAppVersionName() or "1.2.0"

        if updateSwitch and compareVersion(localVersion, serverVersion) then
            postUi(function()
                local activity = activity
                local dp = dp
                local C = getColors()

                local dlgLayout = LinearLayout(activity)
                dlgLayout.setOrientation(LinearLayout.VERTICAL)
                dlgLayout.setPadding(dp(22), dp(22), dp(22), dp(18))
                local bg = cachedRoundedBg(C.CARD, 18)
                if bg then dlgLayout.setBackground(bg) end

                local titleTv = TextView(activity)
                titleTv.setText("发现新版本")
                titleTv.setTextColor(C.TEXT)
                titleTv.setTextSize(18)
                setTextBold(titleTv, true)
                dlgLayout.addView(titleTv)

                local verTv = TextView(activity)
                verTv.setText("最新版本：" .. serverVersion ..
                    (fileSize ~= "" and ("  |  " .. fileSize) or ""))
                verTv.setTextColor(C.TEXT_SUB)
                verTv.setTextSize(12)
                local vLp = LinearLayout.LayoutParams(-1, -2)
                vLp.setMargins(0, dp(6), 0, dp(12))
                verTv.setLayoutParams(vLp)
                dlgLayout.addView(verTv)

                if updateLog ~= "" then
                    local logTv = TextView(activity)
                    logTv.setText(updateLog)
                    logTv.setTextColor(C.TEXT)
                    logTv.setTextSize(13)
                    logTv.setLineSpacing(dp(4), 1.2)
                    logTv.setPadding(dp(14), dp(12), dp(14), dp(12))
                    logTv.setBackground(cachedRoundedBgStroke(
                        C.BG, C.CARD_STROKE, 10, 1))
                    local lLp = LinearLayout.LayoutParams(-1, -2)
                    lLp.setMargins(0, 0, 0, dp(16))
                    logTv.setLayoutParams(lLp)
                    dlgLayout.addView(logTv)
                end

                local btnRow = LinearLayout(activity)
                btnRow.setOrientation(LinearLayout.HORIZONTAL)

                local cancelBtn = TextView(activity)
                cancelBtn.setText("暂不更新")
                cancelBtn.setTextColor(C.TEXT)
                cancelBtn.setTextSize(14)
                cancelBtn.setGravity(Gravity.CENTER)
                cancelBtn.setPadding(0, dp(12), 0, dp(12))
                cancelBtn.setBackground(cachedRoundedBgStroke(
                    C.CARD_STROKE, C.CARD_STROKE, 11, 1))

                local goBtn = TextView(activity)
                goBtn.setText("立即更新")
                goBtn.setTextColor(0xffffffff)
                goBtn.setTextSize(14)
                goBtn.setGravity(Gravity.CENTER)
                goBtn.setPadding(0, dp(12), 0, dp(12))
                goBtn.setBackground(cachedRoundedBg(C.ACCENT, 11))

                if not forceUpdate then
                    local cLp = LinearLayout.LayoutParams(0, -2, 1.0)
                    cLp.setMargins(0, 0, dp(6), 0)
                    cancelBtn.setLayoutParams(cLp)
                    btnRow.addView(cancelBtn)
                end

                local gLp = LinearLayout.LayoutParams(
                    forceUpdate and -1 or 0, -2, 1.0)
                if not forceUpdate then gLp.setMargins(dp(6), 0, 0, 0) end
                goBtn.setLayoutParams(gLp)
                btnRow.addView(goBtn)
                dlgLayout.addView(btnRow)

                local dlg = newDialog(dlgLayout, 48, not forceUpdate)
                if not dlg then return end

                if not forceUpdate then
                    setOnClick(cancelBtn, function() safeDismiss(dlg) end)
                end
                setOnClick(goBtn, function()
                    safeDismiss(dlg)
                    if downloadUrl ~= "" then
                        openExternalUrl(downloadUrl)
                    else
                        toast("下载链接尚未配置")
                    end
                end)

                dlg.show()
                fadeInView(dlgLayout, 200, 0)
            end)
        else
            if isManual then
                postUi(function() toast("当前已是最新版本") end)
            end
        end
    end)
end

-- ==================== 上下文 ====================
local ctx = {
    LUA_DIR = LUA_DIR,
    activity = activity,
    dp = dp,
    SCREEN_DENSITY = SCREEN_DENSITY,
    SCREEN_WIDTH_PX = SCREEN_WIDTH_PX,

    CONNECT_TIMEOUT_MS = CFG.CONNECT_TIMEOUT_MS,
    READ_TIMEOUT_MS = CFG.READ_TIMEOUT_MS,
    BANNER_INTERVAL_MS = CFG.BANNER_INTERVAL_MS,
    VISIBILITY_CACHE_MS = CFG.VISIBILITY_CACHE_MS,
    HOME_MAX_ITEMS = CFG.HOME_MAX_ITEMS,
    HOME_LOAD_TIMEOUT_MS = CFG.HOME_LOAD_TIMEOUT_MS,
    THEME_CHECK_INTERVAL_MS = CFG.THEME_CHECK_INTERVAL_MS,

    OFFICIAL_WEBSITE_URL = CFG.OFFICIAL_WEBSITE_URL,

    SPONSOR_WX_NAMES  = CFG.SPONSOR_WX_NAMES,
    SPONSOR_ALI_NAMES = CFG.SPONSOR_ALI_NAMES,
    SPONSOR_WX_LOCAL  = CFG.SPONSOR_WX_LOCAL,
    SPONSOR_ALI_LOCAL = CFG.SPONSOR_ALI_LOCAL,
    SPONSOR_WX_IMG = CFG.SPONSOR_WX_IMG,
    SPONSOR_ALI_IMG = CFG.SPONSOR_ALI_IMG,
    PKG_WECHAT = CFG.PKG_WECHAT,
    PKG_ALIPAY = CFG.PKG_ALIPAY,

    CATEGORY_LIST = CFG.CATEGORY_LIST,
    HOME_LATEST_CATS = CFG.HOME_LATEST_CATS,
    HOME_STAT_CATS = CFG.HOME_STAT_CATS,
    HOME_CACHE_KEY = CFG.HOME_CACHE_KEY,
    TAB_HOME = CFG.TAB_HOME, TAB_FILES = CFG.TAB_FILES,
    TAB_ZIYUAN = CFG.TAB_ZIYUAN, TAB_SETTINGS = CFG.TAB_SETTINGS,
    SERVER_LINES = CFG.SERVER_LINES,
    API_PATH = CFG.API_PATH,

    EXT_EMOJI = CFG.EXT_EMOJI,
    DEFAULT_FILE_DESC = CFG.DEFAULT_FILE_DESC,
    NETDISK_RULES = CFG.NETDISK_RULES,

    SPONSOR_RES_OVERRIDE = SPONSOR_RES_OVERRIDE,

    LOG = LOG,
    postUi = postUi,
    postDelayed = postDelayed,
    runAsync = runAsync,
    runImgAsync = runImgAsync,
    runOnUiSafely = runOnUiSafely,
    isActivityAlive = isActivityAlive,
    makeRunnable = makeRunnable,
    toast = toast,
    forceTls12 = forceTls12,
    isConnectionReset = isConnectionReset,

    openConn = openConn,
    readAll = readAll,
    httpGetSmart = httpGetSmart,
    httpGetWithRetry = httpGetWithRetry,
    newBAOS = newBAOS,
    newGZIS = newGZIS,
    newUrl = newUrl,
    urlToAscii = _urlToAscii,
    byteArrayLen = byteArrayLen,
    acquireImgSlot = acquireImgSlot,
    releaseImgSlot = releaseImgSlot,
    acquireSponsorImgSlot = acquireSponsorImgSlot,
    releaseSponsorImgSlot = releaseSponsorImgSlot,

    parseJsonList = parseJsonList,
    parseBannerList = parseBannerList,
    fetchBannersData = fetchBannersData,
    fetchNoticeData = fetchNoticeData,
    fetchListData = fetchListData,
    fetchAllLatest = fetchAllLatest,
    sortHomeItems = sortHomeItems,

    cachedRoundedBg = cachedRoundedBg,
    cachedRoundedBgStroke = cachedRoundedBgStroke,
    loadLocalBitmap = loadLocalBitmap,
    loadLocalBitmapMulti = loadLocalBitmapMulti,
    loadNetdiskIcon = loadNetdiskIcon,
    collectResDirs = collectResDirs,
    buildResIndex = buildResIndex,
    resetResDirsCache = resetResDirsCache,
    cacheGetBmp = cacheGetBmp,
    cachePutBmp = cachePutBmp,
    setTextBold = setTextBold,
    setOnClick = setOnClick,
    safeDismiss = safeDismiss,
    newDialog = newDialog,
    fadeInView = fadeInView,
    slideUpFadeIn = slideUpFadeIn,
    openExternalUrl = openExternalUrl,
    openAppByPackage = openAppByPackage,

    makeCard = makeCard,
    recycleCard = recycleCard,
    recycleAllCards = recycleAllCards,
    makeSectionTitle = makeSectionTitle,

    getGreeting = getGreeting,
    getRandomSubtitle = getRandomSubtitle,
    findCatName = findCatName,
    detectNetdisk = detectNetdisk,

    getServerUrl = getServerUrl,
    getServerLabel = getServerLabel,
    apiUrlFor = apiUrlFor,
    readServerPref = readServerPref,
    writeServerPref = writeServerPref,

    getColors = getColors,
    getEffectiveTheme = getEffectiveTheme,
    getAppVersionName = getAppVersionName,

    checkUpdate = checkUpdate,

    rootLayout = rootLayout,
    headerLayout = headerLayout,
    contentLayout = contentLayout,
    scrollView = scrollView,
    scrollContainer = scrollContainer,
    swipeRefresh = swipeRefresh,
    titleText = titleText,
    backBtn = backBtn,
    setTitleVisible = setTitleVisible,
    setNavActive = setNavActive,
    warmupDrawables = warmupDrawables,
    applyThemeToUI = applyThemeToUI,

    state = S,

    showSponsorDialog = function() end,

    -- ★ renderHome6 会在函数定义后赋值
    renderHome6 = nil,
}

ctx.showDetailDialog = function(c, item)
    if not item or not isActivityAlive() then return end
    local ok, err = xpcall(function()
        local dp = c.dp
        local activity = c.activity
        local C = c.getColors()

        local netLink = tostring(item.link or ""):gsub("^%s+", ""):gsub("%s+$", "")
        if netLink == "" then
            local u = tostring(item.url or ""):gsub("^%s+", ""):gsub("%s+$", "")
            if u:find("^http") then netLink = u end
        end

        local dialogLayout = LinearLayout(activity)
        dialogLayout.setOrientation(LinearLayout.VERTICAL)
        dialogLayout.setPadding(dp(22), dp(22), dp(22), dp(18))
        local bg = cachedRoundedBg(C.CARD, 18)
        if bg then dialogLayout.setBackground(bg) end

        local titleRow = LinearLayout(activity)
        titleRow.setOrientation(LinearLayout.HORIZONTAL)
        titleRow.setGravity(Gravity.CENTER_VERTICAL)

        local ext = (item.name or ""):match("%.([%w]+)$")
        ext = ext and ext:lower() or ""
        local emoji = CFG.EXT_EMOJI[ext] or "📄"

        local iconTv = TextView(activity)
        iconTv.setText(emoji); iconTv.setTextSize(24)
        local iconLp = LinearLayout.LayoutParams(
            LinearLayout.LayoutParams.WRAP_CONTENT,
            LinearLayout.LayoutParams.WRAP_CONTENT)
        iconLp.setMargins(0, 0, dp(12), 0)
        iconTv.setLayoutParams(iconLp)
        titleRow.addView(iconTv)

        local dialogTitle = TextView(activity)
        dialogTitle.setText(tostring(item.name or "文件"))
        dialogTitle.setTextColor(C.TEXT); dialogTitle.setTextSize(15)
        setTextBold(dialogTitle, true)
        dialogTitle.setLayoutParams(LinearLayout.LayoutParams(0,
            LinearLayout.LayoutParams.WRAP_CONTENT, 1.0))
        dialogTitle.setMaxLines(2)
        titleRow.addView(dialogTitle)
        dialogLayout.addView(titleRow)

        if item.pinned or item.today then
            local badgeRow = LinearLayout(activity)
            badgeRow.setOrientation(LinearLayout.HORIZONTAL)
            local blp = LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.WRAP_CONTENT,
                LinearLayout.LayoutParams.WRAP_CONTENT)
            blp.setMargins(0, dp(8), 0, 0)
            badgeRow.setLayoutParams(blp)
            if item.pinned then
                local tag = TextView(activity)
                tag.setText(" 置顶")
                tag.setTextSize(11); tag.setTextColor(0xffffffff)
                tag.setPadding(dp(8), dp(3), dp(8), dp(3))
                local tbg = cachedRoundedBg(0xffaf52de, 8)
                if tbg then tag.setBackground(tbg) end
                local tlp = LinearLayout.LayoutParams(
                    LinearLayout.LayoutParams.WRAP_CONTENT,
                    LinearLayout.LayoutParams.WRAP_CONTENT)
                tlp.setMargins(0, 0, dp(6), 0)
                tag.setLayoutParams(tlp)
                badgeRow.addView(tag)
            end
            if item.today then
                local tag = TextView(activity)
                tag.setText("✨ 今日")
                tag.setTextSize(11); tag.setTextColor(0xffffffff)
                tag.setPadding(dp(8), dp(3), dp(8), dp(3))
                local tbg = cachedRoundedBg(C.SUCCESS, 8)
                if tbg then tag.setBackground(tbg) end
                badgeRow.addView(tag)
            end
            dialogLayout.addView(badgeRow)
        end

        local authorStr = tostring(item.author or "")
        if authorStr ~= "" then
            local authorTv = TextView(activity)
            authorTv.setText("👤 " .. authorStr)
            authorTv.setTextColor(C.ACCENT); authorTv.setTextSize(12)
            local authorLp = LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT,
                LinearLayout.LayoutParams.WRAP_CONTENT)
            authorLp.setMargins(0, dp(12), 0, 0)
            authorTv.setLayoutParams(authorLp)
            dialogLayout.addView(authorTv)
        end

        local descStr = item.desc
        if not descStr or descStr == "" then descStr = CFG.DEFAULT_FILE_DESC end

        local dialogDesc = TextView(activity)
        dialogDesc.setText(tostring(descStr))
        dialogDesc.setTextColor(C.TEXT_SUB); dialogDesc.setTextSize(12)
        dialogDesc.setLineSpacing(dp(4), 1.3)
        local descLp = LinearLayout.LayoutParams(
            LinearLayout.LayoutParams.MATCH_PARENT,
            LinearLayout.LayoutParams.WRAP_CONTENT)
        descLp.setMargins(0, dp(10), 0, 0)
        dialogDesc.setLayoutParams(descLp)
        dialogLayout.addView(dialogDesc)

        if item.time and item.time ~= "" then
            local metaTv = TextView(activity)
            metaTv.setText("📅 " .. tostring(item.time))
            metaTv.setTextColor(C.TEXT_SUB); metaTv.setTextSize(11)
            local metaLp = LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.WRAP_CONTENT,
                LinearLayout.LayoutParams.WRAP_CONTENT)
            metaLp.setMargins(0, dp(8), 0, 0)
            metaTv.setLayoutParams(metaLp)
            dialogLayout.addView(metaTv)
        end

        if netLink ~= "" and netLink:find("^http") then
            local netdiskName, netdiskIcon = detectNetdisk(netLink)
            local linkBox = LinearLayout(activity)
            linkBox.setOrientation(LinearLayout.HORIZONTAL)
            linkBox.setGravity(Gravity.CENTER_VERTICAL)
            linkBox.setPadding(dp(12), dp(12), dp(12), dp(12))
            local lbg = cachedRoundedBgStroke(
                C.ACCENT_SOFT_BG, C.ACCENT_SOFT_BORDER, 11, 1)
            if lbg then linkBox.setBackground(lbg) end
            local linkLp = LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT,
                LinearLayout.LayoutParams.WRAP_CONTENT)
            linkLp.setMargins(0, dp(14), 0, 0)
            linkBox.setLayoutParams(linkLp)

            local iconIv = nil
            if netdiskIcon then
                iconIv = ImageView(activity)
                local sz = dp(22)
                local ivLp = LinearLayout.LayoutParams(sz, sz)
                ivLp.setMargins(0, 0, dp(10), 0)
                ivLp.gravity = Gravity.CENTER_VERTICAL
                iconIv.setLayoutParams(ivLp)
                iconIv.setScaleType(ImageView.ScaleType.FIT_CENTER)
                iconIv.setVisibility(View.INVISIBLE)
                linkBox.addView(iconIv)
                do
                    local iconName = netdiskIcon
                    runImgAsync(function()
                        local bmp = loadNetdiskIcon(iconName)
                        if bmp then
                            runOnUiSafely(function()
                                pcall(function()
                                    iconIv.setImageBitmap(bmp)
                                    iconIv.setVisibility(View.VISIBLE)
                                end)
                            end)
                        end
                    end)
                end
            end

            if not iconIv then
                local emojiTv = TextView(activity)
                emojiTv.setText(netdiskName and "☁️" or "🔗")
                emojiTv.setTextSize(16)
                local eLp = LinearLayout.LayoutParams(
                    LinearLayout.LayoutParams.WRAP_CONTENT,
                    LinearLayout.LayoutParams.WRAP_CONTENT)
                eLp.setMargins(0, 0, dp(10), 0)
                eLp.gravity = Gravity.CENTER_VERTICAL
                emojiTv.setLayoutParams(eLp)
                linkBox.addView(emojiTv)
            end

            local linkText = TextView(activity)
            linkText.setText(netdiskName and ("网盘：" .. netdiskName) or "服务器")
            linkText.setTextColor(C.ACCENT); linkText.setTextSize(12)
            linkText.setLayoutParams(LinearLayout.LayoutParams(0,
                LinearLayout.LayoutParams.WRAP_CONTENT, 1.0))
            linkText.setMaxLines(1); linkText.setSingleLine(true)
            linkText.setEllipsize(TextUtils.TruncateAt.END)
            linkBox.addView(linkText)
            dialogLayout.addView(linkBox)
        end

        local btnLayout = LinearLayout(activity)
        btnLayout.setOrientation(LinearLayout.HORIZONTAL)
        local blLp = LinearLayout.LayoutParams(
            LinearLayout.LayoutParams.MATCH_PARENT,
            LinearLayout.LayoutParams.WRAP_CONTENT)
        blLp.setMargins(0, dp(18), 0, 0)
        btnLayout.setLayoutParams(blLp)

        local dialogRef = { dlg = nil }

        local closeBtn = TextView(activity)
        closeBtn.setText("关闭")
        closeBtn.setTextColor(C.TEXT); closeBtn.setTextSize(14)
        closeBtn.setGravity(Gravity.CENTER)
        closeBtn.setPadding(0, dp(12), 0, dp(12))
        local cbg = cachedRoundedBg(C.CARD_STROKE, 11)
        if cbg then closeBtn.setBackground(cbg) end

        if netLink ~= "" and netLink:find("^http") then
            local lcLp = LinearLayout.LayoutParams(0,
                LinearLayout.LayoutParams.WRAP_CONTENT, 1.0)
            lcLp.setMargins(0, 0, dp(6), 0)
            closeBtn.setLayoutParams(lcLp)
        else
            closeBtn.setLayoutParams(LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT,
                LinearLayout.LayoutParams.WRAP_CONTENT))
        end
        btnLayout.addView(closeBtn)

        if netLink ~= "" and netLink:find("^http") then
            local openBtn = TextView(activity)
            openBtn.setText("打开链接")
            openBtn.setTextColor(0xffffffff); openBtn.setTextSize(14)
            openBtn.setGravity(Gravity.CENTER)
            openBtn.setPadding(0, dp(12), 0, dp(12))
            local obg = cachedRoundedBg(C.ACCENT, 11)
            if obg then openBtn.setBackground(obg) end
            local obLp = LinearLayout.LayoutParams(0,
                LinearLayout.LayoutParams.WRAP_CONTENT, 1.0)
            obLp.setMargins(dp(6), 0, 0, 0)
            openBtn.setLayoutParams(obLp)
            btnLayout.addView(openBtn)

            setOnClick(openBtn, function()
                openExternalUrl(netLink)
                safeDismiss(dialogRef.dlg)
            end)
        end

        dialogLayout.addView(btnLayout)

        local dlg = newDialog(dialogLayout, 48, true)
        if not dlg then return end
        dialogRef.dlg = dlg
        setOnClick(closeBtn, function() safeDismiss(dlg) end)
        dlg.show()
        fadeInView(dialogLayout, 200, 0)
    end, function(e)
        return tostring(e) .. "\n" .. debug.traceback()
    end)
    if not ok then LOG("showDetailDialog", tostring(err)) end
end

-- ==================== 布局懒加载 ====================
local _layoutCache = {}

local function _buildLayoutCandidates(name)
    local baseName = name:gsub("%.lua$", "")
    local cands = {}

    if LUA_DIR and LUA_DIR ~= "" then
        cands[#cands + 1] = LUA_DIR .. "/layout/" .. name
        cands[#cands + 1] = LUA_DIR .. "/layout/" .. baseName .. ".lua"
        cands[#cands + 1] = LUA_DIR .. "/" .. name
        cands[#cands + 1] = LUA_DIR .. "/" .. baseName .. ".lua"
    end

    pcall(function()
        local ld = activity.getLuaDir()
        if type(ld) == "string" and ld ~= "" and ld ~= LUA_DIR then
            cands[#cands + 1] = ld .. "/layout/" .. name
            cands[#cands + 1] = ld .. "/layout/" .. baseName .. ".lua"
        end
    end)

    cands[#cands + 1] = "/storage/emulated/0/LuaAppX_Pro/project/辉少工具箱/layout/" .. name
    cands[#cands + 1] = "/sdcard/LuaAppX_Pro/project/辉少工具箱/layout/" .. name

    cands[#cands + 1] = "layout/" .. name
    cands[#cands + 1] = name

    return cands, baseName
end

local function getLayoutModule(name)
    local cached = _layoutCache[name]
    if cached ~= nil then
        return (cached == false) and nil or cached
    end

    local cands, baseName = _buildLayoutCandidates(name)
    local fn = nil
    local lastErr = nil

    pcall(function()
        local r = require("layout." .. baseName)
        if type(r) == "function" then fn = r end
    end)
    if not fn then
        pcall(function()
            local r = require("layout/" .. baseName)
            if type(r) == "function" then fn = r end
        end)
    end
    if not fn then
        pcall(function()
            local r = require(baseName)
            if type(r) == "function" then fn = r end
        end)
    end

    if not fn then
        for _, p in ipairs(cands) do
            local ok, r = pcall(dofile, p)
            if ok and type(r) == "function" then
                fn = r
                break
            end
            if not ok then lastErr = tostring(r) end
        end
    end

    if not fn then
        for _, p in ipairs(cands) do
            local ok, r = pcall(function()
                local lf = loadfile(p)
                if type(lf) ~= "function" then return nil end
                local result = lf()
                if type(result) == "function" then return result end
                return nil
            end)
            if ok and type(r) == "function" then
                fn = r
                break
            end
        end
    end

    if not fn then
        pcall(function()
            print("[getLayoutModule] ✗ " .. name .. " 加载失败: " .. tostring(lastErr))
        end)
        _layoutCache[name] = false
        return nil
    end

    pcall(function()
        print("[getLayoutModule] ✓ " .. name)
    end)
    _layoutCache[name] = fn
    return fn
end

ctx.getLayoutModule = getLayoutModule

local function showFallbackError(tabName, err)
    pcall(function()
        if not contentLayout then return end
        contentLayout.removeAllViews()
        local tv = TextView(activity)
        tv.setText("页面加载失败：" .. tostring(tabName) ..
            "\n\n" .. tostring(err):sub(1, 200))
        tv.setTextColor(C.TEXT_SUB)
        tv.setTextSize(13)
        tv.setGravity(Gravity.CENTER)
        tv.setPadding(dp(20), dp(60), dp(20), dp(60))
        contentLayout.addView(tv)
    end)
end

local function renderHome1()
    local home1 = getLayoutModule("home1.lua")
    if not home1 then showFallbackError("home1", "模块未加载"); return end
    local ok, err = xpcall(home1, function(e)
        return tostring(e) .. "\n" .. debug.traceback()
    end, ctx)
    if not ok then
        LOG("renderHome1", tostring(err))
        showFallbackError("首页", err)
    end
end

local function renderHome2()
    local home2 = getLayoutModule("home2.lua")
    if not home2 then showFallbackError("home2", "模块未加载"); return end
    local ok, err = xpcall(home2, function(e)
        return tostring(e) .. "\n" .. debug.traceback()
    end, ctx)
    if not ok then
        LOG("renderHome2", tostring(err))
        showFallbackError("文件", err)
    end
end

local function renderHome3()
    local home3 = getLayoutModule("home3.lua")
    if not home3 then showFallbackError("home3", "模块未加载"); return end
    local ok, err = xpcall(home3, function(e)
        return tostring(e) .. "\n" .. debug.traceback()
    end, ctx)
    if not ok then
        LOG("renderHome3", tostring(err))
        showFallbackError("资源", err)
    end
end

local function renderHome4()
    local home4 = getLayoutModule("home4.lua")
    if not home4 then showFallbackError("home4", "模块未加载"); return end
    local ok, err = xpcall(home4, function(e)
        return tostring(e) .. "\n" .. debug.traceback()
    end, ctx)
    if not ok then
        LOG("renderHome4", tostring(err))
        showFallbackError("设置", err)
    end
end

-- ★ home6 关于页
local function renderHome6()
    local home6 = getLayoutModule("home6.lua")
    if not home6 then showFallbackError("home6", "模块未加载"); return end
    local ok, err = xpcall(home6, function(e)
        return tostring(e) .. "\n" .. debug.traceback()
    end, ctx)
    if not ok then
        LOG("renderHome6", tostring(err))
        showFallbackError("关于", err)
    end
end

-- ★★★ 在函数定义之后再挂到 ctx 上 ★★★
ctx.renderHome6 = renderHome6

_G.__load_home1 = renderHome1
_G.__load_home2 = renderHome2
_G.__load_home3 = renderHome3
_G.__load_home4 = renderHome4
_G.__load_home6 = renderHome6

local function switchTab(idx)
    local ok, err = xpcall(function()
        if not isActivityAlive() then return end
        if S.searchQuery ~= nil then
            S.searchGen = S.searchGen + 1
            S.searchQuery = nil
            S.searchResults = nil
            S.searchLoading = false
        end
        S.currentTab = idx
        S.subPage = nil
        setNavActive(idx)

        if idx == CFG.TAB_HOME then
            if backBtn then backBtn.setVisibility(View.GONE) end
            setTitleVisible(false)
            renderHome1()
        elseif idx == CFG.TAB_FILES then
            setTitleVisible(true)
            if titleText then titleText.setText("文件") end
            renderHome2()
        elseif idx == CFG.TAB_ZIYUAN then
            if backBtn then backBtn.setVisibility(View.GONE) end
            setTitleVisible(true)
            if titleText then titleText.setText("资源") end
            renderHome3()
        else
            setTitleVisible(true)
            if titleText then titleText.setText("设置") end
            renderHome4()
        end
    end, function(e)
        return tostring(e) .. "\n" .. debug.traceback()
    end)
    if not ok then LOG("switchTab", tostring(err)) end
end

ctx.switchTab = switchTab

ctx.reloadCurrentTab = function()
    if S.currentTab then
        switchTab(S.currentTab)
    else
        switchTab(CFG.TAB_HOME)
    end
end

-- ★ 返回逻辑统一封装
local function handleBack()
    -- 1) 子页面 home6 → 返回设置
    if S.subPage == "home6" then
        S.subPage = nil
        renderHome4()
        return true
    end
    -- 2) 搜索态 → 清除搜索
    if S.searchQuery ~= nil then
        S.searchGen = S.searchGen + 1
        S.searchQuery = nil
        S.searchResults = nil
        S.searchLoading = false
        if backBtn then backBtn.setVisibility(View.GONE) end
        if S.currentTab == CFG.TAB_HOME then
            setTitleVisible(false)
            renderHome1()
        else
            setTitleVisible(true)
        end
        return true
    end
    -- 3) 文件页分类 → 返回分类列表
    if S.currentTab == CFG.TAB_FILES then
        if _G.__home2_resetActiveCat then pcall(_G.__home2_resetActiveCat) end
        renderHome2()
        return true
    end
    -- 4) 其它 → 不处理
    return false
end

setOnClick(backBtn, function()
    if not handleBack() then
        renderHome2()
    end
end)

if liquidTabs then
    pcall(function()
        liquidTabs.setOnTabSelectedListener({
            onTabSelected = function(view, index)
                if suppressTabCallback then return end
                switchTab(index + 1)
            end
        })
    end)
end

if swipeRefresh and SWIPE_LISTENER_CLASS then
    pcall(function()
        swipeRefresh.setOnRefreshListener(luajava.createProxy(SWIPE_LISTENER_CLASS, {
            onRefresh = function()
                local ok, err = xpcall(function()
                    if S.searchQuery ~= nil then
                        pcall(function() swipeRefresh.setRefreshing(false) end)
                        S.searchGen = S.searchGen + 1
                        S.searchQuery = nil
                        S.searchResults = nil
                        S.searchLoading = false
                        renderHome1()
                        return
                    end
                    if S.currentTab == CFG.TAB_SETTINGS then
                        renderHome4()
                        pcall(function() swipeRefresh.setRefreshing(false) end)
                        return
                    end
                    if S.currentTab == CFG.TAB_HOME then
                        S.bannerData = nil
                        S.noticeData = nil
                        S.dataCache[CFG.HOME_CACHE_KEY] = nil
                        S.cachedSubtitle = nil
                        S.isLoading[CFG.HOME_CACHE_KEY] = false
                        for _, catId in ipairs(CFG.HOME_STAT_CATS) do
                            S.dataCache[catId] = nil
                        end
                        runAsync(function()
                            local bOk, bResult = pcall(fetchBannersData)
                            local bData = (bOk and bResult and #bResult > 0) and bResult or {}
                            local nData = nil
                            pcall(function() nData = fetchNoticeData() end)
                            postUi(function()
                                S.bannerData = bData
                                S.noticeData = nData or { text = "", link = "" }
                            end)
                            fetchAllLatest(function(ok2, merged, err2)
                                pcall(function() swipeRefresh.setRefreshing(false) end)
                                if ok2 and merged then
                                    S.dataCache[CFG.HOME_CACHE_KEY] = merged
                                    if S.currentTab == CFG.TAB_HOME then renderHome1() end
                                else
                                    toast("刷新失败，请检查网络")
                                end
                            end)
                        end)
                        return
                    end
                    if S.currentTab == CFG.TAB_FILES then
                        for k in pairs(S.dataCache) do S.dataCache[k] = nil end
                        for k in pairs(S.isLoading) do S.isLoading[k] = nil end
                        S.currentCategory = nil
                        renderHome2()
                        pcall(function() swipeRefresh.setRefreshing(false) end)
                        return
                    end
                    if S.currentTab == CFG.TAB_ZIYUAN then
                        S.dataCache["ziyuan"] = nil
                        S.isLoading["ziyuan"] = false
                        renderHome3()
                        pcall(function() swipeRefresh.setRefreshing(false) end)
                        return
                    end
                end, function(e)
                    return tostring(e) .. "\n" .. debug.traceback()
                end)
                if not ok then
                    LOG("onRefresh", tostring(err))
                    pcall(function() swipeRefresh.setRefreshing(false) end)
                end
            end
        }))
    end)
end

lastEffectiveTheme = getEffectiveTheme()

local function checkAutoTheme()
    local ok, err = pcall(function()
        if S.THEME_MODE ~= "default" then return end
        local nowTheme = getEffectiveTheme()
        if nowTheme == lastEffectiveTheme then return end
        lastEffectiveTheme = nowTheme
        applyThemeToUI()
        if S.searchQuery ~= nil then
            renderHome1()
        elseif S.currentTab == CFG.TAB_SETTINGS then
            renderHome4()
        elseif S.currentTab == CFG.TAB_ZIYUAN then
            renderHome3()
        elseif S.currentTab == CFG.TAB_FILES then
            renderHome2()
        elseif S.currentTab == CFG.TAB_HOME then
            renderHome1()
        end
        toast("已自动切换为：" .. (nowTheme == "light" and "浅色" or "深色"))
    end)
    if not ok then LOG("checkAutoTheme", tostring(err)) end
end

local function themeLoop()
    if not isActivityAlive() then return end
    pcall(checkAutoTheme)
    postDelayed(themeLoop, CFG.THEME_CHECK_INTERVAL_MS)
end

local function preloadSponsorQR()
    runImgAsync(function()
        pcall(buildResIndex)
        pcall(function()
            for _, name in ipairs(CFG.SPONSOR_WX_NAMES) do
                loadLocalBitmap(name, 320)
            end
            for _, name in ipairs(CFG.SPONSOR_ALI_NAMES) do
                loadLocalBitmap(name, 320)
            end
            loadLocalBitmap("zy.png", 48)
            loadLocalBitmap("wj.png", 48)
            loadLocalBitmap("ziyuan.png", 48)
            loadLocalBitmap("sz.png", 48)
            loadLocalBitmap("ss.png", 32)
            loadLocalBitmap("logo.png", 96)
        end)
    end)
end

local function safeBoot()
    pcall(function() warmupDrawables() end)

    local ok = pcall(function()
        activity.setContentView(rootLayout)
    end)

    if not ok then
        pcall(function()
            local tv = TextView(activity)
            tv.setText("界面初始化失败，请查看日志")
            tv.setTextColor(0xff000000)
            tv.setTextSize(16)
            tv.setGravity(Gravity.CENTER)
            activity.setContentView(tv)
        end)
        return
    end

    pcall(function() applyThemeToUI() end)
    pcall(function() switchTab(CFG.TAB_HOME) end)
    pcall(function() postDelayed(themeLoop, CFG.THEME_CHECK_INTERVAL_MS) end)

    pcall(function()
        postDelayed(function()
            pcall(function() getLayoutModule("home2.lua") end)
            pcall(function() getLayoutModule("home3.lua") end)
            pcall(function() getLayoutModule("home4.lua") end)
            pcall(function() getLayoutModule("home6.lua") end)
        end, 500)
    end)

    pcall(function()
        postDelayed(function()
            pcall(preloadSponsorQR)
        end, 1200)
    end)

    pcall(function()
        postDelayed(function()
            pcall(function()
                for i = 1, 6 do
                    local card = LinearLayout(activity)
                    card.setOrientation(LinearLayout.HORIZONTAL)
                    card.setPadding(dp(16), dp(16), dp(16), dp(16))
                    card.setGravity(Gravity.CENTER_VERTICAL)

                    local left = TextView(activity)
                    left.setTextSize(14)
                    left.setLayoutParams(LinearLayout.LayoutParams(0,
                        LinearLayout.LayoutParams.WRAP_CONTENT, 1.0))
                    left.setMaxLines(1); left.setSingleLine(true)
                    left.setEllipsize(TextUtils.TruncateAt.MIDDLE)
                    left.setTag("left")
                    card.addView(left)

                    local right = TextView(activity)
                    right.setTextSize(11)
                    right.setMaxLines(1); right.setSingleLine(true)
                    right.setTag("right")
                    card.addView(right)

                    local lp = LinearLayout.LayoutParams(
                        LinearLayout.LayoutParams.MATCH_PARENT,
                        LinearLayout.LayoutParams.WRAP_CONTENT)
                    lp.setMargins(0, 0, 0, dp(10))
                    card.setLayoutParams(lp)
                    card.setTag("card")

                    if #CA.cardPool < CA.cardPoolMax then
                        CA.cardPool[#CA.cardPool + 1] = card
                    end
                end
            end)
        end, 2000)
    end)

    pcall(function()
        postDelayed(function()
            pcall(function() checkUpdate(false) end)
        end, 3000)
    end)
end

local bootOk, bootErr = xpcall(safeBoot, function(e)
    return tostring(e) .. "\n" .. debug.traceback()
end)

if not bootOk then
    pcall(function()
        print("=== BOOT ERROR ===")
        print(bootErr)
    end)
end

-- ★★★ 系统返回键统一处理 ★★★
function onBackPressed()
    local ok, handled = pcall(handleBack)
    if ok and handled then return end
    pcall(function() activity.finish() end)
end

function onDestroy()
    pcall(function()
        THREAD_POOL_CLOSED = true
        S._homeLatestGen = S._homeLatestGen + 1

        for i = #S.ACTIVE_BANNER_TASKS, 1, -1 do
            local t = S.ACTIVE_BANNER_TASKS[i]
            if t then
                t.running = false
                if t.handler then
                    pcall(function() t.handler.removeCallbacksAndMessages(nil) end)
                end
            end
            S.ACTIVE_BANNER_TASKS[i] = nil
        end

        if MAIN_HANDLER then
            pcall(function() MAIN_HANDLER.removeCallbacksAndMessages(nil) end)
        end
        if THREAD_POOL then pcall(function() THREAD_POOL.shutdownNow() end) end
        if IMG_POOL then pcall(function() IMG_POOL.shutdownNow() end) end

        for k, b in pairs(CA.bmp) do
            pcall(function() if b and not b.isRecycled() then b.recycle() end end)
            CA.bmp[k] = nil
        end
        for i = #CA.bmpOrder, 1, -1 do CA.bmpOrder[i] = nil end
        for k in pairs(CA.netdiskIcon) do CA.netdiskIcon[k] = nil end
        for k in pairs(CA.bmpFail) do CA.bmpFail[k] = nil end
        for k in pairs(CA.drawable) do CA.drawable[k] = nil end
        for i = #CA.cardPool, 1, -1 do CA.cardPool[i] = nil end
        for k, b in pairs(CA.netBmp) do
            pcall(function() if b and not b.isRecycled() then b.recycle() end end)
            CA.netBmp[k] = nil
        end
        for i = #CA.netBmpOrder, 1, -1 do CA.netBmpOrder[i] = nil end
        for k in pairs(CA.clickCallbacks) do CA.clickCallbacks[k] = nil end
        CA.resDirs = nil
        CA.resIndex = nil
        CA.clickProxy = nil

        _layoutCache = {}
        _dpCache = {}
    end)
end

do
    if not _G.__LIFECYCLE_WRAPPED__ then
        _G.__LIFECYCLE_WRAPPED__ = true
        local function wrapLifecycle(name)
            local orig = _G[name]
            if type(orig) ~= "function" then return end
            _G[name] = function(...)
                local n = select("#", ...)
                local args = { ... }
                local ok, err = xpcall(function()
                    return orig(_unpack(args, 1, n))
                end, function(e)
                    return tostring(e) .. "\n" .. debug.traceback()
                end)
                if not ok then LOG("Life:" .. name, tostring(err)) end
            end
        end
        wrapLifecycle("onCreate")
        wrapLifecycle("onStart")
        wrapLifecycle("onResume")
        wrapLifecycle("onPause")
        wrapLifecycle("onStop")
        wrapLifecycle("onRestart")
        wrapLifecycle("onDestroy")
        wrapLifecycle("onBackPressed")
        wrapLifecycle("onActivityResult")
        wrapLifecycle("onNewIntent")
        wrapLifecycle("onSaveInstanceState")
        wrapLifecycle("onRestoreInstanceState")
    end
end