diff --git a/ToolHub.js b/ToolHub.js index 6cf2d62..7ad0b28 100644 --- a/ToolHub.js +++ b/ToolHub.js @@ -2,11 +2,22 @@ // 将本文件放入 ShortX 任务,th_*.js 放入 ShortX 数据根目录/ToolHub/code/ 文件夹 var MODULE_MANIFEST = { - "th_1_base.js": "1.0.0", - "th_2_core.js": "1.0.0", - "th_3_panels.js": "1.0.0", - "th_4_extra.js": "1.0.0", - "th_5_entry.js": "1.0.0" + "th_01_base.js": "1.0.0", + "th_02_core.js": "1.0.0", + "th_03_icon.js": "1.0.0", + "th_04_theme.js": "1.0.0", + "th_05_persistence.js": "1.0.0", + "th_06_icon_parser.js": "1.0.0", + "th_07_shortcut.js": "1.0.0", + "th_08_content.js": "1.0.0", + "th_09_animation.js": "1.0.0", + "th_10_shell.js": "1.0.0", + "th_11_action.js": "1.0.0", + "th_12_rebuild.js": "1.0.0", + "th_13_panel_ui.js": "1.0.0", + "th_14_panels.js": "1.0.0", + "th_15_extra.js": "1.0.0", + "th_16_entry.js": "1.0.0" }; var GIT_BASE = "https://git.xin-blog.com/linshenjianlu/ShortX_ToolHub/raw/branch/main/code/"; @@ -187,7 +198,10 @@ function loadScript(relPath) { } } -var modules = ["th_1_base.js", "th_2_core.js", "th_3_panels.js", "th_4_extra.js", "th_5_entry.js"]; +var modules = ["th_01_base.js", "th_02_core.js", "th_03_icon.js", "th_04_theme.js", "th_05_persistence.js", + "th_06_icon_parser.js", "th_07_shortcut.js", "th_08_content.js", "th_09_animation.js", + "th_10_shell.js", "th_11_action.js", "th_12_rebuild.js", "th_13_panel_ui.js", + "th_14_panels.js", "th_15_extra.js", "th_16_entry.js"]; var loadErrors = []; for (var i = 0; i < modules.length; i++) { try { @@ -195,7 +209,7 @@ for (var i = 0; i < modules.length; i++) { } catch (e) { writeLog("Module load failed: " + modules[i] + " -> " + String(e)); loadErrors.push({ module: modules[i], err: String(e) }); - if (modules[i] === "th_5_entry.js") { + if (modules[i] === "th_16_entry.js") { throw "Critical module failed: " + modules[i]; } } diff --git a/code/th_1_base.js b/code/th_01_base.js similarity index 100% rename from code/th_1_base.js rename to code/th_01_base.js diff --git a/code/th_02_core.js b/code/th_02_core.js new file mode 100644 index 0000000..72b97fc --- /dev/null +++ b/code/th_02_core.js @@ -0,0 +1,123 @@ +// @version 1.0.0 +function FloatBallAppWM(logger) { + this.L = logger || null; + + // # 加载配置 + this.config = ConfigManager.loadSettings(); + this.currentPanelKey = "main"; + this.panels = { main: ConfigManager.loadButtons() }; + + // # 更新 Logger 配置(因为 Logger 初始化时是默认值) + if (this.L) this.L.updateConfig(this.config); + + this.state = { + receivers: [], // 存储广播接收器引用,用于 close 时注销 + wm: null, + dm: null, + density: 1.0, + + screen: { w: 0, h: 0 }, + lastRotation: -1, + lastMonitorTs: 0, + + ht: null, + h: null, + + addedBall: false, + addedPanel: false, + addedSettings: false, + addedViewer: false, + addedMask: false, + + ballRoot: null, + ballContent: null, + ballLp: null, + + panel: null, + panelLp: null, + + settingsPanel: null, + settingsPanelLp: null, + + viewerPanel: null, + viewerPanelLp: null, + + mask: null, + maskLp: null, + + loadedPos: null, + lastSaveTs: 0, + + dragging: false, + rawX: 0, + rawY: 0, + downX: 0, + downY: 0, + + docked: false, + dockSide: null, + + lastMotionTs: 0, + idleDockRunnable: null, + + longPressArmed: false, + longPressTriggered: false, + longPressRunnable: null, + + displayListener: null, + + // # 设置面板:临时编辑缓存 + pendingUserCfg: null, + pendingDirty: false, + + closing: false + }; + + // # 创建实例独立的 UI 工具对象,避免多实例共享颜色状态 + this.ui = {}; + var protoUi = FloatBallAppWM.prototype.ui; + for (var _uiKey in protoUi) { + this.ui[_uiKey] = protoUi[_uiKey]; + } + this.ui.colors = {}; + + // # 初始化莫奈动态配色(传入当前主题避免重复检测) + try { this.refreshMonetColors(this.isDarkTheme()); } catch (eM) {} +} + +// =======================【工具:dp/now/clamp】====================== +FloatBallAppWM.prototype.dp = function(v) { return Math.floor(Number(v) * this.state.density); }; +FloatBallAppWM.prototype.sp = function(v) { try { return Math.floor(Number(v) * context.getResources().getDisplayMetrics().scaledDensity); } catch (e) { return Math.floor(Number(v) * this.state.density); } }; +FloatBallAppWM.prototype.now = function() { return new Date().getTime(); }; +FloatBallAppWM.prototype.clamp = function(v, min, max) { if (v < min) return min; if (v > max) return max; return v; }; +FloatBallAppWM.prototype.rectIntersect = function(ax, ay, aw, ah, bx, by, bw, bh) { + return !(ax + aw <= bx || bx + bw <= ax || ay + ah <= by || by + bh <= ay); +}; + +// # 这段代码的主要内容/用途:安全地在 UI 线程执行代码(用于后台线程回调更新 UI) +FloatBallAppWM.prototype.runOnUiThreadSafe = function(fn) { + try { + if (!fn) return; + var self = this; + // 优先使用 Activity 的 runOnUiThread,否则使用 View.post + if (this.state && this.state.ballRoot) { + this.state.ballRoot.post(new java.lang.Runnable({ + run: function() { try { fn.call(self); } catch(e) {} } + })); + } else { + // 兜底:直接执行(如果已经在 UI 线程)或尝试使用 Handler + try { + var h = new android.os.Handler(android.os.Looper.getMainLooper()); + h.post(new java.lang.Runnable({ + run: function() { try { fn.call(self); } catch(e) {} } + })); + } catch(e) { + // 最后兜底:直接执行 + try { fn.call(self); } catch(e2) {} + } + } + } catch(e) {} +}; + +// # 这段代码的主要内容/用途:统一图标缓存(LRU),减少反复解码/反复走 PackageManager,降低卡顿与内存波动(不改变 UI 与功能) +// 优化后的图标缓存(带 Bitmap 回收,防止内存泄漏) diff --git a/code/th_03_icon.js b/code/th_03_icon.js new file mode 100644 index 0000000..b0bc907 --- /dev/null +++ b/code/th_03_icon.js @@ -0,0 +1,170 @@ +// @version 1.0.0 +FloatBallAppWM.prototype._iconCache = { + map: {}, + keys: [], + max: 80, // 减少缓存数量,降低内存压力 + + get: function(key) { + var item = this.map[key]; + if (!item) return null; + // 移动到末尾(最近使用) + var idx = this.keys.indexOf(key); + if (idx > -1) { + this.keys.splice(idx, 1); + this.keys.push(key); + } + return item.dr; + }, + + put: function(key, drawable) { + // 清理旧的 + if (this.map[key]) { + this._remove(key); + } + + // 空间检查:超过 80% 时批量清理 20% + if (this.keys.length >= this.max * 0.8) { + var removeCount = Math.floor(this.max * 0.2); + for (var i = 0; i < removeCount && this.keys.length > 0; i++) { + var oldKey = this.keys.shift(); + this._remove(oldKey); + } + } + + this.keys.push(key); + this.map[key] = {dr: drawable, ts: Date.now()}; + }, + + _remove: function(key) { + var item = this.map[key]; + if (item && item.dr) { + // 关键:回收 Bitmap,防止内存泄漏 + try { + if (item.dr instanceof android.graphics.drawable.BitmapDrawable) { + var bmp = item.dr.getBitmap(); + if (bmp && !bmp.isRecycled()) bmp.recycle(); + } + } catch(e) {} + delete this.map[key]; + } + }, + + clear: function() { + for (var i = 0; i < this.keys.length; i++) { + this._remove(this.keys[i]); + } + this.keys = []; + } +}; + +// 兼容性封装(保持原有调用方式不变) +FloatBallAppWM.prototype._iconLruEnsure = function() {}; +FloatBallAppWM.prototype._iconLruGet = function(key) { + return this._iconCache.get(key); +}; + +FloatBallAppWM.prototype._iconLruPut = function(key, val) { + try { + this._iconLruEnsure(120); + var k = String(key || ""); + if (!k) return; + if (val == null) return; + + // # 若已存在,先移除旧顺序位置 + try { + var ord = this._iconLru.order; + for (var i = ord.length - 1; i >= 0; i--) { + if (ord[i] === k) { ord.splice(i, 1); break; } + } + ord.push(k); + } catch (eLru3) {} + + this._iconLru.map[k] = val; + + // # 超限清理:按最久未使用淘汰 + try { + var maxN = Math.max(20, Math.floor(Number(this._iconLru.max || 120))); + var ord2 = this._iconLru.order; + while (ord2.length > maxN) { + var oldK = ord2.shift(); + if (oldK != null) { + try { delete this._iconLru.map[oldK]; } catch (eDel) {} + } + } + } catch (eLru4) {} + } catch (eLru5) {} +}; + + +// =======================【工具:悬浮球图标(PNG 文件)】====================== +// # 这段代码的主要内容/用途:从指定路径加载透明 PNG 作为悬浮球图标;带"文件大小/像素上限"保护;按目标尺寸采样解码,避免 system_server OOM。 +FloatBallAppWM.prototype.loadBallIconDrawableFromFile = function(path, targetPx, maxBytes, maxPx) { + try { + var p = String(path || ""); + if (!p) return null; + + // # 统一 LRU 缓存:文件图标(按 path + targetPx + mtime + size 复用 Drawable,避免反复解码) + var f = new java.io.File(p); + if (!f.exists() || !f.isFile()) return null; + + var ckLru = null; + try { + ckLru = "file|" + p + "@" + String(targetPx == null ? "" : targetPx) + "@" + String(f.lastModified()) + "@" + String(f.length()); + var hitLru = this._iconLruGet(ckLru); + if (hitLru) return hitLru; + } catch (eLruF0) {} + + // # 文件大小限制(字节) + var limitBytes = Math.max(0, Math.floor(Number(maxBytes || 0))); + if (limitBytes > 0) { + try { + var sz = Number(f.length()); + if (sz > limitBytes) return null; + } catch (eSz) { return null; } + } + + // # 先只读尺寸(不解码) + var opt = new android.graphics.BitmapFactory.Options(); + opt.inJustDecodeBounds = true; + try { android.graphics.BitmapFactory.decodeFile(p, opt); } catch (eB0) { return null; } + + var w = Number(opt.outWidth || 0); + var h = Number(opt.outHeight || 0); + if (w <= 0 || h <= 0) return null; + + // # 像素边长上限(宽/高任意一边超限则拒绝) + var limitPx = Math.max(0, Math.floor(Number(maxPx || 0))); + if (limitPx > 0) { + if (w > limitPx || h > limitPx) return null; + } + + // # 计算采样倍率:按目标尺寸(一般为 iconSizePx)采样 + var tp = Math.max(1, Math.floor(Number(targetPx || 1))); + // # 允许解码到目标的 2 倍以内,减少锯齿又不浪费内存 + var desired = Math.max(tp * 2, tp); + + var sample = 1; + while ((w / sample) > desired || (h / sample) > desired) sample = sample * 2; + if (sample < 1) sample = 1; + + var opt2 = new android.graphics.BitmapFactory.Options(); + opt2.inJustDecodeBounds = false; + opt2.inSampleSize = sample; + opt2.inPreferredConfig = android.graphics.Bitmap.Config.ARGB_8888; + + var bmp = null; + try { bmp = android.graphics.BitmapFactory.decodeFile(p, opt2); } catch (eB1) { bmp = null; } + if (bmp == null) return null; + + var d = new android.graphics.drawable.BitmapDrawable(context.getResources(), bmp); + + // # 写入统一 LRU 缓存 + try { + if (ckLru) this._iconLruPut(ckLru, d); + } catch (eLruF1) {} + return d; + } catch (e0) { + return null; + } +}; + diff --git a/code/th_04_theme.js b/code/th_04_theme.js new file mode 100644 index 0000000..f591f26 --- /dev/null +++ b/code/th_04_theme.js @@ -0,0 +1,800 @@ +// @version 1.0.0 +// =======================【工具:屏幕/旋转】====================== +FloatBallAppWM.prototype.getScreenSizePx = function() { + var m = new android.util.DisplayMetrics(); + try { this.state.wm.getDefaultDisplay().getRealMetrics(m); } catch (e) { this.state.wm.getDefaultDisplay().getMetrics(m); } + return { w: m.widthPixels, h: m.heightPixels }; +}; +FloatBallAppWM.prototype.getRotation = function() { try { return this.state.wm.getDefaultDisplay().getRotation(); } catch (e) {} return -1; }; + +// =======================【工具:alpha/toast/vibrate】====================== +FloatBallAppWM.prototype.withAlpha = function(colorInt, alpha01) { var a = Math.floor(Number(alpha01) * 255); return (colorInt & 0x00FFFFFF) | (a << 24); }; +FloatBallAppWM.prototype.toast = function(msg) { try { android.widget.Toast.makeText(context, String(msg), 0).show(); } catch (e) {} }; +FloatBallAppWM.prototype.vibrateOnce = function(ms) { + if (!this.config.LONG_PRESS_HAPTIC_ENABLE) return; + try { + var vib = context.getSystemService(android.content.Context.VIBRATOR_SERVICE); + if (!vib) return; + var dur = Math.max(1, Math.floor(ms)); + if (android.os.Build.VERSION.SDK_INT >= 26) { + var ve = android.os.VibrationEffect.createOneShot(dur, android.os.VibrationEffect.DEFAULT_AMPLITUDE); + vib.vibrate(ve); + } else { + vib.vibrate(dur); + } + } catch (e) {} +}; + +// =======================【工具:UI样式辅助】====================== +FloatBallAppWM.prototype.ui = { + // 基础颜色 + colors: { + // 以下为默认回退值,实例化时会被 refreshMonetColors() 覆盖为系统莫奈色 + primary: android.graphics.Color.parseColor("#005BC0"), + primaryDark: android.graphics.Color.parseColor("#041E49"), + accent: android.graphics.Color.parseColor("#00639B"), + danger: android.graphics.Color.parseColor("#BA1A1A"), + success: android.graphics.Color.parseColor("#15803d"), + warning: android.graphics.Color.parseColor("#b45309"), + + bgLight: android.graphics.Color.parseColor("#F8F9FA"), + bgDark: android.graphics.Color.parseColor("#131314"), + + cardLight: android.graphics.Color.parseColor("#E1E3E1"), + cardDark: android.graphics.Color.parseColor("#49454F"), + + textPriLight: android.graphics.Color.parseColor("#1F1F1F"), + textPriDark: android.graphics.Color.parseColor("#E3E3E3"), + + textSecLight: android.graphics.Color.parseColor("#5F6368"), + textSecDark: android.graphics.Color.parseColor("#C4C7C5"), + + dividerLight: android.graphics.Color.parseColor("#747775"), + dividerDark: android.graphics.Color.parseColor("#8E918F"), + + inputBgLight: android.graphics.Color.parseColor("#F8F9FA"), + inputBgDark: android.graphics.Color.parseColor("#131314"), + + // Monet 扩展字段(供面板直接使用) + _monetSurface: android.graphics.Color.parseColor("#F8F9FA"), + _monetOnSurface: android.graphics.Color.parseColor("#1F1F1F"), + _monetOutline: android.graphics.Color.parseColor("#747775"), + _monetOnPrimary: android.graphics.Color.parseColor("#FFFFFF"), + _monetPrimaryContainer: android.graphics.Color.parseColor("#D3E3FD"), + _monetOnPrimaryContainer: android.graphics.Color.parseColor("#041E49"), + _monetSecondary: android.graphics.Color.parseColor("#00639B"), + _monetTertiary: android.graphics.Color.parseColor("#5C5891") + }, + + // 创建圆角背景 (Solid) + createRoundDrawable: function(color, radiusPx) { + var d = new android.graphics.drawable.GradientDrawable(); + d.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE); + d.setColor(color); + d.setCornerRadius(radiusPx); + return d; + }, + + // 创建圆角描边背景 (Stroke) + createStrokeDrawable: function(fillColor, strokeColor, strokeWidthPx, radiusPx) { + var d = new android.graphics.drawable.GradientDrawable(); + d.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE); + if (fillColor) d.setColor(fillColor); + d.setCornerRadius(radiusPx); + d.setStroke(strokeWidthPx, strokeColor); + return d; + }, + + // 创建按压反馈背景 (StateList) + createRippleDrawable: function(normalColor, pressedColor, radiusPx) { + var sd = new android.graphics.drawable.StateListDrawable(); + var p = this.createRoundDrawable(pressedColor, radiusPx); + var n = this.createRoundDrawable(normalColor, radiusPx); + sd.addState([android.R.attr.state_pressed], p); + sd.addState([], n); + return sd; + }, + + // 创建纯色按压反馈 (StateList) - 用于透明背景按钮 + createTransparentRippleDrawable: function(pressedColor, radiusPx) { + var sd = new android.graphics.drawable.StateListDrawable(); + var p = this.createRoundDrawable(pressedColor, radiusPx); + var n = new android.graphics.drawable.ColorDrawable(android.graphics.Color.TRANSPARENT); + sd.addState([android.R.attr.state_pressed], p); + sd.addState([], n); + return sd; + }, + + // 辅助:创建扁平按钮 + createFlatButton: function(app, txt, txtColor, onClick) { + var btn = new android.widget.TextView(context); + btn.setText(txt); + btn.setTextColor(txtColor); + btn.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 14); + btn.setPadding(app.dp(12), app.dp(6), app.dp(12), app.dp(6)); + btn.setGravity(android.view.Gravity.CENTER); + // use divider color or just low alpha text color for ripple + var rippleColor = app.withAlpha ? app.withAlpha(txtColor, 0.1) : 0x22888888; + btn.setBackground(this.createTransparentRippleDrawable(rippleColor, app.dp(8))); + btn.setOnClickListener(new android.view.View.OnClickListener({ + onClick: function(v) { app.touchActivity(); app.guardClick("ui_btn", INTERACTION_CONSTANTS.CLICK_COOLDOWN_MS, function(){ if(onClick) onClick(v); }); } + })); + return btn; + }, + + // 辅助:创建实心按钮 + createSolidButton: function(app, txt, bgColor, txtColor, onClick) { + var btn = new android.widget.TextView(context); + btn.setText(txt); + btn.setTextColor(txtColor); + btn.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 14); + btn.setTypeface(null, android.graphics.Typeface.BOLD); + btn.setPadding(app.dp(16), app.dp(8), app.dp(16), app.dp(8)); + btn.setGravity(android.view.Gravity.CENTER); + var pressedColor = app.withAlpha ? app.withAlpha(bgColor, 0.8) : bgColor; + btn.setBackground(this.createRippleDrawable(bgColor, pressedColor, app.dp(24))); + try { btn.setElevation(app.dp(2)); } catch(e){} + btn.setOnClickListener(new android.view.View.OnClickListener({ + onClick: function(v) { app.touchActivity(); app.guardClick("ui_btn", INTERACTION_CONSTANTS.CLICK_COOLDOWN_MS, function(){ if(onClick) onClick(v); }); } + })); + return btn; + }, + + // 辅助:创建带标签的输入组(支持粘贴) + createInputGroup: function(app, label, initVal, isMultiLine, hint) { + var box = new android.widget.LinearLayout(context); + box.setOrientation(android.widget.LinearLayout.VERTICAL); + box.setPadding(0, 0, 0, app.dp(12)); + + var topLine = new android.widget.LinearLayout(context); + topLine.setOrientation(android.widget.LinearLayout.HORIZONTAL); + topLine.setGravity(android.view.Gravity.CENTER_VERTICAL); + box.addView(topLine); + + var lb = new android.widget.TextView(context); + lb.setText(label); + lb.setTextColor(this.colors.textSecLight); // 默认用浅色主题副文本色,外部可覆盖 + try { if (app.isDarkTheme && app.isDarkTheme()) lb.setTextColor(this.colors.textSecDark); } catch(e){} + lb.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 12); + var lpLb = new android.widget.LinearLayout.LayoutParams(0, -2); + lpLb.weight = 1; + topLine.addView(lb, lpLb); + + var et = new android.widget.EditText(context); + et.setText(initVal ? String(initVal) : ""); + et.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 14); + et.setTextColor(this.colors.textPriLight); + try { if (app.isDarkTheme && app.isDarkTheme()) et.setTextColor(this.colors.textPriDark); } catch(e){} + + // 输入框背景优化 + var strokeColor = this.colors.dividerLight; + try { if (app.isDarkTheme && app.isDarkTheme()) strokeColor = this.colors.dividerDark; } catch(e){} + + var bg = this.createStrokeDrawable(this.colors.inputBgLight, strokeColor, app.dp(1), app.dp(8)); + try { if (app.isDarkTheme && app.isDarkTheme()) bg = this.createStrokeDrawable(this.colors.inputBgDark, strokeColor, app.dp(1), app.dp(8)); } catch(e){} + et.setBackground(bg); + + et.setPadding(app.dp(8), app.dp(8), app.dp(8), app.dp(8)); + if (hint) et.setHint(hint); + + if (isMultiLine) { + et.setSingleLine(false); + et.setMaxLines(4); + } else { + et.setSingleLine(true); + } + + // 粘贴功能 + var pasteBtn = this.createFlatButton(app, "粘贴", this.colors.accent, function() { + try { + var cb = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE); + if (cb.hasPrimaryClip()) { + var item = cb.getPrimaryClip().getItemAt(0); + if (item) { + var txt = item.getText(); + if (txt) { + var st = String(txt); + var old = String(et.getText()); + if (old.length > 0) et.setText(old + st); + else et.setText(st); + et.setSelection(et.getText().length()); + } + } + } else { + app.toast("剪贴板为空"); + } + } catch (eP) { + app.toast("粘贴失败: " + eP); + } + }); + // 调整粘贴按钮样式使其更紧凑 + pasteBtn.setPadding(app.dp(8), app.dp(2), app.dp(8), app.dp(2)); + pasteBtn.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 12); + topLine.addView(pasteBtn); + + box.addView(et); + + // 错误提示 + var errTv = new android.widget.TextView(context); + errTv.setTextColor(this.colors.danger); + errTv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 10); + errTv.setVisibility(android.view.View.GONE); + box.addView(errTv); + + var self = this; + return { + view: box, + input: et, + getValue: function() { return String(et.getText()); }, + setError: function(msg) { + if (msg) { + errTv.setText(msg); + errTv.setVisibility(android.view.View.VISIBLE); + et.setBackground(self.createRoundDrawable(app.withAlpha(self.colors.danger, 0.2), app.dp(4))); + } else { + errTv.setVisibility(android.view.View.GONE); + var strokeColor = self.colors.dividerLight; + try { if (app.isDarkTheme && app.isDarkTheme()) strokeColor = self.colors.dividerDark; } catch(e){} + + var normalBg = self.createStrokeDrawable(self.colors.inputBgLight, strokeColor, app.dp(1), app.dp(8)); + try { if (app.isDarkTheme && app.isDarkTheme()) normalBg = self.createStrokeDrawable(self.colors.inputBgDark, strokeColor, app.dp(1), app.dp(8)); } catch(e){} + et.setBackground(normalBg); + } + } + }; + }, + + // 辅助:创建标准面板容器 + createStyledPanel: function(app, paddingDp) { + var isDark = app.isDarkTheme(); + var bgColor = isDark ? this.colors.bgDark : this.colors.bgLight; + + var panel = new android.widget.LinearLayout(context); + panel.setOrientation(android.widget.LinearLayout.VERTICAL); + + var bgDr = new android.graphics.drawable.GradientDrawable(); + bgDr.setColor(bgColor); + bgDr.setCornerRadius(app.dp(16)); + panel.setBackground(bgDr); + try { panel.setElevation(app.dp(8)); } catch(e){} + + var p = (paddingDp !== undefined) ? app.dp(paddingDp) : app.dp(16); + panel.setPadding(p, p, p, p); + + return panel; + }, + + // 辅助:创建标准标题栏容器 + createStyledHeader: function(app, paddingBottomDp) { + var header = new android.widget.LinearLayout(context); + header.setOrientation(android.widget.LinearLayout.HORIZONTAL); + header.setGravity(android.view.Gravity.CENTER_VERTICAL); + var pb = (paddingBottomDp !== undefined) ? app.dp(paddingBottomDp) : app.dp(8); + header.setPadding(0, 0, 0, pb); + return header; + }, + + // 辅助:创建占位符(撑开空间) + createSpacer: function(app) { + var dummy = new android.view.View(context); + var dummyLp = new android.widget.LinearLayout.LayoutParams(0, 1); + dummyLp.weight = 1; + dummy.setLayoutParams(dummyLp); + return dummy; + } +}; + +// =======================【工具:主题/类莫奈颜色】====================== +// # 主题调试日志工具(仅打印,不改变逻辑) +function _th_hex(c) { + try { + var Integer = Packages.java.lang.Integer; + var s = Integer.toHexString(c); + while (s.length < 8) s = "0" + s; + return "0x" + s; + } catch (e) { + try { return String(c); } catch (e2) { return ""; } + } +} + +function _th_argb(c) { + try { + var Color = Packages.android.graphics.Color; + var ci = Math.floor(Number(c)); + if (isNaN(ci)) return "NaN"; + return "a=" + Color.alpha(ci) + " r=" + Color.red(ci) + " g=" + Color.green(ci) + " b=" + Color.blue(ci); + } catch (e) { + return "argb_err"; + } +} + +function _th_log(L, level, msg) { + try { + if (!L) return; + if (level === "e" && L.e) { L.e(msg); return; } + if (level === "w" && L.w) { L.w(msg); return; } + if (level === "i" && L.i) { L.i(msg); return; } + if (L.d) { L.d(msg); return; } + } catch (e) {} +} + +// =======================【莫奈动态取色工具】====================== +var MonetColorProvider = { + _cacheLight: null, + _cacheDark: null, + + _getResColor: function(resName, fallbackHex) { + try { + var res = android.content.res.Resources.getSystem(); + var id = res.getIdentifier(resName, "color", "android"); + if (id > 0) return res.getColor(id, null); + } catch (e) {} + try { return android.graphics.Color.parseColor(fallbackHex); } catch (e) { return 0; } + }, + + _getFallbackLight: function() { + return { + // 使用更接近 AOSP Monet 标准值的 fallback,日间 primary 更饱和、对比度更高 + primary: android.graphics.Color.parseColor("#005BC0"), + onPrimary: android.graphics.Color.parseColor("#FFFFFF"), + primaryContainer: android.graphics.Color.parseColor("#D3E3FD"), + onPrimaryContainer: android.graphics.Color.parseColor("#041E49"), + secondary: android.graphics.Color.parseColor("#00639B"), + secondaryContainer: android.graphics.Color.parseColor("#C2E4FF"), + tertiary: android.graphics.Color.parseColor("#5C5891"), + surface: android.graphics.Color.parseColor("#F8F9FA"), + onSurface: android.graphics.Color.parseColor("#1F1F1F"), + surfaceVariant: android.graphics.Color.parseColor("#E1E3E1"), + onSurfaceVariant: android.graphics.Color.parseColor("#5F6368"), + outline: android.graphics.Color.parseColor("#747775"), + outlineVariant: android.graphics.Color.parseColor("#C4C7C5"), + error: android.graphics.Color.parseColor("#BA1A1A"), + errorContainer: android.graphics.Color.parseColor("#F9DEDC"), + onErrorContainer: android.graphics.Color.parseColor("#410E0B") + }; + }, + + _getFallbackDark: function() { + return { + primary: android.graphics.Color.parseColor("#A8C7FA"), + onPrimary: android.graphics.Color.parseColor("#062E6F"), + primaryContainer: android.graphics.Color.parseColor("#0842A0"), + onPrimaryContainer: android.graphics.Color.parseColor("#D3E3FD"), + secondary: android.graphics.Color.parseColor("#7FCFFF"), + secondaryContainer: android.graphics.Color.parseColor("#004A77"), + tertiary: android.graphics.Color.parseColor("#C2C5DD"), + surface: android.graphics.Color.parseColor("#131314"), + onSurface: android.graphics.Color.parseColor("#E3E3E3"), + surfaceVariant: android.graphics.Color.parseColor("#49454F"), + onSurfaceVariant: android.graphics.Color.parseColor("#C4C7C5"), + outline: android.graphics.Color.parseColor("#8E918F"), + outlineVariant: android.graphics.Color.parseColor("#49454F"), + error: android.graphics.Color.parseColor("#F2B8B5"), + errorContainer: android.graphics.Color.parseColor("#8C1D18"), + onErrorContainer: android.graphics.Color.parseColor("#F9DEDC") + }; + }, + + _loadMonet: function(isDark) { + var c = isDark ? this._getFallbackDark() : this._getFallbackLight(); + try { + var res = android.content.res.Resources.getSystem(); + var map = isDark ? { + primary: "system_accent1_200", + onPrimary: "system_accent1_800", + primaryContainer: "system_accent1_700", + onPrimaryContainer: "system_accent1_100", + secondary: "system_accent2_200", + secondaryContainer: "system_accent2_700", + tertiary: "system_accent3_200", + surface: "system_neutral1_900", + onSurface: "system_neutral1_100", + surfaceVariant: "system_neutral2_700", + onSurfaceVariant: "system_neutral2_200", + outline: "system_neutral2_400", + outlineVariant: "system_neutral2_700", + error: "system_accent3_200", + errorContainer: "system_accent3_800", + onErrorContainer: "system_accent3_100" + } : { + primary: "system_accent1_600", + onPrimary: "system_accent1_0", + primaryContainer: "system_accent1_100", + onPrimaryContainer: "system_accent1_900", + secondary: "system_accent2_600", + secondaryContainer: "system_accent2_100", + tertiary: "system_accent3_600", + surface: "system_neutral1_10", + onSurface: "system_neutral1_900", + surfaceVariant: "system_neutral2_100", + onSurfaceVariant: "system_neutral2_700", + outline: "system_neutral2_500", + outlineVariant: "system_neutral2_200", + error: "system_accent3_600", + errorContainer: "system_accent3_100", + onErrorContainer: "system_accent3_900" + }; + for (var name in map) { + try { + var id = res.getIdentifier(map[name], "color", "android"); + if (id > 0) c[name] = res.getColor(id, null); + } catch (e) {} + } + } catch (e) {} + return c; + }, + + getColors: function(isDark) { + if (isDark) { + if (!this._cacheDark) this._cacheDark = this._loadMonet(true); + return this._cacheDark; + } else { + if (!this._cacheLight) this._cacheLight = this._loadMonet(false); + return this._cacheLight; + } + }, + + invalidate: function() { + this._cacheLight = null; + this._cacheDark = null; + } +}; + +// =======================【兼容兜底:themeTextInt/themeBgInt】====================== +// 这段代码的主要内容/用途:兼容旧代码或异步回调里误引用 themeTextInt/themeBgInt 导致 ReferenceError 崩溃。 +// 说明:当前版本文字色应通过 getPanelTextColorInt(bgInt) 获取;这里仅作为"兜底全局变量",避免回调炸线程。 + +// 声明全局变量(避免 ReferenceError) +var themeBgInt = 0; +var themeTextInt = 0; + +// =======================【API 兼容性辅助函数】====================== +// 这段代码的主要内容/用途:处理 Android API 级别差异,避免在旧版本上崩溃 + +/** + * 安全创建 UserHandle(兼容 API 17 以下) + * @param {number} userId - 用户 ID + * @returns {android.os.UserHandle} UserHandle 对象或 null + */ +function createUserHandle(userId) { + try { + // UserHandle.of 在 API 17+ 可用 + if (android.os.Build.VERSION.SDK_INT >= 17) { + return android.os.UserHandle.of(userId); + } + // API 17 以下返回当前用户句柄 + return android.os.Process.myUserHandle(); + } catch (e) { + return null; + } +} + +/** + * 安全启动 Activity 跨用户(兼容 API 17 以下) + * @param {Context} ctx - Context + * @param {Intent} intent - Intent + * @param {number} userId - 用户 ID(API 17+ 有效) + */ +function startActivityAsUserSafe(ctx, intent, userId) { + try { + if (android.os.Build.VERSION.SDK_INT >= 17 && userId !== 0) { + var uh = android.os.UserHandle.of(userId); + ctx.startActivityAsUser(intent, uh); + } else { + ctx.startActivity(intent); + } + } catch (e) { + // 降级到普通启动 + try { + ctx.startActivity(intent); + } catch (e2) {} + } +} + +FloatBallAppWM.prototype.isDarkTheme = function() { + // 0) 优先检查用户强制设置 (0=跟随系统, 1=白天, 2=黑夜) + var mode = 0; + try { mode = Math.floor(Number(this.config.THEME_MODE || 0)); } catch(e){} + + if (mode === 2) return true; + if (mode === 1) return false; + + // mode === 0 (or others) -> Fallback to system detection + // 这段代码的主要内容/用途:更稳健地判断当前是否处于"夜间/暗色"模式(并打印调试日志)。 + // 说明:system_server 场景下 Configuration.uiMode 偶发不一致,因此再用 UiModeManager 兜底交叉验证。 + var result = false; + var from = "unknown"; + + try { + // # 1) 优先用 Configuration(最快) + var uiMode = context.getResources().getConfiguration().uiMode; + var nightMask = (uiMode & android.content.res.Configuration.UI_MODE_NIGHT_MASK); + if (nightMask === android.content.res.Configuration.UI_MODE_NIGHT_YES) { result = true; from = "Configuration(UI_MODE_NIGHT_YES)"; } + else if (nightMask === android.content.res.Configuration.UI_MODE_NIGHT_NO) { result = false; from = "Configuration(UI_MODE_NIGHT_NO)"; } + } catch (e1) {} + + if (from === "unknown") { + try { + // # 2) 再用 UiModeManager(更"系统态") + var um = context.getSystemService(android.content.Context.UI_MODE_SERVICE); + if (um) { + var nm = um.getNightMode(); + if (nm === android.app.UiModeManager.MODE_NIGHT_YES) { result = true; from = "UiModeManager(MODE_NIGHT_YES)"; } + else if (nm === android.app.UiModeManager.MODE_NIGHT_NO) { result = false; from = "UiModeManager(MODE_NIGHT_NO)"; } + else { from = "UiModeManager(mode=" + String(nm) + ")"; } + } + } catch (e2) {} + } + + // # 3) 实在判断不了,就按"非暗色"处理,避免自动主题背景黑成一片 + if (from === "unknown") { result = false; from = "fallback(false)"; } + + // 仅在状态改变时打印日志,避免刷屏 + var logKey = String(result) + "|" + from + "|" + mode; + if (this._lastDarkThemeLog !== logKey) { + this._lastDarkThemeLog = logKey; + try { _th_log(this.L, "d", "[theme] isDarkTheme=" + String(result) + " via=" + from + " mode=" + mode); } catch (e3) {} + } + + // # 主题切换时刷新莫奈配色(传入 result 避免递归) + // 注:构造函数中会初始化,这里只在构造完成后的切换时触发 + if (this._lastDarkResult !== undefined && this._lastDarkResult !== result) { + this._lastDarkResult = result; + try { this.refreshMonetColors(result); } catch (eM) {} + } else if (this._lastDarkResult === undefined) { + this._lastDarkResult = result; + } + + return result; +}; + +FloatBallAppWM.prototype.refreshMonetColors = function(forceDark) { + try { + var isDark = (forceDark !== undefined) ? forceDark : this.isDarkTheme(); + var m = MonetColorProvider.getColors(isDark); + var ml = MonetColorProvider.getColors(false); + var md = MonetColorProvider.getColors(true); + var c = this.ui.colors; + + // 浅色配色 + c.bgLight = ml.surface; + c.cardLight = ml.surfaceVariant; + c.textPriLight = ml.onSurface; + c.textSecLight = ml.onSurfaceVariant; + c.dividerLight = ml.outline; + c.inputBgLight = ml.surface; + + // 深色配色 + c.bgDark = md.surface; + c.cardDark = md.surfaceVariant; + c.textPriDark = md.onSurface; + c.textSecDark = md.onSurfaceVariant; + c.dividerDark = md.outline; + c.inputBgDark = md.surface; + + // 当前主题配色(随主题切换) + c.primary = m.primary; + // primaryDark 修正为 onPrimaryContainer:日间为深蓝(#041E49),夜间为浅蓝(#D3E3FD),符合"深色变体"语义 + c.primaryDark = m.onPrimaryContainer; + c.accent = m.secondary; + c.danger = m.error; + // success/warning 优化对比度:日间更深确保可见,夜间保持适度亮度不刺眼 + c.success = isDark ? android.graphics.Color.parseColor("#4ade80") : android.graphics.Color.parseColor("#15803d"); + c.warning = isDark ? android.graphics.Color.parseColor("#fbbf24") : android.graphics.Color.parseColor("#b45309"); + + // 扩展:完整 Monet 语义字段(供面板方法直接使用) + c._monetSurface = m.surface; + c._monetOnSurface = m.onSurface; + c._monetOutline = m.outline; + c._monetOnPrimary = m.onPrimary; + c._monetPrimaryContainer = m.primaryContainer; + c._monetOnPrimaryContainer = m.onPrimaryContainer; + c._monetSecondary = m.secondary; + c._monetTertiary = m.tertiary; + + try { _th_log(this.L, "d", "[monet] refreshed isDark=" + isDark + " primary=" + _th_hex(c.primary) + " primaryDark=" + _th_hex(c.primaryDark) + " accent=" + _th_hex(c.accent)); } catch (e) {} + } catch (e) { + try { _th_log(this.L, "e", "[monet] refresh err=" + String(e)); } catch (e2) {} + } +}; + +FloatBallAppWM.prototype.getMonetAccentForBall = function() { + // 这段代码的主要内容/用途:尝试读取系统动态强调色(Monet/accent);失败则使用兜底颜色;并打印命中信息。 + // 优化点: + // 1) 日间使用 500/600 档(更深、对比度更高),夜间使用 400/300 档(柔和、不刺眼) + // 2) 移除 system_neutral1_* 中性灰色(不是强调色) + var res = context.getResources(); + var dark = this.isDarkTheme(); + var names = dark + ? ["system_accent1_400", "system_accent1_300", "system_accent2_400"] + : ["system_accent1_500", "system_accent1_600", "system_accent2_500"]; + + var i; + for (i = 0; i < names.length; i++) { + try { + var id = res.getIdentifier(names[i], "color", "android"); + if (id > 0) { + var c = res.getColor(id, null); + var logKey = "hit|" + names[i] + "|" + c; + if (this._lastAccentLog !== logKey) { + this._lastAccentLog = logKey; + try { _th_log(this.L, "d", "[theme] hit accent=" + names[i] + " id=" + String(id) + " c=" + _th_hex(c) + " " + _th_argb(c)); } catch (eL0) {} + } + return c; + } + } catch (e) { + try { _th_log(this.L, "w", "[theme] err accent=" + names[i] + " e=" + String(e)); } catch (eL2) {} + } + } + + var fbHex = dark + ? (this.config.BALL_FALLBACK_DARK || CONST_BALL_FALLBACK_DARK) + : (this.config.BALL_FALLBACK_LIGHT || CONST_BALL_FALLBACK_LIGHT); + var fb = android.graphics.Color.parseColor(fbHex); + var logKeyFb = "miss_all|" + fb; + if (this._lastAccentLog !== logKeyFb) { + this._lastAccentLog = logKeyFb; + try { _th_log(this.L, "w", "[theme] accent miss all, fallback=" + _th_hex(fb) + " " + _th_argb(fb)); } catch (eL3) {} + } + return fb; +}; + +FloatBallAppWM.prototype.updateBallContentBackground = function(contentView) { + try { + var ballColor = this.getMonetAccentForBall(); + var dark = this.isDarkTheme(); + var alpha01 = dark ? this.config.BALL_RIPPLE_ALPHA_DARK : this.config.BALL_RIPPLE_ALPHA_LIGHT; + var rippleColor = this.withAlpha(ballColor, alpha01); + + // # 自定义 PNG/APP 模式下:背景透明 + var fillColor = ballColor; + var _usedKind = "none"; + try { _usedKind = this.state.usedIconKind || "none"; } catch(eK){} + + try { + var _pngModeBg = Number(this.config.BALL_PNG_MODE || 0); + if ((_pngModeBg === 1 && _usedKind === "file") || _usedKind === "app") { + fillColor = android.graphics.Color.TRANSPARENT; + } + } catch (eBg) {} + + var content = new android.graphics.drawable.GradientDrawable(); + content.setShape(android.graphics.drawable.GradientDrawable.OVAL); + content.setColor(fillColor); + + // # 描边:根据球体颜色亮度自动选择白/黑描边,确保任何背景下都可见 + if (_usedKind !== "file" && _usedKind !== "app") { + try { + var Color = android.graphics.Color; + var lum = (Color.red(fillColor)*0.299 + Color.green(fillColor)*0.587 + Color.blue(fillColor)*0.114) / 255.0; + var strokeInt = lum > 0.55 + ? Color.parseColor("#33000000") // 浅球用半透明黑边 + : Color.parseColor("#55FFFFFF"); // 深球用半透明白边 + content.setStroke(this.dp(1), strokeInt); + } catch(eS){} + } + + var mask = new android.graphics.drawable.GradientDrawable(); + mask.setShape(android.graphics.drawable.GradientDrawable.OVAL); + mask.setColor(android.graphics.Color.WHITE); + + contentView.setBackground(new android.graphics.drawable.RippleDrawable( + android.content.res.ColorStateList.valueOf(rippleColor), + content, + mask + )); + } catch (e) {} +}; + + +FloatBallAppWM.prototype.safeParseColor = function(hex, fallbackInt) { + // 这段代码的主要内容/用途:安全解析 #RRGGBB/#AARRGGBB,解析失败直接回退,避免 system_server 抛异常。 + try { return android.graphics.Color.parseColor(String(hex)); } catch (e) { return fallbackInt; } +}; + + + +FloatBallAppWM.prototype.getPanelBgColorInt = function() { + // 这段代码的主要内容/用途:配合"白天/夜晚"两档主题,返回统一的背景颜色(不再依赖自动亮度推断)。 + var isDark = this.isDarkTheme(); + + var dayBgHex = (this.config.THEME_DAY_BG_HEX != null) ? String(this.config.THEME_DAY_BG_HEX) : null; + var nightBgHex = (this.config.THEME_NIGHT_BG_HEX != null) ? String(this.config.THEME_NIGHT_BG_HEX) : null; + + // # 兼容旧版默认配色:若仍为旧默认值,自动回退到莫奈色 + if (dayBgHex === "#FAF4E3") dayBgHex = null; + if (nightBgHex === "#191928") nightBgHex = null; + + // # 未配置时使用莫奈 surface 色作为回退 + var dayFallback = this.ui.colors.bgLight || android.graphics.Color.parseColor("#F8F9FA"); + var nightFallback = this.ui.colors.bgDark || android.graphics.Color.parseColor("#131314"); + + var base = isDark + ? (nightBgHex ? this.safeParseColor(nightBgHex, nightFallback) : nightFallback) + : (dayBgHex ? this.safeParseColor(dayBgHex, dayFallback) : dayFallback); + + // # 继承原配置:面板背景透明度(0~1) + var a = 1.0; + try { a = Number(this.config.PANEL_BG_ALPHA); } catch (e1) { a = 0.85; } + if (!(a >= 0.0 && a <= 1.0)) a = 0.85; + + var out = this.withAlpha(base, a); + + try { _th_log(this.L, "d", "[t]bg isDark=" + isDark + " o=" + _th_hex(out)); } catch (e2) {} + + return out; +}; + +FloatBallAppWM.prototype.getPanelTextColorInt = function(bgInt) { + // 这段代码的主要内容/用途:配合"白天/夜晚"两档主题,返回统一的文字颜色(不再依赖自动亮度推断)。 + var isDark = this.isDarkTheme(); + + var dayTextHex = (this.config.THEME_DAY_TEXT_HEX != null) ? String(this.config.THEME_DAY_TEXT_HEX) : null; + var nightTextHex = (this.config.THEME_NIGHT_TEXT_HEX != null) ? String(this.config.THEME_NIGHT_TEXT_HEX) : null; + + // # 兼容旧版默认配色:若仍为旧默认值,自动回退到莫奈色 + if (dayTextHex === "#333333") dayTextHex = null; + if (nightTextHex === "#E6E6F0") nightTextHex = null; + + // # 未配置时使用莫奈 onSurface 色作为回退 + var dayFallback = this.ui.colors.textPriLight || android.graphics.Color.parseColor("#1F1F1F"); + var nightFallback = this.ui.colors.textPriDark || android.graphics.Color.parseColor("#E3E3E3"); + + if (!isDark) return dayTextHex ? this.safeParseColor(dayTextHex, dayFallback) : dayFallback; + return nightTextHex ? this.safeParseColor(nightTextHex, nightFallback) : nightFallback; +}; + +FloatBallAppWM.prototype.applyTextColorRecursive = function(v, colorInt) { + // 这段代码的主要内容/用途:递归设置面板内所有 TextView 的文字颜色(标题/按钮文字/描述/设置项等)。 + try { + if (!v) return; + if (v instanceof android.widget.TextView) { + v.setTextColor(colorInt); + } + if (v instanceof android.view.ViewGroup) { + var i, n = v.getChildCount(); + for (i = 0; i < n; i++) { + this.applyTextColorRecursive(v.getChildAt(i), colorInt); + } + } + } catch (e0) {} +}; + +FloatBallAppWM.prototype.updatePanelBackground = function(panelView) { + // 这段代码的主要内容/用途:统一为"主面板/设置面板/查看器面板"应用背景与文字颜色(自动/亮/暗三档),并输出调试日志(命中哪个颜色)。 + try { + var bg = new android.graphics.drawable.GradientDrawable(); + bg.setCornerRadius(this.dp(22)); + + var bgInt = this.getPanelBgColorInt(); + bg.setColor(bgInt); + + // 轻量描边:亮色时更明显,暗色时也保留一点边界(不提供自定义输入,避免设置页复杂化) + var sw = this.dp(1); + var isDark = this.isDarkTheme(); + var outlineColor = this.ui.colors._monetOutline || (isDark ? android.graphics.Color.parseColor("#8E918F") : android.graphics.Color.parseColor("#747775")); + var stroke = this.withAlpha(outlineColor, isDark ? 0.26 : 0.20); + try { bg.setStroke(sw, stroke); } catch (eS) {} + + panelView.setBackground(bg); + + var tc = this.getPanelTextColorInt(bgInt); + try { themeBgInt = bgInt; themeTextInt = tc; } catch (eT) {} + this.applyTextColorRecursive(panelView, tc); + + try { _th_log(this.L, "d", "[t]apply bg=" + _th_hex(bgInt) + " tx=" + _th_hex(tc)); } catch (e) {} + + + try { + _th_log(this.L, "d", + "[theme:apply] isDark=" + isDark + + " bg=" + _th_hex(bgInt) + " " + _th_argb(bgInt) + + " text=" + _th_hex(tc) + " " + _th_argb(tc) + + " stroke=" + _th_hex(stroke) + ); + } catch (eL0) {} + } catch (e) { + try { _th_log(this.L, "e", "[theme:apply] err=" + String(e)); } catch (eL1) {} + } +}; + diff --git a/code/th_05_persistence.js b/code/th_05_persistence.js new file mode 100644 index 0000000..64ac2c6 --- /dev/null +++ b/code/th_05_persistence.js @@ -0,0 +1,298 @@ +// @version 1.0.0 +// =======================【工具:面板位置持久化】====================== +FloatBallAppWM.prototype.savePanelState = function(key, state) { + if (!key || !state) return; + try { + if (!this.config.PANEL_STATES) this.config.PANEL_STATES = {}; + this.config.PANEL_STATES[key] = state; + // 节流或立即保存? 面板拖动结束通常不频繁,立即保存即可 + // 但为了避免连续事件,还是可以复用 savePos 的节流逻辑,或者直接保存 + ConfigManager.saveSettings(this.config); + } catch (e) {} +}; + +FloatBallAppWM.prototype.loadPanelState = function(key) { + if (!key || !this.config.PANEL_STATES) return null; + return this.config.PANEL_STATES[key]; +}; + +// =======================【工具:位置持久化】====================== +FloatBallAppWM.prototype.savePos = function(x, y) { + try { + this.config.BALL_INIT_X = Math.floor(x); + this.config.BALL_INIT_Y_DP = Math.floor(y / this.state.density); + // # 节流保存 + return ConfigManager.saveSettings(this.config); + } catch (e) { return false; } +}; + +FloatBallAppWM.prototype.loadSavedPos = function() { + // # 直接从 config 返回,因为 config 已经是持久化的 + var x = Number(this.config.BALL_INIT_X || 0); + var y = this.dp(Number(this.config.BALL_INIT_Y_DP || 100)); + return { x: x, y: y }; +}; + +FloatBallAppWM.prototype.trySavePosThrottled = function(x, y) { + var t = this.now(); + if (t - this.state.lastSaveTs < this.config.SAVE_THROTTLE_MS) return false; + this.state.lastSaveTs = t; + return this.savePos(x, y); +}; + +// =======================【工具:配置持久化】====================== +FloatBallAppWM.prototype.saveConfig = function(obj) { + try { + for (var k in obj) { + if (obj.hasOwnProperty(k)) { + this.config[k] = obj[k]; + } + } + if (this.L) this.L.updateConfig(this.config); + return ConfigManager.saveSettings(this.config); + } catch (e) { return false; } +}; + +// =======================【设置面板:schema】====================== +FloatBallAppWM.prototype.getConfigSchema = function() { + return ConfigManager.loadSchema(); +}; + +// =======================【设置面板:临时编辑缓存】====================== +FloatBallAppWM.prototype.beginEditConfig = function() { + try { + var schema = this.getConfigSchema(); + var p = {}; + var i; + for (i = 0; i < schema.length; i++) { + if (!schema[i] || !schema[i].key) continue; + var k = String(schema[i].key); + p[k] = this.config[k]; + } + this.state.pendingUserCfg = p; + this.state.pendingDirty = false; + return true; + } catch (e0) { + this.state.pendingUserCfg = null; + this.state.pendingDirty = false; + return false; + } +}; +FloatBallAppWM.prototype.getPendingValue = function(k) { + if (this.state.pendingUserCfg && this.state.pendingUserCfg.hasOwnProperty(k)) return this.state.pendingUserCfg[k]; + return this.config[k]; +}; +FloatBallAppWM.prototype.setPendingValue = function(k, v) { + if (!this.state.pendingUserCfg) this.beginEditConfig(); + this.state.pendingUserCfg[k] = v; + this.state.pendingDirty = true; + if (this.state.previewMode) { + this.refreshPreview(k); + } +}; + +FloatBallAppWM.prototype.getEffectiveConfig = function() { + if (!this.state.previewMode || !this.state.pendingUserCfg) return this.config; + var cfg = {}; + for (var k in this.config) { cfg[k] = this.config[k]; } + for (var k in this.state.pendingUserCfg) { cfg[k] = this.state.pendingUserCfg[k]; } + return cfg; +}; + +FloatBallAppWM.prototype.refreshPreview = function(changedKey) { + if (this.state.closing) return; + var self = this; + // Post to next tick to avoid destroying view during event dispatch (fixes crash on switch toggle) + if (this.state.h) { + this.state.h.post(new JavaAdapter(java.lang.Runnable, { + run: function() { self._refreshPreviewInternal(changedKey); } + })); + } else { + self._refreshPreviewInternal(changedKey); + } +}; + +FloatBallAppWM.prototype._refreshPreviewInternal = function(changedKey) { + if (this.state.closing) return; + var originalConfig = this.config; + try { + // 使用临时配置 + this.config = this.getEffectiveConfig(); + + var needBall = false; + var needPanel = false; + + if (!changedKey) { + needBall = true; + needPanel = true; + } else { + // 根据修改的 key 判断需要刷新什么,避免全量刷新导致闪烁 + if (changedKey.indexOf("BALL_") === 0) needBall = true; + if (changedKey.indexOf("PANEL_") === 0) needPanel = true; + // 球大小改变会影响面板位置 + if (changedKey === "BALL_SIZE_DP") needPanel = true; + } + + // 1. 刷新悬浮球 (保持面板不关闭) + if (needBall) { + this.rebuildBallForNewSize(true); + } + + // 2. 刷新主面板预览 + if (needPanel) { + // 如果当前没有显示主面板,则创建并显示;如果已显示,则替换 + + var panel = this.buildPanelView("main"); + + // 计算位置 (使用当前球的位置) + var maxH = Math.floor(this.state.screen.h * 0.75); + panel.measure( + android.view.View.MeasureSpec.makeMeasureSpec(this.state.screen.w, android.view.View.MeasureSpec.AT_MOST), + android.view.View.MeasureSpec.makeMeasureSpec(maxH, android.view.View.MeasureSpec.AT_MOST) + ); + var pw = panel.getMeasuredWidth(); + var ph = panel.getMeasuredHeight(); + if (ph > maxH) ph = maxH; + + var bx = this.state.ballLp.x; + var by = this.state.ballLp.y; + var px = this.computePanelX(bx, pw); + var py = by; + + // 尝试调整 Y + var r = this.tryAdjustPanelY(px, py, pw, ph, bx, by); + var finalX = r.ok ? r.x : px; + var finalY = r.ok ? r.y : this.clamp(py, 0, this.state.screen.h - ph); + + // 优化闪烁:先添加新面板,再移除旧面板 (这样新面板会在最上层,符合预览需求) + var oldPanel = this.state.panel; + var oldAdded = this.state.addedPanel; + + // 添加新面板 (addPanel 会更新 this.state.panel) + // 注意:addPanel 中已为 main 添加 FLAG_NOT_FOCUSABLE,所以即使在最上层也不会抢走 Settings 的输入焦点 + this.addPanel(panel, finalX, finalY, "main"); + + // 移除旧面板 + if (oldAdded && oldPanel) { + try { this.state.wm.removeView(oldPanel); } catch(e) {} + } + } + + } catch(e) { + safeLog(this.L, 'e', "refreshPreview err=" + e); + } finally { + this.config = originalConfig; + } +}; +FloatBallAppWM.prototype.persistUserCfgFromObject = function(obj) { + // # 这段代码的主要内容/用途:从临时编辑对象里按 schema 白名单抽取并保存(跳过 section 标题等无 key 项) + try { + var schema = this.getConfigSchema(); + var out = {}; + var i; + for (i = 0; i < schema.length; i++) { + if (!schema[i] || !schema[i].key) continue; + var k = String(schema[i].key); + out[k] = obj[k]; + } + return this.saveConfig(out); + } catch (e0) { return false; } +}; + +FloatBallAppWM.prototype.applyImmediateEffectsForKey = function(k) { + try { + if (k === "LOG_ENABLE") { + try { + if (this.L) { + this.L.enable = !!this.config.LOG_ENABLE; + this.L.cfg.LOG_ENABLE = !!this.config.LOG_ENABLE; + this.L.i("apply LOG_ENABLE=" + String(this.config.LOG_ENABLE)); + } + } catch (eLE) {} + return; + } + if (k === "LOG_DEBUG") { + try { + if (this.L) { + this.L.debug = !!this.config.LOG_DEBUG; + this.L.cfg.LOG_DEBUG = !!this.config.LOG_DEBUG; + this.L.i("apply LOG_DEBUG=" + String(this.config.LOG_DEBUG)); + } + } catch (eLD) {} + return; + } + if (k === "LOG_KEEP_DAYS") { + try { + var n = Math.max(1, Math.floor(Number(this.config.LOG_KEEP_DAYS || 3))); + this.config.LOG_KEEP_DAYS = n; + if (this.L) { + this.L.keepDays = n; + this.L.cfg.LOG_KEEP_DAYS = n; + this.L.i("apply LOG_KEEP_DAYS=" + String(n)); + this.L.cleanupOldFiles(); + } + } catch (eLK) {} + return; + } + if (k === "BALL_SIZE_DP" || k === "BALL_PNG_MODE" || k === "BALL_ICON_TYPE" || k === "BALL_ICON_PKG" || k === "BALL_ICON_FILE_PATH" || k === "BALL_ICON_RES_ID" || k === "BALL_ICON_RES_NAME" || k === "BALL_ICON_SIZE_DP" || k === "BALL_ICON_TINT_HEX" || k === "BALL_TEXT" || k === "BALL_TEXT_SIZE_SP" || k === "BALL_TEXT_COLOR_HEX" || k === "BALL_ICON_TEXT_GAP_DP") { this.rebuildBallForNewSize(); return; } + + if (k === "PANEL_ROWS" || k === "PANEL_COLS" || + k === "PANEL_ITEM_SIZE_DP" || k === "PANEL_GAP_DP" || + k === "PANEL_PADDING_DP" || k === "PANEL_ICON_SIZE_DP" || + k === "PANEL_LABEL_ENABLED" || k === "PANEL_LABEL_TEXT_SIZE_SP" || + k === "PANEL_LABEL_TOP_MARGIN_DP") { + + if (this.state.addedPanel) this.hideMainPanel(); + if (this.state.addedSettings) this.hideSettingsPanel(); + if (this.state.addedViewer) this.hideViewerPanel(); + return; + } + + if (k === "EDGE_VISIBLE_RATIO") { + if (this.state.addedBall && this.state.docked) { + this.state.docked = false; + this.snapToEdgeDocked(false); + } + return; + } + } catch (e0) {} +}; + +FloatBallAppWM.prototype.commitPendingUserCfg = function() { + try { + if (!this.state.pendingUserCfg) return { ok: false, reason: "no_pending" }; + + var schema = this.getConfigSchema(); + var changedKeys = []; + var i; + + for (i = 0; i < schema.length; i++) { + if (!schema[i] || !schema[i].key) continue; + var k = String(schema[i].key); + var oldV = this.config[k]; + var newV = this.state.pendingUserCfg[k]; + if (String(oldV) !== String(newV)) { + this.config[k] = newV; + changedKeys.push(k); + } + } + + this.persistUserCfgFromObject(this.state.pendingUserCfg); + + var j; + for (j = 0; j < changedKeys.length; j++) { + if (changedKeys[j] === "BALL_SIZE_DP") { this.applyImmediateEffectsForKey("BALL_SIZE_DP"); break; } + } + for (j = 0; j < changedKeys.length; j++) { + if (changedKeys[j] !== "BALL_SIZE_DP") this.applyImmediateEffectsForKey(changedKeys[j]); + } + + this.state.pendingDirty = false; + safeLog(this.L, 'i', "commit settings changed=" + JSON.stringify(changedKeys)); + return { ok: true, changed: changedKeys }; + } catch (e0) { + safeLog(this.L, 'e', "commitPendingUserCfg err=" + String(e0)); + return { ok: false, err: String(e0) }; + } +}; + diff --git a/code/th_06_icon_parser.js b/code/th_06_icon_parser.js new file mode 100644 index 0000000..8ac9066 --- /dev/null +++ b/code/th_06_icon_parser.js @@ -0,0 +1,485 @@ +// @version 1.0.0 +// =======================【工具:吸边数据】====================== +FloatBallAppWM.prototype.getDockInfo = function() { + var ballSize = this.dp(this.config.BALL_SIZE_DP); + var visible = Math.max(1, Math.round(ballSize * this.config.EDGE_VISIBLE_RATIO)); + var hidden = ballSize - visible; + return { ballSize: ballSize, visiblePx: visible, hiddenPx: hidden }; +}; + +// =======================【工具:图标解析】====================== + +// =======================【工具:快捷方式图标文件路径】====================== +FloatBallAppWM.prototype.getShortcutIconFilePath = function(pkg, shortcutId, userId) { + // # 主要用途:把快捷方式图标持久化到 shortcut_icons 目录,供按钮页/按钮管理页稳定显示(桌面移除后仍可显示) + try { + var p = (pkg == null) ? "" : String(pkg); + var s = (shortcutId == null) ? "" : String(shortcutId); + var u = (userId == null) ? "0" : String(userId); + + // # 文件名去非法字符,避免路径注入或创建失败 + function _sn(v) { + try { + var t = String(v == null ? "" : v); + t = t.replace(/[^a-zA-Z0-9._-]+/g, "_"); + if (t.length > 120) t = t.substring(0, 120); + return t; + } catch(e) { return ""; } + } + + var dir = String(APP_ROOT_DIR) + "/shortcut_icons"; + var fn = _sn(p) + "__" + _sn(s) + "__u" + _sn(u) + ".png"; + return dir + "/" + fn; + } catch (e0) { + return ""; + } +}; + +FloatBallAppWM.prototype.resolveIconDrawable = function(btn) { + // # 主要用途:解析面板按钮图标(优先 app 包名图标,其次自定义 resId,最后兜底) + try { + if (!btn) return context.getResources().getDrawable(android.R.drawable.ic_menu_help, null); + + // # 0) 优先检查 iconPath (绝对路径) + // # 引用优化:复用 loadBallIconDrawableFromFile 安全加载逻辑 + if (btn.iconPath) { + try { + var path = String(btn.iconPath); + if (path) { + // targetPx: 面板图标大小; Limit: 1MB, 1024px + var sizeDp = this.config.PANEL_ICON_SIZE_DP || 32; + var dr = this.loadBallIconDrawableFromFile(path, this.dp(sizeDp), 1048576, 1024); + if (dr) return dr; + } + } catch (ePath) {} + } + + +// # 1) type=app 且配置了 pkg:自动取应用图标 +try { + var t = (btn.type == null) ? "" : String(btn.type); + if (t === "app") { + var pkg = (btn.pkg == null) ? "" : String(btn.pkg); + if (pkg.length > 0) { + // # 统一 LRU 缓存:避免频繁走 PackageManager(Drawable 可复用);并带容量上限,防止无限增长 + var kApp = "app|" + pkg; + var hitApp = this._iconLruGet(kApp); + if (hitApp) return hitApp; + + var pm = context.getPackageManager(); + var drApp = pm.getApplicationIcon(pkg); + if (drApp != null) { + this._iconLruPut(kApp, drApp); + return drApp; + } + } + } +} catch (eApp) {} + + + // # 1.5) type=shortcut:尝试取 Shortcuts 快捷方式图标(显示与 shortcuts.js 页面一致) + // # 说明:btn 需要包含 pkg + shortcutId;图标获取可能较重,因此做简单缓存,失败则回退到应用图标。 + try { + var t2 = (btn.type == null) ? "" : String(btn.type); + if (t2 === "shortcut") { + var pkg2 = (btn.pkg == null) ? "" : String(btn.pkg); + var sid2 = (btn.shortcutId == null) ? "" : String(btn.shortcutId); + if (pkg2.length > 0 && sid2.length > 0) { + // # 1.5.1) 优先从 shortcut_icons 持久化目录读取(桌面移除后仍可显示正确图标) + try { + var iconFilePath0 = this.getShortcutIconFilePath(pkg2, sid2, (btn.userId != null ? String(btn.userId) : "0")); + if (iconFilePath0) { + try { + var f0 = new java.io.File(iconFilePath0); + if (f0.exists() && f0.isFile()) { + var sizeDp0 = this.config.PANEL_ICON_SIZE_DP || 32; + var iconSizePx0 = this.dp(sizeDp0); + var dr0 = this.loadBallIconDrawableFromFile(iconFilePath0, iconSizePx0, 1048576, 1024); + if (dr0) { + // # 写入统一 LRU 缓存:同一个 shortcut 复用 Drawable,避免反复解码 PNG + try { + var sk0 = pkg2 + "@" + sid2 + "@" + (btn.userId != null ? String(btn.userId) : ""); + this._iconLruPut("sc|" + sk0, dr0); + } catch (eSc0) {} + return dr0; + } + } + } catch(eF0) {} + } + } catch(eFile0) {} +var skey = pkg2 + "@" + sid2 + "@" + (btn.userId != null ? String(btn.userId) : ""); + var kSc = "sc|" + skey; + var hitSc = this._iconLruGet(kSc); + if (hitSc) return hitSc; + // # 失败冷却:某些 ROM/桌面在短时间内反复查询 Shortcuts 会很慢或直接抛异常。 + // # 这里对同一个 shortcut key 做短暂冷却(默认 10 秒),避免面板频繁刷新导致卡顿/ANR 风险。 + if (!this._shortcutIconFailTs) this._shortcutIconFailTs = {}; + var nowTs = 0; + try { nowTs = new Date().getTime(); } catch(eNow) { nowTs = 0; } + var lastFailTs = this._shortcutIconFailTs[skey]; + if (lastFailTs && nowTs > 0 && (nowTs - lastFailTs) < 10000) { + // # 冷却期内直接跳过 shortcut icon 查询,走回退逻辑(应用图标) + } else { + + var la = context.getSystemService(android.content.Context.LAUNCHER_APPS_SERVICE); + if (la) { + + // # 修复:按钮管理页/按钮面板中,微信小程序等 shortcut 图标显示成宿主 App 图标 + // # 根因(工作假设):用 setShortcutIds() 精确查询时,部分 ROM 的实现会返回"退化"的 ShortcutInfo, + // # getShortcutIconDrawable 结果被降级为宿主 App 图标。 + // # 方案:完全复用 shortcuts.js 的做法--先按 package + flags 拉取该包的 shortcuts 列表,再在列表里按 id 过滤命中项取图标。 + // # 注意:不依赖外部 buildShortcutItemsIndex(),避免主程序环境下未加载 shortcuts.js 导致直接回退。 + // # 方案:完全复用"选择快捷方式列表"的取数路径,确保微信小程序等 pinned shortcut 能拿到正确的 ShortcutInfo + // # 1) 优先直连 shortcut service(选择器同款:信息更完整) + // # 2) 失败再回退 LauncherApps.getShortcuts + var list = null; + + // # 1) shortcut service 直连(与选择器保持一致) + try { + var userIdIntForSvc = 0; + try { + var buid2 = null; + if (btn.userId != null) buid2 = btn.userId; + if (buid2 == null && btn.user != null) buid2 = btn.user; + if (buid2 != null) { + var tmpUid2 = parseInt(String(buid2), 10); + if (!isNaN(tmpUid2)) userIdIntForSvc = tmpUid2; + } + } catch(eUidSvc) {} + + var sm2 = android.os.ServiceManager; + var shortcutSvc = null; + try { shortcutSvc = sm2.getService("shortcut"); } catch(eSm2) { shortcutSvc = null; } + if (shortcutSvc) { + var CFG_MATCH_ALL2 = 0x0000000F; + var slice2 = null; + try { slice2 = shortcutSvc.getShortcuts(String(pkg2), CFG_MATCH_ALL2, userIdIntForSvc); } catch(eS0b) { slice2 = null; } + if (slice2) { + var listObj2 = null; + try { listObj2 = slice2.getList(); } catch(eS1b) { listObj2 = null; } + if (listObj2) list = listObj2; + } + } + } catch(eSvc2) {} + + // # 2) LauncherApps 回退(当 shortcut service 不可用或返回空时) + if (list == null) { + var q = new android.content.pm.LauncherApps.ShortcutQuery(); + try { q.setPackage(pkg2); } catch(eSP) {} + + // # 重要:必须设置 QueryFlags,否则 getShortcuts 可能返回空(默认 flags=0) + // # 兼容性:不同 Android/ROM 可能缺少部分 FLAG,逐个 try 叠加 + try { + var qFlags = 0; + try { qFlags = qFlags | android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC; } catch(eF1) {} + try { qFlags = qFlags | android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED; } catch(eF2) {} + try { qFlags = qFlags | android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_MANIFEST; } catch(eF3) {} + try { qFlags = qFlags | android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_CACHED; } catch(eF4) {} + try { q.setQueryFlags(qFlags); } catch(eSF) {} + } catch(eQF) {} + + // # 重要:用户句柄优先用按钮携带的 userId(如有),否则使用当前用户 + var uh = android.os.Process.myUserHandle(); + try { + var buid = null; + if (btn.userId != null) buid = btn.userId; + if (buid == null && btn.user != null) buid = btn.user; + if (buid != null) { + var uidInt = parseInt(String(buid), 10); + if (!isNaN(uidInt)) { + uh = android.os.UserHandle.of(uidInt); + } + } + } catch(eUH) {} + + try { list = la.getShortcuts(q, uh); } catch(eGS) { list = null; } + } + +if (list && list.size && list.size() > 0) { + // # 在返回列表中按 shortcutId 精确命中 + var si = null; + try { + for (var kk = 0; kk < list.size(); kk++) { + var s0 = list.get(kk); + if (s0 != null) { + var id0 = ""; + try { id0 = String(s0.getId()); } catch(eId0) { id0 = ""; } + if (id0 === sid2) { si = s0; break; } + } + } + } catch(eFind) { si = null; } +if (si != null) { + // # 与 shortcuts.js 一致:优先 la.getShortcutIconDrawable(shortcutInfo, 0),再兜底 Icon.loadDrawable + var drSc = null; + try { drSc = la.getShortcutIconDrawable(si, 0); } catch(eIcon0) { drSc = null; } + if (drSc == null) { + try { + var ic = si.getIcon(); + if (ic != null) { + var d2 = ic.loadDrawable(context); + if (d2 != null) drSc = d2; + } + } catch(eIcon1) {} + } + if (drSc != null) { + try { this._iconLruPut("sc|" + skey, drSc); } catch (eSc1) {} + return drSc; + } + } + } + } + + + +// # 如果没拿到 shortcut 图标,记录一次失败时间,触发冷却 + try { + if (nowTs > 0) this._shortcutIconFailTs[skey] = nowTs; + else this._shortcutIconFailTs[skey] = new Date().getTime(); + } catch(eFT) {} + } + + + // # 回退:取应用图标,至少保证按钮有图标可见 + try { + var pm2 = context.getPackageManager(); + var drApp2 = pm2.getApplicationIcon(pkg2); + if (drApp2 != null) return drApp2; + } catch(eFall) {} + } + } + } catch (eSc) {} + // # 2) 显式指定 iconResName (String) 或 iconResId (int) + try { + if (btn.iconResName) { + var drShortx = this.resolveShortXDrawable(btn.iconResName, btn && btn.iconTint ? String(btn.iconTint) : ""); + if (drShortx != null) return drShortx; + var name = this.normalizeShortXIconName(btn.iconResName, true); + // # 回退到 android 系统图标 + var id = context.getResources().getIdentifier(name, "drawable", "android"); + if (id > 0) return context.getResources().getDrawable(id, null); + } + if (btn.iconResId) return context.getResources().getDrawable(btn.iconResId, null); + } catch (e1) {} + + // # 3) 兜底 + return context.getResources().getDrawable(android.R.drawable.ic_menu_help, null); + } catch (e2) {} + return null; +}; + +FloatBallAppWM.prototype.getShortXResHandle = function() { + if (this._shortxResHandle) return this._shortxResHandle; + try { + var flags = 0; + try { flags = android.content.Context.CONTEXT_INCLUDE_CODE | android.content.Context.CONTEXT_IGNORE_SECURITY; } catch (eF) { flags = android.content.Context.CONTEXT_RESTRICTED; } + var sxCtx = context.createPackageContext(CONST_SHORTX_PACKAGE, flags); + this._shortxResHandle = { + ctx: sxCtx, + res: sxCtx.getResources(), + cl: sxCtx.getClassLoader(), + pkg: CONST_SHORTX_PACKAGE + }; + return this._shortxResHandle; + } catch (e) { + safeLog(this.L, 'w', "getShortXResHandle failed: " + String(e)); + return null; + } +}; + +FloatBallAppWM.prototype.normalizeShortXIconName = function(name, keepPrefix) { + try { + var s = String(name == null ? "" : name).replace(/^\s+|\s+$/g, ""); + if (!s) return ""; + if (s.indexOf("@drawable/") === 0) s = s.substring(10); + if (s.indexOf(".") > 0) { + var parts = s.split("."); + s = parts[parts.length - 1]; + } + if (s.indexOf("ic_remix_") === 0) { + return keepPrefix ? s : s.substring("ic_remix_".length); + } + if (s.indexOf("ic_") === 0) { + return s; + } + return keepPrefix ? ("ic_remix_" + s) : s; + } catch (e) {} + return ""; +}; + +FloatBallAppWM.prototype.getShortXApkPaths = function() { + var out = []; + try { + var handle = this.getShortXResHandle(); + var ai = null; + try { if (handle && handle.ctx) ai = handle.ctx.getApplicationInfo(); } catch (eAi0) { ai = null; } + if (ai == null) { + try { ai = context.getPackageManager().getApplicationInfo(CONST_SHORTX_PACKAGE, 0); } catch (eAi1) { ai = null; } + } + function pushPath(p) { + try { + p = String(p || ""); + if (!p) return; + if (out.indexOf(p) < 0) out.push(p); + } catch (eP) {} + } + if (ai) { + try { pushPath(ai.sourceDir); } catch (e0) {} + try { pushPath(ai.publicSourceDir); } catch (e1) {} + try { + var ss = ai.splitSourceDirs; + if (ss) { + var i; + for (i = 0; i < ss.length; i++) pushPath(ss[i]); + } + } catch (e2) {} + } + } catch (e) {} + return out; +}; + +FloatBallAppWM.prototype.scanShortXIconsFromApk = function() { + var out = []; + var seen = {}; + var paths = this.getShortXApkPaths(); + var regex = /^res\/drawable[^\/]*\/(ic_remix_[a-z0-9_]+|ic_shortx|ic_launcher|ic_menu_preferences)\.(xml|png|webp|jpg|jpeg)$/; + var lastErr = ""; + var pi; + for (pi = 0; pi < paths.length; pi++) { + var zip = null; + try { + zip = new java.util.zip.ZipFile(String(paths[pi])); + var en = zip.entries(); + while (en.hasMoreElements()) { + var ze = en.nextElement(); + var name = String(ze.getName()); + var m = regex.exec(name); + if (!m) continue; + var fullName = String(m[1]); + if (seen[fullName]) continue; + seen[fullName] = true; + out.push({ + name: fullName, + shortName: this.normalizeShortXIconName(fullName, false), + id: 0 + }); + } + } catch (eZip) { + lastErr = String(eZip); + } finally { + try { if (zip) zip.close(); } catch (eClose) {} + } + } + if ((!out || out.length === 0) && lastErr) this._shortxIconCatalogError = lastErr; + return out; +}; + +FloatBallAppWM.prototype.getShortXIconLookupNames = function(name) { + var out = []; + try { + var s = String(name == null ? "" : name).replace(/^\s+|\s+$/g, ""); + if (!s) return out; + if (s.indexOf("@drawable/") === 0) s = s.substring(10); + if (s.indexOf(".") > 0) { + var parts = s.split("."); + s = parts[parts.length - 1]; + } + function add(v) { + if (!v) return; + if (out.indexOf(v) < 0) out.push(v); + } + add(s); + if (s.indexOf("ic_remix_") === 0) { + add(s.substring("ic_remix_".length)); + } else if (s.indexOf("ic_") !== 0) { + add("ic_remix_" + s); + add("ic_" + s); + } + } catch (e) {} + return out; +}; + +FloatBallAppWM.prototype.resolveShortXDrawableResId = function(name) { + try { + var handle = this.getShortXResHandle(); + if (!handle || !handle.res) return 0; + var cands = this.getShortXIconLookupNames(name); + var i; + for (i = 0; i < cands.length; i++) { + var resId = 0; + try { resId = handle.res.getIdentifier(String(cands[i]), "drawable", handle.pkg); } catch (e1) { resId = 0; } + if (resId > 0) return resId; + } + } catch (e) {} + return 0; +}; + +FloatBallAppWM.prototype.resolveShortXDrawable = function(name, tintHex) { + try { + var handle = this.getShortXResHandle(); + if (!handle || !handle.res) return null; + var resId = this.resolveShortXDrawableResId(name); + if (resId <= 0) return null; + var dr = handle.res.getDrawable(resId, null); + if (dr && tintHex) { + try { + dr = dr.mutate(); + dr.setColorFilter(android.graphics.Color.parseColor(String(tintHex)), android.graphics.PorterDuff.Mode.SRC_IN); + } catch (eTint) {} + } + return dr; + } catch (e) {} + return null; +}; + +FloatBallAppWM.prototype.getShortXIconCatalog = function(forceReload) { + if (!forceReload && this._shortxIconCatalog) return this._shortxIconCatalog; + var out = []; + this._shortxIconCatalogError = ""; + try { + var handle = this.getShortXResHandle(); + if (handle && handle.cl) { + try { + var clz = handle.cl.loadClass(CONST_SHORTX_PACKAGE + ".R$drawable"); + var fields = clz.getFields(); + var i; + for (i = 0; i < fields.length; i++) { + try { + var f = fields[i]; + var fname = String(f.getName()); + if (fname.indexOf("ic_remix_") !== 0 && fname !== "ic_shortx" && fname !== "ic_launcher" && fname !== "ic_menu_preferences") continue; + out.push({ + name: fname, + shortName: (fname.indexOf("ic_remix_") === 0) ? fname.substring("ic_remix_".length) : fname, + id: f.getInt(null) + }); + } catch (eField) {} + } + } catch (eClz) { + this._shortxIconCatalogError = String(eClz); + safeLog(this.L, 'w', "getShortXIconCatalog reflect failed: " + String(eClz)); + } + } + if (!out || out.length === 0) { + out = this.scanShortXIconsFromApk(); + } + out.sort(function(a, b) { + var aw = a.name.indexOf("ic_remix_") === 0 ? 1 : 0; + var bw = b.name.indexOf("ic_remix_") === 0 ? 1 : 0; + if (aw !== bw) return aw - bw; + var as = String(a.shortName || a.name); + var bs = String(b.shortName || b.name); + return as < bs ? -1 : (as > bs ? 1 : 0); + }); + if (!out || out.length === 0) { + if (!this._shortxIconCatalogError) this._shortxIconCatalogError = "反射与 APK 扫描均未获取到图标"; + } else { + this._shortxIconCatalogError = ""; + } + } catch (e) { + this._shortxIconCatalogError = String(e); + safeLog(this.L, 'w', "getShortXIconCatalog failed: " + String(e)); + } + this._shortxIconCatalog = out; + return out; +}; + diff --git a/code/th_07_shortcut.js b/code/th_07_shortcut.js new file mode 100644 index 0000000..ac84cf4 --- /dev/null +++ b/code/th_07_shortcut.js @@ -0,0 +1,1175 @@ +// @version 1.0.0 +// =======================【工具:快捷方式选择器(内置,合并 shortcuts.js)】====================== +// 这段代码的主要内容/用途:在 toolhub.js 内部提供与 shortcuts.js 等价的"快捷方式浏览/选择"页面(仅在点击"选择快捷方式"时触发)。 +// 设计要点: +// 1) 双线程流水线:UI 线程专管 WM/View;BG 线程专管 icon 加载,避免卡顿/ANR 风险。 +// 2) 稳定性:失败 TTL 熔断 + 限次重试 + icon LRU 上限;滚动触底用轮询,避免 interface listener 的 JavaAdapter 风险。 +// 3) UI 风格:复用 ToolHub 主题(白天/夜晚),避免与主面板割裂;支持关闭与安全销毁。 +// 4) 日志:复用 ToolHubLogger(self.L / _th_log)记录关键步骤与异常。 +FloatBallAppWM.prototype.showShortcutPicker = function(opts) { + var self = this; + var opt = opts || {}; + var mode = (opt.mode != null) ? String(opt.mode) : "browse"; // "pick" | "browse" + var onPick = (typeof opt.onPick === "function") ? opt.onPick : null; + + // # 会话隔离:生成唯一会话 ID,防止状态污染 + var sessionId = String(new Date().getTime()) + "_" + Math.random().toString(36).substr(2, 9); + var currentSession = sessionId; + + // 保存当前会话 ID 到实例(用于新选择器) + this.__currentShortcutSession = sessionId; + + // 会话检查函数:所有异步回调都必须检查 + function checkSession() { + if (self.__currentShortcutSession !== currentSession) { + safeLog(self.L, 'w', "Shortcut picker session expired, dropping callback"); + return false; + } + return true; + } + + // 清理函数 + function destroySession() { + if (self.__currentShortcutSession === currentSession) { + self.__currentShortcutSession = null; + } + } + + // # 稳定性:复用单例选择器,避免频繁 add/remove View 与线程创建销毁导致 system_server 概率性崩溃 + try { + if (self.__shortcutPickerSingleton && typeof self.__shortcutPickerSingleton.show === "function") { + // # 新会话覆盖旧会话 + self.__shortcutPickerSingleton.show(opts); + return; + } + } catch(eSingle) {} + + + // # 兼容:toolhub.js 全局未必定义 Context 别名,这里显式绑定,避免 ReferenceError。 + var Context = android.content.Context; + + // # 常量:可按需调小以降低 system_server 内存峰值 + var CFG_PAGE_BATCH = 40; + var CFG_ICON_CACHE_MAX = 120; + var CFG_ICON_FAIL_TTL_MS = 15000; + var CFG_ICON_FAIL_MAX_RETRY = 2; + var CFG_ICON_LOAD_CONCURRENCY = 2; + // # 这段代码的主要内容/用途:ShortcutService.getShortcuts 的匹配 flags(与 shortcuts.js 保持一致) + // # 说明:使用 0x0000000F 可尽量覆盖 Dynamic / Manifest / Pinned / Cached 等类型 + var CFG_MATCH_ALL = 0x0000000F; + + function now() { try { return new Date().getTime(); } catch(e) { return 0; } } + function lower(s) { try { return String(s || "").toLowerCase(); } catch(e) { return ""; } } + function safeStr(s) { try { return String(s == null ? "" : s); } catch(e) { return ""; } } + + // # 增强版:动态获取系统用户目录(适配不同 ROM) + function getSystemUserDir() { + var candidates = [ + "/data/system_ce", // 标准 AOSP + "/data/system/users", // 部分 MIUI/HyperOS + "/data/data/system_ce" // 极少数定制系统 + ]; + + for (var i = 0; i < candidates.length; i++) { + try { + var f = new java.io.File(candidates[i]); + if (f.exists() && f.isDirectory() && f.canRead()) { + return candidates[i]; + } + } catch(e) {} + } + + // 反射获取环境变量(最可靠但较慢) + try { + var env = java.lang.System.getenv("ANDROID_DATA"); + if (env) { + var envPath = String(env) + "/system_ce"; + var envDir = new java.io.File(envPath); + if (envDir.exists() && envDir.isDirectory()) { + return envPath; + } + } + } catch(e) {} + + // 最终兜底 + return "/data/system_ce"; + } + + // ==================== Shortcut 枚举(system_ce + shortcut service) ==================== + function listUserIdsFromSystemCE() { + // # 这段代码的主要内容/用途:扫描 /data/system_ce 下的用户目录,得到可用 userId 列表。 + var out = []; + try { + var basePath = getSystemUserDir(); + var base = new java.io.File(basePath); + if (!base.exists() || !base.isDirectory()) return out; + var arr = base.listFiles(); + if (!arr) return out; + for (var i = 0; i < arr.length; i++) { + var f = arr[i]; + if (!f || !f.isDirectory()) continue; + var name = safeStr(f.getName()); + if (!name) continue; + // 只收数字目录 + var ok = true; + for (var j = 0; j < name.length; j++) { + var c = name.charCodeAt(j); + if (c < 48 || c > 57) { ok = false; break; } + } + if (!ok) continue; + out.push(parseInt(name, 10)); + } + } catch(e) {} + // 默认兜底:至少有 user 0 + if (out.length === 0) out.push(0); + return out; + } + + function listPackagesHavingShortcuts(userId) { + // # 这段代码的主要内容/用途:从 shortcut_service 的持久化目录推断哪些包存在快捷方式记录(快速筛选)。 + var out = []; + try { + // # 使用动态获取的系统用户目录 + var basePath = "/data/system_ce"; + try { + if (typeof getSystemUserDir === "function") { + basePath = getSystemUserDir(); + } + } catch(eGSD2) {} + + var dir = new java.io.File(basePath + "/" + String(userId) + "/shortcut_service/packages"); + if (!dir.exists() || !dir.isDirectory()) return out; + var fs = dir.listFiles(); + if (!fs) return out; + for (var i = 0; i < fs.length; i++) { + var f = fs[i]; + if (!f || !f.isFile()) continue; + var name = safeStr(f.getName()); + if (!name) continue; + if (name.indexOf(".xml") > 0) { + var pkg = name.substring(0, name.length - 4); + if (pkg && pkg.length > 0) out.push(pkg); + } + } + } catch(e) {} + return out; + } + + function getShortcutServiceBinder() { + // # 这段代码的主要内容/用途:获取 "shortcut" service 的 Binder(不同 ROM 上可能失败,失败则回退到 LauncherApps API 取 icon)。 + try { + var sm = android.os.ServiceManager; + return sm.getService("shortcut"); + } catch(e) { + return null; + } + } + + function getShortcutsForPackage(pkg, userId) { + // # 这段代码的主要内容/用途:尝试从 shortcut service 拉取指定包的 ShortcutInfo 列表。 + // 说明:此处采用"尽力而为",因为 ROM 兼容性差;失败时返回空数组即可。 + var out = []; + try { + // # 修复:与 shortcuts.js 行为一致,优先走 shortcut 服务直连(可拿到"添加到桌面"的微信小程序这类入口) + // # 说明:LauncherApps.getShortcuts 在部分 ROM/桌面上对非 Launcher 调用者可见性不足,导致列表缺项。 + try { + var svc = getShortcutServiceBinder(); + if (svc) { + var slice = null; + try { slice = svc.getShortcuts(String(pkg), CFG_MATCH_ALL, parseInt(String(userId), 10)); } catch(eS0) { slice = null; } + if (slice) { + var listObj = null; + try { listObj = slice.getList(); } catch(eS1) { listObj = null; } + if (listObj) { + try { + var sz = listObj.size(); + for (var si0 = 0; si0 < sz; si0++) { + try { + var s0 = listObj.get(si0); + if (s0) out.push(s0); + } catch(eS2) {} + } + } catch(eS3) {} + if (out.length > 0) return out; + } + } + } + } catch(eSvc) { + // ignore and fallback + } + + // # 兜底:走 LauncherApps.getShortcuts(某些 ROM 上 shortcut 服务直连可能不可用) + var la = context.getSystemService(Context.LAUNCHER_APPS_SERVICE); + if (!la) return out; + + var LauncherApps = android.content.pm.LauncherApps; + var LauncherAppsShortcutQuery = android.content.pm.LauncherApps.ShortcutQuery; + + var q = new LauncherAppsShortcutQuery(); + // FLAG_MATCH_*:尽量全拿(动态/固定/清单) + try { + q.setQueryFlags( + LauncherAppsShortcutQuery.FLAG_MATCH_DYNAMIC | + LauncherAppsShortcutQuery.FLAG_MATCH_PINNED | + LauncherAppsShortcutQuery.FLAG_MATCH_MANIFEST | + LauncherAppsShortcutQuery.FLAG_MATCH_CACHED + ); + } catch(eF) { + // 某些 ROM 没有 FLAG_MATCH_CACHED + try { + q.setQueryFlags( + LauncherAppsShortcutQuery.FLAG_MATCH_DYNAMIC | + LauncherAppsShortcutQuery.FLAG_MATCH_PINNED | + LauncherAppsShortcutQuery.FLAG_MATCH_MANIFEST + ); + } catch(eF2) {} + } + try { q.setPackage(safeStr(pkg)); } catch(eP) {} + + // user 处理:尽量兼容多用户 + var userHandle = null; + try { + if (userId != null) { + var UserHandle = android.os.UserHandle; + userHandle = UserHandle.of(parseInt(String(userId), 10)); + } + } catch(eU) { userHandle = null; } + + var list = null; + try { + if (userHandle) list = la.getShortcuts(q, userHandle); + else list = la.getShortcuts(q, android.os.Process.myUserHandle()); + } catch(eQ) { + list = null; + } + + if (!list) return out; + for (var i = 0; i < list.size(); i++) { + try { + var si = list.get(i); + if (si) out.push(si); + } catch(eI) {} + } + } catch(e) {} + return out; + } + + function getAppLabelAsUser(pkg, userId) { + // # 这段代码的主要内容/用途:获取应用名称(用于分组/搜索显示),失败则返回包名。 + try { + var pm = context.getPackageManager(); + var ai = pm.getApplicationInfo(String(pkg), 0); + var lb = pm.getApplicationLabel(ai); + if (lb != null) return String(lb); + } catch(e) {} + return String(pkg); + } + + function buildShortcutItemsIndex() { + // # 这段代码的主要内容/用途:汇总所有 userId 下的快捷方式为统一列表,结构与 shortcuts.js 保持一致。 + var items = []; + var users = listUserIdsFromSystemCE(); + for (var ui = 0; ui < users.length; ui++) { + var uid = users[ui]; + var pkgs = listPackagesHavingShortcuts(uid); + for (var pi = 0; pi < pkgs.length; pi++) { + var pkg = pkgs[pi]; + if (!pkg) continue; + var sis = getShortcutsForPackage(pkg, uid); + if (!sis || sis.length === 0) continue; + for (var si = 0; si < sis.length; si++) { + var sInfo = sis[si]; + if (!sInfo) continue; + var id = ""; + var label = ""; + var intentUri = ""; + try { id = safeStr(sInfo.getId()); } catch(eId) { id = ""; } + try { label = safeStr(sInfo.getShortLabel()); } catch(eLb) { label = ""; } + try { + var it = sInfo.getIntent(); + if (it) intentUri = safeStr(it.toUri(0)); + } catch(eIt) { intentUri = ""; } + + items.push({ + userId: uid, + pkg: safeStr(pkg), + id: id, + label: label, + intentUri: intentUri, + shortcutInfo: sInfo + }); + } + } + } + return items; + } + + // ==================== icon 加载(与 resolveIconDrawable 回退策略一致) ==================== + function loadShortcutIconDrawable(item) { + // # 这段代码的主要内容/用途:尽力加载 shortcut icon;失败时回退到 app icon。 + try { + if (!item) return null; + var la = context.getSystemService(Context.LAUNCHER_APPS_SERVICE); + if (la && item.shortcutInfo) { + try { + // density: 0 让系统自适应(部分 ROM 更稳) + var dr = la.getShortcutIconDrawable(item.shortcutInfo, 0); + if (dr) return dr; + } catch(eLa) {} + } + } catch(e0) {} + // fallback: app icon + try { + var pm = context.getPackageManager(); + return pm.getApplicationIcon(String(item.pkg)); + } catch(e1) {} + return null; + } + + // ==================== 分组/过滤 ==================== + function groupItems(items) { + // # 这段代码的主要内容/用途:按应用分组,tab 显示用;同时保留 "__ALL__"。 + var groups = {}; + var order = []; + function ensure(key, title) { + if (groups[key]) return; + groups[key] = { key: key, title: title, items: [] }; + order.push(key); + } + ensure("__ALL__", "全部"); + for (var i = 0; i < items.length; i++) { + var it = items[i]; + if (!it) continue; + groups["__ALL__"].items.push(it); + var k = safeStr(it.pkg); + if (!groups[k]) { + ensure(k, getAppLabelAsUser(k, it.userId)); + } + groups[k].items.push(it); + } + // 让 "__ALL__" 固定在第一位,其余按 title 排序(中文/英文混排就随缘) + try { + var rest = []; + for (var j = 0; j < order.length; j++) if (order[j] !== "__ALL__") rest.push(order[j]); + rest.sort(function(a, b) { + var ta = safeStr(groups[a].title); + var tb = safeStr(groups[b].title); + return ta.localeCompare(tb); + }); + order = ["__ALL__"].concat(rest); + } catch(eS) {} + return { groups: groups, order: order }; + } + + function filterItems(items, q) { + var qLower = lower(q || ""); + if (!qLower) return items; + var out = []; + for (var i = 0; i < items.length; i++) { + var it = items[i]; + if (!it) continue; + var hit = false; + if (lower(it.label).indexOf(qLower) > -1) hit = true; + else if (lower(it.pkg).indexOf(qLower) > -1) hit = true; + else if (lower(it.id).indexOf(qLower) > -1) hit = true; + else if (lower(it.intentUri).indexOf(qLower) > -1) hit = true; + if (hit) out.push(it); + } + return out; + } + + // ==================== 主 UI(WM 双线程 + 轮询触底) ==================== + var HandlerThread = android.os.HandlerThread; + var Handler = android.os.Handler; + var Runnable = java.lang.Runnable; + var Thread = java.lang.Thread; + + var ht = new HandlerThread("sx-toolhub-shortcut-ui"); + ht.start(); + var h = new Handler(ht.getLooper()); + + var bgHt = new HandlerThread("sx-toolhub-shortcut-bg"); + bgHt.start(); + var bgH = new Handler(bgHt.getLooper()); + + var wm = context.getSystemService(Context.WINDOW_SERVICE); + + var state = { + destroyed: false, + hidden: false, + // # 单例标识:用于避免旧实例的异步回调误操作新实例 + instanceId: String(now()) + "_" + String(Math.random()), + root: null, + params: null, + isAdded: false, + + allItems: [], + groups: null, + groupOrder: [], + currentGroupKey: "__ALL__", + + query: "", + lastFilterToken: 0, + + iconCache: {}, + iconCacheOrder: [], + iconCacheMax: CFG_ICON_CACHE_MAX, + + iconFail: {}, + + iconQ: [], + iconInFlight: 0, + iconConc: CFG_ICON_LOAD_CONCURRENCY, + + grid: null, + tvStat: null, + etSearch: null, + tabRow: null, + scrollView: null, + + renderList: [], + renderCursor: 0, + isAppending: false, + lastAppendTryTs: 0, + + scrollPollRunning: false, + + // # 回调:关闭/隐藏后通知外层恢复上层 UI(避免被新增按钮页遮挡) + onDismiss: (opt && typeof opt.onDismiss === "function") ? opt.onDismiss : null, + onDismissCalled: false + }; + + + function Ld(msg) { try { if (self.L) self.L.d("[shortcut] " + msg); } catch(e) {} } + function Li(msg) { try { if (self.L) self.L.i("[shortcut] " + msg); } catch(e) {} } + function Le(msg) { try { if (self.L) self.L.e("[shortcut] " + msg); } catch(e) {} } + + function runOn(handler, fn) { + try { + handler.post(new JavaAdapter(Runnable, { run: function() { try { fn(); } catch(e) {} } })); + return true; + } catch(e) { return false; } + } + function hide() { + // # 这段代码的主要内容/用途:隐藏"选择快捷方式"窗口(不 removeView、不销毁线程),显著降低 system_server 概率性崩溃 + runOn(h, function() { + // # 会话检查:过期会话直接丢弃 + if (!checkSession()) return; + + if (state.destroyed) return; + if (state.hidden) return; + state.hidden = true; + state.scrollPollRunning = false; + + // # 停止队列与异步:隐藏后不再触发 UI/图标加载逻辑 + try { h.removeCallbacksAndMessages(null); } catch(eCB0) {} + try { bgH.removeCallbacksAndMessages(null); } catch(eCB1) {} + + // # 输入法/焦点:无论是否弹出输入法,都先退焦点并尝试隐藏软键盘 + try { + if (state.etSearch) { + try { state.etSearch.clearFocus(); } catch(eK0) {} + try { + var imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); + if (imm) imm.hideSoftInputFromWindow(state.etSearch.getWindowToken(), 0); + } catch(eK1) {} + } + } catch(eK2) {} + + // # 仅隐藏 View:不触碰 WM removeView,避免 WM/IME token 状态机被打乱 + try { + if (state.root) { + state.root.setVisibility(android.view.View.GONE); + } + } catch(eV0) {} + + // # 退出线程:hide 时也释放线程,避免反复打开/隐藏导致线程堆积 + try { + var killer = new Thread(new JavaAdapter(Runnable, { + run: function() { + try { bgHt.quitSafely(); } catch(e2) {} + try { ht.quitSafely(); } catch(e3) {} + } + })); + killer.start(); + } catch(eQuit) {} + + // # 通知外层:选择器已隐藏,可恢复上层面板显示 + try { + if (state.onDismiss && !state.onDismissCalled) { + state.onDismissCalled = true; + try { state.onDismiss(); } catch(eOD0) {} + } + } catch(eOD1) {} + }); + } + + function destroy() { + // # 会话清理:标记当前会话已结束 + destroySession(); + + // # 这段代码的主要内容/用途:彻底销毁选择器(仅在 wm_add_failed 等失败场景使用) + runOn(h, function() { + if (state.destroyed) return; + state.destroyed = true; + state.hidden = true; + state.scrollPollRunning = false; + + // # 清理消息队列 + try { h.removeCallbacksAndMessages(null); } catch(eCB0) {} + try { bgH.removeCallbacksAndMessages(null); } catch(eCB1) {} + + try { state.iconQ = []; } catch(e0) {} + state.iconInFlight = 0; + + // # 通知外层:选择器即将销毁,可恢复上层面板显示 + try { + if (state.onDismiss && !state.onDismissCalled) { + state.onDismissCalled = true; + try { state.onDismiss(); } catch(eOD0) {} + } + } catch(eOD1) {} + + // # 输入法/焦点清理 + try { + if (state.etSearch) { + try { state.etSearch.clearFocus(); } catch(eK0) {} + try { + var imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); + if (imm) imm.hideSoftInputFromWindow(state.etSearch.getWindowToken(), 0); + } catch(eK1) {} + } + } catch(eK2) {} + + // # 尝试移除 View(失败也吞掉,避免把 system_server 再次打穿) + var rootRef = state.root; + var wasAdded = state.isAdded; + try { + if (rootRef && wasAdded) { + try { wm.removeViewImmediate(rootRef); } catch(eR0) { try { wm.removeView(rootRef); } catch(eR1) {} } + } + } catch(eR2) {} + + state.isAdded = false; + state.root = null; + + // # 单例清理 + try { if (self.__shortcutPickerSingleton && self.__shortcutPickerSingleton.instanceId === state.instanceId) self.__shortcutPickerSingleton = null; } catch(eS0) {} + + // # 退出线程 + var killer = new Thread(new JavaAdapter(Runnable, { + run: function() { + try { bgHt.quitSafely(); } catch(e2) {} + try { ht.quitSafely(); } catch(e3) {} + } + })); + killer.start(); + }); + } + +function cacheGet(key) { + if (state.iconCache.hasOwnProperty(key)) return state.iconCache[key]; + return null; + } + + function cachePut(key, drawable) { + state.iconCache[key] = drawable; + var seen = false; + for (var i = state.iconCacheOrder.length - 1; i >= 0; i--) { + if (state.iconCacheOrder[i] === key) { seen = true; break; } + } + if (!seen) state.iconCacheOrder.push(key); + + while (state.iconCacheOrder.length > state.iconCacheMax) { + var oldKey = state.iconCacheOrder.shift(); + if (oldKey == null) continue; + if (oldKey === key) continue; + try { delete state.iconCache[oldKey]; } catch(e1) { state.iconCache[oldKey] = null; } + try { delete state.iconFail[oldKey]; } catch(e2) { state.iconFail[oldKey] = null; } + } + } + + function canTryLoadIcon(key) { + var rec = state.iconFail[key]; + if (!rec) return true; + var t = now(); + if (rec.count >= CFG_ICON_FAIL_MAX_RETRY) { + if (t - rec.ts < 300000) return false; + return true; + } + if (t - rec.ts < CFG_ICON_FAIL_TTL_MS) return false; + return true; + } + + function markIconFail(key) { + var t = now(); + var rec = state.iconFail[key]; + if (!rec) rec = { ts: t, count: 1 }; + else { rec.ts = t; rec.count = (rec.count || 0) + 1; } + state.iconFail[key] = rec; + } + + function enqueueIconLoad(item, iv, key) { + if (!item || !iv || !key) return; + if (!canTryLoadIcon(key)) return; + + state.iconQ.push({ item: item, iv: iv, key: key }); + pumpIconQ(); + } + + function pumpIconQ() { + if (state.destroyed) return; + while (state.iconInFlight < state.iconConc && state.iconQ.length > 0) { + var job = state.iconQ.shift(); + if (!job) continue; + state.iconInFlight++; + + (function(j) { + runOn(bgH, function() { + var dr = null; + try { dr = loadShortcutIconDrawable(j.item); } catch(e0) { dr = null; } + runOn(h, function() { + state.iconInFlight--; + if (state.destroyed) return; + + if (dr) { + try { j.iv.setImageDrawable(dr); } catch(e1) {} + cachePut(j.key, dr); + } else { + markIconFail(j.key); + } + pumpIconQ(); + }); + }); + })(job); + } + } + + function setStat(text) { try { if (state.tvStat) state.tvStat.setText(String(text)); } catch(e) {} } + + function rebuildRenderList() { + var g = state.groups ? state.groups[state.currentGroupKey] : null; + var base = (g && g.items) ? g.items : state.allItems; + var filtered = filterItems(base, state.query); + + state.renderList = filtered; + state.renderCursor = 0; + state.isAppending = false; + state.lastAppendTryTs = 0; + } + + function clearGrid() { + try { + if (state.grid) state.grid.removeAllViews(); + } catch(e) {} + } + + function appendBatch() { + if (state.destroyed) return; + if (!state.grid) return; + + if (state.isAppending) return; + state.isAppending = true; + + var start = state.renderCursor; + var end = Math.min(state.renderCursor + CFG_PAGE_BATCH, state.renderList.length); + + for (var i = start; i < end; i++) { + (function(idx) { + var it = state.renderList[idx]; + + var row = new android.widget.LinearLayout(context); + row.setOrientation(android.widget.LinearLayout.HORIZONTAL); + row.setGravity(android.view.Gravity.CENTER_VERTICAL); + + // # 条目间距:卡片式列表,每条之间留 8dp 间隔 + try { + var lpRow = new android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT); + lpRow.setMargins(0, 0, 0, self.dp(8)); + row.setLayoutParams(lpRow); + } catch(eLpR) {} + + row.setPadding(self.dp(12), self.dp(10), self.dp(12), self.dp(10)); + + // # 行背景:与 ToolHub 卡片色一致,并加轻微描边增强层次 + try { + var isDark = self.isDarkTheme(); + var bgColor = isDark ? self.ui.colors.cardDark : self.ui.colors.cardLight; + var stroke = isDark ? self.ui.colors.dividerDark : self.ui.colors.dividerLight; + row.setBackground(self.ui.createStrokeDrawable(bgColor, stroke, self.dp(1), self.dp(12))); + } catch(eBg) {} + + var iv = new android.widget.ImageView(context); + var lpI = new android.widget.LinearLayout.LayoutParams(self.dp(40), self.dp(40)); + lpI.setMargins(0, 0, self.dp(12), 0); + iv.setLayoutParams(lpI); + try { iv.setImageResource(android.R.drawable.sym_def_app_icon); } catch(eI0) {} + + var key = safeStr(it.pkg) + "@" + safeStr(it.id) + "@" + safeStr(it.userId); + var cached = cacheGet(key); + if (cached) { + try { iv.setImageDrawable(cached); } catch(eIC) {} + } else { + enqueueIconLoad(it, iv, key); + } + + var col = new android.widget.LinearLayout(context); + col.setOrientation(android.widget.LinearLayout.VERTICAL); + var lpC = new android.widget.LinearLayout.LayoutParams(0, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT); + lpC.weight = 1; + col.setLayoutParams(lpC); + + var tv1 = new android.widget.TextView(context); + tv1.setText(safeStr(it.label || it.id || it.pkg)); + tv1.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 14); + try { tv1.setTypeface(null, android.graphics.Typeface.BOLD); } catch(eB) {} + try { tv1.setTextColor(self.isDarkTheme() ? self.ui.colors.textPriDark : self.ui.colors.textPriLight); } catch(eC1) {} + tv1.setSingleLine(true); + tv1.setEllipsize(android.text.TextUtils.TruncateAt.END); + col.addView(tv1); + + var tv2 = new android.widget.TextView(context); + tv2.setText(safeStr(it.pkg) + " / " + safeStr(it.id)); + tv2.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 11); + tv2.setSingleLine(true); + tv2.setEllipsize(android.text.TextUtils.TruncateAt.END); + try { tv2.setTextColor(self.isDarkTheme() ? self.ui.colors.textSecDark : self.ui.colors.textSecLight); } catch(eC2) {} + col.addView(tv2); + + // # pick 模式:额外显示 userId,避免多用户/工作资料混淆 + if (mode === "pick") { + var tv3 = new android.widget.TextView(context); + tv3.setText("userId: " + safeStr(it.userId)); + tv3.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 10); + tv3.setSingleLine(true); + try { tv3.setTextColor(self.isDarkTheme() ? self.ui.colors.textSecDark : self.ui.colors.textSecLight); } catch(eC3) {} + col.addView(tv3); + } + + row.addView(iv); + row.addView(col); + + // 点击:pick 模式回传;browse 模式尝试启动 + row.setOnClickListener(new android.view.View.OnClickListener({ + onClick: function() { + try { + if (mode === "pick") { + if (onPick) onPick({ pkg: it.pkg, shortcutId: it.id, label: it.label, userId: it.userId, intentUri: it.intentUri }); + hide(); + return; + } + // browse:尽力启动(用 LauncherApps.startShortcut) + try { + var la = context.getSystemService(Context.LAUNCHER_APPS_SERVICE); + if (la) { + var uh = android.os.UserHandle.of(parseInt(String(it.userId), 10)); + la.startShortcut(String(it.pkg), String(it.id), null, null, uh); + self.toast("已尝试启动: " + safeStr(it.label || it.id)); + } else { + self.toast("LauncherApps 不可用"); + } + } catch(eStart) { + self.toast("启动失败: " + eStart); + } + } catch(eClick) {} + } + })); + + state.grid.addView(row); + + })(i); + } + + state.renderCursor = end; + state.isAppending = false; + + // 统计 + setStat("快捷方式: " + String(state.renderList.length) + " 已渲染: " + String(state.renderCursor)); + } + + function setupTabs() { + try { + if (!state.tabRow) return; + state.tabRow.removeAllViews(); + var order = state.groupOrder || []; + for (var i = 0; i < order.length; i++) { + (function(key) { + var g = state.groups[key]; + var tv = new android.widget.TextView(context); + tv.setText(safeStr(g ? g.title : key)); + tv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 13); + tv.setPadding(self.dp(12), self.dp(8), self.dp(12), self.dp(8)); + tv.setSingleLine(true); + tv.setEllipsize(android.text.TextUtils.TruncateAt.END); + + var isSel = (key === state.currentGroupKey); + var isDark = self.isDarkTheme(); + + // # Tabs 选中态:用 accent 低透明底 + 更醒目的文字;未选中态:轻描边 + try { + var tPri = isDark ? self.ui.colors.textPriDark : self.ui.colors.textPriLight; + var tSec = isDark ? self.ui.colors.textSecDark : self.ui.colors.textSecLight; + + var bgColor = isSel ? self.withAlpha(self.ui.colors.accent, isDark ? 0.22 : 0.14) + : (isDark ? self.withAlpha(android.graphics.Color.BLACK, 0.10) + : self.withAlpha(android.graphics.Color.BLACK, 0.04)); + var stroke = isDark ? self.ui.colors.dividerDark : self.ui.colors.dividerLight; + + tv.setTextColor(isSel ? tPri : tSec); + tv.setBackground(self.ui.createStrokeDrawable(bgColor, stroke, self.dp(1), self.dp(16))); + } catch(eBg) {} + + tv.setOnClickListener(new android.view.View.OnClickListener({ + onClick: function() { + state.currentGroupKey = key; + rebuildRenderList(); + clearGrid(); + appendBatch(); + setupTabs(); + } + })); + + var lp = new android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.WRAP_CONTENT, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT); + lp.setMargins(0, 0, self.dp(8), 0); + tv.setLayoutParams(lp); + state.tabRow.addView(tv); + })(order[i]); + } + } catch(e) {} + } + + function startScrollPoll() { + if (state.scrollPollRunning) return; + state.scrollPollRunning = true; + + function tick() { + if (!state.scrollPollRunning || state.destroyed) return; + runOn(h, function() { + try { + if (!state.scrollView) { schedule(); return; } + var sv = state.scrollView; + var child = sv.getChildAt(0); + if (!child) { schedule(); return; } + + var dy = sv.getScrollY(); + var vh = sv.getHeight(); + var ch = child.getHeight(); + + // 触底阈值:80dp + var near = (dy + vh) >= (ch - self.dp(80)); + if (near) { + if (state.renderCursor < state.renderList.length) { + appendBatch(); + } + } + } catch(e) {} + schedule(); + }); + } + + function schedule() { + try { + h.postDelayed(new JavaAdapter(Runnable, { run: tick }), 180); + } catch(e) {} + } + + schedule(); + } + + function buildUI() { + // # 这段代码的主要内容/用途:构建"选择快捷方式"页面 UI(颜色/间距/字体与 ToolHub 面板统一)。 + // 说明:尽量复用 ToolHub 的 UI helper 色板(light/dark),避免出现"黑底黑字/黄底刺眼"等问题。 + + // # root(标准面板容器:圆角 + 轻阴影 + 统一 padding) + var root = self.ui.createStyledPanel(self, 12); + + // # 顶栏(标题 + 右侧关闭) + var top = self.ui.createStyledHeader(self, 10); + + var title = new android.widget.TextView(context); + title.setText(mode === "pick" ? "选择快捷方式" : "快捷方式浏览器"); + title.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 16); + try { title.setTypeface(null, android.graphics.Typeface.BOLD); } catch(e) {} + try { title.setTextColor(self.isDarkTheme() ? self.ui.colors.textPriDark : self.ui.colors.textPriLight); } catch(e) {} + + var lpT = new android.widget.LinearLayout.LayoutParams(0, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT); + lpT.weight = 1; + title.setLayoutParams(lpT); + + // 关闭按钮:用 flat 风格,颜色与危险色一致(更像"工具面板"的关闭) + var btnClose = self.ui.createFlatButton(self, (mode === "pick" ? "取消" : "关闭"), self.ui.colors.danger, function() { + hide(); + }); + try { + btnClose.setPadding(self.dp(12), self.dp(6), self.dp(12), self.dp(6)); + btnClose.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 13); + } catch(e) {} + + top.addView(title); + top.addView(btnClose); + + // # 搜索框(可聚焦 + 统一输入背景) + var et = new android.widget.EditText(context); + et.setHint("搜索:名称 / 包名 / ID"); + et.setSingleLine(true); + et.setPadding(self.dp(12), self.dp(10), self.dp(12), self.dp(10)); + try { + et.setTextColor(self.isDarkTheme() ? self.ui.colors.textPriDark : self.ui.colors.textPriLight); + et.setHintTextColor(self.isDarkTheme() ? self.ui.colors.textSecDark : self.ui.colors.textSecLight); + } catch(e) {} + + try { + var inBg = self.ui.createRoundDrawable(self.isDarkTheme() ? self.ui.colors.inputBgDark : self.ui.colors.inputBgLight, self.dp(12)); + et.setBackground(inBg); + } catch(eBg) {} + + // # Tabs(横向滚动:统一为"胶囊按钮"风格,选中态更明确) + var tabSv = new android.widget.HorizontalScrollView(context); + tabSv.setHorizontalScrollBarEnabled(false); + tabSv.setOverScrollMode(android.view.View.OVER_SCROLL_NEVER); + + var tabRow = new android.widget.LinearLayout(context); + tabRow.setOrientation(android.widget.LinearLayout.HORIZONTAL); + try { tabRow.setPadding(0, self.dp(8), 0, self.dp(6)); } catch(e) {} + tabSv.addView(tabRow); + + // # 状态栏(数量/渲染进度) + var tvStat = new android.widget.TextView(context); + tvStat.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 12); + try { tvStat.setTextColor(self.isDarkTheme() ? self.ui.colors.textSecDark : self.ui.colors.textSecLight); } catch(e) {} + tvStat.setPadding(0, self.dp(6), 0, self.dp(6)); + + // # 列表(卡片式条目列表) + var sv = new android.widget.ScrollView(context); + sv.setFillViewport(true); + sv.setOverScrollMode(android.view.View.OVER_SCROLL_NEVER); + + var list = new android.widget.LinearLayout(context); + list.setOrientation(android.widget.LinearLayout.VERTICAL); + try { list.setPadding(0, self.dp(2), 0, self.dp(2)); } catch(e) {} + sv.addView(list); + + // bind + state.root = root; + state.etSearch = et; + state.tabRow = tabRow; + state.tvStat = tvStat; + state.grid = list; + state.scrollView = sv; + + // listeners:实时过滤(TextWatcher) + try { + var TextWatcher = android.text.TextWatcher; + et.addTextChangedListener(new JavaAdapter(TextWatcher, { + beforeTextChanged: function() {}, + onTextChanged: function() {}, + afterTextChanged: function(s) { + try { + if (state.destroyed) return; + state.query = safeStr(s); + rebuildRenderList(); + clearGrid(); + appendBatch(); + } catch(e) {} + } + })); + } catch(eTw) {} + + // layout + root.addView(top); + + // 搜索框与 tabs 之间留一点呼吸空间 + try { + var lpEt = new android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT); + lpEt.setMargins(0, 0, 0, self.dp(8)); + et.setLayoutParams(lpEt); + } catch(eLpEt) {} + root.addView(et); + + root.addView(tabSv); + root.addView(tvStat); + + var lpSv = new android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0); + lpSv.weight = 1; + sv.setLayoutParams(lpSv); + root.addView(sv); + + // 主题:用 ToolHub 现有的背景更新逻辑兜底(防止外部主题配置影响) + try { self.updatePanelBackground(root); } catch(eTheme) {} + + return root; + } + + function show() { + runOn(h, function() { + // # 会话检查:过期会话直接丢弃 + if (!checkSession()) return; + + // # 稳定性:复用已添加的 root,避免频繁 add/removeView 触发 system_server WM 状态机崩溃 + if (state.root && state.isAdded) { + state.hidden = false; + // # 每次显示前允许再次触发 onDismiss(用于外层恢复) + state.onDismissCalled = false; + // # UI 修复:每次重新显示时都重新把窗口定位到屏幕顶部(横向居中 + y=0),避免被其它面板遮挡或位置漂移 + try { + if (state.params) { + state.params.gravity = android.view.Gravity.TOP | android.view.Gravity.START; + // # UI 修复:复用显示时也强制关闭 IME 调整,避免上次输入导致位置被系统推下去 + try { + state.params.softInputMode = android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING + | android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN; + } catch(eSIM1) {} + try { + state.params.flags = state.params.flags + | android.view.WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN + | android.view.WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS; + } catch(eTop1) {} + var sw2 = (self.state && self.state.screen && self.state.screen.w) ? self.state.screen.w : 0; + if (sw2 > 0) state.params.x = Math.max(0, Math.round((sw2 - state.params.width) / 2)); + else state.params.x = 0; + state.params.y = 0; + wm.updateViewLayout(state.root, state.params); + } + } catch(ePos2) {} + + // # 层级修复:同类型(TYPE_APPLICATION_OVERLAY)窗口之间的上下层由 addView 顺序决定 + // # 说明:当"新增按钮页/主面板"在本窗口之后 addView 时,会把本窗口盖住;复用 show 仅 setVisibility 无法提升层级 + // # 处理:在 WM 线程内对本窗口做一次 removeViewImmediate + addView 以"提到最上层"(不在关闭时 remove,避免旧崩溃路径) + try { + var tsNow = now(); + if (!state.lastRaiseTs || (tsNow - state.lastRaiseTs) > 300) { + state.lastRaiseTs = tsNow; + try { wm.removeViewImmediate(state.root); } catch(eZ0) {} + try { wm.addView(state.root, state.params); } catch(eZ1) {} + state.isAdded = true; + } + } catch(eZ2) {} +try { state.root.setVisibility(android.view.View.VISIBLE); } catch(eVis) {} + try { + setStat("正在加载快捷方式..."); + Li("reloading shortcuts index..."); + } catch(e0) {} + try { + state.allItems = buildShortcutItemsIndex(); + var gg = groupItems(state.allItems); + state.groups = gg.groups; + state.groupOrder = gg.order; + } catch(e1) { + state.allItems = []; + state.groups = groupItems([]).groups; + state.groupOrder = ["__ALL__"]; + Le("build index err=" + String(e1)); + } + try { setupTabs(); } catch(eT0) {} + try { rebuildRenderList(); } catch(eT1) {} + try { clearGrid(); } catch(eT2) {} + try { appendBatch(); } catch(eT3) {} + try { startScrollPoll(); } catch(eT4) {} + Li("shortcut picker reused items=" + String(state.allItems.length)); + return; + } + + try { + // build data + setStat("正在加载快捷方式..."); + Li("loading shortcuts index..."); + } catch(e0) {} + + try { + state.allItems = buildShortcutItemsIndex(); + var gg = groupItems(state.allItems); + state.groups = gg.groups; + state.groupOrder = gg.order; + } catch(e1) { + state.allItems = []; + state.groups = groupItems([]).groups; + state.groupOrder = ["__ALL__"]; + Le("build index err=" + String(e1)); + } + + // build UI + var root = buildUI(); + + // wm params + var p = new android.view.WindowManager.LayoutParams(); + p.type = android.view.WindowManager.LayoutParams.TYPE_SYSTEM_DIALOG; // 修改:提升窗口层级,确保多次打开后仍在最顶层 + // # 说明:TYPE_APPLICATION_OVERLAY 与其他面板同层时会按 add 顺序叠放,频繁开关后容易被后开的面板盖住。 + // # TYPE_SYSTEM_DIALOG 层级更高,可稳定压过 ToolHub 其他面板(仍不频繁 add/remove,避免崩溃)。 + p.flags = android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; + p.format = android.graphics.PixelFormat.TRANSLUCENT; + // # UI 修复:固定在顶部显示时,避免被输入法/系统 inset 影响导致二次打开位置下移 + // # 说明:不使用 ADJUST_RESIZE/ADJUST_PAN,让窗口位置不被 IME 推动;同时默认隐藏软键盘状态 + try { + p.softInputMode = android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING + | android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN; + } catch(eSIM0) {} + // # 允许窗口覆盖到屏幕顶部区域(含状态栏区域),避免视觉上"不是贴顶" + try { + p.flags = p.flags + | android.view.WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN + | android.view.WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS; + } catch(eTop0) {} + p.width = self.dp(340); + p.height = self.dp(520); + // # UI 修复:选择快捷方式页应贴近屏幕顶部显示(与"新增按钮页"的顶部布局一致),而不是居中 + // # 说明:居中会让用户感觉"弹窗不是在顶部",也更容易被其它面板遮挡。 + p.gravity = android.view.Gravity.TOP | android.view.Gravity.START; + try { + var sw = (self.state && self.state.screen && self.state.screen.w) ? self.state.screen.w : 0; + // 顶部显示:水平居中 + y=0 + if (sw > 0) p.x = Math.max(0, Math.round((sw - p.width) / 2)); + else p.x = 0; + p.y = 0; + } catch(ePos) { + p.x = 0; + p.y = 0; + } + + // 允许输入法:搜索框要能聚焦 + try { + p.flags = p.flags & (~android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE); + } catch(eF) {} + + state.params = p; + + try { + wm.addView(root, p); + state.isAdded = true; + } catch(eAdd) { + Le("wm addView err=" + String(eAdd)); + destroy(); + self.toast("快捷方式选择器打开失败: wm_add_failed"); + return; + } + + // tabs + first render + setupTabs(); + rebuildRenderList(); + appendBatch(); + startScrollPoll(); + + Li("shortcut picker shown items=" + String(state.allItems.length)); + }); + } + + // 入口 + // # 创建单例 API:后续再次打开直接复用已添加的 root 与线程,只做"显示/刷新",不做 removeView + var api = { + instanceId: state.instanceId, + show: function(newOpts) { + try { + var o = newOpts || {}; + mode = (o.mode != null) ? String(o.mode) : mode; + onPick = (typeof o.onPick === "function") ? o.onPick : onPick; + } catch(eOpt) {} + // # 显示前先把隐藏标记清掉 + try { state.hidden = false; } catch(eH0) {} + show(); + }, + hide: hide, + destroy: destroy + }; + try { self.__shortcutPickerSingleton = api; } catch(eSet) {} + api.show(opts); +}; + diff --git a/code/th_08_content.js b/code/th_08_content.js new file mode 100644 index 0000000..7dc6a58 --- /dev/null +++ b/code/th_08_content.js @@ -0,0 +1,209 @@ +// @version 1.0.0 +// =======================【Content:解析 settings URI】====================== +// 这段代码的主要内容/用途:识别 content://settings/(system|secure|global)/KEY 并用 Settings.* get/put 更稳 +FloatBallAppWM.prototype.parseSettingsUri = function(uriStr) { + try { + var s = String(uriStr || ""); + if (s.indexOf("content://settings/") !== 0) return null; + + // content://settings/system/accelerometer_rotation + var rest = s.substring("content://settings/".length); + var parts = rest.split("/"); + if (!parts || parts.length < 1) return null; + + var table = String(parts[0] || ""); + var key = ""; + if (parts.length >= 2) key = String(parts[1] || ""); + + if (table !== "system" && table !== "secure" && table !== "global") return null; + return { table: table, key: key }; + } catch (e) { return null; } +}; + +FloatBallAppWM.prototype.settingsGetStringByTable = function(table, key) { + try { + var cr = context.getContentResolver(); + if (table === "system") return android.provider.Settings.System.getString(cr, String(key)); + if (table === "secure") return android.provider.Settings.Secure.getString(cr, String(key)); + if (table === "global") return android.provider.Settings.Global.getString(cr, String(key)); + return null; + } catch (e) { return null; } +}; + +FloatBallAppWM.prototype.settingsPutStringByTable = function(table, key, value) { + try { + var cr = context.getContentResolver(); + if (table === "system") return android.provider.Settings.System.putString(cr, String(key), String(value)); + if (table === "secure") return android.provider.Settings.Secure.putString(cr, String(key), String(value)); + if (table === "global") return android.provider.Settings.Global.putString(cr, String(key), String(value)); + return false; + } catch (e) { return false; } +}; + +// =======================【Content:通用 query】====================== +// 这段代码的主要内容/用途:ContentResolver.query 并把 Cursor 转成文本(用于查看器面板) +FloatBallAppWM.prototype.contentQueryToText = function(uriStr, projection, selection, selectionArgs, sortOrder, maxRows) { + var out = { ok: false, uri: String(uriStr || ""), rows: 0, text: "", err: "" }; + var cr = null; + var cur = null; + + try { + cr = context.getContentResolver(); + var uri = android.net.Uri.parse(String(uriStr)); + + var projArr = null; + if (projection && projection.length) { + projArr = java.lang.reflect.Array.newInstance(java.lang.String, projection.length); + var i0; + for (i0 = 0; i0 < projection.length; i0++) projArr[i0] = String(projection[i0]); + } + + var sel = (selection === undefined || selection === null) ? null : String(selection); + + var selArgsArr = null; + if (selectionArgs && selectionArgs.length) { + selArgsArr = java.lang.reflect.Array.newInstance(java.lang.String, selectionArgs.length); + var i1; + for (i1 = 0; i1 < selectionArgs.length; i1++) selArgsArr[i1] = String(selectionArgs[i1]); + } + + var so = (sortOrder === undefined || sortOrder === null) ? null : String(sortOrder); + + cur = cr.query(uri, projArr, sel, selArgsArr, so); + if (!cur) { + out.err = "query return null cursor"; + return out; + } + + var colCount = cur.getColumnCount(); + var cols = []; + var ci; + for (ci = 0; ci < colCount; ci++) cols.push(String(cur.getColumnName(ci))); + + var sb = []; + sb.push("URI: " + String(uriStr)); + sb.push("Columns(" + String(colCount) + "): " + cols.join(", ")); + sb.push(""); + + var limit = Math.max(1, Math.floor(Number(maxRows || this.config.CONTENT_MAX_ROWS || 20))); + var row = 0; + + while (cur.moveToNext()) { + row++; + sb.push("#" + String(row)); + var cj; + for (cj = 0; cj < colCount; cj++) { + var v = ""; + try { + if (cur.isNull(cj)) v = "null"; + else v = String(cur.getString(cj)); + } catch (eV) { + try { v = String(cur.getLong(cj)); } catch (eV2) { v = ""; } + } + sb.push(" " + cols[cj] + " = " + v); + } + sb.push(""); + if (row >= limit) break; + } + + out.ok = true; + out.rows = row; + out.text = sb.join("\n"); + return out; + } catch (e) { + out.err = String(e); + return out; + } finally { + try { if (cur) cur.close(); } catch (eC) {} + } +}; + +// =======================【Content:统一入口】====================== +// 这段代码的主要内容/用途:处理按钮里的 type:"content" +FloatBallAppWM.prototype.execContentAction = function(btn) { + var mode = btn.mode ? String(btn.mode) : ((btn.value !== undefined && btn.value !== null) ? "put" : "get"); + var uri = btn.uri ? String(btn.uri) : ""; + if (!uri) return { ok: false, err: "missing uri" }; + + // settings uri 优先走 Settings API + var su = this.parseSettingsUri(uri); + + if (mode === "get") { + if (su && su.key) { + var v = this.settingsGetStringByTable(su.table, su.key); + return { ok: true, mode: "get", kind: "settings", table: su.table, key: su.key, value: (v === null ? "null" : String(v)) }; + } + + // 非 settings:尝试 query 一行 + var q1 = this.contentQueryToText(uri, btn.projection, btn.selection, btn.selectionArgs, btn.sortOrder, 1); + if (!q1.ok) return { ok: false, mode: "get", kind: "query", err: q1.err }; + return { ok: true, mode: "get", kind: "query", text: q1.text, rows: q1.rows }; + } + + if (mode === "put") { + var val = (btn.value === undefined || btn.value === null) ? "" : String(btn.value); + + if (su && su.key) { + var ok = this.settingsPutStringByTable(su.table, su.key, val); + return { ok: !!ok, mode: "put", kind: "settings", table: su.table, key: su.key, value: val }; + } + + // 非 settings:尽力走 update(ContentValues) + try { + var cr = context.getContentResolver(); + var u = android.net.Uri.parse(uri); + var cv = new android.content.ContentValues(); + + // 支持 btn.values = {col1: "...", col2: "..."};否则写 value 列 + if (btn.values) { + var k; + for (k in btn.values) { + if (!btn.values.hasOwnProperty(k)) continue; + cv.put(String(k), String(btn.values[k])); + } + } else { + cv.put("value", val); + } + + var where = (btn.selection === undefined || btn.selection === null) ? null : String(btn.selection); + + var whereArgs = null; + if (btn.selectionArgs && btn.selectionArgs.length) { + whereArgs = java.lang.reflect.Array.newInstance(java.lang.String, btn.selectionArgs.length); + var i2; + for (i2 = 0; i2 < btn.selectionArgs.length; i2++) whereArgs[i2] = String(btn.selectionArgs[i2]); + } + + var n = cr.update(u, cv, where, whereArgs); + return { ok: true, mode: "put", kind: "update", updated: Number(n) }; + } catch (eU) { + return { ok: false, mode: "put", kind: "update", err: String(eU) }; + } + } + + if (mode === "query") { + var maxRows = (btn.maxRows === undefined || btn.maxRows === null) ? this.config.CONTENT_MAX_ROWS : Number(btn.maxRows); + var q = this.contentQueryToText(uri, btn.projection, btn.selection, btn.selectionArgs, btn.sortOrder, maxRows); + if (!q.ok) return { ok: false, mode: "query", err: q.err }; + return { ok: true, mode: "query", rows: q.rows, text: q.text }; + } + + if (mode === "view") { + try { + var it = new android.content.Intent(android.content.Intent.ACTION_VIEW); + it.setData(android.net.Uri.parse(uri)); + it.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(it); + return { ok: true, mode: "view" }; + } catch (eV) { + return { ok: false, mode: "view", err: String(eV) }; + } + } + + return { ok: false, err: "unknown mode=" + mode }; +}; + +/* ======================= + 下面开始:WM 动画、面板、触摸、启动、输出 + ======================= */ + diff --git a/code/th_09_animation.js b/code/th_09_animation.js new file mode 100644 index 0000000..94c5a0d --- /dev/null +++ b/code/th_09_animation.js @@ -0,0 +1,662 @@ +// @version 1.0.0 +FloatBallAppWM.prototype.animateBallLayout = function(toX, toY, toW, durMs, endCb) { + var st = this.state; + if (!st.addedBall || !st.ballRoot || !st.ballLp) { if (endCb) endCb(); return; } + + var fromX = st.ballLp.x; + var fromY = st.ballLp.y; + var fromW = st.ballLp.width; + + try { + var va = android.animation.ValueAnimator.ofFloat(0.0, 1.0); + va.setDuration(durMs); + try { + // 使用 OvershootInterpolator 产生轻微的回弹效果,更加生动 + // 0.7 的张力适中,不会过于夸张 + va.setInterpolator(new android.view.animation.OvershootInterpolator(0.7)); + } catch (eI) { + try { va.setInterpolator(new android.view.animation.DecelerateInterpolator()); } catch (eI2) {} + } + + var self = this; + + va.addUpdateListener(new android.animation.ValueAnimator.AnimatorUpdateListener({ + onAnimationUpdate: function(anim) { + try { + if (self.state.closing) return; + if (!self.state.addedBall) return; + + var f = anim.getAnimatedValue(); + var nx = Math.round(fromX + (toX - fromX) * f); + var ny = Math.round(fromY + (toY - fromY) * f); + var nw = Math.round(fromW + (toW - fromW) * f); + + // 性能优化:只有坐标真正变化时才请求 WindowManager 更新 + if (nx !== self.state.ballLp.x || ny !== self.state.ballLp.y || nw !== self.state.ballLp.width) { + self.state.ballLp.x = nx; + self.state.ballLp.y = ny; + self.state.ballLp.width = nw; + // # 关键操作使用 safeOperation 封装 + safeOperation("dockAnimation.updateViewLayout", function() { + self.state.wm.updateViewLayout(self.state.ballRoot, self.state.ballLp); + }, true, self.L); + } + } catch (e) {} + } + })); + + va.addListener(new android.animation.Animator.AnimatorListener({ + onAnimationStart: function() {}, + onAnimationRepeat: function() {}, + onAnimationCancel: function() {}, + onAnimationEnd: function() { + try { + if (!self.state.closing && self.state.addedBall) { + self.state.ballLp.x = toX; + self.state.ballLp.y = toY; + self.state.ballLp.width = toW; + self.state.wm.updateViewLayout(self.state.ballRoot, self.state.ballLp); + self.savePos(self.state.ballLp.x, self.state.ballLp.y); + } + } catch (e2) {} + try { if (endCb) endCb(); } catch (eCb) { try { if (self && self.L && self.L.e) self.L.e("animateBallLayout endCb err=" + String(eCb)); } catch (eLog) {} } + } + })); + + va.start(); + } catch (e0) { + try { + st.ballLp.x = toX; + st.ballLp.y = toY; + st.ballLp.width = toW; + st.wm.updateViewLayout(st.ballRoot, st.ballLp); + this.savePos(st.ballLp.x, st.ballLp.y); + } catch (e1) {} + try { if (endCb) endCb(); } catch (eCb2) { try { if (this && this.L && this.L.e) this.L.e("animateBallLayout endCb err=" + String(eCb2)); } catch (eLog2) {} } + } +}; + +FloatBallAppWM.prototype.playBounce = function(v) { + if (!this.config.ENABLE_BOUNCE) return; + if (!this.config.ENABLE_ANIMATIONS) return; + + try { v.animate().cancel(); } catch (e0) {} + + var self = this; + var i = 0; + + function step() { + if (self.state.closing) return; + + if (i >= self.config.BOUNCE_TIMES) { + try { v.setScaleX(1); v.setScaleY(1); } catch (e2) {} + return; + } + + var amp = (self.config.BOUNCE_MAX_SCALE - 1) * Math.pow(self.config.BOUNCE_DECAY, i); + var s = 1 + amp; + + v.animate() + .scaleX(s) + .scaleY(s) + .setDuration(self.config.BOUNCE_STEP_MS) + .setInterpolator(new android.view.animation.OvershootInterpolator()) + .withEndAction(new JavaAdapter(java.lang.Runnable, { + run: function() { + v.animate() + .scaleX(1) + .scaleY(1) + .setDuration(self.config.BOUNCE_STEP_MS) + .setInterpolator(new android.view.animation.AccelerateDecelerateInterpolator()) + .withEndAction(new JavaAdapter(java.lang.Runnable, { + run: function() { i++; step(); } + })) + .start(); + } + })) + .start(); + } + + step(); +}; + +FloatBallAppWM.prototype.safeRemoveView = function(v, whichName) { + try { + if (!v) return { ok: true, skipped: true }; + this.state.wm.removeView(v); + return { ok: true }; + } catch (e) { + safeLog(this.L, 'w', "removeView fail which=" + String(whichName || "") + " err=" + String(e)); + return { ok: false, err: String(e), where: whichName || "" }; + } +}; + +FloatBallAppWM.prototype.hideMask = function() { + if (!this.state.addedMask) return; + if (!this.state.mask) return; + + this.safeRemoveView(this.state.mask, "mask"); + this.state.mask = null; + this.state.maskLp = null; + this.state.addedMask = false; +}; + +FloatBallAppWM.prototype.hideMainPanel = function() { + if (!this.state.addedPanel) return; + if (!this.state.panel) return; + + this.safeRemoveView(this.state.panel, "panel"); + this.state.panel = null; + this.state.panelLp = null; + this.state.addedPanel = false; + + this.hideMask(); + this.touchActivity(); + + this._clearHeavyCachesIfAllHidden("hideMainPanel"); +}; + +FloatBallAppWM.prototype.hideSettingsPanel = function() { + if (!this.state.addedSettings) return; + if (!this.state.settingsPanel) return; + + this.safeRemoveView(this.state.settingsPanel, "settingsPanel"); + this.state.settingsPanel = null; + this.state.settingsPanelLp = null; + this.state.addedSettings = false; + + this.state.pendingUserCfg = null; + this.state.pendingDirty = false; + + this.hideMask(); + this.touchActivity(); + + this._clearHeavyCachesIfAllHidden("hideSettingsPanel"); +}; + +FloatBallAppWM.prototype.hideViewerPanel = function() { + if (!this.state.addedViewer) return; + if (!this.state.viewerPanel) return; + + this.safeRemoveView(this.state.viewerPanel, "viewerPanel"); + this.state.viewerPanel = null; + this.state.viewerPanelLp = null; + this.state.addedViewer = false; + + this.hideMask(); + this.touchActivity(); + + this._clearHeavyCachesIfAllHidden("hideViewerPanel"); +}; + +FloatBallAppWM.prototype.clearHeavyCaches = function(reason) { + // 这段代码的主要内容/用途:在所有面板都关闭后,主动清理"图标/快捷方式"等重缓存,降低 system_server 常驻内存。 + // 说明:仅清理缓存引用,不强行 recycle Bitmap,避免误伤仍被使用的 Drawable。 + + // # 防抖:5秒内相同 reason 不重复清理 + var now = Date.now(); + var cacheKey = "_lastClear_" + (reason || "default"); + var lastClear = this.state[cacheKey] || 0; + if (now - lastClear < 5000) { + return; // 5秒内已清理过,跳过 + } + this.state[cacheKey] = now; + + try { this._iconLru = null; } catch (eLruClr) {} + try { this._shortcutIconFailTs = {}; } catch (e2) {} + + // # Shortcuts 相关全局缓存(按钮编辑页/快捷方式选择器可能会创建) + try { if (typeof __scIconCache !== "undefined") __scIconCache = {}; } catch (e3) {} + try { if (typeof __scAppLabelCache !== "undefined") __scAppLabelCache = {}; } catch (e4) {} + + // # 记录一次清理日志(精简:只记录关键 reason,且 5秒防抖) + var keyReasons = ["memory_pressure", "screen_changed", "close"]; + var isKeyReason = keyReasons.indexOf(reason) >= 0; + try { + if (isKeyReason && this.L && this.L.i) { + this.L.i("clearHeavyCaches reason=" + String(reason)); + } + } catch (e5) {} +}; + +FloatBallAppWM.prototype._clearHeavyCachesIfAllHidden = function(reason) { + // 这段代码的主要内容/用途:只在"主面板/设置/查看器"全部关闭后清理缓存,避免页面切换时反复重建导致卡顿。 + try { + if (!this.state.addedPanel && !this.state.addedSettings && !this.state.addedViewer) { + this.clearHeavyCaches(reason || "all_hidden"); + } + } catch (e) {} +}; + +FloatBallAppWM.prototype.hideAllPanels = function() { + this.hideMainPanel(); + this.hideSettingsPanel(); + this.hideViewerPanel(); + this.hideMask(); + + this._clearHeavyCachesIfAllHidden("hideAllPanels"); +}; + +FloatBallAppWM.prototype.showMask = function() { + if (this.state.addedMask) return; + if (this.state.closing) return; + + var self = this; + var mask = new android.widget.FrameLayout(context); + + // 遮罩层背景:轻微的黑色半透明,提升层次感 + try { mask.setBackgroundColor(android.graphics.Color.parseColor("#33000000")); } catch (e0) { + mask.setBackgroundColor(0x33000000); + } + + mask.setOnTouchListener(new JavaAdapter(android.view.View.OnTouchListener, { + onTouch: function(v, e) { + var a = e.getAction(); + if (a === android.view.MotionEvent.ACTION_DOWN) { + self.touchActivity(); + self.hideAllPanels(); + return true; + } + return true; + } + })); + + var lp = new android.view.WindowManager.LayoutParams( + android.view.WindowManager.LayoutParams.MATCH_PARENT, + android.view.WindowManager.LayoutParams.MATCH_PARENT, + android.view.WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, + android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + android.graphics.PixelFormat.TRANSLUCENT + ); + + lp.gravity = android.view.Gravity.TOP | android.view.Gravity.START; + lp.x = 0; + lp.y = 0; + + try { + this.state.wm.addView(mask, lp); + this.state.mask = mask; + this.state.maskLp = lp; + this.state.addedMask = true; + + // 简单的淡入动画 + try { + if (this.config.ENABLE_ANIMATIONS) { + mask.setAlpha(0); + mask.animate().alpha(1).setDuration(200).start(); + } else { + mask.setAlpha(1); + } + } catch(eAnim){} + + } catch (e1) { + safeLog(this.L, 'e', "add mask fail err=" + String(e1)); + this.state.addedMask = false; + } +}; + +FloatBallAppWM.prototype.snapToEdgeDocked = function(withAnim, forceSide) { + if (this.state.closing) return; + // 移除对面板/Mask的检查,允许在任何情况下强制吸边(如果调用方逻辑正确) + // 如果需要保护,调用方自己判断 + if (this.state.dragging) return; + + var di = this.getDockInfo(); + var ballSize = di.ballSize; + var visible = di.visiblePx; + var hidden = di.hiddenPx; + + var snapLeft; + if (forceSide === "left") snapLeft = true; + else if (forceSide === "right") snapLeft = false; + else { + // 默认根据中心点判断 + var centerX = this.state.ballLp.x + Math.round(ballSize / 2); + snapLeft = centerX < Math.round(this.state.screen.w / 2); + } + + var targetW = visible; + var targetY = this.clamp(this.state.ballLp.y, 0, this.state.screen.h - ballSize); + + if (snapLeft) { + this.state.dockSide = "left"; + this.state.docked = true; + + try { this.state.ballContent.setX(-hidden); } catch (eL) {} + + if (withAnim) { + this.animateBallLayout(0, targetY, targetW, this.config.DOCK_ANIM_MS, null); + } else { + this.state.ballLp.x = 0; + this.state.ballLp.y = targetY; + this.state.ballLp.width = targetW; + try { this.state.wm.updateViewLayout(this.state.ballRoot, this.state.ballLp); } catch (eU1) {} + this.savePos(this.state.ballLp.x, this.state.ballLp.y); + } + + safeLog(this.L, 'd', "dock left x=0 y=" + String(targetY) + " w=" + String(targetW)); + + // 闲置变暗 + try { + if (this.config.ENABLE_ANIMATIONS) { + this.state.ballContent.animate().alpha(this.config.BALL_IDLE_ALPHA).setDuration(300).start(); + } else { + this.state.ballContent.setAlpha(this.config.BALL_IDLE_ALPHA); + } + } catch(eA) {} + + return; + } + + this.state.dockSide = "right"; + this.state.docked = true; + + try { this.state.ballContent.setX(0); } catch (eR) {} + + var x2 = this.state.screen.w - visible; + + if (withAnim) { + this.animateBallLayout(x2, targetY, targetW, this.config.DOCK_ANIM_MS, null); + } else { + this.state.ballLp.x = x2; + this.state.ballLp.y = targetY; + this.state.ballLp.width = targetW; + try { this.state.wm.updateViewLayout(this.state.ballRoot, this.state.ballLp); } catch (eU2) {} + this.savePos(this.state.ballLp.x, this.state.ballLp.y); + } + + // # 日志精简:dock 事件添加防抖(10秒内不重复记录相同边) + var dockNow = Date.now(); + var lastDock = this.state._lastDockLog || 0; + if (dockNow - lastDock > 10000) { + safeLog(this.L, 'i', "dock right x=" + String(x2) + " y=" + String(targetY)); + this.state._lastDockLog = dockNow; + } + + // 闲置变暗 + try { + if (this.config.ENABLE_ANIMATIONS) { + this.state.ballContent.animate().alpha(this.config.BALL_IDLE_ALPHA).setDuration(300).start(); + } else { + this.state.ballContent.setAlpha(this.config.BALL_IDLE_ALPHA); + } + } catch(eA) {} +}; + +FloatBallAppWM.prototype.undockToFull = function(withAnim, endCb) { + if (this.state.closing) { if (endCb) endCb(); return; } + if (!this.state.docked) { if (endCb) endCb(); return; } + if (!this.state.addedBall) { if (endCb) endCb(); return; } + + var di = this.getDockInfo(); + var ballSize = di.ballSize; + var targetW = ballSize; + var targetY = this.clamp(this.state.ballLp.y, 0, this.state.screen.h - ballSize); + + try { this.state.ballContent.setX(0); } catch (e0) {} + + if (this.state.dockSide === "left") { + this.state.docked = false; + this.state.dockSide = null; + + if (withAnim) this.animateBallLayout(0, targetY, targetW, this.config.UNDOCK_ANIM_MS, endCb); + else { + this.state.ballLp.x = 0; + this.state.ballLp.y = targetY; + this.state.ballLp.width = targetW; + try { this.state.wm.updateViewLayout(this.state.ballRoot, this.state.ballLp); } catch (eU1) {} + this.savePos(this.state.ballLp.x, this.state.ballLp.y); + if (endCb) endCb(); + } + + // 恢复不透明度 + try { + if (withAnim && this.config.ENABLE_ANIMATIONS) { + this.state.ballContent.animate().alpha(1.0).setDuration(150).start(); + } else { + this.state.ballContent.setAlpha(1.0); + } + } catch(eA) {} + + safeLog(this.L, 'i', "undock from left"); + return; + } + + var x = this.state.screen.w - ballSize; + + this.state.docked = false; + this.state.dockSide = null; + + if (withAnim) this.animateBallLayout(x, targetY, targetW, this.config.UNDOCK_ANIM_MS, endCb); + else { + this.state.ballLp.x = x; + this.state.ballLp.y = targetY; + this.state.ballLp.width = targetW; + try { this.state.wm.updateViewLayout(this.state.ballRoot, this.state.ballLp); } catch (eU2) {} + this.savePos(this.state.ballLp.x, this.state.ballLp.y); + if (endCb) endCb(); + } + + // 恢复不透明度 + try { + if (withAnim && this.config.ENABLE_ANIMATIONS) { + this.state.ballContent.animate().alpha(1.0).setDuration(150).start(); + } else { + this.state.ballContent.setAlpha(1.0); + } + } catch(eA) {} + + // # 日志精简:undock 事件改为 INFO 级别,且记录方向 + var undockSide = this.state.dockSide || "right"; + safeLog(this.L, 'i', "undock from " + undockSide); +}; + +FloatBallAppWM.prototype.cancelDockTimer = function() { + try { if (this.state.idleDockRunnable && this.state.h) this.state.h.removeCallbacks(this.state.idleDockRunnable); } catch (e) {} + this.state.idleDockRunnable = null; +}; + +FloatBallAppWM.prototype.armDockTimer = function() { + if (this.state.closing) return; + if (!this.state.h) return; + if (!this.state.addedBall) return; + if (this.state.docked) return; + + this.cancelDockTimer(); + + var hasPanel = (this.state.addedPanel || this.state.addedSettings || this.state.addedViewer || this.state.addedMask); + var targetMs = hasPanel ? this.config.PANEL_IDLE_CLOSE_AND_DOCK_MS : this.config.DOCK_AFTER_IDLE_MS; + + var self = this; + + this.state.idleDockRunnable = new java.lang.Runnable({ + run: function() { + try { + if (self.state.closing) return; + if (self.state.docked) return; + if (self.state.dragging) return; + + var hasPanel2 = (self.state.addedPanel || self.state.addedSettings || self.state.addedViewer || self.state.addedMask); + var needMs = hasPanel2 ? self.config.PANEL_IDLE_CLOSE_AND_DOCK_MS : self.config.DOCK_AFTER_IDLE_MS; + + var idle = self.now() - self.state.lastMotionTs; + if (idle < needMs) { self.armDockTimer(); return; } + + // if (hasPanel2) self.hideAllPanels(); // 用户要求不再自动关闭面板 + if (self.config.ENABLE_SNAP_TO_EDGE) { + self.snapToEdgeDocked(true); + } + } catch (e0) { + if (self.L) self.L.e("dockTimer run err=" + String(e0)); + } + } + }); + + this.state.h.postDelayed(this.state.idleDockRunnable, targetMs); +}; + +FloatBallAppWM.prototype.touchActivity = function() { + this.state.lastMotionTs = this.now(); + this.armDockTimer(); +} + +// # 点击防抖与安全执行 +// 这段代码的主要内容/用途:防止在悬浮面板上快速/乱点导致重复 add/remove、状态机被打穿,从而引发 system_server 异常重启。 +FloatBallAppWM.prototype.guardClick = function(key, cooldownMs, fn) { + try { + var now = android.os.SystemClock.uptimeMillis(); + if (!this.state._clickGuards) this.state._clickGuards = {}; + var last = this.state._clickGuards[key] || 0; + var cd = (cooldownMs != null ? cooldownMs : INTERACTION_CONSTANTS.CLICK_COOLDOWN_MS); + if (now - last < cd) return false; + this.state._clickGuards[key] = now; + try { + fn && fn(); + } catch (e1) { + safeLog(this.L, 'e', "guardClick err key=" + String(key) + " err=" + String(e1)); + } + return true; + } catch (e0) { + // 兜底:绝不让点击回调异常冒泡到 system_server + try { fn && fn(); } catch (e2) {} + return true; + } +}; + +FloatBallAppWM.prototype.safeUiCall = function(tag, fn) { + try { + fn && fn(); + } catch (e) { + safeLog(this.L, 'e', "safeUiCall err tag=" + String(tag || "") + " err=" + String(e)); + } +}; + +; + +FloatBallAppWM.prototype.onScreenChangedReflow = function() { + if (this.state.closing) return; + if (!this.state.addedBall) return; + + var di = this.getDockInfo(); + + var oldW = this.state.screen.w; + var oldH = this.state.screen.h; + + var newScreen = this.getScreenSizePx(); + var newW = newScreen.w; + var newH = newScreen.h; + + if (newW <= 0 || newH <= 0) return; + if (oldW <= 0) oldW = newW; + if (oldH <= 0) oldH = newH; + + this.state.screen = { w: newW, h: newH }; + + var ballSize = di.ballSize; + var visible = di.visiblePx; + var hidden = di.hiddenPx; + + var oldMaxX = Math.max(1, oldW - ballSize); + var oldMaxY = Math.max(1, oldH - ballSize); + var newMaxX = Math.max(1, newW - ballSize); + var newMaxY = Math.max(1, newH - ballSize); + + var xRatio = this.state.ballLp.x / oldMaxX; + var yRatio = this.state.ballLp.y / oldMaxY; + + var mappedX = Math.round(xRatio * newMaxX); + var mappedY = Math.round(yRatio * newMaxY); + + mappedX = this.clamp(mappedX, 0, newMaxX); + mappedY = this.clamp(mappedY, 0, newMaxY); + + if (this.state.docked) { + this.state.ballLp.y = mappedY; + this.state.ballLp.width = visible; + + if (this.state.dockSide === "left") { + this.state.ballLp.x = 0; + try { this.state.ballContent.setX(-hidden); } catch (eL) {} + } else { + this.state.ballLp.x = newW - visible; + try { this.state.ballContent.setX(0); } catch (eR) {} + } + // 重新进入闲置变暗逻辑(如果需要) + try { this.state.ballContent.setAlpha(this.config.BALL_IDLE_ALPHA); } catch(eA) {} + } else { + this.state.ballLp.x = mappedX; + this.state.ballLp.y = mappedY; + this.state.ballLp.width = ballSize; + try { this.state.ballContent.setX(0); } catch (e0) {} + try { this.state.ballContent.setAlpha(1.0); } catch(eA) {} + } + + try { this.state.wm.updateViewLayout(this.state.ballRoot, this.state.ballLp); } catch (eU) {} + this.savePos(this.state.ballLp.x, this.state.ballLp.y); + + safeLog(this.L, 'i', "screen reflow w=" + String(newW) + " h=" + String(newH) + " x=" + String(this.state.ballLp.x) + " y=" + String(this.state.ballLp.y)); +}; + +FloatBallAppWM.prototype.setupDisplayMonitor = function() { + if (this.state.closing) return; + + try { + var dm = context.getSystemService(android.content.Context.DISPLAY_SERVICE); + if (!dm) return; + + this.state.dm = dm; + this.state.lastRotation = this.getRotation(); + + var self = this; + + var listener = new JavaAdapter(android.hardware.display.DisplayManager.DisplayListener, { + onDisplayAdded: function(displayId) {}, + onDisplayRemoved: function(displayId) {}, + onDisplayChanged: function(displayId) { + try { + if (self.state.closing) return; + var nowTs = self.now(); + if (nowTs - self.state.lastMonitorTs < self.config.SCREEN_MONITOR_THROTTLE_MS) return; + self.state.lastMonitorTs = nowTs; + + self.state.h.post(new JavaAdapter(java.lang.Runnable, { + run: function() { + try { + if (self.state.closing) return; + if (!self.state.addedBall) return; + + var rot = self.getRotation(); + var sz = self.getScreenSizePx(); + + var changed = false; + if (rot !== self.state.lastRotation) { self.state.lastRotation = rot; changed = true; } + if (sz.w !== self.state.screen.w || sz.h !== self.state.screen.h) changed = true; + + if (changed) { + self.cancelDockTimer(); + self.onScreenChangedReflow(); + self.touchActivity(); + } + } catch (e1) { + if (self.L) self.L.e("displayChanged run err=" + String(e1)); + } + } + })); + } catch (e0) {} + } + }); + + this.state.displayListener = listener; + dm.registerDisplayListener(listener, this.state.h); + safeLog(this.L, 'i', "display monitor registered"); + } catch (e2) { + safeLog(this.L, 'e', "setupDisplayMonitor err=" + String(e2)); + } +}; + +FloatBallAppWM.prototype.stopDisplayMonitor = function() { + try { if (this.state.dm && this.state.displayListener) this.state.dm.unregisterDisplayListener(this.state.displayListener); } catch (e) {} + this.state.displayListener = null; + this.state.dm = null; +}; + diff --git a/code/th_10_shell.js b/code/th_10_shell.js new file mode 100644 index 0000000..07b73b7 --- /dev/null +++ b/code/th_10_shell.js @@ -0,0 +1,33 @@ +// @version 1.0.0 +// =======================【Shell:智能执行(Action优先 + 广播桥兜底)】====================== +// 这段代码的主要内容/用途:执行 Shell 按钮动作时,优先尝试 ShortX 的 ShellCommand Action(如可用);失败则走自定义广播桥(由外部接收器实际执行)。 +// 注意:system_server 进程本身不直接执行 shell;这里只负责"触发执行"。 +// 这段代码的主要内容/用途:通过广播桥触发 Shell 执行(仅广播桥,不再使用 ShellCommand Action)。 +// 注意:system_server 进程本身不直接执行 shell;外部接收器负责实际执行。 +FloatBallAppWM.prototype.execShellSmart = function(cmdB64, needRoot) { + var ret = { ok: false, via: "", err: "" }; + + try { + var action = String(this.config.SHELL_BRIDGE_ACTION || CONST_SHELL_BRIDGE_ACTION || "shortx.toolhub.SHELL"); + var it = new android.content.Intent(action); + + // # 固定广播协议:cmd_b64 + root + from(不再发送明文 cmd,避免协议漂移) + it.putExtra(CONST_SHELL_BRIDGE_EXTRA_CMD, String(cmdB64)); + it.putExtra(CONST_SHELL_BRIDGE_EXTRA_ROOT, !!needRoot); + // # from:来源标记,仅用于接收端识别/日志,不参与权限判断 + it.putExtra(CONST_SHELL_BRIDGE_EXTRA_FROM, "ToolHub"); + + context.sendBroadcast(it); + + ret.ok = true; + ret.via = "BroadcastBridge"; + safeLog(this.L, 'i', "shell via broadcast ok action=" + action + " root=" + String(!!needRoot)); + } catch (eB) { + ret.err = "Broadcast err=" + String(eB); + safeLog(this.L, 'e', "shell via broadcast fail err=" + String(eB)); + } + + return ret; +}; + + diff --git a/code/th_11_action.js b/code/th_11_action.js new file mode 100644 index 0000000..99a7fd2 --- /dev/null +++ b/code/th_11_action.js @@ -0,0 +1,320 @@ +// @version 1.0.0 +// =======================【WM 线程:按钮动作执行】====================== +FloatBallAppWM.prototype.execButtonAction = function(btn, idx) { + // # 点击防抖 + // 这段代码的主要内容/用途:防止在按钮面板上连续/乱点导致重复执行与 UI 状态机冲突(可能触发 system_server 异常重启)。 + if (!this.guardClick("btn_exec_" + String(idx), 380, null)) return; + + try { + if (!btn || !btn.type) { + this.toast("按钮#" + idx + " 未配置"); + safeLog(this.L, 'w', "btn#" + String(idx) + " no type"); + return; + } + + var t = String(btn.type); + safeLog(this.L, 'i', "btn click idx=" + String(idx) + " type=" + t + " title=" + String(btn.title || "")); + + if (t === "open_settings") { + this.showPanelAvoidBall("settings"); + return; + } + + if (t === "open_viewer") { + var logPath = (this.L && this.L._filePathForToday) ? this.L._filePathForToday() : ""; + if (!logPath) logPath = PATH_LOG_DIR + "/ShortX_ToolHub_" + (new java.text.SimpleDateFormat("yyyyMMdd").format(new java.util.Date())) + ".log"; + + var content = FileIO.readText(logPath); + if (!content) content = "(日志文件不存在或为空: " + logPath + ")"; + + if (content.length > 30000) { + content = "[...前略...]\n" + content.substring(content.length - 30000); + } + + // 简单的按行倒序,方便查看最新日志 + try { + var lines = content.split("\n"); + if (lines.length > 1) { + content = lines.reverse().join("\n"); + } + } catch(eRev) {} + + this.showViewerPanel("今日日志 (倒序)", content); + return; + } + + if (t === "toast") { + var msg = ""; + if (btn.text !== undefined && btn.text !== null) msg = String(btn.text); + else if (btn.title) msg = String(btn.title); + else msg = "按钮#" + idx; + this.toast(msg); + return; + } + + if (t === "app") { + var pkg = btn.pkg ? String(btn.pkg) : ""; + if (!pkg) { this.toast("按钮#" + idx + " 缺少 pkg"); return; } + + var it = context.getPackageManager().getLaunchIntentForPackage(pkg); + if (!it) { this.toast("无法启动 " + pkg); return; } + + it.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK); + +// # 系统级跨用户启动:Context.startActivityAsUser +// 这段代码的主要内容/用途:支持"主应用/分身应用"选择,避免弹出选择器或误启动到另一用户。 +// 说明:当未配置 launchUserId 时,默认使用 0(主用户);失败则回退 startActivity。 +var launchUid = 0; +try { + if (btn.launchUserId != null && String(btn.launchUserId).length > 0) launchUid = parseInt(String(btn.launchUserId), 10); +} catch(eLU0) { launchUid = 0; } +if (isNaN(launchUid)) launchUid = 0; + +try { + // 运行日志:记录跨用户启动参数(便于定位分身启动失败原因) + safeLog(this.L, 'i', "startAsUser(app) idx=" + idx + " pkg=" + pkg + " launchUserId=" + launchUid); + if (launchUid !== 0) { + context.startActivityAsUser(it, android.os.UserHandle.of(launchUid)); + } else { + context.startActivity(it); + } +} catch (eA) { + // # 兜底:某些 ROM/权限限制下 startActivityAsUser 可能抛异常,回退普通启动 + try { context.startActivity(it); } catch (eA2) {} + this.toast("启动失败"); + safeLog(this.L, 'e', "start app fail pkg=" + pkg + " uid=" + String(launchUid) + " err=" + String(eA)); +} +return; + } + + if (t === "shell") { + // # 这段代码的主要内容/用途:执行 shell(支持 cmd 明文 与 cmd_b64;最终会确保发送/执行的是"真正的 base64") + // # 修复点:历史配置里有些按钮把"明文命令"误存进 cmd_b64(或 b64 被破坏),会导致广播接收端解码失败→看起来"没效果"。 + var cmdB64 = (btn.cmd_b64 !== undefined && btn.cmd_b64 !== null) ? String(btn.cmd_b64) : ""; + var cmdPlain = (btn.cmd !== undefined && btn.cmd !== null) ? String(btn.cmd) : ""; + + // # 1) 只有明文但没有 b64:自动补齐 b64(避免特殊字符在多层字符串传递中被破坏) + if ((!cmdB64 || cmdB64.length === 0) && cmdPlain && cmdPlain.length > 0) { + try { + var b64x = encodeBase64Utf8(cmdPlain); + if (b64x && b64x.length > 0) cmdB64 = String(b64x); + } catch (eB64a) {} + } + + // # 2) cmd_b64 非空但无法解码:把它当作"明文命令"重新编码(保证广播桥/Action 都能吃到正确命令) + // # 说明:decodeBase64Utf8 返回空串通常意味着 b64 非法或被破坏;而真实命令不太可能是空串。 + if (cmdB64 && cmdB64.length > 0) { + try { + var testPlain = decodeBase64Utf8(cmdB64); + if ((!testPlain || testPlain.length === 0) && (!cmdPlain || cmdPlain.length === 0)) { + cmdPlain = String(cmdB64); + cmdB64 = ""; + } + } catch (eB64b) {} + } + if ((!cmdB64 || cmdB64.length === 0) && cmdPlain && cmdPlain.length > 0) { + try { + var b64y = encodeBase64Utf8(cmdPlain); + if (b64y && b64y.length > 0) cmdB64 = String(b64y); + } catch (eB64c) {} + } + + if (!cmdB64 || cmdB64.length === 0) { + this.toast("按钮#" + idx + " 缺少 cmd/cmd_b64"); + safeLog(this.L, 'e', "shell missing cmd idx=" + String(idx)); + return; + } + + // # 广播桥接收端默认以 root 执行,强制使用 root + var needRoot = true; + + var r = this.execShellSmart(cmdB64, needRoot); + if (r && r.ok) return; + + this.toast("shell 失败(Action + 广播桥均失败)"); + safeLog(this.L, 'e', "shell all failed cmd_b64=" + cmdB64 + " ret=" + JSON.stringify(r || {})); + return; + } + + if (t === "broadcast") { + // 这段代码的主要内容/用途:发送自定义广播(兼容 btn.extra / btn.extras),并对 Shell 广播桥(shortx.toolhub.SHELL)做额外兼容(cmd/cmd_b64/root)。 + var action = btn.action ? String(btn.action) : ""; + if (!action) { this.toast("按钮#" + idx + " 缺少 action"); return; } + + var it2 = new android.content.Intent(action); + + // # 1) 兼容字段:extra / extras(两种都认) + var ex = null; + try { + if (btn.extras) ex = btn.extras; + else if (btn.extra) ex = btn.extra; + } catch (eEx0) { ex = null; } + + // # 2) 写入 extras(支持 number / boolean / string;其他类型一律转字符串) + if (ex) { + try { + var k; + for (k in ex) { + if (!ex.hasOwnProperty(k)) continue; + var v = ex[k]; + + if (typeof v === "number") it2.putExtra(String(k), Number(v)); + else if (typeof v === "boolean") it2.putExtra(String(k), !!v); + else it2.putExtra(String(k), String(v)); + } + } catch (eE) {} + } + + // # 3) 对"Shell 广播桥"做额外兼容: + // - 你可以在 cfg 里写 extra.cmd(明文)或 extra.cmd_b64(Base64) + // - 同时会补齐 root/from,并且把 cmd 明文也塞一份,方便外部 MVEL 直接读取 cmd 进行验证 + try { + var bridgeAction = String(this.config.SHELL_BRIDGE_ACTION || "shortx.toolhub.SHELL"); + if (action === bridgeAction) { + var kCmdB64 = String(this.config.SHELL_BRIDGE_EXTRA_CMD || "cmd_b64"); + var kFrom = String(this.config.SHELL_BRIDGE_EXTRA_FROM || "from"); + var kRoot = String(this.config.SHELL_BRIDGE_EXTRA_ROOT || "root"); + + var cmdPlain = ""; + var cmdB64 = ""; + + try { cmdB64 = String(it2.getStringExtra(kCmdB64) || ""); } catch (eC0) { cmdB64 = ""; } + try { cmdPlain = String(it2.getStringExtra("cmd") || ""); } catch (eC1) { cmdPlain = ""; } + + // # 有明文但没 b64:自动补 b64 + if ((!cmdB64 || cmdB64.length === 0) && cmdPlain && cmdPlain.length > 0) { + try { + var b64x = encodeBase64Utf8(cmdPlain); + if (b64x && b64x.length > 0) { + cmdB64 = b64x; + it2.putExtra(kCmdB64, String(cmdB64)); + } + } catch (eC2) {} + } + + // # 有 b64 但没明文:也补一份明文(便于外部规则验证;真正执行仍建议用 cmd_b64) + if ((!cmdPlain || cmdPlain.length === 0) && cmdB64 && cmdB64.length > 0) { + try { + var decoded = decodeBase64Utf8(cmdB64); + if (decoded && decoded.length > 0) { + cmdPlain = decoded; + it2.putExtra("cmd", String(cmdPlain)); + } + } catch (eC3) {} + } + + // # root:广播桥接收端默认以 root 执行,强制传递 true + try { + if (!it2.hasExtra(kRoot)) { + it2.putExtra(kRoot, true); + } + } catch (eR0) { + try { + it2.putExtra(kRoot, true); + } catch (eR1) {} + } + + + // # root 类型纠正:如果外部 cfg 用了字符串 "true"/"false",这里纠正为 boolean,避免外部 getBooleanExtra 读不到 + try { + if (it2.hasExtra(kRoot)) { + var bdl = it2.getExtras(); + if (bdl) { + var raw = bdl.get(kRoot); + if (raw != null) { + var rawStr = String(raw); + if (rawStr === "true" || rawStr === "false") { + it2.removeExtra(kRoot); + it2.putExtra(kRoot, rawStr === "true"); + } + } + } + } + } catch (eRB) {} + + // # from:标识来源(便于外部执行器做白名单/审计) + try { + if (!it2.hasExtra(kFrom)) it2.putExtra(kFrom, "ToolHub@system_server"); + } catch (eF0) { try { it2.putExtra(kFrom, "ToolHub@system_server"); } catch (eF1) {} } + + if (this.L) { + try { + this.L.i("broadcast(shell_bridge) action=" + action + " cmd_len=" + String(cmdPlain ? cmdPlain.length : 0) + + " cmd_b64_len=" + String(cmdB64 ? cmdB64.length : 0) + " root=" + String(it2.getBooleanExtra(kRoot, false))); + } catch (eLg) {} + } + } + } catch (eSB) {} + + try { context.sendBroadcast(it2); } catch (eB) { this.toast("广播失败"); safeLog(this.L, 'e', "broadcast fail action=" + action + " err=" + String(eB)); } + return; + } + + if (t === "shortcut") { + // 这段代码的主要内容/用途:仅使用 JavaScript(startActivityAsUser) 执行快捷方式,取消 Shell 与所有兜底,避免弹出主/分身选择器。 + // 说明: + // 1) 运行时只执行按钮字段 shortcutJsCode(由"选择快捷方式列表"点选自动生成,可手动微调) + // 2) 不再调用 am start,不再回退 LauncherApps.startShortcut(用户要求:取消 shell、取消兜底) + // 3) 目标 userId:launchUserId > userId(用于锁定主/分身) + + var spkg = btn.pkg ? String(btn.pkg) : ""; + var sid = btn.shortcutId ? String(btn.shortcutId) : ""; + var iu = (btn.intentUri != null) ? String(btn.intentUri) : ""; + + var uid = 0; + try { uid = (btn.userId != null) ? parseInt(String(btn.userId), 10) : 0; } catch(eUid0) { uid = 0; } + if (isNaN(uid)) uid = 0; + + // # 启动 userId 优先级:launchUserId > userId + try { + if (btn.launchUserId != null && String(btn.launchUserId).length > 0) { + var lu0 = parseInt(String(btn.launchUserId), 10); + if (!isNaN(lu0)) uid = lu0; + } + } catch(eLu0) {} + + if (!spkg) { this.toast("按钮#" + idx + " 缺少 pkg"); return; } + if (!sid) { this.toast("按钮#" + idx + " 缺少 shortcutId"); return; } + + // # JavaScript 执行:只执行 shortcutJsCode + var jsCode = (btn.shortcutJsCode != null) ? String(btn.shortcutJsCode) : ""; + if (!jsCode || jsCode.length === 0) { + this.toast("按钮#" + idx + " 未配置 JS 启动代码"); + return; + } + + try { + // # 提供少量上下文变量给脚本使用(可选) + // - __sc_intentUri: 当前按钮 intentUri + // - __sc_userId: 当前目标 userId(已合并 launchUserId) + var __sc_intentUri = iu; + var __sc_userId = uid; + + var rjs = eval(jsCode); + + // # 约定:返回值以 ok 开头视为成功;以 err 开头视为失败(失败也不兜底) + var sret = (rjs == null) ? "" : String(rjs); + if (sret.indexOf("ok") === 0) { + safeLog(this.L, 'i', "shortcut(js-only) ok pkg=" + spkg + " id=" + sid + " user=" + String(uid)); + return; + } + + safeLog(this.L, 'e', "shortcut(js-only) fail pkg=" + spkg + " id=" + sid + " user=" + String(uid) + " ret=" + sret); + this.toast("快捷方式 JS 启动失败: " + sret); + return; + } catch (eJsSc) { + safeLog(this.L, 'e', "shortcut(js-only) exception pkg=" + spkg + " id=" + sid + " err=" + eJsSc); + this.toast("快捷方式 JS 异常: " + String(eJsSc)); + return; + } +} + + this.toast("未知 type=" + t); + safeLog(this.L, 'w', "unknown btn type=" + t); + } catch (eBtn) { + try { this.toast("按钮执行异常"); } catch (e0) {} + safeLog(this.L, 'e', "execButtonAction crash idx=" + String(idx) + " err=" + String(eBtn)); + } + +}; + diff --git a/code/th_12_rebuild.js b/code/th_12_rebuild.js new file mode 100644 index 0000000..8fcd9ef --- /dev/null +++ b/code/th_12_rebuild.js @@ -0,0 +1,76 @@ +// @version 1.0.0 +// =======================【新增:改大小后安全重建悬浮球】====================== +FloatBallAppWM.prototype.rebuildBallForNewSize = function(keepPanels) { + if (this.state.closing) return false; + if (!this.state.wm) return false; + if (!this.state.addedBall) return false; + if (!this.state.ballRoot) return false; + if (!this.state.ballLp) return false; + if (this.state.dragging) return false; + + var oldSize = this.state.ballLp.height; + if (!oldSize || oldSize <= 0) oldSize = this.getDockInfo().ballSize; + + var oldX = this.state.ballLp.x; + var oldY = this.state.ballLp.y; + + var oldCenterX = oldX + Math.round(oldSize / 2); + var oldCenterY = oldY + Math.round(oldSize / 2); + + if (!keepPanels) { + this.hideAllPanels(); + } + this.cancelDockTimer(); + + this.state.docked = false; + this.state.dockSide = null; + + this.safeRemoveView(this.state.ballRoot, "ballRoot-rebuild"); + + this.state.ballRoot = null; + this.state.ballContent = null; + this.state.ballLp = null; + this.state.addedBall = false; + + this.createBallViews(); + + var di = this.getDockInfo(); + var newSize = di.ballSize; + + var newX = oldCenterX - Math.round(newSize / 2); + var newY = oldCenterY - Math.round(newSize / 2); + + var maxX = Math.max(0, this.state.screen.w - newSize); + var maxY = Math.max(0, this.state.screen.h - newSize); + + newX = this.clamp(newX, 0, maxX); + newY = this.clamp(newY, 0, maxY); + + var lp = new android.view.WindowManager.LayoutParams( + newSize, + newSize, + android.view.WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, + android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + android.graphics.PixelFormat.TRANSLUCENT + ); + + lp.gravity = android.view.Gravity.TOP | android.view.Gravity.START; + lp.x = newX; + lp.y = newY; + + try { + this.state.wm.addView(this.state.ballRoot, lp); + this.state.ballLp = lp; + this.state.addedBall = true; + } catch (eAdd) { + try { this.toast("重建悬浮球失败: " + String(eAdd)); } catch (eT) {} + safeLog(this.L, 'e', "rebuildBall add fail err=" + String(eAdd)); + return false; + } + + this.savePos(this.state.ballLp.x, this.state.ballLp.y); + this.touchActivity(); + safeLog(this.L, 'i', "rebuildBall ok size=" + String(newSize) + " x=" + String(newX) + " y=" + String(newY)); + return true; +}; + diff --git a/code/th_13_panel_ui.js b/code/th_13_panel_ui.js new file mode 100644 index 0000000..829ff0d --- /dev/null +++ b/code/th_13_panel_ui.js @@ -0,0 +1,375 @@ +// @version 1.0.0 +// =======================【设置面板:UI(右上角确认)】====================== +FloatBallAppWM.prototype.createSectionHeader = function(item, parent) { + var isDark = this.isDarkTheme(); + var C = this.ui.colors; + var color = C.primary; + + var h = new android.widget.TextView(context); + h.setText(String(item.name || "")); + h.setTextColor(color); + h.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 15); + h.setTypeface(null, android.graphics.Typeface.BOLD); + h.setPadding(this.dp(16), this.dp(24), this.dp(16), this.dp(8)); + parent.addView(h); +}; + +FloatBallAppWM.prototype.createSettingItemView = function(item, parent, needDivider) { + var isDark = this.isDarkTheme(); + var C = this.ui.colors; + var textColor = isDark ? C.textPriDark : C.textPriLight; + var secColor = isDark ? C.textSecDark : C.textSecLight; + var dividerColor = isDark ? C.dividerDark : C.dividerLight; + var primary = C.primary; + var switchOff = isDark ? (0xFF555555 | 0) : (0xFFCCCCCC | 0); + + // 增加内边距 + var padH = this.dp(16); + var padV = this.dp(16); + + // 分割线 (顶部) + if (needDivider) { + var line = new android.view.View(context); + var lineLp = new android.widget.LinearLayout.LayoutParams( + android.widget.LinearLayout.LayoutParams.MATCH_PARENT, + 1 // 1px + ); + lineLp.setMargins(padH, 0, padH, 0); + line.setLayoutParams(lineLp); + line.setBackgroundColor(dividerColor); + parent.addView(line); + } + + // 容器 + var row = new android.widget.LinearLayout(context); + row.setOrientation(android.widget.LinearLayout.VERTICAL); + // 增加点击波纹反馈 + try { + var outValue = new android.util.TypedValue(); + context.getTheme().resolveAttribute(android.R.attr.selectableItemBackground, outValue, true); + row.setBackgroundResource(outValue.resourceId); + } catch(e) {} + + row.setPadding(padH, padV, padH, padV); + + var self = this; + + if (item.type === "bool") { + // === 开关类型 === + row.setOrientation(android.widget.LinearLayout.HORIZONTAL); + row.setGravity(android.view.Gravity.CENTER_VERTICAL); + row.setClickable(true); + + var tv = new android.widget.TextView(context); + tv.setText(String(item.name)); + tv.setTextColor(textColor); + tv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 16); + var tvLp = new android.widget.LinearLayout.LayoutParams(0, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT); + tvLp.weight = 1; + tv.setLayoutParams(tvLp); + row.addView(tv); + + var sw = new android.widget.Switch(context); + try { sw.setTextOn(""); sw.setTextOff(""); } catch (eT) {} + + // 优化开关颜色 + try { + var states = [ + [android.R.attr.state_checked], + [-android.R.attr.state_checked] + ]; + var thumbColors = [primary, switchOff]; + var trackColors = [self.withAlpha(primary, 0.5), self.withAlpha(switchOff, 0.5)]; + + var thumbList = new android.content.res.ColorStateList(states, thumbColors); + var trackList = new android.content.res.ColorStateList(states, trackColors); + + sw.setThumbTintList(thumbList); + sw.setTrackTintList(trackList); + } catch(eColor) {} + + var cur = !!self.getPendingValue(item.key); + sw.setChecked(cur); + + // 监听器 + sw.setOnCheckedChangeListener(new android.widget.CompoundButton.OnCheckedChangeListener({ + onCheckedChanged: function(btn, checked) { + try { + self.touchActivity(); + self.setPendingValue(item.key, !!checked); + if (self.L) self.L.d("pending " + String(item.key) + "=" + String(!!checked)); + } catch (e0) {} + } + })); + + // 点击行也触发开关 + row.setOnClickListener(new android.view.View.OnClickListener({ + onClick: function(v) { + sw.setChecked(!sw.isChecked()); + } + })); + + row.addView(sw); + parent.addView(row); + + } else if (item.type === "int" || item.type === "float") { + // === 数值类型 (SeekBar) === + // 垂直布局:上面是 标题+数值,下面是 SeekBar + + // 第一行:标题 + 数值 + var line1 = new android.widget.LinearLayout(context); + line1.setOrientation(android.widget.LinearLayout.HORIZONTAL); + line1.setLayoutParams(new android.widget.LinearLayout.LayoutParams( + android.widget.LinearLayout.LayoutParams.MATCH_PARENT, + android.widget.LinearLayout.LayoutParams.WRAP_CONTENT + )); + + var tv = new android.widget.TextView(context); + tv.setText(String(item.name)); + tv.setTextColor(textColor); + tv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 16); + var tvLp = new android.widget.LinearLayout.LayoutParams(0, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT); + tvLp.weight = 1; + tv.setLayoutParams(tvLp); + line1.addView(tv); + + var valTv = new android.widget.TextView(context); + valTv.setTextColor(primary); + valTv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 14); + valTv.setTypeface(null, android.graphics.Typeface.BOLD); + line1.addView(valTv); + + row.addView(line1); + + // 第二行:SeekBar + var sb = new android.widget.SeekBar(context); + var sbLp = new android.widget.LinearLayout.LayoutParams( + android.widget.LinearLayout.LayoutParams.MATCH_PARENT, + android.widget.LinearLayout.LayoutParams.WRAP_CONTENT + ); + sbLp.topMargin = self.dp(16); // 增加间距 + sb.setLayoutParams(sbLp); + + // 优化 SeekBar 颜色 + try { + sb.getThumb().setTint(primary); + sb.getProgressDrawable().setTint(primary); + } catch(eColor) {} + + // 配置 SeekBar + var min = (item.min !== undefined) ? Number(item.min) : 0; + var max = (item.max !== undefined) ? Number(item.max) : 100; + var step = (item.step !== undefined) ? Number(item.step) : 1; + + var curV = Number(self.getPendingValue(item.key)); + if (isNaN(curV)) curV = min; + curV = self.clamp(curV, min, max); + + var maxP = Math.floor((max - min) / step); + if (maxP < 1) maxP = 1; + sb.setMax(maxP); + + var curP = Math.floor((curV - min) / step); + if (curP < 0) curP = 0; + if (curP > maxP) curP = maxP; + sb.setProgress(curP); + + function formatVal(v) { + if (item.type === "float") return String(Math.round(v * 1000) / 1000); + return String(Math.round(v)); + } + function computeValByProgress(p) { + var v = min + p * step; + v = self.clamp(v, min, max); + if (item.type === "int") v = Math.round(v); + if (item.type === "float") v = Math.round(v * 1000) / 1000; + return v; + } + + valTv.setText(formatVal(curV)); + + sb.setOnSeekBarChangeListener(new android.widget.SeekBar.OnSeekBarChangeListener({ + onProgressChanged: function(seek, progress, fromUser) { + try { + self.touchActivity(); + var v = computeValByProgress(progress); + valTv.setText(formatVal(v)); + if (fromUser) { + self.setPendingValue(item.key, v); + } + } catch (e1) {} + }, + onStartTrackingTouch: function() { try { self.touchActivity(); } catch (e2) {} }, + onStopTrackingTouch: function() { try { self.touchActivity(); } catch (e3) {} } + })); + + row.addView(sb); + parent.addView(row); + + } else if (item.type === "action") { + // === 动作按钮 === + row.setOrientation(android.widget.LinearLayout.HORIZONTAL); + row.setGravity(android.view.Gravity.CENTER_VERTICAL); + row.setClickable(true); + + var tv = new android.widget.TextView(context); + tv.setText(String(item.name)); + tv.setTextColor(textColor); + tv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 16); + var tvLp = new android.widget.LinearLayout.LayoutParams(0, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT); + tvLp.weight = 1; + tv.setLayoutParams(tvLp); + row.addView(tv); + + // 样式化文本按钮 + var btn = new android.widget.TextView(context); + btn.setText("打开"); + btn.setTextColor(primary); + btn.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 14); + btn.setTypeface(null, android.graphics.Typeface.BOLD); + btn.setGravity(android.view.Gravity.CENTER); + btn.setPadding(self.dp(16), self.dp(8), self.dp(16), self.dp(8)); + // 透明波纹背景 + btn.setBackground(self.ui.createTransparentRippleDrawable(primary, self.dp(16))); + + btn.setOnClickListener(new android.view.View.OnClickListener({ + onClick: function(v) { + try { + self.touchActivity(); + if (item.action === "open_btn_mgr") { + self.showPanelAvoidBall("btn_editor"); + } + } catch(e) {} + } + })); + row.addView(btn); + + // 行点击也触发 + row.setOnClickListener(new android.view.View.OnClickListener({ + onClick: function(v) { + try { + self.touchActivity(); + if (item.action === "open_btn_mgr") { + self.showPanelAvoidBall("btn_editor"); + } + } catch(e) {} + } + })); + + parent.addView(row); + } else if (item.type === "text") { + // === 文本输入 === + var tv = new android.widget.TextView(context); + tv.setText(String(item.name)); + tv.setTextColor(textColor); + tv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 16); + row.addView(tv); + + var et = new android.widget.EditText(context); + var curVal = self.getPendingValue(item.key); + if (curVal === undefined || curVal === null) curVal = ""; + et.setText(String(curVal)); + et.setTextColor(textColor); + et.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 14); + et.setBackground(self.ui.createRoundDrawable(isDark ? C.inputBgDark : C.inputBgLight, self.dp(6))); + et.setPadding(self.dp(8), self.dp(8), self.dp(8), self.dp(8)); + et.setSingleLine(true); + + var etLp = new android.widget.LinearLayout.LayoutParams( + android.widget.LinearLayout.LayoutParams.MATCH_PARENT, + android.widget.LinearLayout.LayoutParams.WRAP_CONTENT + ); + etLp.topMargin = self.dp(8); + et.setLayoutParams(etLp); + + // Explicitly request keyboard on click + et.setOnClickListener(new android.view.View.OnClickListener({ + onClick: function(v) { + try { + v.requestFocus(); + var imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(v, 0); + } catch(e) {} + } + })); + + et.addTextChangedListener(new android.text.TextWatcher({ + beforeTextChanged: function(s, start, count, after) {}, + onTextChanged: function(s, start, before, count) {}, + afterTextChanged: function(s) { + try { + self.touchActivity(); + self.setPendingValue(item.key, String(s)); + } catch (e) {} + } + })); + + row.addView(et); + parent.addView(row); + } else if (item.type === "single_choice") { + // === 单选类型 (RadioGroup) === + var tv = new android.widget.TextView(context); + tv.setText(String(item.name)); + tv.setTextColor(textColor); + tv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 16); + row.addView(tv); + + var rg = new android.widget.RadioGroup(context); + rg.setOrientation(android.widget.RadioGroup.VERTICAL); + var rgLp = new android.widget.LinearLayout.LayoutParams( + android.widget.LinearLayout.LayoutParams.MATCH_PARENT, + android.widget.LinearLayout.LayoutParams.WRAP_CONTENT + ); + rgLp.topMargin = self.dp(8); + rg.setLayoutParams(rgLp); + + var curVal = String(self.getPendingValue(item.key) || ""); + if (!curVal) curVal = "auto"; // default + + var options = item.options || []; + for (var i = 0; i < options.length; i++) { + (function(opt) { + var rb = new android.widget.RadioButton(context); + rb.setText(String(opt.label)); + rb.setTextColor(textColor); + rb.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 14); + // 颜色优化 + try { + var states = [[android.R.attr.state_checked], [-android.R.attr.state_checked]]; + var colors = [primary, secColor]; + rb.setButtonTintList(new android.content.res.ColorStateList(states, colors)); + } catch(eC){} + + rb.setId(android.view.View.generateViewId ? android.view.View.generateViewId() : i); + + // Check state + if (String(opt.value) === curVal) { + rb.setChecked(true); + } + + rb.setOnCheckedChangeListener(new android.widget.CompoundButton.OnCheckedChangeListener({ + onCheckedChanged: function(btn, checked) { + if (checked) { + try { + self.touchActivity(); + self.setPendingValue(item.key, String(opt.value)); + } catch(e){} + } + } + })); + rg.addView(rb); + })(options[i]); + } + + row.addView(rg); + parent.addView(row); + } else { + // 兜底文本 + var tv = new android.widget.TextView(context); + tv.setText(String(item.name)); + tv.setTextColor(secColor); + row.addView(tv); + parent.addView(row); + } +}; + diff --git a/code/th_3_panels.js b/code/th_14_panels.js similarity index 100% rename from code/th_3_panels.js rename to code/th_14_panels.js diff --git a/code/th_4_extra.js b/code/th_15_extra.js similarity index 100% rename from code/th_4_extra.js rename to code/th_15_extra.js diff --git a/code/th_5_entry.js b/code/th_16_entry.js similarity index 100% rename from code/th_5_entry.js rename to code/th_16_entry.js diff --git a/code/th_2_core.js b/code/th_2_core.js deleted file mode 100644 index f0e24bf..0000000 --- a/code/th_2_core.js +++ /dev/null @@ -1,4715 +0,0 @@ -// @version 1.0.0 -function FloatBallAppWM(logger) { - this.L = logger || null; - - // # 加载配置 - this.config = ConfigManager.loadSettings(); - this.currentPanelKey = "main"; - this.panels = { main: ConfigManager.loadButtons() }; - - // # 更新 Logger 配置(因为 Logger 初始化时是默认值) - if (this.L) this.L.updateConfig(this.config); - - this.state = { - receivers: [], // 存储广播接收器引用,用于 close 时注销 - wm: null, - dm: null, - density: 1.0, - - screen: { w: 0, h: 0 }, - lastRotation: -1, - lastMonitorTs: 0, - - ht: null, - h: null, - - addedBall: false, - addedPanel: false, - addedSettings: false, - addedViewer: false, - addedMask: false, - - ballRoot: null, - ballContent: null, - ballLp: null, - - panel: null, - panelLp: null, - - settingsPanel: null, - settingsPanelLp: null, - - viewerPanel: null, - viewerPanelLp: null, - - mask: null, - maskLp: null, - - loadedPos: null, - lastSaveTs: 0, - - dragging: false, - rawX: 0, - rawY: 0, - downX: 0, - downY: 0, - - docked: false, - dockSide: null, - - lastMotionTs: 0, - idleDockRunnable: null, - - longPressArmed: false, - longPressTriggered: false, - longPressRunnable: null, - - displayListener: null, - - // # 设置面板:临时编辑缓存 - pendingUserCfg: null, - pendingDirty: false, - - closing: false - }; - - // # 创建实例独立的 UI 工具对象,避免多实例共享颜色状态 - this.ui = {}; - var protoUi = FloatBallAppWM.prototype.ui; - for (var _uiKey in protoUi) { - this.ui[_uiKey] = protoUi[_uiKey]; - } - this.ui.colors = {}; - - // # 初始化莫奈动态配色(传入当前主题避免重复检测) - try { this.refreshMonetColors(this.isDarkTheme()); } catch (eM) {} -} - -// =======================【工具:dp/now/clamp】====================== -FloatBallAppWM.prototype.dp = function(v) { return Math.floor(Number(v) * this.state.density); }; -FloatBallAppWM.prototype.sp = function(v) { try { return Math.floor(Number(v) * context.getResources().getDisplayMetrics().scaledDensity); } catch (e) { return Math.floor(Number(v) * this.state.density); } }; -FloatBallAppWM.prototype.now = function() { return new Date().getTime(); }; -FloatBallAppWM.prototype.clamp = function(v, min, max) { if (v < min) return min; if (v > max) return max; return v; }; -FloatBallAppWM.prototype.rectIntersect = function(ax, ay, aw, ah, bx, by, bw, bh) { - return !(ax + aw <= bx || bx + bw <= ax || ay + ah <= by || by + bh <= ay); -}; - -// # 这段代码的主要内容/用途:安全地在 UI 线程执行代码(用于后台线程回调更新 UI) -FloatBallAppWM.prototype.runOnUiThreadSafe = function(fn) { - try { - if (!fn) return; - var self = this; - // 优先使用 Activity 的 runOnUiThread,否则使用 View.post - if (this.state && this.state.ballRoot) { - this.state.ballRoot.post(new java.lang.Runnable({ - run: function() { try { fn.call(self); } catch(e) {} } - })); - } else { - // 兜底:直接执行(如果已经在 UI 线程)或尝试使用 Handler - try { - var h = new android.os.Handler(android.os.Looper.getMainLooper()); - h.post(new java.lang.Runnable({ - run: function() { try { fn.call(self); } catch(e) {} } - })); - } catch(e) { - // 最后兜底:直接执行 - try { fn.call(self); } catch(e2) {} - } - } - } catch(e) {} -}; - -// # 这段代码的主要内容/用途:统一图标缓存(LRU),减少反复解码/反复走 PackageManager,降低卡顿与内存波动(不改变 UI 与功能) -// 优化后的图标缓存(带 Bitmap 回收,防止内存泄漏) -FloatBallAppWM.prototype._iconCache = { - map: {}, - keys: [], - max: 80, // 减少缓存数量,降低内存压力 - - get: function(key) { - var item = this.map[key]; - if (!item) return null; - // 移动到末尾(最近使用) - var idx = this.keys.indexOf(key); - if (idx > -1) { - this.keys.splice(idx, 1); - this.keys.push(key); - } - return item.dr; - }, - - put: function(key, drawable) { - // 清理旧的 - if (this.map[key]) { - this._remove(key); - } - - // 空间检查:超过 80% 时批量清理 20% - if (this.keys.length >= this.max * 0.8) { - var removeCount = Math.floor(this.max * 0.2); - for (var i = 0; i < removeCount && this.keys.length > 0; i++) { - var oldKey = this.keys.shift(); - this._remove(oldKey); - } - } - - this.keys.push(key); - this.map[key] = {dr: drawable, ts: Date.now()}; - }, - - _remove: function(key) { - var item = this.map[key]; - if (item && item.dr) { - // 关键:回收 Bitmap,防止内存泄漏 - try { - if (item.dr instanceof android.graphics.drawable.BitmapDrawable) { - var bmp = item.dr.getBitmap(); - if (bmp && !bmp.isRecycled()) bmp.recycle(); - } - } catch(e) {} - delete this.map[key]; - } - }, - - clear: function() { - for (var i = 0; i < this.keys.length; i++) { - this._remove(this.keys[i]); - } - this.keys = []; - } -}; - -// 兼容性封装(保持原有调用方式不变) -FloatBallAppWM.prototype._iconLruEnsure = function() {}; -FloatBallAppWM.prototype._iconLruGet = function(key) { - return this._iconCache.get(key); -}; - -FloatBallAppWM.prototype._iconLruPut = function(key, val) { - try { - this._iconLruEnsure(120); - var k = String(key || ""); - if (!k) return; - if (val == null) return; - - // # 若已存在,先移除旧顺序位置 - try { - var ord = this._iconLru.order; - for (var i = ord.length - 1; i >= 0; i--) { - if (ord[i] === k) { ord.splice(i, 1); break; } - } - ord.push(k); - } catch (eLru3) {} - - this._iconLru.map[k] = val; - - // # 超限清理:按最久未使用淘汰 - try { - var maxN = Math.max(20, Math.floor(Number(this._iconLru.max || 120))); - var ord2 = this._iconLru.order; - while (ord2.length > maxN) { - var oldK = ord2.shift(); - if (oldK != null) { - try { delete this._iconLru.map[oldK]; } catch (eDel) {} - } - } - } catch (eLru4) {} - } catch (eLru5) {} -}; - - -// =======================【工具:悬浮球图标(PNG 文件)】====================== -// # 这段代码的主要内容/用途:从指定路径加载透明 PNG 作为悬浮球图标;带"文件大小/像素上限"保护;按目标尺寸采样解码,避免 system_server OOM。 -FloatBallAppWM.prototype.loadBallIconDrawableFromFile = function(path, targetPx, maxBytes, maxPx) { - try { - var p = String(path || ""); - if (!p) return null; - - // # 统一 LRU 缓存:文件图标(按 path + targetPx + mtime + size 复用 Drawable,避免反复解码) - var f = new java.io.File(p); - if (!f.exists() || !f.isFile()) return null; - - var ckLru = null; - try { - ckLru = "file|" + p + "@" + String(targetPx == null ? "" : targetPx) + "@" + String(f.lastModified()) + "@" + String(f.length()); - var hitLru = this._iconLruGet(ckLru); - if (hitLru) return hitLru; - } catch (eLruF0) {} - - // # 文件大小限制(字节) - var limitBytes = Math.max(0, Math.floor(Number(maxBytes || 0))); - if (limitBytes > 0) { - try { - var sz = Number(f.length()); - if (sz > limitBytes) return null; - } catch (eSz) { return null; } - } - - // # 先只读尺寸(不解码) - var opt = new android.graphics.BitmapFactory.Options(); - opt.inJustDecodeBounds = true; - try { android.graphics.BitmapFactory.decodeFile(p, opt); } catch (eB0) { return null; } - - var w = Number(opt.outWidth || 0); - var h = Number(opt.outHeight || 0); - if (w <= 0 || h <= 0) return null; - - // # 像素边长上限(宽/高任意一边超限则拒绝) - var limitPx = Math.max(0, Math.floor(Number(maxPx || 0))); - if (limitPx > 0) { - if (w > limitPx || h > limitPx) return null; - } - - // # 计算采样倍率:按目标尺寸(一般为 iconSizePx)采样 - var tp = Math.max(1, Math.floor(Number(targetPx || 1))); - // # 允许解码到目标的 2 倍以内,减少锯齿又不浪费内存 - var desired = Math.max(tp * 2, tp); - - var sample = 1; - while ((w / sample) > desired || (h / sample) > desired) sample = sample * 2; - if (sample < 1) sample = 1; - - var opt2 = new android.graphics.BitmapFactory.Options(); - opt2.inJustDecodeBounds = false; - opt2.inSampleSize = sample; - opt2.inPreferredConfig = android.graphics.Bitmap.Config.ARGB_8888; - - var bmp = null; - try { bmp = android.graphics.BitmapFactory.decodeFile(p, opt2); } catch (eB1) { bmp = null; } - if (bmp == null) return null; - - var d = new android.graphics.drawable.BitmapDrawable(context.getResources(), bmp); - - // # 写入统一 LRU 缓存 - try { - if (ckLru) this._iconLruPut(ckLru, d); - } catch (eLruF1) {} - return d; - } catch (e0) { - return null; - } -}; - -// =======================【工具:屏幕/旋转】====================== -FloatBallAppWM.prototype.getScreenSizePx = function() { - var m = new android.util.DisplayMetrics(); - try { this.state.wm.getDefaultDisplay().getRealMetrics(m); } catch (e) { this.state.wm.getDefaultDisplay().getMetrics(m); } - return { w: m.widthPixels, h: m.heightPixels }; -}; -FloatBallAppWM.prototype.getRotation = function() { try { return this.state.wm.getDefaultDisplay().getRotation(); } catch (e) {} return -1; }; - -// =======================【工具:alpha/toast/vibrate】====================== -FloatBallAppWM.prototype.withAlpha = function(colorInt, alpha01) { var a = Math.floor(Number(alpha01) * 255); return (colorInt & 0x00FFFFFF) | (a << 24); }; -FloatBallAppWM.prototype.toast = function(msg) { try { android.widget.Toast.makeText(context, String(msg), 0).show(); } catch (e) {} }; -FloatBallAppWM.prototype.vibrateOnce = function(ms) { - if (!this.config.LONG_PRESS_HAPTIC_ENABLE) return; - try { - var vib = context.getSystemService(android.content.Context.VIBRATOR_SERVICE); - if (!vib) return; - var dur = Math.max(1, Math.floor(ms)); - if (android.os.Build.VERSION.SDK_INT >= 26) { - var ve = android.os.VibrationEffect.createOneShot(dur, android.os.VibrationEffect.DEFAULT_AMPLITUDE); - vib.vibrate(ve); - } else { - vib.vibrate(dur); - } - } catch (e) {} -}; - -// =======================【工具:UI样式辅助】====================== -FloatBallAppWM.prototype.ui = { - // 基础颜色 - colors: { - // 以下为默认回退值,实例化时会被 refreshMonetColors() 覆盖为系统莫奈色 - primary: android.graphics.Color.parseColor("#005BC0"), - primaryDark: android.graphics.Color.parseColor("#041E49"), - accent: android.graphics.Color.parseColor("#00639B"), - danger: android.graphics.Color.parseColor("#BA1A1A"), - success: android.graphics.Color.parseColor("#15803d"), - warning: android.graphics.Color.parseColor("#b45309"), - - bgLight: android.graphics.Color.parseColor("#F8F9FA"), - bgDark: android.graphics.Color.parseColor("#131314"), - - cardLight: android.graphics.Color.parseColor("#E1E3E1"), - cardDark: android.graphics.Color.parseColor("#49454F"), - - textPriLight: android.graphics.Color.parseColor("#1F1F1F"), - textPriDark: android.graphics.Color.parseColor("#E3E3E3"), - - textSecLight: android.graphics.Color.parseColor("#5F6368"), - textSecDark: android.graphics.Color.parseColor("#C4C7C5"), - - dividerLight: android.graphics.Color.parseColor("#747775"), - dividerDark: android.graphics.Color.parseColor("#8E918F"), - - inputBgLight: android.graphics.Color.parseColor("#F8F9FA"), - inputBgDark: android.graphics.Color.parseColor("#131314"), - - // Monet 扩展字段(供面板直接使用) - _monetSurface: android.graphics.Color.parseColor("#F8F9FA"), - _monetOnSurface: android.graphics.Color.parseColor("#1F1F1F"), - _monetOutline: android.graphics.Color.parseColor("#747775"), - _monetOnPrimary: android.graphics.Color.parseColor("#FFFFFF"), - _monetPrimaryContainer: android.graphics.Color.parseColor("#D3E3FD"), - _monetOnPrimaryContainer: android.graphics.Color.parseColor("#041E49"), - _monetSecondary: android.graphics.Color.parseColor("#00639B"), - _monetTertiary: android.graphics.Color.parseColor("#5C5891") - }, - - // 创建圆角背景 (Solid) - createRoundDrawable: function(color, radiusPx) { - var d = new android.graphics.drawable.GradientDrawable(); - d.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE); - d.setColor(color); - d.setCornerRadius(radiusPx); - return d; - }, - - // 创建圆角描边背景 (Stroke) - createStrokeDrawable: function(fillColor, strokeColor, strokeWidthPx, radiusPx) { - var d = new android.graphics.drawable.GradientDrawable(); - d.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE); - if (fillColor) d.setColor(fillColor); - d.setCornerRadius(radiusPx); - d.setStroke(strokeWidthPx, strokeColor); - return d; - }, - - // 创建按压反馈背景 (StateList) - createRippleDrawable: function(normalColor, pressedColor, radiusPx) { - var sd = new android.graphics.drawable.StateListDrawable(); - var p = this.createRoundDrawable(pressedColor, radiusPx); - var n = this.createRoundDrawable(normalColor, radiusPx); - sd.addState([android.R.attr.state_pressed], p); - sd.addState([], n); - return sd; - }, - - // 创建纯色按压反馈 (StateList) - 用于透明背景按钮 - createTransparentRippleDrawable: function(pressedColor, radiusPx) { - var sd = new android.graphics.drawable.StateListDrawable(); - var p = this.createRoundDrawable(pressedColor, radiusPx); - var n = new android.graphics.drawable.ColorDrawable(android.graphics.Color.TRANSPARENT); - sd.addState([android.R.attr.state_pressed], p); - sd.addState([], n); - return sd; - }, - - // 辅助:创建扁平按钮 - createFlatButton: function(app, txt, txtColor, onClick) { - var btn = new android.widget.TextView(context); - btn.setText(txt); - btn.setTextColor(txtColor); - btn.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 14); - btn.setPadding(app.dp(12), app.dp(6), app.dp(12), app.dp(6)); - btn.setGravity(android.view.Gravity.CENTER); - // use divider color or just low alpha text color for ripple - var rippleColor = app.withAlpha ? app.withAlpha(txtColor, 0.1) : 0x22888888; - btn.setBackground(this.createTransparentRippleDrawable(rippleColor, app.dp(8))); - btn.setOnClickListener(new android.view.View.OnClickListener({ - onClick: function(v) { app.touchActivity(); app.guardClick("ui_btn", INTERACTION_CONSTANTS.CLICK_COOLDOWN_MS, function(){ if(onClick) onClick(v); }); } - })); - return btn; - }, - - // 辅助:创建实心按钮 - createSolidButton: function(app, txt, bgColor, txtColor, onClick) { - var btn = new android.widget.TextView(context); - btn.setText(txt); - btn.setTextColor(txtColor); - btn.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 14); - btn.setTypeface(null, android.graphics.Typeface.BOLD); - btn.setPadding(app.dp(16), app.dp(8), app.dp(16), app.dp(8)); - btn.setGravity(android.view.Gravity.CENTER); - var pressedColor = app.withAlpha ? app.withAlpha(bgColor, 0.8) : bgColor; - btn.setBackground(this.createRippleDrawable(bgColor, pressedColor, app.dp(24))); - try { btn.setElevation(app.dp(2)); } catch(e){} - btn.setOnClickListener(new android.view.View.OnClickListener({ - onClick: function(v) { app.touchActivity(); app.guardClick("ui_btn", INTERACTION_CONSTANTS.CLICK_COOLDOWN_MS, function(){ if(onClick) onClick(v); }); } - })); - return btn; - }, - - // 辅助:创建带标签的输入组(支持粘贴) - createInputGroup: function(app, label, initVal, isMultiLine, hint) { - var box = new android.widget.LinearLayout(context); - box.setOrientation(android.widget.LinearLayout.VERTICAL); - box.setPadding(0, 0, 0, app.dp(12)); - - var topLine = new android.widget.LinearLayout(context); - topLine.setOrientation(android.widget.LinearLayout.HORIZONTAL); - topLine.setGravity(android.view.Gravity.CENTER_VERTICAL); - box.addView(topLine); - - var lb = new android.widget.TextView(context); - lb.setText(label); - lb.setTextColor(this.colors.textSecLight); // 默认用浅色主题副文本色,外部可覆盖 - try { if (app.isDarkTheme && app.isDarkTheme()) lb.setTextColor(this.colors.textSecDark); } catch(e){} - lb.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 12); - var lpLb = new android.widget.LinearLayout.LayoutParams(0, -2); - lpLb.weight = 1; - topLine.addView(lb, lpLb); - - var et = new android.widget.EditText(context); - et.setText(initVal ? String(initVal) : ""); - et.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 14); - et.setTextColor(this.colors.textPriLight); - try { if (app.isDarkTheme && app.isDarkTheme()) et.setTextColor(this.colors.textPriDark); } catch(e){} - - // 输入框背景优化 - var strokeColor = this.colors.dividerLight; - try { if (app.isDarkTheme && app.isDarkTheme()) strokeColor = this.colors.dividerDark; } catch(e){} - - var bg = this.createStrokeDrawable(this.colors.inputBgLight, strokeColor, app.dp(1), app.dp(8)); - try { if (app.isDarkTheme && app.isDarkTheme()) bg = this.createStrokeDrawable(this.colors.inputBgDark, strokeColor, app.dp(1), app.dp(8)); } catch(e){} - et.setBackground(bg); - - et.setPadding(app.dp(8), app.dp(8), app.dp(8), app.dp(8)); - if (hint) et.setHint(hint); - - if (isMultiLine) { - et.setSingleLine(false); - et.setMaxLines(4); - } else { - et.setSingleLine(true); - } - - // 粘贴功能 - var pasteBtn = this.createFlatButton(app, "粘贴", this.colors.accent, function() { - try { - var cb = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE); - if (cb.hasPrimaryClip()) { - var item = cb.getPrimaryClip().getItemAt(0); - if (item) { - var txt = item.getText(); - if (txt) { - var st = String(txt); - var old = String(et.getText()); - if (old.length > 0) et.setText(old + st); - else et.setText(st); - et.setSelection(et.getText().length()); - } - } - } else { - app.toast("剪贴板为空"); - } - } catch (eP) { - app.toast("粘贴失败: " + eP); - } - }); - // 调整粘贴按钮样式使其更紧凑 - pasteBtn.setPadding(app.dp(8), app.dp(2), app.dp(8), app.dp(2)); - pasteBtn.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 12); - topLine.addView(pasteBtn); - - box.addView(et); - - // 错误提示 - var errTv = new android.widget.TextView(context); - errTv.setTextColor(this.colors.danger); - errTv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 10); - errTv.setVisibility(android.view.View.GONE); - box.addView(errTv); - - var self = this; - return { - view: box, - input: et, - getValue: function() { return String(et.getText()); }, - setError: function(msg) { - if (msg) { - errTv.setText(msg); - errTv.setVisibility(android.view.View.VISIBLE); - et.setBackground(self.createRoundDrawable(app.withAlpha(self.colors.danger, 0.2), app.dp(4))); - } else { - errTv.setVisibility(android.view.View.GONE); - var strokeColor = self.colors.dividerLight; - try { if (app.isDarkTheme && app.isDarkTheme()) strokeColor = self.colors.dividerDark; } catch(e){} - - var normalBg = self.createStrokeDrawable(self.colors.inputBgLight, strokeColor, app.dp(1), app.dp(8)); - try { if (app.isDarkTheme && app.isDarkTheme()) normalBg = self.createStrokeDrawable(self.colors.inputBgDark, strokeColor, app.dp(1), app.dp(8)); } catch(e){} - et.setBackground(normalBg); - } - } - }; - }, - - // 辅助:创建标准面板容器 - createStyledPanel: function(app, paddingDp) { - var isDark = app.isDarkTheme(); - var bgColor = isDark ? this.colors.bgDark : this.colors.bgLight; - - var panel = new android.widget.LinearLayout(context); - panel.setOrientation(android.widget.LinearLayout.VERTICAL); - - var bgDr = new android.graphics.drawable.GradientDrawable(); - bgDr.setColor(bgColor); - bgDr.setCornerRadius(app.dp(16)); - panel.setBackground(bgDr); - try { panel.setElevation(app.dp(8)); } catch(e){} - - var p = (paddingDp !== undefined) ? app.dp(paddingDp) : app.dp(16); - panel.setPadding(p, p, p, p); - - return panel; - }, - - // 辅助:创建标准标题栏容器 - createStyledHeader: function(app, paddingBottomDp) { - var header = new android.widget.LinearLayout(context); - header.setOrientation(android.widget.LinearLayout.HORIZONTAL); - header.setGravity(android.view.Gravity.CENTER_VERTICAL); - var pb = (paddingBottomDp !== undefined) ? app.dp(paddingBottomDp) : app.dp(8); - header.setPadding(0, 0, 0, pb); - return header; - }, - - // 辅助:创建占位符(撑开空间) - createSpacer: function(app) { - var dummy = new android.view.View(context); - var dummyLp = new android.widget.LinearLayout.LayoutParams(0, 1); - dummyLp.weight = 1; - dummy.setLayoutParams(dummyLp); - return dummy; - } -}; - -// =======================【工具:主题/类莫奈颜色】====================== -// # 主题调试日志工具(仅打印,不改变逻辑) -function _th_hex(c) { - try { - var Integer = Packages.java.lang.Integer; - var s = Integer.toHexString(c); - while (s.length < 8) s = "0" + s; - return "0x" + s; - } catch (e) { - try { return String(c); } catch (e2) { return ""; } - } -} - -function _th_argb(c) { - try { - var Color = Packages.android.graphics.Color; - var ci = Math.floor(Number(c)); - if (isNaN(ci)) return "NaN"; - return "a=" + Color.alpha(ci) + " r=" + Color.red(ci) + " g=" + Color.green(ci) + " b=" + Color.blue(ci); - } catch (e) { - return "argb_err"; - } -} - -function _th_log(L, level, msg) { - try { - if (!L) return; - if (level === "e" && L.e) { L.e(msg); return; } - if (level === "w" && L.w) { L.w(msg); return; } - if (level === "i" && L.i) { L.i(msg); return; } - if (L.d) { L.d(msg); return; } - } catch (e) {} -} - -// =======================【莫奈动态取色工具】====================== -var MonetColorProvider = { - _cacheLight: null, - _cacheDark: null, - - _getResColor: function(resName, fallbackHex) { - try { - var res = android.content.res.Resources.getSystem(); - var id = res.getIdentifier(resName, "color", "android"); - if (id > 0) return res.getColor(id, null); - } catch (e) {} - try { return android.graphics.Color.parseColor(fallbackHex); } catch (e) { return 0; } - }, - - _getFallbackLight: function() { - return { - // 使用更接近 AOSP Monet 标准值的 fallback,日间 primary 更饱和、对比度更高 - primary: android.graphics.Color.parseColor("#005BC0"), - onPrimary: android.graphics.Color.parseColor("#FFFFFF"), - primaryContainer: android.graphics.Color.parseColor("#D3E3FD"), - onPrimaryContainer: android.graphics.Color.parseColor("#041E49"), - secondary: android.graphics.Color.parseColor("#00639B"), - secondaryContainer: android.graphics.Color.parseColor("#C2E4FF"), - tertiary: android.graphics.Color.parseColor("#5C5891"), - surface: android.graphics.Color.parseColor("#F8F9FA"), - onSurface: android.graphics.Color.parseColor("#1F1F1F"), - surfaceVariant: android.graphics.Color.parseColor("#E1E3E1"), - onSurfaceVariant: android.graphics.Color.parseColor("#5F6368"), - outline: android.graphics.Color.parseColor("#747775"), - outlineVariant: android.graphics.Color.parseColor("#C4C7C5"), - error: android.graphics.Color.parseColor("#BA1A1A"), - errorContainer: android.graphics.Color.parseColor("#F9DEDC"), - onErrorContainer: android.graphics.Color.parseColor("#410E0B") - }; - }, - - _getFallbackDark: function() { - return { - primary: android.graphics.Color.parseColor("#A8C7FA"), - onPrimary: android.graphics.Color.parseColor("#062E6F"), - primaryContainer: android.graphics.Color.parseColor("#0842A0"), - onPrimaryContainer: android.graphics.Color.parseColor("#D3E3FD"), - secondary: android.graphics.Color.parseColor("#7FCFFF"), - secondaryContainer: android.graphics.Color.parseColor("#004A77"), - tertiary: android.graphics.Color.parseColor("#C2C5DD"), - surface: android.graphics.Color.parseColor("#131314"), - onSurface: android.graphics.Color.parseColor("#E3E3E3"), - surfaceVariant: android.graphics.Color.parseColor("#49454F"), - onSurfaceVariant: android.graphics.Color.parseColor("#C4C7C5"), - outline: android.graphics.Color.parseColor("#8E918F"), - outlineVariant: android.graphics.Color.parseColor("#49454F"), - error: android.graphics.Color.parseColor("#F2B8B5"), - errorContainer: android.graphics.Color.parseColor("#8C1D18"), - onErrorContainer: android.graphics.Color.parseColor("#F9DEDC") - }; - }, - - _loadMonet: function(isDark) { - var c = isDark ? this._getFallbackDark() : this._getFallbackLight(); - try { - var res = android.content.res.Resources.getSystem(); - var map = isDark ? { - primary: "system_accent1_200", - onPrimary: "system_accent1_800", - primaryContainer: "system_accent1_700", - onPrimaryContainer: "system_accent1_100", - secondary: "system_accent2_200", - secondaryContainer: "system_accent2_700", - tertiary: "system_accent3_200", - surface: "system_neutral1_900", - onSurface: "system_neutral1_100", - surfaceVariant: "system_neutral2_700", - onSurfaceVariant: "system_neutral2_200", - outline: "system_neutral2_400", - outlineVariant: "system_neutral2_700", - error: "system_accent3_200", - errorContainer: "system_accent3_800", - onErrorContainer: "system_accent3_100" - } : { - primary: "system_accent1_600", - onPrimary: "system_accent1_0", - primaryContainer: "system_accent1_100", - onPrimaryContainer: "system_accent1_900", - secondary: "system_accent2_600", - secondaryContainer: "system_accent2_100", - tertiary: "system_accent3_600", - surface: "system_neutral1_10", - onSurface: "system_neutral1_900", - surfaceVariant: "system_neutral2_100", - onSurfaceVariant: "system_neutral2_700", - outline: "system_neutral2_500", - outlineVariant: "system_neutral2_200", - error: "system_accent3_600", - errorContainer: "system_accent3_100", - onErrorContainer: "system_accent3_900" - }; - for (var name in map) { - try { - var id = res.getIdentifier(map[name], "color", "android"); - if (id > 0) c[name] = res.getColor(id, null); - } catch (e) {} - } - } catch (e) {} - return c; - }, - - getColors: function(isDark) { - if (isDark) { - if (!this._cacheDark) this._cacheDark = this._loadMonet(true); - return this._cacheDark; - } else { - if (!this._cacheLight) this._cacheLight = this._loadMonet(false); - return this._cacheLight; - } - }, - - invalidate: function() { - this._cacheLight = null; - this._cacheDark = null; - } -}; - -// =======================【兼容兜底:themeTextInt/themeBgInt】====================== -// 这段代码的主要内容/用途:兼容旧代码或异步回调里误引用 themeTextInt/themeBgInt 导致 ReferenceError 崩溃。 -// 说明:当前版本文字色应通过 getPanelTextColorInt(bgInt) 获取;这里仅作为"兜底全局变量",避免回调炸线程。 - -// 声明全局变量(避免 ReferenceError) -var themeBgInt = 0; -var themeTextInt = 0; - -// =======================【API 兼容性辅助函数】====================== -// 这段代码的主要内容/用途:处理 Android API 级别差异,避免在旧版本上崩溃 - -/** - * 安全创建 UserHandle(兼容 API 17 以下) - * @param {number} userId - 用户 ID - * @returns {android.os.UserHandle} UserHandle 对象或 null - */ -function createUserHandle(userId) { - try { - // UserHandle.of 在 API 17+ 可用 - if (android.os.Build.VERSION.SDK_INT >= 17) { - return android.os.UserHandle.of(userId); - } - // API 17 以下返回当前用户句柄 - return android.os.Process.myUserHandle(); - } catch (e) { - return null; - } -} - -/** - * 安全启动 Activity 跨用户(兼容 API 17 以下) - * @param {Context} ctx - Context - * @param {Intent} intent - Intent - * @param {number} userId - 用户 ID(API 17+ 有效) - */ -function startActivityAsUserSafe(ctx, intent, userId) { - try { - if (android.os.Build.VERSION.SDK_INT >= 17 && userId !== 0) { - var uh = android.os.UserHandle.of(userId); - ctx.startActivityAsUser(intent, uh); - } else { - ctx.startActivity(intent); - } - } catch (e) { - // 降级到普通启动 - try { - ctx.startActivity(intent); - } catch (e2) {} - } -} - -FloatBallAppWM.prototype.isDarkTheme = function() { - // 0) 优先检查用户强制设置 (0=跟随系统, 1=白天, 2=黑夜) - var mode = 0; - try { mode = Math.floor(Number(this.config.THEME_MODE || 0)); } catch(e){} - - if (mode === 2) return true; - if (mode === 1) return false; - - // mode === 0 (or others) -> Fallback to system detection - // 这段代码的主要内容/用途:更稳健地判断当前是否处于"夜间/暗色"模式(并打印调试日志)。 - // 说明:system_server 场景下 Configuration.uiMode 偶发不一致,因此再用 UiModeManager 兜底交叉验证。 - var result = false; - var from = "unknown"; - - try { - // # 1) 优先用 Configuration(最快) - var uiMode = context.getResources().getConfiguration().uiMode; - var nightMask = (uiMode & android.content.res.Configuration.UI_MODE_NIGHT_MASK); - if (nightMask === android.content.res.Configuration.UI_MODE_NIGHT_YES) { result = true; from = "Configuration(UI_MODE_NIGHT_YES)"; } - else if (nightMask === android.content.res.Configuration.UI_MODE_NIGHT_NO) { result = false; from = "Configuration(UI_MODE_NIGHT_NO)"; } - } catch (e1) {} - - if (from === "unknown") { - try { - // # 2) 再用 UiModeManager(更"系统态") - var um = context.getSystemService(android.content.Context.UI_MODE_SERVICE); - if (um) { - var nm = um.getNightMode(); - if (nm === android.app.UiModeManager.MODE_NIGHT_YES) { result = true; from = "UiModeManager(MODE_NIGHT_YES)"; } - else if (nm === android.app.UiModeManager.MODE_NIGHT_NO) { result = false; from = "UiModeManager(MODE_NIGHT_NO)"; } - else { from = "UiModeManager(mode=" + String(nm) + ")"; } - } - } catch (e2) {} - } - - // # 3) 实在判断不了,就按"非暗色"处理,避免自动主题背景黑成一片 - if (from === "unknown") { result = false; from = "fallback(false)"; } - - // 仅在状态改变时打印日志,避免刷屏 - var logKey = String(result) + "|" + from + "|" + mode; - if (this._lastDarkThemeLog !== logKey) { - this._lastDarkThemeLog = logKey; - try { _th_log(this.L, "d", "[theme] isDarkTheme=" + String(result) + " via=" + from + " mode=" + mode); } catch (e3) {} - } - - // # 主题切换时刷新莫奈配色(传入 result 避免递归) - // 注:构造函数中会初始化,这里只在构造完成后的切换时触发 - if (this._lastDarkResult !== undefined && this._lastDarkResult !== result) { - this._lastDarkResult = result; - try { this.refreshMonetColors(result); } catch (eM) {} - } else if (this._lastDarkResult === undefined) { - this._lastDarkResult = result; - } - - return result; -}; - -FloatBallAppWM.prototype.refreshMonetColors = function(forceDark) { - try { - var isDark = (forceDark !== undefined) ? forceDark : this.isDarkTheme(); - var m = MonetColorProvider.getColors(isDark); - var ml = MonetColorProvider.getColors(false); - var md = MonetColorProvider.getColors(true); - var c = this.ui.colors; - - // 浅色配色 - c.bgLight = ml.surface; - c.cardLight = ml.surfaceVariant; - c.textPriLight = ml.onSurface; - c.textSecLight = ml.onSurfaceVariant; - c.dividerLight = ml.outline; - c.inputBgLight = ml.surface; - - // 深色配色 - c.bgDark = md.surface; - c.cardDark = md.surfaceVariant; - c.textPriDark = md.onSurface; - c.textSecDark = md.onSurfaceVariant; - c.dividerDark = md.outline; - c.inputBgDark = md.surface; - - // 当前主题配色(随主题切换) - c.primary = m.primary; - // primaryDark 修正为 onPrimaryContainer:日间为深蓝(#041E49),夜间为浅蓝(#D3E3FD),符合"深色变体"语义 - c.primaryDark = m.onPrimaryContainer; - c.accent = m.secondary; - c.danger = m.error; - // success/warning 优化对比度:日间更深确保可见,夜间保持适度亮度不刺眼 - c.success = isDark ? android.graphics.Color.parseColor("#4ade80") : android.graphics.Color.parseColor("#15803d"); - c.warning = isDark ? android.graphics.Color.parseColor("#fbbf24") : android.graphics.Color.parseColor("#b45309"); - - // 扩展:完整 Monet 语义字段(供面板方法直接使用) - c._monetSurface = m.surface; - c._monetOnSurface = m.onSurface; - c._monetOutline = m.outline; - c._monetOnPrimary = m.onPrimary; - c._monetPrimaryContainer = m.primaryContainer; - c._monetOnPrimaryContainer = m.onPrimaryContainer; - c._monetSecondary = m.secondary; - c._monetTertiary = m.tertiary; - - try { _th_log(this.L, "d", "[monet] refreshed isDark=" + isDark + " primary=" + _th_hex(c.primary) + " primaryDark=" + _th_hex(c.primaryDark) + " accent=" + _th_hex(c.accent)); } catch (e) {} - } catch (e) { - try { _th_log(this.L, "e", "[monet] refresh err=" + String(e)); } catch (e2) {} - } -}; - -FloatBallAppWM.prototype.getMonetAccentForBall = function() { - // 这段代码的主要内容/用途:尝试读取系统动态强调色(Monet/accent);失败则使用兜底颜色;并打印命中信息。 - // 优化点: - // 1) 日间使用 500/600 档(更深、对比度更高),夜间使用 400/300 档(柔和、不刺眼) - // 2) 移除 system_neutral1_* 中性灰色(不是强调色) - var res = context.getResources(); - var dark = this.isDarkTheme(); - var names = dark - ? ["system_accent1_400", "system_accent1_300", "system_accent2_400"] - : ["system_accent1_500", "system_accent1_600", "system_accent2_500"]; - - var i; - for (i = 0; i < names.length; i++) { - try { - var id = res.getIdentifier(names[i], "color", "android"); - if (id > 0) { - var c = res.getColor(id, null); - var logKey = "hit|" + names[i] + "|" + c; - if (this._lastAccentLog !== logKey) { - this._lastAccentLog = logKey; - try { _th_log(this.L, "d", "[theme] hit accent=" + names[i] + " id=" + String(id) + " c=" + _th_hex(c) + " " + _th_argb(c)); } catch (eL0) {} - } - return c; - } - } catch (e) { - try { _th_log(this.L, "w", "[theme] err accent=" + names[i] + " e=" + String(e)); } catch (eL2) {} - } - } - - var fbHex = dark - ? (this.config.BALL_FALLBACK_DARK || CONST_BALL_FALLBACK_DARK) - : (this.config.BALL_FALLBACK_LIGHT || CONST_BALL_FALLBACK_LIGHT); - var fb = android.graphics.Color.parseColor(fbHex); - var logKeyFb = "miss_all|" + fb; - if (this._lastAccentLog !== logKeyFb) { - this._lastAccentLog = logKeyFb; - try { _th_log(this.L, "w", "[theme] accent miss all, fallback=" + _th_hex(fb) + " " + _th_argb(fb)); } catch (eL3) {} - } - return fb; -}; - -FloatBallAppWM.prototype.updateBallContentBackground = function(contentView) { - try { - var ballColor = this.getMonetAccentForBall(); - var dark = this.isDarkTheme(); - var alpha01 = dark ? this.config.BALL_RIPPLE_ALPHA_DARK : this.config.BALL_RIPPLE_ALPHA_LIGHT; - var rippleColor = this.withAlpha(ballColor, alpha01); - - // # 自定义 PNG/APP 模式下:背景透明 - var fillColor = ballColor; - var _usedKind = "none"; - try { _usedKind = this.state.usedIconKind || "none"; } catch(eK){} - - try { - var _pngModeBg = Number(this.config.BALL_PNG_MODE || 0); - if ((_pngModeBg === 1 && _usedKind === "file") || _usedKind === "app") { - fillColor = android.graphics.Color.TRANSPARENT; - } - } catch (eBg) {} - - var content = new android.graphics.drawable.GradientDrawable(); - content.setShape(android.graphics.drawable.GradientDrawable.OVAL); - content.setColor(fillColor); - - // # 描边:根据球体颜色亮度自动选择白/黑描边,确保任何背景下都可见 - if (_usedKind !== "file" && _usedKind !== "app") { - try { - var Color = android.graphics.Color; - var lum = (Color.red(fillColor)*0.299 + Color.green(fillColor)*0.587 + Color.blue(fillColor)*0.114) / 255.0; - var strokeInt = lum > 0.55 - ? Color.parseColor("#33000000") // 浅球用半透明黑边 - : Color.parseColor("#55FFFFFF"); // 深球用半透明白边 - content.setStroke(this.dp(1), strokeInt); - } catch(eS){} - } - - var mask = new android.graphics.drawable.GradientDrawable(); - mask.setShape(android.graphics.drawable.GradientDrawable.OVAL); - mask.setColor(android.graphics.Color.WHITE); - - contentView.setBackground(new android.graphics.drawable.RippleDrawable( - android.content.res.ColorStateList.valueOf(rippleColor), - content, - mask - )); - } catch (e) {} -}; - - -FloatBallAppWM.prototype.safeParseColor = function(hex, fallbackInt) { - // 这段代码的主要内容/用途:安全解析 #RRGGBB/#AARRGGBB,解析失败直接回退,避免 system_server 抛异常。 - try { return android.graphics.Color.parseColor(String(hex)); } catch (e) { return fallbackInt; } -}; - - - -FloatBallAppWM.prototype.getPanelBgColorInt = function() { - // 这段代码的主要内容/用途:配合"白天/夜晚"两档主题,返回统一的背景颜色(不再依赖自动亮度推断)。 - var isDark = this.isDarkTheme(); - - var dayBgHex = (this.config.THEME_DAY_BG_HEX != null) ? String(this.config.THEME_DAY_BG_HEX) : null; - var nightBgHex = (this.config.THEME_NIGHT_BG_HEX != null) ? String(this.config.THEME_NIGHT_BG_HEX) : null; - - // # 兼容旧版默认配色:若仍为旧默认值,自动回退到莫奈色 - if (dayBgHex === "#FAF4E3") dayBgHex = null; - if (nightBgHex === "#191928") nightBgHex = null; - - // # 未配置时使用莫奈 surface 色作为回退 - var dayFallback = this.ui.colors.bgLight || android.graphics.Color.parseColor("#F8F9FA"); - var nightFallback = this.ui.colors.bgDark || android.graphics.Color.parseColor("#131314"); - - var base = isDark - ? (nightBgHex ? this.safeParseColor(nightBgHex, nightFallback) : nightFallback) - : (dayBgHex ? this.safeParseColor(dayBgHex, dayFallback) : dayFallback); - - // # 继承原配置:面板背景透明度(0~1) - var a = 1.0; - try { a = Number(this.config.PANEL_BG_ALPHA); } catch (e1) { a = 0.85; } - if (!(a >= 0.0 && a <= 1.0)) a = 0.85; - - var out = this.withAlpha(base, a); - - try { _th_log(this.L, "d", "[t]bg isDark=" + isDark + " o=" + _th_hex(out)); } catch (e2) {} - - return out; -}; - -FloatBallAppWM.prototype.getPanelTextColorInt = function(bgInt) { - // 这段代码的主要内容/用途:配合"白天/夜晚"两档主题,返回统一的文字颜色(不再依赖自动亮度推断)。 - var isDark = this.isDarkTheme(); - - var dayTextHex = (this.config.THEME_DAY_TEXT_HEX != null) ? String(this.config.THEME_DAY_TEXT_HEX) : null; - var nightTextHex = (this.config.THEME_NIGHT_TEXT_HEX != null) ? String(this.config.THEME_NIGHT_TEXT_HEX) : null; - - // # 兼容旧版默认配色:若仍为旧默认值,自动回退到莫奈色 - if (dayTextHex === "#333333") dayTextHex = null; - if (nightTextHex === "#E6E6F0") nightTextHex = null; - - // # 未配置时使用莫奈 onSurface 色作为回退 - var dayFallback = this.ui.colors.textPriLight || android.graphics.Color.parseColor("#1F1F1F"); - var nightFallback = this.ui.colors.textPriDark || android.graphics.Color.parseColor("#E3E3E3"); - - if (!isDark) return dayTextHex ? this.safeParseColor(dayTextHex, dayFallback) : dayFallback; - return nightTextHex ? this.safeParseColor(nightTextHex, nightFallback) : nightFallback; -}; - -FloatBallAppWM.prototype.applyTextColorRecursive = function(v, colorInt) { - // 这段代码的主要内容/用途:递归设置面板内所有 TextView 的文字颜色(标题/按钮文字/描述/设置项等)。 - try { - if (!v) return; - if (v instanceof android.widget.TextView) { - v.setTextColor(colorInt); - } - if (v instanceof android.view.ViewGroup) { - var i, n = v.getChildCount(); - for (i = 0; i < n; i++) { - this.applyTextColorRecursive(v.getChildAt(i), colorInt); - } - } - } catch (e0) {} -}; - -FloatBallAppWM.prototype.updatePanelBackground = function(panelView) { - // 这段代码的主要内容/用途:统一为"主面板/设置面板/查看器面板"应用背景与文字颜色(自动/亮/暗三档),并输出调试日志(命中哪个颜色)。 - try { - var bg = new android.graphics.drawable.GradientDrawable(); - bg.setCornerRadius(this.dp(22)); - - var bgInt = this.getPanelBgColorInt(); - bg.setColor(bgInt); - - // 轻量描边:亮色时更明显,暗色时也保留一点边界(不提供自定义输入,避免设置页复杂化) - var sw = this.dp(1); - var isDark = this.isDarkTheme(); - var outlineColor = this.ui.colors._monetOutline || (isDark ? android.graphics.Color.parseColor("#8E918F") : android.graphics.Color.parseColor("#747775")); - var stroke = this.withAlpha(outlineColor, isDark ? 0.26 : 0.20); - try { bg.setStroke(sw, stroke); } catch (eS) {} - - panelView.setBackground(bg); - - var tc = this.getPanelTextColorInt(bgInt); - try { themeBgInt = bgInt; themeTextInt = tc; } catch (eT) {} - this.applyTextColorRecursive(panelView, tc); - - try { _th_log(this.L, "d", "[t]apply bg=" + _th_hex(bgInt) + " tx=" + _th_hex(tc)); } catch (e) {} - - - try { - _th_log(this.L, "d", - "[theme:apply] isDark=" + isDark + - " bg=" + _th_hex(bgInt) + " " + _th_argb(bgInt) + - " text=" + _th_hex(tc) + " " + _th_argb(tc) + - " stroke=" + _th_hex(stroke) - ); - } catch (eL0) {} - } catch (e) { - try { _th_log(this.L, "e", "[theme:apply] err=" + String(e)); } catch (eL1) {} - } -}; - -// =======================【工具:面板位置持久化】====================== -FloatBallAppWM.prototype.savePanelState = function(key, state) { - if (!key || !state) return; - try { - if (!this.config.PANEL_STATES) this.config.PANEL_STATES = {}; - this.config.PANEL_STATES[key] = state; - // 节流或立即保存? 面板拖动结束通常不频繁,立即保存即可 - // 但为了避免连续事件,还是可以复用 savePos 的节流逻辑,或者直接保存 - ConfigManager.saveSettings(this.config); - } catch (e) {} -}; - -FloatBallAppWM.prototype.loadPanelState = function(key) { - if (!key || !this.config.PANEL_STATES) return null; - return this.config.PANEL_STATES[key]; -}; - -// =======================【工具:位置持久化】====================== -FloatBallAppWM.prototype.savePos = function(x, y) { - try { - this.config.BALL_INIT_X = Math.floor(x); - this.config.BALL_INIT_Y_DP = Math.floor(y / this.state.density); - // # 节流保存 - return ConfigManager.saveSettings(this.config); - } catch (e) { return false; } -}; - -FloatBallAppWM.prototype.loadSavedPos = function() { - // # 直接从 config 返回,因为 config 已经是持久化的 - var x = Number(this.config.BALL_INIT_X || 0); - var y = this.dp(Number(this.config.BALL_INIT_Y_DP || 100)); - return { x: x, y: y }; -}; - -FloatBallAppWM.prototype.trySavePosThrottled = function(x, y) { - var t = this.now(); - if (t - this.state.lastSaveTs < this.config.SAVE_THROTTLE_MS) return false; - this.state.lastSaveTs = t; - return this.savePos(x, y); -}; - -// =======================【工具:配置持久化】====================== -FloatBallAppWM.prototype.saveConfig = function(obj) { - try { - for (var k in obj) { - if (obj.hasOwnProperty(k)) { - this.config[k] = obj[k]; - } - } - if (this.L) this.L.updateConfig(this.config); - return ConfigManager.saveSettings(this.config); - } catch (e) { return false; } -}; - -// =======================【设置面板:schema】====================== -FloatBallAppWM.prototype.getConfigSchema = function() { - return ConfigManager.loadSchema(); -}; - -// =======================【设置面板:临时编辑缓存】====================== -FloatBallAppWM.prototype.beginEditConfig = function() { - try { - var schema = this.getConfigSchema(); - var p = {}; - var i; - for (i = 0; i < schema.length; i++) { - if (!schema[i] || !schema[i].key) continue; - var k = String(schema[i].key); - p[k] = this.config[k]; - } - this.state.pendingUserCfg = p; - this.state.pendingDirty = false; - return true; - } catch (e0) { - this.state.pendingUserCfg = null; - this.state.pendingDirty = false; - return false; - } -}; -FloatBallAppWM.prototype.getPendingValue = function(k) { - if (this.state.pendingUserCfg && this.state.pendingUserCfg.hasOwnProperty(k)) return this.state.pendingUserCfg[k]; - return this.config[k]; -}; -FloatBallAppWM.prototype.setPendingValue = function(k, v) { - if (!this.state.pendingUserCfg) this.beginEditConfig(); - this.state.pendingUserCfg[k] = v; - this.state.pendingDirty = true; - if (this.state.previewMode) { - this.refreshPreview(k); - } -}; - -FloatBallAppWM.prototype.getEffectiveConfig = function() { - if (!this.state.previewMode || !this.state.pendingUserCfg) return this.config; - var cfg = {}; - for (var k in this.config) { cfg[k] = this.config[k]; } - for (var k in this.state.pendingUserCfg) { cfg[k] = this.state.pendingUserCfg[k]; } - return cfg; -}; - -FloatBallAppWM.prototype.refreshPreview = function(changedKey) { - if (this.state.closing) return; - var self = this; - // Post to next tick to avoid destroying view during event dispatch (fixes crash on switch toggle) - if (this.state.h) { - this.state.h.post(new JavaAdapter(java.lang.Runnable, { - run: function() { self._refreshPreviewInternal(changedKey); } - })); - } else { - self._refreshPreviewInternal(changedKey); - } -}; - -FloatBallAppWM.prototype._refreshPreviewInternal = function(changedKey) { - if (this.state.closing) return; - var originalConfig = this.config; - try { - // 使用临时配置 - this.config = this.getEffectiveConfig(); - - var needBall = false; - var needPanel = false; - - if (!changedKey) { - needBall = true; - needPanel = true; - } else { - // 根据修改的 key 判断需要刷新什么,避免全量刷新导致闪烁 - if (changedKey.indexOf("BALL_") === 0) needBall = true; - if (changedKey.indexOf("PANEL_") === 0) needPanel = true; - // 球大小改变会影响面板位置 - if (changedKey === "BALL_SIZE_DP") needPanel = true; - } - - // 1. 刷新悬浮球 (保持面板不关闭) - if (needBall) { - this.rebuildBallForNewSize(true); - } - - // 2. 刷新主面板预览 - if (needPanel) { - // 如果当前没有显示主面板,则创建并显示;如果已显示,则替换 - - var panel = this.buildPanelView("main"); - - // 计算位置 (使用当前球的位置) - var maxH = Math.floor(this.state.screen.h * 0.75); - panel.measure( - android.view.View.MeasureSpec.makeMeasureSpec(this.state.screen.w, android.view.View.MeasureSpec.AT_MOST), - android.view.View.MeasureSpec.makeMeasureSpec(maxH, android.view.View.MeasureSpec.AT_MOST) - ); - var pw = panel.getMeasuredWidth(); - var ph = panel.getMeasuredHeight(); - if (ph > maxH) ph = maxH; - - var bx = this.state.ballLp.x; - var by = this.state.ballLp.y; - var px = this.computePanelX(bx, pw); - var py = by; - - // 尝试调整 Y - var r = this.tryAdjustPanelY(px, py, pw, ph, bx, by); - var finalX = r.ok ? r.x : px; - var finalY = r.ok ? r.y : this.clamp(py, 0, this.state.screen.h - ph); - - // 优化闪烁:先添加新面板,再移除旧面板 (这样新面板会在最上层,符合预览需求) - var oldPanel = this.state.panel; - var oldAdded = this.state.addedPanel; - - // 添加新面板 (addPanel 会更新 this.state.panel) - // 注意:addPanel 中已为 main 添加 FLAG_NOT_FOCUSABLE,所以即使在最上层也不会抢走 Settings 的输入焦点 - this.addPanel(panel, finalX, finalY, "main"); - - // 移除旧面板 - if (oldAdded && oldPanel) { - try { this.state.wm.removeView(oldPanel); } catch(e) {} - } - } - - } catch(e) { - safeLog(this.L, 'e', "refreshPreview err=" + e); - } finally { - this.config = originalConfig; - } -}; -FloatBallAppWM.prototype.persistUserCfgFromObject = function(obj) { - // # 这段代码的主要内容/用途:从临时编辑对象里按 schema 白名单抽取并保存(跳过 section 标题等无 key 项) - try { - var schema = this.getConfigSchema(); - var out = {}; - var i; - for (i = 0; i < schema.length; i++) { - if (!schema[i] || !schema[i].key) continue; - var k = String(schema[i].key); - out[k] = obj[k]; - } - return this.saveConfig(out); - } catch (e0) { return false; } -}; - -FloatBallAppWM.prototype.applyImmediateEffectsForKey = function(k) { - try { - if (k === "LOG_ENABLE") { - try { - if (this.L) { - this.L.enable = !!this.config.LOG_ENABLE; - this.L.cfg.LOG_ENABLE = !!this.config.LOG_ENABLE; - this.L.i("apply LOG_ENABLE=" + String(this.config.LOG_ENABLE)); - } - } catch (eLE) {} - return; - } - if (k === "LOG_DEBUG") { - try { - if (this.L) { - this.L.debug = !!this.config.LOG_DEBUG; - this.L.cfg.LOG_DEBUG = !!this.config.LOG_DEBUG; - this.L.i("apply LOG_DEBUG=" + String(this.config.LOG_DEBUG)); - } - } catch (eLD) {} - return; - } - if (k === "LOG_KEEP_DAYS") { - try { - var n = Math.max(1, Math.floor(Number(this.config.LOG_KEEP_DAYS || 3))); - this.config.LOG_KEEP_DAYS = n; - if (this.L) { - this.L.keepDays = n; - this.L.cfg.LOG_KEEP_DAYS = n; - this.L.i("apply LOG_KEEP_DAYS=" + String(n)); - this.L.cleanupOldFiles(); - } - } catch (eLK) {} - return; - } - if (k === "BALL_SIZE_DP" || k === "BALL_PNG_MODE" || k === "BALL_ICON_TYPE" || k === "BALL_ICON_PKG" || k === "BALL_ICON_FILE_PATH" || k === "BALL_ICON_RES_ID" || k === "BALL_ICON_RES_NAME" || k === "BALL_ICON_SIZE_DP" || k === "BALL_ICON_TINT_HEX" || k === "BALL_TEXT" || k === "BALL_TEXT_SIZE_SP" || k === "BALL_TEXT_COLOR_HEX" || k === "BALL_ICON_TEXT_GAP_DP") { this.rebuildBallForNewSize(); return; } - - if (k === "PANEL_ROWS" || k === "PANEL_COLS" || - k === "PANEL_ITEM_SIZE_DP" || k === "PANEL_GAP_DP" || - k === "PANEL_PADDING_DP" || k === "PANEL_ICON_SIZE_DP" || - k === "PANEL_LABEL_ENABLED" || k === "PANEL_LABEL_TEXT_SIZE_SP" || - k === "PANEL_LABEL_TOP_MARGIN_DP") { - - if (this.state.addedPanel) this.hideMainPanel(); - if (this.state.addedSettings) this.hideSettingsPanel(); - if (this.state.addedViewer) this.hideViewerPanel(); - return; - } - - if (k === "EDGE_VISIBLE_RATIO") { - if (this.state.addedBall && this.state.docked) { - this.state.docked = false; - this.snapToEdgeDocked(false); - } - return; - } - } catch (e0) {} -}; - -FloatBallAppWM.prototype.commitPendingUserCfg = function() { - try { - if (!this.state.pendingUserCfg) return { ok: false, reason: "no_pending" }; - - var schema = this.getConfigSchema(); - var changedKeys = []; - var i; - - for (i = 0; i < schema.length; i++) { - if (!schema[i] || !schema[i].key) continue; - var k = String(schema[i].key); - var oldV = this.config[k]; - var newV = this.state.pendingUserCfg[k]; - if (String(oldV) !== String(newV)) { - this.config[k] = newV; - changedKeys.push(k); - } - } - - this.persistUserCfgFromObject(this.state.pendingUserCfg); - - var j; - for (j = 0; j < changedKeys.length; j++) { - if (changedKeys[j] === "BALL_SIZE_DP") { this.applyImmediateEffectsForKey("BALL_SIZE_DP"); break; } - } - for (j = 0; j < changedKeys.length; j++) { - if (changedKeys[j] !== "BALL_SIZE_DP") this.applyImmediateEffectsForKey(changedKeys[j]); - } - - this.state.pendingDirty = false; - safeLog(this.L, 'i', "commit settings changed=" + JSON.stringify(changedKeys)); - return { ok: true, changed: changedKeys }; - } catch (e0) { - safeLog(this.L, 'e', "commitPendingUserCfg err=" + String(e0)); - return { ok: false, err: String(e0) }; - } -}; - -// =======================【工具:吸边数据】====================== -FloatBallAppWM.prototype.getDockInfo = function() { - var ballSize = this.dp(this.config.BALL_SIZE_DP); - var visible = Math.max(1, Math.round(ballSize * this.config.EDGE_VISIBLE_RATIO)); - var hidden = ballSize - visible; - return { ballSize: ballSize, visiblePx: visible, hiddenPx: hidden }; -}; - -// =======================【工具:图标解析】====================== - -// =======================【工具:快捷方式图标文件路径】====================== -FloatBallAppWM.prototype.getShortcutIconFilePath = function(pkg, shortcutId, userId) { - // # 主要用途:把快捷方式图标持久化到 shortcut_icons 目录,供按钮页/按钮管理页稳定显示(桌面移除后仍可显示) - try { - var p = (pkg == null) ? "" : String(pkg); - var s = (shortcutId == null) ? "" : String(shortcutId); - var u = (userId == null) ? "0" : String(userId); - - // # 文件名去非法字符,避免路径注入或创建失败 - function _sn(v) { - try { - var t = String(v == null ? "" : v); - t = t.replace(/[^a-zA-Z0-9._-]+/g, "_"); - if (t.length > 120) t = t.substring(0, 120); - return t; - } catch(e) { return ""; } - } - - var dir = String(APP_ROOT_DIR) + "/shortcut_icons"; - var fn = _sn(p) + "__" + _sn(s) + "__u" + _sn(u) + ".png"; - return dir + "/" + fn; - } catch (e0) { - return ""; - } -}; - -FloatBallAppWM.prototype.resolveIconDrawable = function(btn) { - // # 主要用途:解析面板按钮图标(优先 app 包名图标,其次自定义 resId,最后兜底) - try { - if (!btn) return context.getResources().getDrawable(android.R.drawable.ic_menu_help, null); - - // # 0) 优先检查 iconPath (绝对路径) - // # 引用优化:复用 loadBallIconDrawableFromFile 安全加载逻辑 - if (btn.iconPath) { - try { - var path = String(btn.iconPath); - if (path) { - // targetPx: 面板图标大小; Limit: 1MB, 1024px - var sizeDp = this.config.PANEL_ICON_SIZE_DP || 32; - var dr = this.loadBallIconDrawableFromFile(path, this.dp(sizeDp), 1048576, 1024); - if (dr) return dr; - } - } catch (ePath) {} - } - - -// # 1) type=app 且配置了 pkg:自动取应用图标 -try { - var t = (btn.type == null) ? "" : String(btn.type); - if (t === "app") { - var pkg = (btn.pkg == null) ? "" : String(btn.pkg); - if (pkg.length > 0) { - // # 统一 LRU 缓存:避免频繁走 PackageManager(Drawable 可复用);并带容量上限,防止无限增长 - var kApp = "app|" + pkg; - var hitApp = this._iconLruGet(kApp); - if (hitApp) return hitApp; - - var pm = context.getPackageManager(); - var drApp = pm.getApplicationIcon(pkg); - if (drApp != null) { - this._iconLruPut(kApp, drApp); - return drApp; - } - } - } -} catch (eApp) {} - - - // # 1.5) type=shortcut:尝试取 Shortcuts 快捷方式图标(显示与 shortcuts.js 页面一致) - // # 说明:btn 需要包含 pkg + shortcutId;图标获取可能较重,因此做简单缓存,失败则回退到应用图标。 - try { - var t2 = (btn.type == null) ? "" : String(btn.type); - if (t2 === "shortcut") { - var pkg2 = (btn.pkg == null) ? "" : String(btn.pkg); - var sid2 = (btn.shortcutId == null) ? "" : String(btn.shortcutId); - if (pkg2.length > 0 && sid2.length > 0) { - // # 1.5.1) 优先从 shortcut_icons 持久化目录读取(桌面移除后仍可显示正确图标) - try { - var iconFilePath0 = this.getShortcutIconFilePath(pkg2, sid2, (btn.userId != null ? String(btn.userId) : "0")); - if (iconFilePath0) { - try { - var f0 = new java.io.File(iconFilePath0); - if (f0.exists() && f0.isFile()) { - var sizeDp0 = this.config.PANEL_ICON_SIZE_DP || 32; - var iconSizePx0 = this.dp(sizeDp0); - var dr0 = this.loadBallIconDrawableFromFile(iconFilePath0, iconSizePx0, 1048576, 1024); - if (dr0) { - // # 写入统一 LRU 缓存:同一个 shortcut 复用 Drawable,避免反复解码 PNG - try { - var sk0 = pkg2 + "@" + sid2 + "@" + (btn.userId != null ? String(btn.userId) : ""); - this._iconLruPut("sc|" + sk0, dr0); - } catch (eSc0) {} - return dr0; - } - } - } catch(eF0) {} - } - } catch(eFile0) {} -var skey = pkg2 + "@" + sid2 + "@" + (btn.userId != null ? String(btn.userId) : ""); - var kSc = "sc|" + skey; - var hitSc = this._iconLruGet(kSc); - if (hitSc) return hitSc; - // # 失败冷却:某些 ROM/桌面在短时间内反复查询 Shortcuts 会很慢或直接抛异常。 - // # 这里对同一个 shortcut key 做短暂冷却(默认 10 秒),避免面板频繁刷新导致卡顿/ANR 风险。 - if (!this._shortcutIconFailTs) this._shortcutIconFailTs = {}; - var nowTs = 0; - try { nowTs = new Date().getTime(); } catch(eNow) { nowTs = 0; } - var lastFailTs = this._shortcutIconFailTs[skey]; - if (lastFailTs && nowTs > 0 && (nowTs - lastFailTs) < 10000) { - // # 冷却期内直接跳过 shortcut icon 查询,走回退逻辑(应用图标) - } else { - - var la = context.getSystemService(android.content.Context.LAUNCHER_APPS_SERVICE); - if (la) { - - // # 修复:按钮管理页/按钮面板中,微信小程序等 shortcut 图标显示成宿主 App 图标 - // # 根因(工作假设):用 setShortcutIds() 精确查询时,部分 ROM 的实现会返回"退化"的 ShortcutInfo, - // # getShortcutIconDrawable 结果被降级为宿主 App 图标。 - // # 方案:完全复用 shortcuts.js 的做法--先按 package + flags 拉取该包的 shortcuts 列表,再在列表里按 id 过滤命中项取图标。 - // # 注意:不依赖外部 buildShortcutItemsIndex(),避免主程序环境下未加载 shortcuts.js 导致直接回退。 - // # 方案:完全复用"选择快捷方式列表"的取数路径,确保微信小程序等 pinned shortcut 能拿到正确的 ShortcutInfo - // # 1) 优先直连 shortcut service(选择器同款:信息更完整) - // # 2) 失败再回退 LauncherApps.getShortcuts - var list = null; - - // # 1) shortcut service 直连(与选择器保持一致) - try { - var userIdIntForSvc = 0; - try { - var buid2 = null; - if (btn.userId != null) buid2 = btn.userId; - if (buid2 == null && btn.user != null) buid2 = btn.user; - if (buid2 != null) { - var tmpUid2 = parseInt(String(buid2), 10); - if (!isNaN(tmpUid2)) userIdIntForSvc = tmpUid2; - } - } catch(eUidSvc) {} - - var sm2 = android.os.ServiceManager; - var shortcutSvc = null; - try { shortcutSvc = sm2.getService("shortcut"); } catch(eSm2) { shortcutSvc = null; } - if (shortcutSvc) { - var CFG_MATCH_ALL2 = 0x0000000F; - var slice2 = null; - try { slice2 = shortcutSvc.getShortcuts(String(pkg2), CFG_MATCH_ALL2, userIdIntForSvc); } catch(eS0b) { slice2 = null; } - if (slice2) { - var listObj2 = null; - try { listObj2 = slice2.getList(); } catch(eS1b) { listObj2 = null; } - if (listObj2) list = listObj2; - } - } - } catch(eSvc2) {} - - // # 2) LauncherApps 回退(当 shortcut service 不可用或返回空时) - if (list == null) { - var q = new android.content.pm.LauncherApps.ShortcutQuery(); - try { q.setPackage(pkg2); } catch(eSP) {} - - // # 重要:必须设置 QueryFlags,否则 getShortcuts 可能返回空(默认 flags=0) - // # 兼容性:不同 Android/ROM 可能缺少部分 FLAG,逐个 try 叠加 - try { - var qFlags = 0; - try { qFlags = qFlags | android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_DYNAMIC; } catch(eF1) {} - try { qFlags = qFlags | android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_PINNED; } catch(eF2) {} - try { qFlags = qFlags | android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_MANIFEST; } catch(eF3) {} - try { qFlags = qFlags | android.content.pm.LauncherApps.ShortcutQuery.FLAG_MATCH_CACHED; } catch(eF4) {} - try { q.setQueryFlags(qFlags); } catch(eSF) {} - } catch(eQF) {} - - // # 重要:用户句柄优先用按钮携带的 userId(如有),否则使用当前用户 - var uh = android.os.Process.myUserHandle(); - try { - var buid = null; - if (btn.userId != null) buid = btn.userId; - if (buid == null && btn.user != null) buid = btn.user; - if (buid != null) { - var uidInt = parseInt(String(buid), 10); - if (!isNaN(uidInt)) { - uh = android.os.UserHandle.of(uidInt); - } - } - } catch(eUH) {} - - try { list = la.getShortcuts(q, uh); } catch(eGS) { list = null; } - } - -if (list && list.size && list.size() > 0) { - // # 在返回列表中按 shortcutId 精确命中 - var si = null; - try { - for (var kk = 0; kk < list.size(); kk++) { - var s0 = list.get(kk); - if (s0 != null) { - var id0 = ""; - try { id0 = String(s0.getId()); } catch(eId0) { id0 = ""; } - if (id0 === sid2) { si = s0; break; } - } - } - } catch(eFind) { si = null; } -if (si != null) { - // # 与 shortcuts.js 一致:优先 la.getShortcutIconDrawable(shortcutInfo, 0),再兜底 Icon.loadDrawable - var drSc = null; - try { drSc = la.getShortcutIconDrawable(si, 0); } catch(eIcon0) { drSc = null; } - if (drSc == null) { - try { - var ic = si.getIcon(); - if (ic != null) { - var d2 = ic.loadDrawable(context); - if (d2 != null) drSc = d2; - } - } catch(eIcon1) {} - } - if (drSc != null) { - try { this._iconLruPut("sc|" + skey, drSc); } catch (eSc1) {} - return drSc; - } - } - } - } - - - -// # 如果没拿到 shortcut 图标,记录一次失败时间,触发冷却 - try { - if (nowTs > 0) this._shortcutIconFailTs[skey] = nowTs; - else this._shortcutIconFailTs[skey] = new Date().getTime(); - } catch(eFT) {} - } - - - // # 回退:取应用图标,至少保证按钮有图标可见 - try { - var pm2 = context.getPackageManager(); - var drApp2 = pm2.getApplicationIcon(pkg2); - if (drApp2 != null) return drApp2; - } catch(eFall) {} - } - } - } catch (eSc) {} - // # 2) 显式指定 iconResName (String) 或 iconResId (int) - try { - if (btn.iconResName) { - var drShortx = this.resolveShortXDrawable(btn.iconResName, btn && btn.iconTint ? String(btn.iconTint) : ""); - if (drShortx != null) return drShortx; - var name = this.normalizeShortXIconName(btn.iconResName, true); - // # 回退到 android 系统图标 - var id = context.getResources().getIdentifier(name, "drawable", "android"); - if (id > 0) return context.getResources().getDrawable(id, null); - } - if (btn.iconResId) return context.getResources().getDrawable(btn.iconResId, null); - } catch (e1) {} - - // # 3) 兜底 - return context.getResources().getDrawable(android.R.drawable.ic_menu_help, null); - } catch (e2) {} - return null; -}; - -FloatBallAppWM.prototype.getShortXResHandle = function() { - if (this._shortxResHandle) return this._shortxResHandle; - try { - var flags = 0; - try { flags = android.content.Context.CONTEXT_INCLUDE_CODE | android.content.Context.CONTEXT_IGNORE_SECURITY; } catch (eF) { flags = android.content.Context.CONTEXT_RESTRICTED; } - var sxCtx = context.createPackageContext(CONST_SHORTX_PACKAGE, flags); - this._shortxResHandle = { - ctx: sxCtx, - res: sxCtx.getResources(), - cl: sxCtx.getClassLoader(), - pkg: CONST_SHORTX_PACKAGE - }; - return this._shortxResHandle; - } catch (e) { - safeLog(this.L, 'w', "getShortXResHandle failed: " + String(e)); - return null; - } -}; - -FloatBallAppWM.prototype.normalizeShortXIconName = function(name, keepPrefix) { - try { - var s = String(name == null ? "" : name).replace(/^\s+|\s+$/g, ""); - if (!s) return ""; - if (s.indexOf("@drawable/") === 0) s = s.substring(10); - if (s.indexOf(".") > 0) { - var parts = s.split("."); - s = parts[parts.length - 1]; - } - if (s.indexOf("ic_remix_") === 0) { - return keepPrefix ? s : s.substring("ic_remix_".length); - } - if (s.indexOf("ic_") === 0) { - return s; - } - return keepPrefix ? ("ic_remix_" + s) : s; - } catch (e) {} - return ""; -}; - -FloatBallAppWM.prototype.getShortXApkPaths = function() { - var out = []; - try { - var handle = this.getShortXResHandle(); - var ai = null; - try { if (handle && handle.ctx) ai = handle.ctx.getApplicationInfo(); } catch (eAi0) { ai = null; } - if (ai == null) { - try { ai = context.getPackageManager().getApplicationInfo(CONST_SHORTX_PACKAGE, 0); } catch (eAi1) { ai = null; } - } - function pushPath(p) { - try { - p = String(p || ""); - if (!p) return; - if (out.indexOf(p) < 0) out.push(p); - } catch (eP) {} - } - if (ai) { - try { pushPath(ai.sourceDir); } catch (e0) {} - try { pushPath(ai.publicSourceDir); } catch (e1) {} - try { - var ss = ai.splitSourceDirs; - if (ss) { - var i; - for (i = 0; i < ss.length; i++) pushPath(ss[i]); - } - } catch (e2) {} - } - } catch (e) {} - return out; -}; - -FloatBallAppWM.prototype.scanShortXIconsFromApk = function() { - var out = []; - var seen = {}; - var paths = this.getShortXApkPaths(); - var regex = /^res\/drawable[^\/]*\/(ic_remix_[a-z0-9_]+|ic_shortx|ic_launcher|ic_menu_preferences)\.(xml|png|webp|jpg|jpeg)$/; - var lastErr = ""; - var pi; - for (pi = 0; pi < paths.length; pi++) { - var zip = null; - try { - zip = new java.util.zip.ZipFile(String(paths[pi])); - var en = zip.entries(); - while (en.hasMoreElements()) { - var ze = en.nextElement(); - var name = String(ze.getName()); - var m = regex.exec(name); - if (!m) continue; - var fullName = String(m[1]); - if (seen[fullName]) continue; - seen[fullName] = true; - out.push({ - name: fullName, - shortName: this.normalizeShortXIconName(fullName, false), - id: 0 - }); - } - } catch (eZip) { - lastErr = String(eZip); - } finally { - try { if (zip) zip.close(); } catch (eClose) {} - } - } - if ((!out || out.length === 0) && lastErr) this._shortxIconCatalogError = lastErr; - return out; -}; - -FloatBallAppWM.prototype.getShortXIconLookupNames = function(name) { - var out = []; - try { - var s = String(name == null ? "" : name).replace(/^\s+|\s+$/g, ""); - if (!s) return out; - if (s.indexOf("@drawable/") === 0) s = s.substring(10); - if (s.indexOf(".") > 0) { - var parts = s.split("."); - s = parts[parts.length - 1]; - } - function add(v) { - if (!v) return; - if (out.indexOf(v) < 0) out.push(v); - } - add(s); - if (s.indexOf("ic_remix_") === 0) { - add(s.substring("ic_remix_".length)); - } else if (s.indexOf("ic_") !== 0) { - add("ic_remix_" + s); - add("ic_" + s); - } - } catch (e) {} - return out; -}; - -FloatBallAppWM.prototype.resolveShortXDrawableResId = function(name) { - try { - var handle = this.getShortXResHandle(); - if (!handle || !handle.res) return 0; - var cands = this.getShortXIconLookupNames(name); - var i; - for (i = 0; i < cands.length; i++) { - var resId = 0; - try { resId = handle.res.getIdentifier(String(cands[i]), "drawable", handle.pkg); } catch (e1) { resId = 0; } - if (resId > 0) return resId; - } - } catch (e) {} - return 0; -}; - -FloatBallAppWM.prototype.resolveShortXDrawable = function(name, tintHex) { - try { - var handle = this.getShortXResHandle(); - if (!handle || !handle.res) return null; - var resId = this.resolveShortXDrawableResId(name); - if (resId <= 0) return null; - var dr = handle.res.getDrawable(resId, null); - if (dr && tintHex) { - try { - dr = dr.mutate(); - dr.setColorFilter(android.graphics.Color.parseColor(String(tintHex)), android.graphics.PorterDuff.Mode.SRC_IN); - } catch (eTint) {} - } - return dr; - } catch (e) {} - return null; -}; - -FloatBallAppWM.prototype.getShortXIconCatalog = function(forceReload) { - if (!forceReload && this._shortxIconCatalog) return this._shortxIconCatalog; - var out = []; - this._shortxIconCatalogError = ""; - try { - var handle = this.getShortXResHandle(); - if (handle && handle.cl) { - try { - var clz = handle.cl.loadClass(CONST_SHORTX_PACKAGE + ".R$drawable"); - var fields = clz.getFields(); - var i; - for (i = 0; i < fields.length; i++) { - try { - var f = fields[i]; - var fname = String(f.getName()); - if (fname.indexOf("ic_remix_") !== 0 && fname !== "ic_shortx" && fname !== "ic_launcher" && fname !== "ic_menu_preferences") continue; - out.push({ - name: fname, - shortName: (fname.indexOf("ic_remix_") === 0) ? fname.substring("ic_remix_".length) : fname, - id: f.getInt(null) - }); - } catch (eField) {} - } - } catch (eClz) { - this._shortxIconCatalogError = String(eClz); - safeLog(this.L, 'w', "getShortXIconCatalog reflect failed: " + String(eClz)); - } - } - if (!out || out.length === 0) { - out = this.scanShortXIconsFromApk(); - } - out.sort(function(a, b) { - var aw = a.name.indexOf("ic_remix_") === 0 ? 1 : 0; - var bw = b.name.indexOf("ic_remix_") === 0 ? 1 : 0; - if (aw !== bw) return aw - bw; - var as = String(a.shortName || a.name); - var bs = String(b.shortName || b.name); - return as < bs ? -1 : (as > bs ? 1 : 0); - }); - if (!out || out.length === 0) { - if (!this._shortxIconCatalogError) this._shortxIconCatalogError = "反射与 APK 扫描均未获取到图标"; - } else { - this._shortxIconCatalogError = ""; - } - } catch (e) { - this._shortxIconCatalogError = String(e); - safeLog(this.L, 'w', "getShortXIconCatalog failed: " + String(e)); - } - this._shortxIconCatalog = out; - return out; -}; - -// =======================【工具:快捷方式选择器(内置,合并 shortcuts.js)】====================== -// 这段代码的主要内容/用途:在 toolhub.js 内部提供与 shortcuts.js 等价的"快捷方式浏览/选择"页面(仅在点击"选择快捷方式"时触发)。 -// 设计要点: -// 1) 双线程流水线:UI 线程专管 WM/View;BG 线程专管 icon 加载,避免卡顿/ANR 风险。 -// 2) 稳定性:失败 TTL 熔断 + 限次重试 + icon LRU 上限;滚动触底用轮询,避免 interface listener 的 JavaAdapter 风险。 -// 3) UI 风格:复用 ToolHub 主题(白天/夜晚),避免与主面板割裂;支持关闭与安全销毁。 -// 4) 日志:复用 ToolHubLogger(self.L / _th_log)记录关键步骤与异常。 -FloatBallAppWM.prototype.showShortcutPicker = function(opts) { - var self = this; - var opt = opts || {}; - var mode = (opt.mode != null) ? String(opt.mode) : "browse"; // "pick" | "browse" - var onPick = (typeof opt.onPick === "function") ? opt.onPick : null; - - // # 会话隔离:生成唯一会话 ID,防止状态污染 - var sessionId = String(new Date().getTime()) + "_" + Math.random().toString(36).substr(2, 9); - var currentSession = sessionId; - - // 保存当前会话 ID 到实例(用于新选择器) - this.__currentShortcutSession = sessionId; - - // 会话检查函数:所有异步回调都必须检查 - function checkSession() { - if (self.__currentShortcutSession !== currentSession) { - safeLog(self.L, 'w', "Shortcut picker session expired, dropping callback"); - return false; - } - return true; - } - - // 清理函数 - function destroySession() { - if (self.__currentShortcutSession === currentSession) { - self.__currentShortcutSession = null; - } - } - - // # 稳定性:复用单例选择器,避免频繁 add/remove View 与线程创建销毁导致 system_server 概率性崩溃 - try { - if (self.__shortcutPickerSingleton && typeof self.__shortcutPickerSingleton.show === "function") { - // # 新会话覆盖旧会话 - self.__shortcutPickerSingleton.show(opts); - return; - } - } catch(eSingle) {} - - - // # 兼容:toolhub.js 全局未必定义 Context 别名,这里显式绑定,避免 ReferenceError。 - var Context = android.content.Context; - - // # 常量:可按需调小以降低 system_server 内存峰值 - var CFG_PAGE_BATCH = 40; - var CFG_ICON_CACHE_MAX = 120; - var CFG_ICON_FAIL_TTL_MS = 15000; - var CFG_ICON_FAIL_MAX_RETRY = 2; - var CFG_ICON_LOAD_CONCURRENCY = 2; - // # 这段代码的主要内容/用途:ShortcutService.getShortcuts 的匹配 flags(与 shortcuts.js 保持一致) - // # 说明:使用 0x0000000F 可尽量覆盖 Dynamic / Manifest / Pinned / Cached 等类型 - var CFG_MATCH_ALL = 0x0000000F; - - function now() { try { return new Date().getTime(); } catch(e) { return 0; } } - function lower(s) { try { return String(s || "").toLowerCase(); } catch(e) { return ""; } } - function safeStr(s) { try { return String(s == null ? "" : s); } catch(e) { return ""; } } - - // # 增强版:动态获取系统用户目录(适配不同 ROM) - function getSystemUserDir() { - var candidates = [ - "/data/system_ce", // 标准 AOSP - "/data/system/users", // 部分 MIUI/HyperOS - "/data/data/system_ce" // 极少数定制系统 - ]; - - for (var i = 0; i < candidates.length; i++) { - try { - var f = new java.io.File(candidates[i]); - if (f.exists() && f.isDirectory() && f.canRead()) { - return candidates[i]; - } - } catch(e) {} - } - - // 反射获取环境变量(最可靠但较慢) - try { - var env = java.lang.System.getenv("ANDROID_DATA"); - if (env) { - var envPath = String(env) + "/system_ce"; - var envDir = new java.io.File(envPath); - if (envDir.exists() && envDir.isDirectory()) { - return envPath; - } - } - } catch(e) {} - - // 最终兜底 - return "/data/system_ce"; - } - - // ==================== Shortcut 枚举(system_ce + shortcut service) ==================== - function listUserIdsFromSystemCE() { - // # 这段代码的主要内容/用途:扫描 /data/system_ce 下的用户目录,得到可用 userId 列表。 - var out = []; - try { - var basePath = getSystemUserDir(); - var base = new java.io.File(basePath); - if (!base.exists() || !base.isDirectory()) return out; - var arr = base.listFiles(); - if (!arr) return out; - for (var i = 0; i < arr.length; i++) { - var f = arr[i]; - if (!f || !f.isDirectory()) continue; - var name = safeStr(f.getName()); - if (!name) continue; - // 只收数字目录 - var ok = true; - for (var j = 0; j < name.length; j++) { - var c = name.charCodeAt(j); - if (c < 48 || c > 57) { ok = false; break; } - } - if (!ok) continue; - out.push(parseInt(name, 10)); - } - } catch(e) {} - // 默认兜底:至少有 user 0 - if (out.length === 0) out.push(0); - return out; - } - - function listPackagesHavingShortcuts(userId) { - // # 这段代码的主要内容/用途:从 shortcut_service 的持久化目录推断哪些包存在快捷方式记录(快速筛选)。 - var out = []; - try { - // # 使用动态获取的系统用户目录 - var basePath = "/data/system_ce"; - try { - if (typeof getSystemUserDir === "function") { - basePath = getSystemUserDir(); - } - } catch(eGSD2) {} - - var dir = new java.io.File(basePath + "/" + String(userId) + "/shortcut_service/packages"); - if (!dir.exists() || !dir.isDirectory()) return out; - var fs = dir.listFiles(); - if (!fs) return out; - for (var i = 0; i < fs.length; i++) { - var f = fs[i]; - if (!f || !f.isFile()) continue; - var name = safeStr(f.getName()); - if (!name) continue; - if (name.indexOf(".xml") > 0) { - var pkg = name.substring(0, name.length - 4); - if (pkg && pkg.length > 0) out.push(pkg); - } - } - } catch(e) {} - return out; - } - - function getShortcutServiceBinder() { - // # 这段代码的主要内容/用途:获取 "shortcut" service 的 Binder(不同 ROM 上可能失败,失败则回退到 LauncherApps API 取 icon)。 - try { - var sm = android.os.ServiceManager; - return sm.getService("shortcut"); - } catch(e) { - return null; - } - } - - function getShortcutsForPackage(pkg, userId) { - // # 这段代码的主要内容/用途:尝试从 shortcut service 拉取指定包的 ShortcutInfo 列表。 - // 说明:此处采用"尽力而为",因为 ROM 兼容性差;失败时返回空数组即可。 - var out = []; - try { - // # 修复:与 shortcuts.js 行为一致,优先走 shortcut 服务直连(可拿到"添加到桌面"的微信小程序这类入口) - // # 说明:LauncherApps.getShortcuts 在部分 ROM/桌面上对非 Launcher 调用者可见性不足,导致列表缺项。 - try { - var svc = getShortcutServiceBinder(); - if (svc) { - var slice = null; - try { slice = svc.getShortcuts(String(pkg), CFG_MATCH_ALL, parseInt(String(userId), 10)); } catch(eS0) { slice = null; } - if (slice) { - var listObj = null; - try { listObj = slice.getList(); } catch(eS1) { listObj = null; } - if (listObj) { - try { - var sz = listObj.size(); - for (var si0 = 0; si0 < sz; si0++) { - try { - var s0 = listObj.get(si0); - if (s0) out.push(s0); - } catch(eS2) {} - } - } catch(eS3) {} - if (out.length > 0) return out; - } - } - } - } catch(eSvc) { - // ignore and fallback - } - - // # 兜底:走 LauncherApps.getShortcuts(某些 ROM 上 shortcut 服务直连可能不可用) - var la = context.getSystemService(Context.LAUNCHER_APPS_SERVICE); - if (!la) return out; - - var LauncherApps = android.content.pm.LauncherApps; - var LauncherAppsShortcutQuery = android.content.pm.LauncherApps.ShortcutQuery; - - var q = new LauncherAppsShortcutQuery(); - // FLAG_MATCH_*:尽量全拿(动态/固定/清单) - try { - q.setQueryFlags( - LauncherAppsShortcutQuery.FLAG_MATCH_DYNAMIC | - LauncherAppsShortcutQuery.FLAG_MATCH_PINNED | - LauncherAppsShortcutQuery.FLAG_MATCH_MANIFEST | - LauncherAppsShortcutQuery.FLAG_MATCH_CACHED - ); - } catch(eF) { - // 某些 ROM 没有 FLAG_MATCH_CACHED - try { - q.setQueryFlags( - LauncherAppsShortcutQuery.FLAG_MATCH_DYNAMIC | - LauncherAppsShortcutQuery.FLAG_MATCH_PINNED | - LauncherAppsShortcutQuery.FLAG_MATCH_MANIFEST - ); - } catch(eF2) {} - } - try { q.setPackage(safeStr(pkg)); } catch(eP) {} - - // user 处理:尽量兼容多用户 - var userHandle = null; - try { - if (userId != null) { - var UserHandle = android.os.UserHandle; - userHandle = UserHandle.of(parseInt(String(userId), 10)); - } - } catch(eU) { userHandle = null; } - - var list = null; - try { - if (userHandle) list = la.getShortcuts(q, userHandle); - else list = la.getShortcuts(q, android.os.Process.myUserHandle()); - } catch(eQ) { - list = null; - } - - if (!list) return out; - for (var i = 0; i < list.size(); i++) { - try { - var si = list.get(i); - if (si) out.push(si); - } catch(eI) {} - } - } catch(e) {} - return out; - } - - function getAppLabelAsUser(pkg, userId) { - // # 这段代码的主要内容/用途:获取应用名称(用于分组/搜索显示),失败则返回包名。 - try { - var pm = context.getPackageManager(); - var ai = pm.getApplicationInfo(String(pkg), 0); - var lb = pm.getApplicationLabel(ai); - if (lb != null) return String(lb); - } catch(e) {} - return String(pkg); - } - - function buildShortcutItemsIndex() { - // # 这段代码的主要内容/用途:汇总所有 userId 下的快捷方式为统一列表,结构与 shortcuts.js 保持一致。 - var items = []; - var users = listUserIdsFromSystemCE(); - for (var ui = 0; ui < users.length; ui++) { - var uid = users[ui]; - var pkgs = listPackagesHavingShortcuts(uid); - for (var pi = 0; pi < pkgs.length; pi++) { - var pkg = pkgs[pi]; - if (!pkg) continue; - var sis = getShortcutsForPackage(pkg, uid); - if (!sis || sis.length === 0) continue; - for (var si = 0; si < sis.length; si++) { - var sInfo = sis[si]; - if (!sInfo) continue; - var id = ""; - var label = ""; - var intentUri = ""; - try { id = safeStr(sInfo.getId()); } catch(eId) { id = ""; } - try { label = safeStr(sInfo.getShortLabel()); } catch(eLb) { label = ""; } - try { - var it = sInfo.getIntent(); - if (it) intentUri = safeStr(it.toUri(0)); - } catch(eIt) { intentUri = ""; } - - items.push({ - userId: uid, - pkg: safeStr(pkg), - id: id, - label: label, - intentUri: intentUri, - shortcutInfo: sInfo - }); - } - } - } - return items; - } - - // ==================== icon 加载(与 resolveIconDrawable 回退策略一致) ==================== - function loadShortcutIconDrawable(item) { - // # 这段代码的主要内容/用途:尽力加载 shortcut icon;失败时回退到 app icon。 - try { - if (!item) return null; - var la = context.getSystemService(Context.LAUNCHER_APPS_SERVICE); - if (la && item.shortcutInfo) { - try { - // density: 0 让系统自适应(部分 ROM 更稳) - var dr = la.getShortcutIconDrawable(item.shortcutInfo, 0); - if (dr) return dr; - } catch(eLa) {} - } - } catch(e0) {} - // fallback: app icon - try { - var pm = context.getPackageManager(); - return pm.getApplicationIcon(String(item.pkg)); - } catch(e1) {} - return null; - } - - // ==================== 分组/过滤 ==================== - function groupItems(items) { - // # 这段代码的主要内容/用途:按应用分组,tab 显示用;同时保留 "__ALL__"。 - var groups = {}; - var order = []; - function ensure(key, title) { - if (groups[key]) return; - groups[key] = { key: key, title: title, items: [] }; - order.push(key); - } - ensure("__ALL__", "全部"); - for (var i = 0; i < items.length; i++) { - var it = items[i]; - if (!it) continue; - groups["__ALL__"].items.push(it); - var k = safeStr(it.pkg); - if (!groups[k]) { - ensure(k, getAppLabelAsUser(k, it.userId)); - } - groups[k].items.push(it); - } - // 让 "__ALL__" 固定在第一位,其余按 title 排序(中文/英文混排就随缘) - try { - var rest = []; - for (var j = 0; j < order.length; j++) if (order[j] !== "__ALL__") rest.push(order[j]); - rest.sort(function(a, b) { - var ta = safeStr(groups[a].title); - var tb = safeStr(groups[b].title); - return ta.localeCompare(tb); - }); - order = ["__ALL__"].concat(rest); - } catch(eS) {} - return { groups: groups, order: order }; - } - - function filterItems(items, q) { - var qLower = lower(q || ""); - if (!qLower) return items; - var out = []; - for (var i = 0; i < items.length; i++) { - var it = items[i]; - if (!it) continue; - var hit = false; - if (lower(it.label).indexOf(qLower) > -1) hit = true; - else if (lower(it.pkg).indexOf(qLower) > -1) hit = true; - else if (lower(it.id).indexOf(qLower) > -1) hit = true; - else if (lower(it.intentUri).indexOf(qLower) > -1) hit = true; - if (hit) out.push(it); - } - return out; - } - - // ==================== 主 UI(WM 双线程 + 轮询触底) ==================== - var HandlerThread = android.os.HandlerThread; - var Handler = android.os.Handler; - var Runnable = java.lang.Runnable; - var Thread = java.lang.Thread; - - var ht = new HandlerThread("sx-toolhub-shortcut-ui"); - ht.start(); - var h = new Handler(ht.getLooper()); - - var bgHt = new HandlerThread("sx-toolhub-shortcut-bg"); - bgHt.start(); - var bgH = new Handler(bgHt.getLooper()); - - var wm = context.getSystemService(Context.WINDOW_SERVICE); - - var state = { - destroyed: false, - hidden: false, - // # 单例标识:用于避免旧实例的异步回调误操作新实例 - instanceId: String(now()) + "_" + String(Math.random()), - root: null, - params: null, - isAdded: false, - - allItems: [], - groups: null, - groupOrder: [], - currentGroupKey: "__ALL__", - - query: "", - lastFilterToken: 0, - - iconCache: {}, - iconCacheOrder: [], - iconCacheMax: CFG_ICON_CACHE_MAX, - - iconFail: {}, - - iconQ: [], - iconInFlight: 0, - iconConc: CFG_ICON_LOAD_CONCURRENCY, - - grid: null, - tvStat: null, - etSearch: null, - tabRow: null, - scrollView: null, - - renderList: [], - renderCursor: 0, - isAppending: false, - lastAppendTryTs: 0, - - scrollPollRunning: false, - - // # 回调:关闭/隐藏后通知外层恢复上层 UI(避免被新增按钮页遮挡) - onDismiss: (opt && typeof opt.onDismiss === "function") ? opt.onDismiss : null, - onDismissCalled: false - }; - - - function Ld(msg) { try { if (self.L) self.L.d("[shortcut] " + msg); } catch(e) {} } - function Li(msg) { try { if (self.L) self.L.i("[shortcut] " + msg); } catch(e) {} } - function Le(msg) { try { if (self.L) self.L.e("[shortcut] " + msg); } catch(e) {} } - - function runOn(handler, fn) { - try { - handler.post(new JavaAdapter(Runnable, { run: function() { try { fn(); } catch(e) {} } })); - return true; - } catch(e) { return false; } - } - function hide() { - // # 这段代码的主要内容/用途:隐藏"选择快捷方式"窗口(不 removeView、不销毁线程),显著降低 system_server 概率性崩溃 - runOn(h, function() { - // # 会话检查:过期会话直接丢弃 - if (!checkSession()) return; - - if (state.destroyed) return; - if (state.hidden) return; - state.hidden = true; - state.scrollPollRunning = false; - - // # 停止队列与异步:隐藏后不再触发 UI/图标加载逻辑 - try { h.removeCallbacksAndMessages(null); } catch(eCB0) {} - try { bgH.removeCallbacksAndMessages(null); } catch(eCB1) {} - - // # 输入法/焦点:无论是否弹出输入法,都先退焦点并尝试隐藏软键盘 - try { - if (state.etSearch) { - try { state.etSearch.clearFocus(); } catch(eK0) {} - try { - var imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); - if (imm) imm.hideSoftInputFromWindow(state.etSearch.getWindowToken(), 0); - } catch(eK1) {} - } - } catch(eK2) {} - - // # 仅隐藏 View:不触碰 WM removeView,避免 WM/IME token 状态机被打乱 - try { - if (state.root) { - state.root.setVisibility(android.view.View.GONE); - } - } catch(eV0) {} - - // # 退出线程:hide 时也释放线程,避免反复打开/隐藏导致线程堆积 - try { - var killer = new Thread(new JavaAdapter(Runnable, { - run: function() { - try { bgHt.quitSafely(); } catch(e2) {} - try { ht.quitSafely(); } catch(e3) {} - } - })); - killer.start(); - } catch(eQuit) {} - - // # 通知外层:选择器已隐藏,可恢复上层面板显示 - try { - if (state.onDismiss && !state.onDismissCalled) { - state.onDismissCalled = true; - try { state.onDismiss(); } catch(eOD0) {} - } - } catch(eOD1) {} - }); - } - - function destroy() { - // # 会话清理:标记当前会话已结束 - destroySession(); - - // # 这段代码的主要内容/用途:彻底销毁选择器(仅在 wm_add_failed 等失败场景使用) - runOn(h, function() { - if (state.destroyed) return; - state.destroyed = true; - state.hidden = true; - state.scrollPollRunning = false; - - // # 清理消息队列 - try { h.removeCallbacksAndMessages(null); } catch(eCB0) {} - try { bgH.removeCallbacksAndMessages(null); } catch(eCB1) {} - - try { state.iconQ = []; } catch(e0) {} - state.iconInFlight = 0; - - // # 通知外层:选择器即将销毁,可恢复上层面板显示 - try { - if (state.onDismiss && !state.onDismissCalled) { - state.onDismissCalled = true; - try { state.onDismiss(); } catch(eOD0) {} - } - } catch(eOD1) {} - - // # 输入法/焦点清理 - try { - if (state.etSearch) { - try { state.etSearch.clearFocus(); } catch(eK0) {} - try { - var imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); - if (imm) imm.hideSoftInputFromWindow(state.etSearch.getWindowToken(), 0); - } catch(eK1) {} - } - } catch(eK2) {} - - // # 尝试移除 View(失败也吞掉,避免把 system_server 再次打穿) - var rootRef = state.root; - var wasAdded = state.isAdded; - try { - if (rootRef && wasAdded) { - try { wm.removeViewImmediate(rootRef); } catch(eR0) { try { wm.removeView(rootRef); } catch(eR1) {} } - } - } catch(eR2) {} - - state.isAdded = false; - state.root = null; - - // # 单例清理 - try { if (self.__shortcutPickerSingleton && self.__shortcutPickerSingleton.instanceId === state.instanceId) self.__shortcutPickerSingleton = null; } catch(eS0) {} - - // # 退出线程 - var killer = new Thread(new JavaAdapter(Runnable, { - run: function() { - try { bgHt.quitSafely(); } catch(e2) {} - try { ht.quitSafely(); } catch(e3) {} - } - })); - killer.start(); - }); - } - -function cacheGet(key) { - if (state.iconCache.hasOwnProperty(key)) return state.iconCache[key]; - return null; - } - - function cachePut(key, drawable) { - state.iconCache[key] = drawable; - var seen = false; - for (var i = state.iconCacheOrder.length - 1; i >= 0; i--) { - if (state.iconCacheOrder[i] === key) { seen = true; break; } - } - if (!seen) state.iconCacheOrder.push(key); - - while (state.iconCacheOrder.length > state.iconCacheMax) { - var oldKey = state.iconCacheOrder.shift(); - if (oldKey == null) continue; - if (oldKey === key) continue; - try { delete state.iconCache[oldKey]; } catch(e1) { state.iconCache[oldKey] = null; } - try { delete state.iconFail[oldKey]; } catch(e2) { state.iconFail[oldKey] = null; } - } - } - - function canTryLoadIcon(key) { - var rec = state.iconFail[key]; - if (!rec) return true; - var t = now(); - if (rec.count >= CFG_ICON_FAIL_MAX_RETRY) { - if (t - rec.ts < 300000) return false; - return true; - } - if (t - rec.ts < CFG_ICON_FAIL_TTL_MS) return false; - return true; - } - - function markIconFail(key) { - var t = now(); - var rec = state.iconFail[key]; - if (!rec) rec = { ts: t, count: 1 }; - else { rec.ts = t; rec.count = (rec.count || 0) + 1; } - state.iconFail[key] = rec; - } - - function enqueueIconLoad(item, iv, key) { - if (!item || !iv || !key) return; - if (!canTryLoadIcon(key)) return; - - state.iconQ.push({ item: item, iv: iv, key: key }); - pumpIconQ(); - } - - function pumpIconQ() { - if (state.destroyed) return; - while (state.iconInFlight < state.iconConc && state.iconQ.length > 0) { - var job = state.iconQ.shift(); - if (!job) continue; - state.iconInFlight++; - - (function(j) { - runOn(bgH, function() { - var dr = null; - try { dr = loadShortcutIconDrawable(j.item); } catch(e0) { dr = null; } - runOn(h, function() { - state.iconInFlight--; - if (state.destroyed) return; - - if (dr) { - try { j.iv.setImageDrawable(dr); } catch(e1) {} - cachePut(j.key, dr); - } else { - markIconFail(j.key); - } - pumpIconQ(); - }); - }); - })(job); - } - } - - function setStat(text) { try { if (state.tvStat) state.tvStat.setText(String(text)); } catch(e) {} } - - function rebuildRenderList() { - var g = state.groups ? state.groups[state.currentGroupKey] : null; - var base = (g && g.items) ? g.items : state.allItems; - var filtered = filterItems(base, state.query); - - state.renderList = filtered; - state.renderCursor = 0; - state.isAppending = false; - state.lastAppendTryTs = 0; - } - - function clearGrid() { - try { - if (state.grid) state.grid.removeAllViews(); - } catch(e) {} - } - - function appendBatch() { - if (state.destroyed) return; - if (!state.grid) return; - - if (state.isAppending) return; - state.isAppending = true; - - var start = state.renderCursor; - var end = Math.min(state.renderCursor + CFG_PAGE_BATCH, state.renderList.length); - - for (var i = start; i < end; i++) { - (function(idx) { - var it = state.renderList[idx]; - - var row = new android.widget.LinearLayout(context); - row.setOrientation(android.widget.LinearLayout.HORIZONTAL); - row.setGravity(android.view.Gravity.CENTER_VERTICAL); - - // # 条目间距:卡片式列表,每条之间留 8dp 间隔 - try { - var lpRow = new android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT); - lpRow.setMargins(0, 0, 0, self.dp(8)); - row.setLayoutParams(lpRow); - } catch(eLpR) {} - - row.setPadding(self.dp(12), self.dp(10), self.dp(12), self.dp(10)); - - // # 行背景:与 ToolHub 卡片色一致,并加轻微描边增强层次 - try { - var isDark = self.isDarkTheme(); - var bgColor = isDark ? self.ui.colors.cardDark : self.ui.colors.cardLight; - var stroke = isDark ? self.ui.colors.dividerDark : self.ui.colors.dividerLight; - row.setBackground(self.ui.createStrokeDrawable(bgColor, stroke, self.dp(1), self.dp(12))); - } catch(eBg) {} - - var iv = new android.widget.ImageView(context); - var lpI = new android.widget.LinearLayout.LayoutParams(self.dp(40), self.dp(40)); - lpI.setMargins(0, 0, self.dp(12), 0); - iv.setLayoutParams(lpI); - try { iv.setImageResource(android.R.drawable.sym_def_app_icon); } catch(eI0) {} - - var key = safeStr(it.pkg) + "@" + safeStr(it.id) + "@" + safeStr(it.userId); - var cached = cacheGet(key); - if (cached) { - try { iv.setImageDrawable(cached); } catch(eIC) {} - } else { - enqueueIconLoad(it, iv, key); - } - - var col = new android.widget.LinearLayout(context); - col.setOrientation(android.widget.LinearLayout.VERTICAL); - var lpC = new android.widget.LinearLayout.LayoutParams(0, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT); - lpC.weight = 1; - col.setLayoutParams(lpC); - - var tv1 = new android.widget.TextView(context); - tv1.setText(safeStr(it.label || it.id || it.pkg)); - tv1.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 14); - try { tv1.setTypeface(null, android.graphics.Typeface.BOLD); } catch(eB) {} - try { tv1.setTextColor(self.isDarkTheme() ? self.ui.colors.textPriDark : self.ui.colors.textPriLight); } catch(eC1) {} - tv1.setSingleLine(true); - tv1.setEllipsize(android.text.TextUtils.TruncateAt.END); - col.addView(tv1); - - var tv2 = new android.widget.TextView(context); - tv2.setText(safeStr(it.pkg) + " / " + safeStr(it.id)); - tv2.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 11); - tv2.setSingleLine(true); - tv2.setEllipsize(android.text.TextUtils.TruncateAt.END); - try { tv2.setTextColor(self.isDarkTheme() ? self.ui.colors.textSecDark : self.ui.colors.textSecLight); } catch(eC2) {} - col.addView(tv2); - - // # pick 模式:额外显示 userId,避免多用户/工作资料混淆 - if (mode === "pick") { - var tv3 = new android.widget.TextView(context); - tv3.setText("userId: " + safeStr(it.userId)); - tv3.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 10); - tv3.setSingleLine(true); - try { tv3.setTextColor(self.isDarkTheme() ? self.ui.colors.textSecDark : self.ui.colors.textSecLight); } catch(eC3) {} - col.addView(tv3); - } - - row.addView(iv); - row.addView(col); - - // 点击:pick 模式回传;browse 模式尝试启动 - row.setOnClickListener(new android.view.View.OnClickListener({ - onClick: function() { - try { - if (mode === "pick") { - if (onPick) onPick({ pkg: it.pkg, shortcutId: it.id, label: it.label, userId: it.userId, intentUri: it.intentUri }); - hide(); - return; - } - // browse:尽力启动(用 LauncherApps.startShortcut) - try { - var la = context.getSystemService(Context.LAUNCHER_APPS_SERVICE); - if (la) { - var uh = android.os.UserHandle.of(parseInt(String(it.userId), 10)); - la.startShortcut(String(it.pkg), String(it.id), null, null, uh); - self.toast("已尝试启动: " + safeStr(it.label || it.id)); - } else { - self.toast("LauncherApps 不可用"); - } - } catch(eStart) { - self.toast("启动失败: " + eStart); - } - } catch(eClick) {} - } - })); - - state.grid.addView(row); - - })(i); - } - - state.renderCursor = end; - state.isAppending = false; - - // 统计 - setStat("快捷方式: " + String(state.renderList.length) + " 已渲染: " + String(state.renderCursor)); - } - - function setupTabs() { - try { - if (!state.tabRow) return; - state.tabRow.removeAllViews(); - var order = state.groupOrder || []; - for (var i = 0; i < order.length; i++) { - (function(key) { - var g = state.groups[key]; - var tv = new android.widget.TextView(context); - tv.setText(safeStr(g ? g.title : key)); - tv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 13); - tv.setPadding(self.dp(12), self.dp(8), self.dp(12), self.dp(8)); - tv.setSingleLine(true); - tv.setEllipsize(android.text.TextUtils.TruncateAt.END); - - var isSel = (key === state.currentGroupKey); - var isDark = self.isDarkTheme(); - - // # Tabs 选中态:用 accent 低透明底 + 更醒目的文字;未选中态:轻描边 - try { - var tPri = isDark ? self.ui.colors.textPriDark : self.ui.colors.textPriLight; - var tSec = isDark ? self.ui.colors.textSecDark : self.ui.colors.textSecLight; - - var bgColor = isSel ? self.withAlpha(self.ui.colors.accent, isDark ? 0.22 : 0.14) - : (isDark ? self.withAlpha(android.graphics.Color.BLACK, 0.10) - : self.withAlpha(android.graphics.Color.BLACK, 0.04)); - var stroke = isDark ? self.ui.colors.dividerDark : self.ui.colors.dividerLight; - - tv.setTextColor(isSel ? tPri : tSec); - tv.setBackground(self.ui.createStrokeDrawable(bgColor, stroke, self.dp(1), self.dp(16))); - } catch(eBg) {} - - tv.setOnClickListener(new android.view.View.OnClickListener({ - onClick: function() { - state.currentGroupKey = key; - rebuildRenderList(); - clearGrid(); - appendBatch(); - setupTabs(); - } - })); - - var lp = new android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.WRAP_CONTENT, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT); - lp.setMargins(0, 0, self.dp(8), 0); - tv.setLayoutParams(lp); - state.tabRow.addView(tv); - })(order[i]); - } - } catch(e) {} - } - - function startScrollPoll() { - if (state.scrollPollRunning) return; - state.scrollPollRunning = true; - - function tick() { - if (!state.scrollPollRunning || state.destroyed) return; - runOn(h, function() { - try { - if (!state.scrollView) { schedule(); return; } - var sv = state.scrollView; - var child = sv.getChildAt(0); - if (!child) { schedule(); return; } - - var dy = sv.getScrollY(); - var vh = sv.getHeight(); - var ch = child.getHeight(); - - // 触底阈值:80dp - var near = (dy + vh) >= (ch - self.dp(80)); - if (near) { - if (state.renderCursor < state.renderList.length) { - appendBatch(); - } - } - } catch(e) {} - schedule(); - }); - } - - function schedule() { - try { - h.postDelayed(new JavaAdapter(Runnable, { run: tick }), 180); - } catch(e) {} - } - - schedule(); - } - - function buildUI() { - // # 这段代码的主要内容/用途:构建"选择快捷方式"页面 UI(颜色/间距/字体与 ToolHub 面板统一)。 - // 说明:尽量复用 ToolHub 的 UI helper 色板(light/dark),避免出现"黑底黑字/黄底刺眼"等问题。 - - // # root(标准面板容器:圆角 + 轻阴影 + 统一 padding) - var root = self.ui.createStyledPanel(self, 12); - - // # 顶栏(标题 + 右侧关闭) - var top = self.ui.createStyledHeader(self, 10); - - var title = new android.widget.TextView(context); - title.setText(mode === "pick" ? "选择快捷方式" : "快捷方式浏览器"); - title.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 16); - try { title.setTypeface(null, android.graphics.Typeface.BOLD); } catch(e) {} - try { title.setTextColor(self.isDarkTheme() ? self.ui.colors.textPriDark : self.ui.colors.textPriLight); } catch(e) {} - - var lpT = new android.widget.LinearLayout.LayoutParams(0, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT); - lpT.weight = 1; - title.setLayoutParams(lpT); - - // 关闭按钮:用 flat 风格,颜色与危险色一致(更像"工具面板"的关闭) - var btnClose = self.ui.createFlatButton(self, (mode === "pick" ? "取消" : "关闭"), self.ui.colors.danger, function() { - hide(); - }); - try { - btnClose.setPadding(self.dp(12), self.dp(6), self.dp(12), self.dp(6)); - btnClose.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 13); - } catch(e) {} - - top.addView(title); - top.addView(btnClose); - - // # 搜索框(可聚焦 + 统一输入背景) - var et = new android.widget.EditText(context); - et.setHint("搜索:名称 / 包名 / ID"); - et.setSingleLine(true); - et.setPadding(self.dp(12), self.dp(10), self.dp(12), self.dp(10)); - try { - et.setTextColor(self.isDarkTheme() ? self.ui.colors.textPriDark : self.ui.colors.textPriLight); - et.setHintTextColor(self.isDarkTheme() ? self.ui.colors.textSecDark : self.ui.colors.textSecLight); - } catch(e) {} - - try { - var inBg = self.ui.createRoundDrawable(self.isDarkTheme() ? self.ui.colors.inputBgDark : self.ui.colors.inputBgLight, self.dp(12)); - et.setBackground(inBg); - } catch(eBg) {} - - // # Tabs(横向滚动:统一为"胶囊按钮"风格,选中态更明确) - var tabSv = new android.widget.HorizontalScrollView(context); - tabSv.setHorizontalScrollBarEnabled(false); - tabSv.setOverScrollMode(android.view.View.OVER_SCROLL_NEVER); - - var tabRow = new android.widget.LinearLayout(context); - tabRow.setOrientation(android.widget.LinearLayout.HORIZONTAL); - try { tabRow.setPadding(0, self.dp(8), 0, self.dp(6)); } catch(e) {} - tabSv.addView(tabRow); - - // # 状态栏(数量/渲染进度) - var tvStat = new android.widget.TextView(context); - tvStat.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 12); - try { tvStat.setTextColor(self.isDarkTheme() ? self.ui.colors.textSecDark : self.ui.colors.textSecLight); } catch(e) {} - tvStat.setPadding(0, self.dp(6), 0, self.dp(6)); - - // # 列表(卡片式条目列表) - var sv = new android.widget.ScrollView(context); - sv.setFillViewport(true); - sv.setOverScrollMode(android.view.View.OVER_SCROLL_NEVER); - - var list = new android.widget.LinearLayout(context); - list.setOrientation(android.widget.LinearLayout.VERTICAL); - try { list.setPadding(0, self.dp(2), 0, self.dp(2)); } catch(e) {} - sv.addView(list); - - // bind - state.root = root; - state.etSearch = et; - state.tabRow = tabRow; - state.tvStat = tvStat; - state.grid = list; - state.scrollView = sv; - - // listeners:实时过滤(TextWatcher) - try { - var TextWatcher = android.text.TextWatcher; - et.addTextChangedListener(new JavaAdapter(TextWatcher, { - beforeTextChanged: function() {}, - onTextChanged: function() {}, - afterTextChanged: function(s) { - try { - if (state.destroyed) return; - state.query = safeStr(s); - rebuildRenderList(); - clearGrid(); - appendBatch(); - } catch(e) {} - } - })); - } catch(eTw) {} - - // layout - root.addView(top); - - // 搜索框与 tabs 之间留一点呼吸空间 - try { - var lpEt = new android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT); - lpEt.setMargins(0, 0, 0, self.dp(8)); - et.setLayoutParams(lpEt); - } catch(eLpEt) {} - root.addView(et); - - root.addView(tabSv); - root.addView(tvStat); - - var lpSv = new android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0); - lpSv.weight = 1; - sv.setLayoutParams(lpSv); - root.addView(sv); - - // 主题:用 ToolHub 现有的背景更新逻辑兜底(防止外部主题配置影响) - try { self.updatePanelBackground(root); } catch(eTheme) {} - - return root; - } - - function show() { - runOn(h, function() { - // # 会话检查:过期会话直接丢弃 - if (!checkSession()) return; - - // # 稳定性:复用已添加的 root,避免频繁 add/removeView 触发 system_server WM 状态机崩溃 - if (state.root && state.isAdded) { - state.hidden = false; - // # 每次显示前允许再次触发 onDismiss(用于外层恢复) - state.onDismissCalled = false; - // # UI 修复:每次重新显示时都重新把窗口定位到屏幕顶部(横向居中 + y=0),避免被其它面板遮挡或位置漂移 - try { - if (state.params) { - state.params.gravity = android.view.Gravity.TOP | android.view.Gravity.START; - // # UI 修复:复用显示时也强制关闭 IME 调整,避免上次输入导致位置被系统推下去 - try { - state.params.softInputMode = android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING - | android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN; - } catch(eSIM1) {} - try { - state.params.flags = state.params.flags - | android.view.WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN - | android.view.WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS; - } catch(eTop1) {} - var sw2 = (self.state && self.state.screen && self.state.screen.w) ? self.state.screen.w : 0; - if (sw2 > 0) state.params.x = Math.max(0, Math.round((sw2 - state.params.width) / 2)); - else state.params.x = 0; - state.params.y = 0; - wm.updateViewLayout(state.root, state.params); - } - } catch(ePos2) {} - - // # 层级修复:同类型(TYPE_APPLICATION_OVERLAY)窗口之间的上下层由 addView 顺序决定 - // # 说明:当"新增按钮页/主面板"在本窗口之后 addView 时,会把本窗口盖住;复用 show 仅 setVisibility 无法提升层级 - // # 处理:在 WM 线程内对本窗口做一次 removeViewImmediate + addView 以"提到最上层"(不在关闭时 remove,避免旧崩溃路径) - try { - var tsNow = now(); - if (!state.lastRaiseTs || (tsNow - state.lastRaiseTs) > 300) { - state.lastRaiseTs = tsNow; - try { wm.removeViewImmediate(state.root); } catch(eZ0) {} - try { wm.addView(state.root, state.params); } catch(eZ1) {} - state.isAdded = true; - } - } catch(eZ2) {} -try { state.root.setVisibility(android.view.View.VISIBLE); } catch(eVis) {} - try { - setStat("正在加载快捷方式..."); - Li("reloading shortcuts index..."); - } catch(e0) {} - try { - state.allItems = buildShortcutItemsIndex(); - var gg = groupItems(state.allItems); - state.groups = gg.groups; - state.groupOrder = gg.order; - } catch(e1) { - state.allItems = []; - state.groups = groupItems([]).groups; - state.groupOrder = ["__ALL__"]; - Le("build index err=" + String(e1)); - } - try { setupTabs(); } catch(eT0) {} - try { rebuildRenderList(); } catch(eT1) {} - try { clearGrid(); } catch(eT2) {} - try { appendBatch(); } catch(eT3) {} - try { startScrollPoll(); } catch(eT4) {} - Li("shortcut picker reused items=" + String(state.allItems.length)); - return; - } - - try { - // build data - setStat("正在加载快捷方式..."); - Li("loading shortcuts index..."); - } catch(e0) {} - - try { - state.allItems = buildShortcutItemsIndex(); - var gg = groupItems(state.allItems); - state.groups = gg.groups; - state.groupOrder = gg.order; - } catch(e1) { - state.allItems = []; - state.groups = groupItems([]).groups; - state.groupOrder = ["__ALL__"]; - Le("build index err=" + String(e1)); - } - - // build UI - var root = buildUI(); - - // wm params - var p = new android.view.WindowManager.LayoutParams(); - p.type = android.view.WindowManager.LayoutParams.TYPE_SYSTEM_DIALOG; // 修改:提升窗口层级,确保多次打开后仍在最顶层 - // # 说明:TYPE_APPLICATION_OVERLAY 与其他面板同层时会按 add 顺序叠放,频繁开关后容易被后开的面板盖住。 - // # TYPE_SYSTEM_DIALOG 层级更高,可稳定压过 ToolHub 其他面板(仍不频繁 add/remove,避免崩溃)。 - p.flags = android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; - p.format = android.graphics.PixelFormat.TRANSLUCENT; - // # UI 修复:固定在顶部显示时,避免被输入法/系统 inset 影响导致二次打开位置下移 - // # 说明:不使用 ADJUST_RESIZE/ADJUST_PAN,让窗口位置不被 IME 推动;同时默认隐藏软键盘状态 - try { - p.softInputMode = android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING - | android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN; - } catch(eSIM0) {} - // # 允许窗口覆盖到屏幕顶部区域(含状态栏区域),避免视觉上"不是贴顶" - try { - p.flags = p.flags - | android.view.WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN - | android.view.WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS; - } catch(eTop0) {} - p.width = self.dp(340); - p.height = self.dp(520); - // # UI 修复:选择快捷方式页应贴近屏幕顶部显示(与"新增按钮页"的顶部布局一致),而不是居中 - // # 说明:居中会让用户感觉"弹窗不是在顶部",也更容易被其它面板遮挡。 - p.gravity = android.view.Gravity.TOP | android.view.Gravity.START; - try { - var sw = (self.state && self.state.screen && self.state.screen.w) ? self.state.screen.w : 0; - // 顶部显示:水平居中 + y=0 - if (sw > 0) p.x = Math.max(0, Math.round((sw - p.width) / 2)); - else p.x = 0; - p.y = 0; - } catch(ePos) { - p.x = 0; - p.y = 0; - } - - // 允许输入法:搜索框要能聚焦 - try { - p.flags = p.flags & (~android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE); - } catch(eF) {} - - state.params = p; - - try { - wm.addView(root, p); - state.isAdded = true; - } catch(eAdd) { - Le("wm addView err=" + String(eAdd)); - destroy(); - self.toast("快捷方式选择器打开失败: wm_add_failed"); - return; - } - - // tabs + first render - setupTabs(); - rebuildRenderList(); - appendBatch(); - startScrollPoll(); - - Li("shortcut picker shown items=" + String(state.allItems.length)); - }); - } - - // 入口 - // # 创建单例 API:后续再次打开直接复用已添加的 root 与线程,只做"显示/刷新",不做 removeView - var api = { - instanceId: state.instanceId, - show: function(newOpts) { - try { - var o = newOpts || {}; - mode = (o.mode != null) ? String(o.mode) : mode; - onPick = (typeof o.onPick === "function") ? o.onPick : onPick; - } catch(eOpt) {} - // # 显示前先把隐藏标记清掉 - try { state.hidden = false; } catch(eH0) {} - show(); - }, - hide: hide, - destroy: destroy - }; - try { self.__shortcutPickerSingleton = api; } catch(eSet) {} - api.show(opts); -}; - -// =======================【Content:解析 settings URI】====================== -// 这段代码的主要内容/用途:识别 content://settings/(system|secure|global)/KEY 并用 Settings.* get/put 更稳 -FloatBallAppWM.prototype.parseSettingsUri = function(uriStr) { - try { - var s = String(uriStr || ""); - if (s.indexOf("content://settings/") !== 0) return null; - - // content://settings/system/accelerometer_rotation - var rest = s.substring("content://settings/".length); - var parts = rest.split("/"); - if (!parts || parts.length < 1) return null; - - var table = String(parts[0] || ""); - var key = ""; - if (parts.length >= 2) key = String(parts[1] || ""); - - if (table !== "system" && table !== "secure" && table !== "global") return null; - return { table: table, key: key }; - } catch (e) { return null; } -}; - -FloatBallAppWM.prototype.settingsGetStringByTable = function(table, key) { - try { - var cr = context.getContentResolver(); - if (table === "system") return android.provider.Settings.System.getString(cr, String(key)); - if (table === "secure") return android.provider.Settings.Secure.getString(cr, String(key)); - if (table === "global") return android.provider.Settings.Global.getString(cr, String(key)); - return null; - } catch (e) { return null; } -}; - -FloatBallAppWM.prototype.settingsPutStringByTable = function(table, key, value) { - try { - var cr = context.getContentResolver(); - if (table === "system") return android.provider.Settings.System.putString(cr, String(key), String(value)); - if (table === "secure") return android.provider.Settings.Secure.putString(cr, String(key), String(value)); - if (table === "global") return android.provider.Settings.Global.putString(cr, String(key), String(value)); - return false; - } catch (e) { return false; } -}; - -// =======================【Content:通用 query】====================== -// 这段代码的主要内容/用途:ContentResolver.query 并把 Cursor 转成文本(用于查看器面板) -FloatBallAppWM.prototype.contentQueryToText = function(uriStr, projection, selection, selectionArgs, sortOrder, maxRows) { - var out = { ok: false, uri: String(uriStr || ""), rows: 0, text: "", err: "" }; - var cr = null; - var cur = null; - - try { - cr = context.getContentResolver(); - var uri = android.net.Uri.parse(String(uriStr)); - - var projArr = null; - if (projection && projection.length) { - projArr = java.lang.reflect.Array.newInstance(java.lang.String, projection.length); - var i0; - for (i0 = 0; i0 < projection.length; i0++) projArr[i0] = String(projection[i0]); - } - - var sel = (selection === undefined || selection === null) ? null : String(selection); - - var selArgsArr = null; - if (selectionArgs && selectionArgs.length) { - selArgsArr = java.lang.reflect.Array.newInstance(java.lang.String, selectionArgs.length); - var i1; - for (i1 = 0; i1 < selectionArgs.length; i1++) selArgsArr[i1] = String(selectionArgs[i1]); - } - - var so = (sortOrder === undefined || sortOrder === null) ? null : String(sortOrder); - - cur = cr.query(uri, projArr, sel, selArgsArr, so); - if (!cur) { - out.err = "query return null cursor"; - return out; - } - - var colCount = cur.getColumnCount(); - var cols = []; - var ci; - for (ci = 0; ci < colCount; ci++) cols.push(String(cur.getColumnName(ci))); - - var sb = []; - sb.push("URI: " + String(uriStr)); - sb.push("Columns(" + String(colCount) + "): " + cols.join(", ")); - sb.push(""); - - var limit = Math.max(1, Math.floor(Number(maxRows || this.config.CONTENT_MAX_ROWS || 20))); - var row = 0; - - while (cur.moveToNext()) { - row++; - sb.push("#" + String(row)); - var cj; - for (cj = 0; cj < colCount; cj++) { - var v = ""; - try { - if (cur.isNull(cj)) v = "null"; - else v = String(cur.getString(cj)); - } catch (eV) { - try { v = String(cur.getLong(cj)); } catch (eV2) { v = ""; } - } - sb.push(" " + cols[cj] + " = " + v); - } - sb.push(""); - if (row >= limit) break; - } - - out.ok = true; - out.rows = row; - out.text = sb.join("\n"); - return out; - } catch (e) { - out.err = String(e); - return out; - } finally { - try { if (cur) cur.close(); } catch (eC) {} - } -}; - -// =======================【Content:统一入口】====================== -// 这段代码的主要内容/用途:处理按钮里的 type:"content" -FloatBallAppWM.prototype.execContentAction = function(btn) { - var mode = btn.mode ? String(btn.mode) : ((btn.value !== undefined && btn.value !== null) ? "put" : "get"); - var uri = btn.uri ? String(btn.uri) : ""; - if (!uri) return { ok: false, err: "missing uri" }; - - // settings uri 优先走 Settings API - var su = this.parseSettingsUri(uri); - - if (mode === "get") { - if (su && su.key) { - var v = this.settingsGetStringByTable(su.table, su.key); - return { ok: true, mode: "get", kind: "settings", table: su.table, key: su.key, value: (v === null ? "null" : String(v)) }; - } - - // 非 settings:尝试 query 一行 - var q1 = this.contentQueryToText(uri, btn.projection, btn.selection, btn.selectionArgs, btn.sortOrder, 1); - if (!q1.ok) return { ok: false, mode: "get", kind: "query", err: q1.err }; - return { ok: true, mode: "get", kind: "query", text: q1.text, rows: q1.rows }; - } - - if (mode === "put") { - var val = (btn.value === undefined || btn.value === null) ? "" : String(btn.value); - - if (su && su.key) { - var ok = this.settingsPutStringByTable(su.table, su.key, val); - return { ok: !!ok, mode: "put", kind: "settings", table: su.table, key: su.key, value: val }; - } - - // 非 settings:尽力走 update(ContentValues) - try { - var cr = context.getContentResolver(); - var u = android.net.Uri.parse(uri); - var cv = new android.content.ContentValues(); - - // 支持 btn.values = {col1: "...", col2: "..."};否则写 value 列 - if (btn.values) { - var k; - for (k in btn.values) { - if (!btn.values.hasOwnProperty(k)) continue; - cv.put(String(k), String(btn.values[k])); - } - } else { - cv.put("value", val); - } - - var where = (btn.selection === undefined || btn.selection === null) ? null : String(btn.selection); - - var whereArgs = null; - if (btn.selectionArgs && btn.selectionArgs.length) { - whereArgs = java.lang.reflect.Array.newInstance(java.lang.String, btn.selectionArgs.length); - var i2; - for (i2 = 0; i2 < btn.selectionArgs.length; i2++) whereArgs[i2] = String(btn.selectionArgs[i2]); - } - - var n = cr.update(u, cv, where, whereArgs); - return { ok: true, mode: "put", kind: "update", updated: Number(n) }; - } catch (eU) { - return { ok: false, mode: "put", kind: "update", err: String(eU) }; - } - } - - if (mode === "query") { - var maxRows = (btn.maxRows === undefined || btn.maxRows === null) ? this.config.CONTENT_MAX_ROWS : Number(btn.maxRows); - var q = this.contentQueryToText(uri, btn.projection, btn.selection, btn.selectionArgs, btn.sortOrder, maxRows); - if (!q.ok) return { ok: false, mode: "query", err: q.err }; - return { ok: true, mode: "query", rows: q.rows, text: q.text }; - } - - if (mode === "view") { - try { - var it = new android.content.Intent(android.content.Intent.ACTION_VIEW); - it.setData(android.net.Uri.parse(uri)); - it.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK); - context.startActivity(it); - return { ok: true, mode: "view" }; - } catch (eV) { - return { ok: false, mode: "view", err: String(eV) }; - } - } - - return { ok: false, err: "unknown mode=" + mode }; -}; - -/* ======================= - 下面开始:WM 动画、面板、触摸、启动、输出 - ======================= */ - -FloatBallAppWM.prototype.animateBallLayout = function(toX, toY, toW, durMs, endCb) { - var st = this.state; - if (!st.addedBall || !st.ballRoot || !st.ballLp) { if (endCb) endCb(); return; } - - var fromX = st.ballLp.x; - var fromY = st.ballLp.y; - var fromW = st.ballLp.width; - - try { - var va = android.animation.ValueAnimator.ofFloat(0.0, 1.0); - va.setDuration(durMs); - try { - // 使用 OvershootInterpolator 产生轻微的回弹效果,更加生动 - // 0.7 的张力适中,不会过于夸张 - va.setInterpolator(new android.view.animation.OvershootInterpolator(0.7)); - } catch (eI) { - try { va.setInterpolator(new android.view.animation.DecelerateInterpolator()); } catch (eI2) {} - } - - var self = this; - - va.addUpdateListener(new android.animation.ValueAnimator.AnimatorUpdateListener({ - onAnimationUpdate: function(anim) { - try { - if (self.state.closing) return; - if (!self.state.addedBall) return; - - var f = anim.getAnimatedValue(); - var nx = Math.round(fromX + (toX - fromX) * f); - var ny = Math.round(fromY + (toY - fromY) * f); - var nw = Math.round(fromW + (toW - fromW) * f); - - // 性能优化:只有坐标真正变化时才请求 WindowManager 更新 - if (nx !== self.state.ballLp.x || ny !== self.state.ballLp.y || nw !== self.state.ballLp.width) { - self.state.ballLp.x = nx; - self.state.ballLp.y = ny; - self.state.ballLp.width = nw; - // # 关键操作使用 safeOperation 封装 - safeOperation("dockAnimation.updateViewLayout", function() { - self.state.wm.updateViewLayout(self.state.ballRoot, self.state.ballLp); - }, true, self.L); - } - } catch (e) {} - } - })); - - va.addListener(new android.animation.Animator.AnimatorListener({ - onAnimationStart: function() {}, - onAnimationRepeat: function() {}, - onAnimationCancel: function() {}, - onAnimationEnd: function() { - try { - if (!self.state.closing && self.state.addedBall) { - self.state.ballLp.x = toX; - self.state.ballLp.y = toY; - self.state.ballLp.width = toW; - self.state.wm.updateViewLayout(self.state.ballRoot, self.state.ballLp); - self.savePos(self.state.ballLp.x, self.state.ballLp.y); - } - } catch (e2) {} - try { if (endCb) endCb(); } catch (eCb) { try { if (self && self.L && self.L.e) self.L.e("animateBallLayout endCb err=" + String(eCb)); } catch (eLog) {} } - } - })); - - va.start(); - } catch (e0) { - try { - st.ballLp.x = toX; - st.ballLp.y = toY; - st.ballLp.width = toW; - st.wm.updateViewLayout(st.ballRoot, st.ballLp); - this.savePos(st.ballLp.x, st.ballLp.y); - } catch (e1) {} - try { if (endCb) endCb(); } catch (eCb2) { try { if (this && this.L && this.L.e) this.L.e("animateBallLayout endCb err=" + String(eCb2)); } catch (eLog2) {} } - } -}; - -FloatBallAppWM.prototype.playBounce = function(v) { - if (!this.config.ENABLE_BOUNCE) return; - if (!this.config.ENABLE_ANIMATIONS) return; - - try { v.animate().cancel(); } catch (e0) {} - - var self = this; - var i = 0; - - function step() { - if (self.state.closing) return; - - if (i >= self.config.BOUNCE_TIMES) { - try { v.setScaleX(1); v.setScaleY(1); } catch (e2) {} - return; - } - - var amp = (self.config.BOUNCE_MAX_SCALE - 1) * Math.pow(self.config.BOUNCE_DECAY, i); - var s = 1 + amp; - - v.animate() - .scaleX(s) - .scaleY(s) - .setDuration(self.config.BOUNCE_STEP_MS) - .setInterpolator(new android.view.animation.OvershootInterpolator()) - .withEndAction(new JavaAdapter(java.lang.Runnable, { - run: function() { - v.animate() - .scaleX(1) - .scaleY(1) - .setDuration(self.config.BOUNCE_STEP_MS) - .setInterpolator(new android.view.animation.AccelerateDecelerateInterpolator()) - .withEndAction(new JavaAdapter(java.lang.Runnable, { - run: function() { i++; step(); } - })) - .start(); - } - })) - .start(); - } - - step(); -}; - -FloatBallAppWM.prototype.safeRemoveView = function(v, whichName) { - try { - if (!v) return { ok: true, skipped: true }; - this.state.wm.removeView(v); - return { ok: true }; - } catch (e) { - safeLog(this.L, 'w', "removeView fail which=" + String(whichName || "") + " err=" + String(e)); - return { ok: false, err: String(e), where: whichName || "" }; - } -}; - -FloatBallAppWM.prototype.hideMask = function() { - if (!this.state.addedMask) return; - if (!this.state.mask) return; - - this.safeRemoveView(this.state.mask, "mask"); - this.state.mask = null; - this.state.maskLp = null; - this.state.addedMask = false; -}; - -FloatBallAppWM.prototype.hideMainPanel = function() { - if (!this.state.addedPanel) return; - if (!this.state.panel) return; - - this.safeRemoveView(this.state.panel, "panel"); - this.state.panel = null; - this.state.panelLp = null; - this.state.addedPanel = false; - - this.hideMask(); - this.touchActivity(); - - this._clearHeavyCachesIfAllHidden("hideMainPanel"); -}; - -FloatBallAppWM.prototype.hideSettingsPanel = function() { - if (!this.state.addedSettings) return; - if (!this.state.settingsPanel) return; - - this.safeRemoveView(this.state.settingsPanel, "settingsPanel"); - this.state.settingsPanel = null; - this.state.settingsPanelLp = null; - this.state.addedSettings = false; - - this.state.pendingUserCfg = null; - this.state.pendingDirty = false; - - this.hideMask(); - this.touchActivity(); - - this._clearHeavyCachesIfAllHidden("hideSettingsPanel"); -}; - -FloatBallAppWM.prototype.hideViewerPanel = function() { - if (!this.state.addedViewer) return; - if (!this.state.viewerPanel) return; - - this.safeRemoveView(this.state.viewerPanel, "viewerPanel"); - this.state.viewerPanel = null; - this.state.viewerPanelLp = null; - this.state.addedViewer = false; - - this.hideMask(); - this.touchActivity(); - - this._clearHeavyCachesIfAllHidden("hideViewerPanel"); -}; - -FloatBallAppWM.prototype.clearHeavyCaches = function(reason) { - // 这段代码的主要内容/用途:在所有面板都关闭后,主动清理"图标/快捷方式"等重缓存,降低 system_server 常驻内存。 - // 说明:仅清理缓存引用,不强行 recycle Bitmap,避免误伤仍被使用的 Drawable。 - - // # 防抖:5秒内相同 reason 不重复清理 - var now = Date.now(); - var cacheKey = "_lastClear_" + (reason || "default"); - var lastClear = this.state[cacheKey] || 0; - if (now - lastClear < 5000) { - return; // 5秒内已清理过,跳过 - } - this.state[cacheKey] = now; - - try { this._iconLru = null; } catch (eLruClr) {} - try { this._shortcutIconFailTs = {}; } catch (e2) {} - - // # Shortcuts 相关全局缓存(按钮编辑页/快捷方式选择器可能会创建) - try { if (typeof __scIconCache !== "undefined") __scIconCache = {}; } catch (e3) {} - try { if (typeof __scAppLabelCache !== "undefined") __scAppLabelCache = {}; } catch (e4) {} - - // # 记录一次清理日志(精简:只记录关键 reason,且 5秒防抖) - var keyReasons = ["memory_pressure", "screen_changed", "close"]; - var isKeyReason = keyReasons.indexOf(reason) >= 0; - try { - if (isKeyReason && this.L && this.L.i) { - this.L.i("clearHeavyCaches reason=" + String(reason)); - } - } catch (e5) {} -}; - -FloatBallAppWM.prototype._clearHeavyCachesIfAllHidden = function(reason) { - // 这段代码的主要内容/用途:只在"主面板/设置/查看器"全部关闭后清理缓存,避免页面切换时反复重建导致卡顿。 - try { - if (!this.state.addedPanel && !this.state.addedSettings && !this.state.addedViewer) { - this.clearHeavyCaches(reason || "all_hidden"); - } - } catch (e) {} -}; - -FloatBallAppWM.prototype.hideAllPanels = function() { - this.hideMainPanel(); - this.hideSettingsPanel(); - this.hideViewerPanel(); - this.hideMask(); - - this._clearHeavyCachesIfAllHidden("hideAllPanels"); -}; - -FloatBallAppWM.prototype.showMask = function() { - if (this.state.addedMask) return; - if (this.state.closing) return; - - var self = this; - var mask = new android.widget.FrameLayout(context); - - // 遮罩层背景:轻微的黑色半透明,提升层次感 - try { mask.setBackgroundColor(android.graphics.Color.parseColor("#33000000")); } catch (e0) { - mask.setBackgroundColor(0x33000000); - } - - mask.setOnTouchListener(new JavaAdapter(android.view.View.OnTouchListener, { - onTouch: function(v, e) { - var a = e.getAction(); - if (a === android.view.MotionEvent.ACTION_DOWN) { - self.touchActivity(); - self.hideAllPanels(); - return true; - } - return true; - } - })); - - var lp = new android.view.WindowManager.LayoutParams( - android.view.WindowManager.LayoutParams.MATCH_PARENT, - android.view.WindowManager.LayoutParams.MATCH_PARENT, - android.view.WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, - android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, - android.graphics.PixelFormat.TRANSLUCENT - ); - - lp.gravity = android.view.Gravity.TOP | android.view.Gravity.START; - lp.x = 0; - lp.y = 0; - - try { - this.state.wm.addView(mask, lp); - this.state.mask = mask; - this.state.maskLp = lp; - this.state.addedMask = true; - - // 简单的淡入动画 - try { - if (this.config.ENABLE_ANIMATIONS) { - mask.setAlpha(0); - mask.animate().alpha(1).setDuration(200).start(); - } else { - mask.setAlpha(1); - } - } catch(eAnim){} - - } catch (e1) { - safeLog(this.L, 'e', "add mask fail err=" + String(e1)); - this.state.addedMask = false; - } -}; - -FloatBallAppWM.prototype.snapToEdgeDocked = function(withAnim, forceSide) { - if (this.state.closing) return; - // 移除对面板/Mask的检查,允许在任何情况下强制吸边(如果调用方逻辑正确) - // 如果需要保护,调用方自己判断 - if (this.state.dragging) return; - - var di = this.getDockInfo(); - var ballSize = di.ballSize; - var visible = di.visiblePx; - var hidden = di.hiddenPx; - - var snapLeft; - if (forceSide === "left") snapLeft = true; - else if (forceSide === "right") snapLeft = false; - else { - // 默认根据中心点判断 - var centerX = this.state.ballLp.x + Math.round(ballSize / 2); - snapLeft = centerX < Math.round(this.state.screen.w / 2); - } - - var targetW = visible; - var targetY = this.clamp(this.state.ballLp.y, 0, this.state.screen.h - ballSize); - - if (snapLeft) { - this.state.dockSide = "left"; - this.state.docked = true; - - try { this.state.ballContent.setX(-hidden); } catch (eL) {} - - if (withAnim) { - this.animateBallLayout(0, targetY, targetW, this.config.DOCK_ANIM_MS, null); - } else { - this.state.ballLp.x = 0; - this.state.ballLp.y = targetY; - this.state.ballLp.width = targetW; - try { this.state.wm.updateViewLayout(this.state.ballRoot, this.state.ballLp); } catch (eU1) {} - this.savePos(this.state.ballLp.x, this.state.ballLp.y); - } - - safeLog(this.L, 'd', "dock left x=0 y=" + String(targetY) + " w=" + String(targetW)); - - // 闲置变暗 - try { - if (this.config.ENABLE_ANIMATIONS) { - this.state.ballContent.animate().alpha(this.config.BALL_IDLE_ALPHA).setDuration(300).start(); - } else { - this.state.ballContent.setAlpha(this.config.BALL_IDLE_ALPHA); - } - } catch(eA) {} - - return; - } - - this.state.dockSide = "right"; - this.state.docked = true; - - try { this.state.ballContent.setX(0); } catch (eR) {} - - var x2 = this.state.screen.w - visible; - - if (withAnim) { - this.animateBallLayout(x2, targetY, targetW, this.config.DOCK_ANIM_MS, null); - } else { - this.state.ballLp.x = x2; - this.state.ballLp.y = targetY; - this.state.ballLp.width = targetW; - try { this.state.wm.updateViewLayout(this.state.ballRoot, this.state.ballLp); } catch (eU2) {} - this.savePos(this.state.ballLp.x, this.state.ballLp.y); - } - - // # 日志精简:dock 事件添加防抖(10秒内不重复记录相同边) - var dockNow = Date.now(); - var lastDock = this.state._lastDockLog || 0; - if (dockNow - lastDock > 10000) { - safeLog(this.L, 'i', "dock right x=" + String(x2) + " y=" + String(targetY)); - this.state._lastDockLog = dockNow; - } - - // 闲置变暗 - try { - if (this.config.ENABLE_ANIMATIONS) { - this.state.ballContent.animate().alpha(this.config.BALL_IDLE_ALPHA).setDuration(300).start(); - } else { - this.state.ballContent.setAlpha(this.config.BALL_IDLE_ALPHA); - } - } catch(eA) {} -}; - -FloatBallAppWM.prototype.undockToFull = function(withAnim, endCb) { - if (this.state.closing) { if (endCb) endCb(); return; } - if (!this.state.docked) { if (endCb) endCb(); return; } - if (!this.state.addedBall) { if (endCb) endCb(); return; } - - var di = this.getDockInfo(); - var ballSize = di.ballSize; - var targetW = ballSize; - var targetY = this.clamp(this.state.ballLp.y, 0, this.state.screen.h - ballSize); - - try { this.state.ballContent.setX(0); } catch (e0) {} - - if (this.state.dockSide === "left") { - this.state.docked = false; - this.state.dockSide = null; - - if (withAnim) this.animateBallLayout(0, targetY, targetW, this.config.UNDOCK_ANIM_MS, endCb); - else { - this.state.ballLp.x = 0; - this.state.ballLp.y = targetY; - this.state.ballLp.width = targetW; - try { this.state.wm.updateViewLayout(this.state.ballRoot, this.state.ballLp); } catch (eU1) {} - this.savePos(this.state.ballLp.x, this.state.ballLp.y); - if (endCb) endCb(); - } - - // 恢复不透明度 - try { - if (withAnim && this.config.ENABLE_ANIMATIONS) { - this.state.ballContent.animate().alpha(1.0).setDuration(150).start(); - } else { - this.state.ballContent.setAlpha(1.0); - } - } catch(eA) {} - - safeLog(this.L, 'i', "undock from left"); - return; - } - - var x = this.state.screen.w - ballSize; - - this.state.docked = false; - this.state.dockSide = null; - - if (withAnim) this.animateBallLayout(x, targetY, targetW, this.config.UNDOCK_ANIM_MS, endCb); - else { - this.state.ballLp.x = x; - this.state.ballLp.y = targetY; - this.state.ballLp.width = targetW; - try { this.state.wm.updateViewLayout(this.state.ballRoot, this.state.ballLp); } catch (eU2) {} - this.savePos(this.state.ballLp.x, this.state.ballLp.y); - if (endCb) endCb(); - } - - // 恢复不透明度 - try { - if (withAnim && this.config.ENABLE_ANIMATIONS) { - this.state.ballContent.animate().alpha(1.0).setDuration(150).start(); - } else { - this.state.ballContent.setAlpha(1.0); - } - } catch(eA) {} - - // # 日志精简:undock 事件改为 INFO 级别,且记录方向 - var undockSide = this.state.dockSide || "right"; - safeLog(this.L, 'i', "undock from " + undockSide); -}; - -FloatBallAppWM.prototype.cancelDockTimer = function() { - try { if (this.state.idleDockRunnable && this.state.h) this.state.h.removeCallbacks(this.state.idleDockRunnable); } catch (e) {} - this.state.idleDockRunnable = null; -}; - -FloatBallAppWM.prototype.armDockTimer = function() { - if (this.state.closing) return; - if (!this.state.h) return; - if (!this.state.addedBall) return; - if (this.state.docked) return; - - this.cancelDockTimer(); - - var hasPanel = (this.state.addedPanel || this.state.addedSettings || this.state.addedViewer || this.state.addedMask); - var targetMs = hasPanel ? this.config.PANEL_IDLE_CLOSE_AND_DOCK_MS : this.config.DOCK_AFTER_IDLE_MS; - - var self = this; - - this.state.idleDockRunnable = new java.lang.Runnable({ - run: function() { - try { - if (self.state.closing) return; - if (self.state.docked) return; - if (self.state.dragging) return; - - var hasPanel2 = (self.state.addedPanel || self.state.addedSettings || self.state.addedViewer || self.state.addedMask); - var needMs = hasPanel2 ? self.config.PANEL_IDLE_CLOSE_AND_DOCK_MS : self.config.DOCK_AFTER_IDLE_MS; - - var idle = self.now() - self.state.lastMotionTs; - if (idle < needMs) { self.armDockTimer(); return; } - - // if (hasPanel2) self.hideAllPanels(); // 用户要求不再自动关闭面板 - if (self.config.ENABLE_SNAP_TO_EDGE) { - self.snapToEdgeDocked(true); - } - } catch (e0) { - if (self.L) self.L.e("dockTimer run err=" + String(e0)); - } - } - }); - - this.state.h.postDelayed(this.state.idleDockRunnable, targetMs); -}; - -FloatBallAppWM.prototype.touchActivity = function() { - this.state.lastMotionTs = this.now(); - this.armDockTimer(); -} - -// # 点击防抖与安全执行 -// 这段代码的主要内容/用途:防止在悬浮面板上快速/乱点导致重复 add/remove、状态机被打穿,从而引发 system_server 异常重启。 -FloatBallAppWM.prototype.guardClick = function(key, cooldownMs, fn) { - try { - var now = android.os.SystemClock.uptimeMillis(); - if (!this.state._clickGuards) this.state._clickGuards = {}; - var last = this.state._clickGuards[key] || 0; - var cd = (cooldownMs != null ? cooldownMs : INTERACTION_CONSTANTS.CLICK_COOLDOWN_MS); - if (now - last < cd) return false; - this.state._clickGuards[key] = now; - try { - fn && fn(); - } catch (e1) { - safeLog(this.L, 'e', "guardClick err key=" + String(key) + " err=" + String(e1)); - } - return true; - } catch (e0) { - // 兜底:绝不让点击回调异常冒泡到 system_server - try { fn && fn(); } catch (e2) {} - return true; - } -}; - -FloatBallAppWM.prototype.safeUiCall = function(tag, fn) { - try { - fn && fn(); - } catch (e) { - safeLog(this.L, 'e', "safeUiCall err tag=" + String(tag || "") + " err=" + String(e)); - } -}; - -; - -FloatBallAppWM.prototype.onScreenChangedReflow = function() { - if (this.state.closing) return; - if (!this.state.addedBall) return; - - var di = this.getDockInfo(); - - var oldW = this.state.screen.w; - var oldH = this.state.screen.h; - - var newScreen = this.getScreenSizePx(); - var newW = newScreen.w; - var newH = newScreen.h; - - if (newW <= 0 || newH <= 0) return; - if (oldW <= 0) oldW = newW; - if (oldH <= 0) oldH = newH; - - this.state.screen = { w: newW, h: newH }; - - var ballSize = di.ballSize; - var visible = di.visiblePx; - var hidden = di.hiddenPx; - - var oldMaxX = Math.max(1, oldW - ballSize); - var oldMaxY = Math.max(1, oldH - ballSize); - var newMaxX = Math.max(1, newW - ballSize); - var newMaxY = Math.max(1, newH - ballSize); - - var xRatio = this.state.ballLp.x / oldMaxX; - var yRatio = this.state.ballLp.y / oldMaxY; - - var mappedX = Math.round(xRatio * newMaxX); - var mappedY = Math.round(yRatio * newMaxY); - - mappedX = this.clamp(mappedX, 0, newMaxX); - mappedY = this.clamp(mappedY, 0, newMaxY); - - if (this.state.docked) { - this.state.ballLp.y = mappedY; - this.state.ballLp.width = visible; - - if (this.state.dockSide === "left") { - this.state.ballLp.x = 0; - try { this.state.ballContent.setX(-hidden); } catch (eL) {} - } else { - this.state.ballLp.x = newW - visible; - try { this.state.ballContent.setX(0); } catch (eR) {} - } - // 重新进入闲置变暗逻辑(如果需要) - try { this.state.ballContent.setAlpha(this.config.BALL_IDLE_ALPHA); } catch(eA) {} - } else { - this.state.ballLp.x = mappedX; - this.state.ballLp.y = mappedY; - this.state.ballLp.width = ballSize; - try { this.state.ballContent.setX(0); } catch (e0) {} - try { this.state.ballContent.setAlpha(1.0); } catch(eA) {} - } - - try { this.state.wm.updateViewLayout(this.state.ballRoot, this.state.ballLp); } catch (eU) {} - this.savePos(this.state.ballLp.x, this.state.ballLp.y); - - safeLog(this.L, 'i', "screen reflow w=" + String(newW) + " h=" + String(newH) + " x=" + String(this.state.ballLp.x) + " y=" + String(this.state.ballLp.y)); -}; - -FloatBallAppWM.prototype.setupDisplayMonitor = function() { - if (this.state.closing) return; - - try { - var dm = context.getSystemService(android.content.Context.DISPLAY_SERVICE); - if (!dm) return; - - this.state.dm = dm; - this.state.lastRotation = this.getRotation(); - - var self = this; - - var listener = new JavaAdapter(android.hardware.display.DisplayManager.DisplayListener, { - onDisplayAdded: function(displayId) {}, - onDisplayRemoved: function(displayId) {}, - onDisplayChanged: function(displayId) { - try { - if (self.state.closing) return; - var nowTs = self.now(); - if (nowTs - self.state.lastMonitorTs < self.config.SCREEN_MONITOR_THROTTLE_MS) return; - self.state.lastMonitorTs = nowTs; - - self.state.h.post(new JavaAdapter(java.lang.Runnable, { - run: function() { - try { - if (self.state.closing) return; - if (!self.state.addedBall) return; - - var rot = self.getRotation(); - var sz = self.getScreenSizePx(); - - var changed = false; - if (rot !== self.state.lastRotation) { self.state.lastRotation = rot; changed = true; } - if (sz.w !== self.state.screen.w || sz.h !== self.state.screen.h) changed = true; - - if (changed) { - self.cancelDockTimer(); - self.onScreenChangedReflow(); - self.touchActivity(); - } - } catch (e1) { - if (self.L) self.L.e("displayChanged run err=" + String(e1)); - } - } - })); - } catch (e0) {} - } - }); - - this.state.displayListener = listener; - dm.registerDisplayListener(listener, this.state.h); - safeLog(this.L, 'i', "display monitor registered"); - } catch (e2) { - safeLog(this.L, 'e', "setupDisplayMonitor err=" + String(e2)); - } -}; - -FloatBallAppWM.prototype.stopDisplayMonitor = function() { - try { if (this.state.dm && this.state.displayListener) this.state.dm.unregisterDisplayListener(this.state.displayListener); } catch (e) {} - this.state.displayListener = null; - this.state.dm = null; -}; - -// =======================【Shell:智能执行(Action优先 + 广播桥兜底)】====================== -// 这段代码的主要内容/用途:执行 Shell 按钮动作时,优先尝试 ShortX 的 ShellCommand Action(如可用);失败则走自定义广播桥(由外部接收器实际执行)。 -// 注意:system_server 进程本身不直接执行 shell;这里只负责"触发执行"。 -// 这段代码的主要内容/用途:通过广播桥触发 Shell 执行(仅广播桥,不再使用 ShellCommand Action)。 -// 注意:system_server 进程本身不直接执行 shell;外部接收器负责实际执行。 -FloatBallAppWM.prototype.execShellSmart = function(cmdB64, needRoot) { - var ret = { ok: false, via: "", err: "" }; - - try { - var action = String(this.config.SHELL_BRIDGE_ACTION || CONST_SHELL_BRIDGE_ACTION || "shortx.toolhub.SHELL"); - var it = new android.content.Intent(action); - - // # 固定广播协议:cmd_b64 + root + from(不再发送明文 cmd,避免协议漂移) - it.putExtra(CONST_SHELL_BRIDGE_EXTRA_CMD, String(cmdB64)); - it.putExtra(CONST_SHELL_BRIDGE_EXTRA_ROOT, !!needRoot); - // # from:来源标记,仅用于接收端识别/日志,不参与权限判断 - it.putExtra(CONST_SHELL_BRIDGE_EXTRA_FROM, "ToolHub"); - - context.sendBroadcast(it); - - ret.ok = true; - ret.via = "BroadcastBridge"; - safeLog(this.L, 'i', "shell via broadcast ok action=" + action + " root=" + String(!!needRoot)); - } catch (eB) { - ret.err = "Broadcast err=" + String(eB); - safeLog(this.L, 'e', "shell via broadcast fail err=" + String(eB)); - } - - return ret; -}; - - -// =======================【WM 线程:按钮动作执行】====================== -FloatBallAppWM.prototype.execButtonAction = function(btn, idx) { - // # 点击防抖 - // 这段代码的主要内容/用途:防止在按钮面板上连续/乱点导致重复执行与 UI 状态机冲突(可能触发 system_server 异常重启)。 - if (!this.guardClick("btn_exec_" + String(idx), 380, null)) return; - - try { - if (!btn || !btn.type) { - this.toast("按钮#" + idx + " 未配置"); - safeLog(this.L, 'w', "btn#" + String(idx) + " no type"); - return; - } - - var t = String(btn.type); - safeLog(this.L, 'i', "btn click idx=" + String(idx) + " type=" + t + " title=" + String(btn.title || "")); - - if (t === "open_settings") { - this.showPanelAvoidBall("settings"); - return; - } - - if (t === "open_viewer") { - var logPath = (this.L && this.L._filePathForToday) ? this.L._filePathForToday() : ""; - if (!logPath) logPath = PATH_LOG_DIR + "/ShortX_ToolHub_" + (new java.text.SimpleDateFormat("yyyyMMdd").format(new java.util.Date())) + ".log"; - - var content = FileIO.readText(logPath); - if (!content) content = "(日志文件不存在或为空: " + logPath + ")"; - - if (content.length > 30000) { - content = "[...前略...]\n" + content.substring(content.length - 30000); - } - - // 简单的按行倒序,方便查看最新日志 - try { - var lines = content.split("\n"); - if (lines.length > 1) { - content = lines.reverse().join("\n"); - } - } catch(eRev) {} - - this.showViewerPanel("今日日志 (倒序)", content); - return; - } - - if (t === "toast") { - var msg = ""; - if (btn.text !== undefined && btn.text !== null) msg = String(btn.text); - else if (btn.title) msg = String(btn.title); - else msg = "按钮#" + idx; - this.toast(msg); - return; - } - - if (t === "app") { - var pkg = btn.pkg ? String(btn.pkg) : ""; - if (!pkg) { this.toast("按钮#" + idx + " 缺少 pkg"); return; } - - var it = context.getPackageManager().getLaunchIntentForPackage(pkg); - if (!it) { this.toast("无法启动 " + pkg); return; } - - it.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK); - -// # 系统级跨用户启动:Context.startActivityAsUser -// 这段代码的主要内容/用途:支持"主应用/分身应用"选择,避免弹出选择器或误启动到另一用户。 -// 说明:当未配置 launchUserId 时,默认使用 0(主用户);失败则回退 startActivity。 -var launchUid = 0; -try { - if (btn.launchUserId != null && String(btn.launchUserId).length > 0) launchUid = parseInt(String(btn.launchUserId), 10); -} catch(eLU0) { launchUid = 0; } -if (isNaN(launchUid)) launchUid = 0; - -try { - // 运行日志:记录跨用户启动参数(便于定位分身启动失败原因) - safeLog(this.L, 'i', "startAsUser(app) idx=" + idx + " pkg=" + pkg + " launchUserId=" + launchUid); - if (launchUid !== 0) { - context.startActivityAsUser(it, android.os.UserHandle.of(launchUid)); - } else { - context.startActivity(it); - } -} catch (eA) { - // # 兜底:某些 ROM/权限限制下 startActivityAsUser 可能抛异常,回退普通启动 - try { context.startActivity(it); } catch (eA2) {} - this.toast("启动失败"); - safeLog(this.L, 'e', "start app fail pkg=" + pkg + " uid=" + String(launchUid) + " err=" + String(eA)); -} -return; - } - - if (t === "shell") { - // # 这段代码的主要内容/用途:执行 shell(支持 cmd 明文 与 cmd_b64;最终会确保发送/执行的是"真正的 base64") - // # 修复点:历史配置里有些按钮把"明文命令"误存进 cmd_b64(或 b64 被破坏),会导致广播接收端解码失败→看起来"没效果"。 - var cmdB64 = (btn.cmd_b64 !== undefined && btn.cmd_b64 !== null) ? String(btn.cmd_b64) : ""; - var cmdPlain = (btn.cmd !== undefined && btn.cmd !== null) ? String(btn.cmd) : ""; - - // # 1) 只有明文但没有 b64:自动补齐 b64(避免特殊字符在多层字符串传递中被破坏) - if ((!cmdB64 || cmdB64.length === 0) && cmdPlain && cmdPlain.length > 0) { - try { - var b64x = encodeBase64Utf8(cmdPlain); - if (b64x && b64x.length > 0) cmdB64 = String(b64x); - } catch (eB64a) {} - } - - // # 2) cmd_b64 非空但无法解码:把它当作"明文命令"重新编码(保证广播桥/Action 都能吃到正确命令) - // # 说明:decodeBase64Utf8 返回空串通常意味着 b64 非法或被破坏;而真实命令不太可能是空串。 - if (cmdB64 && cmdB64.length > 0) { - try { - var testPlain = decodeBase64Utf8(cmdB64); - if ((!testPlain || testPlain.length === 0) && (!cmdPlain || cmdPlain.length === 0)) { - cmdPlain = String(cmdB64); - cmdB64 = ""; - } - } catch (eB64b) {} - } - if ((!cmdB64 || cmdB64.length === 0) && cmdPlain && cmdPlain.length > 0) { - try { - var b64y = encodeBase64Utf8(cmdPlain); - if (b64y && b64y.length > 0) cmdB64 = String(b64y); - } catch (eB64c) {} - } - - if (!cmdB64 || cmdB64.length === 0) { - this.toast("按钮#" + idx + " 缺少 cmd/cmd_b64"); - safeLog(this.L, 'e', "shell missing cmd idx=" + String(idx)); - return; - } - - // # 广播桥接收端默认以 root 执行,强制使用 root - var needRoot = true; - - var r = this.execShellSmart(cmdB64, needRoot); - if (r && r.ok) return; - - this.toast("shell 失败(Action + 广播桥均失败)"); - safeLog(this.L, 'e', "shell all failed cmd_b64=" + cmdB64 + " ret=" + JSON.stringify(r || {})); - return; - } - - if (t === "broadcast") { - // 这段代码的主要内容/用途:发送自定义广播(兼容 btn.extra / btn.extras),并对 Shell 广播桥(shortx.toolhub.SHELL)做额外兼容(cmd/cmd_b64/root)。 - var action = btn.action ? String(btn.action) : ""; - if (!action) { this.toast("按钮#" + idx + " 缺少 action"); return; } - - var it2 = new android.content.Intent(action); - - // # 1) 兼容字段:extra / extras(两种都认) - var ex = null; - try { - if (btn.extras) ex = btn.extras; - else if (btn.extra) ex = btn.extra; - } catch (eEx0) { ex = null; } - - // # 2) 写入 extras(支持 number / boolean / string;其他类型一律转字符串) - if (ex) { - try { - var k; - for (k in ex) { - if (!ex.hasOwnProperty(k)) continue; - var v = ex[k]; - - if (typeof v === "number") it2.putExtra(String(k), Number(v)); - else if (typeof v === "boolean") it2.putExtra(String(k), !!v); - else it2.putExtra(String(k), String(v)); - } - } catch (eE) {} - } - - // # 3) 对"Shell 广播桥"做额外兼容: - // - 你可以在 cfg 里写 extra.cmd(明文)或 extra.cmd_b64(Base64) - // - 同时会补齐 root/from,并且把 cmd 明文也塞一份,方便外部 MVEL 直接读取 cmd 进行验证 - try { - var bridgeAction = String(this.config.SHELL_BRIDGE_ACTION || "shortx.toolhub.SHELL"); - if (action === bridgeAction) { - var kCmdB64 = String(this.config.SHELL_BRIDGE_EXTRA_CMD || "cmd_b64"); - var kFrom = String(this.config.SHELL_BRIDGE_EXTRA_FROM || "from"); - var kRoot = String(this.config.SHELL_BRIDGE_EXTRA_ROOT || "root"); - - var cmdPlain = ""; - var cmdB64 = ""; - - try { cmdB64 = String(it2.getStringExtra(kCmdB64) || ""); } catch (eC0) { cmdB64 = ""; } - try { cmdPlain = String(it2.getStringExtra("cmd") || ""); } catch (eC1) { cmdPlain = ""; } - - // # 有明文但没 b64:自动补 b64 - if ((!cmdB64 || cmdB64.length === 0) && cmdPlain && cmdPlain.length > 0) { - try { - var b64x = encodeBase64Utf8(cmdPlain); - if (b64x && b64x.length > 0) { - cmdB64 = b64x; - it2.putExtra(kCmdB64, String(cmdB64)); - } - } catch (eC2) {} - } - - // # 有 b64 但没明文:也补一份明文(便于外部规则验证;真正执行仍建议用 cmd_b64) - if ((!cmdPlain || cmdPlain.length === 0) && cmdB64 && cmdB64.length > 0) { - try { - var decoded = decodeBase64Utf8(cmdB64); - if (decoded && decoded.length > 0) { - cmdPlain = decoded; - it2.putExtra("cmd", String(cmdPlain)); - } - } catch (eC3) {} - } - - // # root:广播桥接收端默认以 root 执行,强制传递 true - try { - if (!it2.hasExtra(kRoot)) { - it2.putExtra(kRoot, true); - } - } catch (eR0) { - try { - it2.putExtra(kRoot, true); - } catch (eR1) {} - } - - - // # root 类型纠正:如果外部 cfg 用了字符串 "true"/"false",这里纠正为 boolean,避免外部 getBooleanExtra 读不到 - try { - if (it2.hasExtra(kRoot)) { - var bdl = it2.getExtras(); - if (bdl) { - var raw = bdl.get(kRoot); - if (raw != null) { - var rawStr = String(raw); - if (rawStr === "true" || rawStr === "false") { - it2.removeExtra(kRoot); - it2.putExtra(kRoot, rawStr === "true"); - } - } - } - } - } catch (eRB) {} - - // # from:标识来源(便于外部执行器做白名单/审计) - try { - if (!it2.hasExtra(kFrom)) it2.putExtra(kFrom, "ToolHub@system_server"); - } catch (eF0) { try { it2.putExtra(kFrom, "ToolHub@system_server"); } catch (eF1) {} } - - if (this.L) { - try { - this.L.i("broadcast(shell_bridge) action=" + action + " cmd_len=" + String(cmdPlain ? cmdPlain.length : 0) + - " cmd_b64_len=" + String(cmdB64 ? cmdB64.length : 0) + " root=" + String(it2.getBooleanExtra(kRoot, false))); - } catch (eLg) {} - } - } - } catch (eSB) {} - - try { context.sendBroadcast(it2); } catch (eB) { this.toast("广播失败"); safeLog(this.L, 'e', "broadcast fail action=" + action + " err=" + String(eB)); } - return; - } - - if (t === "shortcut") { - // 这段代码的主要内容/用途:仅使用 JavaScript(startActivityAsUser) 执行快捷方式,取消 Shell 与所有兜底,避免弹出主/分身选择器。 - // 说明: - // 1) 运行时只执行按钮字段 shortcutJsCode(由"选择快捷方式列表"点选自动生成,可手动微调) - // 2) 不再调用 am start,不再回退 LauncherApps.startShortcut(用户要求:取消 shell、取消兜底) - // 3) 目标 userId:launchUserId > userId(用于锁定主/分身) - - var spkg = btn.pkg ? String(btn.pkg) : ""; - var sid = btn.shortcutId ? String(btn.shortcutId) : ""; - var iu = (btn.intentUri != null) ? String(btn.intentUri) : ""; - - var uid = 0; - try { uid = (btn.userId != null) ? parseInt(String(btn.userId), 10) : 0; } catch(eUid0) { uid = 0; } - if (isNaN(uid)) uid = 0; - - // # 启动 userId 优先级:launchUserId > userId - try { - if (btn.launchUserId != null && String(btn.launchUserId).length > 0) { - var lu0 = parseInt(String(btn.launchUserId), 10); - if (!isNaN(lu0)) uid = lu0; - } - } catch(eLu0) {} - - if (!spkg) { this.toast("按钮#" + idx + " 缺少 pkg"); return; } - if (!sid) { this.toast("按钮#" + idx + " 缺少 shortcutId"); return; } - - // # JavaScript 执行:只执行 shortcutJsCode - var jsCode = (btn.shortcutJsCode != null) ? String(btn.shortcutJsCode) : ""; - if (!jsCode || jsCode.length === 0) { - this.toast("按钮#" + idx + " 未配置 JS 启动代码"); - return; - } - - try { - // # 提供少量上下文变量给脚本使用(可选) - // - __sc_intentUri: 当前按钮 intentUri - // - __sc_userId: 当前目标 userId(已合并 launchUserId) - var __sc_intentUri = iu; - var __sc_userId = uid; - - var rjs = eval(jsCode); - - // # 约定:返回值以 ok 开头视为成功;以 err 开头视为失败(失败也不兜底) - var sret = (rjs == null) ? "" : String(rjs); - if (sret.indexOf("ok") === 0) { - safeLog(this.L, 'i', "shortcut(js-only) ok pkg=" + spkg + " id=" + sid + " user=" + String(uid)); - return; - } - - safeLog(this.L, 'e', "shortcut(js-only) fail pkg=" + spkg + " id=" + sid + " user=" + String(uid) + " ret=" + sret); - this.toast("快捷方式 JS 启动失败: " + sret); - return; - } catch (eJsSc) { - safeLog(this.L, 'e', "shortcut(js-only) exception pkg=" + spkg + " id=" + sid + " err=" + eJsSc); - this.toast("快捷方式 JS 异常: " + String(eJsSc)); - return; - } -} - - this.toast("未知 type=" + t); - safeLog(this.L, 'w', "unknown btn type=" + t); - } catch (eBtn) { - try { this.toast("按钮执行异常"); } catch (e0) {} - safeLog(this.L, 'e', "execButtonAction crash idx=" + String(idx) + " err=" + String(eBtn)); - } - -}; - -// =======================【新增:改大小后安全重建悬浮球】====================== -FloatBallAppWM.prototype.rebuildBallForNewSize = function(keepPanels) { - if (this.state.closing) return false; - if (!this.state.wm) return false; - if (!this.state.addedBall) return false; - if (!this.state.ballRoot) return false; - if (!this.state.ballLp) return false; - if (this.state.dragging) return false; - - var oldSize = this.state.ballLp.height; - if (!oldSize || oldSize <= 0) oldSize = this.getDockInfo().ballSize; - - var oldX = this.state.ballLp.x; - var oldY = this.state.ballLp.y; - - var oldCenterX = oldX + Math.round(oldSize / 2); - var oldCenterY = oldY + Math.round(oldSize / 2); - - if (!keepPanels) { - this.hideAllPanels(); - } - this.cancelDockTimer(); - - this.state.docked = false; - this.state.dockSide = null; - - this.safeRemoveView(this.state.ballRoot, "ballRoot-rebuild"); - - this.state.ballRoot = null; - this.state.ballContent = null; - this.state.ballLp = null; - this.state.addedBall = false; - - this.createBallViews(); - - var di = this.getDockInfo(); - var newSize = di.ballSize; - - var newX = oldCenterX - Math.round(newSize / 2); - var newY = oldCenterY - Math.round(newSize / 2); - - var maxX = Math.max(0, this.state.screen.w - newSize); - var maxY = Math.max(0, this.state.screen.h - newSize); - - newX = this.clamp(newX, 0, maxX); - newY = this.clamp(newY, 0, maxY); - - var lp = new android.view.WindowManager.LayoutParams( - newSize, - newSize, - android.view.WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, - android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, - android.graphics.PixelFormat.TRANSLUCENT - ); - - lp.gravity = android.view.Gravity.TOP | android.view.Gravity.START; - lp.x = newX; - lp.y = newY; - - try { - this.state.wm.addView(this.state.ballRoot, lp); - this.state.ballLp = lp; - this.state.addedBall = true; - } catch (eAdd) { - try { this.toast("重建悬浮球失败: " + String(eAdd)); } catch (eT) {} - safeLog(this.L, 'e', "rebuildBall add fail err=" + String(eAdd)); - return false; - } - - this.savePos(this.state.ballLp.x, this.state.ballLp.y); - this.touchActivity(); - safeLog(this.L, 'i', "rebuildBall ok size=" + String(newSize) + " x=" + String(newX) + " y=" + String(newY)); - return true; -}; - -// =======================【设置面板:UI(右上角确认)】====================== -FloatBallAppWM.prototype.createSectionHeader = function(item, parent) { - var isDark = this.isDarkTheme(); - var C = this.ui.colors; - var color = C.primary; - - var h = new android.widget.TextView(context); - h.setText(String(item.name || "")); - h.setTextColor(color); - h.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 15); - h.setTypeface(null, android.graphics.Typeface.BOLD); - h.setPadding(this.dp(16), this.dp(24), this.dp(16), this.dp(8)); - parent.addView(h); -}; - -FloatBallAppWM.prototype.createSettingItemView = function(item, parent, needDivider) { - var isDark = this.isDarkTheme(); - var C = this.ui.colors; - var textColor = isDark ? C.textPriDark : C.textPriLight; - var secColor = isDark ? C.textSecDark : C.textSecLight; - var dividerColor = isDark ? C.dividerDark : C.dividerLight; - var primary = C.primary; - var switchOff = isDark ? (0xFF555555 | 0) : (0xFFCCCCCC | 0); - - // 增加内边距 - var padH = this.dp(16); - var padV = this.dp(16); - - // 分割线 (顶部) - if (needDivider) { - var line = new android.view.View(context); - var lineLp = new android.widget.LinearLayout.LayoutParams( - android.widget.LinearLayout.LayoutParams.MATCH_PARENT, - 1 // 1px - ); - lineLp.setMargins(padH, 0, padH, 0); - line.setLayoutParams(lineLp); - line.setBackgroundColor(dividerColor); - parent.addView(line); - } - - // 容器 - var row = new android.widget.LinearLayout(context); - row.setOrientation(android.widget.LinearLayout.VERTICAL); - // 增加点击波纹反馈 - try { - var outValue = new android.util.TypedValue(); - context.getTheme().resolveAttribute(android.R.attr.selectableItemBackground, outValue, true); - row.setBackgroundResource(outValue.resourceId); - } catch(e) {} - - row.setPadding(padH, padV, padH, padV); - - var self = this; - - if (item.type === "bool") { - // === 开关类型 === - row.setOrientation(android.widget.LinearLayout.HORIZONTAL); - row.setGravity(android.view.Gravity.CENTER_VERTICAL); - row.setClickable(true); - - var tv = new android.widget.TextView(context); - tv.setText(String(item.name)); - tv.setTextColor(textColor); - tv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 16); - var tvLp = new android.widget.LinearLayout.LayoutParams(0, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT); - tvLp.weight = 1; - tv.setLayoutParams(tvLp); - row.addView(tv); - - var sw = new android.widget.Switch(context); - try { sw.setTextOn(""); sw.setTextOff(""); } catch (eT) {} - - // 优化开关颜色 - try { - var states = [ - [android.R.attr.state_checked], - [-android.R.attr.state_checked] - ]; - var thumbColors = [primary, switchOff]; - var trackColors = [self.withAlpha(primary, 0.5), self.withAlpha(switchOff, 0.5)]; - - var thumbList = new android.content.res.ColorStateList(states, thumbColors); - var trackList = new android.content.res.ColorStateList(states, trackColors); - - sw.setThumbTintList(thumbList); - sw.setTrackTintList(trackList); - } catch(eColor) {} - - var cur = !!self.getPendingValue(item.key); - sw.setChecked(cur); - - // 监听器 - sw.setOnCheckedChangeListener(new android.widget.CompoundButton.OnCheckedChangeListener({ - onCheckedChanged: function(btn, checked) { - try { - self.touchActivity(); - self.setPendingValue(item.key, !!checked); - if (self.L) self.L.d("pending " + String(item.key) + "=" + String(!!checked)); - } catch (e0) {} - } - })); - - // 点击行也触发开关 - row.setOnClickListener(new android.view.View.OnClickListener({ - onClick: function(v) { - sw.setChecked(!sw.isChecked()); - } - })); - - row.addView(sw); - parent.addView(row); - - } else if (item.type === "int" || item.type === "float") { - // === 数值类型 (SeekBar) === - // 垂直布局:上面是 标题+数值,下面是 SeekBar - - // 第一行:标题 + 数值 - var line1 = new android.widget.LinearLayout(context); - line1.setOrientation(android.widget.LinearLayout.HORIZONTAL); - line1.setLayoutParams(new android.widget.LinearLayout.LayoutParams( - android.widget.LinearLayout.LayoutParams.MATCH_PARENT, - android.widget.LinearLayout.LayoutParams.WRAP_CONTENT - )); - - var tv = new android.widget.TextView(context); - tv.setText(String(item.name)); - tv.setTextColor(textColor); - tv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 16); - var tvLp = new android.widget.LinearLayout.LayoutParams(0, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT); - tvLp.weight = 1; - tv.setLayoutParams(tvLp); - line1.addView(tv); - - var valTv = new android.widget.TextView(context); - valTv.setTextColor(primary); - valTv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 14); - valTv.setTypeface(null, android.graphics.Typeface.BOLD); - line1.addView(valTv); - - row.addView(line1); - - // 第二行:SeekBar - var sb = new android.widget.SeekBar(context); - var sbLp = new android.widget.LinearLayout.LayoutParams( - android.widget.LinearLayout.LayoutParams.MATCH_PARENT, - android.widget.LinearLayout.LayoutParams.WRAP_CONTENT - ); - sbLp.topMargin = self.dp(16); // 增加间距 - sb.setLayoutParams(sbLp); - - // 优化 SeekBar 颜色 - try { - sb.getThumb().setTint(primary); - sb.getProgressDrawable().setTint(primary); - } catch(eColor) {} - - // 配置 SeekBar - var min = (item.min !== undefined) ? Number(item.min) : 0; - var max = (item.max !== undefined) ? Number(item.max) : 100; - var step = (item.step !== undefined) ? Number(item.step) : 1; - - var curV = Number(self.getPendingValue(item.key)); - if (isNaN(curV)) curV = min; - curV = self.clamp(curV, min, max); - - var maxP = Math.floor((max - min) / step); - if (maxP < 1) maxP = 1; - sb.setMax(maxP); - - var curP = Math.floor((curV - min) / step); - if (curP < 0) curP = 0; - if (curP > maxP) curP = maxP; - sb.setProgress(curP); - - function formatVal(v) { - if (item.type === "float") return String(Math.round(v * 1000) / 1000); - return String(Math.round(v)); - } - function computeValByProgress(p) { - var v = min + p * step; - v = self.clamp(v, min, max); - if (item.type === "int") v = Math.round(v); - if (item.type === "float") v = Math.round(v * 1000) / 1000; - return v; - } - - valTv.setText(formatVal(curV)); - - sb.setOnSeekBarChangeListener(new android.widget.SeekBar.OnSeekBarChangeListener({ - onProgressChanged: function(seek, progress, fromUser) { - try { - self.touchActivity(); - var v = computeValByProgress(progress); - valTv.setText(formatVal(v)); - if (fromUser) { - self.setPendingValue(item.key, v); - } - } catch (e1) {} - }, - onStartTrackingTouch: function() { try { self.touchActivity(); } catch (e2) {} }, - onStopTrackingTouch: function() { try { self.touchActivity(); } catch (e3) {} } - })); - - row.addView(sb); - parent.addView(row); - - } else if (item.type === "action") { - // === 动作按钮 === - row.setOrientation(android.widget.LinearLayout.HORIZONTAL); - row.setGravity(android.view.Gravity.CENTER_VERTICAL); - row.setClickable(true); - - var tv = new android.widget.TextView(context); - tv.setText(String(item.name)); - tv.setTextColor(textColor); - tv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 16); - var tvLp = new android.widget.LinearLayout.LayoutParams(0, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT); - tvLp.weight = 1; - tv.setLayoutParams(tvLp); - row.addView(tv); - - // 样式化文本按钮 - var btn = new android.widget.TextView(context); - btn.setText("打开"); - btn.setTextColor(primary); - btn.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 14); - btn.setTypeface(null, android.graphics.Typeface.BOLD); - btn.setGravity(android.view.Gravity.CENTER); - btn.setPadding(self.dp(16), self.dp(8), self.dp(16), self.dp(8)); - // 透明波纹背景 - btn.setBackground(self.ui.createTransparentRippleDrawable(primary, self.dp(16))); - - btn.setOnClickListener(new android.view.View.OnClickListener({ - onClick: function(v) { - try { - self.touchActivity(); - if (item.action === "open_btn_mgr") { - self.showPanelAvoidBall("btn_editor"); - } - } catch(e) {} - } - })); - row.addView(btn); - - // 行点击也触发 - row.setOnClickListener(new android.view.View.OnClickListener({ - onClick: function(v) { - try { - self.touchActivity(); - if (item.action === "open_btn_mgr") { - self.showPanelAvoidBall("btn_editor"); - } - } catch(e) {} - } - })); - - parent.addView(row); - } else if (item.type === "text") { - // === 文本输入 === - var tv = new android.widget.TextView(context); - tv.setText(String(item.name)); - tv.setTextColor(textColor); - tv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 16); - row.addView(tv); - - var et = new android.widget.EditText(context); - var curVal = self.getPendingValue(item.key); - if (curVal === undefined || curVal === null) curVal = ""; - et.setText(String(curVal)); - et.setTextColor(textColor); - et.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 14); - et.setBackground(self.ui.createRoundDrawable(isDark ? C.inputBgDark : C.inputBgLight, self.dp(6))); - et.setPadding(self.dp(8), self.dp(8), self.dp(8), self.dp(8)); - et.setSingleLine(true); - - var etLp = new android.widget.LinearLayout.LayoutParams( - android.widget.LinearLayout.LayoutParams.MATCH_PARENT, - android.widget.LinearLayout.LayoutParams.WRAP_CONTENT - ); - etLp.topMargin = self.dp(8); - et.setLayoutParams(etLp); - - // Explicitly request keyboard on click - et.setOnClickListener(new android.view.View.OnClickListener({ - onClick: function(v) { - try { - v.requestFocus(); - var imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); - imm.showSoftInput(v, 0); - } catch(e) {} - } - })); - - et.addTextChangedListener(new android.text.TextWatcher({ - beforeTextChanged: function(s, start, count, after) {}, - onTextChanged: function(s, start, before, count) {}, - afterTextChanged: function(s) { - try { - self.touchActivity(); - self.setPendingValue(item.key, String(s)); - } catch (e) {} - } - })); - - row.addView(et); - parent.addView(row); - } else if (item.type === "single_choice") { - // === 单选类型 (RadioGroup) === - var tv = new android.widget.TextView(context); - tv.setText(String(item.name)); - tv.setTextColor(textColor); - tv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 16); - row.addView(tv); - - var rg = new android.widget.RadioGroup(context); - rg.setOrientation(android.widget.RadioGroup.VERTICAL); - var rgLp = new android.widget.LinearLayout.LayoutParams( - android.widget.LinearLayout.LayoutParams.MATCH_PARENT, - android.widget.LinearLayout.LayoutParams.WRAP_CONTENT - ); - rgLp.topMargin = self.dp(8); - rg.setLayoutParams(rgLp); - - var curVal = String(self.getPendingValue(item.key) || ""); - if (!curVal) curVal = "auto"; // default - - var options = item.options || []; - for (var i = 0; i < options.length; i++) { - (function(opt) { - var rb = new android.widget.RadioButton(context); - rb.setText(String(opt.label)); - rb.setTextColor(textColor); - rb.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 14); - // 颜色优化 - try { - var states = [[android.R.attr.state_checked], [-android.R.attr.state_checked]]; - var colors = [primary, secColor]; - rb.setButtonTintList(new android.content.res.ColorStateList(states, colors)); - } catch(eC){} - - rb.setId(android.view.View.generateViewId ? android.view.View.generateViewId() : i); - - // Check state - if (String(opt.value) === curVal) { - rb.setChecked(true); - } - - rb.setOnCheckedChangeListener(new android.widget.CompoundButton.OnCheckedChangeListener({ - onCheckedChanged: function(btn, checked) { - if (checked) { - try { - self.touchActivity(); - self.setPendingValue(item.key, String(opt.value)); - } catch(e){} - } - } - })); - rg.addView(rb); - })(options[i]); - } - - row.addView(rg); - parent.addView(row); - } else { - // 兜底文本 - var tv = new android.widget.TextView(context); - tv.setText(String(item.name)); - tv.setTextColor(secColor); - row.addView(tv); - parent.addView(row); - } -}; -