From dcca40e6b94a7dddea2836d2b6461e45b274b8db Mon Sep 17 00:00:00 2001 From: Ashurinnn <77261372+Ashurinnn@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:59:05 +0100 Subject: [PATCH] feat(systemui): enable stacked dual-SIM signals with stable transitions Restore the dormant dual-SIM hooks with fixed normal-width artwork, uniform geometry and frame-safe refreshes. Preserve host animation properties, keep existing visibility hooks frozen, and document the confirmed device behavior and remaining coverage limits. --- README.md | 2 +- .../screen/features/NetworkIconsActivity.kt | 3 +- .../ui/screen/features/StatusBarOptions.kt | 13 + app/src/main/res/values-en/strings.xml | 9 + app/src/main/res/values/strings.xml | 9 + changelog.md | 2 +- docs/CUSTOMIZE.md | 2 +- docs/STATUSBAR_PORT.md | 19 +- .../hyperrefine/hook/systemui/SystemUiLoad.kt | 2 + .../statusbar/DualSimArtworkPolicy.kt | 127 ++-- .../statusbar/DualSimRuntimeSupport.kt | 129 ++-- .../systemui/statusbar/DualSimSignalHook.kt | 713 +++++++++--------- .../statusbar/StackedSignalDrawable.kt | 142 ++-- .../hook/systemui/statusbar/StatusBarKeys.kt | 17 +- .../systemui/statusbar/StatusBarPolicy.kt | 56 +- .../systemui/statusbar/StatusBarSupport.kt | 17 +- .../hook/systemui/statusbar/StatusBarViews.kt | 40 +- .../statusbar/DualSimArtworkPolicyTest.kt | 190 ++--- .../statusbar/DualSimRuntimeSupportTest.kt | 231 +++--- .../systemui/statusbar/StatusBarPolicyTest.kt | 35 +- 20 files changed, 950 insertions(+), 808 deletions(-) diff --git a/README.md b/README.md index d128b82..0ca5cf3 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ HyperRefine 基于个人 Android 模块开发脚手架 MiuixGuiTemplate 创建 通知功能独立实现,未引入参考模块的代码、资源或原生库;参考项目的来源与许可证已登记到应用内参考项目分组。 -状态栏的 Wi-Fi 活动指示器隐藏与移动网络类型隐藏同样独立实现,参考来源统一登记在应用内致谢页;功能范围、验证和暂未启用的双卡双行源码见 [状态栏功能说明](docs/STATUSBAR_PORT.md)。 +状态栏的 Wi-Fi 活动指示器隐藏、移动网络类型隐藏与双排堆叠信号同样独立实现,参考来源统一登记在应用内致谢页;功能范围与验证见 [状态栏功能说明](docs/STATUSBAR_PORT.md)。
diff --git a/app/src/main/java/cn/ianzb/hyperrefine/ui/screen/features/NetworkIconsActivity.kt b/app/src/main/java/cn/ianzb/hyperrefine/ui/screen/features/NetworkIconsActivity.kt index b12749a..bd4b0b2 100644 --- a/app/src/main/java/cn/ianzb/hyperrefine/ui/screen/features/NetworkIconsActivity.kt +++ b/app/src/main/java/cn/ianzb/hyperrefine/ui/screen/features/NetworkIconsActivity.kt @@ -20,7 +20,8 @@ class NetworkIconsActivity : BaseSubPageActivity() { val specs = statusBarSpecs(featureSpecs()) StatusBarSections(listOf( HookSection(R.string.sb_network_section, specs = specs.take(1)), - HookSection(R.string.sb_mobile_type_section, specs = specs.drop(1)), + HookSection(R.string.sb_mobile_type_section, specs = specs.slice(1..1)), + HookSection(R.string.sb_mobile_dual_section, specs = specs.drop(2)), ), contentPadding) } } diff --git a/app/src/main/java/cn/ianzb/hyperrefine/ui/screen/features/StatusBarOptions.kt b/app/src/main/java/cn/ianzb/hyperrefine/ui/screen/features/StatusBarOptions.kt index 6cca6fb..1835330 100644 --- a/app/src/main/java/cn/ianzb/hyperrefine/ui/screen/features/StatusBarOptions.kt +++ b/app/src/main/java/cn/ianzb/hyperrefine/ui/screen/features/StatusBarOptions.kt @@ -12,10 +12,23 @@ internal fun statusBarFeatureSpecs(): List { key = key, type = OptionType.SWITCH, titleRes = title, defaultBoolean = false, targetPackages = listOf(Keys.TARGET), showStatus = true, ) + fun slider(key: String, title: Int, value: Float, min: Float, max: Float, step: Float = 0.5f) = OptionSpec( + key = key, type = OptionType.SLIDER, titleRes = title, defaultFloat = value, + sliderMin = min, sliderMax = max, sliderStep = step, sliderDecimals = 1, + sliderUnitRes = if (key == Keys.DUAL_SCALE) R.string.sb_unit_scale else R.string.sb_unit_dp, + targetPackages = listOf(Keys.TARGET), dependsOn = Keys.DUAL_ROWS, + ) return listOf( OptionSpec(key = KEY_STATUS_BAR, type = OptionType.ARROW, titleRes = R.string.sb_title), toggle(Keys.WIFI_ACTIVITY, R.string.sb_wifi_hide_activity), toggle(Keys.HIDE_TYPE, R.string.sb_mobile_hide_type), + OptionSpec(key = Keys.DUAL_ROWS, type = OptionType.SWITCH, titleRes = R.string.sb_dual_rows, + summaryRes = R.string.sb_dual_rows_summary, defaultBoolean = false, + targetPackages = listOf(Keys.TARGET), showStatus = true), + slider(Keys.DUAL_SCALE, R.string.sb_icon_scale, 1f, 0.6f, 1.6f, 0.1f), + slider(Keys.DUAL_LEFT_GAP, R.string.sb_left_gap, 0f, 0f, 8f), + slider(Keys.DUAL_RIGHT_GAP, R.string.sb_right_gap, 0f, 0f, 8f), + slider(Keys.DUAL_OFFSET, R.string.sb_vertical_offset, 0f, -4f, 4f), ) } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index b08f9d2..76302d4 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -499,4 +499,13 @@ Hide Wi-Fi activity indicators Mobile network type Hide network type completely + Stacked signals + Show two rows of stacked signals + When enabled, mobile signal indicators use a stacked layout. Adjust the scale and position offsets as needed. Restart SystemUI after making changes. + Icon scale + Left spacing + Right spacing + Vertical offset + dp + × diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fe47590..a6cc9c8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -499,4 +499,13 @@ 隐藏 Wi-Fi 网络活动指示器 移动网络类型 完全隐藏网络类型 + 堆叠信号 + 显示双排堆叠信号 + 开启后移动信号将更改为堆叠显示,请自行调整缩放和位置偏移量;修改完成后需要重启系统界面。 + 图标缩放 + 左侧间距 + 右侧间距 + 垂直偏移 + dp + × diff --git a/changelog.md b/changelog.md index 8cb4793..0b69b4d 100644 --- a/changelog.md +++ b/changelog.md @@ -6,7 +6,7 @@ ### 新增 -- **状态栏 · 网络图标**(目标 `com.android.systemui`):新增「系统界面 → 状态栏」页,提供「隐藏 Wi-Fi 活动指示器」「完全隐藏网络类型」两项开关,默认关闭;双卡双行实现暂以注释源码保留,等待后续修复。 +- **状态栏 · 网络图标**(目标 `com.android.systemui`):新增「系统界面 → 状态栏」页,提供「隐藏 Wi-Fi 活动指示器」「完全隐藏网络类型」与「显示双排堆叠信号」开关,默认关闭。双卡固定为上排 SIM 1、下排 SIM 2,使用普通宽度四格图形;移除加粗样式,统一图形比例并在绘制前完成更新,避免过渡中切回原生形状。下拉面板和返回桌面时的双排过渡已通过真机验证。 - **负一屏 · 过渡动画优化**(目标 `com.miui.personalassistant` / system_server):新增「负一屏」功能页。**过渡闪黑修复**消除打开 / 返回快速切换时的纯色闪黑;**上滑合并强制反向**修正返回动画被系统合并时先反向播一半的问题;**放行远程动画权限**(系统服务侧,需重启设备)让负一屏的远程过渡动画完整生效。思路参考 HyperAssistant(非开源,独立实现)。 - **负一屏 · 打开应用动画与过渡遮罩重绘**(目标 `com.miui.personalassistant`):**应用打开动画**让负一屏点开的应用从图标位置缩放展开(options 逐级剥离重试,失败回退框架动画);**过渡遮罩重绘**修复过渡中遮罩显示异常内容。 - **负一屏 · 返回缩回动画**(目标 `com.miui.personalassistant`):关闭应用回到负一屏时,部件快照从全屏缩回原位,接近末尾渐隐;需先开启「应用打开动画」。 diff --git a/docs/CUSTOMIZE.md b/docs/CUSTOMIZE.md index 6c63986..77c2f08 100644 --- a/docs/CUSTOMIZE.md +++ b/docs/CUSTOMIZE.md @@ -413,7 +413,7 @@ HookOptionsPage( 通知功能的参考来源已登记到 `LicensePage.kt` 的参考项目分组,注明 GPL-3.0、仅思路参考、未复用代码或资源。新增七项配置和进程范围见 [通知功能说明](NOTIFICATION_PORT.md)。 -状态栏功能的参考来源也在该分组登记;仅借鉴思路,未引入参考模块或小米 SystemUI 的代码、图片及原生库。两项开关的配置、目标进程和注释中的双卡双行实现见 [状态栏功能说明](STATUSBAR_PORT.md)。 +状态栏功能的参考来源也在该分组登记;仅借鉴思路,未引入参考模块或小米 SystemUI 的代码、图片及原生库。各项开关的配置、目标进程和双排堆叠信号实现见 [状态栏功能说明](STATUSBAR_PORT.md)。 本模板的定位是**个人 Android 模块项目的脚手架模板**。具体参考与致谢清单统一维护在应用内「第三方许可证与致谢」页面(`LicensePage.kt` → `licenses_section_refs` 分组),衍生项目**可增不可删**。 diff --git a/docs/STATUSBAR_PORT.md b/docs/STATUSBAR_PORT.md index 899c3eb..a492b93 100644 --- a/docs/STATUSBAR_PORT.md +++ b/docs/STATUSBAR_PORT.md @@ -1,11 +1,12 @@ # 状态栏功能说明 -入口:**功能 → 系统界面 → 状态栏**。当前仅开放以下两项开关,默认关闭,目标均为 `com.android.systemui`;修改后重新加载系统界面进程生效。 +入口:**功能 → 系统界面 → 状态栏**。以下开关默认关闭,目标均为 `com.android.systemui`;修改后重新加载系统界面进程生效。 | 选项 | 配置键 | 效果 | |---|---|---| | 隐藏 Wi-Fi 网络活动指示器 | `statusbar_wifi_hide_activity` | 隐藏 Wi-Fi 的收发活动图标,保留信号与连接显示 | | 完全隐藏网络类型 | `statusbar_mobile_hide_type` | 隐藏移动网络类型图片、独立文字和特殊 5G 标识,保留原生信号布局 | +| 显示双排堆叠信号 | `statusbar_mobile_dual_rows` | 将两个蜂窝信号合并为上下各四格,上排 SIM 1、下排 SIM 2 | ## 实现与验证 @@ -13,18 +14,22 @@ Wi-Fi Hook 在原生绑定与活动更新之后将对应活动视图隐藏,避 配置通过项目既有 `OptionSpec`、偏好与 Hook 状态机制接入;状态栏子页面继承 `BaseSubPageActivity`,登记到父页搜索,并使用 `NetworkIconsActivity`。没有新增权限、作用域或原生依赖。 -接口静态核对基于提供的 HyperOS `OS4.0.22.0.XPNCNXM` ROM,SystemUI `17.03.260226.r`;其他 ROM 的私有接口兼容性尚未验证。用户已确认本次精简测试 APK 的两个开关均生效、双 SIM 保持原生布局,从状态栏页下拉通知中心/控制中心时背景显示正常。单元测试覆盖配置键、反射成员解析和两种只读 Flow 构造签名;没有新增临时诊断日志。 +接口静态核对基于提供的 HyperOS `OS4.0.22.0.XPNCNXM` ROM,SystemUI `17.03.260226.r`;其他 ROM 的私有接口兼容性尚未验证。用户先前已确认两个隐藏功能生效、关闭双排时保持原生布局,从状态栏页下拉通知中心/控制中心时背景显示正常。本轮恢复双排后,用户确认下拉通知中心、控制中心和返回桌面时双排无跳变,最终文案与三个功能均无异常。单元测试覆盖配置键、反射成员解析、只读 Flow 构造、图形比例、模型回退和绘制前事件处理;没有新增临时诊断日志。 -## 暂不启用双卡双行 +## 双排堆叠信号 -`hook/.../systemui/statusbar/` 中的 `DualSimSignalHook.kt`、`DualSimRuntimeSupport.kt`、`DualSimArtworkPolicy.kt`、`StackedSignalDrawable.kt`、`StatusBarPolicy.kt`、`StatusBarViews.kt` 及对应双排测试以逐行注释保留,供后续修复参考。相关偏好键和宿主 Pair 解析辅助也已注释。 +此前注释保留的双排实现和对应测试现已恢复。双排使用审阅通过的普通宽度、紧凑圆角图形;移除加粗样式和整个样式下拉框,旧 `statusbar_mobile_dual_style` 偏好不再读取。缩放、左右间距和垂直偏移继续提供,仅依赖双排开关;左右间距为原生间距的增量。 -这些文件不生成运行时类;当前没有双排 Hook 注册、配置入口或搜索项,不读取该功能的旧偏好。系统继续使用原生 SIM 信号布局。以后恢复时须重新接入配置与注册,验证各卡信号强度、单双卡切换、数据卡切换、锁屏及主题/明暗表现;不能只取消某个文件的注释就视为可用。 +图形在固定 16×20 的逻辑坐标中定义,随宿主 bounds 等比缩放,不再依赖原生主题图片的固有尺寸。主题、明暗和信号变化更新同一 Drawable;原生收集器返回前立即刷新,并在绘制前清理尚未合并的更新。临时隐藏、圆点及透明度动画不当作 SIM 消失,不因这些展示状态恢复原生单排图形。用户缩放与偏移在 Drawable 内完成,不覆盖 ImageView 的缩放或父容器的平移动画。 -此前电脑端导出的 PNG/SVG 不是运行时依赖,未进入仓库或 APK。当前构建没有图标解包、图片落盘或系统资源替换。 +仅对同一宿主中两张可见蜂窝 SIM 合并;单卡、无可识别强度、卫星或仅 VoWiFi 时恢复原生显示。灰格与亮格保留宿主着色和透明度。双排的模型订阅继续使用原生 JavaAdapter,不新增轮询。开关及四个布局偏好沿用旧键,保留已有配置;关闭双排并重新加载系统界面可恢复原生布局。 + +用户已在 `1.13.0-dualsim.1`(versionCode 30)确认下拉 Control center / notification shade、返回桌面时双排无跳变,并在 `1.13.0-dualsim.2-ui`(versionCode 31)确认最终文案和三个功能正常。两包全部 DEX 和原生库逐字节一致;后一包仅修改文案资源和测试版本号。单双卡/数据卡切换、卫星、仅 VoWiFi、锁屏及主题/明暗切换尚未收到本轮逐项真机记录,不能用已有反馈代替这些覆盖。已合并的两个隐藏 Hook 保持原样。 + +电脑端素材包已删除加粗 PNG/SVG 与包含加粗示例的旧预览,保留 50 张普通 PNG 和 2 张普通 SVG;这 52 个文件的内容与此前审阅版本一致。它们不是运行时依赖,未进入仓库或 APK。当前构建没有图标解包、图片落盘或系统资源替换。 ## 参考与安全边界 功能思路参考 Pengeek(米客)`OS4-26.10.13-test`,来源与许可在应用内「关于 → 第三方许可证与致谢」统一登记。实现独立编写,未复用参考模块或小米专有代码/资源;逆向材料仅保留在本地忽略的 `reverse_report/`。项目继续以 AGPL-3.0 分发。 -新增 Hook 仅修改 SystemUI 进程中的视图可见性与显示 Flow,不请求 Root、不执行 shell、不挂载或重新挂载分区、不操作块设备/AVB,也不向受保护系统分区写文件。安装 APK、项目既有偏好及系统/框架日志仍可能保存普通应用数据,因此此说明不承诺整个模块或临时 Root 环境的所有组件均无写入。 +新增 Hook 仅修改 SystemUI 进程中的视图、显示 Flow 与内存 Drawable,不请求 Root、不执行 shell、不挂载或重新挂载分区、不操作块设备/AVB,也不向受保护系统分区写文件。安装 APK、项目既有偏好及系统/框架日志仍可能保存普通应用数据,因此此说明不承诺整个模块或临时 Root 环境的所有组件均无写入。 diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/SystemUiLoad.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/SystemUiLoad.kt index a0fcfaf..c7176a5 100644 --- a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/SystemUiLoad.kt +++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/SystemUiLoad.kt @@ -12,6 +12,7 @@ import cn.ianzb.hyperrefine.hook.systemui.radius.CcRadiusKeys import cn.ianzb.hyperrefine.hook.systemui.wallpaper.WallpaperDarkDimHook import cn.ianzb.hyperrefine.hook.systemui.wallpaper.WallpaperDimKeys import cn.ianzb.hyperrefine.hook.systemui.statusbar.HideWifiActivityHook +import cn.ianzb.hyperrefine.hook.systemui.statusbar.DualSimSignalHook import cn.ianzb.hyperrefine.hook.systemui.statusbar.MobileIndicatorVisibilityHook import cn.ianzb.hyperrefine.hook.systemui.statusbar.StatusBarKeys @@ -28,6 +29,7 @@ class SystemUiLoad : BaseLoad() { override fun onPackageLoaded(target: PackageTarget) { initHook(HideWifiActivityHook(), HookPrefs.getBoolean(StatusBarKeys.WIFI_ACTIVITY)) initHook(MobileIndicatorVisibilityHook(), HookPrefs.getBoolean(StatusBarKeys.HIDE_TYPE)) + initHook(DualSimSignalHook(), HookPrefs.getBoolean(StatusBarKeys.DUAL_ROWS)) initHook(CcVolumePercentHook(), HookPrefs.getBoolean(CcVolumePercentHook.KEY, false)) initHook(CcBrightnessPercentHook(), HookPrefs.getBoolean(CcBrightnessPercentHook.KEY, false)) initHook(SideVolumePercentHook(), HookPrefs.getBoolean(SideVolumePercentHook.KEY, false)) diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/DualSimArtworkPolicy.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/DualSimArtworkPolicy.kt index 9da9b0e..5fc82c1 100644 --- a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/DualSimArtworkPolicy.kt +++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/DualSimArtworkPolicy.kt @@ -1,61 +1,66 @@ -// DORMANT: preserved from statusbar.6 (ddb0e59); not compiled or registered. -// Restore only after artwork and device regression testing. Runtime diagnostics removed. -// package cn.ianzb.hyperrefine.hook.systemui.statusbar -// -// import kotlin.math.min -// import kotlin.math.roundToInt -// -// /** Original four-cell artwork geometry and slot ordering, independent of the data SIM. */ -// internal object DualSimArtworkPolicy { -// const val CELL_COUNT = 4 -// private const val INACTIVE_ALPHA = 0.28f -// -// data class Levels(val top: Int, val bottom: Int) -// data class Cell(val row: Int, val column: Int, val left: Float, val top: Float, -// val right: Float, val bottom: Float, val radius: Float) -// -// /** HyperOS has six native levels (0..5); round proportionally to four visible cells. */ -// fun fourCellLevel(nativeLevel: Int?): Int? = nativeLevel?.takeIf { it in 0..5 } -// ?.let { (it * CELL_COUNT / 5f).roundToInt() } -// -// fun levels(primarySlot: Int, secondarySlot: Int, primaryLevel: Int?, secondaryLevel: Int?): Levels? { -// val first = fourCellLevel(primaryLevel) ?: return null -// val second = fourCellLevel(secondaryLevel) ?: return null -// return when { -// primarySlot == 0 && secondarySlot == 1 -> Levels(first, second) -// primarySlot == 1 && secondarySlot == 0 -> Levels(second, first) -// else -> null // Unknown/duplicate slots must not invent a SIM 1 / SIM 2 assignment. -// } -// } -// -// fun cells(width: Float, height: Float, thick: Boolean): List { -// if (!width.isFinite() || !height.isFinite() || width <= 0f || height <= 0f) return emptyList() -// val step = width / CELL_COUNT -// val barWidth = step * if (thick) 0.80f else 0.64f -// // Compact proportions independent of a tall Android signal drawable's intrinsic bounds. -// val artworkHeight = min(height * 0.72f, width * 0.80f) -// val artworkTop = height - artworkHeight -// val bottomHeight = min(artworkHeight * 0.24f, barWidth * 0.92f) -// val bottomTop = height - bottomHeight -// val upperBaseline = bottomTop - artworkHeight * 0.09f -// val upperHeight = upperBaseline - artworkTop -// return (0 until CELL_COUNT).map { column -> -// val left = column * step + (step - barWidth) / 2f -// val barHeight = upperHeight * (0.4f + column * 0.2f) -// val radius = min(barWidth, barHeight) * 0.48f -// Cell(0, column, left, upperBaseline - barHeight, left + barWidth, upperBaseline, radius) -// } + (0 until CELL_COUNT).map { column -> -// val left = column * step + (step - barWidth) / 2f -// val radius = min(barWidth, bottomHeight) * 0.48f -// Cell(1, column, left, bottomTop, left + barWidth, height, radius) -// } -// } -// -// /** Preserve the host's RGB and alpha; inactive cells use the same hue at lower opacity. */ -// fun cellColor(foreground: Int, drawableAlpha: Int, filled: Boolean): Int { -// val hostAlpha = foreground ushr 24 -// val strengthAlpha = if (filled) 1f else INACTIVE_ALPHA -// val alpha = (hostAlpha * drawableAlpha.coerceIn(0, 255) / 255f * strengthAlpha).roundToInt() -// return (foreground and 0x00ffffff) or (alpha shl 24) -// } -// } +package cn.ianzb.hyperrefine.hook.systemui.statusbar + +import kotlin.math.min +import kotlin.math.roundToInt + +/** Original four-cell artwork geometry and slot ordering, independent of the data SIM. */ +internal object DualSimArtworkPolicy { + const val CELL_COUNT = 4 + const val VIEWPORT_WIDTH = 16f + const val VIEWPORT_HEIGHT = 20f + private const val INACTIVE_ALPHA = 0.28f + + data class Levels(val top: Int, val bottom: Int) + data class Cell(val row: Int, val column: Int, val left: Float, val top: Float, + val right: Float, val bottom: Float, val radius: Float) + + /** HyperOS has six native levels (0..5); round proportionally to four visible cells. */ + fun fourCellLevel(nativeLevel: Int?): Int? = nativeLevel?.takeIf { it in 0..5 } + ?.let { (it * CELL_COUNT / 5f).roundToInt() } + + fun levels(primarySlot: Int, secondarySlot: Int, primaryLevel: Int?, secondaryLevel: Int?): Levels? { + val first = fourCellLevel(primaryLevel) ?: return null + val second = fourCellLevel(secondaryLevel) ?: return null + return when { + primarySlot == 0 && secondarySlot == 1 -> Levels(first, second) + primarySlot == 1 && secondarySlot == 0 -> Levels(second, first) + else -> null // Unknown/duplicate slots must not invent a SIM 1 / SIM 2 assignment. + } + } + + /** Keep the approved normal artwork's proportions even if host bounds change aspect ratio. */ + fun cells(width: Float, height: Float): List { + if (!width.isFinite() || !height.isFinite() || width <= 0f || height <= 0f) return emptyList() + val scale = min(width / VIEWPORT_WIDTH, height / VIEWPORT_HEIGHT) + val offsetX = (width - VIEWPORT_WIDTH * scale) / 2f + val offsetY = height - VIEWPORT_HEIGHT * scale + val step = VIEWPORT_WIDTH / CELL_COUNT + val barWidth = step * 0.64f + val artworkHeight = min(VIEWPORT_HEIGHT * 0.72f, VIEWPORT_WIDTH * 0.80f) + val artworkTop = VIEWPORT_HEIGHT - artworkHeight + val bottomHeight = min(artworkHeight * 0.24f, barWidth * 0.92f) + val bottomTop = VIEWPORT_HEIGHT - bottomHeight + val upperBaseline = bottomTop - artworkHeight * 0.09f + val upperHeight = upperBaseline - artworkTop + val cells = (0 until CELL_COUNT).map { column -> + val left = column * step + (step - barWidth) / 2f + val barHeight = upperHeight * (0.4f + column * 0.2f) + val radius = min(barWidth, barHeight) * 0.48f + Cell(0, column, left, upperBaseline - barHeight, left + barWidth, upperBaseline, radius) + } + (0 until CELL_COUNT).map { column -> + val left = column * step + (step - barWidth) / 2f + val radius = min(barWidth, bottomHeight) * 0.48f + Cell(1, column, left, bottomTop, left + barWidth, VIEWPORT_HEIGHT, radius) + } + return cells.map { it.copy(left = offsetX + it.left * scale, top = offsetY + it.top * scale, + right = offsetX + it.right * scale, bottom = offsetY + it.bottom * scale, radius = it.radius * scale) } + } + + /** Preserve the host's RGB and alpha; inactive cells use the same hue at lower opacity. */ + fun cellColor(foreground: Int, drawableAlpha: Int, filled: Boolean): Int { + val hostAlpha = foreground ushr 24 + val strengthAlpha = if (filled) 1f else INACTIVE_ALPHA + val alpha = (hostAlpha * drawableAlpha.coerceIn(0, 255) / 255f * strengthAlpha).roundToInt() + return (foreground and 0x00ffffff) or (alpha shl 24) + } +} diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/DualSimRuntimeSupport.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/DualSimRuntimeSupport.kt index 08785ad..5f2cf1b 100644 --- a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/DualSimRuntimeSupport.kt +++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/DualSimRuntimeSupport.kt @@ -1,58 +1,71 @@ -// DORMANT: preserved from statusbar.6 (ddb0e59); not compiled or registered. -// Restore only after artwork and device regression testing. Runtime diagnostics removed. -// package cn.ianzb.hyperrefine.hook.systemui.statusbar -// -// import cn.ianzb.hyperrefine.hook.xposed.Reflect -// import java.lang.reflect.Method -// import java.lang.reflect.Modifier -// import java.util.function.Consumer -// -// /** A later LoadedApk callback must not reinstall the same process's dual-SIM hooks. */ -// internal class DualSimInstallation { -// private var installed = false -// -// @Synchronized -// fun installOnce(install: () -> Unit): Boolean { -// if (installed) return false -// install() // A failed/rolled-back installation remains retryable. -// installed = true -// return true -// } -// } -// -// /** Collect with MobileUiAdapter's actual JavaAdapter, without the removed Dependency.get API. */ -// internal class DualSimFlowObserver { -// private data class Collector(val owner: Any, val method: Method) -// private data class Subscription(val flow: Any, val consumer: Consumer) -// private var collector: Collector? = null -// private val waiting = ArrayDeque() -// -// fun value(flow: Any): Any? = Reflect.callMethod(flow, "getValue") -// -// @Synchronized -// fun attach(adapter: Any): Boolean { -// if (collector?.owner === adapter) return false -// check(collector == null) { "Dual-SIM JavaAdapter changed within one process" } -// val method = StatusBarMembers.methods(adapter.javaClass, "alwaysCollectFlow") { -// !Modifier.isStatic(it.modifiers) && it.parameterCount == 2 && -// !it.parameterTypes[0].isPrimitive && it.parameterTypes[1] == Consumer::class.java -// }.single() -// // Binding can precede MobileUiAdapter.start. Drain only after the real adapter is ready. -// // Remove each subscription after success, so a failed drain does not lose pending work. -// while (waiting.isNotEmpty()) { -// val subscription = waiting.first() -// method.invoke(adapter, subscription.flow, subscription.consumer) -// waiting.removeFirst() -// } -// collector = Collector(adapter, method) -// return true -// } -// -// @Synchronized -// fun observe(flow: Any, onValue: (Any?) -> Unit) { -// val consumer = Consumer { onValue(it) } -// val active = collector -// if (active == null) waiting.addLast(Subscription(flow, consumer)) -// else active.method.invoke(active.owner, flow, consumer) -// } -// } +package cn.ianzb.hyperrefine.hook.systemui.statusbar + +import cn.ianzb.hyperrefine.hook.xposed.Reflect +import java.lang.reflect.Method +import java.lang.reflect.Modifier +import java.util.function.Consumer +import java.util.concurrent.atomic.AtomicBoolean + +/** One refresh per event batch; an immediate/pre-draw flush makes the queued callback a no-op. */ +internal class DualSimRefreshGate( + private val post: (() -> Unit) -> Unit, + private val refresh: () -> Unit, +) { + private val pending = AtomicBoolean() + fun request() { + if (pending.compareAndSet(false, true)) post { flush() } + } + fun flush() { + if (pending.compareAndSet(true, false)) refresh() + } +} + +/** A later LoadedApk callback must not reinstall the same process's dual-SIM hooks. */ +internal class DualSimInstallation { + private var installed = false + + @Synchronized + fun installOnce(install: () -> Unit): Boolean { + if (installed) return false + install() // A failed/rolled-back installation remains retryable. + installed = true + return true + } +} + +/** Collect with MobileUiAdapter's actual JavaAdapter, without the removed Dependency.get API. */ +internal class DualSimFlowObserver { + private data class Collector(val owner: Any, val method: Method) + private data class Subscription(val flow: Any, val consumer: Consumer) + private var collector: Collector? = null + private val waiting = ArrayDeque() + + fun value(flow: Any): Any? = Reflect.callMethod(flow, "getValue") + + @Synchronized + fun attach(adapter: Any): Boolean { + if (collector?.owner === adapter) return false + check(collector == null) { "Dual-SIM JavaAdapter changed within one process" } + val method = StatusBarMembers.methods(adapter.javaClass, "alwaysCollectFlow") { + !Modifier.isStatic(it.modifiers) && it.parameterCount == 2 && + !it.parameterTypes[0].isPrimitive && it.parameterTypes[1] == Consumer::class.java + }.single() + // Binding can precede MobileUiAdapter.start. Drain only after the real adapter is ready. + // Remove each subscription after success, so a failed drain does not lose pending work. + while (waiting.isNotEmpty()) { + val subscription = waiting.first() + method.invoke(adapter, subscription.flow, subscription.consumer) + waiting.removeFirst() + } + collector = Collector(adapter, method) + return true + } + + @Synchronized + fun observe(flow: Any, onValue: (Any?) -> Unit) { + val consumer = Consumer { onValue(it) } + val active = collector + if (active == null) waiting.addLast(Subscription(flow, consumer)) + else active.method.invoke(active.owner, flow, consumer) + } +} diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/DualSimSignalHook.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/DualSimSignalHook.kt index f9fe916..1c36fea 100644 --- a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/DualSimSignalHook.kt +++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/DualSimSignalHook.kt @@ -1,363 +1,350 @@ -// DORMANT: preserved from statusbar.6 (ddb0e59); not compiled or registered. -// Restore only after artwork and device regression testing. Runtime diagnostics removed. -// package cn.ianzb.hyperrefine.hook.systemui.statusbar -// -// import android.graphics.Color -// import android.graphics.Bitmap -// import android.graphics.Canvas -// import android.graphics.Rect -// import android.graphics.drawable.Drawable -// import android.os.Handler -// import android.os.Looper -// import android.view.View -// import android.view.ViewGroup -// import android.widget.ImageView -// import cn.ianzb.hyperrefine.hook.base.BaseHook -// import cn.ianzb.hyperrefine.hook.prefs.HookPrefs -// import cn.ianzb.hyperrefine.hook.xposed.Reflect -// import io.github.libxposed.api.XposedInterface.HookHandle -// import java.lang.ref.WeakReference -// import java.util.WeakHashMap -// import java.util.concurrent.atomic.AtomicBoolean -// import kotlin.math.roundToInt -// -// /** Event-driven native-view composition; no polling, telephony changes, or copied signal assets. */ -// class DualSimSignalHook : BaseHook() { -// override val key = StatusBarKeys.DUAL_ROWS -// override fun init() { -// installation.installOnce { installFor(target.classLoader) } -// } -// -// private fun installFor(loader: ClassLoader?) { -// val binder = Reflect.findClass(StatusBarViews.MOBILE_BINDER, loader) -// val binds = StatusBarMembers.methods(binder, "bind") { -// it.parameterCount == 4 && ViewGroup::class.java.isAssignableFrom(it.parameterTypes[0]) && -// it.parameterTypes[1].name == "com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.LocationBasedMobileViewModel" && -// it.parameterTypes[2].name == "com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.MiuiMobileIconViewModel" && -// it.parameterTypes[3].name == "com.android.systemui.statusbar.pipeline.mobile.ui.MobileViewLogger" -// } -// val adapter = Reflect.findClass("com.android.systemui.statusbar.pipeline.mobile.ui.MobileUiAdapter", loader) -// val starts = StatusBarMembers.methods(adapter, "start") { it.parameterCount == 0 } -// val collector = Reflect.findClass(StatusBarViews.MOBILE_BINDER + "\$bind\$1\$1\$4\$2", loader) -// val icon = StatusBarMembers.field(collector, "\$mobile") -// require(ImageView::class.java.isAssignableFrom(icon.type)) -// val emits = StatusBarMembers.methods(collector, "emit") { -// it.parameterCount == 2 && it.parameterTypes[0] == Any::class.java && -// it.parameterTypes[1].name == "kotlin.coroutines.Continuation" -// } -// val themeCollector = Reflect.findClass(StatusBarViews.MOBILE_BINDER + "\$bind\$1\$1\$26\$2", loader) -// val themeIcon = StatusBarMembers.field(themeCollector, "\$mobile") -// require(ImageView::class.java.isAssignableFrom(themeIcon.type)) -// val themeEmits = StatusBarMembers.methods(themeCollector, "emit") { -// it.parameterCount == 2 && it.parameterTypes[0] == Any::class.java && -// it.parameterTypes[1].name == "kotlin.coroutines.Continuation" -// } -// val tintCollector = Reflect.findClass(StatusBarViews.MOBILE_BINDER + "\$bind\$1\$1\$5\$1", loader) -// val tintViews = StatusBarMembers.field(tintCollector, "\$tintViewList") -// require(List::class.java.isAssignableFrom(tintViews.type)) -// val tintUpdates = StatusBarMembers.methods(tintCollector, "invokeSuspend") { -// it.parameterTypes.contentEquals(arrayOf(Any::class.java)) && it.returnType == Any::class.java -// } -// val mobileView = Reflect.findClass("com.android.systemui.statusbar.pipeline.mobile.ui.view.ModernStatusBarMobileView", loader) -// val states = StatusBarMembers.methods(mobileView, "setVisibleState") { -// it.parameterTypes.contentEquals(arrayOf(Int::class.javaPrimitiveType, Boolean::class.javaPrimitiveType)) -// } -// val runtime = DualSignalRuntime(DualSimFlowObserver()) -// val guard = StatusBarGuard(tag) -// val installs = mutableListOf<() -> HookHandle>() -// binds.forEach { method -> installs += { StatusBarMembers.after(method) { param -> guard.run { -// val root = param.args.firstOrNull() as? ViewGroup ?: return@run -// // bind(root, locationModel, miuiModel, logger): the location wrapper has no cell flows. -// val model = param.args.getOrNull(2) ?: return@run -// if (StatusBarViews.isStatusBarMobile(root, model)) runtime.bind(root, model) -// } } } } -// starts.forEach { method -> installs += { StatusBarMembers.after(method) { param -> guard.run { -// param.thisObject?.let(runtime::adapter) -// } } } } -// emits.forEach { method -> installs += { StatusBarMembers.after(method) { param -> guard.run { -// val instance = param.thisObject ?: return@run -// val image = icon.get(instance) as? ImageView ?: return@run -// runtime.emitted(image) -// } } } } -// themeEmits.forEach { method -> installs += { StatusBarMembers.after(method) { param -> guard.run { -// val instance = param.thisObject ?: return@run -// (themeIcon.get(instance) as? ImageView)?.let(runtime::emitted) -// } } } } -// tintUpdates.forEach { method -> installs += { StatusBarMembers.after(method) { param -> guard.run { -// val instance = param.thisObject ?: return@run -// (tintViews.get(instance) as? List<*>)?.filterIsInstance()?.forEach(runtime::emitted) -// } } } } -// states.forEach { method -> installs += { StatusBarMembers.after(method) { param -> -// guard.run { (param.thisObject as? ViewGroup)?.let { runtime.visibleState(it, param.args[0] as Int) } } -// } } } -// StatusBarMembers.install(installs) -// } -// -// private companion object { -// val installation = DualSimInstallation() -// } -// } -// -// private class DualSignalRuntime(private val flows: DualSimFlowObserver) { -// private class Row(root: ViewGroup, image: ImageView, val subId: Int) { -// val root = WeakReference(root) -// val image = WeakReference(image) -// var native: Drawable? = null -// var hiddenByModule = false -// var nativeVisibility = root.visibility -// var iconState = 0 -// var nativeColor = Color.WHITE -// var renderKey: List? = null -// val originalScaleX = image.scaleX -// val originalScaleY = image.scaleY -// val parent = WeakReference(image.parent as? ViewGroup) -// val originalTranslation = (image.parent as? View)?.translationY ?: 0f -// val originalClipChildren = (image.parent as? ViewGroup)?.clipChildren ?: true -// val originalClipPadding = (image.parent as? ViewGroup)?.clipToPadding ?: true -// val group = WeakReference(StatusBarViews.find(root, "mobile_group") as? ViewGroup) -// val originalLeft = group.get()?.paddingLeft ?: 0 -// val originalRight = group.get()?.paddingRight ?: 0 -// var styled = false -// } -// -// private val rows = WeakHashMap() // Accessed on the UI thread only. -// private val observed = WeakHashMap() -// private val signals = mutableMapOf() -// private val slotIndexes = mutableMapOf() -// private val signalLevels = mutableMapOf() -// private var dataSubId = -1 -// private val handler = Handler(Looper.getMainLooper()) -// private val pending = AtomicBoolean() -// private val guard = StatusBarGuard("DualSimSignalHook") -// -// fun adapter(adapter: Any) { -// val hdController = Reflect.getObjectField(adapter, "hdController") ?: error("No mobile HD controller") -// val javaAdapter = Reflect.getObjectField(hdController, "javaAdapter") ?: error("No mobile JavaAdapter") -// flows.attach(javaAdapter) -// val provider = Reflect.getObjectField(adapter, "mobileIconsViewModel") ?: error("No mobile model provider") -// val model = Reflect.callMethod(provider, "get") ?: error("No mobile icons model") -// val interactor = runCatching { -// Reflect.callMethod(Reflect.getObjectField(model, "miuiIntsLazy")!!, "get") -// }.getOrNull() ?: Reflect.getObjectField(model, "miuiInt") ?: error("No mobile interactor") -// val repository = Reflect.getObjectField(interactor, "mobileConnectionsRepo") ?: error("No mobile repository") -// val source = Reflect.getObjectField(repository, "activeMobileDataSubscriptionId") ?: error("No active SIM flow") -// val initial = flows.value(source) as? Int ?: -1 -// synchronized(signals) { dataSubId = initial } -// flows.observe(source) { value -> -// (value as? Int)?.let { synchronized(signals) { dataSubId = it }; schedule() } -// } -// } -// -// fun bind(root: ViewGroup, model: Any) { -// if (rows.containsKey(root)) return -// val image = StatusBarViews.find(root, "mobile_signal") as? ImageView ?: error("No native signal image") -// val subId = Reflect.getObjectField(root, "subId") as? Int ?: error("No SIM subscription id") -// val cell = runCatching { Reflect.callMethod(model, "getCellProvider") }.getOrNull() ?: model -// val signalFlow = Reflect.getObjectField(cell, "signalIconId") ?: error("No native signal flow") -// val visibleFlow = runCatching { Reflect.getObjectField(model, "isVisible") }.getOrNull() -// ?: Reflect.getObjectField(cell, "isVisible") ?: error("No native signal visibility") -// val providerFlow = Reflect.getObjectField(model, "vmProvider") ?: error("No active mobile model flow") -// val satelliteFlow = Reflect.getObjectField(cell, "satelliteVisible") ?: error("No satellite visibility flow") -// val interactor = Reflect.getObjectField(model, "iconInteractor") ?: error("No SIM slot interactor") -// val slotFlow = Reflect.getObjectField(interactor, "phoneId") ?: error("No SIM slot flow") -// synchronized(signals) { slotIndexes.putIfAbsent(subId, flows.value(slotFlow) as? Int ?: -1) } -// val resource = flows.value(signalFlow) as? Int ?: error("Unexpected signal flow value") -// val visibility = StatusBarMembers.mobileVisibility(flows.value(visibleFlow)) ?: error("Unexpected visibility flow value") -// val initial = StatusBarPolicy.Signal(subId, resource, visibility.signal, visibility.root, -// flows.value(providerFlow) === cell, -// flows.value(satelliteFlow) as? Boolean ?: error("Unexpected satellite visibility")) -// val row = Row(root, image, subId) -// capture(row, image) -// if (!observed.containsKey(model)) { -// flows.observe(signalFlow) { value -> guard.run { -// val res = value as? Int ?: return@run -// synchronized(signals) { -// signals[subId] = (signals[subId] ?: initial).copy(resourceId = res) -// } -// schedule() -// } } -// flows.observe(visibleFlow) { value -> guard.run { -// val shown = StatusBarMembers.mobileVisibility(value) ?: return@run -// synchronized(signals) { -// signals[subId] = (signals[subId] ?: initial).copy(visible = shown.signal, rootVisible = shown.root) -// } -// schedule() -// } } -// flows.observe(providerFlow) { value -> guard.run { -// synchronized(signals) { signals[subId] = (signals[subId] ?: initial).copy(cellularMode = value === cell) } -// schedule() -// } } -// flows.observe(satelliteFlow) { value -> guard.run { -// val satellite = value as? Boolean ?: return@run -// synchronized(signals) { signals[subId] = (signals[subId] ?: initial).copy(satellite = satellite) } -// schedule() -// } } -// // A subscription ID is not a SIM slot; observe the host's actual slot mapping once per model. -// flows.observe(slotFlow) { value -> -// synchronized(signals) { slotIndexes[subId] = value as? Int ?: -1 } -// schedule() -// } -// observed[model] = true -// } -// synchronized(signals) { signals.putIfAbsent(subId, initial) } -// rows[root] = row -// val listener = android.view.ViewTreeObserver.OnGlobalLayoutListener { schedule() } -// var listening = false -// fun attachListener() { -// if (!listening && root.viewTreeObserver.isAlive) { -// root.viewTreeObserver.addOnGlobalLayoutListener(listener); listening = true -// } -// } -// root.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { -// override fun onViewAttachedToWindow(v: View) { attachListener(); schedule() } -// override fun onViewDetachedFromWindow(v: View) { -// if (v.viewTreeObserver.isAlive) v.viewTreeObserver.removeOnGlobalLayoutListener(listener) -// listening = false -// schedule() -// } -// }) -// if (root.isAttachedToWindow) attachListener() -// schedule() -// } -// -// fun visibleState(root: ViewGroup, state: Int) { -// rows[root]?.let { it.iconState = state; schedule() } -// } -// -// fun emitted(image: ImageView) { -// val affected = rows.values.filter { it.image.get() === image } -// if (affected.isEmpty()) return -// affected.forEach { row -> capture(row, image) } -// // A native emission may update the secondary theme drawable without changing its resource id. -// rows.values.forEach { it.renderKey = null } -// schedule() -// } -// -// private fun capture(row: Row, image: ImageView) { -// val native = image.drawable ?: return -// if (native is StackedSignalDrawable) return -// row.native = native.constantState?.newDrawable(image.resources)?.mutate()?.also { -// it.colorFilter = native.colorFilter; it.state = native.state -// } -// row.native?.let { drawable -> row.nativeColor = sampleColor(drawable) } -// } -// -// /** Read a tiny in-memory rendering, including the native tint, when a signal emission changes. */ -// private fun sampleColor(drawable: Drawable): Int { -// val bitmap = Bitmap.createBitmap(24, 24, Bitmap.Config.ARGB_8888) -// val original = Rect(drawable.bounds) -// try { -// drawable.setBounds(0, 0, 24, 24) -// drawable.draw(Canvas(bitmap)) -// var best = -1f -// var color = Color.WHITE -// for (y in 0..23) for (x in 0..23) { -// val pixel = bitmap.getPixel(x, y) -// val brightness = (Color.red(pixel) + Color.green(pixel) + Color.blue(pixel)) / 3f -// val score = Color.alpha(pixel) * kotlin.math.abs(brightness - 127.5f) -// if (Color.alpha(pixel) > 0 && score > best) { best = score; color = pixel or (0xff shl 24) } -// } -// return color -// } finally { drawable.bounds = original; bitmap.recycle() } -// } -// -// private fun schedule() { -// if (pending.compareAndSet(false, true)) handler.post { -// pending.set(false) -// guard.run { refresh() } -// } -// } -// -// private fun refresh() { -// val snapshot = synchronized(signals) { Triple(signals.values.toList(), dataSubId, slotIndexes.toMap()) } -// // Separate hosts (home / lock screen / another display) never share a composed view. -// rows.values.toList().filter { it.root.get()?.isAttachedToWindow == true } -// .groupBy { it.root.get()?.parent }.values.forEach { group -> -// val eligible = group.filter { -// it.iconState == 0 && it.image.get()?.visibility == View.VISIBLE && -// it.parent.get()?.visibility == View.VISIBLE -// }.map { it.subId }.toSet() -// val pairing = StatusBarPolicy.pair(snapshot.first.filter { it.subId in eligible }, snapshot.second) -// val first = pairing?.let { pair -> group.firstOrNull { it.subId == pair.primary.subId } } -// val second = pairing?.let { pair -> group.firstOrNull { it.subId == pair.secondary.subId } } -// val levels = if (pairing != null && first != null && second != null) { -// fun nativeLevel(row: Row, resource: Int): Int? { -// if (!signalLevels.containsKey(resource)) signalLevels[resource] = runCatching { -// StatusBarPolicy.signalLevel(row.image.get()!!.resources.getResourceEntryName(resource)) -// }.getOrNull() -// return signalLevels[resource] -// } -// DualSimArtworkPolicy.levels(snapshot.third[first.subId] ?: -1, snapshot.third[second.subId] ?: -1, -// nativeLevel(first, pairing.primary.resourceId), nativeLevel(second, pairing.secondary.resourceId)) -// } else null -// if (pairing == null || first?.native == null || second?.native == null || levels == null) { -// group.forEach { restore(it, snapshot.first.firstOrNull { state -> state.subId == it.subId }?.rootVisible) } -// return@forEach -// } -// group.forEach { row -> -// val root = row.root.get() ?: return@forEach -// if (row === second) { -// if (!row.hiddenByModule) { row.nativeVisibility = root.visibility; row.hiddenByModule = true } -// if (root.visibility != View.GONE) root.visibility = View.GONE -// } else if (row === first) { -// restoreVisibility(row, true) -// render(row, pairing, levels) -// } else restore(row, snapshot.first.firstOrNull { it.subId == row.subId }?.rootVisible) -// } -// } -// } -// -// private fun restoreVisibility(row: Row, visible: Boolean?) { -// if (row.hiddenByModule) { -// row.root.get()?.visibility = when (visible) { -// true -> View.VISIBLE -// false -> View.GONE -// null -> row.nativeVisibility -// } -// row.hiddenByModule = false -// } -// } -// -// private fun restore(row: Row, visible: Boolean?) { -// restoreVisibility(row, visible) -// row.image.get()?.let { image -> -// if (image.drawable is StackedSignalDrawable) row.native?.let(image::setImageDrawable) -// if (row.styled) { image.scaleX = row.originalScaleX; image.scaleY = row.originalScaleY } -// } -// if (row.styled) { -// row.parent.get()?.let { parent -> -// parent.translationY = row.originalTranslation -// parent.clipChildren = row.originalClipChildren; parent.clipToPadding = row.originalClipPadding -// } -// row.group.get()?.let { it.setPadding(row.originalLeft, it.paddingTop, row.originalRight, it.paddingBottom) } -// row.styled = false -// } -// row.renderKey = null -// } -// -// private fun render(first: Row, pair: StatusBarPolicy.Pairing, levels: DualSimArtworkPolicy.Levels) { -// val image = first.image.get() ?: return -// val root = first.root.get() ?: return -// first.styled = true -// val style = HookPrefs.getString(StatusBarKeys.DUAL_STYLE, "standard").takeIf { it in listOf("standard", "thick", "theme") } ?: "standard" -// fun setting(key: String, default: Float, min: Float, max: Float) = StatusBarPolicy.bounded(HookPrefs.getFloat(key, default), default, min, max) -// val scale = setting(StatusBarKeys.DUAL_SCALE, 1f, 0.6f, 1.6f) -// val color = image.imageTintList?.getColorForState(image.drawableState, first.nativeColor) ?: first.nativeColor -// val key = listOf(pair.primary.resourceId, pair.secondary.resourceId, levels.top, levels.bottom, style, color) -// if (first.renderKey != key || image.drawable !is StackedSignalDrawable) { -// image.setImageDrawable(StackedSignalDrawable(first.native!!, levels.top, levels.bottom, style, color)) -// first.renderKey = key -// } -// if (image.scaleX != scale) { image.scaleX = scale; image.scaleY = scale } -// (image.parent as? ViewGroup)?.let { parent -> -// parent.clipChildren = false; parent.clipToPadding = false -// val offset = setting(StatusBarKeys.DUAL_OFFSET, 0f, -4f, 4f) * image.resources.displayMetrics.density -// if (parent.translationY != offset) parent.translationY = offset -// } -// val group = StatusBarViews.find(root, "mobile_group") as? ViewGroup ?: return -// val density = image.resources.displayMetrics.density -// val left = (setting(StatusBarKeys.DUAL_LEFT_GAP, 0f, 0f, 8f) * density).roundToInt() -// val right = (setting(StatusBarKeys.DUAL_RIGHT_GAP, 0f, 0f, 8f) * density).roundToInt() -// if (group.paddingLeft != left || group.paddingRight != right) group.setPadding(left, group.paddingTop, right, group.paddingBottom) -// } -// } +package cn.ianzb.hyperrefine.hook.systemui.statusbar + +import android.graphics.Color +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.os.Handler +import android.os.Looper +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import cn.ianzb.hyperrefine.hook.base.BaseHook +import cn.ianzb.hyperrefine.hook.prefs.HookPrefs +import cn.ianzb.hyperrefine.hook.xposed.Reflect +import io.github.libxposed.api.XposedInterface.HookHandle +import java.lang.ref.WeakReference +import java.util.WeakHashMap +import kotlin.math.roundToInt + +/** Event-driven native-view composition; no polling, telephony changes, or copied signal assets. */ +class DualSimSignalHook : BaseHook() { + override val key = StatusBarKeys.DUAL_ROWS + override fun init() { + installation.installOnce { installFor(target.classLoader) } + } + + private fun installFor(loader: ClassLoader?) { + val binder = Reflect.findClass(StatusBarViews.MOBILE_BINDER, loader) + val binds = StatusBarMembers.methods(binder, "bind") { + it.parameterCount == 4 && ViewGroup::class.java.isAssignableFrom(it.parameterTypes[0]) && + it.parameterTypes[1].name == "com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.LocationBasedMobileViewModel" && + it.parameterTypes[2].name == "com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.MiuiMobileIconViewModel" && + it.parameterTypes[3].name == "com.android.systemui.statusbar.pipeline.mobile.ui.MobileViewLogger" + } + val adapter = Reflect.findClass("com.android.systemui.statusbar.pipeline.mobile.ui.MobileUiAdapter", loader) + val starts = StatusBarMembers.methods(adapter, "start") { it.parameterCount == 0 } + val collector = Reflect.findClass(StatusBarViews.MOBILE_BINDER + "\$bind\$1\$1\$4\$2", loader) + val icon = StatusBarMembers.field(collector, "\$mobile") + require(ImageView::class.java.isAssignableFrom(icon.type)) + val emits = StatusBarMembers.methods(collector, "emit") { + it.parameterCount == 2 && it.parameterTypes[0] == Any::class.java && + it.parameterTypes[1].name == "kotlin.coroutines.Continuation" + } + val themeCollector = Reflect.findClass(StatusBarViews.MOBILE_BINDER + "\$bind\$1\$1\$26\$2", loader) + val themeIcon = StatusBarMembers.field(themeCollector, "\$mobile") + require(ImageView::class.java.isAssignableFrom(themeIcon.type)) + val themeEmits = StatusBarMembers.methods(themeCollector, "emit") { + it.parameterCount == 2 && it.parameterTypes[0] == Any::class.java && + it.parameterTypes[1].name == "kotlin.coroutines.Continuation" + } + val tintCollector = Reflect.findClass(StatusBarViews.MOBILE_BINDER + "\$bind\$1\$1\$5\$1", loader) + val tintViews = StatusBarMembers.field(tintCollector, "\$tintViewList") + require(List::class.java.isAssignableFrom(tintViews.type)) + val tintUpdates = StatusBarMembers.methods(tintCollector, "invokeSuspend") { + it.parameterTypes.contentEquals(arrayOf(Any::class.java)) && it.returnType == Any::class.java + } + val runtime = DualSignalRuntime(DualSimFlowObserver()) + val guard = StatusBarGuard(tag) + val installs = mutableListOf<() -> HookHandle>() + binds.forEach { method -> installs += { StatusBarMembers.after(method) { param -> guard.run { + val root = param.args.firstOrNull() as? ViewGroup ?: return@run + // bind(root, locationModel, miuiModel, logger): the location wrapper has no cell flows. + val model = param.args.getOrNull(2) ?: return@run + if (StatusBarViews.isStatusBarMobile(root, model)) runtime.bind(root, model) + } } } } + starts.forEach { method -> installs += { StatusBarMembers.after(method) { param -> guard.run { + param.thisObject?.let(runtime::adapter) + } } } } + emits.forEach { method -> installs += { StatusBarMembers.after(method) { param -> guard.run { + val instance = param.thisObject ?: return@run + val image = icon.get(instance) as? ImageView ?: return@run + runtime.emitted(image) + } } } } + themeEmits.forEach { method -> installs += { StatusBarMembers.after(method) { param -> guard.run { + val instance = param.thisObject ?: return@run + (themeIcon.get(instance) as? ImageView)?.let(runtime::emitted) + } } } } + tintUpdates.forEach { method -> installs += { StatusBarMembers.after(method) { param -> guard.run { + val instance = param.thisObject ?: return@run + (tintViews.get(instance) as? List<*>)?.filterIsInstance()?.forEach(runtime::emitted) + } } } } + StatusBarMembers.install(installs) + } + + private companion object { + val installation = DualSimInstallation() + } +} + +private class DualSignalRuntime(private val flows: DualSimFlowObserver) { + private class Row(root: ViewGroup, image: ImageView, val subId: Int) { + val root = WeakReference(root) + val image = WeakReference(image) + var native: Drawable? = null + var hiddenByModule = false + var nativeVisibility = root.visibility + var nativeColor = Color.WHITE + var stacked: StackedSignalDrawable? = null + val parent = WeakReference(image.parent as? ViewGroup) + val originalClipChildren = (image.parent as? ViewGroup)?.clipChildren ?: true + val originalClipPadding = (image.parent as? ViewGroup)?.clipToPadding ?: true + val group = WeakReference(StatusBarViews.find(root, "mobile_group") as? ViewGroup) + val originalLeft = group.get()?.paddingLeft ?: 0 + val originalRight = group.get()?.paddingRight ?: 0 + var styled = false + } + + private val rows = WeakHashMap() // Accessed on the UI thread only. + private val observed = WeakHashMap() + private val signals = mutableMapOf() + private val slotIndexes = mutableMapOf() + private val signalLevels = mutableMapOf() + private var dataSubId = -1 + private val handler = Handler(Looper.getMainLooper()) + private val guard = StatusBarGuard("DualSimSignalHook") + private val refreshGate = DualSimRefreshGate({ callback -> handler.post { callback() } }, { + guard.run { refresh() } + }) + + fun adapter(adapter: Any) { + val hdController = Reflect.getObjectField(adapter, "hdController") ?: error("No mobile HD controller") + val javaAdapter = Reflect.getObjectField(hdController, "javaAdapter") ?: error("No mobile JavaAdapter") + flows.attach(javaAdapter) + val provider = Reflect.getObjectField(adapter, "mobileIconsViewModel") ?: error("No mobile model provider") + val model = Reflect.callMethod(provider, "get") ?: error("No mobile icons model") + val interactor = runCatching { + Reflect.callMethod(Reflect.getObjectField(model, "miuiIntsLazy")!!, "get") + }.getOrNull() ?: Reflect.getObjectField(model, "miuiInt") ?: error("No mobile interactor") + val repository = Reflect.getObjectField(interactor, "mobileConnectionsRepo") ?: error("No mobile repository") + val source = Reflect.getObjectField(repository, "activeMobileDataSubscriptionId") ?: error("No active SIM flow") + val initial = flows.value(source) as? Int ?: -1 + synchronized(signals) { dataSubId = initial } + flows.observe(source) { value -> + (value as? Int)?.let { synchronized(signals) { dataSubId = it }; schedule() } + } + } + + fun bind(root: ViewGroup, model: Any) { + if (rows.containsKey(root)) return + val image = StatusBarViews.find(root, "mobile_signal") as? ImageView ?: error("No native signal image") + val subId = Reflect.getObjectField(root, "subId") as? Int ?: error("No SIM subscription id") + val cell = runCatching { Reflect.callMethod(model, "getCellProvider") }.getOrNull() ?: model + val signalFlow = Reflect.getObjectField(cell, "signalIconId") ?: error("No native signal flow") + val visibleFlow = runCatching { Reflect.getObjectField(model, "isVisible") }.getOrNull() + ?: Reflect.getObjectField(cell, "isVisible") ?: error("No native signal visibility") + val providerFlow = Reflect.getObjectField(model, "vmProvider") ?: error("No active mobile model flow") + val satelliteFlow = Reflect.getObjectField(cell, "satelliteVisible") ?: error("No satellite visibility flow") + val interactor = Reflect.getObjectField(model, "iconInteractor") ?: error("No SIM slot interactor") + val slotFlow = Reflect.getObjectField(interactor, "phoneId") ?: error("No SIM slot flow") + synchronized(signals) { slotIndexes.putIfAbsent(subId, flows.value(slotFlow) as? Int ?: -1) } + val resource = flows.value(signalFlow) as? Int ?: error("Unexpected signal flow value") + val visibility = StatusBarMembers.mobileVisibility(flows.value(visibleFlow)) ?: error("Unexpected visibility flow value") + val initial = StatusBarPolicy.Signal(subId, resource, visibility.signal, visibility.root, + flows.value(providerFlow) === cell, + flows.value(satelliteFlow) as? Boolean ?: error("Unexpected satellite visibility")) + val row = Row(root, image, subId) + capture(row, image) + if (!observed.containsKey(model)) { + flows.observe(signalFlow) { value -> guard.run { + val res = value as? Int ?: return@run + synchronized(signals) { + signals[subId] = (signals[subId] ?: initial).copy(resourceId = res) + } + schedule() + } } + flows.observe(visibleFlow) { value -> guard.run { + val shown = StatusBarMembers.mobileVisibility(value) ?: return@run + synchronized(signals) { + signals[subId] = (signals[subId] ?: initial).copy(visible = shown.signal, rootVisible = shown.root) + } + schedule() + } } + flows.observe(providerFlow) { value -> guard.run { + synchronized(signals) { signals[subId] = (signals[subId] ?: initial).copy(cellularMode = value === cell) } + schedule() + } } + flows.observe(satelliteFlow) { value -> guard.run { + val satellite = value as? Boolean ?: return@run + synchronized(signals) { signals[subId] = (signals[subId] ?: initial).copy(satellite = satellite) } + schedule() + } } + // A subscription ID is not a SIM slot; observe the host's actual slot mapping once per model. + flows.observe(slotFlow) { value -> + synchronized(signals) { slotIndexes[subId] = value as? Int ?: -1 } + schedule() + } + observed[model] = true + } + synchronized(signals) { signals.putIfAbsent(subId, initial) } + rows[root] = row + val listener = android.view.ViewTreeObserver.OnPreDrawListener { + // Final barrier: native visibility/resource updates must not leak an intermediate frame. + if (row.styled && image.drawable !is StackedSignalDrawable) { + capture(row, image) + schedule() + } + if (row.hiddenByModule && root.visibility != View.GONE) schedule() + flushPending() + true + } + var listening = false + fun attachListener() { + if (!listening && root.viewTreeObserver.isAlive) { + root.viewTreeObserver.addOnPreDrawListener(listener); listening = true + } + } + root.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { attachListener(); schedule() } + override fun onViewDetachedFromWindow(v: View) { + if (v.viewTreeObserver.isAlive) v.viewTreeObserver.removeOnPreDrawListener(listener) + listening = false + schedule() + } + }) + if (root.isAttachedToWindow) attachListener() + schedule() + } + + fun emitted(image: ImageView) { + val affected = rows.values.filter { it.image.get() === image } + if (affected.isEmpty()) return + affected.forEach { row -> capture(row, image) } + schedule() + // Native collectors run on the UI thread. Restore composition before returning to drawing. + if (Looper.myLooper() == handler.looper) flushPending() + } + + private fun capture(row: Row, image: ImageView) { + val native = image.drawable ?: return + if (native is StackedSignalDrawable) return + row.native = native.constantState?.newDrawable(image.resources)?.mutate()?.also { + it.colorFilter = native.colorFilter; it.state = native.state + } ?: native + row.native?.let { drawable -> row.nativeColor = sampleColor(drawable) } + } + + /** Read a tiny in-memory rendering, including the native tint, when a signal emission changes. */ + private fun sampleColor(drawable: Drawable): Int { + val bitmap = Bitmap.createBitmap(24, 24, Bitmap.Config.ARGB_8888) + val original = Rect(drawable.bounds) + try { + drawable.setBounds(0, 0, 24, 24) + drawable.draw(Canvas(bitmap)) + var best = -1f + var color = Color.WHITE + for (y in 0..23) for (x in 0..23) { + val pixel = bitmap.getPixel(x, y) + val brightness = (Color.red(pixel) + Color.green(pixel) + Color.blue(pixel)) / 3f + val score = Color.alpha(pixel) * kotlin.math.abs(brightness - 127.5f) + if (Color.alpha(pixel) > 0 && score > best) { best = score; color = pixel or (0xff shl 24) } + } + return color + } finally { drawable.bounds = original; bitmap.recycle() } + } + + private fun schedule() { + refreshGate.request() + } + + private fun flushPending() { + refreshGate.flush() + } + + private fun refresh() { + val snapshot = synchronized(signals) { Triple(signals.values.toList(), dataSubId, slotIndexes.toMap()) } + // Separate hosts (home / lock screen / another display) never share a composed view. + rows.values.toList().filter { it.root.get()?.isAttachedToWindow == true } + .groupBy { it.root.get()?.parent }.values.forEach { group -> + // Host icon/dot/alpha transitions are presentation, not a SIM disappearing. + // Real visibility, provider and satellite state still come from the model below. + val eligible = group.map { it.subId }.toSet() + val pairing = StatusBarPolicy.pair(snapshot.first.filter { it.subId in eligible }, snapshot.second) + val first = pairing?.let { pair -> group.firstOrNull { it.subId == pair.primary.subId } } + val second = pairing?.let { pair -> group.firstOrNull { it.subId == pair.secondary.subId } } + val levels = if (pairing != null && first != null && second != null) { + fun nativeLevel(row: Row, resource: Int): Int? { + if (!signalLevels.containsKey(resource)) signalLevels[resource] = runCatching { + StatusBarPolicy.signalLevel(row.image.get()!!.resources.getResourceEntryName(resource)) + }.getOrNull() + return signalLevels[resource] + } + DualSimArtworkPolicy.levels(snapshot.third[first.subId] ?: -1, snapshot.third[second.subId] ?: -1, + nativeLevel(first, pairing.primary.resourceId), nativeLevel(second, pairing.secondary.resourceId)) + } else null + if (pairing == null || first?.native == null || second?.native == null || levels == null) { + group.forEach { restore(it, snapshot.first.firstOrNull { state -> state.subId == it.subId }?.rootVisible) } + return@forEach + } + group.forEach { row -> + val root = row.root.get() ?: return@forEach + if (row === second) { + if (!row.hiddenByModule) { row.nativeVisibility = root.visibility; row.hiddenByModule = true } + if (root.visibility != View.GONE) root.visibility = View.GONE + } else if (row === first) { + restoreVisibility(row, true) + render(row, levels) + } else restore(row, snapshot.first.firstOrNull { it.subId == row.subId }?.rootVisible) + } + } + } + + private fun restoreVisibility(row: Row, visible: Boolean?) { + if (row.hiddenByModule) { + row.root.get()?.visibility = when (visible) { + true -> View.VISIBLE + false -> View.GONE + null -> row.nativeVisibility + } + row.hiddenByModule = false + } + } + + private fun restore(row: Row, visible: Boolean?) { + restoreVisibility(row, visible) + row.image.get()?.let { image -> + if (image.drawable is StackedSignalDrawable) row.native?.let(image::setImageDrawable) + } + if (row.styled) { + row.parent.get()?.let { parent -> + parent.clipChildren = row.originalClipChildren; parent.clipToPadding = row.originalClipPadding + } + row.group.get()?.let { it.setPadding(row.originalLeft, it.paddingTop, row.originalRight, it.paddingBottom) } + row.styled = false + } + } + + private fun render(first: Row, levels: DualSimArtworkPolicy.Levels) { + val image = first.image.get() ?: return + val root = first.root.get() ?: return + first.styled = true + fun setting(key: String, default: Float, min: Float, max: Float) = StatusBarPolicy.bounded(HookPrefs.getFloat(key, default), default, min, max) + val scale = setting(StatusBarKeys.DUAL_SCALE, 1f, 0.6f, 1.6f) + val color = image.imageTintList?.getColorForState(image.drawableState, first.nativeColor) ?: first.nativeColor + val density = image.resources.displayMetrics.density + val offset = setting(StatusBarKeys.DUAL_OFFSET, 0f, -4f, 4f) * density + val drawable = first.stacked ?: StackedSignalDrawable(density, levels.top, levels.bottom, color) + .also { first.stacked = it } + drawable.update(levels.top, levels.bottom, color, scale, offset) + if (image.drawable !== drawable) image.setImageDrawable(drawable) + (image.parent as? ViewGroup)?.let { parent -> + parent.clipChildren = if (scale > 1f || offset != 0f) false else first.originalClipChildren + parent.clipToPadding = if (scale > 1f || offset != 0f) false else first.originalClipPadding + } + val group = StatusBarViews.find(root, "mobile_group") as? ViewGroup ?: return + val left = first.originalLeft + (setting(StatusBarKeys.DUAL_LEFT_GAP, 0f, 0f, 8f) * density).roundToInt() + val right = first.originalRight + (setting(StatusBarKeys.DUAL_RIGHT_GAP, 0f, 0f, 8f) * density).roundToInt() + if (group.paddingLeft != left || group.paddingRight != right) group.setPadding(left, group.paddingTop, right, group.paddingBottom) + } +} diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StackedSignalDrawable.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StackedSignalDrawable.kt index 6b7f8ce..c65d365 100644 --- a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StackedSignalDrawable.kt +++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StackedSignalDrawable.kt @@ -1,65 +1,77 @@ -// DORMANT: preserved from statusbar.6 (ddb0e59); not compiled or registered. -// Restore only after artwork and device regression testing. Runtime diagnostics removed. -// package cn.ianzb.hyperrefine.hook.systemui.statusbar -// -// import android.content.res.ColorStateList -// import android.graphics.Canvas -// import android.graphics.ColorFilter -// import android.graphics.Paint -// import android.graphics.PixelFormat -// import android.graphics.drawable.Drawable -// -// /** Four independently drawn cells per SIM; SystemUI supplies the current light/dark tint. */ -// internal class StackedSignalDrawable( -// private val nativeSizing: Drawable, -// private val topLevel: Int, -// private val bottomLevel: Int, -// private val style: String, -// private val baseColor: Int, -// ) : Drawable() { -// private val paint = Paint(Paint.ANTI_ALIAS_FLAG) -// private var tint: ColorStateList? = null -// private var foreground = baseColor -// private var opacity = 255 -// private var geometry: List = emptyList() -// private var geometryWidth = -1 -// private var geometryHeight = -1 -// -// override fun draw(canvas: Canvas) { -// if (bounds.width() != geometryWidth || bounds.height() != geometryHeight) { -// geometryWidth = bounds.width(); geometryHeight = bounds.height() -// geometry = DualSimArtworkPolicy.cells(geometryWidth.toFloat(), geometryHeight.toFloat(), style == "thick") -// } -// geometry.forEach { cell -> -// val level = if (cell.row == 0) topLevel else bottomLevel -// paint.color = DualSimArtworkPolicy.cellColor(foreground, opacity, cell.column < level) -// canvas.drawRoundRect(bounds.left + cell.left, bounds.top + cell.top, -// bounds.left + cell.right, bounds.top + cell.bottom, cell.radius, cell.radius, paint) -// } -// } -// -// override fun setAlpha(alpha: Int) { -// opacity = alpha.coerceIn(0, 255); nativeSizing.alpha = opacity; invalidateSelf() -// } -// override fun getAlpha() = opacity -// override fun setColorFilter(filter: ColorFilter?) { -// paint.colorFilter = filter; nativeSizing.colorFilter = filter; invalidateSelf() -// } -// override fun setTintList(tint: ColorStateList?) { -// this.tint = tint -// nativeSizing.setTintList(tint) // Keep the private native clone ready for single-SIM restoration. -// foreground = tint?.getColorForState(state, tint.defaultColor) ?: baseColor -// invalidateSelf() -// } -// override fun isStateful() = tint?.isStateful == true || nativeSizing.isStateful -// override fun onStateChange(state: IntArray): Boolean { -// val next = tint?.let { it.getColorForState(state, it.defaultColor) } ?: baseColor -// val nativeChanged = nativeSizing.setState(state) -// if (foreground == next) return nativeChanged -// foreground = next; invalidateSelf(); return true -// } -// @Deprecated("Drawable opacity is retained for Android compatibility") -// override fun getOpacity() = PixelFormat.TRANSLUCENT -// override fun getIntrinsicWidth() = nativeSizing.intrinsicWidth -// override fun getIntrinsicHeight() = nativeSizing.intrinsicHeight -// } +package cn.ianzb.hyperrefine.hook.systemui.statusbar + +import android.content.res.ColorStateList +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.PixelFormat +import android.graphics.drawable.Drawable +import kotlin.math.roundToInt + +/** Four independently drawn cells per SIM; SystemUI supplies the current light/dark tint. */ +internal class StackedSignalDrawable( + private val density: Float, + private var topLevel: Int, + private var bottomLevel: Int, + private var baseColor: Int, +) : Drawable() { + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private var tint: ColorStateList? = null + private var foreground = baseColor + private var opacity = 255 + private var geometry: List = emptyList() + private var geometryWidth = -1 + private var geometryHeight = -1 + private var artworkScale = 1f + private var verticalOffset = 0f + + /** Update the same drawable: a tint or signal event must not swap its shape or intrinsic size. */ + fun update(top: Int, bottom: Int, color: Int, scale: Float, offset: Float) { + if (topLevel == top && bottomLevel == bottom && baseColor == color && + artworkScale == scale && verticalOffset == offset) return + topLevel = top; bottomLevel = bottom; baseColor = color + artworkScale = scale; verticalOffset = offset + foreground = tint?.getColorForState(state, tint!!.defaultColor) ?: color + invalidateSelf() + } + + override fun draw(canvas: Canvas) { + if (bounds.width() != geometryWidth || bounds.height() != geometryHeight) { + geometryWidth = bounds.width(); geometryHeight = bounds.height() + geometry = DualSimArtworkPolicy.cells(geometryWidth.toFloat(), geometryHeight.toFloat()) + } + val saved = canvas.save() + canvas.translate(bounds.left.toFloat(), bounds.top + verticalOffset) + // User sizing stays inside the drawable; do not overwrite native ImageView animations. + canvas.scale(artworkScale, artworkScale, bounds.width() / 2f, bounds.height().toFloat()) + geometry.forEach { cell -> + val level = if (cell.row == 0) topLevel else bottomLevel + paint.color = DualSimArtworkPolicy.cellColor(foreground, opacity, cell.column < level) + canvas.drawRoundRect(cell.left, cell.top, cell.right, cell.bottom, cell.radius, cell.radius, paint) + } + canvas.restoreToCount(saved) + } + + override fun setAlpha(alpha: Int) { + opacity = alpha.coerceIn(0, 255); invalidateSelf() + } + override fun getAlpha() = opacity + override fun setColorFilter(filter: ColorFilter?) { + paint.colorFilter = filter; invalidateSelf() + } + override fun setTintList(tint: ColorStateList?) { + this.tint = tint + foreground = tint?.getColorForState(state, tint.defaultColor) ?: baseColor + invalidateSelf() + } + override fun isStateful() = tint?.isStateful == true + override fun onStateChange(state: IntArray): Boolean { + val next = tint?.let { it.getColorForState(state, it.defaultColor) } ?: baseColor + if (foreground == next) return false + foreground = next; invalidateSelf(); return true + } + @Deprecated("Drawable opacity is retained for Android compatibility") + override fun getOpacity() = PixelFormat.TRANSLUCENT + override fun getIntrinsicWidth() = (DualSimArtworkPolicy.VIEWPORT_WIDTH * density).roundToInt().coerceAtLeast(1) + override fun getIntrinsicHeight() = (DualSimArtworkPolicy.VIEWPORT_HEIGHT * density).roundToInt().coerceAtLeast(1) +} diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StatusBarKeys.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StatusBarKeys.kt index 7ece9bd..69b3b51 100644 --- a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StatusBarKeys.kt +++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StatusBarKeys.kt @@ -4,13 +4,12 @@ object StatusBarKeys { const val TARGET = "com.android.systemui" const val WIFI_ACTIVITY = "statusbar_wifi_hide_activity" const val HIDE_TYPE = "statusbar_mobile_hide_type" - val ALL = listOf(WIFI_ACTIVITY, HIDE_TYPE) - - // DORMANT: restore these only with the commented dual-SIM implementation and its UI. - // const val DUAL_ROWS = "statusbar_mobile_dual_rows" - // const val DUAL_STYLE = "statusbar_mobile_dual_style" - // const val DUAL_SCALE = "statusbar_mobile_dual_scale" - // const val DUAL_LEFT_GAP = "statusbar_mobile_dual_left_gap" - // const val DUAL_RIGHT_GAP = "statusbar_mobile_dual_right_gap" - // const val DUAL_OFFSET = "statusbar_mobile_dual_offset" + const val DUAL_ROWS = "statusbar_mobile_dual_rows" + const val DUAL_SCALE = "statusbar_mobile_dual_scale" + const val DUAL_LEFT_GAP = "statusbar_mobile_dual_left_gap" + const val DUAL_RIGHT_GAP = "statusbar_mobile_dual_right_gap" + const val DUAL_OFFSET = "statusbar_mobile_dual_offset" + // Deliberately do not read the retired style/bold preference, even from an older installation. + val DUAL_KEYS = listOf(DUAL_ROWS, DUAL_SCALE, DUAL_LEFT_GAP, DUAL_RIGHT_GAP, DUAL_OFFSET) + val ALL = listOf(WIFI_ACTIVITY, HIDE_TYPE) + DUAL_KEYS } diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StatusBarPolicy.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StatusBarPolicy.kt index 94af558..9a4e9aa 100644 --- a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StatusBarPolicy.kt +++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StatusBarPolicy.kt @@ -1,29 +1,27 @@ -// DORMANT: preserved from statusbar.6 (ddb0e59); not compiled or registered. -// Restore only after artwork and device regression testing. Runtime diagnostics removed. -// package cn.ianzb.hyperrefine.hook.systemui.statusbar -// -// internal object StatusBarPolicy { -// -// data class Visibility(val root: Boolean, val signal: Boolean) -// data class Signal( -// val subId: Int, val resourceId: Int, val visible: Boolean, val rootVisible: Boolean = visible, -// val cellularMode: Boolean = true, val satellite: Boolean = false, -// ) -// data class Pairing(val primary: Signal, val secondary: Signal) -// -// /** Only combine two real visible subscriptions. Unknown / single-SIM state keeps native layout. */ -// fun pair(signals: List, dataSubId: Int): Pairing? { -// val visible = signals.filter { it.visible && it.cellularMode && !it.satellite && it.subId >= 0 && it.resourceId > 0 } -// .distinctBy { it.subId }.sortedBy { it.subId } -// if (visible.size != 2) return null -// val primary = visible.firstOrNull { it.subId == dataSubId } ?: visible.first() -// return Pairing(primary, visible.first { it.subId != primary.subId }) -// } -// -// private val levelName = Regex("^stat_sys_signal_([0-5])(?:_.*)?$") -// fun signalLevel(resourceName: String): Int? = -// if (resourceName == "stat_sys_signal_null") 0 else levelName.matchEntire(resourceName)?.groupValues?.get(1)?.toInt() -// -// fun bounded(value: Float, default: Float, min: Float, max: Float): Float = -// if (value.isFinite()) value.coerceIn(min, max) else default -// } +package cn.ianzb.hyperrefine.hook.systemui.statusbar + +internal object StatusBarPolicy { + + data class Visibility(val root: Boolean, val signal: Boolean) + data class Signal( + val subId: Int, val resourceId: Int, val visible: Boolean, val rootVisible: Boolean = visible, + val cellularMode: Boolean = true, val satellite: Boolean = false, + ) + data class Pairing(val primary: Signal, val secondary: Signal) + + /** Only combine two real visible subscriptions. Unknown / single-SIM state keeps native layout. */ + fun pair(signals: List, dataSubId: Int): Pairing? { + val visible = signals.filter { it.visible && it.cellularMode && !it.satellite && it.subId >= 0 && it.resourceId > 0 } + .distinctBy { it.subId }.sortedBy { it.subId } + if (visible.size != 2) return null + val primary = visible.firstOrNull { it.subId == dataSubId } ?: visible.first() + return Pairing(primary, visible.first { it.subId != primary.subId }) + } + + private val levelName = Regex("^stat_sys_signal_([0-5])(?:_.*)?$") + fun signalLevel(resourceName: String): Int? = + if (resourceName == "stat_sys_signal_null") 0 else levelName.matchEntire(resourceName)?.groupValues?.get(1)?.toInt() + + fun bounded(value: Float, default: Float, min: Float, max: Float): Float = + if (value.isFinite()) value.coerceIn(min, max) else default +} diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StatusBarSupport.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StatusBarSupport.kt index d41b2a5..c56b661 100644 --- a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StatusBarSupport.kt +++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StatusBarSupport.kt @@ -60,15 +60,14 @@ internal object StatusBarMembers { else constructor.newInstance(flow, null) } - // DORMANT: helper required when restoring DualSimSignalHook and StatusBarPolicy. - // The host Pair comes from its own Kotlin class loader; do not cast to the module's Pair. - // fun mobileVisibility(value: Any?): StatusBarPolicy.Visibility? { - // if (value is Boolean) return StatusBarPolicy.Visibility(value, value) - // if (value?.javaClass?.name != "kotlin.Pair") return null - // val root = Reflect.callMethod(value, "getFirst") as? Boolean ?: return null - // val signal = Reflect.callMethod(value, "getSecond") as? Boolean ?: return null - // return StatusBarPolicy.Visibility(root, root && signal) - // } + /** The host Pair comes from its own Kotlin loader; do not cast it to the module's Pair. */ + fun mobileVisibility(value: Any?): StatusBarPolicy.Visibility? { + if (value is Boolean) return StatusBarPolicy.Visibility(value, value) + if (value?.javaClass?.name != "kotlin.Pair") return null + val root = Reflect.callMethod(value, "getFirst") as? Boolean ?: return null + val signal = Reflect.callMethod(value, "getSecond") as? Boolean ?: return null + return StatusBarPolicy.Visibility(root, root && signal) + } } internal class StatusBarGuard(private val label: String) { diff --git a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StatusBarViews.kt b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StatusBarViews.kt index ffdb6b8..d26f328 100644 --- a/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StatusBarViews.kt +++ b/hook/src/main/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StatusBarViews.kt @@ -1,21 +1,19 @@ -// DORMANT: preserved from statusbar.6 (ddb0e59); not compiled or registered. -// Restore only after artwork and device regression testing. Runtime diagnostics removed. -// package cn.ianzb.hyperrefine.hook.systemui.statusbar -// -// import android.view.View -// import android.view.ViewGroup -// import cn.ianzb.hyperrefine.hook.xposed.Reflect -// -// /** Native status-bar lookup only; no network-type styling or view reparenting. */ -// internal object StatusBarViews { -// const val MOBILE_BINDER = "com.android.systemui.statusbar.pipeline.mobile.ui.binder.MiuiMobileIconBinder" -// -// fun find(root: View, name: String): View? { -// val id = root.resources.getIdentifier(name, "id", StatusBarKeys.TARGET) -// return if (id == 0) null else root.findViewById(id) -// } -// -// fun isStatusBarMobile(root: ViewGroup, model: Any?): Boolean = -// model?.javaClass?.name?.contains("ShadeCarrierGroup") != true && -// (runCatching { Reflect.getObjectField(root, "slot") }.getOrNull() in listOf("mobile", "demo_mobile")) -// } +package cn.ianzb.hyperrefine.hook.systemui.statusbar + +import android.view.View +import android.view.ViewGroup +import cn.ianzb.hyperrefine.hook.xposed.Reflect + +/** Native status-bar lookup only; no network-type styling or view reparenting. */ +internal object StatusBarViews { + const val MOBILE_BINDER = "com.android.systemui.statusbar.pipeline.mobile.ui.binder.MiuiMobileIconBinder" + + fun find(root: View, name: String): View? { + val id = root.resources.getIdentifier(name, "id", StatusBarKeys.TARGET) + return if (id == 0) null else root.findViewById(id) + } + + fun isStatusBarMobile(root: ViewGroup, model: Any?): Boolean = + model?.javaClass?.name?.contains("ShadeCarrierGroup") != true && + (runCatching { Reflect.getObjectField(root, "slot") }.getOrNull() in listOf("mobile", "demo_mobile")) +} diff --git a/hook/src/test/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/DualSimArtworkPolicyTest.kt b/hook/src/test/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/DualSimArtworkPolicyTest.kt index c623a62..29da707 100644 --- a/hook/src/test/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/DualSimArtworkPolicyTest.kt +++ b/hook/src/test/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/DualSimArtworkPolicyTest.kt @@ -1,84 +1,106 @@ -// DORMANT: preserved from statusbar.6 (ddb0e59); not compiled or registered. -// Restore only after artwork and device regression testing. Runtime diagnostics removed. -// package cn.ianzb.hyperrefine.hook.systemui.statusbar -// -// import org.junit.Assert.* -// import org.junit.Test -// -// class DualSimArtworkPolicyTest { -// @Test fun sixNativeStrengthLevelsRemainMonotonicWithinFourCells() { -// assertEquals(listOf(0, 1, 2, 2, 3, 4), (0..5).map(DualSimArtworkPolicy::fourCellLevel)) -// assertNull(DualSimArtworkPolicy.fourCellLevel(null)) -// assertNull(DualSimArtworkPolicy.fourCellLevel(-1)) -// assertNull(DualSimArtworkPolicy.fourCellLevel(6)) -// } -// -// @Test fun dataSimSwitchDoesNotSwapTheDisplayedPhysicalSimRows() { -// val fromSim1 = DualSimArtworkPolicy.levels(0, 1, 5, 2) -// val fromSim2 = DualSimArtworkPolicy.levels(1, 0, 2, 5) -// assertEquals(DualSimArtworkPolicy.Levels(4, 2), fromSim1) -// assertEquals(fromSim1, fromSim2) -// } -// -// @Test fun duplicateOrUnknownSlotsAndUnknownStrengthKeepNativeLayout() { -// assertNull(DualSimArtworkPolicy.levels(0, 0, 3, 4)) -// assertNull(DualSimArtworkPolicy.levels(-1, 1, 3, 4)) -// assertNull(DualSimArtworkPolicy.levels(0, 2, 3, 4)) -// assertNull(DualSimArtworkPolicy.levels(0, 1, null, 4)) -// } -// -// @Test fun bothWidthsHaveFourAlignedCellsPerRowInsideTheDrawable() { -// for (thick in listOf(false, true)) for ((width, height) in listOf(16f to 20f, 32f to 28f, 8f to 8f)) { -// val cells = DualSimArtworkPolicy.cells(width, height, thick) -// assertEquals(8, cells.size) -// assertEquals(listOf(0, 1, 2, 3), cells.filter { it.row == 0 }.map { it.column }) -// cells.forEach { assertTrue(it.left >= 0 && it.top >= -0.0001f && it.right <= width && it.bottom <= height) } -// cells.take(4).zip(cells.drop(4)).forEach { (upper, lower) -> -// assertEquals(upper.left, lower.left, 0f) -// assertEquals(upper.right, lower.right, 0f) -// assertTrue(upper.bottom < lower.top) -// } -// } -// } -// -// @Test fun upperRowRisesAndLowerRowHasEqualShortCells() { -// val cells = DualSimArtworkPolicy.cells(16f, 20f, false) -// val upper = cells.take(4) -// val lower = cells.drop(4) -// assertTrue(upper.zipWithNext().all { (a, b) -> a.top > b.top }) -// assertEquals(1, upper.map { it.bottom }.distinct().size) -// assertEquals(1, lower.map { it.top }.distinct().size) -// assertEquals(1, lower.map { it.bottom }.distinct().size) -// assertTrue(lower.first().bottom - lower.first().top < upper.first().bottom - upper.first().top) -// // Both rows fit a compact silhouette rather than filling the host's tall drawable bounds. -// assertTrue(lower.last().bottom - upper.last().top < 16f) -// cells.forEach { -// val shortSide = minOf(it.right - it.left, it.bottom - it.top) -// assertTrue(it.radius > shortSide * 0.4f) -// assertTrue(it.radius <= shortSide / 2f) -// } -// } -// -// @Test fun invalidBoundsDoNotProduceInvalidCoordinates() { -// assertTrue(DualSimArtworkPolicy.cells(0f, 20f, false).isEmpty()) -// assertTrue(DualSimArtworkPolicy.cells(16f, -1f, false).isEmpty()) -// assertTrue(DualSimArtworkPolicy.cells(Float.NaN, 20f, false).isEmpty()) -// } -// -// @Test fun whiteAndBlackHostPalettesRetainRgbAndDimOnlyInactiveCells() { -// for (color in listOf(0xffffffff.toInt(), 0xff000000.toInt(), 0xff75aaff.toInt())) { -// assertEquals(color, DualSimArtworkPolicy.cellColor(color, 255, true)) -// val inactive = DualSimArtworkPolicy.cellColor(color, 255, false) -// assertEquals(color and 0x00ffffff, inactive and 0x00ffffff) -// assertEquals(71, inactive ushr 24) -// } -// } -// -// @Test fun hostTransparencyAndDrawableFadeMultiplyForBothSignalStates() { -// val color = 0x80ffffff.toInt() -// assertEquals(64, DualSimArtworkPolicy.cellColor(color, 128, true) ushr 24) -// assertEquals(18, DualSimArtworkPolicy.cellColor(color, 128, false) ushr 24) -// assertEquals(0, DualSimArtworkPolicy.cellColor(color, 0, true) ushr 24) -// assertEquals(128, DualSimArtworkPolicy.cellColor(color, 999, true) ushr 24) -// } -// } +package cn.ianzb.hyperrefine.hook.systemui.statusbar + +import org.junit.Assert.* +import org.junit.Test + +class DualSimArtworkPolicyTest { + @Test fun sixNativeStrengthLevelsRemainMonotonicWithinFourCells() { + assertEquals(listOf(0, 1, 2, 2, 3, 4), (0..5).map(DualSimArtworkPolicy::fourCellLevel)) + assertNull(DualSimArtworkPolicy.fourCellLevel(null)) + assertNull(DualSimArtworkPolicy.fourCellLevel(-1)) + assertNull(DualSimArtworkPolicy.fourCellLevel(6)) + } + + @Test fun dataSimSwitchDoesNotSwapTheDisplayedPhysicalSimRows() { + val fromSim1 = DualSimArtworkPolicy.levels(0, 1, 5, 2) + val fromSim2 = DualSimArtworkPolicy.levels(1, 0, 2, 5) + assertEquals(DualSimArtworkPolicy.Levels(4, 2), fromSim1) + assertEquals(fromSim1, fromSim2) + } + + @Test fun duplicateOrUnknownSlotsAndUnknownStrengthKeepNativeLayout() { + assertNull(DualSimArtworkPolicy.levels(0, 0, 3, 4)) + assertNull(DualSimArtworkPolicy.levels(-1, 1, 3, 4)) + assertNull(DualSimArtworkPolicy.levels(0, 2, 3, 4)) + assertNull(DualSimArtworkPolicy.levels(0, 1, null, 4)) + } + + @Test fun normalArtworkHasFourAlignedCellsPerRowInsideTheDrawable() { + for ((width, height) in listOf(16f to 20f, 32f to 28f, 8f to 8f)) { + val cells = DualSimArtworkPolicy.cells(width, height) + assertEquals(8, cells.size) + assertEquals(listOf(0, 1, 2, 3), cells.filter { it.row == 0 }.map { it.column }) + cells.forEach { assertTrue(it.left >= 0 && it.top >= -0.0001f && it.right <= width && it.bottom <= height) } + cells.take(4).zip(cells.drop(4)).forEach { (upper, lower) -> + assertEquals(upper.left, lower.left, 0f) + assertEquals(upper.right, lower.right, 0f) + assertTrue(upper.bottom < lower.top) + } + } + } + + @Test fun upperRowRisesAndLowerRowHasEqualShortCells() { + val cells = DualSimArtworkPolicy.cells(16f, 20f) + val upper = cells.take(4) + val lower = cells.drop(4) + assertTrue(upper.zipWithNext().all { (a, b) -> a.top > b.top }) + assertEquals(1, upper.map { it.bottom }.distinct().size) + assertEquals(1, lower.map { it.top }.distinct().size) + assertEquals(1, lower.map { it.bottom }.distinct().size) + assertTrue(lower.first().bottom - lower.first().top < upper.first().bottom - upper.first().top) + // Both rows fit a compact silhouette rather than filling the host's tall drawable bounds. + assertTrue(lower.last().bottom - upper.last().top < 16f) + cells.forEach { + val shortSide = minOf(it.right - it.left, it.bottom - it.top) + assertTrue(it.radius > shortSide * 0.4f) + assertTrue(it.radius <= shortSide / 2f) + } + } + + @Test fun invalidBoundsDoNotProduceInvalidCoordinates() { + assertTrue(DualSimArtworkPolicy.cells(0f, 20f).isEmpty()) + assertTrue(DualSimArtworkPolicy.cells(16f, -1f).isEmpty()) + assertTrue(DualSimArtworkPolicy.cells(Float.NaN, 20f).isEmpty()) + assertTrue(DualSimArtworkPolicy.cells(16f, Float.POSITIVE_INFINITY).isEmpty()) + } + + @Test fun canonicalViewportMatchesTheApprovedNormalSvg() { + val cells = DualSimArtworkPolicy.cells(16f, 20f) + assertEquals(0.72f, cells.first().left, 0.00001f) + assertEquals(2.56f, cells.first().right - cells.first().left, 0.00001f) + assertEquals(7.2f, cells[3].top, 0.00001f) + assertEquals(17.6448f, cells[4].top, 0.00001f) + assertEquals(20f, cells.last().bottom, 0f) + } + + @Test fun aspectChangesScaleUniformlyWithoutMorphingTheBarsOrCorners() { + val template = DualSimArtworkPolicy.cells(16f, 20f) + for ((width, height) in listOf(32f to 28f, 20f to 20f, 16f to 48f, 64f to 16f)) { + val scale = minOf(width / 16f, height / 20f) + val cells = DualSimArtworkPolicy.cells(width, height) + template.zip(cells).forEach { (before, after) -> + assertEquals((before.right - before.left) * scale, after.right - after.left, 0.00001f) + assertEquals((before.bottom - before.top) * scale, after.bottom - after.top, 0.00001f) + assertEquals(before.radius * scale, after.radius, 0.00001f) + } + assertEquals(height, cells.last().bottom, 0.00001f) + } + } + + @Test fun whiteAndBlackHostPalettesRetainRgbAndDimOnlyInactiveCells() { + for (color in listOf(0xffffffff.toInt(), 0xff000000.toInt(), 0xff75aaff.toInt())) { + assertEquals(color, DualSimArtworkPolicy.cellColor(color, 255, true)) + val inactive = DualSimArtworkPolicy.cellColor(color, 255, false) + assertEquals(color and 0x00ffffff, inactive and 0x00ffffff) + assertEquals(71, inactive ushr 24) + } + } + + @Test fun hostTransparencyAndDrawableFadeMultiplyForBothSignalStates() { + val color = 0x80ffffff.toInt() + assertEquals(64, DualSimArtworkPolicy.cellColor(color, 128, true) ushr 24) + assertEquals(18, DualSimArtworkPolicy.cellColor(color, 128, false) ushr 24) + assertEquals(0, DualSimArtworkPolicy.cellColor(color, 0, true) ushr 24) + assertEquals(128, DualSimArtworkPolicy.cellColor(color, 999, true) ushr 24) + } +} diff --git a/hook/src/test/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/DualSimRuntimeSupportTest.kt b/hook/src/test/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/DualSimRuntimeSupportTest.kt index 966f020..d2dae24 100644 --- a/hook/src/test/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/DualSimRuntimeSupportTest.kt +++ b/hook/src/test/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/DualSimRuntimeSupportTest.kt @@ -1,96 +1,135 @@ -// DORMANT: preserved from statusbar.6 (ddb0e59); not compiled or registered. -// Restore only after artwork and device regression testing. Runtime diagnostics removed. -// package cn.ianzb.hyperrefine.hook.systemui.statusbar -// -// import org.junit.Assert.* -// import org.junit.Test -// import java.lang.reflect.InvocationTargetException -// import java.util.function.Consumer -// -// class DualSimRuntimeSupportTest { -// class Flow(var current: Any?) { -// val listeners = mutableListOf>() -// fun getValue(): Any? = current -// fun emit(value: Any?) { current = value; listeners.forEach { it.accept(value) } } -// } -// -// class Adapter { -// var failNext = false -// fun alwaysCollectFlow(flow: Flow, consumer: Consumer) { -// if (failNext) { failNext = false; error("collector not ready") } -// flow.listeners += consumer -// consumer.accept(flow.current) -// } -// } -// -// @Test fun bindBeforeAdapterPreservesInitialAndLaterSignalEvents() { -// val observer = DualSimFlowObserver() -// val signal = Flow(2) -// val seen = mutableListOf() -// observer.observe(signal) { seen += it } -// signal.emit(3) -// assertTrue(seen.isEmpty()) -// assertTrue(observer.attach(Adapter())) -// signal.emit(4) -// assertEquals(listOf(3, 4), seen) -// assertEquals(4, observer.value(signal)) -// } -// -// @Test fun adapterBeforeBindCollectsIndependentFlowsAndDoesNotSubscribeTwice() { -// val observer = DualSimFlowObserver() -// val adapter = Adapter() -// assertTrue(observer.attach(adapter)) -// val first = Flow(1) -// val second = Flow(2) -// val firstSeen = mutableListOf() -// val secondSeen = mutableListOf() -// observer.observe(first) { firstSeen += it } -// observer.observe(second) { secondSeen += it } -// assertFalse(observer.attach(adapter)) -// first.emit(3) -// second.emit(4) -// assertEquals(listOf(1, 3), firstSeen) -// assertEquals(listOf(2, 4), secondSeen) -// assertEquals(1, first.listeners.size) -// assertEquals(1, second.listeners.size) -// } -// -// @Test fun failedDrainRetainsPendingSubscriptionForRetry() { -// val observer = DualSimFlowObserver() -// val adapter = Adapter().apply { failNext = true } -// val signal = Flow(5) -// val seen = mutableListOf() -// observer.observe(signal) { seen += it } -// try { observer.attach(adapter); fail("Must expose collection failure") } -// catch (error: InvocationTargetException) { assertEquals("collector not ready", error.cause?.message) } -// assertTrue(observer.attach(adapter)) -// assertEquals(listOf(5), seen) -// assertEquals(1, signal.listeners.size) -// } -// -// @Test fun unrelatedAdapterCannotConsumeOrDropPendingSignals() { -// val observer = DualSimFlowObserver() -// val seen = mutableListOf() -// observer.observe(Flow(1)) { seen += it } -// try { observer.attach(Any()); fail("Must reject an unsupported adapter") } -// catch (_: IllegalStateException) { assertTrue(seen.isEmpty()) } -// observer.attach(Adapter()) -// assertEquals(listOf(1), seen) -// } -// -// @Test fun laterLoadedApkCallbacksReuseOnlyCompletedInstallation() { -// val installation = DualSimInstallation() -// var calls = 0 -// assertTrue(installation.installOnce { calls++ }) -// assertFalse(installation.installOnce { error("Should not resolve classes from another loader") }) -// assertEquals(1, calls) -// } -// -// @Test fun failedInstallationKeepsOriginalErrorAndAllowsRealRetry() { -// val installation = DualSimInstallation() -// val expected = ClassNotFoundException("binder") -// try { installation.installOnce { throw expected }; fail("Must not record failed installation") } -// catch (actual: ClassNotFoundException) { assertSame(expected, actual) } -// assertTrue(installation.installOnce {}) -// } -// } +package cn.ianzb.hyperrefine.hook.systemui.statusbar + +import org.junit.Assert.* +import org.junit.Test +import java.lang.reflect.InvocationTargetException +import java.util.function.Consumer + +class DualSimRuntimeSupportTest { + @Test fun immediateNativeEmissionFlushDoesNotRunAgainOnTheNextFrame() { + val queued = mutableListOf<() -> Unit>() + var refreshes = 0 + val gate = DualSimRefreshGate({ queued += it }, { refreshes++ }) + gate.request() + gate.flush() + assertEquals(1, refreshes) + queued.single().invoke() + assertEquals(1, refreshes) + } + + @Test fun multipleSignalEventsCoalesceAtThePreDrawBarrier() { + val queued = mutableListOf<() -> Unit>() + var latest = 0 + val drawn = mutableListOf() + val gate = DualSimRefreshGate({ queued += it }, { drawn += latest }) + latest = 1; gate.request() + latest = 4; gate.request() + assertEquals(1, queued.size) + gate.flush() + queued.single().invoke() + assertEquals(listOf(4), drawn) + } + + @Test fun anEventDuringRefreshIsNotLostWhenThePreviousBatchFinishes() { + val queued = mutableListOf<() -> Unit>() + var refreshes = 0 + lateinit var gate: DualSimRefreshGate + gate = DualSimRefreshGate({ queued += it }, { + refreshes++ + if (refreshes == 1) gate.request() + }) + gate.request() + queued.removeAt(0).invoke() + assertEquals(1, refreshes) + queued.removeAt(0).invoke() + assertEquals(2, refreshes) + gate.flush() + assertEquals(2, refreshes) + } + + class Flow(var current: Any?) { + val listeners = mutableListOf>() + fun getValue(): Any? = current + fun emit(value: Any?) { current = value; listeners.forEach { it.accept(value) } } + } + + class Adapter { + var failNext = false + fun alwaysCollectFlow(flow: Flow, consumer: Consumer) { + if (failNext) { failNext = false; error("collector not ready") } + flow.listeners += consumer + consumer.accept(flow.current) + } + } + + @Test fun bindBeforeAdapterPreservesInitialAndLaterSignalEvents() { + val observer = DualSimFlowObserver() + val signal = Flow(2) + val seen = mutableListOf() + observer.observe(signal) { seen += it } + signal.emit(3) + assertTrue(seen.isEmpty()) + assertTrue(observer.attach(Adapter())) + signal.emit(4) + assertEquals(listOf(3, 4), seen) + assertEquals(4, observer.value(signal)) + } + + @Test fun adapterBeforeBindCollectsIndependentFlowsAndDoesNotSubscribeTwice() { + val observer = DualSimFlowObserver() + val adapter = Adapter() + assertTrue(observer.attach(adapter)) + val first = Flow(1) + val second = Flow(2) + val firstSeen = mutableListOf() + val secondSeen = mutableListOf() + observer.observe(first) { firstSeen += it } + observer.observe(second) { secondSeen += it } + assertFalse(observer.attach(adapter)) + first.emit(3) + second.emit(4) + assertEquals(listOf(1, 3), firstSeen) + assertEquals(listOf(2, 4), secondSeen) + assertEquals(1, first.listeners.size) + assertEquals(1, second.listeners.size) + } + + @Test fun failedDrainRetainsPendingSubscriptionForRetry() { + val observer = DualSimFlowObserver() + val adapter = Adapter().apply { failNext = true } + val signal = Flow(5) + val seen = mutableListOf() + observer.observe(signal) { seen += it } + try { observer.attach(adapter); fail("Must expose collection failure") } + catch (error: InvocationTargetException) { assertEquals("collector not ready", error.cause?.message) } + assertTrue(observer.attach(adapter)) + assertEquals(listOf(5), seen) + assertEquals(1, signal.listeners.size) + } + + @Test fun unrelatedAdapterCannotConsumeOrDropPendingSignals() { + val observer = DualSimFlowObserver() + val seen = mutableListOf() + observer.observe(Flow(1)) { seen += it } + try { observer.attach(Any()); fail("Must reject an unsupported adapter") } + catch (_: IllegalStateException) { assertTrue(seen.isEmpty()) } + observer.attach(Adapter()) + assertEquals(listOf(1), seen) + } + + @Test fun laterLoadedApkCallbacksReuseOnlyCompletedInstallation() { + val installation = DualSimInstallation() + var calls = 0 + assertTrue(installation.installOnce { calls++ }) + assertFalse(installation.installOnce { error("Should not resolve classes from another loader") }) + assertEquals(1, calls) + } + + @Test fun failedInstallationKeepsOriginalErrorAndAllowsRealRetry() { + val installation = DualSimInstallation() + val expected = ClassNotFoundException("binder") + try { installation.installOnce { throw expected }; fail("Must not record failed installation") } + catch (actual: ClassNotFoundException) { assertSame(expected, actual) } + assertTrue(installation.installOnce {}) + } +} diff --git a/hook/src/test/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StatusBarPolicyTest.kt b/hook/src/test/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StatusBarPolicyTest.kt index 1b28cbb..fffde6e 100644 --- a/hook/src/test/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StatusBarPolicyTest.kt +++ b/hook/src/test/java/cn/ianzb/hyperrefine/hook/systemui/statusbar/StatusBarPolicyTest.kt @@ -5,8 +5,9 @@ import org.junit.Test class StatusBarPolicyTest { @Test fun retainedOptionsHaveUniqueKeysAndOnlySystemUiIsTargeted() { - assertEquals(listOf(StatusBarKeys.WIFI_ACTIVITY, StatusBarKeys.HIDE_TYPE), StatusBarKeys.ALL) - assertEquals(2, StatusBarKeys.ALL.distinct().size) + assertEquals(listOf(StatusBarKeys.WIFI_ACTIVITY, StatusBarKeys.HIDE_TYPE) + StatusBarKeys.DUAL_KEYS, StatusBarKeys.ALL) + assertEquals(7, StatusBarKeys.ALL.distinct().size) + assertFalse(StatusBarKeys.ALL.any { it.contains("style") || it.contains("bold") }) assertEquals("com.android.systemui", StatusBarKeys.TARGET) } @@ -32,6 +33,36 @@ class StatusBarPolicyTest { class UnoptimizedReadonly(val flow: MutableFlow, val job: Any?) class InvalidReadonly(val value: Int) + @Test fun singleSimUnknownStrengthAndInvisibleCellKeepNativeLayout() { + val first = StatusBarPolicy.Signal(10, 101, true) + assertNull(StatusBarPolicy.pair(listOf(first), 10)) + assertNull(StatusBarPolicy.pair(listOf(first, StatusBarPolicy.Signal(20, 0, true)), 10)) + assertNull(StatusBarPolicy.pair(listOf(first, StatusBarPolicy.Signal(20, 102, false)), 10)) + } + + @Test fun satelliteAndVoWifiOnlyRowsDoNotBecomeCellularBars() { + val first = StatusBarPolicy.Signal(10, 101, true) + val second = StatusBarPolicy.Signal(20, 102, true) + assertNull(StatusBarPolicy.pair(listOf(first, second.copy(satellite = true)), 10)) + assertNull(StatusBarPolicy.pair(listOf(first, second.copy(cellularMode = false)), 10)) + assertNull(StatusBarPolicy.pair(listOf(first, second.copy(visible = false, rootVisible = true)), 10)) + } + + @Test fun dataSimChangesTheNativeCarrierButPhysicalSimStrengthOrderStaysFixed() { + val first = StatusBarPolicy.Signal(10, 101, true) + val second = StatusBarPolicy.Signal(20, 102, true) + assertEquals(first, StatusBarPolicy.pair(listOf(first, second), 10)?.primary) + assertEquals(second, StatusBarPolicy.pair(listOf(first, second), 20)?.primary) + assertEquals(DualSimArtworkPolicy.levels(0, 1, 5, 2), DualSimArtworkPolicy.levels(1, 0, 2, 5)) + } + + @Test fun hostPairVisibilityDistinguishesCellularSignalFromTheRoot() { + assertEquals(StatusBarPolicy.Visibility(true, false), StatusBarMembers.mobileVisibility(true to false)) + assertEquals(StatusBarPolicy.Visibility(false, false), StatusBarMembers.mobileVisibility(false to true)) + assertEquals(StatusBarPolicy.Visibility(true, true), StatusBarMembers.mobileVisibility(true)) + assertNull(StatusBarMembers.mobileVisibility(true to "unknown")) + } + @Test fun romOptimizedReadonlyFlowConstructorRetainsTheReplacementFlow() { val source = MutableFlow(false) val wrapped = StatusBarMembers.readonlyFlow(OptimizedReadonly::class.java, source) as OptimizedReadonly