diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f8baea5..d93d630 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -2,9 +2,7 @@ name: Build and Release
on:
push:
- branches: ["master", "main"]
- pull_request:
- branches: ["master", "main"]
+ branches: [ "master", "main", "dev" ]
workflow_dispatch:
jobs:
@@ -20,15 +18,21 @@ jobs:
with:
fetch-depth: 0
- - name: Set up JDK 17
- uses: actions/setup-java@v5
- with:
- java-version: "17"
- distribution: "temurin"
- cache: gradle
+ #- name: Set up JDK 21
+ # uses: actions/setup-java@v5
+ # with:
+ # java-version: "21"
+ # distribution: "temurin"
+ # cache: gradle
- name: Setup Android SDK
uses: amyu/setup-android@v5
+ with:
+ distribution: "zulu"
+ java-version: "21"
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v6
- name: Grant execute permission for gradlew
run: chmod +x gradlew
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index dbf5cc7..bc3364d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -21,10 +21,10 @@ jobs:
with:
fetch-depth: 0
- - name: Set up JDK 17
+ - name: Set up JDK 21
uses: actions/setup-java@v5
with:
- java-version: "17"
+ java-version: "21"
distribution: "temurin"
cache: gradle
@@ -77,6 +77,11 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Prepare Changelog for Xposed Repo
+ run: |
+ echo "https://github.com/killerprojecte/REAREye/releases/tag/${{ github.ref_name }}" > changelog_xposed.md
+ cat changelog.md >> changelog_xposed.md
+
- name: Publish to Xposed Modules Repo
uses: softprops/action-gh-release@v2
with:
@@ -85,5 +90,5 @@ jobs:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
generate_release_notes: false
- body: "https://github.com/killerprojecte/REAREye/releases/tag/${{ github.ref_name }}"
+ body_path: changelog_xposed.md
files: app/build/outputs/apk/release/*.apk
diff --git a/.gitignore b/.gitignore
index 2c6150c..9ab946a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,4 +15,10 @@
.cxx
local.properties
.claude
-.tmp-ref
\ No newline at end of file
+.tmp-ref
+rear-widget-api/build
+**/.project
+.project
+.settings
+**/.settings
+**/.classpath
\ No newline at end of file
diff --git a/README.md b/README.md
index bfbd5ec..3bb43f7 100644
--- a/README.md
+++ b/README.md
@@ -1,69 +1,188 @@
-# REAREye
-- 针对小米17Pro/Pro Max的背屏增强模块
-
-
-## 功能
-- 允许自定义应用在背屏中开启
-> 使用ADB或者其他应用开启 目前没有计划内置启动器
-- 允许自定义的音乐控件在背屏中显示
-> 可以支持任意使用媒体控件的APP
->
-> 如: Apple Music, BiliBili等
->
-> **修改白名单应用需要重启 com.xiaomi.subscreencenter - 背屏**
-
-- 将指定的应用设为后台白名单(防止在背屏被系统杀除)
-
-> 修改后需要重启系统生效
-
-- 强制更新音乐控件状态
-
-> 部分国外音乐APP在背屏息屏状态时播放下一首歌曲(自动切换)
->
-> 可能不会在背屏上立刻变更显示内容(在时钟变化时系统会触发更新)
->
-> 在开启此功能后 音乐播放状态发生变更时会强制触发更新
-
-### 主题管理
-
-- 移除视频长度/帧率限制
-
-> 移除视频长度限制(仍然保留视频剪辑功能)
->
-> - 合理范围内的视频长度都能正常剪辑并加载
-> - 已测试过2小时的哪吒2无法正常进入剪辑 但是可以通过先进入剪辑后取消再载入(该状态不会进入剪辑模式)
->
-> 移除视频帧率限制
-> - 默认帧率限制为最大30fps
-> - 启用本功能后自动使用视频的帧率
-> - 但上限为120帧(屏幕上限)
-
-- 移除背屏模板的数量限制
-
-> 原版背屏模板数量上限为15个
->
-> 开启本功能后将不再限制数量
->
-> 暂未测试在超过20个以上十分能够正常翻页
-
-## 杂项功能
-
-因为懒得重新写一个独立的Xposed模块
-
-所以先写在这了
-
-- 移除国行GMS服务限制
-
-> 启用并重启系统后将会强制在SystemConfig中移除下列feature
->
-> > cn.google.services
-> >
-> > com.google.android.feature.services_updater
->
-> 开启该功能后可以解锁GMS针对国行系统的限制
->
-> 例如 `Google Location History`, `Google Map Timeline` 等功能
->
-> 理论上可以彻底替代 https://github.com/fei-ke/unlock-cn-gms
-> 或类似的 Magisk/KernelSU 模块
-> 并且可以提供更广泛的兼容性
\ No newline at end of file
+
+
+
+
+REAREye
+
+
+ 适用于小米 17 Pro / 17 Pro Max 的背屏增强模块。
+ 基于 LSPosed / Xposed,为系统框架、背屏中心和主题管理提供更细粒度的可配置能力。
+
+
+
+
+
+
+
+
+
+
+## 功能概览
+
+- 放行指定应用在背屏启动,并支持活动白名单控制
+- 自定义音乐控件白名单,兼容更多媒体类应用
+- 增强歌词显示,支持原文、翻译、罗马音与不同歌词提供器
+- 管理背屏组件模板、常驻卡片与组件额外显示选项
+- 管理背屏壁纸、定时轮播、拖拽排序与轮播间隔
+- 解除主题管理中的多项限制,包括视频壁纸时长、帧率和模板数量限制
+- 移除国行 GMS 功能限制
+- 提供切换主屏 / 背屏的快捷设置 Tile
+
+## 主要功能
+
+### 模块设置
+
+- 主题模式切换
+ - 支持 Miuix / Monet 两套风格
+ - 支持跟随系统、浅色、深色模式
+- 隐藏桌面入口
+ - 可选隐藏 REAREye 的 Launcher 图标
+- 更多调试输出
+ - 便于排查模块行为与兼容性问题
+
+### 系统框架增强
+
+- 背屏应用活动白名单
+ - 自定义哪些应用允许在背屏中打开
+ - 可单独维护允许的应用列表
+- 允许所有 Activity 在背屏打开
+ - 适合自行测试未适配应用
+- 后台白名单
+ - 让指定应用在背屏使用时更不容易被系统杀掉
+- 锁定应用进程
+ - 将指定应用以更强的保活方式维持在后台
+- 阻止锁屏后背屏返回桌面
+ - 保持背屏当前状态,减少锁屏后的自动跳回
+- 禁用背屏保护
+ - 主屏进入全屏模式时,不再自动锁定背屏
+
+### 背屏中心增强
+
+#### 背屏个性化管理
+
+- 组件模板管理器
+ - 管理 Widget 到模板文件的映射关系
+ - 支持导入模板文件并立即更新运行时映射
+- 卡片管理器
+ - 管理常驻卡片的标题、目标包名、组件名称、优先级与启用状态
+- 组件额外设置管理器
+ - 手动添加需要额外显示选项的组件
+ - 当前支持为指定组件关闭通知时间显示
+- 壁纸管理器
+ - 查看当前系统可用背屏壁纸
+ - 设定当前壁纸
+ - 将壁纸加入轮播列表
+ - 拖拽调整轮播顺序
+ - 按毫秒配置每张壁纸的切换间隔
+ - 开启或关闭背屏壁纸轮播
+
+#### 音乐控件增强
+
+- 音乐控件白名单
+ - 自定义哪些应用的音乐控件允许显示在背屏中
+ - 对使用标准媒体控件的应用更友好
+- 强制刷新音乐控件状态
+ - 解决部分应用在切歌或状态变化后背屏控件更新不及时的问题
+
+#### 歌词增强
+
+- 歌词显示模式
+ - 支持原文、翻译、罗马音
+- 歌词提供器切换
+ - 支持 `Lyricon`
+ - 支持 `SuperLyric`
+- 逐行歌词显示模式
+ - 可选择显示原文或翻译
+
+#### 视频与媒体行为
+
+- 强制视频壁纸循环播放
+- 视频壁纸音量调节
+ - 支持从 0% 到 100% 细粒度调节播放音量
+
+### 主题管理增强
+
+- 解除视频壁纸限制
+ - 移除视频时长限制
+ - 移除默认帧率限制,尽量按素材原始帧率加载
+- 解除背屏模板数量上限
+ - 不再受原厂默认模板数量限制
+- 解除视频壁纸静音限制
+ - 允许添加带音频的视频壁纸
+
+### 杂项功能
+
+- 移除国行 GMS 服务限制
+ - 用于解除部分国行系统对 Google 服务功能的限制
+ - 例如时间线、位置记录等功能的可用性问题
+
+### 额外功能
+
+- 快捷设置 Tile
+ - 切换当前应用到背屏
+ - 将先前移至背屏的应用切回主屏
+- 首页状态与快捷操作
+ - 查看模块工作状态、版本信息、更新状态
+ - 提供针对背屏中心 / 主题管理等目标应用的快捷操作入口
+
+## 模块作用域
+
+默认作用域包含以下目标进程:
+
+- `android`
+- `com.xiaomi.subscreencenter`
+- `com.android.thememanager`
+- `com.android.systemui`
+
+## 前置要求
+
+- Android 8.1 及以上
+- LSPosed / Xposed 兼容环境
+- Xposed API 93 及以上
+- 适用于带背屏的目标机型,当前仓库主要面向小米 17 Pro / 17 Pro Max
+- 部分功能或快捷操作需要 Root 权限
+
+## 安装方式
+
+1. 从 [Releases](https://github.com/killerprojecte/REAREye/releases) 下载最新 APK 并安装。
+2. 在 LSPosed 或兼容框架中启用 `REAREye`。
+3. 确认模块作用域包含:`android`、`com.xiaomi.subscreencenter`、`com.android.thememanager`、
+ `com.android.systemui`。
+4. 重启设备,或至少重启相关目标应用后再进入模块配置。
+5. 打开模块应用,根据自己的需求启用对应功能。
+
+## 使用说明
+
+- 背屏白名单、音乐控件白名单、组件模板、卡片与壁纸轮播等配置修改后,建议重启
+ `com.xiaomi.subscreencenter`。
+- 系统框架类修改通常需要重启系统后才能稳定生效。
+- 主题管理相关修改建议在变更后重启 `com.android.thememanager`,必要时重启系统。
+- 部分快捷操作或系统级能力依赖 Root 权限,请确保 Root 管理器已正确授权。
+- 本模块不内置背屏应用启动器;如果你需要直接在背屏拉起某些应用,可以配合 ADB 或其他启动方式使用。
+- 解锁模板数量上限后,极端大数量模板场景仍建议自行测试稳定性。
+
+## 适用场景
+
+- 想让更多应用在背屏上正常启动或驻留
+- 想在背屏使用更多第三方音乐 App 的媒体控件
+- 想更自由地控制歌词来源和显示内容
+- 想自己维护背屏组件模板和常驻卡片
+- 想给背屏壁纸做定时轮播和排序管理
+- 想解除原厂主题管理器对视频壁纸和模板数量的限制
+- 想在不刷额外 Magisk 模块的情况下处理国行 GMS 限制
+
+## 获取帮助
+
+- 提交问题反馈: [Issues](https://github.com/killerprojecte/REAREye/issues)
+- 查看版本发布: [Releases](https://github.com/killerprojecte/REAREye/releases)
+- 项目仓库地址: [killerprojecte/REAREye](https://github.com/killerprojecte/REAREye)
+
+## 免责声明
+
+- 本模块会修改系统与目标应用行为,请自行评估风险。
+- 不同系统版本、不同固件版本、不同 Root / Xposed 环境之间可能存在兼容性差异。
+- 使用本模块造成的功能异常、数据问题或设备风险,请自行承担。
+
+## License
+
+See [LICENSE](./LICENSE).
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b1285fc..47e8c67 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -23,8 +23,14 @@ fun runGitCommand(vararg args: String): String? = runCatching {
}.getOrNull()
android {
+ buildToolsVersion = gropify.project.android.buildToolsVersion
namespace = gropify.project.app.packageName
- compileSdk = gropify.project.android.compileSdk
+ compileSdk {
+ version =
+ release(gropify.project.android.compileSdk) {
+ minorApiLevel = gropify.project.android.compileSdkMinor
+ }
+ }
val baseVersionName = gropify.project.app.versionName.replace("\"", "")
val buildSuffix = project.findProperty("buildSuffix") as? String ?: "dev"
@@ -102,6 +108,8 @@ tasks.withType().configureEach {
}
dependencies {
+ implementation(project(":rear-widget-api"))
+
compileOnly(libs.rovo89.xposed.api)
ksp(libs.yukihookapi.ksp.xposed)
implementation(libs.yukihookapi)
@@ -122,7 +130,15 @@ dependencies {
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
- implementation(libs.miuix)
+ implementation(libs.miuix.ui)
+ implementation(libs.miuix.preference)
implementation(libs.miuix.icons)
+ implementation(libs.haze)
+ implementation(libs.backdrop)
+ implementation(libs.capsule)
implementation(libs.okhttp)
+ implementation(libs.gson)
+ implementation(libs.lyricon.provider)
+ implementation(libs.lyricon.central)
+ implementation(libs.superlyric)
}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 81315c8..15d16a1 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -38,4 +38,5 @@
-dontwarn java.lang.reflect.AnnotatedType
# --- Tool ---
--keep class hk.uwu.reareye.hook.** { *; }
\ No newline at end of file
+-keep class hk.uwu.reareye.hook.** { *; }
+-keep class com.hchen.superlyricapi.* {*;}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3ad6023..3572f6e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,7 @@
+
+
+
+
diff --git a/app/src/main/java/hk/uwu/reareye/actions/RearWidgetApi.kt b/app/src/main/java/hk/uwu/reareye/actions/RearWidgetApi.kt
deleted file mode 100644
index aa4d955..0000000
--- a/app/src/main/java/hk/uwu/reareye/actions/RearWidgetApi.kt
+++ /dev/null
@@ -1,635 +0,0 @@
-@file:Suppress("UNCHECKED_CAST")
-
-package hk.uwu.reareye.actions
-
-import android.os.Bundle
-import org.json.JSONObject
-import java.util.concurrent.ConcurrentHashMap
-import java.util.concurrent.atomic.AtomicBoolean
-import java.util.concurrent.atomic.AtomicInteger
-
-/**
- * App 与 Hook 共用的业务 API。
- *
- * - App 侧:组装 dataChannel 命令 payload
- * - Hook 侧:接收命令后更新内存路由、通知池,并提供渲染所需 Bundle
- */
-object RearWidgetApi {
-
- object Channel {
- const val KEY_REGISTER_BUSINESS_FILE = "rear_register_business_file"
- const val KEY_UNREGISTER_BUSINESS_FILE = "rear_unregister_business_file"
- const val KEY_REGISTER_BUSINESS = "rear_register_business"
- const val KEY_UNREGISTER_BUSINESS = "rear_unregister_business"
- const val KEY_DISABLE_BUSINESS_DISPLAY = "rear_disable_business_display"
- const val KEY_POST_NOTICE = "rear_post_notice"
- const val KEY_UPDATE_NOTICE = "rear_update_notice"
- const val KEY_REMOVE_NOTICE = "rear_remove_notice"
- const val KEY_SYNC_STATE = "rear_sync_state"
- const val KEY_ACK = "rear_ack"
-
- const val OP_REGISTER_FILE = "register_file"
- const val OP_UNREGISTER_FILE = "unregister_file"
- const val OP_REGISTER = "register"
- const val OP_UNREGISTER = "unregister"
- const val OP_DISABLE_DISPLAY = "disable_display"
- const val OP_POST = "post"
- const val OP_UPDATE = "update"
- const val OP_REMOVE = "remove"
- const val OP_SYNC = "sync"
- }
-
- fun opFromChannelKey(key: String): String? = when (key) {
- Channel.KEY_REGISTER_BUSINESS_FILE -> Channel.OP_REGISTER_FILE
- Channel.KEY_UNREGISTER_BUSINESS_FILE -> Channel.OP_UNREGISTER_FILE
- Channel.KEY_REGISTER_BUSINESS -> Channel.OP_REGISTER
- Channel.KEY_UNREGISTER_BUSINESS -> Channel.OP_UNREGISTER
- Channel.KEY_DISABLE_BUSINESS_DISPLAY -> Channel.OP_DISABLE_DISPLAY
- Channel.KEY_POST_NOTICE -> Channel.OP_POST
- Channel.KEY_UPDATE_NOTICE -> Channel.OP_UPDATE
- Channel.KEY_REMOVE_NOTICE -> Channel.OP_REMOVE
- Channel.KEY_SYNC_STATE -> Channel.OP_SYNC
- else -> null
- }
-
- data class BusinessSpec(
- val packageName: String,
- val business: String,
- val filePath: String,
- val defaultIndex: Int = 0,
- val defaultPriority: Int = 500,
- )
-
- data class NoticeOptions(
- val sticky: Boolean = false,
- val disablePopup: Boolean = true,
- val forcePopup: Boolean = false,
- val enableFloat: Boolean = false,
- val showTimeTip: Boolean = true,
- val index: Int? = null,
- val priority: Int? = null,
- )
-
- data class NoticeTicket(
- val packageName: String,
- val business: String,
- val notificationId: Int,
- val compositeKey: String,
- )
-
- data class ActiveNotice(
- val ticket: NoticeTicket,
- val payload: Bundle,
- val options: NoticeOptions,
- val createdAt: Long = System.currentTimeMillis(),
- )
-
- @Volatile
- var defaultPackageName: String = "com.xiaomi.subscreencenter"
-
- internal val mapsDirty = AtomicBoolean(false)
-
- // 全局 business -> filePath 映射,可先注册文件再注册业务。
- private val businessFiles = ConcurrentHashMap()
-
- private val routes = ConcurrentHashMap>()
- private val notices = ConcurrentHashMap()
- private val cardNoticeIdIndex = ConcurrentHashMap()
- private val cardNoticeCompositeIndex = ConcurrentHashMap()
- private val idSeed = AtomicInteger(310000)
-
- fun install(defaultPkg: String) {
- defaultPackageName = defaultPkg
- routes.computeIfAbsent(defaultPkg) { linkedMapOf() }
- mapsDirty.set(true)
- }
-
- /**
- * 注册或覆盖 business 对应的模板文件路径。
- *
- * 流程建议:先调用本方法,再按需调用 registerBusinessWithoutFile。
- */
- fun registerBusinessFile(business: String, filePath: String) {
- businessFiles[business] = filePath
- // 若该 business 已绑定到某些包,顺带更新其 filePath。
- routes.forEach { (_, bizMap) ->
- val old = bizMap[business]
- if (old != null) bizMap[business] = old.copy(filePath = filePath)
- }
- mapsDirty.set(true)
- }
-
- fun getBusinessFile(business: String): String? =
- businessFiles[business] ?: routes.values
- .firstNotNullOfOrNull { it[business]?.filePath }
-
- fun unregisterBusinessFile(business: String) {
- businessFiles.remove(business)
- routes.forEach { (_, bizMap) -> bizMap.remove(business) }
- mapsDirty.set(true)
- }
-
- fun registerBusiness(
- packageName: String = defaultPackageName,
- business: String,
- filePath: String,
- defaultIndex: Int = 0,
- defaultPriority: Int = 500,
- ) {
- businessFiles[business] = filePath
- val spec = BusinessSpec(packageName, business, filePath, defaultIndex, defaultPriority)
- routes.computeIfAbsent(packageName) { linkedMapOf() }[business] = spec
- mapsDirty.set(true)
- }
-
- /**
- * 不传 filePath 的业务注册:将使用已登记的 business 文件路径。
- * 返回 false 表示未找到对应文件路径。
- */
- fun registerBusinessWithoutFile(
- packageName: String = defaultPackageName,
- business: String,
- defaultIndex: Int = 0,
- defaultPriority: Int = 500,
- ): Boolean {
- val filePath = getBusinessFile(business) ?: return false
- registerBusiness(packageName, business, filePath, defaultIndex, defaultPriority)
- return true
- }
-
- fun isBusinessRegistered(packageName: String = defaultPackageName, business: String): Boolean {
- return routes[packageName]?.containsKey(business) == true
- }
-
- fun unregisterBusiness(packageName: String = defaultPackageName, business: String) {
- routes[packageName]?.remove(business)
- mapsDirty.set(true)
- }
-
- fun listBusinesses(packageName: String = defaultPackageName): List {
- return routes[packageName]?.values?.toList().orEmpty()
- }
-
- fun postNotice(
- business: String,
- payload: Bundle = Bundle(),
- options: NoticeOptions = NoticeOptions(),
- packageName: String = defaultPackageName,
- ): NoticeTicket {
- val spec = routes[packageName]?.get(business)
- ?: error("Business not registered: $packageName/$business")
- val merged = options.copy(
- index = options.index ?: spec.defaultIndex,
- priority = options.priority ?: spec.defaultPriority,
- )
-
- val cardId = payload.getString("__rear_card_id__")?.trim().orEmpty()
- if (cardId.isNotBlank()) {
- val cardKey = cardNoticeKey(packageName, business, cardId)
- val existingComposite = cardNoticeCompositeIndex[cardKey]
- if (!existingComposite.isNullOrBlank()) {
- val existing = notices[existingComposite]
- if (existing != null) {
- notices[existingComposite] = existing.copy(
- payload = Bundle(payload),
- options = merged,
- )
- return existing.ticket
- }
- cardNoticeCompositeIndex.remove(cardKey)
- }
-
- val id = cardNoticeIdIndex.getOrPut(cardKey) { idSeed.incrementAndGet() }
- val key = "$packageName:$business:$id"
- val ticket = NoticeTicket(packageName, business, id, key)
- notices[key] = ActiveNotice(ticket, Bundle(payload), merged)
- cardNoticeCompositeIndex[cardKey] = key
- return ticket
- }
-
- val id = idSeed.incrementAndGet()
- val key = "$packageName:$business:$id"
- val ticket = NoticeTicket(packageName, business, id, key)
- notices[key] = ActiveNotice(ticket, Bundle(payload), merged)
- return ticket
- }
-
- /** 启用并显示指定 business(语义化别名)。 */
- fun enableBusinessDisplay(
- business: String,
- payload: Bundle = Bundle(),
- options: NoticeOptions = NoticeOptions(),
- packageName: String = defaultPackageName,
- ): NoticeTicket = postNotice(business, payload, options, packageName)
-
- fun updateNotice(
- ticket: NoticeTicket,
- payload: Bundle? = null,
- options: NoticeOptions? = null
- ) {
- val old = notices[ticket.compositeKey] ?: return
- notices[ticket.compositeKey] = old.copy(
- payload = payload ?: old.payload,
- options = options ?: old.options,
- )
- }
-
- fun removeNotice(ticket: NoticeTicket) {
- val removed = notices.remove(ticket.compositeKey) ?: return
- val cardId = removed.payload.getString("__rear_card_id__")?.trim().orEmpty()
- if (cardId.isNotBlank()) {
- val cardKey = cardNoticeKey(ticket.packageName, ticket.business, cardId)
- cardNoticeCompositeIndex.remove(cardKey)
- }
- }
-
- /** 解除显示指定 ticket(语义化别名)。 */
- fun disableBusinessDisplay(ticket: NoticeTicket) {
- removeNotice(ticket)
- }
-
- /** 解除显示某包下某 business 的全部实例。 */
- fun disableBusinessDisplay(packageName: String = defaultPackageName, business: String): Int {
- val targets = notices.values
- .filter { it.ticket.packageName == packageName && it.ticket.business == business }
- .map { it.ticket }
- targets.forEach { removeNotice(it) }
- return targets.size
- }
-
- fun setSticky(ticket: NoticeTicket, sticky: Boolean) {
- val old = notices[ticket.compositeKey] ?: return
- notices[ticket.compositeKey] = old.copy(options = old.options.copy(sticky = sticky))
- }
-
- fun listNotices(): List = notices.values.sortedByDescending { it.createdAt }
-
- fun getNotice(compositeKey: String): ActiveNotice? = notices[compositeKey]
-
- /** Hook 侧调用:将业务通知转为 SmartAssistant 可消费的 extras。 */
- fun buildDecoratedExtras(ticket: NoticeTicket): Bundle {
- val notice = notices[ticket.compositeKey]
- ?: error("Notice not found: ${ticket.compositeKey}")
- return buildDecoratedExtras(notice)
- }
-
- private fun buildDecoratedExtras(notice: ActiveNotice): Bundle {
- val ticket = notice.ticket
- val options = notice.options
- val out = Bundle(notice.payload)
-
- out.putString("package_name", ticket.packageName)
- out.putString("creator_package", ticket.packageName)
- out.putString("business", ticket.business)
- out.putInt("index", options.index ?: 0)
- out.putInt("priority", options.priority ?: 500)
- out.putInt("notification_id", ticket.notificationId)
- out.putInt("widget_id", ticket.notificationId)
- out.putString("composite_key", ticket.compositeKey)
- out.putLong("timestamp", System.currentTimeMillis())
-
- out.putBoolean("disable_popup", options.disablePopup)
- out.putBoolean("force_popup", options.forcePopup)
- out.putBoolean("enableFloat", options.enableFloat)
- out.putBoolean("show_time_tip", options.showTimeTip)
- out.putBoolean("__x_sticky__", options.sticky)
-
- out.putString("miui.rear.param", buildRearParamJson(ticket.business, options))
- out.putString("miui.focus.param", buildFocusParamJson(ticket.business, options))
- out.putString("__xposed_origin__", ticket.packageName)
- return out
- }
-
- /** dataChannel 收到命令后统一处理入口。 */
- fun handleChannelCommand(op: String, payloadJson: String): String {
- return runCatching {
- val obj = JSONObject(payloadJson)
- when (op) {
- Channel.OP_REGISTER_FILE -> {
- registerBusinessFile(
- business = obj.getString("business"),
- filePath = obj.getString("filePath"),
- )
- ack(true, op, "ok")
- }
-
- Channel.OP_UNREGISTER_FILE -> {
- unregisterBusinessFile(obj.getString("business"))
- ack(true, op, "ok")
- }
-
- Channel.OP_REGISTER -> {
- val pkg = obj.optString("packageName", defaultPackageName)
- val biz = obj.getString("business")
- val index = obj.optInt("defaultIndex", 0)
- val priority = obj.optInt("defaultPriority", 500)
- if (obj.has("filePath")) {
- registerBusiness(
- packageName = pkg,
- business = biz,
- filePath = obj.getString("filePath"),
- defaultIndex = index,
- defaultPriority = priority,
- )
- } else {
- val ok = registerBusinessWithoutFile(
- packageName = pkg,
- business = biz,
- defaultIndex = index,
- defaultPriority = priority,
- )
- if (!ok) return@runCatching ack(
- false,
- op,
- "filePath not found for business: $biz"
- )
- }
- ack(true, op, "ok")
- }
-
- Channel.OP_UNREGISTER -> {
- val pkg = obj.optString("packageName", defaultPackageName)
- unregisterBusiness(pkg, obj.getString("business"))
- ack(true, op, "ok")
- }
-
- Channel.OP_DISABLE_DISPLAY -> {
- val pkg = obj.optString("packageName", defaultPackageName)
- val count = disableBusinessDisplay(pkg, obj.getString("business"))
- ack(true, op, "ok", JSONObject().put("removed", count))
- }
-
- Channel.OP_POST -> {
- val pkg = obj.optString("packageName", defaultPackageName)
- val ticket = postNotice(
- packageName = pkg,
- business = obj.getString("business"),
- payload = jsonToBundle(obj.optJSONObject("payload")),
- options = jsonToOptions(obj.optJSONObject("options")),
- )
- ack(
- true,
- op,
- "ok",
- JSONObject()
- .put("packageName", ticket.packageName)
- .put("business", ticket.business)
- .put("notificationId", ticket.notificationId)
- .put("compositeKey", ticket.compositeKey)
- )
- }
-
- Channel.OP_UPDATE -> {
- val ticket = jsonToTicket(obj.getJSONObject("ticket"))
- val payload =
- if (obj.has("payload")) jsonToBundle(obj.optJSONObject("payload")) else null
- val options =
- if (obj.has("options")) jsonToOptions(obj.optJSONObject("options")) else null
- updateNotice(ticket, payload, options)
- ack(true, op, "ok")
- }
-
- Channel.OP_REMOVE -> {
- removeNotice(jsonToTicket(obj.getJSONObject("ticket")))
- ack(true, op, "ok")
- }
-
- Channel.OP_SYNC -> ack(true, op, "ok")
- else -> ack(false, op, "unknown op: $op")
- }
- }.getOrElse { e ->
- ack(false, op, e.message ?: "unknown error")
- }
- }
-
- // -------- App 侧 payload 构造工具 --------
-
- fun buildRegisterBusinessFilePayload(
- business: String,
- filePath: String,
- ): String {
- return JSONObject()
- .put("business", business)
- .put("filePath", filePath)
- .toString()
- }
-
- fun buildRegisterBusinessPayload(
- business: String,
- filePath: String,
- packageName: String = defaultPackageName,
- defaultIndex: Int = 0,
- defaultPriority: Int = 500,
- ): String {
- return JSONObject()
- .put("packageName", packageName)
- .put("business", business)
- .put("filePath", filePath)
- .put("defaultIndex", defaultIndex)
- .put("defaultPriority", defaultPriority)
- .toString()
- }
-
- fun buildRegisterBusinessPayloadWithoutFile(
- business: String,
- packageName: String = defaultPackageName,
- defaultIndex: Int = 0,
- defaultPriority: Int = 500,
- ): String {
- return JSONObject()
- .put("packageName", packageName)
- .put("business", business)
- .put("defaultIndex", defaultIndex)
- .put("defaultPriority", defaultPriority)
- .toString()
- }
-
- fun buildUnregisterBusinessPayload(
- business: String,
- packageName: String = defaultPackageName,
- ): String {
- return JSONObject()
- .put("packageName", packageName)
- .put("business", business)
- .toString()
- }
-
- fun buildUnregisterBusinessFilePayload(business: String): String {
- return JSONObject()
- .put("business", business)
- .toString()
- }
-
- fun buildDisableBusinessDisplayPayload(
- business: String,
- packageName: String = defaultPackageName,
- ): String {
- return JSONObject()
- .put("packageName", packageName)
- .put("business", business)
- .toString()
- }
-
- fun buildPostNoticePayload(
- business: String,
- packageName: String = defaultPackageName,
- payload: Bundle = Bundle(),
- options: NoticeOptions = NoticeOptions(),
- ): String {
- return JSONObject()
- .put("packageName", packageName)
- .put("business", business)
- .put("payload", bundleToJson(payload))
- .put("options", optionsToJson(options))
- .toString()
- }
-
- fun buildUpdateNoticePayload(
- ticket: NoticeTicket,
- payload: Bundle? = null,
- options: NoticeOptions? = null,
- ): String {
- val root = JSONObject().put("ticket", ticketToJson(ticket))
- if (payload != null) root.put("payload", bundleToJson(payload))
- if (options != null) root.put("options", optionsToJson(options))
- return root.toString()
- }
-
- fun buildRemoveNoticePayload(ticket: NoticeTicket): String {
- return JSONObject().put("ticket", ticketToJson(ticket)).toString()
- }
-
- fun buildSyncPayload(): String = JSONObject().toString()
-
- fun parseAck(ackJson: String): JSONObject = JSONObject(ackJson)
-
- // -------- Hook 侧路由辅助 --------
-
- internal fun allPkgBusinesses(): Map> =
- routes.mapValues { it.value.keys.toSet() }
-
- internal fun allPackages(): Set = routes.keys
-
- internal fun primaryBusinessByPkg(): Map =
- routes.mapValues { (_, bizMap) -> bizMap.keys.firstOrNull().orEmpty() }
-
- internal fun allBusinessPath(): Map {
- val out = HashMap()
- out.putAll(businessFiles)
- routes.values.flatMap { it.values }.forEach { spec -> out[spec.business] = spec.filePath }
- return out
- }
-
- internal fun fallbackBusiness(packageName: String): String? {
- val latest = notices.values.asSequence()
- .filter { it.ticket.packageName == packageName }.maxByOrNull { it.createdAt }
- return latest?.ticket?.business ?: routes[packageName]?.keys?.firstOrNull()
- }
-
- // -------- JSON/Bundle 转换 --------
-
- private fun jsonToTicket(obj: JSONObject): NoticeTicket = NoticeTicket(
- packageName = obj.getString("packageName"),
- business = obj.getString("business"),
- notificationId = obj.getInt("notificationId"),
- compositeKey = obj.getString("compositeKey"),
- )
-
- private fun jsonToOptions(obj: JSONObject?): NoticeOptions {
- if (obj == null) return NoticeOptions()
- return NoticeOptions(
- sticky = obj.optBoolean("sticky", false),
- disablePopup = obj.optBoolean("disablePopup", true),
- forcePopup = obj.optBoolean("forcePopup", false),
- enableFloat = obj.optBoolean("enableFloat", false),
- showTimeTip = obj.optBoolean("showTimeTip", true),
- index = if (obj.has("index")) obj.optInt("index") else null,
- priority = if (obj.has("priority")) obj.optInt("priority") else null,
- )
- }
-
- private fun jsonToBundle(obj: JSONObject?): Bundle {
- val out = Bundle()
- if (obj == null) return out
- val it = obj.keys()
- while (it.hasNext()) {
- val key = it.next()
- when (val value = obj.opt(key)) {
- is String -> out.putString(key, value)
- is Int -> out.putInt(key, value)
- is Long -> out.putLong(key, value)
- is Boolean -> out.putBoolean(key, value)
- is Double -> out.putDouble(key, value)
- is JSONObject -> out.putString(key, value.toString())
- null -> Unit
- else -> out.putString(key, value.toString())
- }
- }
- return out
- }
-
- private fun bundleToJson(bundle: Bundle): JSONObject {
- val out = JSONObject()
- for (k in bundle.keySet()) {
- when (@Suppress("DEPRECATION") val v = bundle.get(k)) {
- is String, is Int, is Long, is Boolean, is Double -> out.put(k, v)
- is Bundle -> out.put(k, bundleToJson(v))
- null -> out.put(k, JSONObject.NULL)
- else -> out.put(k, v.toString())
- }
- }
- return out
- }
-
- private fun ticketToJson(ticket: NoticeTicket): JSONObject = JSONObject()
- .put("packageName", ticket.packageName)
- .put("business", ticket.business)
- .put("notificationId", ticket.notificationId)
- .put("compositeKey", ticket.compositeKey)
-
- private fun optionsToJson(options: NoticeOptions): JSONObject = JSONObject()
- .put("sticky", options.sticky)
- .put("disablePopup", options.disablePopup)
- .put("forcePopup", options.forcePopup)
- .put("enableFloat", options.enableFloat)
- .put("showTimeTip", options.showTimeTip)
- .put("index", options.index)
- .put("priority", options.priority)
-
- private fun buildRearParamJson(business: String, options: NoticeOptions): String {
- val v1 = JSONObject()
- .put("business", business)
- .put("index", options.index ?: 0)
- .put("priority", options.priority ?: 500)
- .put("disable_popup", options.disablePopup)
- .put("show_time_tip", options.showTimeTip)
- .put("swipe_out_screen_listener", false)
- .put("enableFloat", options.enableFloat)
- return JSONObject().put("rear_param_v1", v1).toString()
- }
-
- private fun buildFocusParamJson(business: String, options: NoticeOptions): String {
- return JSONObject()
- .put("business", business)
- .put("index", options.index ?: 0)
- .put("priority", options.priority ?: 500)
- .put("disable_popup", options.disablePopup)
- .put("show_time_tip", options.showTimeTip)
- .put("swipe_out_screen_listener", false)
- .put("enableFloat", options.enableFloat)
- .toString()
- }
-
- private fun ack(ok: Boolean, op: String, message: String, data: JSONObject? = null): String {
- val root = JSONObject()
- .put("ok", ok)
- .put("op", op)
- .put("message", message)
- if (data != null) root.put("data", data)
- return root.toString()
- }
-
- private fun cardNoticeKey(packageName: String, business: String, cardId: String): String {
- return "$packageName:$business:$cardId"
- }
-}
diff --git a/app/src/main/java/hk/uwu/reareye/application/DefaultApplication.kt b/app/src/main/java/hk/uwu/reareye/application/DefaultApplication.kt
index bca00f1..b7c3943 100644
--- a/app/src/main/java/hk/uwu/reareye/application/DefaultApplication.kt
+++ b/app/src/main/java/hk/uwu/reareye/application/DefaultApplication.kt
@@ -1,11 +1,13 @@
package hk.uwu.reareye.application
import com.highcapable.yukihookapi.hook.xposed.application.ModuleApplication
+import hk.uwu.reareye.repository.contributor.ContributorRepository
class DefaultApplication : ModuleApplication() {
override fun onCreate() {
super.onCreate()
+ ContributorRepository.preload()
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/hk/uwu/reareye/hook/HookEntry.kt b/app/src/main/java/hk/uwu/reareye/hook/HookEntry.kt
index 6186310..d062e70 100644
--- a/app/src/main/java/hk/uwu/reareye/hook/HookEntry.kt
+++ b/app/src/main/java/hk/uwu/reareye/hook/HookEntry.kt
@@ -26,6 +26,8 @@ class HookEntry : IYukiHookXposedInit {
override fun onInit() = configs {
debugLog {
tag = "REAREye"
+ isEnable = true
+ elements(TAG, PRIORITY, PACKAGE_NAME, USER_ID)
}
}
diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/SubscreenCenterScope.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/SubscreenCenterScope.kt
index 4c8d281..63e9135 100644
--- a/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/SubscreenCenterScope.kt
+++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/SubscreenCenterScope.kt
@@ -3,13 +3,21 @@ package hk.uwu.reareye.hook.scopes.subscreencenter
import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker
import hk.uwu.reareye.hook.scopes.Scope
import hk.uwu.reareye.hook.scopes.subscreencenter.modules.MusicControlWhitelistModule
+import hk.uwu.reareye.hook.scopes.subscreencenter.modules.RearWallpaperHook
import hk.uwu.reareye.hook.scopes.subscreencenter.modules.VideoLoopModule
-import hk.uwu.reareye.hook.scopes.subscreencenter.modules.rearwidget.RearWidgetHooker
+import hk.uwu.reareye.hook.scopes.subscreencenter.modules.VideoVolumeHook
+import hk.uwu.reareye.hook.scopes.subscreencenter.modules.lyrics.LyriconHook
+import hk.uwu.reareye.hook.scopes.subscreencenter.modules.rearwidget.ExtraTimeTipHook
+import hk.uwu.reareye.hook.scopes.subscreencenter.modules.rearwidget.RearWidgetHook
class SubscreenCenterScope : Scope {
override val hooks: List = listOf(
MusicControlWhitelistModule(),
VideoLoopModule(),
- RearWidgetHooker(),
+ RearWallpaperHook(),
+ RearWidgetHook(),
+ LyriconHook(),
+ VideoVolumeHook(),
+ ExtraTimeTipHook()
)
}
diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/MusicControlWhitelistModule.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/MusicControlWhitelistModule.kt
index 3584a3d..a40006e 100644
--- a/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/MusicControlWhitelistModule.kt
+++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/MusicControlWhitelistModule.kt
@@ -4,7 +4,7 @@ import android.media.MediaMetadata
import com.highcapable.kavaref.KavaRef.Companion.asResolver
import com.highcapable.kavaref.KavaRef.Companion.resolve
import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker
-import de.robv.android.xposed.XposedBridge
+import com.highcapable.yukihookapi.hook.log.YLog
import hk.uwu.reareye.ui.config.ConfigKeys
class MusicControlWhitelistModule : YukiBaseHooker() {
@@ -24,7 +24,7 @@ class MusicControlWhitelistModule : YukiBaseHooker() {
}
if (prefs.getBoolean(ConfigKeys.HOOK_MUSIC_CONTROLS_WHITELIST, true)) {
field.set(map)
- XposedBridge.log("Hooked SubscreenCenter whitelist ${field.get()}")
+ YLog.debug("Hooked SubscreenCenter whitelist ${field.get()}")
}
val musicControlListenerClz =
@@ -49,7 +49,9 @@ class MusicControlWhitelistModule : YukiBaseHooker() {
mRoot.asResolver().firstMethod {
name = "requestUpdate"
}.invoke()
- XposedBridge.log("Request render controller to update metadata")
+ if (prefs.getBoolean(ConfigKeys.MORE_DEBUG, false)) {
+ YLog.debug("Request render controller to update metadata")
+ }
}
}
}
diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/RearWallpaperHook.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/RearWallpaperHook.kt
new file mode 100644
index 0000000..dd8cf92
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/RearWallpaperHook.kt
@@ -0,0 +1,640 @@
+@file:Suppress("UNCHECKED_CAST")
+
+package hk.uwu.reareye.hook.scopes.subscreencenter.modules
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.os.Binder
+import android.os.Bundle
+import android.os.Handler
+import android.os.Process
+import androidx.core.content.ContextCompat
+import androidx.core.content.edit
+import com.highcapable.kavaref.KavaRef.Companion.asResolver
+import com.highcapable.kavaref.KavaRef.Companion.resolve
+import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker
+import com.highcapable.yukihookapi.hook.log.YLog
+import hk.uwu.reareye.ui.config.ConfigKeys
+import hk.uwu.reareye.widgetapi.IRearWallpaperApiConnection
+import hk.uwu.reareye.widgetapi.IRearWallpaperApiService
+import hk.uwu.reareye.widgetapi.RearWallpaperApiContract
+import hk.uwu.reareye.widgetapi.RearWallpaperScheduleCodec
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.util.concurrent.atomic.AtomicBoolean
+
+class RearWallpaperHook : YukiBaseHooker() {
+
+ companion object {
+ private const val TAG = "REAREye-RearWallpaper"
+ private const val RETRY_SWITCH_DELAY_MS = 350L
+ private const val RUNTIME_PREFS_NAME = "reareye_hook_runtime"
+
+ @Volatile
+ private var cachedNextSwitchAtMillis: Long = Long.MIN_VALUE
+
+ @Volatile
+ private var cachedScheduleConfig: ScheduleConfig? = null
+ }
+
+ private data class WallpaperEntry(
+ val wallpaperId: Int,
+ val title: String,
+ val name: String,
+ val previewPath: String?,
+ val previewSignature: String,
+ val widget: Any,
+ )
+
+ private data class ResolvedScheduleItem(
+ val wallpaperId: Int,
+ val runtimeIndex: Int,
+ val delayMs: Long,
+ )
+
+ private data class ScheduleConfig(
+ val enabled: Boolean,
+ val scheduleData: String,
+ )
+
+ private data class SwitchResult(
+ val exists: Boolean,
+ val applied: Boolean,
+ )
+
+ private val bootstrapReceiverRegistered = AtomicBoolean(false)
+ private var hostContext: Context? = null
+ private var mainPanel: Any? = null
+ private var smartPanel: Any? = null
+ private var mainHandler: Handler? = null
+ private var schedulerTask: Runnable? = null
+
+ override fun onHook() {
+ loadApp("com.xiaomi.subscreencenter") {
+ val appRef = "com.xiaomi.subscreencenter.SubScreenCenterApp".toClass().resolve()
+ val launcherRef = "com.xiaomi.subscreencenter.SubScreenLauncher".toClass().resolve()
+
+ appRef.firstMethod {
+ name = "attachBaseContext"
+ parameterCount = 1
+ }.hook().after {
+ hostContext = (args[0] as? Context)?.applicationContext ?: (args[0] as? Context)
+ registerHookBootstrapReceiver()
+ }
+
+ launcherRef.firstMethod {
+ name = "onCreate"
+ parameterCount = 1
+ }.hook().after {
+ capturePanels(instance)
+ refreshSchedule(forceApply = true)
+ }
+
+ launcherRef.firstMethod {
+ name = "onResume"
+ parameterCount = 0
+ }.hook().after {
+ capturePanels(instance)
+ refreshSchedule(forceApply = true)
+ }
+
+ launcherRef.firstMethod {
+ name = "onPause"
+ parameterCount = 0
+ }.hook().before {
+ debugLog("launcher onPause keep scheduler nextAt=${readNextSwitchAt()}")
+ }
+
+ launcherRef.firstMethod {
+ name = "onDestroy"
+ parameterCount = 0
+ }.hook().before {
+ stopScheduler()
+ mainPanel = null
+ smartPanel = null
+ mainHandler = null
+ }
+ }
+ }
+
+ private val hookBinder = object : IRearWallpaperApiService.Stub() {
+ override fun getCatalog(): Bundle {
+ enforceCallerPermission()
+ return buildCatalogBundle()
+ }
+
+ override fun getPreview(wallpaperId: Int): ByteArray? {
+ enforceCallerPermission()
+ val entry =
+ loadWallpaperEntries().firstOrNull { it.wallpaperId == wallpaperId } ?: return null
+ return loadPreviewBytes(entry.previewPath)
+ }
+
+ override fun switchWallpaper(wallpaperId: Int): Boolean {
+ enforceCallerPermission()
+ return switchWallpaperInternal(wallpaperId).exists
+ }
+
+ override fun syncSchedule(enabled: Boolean, scheduleData: String?): Boolean {
+ enforceCallerPermission()
+ updateScheduleConfig(enabled, scheduleData)
+ persistNextSwitchAt(0L)
+ refreshSchedule(forceApply = true)
+ return true
+ }
+ }
+
+ private val hookBootstrapReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action != RearWallpaperApiContract.ACTION_REQUEST_HOOK_SERVICE) return
+ val callbackBinder = intent
+ .getBundleExtra(RearWallpaperApiContract.Extras.BUNDLE)
+ ?.getBinder(RearWallpaperApiContract.Extras.BINDER)
+ val callback = IRearWallpaperApiConnection.Stub.asInterface(callbackBinder)
+ val forceSync =
+ intent.getBooleanExtra(RearWallpaperApiContract.Extras.FORCE_SYNC, false)
+ if (forceSync) {
+ refreshSchedule(forceApply = true)
+ }
+ runCatching {
+ callback?.onServiceConnected(hookBinder)
+ }.onFailure(YLog::error)
+ }
+ }
+
+ private fun registerHookBootstrapReceiver() {
+ if (!bootstrapReceiverRegistered.compareAndSet(false, true)) return
+ val ctx = hostContext ?: run {
+ bootstrapReceiverRegistered.set(false)
+ return
+ }
+ runCatching {
+ ContextCompat.registerReceiver(
+ ctx,
+ hookBootstrapReceiver,
+ IntentFilter(RearWallpaperApiContract.ACTION_REQUEST_HOOK_SERVICE),
+ RearWallpaperApiContract.SERVICE_PERMISSION,
+ null,
+ ContextCompat.RECEIVER_EXPORTED,
+ )
+ }.onFailure {
+ bootstrapReceiverRegistered.set(false)
+ YLog.error(it)
+ }
+ }
+
+ private fun enforceCallerPermission() {
+ val ctx = hostContext
+ val uid = Binder.getCallingUid()
+ if (uid == Process.myUid()) return
+ if (ctx == null) {
+ throw SecurityException("context not ready for permission check")
+ }
+ val granted = ctx.checkPermission(
+ RearWallpaperApiContract.SERVICE_PERMISSION,
+ Binder.getCallingPid(),
+ uid,
+ ) == PackageManager.PERMISSION_GRANTED
+ if (!granted) {
+ throw SecurityException(
+ "caller uid=$uid requires ${RearWallpaperApiContract.SERVICE_PERMISSION}"
+ )
+ }
+ }
+
+ private fun buildCatalogBundle(): Bundle {
+ val entries = loadWallpaperEntries()
+ val currentIndex = readCurrentSelectionIndex(entries.lastIndex)
+ val currentWallpaperId = entries.getOrNull(currentIndex)?.wallpaperId
+ val itemBundles = ArrayList(entries.size)
+ entries.forEach { entry ->
+ itemBundles += Bundle().apply {
+ putInt(RearWallpaperApiContract.BundleKeys.WALLPAPER_ID, entry.wallpaperId)
+ putString(RearWallpaperApiContract.BundleKeys.TITLE, entry.title)
+ putString(RearWallpaperApiContract.BundleKeys.NAME, entry.name)
+ putBoolean(
+ RearWallpaperApiContract.BundleKeys.PREVIEW_AVAILABLE,
+ !entry.previewPath.isNullOrBlank(),
+ )
+ putString(
+ RearWallpaperApiContract.BundleKeys.PREVIEW_SIGNATURE,
+ entry.previewSignature
+ )
+ }
+ }
+ return Bundle().apply {
+ putParcelableArrayList(RearWallpaperApiContract.BundleKeys.ITEMS, itemBundles)
+ putInt(RearWallpaperApiContract.BundleKeys.CURRENT_INDEX, currentIndex)
+ if (currentWallpaperId != null) {
+ putInt(RearWallpaperApiContract.BundleKeys.CURRENT_WALLPAPER_ID, currentWallpaperId)
+ }
+ }
+ }
+
+ private fun capturePanels(launcherInstance: Any?) {
+ val resolver = launcherInstance?.asResolver() ?: return
+ mainPanel = runCatching {
+ resolver.firstField { name = "y" }.get()
+ }.getOrNull()
+ smartPanel = runCatching {
+ resolver.firstField { name = "z" }.get()
+ }.getOrNull()
+ mainHandler = runCatching {
+ resolver.firstField { name = "c0" }.get() as? Handler
+ }.getOrNull()
+ }
+
+ private fun refreshSchedule(forceApply: Boolean) {
+ stopScheduler()
+ val scheduleConfig = readScheduleConfig()
+ if (!scheduleConfig.enabled) {
+ persistNextSwitchAt(0L)
+ debugLog("refreshSchedule disabled force=$forceApply")
+ return
+ }
+
+ val entries = loadWallpaperEntries()
+ if (entries.isEmpty()) {
+ persistNextSwitchAt(0L)
+ debugLog("refreshSchedule skipped: no wallpaper entries force=$forceApply")
+ return
+ }
+
+ val resolvedSchedule = loadResolvedSchedule(entries, scheduleConfig)
+ if (resolvedSchedule.isEmpty()) {
+ persistNextSwitchAt(0L)
+ debugLog("refreshSchedule skipped: resolved schedule empty force=$forceApply")
+ return
+ }
+
+ val now = System.currentTimeMillis()
+ val currentIndex = readCurrentSelectionIndex(entries.lastIndex)
+ val currentId = entries.getOrNull(currentIndex)?.wallpaperId
+ val currentPos = resolvedSchedule.indexOfFirst { it.wallpaperId == currentId }
+ val nextAt = readNextSwitchAt()
+ debugLog(
+ "refreshSchedule force=$forceApply currentIndex=$currentIndex currentId=$currentId currentPos=$currentPos nextAt=$nextAt schedule=${resolvedSchedule.joinToString { "${it.wallpaperId}:${it.delayMs}" }}"
+ )
+
+ if (currentPos < 0) {
+ val first = resolvedSchedule.first()
+ val result = switchToResolved(first, entries)
+ val nextAt = now + if (result.applied) first.delayMs else RETRY_SWITCH_DELAY_MS
+ persistNextSwitchAt(nextAt)
+ debugLog("refreshSchedule no current match -> switch first=${first.wallpaperId} applied=${result.applied} nextAt=$nextAt")
+ scheduleAt(nextAt)
+ return
+ }
+
+ val currentItem = resolvedSchedule[currentPos]
+ if (nextAt <= 0L) {
+ val dueAt = now + currentItem.delayMs
+ persistNextSwitchAt(dueAt)
+ debugLog("refreshSchedule initialized nextAt=$dueAt current=${currentItem.wallpaperId} delay=${currentItem.delayMs}")
+ scheduleAt(dueAt)
+ return
+ }
+
+ if (nextAt <= now) {
+ val nextPos = (currentPos + 1).floorMod(resolvedSchedule.size)
+ val result = switchToResolved(resolvedSchedule[nextPos], entries)
+ val dueAt = now + if (result.applied) {
+ resolvedSchedule[nextPos].delayMs
+ } else {
+ RETRY_SWITCH_DELAY_MS
+ }
+ persistNextSwitchAt(dueAt)
+ debugLog("refreshSchedule due -> switch next=${resolvedSchedule[nextPos].wallpaperId} applied=${result.applied} dueAt=$dueAt")
+ scheduleAt(dueAt)
+ return
+ }
+
+ if (forceApply) {
+ debugLog("refreshSchedule force keep existing nextAt=$nextAt")
+ scheduleAt(nextAt)
+ return
+ }
+
+ debugLog("refreshSchedule waiting nextAt=$nextAt delay=${nextAt - now}")
+ scheduleAt(nextAt)
+ }
+
+ private fun scheduleAt(triggerAt: Long) {
+ stopScheduler()
+ val handler = mainHandler ?: return
+ val delayMs = (triggerAt - System.currentTimeMillis()).coerceAtLeast(0L)
+ debugLog("scheduleAt triggerAt=$triggerAt delayMs=$delayMs")
+ schedulerTask = Runnable {
+ debugLog("scheduleAt fired triggerAt=$triggerAt now=${System.currentTimeMillis()}")
+ refreshSchedule(forceApply = false)
+ }
+ handler.postDelayed(schedulerTask!!, delayMs)
+ }
+
+ private fun stopScheduler() {
+ schedulerTask?.let { task -> mainHandler?.removeCallbacks(task) }
+ if (schedulerTask != null) debugLog("stopScheduler removed pending task")
+ schedulerTask = null
+ }
+
+ private fun loadResolvedSchedule(
+ entries: List = loadWallpaperEntries(),
+ scheduleConfig: ScheduleConfig = readScheduleConfig(),
+ ): List {
+ val byId = entries.associateBy { it.wallpaperId }
+ return RearWallpaperScheduleCodec.parse(
+ scheduleConfig.scheduleData
+ ).mapNotNull { item ->
+ val entry = byId[item.wallpaperId] ?: return@mapNotNull null
+ ResolvedScheduleItem(
+ wallpaperId = item.wallpaperId,
+ runtimeIndex = entries.indexOf(entry),
+ delayMs = item.delayMs,
+ )
+ }
+ }
+
+ private fun switchWallpaperInternal(wallpaperId: Int): SwitchResult {
+ val entries = loadWallpaperEntries()
+ val target = entries.firstOrNull { it.wallpaperId == wallpaperId }
+ ?: return SwitchResult(exists = false, applied = false)
+ val result = switchToResolved(
+ item = ResolvedScheduleItem(
+ wallpaperId = wallpaperId,
+ runtimeIndex = entries.indexOf(target),
+ delayMs = RearWallpaperScheduleCodec.DEFAULT_DELAY_MS,
+ ),
+ entries = entries,
+ )
+ resetNextSwitchAtForCurrent(wallpaperId, entries)
+ debugLog("switchWallpaperInternal wallpaperId=$wallpaperId exists=true applied=${result.applied}")
+ return result
+ }
+
+ private fun switchToResolved(
+ item: ResolvedScheduleItem,
+ entries: List
+ ): SwitchResult {
+ if (entries.isEmpty()) return SwitchResult(exists = false, applied = false)
+ if (isMainPanelEditing()) {
+ debugLog("switchToResolved blocked by editMode wallpaperId=${item.wallpaperId}")
+ return SwitchResult(exists = true, applied = false)
+ }
+
+ val targetIndex = item.runtimeIndex.coerceIn(0, entries.lastIndex)
+ persistSelectionIndex(targetIndex)
+ debugLog("switchToResolved wallpaperId=${item.wallpaperId} runtimeIndex=${item.runtimeIndex} targetIndex=$targetIndex")
+
+ val widgets = entries.map { it.widget }
+ var applied = false
+ mainPanel?.let { panel ->
+ applied = dispatchSelection(panel, widgets, targetIndex) || applied
+ }
+ smartPanel?.let { panel ->
+ applied = dispatchSelection(panel, widgets, targetIndex) || applied
+ }
+ debugLog("switchToResolved result wallpaperId=${item.wallpaperId} applied=$applied main=${mainPanel != null} smart=${smartPanel != null}")
+ return SwitchResult(exists = true, applied = applied)
+ }
+
+ private fun isMainPanelEditing(): Boolean {
+ val panel = mainPanel ?: return false
+ return runCatching {
+ panel.asResolver().firstField { name = "m" }.get() as? Boolean ?: false
+ }.getOrDefault(false)
+ }
+
+ private fun dispatchSelection(panel: Any, widgets: List, index: Int): Boolean {
+ val action = Runnable {
+ runCatching {
+ panel.asResolver().firstMethod {
+ name = "d"
+ parameterCount = 2
+ }.invoke(widgets, index)
+ debugLog("dispatchSelection success panel=${panel.javaClass.name} index=$index widgets=${widgets.size}")
+ }.onFailure(YLog::error)
+ }
+ val handler = mainHandler
+ return if (handler != null) {
+ handler.post(action)
+ } else {
+ action.run()
+ true
+ }
+ }
+
+ private fun loadWallpaperEntries(): List {
+ val specList = loadWallpaperSpecs()
+ if (specList.isEmpty()) return emptyList()
+
+ val widgetRef = "t2.r".toClass().resolve()
+ val localeSuffix = readLocalePreviewSuffix()
+ return buildList {
+ specList.forEach { spec ->
+ val widget = runCatching {
+ widgetRef.firstMethod {
+ name = "g"
+ parameterCount = 1
+ }.invoke(spec)
+ }.getOrNull() ?: return@forEach
+
+ val resolver = spec.asResolver()
+ val extras = runCatching {
+ resolver.firstField { name = "d" }.get() as? Bundle
+ }.getOrNull()
+ val previewPath = extras.resolvePreviewPath(localeSuffix)
+ add(
+ WallpaperEntry(
+ wallpaperId = runCatching {
+ resolver.firstField { name = "a" }.get() as Int
+ }.getOrDefault(0),
+ title = extras?.getString("title").orEmpty().ifBlank { "Wallpaper" },
+ name = extras?.getString("resName").orEmpty().ifBlank { "unknown" },
+ previewPath = previewPath,
+ previewSignature = buildPreviewSignature(previewPath),
+ widget = widget,
+ )
+ )
+ }
+ }
+ }
+
+ private fun loadWallpaperSpecs(): List {
+ val prefStore = runCatching {
+ "Z1.S".toClass().resolve().firstField { name = "a" }.get()
+ }.getOrNull()
+
+ val persisted = runCatching {
+ prefStore?.asResolver()?.firstMethod {
+ name = "e"
+ parameterCount = 1
+ }?.invoke(false) as? List
+ }.getOrNull().orEmpty()
+ if (persisted.isNotEmpty()) return persisted
+
+ return runCatching {
+ "com.bumptech.glide.d".toClass().resolve().firstMethod {
+ name = "G"
+ parameterCount = 1
+ }.invoke(true) as? List
+ }.getOrNull().orEmpty()
+ }
+
+ private fun readCurrentSelectionIndex(maxIndex: Int): Int {
+ val index = runCatching {
+ val store =
+ "Z1.S".toClass().resolve().firstField { name = "a" }.get() ?: return@runCatching 0
+ store.asResolver().firstMethod {
+ name = "c"
+ parameterCount = 3
+ }.invoke(Int::class.javaPrimitiveType!!, 0, "user_select") as? Int ?: 0
+ }.getOrDefault(0)
+ if (maxIndex < 0) return -1
+ return index.coerceIn(0, maxIndex)
+ }
+
+ private fun persistSelectionIndex(index: Int) {
+ runCatching {
+ val store = "Z1.S".toClass().resolve().firstField { name = "a" }.get() ?: return
+ store.asResolver().firstMethod {
+ name = "j"
+ parameterCount = 2
+ }.invoke(index, "user_select")
+ }.onFailure(YLog::error)
+ }
+
+ private fun resetNextSwitchAtForCurrent(wallpaperId: Int, entries: List) {
+ if (!readScheduleConfig().enabled) {
+ persistNextSwitchAt(0L)
+ debugLog("resetNextSwitchAtForCurrent disabled wallpaperId=$wallpaperId")
+ return
+ }
+ val resolved = loadResolvedSchedule(entries)
+ val current = resolved.firstOrNull { it.wallpaperId == wallpaperId }
+ if (current == null) {
+ persistNextSwitchAt(0L)
+ debugLog("resetNextSwitchAtForCurrent missing wallpaperId=$wallpaperId")
+ return
+ }
+ val nextAt = System.currentTimeMillis() + current.delayMs
+ persistNextSwitchAt(nextAt)
+ debugLog("resetNextSwitchAtForCurrent wallpaperId=$wallpaperId nextAt=$nextAt delay=${current.delayMs}")
+ scheduleAt(nextAt)
+ }
+
+ private fun readNextSwitchAt(): Long {
+ val cached = cachedNextSwitchAtMillis
+ if (cached != Long.MIN_VALUE) return cached
+ val persisted = runtimePrefs()
+ ?.getLong(ConfigKeys.REAR_WALLPAPER_SCHEDULE_NEXT_AT, 0L)
+ ?: 0L
+ cachedNextSwitchAtMillis = persisted
+ return persisted
+ }
+
+ private fun persistNextSwitchAt(timestamp: Long) {
+ cachedNextSwitchAtMillis = timestamp
+ runtimePrefs()?.edit {
+ putLong(ConfigKeys.REAR_WALLPAPER_SCHEDULE_NEXT_AT, timestamp)
+ apply()
+ }
+ debugLog("persistNextSwitchAt=$timestamp")
+ }
+
+ private fun readScheduleConfig(): ScheduleConfig {
+ cachedScheduleConfig?.let { return it }
+ val config = ScheduleConfig(
+ enabled = prefs.getBoolean(ConfigKeys.REAR_WALLPAPER_SCHEDULE_ENABLED, false),
+ scheduleData = prefs.getString(
+ ConfigKeys.REAR_WALLPAPER_SCHEDULE_DATA,
+ RearWallpaperScheduleCodec.EMPTY_ARRAY,
+ ).ifBlank { RearWallpaperScheduleCodec.EMPTY_ARRAY },
+ )
+ cachedScheduleConfig = config
+ return config
+ }
+
+ private fun updateScheduleConfig(enabled: Boolean, scheduleData: String?) {
+ cachedScheduleConfig = ScheduleConfig(
+ enabled = enabled,
+ scheduleData = scheduleData?.takeIf { it.isNotBlank() }
+ ?: RearWallpaperScheduleCodec.EMPTY_ARRAY,
+ )
+ }
+
+ private fun runtimePrefs() =
+ hostContext?.getSharedPreferences(RUNTIME_PREFS_NAME, Context.MODE_PRIVATE)
+
+ private fun debugLog(message: String) {
+ if (prefs.getBoolean(ConfigKeys.MORE_DEBUG, false)) {
+ YLog.debug("[$TAG] $message")
+ }
+ }
+
+ private fun loadPreviewBytes(previewPath: String?): ByteArray? {
+ val path = previewPath?.takeIf { it.isNotBlank() } ?: return null
+ val file = File(path)
+ if (!file.isFile) return null
+
+ val bounds = BitmapFactory.Options().apply {
+ inJustDecodeBounds = true
+ }
+ BitmapFactory.decodeFile(path, bounds)
+ if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
+
+ val decodeOptions = BitmapFactory.Options().apply {
+ inSampleSize = computeInSampleSize(bounds.outWidth, bounds.outHeight, 640)
+ inPreferredConfig = Bitmap.Config.ARGB_8888
+ }
+ val bitmap = BitmapFactory.decodeFile(path, decodeOptions) ?: return null
+ return ByteArrayOutputStream().use { output ->
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 90, output)
+ bitmap.recycle()
+ output.toByteArray()
+ }
+ }
+
+ private fun computeInSampleSize(width: Int, height: Int, maxSize: Int): Int {
+ var sample = 1
+ var targetWidth = width
+ var targetHeight = height
+ while (targetWidth > maxSize || targetHeight > maxSize) {
+ sample *= 2
+ targetWidth /= 2
+ targetHeight /= 2
+ }
+ return sample.coerceAtLeast(1)
+ }
+
+ private fun buildPreviewSignature(previewPath: String?): String {
+ val file = previewPath?.let(::File)
+ if (file != null && file.isFile) {
+ return "${file.absolutePath.hashCode()}_${file.length()}_${file.lastModified()}"
+ }
+ return "missing"
+ }
+
+ private fun readLocalePreviewSuffix(): String? {
+ return runCatching {
+ "r2.e".toClass().resolve().firstField { name = "m" }.get() as? String
+ }.getOrNull()?.takeIf { it.isNotBlank() }
+ }
+
+ private fun Bundle?.resolvePreviewPath(localeSuffix: String?): String? {
+ if (this == null) return null
+ val localized = localeSuffix?.let { getString("snapshotPath_$it") }
+ return localized?.takeIf { it.isNotBlank() }
+ ?: getString("snapshotPath")?.takeIf { it.isNotBlank() }
+ }
+
+ private fun Int.floorMod(size: Int): Int {
+ if (size <= 0) return 0
+ val mod = this % size
+ return if (mod < 0) mod + size else mod
+ }
+}
diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/VideoVolumeHook.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/VideoVolumeHook.kt
new file mode 100644
index 0000000..7e0486c
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/VideoVolumeHook.kt
@@ -0,0 +1,32 @@
+package hk.uwu.reareye.hook.scopes.subscreencenter.modules
+
+import com.highcapable.kavaref.KavaRef.Companion.asResolver
+import com.highcapable.kavaref.KavaRef.Companion.resolve
+import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker
+import com.highcapable.yukihookapi.hook.log.YLog
+import hk.uwu.reareye.ui.config.ConfigKeys
+
+class VideoVolumeHook : YukiBaseHooker() {
+ override fun onHook() {
+ loadApp("com.xiaomi.subscreencenter") {
+ val clz = "com.miui.maml.elements.video.VideoElement".toClass().resolve()
+ clz.firstMethod {
+ name = "load"
+ parameterCount = 1
+ }.hook().after {
+ val vol = prefs.getFloat(
+ ConfigKeys.VIDEO_WALLPAPER_VOLUME,
+ ConfigKeys.VIDEO_WALLPAPER_VOLUME_DEFAULT
+ )
+ if (vol > 0f) {
+ val setVol = instance.asResolver().firstMethod {
+ name = "setVolume"
+ parameters(Float::class.java)
+ }
+ setVol.invoke(vol)
+ YLog.debug("Changed video volume to $vol")
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/lyrics/LyriconHook.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/lyrics/LyriconHook.kt
new file mode 100644
index 0000000..61fb8d2
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/lyrics/LyriconHook.kt
@@ -0,0 +1,370 @@
+package hk.uwu.reareye.hook.scopes.subscreencenter.modules.lyrics
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.media.MediaMetadata
+import android.os.Build
+import com.hchen.superlyricapi.ISuperLyric
+import com.hchen.superlyricapi.SuperLyricData
+import com.hchen.superlyricapi.SuperLyricTool
+import com.highcapable.kavaref.KavaRef.Companion.asResolver
+import com.highcapable.kavaref.KavaRef.Companion.resolve
+import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker
+import com.highcapable.yukihookapi.hook.log.YLog
+import de.robv.android.xposed.XposedHelpers
+import hk.uwu.reareye.lyrics.LyricParser
+import hk.uwu.reareye.ui.config.ConfigKeys
+import hk.uwu.reareye.ui.config.LyricProvider
+import io.github.proify.lyricon.central.BridgeCentral
+import io.github.proify.lyricon.lyric.model.Song
+import io.github.proify.lyricon.provider.LyriconFactory
+import io.github.proify.lyricon.provider.ProviderInfo
+import io.github.proify.lyricon.subscriber.ActivePlayerListener
+import io.github.proify.lyricon.subscriber.ActivePlayerMonitor
+import java.util.concurrent.CopyOnWriteArrayList
+
+
+class LyriconHook : YukiBaseHooker() {
+ private val lyricParser = LyricParser()
+
+ @Volatile
+ private var latestLyricLrc: String = ""
+
+ @Volatile
+ private var currentProvider: ProviderInfo? = null
+ private val elements: CopyOnWriteArrayList = CopyOnWriteArrayList()
+
+ @Volatile
+ var monitor: ActivePlayerMonitor? = null
+
+ @Volatile
+ var superLyricStub: ISuperLyric.Stub? = null
+
+ override fun onHook() {
+ loadApp("com.android.systemui") {
+ onAppLifecycle {
+ onCreate {
+ val context = appContext ?: return@onCreate
+ if (LyricProvider.fromValue(
+ prefs.getInt(
+ ConfigKeys.LYRIC_PROVIDER,
+ ConfigKeys.LYRIC_PROVIDER_DEFAULT
+ )
+ ) == LyricProvider.LYRICON
+ ) {
+ if (!isLyriconInstalled(context)) {
+ YLog.info("Lyricon is not found, starting bundled central")
+ BridgeCentral.initialize(context)
+ BridgeCentral.sendBootCompleted()
+ } else {
+ YLog.info("Lyricon is found, skip to start central")
+ }
+ }
+ }
+ }
+ }
+
+ loadApp("com.xiaomi.subscreencenter") {
+ onAppLifecycle {
+ onCreate {
+ val context = appContext ?: return@onCreate
+ when (LyricProvider.fromValue(
+ prefs.getInt(
+ ConfigKeys.LYRIC_PROVIDER,
+ ConfigKeys.LYRIC_PROVIDER_DEFAULT
+ )
+ )) {
+ LyricProvider.LYRICON -> {
+ val listener = createLyricListener()
+ val monitor =
+ LyriconFactory.createActivePlayerMonitor(
+ context,
+ listener = listener
+ )
+ monitor.register()
+ YLog.info("Registered lyricon player monitor")
+ }
+
+ LyricProvider.SUPER_LYRIC -> {
+ superLyricStub = object : ISuperLyric.Stub() {
+ override fun onStop(data: SuperLyricData) {
+ }
+
+ override fun onSuperLyric(data: SuperLyricData) {
+ runCatching {
+ if (prefs.getBoolean(ConfigKeys.MORE_DEBUG, false)) {
+ YLog.debug("onSuperLyric ${data.lyric} ${data.translation}")
+ }
+ val mode = prefs.getInt(
+ ConfigKeys.SUPER_LYRIC_DISPLAY_MODE,
+ ConfigKeys.SUPER_LYRIC_DISPLAY_MODE_DEFAULT
+ )
+ val originalLines = data.lyric.split("\n")
+ val lyric = when {
+ LyricParser.DisplayMode.shouldShowTranslation(mode) -> {
+ val translation = data.translation
+ if (!translation.isNullOrEmpty()) {
+ translation
+ } else if (originalLines.size > 1) {
+ originalLines[1]
+ } else {
+ data.lyric
+ }
+ }
+
+ else -> {
+ originalLines[0]
+ }
+ }
+ if (lyric.isNotEmpty()) {
+ updateFallbackLyric(lyric)
+ }
+ }.onFailure {
+ YLog.error(it)
+ }
+ }
+ }
+ SuperLyricTool.registerSuperLyric(context, superLyricStub!!)
+ YLog.info("Registered super-lyric listener")
+ }
+ }
+ }
+
+ onTerminate {
+ monitor?.also {
+ it.unregister()
+ it.destroy()
+ }
+ val context = appContext ?: return@onTerminate
+ superLyricStub?.also {
+ SuperLyricTool.unregisterSuperLyric(context, it)
+ }
+ YLog.debug("Terminated music lyric services")
+ }
+ }
+
+ val clz = "com.miui.maml.elements.MusicControlScreenElement".toClass()
+ val ref = clz.resolve()
+ ref.constructor().build().hookAll {
+ after {
+ elements.addIfAbsent(instance)
+ if (latestLyricLrc.isNotEmpty()) {
+ elements.forEach {
+ forceUpdateLyric(it, latestLyricLrc)
+ }
+ }
+ }
+ }
+
+ ref.firstMethod {
+ name = "resetLyric"
+ }.hook().replaceUnit {
+ val iRef = instance.asResolver()
+ val mMetadata = iRef.firstField { name = "mMetadata" }.get()
+ if (mMetadata != null && XposedHelpers.getAdditionalInstanceField(
+ instance,
+ "OLD_MEDIA_ID"
+ ) == mMetadata.description.mediaId
+ ) {
+ YLog.debug("Reject reset lyric while media id is not changed")
+ return@replaceUnit
+ } else {
+ invokeOriginal()
+ }
+ }
+
+ val seClz = "com.miui.maml.elements.ScreenElement".toClass().resolve()
+ seClz.firstMethod {
+ name = "show"
+ parameters(Boolean::class.java)
+ }.hook().after {
+ if (instanceClass == clz && !args(0).boolean()) {
+ YLog.debug("Release music control instance: $instance")
+ elements.remove(instance)
+ }
+ }
+
+ val musicControlListenerClz =
+ "com.miui.maml.elements.MusicControlScreenElement$1".toClass().resolve()
+ musicControlListenerClz.firstMethod {
+ name = "onClientMetadataUpdate"
+ returnType = Void.TYPE
+ parameters(MediaMetadata::class.java)
+ }.hook().after {
+ val i = instance.asResolver().firstField {
+ name = "this$0"
+ }.get() ?: return@after
+ val iRef = i.asResolver()
+ val mLyric = iRef.firstField { name = "mLyric" }.get()
+ val lrc = XposedHelpers.getAdditionalInstanceField(i, "TEMP_LRC") as? String
+ if (mLyric == null) {
+ if (lrc != null || latestLyricLrc.isNotEmpty()) {
+ YLog.debug("onUpdateLrc $mLyric ${lrc == null} ${latestLyricLrc.isEmpty()}")
+ forceUpdateLyric(i, lrc ?: latestLyricLrc)
+ return@after
+ }
+ val line =
+ XposedHelpers.getAdditionalInstanceField(i, "TEMP_LYRIC_LINE") as? String
+ val mLyricCurrentVar =
+ iRef.firstField { name = "mLyricCurrentVar" }.get() ?: return@after
+ val currentLyric =
+ mLyricCurrentVar.asResolver().firstMethod { name = "get" }.invoke()
+ if (line != null && currentLyric == null) {
+ YLog.debug("onUpdateLine $line")
+ updateFallbackLine(i, line)
+ }
+ }
+ }
+ }
+ }
+
+ private fun isLyriconInstalled(context: Context): Boolean {
+ return runCatching {
+ val pm = context.packageManager
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ pm.getPackageInfo(TARGET_LYRICON_PACKAGE, PackageManager.PackageInfoFlags.of(0))
+ } else {
+ @Suppress("DEPRECATION")
+ pm.getPackageInfo(TARGET_LYRICON_PACKAGE, 0)
+ }
+ }.isSuccess
+ }
+
+ private fun createLyricListener(): ActivePlayerListener {
+ return object : ActivePlayerListener {
+
+ override fun onActiveProviderChanged(providerInfo: ProviderInfo?) {
+ currentProvider = providerInfo
+ YLog.debug("onProviderChanged $currentProvider")
+ }
+
+ override fun onSongChanged(song: Song?) {
+ runCatching {
+ val lrc = lyricParser.toLrc(
+ song = song,
+ displayMode = prefs.getInt(
+ ConfigKeys.LYRIC_DISPLAY_MODE,
+ ConfigKeys.LYRIC_DISPLAY_MODE_DEFAULT,
+ ),
+ showArtistBeforeFirstLine = prefs.getBoolean(
+ ConfigKeys.LYRIC_SHOW_ARTIST_BEFORE_FIRST_LINE,
+ false,
+ ),
+ )
+ latestLyricLrc = normalizeForMiuiParser(lrc)
+ YLog.debug("REAREye getSongLRC $latestLyricLrc")
+ YLog.debug("onSongChanged converted LRC length=${latestLyricLrc.length}")
+ YLog.debug("current instance size ${elements.size}")
+ elements.forEach {
+ forceUpdateLyric(it, latestLyricLrc)
+ }
+ if (elements.isNotEmpty()) {
+ latestLyricLrc = ""
+ }
+ }.onFailure {
+ YLog.error(it)
+ }
+ }
+
+ override fun onPlaybackStateChanged(isPlaying: Boolean) = Unit
+
+ override fun onPositionChanged(position: Long) = Unit
+
+ override fun onSeekTo(position: Long) = Unit
+
+ override fun onSendText(text: String?) {
+ runCatching {
+ if (prefs.getBoolean(ConfigKeys.MORE_DEBUG, false)) {
+ YLog.debug("onSendText $text")
+ }
+ val mode = prefs.getInt(
+ ConfigKeys.SUPER_LYRIC_DISPLAY_MODE,
+ ConfigKeys.SUPER_LYRIC_DISPLAY_MODE_DEFAULT
+ )
+ if (text != null) {
+ val originalLines = text.split("\n")
+ val lyric = when {
+ LyricParser.DisplayMode.shouldShowTranslation(mode) -> {
+ if (originalLines.size > 1) {
+ originalLines[1]
+ } else {
+ text
+ }
+ }
+
+ else -> {
+ originalLines[0]
+ }
+ }
+ if (lyric.isNotEmpty()) {
+ updateFallbackLyric(lyric)
+ }
+ }
+ }.onFailure {
+ YLog.error(it)
+ }
+ }
+
+ override fun onDisplayTranslationChanged(isDisplayTranslation: Boolean) = Unit
+
+ override fun onDisplayRomaChanged(displayRoma: Boolean) = Unit
+ }
+ }
+
+ private fun updateFallbackLyric(text: String) {
+ elements.forEach { element ->
+ XposedHelpers.setAdditionalInstanceField(element, "TEMP_LYRIC_LINE", text)
+ updateFallbackLine(element, text)
+ }
+ }
+
+ private fun updateFallbackLine(element: Any, text: String) {
+ val ref = element.asResolver()
+ val mLyric = ref.firstField { name = "mLyric" }.get()
+ if (mLyric != null) return
+ val mLyricCurrentVar =
+ ref.firstField { name = "mLyricCurrentVar" }.get() ?: return
+ mLyricCurrentVar.asResolver().firstMethod {
+ name = "set"
+ parameters(Any::class.java)
+ }.invoke(text)
+ }
+
+ private fun forceUpdateLyric(element: Any, lrc: String) {
+ YLog.debug("handle instance: $element")
+ val ref = element.asResolver()
+ val mLyric = ref.firstField { name = "mLyric" }
+ val parserClz = "com.miui.maml.elements.MusicLyricParser".toClass().resolve()
+ val nLyric = parserClz.firstMethod {
+ name = "parseLyric"
+ parameters(String::class.java)
+ }.invoke(lrc)
+ YLog.debug("parsed $nLyric")
+ if (nLyric != null) {
+ nLyric.asResolver().firstMethod { name = "decorate" }.invoke()
+ mLyric.set(nLyric)
+ ref.firstMethod { name = "updateLyric" }.invoke(nLyric)
+ YLog.debug("Force Update Lyric")
+ ref.firstField { name = "mMetadata" }.get()?.also {
+ XposedHelpers.setAdditionalInstanceField(
+ element,
+ "OLD_MEDIA_ID",
+ it.description.mediaId
+ )
+ }
+ XposedHelpers.setAdditionalInstanceField(element, "TEMP_LRC", lrc)
+ }
+ }
+
+ private fun normalizeForMiuiParser(rawLrc: String): String {
+ if (rawLrc.isEmpty()) return rawLrc
+ return rawLrc
+ .replace("\r\n", "\n")
+ .replace('\r', '\n')
+ .replace("\n", "\r\n")
+ }
+
+ private companion object {
+ private const val TARGET_LYRICON_PACKAGE = "io.github.proify.lyricon"
+ }
+}
diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/rearwidget/ExtraTimeTipHook.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/rearwidget/ExtraTimeTipHook.kt
new file mode 100644
index 0000000..cc08150
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/rearwidget/ExtraTimeTipHook.kt
@@ -0,0 +1,59 @@
+package hk.uwu.reareye.hook.scopes.subscreencenter.modules.rearwidget
+
+import android.os.Bundle
+import com.highcapable.kavaref.KavaRef.Companion.asResolver
+import com.highcapable.kavaref.KavaRef.Companion.resolve
+import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker
+import com.highcapable.yukihookapi.hook.log.YLog
+import hk.uwu.reareye.repository.rearwidget.RearBusinessExtraConfigRepository.getShowTimeTipForBusiness
+import hk.uwu.reareye.ui.config.ConfigKeys
+import hk.uwu.reareye.ui.config.PrefsManager.Companion.getPrefsManager
+
+class ExtraTimeTipHook : YukiBaseHooker() {
+ override fun onHook() {
+ loadApp("com.xiaomi.subscreencenter") {
+ val clz = "m2.a".toClass().resolve()
+ clz.constructor().build().hookAll().after {
+ val ref = instance.asResolver()
+ val moreDebug = prefs.getBoolean(ConfigKeys.MORE_DEBUG, false)
+ val bundle = ref.firstField {
+ name = "d"
+ type = Bundle::class.java
+ }.get()
+ if (bundle == null) {
+ if (moreDebug) {
+ YLog.debug("bundle is null ${args.joinToString { it.toString() }}")
+ }
+ return@after
+ }
+ val pm = prefs.getPrefsManager()
+ val business = bundle.getString("business")
+ if (business != null) {
+ if (moreDebug) {
+ YLog.debug("time tip process biz: $business")
+ }
+ val showTimeTip = pm.getShowTimeTipForBusiness(business)
+ ref.firstField {
+ name = "l"
+ type = Boolean::class.java
+ }.set(showTimeTip)
+ if (moreDebug) {
+ YLog.debug("time tip state biz=$business showTimeTip=$showTimeTip")
+ }
+ } else {
+ if (moreDebug) {
+ YLog.debug(
+ "business is null ${
+ bundle.keySet()
+ ?.joinToString(separator = "\n") { key ->
+ @Suppress("DEPRECATION")
+ "$key=${bundle.get(key)}"
+ }
+ }"
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/rearwidget/RearWidgetHook.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/rearwidget/RearWidgetHook.kt
new file mode 100644
index 0000000..1d78c12
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/rearwidget/RearWidgetHook.kt
@@ -0,0 +1,1045 @@
+@file:Suppress("UNCHECKED_CAST")
+
+package hk.uwu.reareye.hook.scopes.subscreencenter.modules.rearwidget
+
+import android.annotation.SuppressLint
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageManager
+import android.os.Binder
+import android.os.Bundle
+import android.os.Handler
+import android.os.Process
+import android.util.Base64
+import androidx.core.content.ContextCompat
+import com.highcapable.kavaref.KavaRef.Companion.asResolver
+import com.highcapable.kavaref.KavaRef.Companion.resolve
+import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker
+import com.highcapable.yukihookapi.hook.log.YLog
+import hk.uwu.reareye.repository.rearwidget.RearBusinessExtraConfigRepository.getShowTimeTipForBusiness
+import hk.uwu.reareye.repository.rearwidget.RearWidgetConfigCodec
+import hk.uwu.reareye.ui.config.ConfigKeys
+import hk.uwu.reareye.ui.config.PrefsManager.Companion.getPrefsManager
+import hk.uwu.reareye.widgetapi.IRearWidgetApiConnection
+import hk.uwu.reareye.widgetapi.IRearWidgetApiService
+import hk.uwu.reareye.widgetapi.RearWidgetActiveNotice
+import hk.uwu.reareye.widgetapi.RearWidgetApiContract
+import hk.uwu.reareye.widgetapi.RearWidgetNoticeOptions
+import hk.uwu.reareye.widgetapi.RearWidgetNoticeTicket
+import java.io.File
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+
+class RearWidgetHook : YukiBaseHooker() {
+
+ private data class OperationOutcome(
+ val injectCompositeKey: String? = null,
+ val ejectTicket: RearWidgetNoticeTicket? = null,
+ val ejectBusiness: Pair? = null,
+ )
+
+ companion object {
+ private const val TAG = "REAREye-RearWidget"
+ private const val TEMPLATE_BASE =
+ "/data/system/theme_magic/users/%s/subscreencenter/smart_assistant"
+ }
+
+ private val appliedOnce = AtomicBoolean(false)
+ private val startupBootstrapped = AtomicBoolean(false)
+ private val bootstrapReceiverRegistered = AtomicBoolean(false)
+ private val deployedBlobMetaCache = ConcurrentHashMap()
+ private val injectedCardSignatureCache = ConcurrentHashMap()
+ private val injectedCompositeAt = ConcurrentHashMap()
+ private val bootstrapRetryCount = AtomicInteger(0)
+ private val managerEpoch = AtomicInteger(0)
+
+ private var manager: Any? = null
+ private var mainHandler: Handler? = null
+ private var hostContext: Context? = null
+
+ override fun onHook() {
+ loadApp("com.xiaomi.subscreencenter") {
+ debugLog("hook process=$processName")
+ RearWidgetRuntimeStore.install(packageName)
+ debugLog("onHook start")
+
+ val appRef = "com.xiaomi.subscreencenter.SubScreenCenterApp".toClass().resolve()
+ val d0Ref = "Z1.d0".toClass().resolve()
+ val persistenceRef = "H.d".toClass().resolve()
+ val p2cRef = "p2.c".toClass().resolve()
+ val z1mRef = "Z1.m".toClass().resolve()
+
+ appRef.firstMethod {
+ name = "attachBaseContext"
+ parameterCount = 1
+ }.hook().after {
+ hostContext = (args[0] as? Context)?.applicationContext ?: (args[0] as? Context)
+ registerHookBootstrapReceiver()
+ applyRuntimeMaps(force = true)
+ debugLog("attachBaseContext applied runtime maps and waiting for preset release")
+ }
+
+ persistenceRef.firstConstructor {
+ parameterCount = 0
+ }.hook().after {
+ schedulePostPresetBootstrap(instance)
+ debugLog("PersistenceManager created, scheduled custom widget restore after preset release")
+ }
+
+ d0Ref.firstMethod {
+ name = "l"
+ parameterCount = 1
+ }.hook().after {
+ val oldManager = manager
+ manager = instance
+ mainHandler = runCatching {
+ d0Ref.firstField { name = "E" }.get() as? Handler
+ }.getOrNull()
+ val managerChanged = oldManager !== manager
+ if (managerChanged) {
+ managerEpoch.incrementAndGet()
+ injectedCardSignatureCache.clear()
+ injectedCompositeAt.clear()
+ }
+
+ if (!managerChanged && startupBootstrapped.get()) {
+ applyRuntimeMaps(force = true)
+ patchManagerAppGates(manager)
+ scheduleInjectAllActiveNotices()
+ debugLog("captured manager unchanged, skip bootstrap and reinject active notices")
+ return@after
+ }
+
+ val bootOk = bootstrapFromPrefsOnInit(force = false)
+ if (!bootOk) scheduleBootstrapRetry()
+ applyRuntimeMaps(force = true)
+ patchManagerAppGates(manager)
+ scheduleInjectAllActiveNotices()
+ debugLog("captured manager=${manager != null}, handler=${mainHandler != null}")
+ }
+
+ d0Ref.firstMethod {
+ name = "o"
+ parameterCount = 1
+ }.hook().after {
+ patchManagerAppGates(instance)
+ }
+
+ p2cRef.firstMethod {
+ name = "r"
+ parameterCount = 2
+ }.hook().after {
+ val pkg = args[0] as? String ?: return@after
+ if (result != null) return@after
+ val biz = RearWidgetRuntimeStore.fallbackBusiness(pkg) ?: return@after
+ result = createU0b(biz, 0, 600)
+ debugLog("p2.c.r fallback pkg=$pkg -> business=$biz")
+ }
+
+ p2cRef.firstMethod {
+ name = "i"
+ parameterCount = 2
+ }.hook().after {
+ val pkg = args[0] as? String ?: return@after
+ val biz = args[1] as? String ?: return@after
+ // business 文件映射是全局覆盖 只要注册了该 business 文件 就覆盖系统内置路径
+ val path = RearWidgetRuntimeStore.getBusinessFile(biz) ?: return@after
+ result = path
+ debugLog("p2.c.i override path pkg=$pkg biz=$biz path=$path")
+ }
+
+ p2cRef.firstMethod {
+ name = "k"
+ parameterCount = 3
+ }.hook().before {
+ val pkg = args[0] as? String ?: return@before
+ if (RearWidgetRuntimeStore.allPkgBusinesses().containsKey(pkg)) {
+ result = true
+ debugLog("p2.c.k force pass pkg=$pkg")
+ }
+ }
+
+ z1mRef.firstMethod {
+ name = "run"
+ parameterCount = 0
+ }.hook().before {
+ allowSelfDescribedNotificationPackage(instance)
+ }
+
+ p2cRef.firstMethod {
+ name = "s"
+ parameterCount = 10
+ }.hook().after {
+ applyRuntimeMaps(force = false)
+ val out = result as? Bundle ?: return@after
+ val key = out.getString("composite_key") ?: (args.getOrNull(1) as? String)
+ val notice = key?.let { RearWidgetRuntimeStore.getNotice(it) } ?: return@after
+ out.putAll(RearWidgetRuntimeStore.buildDecoratedExtras(notice.ticket))
+ }
+ }
+ }
+
+ private val hookBinder = object : IRearWidgetApiService.Stub() {
+ override fun registerBusinessFile(business: String?, filePath: String?) {
+ enforceCallerPermission()
+ val normalizedBusiness = business?.trim().orEmpty()
+ val normalizedFilePath = filePath?.trim().orEmpty()
+ if (normalizedBusiness.isBlank() || normalizedFilePath.isBlank()) return
+ dispatchOperation(
+ op = RearWidgetApiContract.Operation.REGISTER_FILE,
+ action = {
+ val deployedPath =
+ deployBusinessTemplate(normalizedBusiness, normalizedFilePath)
+ ?: error("deploy template failed for business=$normalizedBusiness source=$normalizedFilePath")
+ RearWidgetRuntimeStore.registerBusinessFile(normalizedBusiness, deployedPath)
+ OperationOutcome()
+ }
+ )
+ }
+
+ override fun unregisterBusinessFile(business: String?) {
+ enforceCallerPermission()
+ val normalizedBusiness = business?.trim().orEmpty()
+ if (normalizedBusiness.isBlank()) return
+ dispatchOperation(
+ op = RearWidgetApiContract.Operation.UNREGISTER_FILE,
+ action = {
+ RearWidgetRuntimeStore.unregisterBusinessFile(normalizedBusiness)
+ removeDeployedBusinessTemplate(normalizedBusiness)
+ OperationOutcome()
+ }
+ )
+ }
+
+ override fun registerBusiness(
+ targetPackage: String?,
+ business: String?,
+ filePath: String?,
+ defaultIndex: Int,
+ defaultPriority: Int,
+ ) {
+ enforceCallerPermission()
+ val normalizedBusiness = business?.trim().orEmpty()
+ val normalizedFilePath = filePath?.trim().orEmpty()
+ if (normalizedBusiness.isBlank() || normalizedFilePath.isBlank()) return
+ dispatchOperation(
+ op = RearWidgetApiContract.Operation.REGISTER,
+ action = {
+ val deployedPath =
+ deployBusinessTemplate(normalizedBusiness, normalizedFilePath)
+ ?: error("deploy template failed for business=$normalizedBusiness source=$normalizedFilePath")
+ RearWidgetRuntimeStore.registerBusiness(
+ packageName = normalizeTargetPackage(targetPackage),
+ business = normalizedBusiness,
+ filePath = deployedPath,
+ defaultIndex = defaultIndex,
+ defaultPriority = defaultPriority,
+ )
+ OperationOutcome()
+ }
+ )
+ }
+
+ override fun registerBusinessWithoutFile(
+ targetPackage: String?,
+ business: String?,
+ defaultIndex: Int,
+ defaultPriority: Int,
+ ) {
+ enforceCallerPermission()
+ val normalizedBusiness = business?.trim().orEmpty()
+ if (normalizedBusiness.isBlank()) return
+ dispatchOperation(
+ op = RearWidgetApiContract.Operation.REGISTER,
+ action = {
+ val registered = RearWidgetRuntimeStore.registerBusinessWithoutFile(
+ packageName = normalizeTargetPackage(targetPackage),
+ business = normalizedBusiness,
+ defaultIndex = defaultIndex,
+ defaultPriority = defaultPriority,
+ )
+ check(registered) { "filePath not found for business: $normalizedBusiness" }
+ OperationOutcome()
+ }
+ )
+ }
+
+ override fun unregisterBusiness(targetPackage: String?, business: String?) {
+ enforceCallerPermission()
+ val normalizedBusiness = business?.trim().orEmpty()
+ if (normalizedBusiness.isBlank()) return
+ val packageName = normalizeTargetPackage(targetPackage)
+ dispatchOperation(
+ op = RearWidgetApiContract.Operation.UNREGISTER,
+ action = {
+ RearWidgetRuntimeStore.unregisterBusiness(packageName, normalizedBusiness)
+ OperationOutcome(ejectBusiness = packageName to normalizedBusiness)
+ }
+ )
+ }
+
+ override fun disableBusinessDisplay(targetPackage: String?, business: String?) {
+ enforceCallerPermission()
+ val normalizedBusiness = business?.trim().orEmpty()
+ if (normalizedBusiness.isBlank()) return
+ val packageName = normalizeTargetPackage(targetPackage)
+ dispatchOperation(
+ op = RearWidgetApiContract.Operation.DISABLE_DISPLAY,
+ action = {
+ RearWidgetRuntimeStore.disableBusinessDisplay(packageName, normalizedBusiness)
+ OperationOutcome(ejectBusiness = packageName to normalizedBusiness)
+ }
+ )
+ }
+
+ override fun postNotice(
+ targetPackage: String?,
+ business: String?,
+ payload: Bundle?,
+ options: Bundle?,
+ ) {
+ enforceCallerPermission()
+ val normalizedBusiness = business?.trim().orEmpty()
+ if (normalizedBusiness.isBlank()) return
+ val noticeOptions = RearWidgetNoticeOptions.fromBundle(options)
+ dispatchOperation(
+ op = RearWidgetApiContract.Operation.POST,
+ action = {
+ val ticket = RearWidgetRuntimeStore.postNotice(
+ packageName = normalizeTargetPackage(targetPackage),
+ business = normalizedBusiness,
+ payload = payload ?: Bundle(),
+ options = noticeOptions,
+ )
+ OperationOutcome(injectCompositeKey = ticket.compositeKey)
+ }
+ )
+ }
+
+ override fun updateNotice(
+ ticket: Bundle?,
+ payload: Bundle?,
+ options: Bundle?,
+ updatePayload: Boolean,
+ updateOptions: Boolean,
+ ) {
+ enforceCallerPermission()
+ val noticeTicket = RearWidgetNoticeTicket.fromBundle(ticket) ?: return
+ val payloadArg = if (updatePayload) payload ?: Bundle() else null
+ val optionsArg = if (updateOptions) {
+ RearWidgetNoticeOptions.fromBundle(options)
+ } else {
+ null
+ }
+ dispatchOperation(
+ op = RearWidgetApiContract.Operation.UPDATE,
+ action = {
+ RearWidgetRuntimeStore.updateNotice(noticeTicket, payloadArg, optionsArg)
+ OperationOutcome(injectCompositeKey = noticeTicket.compositeKey)
+ }
+ )
+ }
+
+ override fun removeNotice(ticket: Bundle?) {
+ enforceCallerPermission()
+ val noticeTicket = RearWidgetNoticeTicket.fromBundle(ticket) ?: return
+ dispatchOperation(
+ op = RearWidgetApiContract.Operation.REMOVE,
+ action = {
+ RearWidgetRuntimeStore.removeNotice(noticeTicket)
+ OperationOutcome(ejectTicket = noticeTicket)
+ }
+ )
+ }
+
+ override fun syncState() {
+ enforceCallerPermission()
+ bootstrapFromPrefsOnInit(force = true)
+ applyRuntimeMaps(force = true)
+ patchManagerAppGates(manager)
+ scheduleInjectAllActiveNotices()
+ }
+ }
+
+ private val hookBootstrapReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action != RearWidgetApiContract.ACTION_REQUEST_HOOK_SERVICE) return
+ val callbackBinder = intent
+ .getBundleExtra(RearWidgetApiContract.Extras.BUNDLE)
+ ?.getBinder(RearWidgetApiContract.Extras.BINDER)
+ val callback = IRearWidgetApiConnection.Stub.asInterface(callbackBinder)
+ val forceSync = intent.getBooleanExtra(RearWidgetApiContract.Extras.FORCE_SYNC, false)
+ if (forceSync) {
+ bootstrapFromPrefsOnInit(force = true)
+ }
+ runCatching {
+ callback?.onServiceConnected(hookBinder)
+ }.onFailure {
+ debugLog("reply hook binder failed err=${it.message}")
+ }
+ }
+ }
+
+ private fun registerHookBootstrapReceiver() {
+ if (!bootstrapReceiverRegistered.compareAndSet(false, true)) return
+ val ctx = hostContext ?: run {
+ bootstrapReceiverRegistered.set(false)
+ return
+ }
+ val ok = runCatching {
+ val filter = IntentFilter(RearWidgetApiContract.ACTION_REQUEST_HOOK_SERVICE)
+ ContextCompat.registerReceiver(
+ ctx,
+ hookBootstrapReceiver,
+ filter,
+ RearWidgetApiContract.SERVICE_PERMISSION,
+ null,
+ ContextCompat.RECEIVER_EXPORTED
+ )
+ true
+ }.onFailure {
+ bootstrapReceiverRegistered.set(false)
+ debugLog("register bootstrap receiver failed err=${it.message}")
+ }.getOrDefault(false)
+ if (ok) {
+ debugLog("register bootstrap receiver success")
+ }
+ }
+
+ private fun dispatchOperation(op: String, action: () -> OperationOutcome) {
+ val outcome = action()
+ clearInjectCache(op, outcome)
+ applyRuntimeMaps(force = true)
+ patchManagerAppGates(manager)
+ outcome.injectCompositeKey?.let { injectByCompositeKey(it) }
+ outcome.ejectTicket?.let { ejectByTicket(it) }
+ outcome.ejectBusiness?.let { (pkg, biz) -> ejectBusinessDisplay(pkg, biz) }
+ }
+
+ private fun enforceCallerPermission() {
+ val ctx = hostContext
+ val uid = Binder.getCallingUid()
+ if (uid == Process.myUid()) return
+ if (ctx == null) {
+ throw SecurityException("context not ready for permission check")
+ }
+ val granted = ctx.checkPermission(
+ RearWidgetApiContract.SERVICE_PERMISSION,
+ Binder.getCallingPid(),
+ uid,
+ ) == PackageManager.PERMISSION_GRANTED
+ if (!granted) {
+ throw SecurityException(
+ "caller uid=$uid requires ${RearWidgetApiContract.SERVICE_PERMISSION}"
+ )
+ }
+ }
+
+ private fun normalizeTargetPackage(targetPackage: String?): String {
+ return targetPackage?.trim().takeUnless { it.isNullOrBlank() }
+ ?: RearWidgetRuntimeStore.defaultPackageName
+ }
+
+ private fun bootstrapFromPrefsOnInit(force: Boolean = false): Boolean {
+ if (!force && startupBootstrapped.get()) return true
+
+ val businessRaw = prefs.getString(
+ ConfigKeys.REAR_WIDGET_BUSINESS_DATA,
+ RearWidgetConfigCodec.EMPTY_ARRAY,
+ )
+ val cardRaw = prefs.getString(
+ ConfigKeys.REAR_WIDGET_CARD_DATA,
+ RearWidgetConfigCodec.EMPTY_ARRAY,
+ )
+ val businesses = RearWidgetConfigCodec.parseBusinesses(businessRaw)
+ val cards = RearWidgetConfigCodec.parseCards(cardRaw).filter { it.enabled }
+ val stickyCards = cards.filter { it.sticky }
+ val prefsManager = prefs.getPrefsManager()
+ if (!force && businesses.isEmpty() && cards.isEmpty()) {
+ debugLog("bootstrap init skipped: no config yet")
+ return false
+ }
+
+ val businessPathMap = LinkedHashMap()
+ businesses.forEach { item ->
+ val deployedPath = deployBusinessTemplate(item.business, item.filePath)
+ ?: run {
+ debugLog("bootstrap register_file failed business=${item.business} deploy failed")
+ return false
+ }
+ businessPathMap[item.business] = deployedPath
+ RearWidgetRuntimeStore.registerBusinessFile(item.business, deployedPath)
+ }
+
+ val uniquePairs = LinkedHashSet>()
+ cards.forEach { uniquePairs += (it.packageName to it.business) }
+
+ // 重放前先清掉目标业务的旧展示 保证重复 bootstrap 不会叠加出重复卡片
+ uniquePairs.forEach { (pkg, biz) ->
+ RearWidgetRuntimeStore.disableBusinessDisplay(pkg, biz)
+ }
+
+ uniquePairs.forEach { (pkg, biz) ->
+ val ok = businessPathMap[biz]?.let { path ->
+ RearWidgetRuntimeStore.registerBusiness(
+ packageName = pkg,
+ business = biz,
+ filePath = path,
+ defaultIndex = 0,
+ defaultPriority = 500,
+ )
+ true
+ } ?: RearWidgetRuntimeStore.registerBusinessWithoutFile(
+ packageName = pkg,
+ business = biz,
+ defaultIndex = 0,
+ defaultPriority = 500,
+ )
+ if (!ok) {
+ debugLog("bootstrap register failed pkg=$pkg biz=$biz")
+ return false
+ }
+ }
+
+ stickyCards.forEachIndexed { index, card ->
+ val payload = Bundle().apply {
+ putString("title", card.title.ifBlank { card.business })
+ putString("business", card.business)
+ putString("__rear_card_id__", card.id)
+ }
+ val options = RearWidgetNoticeOptions(
+ sticky = card.sticky,
+ disablePopup = true,
+ showTimeTip = prefsManager.getShowTimeTipForBusiness(card.business),
+ index = index,
+ priority = card.priority,
+ )
+ runCatching {
+ RearWidgetRuntimeStore.postNotice(
+ business = card.business,
+ packageName = card.packageName,
+ payload = payload,
+ options = options,
+ )
+ }.onFailure {
+ debugLog("bootstrap post failed pkg=${card.packageName} biz=${card.business} cardId=${card.id} err=${it.message}")
+ return false
+ }
+ }
+
+ applyRuntimeMaps(force = true)
+ startupBootstrapped.set(true)
+ bootstrapRetryCount.set(0)
+ debugLog("bootstrap init replay businesses=${businesses.size} enabledCards=${cards.size} stickyCards=${stickyCards.size} force=$force ok=true")
+ return true
+ }
+
+ private fun scheduleBootstrapRetry() {
+ val handler = mainHandler ?: return
+ val retry = bootstrapRetryCount.incrementAndGet()
+ if (retry > 5) {
+ debugLog("bootstrap retry stop: max reached")
+ return
+ }
+
+ val delay = 1200L * retry
+ handler.postDelayed({
+ if (startupBootstrapped.get()) return@postDelayed
+ val ok = bootstrapFromPrefsOnInit(force = false)
+ if (!ok) scheduleBootstrapRetry()
+ }, delay)
+ debugLog("bootstrap retry scheduled count=$retry delay=${delay}ms")
+ }
+
+ private fun injectAllActiveNotices() {
+ RearWidgetRuntimeStore.listNotices().forEach { notice ->
+ injectByCompositeKey(notice.ticket.compositeKey, true)
+ }
+ }
+
+ private fun scheduleInjectAllActiveNotices() {
+ val handler = mainHandler ?: return
+ val epoch = managerEpoch.get()
+ handler.postDelayed({
+ if (epoch != managerEpoch.get()) return@postDelayed
+ injectAllActiveNotices()
+ }, 1200L)
+ handler.postDelayed({
+ if (epoch != managerEpoch.get()) return@postDelayed
+ injectAllActiveNotices()
+ }, 2800L)
+ }
+
+ private fun schedulePostPresetBootstrap(persistenceManager: Any?) {
+ val handler = runCatching {
+ persistenceManager?.asResolver()?.firstField { name = "c" }?.get() as? Handler
+ }.getOrNull() ?: return
+
+ handler.post {
+ runCatching {
+ bootstrapFromPrefsOnInit(force = true)
+ applyRuntimeMaps(force = true)
+ patchManagerAppGates(manager)
+ debugLog("restored custom widget templates after preset release")
+ }.onFailure {
+ debugLog("post-preset bootstrap failed err=${it.message}")
+ }
+ }
+ }
+
+ private fun injectByCompositeKey(compositeKey: String, force: Boolean = false) {
+ val notice = RearWidgetRuntimeStore.getNotice(compositeKey) ?: return
+ val mgr = manager ?: return
+ val handler = mainHandler ?: return
+
+ val now = System.currentTimeMillis()
+ val lastAt = injectedCompositeAt[compositeKey] ?: 0L
+ if (!force && now - lastAt < 1200L) {
+ debugLog("skip duplicate inject by composite window key=$compositeKey")
+ return
+ }
+
+ val cardId = notice.payload.getString("__rear_card_id__")?.trim().orEmpty()
+ if (cardId.isNotBlank()) {
+ val cardKey = "${notice.ticket.packageName}:${notice.ticket.business}:$cardId"
+ val signature = buildInjectSignature(notice)
+ val old = injectedCardSignatureCache[cardKey]
+ if (!force && old == signature) {
+ debugLog("skip duplicate inject by card signature card=$cardKey")
+ injectedCompositeAt[compositeKey] = now
+ return
+ }
+ injectedCardSignatureCache[cardKey] = signature
+ }
+
+ runCatching {
+ val extras = RearWidgetRuntimeStore.buildDecoratedExtras(notice.ticket)
+ val runnable = "Z1.m".toClass().resolve().firstConstructor {
+ parameterCount = 5
+ }.create(
+ mgr,
+ notice.ticket.notificationId,
+ notice.ticket.packageName,
+ notice.ticket.compositeKey,
+ extras,
+ ) as? Runnable ?: return
+ handler.post(runnable)
+ injectedCompositeAt[compositeKey] = now
+ debugLog("injected ticket key=${notice.ticket.compositeKey} business=${notice.ticket.business}")
+ }.onFailure {
+ debugLog("inject failed key=$compositeKey err=${it.message}")
+ }
+ }
+
+ private fun ejectByTicket(ticket: RearWidgetNoticeTicket) {
+ val mgr = manager ?: return
+ val handler = mainHandler ?: return
+ runCatching {
+ handler.post {
+ runCatching {
+ mgr.asResolver().firstMethod {
+ name = "p"
+ parameterCount = 3
+ }.invoke(ticket.notificationId, ticket.packageName, 0)
+ debugLog("ejected ticket key=${ticket.compositeKey}")
+ }.onFailure {
+ debugLog("eject failed key=${ticket.compositeKey} err=${it.message}")
+ }
+ }
+ }.onFailure {
+ debugLog("eject schedule failed key=${ticket.compositeKey} err=${it.message}")
+ }
+ }
+
+ private fun ejectBusinessDisplay(packageName: String, business: String) {
+ val mgr = manager ?: return
+ val handler = mainHandler ?: return
+ val prefix = "$packageName:$business:"
+ injectedCardSignatureCache.keys.removeIf { it.startsWith(prefix) }
+ injectedCompositeAt.keys.removeIf { it.startsWith(prefix) }
+ runCatching {
+ handler.post {
+ runCatching {
+ mgr.asResolver().firstMethod {
+ name = "v"
+ parameterCount = 2
+ }.invoke(packageName, business)
+ debugLog("ejected business display pkg=$packageName biz=$business")
+ }.onFailure {
+ debugLog("eject business display failed pkg=$packageName biz=$business err=${it.message}")
+ }
+ }
+ }.onFailure {
+ debugLog("eject schedule failed pkg=$packageName biz=$business err=${it.message}")
+ }
+ }
+
+ private fun applyRuntimeMaps(force: Boolean) {
+ if (!force && !RearWidgetRuntimeStore.mapsDirty.get()) return
+ if (!appliedOnce.compareAndSet(
+ false,
+ true
+ ) && !force && !RearWidgetRuntimeStore.mapsDirty.get()
+ ) return
+
+ val pkgBiz = RearWidgetRuntimeStore.allPkgBusinesses()
+ val pkgPrimary = RearWidgetRuntimeStore.primaryBusinessByPkg()
+ val bizPath = RearWidgetRuntimeStore.allBusinessPath()
+
+ replaceStaticMap("p2.a", "a") { map ->
+ pkgPrimary.forEach { (pkg, biz) -> if (biz.isNotBlank()) map[pkg] = biz }
+ }
+ replaceStaticMap("p2.a", "c") { map ->
+ pkgBiz.forEach { (pkg, set) -> map[pkg] = HashSet(set) }
+ }
+ replaceStaticMap("p2.a", "d") { map ->
+ bizPath.forEach { (biz, path) -> map[biz] = path }
+ }
+ replaceStaticMap("p2.c", "d") { map ->
+ pkgBiz.keys.forEach { pkg -> map[pkg] = null }
+ }
+ replaceStaticList("p2.c", "b") { list ->
+ bizPath.keys.forEach { biz -> if (!list.contains(biz)) list.add(biz) }
+ }
+
+ RearWidgetRuntimeStore.mapsDirty.set(false)
+ dumpRuntimeMaps(bizPath)
+ }
+
+ private fun patchManagerAppGates(target: Any?) {
+ val instance = target ?: return
+ val pkgBiz = RearWidgetRuntimeStore.allPkgBusinesses()
+ if (pkgBiz.isEmpty()) return
+
+ runCatching {
+ @Suppress("UNCHECKED_CAST")
+ val rSet = instance.asResolver().firstField { name = "r" }
+ .get() as ConcurrentHashMap.KeySetView
+
+ @Suppress("UNCHECKED_CAST")
+ val qMap = instance.asResolver().firstField { name = "q" }
+ .get() as ConcurrentHashMap
+
+ pkgBiz.forEach { (pkg, businesses) ->
+ rSet.add(pkg)
+ qMap[pkg] = true
+ businesses.forEach { biz ->
+ qMap["${pkg}_$biz"] = true
+ }
+ }
+ debugLog("patched manager app gates packages=${pkgBiz.keys}")
+ }
+ }
+
+ private fun allowSelfDescribedNotificationPackage(runnable: Any) {
+ val ref = runnable.asResolver()
+ val owner = runCatching {
+ ref.firstField { name = "c" }.get()
+ }.getOrNull() ?: return
+ if (owner.javaClass.name != "Z1.d0") return
+
+ val packageName = runCatching {
+ ref.firstField { name = "d" }.get()
+ }.getOrNull()?.trim().orEmpty()
+ if (packageName.isBlank()) return
+
+ val extras = runCatching {
+ ref.firstField { name = "f" }.get()
+ }.getOrNull() ?: return
+ if (extras.isEmpty) return
+
+ val business = parseBusinessFromParams(packageName, extras) ?: return
+ logNoWidgetPathIfNeeded(packageName, business, extras)
+
+ if (!prefs.getBoolean(ConfigKeys.HOOK_ALLOW_REAR_FOCUS_NOTICES, false)) return
+
+ runCatching {
+ @Suppress("UNCHECKED_CAST")
+ val rSet = owner.asResolver().firstField { name = "r" }
+ .get() as ConcurrentHashMap.KeySetView
+
+ @Suppress("UNCHECKED_CAST")
+ val qMap = owner.asResolver().firstField { name = "q" }
+ .get() as ConcurrentHashMap
+
+ rSet.add(packageName)
+ qMap[packageName] = true
+ qMap["${packageName}_$business"] = true
+ debugLog("dynamic allow pkg=$packageName biz=$business")
+ }.onFailure {
+ debugLog("dynamic allow failed pkg=$packageName biz=$business err=${it.message}")
+ }
+ }
+
+ private fun parseBusinessFromParams(packageName: String, extras: Bundle): String? {
+ val parser = runCatching {
+ "L1.a".toClass().resolve().firstMethod {
+ name = "y"
+ parameterCount = 1
+ }.invoke(extras)
+ }.getOrNull() ?: return null
+
+ val parsed = runCatching {
+ "p2.c".toClass().resolve().firstMethod {
+ name = "r"
+ parameterCount = 2
+ }.invoke(packageName, parser)
+ }.getOrNull() ?: return null
+
+ return runCatching {
+ parsed.asResolver().firstField { name = "c" }.get()
+ }.getOrNull()?.trim()?.ifBlank { null }
+ }
+
+ private fun logNoWidgetPathIfNeeded(packageName: String, business: String, extras: Bundle) {
+ val hasRemoteView =
+ extras.containsKey("miui.rear.rv") || extras.containsKey("miui.rear.rvAOD")
+ if (hasRemoteView) return
+
+ val builtInSupported = runCatching {
+ "p2.a".toClass().resolve().firstMethod {
+ name = "d"
+ parameterCount = 2
+ }.invoke(packageName, business) ?: false
+ }.getOrDefault(false)
+ if (builtInSupported) return
+
+ val widgetPath = runCatching {
+ "p2.c".toClass().resolve().firstMethod {
+ name = "i"
+ parameterCount = 2
+ }.invoke(packageName, business)
+ }.getOrNull()
+
+ if (widgetPath.isNullOrBlank()) {
+ YLog.debug("[$TAG] No widget path pkg=$packageName business=$business")
+ }
+ }
+
+ @Suppress("SameParameterValue")
+ private fun createU0b(business: String, index: Int, priority: Int): Any {
+ return "U0.b".toClass().resolve().firstConstructor {
+ parameterCount = 3
+ }.create(
+ business,
+ index,
+ priority,
+ )
+ }
+
+ private fun replaceStaticMap(
+ className: String,
+ fieldName: String,
+ mutate: (MutableMap) -> Unit,
+ ) {
+ val field = className.toClass().resolve().firstField { name = fieldName }
+ val raw = field.get() ?: error("$className.$fieldName is null")
+ val current = unwrapMutableMap(raw)
+ val out = HashMap(current.size + 8)
+ current.forEach { (k, v) -> if (k != null) out[k] = v }
+ mutate(out)
+ field.set(out)
+ }
+
+ @Suppress("SameParameterValue")
+ private fun replaceStaticList(
+ className: String,
+ fieldName: String,
+ mutate: (MutableList) -> Unit,
+ ) {
+ val field = className.toClass().resolve().firstField { name = fieldName }
+ val raw = field.get() ?: error("$className.$fieldName is null")
+ val current = unwrapMutableList(raw)
+ val out = ArrayList(current.size + 16)
+ current.forEach { if (it != null) out.add(it) }
+ mutate(out)
+ field.set(out)
+ }
+
+ private fun unwrapMutableMap(any: Any): MutableMap<*, *> {
+ if (any is MutableMap<*, *>) {
+ return runCatching {
+ any.asResolver().firstField { name = "m" }.get>() ?: any
+ }.recoverCatching {
+ any.asResolver().firstField { name = "isReadOnly" }.set(false)
+ any
+ }.getOrElse { any }
+ }
+ error("Not a map: ${any.javaClass.name}")
+ }
+
+ private fun unwrapMutableList(any: Any): MutableList<*> {
+ if (any is MutableList<*>) {
+ return runCatching {
+ any.asResolver().firstField { name = "list" }.get>() ?: any
+ }.recoverCatching {
+ any.asResolver().firstField { name = "c" }.get>() ?: any
+ }.recoverCatching {
+ any.asResolver().firstField { name = "isReadOnly" }.set(false)
+ any
+ }.getOrElse { any }
+ }
+ error("Not a list: ${any.javaClass.name}")
+ }
+
+ private fun debugLog(message: String) {
+ if (prefs.getBoolean(ConfigKeys.MORE_DEBUG, false)) {
+ YLog.debug("[$TAG] $message")
+ }
+ }
+
+ private fun dumpRuntimeMaps(
+ bizPath: Map,
+ ) {
+ runCatching {
+ val aMap = readStaticMap("p2.a", "a")
+ val cMap = readStaticMap("p2.a", "c")
+ val dMap = readStaticMap("p2.a", "d")
+ val cPersistMap = readStaticMap("p2.c", "d")
+ val cWhitelist = readStaticList("p2.c", "b").toSet()
+
+ val missingInWhitelist = bizPath.keys.sorted().filter { it !in cWhitelist }
+ debugLog(
+ "dump p2.a.a=$aMap, p2.a.c=$cMap, p2.a.d=$dMap, " +
+ "p2.c.d=$cPersistMap, p2.c.b.missing=$missingInWhitelist"
+ )
+ }.onFailure {
+ debugLog("dump failed: ${it.message}")
+ }
+ }
+
+ private fun readStaticMap(className: String, fieldName: String): Map {
+ val raw = className.toClass().resolve().firstField { name = fieldName }.get()
+ ?: return emptyMap()
+ val map = unwrapMutableMap(raw)
+ return map.entries.associate { (k, v) -> k.toString() to v }
+ }
+
+ @Suppress("SameParameterValue")
+ private fun readStaticList(className: String, fieldName: String): List {
+ val raw = className.toClass().resolve().firstField { name = fieldName }.get()
+ ?: return emptyList()
+ return unwrapMutableList(raw).map { it.toString() }
+ }
+
+ private fun deployBusinessTemplate(business: String, sourcePath: String): String? {
+ val source = sourcePath.trim()
+ val target = resolveTemplatePath(business)
+ val targetFile = File(target)
+
+ val blobMeta = prefs.getString(RearWidgetConfigCodec.businessBlobMetaKey(business), "")
+ if (blobMeta.isNotBlank() && deployedBlobMetaCache[business] == blobMeta && targetFile.exists()) {
+ return target
+ }
+
+ if (blobMeta.isNotBlank()) {
+ val encoded = prefs.getString(RearWidgetConfigCodec.businessBlobKey(business), "")
+ val bytes = runCatching { Base64.decode(encoded, Base64.DEFAULT) }.getOrNull()
+ if (bytes != null && bytes.isNotEmpty()) {
+ val ok = runCatching {
+ targetFile.parentFile?.mkdirs()
+ val tmp =
+ File(targetFile.parentFile, "${targetFile.name}.tmp.${Process.myPid()}")
+ tmp.outputStream().use { it.write(bytes) }
+ if (targetFile.exists()) targetFile.delete()
+ val moved = tmp.renameTo(targetFile)
+ if (!moved) {
+ tmp.copyTo(targetFile, overwrite = true)
+ tmp.delete()
+ }
+ ensureReadable(targetFile)
+ true
+ }.getOrDefault(false)
+
+ if (ok) {
+ deployedBlobMetaCache[business] = blobMeta
+ debugLog("deployed business template from prefs business=$business -> $target size=${bytes.size}")
+ return target
+ }
+ }
+ debugLog("deploy blob decode/write failed business=$business meta=$blobMeta")
+ } else {
+ debugLog("deploy blob missing business=$business")
+ }
+
+ val sourceFile = File(source)
+ if (sourceFile.exists() && sourceFile.isFile) {
+ val ok = runCatching {
+ targetFile.parentFile?.mkdirs()
+ sourceFile.inputStream().use { input ->
+ targetFile.outputStream().use { output ->
+ input.copyTo(output)
+ }
+ }
+ ensureReadable(targetFile)
+ true
+ }.getOrDefault(false)
+ if (ok) {
+ debugLog("deployed business template from file business=$business source=$source -> $target")
+ return target
+ }
+ }
+
+ debugLog("deploy failed business=$business source=$source blobMeta=$blobMeta")
+ return null
+ }
+
+ private fun resolveTemplatePath(business: String): String {
+ val userId = Process.myUid() / 100000
+ val base = TEMPLATE_BASE.format(userId.toString())
+ val safeBiz = business.trim().replace(Regex("[^a-zA-Z0-9._-]"), "_")
+ return "$base/re_$safeBiz"
+ }
+
+ private fun removeDeployedBusinessTemplate(business: String) {
+ runCatching {
+ deployedBlobMetaCache.remove(business)
+ val target = resolveTemplatePath(business)
+ val file = File(target)
+ if (file.exists() && file.delete()) {
+ debugLog("removed stale deployed template business=$business path=$target")
+ }
+ }.onFailure {
+ debugLog("remove stale deployed template failed business=$business err=${it.message}")
+ }
+ }
+
+ @SuppressLint("SetWorldReadable")
+ private fun ensureReadable(file: File) {
+ file.setReadable(true, false)
+ file.parentFile?.setReadable(true, false)
+ file.parentFile?.setExecutable(true, false)
+ }
+
+ private fun clearInjectCache(op: String, outcome: OperationOutcome) {
+ when (op) {
+ RearWidgetApiContract.Operation.DISABLE_DISPLAY,
+ RearWidgetApiContract.Operation.UNREGISTER -> {
+ val (pkg, biz) = outcome.ejectBusiness ?: return
+ val prefix = "$pkg:$biz:"
+ injectedCardSignatureCache.keys.removeIf { it.startsWith(prefix) }
+ injectedCompositeAt.keys.removeIf { it.startsWith(prefix) }
+ }
+
+ RearWidgetApiContract.Operation.REMOVE -> {
+ val composite = outcome.ejectTicket?.compositeKey.orEmpty()
+ if (composite.isNotBlank()) injectedCompositeAt.remove(composite)
+ }
+ }
+ }
+
+ private fun buildInjectSignature(notice: RearWidgetActiveNotice): String {
+ val payload = notice.payload
+ return buildString {
+ append(notice.ticket.compositeKey)
+ append('|').append(payload.getString("title").orEmpty())
+ append('|').append(payload.getString("business").orEmpty())
+ append('|').append(notice.options.index ?: -1)
+ append('|').append(notice.options.priority ?: -1)
+ append('|').append(notice.options.sticky)
+ }
+ }
+}
diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/rearwidget/RearWidgetHooker.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/rearwidget/RearWidgetHooker.kt
deleted file mode 100644
index 609ba0b..0000000
--- a/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/rearwidget/RearWidgetHooker.kt
+++ /dev/null
@@ -1,741 +0,0 @@
-@file:Suppress("UNCHECKED_CAST")
-
-package hk.uwu.reareye.hook.scopes.subscreencenter.modules.rearwidget
-
-import android.annotation.SuppressLint
-import android.os.Bundle
-import android.os.Handler
-import android.os.Process
-import android.util.Base64
-import com.highcapable.kavaref.KavaRef.Companion.asResolver
-import com.highcapable.kavaref.KavaRef.Companion.resolve
-import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker
-import de.robv.android.xposed.XposedBridge
-import hk.uwu.reareye.actions.RearWidgetApi
-import hk.uwu.reareye.rearwidget.RearWidgetConfigCodec
-import hk.uwu.reareye.ui.config.ConfigKeys
-import org.json.JSONObject
-import java.io.File
-import java.util.concurrent.ConcurrentHashMap
-import java.util.concurrent.atomic.AtomicBoolean
-import java.util.concurrent.atomic.AtomicInteger
-
-/**
- * Hook 侧实现:
- * - 通过 RearWidgetApi 的 channel 命令维护业务路由/通知
- * - 将运行时映射注入 p2.a / p2.c
- * - post/update 后立即触发注入,减少链路时序依赖
- */
-class RearWidgetHooker : YukiBaseHooker() {
-
- companion object {
- private const val TAG = "REAREye-RearWidget"
- private const val TEMPLATE_BASE =
- "/data/system/theme_magic/users/%s/subscreencenter/smart_assistant"
- }
-
- private val appliedOnce = AtomicBoolean(false)
- private val startupBootstrapped = AtomicBoolean(false)
- private val channelBridgeRegistered = AtomicBoolean(false)
- private val deployedBlobMetaCache = ConcurrentHashMap()
- private val injectedCardSignatureCache = ConcurrentHashMap()
- private val injectedCompositeAt = ConcurrentHashMap()
- private val bootstrapRetryCount = AtomicInteger(0)
- private val managerEpoch = AtomicInteger(0)
-
- private var manager: Any? = null
- private var mainHandler: Handler? = null
-
- override fun onHook() {
- loadApp("com.xiaomi.subscreencenter") {
- debugLog("hook process=$processName")
- RearWidgetApi.install(packageName)
- registerChannelBridge()
- debugLog("onHook start")
-
- val appRef = "com.xiaomi.subscreencenter.SubScreenCenterApp".toClass().resolve()
- val d0Ref = "Z1.d0".toClass().resolve()
- val p2cRef = "p2.c".toClass().resolve()
-
- appRef.firstMethod {
- name = "attachBaseContext"
- parameterCount = 1
- }.hook().after {
- applyRuntimeMaps(force = true)
- debugLog("attachBaseContext applied runtime maps (defer bootstrap to manager init)")
- }
-
- d0Ref.firstMethod {
- name = "l"
- parameterCount = 1
- }.hook().after {
- val oldManager = manager
- manager = instance
- mainHandler = runCatching {
- d0Ref.firstField { name = "E" }.get() as? Handler
- }.getOrNull()
- val managerChanged = oldManager !== manager
- if (managerChanged) {
- managerEpoch.incrementAndGet()
- injectedCardSignatureCache.clear()
- injectedCompositeAt.clear()
- }
-
- if (!managerChanged && startupBootstrapped.get()) {
- applyRuntimeMaps(force = true)
- patchManagerAppGates(manager)
- debugLog("captured manager unchanged, skip bootstrap/reinject")
- return@after
- }
-
- val bootOk = bootstrapFromPrefsOnInit(force = false)
- if (!bootOk) scheduleBootstrapRetry()
- applyRuntimeMaps(force = true)
- patchManagerAppGates(manager)
- scheduleInjectAllActiveNotices()
- debugLog("captured manager=${manager != null}, handler=${mainHandler != null}")
- }
-
- d0Ref.firstMethod {
- name = "o"
- parameterCount = 1
- }.hook().after {
- patchManagerAppGates(instance)
- }
-
- p2cRef.firstMethod {
- name = "r"
- parameterCount = 2
- }.hook().after {
- val pkg = args[0] as? String ?: return@after
- if (result != null) return@after
- val biz = RearWidgetApi.fallbackBusiness(pkg) ?: return@after
- result = createU0b(biz, 0, 600)
- debugLog("p2.c.r fallback pkg=$pkg -> business=$biz")
- }
-
- p2cRef.firstMethod {
- name = "i"
- parameterCount = 2
- }.hook().after {
- val pkg = args[0] as? String ?: return@after
- val biz = args[1] as? String ?: return@after
- // business 文件映射是全局覆盖语义:只要注册了该 business 文件,就覆盖系统内置路径。
- val path = RearWidgetApi.getBusinessFile(biz) ?: return@after
- result = path
- debugLog("p2.c.i override path pkg=$pkg biz=$biz path=$path")
- }
-
- p2cRef.firstMethod {
- name = "k"
- parameterCount = 3
- }.hook().before {
- val pkg = args[0] as? String ?: return@before
- if (RearWidgetApi.allPkgBusinesses().containsKey(pkg)) {
- result = true
- debugLog("p2.c.k force pass pkg=$pkg")
- }
- }
-
- p2cRef.firstMethod {
- name = "s"
- parameterCount = 10
- }.hook().after {
- applyRuntimeMaps(force = false)
- val out = result as? Bundle ?: return@after
- val key = out.getString("composite_key") ?: (args.getOrNull(1) as? String)
- val notice = key?.let { RearWidgetApi.getNotice(it) } ?: return@after
- out.putAll(RearWidgetApi.buildDecoratedExtras(notice.ticket))
- }
- }
- }
-
- fun onChannelMessage(channelKey: String, payload: String): String {
- val op = RearWidgetApi.opFromChannelKey(channelKey)
- ?: return JSONObject().put("ok", false)
- .put("message", "unknown channel key: $channelKey").toString()
-
- clearInjectCacheByOperation(op, payload)
-
- if (op == RearWidgetApi.Channel.OP_SYNC) {
- bootstrapFromPrefsOnInit(force = true)
- }
-
- val ack = executeApiOperation(op, payload, applyMaps = true, injectNow = true)
- debugLog("channel key=$channelKey op=$op ack=$ack")
- return ack
- }
-
- private fun executeApiOperation(
- op: String,
- payload: String,
- applyMaps: Boolean,
- injectNow: Boolean,
- ): String {
- val normalizedPayload = runCatching {
- normalizePayloadForHook(op, payload)
- }.getOrElse {
- return JSONObject()
- .put("ok", false)
- .put("op", op)
- .put("message", it.message ?: "normalize payload failed")
- .toString()
- }
- val ack = RearWidgetApi.handleChannelCommand(op, normalizedPayload)
- if (applyMaps) {
- applyRuntimeMaps(force = true)
- patchManagerAppGates(manager)
- }
- if (!injectNow) return ack
-
- when (op) {
- RearWidgetApi.Channel.OP_POST -> {
- val key = runCatching {
- JSONObject(ack).optJSONObject("data")?.optString("compositeKey")
- }.getOrNull()
- if (!key.isNullOrBlank()) injectByCompositeKey(key)
- }
-
- RearWidgetApi.Channel.OP_UPDATE -> {
- val key = runCatching {
- JSONObject(normalizedPayload).optJSONObject("ticket")?.optString("compositeKey")
- }.getOrNull()
- if (!key.isNullOrBlank()) injectByCompositeKey(key)
- }
- }
- return ack
- }
-
- private fun bootstrapFromPrefsOnInit(force: Boolean = false): Boolean {
- if (!force && startupBootstrapped.get()) return true
-
- val businessRaw = prefs.getString(
- ConfigKeys.REAR_WIDGET_BUSINESS_DATA,
- RearWidgetConfigCodec.EMPTY_ARRAY,
- )
- val cardRaw = prefs.getString(
- ConfigKeys.REAR_WIDGET_CARD_DATA,
- RearWidgetConfigCodec.EMPTY_ARRAY,
- )
- val businesses = RearWidgetConfigCodec.parseBusinesses(businessRaw)
- val cards = RearWidgetConfigCodec.parseCards(cardRaw).filter { it.enabled }
- if (!force && businesses.isEmpty() && cards.isEmpty()) {
- debugLog("bootstrap init skipped: no config yet")
- return false
- }
-
- val businessPathMap = LinkedHashMap()
- var allOk = true
- businesses.forEach { item ->
- businessPathMap[item.business] = item.filePath
- val ack = executeApiOperation(
- op = RearWidgetApi.Channel.OP_REGISTER_FILE,
- payload = RearWidgetApi.buildRegisterBusinessFilePayload(
- item.business,
- item.filePath
- ),
- applyMaps = false,
- injectNow = false,
- )
- if (!isAckOk(ack)) {
- allOk = false
- debugLog("bootstrap register_file failed business=${item.business} ack=$ack")
- }
- }
-
- val uniquePairs = LinkedHashSet>()
- cards.forEach { uniquePairs += (it.packageName to it.business) }
-
- // 重放前先清掉目标业务的旧展示,保证重复 bootstrap 不会叠加出重复卡片。
- uniquePairs.forEach { (pkg, biz) ->
- val ack = executeApiOperation(
- op = RearWidgetApi.Channel.OP_DISABLE_DISPLAY,
- payload = RearWidgetApi.buildDisableBusinessDisplayPayload(
- business = biz,
- packageName = pkg,
- ),
- applyMaps = false,
- injectNow = false,
- )
- if (!isAckOk(ack)) {
- allOk = false
- debugLog("bootstrap disable_display failed pkg=$pkg biz=$biz ack=$ack")
- }
- }
-
- uniquePairs.forEach { (pkg, biz) ->
- val payload = businessPathMap[biz]?.let { path ->
- RearWidgetApi.buildRegisterBusinessPayload(
- business = biz,
- filePath = path,
- packageName = pkg,
- defaultIndex = 0,
- defaultPriority = 500,
- )
- } ?: RearWidgetApi.buildRegisterBusinessPayloadWithoutFile(
- business = biz,
- packageName = pkg,
- defaultIndex = 0,
- defaultPriority = 500,
- )
- val ack = executeApiOperation(
- op = RearWidgetApi.Channel.OP_REGISTER,
- payload = payload,
- applyMaps = false,
- injectNow = false,
- )
- if (!isAckOk(ack)) {
- allOk = false
- debugLog("bootstrap register failed pkg=$pkg biz=$biz ack=$ack")
- }
- }
-
- cards.forEachIndexed { index, card ->
- val payload = Bundle().apply {
- putString("title", card.title.ifBlank { card.business })
- putString("business", card.business)
- putString("__rear_card_id__", card.id)
- }
- val options = RearWidgetApi.NoticeOptions(
- sticky = true,
- disablePopup = true,
- showTimeTip = true,
- index = index,
- priority = card.priority,
- )
- val ack = executeApiOperation(
- op = RearWidgetApi.Channel.OP_POST,
- payload = RearWidgetApi.buildPostNoticePayload(
- business = card.business,
- packageName = card.packageName,
- payload = payload,
- options = options,
- ),
- applyMaps = false,
- injectNow = false,
- )
- if (!isAckOk(ack)) {
- allOk = false
- debugLog("bootstrap post failed pkg=${card.packageName} biz=${card.business} cardId=${card.id} ack=$ack")
- }
- }
-
- applyRuntimeMaps(force = true)
- if (allOk) {
- startupBootstrapped.set(true)
- bootstrapRetryCount.set(0)
- }
- debugLog("bootstrap init replay businesses=${businesses.size} enabledCards=${cards.size} force=$force ok=$allOk")
- return allOk
- }
-
- private fun scheduleBootstrapRetry() {
- val handler = mainHandler ?: return
- val retry = bootstrapRetryCount.incrementAndGet()
- if (retry > 5) {
- debugLog("bootstrap retry stop: max reached")
- return
- }
-
- val delay = 1200L * retry
- handler.postDelayed({
- if (startupBootstrapped.get()) return@postDelayed
- val ok = bootstrapFromPrefsOnInit(force = false)
- if (!ok) scheduleBootstrapRetry()
- }, delay)
- debugLog("bootstrap retry scheduled count=$retry delay=${delay}ms")
- }
-
- private fun isAckOk(ack: String): Boolean {
- return runCatching { JSONObject(ack).optBoolean("ok", false) }.getOrDefault(false)
- }
-
- private fun injectAllActiveNotices() {
- RearWidgetApi.listNotices().forEach { notice ->
- injectByCompositeKey(notice.ticket.compositeKey, true)
- }
- }
-
- private fun scheduleInjectAllActiveNotices() {
- val handler = mainHandler ?: return
- val epoch = managerEpoch.get()
- handler.postDelayed({
- if (epoch != managerEpoch.get()) return@postDelayed
- injectAllActiveNotices()
- }, 1200L)
- handler.postDelayed({
- if (epoch != managerEpoch.get()) return@postDelayed
- injectAllActiveNotices()
- }, 2800L)
- }
-
- private fun normalizePayloadForHook(op: String, payload: String): String {
- val obj = runCatching { JSONObject(payload) }.getOrNull() ?: return payload
-
- if (op == RearWidgetApi.Channel.OP_REGISTER_FILE || op == RearWidgetApi.Channel.OP_REGISTER) {
- val business = obj.optString("business").trim()
- val filePath = obj.optString("filePath").trim()
- if (business.isNotBlank() && filePath.isNotBlank()) {
- val deployedPath = deployBusinessTemplate(business, filePath)
- ?: error("deploy template failed for business=$business source=$filePath")
- obj.put("filePath", deployedPath)
- }
- }
-
- if (op == RearWidgetApi.Channel.OP_UNREGISTER_FILE) {
- val business = obj.optString("business").trim()
- if (business.isNotBlank()) {
- removeDeployedBusinessTemplate(business)
- }
- }
- return obj.toString()
- }
-
- private fun registerChannelBridge() {
- if (!channelBridgeRegistered.compareAndSet(false, true)) return
- val keys = listOf(
- RearWidgetApi.Channel.KEY_REGISTER_BUSINESS_FILE,
- RearWidgetApi.Channel.KEY_UNREGISTER_BUSINESS_FILE,
- RearWidgetApi.Channel.KEY_REGISTER_BUSINESS,
- RearWidgetApi.Channel.KEY_UNREGISTER_BUSINESS,
- RearWidgetApi.Channel.KEY_DISABLE_BUSINESS_DISPLAY,
- RearWidgetApi.Channel.KEY_POST_NOTICE,
- RearWidgetApi.Channel.KEY_UPDATE_NOTICE,
- RearWidgetApi.Channel.KEY_REMOVE_NOTICE,
- RearWidgetApi.Channel.KEY_SYNC_STATE,
- )
- keys.forEach { key ->
- dataChannel.wait(key) { payload ->
- val ack = onChannelMessage(key, payload)
- dataChannel.put(RearWidgetApi.Channel.KEY_ACK, ack)
- }
- }
- debugLog("registered dataChannel bridge keys=${keys.joinToString()}")
- }
-
- private fun injectByCompositeKey(compositeKey: String, force: Boolean = false) {
- val notice = RearWidgetApi.getNotice(compositeKey) ?: return
- val mgr = manager ?: return
- val handler = mainHandler ?: return
-
- val now = System.currentTimeMillis()
- val lastAt = injectedCompositeAt[compositeKey] ?: 0L
- if (!force && now - lastAt < 1200L) {
- debugLog("skip duplicate inject by composite window key=$compositeKey")
- return
- }
-
- val cardId = notice.payload.getString("__rear_card_id__")?.trim().orEmpty()
- if (cardId.isNotBlank()) {
- val cardKey = "${notice.ticket.packageName}:${notice.ticket.business}:$cardId"
- val signature = buildInjectSignature(notice)
- val old = injectedCardSignatureCache[cardKey]
- if (!force && old == signature) {
- debugLog("skip duplicate inject by card signature card=$cardKey")
- injectedCompositeAt[compositeKey] = now
- return
- }
- injectedCardSignatureCache[cardKey] = signature
- }
-
- runCatching {
- val extras = RearWidgetApi.buildDecoratedExtras(notice.ticket)
- val runnable = "Z1.m".toClass().resolve().firstConstructor {
- parameterCount = 5
- }.create(
- mgr,
- notice.ticket.notificationId,
- notice.ticket.packageName,
- notice.ticket.compositeKey,
- extras,
- ) as? Runnable ?: return
- handler.post(runnable)
- injectedCompositeAt[compositeKey] = now
- debugLog("injected ticket key=${notice.ticket.compositeKey} business=${notice.ticket.business}")
- }.onFailure {
- debugLog("inject failed key=$compositeKey err=${it.message}")
- }
- }
-
- private fun applyRuntimeMaps(force: Boolean) {
- if (!force && !RearWidgetApi.mapsDirty.get()) return
- if (!appliedOnce.compareAndSet(
- false,
- true
- ) && !force && !RearWidgetApi.mapsDirty.get()
- ) return
-
- val pkgBiz = RearWidgetApi.allPkgBusinesses()
- val pkgPrimary = RearWidgetApi.primaryBusinessByPkg()
- val bizPath = RearWidgetApi.allBusinessPath()
-
- replaceStaticMap("p2.a", "a") { map ->
- pkgPrimary.forEach { (pkg, biz) -> if (biz.isNotBlank()) map[pkg] = biz }
- }
- replaceStaticMap("p2.a", "c") { map ->
- pkgBiz.forEach { (pkg, set) -> map[pkg] = HashSet(set) }
- }
- replaceStaticMap("p2.a", "d") { map ->
- bizPath.forEach { (biz, path) -> map[biz] = path }
- }
- replaceStaticMap("p2.c", "d") { map ->
- pkgBiz.keys.forEach { pkg -> map[pkg] = null }
- }
- replaceStaticList("p2.c", "b") { list ->
- bizPath.keys.forEach { biz -> if (!list.contains(biz)) list.add(biz) }
- }
-
- RearWidgetApi.mapsDirty.set(false)
- dumpRuntimeMaps(bizPath)
- }
-
- private fun patchManagerAppGates(target: Any?) {
- val instance = target ?: return
- val pkgBiz = RearWidgetApi.allPkgBusinesses()
- if (pkgBiz.isEmpty()) return
-
- runCatching {
- @Suppress("UNCHECKED_CAST")
- val rSet = instance.asResolver().firstField { name = "r" }
- .get() as ConcurrentHashMap.KeySetView
-
- @Suppress("UNCHECKED_CAST")
- val qMap = instance.asResolver().firstField { name = "q" }
- .get() as ConcurrentHashMap
-
- pkgBiz.forEach { (pkg, businesses) ->
- rSet.add(pkg)
- qMap[pkg] = true
- businesses.forEach { biz ->
- qMap["${pkg}_$biz"] = true
- }
- }
- debugLog("patched manager app gates packages=${pkgBiz.keys}")
- }
- }
-
- @Suppress("SameParameterValue")
- private fun createU0b(business: String, index: Int, priority: Int): Any {
- return "U0.b".toClass().resolve().firstConstructor {
- parameterCount = 3
- }.create(
- business,
- index,
- priority,
- )
- }
-
- private fun replaceStaticMap(
- className: String,
- fieldName: String,
- mutate: (MutableMap) -> Unit,
- ) {
- val field = className.toClass().resolve().firstField { name = fieldName }
- val raw = field.get() ?: error("$className.$fieldName is null")
- val current = unwrapMutableMap(raw)
- val out = HashMap(current.size + 8)
- current.forEach { (k, v) -> if (k != null) out[k] = v }
- mutate(out)
- field.set(out)
- }
-
- @Suppress("SameParameterValue")
- private fun replaceStaticList(
- className: String,
- fieldName: String,
- mutate: (MutableList) -> Unit,
- ) {
- val field = className.toClass().resolve().firstField { name = fieldName }
- val raw = field.get() ?: error("$className.$fieldName is null")
- val current = unwrapMutableList(raw)
- val out = ArrayList(current.size + 16)
- current.forEach { if (it != null) out.add(it) }
- mutate(out)
- field.set(out)
- }
-
- private fun unwrapMutableMap(any: Any): MutableMap<*, *> {
- if (any is MutableMap<*, *>) {
- return runCatching {
- any.asResolver().firstField { name = "m" }.get>() ?: any
- }.recoverCatching {
- any.asResolver().firstField { name = "isReadOnly" }.set(false)
- any
- }.getOrElse { any }
- }
- error("Not a map: ${any.javaClass.name}")
- }
-
- private fun unwrapMutableList(any: Any): MutableList<*> {
- if (any is MutableList<*>) {
- return runCatching {
- any.asResolver().firstField { name = "list" }.get>() ?: any
- }.recoverCatching {
- any.asResolver().firstField { name = "c" }.get>() ?: any
- }.recoverCatching {
- any.asResolver().firstField { name = "isReadOnly" }.set(false)
- any
- }.getOrElse { any }
- }
- error("Not a list: ${any.javaClass.name}")
- }
-
- private fun debugLog(message: String) {
- XposedBridge.log("[$TAG] $message")
- }
-
- private fun dumpRuntimeMaps(
- bizPath: Map,
- ) {
- runCatching {
- val aMap = readStaticMap("p2.a", "a")
- val cMap = readStaticMap("p2.a", "c")
- val dMap = readStaticMap("p2.a", "d")
- val cPersistMap = readStaticMap("p2.c", "d")
- val cWhitelist = readStaticList("p2.c", "b").toSet()
-
- val missingInWhitelist = bizPath.keys.sorted().filter { it !in cWhitelist }
- debugLog(
- "dump p2.a.a=$aMap, p2.a.c=$cMap, p2.a.d=$dMap, " +
- "p2.c.d=$cPersistMap, p2.c.b.missing=$missingInWhitelist"
- )
- }.onFailure {
- debugLog("dump failed: ${it.message}")
- }
- }
-
- private fun readStaticMap(className: String, fieldName: String): Map {
- val raw = className.toClass().resolve().firstField { name = fieldName }.get()
- ?: return emptyMap()
- val map = unwrapMutableMap(raw)
- return map.entries.associate { (k, v) -> k.toString() to v }
- }
-
- @Suppress("SameParameterValue")
- private fun readStaticList(className: String, fieldName: String): List {
- val raw = className.toClass().resolve().firstField { name = fieldName }.get()
- ?: return emptyList()
- return unwrapMutableList(raw).map { it.toString() }
- }
-
- private fun deployBusinessTemplate(business: String, sourcePath: String): String? {
- val source = sourcePath.trim()
- val target = resolveTemplatePath(business)
- val targetFile = File(target)
-
- val blobMeta = prefs.getString(RearWidgetConfigCodec.businessBlobMetaKey(business), "")
- if (blobMeta.isNotBlank() && deployedBlobMetaCache[business] == blobMeta && targetFile.exists()) {
- return target
- }
-
- if (blobMeta.isNotBlank()) {
- val encoded = prefs.getString(RearWidgetConfigCodec.businessBlobKey(business), "")
- val bytes = runCatching { Base64.decode(encoded, Base64.DEFAULT) }.getOrNull()
- if (bytes != null && bytes.isNotEmpty()) {
- val ok = runCatching {
- targetFile.parentFile?.mkdirs()
- val tmp =
- File(targetFile.parentFile, "${targetFile.name}.tmp.${Process.myPid()}")
- tmp.outputStream().use { it.write(bytes) }
- if (targetFile.exists()) targetFile.delete()
- val moved = tmp.renameTo(targetFile)
- if (!moved) {
- tmp.copyTo(targetFile, overwrite = true)
- tmp.delete()
- }
- ensureReadable(targetFile)
- true
- }.getOrDefault(false)
-
- if (ok) {
- deployedBlobMetaCache[business] = blobMeta
- debugLog("deployed business template from prefs business=$business -> $target size=${bytes.size}")
- return target
- }
- }
- debugLog("deploy blob decode/write failed business=$business meta=$blobMeta")
- } else {
- debugLog("deploy blob missing business=$business")
- }
-
- val sourceFile = File(source)
- if (sourceFile.exists() && sourceFile.isFile) {
- val ok = runCatching {
- targetFile.parentFile?.mkdirs()
- sourceFile.inputStream().use { input ->
- targetFile.outputStream().use { output ->
- input.copyTo(output)
- }
- }
- ensureReadable(targetFile)
- true
- }.getOrDefault(false)
- if (ok) {
- debugLog("deployed business template from file business=$business source=$source -> $target")
- return target
- }
- }
-
- debugLog("deploy failed business=$business source=$source blobMeta=$blobMeta")
- return null
- }
-
- private fun resolveTemplatePath(business: String): String {
- val userId = Process.myUid() / 100000
- val base = TEMPLATE_BASE.format(userId.toString())
- val safeBiz = business.trim().replace(Regex("[^a-zA-Z0-9._-]"), "_")
- return "$base/re_$safeBiz"
- }
-
- private fun removeDeployedBusinessTemplate(business: String) {
- runCatching {
- deployedBlobMetaCache.remove(business)
- val target = resolveTemplatePath(business)
- val file = File(target)
- if (file.exists() && file.delete()) {
- debugLog("removed stale deployed template business=$business path=$target")
- }
- }.onFailure {
- debugLog("remove stale deployed template failed business=$business err=${it.message}")
- }
- }
-
- @SuppressLint("SetWorldReadable")
- private fun ensureReadable(file: File) {
- file.setReadable(true, false)
- file.parentFile?.setReadable(true, false)
- file.parentFile?.setExecutable(true, false)
- }
-
- private fun clearInjectCacheByOperation(op: String, payload: String) {
- when (op) {
- RearWidgetApi.Channel.OP_DISABLE_DISPLAY,
- RearWidgetApi.Channel.OP_UNREGISTER -> {
- val obj = runCatching { JSONObject(payload) }.getOrNull() ?: return
- val pkg = obj.optString("packageName").trim()
- val biz = obj.optString("business").trim()
- if (pkg.isBlank() || biz.isBlank()) return
- val prefix = "$pkg:$biz:"
- injectedCardSignatureCache.keys.removeIf { it.startsWith(prefix) }
- }
-
- RearWidgetApi.Channel.OP_REMOVE -> {
- val obj = runCatching { JSONObject(payload) }.getOrNull() ?: return
- val composite = obj.optJSONObject("ticket")?.optString("compositeKey").orEmpty()
- if (composite.isNotBlank()) injectedCompositeAt.remove(composite)
- }
- }
- }
-
- private fun buildInjectSignature(notice: RearWidgetApi.ActiveNotice): String {
- val payload = notice.payload
- return buildString {
- append(notice.ticket.compositeKey)
- append('|').append(payload.getString("title").orEmpty())
- append('|').append(payload.getString("business").orEmpty())
- append('|').append(notice.options.index ?: -1)
- append('|').append(notice.options.priority ?: -1)
- append('|').append(notice.options.sticky)
- }
- }
-}
diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/rearwidget/RearWidgetRuntimeStore.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/rearwidget/RearWidgetRuntimeStore.kt
new file mode 100644
index 0000000..16c564a
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/subscreencenter/modules/rearwidget/RearWidgetRuntimeStore.kt
@@ -0,0 +1,244 @@
+package hk.uwu.reareye.hook.scopes.subscreencenter.modules.rearwidget
+
+import android.os.Bundle
+import hk.uwu.reareye.widgetapi.RearWidgetActiveNotice
+import hk.uwu.reareye.widgetapi.RearWidgetBusinessSpec
+import hk.uwu.reareye.widgetapi.RearWidgetNoticeOptions
+import hk.uwu.reareye.widgetapi.RearWidgetNoticeTicket
+import org.json.JSONObject
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+
+object RearWidgetRuntimeStore {
+
+ @Volatile
+ var defaultPackageName: String = "com.xiaomi.subscreencenter"
+
+ val mapsDirty = AtomicBoolean(false)
+
+ private val businessFiles = ConcurrentHashMap()
+ private val routes = ConcurrentHashMap>()
+ private val notices = ConcurrentHashMap()
+ private val cardNoticeIdIndex = ConcurrentHashMap()
+ private val cardNoticeCompositeIndex = ConcurrentHashMap()
+ private val idSeed = AtomicInteger(310000)
+
+ fun install(defaultPkg: String) {
+ defaultPackageName = defaultPkg
+ routes.computeIfAbsent(defaultPkg) { linkedMapOf() }
+ mapsDirty.set(true)
+ }
+
+ fun registerBusinessFile(business: String, filePath: String) {
+ businessFiles[business] = filePath
+ routes.forEach { (_, bizMap) ->
+ val old = bizMap[business]
+ if (old != null) bizMap[business] = old.copy(filePath = filePath)
+ }
+ mapsDirty.set(true)
+ }
+
+ fun getBusinessFile(business: String): String? {
+ return businessFiles[business]
+ ?: routes.values.firstNotNullOfOrNull { it[business]?.filePath }
+ }
+
+ fun unregisterBusinessFile(business: String) {
+ businessFiles.remove(business)
+ routes.forEach { (_, bizMap) -> bizMap.remove(business) }
+ mapsDirty.set(true)
+ }
+
+ fun registerBusiness(
+ packageName: String = defaultPackageName,
+ business: String,
+ filePath: String,
+ defaultIndex: Int = 0,
+ defaultPriority: Int = 500,
+ ) {
+ businessFiles[business] = filePath
+ val spec =
+ RearWidgetBusinessSpec(packageName, business, filePath, defaultIndex, defaultPriority)
+ routes.computeIfAbsent(packageName) { linkedMapOf() }[business] = spec
+ mapsDirty.set(true)
+ }
+
+ fun registerBusinessWithoutFile(
+ packageName: String = defaultPackageName,
+ business: String,
+ defaultIndex: Int = 0,
+ defaultPriority: Int = 500,
+ ): Boolean {
+ val filePath = getBusinessFile(business) ?: return false
+ registerBusiness(packageName, business, filePath, defaultIndex, defaultPriority)
+ return true
+ }
+
+ fun unregisterBusiness(packageName: String = defaultPackageName, business: String) {
+ routes[packageName]?.remove(business)
+ mapsDirty.set(true)
+ }
+
+ fun postNotice(
+ business: String,
+ payload: Bundle = Bundle(),
+ options: RearWidgetNoticeOptions = RearWidgetNoticeOptions(),
+ packageName: String = defaultPackageName,
+ ): RearWidgetNoticeTicket {
+ val spec = routes[packageName]?.get(business)
+ ?: error("Business not registered: $packageName/$business")
+ val merged = options.copy(
+ index = options.index ?: spec.defaultIndex,
+ priority = options.priority ?: spec.defaultPriority,
+ )
+
+ val cardId = payload.getString("__rear_card_id__")?.trim().orEmpty()
+ if (cardId.isNotBlank()) {
+ val cardKey = cardNoticeKey(packageName, business, cardId)
+ val existingComposite = cardNoticeCompositeIndex[cardKey]
+ if (!existingComposite.isNullOrBlank()) {
+ val existing = notices[existingComposite]
+ if (existing != null) {
+ notices[existingComposite] = existing.copy(
+ payload = Bundle(payload),
+ options = merged,
+ )
+ return existing.ticket
+ }
+ cardNoticeCompositeIndex.remove(cardKey)
+ }
+
+ val id = cardNoticeIdIndex.getOrPut(cardKey) { idSeed.incrementAndGet() }
+ val key = "$packageName:$business:$id"
+ val ticket = RearWidgetNoticeTicket(packageName, business, id, key)
+ notices[key] = RearWidgetActiveNotice(ticket, Bundle(payload), merged)
+ cardNoticeCompositeIndex[cardKey] = key
+ return ticket
+ }
+
+ val id = idSeed.incrementAndGet()
+ val key = "$packageName:$business:$id"
+ val ticket = RearWidgetNoticeTicket(packageName, business, id, key)
+ notices[key] = RearWidgetActiveNotice(ticket, Bundle(payload), merged)
+ return ticket
+ }
+
+ fun updateNotice(
+ ticket: RearWidgetNoticeTicket,
+ payload: Bundle? = null,
+ options: RearWidgetNoticeOptions? = null,
+ ) {
+ val old = notices[ticket.compositeKey] ?: return
+ notices[ticket.compositeKey] = old.copy(
+ payload = payload ?: old.payload,
+ options = options ?: old.options,
+ )
+ }
+
+ fun removeNotice(ticket: RearWidgetNoticeTicket) {
+ val removed = notices.remove(ticket.compositeKey) ?: return
+ val cardId = removed.payload.getString("__rear_card_id__")?.trim().orEmpty()
+ if (cardId.isNotBlank()) {
+ cardNoticeCompositeIndex.remove(
+ cardNoticeKey(
+ ticket.packageName,
+ ticket.business,
+ cardId
+ )
+ )
+ }
+ }
+
+ fun disableBusinessDisplay(packageName: String = defaultPackageName, business: String): Int {
+ val targets = notices.values
+ .filter { it.ticket.packageName == packageName && it.ticket.business == business }
+ .map { it.ticket }
+ targets.forEach(::removeNotice)
+ return targets.size
+ }
+
+ fun listNotices(): List {
+ return notices.values.sortedByDescending { it.createdAt }
+ }
+
+ fun getNotice(compositeKey: String): RearWidgetActiveNotice? = notices[compositeKey]
+
+ fun buildDecoratedExtras(ticket: RearWidgetNoticeTicket): Bundle {
+ val notice = notices[ticket.compositeKey]
+ ?: error("Notice not found: ${ticket.compositeKey}")
+ val options = notice.options
+ return Bundle(notice.payload).apply {
+ putString("package_name", ticket.packageName)
+ putString("creator_package", ticket.packageName)
+ putString("business", ticket.business)
+ putInt("index", options.index ?: 0)
+ putInt("priority", options.priority ?: 500)
+ putInt("notification_id", ticket.notificationId)
+ putInt("widget_id", ticket.notificationId)
+ putString("composite_key", ticket.compositeKey)
+ putLong("timestamp", System.currentTimeMillis())
+
+ putBoolean("disable_popup", options.disablePopup)
+ putBoolean("force_popup", options.forcePopup)
+ putBoolean("enableFloat", options.enableFloat)
+ putBoolean("show_time_tip", options.showTimeTip)
+ putBoolean("__x_sticky__", options.sticky)
+
+ if (getString("miui.rear.param").isNullOrBlank()) {
+ putString("miui.rear.param", buildRearParamJson(ticket.business, options))
+ }
+ if (getString("miui.focus.param").isNullOrBlank()) {
+ putString("miui.focus.param", buildFocusParamJson(ticket.business, options))
+ }
+ putString("__xposed_origin__", ticket.packageName)
+ }
+ }
+
+ fun allPkgBusinesses(): Map> = routes.mapValues { it.value.keys.toSet() }
+
+ fun primaryBusinessByPkg(): Map =
+ routes.mapValues { (_, bizMap) -> bizMap.keys.firstOrNull().orEmpty() }
+
+ fun allBusinessPath(): Map {
+ val out = HashMap()
+ out.putAll(businessFiles)
+ routes.values.flatMap { it.values }.forEach { spec -> out[spec.business] = spec.filePath }
+ return out
+ }
+
+ fun fallbackBusiness(packageName: String): String? {
+ val latest = notices.values.asSequence()
+ .filter { it.ticket.packageName == packageName }
+ .maxByOrNull { it.createdAt }
+ return latest?.ticket?.business ?: routes[packageName]?.keys?.firstOrNull()
+ }
+
+ private fun buildRearParamJson(business: String, options: RearWidgetNoticeOptions): String {
+ val v1 = JSONObject()
+ .put("business", business)
+ .put("index", options.index ?: 0)
+ .put("priority", options.priority ?: 500)
+ .put("disable_popup", options.disablePopup)
+ .put("show_time_tip", options.showTimeTip)
+ .put("swipe_out_screen_listener", false)
+ .put("enableFloat", options.enableFloat)
+ return JSONObject().put("rear_param_v1", v1).toString()
+ }
+
+ private fun buildFocusParamJson(business: String, options: RearWidgetNoticeOptions): String {
+ return JSONObject()
+ .put("business", business)
+ .put("index", options.index ?: 0)
+ .put("priority", options.priority ?: 500)
+ .put("disable_popup", options.disablePopup)
+ .put("show_time_tip", options.showTimeTip)
+ .put("swipe_out_screen_listener", false)
+ .put("enableFloat", options.enableFloat)
+ .toString()
+ }
+
+ private fun cardNoticeKey(packageName: String, business: String, cardId: String): String {
+ return "$packageName:$business:$cardId"
+ }
+}
diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/system/SystemScope.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/SystemScope.kt
index 5569b40..8d31668 100644
--- a/app/src/main/java/hk/uwu/reareye/hook/scopes/system/SystemScope.kt
+++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/SystemScope.kt
@@ -3,6 +3,7 @@ package hk.uwu.reareye.hook.scopes.system
import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker
import hk.uwu.reareye.hook.scopes.Scope
import hk.uwu.reareye.hook.scopes.system.modules.BackgroundWhitelistModule
+import hk.uwu.reareye.hook.scopes.system.modules.DisableRearScreenCoverHook
import hk.uwu.reareye.hook.scopes.system.modules.RearScreenActivityWhitelistModule
import hk.uwu.reareye.hook.scopes.system.modules.misc.GMSUnlockModule
@@ -11,6 +12,7 @@ class SystemScope : Scope {
override val hooks: List = listOf(
RearScreenActivityWhitelistModule(),
BackgroundWhitelistModule(),
- GMSUnlockModule()
+ GMSUnlockModule(),
+ DisableRearScreenCoverHook()
)
}
\ No newline at end of file
diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/BackgroundWhitelistModule.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/BackgroundWhitelistModule.kt
index 9d58eea..9a7f94f 100644
--- a/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/BackgroundWhitelistModule.kt
+++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/BackgroundWhitelistModule.kt
@@ -4,7 +4,7 @@ import android.content.Context
import com.highcapable.kavaref.KavaRef.Companion.asResolver
import com.highcapable.kavaref.KavaRef.Companion.resolve
import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker
-import de.robv.android.xposed.XposedBridge
+import com.highcapable.yukihookapi.hook.log.YLog
import hk.uwu.reareye.ui.config.ConfigKeys
class BackgroundWhitelistModule : YukiBaseHooker() {
@@ -22,7 +22,7 @@ class BackgroundWhitelistModule : YukiBaseHooker() {
r[it] = true
}
result = r
- XposedBridge.log("Injected apps into dynamic whitelist")
+ YLog.debug("Injected apps into dynamic whitelist")
}
}
@@ -40,7 +40,7 @@ class BackgroundWhitelistModule : YukiBaseHooker() {
prefs.getStringSet(ConfigKeys.BACKGROUND_LOCK_APPS).forEach {
method.invoke(it, -100, true)
}
- XposedBridge.log("Injected apps into application locked state")
+ YLog.debug("Injected apps into application locked state")
}
}
}
diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/DisableRearScreenCoverHook.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/DisableRearScreenCoverHook.kt
new file mode 100644
index 0000000..bc0a0ba
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/DisableRearScreenCoverHook.kt
@@ -0,0 +1,31 @@
+package hk.uwu.reareye.hook.scopes.system.modules
+
+import com.highcapable.kavaref.KavaRef.Companion.resolve
+import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker
+import com.highcapable.yukihookapi.hook.log.YLog
+import hk.uwu.reareye.ui.config.ConfigKeys
+
+class DisableRearScreenCoverHook : YukiBaseHooker() {
+ override fun onHook() {
+ loadSystem {
+ val clz = "com.android.server.power.DualScreenCoverManager".toClass().resolve()
+ clz.firstMethod {
+ name = "showCoverView"
+ parameters(Int::class.java)
+ }.hook().replaceUnit {
+ val displayId = args(0).int()
+ if (displayId == 1 && prefs.getBoolean(
+ ConfigKeys.HOOK_DISABLE_REAR_SCREEN_COVER,
+ false
+ )
+ ) {
+ // 阻止显示cover view
+ YLog.debug("Rejected show cover view on rear screen")
+ return@replaceUnit
+ } else {
+ invokeOriginal(displayId)
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/RearScreenActivityWhitelistModule.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/RearScreenActivityWhitelistModule.kt
index 37fe91a..b434297 100644
--- a/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/RearScreenActivityWhitelistModule.kt
+++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/RearScreenActivityWhitelistModule.kt
@@ -3,7 +3,7 @@ package hk.uwu.reareye.hook.scopes.system.modules
import com.highcapable.kavaref.KavaRef.Companion.asResolver
import com.highcapable.kavaref.KavaRef.Companion.resolve
import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker
-import de.robv.android.xposed.XposedBridge
+import com.highcapable.yukihookapi.hook.log.YLog
import hk.uwu.reareye.ui.config.ConfigKeys
class RearScreenActivityWhitelistModule : YukiBaseHooker() {
@@ -26,7 +26,7 @@ class RearScreenActivityWhitelistModule : YukiBaseHooker() {
set.clear()
set.add("com.retroarch")
set.addAll(whitelist)
- XposedBridge.log("Injected Activities Whitelist")
+ YLog.debug("Injected Activities Whitelist")
}
after {
@@ -55,7 +55,7 @@ class RearScreenActivityWhitelistModule : YukiBaseHooker() {
}.get()
if (whitelist.contains(packageName)) {
resultTrue()
- XposedBridge.log("Allow starting $packageName while rear screen is locked")
+ YLog.debug("Allow starting $packageName while rear screen is locked")
}
}
}
diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/misc/GMSUnlockModule.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/misc/GMSUnlockModule.kt
index c2a0571..92abca3 100644
--- a/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/misc/GMSUnlockModule.kt
+++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/system/modules/misc/GMSUnlockModule.kt
@@ -5,7 +5,7 @@ import android.util.ArrayMap
import com.highcapable.kavaref.KavaRef.Companion.asResolver
import com.highcapable.kavaref.KavaRef.Companion.resolve
import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker
-import de.robv.android.xposed.XposedBridge
+import com.highcapable.yukihookapi.hook.log.YLog
import hk.uwu.reareye.ui.config.ConfigKeys
class GMSUnlockModule : YukiBaseHooker() {
@@ -30,16 +30,16 @@ class GMSUnlockModule : YukiBaseHooker() {
val map = instance.asResolver().firstMethod {
name = "getAvailableFeatures"
}.invoke() as ArrayMap
- XposedBridge.log("Hooked system features $map")
+ YLog.debug("Hooked system features $map")
} else {
- XposedBridge.log("Removed system features")
+ YLog.debug("Removed system features")
}
}
}
clz.firstConstructor {
parameterCount = 0
}.hook().after {
- XposedBridge.log("Hooking SystemConfig constructor")
+ YLog.debug("Hooking SystemConfig constructor")
if (prefs.getBoolean(ConfigKeys.MISC_HOOK_GMS_UNLOCK, false)) {
remove(instance, true)
}
@@ -51,7 +51,7 @@ class GMSUnlockModule : YukiBaseHooker() {
before {
if (prefs.getBoolean(ConfigKeys.MISC_HOOK_GMS_UNLOCK, false)) {
remove(instance, false)
- XposedBridge.log("Features has been patched, remove this hook")
+ YLog.debug("Features has been patched, remove this hook")
removeSelf()
}
}
diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/thememanager/ThemeManagerScope.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/thememanager/ThemeManagerScope.kt
index aaf9d21..463586e 100644
--- a/app/src/main/java/hk/uwu/reareye/hook/scopes/thememanager/ThemeManagerScope.kt
+++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/thememanager/ThemeManagerScope.kt
@@ -4,10 +4,12 @@ import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker
import hk.uwu.reareye.hook.scopes.Scope
import hk.uwu.reareye.hook.scopes.thememanager.modules.UnlockTemplateMaximumLimitHook
import hk.uwu.reareye.hook.scopes.thememanager.modules.UnlockVideoRestrictionsHook
+import hk.uwu.reareye.hook.scopes.thememanager.modules.UnmuteVideoWallpaperHook
class ThemeManagerScope : Scope {
override val hooks: List = listOf(
UnlockVideoRestrictionsHook(),
- UnlockTemplateMaximumLimitHook()
+ UnlockTemplateMaximumLimitHook(),
+ UnmuteVideoWallpaperHook()
)
}
\ No newline at end of file
diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/thememanager/modules/UnlockVideoRestrictionsHook.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/thememanager/modules/UnlockVideoRestrictionsHook.kt
index 2eec407..989b9d6 100644
--- a/app/src/main/java/hk/uwu/reareye/hook/scopes/thememanager/modules/UnlockVideoRestrictionsHook.kt
+++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/thememanager/modules/UnlockVideoRestrictionsHook.kt
@@ -7,7 +7,7 @@ import android.util.Size
import com.highcapable.kavaref.KavaRef.Companion.asResolver
import com.highcapable.kavaref.KavaRef.Companion.resolve
import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker
-import de.robv.android.xposed.XposedBridge
+import com.highcapable.yukihookapi.hook.log.YLog
import hk.uwu.reareye.ui.config.ConfigKeys
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
@@ -20,8 +20,8 @@ class UnlockVideoRestrictionsHook : YukiBaseHooker() {
@SuppressLint("ResourceType")
override fun onHook() {
loadApp("com.android.thememanager") {
- val videoEditClz =
- "com.android.thememanager.videoedit.VideoEditActivity".toClass().resolve()
+ val videoEditClz = "com.android.thememanager.videoedit.VideoEditActivity".toClass()
+ val videoEditRef = videoEditClz.resolve()
val fpsLimitClz =
$$"com.android.thememanager.videoedit.VideoEditActivity$zy".toClass().resolve()
val editorCfgClz =
@@ -47,7 +47,7 @@ class UnlockVideoRestrictionsHook : YukiBaseHooker() {
type = Boolean::class.java
}.all { it.get() == true }
if (isCallFromRearScreen) {
- XposedBridge.log("Overwriting video editor max duration & frame-rate limitations")
+ YLog.debug("Overwriting video editor max duration & frame-rate limitations")
// 视频长度
ref.firstField {
type = Long::class.java
@@ -82,7 +82,7 @@ class UnlockVideoRestrictionsHook : YukiBaseHooker() {
}
}
// 修补视频编辑器
- videoEditClz.firstMethod {
+ videoEditRef.firstMethod {
name = "nsb"
returnType = Void.TYPE
}.hook().replaceUnit {
@@ -154,7 +154,7 @@ class UnlockVideoRestrictionsHook : YukiBaseHooker() {
"com.android.thememanager.settings.a9".toClass().resolve().firstMethod {
name = "f7l8"
}.invoke() as String
- val iVEA = instance.asResolver().firstField { name = "this$0" }.get()!!
+ val iVEA = instance.asResolver().firstField { type = videoEditClz }.get()!!
val iRef = iVEA.asResolver()
val yObj = iRef.firstField { name = "y" }.get()
val cFieldRef = iRef.firstField { name = "c" }
@@ -177,7 +177,7 @@ class UnlockVideoRestrictionsHook : YukiBaseHooker() {
return@replaceUnit
}
val pVarN2t = iRef.firstMethod { name = "n2t" }
- .invoke(videoEditClz.firstField { name = "b" }.get(), width, height)!!
+ .invoke(videoEditRef.firstField { name = "b" }.get(), width, height)!!
.asResolver()
val k = pVarN2t.firstField { name = "k" }.get() as Int
val toq = pVarN2t.firstField { name = "toq" }.get() as Int
diff --git a/app/src/main/java/hk/uwu/reareye/hook/scopes/thememanager/modules/UnmuteVideoWallpaperHook.kt b/app/src/main/java/hk/uwu/reareye/hook/scopes/thememanager/modules/UnmuteVideoWallpaperHook.kt
new file mode 100644
index 0000000..33db5f3
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/hook/scopes/thememanager/modules/UnmuteVideoWallpaperHook.kt
@@ -0,0 +1,32 @@
+package hk.uwu.reareye.hook.scopes.thememanager.modules
+
+import android.util.Pair
+import com.highcapable.kavaref.KavaRef.Companion.resolve
+import com.highcapable.yukihookapi.hook.entity.YukiBaseHooker
+import com.highcapable.yukihookapi.hook.log.YLog
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+
+class UnmuteVideoWallpaperHook : YukiBaseHooker() {
+ override fun onHook() {
+ loadApp("com.android.thememanager") {
+ val ref = "com.android.thememanager.util.wx16".toClass().resolve()
+ ref.firstMethod {
+ name = "toq"
+ parameters(File::class.java, File::class.java, File::class.java)
+ }.hook().replaceAny {
+ val input = args(0).cast()!!
+ val output = args(1).cast()!!
+ YLog.debug("Input path: ${input.absolutePath} length: ${input.length() / 1024.0}")
+ YLog.debug("Output path: $output")
+ if (input.absolutePath.contains("rear")) {
+ YLog.debug("Patch rear screen video wallpaper")
+ Files.copy(input.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING)
+ return@replaceAny Pair(output, null)
+ }
+ return@replaceAny invokeOriginal(*args)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/hk/uwu/reareye/lyrics/LyricParser.kt b/app/src/main/java/hk/uwu/reareye/lyrics/LyricParser.kt
new file mode 100644
index 0000000..08b888d
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/lyrics/LyricParser.kt
@@ -0,0 +1,164 @@
+package hk.uwu.reareye.lyrics
+
+import io.github.proify.lyricon.lyric.model.LyricWord
+import io.github.proify.lyricon.lyric.model.RichLyricLine
+import io.github.proify.lyricon.lyric.model.Song
+import java.util.Locale
+
+class LyricParser {
+
+ private companion object {
+ private const val MIUI_LRC_LINE_ENDING = "\r\n"
+ private const val ARTIST_SEPARATOR = "/"
+ }
+
+ enum class DisplayMode(val mask: Int) {
+ ORIGINAL(0x01), // 显示原文
+ TRANSLATION(0x02),// 显示翻译
+ ROMANIZATION(0x04);// 显示罗马音
+
+ companion object {
+ fun shouldShowOriginal(mask: Int): Boolean =
+ (mask and ORIGINAL.mask) != 0
+
+ fun shouldShowTranslation(mask: Int): Boolean =
+ (mask and TRANSLATION.mask) != 0
+
+ fun shouldShowRomanization(mask: Int): Boolean =
+ (mask and ROMANIZATION.mask) != 0
+ }
+ }
+
+ fun toLrc(
+ song: Song?,
+ displayMode: Int,
+ showArtistBeforeFirstLine: Boolean = false,
+ ): String {
+ if (song == null) return ""
+ val builder = StringBuilder()
+ val sortedLyrics = song.lyrics
+ ?.sortedBy { it.begin }
+ .orEmpty()
+
+ appendLrcTag(builder, "ti", song.name)
+ appendLrcTag(builder, "ar", song.artist)
+ appendLrcTag(builder, "id", song.id)
+ if (song.duration > 0) {
+ appendLrcTag(builder, "length", formatLength(song.duration))
+ }
+ if (builder.isNotEmpty()) builder.append(MIUI_LRC_LINE_ENDING)
+
+ if (showArtistBeforeFirstLine) {
+ appendArtistLeadIn(builder, song.artist, sortedLyrics.firstOrNull()?.begin ?: 0L)
+ }
+
+ sortedLyrics.forEach { line ->
+ val timestamp = formatTimestamp(line.begin)
+ line.toLrcTexts(displayMode).forEach { text ->
+ builder.append('[')
+ .append(timestamp)
+ .append(']')
+ .append(text)
+ .append(MIUI_LRC_LINE_ENDING)
+ }
+ }
+
+ return builder.toString().removeSuffix(MIUI_LRC_LINE_ENDING)
+ }
+
+ private fun appendArtistLeadIn(
+ builder: StringBuilder,
+ rawArtist: String?,
+ firstLineBegin: Long
+ ) {
+ if (firstLineBegin <= 0L) return
+
+ val artists = rawArtist
+ ?.split(ARTIST_SEPARATOR)
+ .orEmpty()
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ if (artists.isEmpty()) return
+
+ val artistPrefix = if (Locale.getDefault().language == Locale.CHINESE.language) {
+ "歌手:"
+ } else {
+ "Artist: "
+ }
+
+ artists.forEachIndexed { index, artist ->
+ val timestamp = formatTimestamp(firstLineBegin * index / artists.size)
+ builder.append('[')
+ .append(timestamp)
+ .append(']')
+ .append(artistPrefix)
+ .append(artist)
+ .append(MIUI_LRC_LINE_ENDING)
+ }
+ }
+
+ private fun RichLyricLine.toLrcTexts(displayMode: Int): List {
+ val main = resolveText(text, words)
+ val secondaryText = resolveText(secondary, secondaryWords)
+ val translationText = resolveText(translation, translationWords)
+ val romaText = normalizeText(roma)
+
+ val result = mutableListOf()
+
+ if (DisplayMode.shouldShowOriginal(displayMode)) {
+ result.add(main)
+ result.add(secondaryText)
+ }
+ if (DisplayMode.shouldShowTranslation(displayMode)) {
+ result.add(translationText)
+ }
+ if (DisplayMode.shouldShowRomanization(displayMode)) {
+ result.add(romaText)
+ }
+
+ return result.filter { it.isNotBlank() }
+ }
+
+ private fun resolveText(rawText: String?, words: List?): String {
+ val directText = normalizeText(rawText)
+ if (directText.isNotEmpty()) return directText
+
+ val wordsText = words
+ ?.joinToString(separator = "") { it.text.orEmpty() }
+ .orEmpty()
+ return normalizeText(wordsText)
+ }
+
+ private fun normalizeText(text: String?): String =
+ text
+ ?.replace("\n", " ")
+ ?.replace("\r", " ")
+ ?.trim()
+ .orEmpty()
+
+ private fun appendLrcTag(builder: StringBuilder, key: String, value: String?) {
+ val normalized = value?.trim().orEmpty()
+ if (normalized.isEmpty()) return
+ builder.append('[')
+ .append(key)
+ .append(':')
+ .append(normalized)
+ .append(']')
+ .append(MIUI_LRC_LINE_ENDING)
+ }
+
+ private fun formatLength(durationMs: Long): String {
+ val totalSeconds = durationMs.coerceAtLeast(0) / 1000
+ val minutes = totalSeconds / 60
+ val seconds = totalSeconds % 60
+ return String.format(Locale.ROOT, "%02d:%02d", minutes, seconds)
+ }
+
+ private fun formatTimestamp(timeMs: Long): String {
+ val ms = timeMs.coerceAtLeast(0)
+ val minutes = ms / 60_000
+ val seconds = (ms % 60_000) / 1_000
+ val centiseconds = (ms % 1_000) / 10
+ return String.format(Locale.ROOT, "%02d:%02d.%02d", minutes, seconds, centiseconds)
+ }
+}
diff --git a/app/src/main/java/hk/uwu/reareye/repository/contributor/ContributorRepository.kt b/app/src/main/java/hk/uwu/reareye/repository/contributor/ContributorRepository.kt
new file mode 100644
index 0000000..5ead0ff
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/repository/contributor/ContributorRepository.kt
@@ -0,0 +1,108 @@
+package hk.uwu.reareye.repository.contributor
+
+import android.util.Log
+import com.google.gson.Gson
+import com.google.gson.annotations.SerializedName
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import okhttp3.OkHttpClient
+import okhttp3.Request
+
+data class ContributorProfile(
+ @SerializedName("name")
+ val name: String,
+ @SerializedName("description")
+ val description: String,
+ @SerializedName("link")
+ val link: String? = null,
+ @SerializedName("avatar")
+ val avatar: String? = null,
+)
+
+sealed interface ContributorLoadState {
+ data object Idle : ContributorLoadState
+ data object Loading : ContributorLoadState
+ data class Loaded(val contributors: List) : ContributorLoadState
+ data object Failed : ContributorLoadState
+}
+
+private data class ContributorResponse(
+ @SerializedName("contributors")
+ val contributors: List = emptyList(),
+)
+
+object ContributorRepository {
+ private const val CONTRIBUTORS_URL = "https://reareye.uwu.hk/contributors.json"
+
+ private val requestLock = Mutex()
+ private val requestScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val httpClient = OkHttpClient()
+ private val gson = Gson()
+
+ private val _state = MutableStateFlow(ContributorLoadState.Idle)
+ val state: StateFlow = _state.asStateFlow()
+
+ fun preload() {
+ ensureLoaded(force = false)
+ }
+
+ fun ensureLoaded(force: Boolean) {
+ val current = _state.value
+ if (!force && (current is ContributorLoadState.Loading || current is ContributorLoadState.Loaded)) {
+ return
+ }
+
+ requestScope.launch {
+ requestLock.withLock {
+ val latest = _state.value
+ if (!force && (latest is ContributorLoadState.Loading || latest is ContributorLoadState.Loaded)) {
+ return@withLock
+ }
+
+ _state.value = ContributorLoadState.Loading
+ val contributors = fetchContributors()
+ _state.value = if (contributors != null) {
+ ContributorLoadState.Loaded(contributors)
+ } else {
+ ContributorLoadState.Failed
+ }
+ }
+ }
+ }
+
+ private fun fetchContributors(): List? {
+ return runCatching {
+ val request = Request.Builder()
+ .url(CONTRIBUTORS_URL)
+ .build()
+ httpClient.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) return null
+
+ val body = response.body.string()
+ val payload = gson.fromJson(body, ContributorResponse::class.java)
+ payload.contributors.mapNotNull(::normalizeContributor)
+ }
+ }.onFailure {
+ Log.d("Contributor", "fetch error", it)
+ }.getOrNull()
+ }
+
+ private fun normalizeContributor(item: ContributorProfile): ContributorProfile? {
+ val name = item.name.trim()
+ if (name.isBlank()) return null
+
+ return ContributorProfile(
+ name = name,
+ description = item.description.trim(),
+ link = item.link?.trim().takeUnless { it.isNullOrBlank() },
+ avatar = item.avatar?.trim().takeUnless { it.isNullOrBlank() },
+ )
+ }
+}
diff --git a/app/src/main/java/hk/uwu/reareye/repository/rearwallpaper/RearWallpaperModels.kt b/app/src/main/java/hk/uwu/reareye/repository/rearwallpaper/RearWallpaperModels.kt
new file mode 100644
index 0000000..041ba9f
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/repository/rearwallpaper/RearWallpaperModels.kt
@@ -0,0 +1,16 @@
+package hk.uwu.reareye.repository.rearwallpaper
+
+data class RearWallpaperInfo(
+ val wallpaperId: Int,
+ val title: String,
+ val name: String,
+ val previewAvailable: Boolean,
+ val previewSignature: String,
+ val cachePath: String? = null,
+)
+
+data class RearWallpaperCatalog(
+ val wallpapers: List,
+ val currentIndex: Int,
+ val currentWallpaperId: Int?,
+)
diff --git a/app/src/main/java/hk/uwu/reareye/repository/rearwallpaper/RearWallpaperRepository.kt b/app/src/main/java/hk/uwu/reareye/repository/rearwallpaper/RearWallpaperRepository.kt
new file mode 100644
index 0000000..395d7d0
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/repository/rearwallpaper/RearWallpaperRepository.kt
@@ -0,0 +1,223 @@
+package hk.uwu.reareye.repository.rearwallpaper
+
+import android.content.Context
+import android.os.Bundle
+import hk.uwu.reareye.ui.config.ConfigKeys
+import hk.uwu.reareye.ui.config.PrefsManager
+import hk.uwu.reareye.widgetapi.RearWallpaperApiClient
+import hk.uwu.reareye.widgetapi.RearWallpaperApiContract
+import hk.uwu.reareye.widgetapi.RearWallpaperScheduleCodec
+import hk.uwu.reareye.widgetapi.RearWallpaperScheduleEntry
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import java.io.File
+
+object RearWallpaperRepository {
+
+ private const val PREVIEW_CACHE_DIR = "rear_wallpaper_preview_cache"
+
+ private val clientMutex = Mutex()
+
+ @Volatile
+ private var remoteClient: RearWallpaperApiClient? = null
+
+ suspend fun loadCatalog(context: Context): RearWallpaperCatalog {
+ val catalogBundle = withContext(Dispatchers.IO) {
+ withRemote(context) { client ->
+ client.getCatalog()
+ }
+ }
+ val currentIndex =
+ catalogBundle.getInt(RearWallpaperApiContract.BundleKeys.CURRENT_INDEX, -1)
+ val currentWallpaperId = catalogBundle.readNullableInt(
+ RearWallpaperApiContract.BundleKeys.CURRENT_WALLPAPER_ID,
+ )
+
+ val remoteItems = parseCatalogItems(catalogBundle)
+ val wallpapers = syncPreviewCache(context, remoteItems)
+
+ return RearWallpaperCatalog(
+ wallpapers = wallpapers,
+ currentIndex = currentIndex,
+ currentWallpaperId = currentWallpaperId,
+ )
+ }
+
+ suspend fun switchWallpaper(context: Context, wallpaperId: Int): Boolean {
+ return withContext(Dispatchers.IO) {
+ withRemote(context) { client ->
+ client.switchWallpaper(wallpaperId)
+ }
+ }
+ }
+
+ suspend fun syncSchedule(
+ context: Context,
+ enabled: Boolean,
+ schedule: List,
+ ): Boolean {
+ val encoded = encodeSchedule(schedule)
+ return withContext(Dispatchers.IO) {
+ withRemote(context) { client ->
+ client.syncSchedule(enabled, encoded)
+ }
+ }
+ }
+
+ fun loadSchedule(prefsManager: PrefsManager): List {
+ return RearWallpaperScheduleCodec.parse(
+ prefsManager.getString(
+ ConfigKeys.REAR_WALLPAPER_SCHEDULE_DATA,
+ RearWallpaperScheduleCodec.EMPTY_ARRAY,
+ )
+ )
+ }
+
+ fun saveSchedule(
+ prefsManager: PrefsManager,
+ schedule: List,
+ ) {
+ val encoded = encodeSchedule(schedule)
+ prefsManager.prefs.edit()
+ .putString(
+ ConfigKeys.REAR_WALLPAPER_SCHEDULE_DATA,
+ encoded,
+ )
+ .commit()
+ }
+
+ fun isScheduleEnabled(prefsManager: PrefsManager): Boolean {
+ return prefsManager.getBoolean(ConfigKeys.REAR_WALLPAPER_SCHEDULE_ENABLED, false)
+ }
+
+ fun setScheduleEnabled(prefsManager: PrefsManager, enabled: Boolean) {
+ prefsManager.prefs.edit()
+ .putBoolean(ConfigKeys.REAR_WALLPAPER_SCHEDULE_ENABLED, enabled)
+ .commit()
+ }
+
+ private suspend fun withRemote(
+ context: Context,
+ block: (RearWallpaperApiClient) -> T,
+ ): T {
+ return clientMutex.withLock {
+ val appContext = context.applicationContext
+ val client = remoteClient ?: RearWallpaperApiClient().also {
+ remoteClient = it
+ }
+
+ fun ensureConnected(): RearWallpaperApiClient {
+ if (client.isConnected() || client.bind(appContext)) return client
+ throw IllegalStateException("rear wallpaper hook service is not connected")
+ }
+
+ runCatching {
+ val connectedClient = ensureConnected()
+ runCatching {
+ block(connectedClient)
+ }.recoverCatching {
+ client.unbind()
+ block(ensureConnected())
+ }.getOrThrow()
+ }.onFailure {
+ client.unbind()
+ if (remoteClient === client) remoteClient = null
+ }.getOrThrow()
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun parseCatalogItems(bundle: Bundle): List {
+ val rawItems =
+ bundle.getParcelableArrayList(RearWallpaperApiContract.BundleKeys.ITEMS)
+ .orEmpty()
+ return rawItems.map { item ->
+ RearWallpaperInfo(
+ wallpaperId = item.getInt(RearWallpaperApiContract.BundleKeys.WALLPAPER_ID),
+ title = item.getString(RearWallpaperApiContract.BundleKeys.TITLE).orEmpty()
+ .ifBlank { "Wallpaper" },
+ name = item.getString(RearWallpaperApiContract.BundleKeys.NAME).orEmpty()
+ .ifBlank { "unknown" },
+ previewAvailable = item.getBoolean(
+ RearWallpaperApiContract.BundleKeys.PREVIEW_AVAILABLE,
+ false,
+ ),
+ previewSignature = item.getString(RearWallpaperApiContract.BundleKeys.PREVIEW_SIGNATURE)
+ .orEmpty(),
+ )
+ }
+ }
+
+ private suspend fun syncPreviewCache(
+ context: Context,
+ wallpapers: List,
+ ): List {
+ return withContext(Dispatchers.IO) {
+ val root = resolvePreviewCacheRoot(context)
+ if (!root.exists()) root.mkdirs()
+
+ val expectedFiles = HashSet()
+ val result = wallpapers.map { wallpaper ->
+ if (!wallpaper.previewAvailable || wallpaper.previewSignature.isBlank()) {
+ return@map wallpaper.copy(cachePath = null)
+ }
+
+ val cacheFile = File(root, previewCacheFileName(wallpaper))
+ expectedFiles += cacheFile.name
+
+ if (!cacheFile.isFile || cacheFile.length() <= 0L) {
+ fetchAndStorePreview(context, wallpaper.wallpaperId, cacheFile)
+ }
+
+ wallpaper.copy(cachePath = cacheFile.takeIf { it.isFile && it.length() > 0L }?.absolutePath)
+ }
+
+ root.listFiles()?.forEach { file ->
+ if (!file.isFile) return@forEach
+ if (file.name !in expectedFiles) runCatching { file.delete() }
+ }
+
+ result
+ }
+ }
+
+ private fun previewCacheFileName(wallpaper: RearWallpaperInfo): String {
+ return "${wallpaper.wallpaperId}_${wallpaper.previewSignature}.jpg"
+ }
+
+ private fun encodeSchedule(schedule: List): String {
+ return RearWallpaperScheduleCodec.encode(schedule)
+ }
+
+ private suspend fun fetchAndStorePreview(
+ context: Context,
+ wallpaperId: Int,
+ targetFile: File,
+ ) {
+ val bytes = withRemote(context) { client ->
+ client.getPreview(wallpaperId)
+ }
+ if (bytes == null || bytes.isEmpty()) return
+ writeBytesAtomically(targetFile, bytes)
+ }
+
+ private fun writeBytesAtomically(targetFile: File, bytes: ByteArray) {
+ runCatching {
+ targetFile.parentFile?.mkdirs()
+ val tempFile = File(targetFile.parentFile, "${targetFile.name}.tmp")
+ tempFile.outputStream().use { it.write(bytes) }
+ if (targetFile.exists()) targetFile.delete()
+ tempFile.renameTo(targetFile)
+ }
+ }
+
+ private fun resolvePreviewCacheRoot(context: Context): File {
+ return File(context.filesDir, PREVIEW_CACHE_DIR)
+ }
+
+ private fun Bundle.readNullableInt(key: String): Int? {
+ return if (containsKey(key)) getInt(key) else null
+ }
+}
diff --git a/app/src/main/java/hk/uwu/reareye/repository/rearwidget/RearBusinessExtraConfigRepository.kt b/app/src/main/java/hk/uwu/reareye/repository/rearwidget/RearBusinessExtraConfigRepository.kt
new file mode 100644
index 0000000..7cbb375
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/repository/rearwidget/RearBusinessExtraConfigRepository.kt
@@ -0,0 +1,204 @@
+package hk.uwu.reareye.repository.rearwidget
+
+import com.google.gson.Gson
+import com.google.gson.GsonBuilder
+import com.google.gson.JsonObject
+import com.google.gson.annotations.SerializedName
+import hk.uwu.reareye.ui.config.ConfigKeys
+import hk.uwu.reareye.ui.config.PrefsManager
+
+object RearBusinessExtraConfigFields {
+ const val HIDE_TIME_TIP = "hide_time_tip"
+}
+
+data class RearBusinessExtraConfig(
+ val values: JsonObject = JsonObject(),
+) {
+ fun getBoolean(key: String, defaultValue: Boolean): Boolean {
+ return values.get(key)?.takeIf { it.isJsonPrimitive }?.asBoolean ?: defaultValue
+ }
+
+ fun getShowTimeTipOrDefault(): Boolean {
+ return !getBoolean(RearBusinessExtraConfigFields.HIDE_TIME_TIP, false)
+ }
+
+ fun withBoolean(key: String, value: Boolean): RearBusinessExtraConfig {
+ val next = values.deepCopy()
+ next.addProperty(key, value)
+ return RearBusinessExtraConfig(next)
+ }
+}
+
+data class RearBusinessExtraConfigEntry(
+ val business: String,
+ val config: RearBusinessExtraConfig,
+)
+
+object RearBusinessExtraConfigRepository {
+ private const val STORE_VERSION = 1
+ private val gson: Gson = GsonBuilder().create()
+ private val defaultConfig = RearBusinessExtraConfig(
+ JsonObject().apply {
+ addProperty(RearBusinessExtraConfigFields.HIDE_TIME_TIP, false)
+ }
+ )
+
+ fun getAllConfigs(prefsManager: PrefsManager): List {
+ return loadAllNormalized(prefsManager)
+ .map { (business, config) ->
+ RearBusinessExtraConfigEntry(
+ business = business,
+ config = config,
+ )
+ }
+ .sortedBy { it.business.lowercase() }
+ }
+
+ fun getConfigForBusiness(
+ prefsManager: PrefsManager,
+ business: String
+ ): RearBusinessExtraConfig {
+ val map = loadAllNormalized(prefsManager)
+ return map[business.trim()] ?: normalizeConfig(RearBusinessExtraConfig())
+ }
+
+ fun saveConfigForBusiness(
+ prefsManager: PrefsManager,
+ business: String,
+ config: RearBusinessExtraConfig,
+ ) {
+ val normalizedBusiness = business.trim()
+ if (normalizedBusiness.isBlank()) return
+ val map = loadAllNormalized(prefsManager).toMutableMap()
+ map[normalizedBusiness] = config
+ saveAll(prefsManager, map)
+ }
+
+ fun updateConfigForBusiness(
+ prefsManager: PrefsManager,
+ business: String,
+ transform: (RearBusinessExtraConfig) -> RearBusinessExtraConfig,
+ ): RearBusinessExtraConfig {
+ val current = getConfigForBusiness(prefsManager, business)
+ val updated = transform(current)
+ saveConfigForBusiness(prefsManager, business, updated)
+ return updated
+ }
+
+ fun renameBusiness(
+ prefsManager: PrefsManager,
+ oldBusiness: String,
+ newBusiness: String,
+ ) {
+ val normalizedOld = oldBusiness.trim()
+ val normalizedNew = newBusiness.trim()
+ if (normalizedOld.isBlank() || normalizedNew.isBlank() || normalizedOld == normalizedNew) return
+
+ val map = loadAllNormalized(prefsManager).toMutableMap()
+ val config = map.remove(normalizedOld) ?: return
+ map[normalizedNew] = config
+ saveAll(prefsManager, map)
+ }
+
+ fun removeConfigForBusiness(prefsManager: PrefsManager, business: String) {
+ val normalizedBusiness = business.trim()
+ if (normalizedBusiness.isBlank()) return
+ val map = loadAllNormalized(prefsManager).toMutableMap()
+ if (map.remove(normalizedBusiness) != null) saveAll(prefsManager, map)
+ }
+
+ private fun loadAllNormalized(prefsManager: PrefsManager): Map {
+ val raw = prefsManager.getString(ConfigKeys.REAR_WIDGET_BUSINESS_EXTRA_CONFIG_DATA, "")
+ val parsed = parseStore(raw)
+ if (raw.isNotBlank() && raw != parsed.normalizedJson) {
+ prefsManager.putString(
+ ConfigKeys.REAR_WIDGET_BUSINESS_EXTRA_CONFIG_DATA,
+ parsed.normalizedJson
+ )
+ }
+ return parsed.configByBusiness
+ }
+
+ private fun saveAll(
+ prefsManager: PrefsManager,
+ configByBusiness: Map
+ ) {
+ prefsManager.putString(
+ ConfigKeys.REAR_WIDGET_BUSINESS_EXTRA_CONFIG_DATA,
+ encodeStore(configByBusiness),
+ )
+ }
+
+ private data class ParsedStore(
+ val configByBusiness: Map,
+ val normalizedJson: String,
+ )
+
+ private data class StoreModel(
+ @SerializedName("version")
+ val version: Int = STORE_VERSION,
+ @SerializedName("items")
+ val items: List = emptyList(),
+ )
+
+ private data class ItemModel(
+ @SerializedName("business")
+ val business: String? = null,
+ @SerializedName("config")
+ val config: JsonObject? = null,
+ )
+
+ private fun parseStore(raw: String?): ParsedStore {
+ if (raw.isNullOrBlank()) {
+ val empty = emptyMap()
+ return ParsedStore(empty, encodeStore(empty))
+ }
+
+ val parsedModel = runCatching {
+ gson.fromJson(raw, StoreModel::class.java)
+ }.getOrNull() ?: return ParsedStore(emptyMap(), encodeStore(emptyMap()))
+
+ val configByBusiness = linkedMapOf()
+ parsedModel.items.forEach { item ->
+ val business = (item.business).orEmpty().trim()
+ if (business.isBlank()) return@forEach
+
+ val config = normalizeConfig(RearBusinessExtraConfig(item.config ?: JsonObject()))
+ configByBusiness[business] = config
+ }
+
+ return ParsedStore(configByBusiness, encodeStore(configByBusiness))
+ }
+
+ private fun encodeStore(configByBusiness: Map): String {
+ val model = StoreModel(
+ version = STORE_VERSION,
+ items = configByBusiness.map { (business, config) ->
+ ItemModel(
+ business = business,
+ config = normalizeConfig(config).values,
+ )
+ }
+ )
+ return gson.toJson(model)
+ }
+
+ private fun normalizeConfig(config: RearBusinessExtraConfig): RearBusinessExtraConfig {
+ val normalized = config.values.deepCopy()
+ val defaults = defaultConfig.values
+ defaults.entrySet().forEach { (key, value) ->
+ if (!normalized.has(key) || normalized.get(key).isJsonNull) {
+ normalized.add(key, value.deepCopy())
+ }
+ }
+ return RearBusinessExtraConfig(normalized)
+ }
+
+ fun PrefsManager.getShowTimeTipForBusiness(business: String): Boolean {
+ val normalizedBusiness = business.trim()
+ if (normalizedBusiness.isBlank()) return true
+ return loadAllNormalized(this)[normalizedBusiness]?.getShowTimeTipOrDefault() ?: true
+ }
+
+ fun PrefsManager.getExtraConfig(business: String) = getConfigForBusiness(this, business)
+}
diff --git a/app/src/main/java/hk/uwu/reareye/rearwidget/RearWidgetConfigCodec.kt b/app/src/main/java/hk/uwu/reareye/repository/rearwidget/RearWidgetConfigCodec.kt
similarity index 96%
rename from app/src/main/java/hk/uwu/reareye/rearwidget/RearWidgetConfigCodec.kt
rename to app/src/main/java/hk/uwu/reareye/repository/rearwidget/RearWidgetConfigCodec.kt
index 71c8127..3fe62ef 100644
--- a/app/src/main/java/hk/uwu/reareye/rearwidget/RearWidgetConfigCodec.kt
+++ b/app/src/main/java/hk/uwu/reareye/repository/rearwidget/RearWidgetConfigCodec.kt
@@ -1,4 +1,4 @@
-package hk.uwu.reareye.rearwidget
+package hk.uwu.reareye.repository.rearwidget
import org.json.JSONArray
import org.json.JSONObject
@@ -19,6 +19,7 @@ data class RearCardConfig(
val packageName: String,
val business: String,
val enabled: Boolean = true,
+ val sticky: Boolean = true,
val priority: Int = 500,
)
@@ -85,6 +86,7 @@ object RearWidgetConfigCodec {
packageName = packageName,
business = business,
enabled = obj.optBoolean("enabled", true),
+ sticky = obj.optBoolean("sticky", true),
priority = obj.optInt("priority", 500),
)
}
@@ -116,6 +118,7 @@ object RearWidgetConfigCodec {
.put("packageName", item.packageName)
.put("business", item.business)
.put("enabled", item.enabled)
+ .put("sticky", item.sticky)
.put("priority", item.priority)
)
}
diff --git a/app/src/main/java/hk/uwu/reareye/rearwidget/RearWidgetManagerRepository.kt b/app/src/main/java/hk/uwu/reareye/repository/rearwidget/RearWidgetManagerRepository.kt
similarity index 68%
rename from app/src/main/java/hk/uwu/reareye/rearwidget/RearWidgetManagerRepository.kt
rename to app/src/main/java/hk/uwu/reareye/repository/rearwidget/RearWidgetManagerRepository.kt
index 9553b9d..9960650 100644
--- a/app/src/main/java/hk/uwu/reareye/rearwidget/RearWidgetManagerRepository.kt
+++ b/app/src/main/java/hk/uwu/reareye/repository/rearwidget/RearWidgetManagerRepository.kt
@@ -1,22 +1,26 @@
-package hk.uwu.reareye.rearwidget
+package hk.uwu.reareye.repository.rearwidget
import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.provider.OpenableColumns
import android.util.Base64
-import com.highcapable.yukihookapi.hook.factory.dataChannel
-import hk.uwu.reareye.actions.RearWidgetApi
+import hk.uwu.reareye.repository.rearwidget.RearBusinessExtraConfigRepository.getShowTimeTipForBusiness
import hk.uwu.reareye.ui.config.ConfigKeys
import hk.uwu.reareye.ui.config.PrefsManager
+import hk.uwu.reareye.ui.config.PrefsManager.Companion.getPrefsManager
+import hk.uwu.reareye.widgetapi.RearWidgetApiClient
+import hk.uwu.reareye.widgetapi.RearWidgetNoticeOptions
import java.io.File
import java.security.MessageDigest
object RearWidgetManagerRepository {
- private const val TARGET_HOOK_PACKAGE = "com.xiaomi.subscreencenter"
private const val BUSINESS_TEMPLATE_DIR = "rear_widget_business"
+ @Volatile
+ private var remoteClient: RearWidgetApiClient? = null
+
fun loadBusinesses(prefsManager: PrefsManager): List {
val raw = prefsManager.getString(
ConfigKeys.REAR_WIDGET_BUSINESS_DATA,
@@ -63,6 +67,31 @@ object RearWidgetManagerRepository {
applyCardsViaApi(context, oldCards, cards, businesses)
}
+ fun setCardEnabled(
+ context: Context,
+ prefsManager: PrefsManager,
+ cardId: String,
+ enabled: Boolean,
+ ) {
+ val oldCards = loadCards(prefsManager)
+ val targetIndex = oldCards.indexOfFirst { it.id == cardId }
+ if (targetIndex < 0) return
+
+ val oldCard = oldCards[targetIndex]
+ if (oldCard.enabled == enabled) return
+
+ val newCards = oldCards.toMutableList().apply {
+ this[targetIndex] = oldCard.copy(enabled = enabled)
+ }
+
+ prefsManager.putString(
+ ConfigKeys.REAR_WIDGET_CARD_DATA,
+ RearWidgetConfigCodec.encodeCards(newCards),
+ )
+
+ applyCardsViaApi(context, oldCards, newCards, loadBusinesses(prefsManager))
+ }
+
fun refreshRuntimeFromPrefs(context: Context, prefsManager: PrefsManager) {
val businesses = loadBusinesses(prefsManager)
val preparedBusinesses = prepareBusinessesInManagedDir(context, businesses)
@@ -115,19 +144,11 @@ object RearWidgetManagerRepository {
val staleBusinesses = oldByBusiness.keys - newByBusiness.keys
staleBusinesses.forEach { business ->
- sendChannel(
- context,
- RearWidgetApi.Channel.KEY_UNREGISTER_BUSINESS_FILE,
- RearWidgetApi.buildUnregisterBusinessFilePayload(business),
- )
+ unregisterBusinessFile(context, business)
}
newBusinesses.forEach { item ->
- sendChannel(
- context,
- RearWidgetApi.Channel.KEY_REGISTER_BUSINESS_FILE,
- RearWidgetApi.buildRegisterBusinessFilePayload(item.business, item.filePath),
- )
+ registerBusinessFile(context, item.business, item.filePath)
}
}
@@ -137,6 +158,7 @@ object RearWidgetManagerRepository {
newCards: List,
businesses: List,
) {
+ val prefsManager = context.getPrefsManager()
val businessPathByName = businesses.associate { it.business to it.filePath }
val oldPairs = oldCards.mapTo(LinkedHashSet()) { it.packageName to it.business }
@@ -148,11 +170,7 @@ object RearWidgetManagerRepository {
// 先清空受影响业务显示,避免残留旧卡片。
allPairs.forEach { (pkg, biz) ->
- sendChannel(
- context,
- RearWidgetApi.Channel.KEY_DISABLE_BUSINESS_DISPLAY,
- RearWidgetApi.buildDisableBusinessDisplayPayload(business = biz, packageName = pkg),
- )
+ disableBusinessDisplay(context, pkg, biz)
}
val enabledCards = newCards.filter { it.enabled }
@@ -161,74 +179,164 @@ object RearWidgetManagerRepository {
enabledPairs.forEach { (pkg, biz) ->
val filePath = businessPathByName[biz]
if (!filePath.isNullOrBlank()) {
- sendChannel(
- context,
- RearWidgetApi.Channel.KEY_REGISTER_BUSINESS,
- RearWidgetApi.buildRegisterBusinessPayload(
- business = biz,
- filePath = filePath,
- packageName = pkg,
- defaultIndex = 0,
- defaultPriority = 500,
- ),
+ registerBusiness(
+ context = context,
+ packageName = pkg,
+ business = biz,
+ filePath = filePath,
)
} else {
- sendChannel(
- context,
- RearWidgetApi.Channel.KEY_REGISTER_BUSINESS,
- RearWidgetApi.buildRegisterBusinessPayloadWithoutFile(
- business = biz,
- packageName = pkg,
- defaultIndex = 0,
- defaultPriority = 500,
- ),
+ registerBusiness(
+ context = context,
+ packageName = pkg,
+ business = biz,
+ filePath = null,
)
}
}
(allPairs - enabledPairs).forEach { (pkg, biz) ->
- sendChannel(
- context,
- RearWidgetApi.Channel.KEY_UNREGISTER_BUSINESS,
- RearWidgetApi.buildUnregisterBusinessPayload(
- business = biz,
- packageName = pkg,
- ),
- )
+ unregisterBusiness(context, pkg, biz)
}
- enabledCards.forEachIndexed { index, card ->
- val payload = Bundle().apply {
- putString("title", card.title.ifBlank { card.business })
- putString("business", card.business)
- putString("__rear_card_id__", card.id)
+ enabledCards
+ .filter { it.sticky }
+ .forEachIndexed { index, card ->
+ postCard(
+ context = context,
+ prefsManager = prefsManager,
+ card = card,
+ index = index,
+ )
+ }
+ }
+
+ private fun registerBusiness(
+ context: Context,
+ packageName: String,
+ business: String,
+ filePath: String?,
+ ) {
+ runCatching {
+ withApiClient(context) { client ->
+ if (!filePath.isNullOrBlank()) {
+ client.registerBusiness(
+ packageName,
+ business,
+ filePath,
+ 0,
+ 500,
+ )
+ } else {
+ client.registerBusinessWithoutFile(
+ packageName,
+ business,
+ 0,
+ 500,
+ )
+ }
}
- val options = RearWidgetApi.NoticeOptions(
- sticky = true,
- disablePopup = true,
- showTimeTip = true,
- index = index,
- priority = card.priority,
- )
- sendChannel(
- context,
- RearWidgetApi.Channel.KEY_POST_NOTICE,
- RearWidgetApi.buildPostNoticePayload(
- business = card.business,
- packageName = card.packageName,
- payload = payload,
- options = options,
- ),
- )
}
}
- private fun sendChannel(context: Context, key: String, payload: String) {
+ private fun disableBusinessDisplay(
+ context: Context,
+ packageName: String,
+ business: String,
+ ) {
runCatching {
- context.dataChannel(TARGET_HOOK_PACKAGE).put(key, payload)
+ withApiClient(context) { client ->
+ client.disableBusinessDisplay(packageName, business)
+ }
}
}
+ private fun unregisterBusiness(
+ context: Context,
+ packageName: String,
+ business: String,
+ ) {
+ runCatching {
+ withApiClient(context) { client ->
+ client.unregisterBusiness(packageName, business)
+ }
+ }
+ }
+
+ private fun postCard(
+ context: Context,
+ prefsManager: PrefsManager,
+ card: RearCardConfig,
+ index: Int,
+ ) {
+ if (!card.sticky) return
+
+ val payload = Bundle().apply {
+ putString("title", card.title.ifBlank { card.business })
+ putString("business", card.business)
+ putString("__rear_card_id__", card.id)
+ }
+ val options = RearWidgetNoticeOptions(
+ sticky = card.sticky,
+ disablePopup = true,
+ showTimeTip = prefsManager.getShowTimeTipForBusiness(card.business),
+ index = index,
+ priority = card.priority,
+ )
+ postNotice(
+ context = context,
+ packageName = card.packageName,
+ business = card.business,
+ payload = payload,
+ options = options,
+ )
+ }
+
+ private fun registerBusinessFile(context: Context, business: String, filePath: String) {
+ runCatching {
+ withApiClient(context) { client ->
+ client.registerBusinessFile(business, filePath)
+ }
+ }
+ }
+
+ private fun unregisterBusinessFile(context: Context, business: String) {
+ runCatching {
+ withApiClient(context) { client ->
+ client.unregisterBusinessFile(business)
+ }
+ }
+ }
+
+ private fun postNotice(
+ context: Context,
+ packageName: String,
+ business: String,
+ payload: Bundle,
+ options: RearWidgetNoticeOptions,
+ ) {
+ runCatching {
+ withApiClient(context) { client ->
+ client.postNotice(packageName, business, payload, options)
+ }
+ }
+ }
+
+ private fun withApiClient(
+ context: Context,
+ block: (RearWidgetApiClient) -> T,
+ ): T {
+ val appContext = context.applicationContext
+ val client = remoteClient ?: RearWidgetApiClient().also { remoteClient = it }
+ runCatching {
+ if (client.isConnected() || client.bind(appContext)) return block(client)
+ throw IllegalStateException("RearWidget API service is not connected")
+ }.onFailure {
+ client.unbind()
+ if (remoteClient === client) remoteClient = null
+ }.getOrThrow()
+ }
+
private fun cleanupStaleManagedFiles(
context: Context,
oldBusinesses: List,
diff --git a/app/src/main/java/hk/uwu/reareye/ui/MainActivity.kt b/app/src/main/java/hk/uwu/reareye/ui/MainActivity.kt
index 689cf32..fdf4aeb 100644
--- a/app/src/main/java/hk/uwu/reareye/ui/MainActivity.kt
+++ b/app/src/main/java/hk/uwu/reareye/ui/MainActivity.kt
@@ -5,44 +5,48 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.AnimatedContent
-import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
+import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
-import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
-import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Cottage
-import androidx.compose.material.icons.filled.Info
-import androidx.compose.material.icons.filled.Settings
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
-import hk.uwu.reareye.R
+import com.kyant.backdrop.backdrops.layerBackdrop
+import com.kyant.backdrop.backdrops.rememberLayerBackdrop
+import hk.uwu.reareye.ui.components.navigation.RearNavigationBar
import hk.uwu.reareye.ui.config.ConfigKeys
+import hk.uwu.reareye.ui.config.ModuleNavigationBarMode
import hk.uwu.reareye.ui.config.ModuleSettingsController
-import hk.uwu.reareye.ui.config.PrefsManager
+import hk.uwu.reareye.ui.config.PrefsManager.Companion.getPrefsManager
import hk.uwu.reareye.ui.screen.AboutScreen
import hk.uwu.reareye.ui.screen.ConfigScreen
import hk.uwu.reareye.ui.screen.HomeScreen
-import top.yukonga.miuix.kmp.basic.NavigationBar
-import top.yukonga.miuix.kmp.basic.NavigationBarDisplayMode
-import top.yukonga.miuix.kmp.basic.NavigationBarItem
+import hk.uwu.reareye.ui.theme.AppTheme
+import hk.uwu.reareye.ui.theme.AppThemeMode
import top.yukonga.miuix.kmp.basic.Scaffold
+import top.yukonga.miuix.kmp.theme.MiuixTheme
+
+private val MainScreenOrder = listOf("home", "config", "about")
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -52,17 +56,15 @@ class MainActivity : ComponentActivity() {
val permissionInfo = applicationContext.packageManager
.getPermissionInfo("com.android.permission.GET_INSTALLED_APPS", 0)
if (permissionInfo != null && permissionInfo.packageName == "com.lbe.security.miui") {
- //MIUI 系统支持动态申请该权限
if (ContextCompat.checkSelfPermission(
applicationContext,
"com.android.permission.GET_INSTALLED_APPS"
) != PackageManager.PERMISSION_GRANTED
) {
- //没有权限,需要申请
ActivityCompat.requestPermissions(
this@MainActivity,
arrayOf("com.android.permission.GET_INSTALLED_APPS"),
- 999
+ 999,
)
}
}
@@ -70,129 +72,177 @@ class MainActivity : ComponentActivity() {
e.printStackTrace()
}
- val prefsManager = PrefsManager(applicationContext)
+ val prefsManager = applicationContext.getPrefsManager()
ModuleSettingsController.syncLauncherEntryVisibility(
context = applicationContext,
hidden = prefsManager.getBoolean(ConfigKeys.MODULE_HIDE_LAUNCHER_ENTRY, false),
)
setContent {
+ var themeModeValue by remember {
+ mutableIntStateOf(
+ prefsManager.getInt(
+ ConfigKeys.MODULE_THEME_MODE,
+ AppThemeMode.default.value,
+ )
+ )
+ }
+ var navigationBarModeValue by remember {
+ mutableIntStateOf(
+ prefsManager.getInt(
+ ConfigKeys.MODULE_NAVIGATION_BAR_MODE,
+ ModuleNavigationBarMode.default.value,
+ )
+ )
+ }
var currentScreen by remember { mutableStateOf("home") }
var navBarVisible by remember { mutableStateOf(false) }
var configInAppListMode by remember { mutableStateOf(false) }
- val screenOrder = remember { listOf("home", "config", "about") }
LaunchedEffect(Unit) {
navBarVisible = true
}
- hk.uwu.reareye.ui.theme.AppTheme {
- Scaffold(
- bottomBar = {
- val showNavigation =
- navBarVisible && !(currentScreen == "config" && configInAppListMode)
+ AppTheme(themeMode = AppThemeMode.fromValue(themeModeValue)) {
+ val navigationBarMode = ModuleNavigationBarMode.fromValue(navigationBarModeValue)
+ val enableFloatingGlass =
+ navigationBarMode == ModuleNavigationBarMode.FLOATING_GLASS
+ val showNavigation =
+ navBarVisible && !(currentScreen == "config" && configInAppListMode)
+ val density = LocalDensity.current
+ val surfaceColor = MiuixTheme.colorScheme.surface
+ val backdrop = rememberLayerBackdrop {
+ drawRect(surfaceColor)
+ drawContent()
+ }
+ var stableBottomInset by remember { mutableStateOf(0.dp) }
- AnimatedVisibility(
- visible = showNavigation,
- enter = fadeIn(
- animationSpec = tween(
- durationMillis = 260,
- easing = LinearOutSlowInEasing,
- )
- ) + slideInVertically(
- animationSpec = tween(
- durationMillis = 380,
- easing = FastOutSlowInEasing,
- )
- ) { it / 3 },
- exit = fadeOut(
- animationSpec = tween(
- durationMillis = 180,
- easing = FastOutLinearInEasing,
- )
- ) + slideOutVertically(
- animationSpec = tween(
- durationMillis = 240,
- easing = FastOutLinearInEasing,
+ Scaffold { _ ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .graphicsLayer { clip = true }
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .then(
+ if (enableFloatingGlass) {
+ Modifier.layerBackdrop(backdrop)
+ } else {
+ Modifier
+ }
)
- ) { it / 3 }
) {
- NavigationBar(
- mode = NavigationBarDisplayMode.IconAndText
- ) {
- NavigationBarItem(
- selected = currentScreen == "home",
- onClick = { currentScreen = "home" },
- icon = Icons.Filled.Cottage,
- label = stringResource(R.string.home_navigation)
- )
- NavigationBarItem(
- selected = currentScreen == "config",
- onClick = { currentScreen = "config" },
- icon = Icons.Filled.Settings,
- label = stringResource(R.string.configuration_navigation)
- )
- NavigationBarItem(
- selected = currentScreen == "about",
- onClick = { currentScreen = "about" },
- icon = Icons.Filled.Info,
- label = stringResource(R.string.about_navigation)
- )
- }
- }
- }
- ) { paddingValues ->
- Box(modifier = Modifier.padding(bottom = paddingValues.calculateBottomPadding())) {
- AnimatedContent(
- targetState = currentScreen,
- transitionSpec = {
- val initialIndex =
- screenOrder.indexOf(initialState).coerceAtLeast(0)
- val targetIndex = screenOrder.indexOf(targetState).coerceAtLeast(0)
- val forward = targetIndex >= initialIndex
+ AnimatedContent(
+ targetState = currentScreen,
+ contentKey = { it },
+ transitionSpec = {
+ val initialIndex =
+ MainScreenOrder.indexOf(initialState).coerceAtLeast(0)
+ val targetIndex =
+ MainScreenOrder.indexOf(targetState).coerceAtLeast(0)
+ val forward = targetIndex >= initialIndex
- (fadeIn(
- animationSpec = tween(
- durationMillis = 260,
- delayMillis = 40,
- easing = LinearOutSlowInEasing,
- )
- ) + slideInHorizontally(
- animationSpec = tween(
- durationMillis = 320,
- easing = FastOutSlowInEasing,
- )
- ) { fullWidth ->
- if (forward) fullWidth / 5 else -fullWidth / 5
- }) togetherWith (
- fadeOut(
- animationSpec = tween(
- durationMillis = 180,
- easing = FastOutLinearInEasing,
- )
- ) + slideOutHorizontally(
- animationSpec = tween(
- durationMillis = 240,
- easing = FastOutLinearInEasing,
- )
- ) { fullWidth ->
- if (forward) -fullWidth / 6 else fullWidth / 6
- }
+ fadeIn(
+ animationSpec = tween(
+ durationMillis = 210,
+ delayMillis = 50,
+ easing = LinearOutSlowInEasing,
+ )
+ ) + slideInHorizontally(
+ animationSpec = tween(
+ durationMillis = 280,
+ easing = FastOutSlowInEasing,
)
- },
- label = "ScreenTransition"
- ) { screen ->
- when (screen) {
- "home" -> HomeScreen()
+ ) { fullWidth ->
+ if (forward) fullWidth / 9 else -fullWidth / 9
+ } togetherWith (
+ fadeOut(
+ animationSpec = tween(
+ durationMillis = 110,
+ easing = FastOutLinearInEasing,
+ )
+ ) + slideOutHorizontally(
+ animationSpec = tween(
+ durationMillis = 190,
+ easing = FastOutLinearInEasing,
+ )
+ ) { fullWidth ->
+ if (forward) -fullWidth / 12 else fullWidth / 12
+ }
+ )
+ },
+ label = "ScreenTransition"
+ ) { screen ->
+ when (screen) {
+ "home" -> HomeScreen(bottomInnerPadding = stableBottomInset)
- "config" -> ConfigScreen(
- onAppListModeChange = {
- configInAppListMode = it
- }
- )
+ "config" -> ConfigScreen(
+ bottomInnerPadding = stableBottomInset,
+ onAppListModeChange = {
+ configInAppListMode = it
+ },
+ onThemeModeChange = { themeModeValue = it },
+ onNavigationBarModeChange = {
+ navigationBarModeValue = it
+ },
+ )
+
+ "about" -> AboutScreen(bottomInnerPadding = stableBottomInset)
+ }
+ }
+ }
- "about" -> AboutScreen()
+ val navAlphaProgress by animateFloatAsState(
+ targetValue = if (showNavigation) 1f else 0f,
+ animationSpec = tween(
+ durationMillis = if (showNavigation) 260 else 180,
+ easing = if (showNavigation) {
+ LinearOutSlowInEasing
+ } else {
+ FastOutLinearInEasing
+ },
+ ),
+ label = "NavigationAlphaProgress",
+ )
+ val navSlideProgress by animateFloatAsState(
+ targetValue = if (showNavigation) 1f else 0f,
+ animationSpec = tween(
+ durationMillis = if (showNavigation) 380 else 240,
+ easing = if (showNavigation) {
+ FastOutSlowInEasing
+ } else {
+ FastOutLinearInEasing
+ },
+ ),
+ label = "NavigationSlideProgress",
+ )
+ if (showNavigation || navAlphaProgress > 0.001f || navSlideProgress > 0.001f) {
+ val hiddenOffsetPx = with(density) {
+ ((if (stableBottomInset > 0.dp) stableBottomInset else 84.dp) / 3).toPx()
}
+ RearNavigationBar(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .graphicsLayer {
+ alpha = navAlphaProgress
+ translationY = (1f - navSlideProgress) * hiddenOffsetPx
+ }
+ .onGloballyPositioned { coordinates ->
+ val totalHeight = with(density) {
+ coordinates.size.height.toDp()
+ }
+ if (totalHeight != stableBottomInset) {
+ stableBottomInset = totalHeight
+ }
+ },
+ currentScreen = currentScreen,
+ navigationBarMode = navigationBarMode,
+ backdrop = backdrop,
+ shadowVisibilityProgress = navSlideProgress,
+ onScreenSelected = { currentScreen = it },
+ )
}
}
}
diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/PackageIconBitmap.kt b/app/src/main/java/hk/uwu/reareye/ui/components/PackageIconBitmap.kt
new file mode 100644
index 0000000..ed7d337
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/ui/components/PackageIconBitmap.kt
@@ -0,0 +1,101 @@
+package hk.uwu.reareye.ui.components
+
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageManager
+import android.graphics.Canvas
+import android.graphics.drawable.BitmapDrawable
+import android.util.LruCache
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.core.graphics.createBitmap
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+private object PackageIconBitmapCache {
+ private val cache = LruCache(96)
+
+ fun get(packageName: String): ImageBitmap? = cache.get(packageName)
+
+ fun put(packageName: String, bitmap: ImageBitmap) {
+ cache.put(packageName, bitmap)
+ }
+}
+
+@Composable
+fun rememberApplicationIconBitmap(
+ packageManager: PackageManager,
+ applicationInfo: ApplicationInfo,
+): ImageBitmap? {
+ val packageName = applicationInfo.packageName
+ val bitmap by produceState(
+ initialValue = PackageIconBitmapCache.get(packageName),
+ key1 = packageName,
+ key2 = packageManager,
+ ) {
+ if (value != null) return@produceState
+
+ val loadedBitmap = withContext(Dispatchers.IO) {
+ loadIconBitmap(packageManager) {
+ applicationInfo.loadIcon(packageManager)
+ }
+ }
+
+ if (loadedBitmap != null) {
+ PackageIconBitmapCache.put(packageName, loadedBitmap)
+ value = loadedBitmap
+ }
+ }
+
+ return bitmap
+}
+
+@Composable
+fun rememberPackageIconBitmap(
+ packageManager: PackageManager,
+ packageName: String,
+): ImageBitmap? {
+ val bitmap by produceState(
+ initialValue = PackageIconBitmapCache.get(packageName),
+ key1 = packageName,
+ key2 = packageManager,
+ ) {
+ if (value != null) return@produceState
+
+ val loadedBitmap = withContext(Dispatchers.IO) {
+ loadIconBitmap(packageManager) {
+ packageManager.getApplicationIcon(packageName)
+ }
+ }
+
+ if (loadedBitmap != null) {
+ PackageIconBitmapCache.put(packageName, loadedBitmap)
+ value = loadedBitmap
+ }
+ }
+
+ return bitmap
+}
+
+private fun loadIconBitmap(
+ packageManager: PackageManager,
+ drawableProvider: () -> android.graphics.drawable.Drawable,
+): ImageBitmap? {
+ return runCatching {
+ val drawable = drawableProvider()
+ val bitmap = if (drawable is BitmapDrawable && drawable.bitmap != null) {
+ drawable.bitmap
+ } else {
+ val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: 1
+ val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: 1
+ val createdBitmap = createBitmap(width, height)
+ val canvas = Canvas(createdBitmap)
+ drawable.setBounds(0, 0, canvas.width, canvas.height)
+ drawable.draw(canvas)
+ createdBitmap
+ }
+ bitmap.asImageBitmap()
+ }.getOrNull()
+}
diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/RearWallpaperPreviewBitmap.kt b/app/src/main/java/hk/uwu/reareye/ui/components/RearWallpaperPreviewBitmap.kt
new file mode 100644
index 0000000..4053eb8
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/ui/components/RearWallpaperPreviewBitmap.kt
@@ -0,0 +1,51 @@
+package hk.uwu.reareye.ui.components
+
+import android.graphics.BitmapFactory
+import android.util.LruCache
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+
+private object RearWallpaperPreviewBitmapCache {
+ private val cache = LruCache(64)
+
+ fun get(path: String): ImageBitmap? = cache.get(path)
+
+ fun put(path: String, bitmap: ImageBitmap) {
+ cache.put(path, bitmap)
+ }
+}
+
+@Composable
+fun rememberRearWallpaperPreviewBitmap(cachePath: String?): ImageBitmap? {
+ val bitmap by produceState(
+ initialValue = cachePath?.let(RearWallpaperPreviewBitmapCache::get),
+ key1 = cachePath,
+ ) {
+ val path = cachePath?.takeIf { it.isNotBlank() } ?: return@produceState
+ if (value != null) return@produceState
+
+ val loadedBitmap = withContext(Dispatchers.IO) {
+ loadPreviewBitmap(path)
+ }
+
+ if (loadedBitmap != null) {
+ RearWallpaperPreviewBitmapCache.put(path, loadedBitmap)
+ value = loadedBitmap
+ }
+ }
+ return bitmap
+}
+
+private fun loadPreviewBitmap(path: String): ImageBitmap? {
+ val file = File(path)
+ if (!file.isFile || file.length() <= 0L) return null
+ return runCatching {
+ BitmapFactory.decodeFile(path)?.asImageBitmap()
+ }.getOrNull()
+}
diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/card/Card.kt b/app/src/main/java/hk/uwu/reareye/ui/components/card/Card.kt
index 2029505..7e701ae 100644
--- a/app/src/main/java/hk/uwu/reareye/ui/components/card/Card.kt
+++ b/app/src/main/java/hk/uwu/reareye/ui/components/card/Card.kt
@@ -1,5 +1,7 @@
package hk.uwu.reareye.ui.components.card
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.runtime.Composable
@@ -26,7 +28,12 @@ fun SuperCard(
enabled: Boolean = true,
) {
BasicComponent(
- modifier = modifier,
+ modifier = modifier.animateContentSize(
+ animationSpec = spring(
+ dampingRatio = 0.92f,
+ stiffness = 650f,
+ )
+ ),
insideMargin = insideMargin,
title = title,
titleColor = titleColor,
@@ -39,4 +46,4 @@ fun SuperCard(
holdDownState = holdDownState,
enabled = enabled,
)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/card/ManagerModuleStyleCard.kt b/app/src/main/java/hk/uwu/reareye/ui/components/card/ManagerModuleStyleCard.kt
index 70e1256..9efea7b 100644
--- a/app/src/main/java/hk/uwu/reareye/ui/components/card/ManagerModuleStyleCard.kt
+++ b/app/src/main/java/hk/uwu/reareye/ui/components/card/ManagerModuleStyleCard.kt
@@ -1,6 +1,7 @@
package hk.uwu.reareye.ui.components.card
-import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -11,6 +12,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -34,6 +36,12 @@ fun ModuleStyleManagerCard(
) {
Card(
modifier = Modifier
+ .animateContentSize(
+ animationSpec = spring(
+ dampingRatio = 0.9f,
+ stiffness = 620f,
+ )
+ )
.padding(bottom = 12.dp),
insideMargin = PaddingValues(16.dp),
onClick = onCardClick ?: {},
@@ -46,7 +54,7 @@ fun ModuleStyleManagerCard(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp),
- verticalArrangement = Arrangement.spacedBy(2.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = title,
@@ -86,8 +94,9 @@ fun ModuleStyleIconAction(
onClick: () -> Unit,
) {
val secondaryContainer = MiuixTheme.colorScheme.secondaryContainer.copy(alpha = 0.8f)
+ val actionIconAlpha = if (secondaryContainer.luminance() < 0.5f) 0.7f else 0.9f
val actionIconTint =
- MiuixTheme.colorScheme.onSurface.copy(alpha = if (isSystemInDarkTheme()) 0.7f else 0.9f)
+ MiuixTheme.colorScheme.onSurface.copy(alpha = actionIconAlpha)
IconButton(
minHeight = 35.dp,
minWidth = 35.dp,
@@ -109,8 +118,9 @@ fun ModuleStyleDeleteAction(
onClick: () -> Unit,
) {
val secondaryContainer = MiuixTheme.colorScheme.secondaryContainer.copy(alpha = 0.8f)
+ val actionIconAlpha = if (secondaryContainer.luminance() < 0.5f) 0.7f else 0.9f
val actionIconTint =
- MiuixTheme.colorScheme.onSurface.copy(alpha = if (isSystemInDarkTheme()) 0.7f else 0.9f)
+ MiuixTheme.colorScheme.onSurface.copy(alpha = actionIconAlpha)
IconButton(
minHeight = 35.dp,
minWidth = 35.dp,
diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/config/AppListSelectorScreen.kt b/app/src/main/java/hk/uwu/reareye/ui/components/config/AppListSelectorScreen.kt
index 3adeef3..f269640 100644
--- a/app/src/main/java/hk/uwu/reareye/ui/components/config/AppListSelectorScreen.kt
+++ b/app/src/main/java/hk/uwu/reareye/ui/components/config/AppListSelectorScreen.kt
@@ -2,33 +2,19 @@ package hk.uwu.reareye.ui.components.config
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
-import android.graphics.Canvas
-import android.graphics.drawable.BitmapDrawable
-import android.text.Editable
-import android.text.InputType
-import android.text.TextWatcher
-import android.util.TypedValue
-import android.view.View
-import android.view.inputmethod.EditorInfo
-import android.widget.EditText
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState
-import androidx.compose.animation.core.Animatable
-import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
-import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
-import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -41,6 +27,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Apps
@@ -51,40 +40,42 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
+import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.ImageBitmap
-import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.layout.boundsInParent
-import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.viewinterop.AndroidView
-import androidx.compose.ui.zIndex
-import androidx.core.graphics.createBitmap
+import androidx.compose.ui.unit.sp
import hk.uwu.reareye.R
+import hk.uwu.reareye.ui.components.rememberApplicationIconBitmap
import hk.uwu.reareye.ui.config.ConfigItem
import hk.uwu.reareye.ui.config.PrefsManager
+import hk.uwu.reareye.ui.theme.rearAcrylicEffect
+import hk.uwu.reareye.ui.theme.rearAcrylicSource
+import hk.uwu.reareye.ui.theme.rememberAcrylicHazeState
+import hk.uwu.reareye.ui.theme.rememberAcrylicHazeStyle
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -105,16 +96,15 @@ import top.yukonga.miuix.kmp.basic.SpinnerEntry
import top.yukonga.miuix.kmp.basic.SpinnerItemImpl
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TopAppBar
-import top.yukonga.miuix.kmp.extra.SuperListPopup
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Check
import top.yukonga.miuix.kmp.icon.extended.MoreCircle
import top.yukonga.miuix.kmp.icon.extended.Sort
import top.yukonga.miuix.kmp.icon.extended.Tune
+import top.yukonga.miuix.kmp.overlay.OverlayListPopup
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.utils.overScrollVertical
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
-import kotlin.math.abs
private enum class AppSortMode {
LABEL,
@@ -126,53 +116,311 @@ data class AppItem(
val label: String,
val packageName: String,
val isSystem: Boolean,
+ val labelKey: String,
+ val packageKey: String,
)
+private data class AppCatalog(
+ val byLabel: List,
+ val byPackage: List,
+)
+
+private val appRowPlacementSpec = spring(
+ dampingRatio = 0.92f,
+ stiffness = 520f,
+)
+
+private fun buildAppCatalog(packageManager: PackageManager): AppCatalog {
+ val allApps = packageManager
+ .getInstalledApplications(PackageManager.GET_META_DATA)
+ .map { app ->
+ val label = app.loadLabel(packageManager).toString()
+ AppItem(
+ applicationInfo = app,
+ label = label,
+ packageName = app.packageName,
+ isSystem =
+ (app.flags and ApplicationInfo.FLAG_SYSTEM) != 0 ||
+ (app.flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) != 0,
+ labelKey = label.lowercase(),
+ packageKey = app.packageName.lowercase(),
+ )
+ }
+
+ return AppCatalog(
+ byLabel = allApps.sortedBy(AppItem::labelKey),
+ byPackage = allApps.sortedBy(AppItem::packageKey),
+ )
+}
+
+private fun AppCatalog.buildVisibleApps(
+ sortMode: AppSortMode,
+ reverseOrder: Boolean,
+ showSystemApps: Boolean,
+ query: String,
+ selectedOrder: List,
+): List {
+ val normalizedQuery = query.trim().lowercase()
+ val source = when (sortMode) {
+ AppSortMode.LABEL -> byLabel
+ AppSortMode.PACKAGE -> byPackage
+ }
+ val visibleApps = source.filter { app ->
+ val matchesVisibility = showSystemApps || !app.isSystem
+ val matchesQuery = normalizedQuery.isEmpty() ||
+ app.labelKey.contains(normalizedQuery) ||
+ app.packageKey.contains(normalizedQuery)
+ matchesVisibility && matchesQuery
+ }
+ val orderedApps = if (reverseOrder) visibleApps.asReversed() else visibleApps
+
+ if (selectedOrder.isEmpty()) {
+ return orderedApps
+ }
+
+ val selectedLookup = selectedOrder.toHashSet()
+ val selectedItems = HashMap(selectedOrder.size)
+ val unselectedItems = ArrayList(orderedApps.size)
+
+ orderedApps.forEach { app ->
+ if (app.packageName in selectedLookup) {
+ selectedItems[app.packageName] = app
+ } else {
+ unselectedItems += app
+ }
+ }
+
+ return buildList(orderedApps.size) {
+ selectedOrder.forEach { packageName ->
+ selectedItems[packageName]?.let(::add)
+ }
+ addAll(unselectedItems)
+ }
+}
+
+private fun MutableList.toggleSelection(packageName: String) {
+ if (!remove(packageName)) {
+ add(packageName)
+ }
+}
+
@Composable
private fun AppIcon(
appInfo: ApplicationInfo,
pm: PackageManager,
modifier: Modifier = Modifier,
) {
- var imageBitmap by remember(appInfo.packageName) { mutableStateOf(null) }
-
- LaunchedEffect(appInfo.packageName) {
- withContext(Dispatchers.IO) {
- try {
- val drawable = appInfo.loadIcon(pm)
- val bitmap = if (drawable is BitmapDrawable) {
- drawable.bitmap
- } else {
- val bmp = createBitmap(
- drawable.intrinsicWidth.takeIf { it > 0 } ?: 1,
- drawable.intrinsicHeight.takeIf { it > 0 } ?: 1,
- )
- val canvas = Canvas(bmp)
- drawable.setBounds(0, 0, canvas.width, canvas.height)
- drawable.draw(canvas)
- bmp
- }
- imageBitmap = bitmap.asImageBitmap()
- } catch (_: Exception) {
- // Ignore icon loading errors.
- }
- }
- }
+ val imageBitmap = rememberApplicationIconBitmap(
+ packageManager = pm,
+ applicationInfo = appInfo,
+ )
if (imageBitmap != null) {
- Image(bitmap = imageBitmap!!, contentDescription = null, modifier = modifier.size(44.dp))
+ Image(bitmap = imageBitmap, contentDescription = null, modifier = modifier.size(44.dp))
} else {
Spacer(modifier = modifier.size(44.dp))
}
}
+@Composable
+private fun LoadingAppsCard() {
+ Card(
+ modifier = Modifier
+ .padding(top = 12.dp)
+ .fillMaxWidth(),
+ insideMargin = PaddingValues(vertical = 26.dp),
+ ) {
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+}
+
+@Composable
+private fun EmptyAppsCard() {
+ Card(
+ modifier = Modifier
+ .padding(top = 12.dp)
+ .fillMaxWidth(),
+ insideMargin = PaddingValues(vertical = 22.dp),
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Search,
+ contentDescription = null,
+ tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.app_list_empty_result),
+ style = MiuixTheme.textStyles.body2,
+ color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
+ )
+ }
+ }
+}
+
+@Composable
+private fun AppSelectorRow(
+ appItem: AppItem,
+ packageManager: PackageManager,
+ selected: Boolean,
+ currentIndex: Int,
+ averageItemHeightPx: Float,
+ onToggle: () -> Unit,
+) {
+ val density = LocalDensity.current
+ var previousIndex by remember(appItem.packageName) { mutableStateOf(currentIndex) }
+ var placementOffset by remember(appItem.packageName) { mutableFloatStateOf(0f) }
+ val animatedPlacementOffset by animateFloatAsState(
+ targetValue = placementOffset,
+ animationSpec = appRowPlacementSpec,
+ label = "AppRowPlacementOffset",
+ )
+ val selectionOverlayAlpha by animateFloatAsState(
+ targetValue = if (selected) 0.08f else 0f,
+ animationSpec = spring(
+ dampingRatio = 0.9f,
+ stiffness = 520f,
+ ),
+ label = "AppRowSelectionOverlayAlpha",
+ )
+ val selectionOverlayScale by animateFloatAsState(
+ targetValue = if (selected) 1f else 0.965f,
+ animationSpec = spring(
+ dampingRatio = 0.92f,
+ stiffness = 540f,
+ ),
+ label = "AppRowSelectionOverlayScale",
+ )
+ val contentOffsetPx by animateFloatAsState(
+ targetValue = with(density) { if (selected) 1.5.dp.toPx() else 0.dp.toPx() },
+ animationSpec = spring(
+ dampingRatio = 0.92f,
+ stiffness = 560f,
+ ),
+ label = "AppRowContentOffset",
+ )
+ val contentScale by animateFloatAsState(
+ targetValue = if (selected) 0.998f else 1f,
+ animationSpec = spring(
+ dampingRatio = 0.95f,
+ stiffness = 600f,
+ ),
+ label = "AppRowContentScale",
+ )
+
+ LaunchedEffect(currentIndex, averageItemHeightPx) {
+ if (previousIndex == currentIndex) {
+ return@LaunchedEffect
+ }
+
+ placementOffset = (previousIndex - currentIndex) * averageItemHeightPx
+ previousIndex = currentIndex
+ withFrameNanos { }
+ placementOffset = 0f
+ }
+
+ Card(
+ modifier = Modifier
+ .padding(top = 10.dp)
+ .graphicsLayer {
+ translationY = animatedPlacementOffset
+ }
+ .fillMaxWidth(),
+ insideMargin = PaddingValues(horizontal = 14.dp, vertical = 12.dp),
+ onClick = onToggle,
+ showIndication = true,
+ ) {
+ Box(modifier = Modifier.fillMaxWidth()) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .graphicsLayer {
+ alpha = selectionOverlayAlpha
+ scaleX = selectionOverlayScale
+ scaleY = selectionOverlayScale
+ }
+ .background(
+ color = MiuixTheme.colorScheme.primary,
+ shape = RoundedCornerShape(20.dp),
+ )
+ )
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .graphicsLayer {
+ translationX = contentOffsetPx
+ scaleX = contentScale
+ scaleY = contentScale
+ },
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ AppIcon(
+ appInfo = appItem.applicationInfo,
+ pm = packageManager,
+ modifier = Modifier.padding(end = 12.dp),
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = appItem.label,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = appItem.packageName,
+ style = MiuixTheme.textStyles.body2,
+ color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ SelectionIndicator(selected = selected)
+ }
+ }
+ }
+}
+
@Composable
private fun SelectionIndicator(selected: Boolean) {
+ val haloAlpha by animateFloatAsState(
+ targetValue = if (selected) 0.18f else 0f,
+ animationSpec = spring(
+ dampingRatio = 0.88f,
+ stiffness = 460f,
+ ),
+ label = "SelectionHaloAlpha",
+ )
+ val haloScale by animateFloatAsState(
+ targetValue = if (selected) 1.7f else 0.7f,
+ animationSpec = spring(
+ dampingRatio = 0.82f,
+ stiffness = 420f,
+ ),
+ label = "SelectionHaloScale",
+ )
val ringColor by animateColorAsState(
targetValue = if (selected) MiuixTheme.colorScheme.primary else MiuixTheme.colorScheme.outline,
animationSpec = spring(stiffness = 500f),
label = "SelectionRingColor",
)
+ val ringScale by animateFloatAsState(
+ targetValue = if (selected) 1f else 0.94f,
+ animationSpec = spring(
+ dampingRatio = 0.88f,
+ stiffness = 520f,
+ ),
+ label = "SelectionRingScale",
+ )
val fillScale by animateFloatAsState(
targetValue = if (selected) 1f else 0f,
animationSpec = spring(
@@ -189,35 +437,155 @@ private fun SelectionIndicator(selected: Boolean) {
),
label = "SelectionIconAlpha",
)
+ val iconRotation by animateFloatAsState(
+ targetValue = if (selected) 0f else -16f,
+ animationSpec = spring(
+ dampingRatio = 0.84f,
+ stiffness = 560f,
+ ),
+ label = "SelectionIconRotation",
+ )
Box(
- modifier = Modifier
- .size(24.dp)
- .border(width = 2.dp, color = ringColor, shape = CircleShape),
+ modifier = Modifier.size(30.dp),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
- .fillMaxSize()
+ .size(18.dp)
.graphicsLayer {
- scaleX = fillScale
- scaleY = fillScale
+ alpha = haloAlpha
+ scaleX = haloScale
+ scaleY = haloScale
}
.background(MiuixTheme.colorScheme.primary, CircleShape)
)
- Icon(
- imageVector = MiuixIcons.Basic.Check,
- contentDescription = null,
- tint = MiuixTheme.colorScheme.onPrimary,
+ Box(
modifier = Modifier
- .size(16.dp)
+ .size(24.dp)
.graphicsLayer {
- alpha = iconAlpha
- scaleX = 0.92f + (0.08f * fillScale)
- scaleY = 0.92f + (0.08f * fillScale)
+ scaleX = ringScale
+ scaleY = ringScale
}
+ .border(width = 2.dp, color = ringColor, shape = CircleShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .graphicsLayer {
+ scaleX = fillScale
+ scaleY = fillScale
+ }
+ .background(MiuixTheme.colorScheme.primary, CircleShape)
+ )
+
+ Icon(
+ imageVector = MiuixIcons.Basic.Check,
+ contentDescription = null,
+ tint = MiuixTheme.colorScheme.onPrimary,
+ modifier = Modifier
+ .size(16.dp)
+ .graphicsLayer {
+ alpha = iconAlpha
+ rotationZ = iconRotation
+ scaleX = 0.9f + (0.1f * fillScale)
+ scaleY = 0.9f + (0.1f * fillScale)
+ }
+ )
+ }
+ }
+}
+
+@Composable
+private fun AppSelectorHeader(
+ searchQuery: String,
+ onSearchQueryChange: (String) -> Unit,
+ onSearchFocusChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val focusManager = LocalFocusManager.current
+ val keyboardController = LocalSoftwareKeyboardController.current
+ val searchAcrylicShape = RoundedCornerShape(14.dp)
+ val searchAcrylicBase = Color(0xFF9EA6B2).copy(alpha = 0.34f)
+ val searchAcrylicStroke = Color.White.copy(alpha = 0.34f)
+ val searchAcrylicOverlay = Brush.verticalGradient(
+ colors = listOf(
+ Color.White.copy(alpha = 0.18f),
+ Color.White.copy(alpha = 0.04f),
)
+ )
+ val searchHint = stringResource(R.string.search_apps)
+ val searchTextStyle = TextStyle(
+ color = MiuixTheme.colorScheme.onBackground,
+ fontSize = 14.sp,
+ )
+
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ Card(
+ modifier = Modifier
+ .border(1.dp, searchAcrylicStroke, searchAcrylicShape)
+ .fillMaxWidth(),
+ cornerRadius = 14.dp,
+ colors = CardDefaults.defaultColors(
+ color = searchAcrylicBase,
+ contentColor = MiuixTheme.colorScheme.onSurfaceVariantSummary,
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(44.dp)
+ .background(searchAcrylicOverlay)
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Search,
+ contentDescription = null,
+ tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
+ modifier = Modifier
+ .size(18.dp)
+ .padding(end = 6.dp),
+ )
+
+ BasicTextField(
+ value = searchQuery,
+ onValueChange = onSearchQueryChange,
+ modifier = Modifier
+ .weight(1f)
+ .onFocusChanged { onSearchFocusChange(it.isFocused) },
+ singleLine = true,
+ textStyle = searchTextStyle,
+ cursorBrush = SolidColor(MiuixTheme.colorScheme.primary),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardActions = KeyboardActions(
+ onSearch = {
+ focusManager.clearFocus(force = true)
+ keyboardController?.hide()
+ }
+ ),
+ decorationBox = { innerTextField ->
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ if (searchQuery.isEmpty()) {
+ Text(
+ text = searchHint,
+ style = MiuixTheme.textStyles.body2,
+ color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
+ )
+ }
+ innerTextField()
+ }
+ },
+ )
+ }
+ }
}
}
@@ -234,21 +602,18 @@ fun AppListSelectorScreen(
val density = LocalDensity.current
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
+ val popupScope = rememberCoroutineScope()
- var installedApps by remember { mutableStateOf?>(null) }
+ var appCatalog by remember { mutableStateOf(null) }
var loading by remember { mutableStateOf(true) }
- val selectedPackages = remember(configItem.key) {
- mutableStateListOf().apply {
- addAll(prefsManager.getStringSet(configItem.key, configItem.type.defaultStringSet))
- }
- }
val selectedOrder = remember(configItem.key) {
mutableStateListOf().apply {
- addAll(selectedPackages.sorted())
+ addAll(prefsManager.getStringSet(configItem.key, configItem.type.defaultStringSet))
}
}
var showSystemApps by remember(configItem.key) { mutableStateOf(false) }
+ var searchInput by remember(configItem.key) { mutableStateOf("") }
var searchQuery by remember(configItem.key) { mutableStateOf("") }
var searchFocused by remember(configItem.key) { mutableStateOf(false) }
val listState = rememberLazyListState()
@@ -258,12 +623,9 @@ fun AppListSelectorScreen(
val showSortMenu = remember(configItem.key) { mutableStateOf(false) }
val showFilterMenu = remember(configItem.key) { mutableStateOf(false) }
val showMoreMenu = remember(configItem.key) { mutableStateOf(false) }
- var searchBarBounds by remember { mutableStateOf(null) }
val scrollBehavior = MiuixScrollBehavior()
-
- val dynamicTopPadding by remember {
- derivedStateOf { 12.dp * (1f - scrollBehavior.state.collapsedFraction) }
- }
+ val hazeState = rememberAcrylicHazeState()
+ val hazeStyle = rememberAcrylicHazeStyle()
BackHandler(enabled = searchFocused) {
focusManager.clearFocus(force = true)
@@ -272,250 +634,272 @@ fun AppListSelectorScreen(
LaunchedEffect(configItem.key) {
loading = true
- installedApps = null
- withContext(Dispatchers.IO) {
- val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
- val items = apps.map { app ->
- AppItem(
- applicationInfo = app,
- label = app.loadLabel(pm).toString(),
- packageName = app.packageName,
- isSystem =
- (app.flags and ApplicationInfo.FLAG_SYSTEM) != 0 ||
- (app.flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) != 0,
- )
- }
-
- withContext(Dispatchers.Main) {
- installedApps = items
- loading = false
- }
- }
+ appCatalog = null
+ delay(220)
+ appCatalog = withContext(Dispatchers.IO) { buildAppCatalog(pm) }
+ delay(80)
+ loading = false
}
- val selectedSnapshot = selectedPackages.toSet()
- LaunchedEffect(selectedSnapshot) {
- selectedOrder.removeAll { it !in selectedSnapshot }
- selectedSnapshot.forEach { packageName ->
- if (packageName !in selectedOrder) {
- selectedOrder.add(packageName)
- }
+ LaunchedEffect(searchInput) {
+ delay(90)
+ if (searchQuery != searchInput) {
+ searchQuery = searchInput
}
}
- val selectedOrderSnapshot = selectedOrder.toList()
- val filteredApps = remember(
- installedApps,
+ val selectedOrderSnapshot by remember {
+ derivedStateOf { selectedOrder.toList() }
+ }
+ val selectedLookup = remember(selectedOrderSnapshot) {
+ selectedOrderSnapshot.toHashSet()
+ }
+ val filteredApps by remember(
+ appCatalog,
showSystemApps,
searchQuery,
- selectedSnapshot,
selectedOrderSnapshot,
sortMode,
reverseOrder,
) {
- val query = searchQuery.trim().lowercase()
- val base = installedApps?.filter { app ->
- val matchesVisibility = showSystemApps || !app.isSystem
- val matchesQuery = if (query.isBlank()) {
- true
- } else {
- app.label.lowercase().contains(query) || app.packageName.lowercase().contains(query)
- }
- matchesVisibility && matchesQuery
- } ?: emptyList()
-
- val sorted = when (sortMode) {
- AppSortMode.LABEL -> base.sortedBy { it.label.lowercase() }
- AppSortMode.PACKAGE -> base.sortedBy { it.packageName.lowercase() }
+ derivedStateOf {
+ appCatalog?.buildVisibleApps(
+ sortMode = sortMode,
+ reverseOrder = reverseOrder,
+ showSystemApps = showSystemApps,
+ query = searchQuery,
+ selectedOrder = selectedOrderSnapshot,
+ ) ?: emptyList()
}
-
- val baseOrder = if (reverseOrder) sorted.reversed() else sorted
- val byPackage = baseOrder.associateBy { it.packageName }
- val selectedPart = selectedOrderSnapshot.mapNotNull(byPackage::get)
- val unselectedPart = baseOrder.filterNot { it.packageName in selectedSnapshot }
-
- selectedPart + unselectedPart
}
val indexMap = remember(filteredApps) {
filteredApps.mapIndexed { index, appItem -> appItem.packageName to index }.toMap()
}
+ val averageItemHeightPx by remember(listState, density) {
+ derivedStateOf {
+ listState.layoutInfo.visibleItemsInfo
+ .takeIf { it.isNotEmpty() }
+ ?.map { it.size }
+ ?.average()
+ ?.toFloat()
+ ?.takeIf { it > 0f }
+ ?: with(density) { 82.dp.toPx() }
+ }
+ }
+ val dismissThenApply = remember(popupScope) {
+ { dismiss: () -> Unit, apply: () -> Unit ->
+ dismiss()
+ popupScope.launch {
+ withFrameNanos { }
+ apply()
+ }
+ }
+ }
Scaffold(
topBar = {
- TopAppBar(
- title = stringResource(configItem.titleRes),
- navigationIcon = {
- IconButton(modifier = Modifier.padding(start = 16.dp), onClick = onCancel) {
- Icon(
- modifier = Modifier.graphicsLayer {
- if (layoutDirection == LayoutDirection.Rtl) scaleX = -1f
- },
- imageVector = Icons.AutoMirrored.Filled.ArrowBack,
- contentDescription = null,
- tint = MiuixTheme.colorScheme.onBackground,
- )
- }
- },
- actions = {
- IconButton(
- onClick = { showSortMenu.value = true },
- holdDownState = showSortMenu.value
- ) {
- Icon(
- imageVector = MiuixIcons.Regular.Sort,
- contentDescription = stringResource(R.string.app_list_sort),
- tint = MiuixTheme.colorScheme.onBackground,
- )
- }
- SuperListPopup(
- show = showSortMenu.value,
- popupModifier = Modifier,
- popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
- alignment = PopupPositionProvider.Align.TopEnd,
- enableWindowDim = true,
- onDismissRequest = { showSortMenu.value = false },
- maxHeight = null,
- minWidth = 200.dp,
- renderInRootScaffold = true,
- content = {
- ListPopupColumn {
- SpinnerItemImpl(
- entry = SpinnerEntry(title = stringResource(R.string.app_list_sort_by_name)),
- entryCount = 3,
- isSelected = sortMode == AppSortMode.LABEL,
- index = 0,
- spinnerColors = SpinnerDefaults.spinnerColors(),
- onSelectedIndexChange = {
- sortMode = AppSortMode.LABEL
- showSortMenu.value = false
- },
- )
- SpinnerItemImpl(
- entry = SpinnerEntry(title = stringResource(R.string.app_list_sort_by_package)),
- entryCount = 3,
- isSelected = sortMode == AppSortMode.PACKAGE,
- index = 1,
- spinnerColors = SpinnerDefaults.spinnerColors(),
- onSelectedIndexChange = {
- sortMode = AppSortMode.PACKAGE
- showSortMenu.value = false
- },
- )
- SpinnerItemImpl(
- entry = SpinnerEntry(title = stringResource(R.string.app_list_sort_reverse)),
- entryCount = 3,
- isSelected = reverseOrder,
- index = 2,
- spinnerColors = SpinnerDefaults.spinnerColors(),
- onSelectedIndexChange = {
- reverseOrder = !reverseOrder
- showSortMenu.value = false
- },
- )
- }
- })
-
- IconButton(
- onClick = { showFilterMenu.value = true },
- holdDownState = showFilterMenu.value
- ) {
- Icon(
- imageVector = MiuixIcons.Regular.Tune,
- contentDescription = stringResource(R.string.app_list_filter),
- tint = MiuixTheme.colorScheme.onBackground,
- )
- }
- SuperListPopup(
- show = showFilterMenu.value,
- popupModifier = Modifier,
- popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
- alignment = PopupPositionProvider.Align.TopEnd,
- enableWindowDim = true,
- onDismissRequest = { showFilterMenu.value = false },
- maxHeight = null,
- minWidth = 200.dp,
- renderInRootScaffold = true,
- content = {
- ListPopupColumn {
- SpinnerItemImpl(
- entry = SpinnerEntry(
- icon = { modifier ->
- Icon(
- imageVector = Icons.Filled.Apps,
- contentDescription = null,
- modifier = modifier,
- tint = MiuixTheme.colorScheme.onBackground,
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .rearAcrylicEffect(hazeState, hazeStyle),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ TopAppBar(
+ color = Color.Transparent,
+ title = stringResource(configItem.titleRes),
+ navigationIconPadding = 12.dp,
+ actionIconPadding = 12.dp,
+ navigationIcon = {
+ IconButton(onClick = onCancel) {
+ Icon(
+ modifier = Modifier.graphicsLayer {
+ if (layoutDirection == LayoutDirection.Rtl) scaleX = -1f
+ },
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = null,
+ tint = MiuixTheme.colorScheme.onBackground,
+ )
+ }
+ },
+ actions = {
+ IconButton(
+ onClick = { showSortMenu.value = true },
+ holdDownState = showSortMenu.value
+ ) {
+ Icon(
+ imageVector = MiuixIcons.Regular.Sort,
+ contentDescription = stringResource(R.string.app_list_sort),
+ tint = MiuixTheme.colorScheme.onBackground,
+ )
+ }
+ OverlayListPopup(
+ show = showSortMenu.value,
+ popupModifier = Modifier,
+ popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
+ alignment = PopupPositionProvider.Align.TopEnd,
+ enableWindowDim = true,
+ onDismissRequest = { showSortMenu.value = false },
+ maxHeight = null,
+ minWidth = 200.dp,
+ renderInRootScaffold = true,
+ content = {
+ ListPopupColumn {
+ SpinnerItemImpl(
+ entry = SpinnerEntry(title = stringResource(R.string.app_list_sort_by_name)),
+ entryCount = 3,
+ isSelected = sortMode == AppSortMode.LABEL,
+ index = 0,
+ spinnerColors = SpinnerDefaults.spinnerColors(),
+ onSelectedIndexChange = {
+ dismissThenApply(
+ { showSortMenu.value = false },
+ { sortMode = AppSortMode.LABEL },
)
},
- title = stringResource(R.string.show_system_apps),
- ),
- entryCount = 1,
- isSelected = showSystemApps,
- index = 0,
- spinnerColors = SpinnerDefaults.spinnerColors(),
- onSelectedIndexChange = {
- showSystemApps = !showSystemApps
- showFilterMenu.value = false
- },
- )
- }
- })
-
- IconButton(
- modifier = Modifier.padding(end = 16.dp),
- onClick = { showMoreMenu.value = true },
- holdDownState = showMoreMenu.value,
- ) {
- Icon(
- imageVector = MiuixIcons.MoreCircle,
- contentDescription = stringResource(R.string.app_list_more_actions),
- tint = MiuixTheme.colorScheme.onBackground,
- )
- }
- SuperListPopup(
- show = showMoreMenu.value,
- popupModifier = Modifier,
- popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
- alignment = PopupPositionProvider.Align.TopEnd,
- enableWindowDim = true,
- onDismissRequest = { showMoreMenu.value = false },
- maxHeight = null,
- minWidth = 200.dp,
- renderInRootScaffold = true,
- content = {
- ListPopupColumn {
- SpinnerItemImpl(
- entry = SpinnerEntry(
- icon = { modifier ->
- Icon(
- imageVector = Icons.Filled.Delete,
- contentDescription = null,
- modifier = modifier,
- tint = MiuixTheme.colorScheme.onBackground,
+ )
+ SpinnerItemImpl(
+ entry = SpinnerEntry(title = stringResource(R.string.app_list_sort_by_package)),
+ entryCount = 3,
+ isSelected = sortMode == AppSortMode.PACKAGE,
+ index = 1,
+ spinnerColors = SpinnerDefaults.spinnerColors(),
+ onSelectedIndexChange = {
+ dismissThenApply(
+ { showSortMenu.value = false },
+ { sortMode = AppSortMode.PACKAGE },
)
},
- title = stringResource(R.string.selection_clear),
- ),
- entryCount = 1,
- isSelected = false,
- index = 0,
- spinnerColors = SpinnerDefaults.spinnerColors(),
- onSelectedIndexChange = {
- selectedPackages.clear()
- selectedOrder.clear()
- showMoreMenu.value = false
- },
- )
- }
- })
- },
- scrollBehavior = scrollBehavior,
- )
+ )
+ SpinnerItemImpl(
+ entry = SpinnerEntry(title = stringResource(R.string.app_list_sort_reverse)),
+ entryCount = 3,
+ isSelected = reverseOrder,
+ index = 2,
+ spinnerColors = SpinnerDefaults.spinnerColors(),
+ onSelectedIndexChange = {
+ dismissThenApply(
+ { showSortMenu.value = false },
+ { reverseOrder = !reverseOrder },
+ )
+ },
+ )
+ }
+ })
+
+ IconButton(
+ onClick = { showFilterMenu.value = true },
+ holdDownState = showFilterMenu.value
+ ) {
+ Icon(
+ imageVector = MiuixIcons.Regular.Tune,
+ contentDescription = stringResource(R.string.app_list_filter),
+ tint = MiuixTheme.colorScheme.onBackground,
+ )
+ }
+ OverlayListPopup(
+ show = showFilterMenu.value,
+ popupModifier = Modifier,
+ popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
+ alignment = PopupPositionProvider.Align.TopEnd,
+ enableWindowDim = true,
+ onDismissRequest = { showFilterMenu.value = false },
+ maxHeight = null,
+ minWidth = 200.dp,
+ renderInRootScaffold = true,
+ content = {
+ ListPopupColumn {
+ SpinnerItemImpl(
+ entry = SpinnerEntry(
+ icon = { modifier ->
+ Icon(
+ imageVector = Icons.Filled.Apps,
+ contentDescription = null,
+ modifier = modifier,
+ tint = MiuixTheme.colorScheme.onBackground,
+ )
+ },
+ title = stringResource(R.string.show_system_apps),
+ ),
+ entryCount = 1,
+ isSelected = showSystemApps,
+ index = 0,
+ spinnerColors = SpinnerDefaults.spinnerColors(),
+ onSelectedIndexChange = {
+ dismissThenApply(
+ { showFilterMenu.value = false },
+ { showSystemApps = !showSystemApps },
+ )
+ },
+ )
+ }
+ })
+
+ IconButton(
+ modifier = Modifier.padding(end = 16.dp),
+ onClick = { showMoreMenu.value = true },
+ holdDownState = showMoreMenu.value,
+ ) {
+ Icon(
+ imageVector = MiuixIcons.MoreCircle,
+ contentDescription = stringResource(R.string.app_list_more_actions),
+ tint = MiuixTheme.colorScheme.onBackground,
+ )
+ }
+ OverlayListPopup(
+ show = showMoreMenu.value,
+ popupModifier = Modifier,
+ popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
+ alignment = PopupPositionProvider.Align.TopEnd,
+ enableWindowDim = true,
+ onDismissRequest = { showMoreMenu.value = false },
+ maxHeight = null,
+ minWidth = 200.dp,
+ renderInRootScaffold = true,
+ content = {
+ ListPopupColumn {
+ SpinnerItemImpl(
+ entry = SpinnerEntry(
+ icon = { modifier ->
+ Icon(
+ imageVector = Icons.Filled.Delete,
+ contentDescription = null,
+ modifier = modifier,
+ tint = MiuixTheme.colorScheme.onBackground,
+ )
+ },
+ title = stringResource(R.string.selection_clear),
+ ),
+ entryCount = 1,
+ isSelected = false,
+ index = 0,
+ spinnerColors = SpinnerDefaults.spinnerColors(),
+ onSelectedIndexChange = {
+ dismissThenApply(
+ { showMoreMenu.value = false },
+ { selectedOrder.clear() },
+ )
+ },
+ )
+ }
+ })
+ },
+ scrollBehavior = scrollBehavior,
+ )
+
+ AppSelectorHeader(
+ modifier = Modifier
+ .padding(horizontal = 12.dp)
+ .padding(bottom = 12.dp),
+ searchQuery = searchInput,
+ onSearchQueryChange = { searchInput = it },
+ onSearchFocusChange = { searchFocused = it },
+ )
+ }
},
floatingActionButton = {
FloatingActionButton(
onClick = {
- prefsManager.putStringSet(configItem.key, selectedPackages.toSet())
+ prefsManager.putStringSet(configItem.key, selectedLookup)
onSave()
},
) {
@@ -530,487 +914,39 @@ fun AppListSelectorScreen(
floatingActionButtonPosition = FabPosition.End,
contentWindowInsets = androidx.compose.foundation.layout.WindowInsets.systemBars,
) { paddingValues ->
- val searchTopPadding = paddingValues.calculateTopPadding() + dynamicTopPadding
- val searchAcrylicShape = RoundedCornerShape(14.dp)
- val searchAcrylicBase = Color(0xFF9EA6B2).copy(alpha = 0.34f)
- val searchAcrylicStroke = Color.White.copy(alpha = 0.34f)
- val searchAcrylicOverlay = Brush.verticalGradient(
- colors = listOf(
- Color.White.copy(alpha = 0.18f),
- Color.White.copy(alpha = 0.04f)
- )
- )
-
- Box(
- modifier = Modifier.fillMaxSize()
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .scrollEndHaptic()
+ .overScrollVertical()
+ .nestedScroll(scrollBehavior.nestedScrollConnection)
+ .rearAcrylicSource(hazeState)
+ .padding(horizontal = 12.dp),
+ state = listState,
+ contentPadding = PaddingValues(
+ top = paddingValues.calculateTopPadding() + 12.dp,
+ bottom = paddingValues.calculateBottomPadding() + 84.dp,
+ ),
+ overscrollEffect = null,
) {
- LazyColumn(
- modifier = Modifier
- .fillMaxHeight()
- .scrollEndHaptic()
- .overScrollVertical()
- .nestedScroll(scrollBehavior.nestedScrollConnection)
- .padding(horizontal = 12.dp),
- state = listState,
- contentPadding = PaddingValues(
- top = searchTopPadding + 46.dp,
- bottom = paddingValues.calculateBottomPadding() + 84.dp,
- ),
- overscrollEffect = null,
- ) {
- if (loading || installedApps == null) {
- item {
- Card(
- modifier = Modifier
- .padding(top = 12.dp)
- .fillMaxWidth(),
- insideMargin = PaddingValues(vertical = 26.dp),
- ) {
- Box(
- modifier = Modifier.fillMaxWidth(),
- contentAlignment = Alignment.Center
- ) {
- CircularProgressIndicator()
- }
- }
- }
- } else if (filteredApps.isEmpty()) {
- item {
- Card(
- modifier = Modifier
- .padding(top = 12.dp)
- .fillMaxWidth(),
- insideMargin = PaddingValues(vertical = 22.dp),
- ) {
- Column(
- modifier = Modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center,
- ) {
- Icon(
- imageVector = Icons.Filled.Search,
- contentDescription = null,
- tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
- )
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- text = stringResource(R.string.app_list_empty_result),
- style = MiuixTheme.textStyles.body2,
- color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
- )
- }
- }
- }
- } else {
- items(filteredApps, key = { it.packageName }) { appItem ->
- val packageName = appItem.packageName
- val isSelected = packageName in selectedPackages
- val reorderAnimation = remember(packageName) { Animatable(0f) }
- val cardAlphaAnimation = remember(packageName) { Animatable(1f) }
- val whiteOverlayAnimation = remember(packageName) { Animatable(0f) }
- val previousIndexState = remember(packageName) {
- mutableStateOf(indexMap[packageName])
- }
- val currentIndex = indexMap[packageName]
- val frameAvgItemHeight =
- listState.layoutInfo.visibleItemsInfo.takeIf { it.isNotEmpty() }
- ?.map { it.size }
- ?.average()
- ?.toFloat()
- ?.takeIf { it > 0f }
- ?: with(density) { 82.dp.toPx() }
- val frameDeltaIndex =
- if (previousIndexState.value != null && currentIndex != null) {
- previousIndexState.value!! - currentIndex
- } else {
- 0
- }
- val shouldUsePreTranslate =
- frameDeltaIndex != 0 &&
- !reorderAnimation.isRunning &&
- abs(reorderAnimation.value) < with(density) { 1.dp.toPx() }
- val preTranslatePx =
- if (shouldUsePreTranslate) frameDeltaIndex * frameAvgItemHeight else 0f
-
- LaunchedEffect(currentIndex, isSelected) {
- val previousIndex = previousIndexState.value
- if (previousIndex != null && currentIndex != null) {
- val deltaIndex = previousIndex - currentIndex
- if (deltaIndex != 0) {
- // Update immediately so rapid toggles don't reuse stale indices.
- previousIndexState.value = currentIndex
-
- val visibleItems = listState.layoutInfo.visibleItemsInfo
- val avgItemHeight =
- visibleItems.takeIf { it.isNotEmpty() }
- ?.map { it.size }
- ?.average()
- ?.toFloat()
- ?.takeIf { it > 0f }
- ?: with(density) { 82.dp.toPx() }
-
- val moveDuration =
- (460 + abs(deltaIndex) * 120).coerceAtMost(1350)
- val longUpwardMove = isSelected && deltaIndex > 1
- val baseShift = deltaIndex * avgItemHeight
- val currentOffset = reorderAnimation.value
- // Preserve visual continuity when list order changes mid-animation.
- val startOffset = currentOffset + baseShift
-
- val startAdjustDistance = abs(currentOffset - startOffset)
- val isInterrupted =
- reorderAnimation.isRunning ||
- cardAlphaAnimation.isRunning ||
- whiteOverlayAnimation.isRunning
-
- if (isInterrupted && startAdjustDistance > with(density) { 8.dp.toPx() }) {
- reorderAnimation.animateTo(
- targetValue = startOffset,
- animationSpec = tween(
- durationMillis =
- (90 + (startAdjustDistance / avgItemHeight * 50).toInt())
- .coerceAtMost(210),
- easing = FastOutSlowInEasing,
- ),
- )
- } else {
- reorderAnimation.snapTo(startOffset)
- }
-
- if (isInterrupted) {
- coroutineScope {
- launch {
- cardAlphaAnimation.animateTo(
- targetValue = 1f,
- animationSpec = tween(
- durationMillis = 100,
- easing = FastOutSlowInEasing,
- ),
- )
- }
- launch {
- whiteOverlayAnimation.animateTo(
- targetValue = 0f,
- animationSpec = tween(
- durationMillis = 100,
- easing = FastOutSlowInEasing,
- ),
- )
- }
- }
- } else {
- cardAlphaAnimation.snapTo(1f)
- whiteOverlayAnimation.snapTo(0f)
- }
-
- if (longUpwardMove) {
- val oneBeforeOffset = avgItemHeight
- val initialRiseDuration =
- (moveDuration * 0.22f).toInt().coerceAtLeast(130)
- val turnWhiteDuration =
- (moveDuration * 0.13f).toInt().coerceAtLeast(90)
- val fadeOutDuration =
- (moveDuration * 0.08f).toInt().coerceAtLeast(70)
- val hiddenHoldDuration = 40
- val revealDuration = 110
- val consumed =
- initialRiseDuration + turnWhiteDuration + fadeOutDuration +
- hiddenHoldDuration + revealDuration
- val finalSlideDuration =
- (moveDuration - consumed).coerceAtLeast(240)
-
- // 1) Start moving upward while still normal.
- reorderAnimation.animateTo(
- targetValue = startOffset * 0.82f,
- animationSpec = tween(
- durationMillis = initialRiseDuration,
- easing = FastOutSlowInEasing,
- ),
- )
-
- // 2) Turn white while still moving upward.
- coroutineScope {
- launch {
- whiteOverlayAnimation.animateTo(
- targetValue = 1f,
- animationSpec = tween(
- durationMillis = turnWhiteDuration,
- easing = FastOutSlowInEasing,
- ),
- )
- }
- launch {
- reorderAnimation.animateTo(
- targetValue = startOffset * 0.58f,
- animationSpec = tween(
- durationMillis = turnWhiteDuration,
- easing = FastOutSlowInEasing,
- ),
- )
- }
- }
-
- // 3) Fade out, then jump under target and wait.
- coroutineScope {
- launch {
- cardAlphaAnimation.animateTo(
- targetValue = 0f,
- animationSpec = tween(
- durationMillis = fadeOutDuration,
- easing = FastOutSlowInEasing,
- ),
- )
- }
- launch {
- reorderAnimation.animateTo(
- targetValue = startOffset * 0.5f,
- animationSpec = tween(
- durationMillis = fadeOutDuration,
- easing = FastOutSlowInEasing,
- ),
- )
- }
- }
-
- reorderAnimation.snapTo(oneBeforeOffset)
- delay(hiddenHoldDuration.toLong())
-
- // 4) Show again while others are still descending, then settle.
- coroutineScope {
- launch {
- cardAlphaAnimation.animateTo(
- targetValue = 1f,
- animationSpec = tween(
- durationMillis = revealDuration,
- easing = FastOutSlowInEasing,
- ),
- )
- }
- launch {
- whiteOverlayAnimation.animateTo(
- targetValue = 0f,
- animationSpec = tween(
- durationMillis = revealDuration,
- easing = FastOutSlowInEasing,
- ),
- )
- }
- launch {
- reorderAnimation.animateTo(
- targetValue = 0f,
- animationSpec = tween(
- durationMillis = finalSlideDuration,
- easing = FastOutSlowInEasing,
- ),
- )
- }
- }
- } else {
- reorderAnimation.animateTo(
- targetValue = 0f,
- animationSpec = tween(
- durationMillis = moveDuration,
- easing = FastOutSlowInEasing,
- ),
- )
- cardAlphaAnimation.snapTo(1f)
- whiteOverlayAnimation.snapTo(0f)
- }
- }
- }
- if (previousIndexState.value != currentIndex) {
- previousIndexState.value = currentIndex
- }
- }
-
- Card(
- modifier = Modifier
- .padding(top = 10.dp)
- .graphicsLayer {
- translationY = reorderAnimation.value + preTranslatePx
- alpha = cardAlphaAnimation.value
- }
- .fillMaxWidth(),
- insideMargin = PaddingValues(horizontal = 14.dp, vertical = 12.dp),
- onClick = {
- if (isSelected) {
- selectedPackages.remove(packageName)
- selectedOrder.remove(packageName)
- } else {
- selectedPackages.add(packageName)
- if (packageName !in selectedOrder) {
- selectedOrder.add(packageName)
- }
- }
- },
- showIndication = true,
- ) {
- Box(modifier = Modifier.fillMaxWidth()) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- AppIcon(
- appInfo = appItem.applicationInfo,
- pm = pm,
- modifier = Modifier.padding(end = 12.dp),
- )
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = appItem.label,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- Text(
- text = packageName,
- style = MiuixTheme.textStyles.body2,
- color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
- Spacer(modifier = Modifier.width(8.dp))
- SelectionIndicator(selected = isSelected)
- }
-
- if (whiteOverlayAnimation.value > 0f) {
- Box(
- modifier = Modifier
- .matchParentSize()
- .background(
- Color.White.copy(alpha = whiteOverlayAnimation.value)
- )
- )
- }
- }
- }
- }
+ if (loading || appCatalog == null) {
+ item {
+ LoadingAppsCard()
}
- }
-
- if (searchFocused) {
- Box(
- modifier = Modifier
- .matchParentSize()
- .zIndex(1f)
- .pointerInput(searchBarBounds) {
- detectTapGestures { tapOffset ->
- val bounds = searchBarBounds
- if (bounds == null || !bounds.contains(tapOffset)) {
- focusManager.clearFocus(force = true)
- keyboardController?.hide()
- }
- }
- }
- )
- }
-
- Card(
- modifier = Modifier
- .align(Alignment.TopCenter)
- .zIndex(2f)
- .padding(top = searchTopPadding)
- .onGloballyPositioned { coordinates ->
- searchBarBounds = coordinates.boundsInParent()
- }
- .border(1.dp, searchAcrylicStroke, searchAcrylicShape)
- .fillMaxWidth(0.88f),
- cornerRadius = 14.dp,
- colors = CardDefaults.defaultColors(
- color = searchAcrylicBase,
- contentColor = MiuixTheme.colorScheme.onSurfaceVariantSummary,
- )
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .height(44.dp)
- .background(searchAcrylicOverlay)
- .padding(horizontal = 12.dp, vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Icon(
- imageVector = Icons.Filled.Search,
- contentDescription = null,
- tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
- modifier = Modifier
- .size(18.dp)
- .padding(end = 6.dp),
- )
-
- val searchHint = stringResource(R.string.search_apps)
- val searchTextColor = MiuixTheme.colorScheme.onBackground.toArgb()
- val searchHintColor = MiuixTheme.colorScheme.onSurfaceVariantSummary.toArgb()
-
- AndroidView(
- modifier = Modifier
- .weight(1f)
- .fillMaxHeight(),
- factory = { editContext ->
- EditText(editContext).apply {
- background = null
- setSingleLine(true)
- maxLines = 1
- imeOptions = EditorInfo.IME_ACTION_SEARCH
- inputType =
- InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
- importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO
- setPadding(0, 0, 0, 0)
- minHeight = 0
- minimumHeight = 0
- includeFontPadding = false
- setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
-
- setText(searchQuery)
- setSelection(text?.length ?: 0)
-
- setOnFocusChangeListener { _, hasFocus ->
- searchFocused = hasFocus
- }
-
- setOnEditorActionListener { _, actionId, _ ->
- if (actionId == EditorInfo.IME_ACTION_SEARCH || actionId == EditorInfo.IME_ACTION_DONE) {
- focusManager.clearFocus(force = true)
- keyboardController?.hide()
- true
- } else {
- false
- }
- }
-
- addTextChangedListener(object : TextWatcher {
- override fun beforeTextChanged(
- s: CharSequence?,
- start: Int,
- count: Int,
- after: Int,
- ) = Unit
-
- override fun onTextChanged(
- s: CharSequence?,
- start: Int,
- before: Int,
- count: Int,
- ) {
- val next = s?.toString().orEmpty()
- if (next != searchQuery) {
- searchQuery = next
- }
- }
-
- override fun afterTextChanged(s: Editable?) = Unit
- })
- }
- },
- update = { editText ->
- val expectedText = searchQuery
- if (editText.text?.toString() != expectedText) {
- editText.setText(expectedText)
- editText.setSelection(expectedText.length)
- }
-
- editText.hint = searchHint
- editText.importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO
- editText.setTextColor(searchTextColor)
- editText.setHintTextColor(searchHintColor)
- },
+ } else if (filteredApps.isEmpty()) {
+ item {
+ EmptyAppsCard()
+ }
+ } else {
+ items(filteredApps, key = { it.packageName }) { appItem ->
+ val currentIndex = indexMap.getValue(appItem.packageName)
+ AppSelectorRow(
+ appItem = appItem,
+ packageManager = pm,
+ selected = appItem.packageName in selectedLookup,
+ currentIndex = currentIndex,
+ averageItemHeightPx = averageItemHeightPx,
+ onToggle = { selectedOrder.toggleSelection(appItem.packageName) },
)
}
}
diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/config/BusinessExtraConfigScreen.kt b/app/src/main/java/hk/uwu/reareye/ui/components/config/BusinessExtraConfigScreen.kt
new file mode 100644
index 0000000..af031d7
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/ui/components/config/BusinessExtraConfigScreen.kt
@@ -0,0 +1,525 @@
+package hk.uwu.reareye.ui.components.config
+
+import android.annotation.SuppressLint
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.rounded.EditNote
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import hk.uwu.reareye.R
+import hk.uwu.reareye.repository.rearwidget.RearBusinessExtraConfig
+import hk.uwu.reareye.repository.rearwidget.RearBusinessExtraConfigEntry
+import hk.uwu.reareye.repository.rearwidget.RearBusinessExtraConfigFields
+import hk.uwu.reareye.repository.rearwidget.RearBusinessExtraConfigRepository
+import hk.uwu.reareye.ui.components.card.ModuleStyleDeleteAction
+import hk.uwu.reareye.ui.components.card.ModuleStyleIconAction
+import hk.uwu.reareye.ui.components.card.ModuleStyleManagerCard
+import hk.uwu.reareye.ui.components.card.SuperCard
+import hk.uwu.reareye.ui.components.motion.ArtRevealItem
+import hk.uwu.reareye.ui.components.motion.ArtStaggeredReveal
+import hk.uwu.reareye.ui.components.motion.ArtSwapContent
+import hk.uwu.reareye.ui.config.ConfigGroup
+import hk.uwu.reareye.ui.config.ConfigItem
+import hk.uwu.reareye.ui.config.ConfigNode
+import hk.uwu.reareye.ui.config.ConfigType
+import hk.uwu.reareye.ui.config.PrefsManager
+import hk.uwu.reareye.ui.theme.rearAcrylicEffect
+import hk.uwu.reareye.ui.theme.rearAcrylicSource
+import hk.uwu.reareye.ui.theme.rememberAcrylicHazeState
+import hk.uwu.reareye.ui.theme.rememberAcrylicHazeStyle
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
+import top.yukonga.miuix.kmp.basic.Button
+import top.yukonga.miuix.kmp.basic.ButtonDefaults
+import top.yukonga.miuix.kmp.basic.Card
+import top.yukonga.miuix.kmp.basic.CircularProgressIndicator
+import top.yukonga.miuix.kmp.basic.Icon
+import top.yukonga.miuix.kmp.basic.IconButton
+import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
+import top.yukonga.miuix.kmp.basic.Scaffold
+import top.yukonga.miuix.kmp.basic.Text
+import top.yukonga.miuix.kmp.basic.TextField
+import top.yukonga.miuix.kmp.basic.TopAppBar
+import top.yukonga.miuix.kmp.icon.MiuixIcons
+import top.yukonga.miuix.kmp.icon.extended.Delete
+import top.yukonga.miuix.kmp.overlay.OverlayDialog
+import top.yukonga.miuix.kmp.preference.SwitchPreference
+import top.yukonga.miuix.kmp.utils.overScrollVertical
+import top.yukonga.miuix.kmp.utils.scrollEndHaptic
+
+private val BusinessExtraConfigNodes = listOf(
+ ConfigGroup(
+ children = listOf(
+ ConfigItem(
+ key = RearBusinessExtraConfigFields.HIDE_TIME_TIP,
+ titleRes = R.string.rear_widget_business_hide_time_tip,
+ descriptionRes = R.string.rear_widget_business_hide_time_tip_desc,
+ type = ConfigType.BooleanVal(defaultValue = false),
+ )
+ )
+ )
+)
+
+@SuppressLint("LocalContextGetResourceValueCall")
+@Composable
+fun BusinessExtraConfigManagerScreen(
+ prefsManager: PrefsManager,
+ onBack: () -> Unit,
+) {
+ val context = LocalContext.current
+ val layoutDirection = LocalLayoutDirection.current
+ val scrollBehavior = MiuixScrollBehavior()
+ val hazeState = rememberAcrylicHazeState()
+ val hazeStyle = rememberAcrylicHazeStyle()
+ val entries = remember { mutableStateListOf() }
+ var loaded by remember { mutableStateOf(false) }
+ var dataCardsVisible by remember { mutableStateOf(false) }
+ var showDialog by remember { mutableStateOf(false) }
+ var draftBusiness by remember { mutableStateOf("") }
+ var editingBusiness by remember { mutableStateOf(null) }
+
+ suspend fun reloadEntries() {
+ val loadedEntries = withContext(Dispatchers.IO) {
+ RearBusinessExtraConfigRepository.getAllConfigs(prefsManager)
+ }
+ entries.clear()
+ entries.addAll(loadedEntries)
+ }
+
+ LaunchedEffect(Unit) {
+ loaded = false
+ dataCardsVisible = false
+ delay(220)
+ reloadEntries()
+ loaded = true
+ delay(90)
+ dataCardsVisible = true
+ }
+
+ LaunchedEffect(editingBusiness) {
+ if (editingBusiness == null && loaded) {
+ dataCardsVisible = false
+ reloadEntries()
+ delay(90)
+ dataCardsVisible = true
+ }
+ }
+
+ fun openCreateDialog() {
+ draftBusiness = ""
+ showDialog = true
+ }
+
+ fun submitDialog() {
+ val business = draftBusiness.trim()
+ if (business.isBlank()) {
+ Toast.makeText(
+ context,
+ context.getString(R.string.rear_widget_form_invalid),
+ Toast.LENGTH_SHORT,
+ ).show()
+ return
+ }
+
+ val existing = entries.firstOrNull { it.business == business }
+ if (existing == null) {
+ RearBusinessExtraConfigRepository.saveConfigForBusiness(
+ prefsManager = prefsManager,
+ business = business,
+ config = RearBusinessExtraConfig(),
+ )
+
+ entries.add(
+ RearBusinessExtraConfigEntry(
+ business = business,
+ config = RearBusinessExtraConfigRepository.getConfigForBusiness(
+ prefsManager = prefsManager,
+ business = business,
+ ),
+ )
+ )
+ entries.sortBy { it.business.lowercase() }
+ }
+
+ showDialog = false
+ editingBusiness = business
+ }
+
+ BackHandler(enabled = editingBusiness != null) {
+ editingBusiness = null
+ }
+
+ ArtSwapContent(
+ targetState = editingBusiness,
+ modifier = Modifier.fillMaxSize(),
+ contentKey = { it ?: "manager" },
+ ) { currentBusiness ->
+ if (currentBusiness != null) {
+ BusinessExtraConfigScreen(
+ prefsManager = prefsManager,
+ business = currentBusiness,
+ onBack = {
+ editingBusiness = null
+ showDialog = false
+ },
+ )
+ } else {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ modifier = Modifier.rearAcrylicEffect(hazeState, hazeStyle),
+ color = Color.Transparent,
+ title = stringResource(R.string.rear_widget_business_extra_manager),
+ navigationIcon = {
+ IconButton(
+ modifier = Modifier.padding(start = 16.dp),
+ onClick = onBack
+ ) {
+ Icon(
+ modifier = Modifier.graphicsLayer {
+ if (layoutDirection == LayoutDirection.Rtl) scaleX = -1f
+ },
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = null,
+ )
+ }
+ },
+ actions = {
+ IconButton(
+ modifier = Modifier.padding(end = 16.dp),
+ onClick = { if (loaded) openCreateDialog() },
+ ) {
+ Icon(imageVector = Icons.Filled.Add, contentDescription = null)
+ }
+ },
+ scrollBehavior = scrollBehavior,
+ )
+ }
+ ) { paddingValues ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .nestedScroll(scrollBehavior.nestedScrollConnection)
+ .scrollEndHaptic()
+ .overScrollVertical()
+ .rearAcrylicSource(hazeState)
+ .padding(horizontal = 12.dp),
+ contentPadding = PaddingValues(
+ top = paddingValues.calculateTopPadding() + 12.dp,
+ bottom = paddingValues.calculateBottomPadding() + 12.dp,
+ ),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ overscrollEffect = null,
+ ) {
+ item {
+ Card(
+ modifier = Modifier
+ .padding(bottom = 12.dp)
+ .fillMaxWidth()
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ SuperCard(
+ title = stringResource(R.string.rear_widget_business_extra_hint_title),
+ summary = buildString {
+ if (loaded) {
+ append(
+ context.getString(
+ R.string.rear_widget_business_extra_count,
+ entries.size,
+ )
+ )
+ append('\n')
+ }
+ append(context.getString(R.string.rear_widget_business_extra_hint))
+ },
+ onClick = {},
+ bottomAction = {
+ Button(
+ onClick = { openCreateDialog() },
+ colors = ButtonDefaults.buttonColorsPrimary(),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Add,
+ contentDescription = null,
+ modifier = Modifier.padding(end = 6.dp),
+ )
+ Text(text = stringResource(R.string.rear_widget_business_extra_add))
+ }
+ }
+ )
+ }
+ }
+ }
+
+ if (!dataCardsVisible) {
+ item {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ insideMargin = PaddingValues(vertical = 24.dp),
+ ) {
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ CircularProgressIndicator()
+ Text(text = stringResource(R.string.rear_widget_loading_data))
+ }
+ }
+ }
+ }
+ }
+
+ if (dataCardsVisible) {
+ itemsIndexed(entries, key = { _, item -> item.business }) { index, item ->
+ ArtStaggeredReveal(
+ visible = true,
+ revealKey = item.business,
+ delayMillis = (36 + index * 18).coerceAtMost(150),
+ ) {
+ ModuleStyleManagerCard(
+ title = item.business,
+ summaryLines = listOf(),
+ onCardClick = { editingBusiness = item.business },
+ leftAction = {
+ ModuleStyleIconAction(
+ icon = Icons.Rounded.EditNote,
+ onClick = { editingBusiness = item.business },
+ )
+ },
+ rightAction = {
+ ModuleStyleDeleteAction(
+ icon = MiuixIcons.Delete,
+ text = stringResource(R.string.rear_widget_action_delete),
+ onClick = {
+ RearBusinessExtraConfigRepository.removeConfigForBusiness(
+ prefsManager = prefsManager,
+ business = item.business,
+ )
+ entries.remove(item)
+ },
+ )
+ },
+ )
+ }
+ }
+ }
+
+ item {
+ if (dataCardsVisible && entries.isEmpty()) {
+ ArtRevealItem(visible = true, delayMillis = 40) {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = stringResource(R.string.rear_widget_business_extra_empty),
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ OverlayDialog(
+ show = showDialog,
+ title = stringResource(R.string.rear_widget_business_extra_add),
+ onDismissRequest = { showDialog = false },
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .imePadding(),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ TextField(
+ value = draftBusiness,
+ onValueChange = { draftBusiness = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = stringResource(R.string.rear_widget_business_name),
+ singleLine = true,
+ )
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Button(
+ onClick = { submitDialog() },
+ colors = ButtonDefaults.buttonColorsPrimary(),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(R.string.rear_widget_confirm))
+ }
+ Button(
+ onClick = { showDialog = false },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.rear_widget_cancel))
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun BusinessExtraConfigScreen(
+ prefsManager: PrefsManager,
+ business: String,
+ onBack: () -> Unit,
+) {
+ val layoutDirection = LocalLayoutDirection.current
+ val scrollBehavior = MiuixScrollBehavior()
+ val hazeState = rememberAcrylicHazeState()
+ val hazeStyle = rememberAcrylicHazeStyle()
+ var config by remember(business) {
+ mutableStateOf(
+ RearBusinessExtraConfigRepository.getConfigForBusiness(
+ prefsManager = prefsManager,
+ business = business,
+ )
+ )
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ modifier = Modifier.rearAcrylicEffect(hazeState, hazeStyle),
+ color = Color.Transparent,
+ title = stringResource(R.string.rear_widget_business_extra_settings),
+ navigationIconPadding = 12.dp,
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ modifier = Modifier.graphicsLayer {
+ if (layoutDirection == LayoutDirection.Rtl) scaleX = -1f
+ },
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = null,
+ )
+ }
+ },
+ scrollBehavior = scrollBehavior,
+ )
+ }
+ ) { paddingValues ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .nestedScroll(scrollBehavior.nestedScrollConnection)
+ .scrollEndHaptic()
+ .overScrollVertical()
+ .rearAcrylicSource(hazeState)
+ .padding(horizontal = 12.dp),
+ contentPadding = PaddingValues(
+ top = paddingValues.calculateTopPadding() + 12.dp,
+ bottom = paddingValues.calculateBottomPadding() + 12.dp,
+ ),
+ overscrollEffect = null,
+ ) {
+ item {
+ Card(
+ modifier = Modifier
+ .padding(bottom = 8.dp)
+ .fillMaxWidth()
+ ) {
+ Text(
+ text = stringResource(
+ R.string.rear_widget_business_extra_settings_target,
+ business
+ ),
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
+ )
+ }
+ }
+
+ itemsIndexed(
+ BusinessExtraConfigNodes,
+ key = { index, node -> node.key ?: "extra_$index" }) { index, node ->
+ if (node is ConfigGroup) {
+ Card(
+ modifier = Modifier
+ .padding(top = if (index == 0) 0.dp else 8.dp)
+ .fillMaxWidth()
+ ) {
+ node.children.forEach { child ->
+ BusinessExtraConfigNodeRow(
+ node = child,
+ config = config,
+ onConfigChange = { updated ->
+ config = updated
+ RearBusinessExtraConfigRepository.saveConfigForBusiness(
+ prefsManager = prefsManager,
+ business = business,
+ config = updated,
+ )
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun BusinessExtraConfigNodeRow(
+ node: ConfigNode,
+ config: RearBusinessExtraConfig,
+ onConfigChange: (RearBusinessExtraConfig) -> Unit,
+) {
+ if (node !is ConfigItem) return
+ when (val type = node.type) {
+ is ConfigType.BooleanVal -> {
+ val checked = config.getBoolean(node.key, type.defaultValue)
+ SwitchPreference(
+ title = stringResource(node.titleRes),
+ summary = node.descriptionRes?.let { stringResource(it) },
+ checked = checked,
+ onCheckedChange = { value ->
+ onConfigChange(config.withBoolean(node.key, value))
+ },
+ )
+ }
+
+ else -> Unit
+ }
+}
diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/config/BusinessManagerScreen.kt b/app/src/main/java/hk/uwu/reareye/ui/components/config/BusinessManagerScreen.kt
index e64c03e..5cf0117 100644
--- a/app/src/main/java/hk/uwu/reareye/ui/components/config/BusinessManagerScreen.kt
+++ b/app/src/main/java/hk/uwu/reareye/ui/components/config/BusinessManagerScreen.kt
@@ -4,10 +4,11 @@ import android.annotation.SuppressLint
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
@@ -28,7 +29,9 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
@@ -37,17 +40,27 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import hk.uwu.reareye.R
-import hk.uwu.reareye.rearwidget.RearBusinessConfig
-import hk.uwu.reareye.rearwidget.RearWidgetConfigCodec
-import hk.uwu.reareye.rearwidget.RearWidgetManagerRepository
+import hk.uwu.reareye.repository.rearwidget.RearBusinessConfig
+import hk.uwu.reareye.repository.rearwidget.RearWidgetConfigCodec
+import hk.uwu.reareye.repository.rearwidget.RearWidgetManagerRepository
import hk.uwu.reareye.ui.components.card.ModuleStyleDeleteAction
import hk.uwu.reareye.ui.components.card.ModuleStyleIconAction
import hk.uwu.reareye.ui.components.card.ModuleStyleManagerCard
import hk.uwu.reareye.ui.components.card.SuperCard
+import hk.uwu.reareye.ui.components.motion.ArtRevealItem
+import hk.uwu.reareye.ui.components.motion.ArtStaggeredReveal
import hk.uwu.reareye.ui.config.PrefsManager
+import hk.uwu.reareye.ui.theme.rearAcrylicEffect
+import hk.uwu.reareye.ui.theme.rearAcrylicSource
+import hk.uwu.reareye.ui.theme.rememberAcrylicHazeState
+import hk.uwu.reareye.ui.theme.rememberAcrylicHazeStyle
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.Card
+import top.yukonga.miuix.kmp.basic.CircularProgressIndicator
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
@@ -55,9 +68,9 @@ import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.basic.TopAppBar
-import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Delete
+import top.yukonga.miuix.kmp.overlay.OverlayDialog
import top.yukonga.miuix.kmp.utils.overScrollVertical
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
@@ -72,11 +85,11 @@ fun BusinessManagerScreen(
val context = LocalContext.current
val layoutDirection = LocalLayoutDirection.current
val scrollBehavior = MiuixScrollBehavior()
- val widgets = remember {
- mutableStateListOf().apply {
- addAll(RearWidgetManagerRepository.loadBusinesses(prefsManager))
- }
- }
+ val hazeState = rememberAcrylicHazeState()
+ val hazeStyle = rememberAcrylicHazeStyle()
+ val widgets = remember { mutableStateListOf() }
+ var widgetsLoaded by remember { mutableStateOf(false) }
+ var dataCardsVisible by remember { mutableStateOf(false) }
var showDialog by remember { mutableStateOf(false) }
var editingId by remember { mutableStateOf(null) }
@@ -84,7 +97,20 @@ fun BusinessManagerScreen(
var draftFilePath by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
- RearWidgetManagerRepository.refreshRuntimeFromPrefs(context, prefsManager)
+ widgetsLoaded = false
+ dataCardsVisible = false
+ delay(220)
+ val loadedWidgets = withContext(Dispatchers.IO) {
+ RearWidgetManagerRepository.loadBusinesses(prefsManager)
+ }
+ widgets.clear()
+ widgets.addAll(loadedWidgets)
+ widgetsLoaded = true
+ delay(90)
+ dataCardsVisible = true
+ withContext(Dispatchers.IO) {
+ RearWidgetManagerRepository.refreshRuntimeFromPrefs(context, prefsManager)
+ }
}
fun persist() {
@@ -176,9 +202,13 @@ fun BusinessManagerScreen(
Scaffold(
topBar = {
TopAppBar(
+ modifier = Modifier.rearAcrylicEffect(hazeState, hazeStyle),
+ color = Color.Transparent,
title = stringResource(R.string.rear_widget_business_manager),
+ navigationIconPadding = 12.dp,
+ actionIconPadding = 12.dp,
navigationIcon = {
- IconButton(modifier = Modifier.padding(start = 16.dp), onClick = onBack) {
+ IconButton(onClick = onBack) {
Icon(
modifier = Modifier.graphicsLayer {
if (layoutDirection == LayoutDirection.Rtl) scaleX = -1f
@@ -190,8 +220,7 @@ fun BusinessManagerScreen(
},
actions = {
IconButton(
- modifier = Modifier.padding(end = 16.dp),
- onClick = { openCreateDialog() }) {
+ onClick = { if (widgetsLoaded) openCreateDialog() }) {
Icon(imageVector = Icons.Filled.Add, contentDescription = null)
}
},
@@ -205,6 +234,7 @@ fun BusinessManagerScreen(
.nestedScroll(scrollBehavior.nestedScrollConnection)
.scrollEndHaptic()
.overScrollVertical()
+ .rearAcrylicSource(hazeState)
.padding(horizontal = 12.dp),
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding() + 12.dp,
@@ -222,10 +252,18 @@ fun BusinessManagerScreen(
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
SuperCard(
title = stringResource(R.string.rear_widget_business_file_mode_title),
- summary = stringResource(
- R.string.rear_widget_component_count,
- widgets.size,
- ) + "\n" + stringResource(R.string.rear_widget_business_file_mode_hint),
+ summary = buildString {
+ if (widgetsLoaded) {
+ append(
+ context.getString(
+ R.string.rear_widget_component_count,
+ widgets.size,
+ )
+ )
+ append('\n')
+ }
+ append(context.getString(R.string.rear_widget_business_file_mode_hint))
+ },
onClick = {},
bottomAction = {
Button(
@@ -246,44 +284,76 @@ fun BusinessManagerScreen(
}
}
- itemsIndexed(widgets, key = { _, item -> item.id }) { _, item ->
- ModuleStyleManagerCard(
- title = item.business,
- summaryLines = listOf(item.filePath),
- onCardClick = { openEditDialog(item) },
- leftAction = {
- ModuleStyleIconAction(
- icon = Icons.Rounded.EditNote,
- onClick = { openEditDialog(item) },
- )
- },
- rightAction = {
- ModuleStyleDeleteAction(
- icon = MiuixIcons.Delete,
- text = stringResource(R.string.rear_widget_action_delete),
- onClick = {
- widgets.remove(item)
- persist()
+ if (!dataCardsVisible) {
+ item {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ insideMargin = PaddingValues(vertical = 24.dp),
+ ) {
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ CircularProgressIndicator()
+ Text(text = stringResource(R.string.rear_widget_loading_data))
+ }
+ }
+ }
+ }
+ }
+
+ if (dataCardsVisible) {
+ itemsIndexed(widgets, key = { _, item -> item.id }) { index, item ->
+ ArtStaggeredReveal(
+ visible = true,
+ revealKey = item.id,
+ delayMillis = (36 + index * 18).coerceAtMost(150),
+ ) {
+ ModuleStyleManagerCard(
+ title = item.business,
+ summaryLines = listOf(item.filePath),
+ onCardClick = { openEditDialog(item) },
+ leftAction = {
+ ModuleStyleIconAction(
+ icon = Icons.Rounded.EditNote,
+ onClick = { openEditDialog(item) },
+ )
+ },
+ rightAction = {
+ ModuleStyleDeleteAction(
+ icon = MiuixIcons.Delete,
+ text = stringResource(R.string.rear_widget_action_delete),
+ onClick = {
+ widgets.remove(item)
+ persist()
+ },
+ )
},
)
- },
- )
+ }
+ }
}
item {
- AnimatedVisibility(visible = widgets.isEmpty()) {
- Card(modifier = Modifier.fillMaxWidth()) {
- Text(
- text = stringResource(R.string.rear_widget_empty_business),
- modifier = Modifier.padding(16.dp),
- )
+ if (dataCardsVisible && widgets.isEmpty()) {
+ ArtRevealItem(visible = true, delayMillis = 40) {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = stringResource(R.string.rear_widget_empty_business),
+ modifier = Modifier.padding(16.dp),
+ )
+ }
}
}
}
}
}
- SuperDialog(
+ OverlayDialog(
show = showDialog,
title = stringResource(
if (editingId == null) R.string.rear_widget_add_business else R.string.rear_widget_edit_business,
diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/config/CardManagerScreen.kt b/app/src/main/java/hk/uwu/reareye/ui/components/config/CardManagerScreen.kt
index 1209412..7811f2b 100644
--- a/app/src/main/java/hk/uwu/reareye/ui/components/config/CardManagerScreen.kt
+++ b/app/src/main/java/hk/uwu/reareye/ui/components/config/CardManagerScreen.kt
@@ -2,10 +2,11 @@ package hk.uwu.reareye.ui.components.config
import android.annotation.SuppressLint
import android.widget.Toast
-import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
@@ -24,8 +25,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
@@ -34,17 +38,28 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import hk.uwu.reareye.R
-import hk.uwu.reareye.rearwidget.RearCardConfig
-import hk.uwu.reareye.rearwidget.RearWidgetConfigCodec
-import hk.uwu.reareye.rearwidget.RearWidgetManagerRepository
+import hk.uwu.reareye.repository.rearwidget.RearCardConfig
+import hk.uwu.reareye.repository.rearwidget.RearWidgetConfigCodec
+import hk.uwu.reareye.repository.rearwidget.RearWidgetManagerRepository
import hk.uwu.reareye.ui.components.card.ModuleStyleDeleteAction
import hk.uwu.reareye.ui.components.card.ModuleStyleIconAction
import hk.uwu.reareye.ui.components.card.ModuleStyleManagerCard
import hk.uwu.reareye.ui.components.card.SuperCard
+import hk.uwu.reareye.ui.components.motion.ArtRevealItem
+import hk.uwu.reareye.ui.components.motion.ArtStaggeredReveal
import hk.uwu.reareye.ui.config.PrefsManager
+import hk.uwu.reareye.ui.theme.rearAcrylicEffect
+import hk.uwu.reareye.ui.theme.rearAcrylicSource
+import hk.uwu.reareye.ui.theme.rememberAcrylicHazeState
+import hk.uwu.reareye.ui.theme.rememberAcrylicHazeStyle
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.Card
+import top.yukonga.miuix.kmp.basic.CircularProgressIndicator
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
@@ -53,9 +68,10 @@ import top.yukonga.miuix.kmp.basic.Switch
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.basic.TopAppBar
-import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Delete
+import top.yukonga.miuix.kmp.overlay.OverlayDialog
+import top.yukonga.miuix.kmp.preference.SwitchPreference
import top.yukonga.miuix.kmp.utils.overScrollVertical
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
@@ -66,15 +82,29 @@ fun CardManagerScreen(
) {
val context = LocalContext.current
val layoutDirection = LocalLayoutDirection.current
+ val scope = rememberCoroutineScope()
val scrollBehavior = MiuixScrollBehavior()
- val cards = remember {
- mutableStateListOf().apply {
- addAll(RearWidgetManagerRepository.loadCards(prefsManager))
- }
- }
+ val hazeState = rememberAcrylicHazeState()
+ val hazeStyle = rememberAcrylicHazeStyle()
+ val cards = remember { mutableStateListOf() }
+ var cardsLoaded by remember { mutableStateOf(false) }
+ var dataCardsVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
- RearWidgetManagerRepository.refreshRuntimeFromPrefs(context, prefsManager)
+ cardsLoaded = false
+ dataCardsVisible = false
+ delay(220)
+ val loadedCards = withContext(Dispatchers.IO) {
+ RearWidgetManagerRepository.loadCards(prefsManager)
+ }
+ cards.clear()
+ cards.addAll(loadedCards)
+ cardsLoaded = true
+ delay(90)
+ dataCardsVisible = true
+ withContext(Dispatchers.IO) {
+ RearWidgetManagerRepository.refreshRuntimeFromPrefs(context, prefsManager)
+ }
}
var showDialog by remember { mutableStateOf(false) }
@@ -83,6 +113,7 @@ fun CardManagerScreen(
var draftPackageName by remember { mutableStateOf("hk.uwu.reareye") }
var draftBusiness by remember { mutableStateOf("") }
var draftPriorityText by remember { mutableStateOf("500") }
+ var draftSticky by remember { mutableStateOf(true) }
fun persist() {
RearWidgetManagerRepository.saveCards(context, prefsManager, cards.toList())
@@ -94,6 +125,7 @@ fun CardManagerScreen(
draftPackageName = "hk.uwu.reareye"
draftBusiness = ""
draftPriorityText = "500"
+ draftSticky = true
showDialog = true
}
@@ -103,6 +135,7 @@ fun CardManagerScreen(
draftPackageName = item.packageName
draftBusiness = item.business
draftPriorityText = item.priority.toString()
+ draftSticky = item.sticky
showDialog = true
}
@@ -127,6 +160,7 @@ fun CardManagerScreen(
packageName = pkg,
business = widget,
enabled = enabled,
+ sticky = draftSticky,
priority = draftPriorityText.toIntOrNull() ?: 500,
)
@@ -149,9 +183,13 @@ fun CardManagerScreen(
Scaffold(
topBar = {
TopAppBar(
+ modifier = Modifier.rearAcrylicEffect(hazeState, hazeStyle),
+ color = Color.Transparent,
title = stringResource(R.string.rear_widget_card_manager),
+ navigationIconPadding = 12.dp,
+ actionIconPadding = 12.dp,
navigationIcon = {
- IconButton(modifier = Modifier.padding(start = 16.dp), onClick = onBack) {
+ IconButton(onClick = onBack) {
Icon(
modifier = Modifier.graphicsLayer {
if (layoutDirection == LayoutDirection.Rtl) scaleX = -1f
@@ -163,8 +201,7 @@ fun CardManagerScreen(
},
actions = {
IconButton(
- modifier = Modifier.padding(end = 16.dp),
- onClick = { openCreateDialog() }) {
+ onClick = { if (cardsLoaded) openCreateDialog() }) {
Icon(imageVector = Icons.Filled.Add, contentDescription = null)
}
},
@@ -178,6 +215,7 @@ fun CardManagerScreen(
.nestedScroll(scrollBehavior.nestedScrollConnection)
.scrollEndHaptic()
.overScrollVertical()
+ .rearAcrylicSource(hazeState)
.padding(horizontal = 12.dp),
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding() + 12.dp,
@@ -195,10 +233,18 @@ fun CardManagerScreen(
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
SuperCard(
title = stringResource(R.string.rear_widget_card_dialog_hint_title),
- summary = stringResource(
- R.string.rear_widget_card_count,
- cards.size,
- ) + "\n" + stringResource(R.string.rear_widget_card_dialog_hint),
+ summary = buildString {
+ if (cardsLoaded) {
+ append(
+ stringResource(
+ R.string.rear_widget_card_count,
+ cards.size,
+ )
+ )
+ append('\n')
+ }
+ append(stringResource(R.string.rear_widget_card_dialog_hint))
+ },
onClick = {},
bottomAction = {
Button(
@@ -219,63 +265,112 @@ fun CardManagerScreen(
}
}
- itemsIndexed(cards, key = { _, item -> item.id }) { _, item ->
- ModuleStyleManagerCard(
- title = item.title,
- summaryLines = listOf(
- stringResource(
- R.string.rear_widget_card_summary,
- item.packageName,
- item.business,
- item.priority,
- ),
- ),
- trailing = {
- Switch(
- checked = item.enabled,
- onCheckedChange = { checked ->
- val i = cards.indexOfFirst { it.id == item.id }
- if (i >= 0) {
- cards[i] = cards[i].copy(enabled = checked)
- persist()
- }
+ if (!dataCardsVisible) {
+ item {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ insideMargin = PaddingValues(vertical = 24.dp),
+ ) {
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ CircularProgressIndicator()
+ Text(text = stringResource(R.string.rear_widget_loading_data))
+ }
+ }
+ }
+ }
+ }
+
+ if (dataCardsVisible) {
+ itemsIndexed(cards, key = { _, item -> item.id }) { index, item ->
+ ArtStaggeredReveal(
+ visible = true,
+ revealKey = item.id,
+ delayMillis = (36 + index * 18).coerceAtMost(150),
+ ) {
+ ModuleStyleManagerCard(
+ title = item.title,
+ summaryLines = listOf(
+ stringResource(
+ R.string.rear_widget_card_summary,
+ item.packageName,
+ item.business,
+ item.priority,
+ ),
+ stringResource(
+ R.string.rear_widget_card_sticky_summary,
+ stringResource(
+ if (item.sticky) {
+ R.string.rear_wallpaper_schedule_on
+ } else {
+ R.string.rear_wallpaper_schedule_off
+ }
+ ),
+ ),
+ ),
+ trailing = {
+ Switch(
+ checked = item.enabled,
+ onCheckedChange = { checked ->
+ val i = cards.indexOfFirst { it.id == item.id }
+ if (i >= 0) {
+ cards[i] = cards[i].copy(enabled = checked)
+ scope.launch(Dispatchers.IO) {
+ RearWidgetManagerRepository.setCardEnabled(
+ context = context,
+ prefsManager = prefsManager,
+ cardId = item.id,
+ enabled = checked,
+ )
+ }
+ }
+ },
+ )
},
- )
- },
- onCardClick = { openEditDialog(item) },
- leftAction = {
- ModuleStyleIconAction(
- icon = Icons.Rounded.EditNote,
- onClick = { openEditDialog(item) },
- )
- },
- rightAction = {
- ModuleStyleDeleteAction(
- icon = MiuixIcons.Delete,
- text = stringResource(R.string.rear_widget_action_delete),
- onClick = {
- cards.remove(item)
- persist()
+ onCardClick = { openEditDialog(item) },
+ leftAction = {
+ ModuleStyleIconAction(
+ icon = Icons.Rounded.EditNote,
+ onClick = { openEditDialog(item) },
+ )
+ },
+ rightAction = {
+ ModuleStyleDeleteAction(
+ icon = MiuixIcons.Delete,
+ text = stringResource(R.string.rear_widget_action_delete),
+ onClick = {
+ cards.remove(item)
+ persist()
+ },
+ )
},
)
- },
- )
+ }
+ }
}
item {
- AnimatedVisibility(visible = cards.isEmpty()) {
- Card(modifier = Modifier.fillMaxWidth()) {
- Text(
- text = stringResource(R.string.rear_widget_empty_card),
- modifier = Modifier.padding(16.dp),
- )
+ if (dataCardsVisible && cards.isEmpty()) {
+ ArtRevealItem(visible = true, delayMillis = 40) {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = stringResource(R.string.rear_widget_empty_card),
+ modifier = Modifier.padding(16.dp),
+ )
+ }
}
}
}
}
}
- SuperDialog(
+ OverlayDialog(
show = showDialog,
title = stringResource(
if (editingCardId == null) R.string.rear_widget_add_card else R.string.rear_widget_edit_card,
@@ -317,6 +412,12 @@ fun CardManagerScreen(
label = stringResource(R.string.rear_widget_default_priority),
singleLine = true,
)
+ SwitchPreference(
+ title = stringResource(R.string.rear_widget_card_sticky),
+ summary = stringResource(R.string.rear_widget_card_sticky_desc),
+ checked = draftSticky,
+ onCheckedChange = { draftSticky = it },
+ )
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/config/ConfigNodeRows.kt b/app/src/main/java/hk/uwu/reareye/ui/components/config/ConfigNodeRows.kt
index 7d157af..a3a2b16 100644
--- a/app/src/main/java/hk/uwu/reareye/ui/components/config/ConfigNodeRows.kt
+++ b/app/src/main/java/hk/uwu/reareye/ui/components/config/ConfigNodeRows.kt
@@ -1,39 +1,54 @@
package hk.uwu.reareye.ui.components.config
-import android.graphics.Canvas
-import android.graphics.drawable.BitmapDrawable
+import android.annotation.SuppressLint
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
+import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.ImageBitmap
-import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import androidx.core.graphics.createBitmap
+import androidx.compose.ui.unit.sp
import hk.uwu.reareye.R
+import hk.uwu.reareye.ui.components.rememberPackageIconBitmap
import hk.uwu.reareye.ui.config.ConfigCategory
import hk.uwu.reareye.ui.config.ConfigCategoryIcon
import hk.uwu.reareye.ui.config.ConfigGroup
import hk.uwu.reareye.ui.config.ConfigItem
import hk.uwu.reareye.ui.config.ConfigKeys
import hk.uwu.reareye.ui.config.ConfigNode
+import hk.uwu.reareye.ui.config.ConfigType
import hk.uwu.reareye.ui.config.ModuleSettingsController
import hk.uwu.reareye.ui.config.PrefsManager
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
+import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Icon
-import top.yukonga.miuix.kmp.extra.SuperArrow
-import top.yukonga.miuix.kmp.extra.SuperSwitch
+import top.yukonga.miuix.kmp.basic.ListPopupColumn
+import top.yukonga.miuix.kmp.basic.ListPopupDefaults
+import top.yukonga.miuix.kmp.basic.PopupPositionProvider
+import top.yukonga.miuix.kmp.basic.Slider
+import top.yukonga.miuix.kmp.basic.SpinnerDefaults
+import top.yukonga.miuix.kmp.basic.SpinnerEntry
+import top.yukonga.miuix.kmp.basic.SpinnerItemImpl
+import top.yukonga.miuix.kmp.basic.Text
+import top.yukonga.miuix.kmp.overlay.OverlayListPopup
+import top.yukonga.miuix.kmp.preference.ArrowPreference
+import top.yukonga.miuix.kmp.preference.SwitchPreference
import top.yukonga.miuix.kmp.theme.MiuixTheme
@Composable
@@ -43,6 +58,7 @@ fun ConfigNodeRow(
onOpenCategory: (ConfigCategory) -> Unit,
onOpenAppList: (ConfigItem) -> Unit,
onOpenManager: (ConfigItem) -> Unit,
+ onPreferenceChanged: (ConfigItem) -> Unit = {},
) {
when (node) {
is ConfigCategory -> ConfigCategoryNodeRow(
@@ -57,13 +73,14 @@ fun ConfigNodeRow(
prefsManager = prefsManager,
onOpenAppList = onOpenAppList,
onOpenManager = onOpenManager,
+ onPreferenceChanged = onPreferenceChanged,
)
}
}
@Composable
fun ConfigCategoryNodeRow(category: ConfigCategory, onClick: () -> Unit) {
- SuperArrow(
+ ArrowPreference(
title = stringResource(category.titleRes),
summary = category.descriptionRes?.let { stringResource(it) },
startAction = category.icon?.let { icon ->
@@ -91,43 +108,20 @@ private fun ConfigCategoryStartIcon(icon: ConfigCategoryIcon) {
is ConfigCategoryIcon.Package -> {
val context = LocalContext.current
- var imageBitmap by remember(icon.packageName) { mutableStateOf(null) }
- var loadFinished by remember(icon.packageName) { mutableStateOf(false) }
-
- LaunchedEffect(icon.packageName) {
- val loadedBitmap = withContext(Dispatchers.IO) {
- runCatching {
- val drawable = context.packageManager.getApplicationIcon(icon.packageName)
- if (drawable is BitmapDrawable) {
- drawable.bitmap
- } else {
- val bmp = createBitmap(
- drawable.intrinsicWidth.takeIf { it > 0 } ?: 1,
- drawable.intrinsicHeight.takeIf { it > 0 } ?: 1
- )
- val canvas = Canvas(bmp)
- drawable.setBounds(0, 0, canvas.width, canvas.height)
- drawable.draw(canvas)
- bmp
- }
- }.getOrNull()?.asImageBitmap()
- }
-
- withContext(Dispatchers.Main) {
- imageBitmap = loadedBitmap
- loadFinished = true
- }
- }
+ val imageBitmap = rememberPackageIconBitmap(
+ packageManager = context.packageManager,
+ packageName = icon.packageName,
+ )
if (imageBitmap != null) {
Box(modifier = Modifier.size(28.dp), contentAlignment = Alignment.Center) {
Image(
- bitmap = imageBitmap!!,
+ bitmap = imageBitmap,
contentDescription = null,
modifier = Modifier.size(28.dp)
)
}
- } else if (!loadFinished) {
+ } else {
Spacer(modifier = Modifier.size(28.dp))
}
}
@@ -140,23 +134,30 @@ fun ConfigItemNodeRow(
prefsManager: PrefsManager,
onOpenAppList: (ConfigItem) -> Unit,
onOpenManager: (ConfigItem) -> Unit,
+ onPreferenceChanged: (ConfigItem) -> Unit = {},
) {
item.type.RenderInput(
item = item,
prefsManager = prefsManager,
onOpenAppList = onOpenAppList,
onOpenManager = onOpenManager,
+ onPreferenceChanged = onPreferenceChanged,
)
}
@Composable
-fun BooleanConfigInput(item: ConfigItem, defaultValue: Boolean, prefsManager: PrefsManager) {
+fun BooleanConfigInput(
+ item: ConfigItem,
+ defaultValue: Boolean,
+ prefsManager: PrefsManager,
+ onPreferenceChanged: (ConfigItem) -> Unit = {},
+) {
val context = LocalContext.current
var checked by remember(item.key) {
mutableStateOf(prefsManager.getBoolean(item.key, defaultValue))
}
- SuperSwitch(
+ SwitchPreference(
title = stringResource(item.titleRes),
summary = item.descriptionRes?.let { stringResource(it) },
checked = checked,
@@ -166,6 +167,7 @@ fun BooleanConfigInput(item: ConfigItem, defaultValue: Boolean, prefsManager: Pr
if (item.key == ConfigKeys.MODULE_HIDE_LAUNCHER_ENTRY) {
ModuleSettingsController.syncLauncherEntryVisibility(context, hidden = it)
}
+ onPreferenceChanged(item)
}
)
}
@@ -186,16 +188,247 @@ fun AppListConfigInput(
"$description\n$selectedSummary"
}
- SuperArrow(
+ ArrowPreference(
title = stringResource(item.titleRes),
summary = summary,
onClick = onClick
)
}
+@SuppressLint("LocalContextGetResourceValueCall")
+@Composable
+fun MaskMultiSelectConfigInput(
+ item: ConfigItem,
+ defaultValue: Int,
+ options: List,
+ prefsManager: PrefsManager,
+ onPreferenceChanged: (ConfigItem) -> Unit = {},
+) {
+ val context = LocalContext.current
+ val configuration = LocalConfiguration.current
+ val popupScope = rememberCoroutineScope()
+ var showModePopup by remember(item.key) { mutableStateOf(false) }
+ var selectedMask by remember(item.key) {
+ mutableIntStateOf(prefsManager.getInt(item.key, defaultValue))
+ }
+ val optionTitles = remember(item.key, options, configuration) {
+ options.map { context.getString(it.titleRes) }
+ }
+ val optionEntries = remember(item.key, optionTitles) {
+ optionTitles.map { SpinnerEntry(title = it) }
+ }
+
+ val selectedLabels = options
+ .mapIndexedNotNull { index, option ->
+ if ((selectedMask and option.maskValue) != 0) optionTitles[index] else null
+ }
+
+ val selectedSummary = if (selectedLabels.isEmpty()) {
+ stringResource(R.string.lyric_display_mode_none)
+ } else {
+ selectedLabels.joinToString(separator = " / ")
+ }
+ val description = item.descriptionRes?.let { stringResource(it) }
+ val summary = if (description.isNullOrBlank()) {
+ selectedSummary
+ } else {
+ "$description\n$selectedSummary"
+ }
+
+ ArrowPreference(
+ title = stringResource(item.titleRes),
+ summary = summary,
+ holdDownState = showModePopup,
+ onClick = { showModePopup = true }
+ )
+
+ OverlayListPopup(
+ show = showModePopup,
+ popupModifier = Modifier,
+ popupPositionProvider = ListPopupDefaults.DropdownPositionProvider,
+ alignment = PopupPositionProvider.Align.End,
+ enableWindowDim = true,
+ onDismissRequest = { showModePopup = false },
+ maxHeight = null,
+ minWidth = 220.dp,
+ renderInRootScaffold = true,
+ ) {
+ ListPopupColumn {
+ options.forEachIndexed { index, option ->
+ SpinnerItemImpl(
+ entry = optionEntries[index],
+ entryCount = options.size,
+ isSelected = (selectedMask and option.maskValue) != 0,
+ index = index,
+ spinnerColors = SpinnerDefaults.spinnerColors(),
+ onSelectedIndexChange = {
+ val nextMask = selectedMask xor option.maskValue
+ showModePopup = false
+ popupScope.launch {
+ withFrameNanos { }
+ selectedMask = nextMask
+ prefsManager.putInt(item.key, selectedMask)
+ onPreferenceChanged(item)
+ }
+ },
+ )
+ }
+ }
+ }
+}
+
+@SuppressLint("LocalContextGetResourceValueCall")
+@Composable
+fun EnumSingleSelectConfigInput(
+ item: ConfigItem,
+ defaultValue: Int,
+ options: List,
+ prefsManager: PrefsManager,
+ onPreferenceChanged: (ConfigItem) -> Unit = {},
+) {
+ val context = LocalContext.current
+ val configuration = LocalConfiguration.current
+ val popupScope = rememberCoroutineScope()
+ var showEnumPopup by remember(item.key) { mutableStateOf(false) }
+ var selectedValue by remember(item.key) {
+ mutableIntStateOf(prefsManager.getInt(item.key, defaultValue))
+ }
+ val optionTitles = remember(item.key, options, configuration) {
+ options.map { context.getString(it.titleRes) }
+ }
+ val optionEntries = remember(item.key, optionTitles) {
+ optionTitles.map { SpinnerEntry(title = it) }
+ }
+
+ val selectedLabel = options
+ .indexOfFirst { it.value == selectedValue }
+ .takeIf { it >= 0 }
+ ?.let { optionTitles[it] }
+ ?: optionTitles.firstOrNull().orEmpty()
+ val description = item.descriptionRes?.let { stringResource(it) }
+ val summary = if (description.isNullOrBlank()) {
+ selectedLabel
+ } else {
+ "$description\n$selectedLabel"
+ }
+
+ ArrowPreference(
+ title = stringResource(item.titleRes),
+ summary = summary,
+ holdDownState = showEnumPopup,
+ onClick = { showEnumPopup = true }
+ )
+
+ OverlayListPopup(
+ show = showEnumPopup,
+ popupModifier = Modifier,
+ popupPositionProvider = ListPopupDefaults.DropdownPositionProvider,
+ alignment = PopupPositionProvider.Align.End,
+ enableWindowDim = true,
+ onDismissRequest = { showEnumPopup = false },
+ maxHeight = null,
+ minWidth = 220.dp,
+ renderInRootScaffold = true,
+ ) {
+ ListPopupColumn {
+ options.forEachIndexed { index, option ->
+ SpinnerItemImpl(
+ entry = optionEntries[index],
+ entryCount = options.size,
+ isSelected = selectedValue == option.value,
+ index = index,
+ spinnerColors = SpinnerDefaults.spinnerColors(),
+ onSelectedIndexChange = {
+ showEnumPopup = false
+ popupScope.launch {
+ withFrameNanos { }
+ selectedValue = option.value
+ prefsManager.putInt(item.key, selectedValue)
+ onPreferenceChanged(item)
+ }
+ },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun FloatSliderConfigInput(
+ item: ConfigItem,
+ sliderConfig: ConfigType.FloatSlider,
+ prefsManager: PrefsManager,
+ onPreferenceChanged: (ConfigItem) -> Unit = {},
+) {
+ var selectedValue by remember(item.key) {
+ mutableFloatStateOf(
+ sliderConfig.normalizeValue(
+ prefsManager.getFloat(
+ item.key,
+ sliderConfig.defaultValue
+ )
+ )
+ )
+ }
+ val description = item.descriptionRes?.let { stringResource(it) }
+ val valueText = remember(selectedValue, sliderConfig) {
+ sliderConfig.formatValue(selectedValue)
+ }
+ val valueSummary = stringResource(R.string.config_slider_current_value, valueText)
+ val summary = if (description.isNullOrBlank()) {
+ valueSummary
+ } else {
+ "$description\n$valueSummary"
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 14.dp)
+ ) {
+ Text(
+ text = stringResource(item.titleRes),
+ fontSize = 17.sp,
+ )
+ Text(
+ text = summary,
+ style = MiuixTheme.textStyles.body2,
+ color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
+ modifier = Modifier.padding(top = 2.dp)
+ )
+ Slider(
+ value = selectedValue,
+ onValueChange = {
+ val normalizedValue = sliderConfig.normalizeValue(it)
+ selectedValue = normalizedValue
+ prefsManager.putFloat(item.key, normalizedValue)
+ onPreferenceChanged(item)
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 10.dp),
+ valueRange = sliderConfig.minValue..sliderConfig.maxValue,
+ steps = sliderConfig.steps,
+ )
+ Row(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = sliderConfig.formatValue(sliderConfig.minValue),
+ style = MiuixTheme.textStyles.body2,
+ color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ Text(
+ text = sliderConfig.formatValue(sliderConfig.maxValue),
+ style = MiuixTheme.textStyles.body2,
+ color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
+ )
+ }
+ }
+}
+
@Composable
fun ManagerConfigInput(item: ConfigItem, onClick: () -> Unit) {
- SuperArrow(
+ ArrowPreference(
title = stringResource(item.titleRes),
summary = item.descriptionRes?.let { stringResource(it) },
onClick = onClick,
diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/config/RearWallpaperManagerScreen.kt b/app/src/main/java/hk/uwu/reareye/ui/components/config/RearWallpaperManagerScreen.kt
new file mode 100644
index 0000000..554d76a
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/ui/components/config/RearWallpaperManagerScreen.kt
@@ -0,0 +1,928 @@
+package hk.uwu.reareye.ui.components.config
+
+import android.annotation.SuppressLint
+import android.widget.Toast
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.rounded.EditNote
+import androidx.compose.material.icons.rounded.Image
+import androidx.compose.material.icons.rounded.Refresh
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionInRoot
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import hk.uwu.reareye.R
+import hk.uwu.reareye.repository.rearwallpaper.RearWallpaperCatalog
+import hk.uwu.reareye.repository.rearwallpaper.RearWallpaperInfo
+import hk.uwu.reareye.repository.rearwallpaper.RearWallpaperRepository
+import hk.uwu.reareye.ui.components.card.ModuleStyleDeleteAction
+import hk.uwu.reareye.ui.components.card.ModuleStyleIconAction
+import hk.uwu.reareye.ui.components.card.SuperCard
+import hk.uwu.reareye.ui.components.rememberRearWallpaperPreviewBitmap
+import hk.uwu.reareye.ui.config.PrefsManager
+import hk.uwu.reareye.ui.theme.rearAcrylicEffect
+import hk.uwu.reareye.ui.theme.rearAcrylicSource
+import hk.uwu.reareye.ui.theme.rememberAcrylicHazeState
+import hk.uwu.reareye.ui.theme.rememberAcrylicHazeStyle
+import hk.uwu.reareye.widgetapi.RearWallpaperScheduleCodec
+import hk.uwu.reareye.widgetapi.RearWallpaperScheduleEntry
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import top.yukonga.miuix.kmp.basic.BasicComponent
+import top.yukonga.miuix.kmp.basic.BasicComponentDefaults
+import top.yukonga.miuix.kmp.basic.Button
+import top.yukonga.miuix.kmp.basic.ButtonDefaults
+import top.yukonga.miuix.kmp.basic.Card
+import top.yukonga.miuix.kmp.basic.CardDefaults
+import top.yukonga.miuix.kmp.basic.CircularProgressIndicator
+import top.yukonga.miuix.kmp.basic.HorizontalDivider
+import top.yukonga.miuix.kmp.basic.Icon
+import top.yukonga.miuix.kmp.basic.IconButton
+import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
+import top.yukonga.miuix.kmp.basic.Scaffold
+import top.yukonga.miuix.kmp.basic.Switch
+import top.yukonga.miuix.kmp.basic.Text
+import top.yukonga.miuix.kmp.basic.TextField
+import top.yukonga.miuix.kmp.basic.TopAppBar
+import top.yukonga.miuix.kmp.icon.MiuixIcons
+import top.yukonga.miuix.kmp.icon.extended.Delete
+import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet
+import top.yukonga.miuix.kmp.overlay.OverlayDialog
+import top.yukonga.miuix.kmp.theme.MiuixTheme
+import top.yukonga.miuix.kmp.utils.overScrollVertical
+import top.yukonga.miuix.kmp.utils.scrollEndHaptic
+import java.util.Locale
+import kotlin.math.roundToInt
+
+private data class ItemBounds(
+ val top: Float,
+ val height: Float,
+)
+
+private enum class WallpaperPickerMode { ADD_TO_SCHEDULE }
+
+private const val WALLPAPER_PREVIEW_RATIO = 1.6f
+private val SCHEDULE_ITEM_SHAPE = RoundedCornerShape(24.dp)
+
+@Composable
+fun RearWallpaperManagerScreen(
+ prefsManager: PrefsManager,
+ onBack: () -> Unit,
+) {
+ val context = LocalContext.current
+ val layoutDirection = LocalLayoutDirection.current
+ val scope = rememberCoroutineScope()
+ val scrollBehavior = MiuixScrollBehavior()
+ val hazeState = rememberAcrylicHazeState()
+ val hazeStyle = rememberAcrylicHazeStyle()
+ val listState = rememberLazyListState()
+
+ val wallpapers = remember { mutableStateListOf() }
+ val schedule = remember { mutableStateListOf() }
+
+ var currentWallpaperId by remember { mutableStateOf(null) }
+ var loading by remember { mutableStateOf(true) }
+ var refreshing by remember { mutableStateOf(false) }
+ var scheduleEnabled by remember { mutableStateOf(false) }
+ var pickerMode by remember { mutableStateOf(null) }
+
+ var editTargetId by remember { mutableStateOf(null) }
+ var delayInput by remember { mutableStateOf("") }
+
+ val scheduleItemBounds = remember { mutableStateMapOf() }
+ var draggedId by remember { mutableStateOf(null) }
+ var draggedInsertIndex by remember { mutableStateOf(null) }
+ var draggedStartTop by remember { mutableFloatStateOf(0f) }
+ var draggedItemHeight by remember { mutableFloatStateOf(0f) }
+ var draggedOffsetY by remember { mutableFloatStateOf(0f) }
+ var contentTopInRoot by remember { mutableFloatStateOf(0f) }
+
+ @SuppressLint("LocalContextGetResourceValueCall")
+ fun toast(resId: Int) {
+ Toast.makeText(context, context.getString(resId), Toast.LENGTH_SHORT).show()
+ }
+
+ fun persistSchedule() {
+ val snapshot = schedule.toList()
+ val enabled = scheduleEnabled && snapshot.isNotEmpty()
+ if (scheduleEnabled && snapshot.isEmpty()) {
+ scheduleEnabled = false
+ toast(R.string.rear_wallpaper_schedule_empty)
+ }
+ scope.launch {
+ val synced = withContext(Dispatchers.IO) {
+ RearWallpaperRepository.saveSchedule(prefsManager, snapshot)
+ RearWallpaperRepository.setScheduleEnabled(prefsManager, enabled)
+ RearWallpaperRepository.syncSchedule(context, enabled, snapshot)
+ }
+ if (!synced) toast(R.string.rear_wallpaper_schedule_sync_failed)
+ }
+ }
+
+ @SuppressLint("LocalContextGetResourceValueCall")
+ fun refreshCatalog(showSuccessToast: Boolean = false) {
+ scope.launch {
+ refreshing = true
+ val result = runCatching {
+ withContext(Dispatchers.IO) {
+ RearWallpaperRepository.loadCatalog(context)
+ }
+ }
+ result.onSuccess { catalog: RearWallpaperCatalog ->
+ wallpapers.clear()
+ wallpapers.addAll(catalog.wallpapers)
+ currentWallpaperId = catalog.currentWallpaperId
+ if (showSuccessToast) toast(R.string.rear_wallpaper_refresh_success)
+ }.onFailure {
+ Toast.makeText(
+ context,
+ context.getString(
+ R.string.rear_wallpaper_refresh_failed,
+ it.message ?: "unknown"
+ ),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ loading = false
+ refreshing = false
+ }
+ }
+
+ fun switchWallpaper(wallpaperId: Int) {
+ scope.launch {
+ val success = withContext(Dispatchers.IO) {
+ RearWallpaperRepository.switchWallpaper(context, wallpaperId)
+ }
+ if (success) {
+ currentWallpaperId = wallpaperId
+ pickerMode = null
+ } else {
+ toast(R.string.rear_wallpaper_switch_failed)
+ }
+ }
+ }
+
+ fun addToSchedule(wallpaperId: Int) {
+ if (schedule.any { it.wallpaperId == wallpaperId }) {
+ toast(R.string.rear_wallpaper_already_added)
+ return
+ }
+ schedule.add(
+ RearWallpaperScheduleEntry(
+ wallpaperId = wallpaperId,
+ delayMs = RearWallpaperScheduleCodec.DEFAULT_DELAY_MS,
+ )
+ )
+ persistSchedule()
+ pickerMode = null
+ toast(R.string.rear_wallpaper_added)
+ }
+
+ LaunchedEffect(Unit) {
+ schedule.clear()
+ schedule.addAll(RearWallpaperRepository.loadSchedule(prefsManager))
+ scheduleEnabled = RearWallpaperRepository.isScheduleEnabled(prefsManager)
+ refreshCatalog(showSuccessToast = false)
+ }
+
+ LaunchedEffect(schedule.map { it.wallpaperId }) {
+ val activeIds = schedule.mapTo(HashSet()) { it.wallpaperId }
+ scheduleItemBounds.keys.toList()
+ .filter { it !in activeIds }
+ .forEach(scheduleItemBounds::remove)
+ }
+
+ val wallpaperMap = wallpapers.associateBy { it.wallpaperId }
+ val renderedSchedule = previewScheduleEntries(schedule, draggedId, draggedInsertIndex)
+ val draggedEntry =
+ draggedId?.let { wallpaperId -> schedule.firstOrNull { it.wallpaperId == wallpaperId } }
+ val currentWallpaperName = wallpaperMap[currentWallpaperId]?.name
+ ?: stringResource(R.string.rear_wallpaper_current_none)
+ val statusLines = listOf(
+ stringResource(R.string.rear_wallpaper_status_current_line, currentWallpaperName),
+ stringResource(R.string.rear_wallpaper_status_count_line, wallpapers.size),
+ stringResource(
+ R.string.rear_wallpaper_status_schedule_line,
+ stringResource(
+ if (scheduleEnabled) {
+ R.string.rear_wallpaper_schedule_on
+ } else {
+ R.string.rear_wallpaper_schedule_off
+ }
+ ),
+ ),
+ )
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ modifier = Modifier.rearAcrylicEffect(hazeState, hazeStyle),
+ color = Color.Transparent,
+ title = stringResource(R.string.rear_wallpaper_manager),
+ navigationIconPadding = 12.dp,
+ actionIconPadding = 12.dp,
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ modifier = Modifier.graphicsLayer {
+ if (layoutDirection == LayoutDirection.Rtl) scaleX = -1f
+ },
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = null,
+ )
+ }
+ },
+ actions = {
+ IconButton(
+ onClick = { if (!refreshing) refreshCatalog(showSuccessToast = true) },
+ ) {
+ Icon(imageVector = Icons.Rounded.Refresh, contentDescription = null)
+ }
+ },
+ scrollBehavior = scrollBehavior,
+ )
+ },
+ ) { paddingValues ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .onGloballyPositioned { coordinates ->
+ contentTopInRoot = coordinates.positionInRoot().y
+ }
+ ) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .nestedScroll(scrollBehavior.nestedScrollConnection)
+ .scrollEndHaptic()
+ .overScrollVertical()
+ .rearAcrylicSource(hazeState)
+ .padding(horizontal = 12.dp),
+ state = listState,
+ contentPadding = PaddingValues(
+ top = paddingValues.calculateTopPadding() + 12.dp,
+ bottom = paddingValues.calculateBottomPadding() + 12.dp,
+ ),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ overscrollEffect = null,
+ userScrollEnabled = draggedId == null,
+ ) {
+ item {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ SuperCard(
+ title = stringResource(R.string.rear_wallpaper_status_title),
+ summary = statusLines.joinToString(separator = "\n"),
+ onClick = {},
+ bottomAction = {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Button(
+ onClick = {
+ pickerMode = WallpaperPickerMode.ADD_TO_SCHEDULE
+ },
+ colors = ButtonDefaults.buttonColorsPrimary(),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Add,
+ contentDescription = null,
+ modifier = Modifier.padding(end = 6.dp),
+ )
+ Text(stringResource(R.string.rear_wallpaper_add_sheet_trigger))
+ }
+ }
+ },
+ )
+ }
+ }
+ }
+
+ item {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ BasicComponent(
+ title = stringResource(R.string.rear_wallpaper_schedule_feature_title),
+ summary = stringResource(R.string.rear_wallpaper_schedule_hint),
+ summaryColor = BasicComponentDefaults.summaryColor(),
+ onClick = {},
+ endActions = {
+ Switch(
+ checked = scheduleEnabled,
+ onCheckedChange = { checked ->
+ if (checked && schedule.isEmpty()) {
+ toast(R.string.rear_wallpaper_schedule_empty)
+ } else {
+ scheduleEnabled = checked
+ persistSchedule()
+ }
+ },
+ )
+ },
+ )
+ }
+ }
+
+ item {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ BasicComponent(
+ title = stringResource(R.string.rear_wallpaper_schedule_title),
+ summary = if (schedule.isEmpty()) {
+ stringResource(R.string.rear_wallpaper_schedule_empty)
+ } else {
+ stringResource(R.string.rear_wallpaper_schedule_order_hint)
+ },
+ summaryColor = BasicComponentDefaults.summaryColor(),
+ onClick = {},
+ )
+ }
+ }
+ }
+
+ if (schedule.isNotEmpty()) {
+ items(schedule, key = { it.wallpaperId }) { entry ->
+ val wallpaper = wallpaperMap[entry.wallpaperId]
+ val isDragged = draggedId == entry.wallpaperId
+ val previewOffsetY = if (draggedId != null && !isDragged) {
+ calculatePreviewOffsetY(
+ schedule = schedule,
+ previewSchedule = renderedSchedule,
+ bounds = scheduleItemBounds,
+ wallpaperId = entry.wallpaperId,
+ )
+ } else {
+ 0f
+ }
+ val animatedPreviewOffsetY by animateFloatAsState(
+ targetValue = previewOffsetY,
+ label = "schedulePreviewOffset",
+ )
+ ScheduleItemCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .zIndex(if (isDragged) 1f else 0f)
+ .onGloballyPositioned { coordinates ->
+ scheduleItemBounds[entry.wallpaperId] = ItemBounds(
+ top = coordinates.positionInRoot().y,
+ height = coordinates.size.height.toFloat(),
+ )
+ }
+ .pointerInput(entry.wallpaperId) {
+ detectDragGesturesAfterLongPress(
+ onDragStart = {
+ draggedId = entry.wallpaperId
+ val bounds = scheduleItemBounds[entry.wallpaperId]
+ draggedInsertIndex =
+ schedule.indexOfFirst { it.wallpaperId == entry.wallpaperId }
+ draggedStartTop = bounds?.top ?: 0f
+ draggedItemHeight = bounds?.height ?: 0f
+ draggedOffsetY = 0f
+ },
+ onDragCancel = {
+ draggedId = null
+ draggedInsertIndex = null
+ draggedItemHeight = 0f
+ draggedOffsetY = 0f
+ },
+ onDragEnd = {
+ val finalSchedule = previewScheduleEntries(
+ schedule = schedule,
+ draggingId = draggedId,
+ insertIndex = draggedInsertIndex,
+ )
+ if (finalSchedule.map { it.wallpaperId } != schedule.map { it.wallpaperId }) {
+ schedule.clear()
+ schedule.addAll(finalSchedule)
+ persistSchedule()
+ }
+ draggedId = null
+ draggedInsertIndex = null
+ draggedItemHeight = 0f
+ draggedOffsetY = 0f
+ },
+ onDrag = { change, dragAmount ->
+ change.consume()
+ val draggingId =
+ draggedId ?: return@detectDragGesturesAfterLongPress
+ val currentHeight =
+ scheduleItemBounds[draggingId]?.height
+ ?: draggedItemHeight
+ draggedOffsetY += dragAmount.y
+ val draggedCenter =
+ draggedStartTop + draggedOffsetY + currentHeight / 2f
+ draggedInsertIndex = findDraggedInsertIndex(
+ schedule = schedule,
+ bounds = scheduleItemBounds,
+ draggedCenter = draggedCenter,
+ )
+ },
+ )
+ },
+ wallpaper = wallpaper,
+ scheduleEntry = entry,
+ isCurrent = currentWallpaperId == entry.wallpaperId,
+ isDragged = isDragged,
+ isDragPlaceholder = isDragged,
+ dragOffsetY = animatedPreviewOffsetY,
+ onEdit = {
+ editTargetId = entry.wallpaperId
+ delayInput = entry.delayMs.toString()
+ },
+ onDelete = {
+ schedule.removeAll { it.wallpaperId == entry.wallpaperId }
+ persistSchedule()
+ },
+ )
+ }
+ }
+
+ if (loading) {
+ item {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ CircularProgressIndicator()
+ Spacer(Modifier.width(10.dp))
+ Text(text = stringResource(R.string.rear_wallpaper_loading))
+ }
+ }
+ }
+ }
+
+ if (!loading && wallpapers.isEmpty()) {
+ item {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ SuperCard(
+ title = stringResource(R.string.rear_wallpaper_catalog_empty)
+ )
+ }
+ }
+ }
+ }
+
+ draggedEntry?.let { entry ->
+ ScheduleItemCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp)
+ .offset {
+ IntOffset(
+ x = 0,
+ y = (draggedStartTop - contentTopInRoot + draggedOffsetY).roundToInt(),
+ )
+ }
+ .zIndex(3f),
+ wallpaper = wallpaperMap[entry.wallpaperId],
+ scheduleEntry = entry,
+ isCurrent = currentWallpaperId == entry.wallpaperId,
+ isDragged = true,
+ isDragPlaceholder = false,
+ dragOffsetY = 0f,
+ onEdit = {
+ editTargetId = entry.wallpaperId
+ delayInput = entry.delayMs.toString()
+ },
+ onDelete = {
+ schedule.removeAll { it.wallpaperId == entry.wallpaperId }
+ draggedId = null
+ draggedInsertIndex = null
+ draggedItemHeight = 0f
+ draggedOffsetY = 0f
+ persistSchedule()
+ },
+ )
+ }
+ }
+ }
+
+ OverlayBottomSheet(
+ show = pickerMode != null,
+ title = stringResource(
+ when (pickerMode) {
+ WallpaperPickerMode.ADD_TO_SCHEDULE -> R.string.rear_wallpaper_picker_add
+ null -> R.string.rear_wallpaper_manager
+ }
+ ),
+ onDismissRequest = { pickerMode = null },
+ ) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = 560.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ contentPadding = PaddingValues(top = 4.dp, bottom = 34.dp),
+ ) {
+ if (loading) {
+ item {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ CircularProgressIndicator()
+ Spacer(Modifier.width(10.dp))
+ Text(text = stringResource(R.string.rear_wallpaper_loading))
+ }
+ }
+ }
+
+ items(wallpapers, key = { it.wallpaperId }) { wallpaper ->
+ WallpaperPickerCard(
+ wallpaper = wallpaper,
+ isCurrent = wallpaper.wallpaperId == currentWallpaperId,
+ inSchedule = schedule.any { it.wallpaperId == wallpaper.wallpaperId },
+ onUseNow = { switchWallpaper(wallpaper.wallpaperId) },
+ onAddToSchedule = { addToSchedule(wallpaper.wallpaperId) },
+ )
+ }
+ }
+ }
+
+ OverlayDialog(
+ show = editTargetId != null,
+ title = stringResource(R.string.rear_wallpaper_edit_interval),
+ onDismissRequest = { editTargetId = null },
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .imePadding(),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ TextField(
+ value = delayInput,
+ onValueChange = { delayInput = it.filter(Char::isDigit) },
+ modifier = Modifier.fillMaxWidth(),
+ label = stringResource(R.string.rear_wallpaper_interval_millis),
+ singleLine = true,
+ )
+ Button(
+ onClick = {
+ val targetId = editTargetId ?: return@Button
+ val delayMs = delayInput.toLongOrNull()
+ if (delayMs == null || delayMs < RearWallpaperScheduleCodec.MIN_DELAY_MS) {
+ toast(R.string.rear_wallpaper_interval_invalid)
+ return@Button
+ }
+ val index = schedule.indexOfFirst { it.wallpaperId == targetId }
+ if (index >= 0) {
+ schedule[index] = schedule[index].copy(delayMs = delayMs)
+ persistSchedule()
+ }
+ editTargetId = null
+ },
+ colors = ButtonDefaults.buttonColorsPrimary(),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(R.string.rear_widget_confirm))
+ }
+ Button(
+ onClick = { editTargetId = null },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(R.string.rear_widget_cancel))
+ }
+ }
+ }
+}
+
+private fun previewScheduleEntries(
+ schedule: List,
+ draggingId: Int?,
+ insertIndex: Int?,
+): List {
+ if (draggingId == null || insertIndex == null) return schedule
+
+ val draggedIndex = schedule.indexOfFirst { it.wallpaperId == draggingId }
+ if (draggedIndex < 0) return schedule
+
+ val draggedEntry = schedule[draggedIndex]
+ val remaining = schedule.filterNot { it.wallpaperId == draggingId }
+ val safeInsertIndex = insertIndex.coerceIn(0, remaining.size)
+ return buildList(schedule.size) {
+ addAll(remaining.take(safeInsertIndex))
+ add(draggedEntry)
+ addAll(remaining.drop(safeInsertIndex))
+ }
+}
+
+private fun findDraggedInsertIndex(
+ schedule: List,
+ bounds: Map,
+ draggedCenter: Float,
+): Int {
+ val orderedBounds = schedule.mapNotNull { entry ->
+ bounds[entry.wallpaperId]?.let { entry.wallpaperId to it }
+ }
+
+ if (orderedBounds.size <= 1) return 0
+
+ val boundaries = orderedBounds
+ .zipWithNext { (_, current), (_, next) ->
+ val currentCenter = current.top + current.height / 2f
+ val nextCenter = next.top + next.height / 2f
+ (currentCenter + nextCenter) / 2f
+ }
+
+ return boundaries.count { draggedCenter > it }
+}
+
+private fun calculatePreviewOffsetY(
+ schedule: List,
+ previewSchedule: List,
+ bounds: Map,
+ wallpaperId: Int,
+): Float {
+ val currentTop = bounds[wallpaperId]?.top ?: return 0f
+ val previewIndex = previewSchedule.indexOfFirst { it.wallpaperId == wallpaperId }
+ if (previewIndex < 0) return 0f
+
+ val slotId = schedule.getOrNull(previewIndex)?.wallpaperId ?: return 0f
+ val targetTop = bounds[slotId]?.top ?: return 0f
+ return targetTop - currentTop
+}
+
+@Composable
+private fun ScheduleItemCard(
+ modifier: Modifier,
+ wallpaper: RearWallpaperInfo?,
+ scheduleEntry: RearWallpaperScheduleEntry,
+ isCurrent: Boolean,
+ isDragged: Boolean,
+ isDragPlaceholder: Boolean,
+ dragOffsetY: Float,
+ onEdit: () -> Unit,
+ onDelete: () -> Unit,
+) {
+ val locale = LocalConfiguration.current.locales[0] ?: Locale.getDefault()
+ val title = wallpaper?.name ?: stringResource(
+ R.string.rear_wallpaper_unavailable,
+ scheduleEntry.wallpaperId,
+ )
+ val unavailableSummary = stringResource(R.string.rear_wallpaper_unavailable_desc)
+ val intervalSummary = stringResource(
+ R.string.rear_wallpaper_interval_summary,
+ formatDelay(scheduleEntry.delayMs, locale),
+ )
+ val currentLabel = stringResource(R.string.rear_wallpaper_current)
+ val animatedScale by animateFloatAsState(if (isDragged) 1.018f else 1f, label = "scheduleScale")
+ val animatedShadow by animateDpAsState(if (isDragged) 18.dp else 0.dp, label = "scheduleShadow")
+
+ Card(
+ modifier = modifier
+ .alpha(if (isDragPlaceholder) 0f else 1f)
+ .graphicsLayer {
+ translationY = dragOffsetY
+ scaleX = animatedScale
+ scaleY = animatedScale
+ }
+ .shadow(animatedShadow, SCHEDULE_ITEM_SHAPE, clip = false),
+ insideMargin = PaddingValues(12.dp),
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ BasicComponent(
+ title = title,
+ summary = listOfNotNull(
+ wallpaper?.title ?: unavailableSummary,
+ intervalSummary,
+ currentLabel.takeIf { isCurrent },
+ ).joinToString(separator = "\n"),
+ startAction = {
+ WallpaperPreview(
+ cachePath = wallpaper?.cachePath,
+ modifier = Modifier
+ .width(88.dp)
+ .aspectRatio(WALLPAPER_PREVIEW_RATIO),
+ )
+ },
+ onClick = onEdit,
+ )
+ HorizontalDivider(
+ modifier = Modifier.padding(vertical = 2.dp),
+ thickness = 0.5.dp,
+ color = MiuixTheme.colorScheme.outline.copy(alpha = 0.5f),
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ModuleStyleIconAction(
+ icon = Icons.Rounded.EditNote,
+ onClick = onEdit,
+ )
+ Spacer(Modifier.weight(1f))
+ ModuleStyleDeleteAction(
+ icon = MiuixIcons.Delete,
+ text = stringResource(R.string.rear_widget_action_delete),
+ onClick = onDelete,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun WallpaperPickerCard(
+ wallpaper: RearWallpaperInfo,
+ isCurrent: Boolean,
+ inSchedule: Boolean,
+ onUseNow: () -> Unit,
+ onAddToSchedule: () -> Unit,
+) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 1.dp),
+ insideMargin = PaddingValues(8.dp),
+ colors = CardDefaults.defaultColors(
+ color = MiuixTheme.colorScheme.secondaryContainer.copy(alpha = 0.92f),
+ contentColor = MiuixTheme.colorScheme.onSurface,
+ ),
+ ) {
+ BasicComponent(
+ title = wallpaper.name,
+ summary = buildString {
+ append(wallpaper.title)
+ if (isCurrent) {
+ append('\n')
+ append(stringResource(R.string.rear_wallpaper_current))
+ }
+ },
+ startAction = {
+ WallpaperPreview(
+ cachePath = wallpaper.cachePath,
+ modifier = Modifier
+ .width(104.dp)
+ .aspectRatio(WALLPAPER_PREVIEW_RATIO),
+ )
+ },
+ bottomAction = {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Button(
+ onClick = onAddToSchedule,
+ enabled = !inSchedule,
+ colors = ButtonDefaults.buttonColorsPrimary(),
+ modifier = Modifier.weight(1f),
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Add,
+ contentDescription = null,
+ modifier = Modifier.padding(end = 6.dp),
+ )
+ Text(
+ text = stringResource(
+ if (inSchedule) {
+ R.string.rear_wallpaper_already_in_schedule
+ } else {
+ R.string.rear_wallpaper_add_to_schedule
+ }
+ )
+ )
+ }
+ Button(
+ onClick = onUseNow,
+ colors = ButtonDefaults.buttonColors(
+ color = MiuixTheme.colorScheme.surface,
+ contentColor = MiuixTheme.colorScheme.onSurface,
+ ),
+ modifier = Modifier.weight(1f),
+ ) {
+ Text(text = stringResource(R.string.rear_wallpaper_set_now))
+ }
+ }
+ },
+ onClick = {},
+ )
+ }
+}
+
+@Composable
+private fun WallpaperPreview(
+ cachePath: String?,
+ modifier: Modifier = Modifier,
+) {
+ val bitmap = rememberRearWallpaperPreviewBitmap(cachePath)
+ val iconTint = Color.White.copy(alpha = 0.82f)
+
+ Box(
+ modifier = modifier
+ .clip(RoundedCornerShape(18.dp))
+ .background(Color.Black)
+ .graphicsLayer { clip = true },
+ contentAlignment = Alignment.Center,
+ ) {
+ if (bitmap != null) {
+ Image(
+ bitmap = bitmap,
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop,
+ )
+ } else {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .clip(RoundedCornerShape(18.dp))
+ .background(Color.Black),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Image,
+ contentDescription = null,
+ tint = iconTint,
+ )
+ }
+ }
+ }
+}
+
+private fun formatDelay(delayMs: Long, locale: Locale): String {
+ val totalSeconds = ((delayMs + 999L) / 1000L).coerceAtLeast(1L)
+ val minutes = totalSeconds / 60L
+ val seconds = totalSeconds % 60L
+ val isChinese = locale.language.startsWith("zh")
+
+ if (minutes <= 0L) {
+ return if (isChinese) {
+ "${totalSeconds}秒"
+ } else {
+ "${totalSeconds} s"
+ }
+ }
+
+ if (seconds == 0L) {
+ return if (isChinese) {
+ "${minutes}分钟"
+ } else {
+ "${minutes} min"
+ }
+ }
+
+ return if (isChinese) {
+ "${minutes}分钟 ${seconds}秒"
+ } else {
+ "${minutes} min ${seconds} s"
+ }
+}
diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/motion/ArtMotion.kt b/app/src/main/java/hk/uwu/reareye/ui/components/motion/ArtMotion.kt
new file mode 100644
index 0000000..2122d61
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/ui/components/motion/ArtMotion.kt
@@ -0,0 +1,211 @@
+package hk.uwu.reareye.ui.components.motion
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.core.FastOutLinearInEasing
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.LinearOutSlowInEasing
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.delay
+
+private const val DEFAULT_VISIBILITY_DELAY = 0
+
+@Composable
+fun ArtRevealItem(
+ visible: Boolean,
+ delayMillis: Int = 0,
+ content: @Composable () -> Unit,
+) {
+ ArtRevealContainer(
+ visible = visible,
+ delayMillis = delayMillis,
+ enterAlphaDurationMillis = 240,
+ enterTransformDurationMillis = 360,
+ exitAlphaDurationMillis = 120,
+ exitTransformDurationMillis = 160,
+ hiddenEnterScale = 0.985f,
+ hiddenExitScale = 0.992f,
+ slideDivisor = 14,
+ ) {
+ content()
+ }
+}
+
+@Composable
+fun ArtStaggeredReveal(
+ visible: Boolean,
+ revealKey: Any,
+ delayMillis: Int = DEFAULT_VISIBILITY_DELAY,
+ content: @Composable () -> Unit,
+) {
+ ArtRevealContainer(
+ visible = visible,
+ revealKey = revealKey,
+ delayMillis = delayMillis,
+ enterAlphaDurationMillis = 220,
+ enterTransformDurationMillis = 320,
+ exitAlphaDurationMillis = 90,
+ exitTransformDurationMillis = 120,
+ hiddenEnterScale = 0.988f,
+ hiddenExitScale = 0.992f,
+ slideDivisor = 18,
+ ) {
+ content()
+ }
+}
+
+@Composable
+private fun ArtRevealContainer(
+ visible: Boolean,
+ delayMillis: Int,
+ enterAlphaDurationMillis: Int,
+ enterTransformDurationMillis: Int,
+ exitAlphaDurationMillis: Int,
+ exitTransformDurationMillis: Int,
+ hiddenEnterScale: Float,
+ hiddenExitScale: Float,
+ slideDivisor: Int,
+ revealKey: Any = Unit,
+ content: @Composable () -> Unit,
+) {
+ val density = LocalDensity.current
+ var contentHeightPx by remember(revealKey) { mutableIntStateOf(0) }
+ var startedVisible by remember(revealKey) { mutableStateOf(false) }
+
+ LaunchedEffect(visible, revealKey, delayMillis) {
+ if (visible) {
+ if (delayMillis > 0) {
+ delay(delayMillis.toLong())
+ }
+ startedVisible = true
+ } else {
+ startedVisible = false
+ }
+ }
+
+ val targetAlpha = if (startedVisible) 1f else 0f
+ if (startedVisible) 1f else hiddenExitScale
+ val hiddenOffsetPx = remember(contentHeightPx, density, slideDivisor) {
+ if (contentHeightPx > 0) {
+ contentHeightPx / slideDivisor.toFloat()
+ } else {
+ with(density) { 18.dp.toPx() }
+ }
+ }
+ val targetTranslationY = if (startedVisible) 0f else hiddenOffsetPx
+
+ val alpha by animateFloatAsState(
+ targetValue = targetAlpha,
+ animationSpec = tween(
+ durationMillis = if (startedVisible) enterAlphaDurationMillis else exitAlphaDurationMillis,
+ delayMillis = 0,
+ easing = if (startedVisible) LinearOutSlowInEasing else FastOutLinearInEasing,
+ ),
+ label = "ArtRevealAlpha",
+ )
+ val scale by animateFloatAsState(
+ targetValue = if (startedVisible) 1f else if (visible) hiddenEnterScale else hiddenExitScale,
+ animationSpec = tween(
+ durationMillis = if (startedVisible) enterTransformDurationMillis else exitTransformDurationMillis,
+ delayMillis = 0,
+ easing = if (startedVisible) FastOutSlowInEasing else FastOutLinearInEasing,
+ ),
+ label = "ArtRevealScale",
+ )
+ val translationY by animateFloatAsState(
+ targetValue = targetTranslationY,
+ animationSpec = tween(
+ durationMillis = if (startedVisible) enterTransformDurationMillis else exitTransformDurationMillis,
+ delayMillis = 0,
+ easing = if (startedVisible) FastOutSlowInEasing else FastOutLinearInEasing,
+ ),
+ label = "ArtRevealTranslationY",
+ )
+
+ Box(
+ modifier = Modifier
+ .onSizeChanged { contentHeightPx = it.height }
+ .graphicsLayer {
+ this.alpha = alpha
+ scaleX = scale
+ scaleY = scale
+ this.translationY = translationY
+ }
+ ) {
+ content()
+ }
+}
+
+@Composable
+fun ArtSwapContent(
+ targetState: T,
+ modifier: Modifier = Modifier,
+ contentKey: (T) -> Any = { it as Any },
+ content: @Composable (T) -> Unit,
+) {
+ AnimatedContent(
+ modifier = modifier.graphicsLayer { clip = true },
+ targetState = targetState,
+ contentKey = contentKey,
+ transitionSpec = {
+ (fadeIn(
+ animationSpec = tween(
+ durationMillis = 200,
+ delayMillis = 40,
+ easing = LinearOutSlowInEasing,
+ )
+ ) + scaleIn(
+ initialScale = 0.992f,
+ animationSpec = tween(
+ durationMillis = 260,
+ easing = FastOutSlowInEasing,
+ ),
+ ) + slideInVertically(
+ animationSpec = tween(
+ durationMillis = 260,
+ easing = FastOutSlowInEasing,
+ )
+ ) { it / 20 }) togetherWith (
+ fadeOut(
+ animationSpec = tween(
+ durationMillis = 100,
+ easing = FastOutLinearInEasing,
+ )
+ ) + scaleOut(
+ targetScale = 0.996f,
+ animationSpec = tween(
+ durationMillis = 120,
+ easing = FastOutLinearInEasing,
+ ),
+ ) + slideOutVertically(
+ animationSpec = tween(
+ durationMillis = 120,
+ easing = FastOutLinearInEasing,
+ )
+ ) { it / 24 }
+ )
+ },
+ label = "ArtSwapContent",
+ content = { state -> content(state) },
+ )
+}
diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/navigation/DampedDragAnimation.kt b/app/src/main/java/hk/uwu/reareye/ui/components/navigation/DampedDragAnimation.kt
new file mode 100644
index 0000000..d5621c3
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/ui/components/navigation/DampedDragAnimation.kt
@@ -0,0 +1,140 @@
+package hk.uwu.reareye.ui.components.navigation
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.MutatorMutex
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.input.pointer.util.VelocityTracker
+import androidx.compose.ui.unit.IntSize
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.android.awaitFrame
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlin.math.abs
+
+class DampedDragAnimation(
+ private val animationScope: CoroutineScope,
+ val initialValue: Float,
+ val valueRange: ClosedRange,
+ val visibilityThreshold: Float,
+ val initialScale: Float,
+ val pressedScale: Float,
+ val canDrag: (Offset) -> Boolean = { true },
+ val onDragStarted: DampedDragAnimation.(position: Offset) -> Unit,
+ val onDragStopped: DampedDragAnimation.() -> Unit,
+ val onDrag: DampedDragAnimation.(size: IntSize, dragAmount: Offset) -> Unit,
+) {
+
+ private val valueAnimationSpec = spring(1f, 1000f, visibilityThreshold)
+ private val velocityAnimationSpec = spring(0.5f, 300f, visibilityThreshold * 10f)
+ private val pressProgressAnimationSpec = spring(1f, 1000f, 0.001f)
+ private val scaleXAnimationSpec = spring(0.6f, 250f, 0.001f)
+ private val scaleYAnimationSpec = spring(0.7f, 250f, 0.001f)
+
+ private val valueAnimation = Animatable(initialValue, visibilityThreshold)
+ private val velocityAnimation = Animatable(0f, 5f)
+ private val pressProgressAnimation = Animatable(0f, 0.001f)
+ private val scaleXAnimation = Animatable(initialScale, 0.001f)
+ private val scaleYAnimation = Animatable(initialScale, 0.001f)
+
+ private val mutatorMutex = MutatorMutex()
+ private val velocityTracker = VelocityTracker()
+
+ val value: Float get() = valueAnimation.value
+ val targetValue: Float get() = valueAnimation.targetValue
+ val pressProgress: Float get() = pressProgressAnimation.value
+ val scaleX: Float get() = scaleXAnimation.value
+ val scaleY: Float get() = scaleYAnimation.value
+ val velocity: Float get() = velocityAnimation.value
+
+ val modifier: Modifier = Modifier.pointerInput(Unit) {
+ inspectDragGestures(
+ onDragStart = { down ->
+ onDragStarted(down.position)
+ press()
+ },
+ onDragEnd = {
+ onDragStopped()
+ release()
+ },
+ onDragCancel = {
+ onDragStopped()
+ release()
+ },
+ ) { change, dragAmount ->
+ val position = change.position
+ val previousPosition = change.previousPosition
+
+ val isInside = canDrag(position)
+ val wasInside = canDrag(previousPosition)
+
+ if (isInside && wasInside) {
+ onDrag(size, dragAmount)
+ }
+ }
+ }
+
+ fun press() {
+ velocityTracker.resetTracking()
+ animationScope.launch {
+ launch { pressProgressAnimation.animateTo(1f, pressProgressAnimationSpec) }
+ launch { scaleXAnimation.animateTo(pressedScale, scaleXAnimationSpec) }
+ launch { scaleYAnimation.animateTo(pressedScale, scaleYAnimationSpec) }
+ }
+ }
+
+ fun release() {
+ animationScope.launch {
+ awaitFrame()
+ if (value != targetValue) {
+ val threshold = (valueRange.endInclusive - valueRange.start) * 0.025f
+ snapshotFlow { valueAnimation.value }
+ .filter { abs(it - valueAnimation.targetValue) < threshold }
+ .first()
+ }
+ launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) }
+ launch { scaleXAnimation.animateTo(initialScale, scaleXAnimationSpec) }
+ launch { scaleYAnimation.animateTo(initialScale, scaleYAnimationSpec) }
+ }
+ }
+
+ fun updateValue(value: Float) {
+ val targetValue = value.coerceIn(valueRange)
+ animationScope.launch {
+ launch {
+ valueAnimation.animateTo(
+ targetValue,
+ valueAnimationSpec
+ ) { updateVelocity() }
+ }
+ }
+ }
+
+ fun animateToValue(value: Float) {
+ animationScope.launch {
+ mutatorMutex.mutate {
+ press()
+ val targetValue = value.coerceIn(valueRange)
+ launch { valueAnimation.animateTo(targetValue, valueAnimationSpec) }
+ if (velocity != 0f) {
+ launch { velocityAnimation.animateTo(0f, velocityAnimationSpec) }
+ }
+ release()
+ }
+ }
+ }
+
+ private fun updateVelocity() {
+ velocityTracker.addPosition(
+ System.currentTimeMillis(),
+ Offset(value, 0f),
+ )
+ val targetVelocity =
+ velocityTracker.calculateVelocity().x / (valueRange.endInclusive - valueRange.start)
+ animationScope.launch { velocityAnimation.animateTo(targetVelocity, velocityAnimationSpec) }
+ }
+}
diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/navigation/DragGestureInspector.kt b/app/src/main/java/hk/uwu/reareye/ui/components/navigation/DragGestureInspector.kt
new file mode 100644
index 0000000..3e469d5
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/ui/components/navigation/DragGestureInspector.kt
@@ -0,0 +1,82 @@
+package hk.uwu.reareye.ui.components.navigation
+
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.AwaitPointerEventScope
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.PointerId
+import androidx.compose.ui.input.pointer.PointerInputChange
+import androidx.compose.ui.input.pointer.PointerInputScope
+import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
+import androidx.compose.ui.input.pointer.positionChange
+import androidx.compose.ui.util.fastFirstOrNull
+
+suspend fun PointerInputScope.inspectDragGestures(
+ onDragStart: (down: PointerInputChange) -> Unit = {},
+ onDragEnd: (change: PointerInputChange) -> Unit = {},
+ onDragCancel: () -> Unit = {},
+ onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit,
+) {
+ awaitEachGesture {
+ val initialDown = awaitFirstDown(false, PointerEventPass.Initial)
+ val down = awaitFirstDown(false)
+
+ onDragStart(down)
+ onDrag(initialDown, Offset.Zero)
+ val upEvent = drag(
+ pointerId = initialDown.id,
+ onDrag = { onDrag(it, it.positionChange()) },
+ )
+ if (upEvent == null) {
+ onDragCancel()
+ } else {
+ onDragEnd(upEvent)
+ }
+ }
+}
+
+private suspend inline fun AwaitPointerEventScope.drag(
+ pointerId: PointerId,
+ onDrag: (PointerInputChange) -> Unit,
+): PointerInputChange? {
+ val isPointerUp = currentEvent.changes.fastFirstOrNull { it.id == pointerId }?.pressed != true
+ if (isPointerUp) {
+ return null
+ }
+ var pointer = pointerId
+ while (true) {
+ val change = awaitDragOrUp(pointer) ?: return null
+ if (change.isConsumed) {
+ return null
+ }
+ if (change.changedToUpIgnoreConsumed()) {
+ return change
+ }
+ onDrag(change)
+ pointer = change.id
+ }
+}
+
+private suspend inline fun AwaitPointerEventScope.awaitDragOrUp(
+ pointerId: PointerId,
+): PointerInputChange? {
+ var pointer = pointerId
+ while (true) {
+ val event = awaitPointerEvent()
+ val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null
+ if (dragEvent.changedToUpIgnoreConsumed()) {
+ val otherDown = event.changes.fastFirstOrNull { it.pressed }
+ if (otherDown == null) {
+ return dragEvent
+ } else {
+ pointer = otherDown.id
+ }
+ } else {
+ val hasDragged = dragEvent.previousPosition != dragEvent.position
+ if (hasDragged) {
+ return dragEvent
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/navigation/FloatingBottomBar.kt b/app/src/main/java/hk/uwu/reareye/ui/components/navigation/FloatingBottomBar.kt
new file mode 100644
index 0000000..987cefa
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/ui/components/navigation/FloatingBottomBar.kt
@@ -0,0 +1,386 @@
+package hk.uwu.reareye.ui.components.navigation
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.EaseOut
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.fastCoerceIn
+import androidx.compose.ui.util.fastRoundToInt
+import androidx.compose.ui.util.lerp
+import com.kyant.backdrop.Backdrop
+import com.kyant.backdrop.backdrops.layerBackdrop
+import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
+import com.kyant.backdrop.backdrops.rememberLayerBackdrop
+import com.kyant.backdrop.drawBackdrop
+import com.kyant.backdrop.effects.blur
+import com.kyant.backdrop.effects.lens
+import com.kyant.backdrop.effects.vibrancy
+import com.kyant.backdrop.highlight.Highlight
+import com.kyant.backdrop.shadow.InnerShadow
+import com.kyant.backdrop.shadow.Shadow
+import com.kyant.capsule.ContinuousCapsule
+import kotlinx.coroutines.launch
+import top.yukonga.miuix.kmp.theme.MiuixTheme
+import kotlin.math.abs
+import kotlin.math.sign
+
+val LocalFloatingBottomBarTabScale = staticCompositionLocalOf { { 1f } }
+
+@Composable
+fun RowScope.FloatingBottomBarItem(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ val scale = LocalFloatingBottomBarTabScale.current
+ Column(
+ modifier
+ .clip(ContinuousCapsule)
+ .clickable(
+ interactionSource = null,
+ indication = null,
+ role = Role.Tab,
+ onClick = onClick,
+ )
+ .fillMaxHeight()
+ .weight(1f)
+ .graphicsLayer {
+ val scaleValue = scale()
+ scaleX = scaleValue
+ scaleY = scaleValue
+ },
+ verticalArrangement = Arrangement.spacedBy(1.dp, Alignment.CenterVertically),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ content = content,
+ )
+}
+
+@Composable
+fun FloatingBottomBar(
+ modifier: Modifier = Modifier,
+ selectedIndex: Int,
+ onSelected: (index: Int) -> Unit,
+ backdrop: Backdrop,
+ tabsCount: Int,
+ isBlurEnabled: Boolean = true,
+ shadowVisibilityProgress: Float = 1f,
+ content: @Composable RowScope.() -> Unit,
+) {
+ val isInLightTheme = MiuixTheme.colorScheme.surface.luminance() >= 0.5f
+ val accentColor = MiuixTheme.colorScheme.primary
+ val containerColor = if (isBlurEnabled) {
+ MiuixTheme.colorScheme.surface.copy(alpha = 0.4f)
+ } else {
+ MiuixTheme.colorScheme.surface
+ }
+
+ val tabsBackdrop = if (isBlurEnabled) rememberLayerBackdrop() else null
+ val density = LocalDensity.current
+ val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
+ val animationScope = rememberCoroutineScope()
+
+ var tabWidthPx by remember { mutableFloatStateOf(0f) }
+ var totalWidthPx by remember { mutableFloatStateOf(0f) }
+
+ val offsetAnimation = remember { Animatable(0f) }
+ val panelOffset by remember(density) {
+ derivedStateOf {
+ if (totalWidthPx == 0f) 0f else {
+ val fraction = (offsetAnimation.value / totalWidthPx).fastCoerceIn(-1f, 1f)
+ with(density) {
+ 4f.dp.toPx() * fraction.sign * EaseOut.transform(abs(fraction))
+ }
+ }
+ }
+ }
+
+ var currentIndex by remember { mutableIntStateOf(selectedIndex) }
+
+ class DampedDragAnimationHolder {
+ var instance: DampedDragAnimation? = null
+ }
+
+ val holder = remember { DampedDragAnimationHolder() }
+
+ val dampedDragAnimation = remember(animationScope, tabsCount, density, isLtr) {
+ DampedDragAnimation(
+ animationScope = animationScope,
+ initialValue = selectedIndex.toFloat(),
+ valueRange = 0f..(tabsCount - 1).toFloat(),
+ visibilityThreshold = 0.001f,
+ initialScale = 1f,
+ pressedScale = 78f / 56f,
+ canDrag = { offset ->
+ val anim = holder.instance ?: return@DampedDragAnimation true
+ if (tabWidthPx == 0f) return@DampedDragAnimation false
+
+ val currentValue = anim.value
+ val indicatorX = currentValue * tabWidthPx
+ val padding = with(density) { 4.dp.toPx() }
+ val globalTouchX = if (isLtr) {
+ val touchX = indicatorX + offset.x
+ padding + touchX
+ } else {
+ totalWidthPx - padding - tabWidthPx - indicatorX + offset.x
+ }
+ globalTouchX in 0f..totalWidthPx
+ },
+ onDragStarted = {},
+ onDragStopped = {
+ val targetIndex = targetValue.fastRoundToInt().fastCoerceIn(0, tabsCount - 1)
+ currentIndex = targetIndex
+ animateToValue(targetIndex.toFloat())
+ animationScope.launch {
+ offsetAnimation.animateTo(0f, spring(1f, 300f, 0.5f))
+ }
+ },
+ onDrag = { _, dragAmount ->
+ if (tabWidthPx > 0) {
+ updateValue(
+ (targetValue + dragAmount.x / tabWidthPx * if (isLtr) 1f else -1f)
+ .fastCoerceIn(0f, (tabsCount - 1).toFloat())
+ )
+ animationScope.launch {
+ offsetAnimation.snapTo(offsetAnimation.value + dragAmount.x)
+ }
+ }
+ },
+ ).also { holder.instance = it }
+ }
+
+ LaunchedEffect(selectedIndex) {
+ if (currentIndex != selectedIndex) {
+ currentIndex = selectedIndex
+ dampedDragAnimation.animateToValue(selectedIndex.toFloat())
+ }
+ }
+ LaunchedEffect(currentIndex) {
+ if (currentIndex != selectedIndex) {
+ onSelected(currentIndex)
+ }
+ }
+
+ val interactiveHighlight = if (isBlurEnabled) {
+ remember(animationScope, tabWidthPx) {
+ InteractiveHighlight(
+ animationScope = animationScope,
+ position = { size, _ ->
+ Offset(
+ if (isLtr) (dampedDragAnimation.value + 0.5f) * tabWidthPx + panelOffset
+ else size.width - (dampedDragAnimation.value + 0.5f) * tabWidthPx + panelOffset,
+ size.height / 2f,
+ )
+ },
+ )
+ }
+ } else {
+ null
+ }
+
+ Box(
+ modifier = modifier.width(IntrinsicSize.Min),
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ Row(
+ Modifier
+ .onGloballyPositioned { coords ->
+ totalWidthPx = coords.size.width.toFloat()
+ val contentWidthPx = totalWidthPx - with(density) { 8.dp.toPx() }
+ tabWidthPx = contentWidthPx / tabsCount
+ }
+ .graphicsLayer { translationX = panelOffset }
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = {},
+ )
+ .drawBackdrop(
+ backdrop = backdrop,
+ shape = { ContinuousCapsule },
+ effects = {
+ if (isBlurEnabled) {
+ vibrancy()
+ blur(8f.dp.toPx())
+ lens(24f.dp.toPx(), 24f.dp.toPx())
+ }
+ },
+ highlight = {
+ Highlight.Default.copy(alpha = if (isBlurEnabled) 1f else 0f)
+ },
+ shadow = {
+ Shadow.Default.copy(
+ color = Color.Black.copy(if (isInLightTheme) 0.1f else 0.2f),
+ alpha = shadowVisibilityProgress,
+ )
+ },
+ layerBlock = {
+ if (isBlurEnabled) {
+ val progress = dampedDragAnimation.pressProgress
+ val scale = lerp(1f, 1f + 16f.dp.toPx() / size.width, progress)
+ scaleX = scale
+ scaleY = scale
+ }
+ },
+ onDrawSurface = { drawRect(containerColor) },
+ )
+ .then(interactiveHighlight?.modifier ?: Modifier)
+ .height(64.dp)
+ .padding(4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ content = content,
+ )
+
+ if (isBlurEnabled) {
+ CompositionLocalProvider(
+ LocalFloatingBottomBarTabScale provides {
+ lerp(1f, 1.2f, dampedDragAnimation.pressProgress)
+ }
+ ) {
+ Row(
+ Modifier
+ .clearAndSetSemantics {}
+ .alpha(0f)
+ .layerBackdrop(tabsBackdrop!!)
+ .graphicsLayer { translationX = panelOffset }
+ .drawBackdrop(
+ backdrop = backdrop,
+ shape = { ContinuousCapsule },
+ effects = {
+ val progress = dampedDragAnimation.pressProgress
+ vibrancy()
+ blur(8f.dp.toPx())
+ lens(24f.dp.toPx() * progress, 24f.dp.toPx() * progress)
+ },
+ highlight = {
+ Highlight.Default.copy(alpha = dampedDragAnimation.pressProgress)
+ },
+ onDrawSurface = { drawRect(containerColor) },
+ )
+ .then(interactiveHighlight!!.modifier)
+ .height(56.dp)
+ .padding(horizontal = 4.dp)
+ .graphicsLayer(colorFilter = ColorFilter.tint(accentColor)),
+ verticalAlignment = Alignment.CenterVertically,
+ content = content,
+ )
+ }
+ }
+
+ if (tabWidthPx > 0f) {
+ Box(
+ Modifier
+ .padding(horizontal = 4.dp)
+ .graphicsLayer {
+ val contentWidth = totalWidthPx - with(density) { 8.dp.toPx() }
+ val singleTabWidth = contentWidth / tabsCount
+ val progressOffset = dampedDragAnimation.value * singleTabWidth
+
+ translationX = if (isLtr) {
+ progressOffset + panelOffset
+ } else {
+ -progressOffset + panelOffset
+ }
+ }
+ .then(interactiveHighlight?.gestureModifier ?: Modifier)
+ .then(dampedDragAnimation.modifier)
+ .drawBackdrop(
+ backdrop = if (isBlurEnabled) {
+ rememberCombinedBackdrop(backdrop, tabsBackdrop!!)
+ } else {
+ backdrop
+ },
+ shape = { ContinuousCapsule },
+ effects = {
+ if (isBlurEnabled) {
+ val progress = dampedDragAnimation.pressProgress
+ lens(10f.dp.toPx() * progress, 14f.dp.toPx() * progress, true)
+ }
+ },
+ highlight = {
+ Highlight.Default.copy(
+ alpha = if (isBlurEnabled) dampedDragAnimation.pressProgress else 0f,
+ )
+ },
+ shadow = {
+ Shadow(
+ alpha = if (isBlurEnabled) {
+ dampedDragAnimation.pressProgress * shadowVisibilityProgress
+ } else {
+ 0f
+ }
+ )
+ },
+ innerShadow = {
+ InnerShadow(
+ radius = 8f.dp * dampedDragAnimation.pressProgress,
+ alpha = if (isBlurEnabled) dampedDragAnimation.pressProgress else 0f,
+ )
+ },
+ layerBlock = {
+ if (isBlurEnabled) {
+ scaleX = dampedDragAnimation.scaleX
+ scaleY = dampedDragAnimation.scaleY
+ val velocity = dampedDragAnimation.velocity / 10f
+ scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.2f, 0.2f)
+ scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.2f, 0.2f)
+ }
+ },
+ onDrawSurface = {
+ val progress =
+ if (isBlurEnabled) dampedDragAnimation.pressProgress else 0f
+ drawRect(
+ color = if (isInLightTheme) {
+ Color.Black.copy(0.1f)
+ } else {
+ Color.White.copy(0.1f)
+ },
+ alpha = 1f - progress,
+ )
+ drawRect(Color.Black.copy(alpha = 0.03f * progress))
+ },
+ )
+ .height(56.dp)
+ .width(with(density) { ((totalWidthPx - 8.dp.toPx()) / tabsCount).toDp() })
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/navigation/InteractiveHighlight.kt b/app/src/main/java/hk/uwu/reareye/ui/components/navigation/InteractiveHighlight.kt
new file mode 100644
index 0000000..a3725ff
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/ui/components/navigation/InteractiveHighlight.kt
@@ -0,0 +1,105 @@
+package hk.uwu.reareye.ui.components.navigation
+
+import android.annotation.SuppressLint
+import android.graphics.RuntimeShader
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.VectorConverter
+import androidx.compose.animation.core.VisibilityThreshold
+import androidx.compose.animation.core.spring
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ShaderBrush
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.util.fastCoerceIn
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import org.intellij.lang.annotations.Language
+
+@SuppressLint("NewApi")
+class InteractiveHighlight(
+ val animationScope: CoroutineScope,
+ val position: (size: Size, offset: Offset) -> Offset = { _, offset -> offset },
+) {
+
+ private val pressProgressAnimationSpec = spring(0.5f, 300f, 0.001f)
+ private val positionAnimationSpec = spring(0.5f, 300f, Offset.VisibilityThreshold)
+
+ private val pressProgressAnimation = Animatable(0f, 0.001f)
+ private val positionAnimation =
+ Animatable(Offset.Zero, Offset.VectorConverter, Offset.VisibilityThreshold)
+
+ private var startPosition = Offset.Zero
+
+ @Language("AGSL")
+ private val shader = RuntimeShader(
+ """
+ uniform float2 size;
+ layout(color) uniform half4 color;
+ uniform float radius;
+ uniform float2 position;
+
+ half4 main(float2 coord) {
+ float dist = distance(coord, position);
+ float intensity = smoothstep(radius, radius * 0.5, dist);
+ return color * intensity;
+ }"""
+ )
+
+ val modifier: Modifier = Modifier.drawWithContent {
+ val progress = pressProgressAnimation.value
+ if (progress > 0f) {
+ drawRect(
+ Color.White.copy(0.06f * progress),
+ blendMode = BlendMode.Plus,
+ )
+ shader.apply {
+ val position = position(size, positionAnimation.value)
+ setFloatUniform("size", size.width, size.height)
+ setColorUniform("color", Color.White.copy(0.12f * progress).toArgb())
+ setFloatUniform("radius", size.minDimension * 1.2f)
+ setFloatUniform(
+ "position",
+ position.x.fastCoerceIn(0f, size.width),
+ position.y.fastCoerceIn(0f, size.height),
+ )
+ }
+ drawRect(
+ ShaderBrush(shader),
+ blendMode = BlendMode.Plus,
+ )
+ }
+
+ drawContent()
+ }
+
+ val gestureModifier: Modifier = Modifier.pointerInput(animationScope) {
+ inspectDragGestures(
+ onDragStart = { down ->
+ startPosition = down.position
+ animationScope.launch {
+ launch { pressProgressAnimation.animateTo(1f, pressProgressAnimationSpec) }
+ launch { positionAnimation.snapTo(startPosition) }
+ }
+ },
+ onDragEnd = {
+ animationScope.launch {
+ launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) }
+ launch { positionAnimation.animateTo(startPosition, positionAnimationSpec) }
+ }
+ },
+ onDragCancel = {
+ animationScope.launch {
+ launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) }
+ launch { positionAnimation.animateTo(startPosition, positionAnimationSpec) }
+ }
+ },
+ ) { change, _ ->
+ animationScope.launch { positionAnimation.snapTo(change.position) }
+ }
+ }
+}
diff --git a/app/src/main/java/hk/uwu/reareye/ui/components/navigation/RearNavigationBar.kt b/app/src/main/java/hk/uwu/reareye/ui/components/navigation/RearNavigationBar.kt
new file mode 100644
index 0000000..18033d2
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/ui/components/navigation/RearNavigationBar.kt
@@ -0,0 +1,139 @@
+package hk.uwu.reareye.ui.components.navigation
+
+import android.os.Build
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Cottage
+import androidx.compose.material.icons.rounded.Info
+import androidx.compose.material.icons.rounded.Settings
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.kyant.backdrop.Backdrop
+import hk.uwu.reareye.R
+import hk.uwu.reareye.ui.config.ModuleNavigationBarMode
+import top.yukonga.miuix.kmp.basic.Icon
+import top.yukonga.miuix.kmp.basic.NavigationBar
+import top.yukonga.miuix.kmp.basic.NavigationBarDisplayMode
+import top.yukonga.miuix.kmp.basic.NavigationBarItem
+import top.yukonga.miuix.kmp.basic.Text
+import top.yukonga.miuix.kmp.theme.MiuixTheme
+
+@Immutable
+private data class NavigationDestination(
+ val route: String,
+ val label: String,
+ val icon: ImageVector,
+)
+
+private const val HOME_ROUTE = "home"
+private const val CONFIG_ROUTE = "config"
+private const val ABOUT_ROUTE = "about"
+
+@Composable
+fun RearNavigationBar(
+ modifier: Modifier = Modifier,
+ currentScreen: String,
+ navigationBarMode: ModuleNavigationBarMode,
+ backdrop: Backdrop,
+ shadowVisibilityProgress: Float = 1f,
+ onScreenSelected: (String) -> Unit,
+) {
+ val homeLabel = stringResource(R.string.home_navigation)
+ val configLabel = stringResource(R.string.configuration_navigation)
+ val aboutLabel = stringResource(R.string.about_navigation)
+ val items = remember(homeLabel, configLabel, aboutLabel) {
+ listOf(
+ NavigationDestination(
+ route = HOME_ROUTE,
+ label = homeLabel,
+ icon = Icons.Rounded.Cottage,
+ ),
+ NavigationDestination(
+ route = CONFIG_ROUTE,
+ label = configLabel,
+ icon = Icons.Rounded.Settings,
+ ),
+ NavigationDestination(
+ route = ABOUT_ROUTE,
+ label = aboutLabel,
+ icon = Icons.Rounded.Info,
+ ),
+ )
+ }
+
+ if (navigationBarMode == ModuleNavigationBarMode.NORMAL) {
+ NavigationBar(
+ modifier = modifier,
+ color = MiuixTheme.colorScheme.surface,
+ mode = NavigationBarDisplayMode.IconAndText,
+ ) {
+ items.forEach { item ->
+ NavigationBarItem(
+ selected = currentScreen == item.route,
+ onClick = { onScreenSelected(item.route) },
+ icon = item.icon,
+ label = item.label,
+ )
+ }
+ }
+ return
+ }
+
+ val enableGlass = navigationBarMode == ModuleNavigationBarMode.FLOATING_GLASS &&
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
+ val selectedIndex = items.indexOfFirst { it.route == currentScreen }.coerceAtLeast(0)
+
+ FloatingBottomBar(
+ modifier = modifier
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = {},
+ )
+ .padding(
+ bottom = 12.dp + WindowInsets.navigationBars.asPaddingValues()
+ .calculateBottomPadding()
+ ),
+ selectedIndex = selectedIndex,
+ onSelected = { onScreenSelected(items[it].route) },
+ backdrop = backdrop,
+ tabsCount = items.size,
+ isBlurEnabled = enableGlass,
+ shadowVisibilityProgress = shadowVisibilityProgress,
+ ) {
+ items.forEachIndexed { index, item ->
+ FloatingBottomBarItem(
+ onClick = { onScreenSelected(items[index].route) },
+ modifier = Modifier.defaultMinSize(minWidth = 76.dp),
+ ) {
+ Icon(
+ imageVector = item.icon,
+ contentDescription = item.label,
+ tint = MiuixTheme.colorScheme.onSurface,
+ )
+ Text(
+ text = item.label,
+ fontSize = 11.sp,
+ lineHeight = 14.sp,
+ color = MiuixTheme.colorScheme.onSurface,
+ maxLines = 1,
+ softWrap = false,
+ overflow = TextOverflow.Visible,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/hk/uwu/reareye/ui/config/Config.kt b/app/src/main/java/hk/uwu/reareye/ui/config/Config.kt
index 0231312..01c3555 100644
--- a/app/src/main/java/hk/uwu/reareye/ui/config/Config.kt
+++ b/app/src/main/java/hk/uwu/reareye/ui/config/Config.kt
@@ -1,11 +1,18 @@
package hk.uwu.reareye.ui.config
+import androidx.annotation.IntRange
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import hk.uwu.reareye.ui.components.config.AppListConfigInput
import hk.uwu.reareye.ui.components.config.BooleanConfigInput
+import hk.uwu.reareye.ui.components.config.EnumSingleSelectConfigInput
+import hk.uwu.reareye.ui.components.config.FloatSliderConfigInput
import hk.uwu.reareye.ui.components.config.ManagerConfigInput
+import hk.uwu.reareye.ui.components.config.MaskMultiSelectConfigInput
+import java.util.Locale
+import kotlin.math.pow
+import kotlin.math.roundToInt
sealed class ConfigType {
@Composable
@@ -14,10 +21,21 @@ sealed class ConfigType {
prefsManager: PrefsManager,
onOpenAppList: (ConfigItem) -> Unit,
onOpenManager: (ConfigItem) -> Unit,
+ onPreferenceChanged: (ConfigItem) -> Unit,
)
open val defaultStringSet: Set = emptySet()
+ data class MaskOption(
+ @param:StringRes val titleRes: Int,
+ val maskValue: Int,
+ )
+
+ data class EnumOption(
+ @param:StringRes val titleRes: Int,
+ val value: Int,
+ )
+
data class BooleanVal(val defaultValue: Boolean = false) : ConfigType() {
@Composable
override fun RenderInput(
@@ -25,11 +43,13 @@ sealed class ConfigType {
prefsManager: PrefsManager,
onOpenAppList: (ConfigItem) -> Unit,
onOpenManager: (ConfigItem) -> Unit,
+ onPreferenceChanged: (ConfigItem) -> Unit,
) {
BooleanConfigInput(
item = item,
defaultValue = defaultValue,
- prefsManager = prefsManager
+ prefsManager = prefsManager,
+ onPreferenceChanged = onPreferenceChanged,
)
}
}
@@ -44,19 +64,115 @@ sealed class ConfigType {
prefsManager: PrefsManager,
onOpenAppList: (ConfigItem) -> Unit,
onOpenManager: (ConfigItem) -> Unit,
+ onPreferenceChanged: (ConfigItem) -> Unit,
) {
AppListConfigInput(
item = item,
defaultValues = defaultValues,
prefsManager = prefsManager,
- onClick = { onOpenAppList(item) }
+ onClick = { onOpenAppList(item) },
+ )
+ }
+ }
+
+ data class MaskMultiSelect(
+ val defaultValue: Int,
+ val options: List,
+ ) : ConfigType() {
+ @Composable
+ override fun RenderInput(
+ item: ConfigItem,
+ prefsManager: PrefsManager,
+ onOpenAppList: (ConfigItem) -> Unit,
+ onOpenManager: (ConfigItem) -> Unit,
+ onPreferenceChanged: (ConfigItem) -> Unit,
+ ) {
+ MaskMultiSelectConfigInput(
+ item = item,
+ defaultValue = defaultValue,
+ options = options,
+ prefsManager = prefsManager,
+ onPreferenceChanged = onPreferenceChanged,
+ )
+ }
+ }
+
+ data class EnumSingleSelect(
+ val defaultValue: Int,
+ val options: List,
+ ) : ConfigType() {
+ @Composable
+ override fun RenderInput(
+ item: ConfigItem,
+ prefsManager: PrefsManager,
+ onOpenAppList: (ConfigItem) -> Unit,
+ onOpenManager: (ConfigItem) -> Unit,
+ onPreferenceChanged: (ConfigItem) -> Unit,
+ ) {
+ EnumSingleSelectConfigInput(
+ item = item,
+ defaultValue = defaultValue,
+ options = options,
+ prefsManager = prefsManager,
+ onPreferenceChanged = onPreferenceChanged,
)
}
}
+ data class FloatSlider(
+ val defaultValue: Float,
+ val minValue: Float,
+ val maxValue: Float,
+ @param:IntRange(from = 0) val steps: Int = 0,
+ @param:IntRange(from = 0) val decimalPlaces: Int = 2,
+ val valueFormatter: ((Float) -> String)? = null,
+ ) : ConfigType() {
+ init {
+ require(minValue < maxValue) { "minValue should be less than maxValue" }
+ require(defaultValue in minValue..maxValue) { "defaultValue should be within [minValue, maxValue]" }
+ require(steps >= 0) { "steps should be >= 0" }
+ require(decimalPlaces >= 0) { "decimalPlaces should be >= 0" }
+ }
+
+ @Composable
+ override fun RenderInput(
+ item: ConfigItem,
+ prefsManager: PrefsManager,
+ onOpenAppList: (ConfigItem) -> Unit,
+ onOpenManager: (ConfigItem) -> Unit,
+ onPreferenceChanged: (ConfigItem) -> Unit,
+ ) {
+ FloatSliderConfigInput(
+ item = item,
+ sliderConfig = this,
+ prefsManager = prefsManager,
+ onPreferenceChanged = onPreferenceChanged,
+ )
+ }
+
+ fun normalizeValue(value: Float): Float {
+ val clampedValue = value.coerceIn(minValue, maxValue)
+ val safePrecision = decimalPlaces.coerceIn(0, 6)
+ if (safePrecision == 0) return clampedValue.roundToInt().toFloat()
+
+ val factor = 10.0.pow(safePrecision.toDouble())
+ return ((clampedValue * factor).roundToInt() / factor).toFloat()
+ }
+
+ fun formatValue(value: Float): String {
+ val normalizedValue = normalizeValue(value)
+ valueFormatter?.let { return it(normalizedValue) }
+
+ val safePrecision = decimalPlaces.coerceIn(0, 6)
+ return "%.${safePrecision}f".format(Locale.getDefault(), normalizedValue)
+ }
+ }
+
enum class ManagerType {
+ REAR_WALLPAPER,
BUSINESS,
CARD,
+ BUSINESS_EXTRA,
}
data class Manager(val managerType: ManagerType) : ConfigType() {
@@ -66,6 +182,7 @@ sealed class ConfigType {
prefsManager: PrefsManager,
onOpenAppList: (ConfigItem) -> Unit,
onOpenManager: (ConfigItem) -> Unit,
+ onPreferenceChanged: (ConfigItem) -> Unit,
) {
ManagerConfigInput(
item = item,
diff --git a/app/src/main/java/hk/uwu/reareye/ui/config/ModuleConfig.kt b/app/src/main/java/hk/uwu/reareye/ui/config/ModuleConfig.kt
index 9198e4b..38a2920 100644
--- a/app/src/main/java/hk/uwu/reareye/ui/config/ModuleConfig.kt
+++ b/app/src/main/java/hk/uwu/reareye/ui/config/ModuleConfig.kt
@@ -1,11 +1,17 @@
package hk.uwu.reareye.ui.config
+import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Tune
import hk.uwu.reareye.R
+import hk.uwu.reareye.lyrics.LyricParser
+import hk.uwu.reareye.ui.theme.AppThemeMode
+import kotlin.math.roundToInt
object ConfigKeys {
const val MODULE_HIDE_LAUNCHER_ENTRY = "module_hide_launcher_entry"
+ const val MODULE_THEME_MODE = "module_theme_mode"
+ const val MODULE_NAVIGATION_BAR_MODE = "module_navigation_bar_mode"
const val HOOK_ACTIVITIES_WHITELIST = "enable_activities_whitelist_hook"
const val ACTIVITIES_WHITELIST_APPS = "activities_whitelist_apps"
@@ -17,10 +23,18 @@ object ConfigKeys {
const val HOOK_MUSIC_CONTROLS_FORCE_UPDATE = "enable_music_controls_force_update"
const val HOOK_VIDEO_LOOPING = "enable_video_looping"
+ const val CFG_REAR_WALLPAPER_MANAGER = "cfg_rear_wallpaper_manager"
+ const val REAR_WALLPAPER_SCHEDULE_ENABLED = "rear_wallpaper_schedule_enabled"
+ const val REAR_WALLPAPER_SCHEDULE_DATA = "rear_wallpaper_schedule_data"
+ const val REAR_WALLPAPER_SCHEDULE_NEXT_AT = "rear_wallpaper_schedule_next_at"
+
const val CFG_REAR_WIDGET_BUSINESS_MANAGER = "cfg_rear_widget_business_manager"
const val CFG_REAR_WIDGET_CARD_MANAGER = "cfg_rear_widget_card_manager"
+ const val CFG_REAR_WIDGET_BUSINESS_EXTRA_MANAGER = "cfg_rear_widget_business_extra_manager"
const val REAR_WIDGET_BUSINESS_DATA = "rear_widget_business_data"
const val REAR_WIDGET_CARD_DATA = "rear_widget_card_data"
+ const val REAR_WIDGET_BUSINESS_EXTRA_CONFIG_DATA = "rear_widget_business_extra_config_data"
+ const val HOOK_ALLOW_REAR_FOCUS_NOTICES = "enable_allow_rear_focus_notices"
const val HOOK_BACKGROUND_WHITELIST = "enable_background_whitelist_hook"
const val BACKGROUND_WHITELIST_APPS = "background_whitelist_apps"
@@ -30,6 +44,75 @@ object ConfigKeys {
const val HOOK_UNLOCK_VIDEO_RESTRICTIONS = "enable_unlock_video_restrictions"
const val HOOK_UNLOCK_TEMPLATE_MAXIMUM_LIMIT = "enable_unlock_template_maximum_limit"
+ const val HOOK_UNMUTE_VIDEO_WALLPAPER = "enable_unmute_video_wallpaper"
+ const val VIDEO_WALLPAPER_VOLUME = "unmute_video_wallpaper_volume"
+ const val VIDEO_WALLPAPER_VOLUME_DEFAULT = 0.0f
+
+ const val LYRIC_DISPLAY_MODE = "lyric_display_mode"
+ val LYRIC_DISPLAY_MODE_DEFAULT = LyricParser.DisplayMode.ORIGINAL.mask or
+ LyricParser.DisplayMode.TRANSLATION.mask or
+ LyricParser.DisplayMode.ROMANIZATION.mask
+ const val LYRIC_SHOW_ARTIST_BEFORE_FIRST_LINE = "lyric_show_artist_before_first_line"
+
+ const val LYRIC_PROVIDER = "lyric_provider"
+ val LYRIC_PROVIDER_DEFAULT = LyricProvider.LYRICON.value
+
+ const val SUPER_LYRIC_DISPLAY_MODE = "super_lyric_display_mode"
+ val SUPER_LYRIC_DISPLAY_MODE_DEFAULT = LyricParser.DisplayMode.ORIGINAL.mask
+
+ const val HOOK_DISABLE_REAR_SCREEN_COVER = "enable_hook_rear_screen_cover"
+
+ const val MORE_DEBUG = "enable_more_debug_logging"
+}
+
+enum class ModuleNavigationBarMode(
+ val value: Int,
+ @param:StringRes val titleRes: Int,
+) {
+ NORMAL(
+ value = 0,
+ titleRes = R.string.module_navigation_bar_mode_normal,
+ ),
+ FLOATING(
+ value = 1,
+ titleRes = R.string.module_navigation_bar_mode_floating,
+ ),
+ FLOATING_GLASS(
+ value = 2,
+ titleRes = R.string.module_navigation_bar_mode_floating_glass,
+ );
+
+ companion object {
+ val default = NORMAL
+ val selectableEntries = listOf(
+ NORMAL,
+ FLOATING,
+ FLOATING_GLASS,
+ )
+
+ fun fromValue(value: Int): ModuleNavigationBarMode {
+ return entries.firstOrNull { it.value == value } ?: default
+ }
+ }
+}
+
+enum class LyricProvider(val value: Int, val titleRes: Int) {
+ LYRICON(value = 0, titleRes = R.string.lyric_provider_lyricon),
+ SUPER_LYRIC(value = 1, titleRes = R.string.lyric_provider_superlyric);
+
+ companion object {
+ fun fromValue(value: Int): LyricProvider {
+ return entries.firstOrNull { it.value == value } ?: LYRICON
+ }
+ }
+}
+
+private fun LyricParser.DisplayMode.toTitleRes(): Int {
+ return when (this) {
+ LyricParser.DisplayMode.ORIGINAL -> R.string.lyric_display_mode_original
+ LyricParser.DisplayMode.TRANSLATION -> R.string.lyric_display_mode_translation
+ LyricParser.DisplayMode.ROMANIZATION -> R.string.lyric_display_mode_romanization
+ }
}
val REAREyeConfig = listOf(
@@ -38,16 +121,49 @@ val REAREyeConfig = listOf(
titleRes = R.string.category_module_settings,
descriptionRes = R.string.category_module_settings_desc,
children = listOf(
+ ConfigItem(
+ key = ConfigKeys.MODULE_THEME_MODE,
+ titleRes = R.string.module_theme_mode,
+ descriptionRes = R.string.module_theme_mode_desc,
+ type = ConfigType.EnumSingleSelect(
+ defaultValue = AppThemeMode.default.value,
+ options = AppThemeMode.selectableEntries.map {
+ ConfigType.EnumOption(
+ titleRes = it.titleRes,
+ value = it.value,
+ )
+ },
+ ),
+ ),
+ ConfigItem(
+ key = ConfigKeys.MODULE_NAVIGATION_BAR_MODE,
+ titleRes = R.string.module_navigation_bar_mode,
+ descriptionRes = R.string.module_navigation_bar_mode_desc,
+ type = ConfigType.EnumSingleSelect(
+ defaultValue = ModuleNavigationBarMode.default.value,
+ options = ModuleNavigationBarMode.selectableEntries.map {
+ ConfigType.EnumOption(
+ titleRes = it.titleRes,
+ value = it.value,
+ )
+ },
+ ),
+ ),
ConfigItem(
key = ConfigKeys.MODULE_HIDE_LAUNCHER_ENTRY,
titleRes = R.string.hide_launcher_entry,
descriptionRes = R.string.hide_launcher_entry_desc,
type = ConfigType.BooleanVal(defaultValue = false)
+ ),
+ ConfigItem(
+ key = ConfigKeys.MORE_DEBUG,
+ titleRes = R.string.cfg_more_debug,
+ type = ConfigType.BooleanVal(defaultValue = false)
)
)
),
ConfigCategory(
- icon = ConfigCategoryIcon.Package("com.android.systemui"),
+ icon = ConfigCategoryIcon.Package("system"),
titleRes = R.string.category_system,
children = listOf(
ConfigCategory(
@@ -104,6 +220,12 @@ val REAREyeConfig = listOf(
key = ConfigKeys.HOOK_SKIP_LOCK_BACK_HOME,
titleRes = R.string.skip_lock_back_home,
type = ConfigType.BooleanVal(defaultValue = false)
+ ),
+ ConfigItem(
+ key = ConfigKeys.HOOK_DISABLE_REAR_SCREEN_COVER,
+ titleRes = R.string.cfg_disable_rear_screen_cover,
+ descriptionRes = R.string.cfg_disable_rear_screen_cover_desc,
+ type = ConfigType.BooleanVal(defaultValue = false)
)
)
),
@@ -111,6 +233,42 @@ val REAREyeConfig = listOf(
icon = ConfigCategoryIcon.Package("com.xiaomi.subscreencenter"),
titleRes = R.string.category_subscreencenter,
children = listOf(
+ ConfigCategory(
+ titleRes = R.string.rear_widget_manager_category,
+ descriptionRes = R.string.rear_widget_manager_category_desc,
+ children = listOf(
+ ConfigItem(
+ key = ConfigKeys.CFG_REAR_WIDGET_BUSINESS_MANAGER,
+ titleRes = R.string.rear_widget_business_manager,
+ descriptionRes = R.string.rear_widget_business_manager_desc,
+ type = ConfigType.Manager(ConfigType.ManagerType.BUSINESS),
+ ),
+ ConfigItem(
+ key = ConfigKeys.CFG_REAR_WIDGET_CARD_MANAGER,
+ titleRes = R.string.rear_widget_card_manager,
+ descriptionRes = R.string.rear_widget_card_manager_desc,
+ type = ConfigType.Manager(ConfigType.ManagerType.CARD),
+ ),
+ ConfigItem(
+ key = ConfigKeys.CFG_REAR_WALLPAPER_MANAGER,
+ titleRes = R.string.rear_wallpaper_manager,
+ descriptionRes = R.string.rear_wallpaper_manager_desc,
+ type = ConfigType.Manager(ConfigType.ManagerType.REAR_WALLPAPER),
+ ),
+ ConfigItem(
+ key = ConfigKeys.CFG_REAR_WIDGET_BUSINESS_EXTRA_MANAGER,
+ titleRes = R.string.rear_widget_business_extra_manager,
+ descriptionRes = R.string.rear_widget_business_extra_manager_desc,
+ type = ConfigType.Manager(ConfigType.ManagerType.BUSINESS_EXTRA),
+ ),
+ ConfigItem(
+ key = ConfigKeys.HOOK_ALLOW_REAR_FOCUS_NOTICES,
+ titleRes = R.string.allow_rear_focus_notices,
+ descriptionRes = R.string.allow_rear_focus_notices_desc,
+ type = ConfigType.BooleanVal(defaultValue = false),
+ ),
+ ),
+ ),
ConfigCategory(
titleRes = R.string.cfg_music_control_whitelist,
descriptionRes = R.string.cfg_music_control_whitelist_desc,
@@ -138,28 +296,80 @@ val REAREyeConfig = listOf(
)
)
),
- ConfigItem(
- key = ConfigKeys.HOOK_VIDEO_LOOPING,
- titleRes = R.string.enable_video_looping,
- type = ConfigType.BooleanVal(defaultValue = false)
- ),
ConfigCategory(
- titleRes = R.string.rear_widget_manager_category,
- descriptionRes = R.string.rear_widget_manager_category_desc,
+ titleRes = R.string.subcategory_lyrics,
+ descriptionRes = R.string.subcategory_lyrics_desc,
children = listOf(
ConfigItem(
- key = ConfigKeys.CFG_REAR_WIDGET_BUSINESS_MANAGER,
- titleRes = R.string.rear_widget_business_manager,
- descriptionRes = R.string.rear_widget_business_manager_desc,
- type = ConfigType.Manager(ConfigType.ManagerType.BUSINESS),
+ key = ConfigKeys.LYRIC_DISPLAY_MODE,
+ titleRes = R.string.lyric_display_mode,
+ descriptionRes = R.string.lyric_display_mode_desc,
+ type = ConfigType.MaskMultiSelect(
+ defaultValue = ConfigKeys.LYRIC_DISPLAY_MODE_DEFAULT,
+ options = LyricParser.DisplayMode.entries.map {
+ ConfigType.MaskOption(
+ titleRes = it.toTitleRes(),
+ maskValue = it.mask,
+ )
+ }
+ )
),
ConfigItem(
- key = ConfigKeys.CFG_REAR_WIDGET_CARD_MANAGER,
- titleRes = R.string.rear_widget_card_manager,
- descriptionRes = R.string.rear_widget_card_manager_desc,
- type = ConfigType.Manager(ConfigType.ManagerType.CARD),
+ key = ConfigKeys.LYRIC_SHOW_ARTIST_BEFORE_FIRST_LINE,
+ titleRes = R.string.lyric_show_artist_before_first_line,
+ descriptionRes = R.string.lyric_show_artist_before_first_line_desc,
+ type = ConfigType.BooleanVal(defaultValue = false)
),
- ),
+ ConfigItem(
+ key = ConfigKeys.LYRIC_PROVIDER,
+ titleRes = R.string.lyric_provider,
+ descriptionRes = R.string.lyric_provider_desc,
+ type = ConfigType.EnumSingleSelect(
+ defaultValue = ConfigKeys.LYRIC_PROVIDER_DEFAULT,
+ options = LyricProvider.entries.map {
+ ConfigType.EnumOption(
+ titleRes = it.titleRes,
+ value = it.value,
+ )
+ },
+ )
+ ),
+ ConfigItem(
+ key = ConfigKeys.SUPER_LYRIC_DISPLAY_MODE,
+ titleRes = R.string.super_lyric_display_mode,
+ descriptionRes = R.string.super_lyric_display_mode_desc,
+ type = ConfigType.EnumSingleSelect(
+ defaultValue = ConfigKeys.SUPER_LYRIC_DISPLAY_MODE_DEFAULT,
+ options = listOf(
+ LyricParser.DisplayMode.ORIGINAL,
+ LyricParser.DisplayMode.TRANSLATION,
+ ).map {
+ ConfigType.EnumOption(
+ titleRes = it.toTitleRes(),
+ value = it.mask,
+ )
+ },
+ )
+ )
+ )
+ ),
+ ConfigItem(
+ key = ConfigKeys.HOOK_VIDEO_LOOPING,
+ titleRes = R.string.enable_video_looping,
+ type = ConfigType.BooleanVal(defaultValue = false)
+ ),
+ ConfigItem(
+ key = ConfigKeys.VIDEO_WALLPAPER_VOLUME,
+ titleRes = R.string.cfg_video_wallpaper_volume,
+ descriptionRes = R.string.cfg_video_wallpaper_volume_desc,
+ type = ConfigType.FloatSlider(
+ defaultValue = ConfigKeys.VIDEO_WALLPAPER_VOLUME_DEFAULT,
+ minValue = 0f,
+ maxValue = 1.0f,
+ steps = 99,
+ decimalPlaces = 2,
+ valueFormatter = { value -> "${(value * 100f).roundToInt()}%" },
+ )
)
)
),
@@ -180,6 +390,12 @@ val REAREyeConfig = listOf(
titleRes = R.string.cfg_unlock_template_maximum_limit,
descriptionRes = R.string.cfg_unlock_template_maximum_limit_desc,
type = ConfigType.BooleanVal(defaultValue = true)
+ ),
+ ConfigItem(
+ key = ConfigKeys.HOOK_UNMUTE_VIDEO_WALLPAPER,
+ titleRes = R.string.cfg_unmute_video_wallpaper,
+ descriptionRes = R.string.cfg_unmute_video_wallpaper_desc,
+ type = ConfigType.BooleanVal(defaultValue = false)
)
)
)
diff --git a/app/src/main/java/hk/uwu/reareye/ui/config/PrefsManager.kt b/app/src/main/java/hk/uwu/reareye/ui/config/PrefsManager.kt
index c0d19f7..76a9844 100644
--- a/app/src/main/java/hk/uwu/reareye/ui/config/PrefsManager.kt
+++ b/app/src/main/java/hk/uwu/reareye/ui/config/PrefsManager.kt
@@ -4,8 +4,7 @@ import android.content.Context
import com.highcapable.yukihookapi.hook.factory.prefs
import com.highcapable.yukihookapi.hook.xposed.prefs.YukiHookPrefsBridge
-class PrefsManager(context: Context) {
- val prefs: YukiHookPrefsBridge = context.prefs()
+class PrefsManager(val prefs: YukiHookPrefsBridge) {
fun getBoolean(key: String, defValue: Boolean): Boolean {
return prefs.getBoolean(key, defValue)
@@ -30,4 +29,25 @@ class PrefsManager(context: Context) {
fun putString(key: String, value: String) {
prefs.edit().putString(key, value).apply()
}
-}
+
+ fun getInt(key: String, defValue: Int): Int {
+ return prefs.getInt(key, defValue)
+ }
+
+ fun putInt(key: String, value: Int) {
+ prefs.edit().putInt(key, value).apply()
+ }
+
+ fun getFloat(key: String, defValue: Float): Float {
+ return prefs.getFloat(key, defValue)
+ }
+
+ fun putFloat(key: String, value: Float) {
+ prefs.edit().putFloat(key, value).apply()
+ }
+
+ companion object {
+ fun Context.getPrefsManager() = PrefsManager(this.prefs())
+ fun YukiHookPrefsBridge.getPrefsManager() = PrefsManager(this)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/hk/uwu/reareye/ui/easteregg/EasterEggManager.kt b/app/src/main/java/hk/uwu/reareye/ui/easteregg/EasterEggManager.kt
new file mode 100644
index 0000000..0233580
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/ui/easteregg/EasterEggManager.kt
@@ -0,0 +1,180 @@
+package hk.uwu.reareye.ui.easteregg
+
+import android.content.Context
+import androidx.core.content.edit
+import java.util.Calendar
+
+enum class EasterEggType {
+ NONE,
+ NEW_YEAR,
+ APRIL_FOOLS,
+ EASTER,
+ MI_FANS
+}
+
+enum class EasterEggDisableScope {
+ YEAR,
+ DATE,
+}
+
+sealed interface EasterEggTrigger {
+ data class Annual(val month: Int, val day: Int) : EasterEggTrigger
+ data class ExactDate(val year: Int, val month: Int, val day: Int) : EasterEggTrigger
+}
+
+data class EasterEggDefinition(
+ val type: EasterEggType,
+ val trigger: EasterEggTrigger,
+)
+
+data class EasterEggToggleResult(
+ val matchedToday: Boolean,
+ val type: EasterEggType,
+ val isEnabled: Boolean,
+ val disableScope: EasterEggDisableScope,
+)
+
+object EasterEggManager {
+ private const val PREF_NAME = "reareye_easter_eggs"
+ private const val PREF_DISABLED_SET = "disabled_records"
+
+ private val lock = Any()
+ private var disabledLoaded = false
+ private val disabledRecords = linkedSetOf()
+
+ private var cachedDateKey = -1
+ private var cachedType = EasterEggType.NONE
+
+ private val easterEggs = listOf(
+ EasterEggDefinition(EasterEggType.NEW_YEAR, EasterEggTrigger.Annual(month = 1, day = 1)),
+ EasterEggDefinition(EasterEggType.APRIL_FOOLS, EasterEggTrigger.Annual(month = 4, day = 1)),
+ EasterEggDefinition(
+ EasterEggType.EASTER,
+ EasterEggTrigger.ExactDate(year = 2026, month = 4, day = 5)
+ ),
+ EasterEggDefinition(
+ EasterEggType.MI_FANS,
+ EasterEggTrigger.Annual(month = 4, day = 6)
+ )
+ )
+
+ fun getCurrentEasterEggType(context: Context): EasterEggType {
+ synchronized(lock) {
+ val today = DateSnapshot.now()
+ ensureDisabledRecordsLoaded(context.applicationContext)
+ if (cachedDateKey == today.key) return cachedType
+
+ val matched = findTodayEasterEgg(today)
+ cachedType = matched
+ ?.takeUnless { isDisabled(it, today) }
+ ?.type
+ ?: EasterEggType.NONE
+ cachedDateKey = today.key
+ return cachedType
+ }
+ }
+
+ fun toggleTodayEasterEggEnabled(context: Context): EasterEggToggleResult {
+ synchronized(lock) {
+ val appContext = context.applicationContext
+ ensureDisabledRecordsLoaded(appContext)
+ val today = DateSnapshot.now()
+ val matched = findTodayEasterEgg(today) ?: return EasterEggToggleResult(
+ matchedToday = false,
+ type = EasterEggType.NONE,
+ isEnabled = false,
+ disableScope = EasterEggDisableScope.DATE,
+ )
+
+ val disableKey = buildDisableKey(matched, today)
+ val isEnabled = if (disabledRecords.contains(disableKey)) {
+ disabledRecords.remove(disableKey)
+ true
+ } else {
+ disabledRecords.add(disableKey)
+ false
+ }
+
+ appContext
+ .getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
+ .edit {
+ putStringSet(PREF_DISABLED_SET, disabledRecords.toSet())
+ }
+
+ cachedDateKey = -1
+ cachedType = EasterEggType.NONE
+
+ return EasterEggToggleResult(
+ matchedToday = true,
+ type = matched.type,
+ isEnabled = isEnabled,
+ disableScope = when (matched.trigger) {
+ is EasterEggTrigger.Annual -> EasterEggDisableScope.YEAR
+ is EasterEggTrigger.ExactDate -> EasterEggDisableScope.DATE
+ },
+ )
+ }
+ }
+
+ private fun ensureDisabledRecordsLoaded(context: Context) {
+ if (disabledLoaded) return
+ val storedSet = context
+ .getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
+ .getStringSet(PREF_DISABLED_SET, emptySet())
+ .orEmpty()
+ disabledRecords.clear()
+ disabledRecords.addAll(storedSet)
+ disabledLoaded = true
+ }
+
+ private fun findTodayEasterEgg(today: DateSnapshot): EasterEggDefinition? {
+ return easterEggs.firstOrNull { definition ->
+ when (val trigger = definition.trigger) {
+ is EasterEggTrigger.Annual -> {
+ trigger.month == today.month && trigger.day == today.day
+ }
+
+ is EasterEggTrigger.ExactDate -> {
+ trigger.year == today.year &&
+ trigger.month == today.month &&
+ trigger.day == today.day
+ }
+ }
+ }
+ }
+
+ private fun isDisabled(definition: EasterEggDefinition, today: DateSnapshot): Boolean {
+ return disabledRecords.contains(buildDisableKey(definition, today))
+ }
+
+ private fun buildDisableKey(definition: EasterEggDefinition, today: DateSnapshot): String {
+ return when (val trigger = definition.trigger) {
+ is EasterEggTrigger.Annual -> {
+ "annual:${definition.type.name}:${today.year}:${trigger.month}:${trigger.day}"
+ }
+
+ is EasterEggTrigger.ExactDate -> {
+ "date:${definition.type.name}:${trigger.year}:${trigger.month}:${trigger.day}"
+ }
+ }
+ }
+
+ private data class DateSnapshot(
+ val year: Int,
+ val month: Int,
+ val day: Int,
+ ) {
+ val key: Int = year * 10_000 + month * 100 + day
+
+ companion object {
+ fun now(): DateSnapshot {
+ val calendar = Calendar.getInstance()
+ return DateSnapshot(
+ year = calendar.get(Calendar.YEAR),
+ month = calendar.get(Calendar.MONTH) + 1,
+ day = calendar.get(Calendar.DAY_OF_MONTH),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/hk/uwu/reareye/ui/screen/AboutScreen.kt b/app/src/main/java/hk/uwu/reareye/ui/screen/AboutScreen.kt
index 8291ba7..7366fc2 100644
--- a/app/src/main/java/hk/uwu/reareye/ui/screen/AboutScreen.kt
+++ b/app/src/main/java/hk/uwu/reareye/ui/screen/AboutScreen.kt
@@ -1,9 +1,29 @@
package hk.uwu.reareye.ui.screen
import android.content.Intent
+import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.core.FastOutLinearInEasing
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.LinearOutSlowInEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -14,42 +34,124 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Apps
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.graphics.createBitmap
import androidx.core.net.toUri
+import dev.chrisbanes.haze.HazeState
import hk.uwu.reareye.R
import hk.uwu.reareye.generated.AppProperties
+import hk.uwu.reareye.repository.contributor.ContributorLoadState
+import hk.uwu.reareye.repository.contributor.ContributorProfile
+import hk.uwu.reareye.repository.contributor.ContributorRepository
import hk.uwu.reareye.ui.components.card.SuperCard
+import hk.uwu.reareye.ui.components.motion.ArtRevealItem
+import hk.uwu.reareye.ui.components.motion.ArtStaggeredReveal
+import hk.uwu.reareye.ui.theme.rearAcrylicEffect
+import hk.uwu.reareye.ui.theme.rearAcrylicSource
+import hk.uwu.reareye.ui.theme.rememberAcrylicHazeState
+import hk.uwu.reareye.ui.theme.rememberAcrylicHazeStyle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import top.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.Card
+import top.yukonga.miuix.kmp.basic.CircularProgressIndicator
import top.yukonga.miuix.kmp.basic.Icon
+import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.Scaffold
+import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.icon.MiuixIcons
+import top.yukonga.miuix.kmp.icon.extended.Create
import top.yukonga.miuix.kmp.icon.extended.Link
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.utils.PressFeedbackType
import top.yukonga.miuix.kmp.utils.overScrollVertical
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
+import java.util.concurrent.ConcurrentHashMap
+
+private val contributorAvatarHttpClient = OkHttpClient()
+
+private object AppLogoCache {
+ @Volatile
+ private var cachedImage: ImageBitmap? = null
+
+ fun peek(): ImageBitmap? = cachedImage
+
+ fun store(image: ImageBitmap) {
+ cachedImage = image
+ }
+}
+
+private object ContributorAvatarCache {
+ private val cache = ConcurrentHashMap()
+
+ fun peek(url: String?): ImageBitmap? {
+ val key = url?.takeIf { it.isNotBlank() } ?: return null
+ return cache[key]
+ }
+
+ suspend fun preload(urls: List) {
+ urls.distinct().forEach { url ->
+ load(url)
+ }
+ }
+
+ suspend fun load(url: String?): ImageBitmap? {
+ val key = url?.takeIf { it.isNotBlank() } ?: return null
+ cache[key]?.let { return it }
+
+ val image = withContext(Dispatchers.IO) {
+ runCatching {
+ val request = Request.Builder()
+ .url(key)
+ .build()
+ contributorAvatarHttpClient.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) return@withContext null
+ BitmapFactory.decodeStream(response.body.byteStream())?.asImageBitmap()
+ }
+ }.getOrNull()
+ }
+
+ if (image != null) {
+ cache.putIfAbsent(key, image)
+ }
+ return cache[key] ?: image
+ }
+}
+
+private sealed interface AboutRoute {
+ data object Root : AboutRoute
+ data object Contributors : AboutRoute
+}
private data class CreditEntry(
val titleRes: Int,
@@ -58,53 +160,203 @@ private data class CreditEntry(
)
@Composable
-fun AboutScreen() {
- val context = LocalContext.current
+private fun rememberSkeletonPulseAlpha(label: String): Float {
+ val infiniteTransition = rememberInfiniteTransition(label = label)
+ val alpha = infiniteTransition.animateFloat(
+ initialValue = 0.45f,
+ targetValue = 0.9f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 850),
+ repeatMode = RepeatMode.Reverse,
+ ),
+ label = "$label-alpha",
+ )
+ return alpha.value
+}
+
+@Composable
+fun AboutScreen(bottomInnerPadding: Dp = 0.dp) {
+ val layoutDirection = LocalLayoutDirection.current
val scrollBehavior = MiuixScrollBehavior()
+ val hazeState = rememberAcrylicHazeState()
+ val hazeStyle = rememberAcrylicHazeStyle()
val versionText = rememberVersionText()
+ val contributorState by ContributorRepository.state.collectAsState()
- val entries = listOf(
- CreditEntry(
- titleRes = R.string.credits_github_title,
- summaryRes = R.string.credits_github_desc,
- url = "https://github.com/killerprojecte/REAREye",
- ),
- CreditEntry(
- titleRes = R.string.credits_afdian_title,
- summaryRes = R.string.credits_afdian_desc,
- url = "https://ifdian.net/a/rgbmc",
- ),
- CreditEntry(
- titleRes = R.string.credits_qq_title,
- summaryRes = R.string.credits_qq_desc,
- url = "https://qm.qq.com/q/cg2MU3kw6W"
- ),
- CreditEntry(
- titleRes = R.string.credits_coolapk_title,
- summaryRes = R.string.credits_coolapk_desc,
- url = "https://www.coolapk.com/u/7190992"
+ var route by remember { mutableStateOf(AboutRoute.Root) }
+
+ val entries = remember {
+ listOf(
+ CreditEntry(
+ titleRes = R.string.credits_github_title,
+ summaryRes = R.string.credits_github_desc,
+ url = "https://github.com/killerprojecte/REAREye",
+ ),
+ CreditEntry(
+ titleRes = R.string.credits_docs,
+ summaryRes = R.string.credits_docs_desc,
+ url = "https://reareye.uwu.hk"
+ ),
+ CreditEntry(
+ titleRes = R.string.credits_afdian_title,
+ summaryRes = R.string.credits_afdian_desc,
+ url = "https://ifdian.net/a/rgbmc",
+ ),
+ CreditEntry(
+ titleRes = R.string.credits_qq_title,
+ summaryRes = R.string.credits_qq_desc,
+ url = "https://qm.qq.com/q/cg2MU3kw6W"
+ ),
+ CreditEntry(
+ titleRes = R.string.credits_coolapk_title,
+ summaryRes = R.string.credits_coolapk_desc,
+ url = "https://www.coolapk.com/u/7190992"
+ )
)
- )
+ }
+
+ LaunchedEffect(Unit) {
+ ContributorRepository.preload()
+ }
+
+ LaunchedEffect(route) {
+ if (route is AboutRoute.Contributors) {
+ ContributorRepository.ensureLoaded(force = false)
+ }
+ }
+
+ LaunchedEffect(contributorState) {
+ val loadedState = contributorState as? ContributorLoadState.Loaded ?: return@LaunchedEffect
+ ContributorAvatarCache.preload(
+ loadedState.contributors.mapNotNull { it.avatar?.takeIf(String::isNotBlank) }
+ )
+ }
+
+ BackHandler(enabled = route is AboutRoute.Contributors) {
+ route = AboutRoute.Root
+ }
Scaffold(
topBar = {
TopAppBar(
- title = stringResource(R.string.about_navigation),
+ modifier = Modifier.rearAcrylicEffect(hazeState, hazeStyle),
+ color = Color.Transparent,
+ title = stringResource(
+ if (route is AboutRoute.Root) {
+ R.string.about_navigation
+ } else {
+ R.string.credits_contributors_title
+ }
+ ),
+ navigationIcon = {
+ if (route is AboutRoute.Contributors) {
+ IconButton(onClick = { route = AboutRoute.Root }) {
+ Icon(
+ modifier = Modifier.graphicsLayer {
+ if (layoutDirection == LayoutDirection.Rtl) scaleX = -1f
+ },
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = null,
+ )
+ }
+ }
+ },
+ navigationIconPadding = 12.dp,
scrollBehavior = scrollBehavior,
)
}
) { paddingValues ->
- LazyColumn(
+ AnimatedContent(
modifier = Modifier
.fillMaxSize()
- .scrollEndHaptic()
- .overScrollVertical()
- .nestedScroll(scrollBehavior.nestedScrollConnection)
- .padding(horizontal = 12.dp),
- contentPadding = paddingValues,
- overscrollEffect = null,
- ) {
- item {
+ .graphicsLayer { clip = true },
+ targetState = route,
+ contentKey = { it },
+ transitionSpec = {
+ val forward = targetState is AboutRoute.Contributors
+
+ fadeIn(
+ animationSpec = tween(
+ durationMillis = 210,
+ delayMillis = 50,
+ easing = LinearOutSlowInEasing,
+ )
+ ) + slideInHorizontally(
+ animationSpec = tween(
+ durationMillis = 280,
+ easing = FastOutSlowInEasing,
+ )
+ ) { fullWidth ->
+ if (forward) fullWidth / 9 else -fullWidth / 9
+ } togetherWith (
+ fadeOut(
+ animationSpec = tween(
+ durationMillis = 110,
+ easing = FastOutLinearInEasing,
+ )
+ ) + slideOutHorizontally(
+ animationSpec = tween(
+ durationMillis = 190,
+ easing = FastOutLinearInEasing,
+ )
+ ) { fullWidth ->
+ if (forward) -fullWidth / 12 else fullWidth / 12
+ }
+ )
+ },
+ label = "AboutRouteTransition",
+ ) { currentRoute ->
+ when (currentRoute) {
+ AboutRoute.Root -> AboutRootContent(
+ bottomInnerPadding = bottomInnerPadding,
+ paddingValues = paddingValues,
+ scrollBehavior = scrollBehavior,
+ hazeState = hazeState,
+ versionText = versionText,
+ entries = entries,
+ onOpenContributors = { route = AboutRoute.Contributors },
+ )
+
+ AboutRoute.Contributors -> ContributorListContent(
+ bottomInnerPadding = bottomInnerPadding,
+ paddingValues = paddingValues,
+ scrollBehavior = scrollBehavior,
+ hazeState = hazeState,
+ state = contributorState,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun AboutRootContent(
+ bottomInnerPadding: Dp,
+ paddingValues: PaddingValues,
+ scrollBehavior: ScrollBehavior,
+ hazeState: HazeState,
+ versionText: String,
+ entries: List,
+ onOpenContributors: () -> Unit,
+) {
+ val context = LocalContext.current
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .scrollEndHaptic()
+ .overScrollVertical()
+ .nestedScroll(scrollBehavior.nestedScrollConnection)
+ .rearAcrylicSource(hazeState)
+ .padding(horizontal = 12.dp),
+ contentPadding = PaddingValues(
+ top = paddingValues.calculateTopPadding(),
+ bottom = paddingValues.calculateBottomPadding() + bottomInnerPadding,
+ ),
+ overscrollEffect = null,
+ ) {
+ item {
+ ArtRevealItem(visible = true, delayMillis = 18) {
Card(
modifier = Modifier
.fillMaxWidth()
@@ -116,7 +368,7 @@ fun AboutScreen() {
Row(verticalAlignment = Alignment.CenterVertically) {
AppLogo(modifier = Modifier.size(52.dp))
Spacer(modifier = Modifier.size(12.dp))
- androidx.compose.foundation.layout.Column(modifier = Modifier.weight(1f)) {
+ Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.app_name),
style = MiuixTheme.textStyles.title3,
@@ -131,8 +383,41 @@ fun AboutScreen() {
}
}
}
+ }
+
+ item {
+ ArtStaggeredReveal(
+ visible = true,
+ revealKey = "contributors",
+ delayMillis = 36,
+ ) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp),
+ ) {
+ SuperCard(
+ title = stringResource(R.string.credits_contributors_title),
+ summary = stringResource(R.string.credits_contributors_desc),
+ onClick = onOpenContributors,
+ endActions = {
+ Icon(
+ imageVector = MiuixIcons.Create,
+ tint = colorScheme.onSurface,
+ contentDescription = null,
+ )
+ },
+ )
+ }
+ }
+ }
- itemsIndexed(entries) { _, entry ->
+ itemsIndexed(entries, key = { _, entry -> entry.url }) { index, entry ->
+ ArtStaggeredReveal(
+ visible = true,
+ revealKey = entry.url,
+ delayMillis = (54 + index * 18).coerceAtMost(150),
+ ) {
Card(
modifier = Modifier
.fillMaxWidth()
@@ -158,18 +443,199 @@ fun AboutScreen() {
}
}
+@Composable
+private fun ContributorListContent(
+ bottomInnerPadding: Dp,
+ paddingValues: PaddingValues,
+ scrollBehavior: ScrollBehavior,
+ hazeState: HazeState,
+ state: ContributorLoadState,
+) {
+ val avatarPlaceholderAlpha = rememberSkeletonPulseAlpha("contributor-avatar-skeleton")
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .scrollEndHaptic()
+ .overScrollVertical()
+ .nestedScroll(scrollBehavior.nestedScrollConnection)
+ .rearAcrylicSource(hazeState)
+ .padding(horizontal = 12.dp),
+ contentPadding = PaddingValues(
+ top = paddingValues.calculateTopPadding(),
+ bottom = paddingValues.calculateBottomPadding() + bottomInnerPadding,
+ ),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ overscrollEffect = null,
+ ) {
+ item {
+ Spacer(modifier = Modifier.height(12.dp))
+ }
+
+ when (state) {
+ ContributorLoadState.Idle,
+ ContributorLoadState.Loading,
+ -> item {
+ ArtRevealItem(visible = true, delayMillis = 40) {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ CircularProgressIndicator()
+ Text(text = stringResource(R.string.credits_contributors_loading))
+ }
+ }
+ }
+ }
+
+ ContributorLoadState.Failed -> item {
+ ArtRevealItem(visible = true, delayMillis = 40) {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ SuperCard(
+ title = stringResource(R.string.credits_contributors_title),
+ summary = stringResource(R.string.credits_contributors_load_failed),
+ bottomAction = {
+ Button(
+ onClick = { ContributorRepository.ensureLoaded(force = true) },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(text = stringResource(R.string.credits_contributors_retry))
+ }
+ },
+ )
+ }
+ }
+ }
+
+ is ContributorLoadState.Loaded -> {
+ if (state.contributors.isEmpty()) {
+ item {
+ ArtRevealItem(visible = true, delayMillis = 40) {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = stringResource(R.string.credits_contributors_empty),
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ }
+ }
+ } else {
+ itemsIndexed(
+ state.contributors,
+ key = { _, item -> item.link?.takeIf { it.isNotBlank() } ?: item.name },
+ ) { index, item ->
+ val revealKey = item.link?.takeIf { it.isNotBlank() } ?: item.name
+ ArtStaggeredReveal(
+ visible = true,
+ revealKey = revealKey,
+ delayMillis = (36 + index * 18).coerceAtMost(150),
+ ) {
+ ContributorCard(
+ item = item,
+ avatarPlaceholderAlpha = avatarPlaceholderAlpha,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ContributorCard(
+ item: ContributorProfile,
+ avatarPlaceholderAlpha: Float,
+) {
+ val context = LocalContext.current
+ val link = item.link?.takeIf { it.isNotBlank() }
+ val hasLink = link != null
+
+ Card(modifier = Modifier.fillMaxWidth()) {
+ SuperCard(
+ title = item.name,
+ summary = item.description.takeIf { it.isNotBlank() },
+ startAction = {
+ ContributorAvatar(
+ avatarUrl = item.avatar,
+ placeholderAlpha = avatarPlaceholderAlpha,
+ )
+ },
+ onClick = link?.let { targetLink ->
+ {
+ context.startActivity(Intent(Intent.ACTION_VIEW, targetLink.toUri()))
+ }
+ },
+ endActions = {
+ if (hasLink) {
+ Icon(
+ imageVector = MiuixIcons.Link,
+ tint = colorScheme.onSurface,
+ contentDescription = null,
+ )
+ }
+ },
+ )
+ }
+}
+
@Composable
private fun rememberVersionText(): String {
return "${AppProperties.PROJECT_APP_VERSION_NAME}-${AppProperties.GIT_HASH}-r${AppProperties.BUILD_NUMBER}-${AppProperties.BUILD_CHANNEL}"
}
+@Composable
+private fun ContributorAvatar(
+ avatarUrl: String?,
+ placeholderAlpha: Float,
+ modifier: Modifier = Modifier,
+) {
+ var imageBitmap by remember(avatarUrl) {
+ mutableStateOf(ContributorAvatarCache.peek(avatarUrl))
+ }
+
+ LaunchedEffect(avatarUrl) {
+ if (avatarUrl.isNullOrBlank()) {
+ imageBitmap = null
+ return@LaunchedEffect
+ }
+
+ imageBitmap = ContributorAvatarCache.load(avatarUrl)
+ }
+
+ if (imageBitmap != null) {
+ Image(
+ bitmap = imageBitmap!!,
+ contentDescription = null,
+ modifier = modifier
+ .size(42.dp)
+ .clip(CircleShape),
+ )
+ } else {
+ Box(
+ modifier = modifier
+ .size(42.dp)
+ .clip(CircleShape)
+ .background(colorScheme.secondaryContainer.copy(alpha = placeholderAlpha)),
+ )
+ }
+}
+
@Composable
private fun AppLogo(modifier: Modifier = Modifier) {
- val context = LocalContext.current
- var imageBitmap by remember { mutableStateOf(null) }
+ val context = LocalContext.current.applicationContext
+ var imageBitmap by remember { mutableStateOf(AppLogoCache.peek()) }
LaunchedEffect(Unit) {
- withContext(Dispatchers.IO) {
+ if (imageBitmap != null) {
+ return@LaunchedEffect
+ }
+
+ val loadedBitmap = withContext(Dispatchers.IO) {
runCatching {
val drawable = context.packageManager.getApplicationIcon(context.packageName)
val bitmap = if (drawable is BitmapDrawable) {
@@ -184,8 +650,13 @@ private fun AppLogo(modifier: Modifier = Modifier) {
drawable.draw(canvas)
bmp
}
- imageBitmap = bitmap.asImageBitmap()
+ bitmap.asImageBitmap()
}
+ }.getOrNull()
+
+ if (loadedBitmap != null) {
+ AppLogoCache.store(loadedBitmap)
+ imageBitmap = loadedBitmap
}
}
diff --git a/app/src/main/java/hk/uwu/reareye/ui/screen/ConfigScreen.kt b/app/src/main/java/hk/uwu/reareye/ui/screen/ConfigScreen.kt
index 7bf8d0e..4de8457 100644
--- a/app/src/main/java/hk/uwu/reareye/ui/screen/ConfigScreen.kt
+++ b/app/src/main/java/hk/uwu/reareye/ui/screen/ConfigScreen.kt
@@ -18,28 +18,43 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import hk.uwu.reareye.R
import hk.uwu.reareye.ui.components.config.AppListSelectorScreen
+import hk.uwu.reareye.ui.components.config.BusinessExtraConfigManagerScreen
import hk.uwu.reareye.ui.components.config.BusinessManagerScreen
import hk.uwu.reareye.ui.components.config.CardManagerScreen
import hk.uwu.reareye.ui.components.config.ConfigNodeRow
+import hk.uwu.reareye.ui.components.config.RearWallpaperManagerScreen
import hk.uwu.reareye.ui.config.ConfigCategory
import hk.uwu.reareye.ui.config.ConfigGroup
import hk.uwu.reareye.ui.config.ConfigItem
+import hk.uwu.reareye.ui.config.ConfigKeys
import hk.uwu.reareye.ui.config.ConfigNode
import hk.uwu.reareye.ui.config.ConfigType
+import hk.uwu.reareye.ui.config.ModuleNavigationBarMode
import hk.uwu.reareye.ui.config.PrefsManager
+import hk.uwu.reareye.ui.config.PrefsManager.Companion.getPrefsManager
import hk.uwu.reareye.ui.config.REAREyeConfig
+import hk.uwu.reareye.ui.theme.AppThemeMode
+import hk.uwu.reareye.ui.theme.rearAcrylicEffect
+import hk.uwu.reareye.ui.theme.rearAcrylicSource
+import hk.uwu.reareye.ui.theme.rememberAcrylicHazeState
+import hk.uwu.reareye.ui.theme.rememberAcrylicHazeStyle
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.Scaffold
@@ -52,37 +67,124 @@ private sealed interface ConfigRoute {
data object Root : ConfigRoute
data class Category(val category: ConfigCategory) : ConfigRoute
data class AppList(val item: ConfigItem) : ConfigRoute
+ data object RearWallpaperManager : ConfigRoute
data object BusinessManager : ConfigRoute
data object CardManager : ConfigRoute
+ data object BusinessExtraManager : ConfigRoute
+}
+
+private const val NAV_BAR_EXIT_DURATION_MS = 220L
+private const val OVERLAY_ROUTE_EXIT_DURATION_MS = 220L
+
+private data class ConfigAnimatedRoute(
+ val route: ConfigRoute,
+ val depth: Int,
+)
+
+private fun ConfigRoute.isOverlayRoute(): Boolean {
+ return this is ConfigRoute.AppList ||
+ this is ConfigRoute.RearWallpaperManager ||
+ this is ConfigRoute.BusinessManager ||
+ this is ConfigRoute.CardManager ||
+ this is ConfigRoute.BusinessExtraManager
}
@Composable
-fun ConfigScreen(onAppListModeChange: (Boolean) -> Unit = {}) {
+fun ConfigScreen(
+ bottomInnerPadding: Dp = 0.dp,
+ onAppListModeChange: (Boolean) -> Unit = {},
+ onThemeModeChange: (Int) -> Unit = {},
+ onNavigationBarModeChange: (Int) -> Unit = {},
+) {
val context = LocalContext.current
- val prefsManager = remember { PrefsManager(context) }
+ val prefsManager = remember { context.getPrefsManager() }
var routeStack by remember { mutableStateOf(listOf(ConfigRoute.Root)) }
val currentRoute = routeStack.last()
- val isOverlayMode = currentRoute is ConfigRoute.AppList ||
- currentRoute is ConfigRoute.BusinessManager ||
- currentRoute is ConfigRoute.CardManager
+ val isOverlayMode = currentRoute.isOverlayRoute()
+ val animatedRoute = remember(currentRoute, routeStack.size) {
+ ConfigAnimatedRoute(
+ route = currentRoute,
+ depth = routeStack.size,
+ )
+ }
val scrollBehavior = MiuixScrollBehavior()
+ val hazeState = rememberAcrylicHazeState()
+ val hazeStyle = rememberAcrylicHazeStyle()
+ val routeScope = rememberCoroutineScope()
+
+ val handlePreferenceChanged = remember(
+ prefsManager,
+ onThemeModeChange,
+ onNavigationBarModeChange,
+ ) {
+ { item: ConfigItem ->
+ when (item.key) {
+ ConfigKeys.MODULE_THEME_MODE -> {
+ onThemeModeChange(
+ prefsManager.getInt(
+ ConfigKeys.MODULE_THEME_MODE,
+ AppThemeMode.default.value,
+ )
+ )
+ }
- LaunchedEffect(isOverlayMode) {
- onAppListModeChange(isOverlayMode)
+ ConfigKeys.MODULE_NAVIGATION_BAR_MODE -> {
+ onNavigationBarModeChange(
+ prefsManager.getInt(
+ ConfigKeys.MODULE_NAVIGATION_BAR_MODE,
+ ModuleNavigationBarMode.default.value,
+ )
+ )
+ }
+ }
+ }
}
BackHandler(enabled = routeStack.size > 1) {
- routeStack = routeStack.dropLast(1)
+ if (isOverlayMode) {
+ routeScope.launch {
+ val newStack = routeStack.dropLast(1)
+ routeStack = newStack
+ delay(OVERLAY_ROUTE_EXIT_DURATION_MS)
+ if (!newStack.last().isOverlayRoute()) {
+ onAppListModeChange(false)
+ }
+ }
+ } else {
+ routeStack = routeStack.dropLast(1)
+ }
+ }
+
+ fun openOverlayRoute(route: ConfigRoute) {
+ routeScope.launch {
+ onAppListModeChange(true)
+ delay(NAV_BAR_EXIT_DURATION_MS)
+ routeStack = routeStack + route
+ }
+ }
+
+ fun closeOverlayRoute() {
+ routeScope.launch {
+ val newStack = routeStack.dropLast(1)
+ routeStack = newStack
+ delay(OVERLAY_ROUTE_EXIT_DURATION_MS)
+ if (!newStack.last().isOverlayRoute()) {
+ onAppListModeChange(false)
+ }
+ }
}
Scaffold(
topBar = {
if (!isOverlayMode) {
TopAppBar(
+ modifier = Modifier.rearAcrylicEffect(hazeState, hazeStyle),
+ color = Color.Transparent,
title = when (currentRoute) {
ConfigRoute.Root -> stringResource(R.string.configuration_title)
is ConfigRoute.Category -> stringResource(currentRoute.category.titleRes)
+ else -> stringResource(R.string.configuration_title)
},
scrollBehavior = scrollBehavior
)
@@ -90,109 +192,149 @@ fun ConfigScreen(onAppListModeChange: (Boolean) -> Unit = {}) {
}
) { paddingValues ->
AnimatedContent(
- targetState = routeStack,
+ modifier = Modifier
+ .fillMaxWidth()
+ .graphicsLayer { clip = true },
+ targetState = animatedRoute,
+ contentKey = { it.route },
transitionSpec = {
- val forward = targetState.size >= initialState.size
+ val forward = targetState.depth >= initialState.depth
- (fadeIn(
+ fadeIn(
animationSpec = tween(
- durationMillis = 260,
- delayMillis = 40,
+ durationMillis = 210,
+ delayMillis = 50,
easing = LinearOutSlowInEasing,
)
) + slideInHorizontally(
animationSpec = tween(
- durationMillis = 320,
+ durationMillis = 280,
easing = FastOutSlowInEasing,
)
) { fullWidth ->
- if (forward) fullWidth / 5 else -fullWidth / 5
- }) togetherWith (
+ if (forward) fullWidth / 9 else -fullWidth / 9
+ } togetherWith (
fadeOut(
animationSpec = tween(
- durationMillis = 180,
+ durationMillis = 110,
easing = FastOutLinearInEasing,
)
) + slideOutHorizontally(
animationSpec = tween(
- durationMillis = 240,
+ durationMillis = 190,
easing = FastOutLinearInEasing,
)
) { fullWidth ->
- if (forward) -fullWidth / 6 else fullWidth / 6
+ if (forward) -fullWidth / 12 else fullWidth / 12
}
)
},
label = "ConfigRouteTransition"
- ) { stack ->
- when (val route = stack.last()) {
+ ) { target ->
+ when (val route = target.route) {
ConfigRoute.Root -> ConfigNodeList(
nodes = REAREyeConfig,
prefsManager = prefsManager,
- contentPadding = paddingValues,
+ contentPadding = PaddingValues(
+ top = paddingValues.calculateTopPadding(),
+ bottom = paddingValues.calculateBottomPadding() + bottomInnerPadding,
+ ),
scrollBehavior = scrollBehavior,
+ modifier = Modifier.rearAcrylicSource(hazeState),
onOpenCategory = { category ->
routeStack = routeStack + ConfigRoute.Category(category)
},
onOpenAppList = { item ->
- routeStack = routeStack + ConfigRoute.AppList(item)
+ openOverlayRoute(ConfigRoute.AppList(item))
},
onOpenManager = { item ->
when ((item.type as? ConfigType.Manager)?.managerType) {
+ ConfigType.ManagerType.REAR_WALLPAPER -> {
+ openOverlayRoute(ConfigRoute.RearWallpaperManager)
+ }
+
ConfigType.ManagerType.BUSINESS -> {
- routeStack = routeStack + ConfigRoute.BusinessManager
+ openOverlayRoute(ConfigRoute.BusinessManager)
}
ConfigType.ManagerType.CARD -> {
- routeStack = routeStack + ConfigRoute.CardManager
+ openOverlayRoute(ConfigRoute.CardManager)
+ }
+
+ ConfigType.ManagerType.BUSINESS_EXTRA -> {
+ openOverlayRoute(ConfigRoute.BusinessExtraManager)
}
null -> Unit
}
- }
+ },
+ onPreferenceChanged = handlePreferenceChanged,
)
is ConfigRoute.Category -> ConfigNodeList(
nodes = route.category.children,
prefsManager = prefsManager,
- contentPadding = paddingValues,
+ contentPadding = PaddingValues(
+ top = paddingValues.calculateTopPadding(),
+ bottom = paddingValues.calculateBottomPadding() + bottomInnerPadding,
+ ),
scrollBehavior = scrollBehavior,
+ modifier = Modifier.rearAcrylicSource(hazeState),
onOpenCategory = { category ->
routeStack = routeStack + ConfigRoute.Category(category)
},
onOpenAppList = { item ->
- routeStack = routeStack + ConfigRoute.AppList(item)
+ openOverlayRoute(ConfigRoute.AppList(item))
},
onOpenManager = { item ->
when ((item.type as? ConfigType.Manager)?.managerType) {
+ ConfigType.ManagerType.REAR_WALLPAPER -> {
+ openOverlayRoute(ConfigRoute.RearWallpaperManager)
+ }
+
ConfigType.ManagerType.BUSINESS -> {
- routeStack = routeStack + ConfigRoute.BusinessManager
+ openOverlayRoute(ConfigRoute.BusinessManager)
}
ConfigType.ManagerType.CARD -> {
- routeStack = routeStack + ConfigRoute.CardManager
+ openOverlayRoute(ConfigRoute.CardManager)
+ }
+
+ ConfigType.ManagerType.BUSINESS_EXTRA -> {
+ openOverlayRoute(ConfigRoute.BusinessExtraManager)
}
null -> Unit
}
- }
+ },
+ onPreferenceChanged = handlePreferenceChanged,
)
is ConfigRoute.AppList -> AppListSelectorScreen(
configItem = route.item,
prefsManager = prefsManager,
- onCancel = { routeStack = routeStack.dropLast(1) },
- onSave = { routeStack = routeStack.dropLast(1) }
+ onCancel = { closeOverlayRoute() },
+ onSave = { closeOverlayRoute() }
+ )
+
+ ConfigRoute.RearWallpaperManager -> RearWallpaperManagerScreen(
+ prefsManager = prefsManager,
+ onBack = { closeOverlayRoute() },
)
ConfigRoute.BusinessManager -> BusinessManagerScreen(
prefsManager = prefsManager,
- onBack = { routeStack = routeStack.dropLast(1) },
+ onBack = { closeOverlayRoute() },
)
ConfigRoute.CardManager -> CardManagerScreen(
prefsManager = prefsManager,
- onBack = { routeStack = routeStack.dropLast(1) },
+ onBack = { closeOverlayRoute() },
+ )
+
+ ConfigRoute.BusinessExtraManager -> BusinessExtraConfigManagerScreen(
+ prefsManager = prefsManager,
+ onBack = { closeOverlayRoute() },
)
}
}
@@ -205,9 +347,11 @@ private fun ConfigNodeList(
prefsManager: PrefsManager,
contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior,
+ modifier: Modifier = Modifier,
onOpenCategory: (ConfigCategory) -> Unit,
onOpenAppList: (ConfigItem) -> Unit,
onOpenManager: (ConfigItem) -> Unit,
+ onPreferenceChanged: (ConfigItem) -> Unit = {},
) {
LazyColumn(
modifier = Modifier
@@ -215,6 +359,7 @@ private fun ConfigNodeList(
.scrollEndHaptic()
.overScrollVertical()
.nestedScroll(scrollBehavior.nestedScrollConnection)
+ .then(modifier)
.padding(horizontal = 12.dp),
contentPadding = contentPadding,
overscrollEffect = null
@@ -233,6 +378,7 @@ private fun ConfigNodeList(
onOpenCategory = onOpenCategory,
onOpenAppList = onOpenAppList,
onOpenManager = onOpenManager,
+ onPreferenceChanged = onPreferenceChanged,
)
}
}
@@ -248,6 +394,7 @@ private fun ConfigNodeList(
onOpenCategory = onOpenCategory,
onOpenAppList = onOpenAppList,
onOpenManager = onOpenManager,
+ onPreferenceChanged = onPreferenceChanged,
)
}
}
diff --git a/app/src/main/java/hk/uwu/reareye/ui/screen/HomeScreen.kt b/app/src/main/java/hk/uwu/reareye/ui/screen/HomeScreen.kt
index 24daf36..947d573 100644
--- a/app/src/main/java/hk/uwu/reareye/ui/screen/HomeScreen.kt
+++ b/app/src/main/java/hk/uwu/reareye/ui/screen/HomeScreen.kt
@@ -1,6 +1,7 @@
package hk.uwu.reareye.ui.screen
import android.annotation.SuppressLint
+import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
@@ -11,7 +12,6 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
-import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -27,9 +27,13 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.CheckCircle
+import androidx.compose.material.icons.outlined.CrueltyFree
import androidx.compose.material.icons.outlined.DoNotDisturb
+import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Sync
+import androidx.compose.material.icons.outlined.Warning
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -40,15 +44,23 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import com.highcapable.yukihookapi.YukiHookAPI
import hk.uwu.reareye.R
import hk.uwu.reareye.generated.AppProperties
+import hk.uwu.reareye.ui.easteregg.EasterEggManager
+import hk.uwu.reareye.ui.easteregg.EasterEggType
+import hk.uwu.reareye.ui.theme.rearAcrylicEffect
+import hk.uwu.reareye.ui.theme.rearAcrylicSource
+import hk.uwu.reareye.ui.theme.rememberAcrylicHazeState
+import hk.uwu.reareye.ui.theme.rememberAcrylicHazeStyle
import hk.uwu.reareye.utils.RootHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -72,20 +84,31 @@ import top.yukonga.miuix.kmp.basic.SpinnerEntry
import top.yukonga.miuix.kmp.basic.SpinnerItemImpl
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TopAppBar
-import top.yukonga.miuix.kmp.extra.SuperListPopup
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.MoreCircle
+import top.yukonga.miuix.kmp.overlay.OverlayListPopup
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.utils.PressFeedbackType
import top.yukonga.miuix.kmp.utils.overScrollVertical
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
+private val updateInfoHttpClient = OkHttpClient()
+
private object UpdateInfoCache {
val lock = Mutex()
- var loaded = false
var latestCommitHash: String? = null
}
+private class ToastHolder {
+ var toast: Toast? = null
+}
+
+private fun showSingleToast(context: Context, holder: ToastHolder, message: String) {
+ holder.toast?.cancel()
+ holder.toast = Toast.makeText(context.applicationContext, message, Toast.LENGTH_SHORT)
+ holder.toast?.show()
+}
+
private suspend fun fetchLatestCommitHashFromNetwork(): String? {
return withContext(Dispatchers.IO) {
runCatching {
@@ -97,7 +120,7 @@ private suspend fun fetchLatestCommitHashFromNetwork(): String? {
val request = Request.Builder()
.url("https://api.github.com/repos/$owner/$repo/commits/$branch")
.build()
- val response = OkHttpClient().newCall(request).execute()
+ val response = updateInfoHttpClient.newCall(request).execute()
if (response.isSuccessful) {
JSONObject(response.body.string()).optString("sha", "").take(7).ifBlank { null }
} else {
@@ -107,20 +130,27 @@ private suspend fun fetchLatestCommitHashFromNetwork(): String? {
}
}
+@SuppressLint("LocalContextGetResourceValueCall")
@Composable
-fun HomeScreen() {
+fun HomeScreen(bottomInnerPadding: Dp = 0.dp) {
val isActivated = YukiHookAPI.Status.isModuleActive
val showTopMenu = remember { mutableStateOf(false) }
val scrollBehavior = MiuixScrollBehavior()
val context = LocalContext.current
+ val hazeState = rememberAcrylicHazeState()
+ val hazeStyle = rememberAcrylicHazeStyle()
val coroutineScope = rememberCoroutineScope()
+ val easterEggToastHolder = remember { ToastHolder() }
var latestCommitHash by remember { mutableStateOf(null) }
var isCheckingUpdate by remember { mutableStateOf(false) }
var hasRootAccess by remember { mutableStateOf(null) }
+ var easterEggType by remember {
+ mutableStateOf(EasterEggManager.getCurrentEasterEggType(context))
+ }
LaunchedEffect(Unit) {
- if (UpdateInfoCache.loaded) {
+ if (!UpdateInfoCache.latestCommitHash.isNullOrBlank()) {
latestCommitHash = UpdateInfoCache.latestCommitHash
isCheckingUpdate = false
return@LaunchedEffect
@@ -128,9 +158,11 @@ fun HomeScreen() {
isCheckingUpdate = true
latestCommitHash = UpdateInfoCache.lock.withLock {
- if (!UpdateInfoCache.loaded) {
- UpdateInfoCache.latestCommitHash = fetchLatestCommitHashFromNetwork()
- UpdateInfoCache.loaded = true
+ if (UpdateInfoCache.latestCommitHash.isNullOrBlank()) {
+ val fetchedHash = fetchLatestCommitHashFromNetwork()
+ if (!fetchedHash.isNullOrBlank()) {
+ UpdateInfoCache.latestCommitHash = fetchedHash
+ }
}
UpdateInfoCache.latestCommitHash
}
@@ -144,7 +176,12 @@ fun HomeScreen() {
}
val statusTitle = if (isActivated) {
- androidx.compose.ui.res.stringResource(R.string.home_status_working)
+ when (easterEggType) {
+ EasterEggType.APRIL_FOOLS -> androidx.compose.ui.res.stringResource(R.string.home_easter_egg_april_fools_working)
+ EasterEggType.EASTER -> androidx.compose.ui.res.stringResource(R.string.home_easter_egg_easter_working)
+ EasterEggType.MI_FANS -> androidx.compose.ui.res.stringResource(R.string.home_easter_egg_mifans_working)
+ else -> androidx.compose.ui.res.stringResource(R.string.home_status_working)
+ }
} else {
androidx.compose.ui.res.stringResource(R.string.home_status_inactive)
}
@@ -155,10 +192,31 @@ fun HomeScreen() {
val showRootWarning = hasRootAccess == false
val updateInfoDelay = if (showRootWarning) 150 else 100
+ val appTitle = when (easterEggType) {
+ EasterEggType.APRIL_FOOLS -> "FOOLEye"
+ EasterEggType.EASTER -> "BUNNYEgg"
+ EasterEggType.MI_FANS -> "JINFan"
+ else -> "REAREye"
+ }
+ val moduleVersion = when (easterEggType) {
+ EasterEggType.APRIL_FOOLS -> "4.1.0-41f001u-r${AppProperties.BUILD_NUMBER}-fool"
+ EasterEggType.EASTER -> "7.7.7-holyegg-r${AppProperties.BUILD_NUMBER}-rebirth"
+ EasterEggType.MI_FANS -> "本彩蛋仅为娱乐用途\n不代表开发者或任何组织的立场或观点"
+ else -> "${AppProperties.PROJECT_APP_VERSION_NAME}-${AppProperties.GIT_HASH}-r${AppProperties.BUILD_NUMBER}-${AppProperties.BUILD_CHANNEL}"
+ }
+ val releaseChannel = when (easterEggType) {
+ EasterEggType.APRIL_FOOLS -> "Oops"
+ EasterEggType.EASTER -> "Respawn Entertainment"
+ EasterEggType.MI_FANS -> "HyperOS Beta"
+ else -> AppProperties.BUILD_CHANNEL
+ }
+
Scaffold(
topBar = {
TopAppBar(
- title = "REAREye",
+ modifier = Modifier.rearAcrylicEffect(hazeState, hazeStyle),
+ color = Color.Transparent,
+ title = appTitle,
actions = {
Box {
IconButton(
@@ -173,7 +231,7 @@ fun HomeScreen() {
)
}
- SuperListPopup(
+ OverlayListPopup(
show = showTopMenu.value,
popupModifier = Modifier,
popupPositionProvider = ListPopupDefaults.DropdownPositionProvider,
@@ -186,13 +244,15 @@ fun HomeScreen() {
) {
@SuppressLint("LocalContextGetResourceValueCall")
ListPopupColumn {
+ Spacer(modifier = Modifier.height(4.dp))
+
SpinnerItemImpl(
entry = SpinnerEntry(
title = androidx.compose.ui.res.stringResource(
R.string.quick_stop_subscreencenter,
),
),
- entryCount = 2,
+ entryCount = 3,
isSelected = false,
index = 0,
spinnerColors = SpinnerDefaults.spinnerColors(),
@@ -215,7 +275,7 @@ fun HomeScreen() {
R.string.quick_stop_thememanager,
),
),
- entryCount = 2,
+ entryCount = 3,
isSelected = false,
index = 1,
spinnerColors = SpinnerDefaults.spinnerColors(),
@@ -230,6 +290,27 @@ fun HomeScreen() {
}
},
)
+
+ SpinnerItemImpl(
+ entry = SpinnerEntry(
+ title = androidx.compose.ui.res.stringResource(
+ R.string.quick_stop_systemui,
+ ),
+ ),
+ entryCount = 3,
+ isSelected = false,
+ index = 2,
+ spinnerColors = SpinnerDefaults.spinnerColors(),
+ onSelectedIndexChange = {
+ showTopMenu.value = false
+ coroutineScope.launch {
+ forceKillSystemUI(context)
+ }
+ },
+ )
+
+ Spacer(modifier = Modifier.height(2.dp))
+
}
}
}
@@ -248,10 +329,11 @@ fun HomeScreen() {
.scrollEndHaptic()
.overScrollVertical()
.nestedScroll(scrollBehavior.nestedScrollConnection)
+ .rearAcrylicSource(hazeState)
.padding(horizontal = 12.dp),
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding() + 12.dp,
- bottom = paddingValues.calculateBottomPadding() + 12.dp,
+ bottom = paddingValues.calculateBottomPadding() + bottomInnerPadding + 12.dp,
),
overscrollEffect = null,
) {
@@ -263,6 +345,32 @@ fun HomeScreen() {
statusTitle = statusTitle,
statusVersion = AppProperties.BUILD_NUMBER.toString(),
activated = isActivated,
+ easterEggType = easterEggType,
+ onLongPress = {
+ val result =
+ EasterEggManager.toggleTodayEasterEggEnabled(context)
+ if (!result.matchedToday) {
+ showSingleToast(
+ context = context,
+ holder = easterEggToastHolder,
+ message = context.getString(R.string.home_easter_egg_no_today),
+ )
+ return@WorkingStatusCard
+ }
+
+ easterEggType =
+ EasterEggManager.getCurrentEasterEggType(context)
+ val eggName = context.getString(result.type.toTitleRes())
+ val messageRes = when {
+ result.isEnabled -> R.string.home_easter_egg_enabled
+ else -> R.string.home_easter_egg_disabled
+ }
+ showSingleToast(
+ context = context,
+ holder = easterEggToastHolder,
+ message = context.getString(messageRes, eggName),
+ )
+ },
)
if (showRootWarning) {
@@ -283,16 +391,26 @@ fun HomeScreen() {
RevealItem(visible = visible, delayMillis = 50) {
ModuleInfoCard(
activated = isActivated,
- moduleVersion =
- "${AppProperties.PROJECT_APP_VERSION_NAME}-${AppProperties.GIT_HASH}-r${AppProperties.BUILD_NUMBER}-${AppProperties.BUILD_CHANNEL}",
- releaseChannel = AppProperties.BUILD_CHANNEL,
+ moduleVersion = moduleVersion,
+ releaseChannel = releaseChannel,
+ easterEggType = easterEggType
)
}
RevealItem(visible = visible, delayMillis = updateInfoDelay) {
UpdateInfoCard(
- currentHash = AppProperties.GIT_HASH,
- latestHash = latestCommitHash,
+ currentHash = when (easterEggType) {
+ EasterEggType.APRIL_FOOLS -> "41f001u"
+ EasterEggType.EASTER -> "holyegg"
+ EasterEggType.MI_FANS -> "leijun"
+ else -> AppProperties.GIT_HASH
+ },
+ latestHash = when (easterEggType) {
+ EasterEggType.APRIL_FOOLS if AppProperties.GIT_HASH == latestCommitHash -> "41f001u"
+ EasterEggType.EASTER if AppProperties.GIT_HASH == latestCommitHash -> "candies"
+ EasterEggType.MI_FANS if AppProperties.GIT_HASH == latestCommitHash -> "jinfan"
+ else -> latestCommitHash
+ },
checking = isCheckingUpdate,
)
}
@@ -304,7 +422,7 @@ fun HomeScreen() {
}
private suspend fun forceStopPackageByRoot(
- context: android.content.Context,
+ context: Context,
packageName: String,
appName: String,
) {
@@ -334,6 +452,23 @@ private suspend fun forceStopPackageByRoot(
}
}
+private suspend fun forceKillSystemUI(context: Context) {
+ withContext(Dispatchers.IO) {
+ if (!RootHelper.hasRootAccess()) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(
+ context,
+ context.getString(R.string.toast_need_root_permission),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ return@withContext
+ }
+
+ RootHelper.executeRootCommandSuccess("kill -9 $(pgrep systemui)")
+ }
+}
+
@Composable
private fun RevealItem(
visible: Boolean,
@@ -367,11 +502,66 @@ private fun RevealItem(
}
@Composable
-private fun WorkingStatusCard(statusTitle: String, statusVersion: String, activated: Boolean) {
- val cardColor = if (activated) Color(0xFFDFFAE4) else Color(0xFFF8E2E2)
- val iconColor = if (activated) Color(0xFF36D167) else Color(0xFFE06767)
- val titleColor = if (activated) Color(0xFF1E5A31) else Color(0xFF7A2A2A)
- val summaryColor = if (activated) Color(0xFF2C7D45) else Color(0xFF9A4D4D)
+private fun WorkingStatusCard(
+ statusTitle: String,
+ statusVersion: String,
+ activated: Boolean,
+ easterEggType: EasterEggType,
+ onLongPress: () -> Unit,
+) {
+ val (cardColor, iconColor, titleColor, summaryColor) = when {
+ !activated -> listOf(
+ Color(0xFFF8E2E2),
+ Color(0xFFE06767),
+ Color(0xFF7A2A2A),
+ Color(0xFF9A4D4D),
+ )
+
+ easterEggType == EasterEggType.NEW_YEAR -> listOf(
+ Color(0xFFFFECEC),
+ Color(0xFFE64B4B),
+ Color(0xFF7F1E1E),
+ Color(0xFF9A3939),
+ )
+
+ easterEggType == EasterEggType.APRIL_FOOLS -> listOf(
+ Color(0xFFFFF6D8),
+ Color(0xFFEBB027),
+ Color(0xFF7A5100),
+ Color(0xFF8E6900),
+ )
+
+ easterEggType == EasterEggType.EASTER -> listOf(
+ Color(0xFFFFF4E6),
+ Color(0xFFED9A9A),
+ Color(0xFF6A4C93),
+ Color(0xFF4CA66B),
+ )
+
+ easterEggType == EasterEggType.MI_FANS -> listOf(
+ Color(0xFFFFF2E0),
+ Color(0xFFFF6900),
+ Color(0xFFB34700),
+ Color(0xFF8C6239),
+ )
+
+ else -> listOf(
+ Color(0xFFDFFAE4),
+ Color(0xFF36D167),
+ Color(0xFF1E5A31),
+ Color(0xFF2C7D45),
+ )
+ }
+
+ val statusIcon = when {
+ !activated -> Icons.Outlined.Warning
+
+ easterEggType == EasterEggType.APRIL_FOOLS -> Icons.Outlined.BugReport
+ easterEggType == EasterEggType.EASTER -> Icons.Outlined.CrueltyFree
+ easterEggType == EasterEggType.MI_FANS -> Icons.Outlined.Lock
+
+ else -> Icons.Outlined.CheckCircle
+ }
Card(
modifier = Modifier
@@ -380,11 +570,14 @@ private fun WorkingStatusCard(statusTitle: String, statusVersion: String, activa
colors = CardDefaults.defaultColors(color = cardColor),
insideMargin = PaddingValues(14.dp),
pressFeedbackType = PressFeedbackType.Tilt,
- showIndication = false
+ showIndication = false,
+ onLongPress = {
+ onLongPress()
+ }
) {
Box(modifier = Modifier.fillMaxSize()) {
Icon(
- imageVector = Icons.Outlined.CheckCircle,
+ imageVector = statusIcon,
contentDescription = null,
tint = iconColor,
modifier = Modifier
@@ -413,9 +606,19 @@ private fun WorkingStatusCard(statusTitle: String, statusVersion: String, activa
}
}
+private fun EasterEggType.toTitleRes(): Int {
+ return when (this) {
+ EasterEggType.NONE -> R.string.home_easter_egg_none
+ EasterEggType.NEW_YEAR -> R.string.home_easter_egg_new_year
+ EasterEggType.APRIL_FOOLS -> R.string.home_easter_egg_april_fools
+ EasterEggType.EASTER -> R.string.home_easter_egg_easter
+ EasterEggType.MI_FANS -> R.string.home_easter_egg_mifans
+ }
+}
+
@Composable
private fun RootWarningCard() {
- val darkMode = isSystemInDarkTheme()
+ val darkMode = MiuixTheme.colorScheme.background.luminance() < 0.5f
val cardColor = if (darkMode) Color(0xFF4E2528) else Color(0xFFFDE9E9)
val iconColor = if (darkMode) Color(0xFFFF8A80) else Color(0xFFD94B4B)
val titleColor = if (darkMode) Color(0xFFFFD2CC) else Color(0xFF8C1F1F)
@@ -516,17 +719,31 @@ private fun UpdateWarningCard(currentHash: String, latestHash: String) {
}
@Composable
-private fun ModuleInfoCard(activated: Boolean, moduleVersion: String, releaseChannel: String) {
+private fun ModuleInfoCard(
+ activated: Boolean,
+ moduleVersion: String,
+ releaseChannel: String,
+ easterEggType: EasterEggType
+) {
Card(
modifier = Modifier.fillMaxWidth(),
insideMargin = PaddingValues(16.dp),
) {
InfoLine(
title = androidx.compose.ui.res.stringResource(R.string.status_card),
- value = if (activated) {
- androidx.compose.ui.res.stringResource(R.string.module_is_activated)
- } else {
- androidx.compose.ui.res.stringResource(R.string.module_not_activated)
+ value = when {
+ !activated -> androidx.compose.ui.res.stringResource(R.string.module_not_activated)
+ easterEggType == EasterEggType.APRIL_FOOLS -> androidx.compose.ui.res.stringResource(
+ R.string.home_easter_egg_april_fools_activated
+ )
+ easterEggType == EasterEggType.EASTER -> androidx.compose.ui.res.stringResource(
+ R.string.home_easter_egg_easter_activated
+ )
+ easterEggType == EasterEggType.MI_FANS -> androidx.compose.ui.res.stringResource(
+ R.string.home_easter_egg_mifans_activated
+ )
+
+ else -> androidx.compose.ui.res.stringResource(R.string.module_is_activated)
},
)
Spacer(modifier = Modifier.height(12.dp))
diff --git a/app/src/main/java/hk/uwu/reareye/ui/theme/Acrylic.kt b/app/src/main/java/hk/uwu/reareye/ui/theme/Acrylic.kt
new file mode 100644
index 0000000..41a3ac1
--- /dev/null
+++ b/app/src/main/java/hk/uwu/reareye/ui/theme/Acrylic.kt
@@ -0,0 +1,54 @@
+package hk.uwu.reareye.ui.theme
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import dev.chrisbanes.haze.ExperimentalHazeApi
+import dev.chrisbanes.haze.HazeInputScale
+import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.HazeStyle
+import dev.chrisbanes.haze.HazeTint
+import dev.chrisbanes.haze.hazeEffect
+import dev.chrisbanes.haze.hazeSource
+import top.yukonga.miuix.kmp.theme.MiuixTheme
+
+@Composable
+fun rememberAcrylicHazeState(): HazeState {
+ return remember { HazeState() }
+}
+
+@Composable
+fun rememberAcrylicHazeStyle(): HazeStyle {
+ val surface = MiuixTheme.colorScheme.surface
+ val tintAlpha = if (surface.luminance() < 0.5f) 0.72f else 0.82f
+
+ return remember(surface, tintAlpha) {
+ HazeStyle(
+ backgroundColor = surface,
+ tint = HazeTint(surface.copy(alpha = tintAlpha)),
+ )
+ }
+}
+
+@OptIn(ExperimentalHazeApi::class)
+fun Modifier.rearAcrylicEffect(
+ hazeState: HazeState,
+ hazeStyle: HazeStyle,
+ blurRadius: Dp = 24.dp,
+): Modifier = this.hazeEffect(
+ state = hazeState,
+ style = hazeStyle,
+) {
+ this.blurRadius = blurRadius
+ inputScale = HazeInputScale.Fixed(0.35f)
+ noiseFactor = 0f
+ forceInvalidateOnPreDraw = false
+}
+
+@OptIn(ExperimentalHazeApi::class)
+fun Modifier.rearAcrylicSource(hazeState: HazeState): Modifier {
+ return this.hazeSource(state = hazeState)
+}
diff --git a/app/src/main/java/hk/uwu/reareye/ui/theme/Theme.kt b/app/src/main/java/hk/uwu/reareye/ui/theme/Theme.kt
index 02fe6c7..c61deab 100644
--- a/app/src/main/java/hk/uwu/reareye/ui/theme/Theme.kt
+++ b/app/src/main/java/hk/uwu/reareye/ui/theme/Theme.kt
@@ -1,18 +1,105 @@
package hk.uwu.reareye.ui.theme
+import android.app.Activity
+import androidx.annotation.StringRes
+import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.view.WindowInsetsControllerCompat
+import hk.uwu.reareye.R
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.ThemeController
+enum class AppThemeMode(
+ val value: Int,
+ @param:StringRes val titleRes: Int,
+ val colorSchemeMode: ColorSchemeMode,
+) {
+ MIUIX_SYSTEM(
+ value = 3,
+ titleRes = R.string.module_theme_mode_miuix_system,
+ colorSchemeMode = ColorSchemeMode.System,
+ ),
+ MIUIX_LIGHT(
+ value = 0,
+ titleRes = R.string.module_theme_mode_miuix_light,
+ colorSchemeMode = ColorSchemeMode.Light,
+ ),
+ MIUIX_DARK(
+ value = 1,
+ titleRes = R.string.module_theme_mode_miuix_dark,
+ colorSchemeMode = ColorSchemeMode.Dark,
+ ),
+ MONET_SYSTEM(
+ value = 2,
+ titleRes = R.string.module_theme_mode_monet_system,
+ colorSchemeMode = ColorSchemeMode.MonetSystem,
+ ),
+ MONET_LIGHT(
+ value = 4,
+ titleRes = R.string.module_theme_mode_monet_light,
+ colorSchemeMode = ColorSchemeMode.MonetLight,
+ ),
+ MONET_DARK(
+ value = 5,
+ titleRes = R.string.module_theme_mode_monet_dark,
+ colorSchemeMode = ColorSchemeMode.MonetDark,
+ );
+
+ fun isDark(systemDark: Boolean): Boolean {
+ return when (this) {
+ MIUIX_SYSTEM -> systemDark
+ MIUIX_LIGHT -> false
+ MIUIX_DARK -> true
+ MONET_SYSTEM -> systemDark
+ MONET_LIGHT -> false
+ MONET_DARK -> true
+ }
+ }
+
+ companion object {
+ val default = MIUIX_SYSTEM
+ val selectableEntries = listOf(
+ MIUIX_SYSTEM,
+ MIUIX_LIGHT,
+ MIUIX_DARK,
+ MONET_SYSTEM,
+ MONET_LIGHT,
+ MONET_DARK,
+ )
+
+ fun fromValue(value: Int): AppThemeMode {
+ return entries.firstOrNull { it.value == value } ?: default
+ }
+ }
+}
+
+@Composable
+@ReadOnlyComposable
+fun isAppInDarkTheme(themeMode: AppThemeMode): Boolean {
+ return themeMode.isDark(isSystemInDarkTheme())
+}
+
@Composable
fun AppTheme(
+ themeMode: AppThemeMode = AppThemeMode.default,
content: @Composable () -> Unit
) {
- val controller = remember { ThemeController(ColorSchemeMode.System) }
+ val context = LocalContext.current
+ val darkTheme = isAppInDarkTheme(themeMode)
+ val controller = ThemeController(themeMode.colorSchemeMode)
MiuixTheme(controller) {
+ LaunchedEffect(darkTheme) {
+ val window = (context as? Activity)?.window ?: return@LaunchedEffect
+ WindowInsetsControllerCompat(window, window.decorView).apply {
+ isAppearanceLightStatusBars = !darkTheme
+ isAppearanceLightNavigationBars = !darkTheme
+ }
+ }
content()
}
}
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 5d4c849..c5b907d 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -16,6 +16,7 @@
保存
清空
已选择 %1$d 个应用
+ 当前值:%1$s
更多操作
排序
筛选
@@ -28,6 +29,19 @@
模块设置
REAREye 模块自身设置
+ 主题样式
+ 在 Miuix 与 Monet 的跟随系统、浅色、深色主题之间切换
+ 导航栏样式
+ 在普通、悬浮、悬浮液态玻璃三种导航栏模式之间切换
+ 普通
+ 悬浮
+ 悬浮液态玻璃
+ Miuix 跟随系统
+ Miuix 浅色
+ Miuix 深色
+ Monet 跟随系统
+ Monet 浅色
+ Monet 深色
隐藏桌面入口
可选:从桌面隐藏 REAREye 图标
@@ -54,31 +68,80 @@
以系统用户的身份锁定选中应用的进程
启用强制更新音乐控件
在曲目发生变化时强制更新音乐控件
- 背屏组件管理
- 管理自定义组件模板与常驻卡片
+ 歌词显示模式
+ 仅在 Lyricon 的部分提供器中生效,选择背屏歌词中要显示的内容
+ 原文
+ 翻译
+ 罗马音
+ 未选择任何显示模式
+ 首句前显示歌手名
+ 仅对静态歌词 LRC 生效
+ 歌词提供器
+ 选择使用的歌词来源后端
+ Lyricon (词幕)
+ SuperLyric
+ 逐行歌词显示模式
+ 选择 逐行歌词(SuperLyric) 显示原文或翻译
+ 背屏个性化
+ 管理背屏的个性化功能
+ 壁纸管理器
+ 查看当前壁纸列表与预览,并配置可拖拽排序的定时轮播
+ 壁纸状态
+ 当前壁纸:%1$s
+ 可用壁纸数:%1$d
+ 轮播功能:%1$s
+ 未选中
+ 轮播顺序
+ 轮播功能
+ 长按整张卡片即可拖动排序,编辑按钮可按毫秒调整切换间隔。
+ 长按壁纸卡片即可拖动调整轮播顺序。
+ 已开启
+ 已关闭
+ 轮播列表里还没有壁纸
+ 添加轮播壁纸
+ 正在加载壁纸…
+ 当前没有可用壁纸
+ 刷新壁纸列表
+ 壁纸列表已刷新
+ 刷新壁纸列表失败:%1$s
+ 设为当前
+ 加入轮播
+ 添加壁纸
+ 已加入轮播
+ 该壁纸已在轮播列表中
+ 已添加
+ 切换壁纸失败
+ 同步轮播设置失败
+ 编辑间隔
+ 切换间隔(毫秒)
+ 请输入有效的毫秒间隔
+ 在 %1$s 后切换
+ 当前
+ 不可用壁纸 #%1$d
+ 该壁纸已不在当前系统壁纸列表中
组件模板管理器
仅管理 Widget 到模板文件的映射,不注入包路由
+ 组件额外设置管理器
+ 手动添加需要额外显示选项的组件
卡片管理器
管理常驻卡片、显示顺序与启用状态
+ 允许焦点通知在背屏显示
+ 必须先存在该焦点通知对应的组件或模板,否则系统仍会拒绝显示
目标包名
组件名称(Widget)
组件模板映射
仅维护组件(Widget)到模板文件的映射,例如 music -> 自定义模板
模板文件路径
- 默认索引
默认优先级 (数值越低优先级越高)
- 优先级:%1$d
选择文件
- 保存组件
- 保存卡片
卡片标题
- 启用
%1$s
组件:%2$s · 优先级:%3$d
卡片显示设置
- 通过对话框新增或编辑卡片,启用后会常驻显示在背屏
+ 通过对话框新增或编辑卡片,启用后可选择是否常驻显示在背屏
暂无已注册组件模板
暂无已配置卡片
+ 正在加载数据…
请填写完整必填项
导入文件失败
模板文件已导入
@@ -86,14 +149,21 @@
卡片已保存
新增组件
编辑组件
+ 添加组件
+ 暂无组件额外设置
+ 组件额外选项
+ 先添加组件,再进入详情页配置额外选项
+ 已配置组件:%1$d
+ 组件额外设置
+ 当前组件:%1$s
+ 关闭显示时间
+ 关闭该组件通知中的时间显示
+ 常驻卡片
+ 关闭后仅保存卡片配置,不发布常驻背屏显示
+ 常驻卡片:%1$s
新增卡片
编辑卡片
- 编辑
删除
- 上移
- 下移
- 填写组件名称并绑定模板文件,保存后会立即更新运行时映射。
- 填写卡片标题、目标包名和组件名称,启用后会作为背屏常驻卡片注入
取消
确定
已注册组件:%1$d
@@ -102,6 +172,12 @@
工作中
未激活
版本:%1$s
+ 无
+ 新年
+ 愚人节
+ 今天没有可用彩蛋
+ 已启用彩蛋:%1$s
+ 已关闭彩蛋:%1$s
版本更新
当前提交
最新提交
@@ -112,7 +188,6 @@
Root 不可用
部分功能依赖 Root 权限, 请在Root管理器中授予本应用Root权限
模块版本
- 暂无快捷功能
快捷终止背屏中心
快捷终止主题管理
已终止 %1$s
@@ -148,4 +223,30 @@
加入我们的QQ群,获取更及时的反馈
酷安主页
关注我的账号以获取更多资讯
+ 快捷终止系统界面
+ 致命错误
+ 模块已崩溃
+ 解除视频壁纸静音
+ 允许您添加带有音频的背屏视频壁纸
+ 视频壁纸音量
+ 设置视频壁纸的播放音量
+ 禁用背屏保护
+ 启用此功能后,当主屏处于全屏模式时,背屏将不会被锁定
+ 更多调试输出
+ 歌词
+ 管理歌词配置
+ 文档
+ 使用教程和疑难解惑
+ 鸣谢人员
+ 查看参与项目开发、测试与支持的人员名单
+ 正在加载鸣谢人员名单…
+ 获取鸣谢人员名单失败,请稍后重试
+ 重试
+ 暂无鸣谢人员信息
+ 复活节
+ 模块复活中
+ 模块已死亡
+ 米粉节
+ 雷军!金凡!
+ 我真的是服了小米了
diff --git a/app/src/main/res/values/array.xml b/app/src/main/res/values/array.xml
index c9323fb..d715ccf 100644
--- a/app/src/main/res/values/array.xml
+++ b/app/src/main/res/values/array.xml
@@ -4,5 +4,6 @@
- android
- com.xiaomi.subscreencenter
- com.android.thememanager
+ - com.android.systemui
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8bf9f33..cb6ba32 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -3,7 +3,7 @@
Something is always watching from behind
Status
Module not activated
- Module is activated
+ Module activated
Home
Config
About
@@ -15,6 +15,7 @@
Save
Clear
Selected %1$d apps
+ Current value: %1$s
More actions
Sort
Filter
@@ -27,6 +28,19 @@
Module Settings
Settings for REAREye itself
+ Theme Style
+ Switch between Miuix and Monet theme variants, including follow-system, light, and dark modes
+ Navigation Bar Style
+ Choose between the standard layout, floating layout, and floating liquid-glass layout
+ Standard
+ Floating
+ Floating Liquid Glass
+ Miuix Follow System
+ Miuix Light
+ Miuix Dark
+ Monet Follow System
+ Monet Light
+ Monet Dark
Hide launcher entry
Optional: hide REAREye icon from launcher
@@ -53,31 +67,80 @@
Lock the selected application process as a system user
Enable Music Controls Forced Update
Force update music controls when track changes
- Rear Widget Managers
- Manage custom widget templates and resident cards
+ Lyric Display Mode
+ Only applies to some Lyricon providers. Choose which lyric content should be shown on the rear screen
+ Original
+ Translation
+ Romanization
+ No display mode selected
+ Show artist before first line
+ Only for static LRC lyrics
+ Lyric Provider
+ Choose which lyric source backend to use
+ Lyricon
+ SuperLyric
+ Live Lyric Display Mode
+ Choose whether live lyric shows original text or translation
+ Rear Screen Personalization Settings
+ Manage rear screen personalization features
+ Wallpaper Manager
+ Preview the current wallpaper list and configure timed rotation with drag-to-reorder
+ Wallpaper Status
+ Current wallpaper: %1$s
+ Available wallpapers: %1$d
+ Rotation feature: %1$s
+ Not selected
+ Rotation Schedule
+ Rotation Feature
+ Long press a card to reorder it. Edit each item to adjust its interval in milliseconds.
+ Long press a wallpaper card to reorder the rotation sequence.
+ On
+ Off
+ No wallpapers in the rotation list yet
+ Add Wallpaper to Schedule
+ Loading wallpapers…
+ No available wallpapers found
+ Refresh Wallpaper List
+ Wallpaper list refreshed
+ Failed to refresh wallpaper list: %1$s
+ Set Current
+ Add to Schedule
+ Add Wallpaper
+ Added to rotation
+ This wallpaper is already in the rotation list
+ Already Added
+ Failed to switch wallpaper
+ Failed to sync rotation settings
+ Edit Interval
+ Interval (milliseconds)
+ Please enter a valid interval in milliseconds
+ Switch after %1$s
+ Current
+ Unavailable wallpaper #%1$d
+ This wallpaper is no longer present in the current system list
Widget Template Manager
Manage widget-to-template mapping only, without package route injection
+ Widget Extra Settings Manager
+ Manually add widgets that need extra display options
Card Manager
Manage resident cards, display order, and enabled state
+ Allow focus notices on rear screen
+ Allow notifications that already carry valid focus/rear params to pass the rear-screen whitelist. The matching widget business/component must already exist, otherwise the system will still reject it.
Target Package
Widget Name
Widget Template Mapping
Maintain widget-to-template mapping only (for example, music -> custom template)
Template File Path
- Default Index
Default Priority (The lower the value, the higher the priority)
- Priority: %1$d
Select File
- Save Widget
- Save Card
Card Title
- Enabled
%1$s
Widget: %2$s · Priority: %3$d
Card Display Settings
- Use dialogs to add or edit cards. Enabled cards will stay resident on the rear screen
+ Use dialogs to add or edit cards. Enabled cards can optionally stay resident on the rear screen
No widget template registered
No card configured
+ Loading data…
Please complete all required fields
Failed to import file
Template file imported
@@ -85,14 +148,21 @@ Widget: %2$s · Priority: %3$d
Card saved
Add Widget
Edit Widget
+ Add Component
+ No component extra settings configured
+ Component Extra Options
+ Add a component first, then enter its detail page to configure extra options
+ Configured Components: %1$d
+ Component Extra Settings
+ Current Component: %1$s
+ Hide time tip
+ Disable timestamp display for this component\'s notices
+ Resident card
+ Turn off to save the card without publishing a resident rear-screen notice
+ Resident card: %1$s
Add Card
Edit Card
- Edit
Delete
- Move Up
- Move Down
- Configure a widget name and bind a template file. Saving will update runtime mappings immediately.
- Configure card title, package, and widget. Enabled cards are injected as resident rear-screen cards
Cancel
Confirm
Registered Widgets: %1$d
@@ -101,6 +171,12 @@ Widget: %2$s · Priority: %3$d
Working
Inactive
Version: %1$s
+ None
+ New Year
+ April Fools
+ No easter egg available today
+ Easter egg enabled: %1$s
+ Easter egg disabled: %1$s
Version Update
Current Commit
Latest Commit
@@ -111,7 +187,6 @@ Widget: %2$s · Priority: %3$d
Root unavailable
Some features require root privileges. Please grant this application root privileges using your root manager
Module Version
- No quick actions yet
Force stop Subscreen Center
Force stop Theme Manager
Stopped %1$s
@@ -149,4 +224,30 @@ Widget: %2$s · Priority: %3$d
Join our QQ group to get more timely feedback
Coolapk Homepage
Follow my account for more information
+ Force Stop System UI
+ Fatal Error
+ The module has crashed
+ Unmute video wallpaper
+ Allows you to add video wallpapers with audio
+ Video wallpaper volume
+ Set playback volume for video wallpapers
+ Disable rear screen cover
+ When enabled, the rear screen will not be locked when the main screen is in full-screen mode
+ More debug output
+ Lyrics
+ Manage lyrics options
+ Documentation
+ User Guide and Troubleshooting
+ Contributors
+ View the people who helped develop, test, and support this project
+ Loading contributors…
+ Failed to load contributors. Please try again later
+ Retry
+ No contributor information available
+ Easter
+ Module is being revived
+ The module is dead
+ Xiaomi Fans Festival
+ Lei Jun!Jin Fan!
+ I\'m truly impressed by Xiaomi
diff --git a/build.gradle.kts b/build.gradle.kts
index 52dfba3..d24e326 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.ksp) apply false
alias(libs.plugins.compose.compiler) apply false
-}
\ No newline at end of file
+}
diff --git a/gradle.properties b/gradle.properties
index d7fb430..17d29e5 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -5,10 +5,13 @@ android.nonTransitiveRClass=true
kotlin.code.style=official
# Project Configuration
project.name=REAREye
-project.android.compileSdk=36
+project.android.compileSdk=37
project.android.minSdk=27
-project.android.targetSdk=36
+project.android.targetSdk=37
+project.android.buildToolsVersion=37.0.0
+project.android.compileSdkMinor=0
project.app.packageName=hk.uwu.reareye
-project.app.versionName="1.0.3"
+project.app.versionName="1.0.4"
android.builtInKotlin=false
-android.newDsl=false
\ No newline at end of file
+android.newDsl=false
+android.suppressUnsupportedCompileSdk=37.0
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 4125a82..1f1a6b9 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -13,10 +13,17 @@ androidx-test-espresso-core = "3.7.0"
compose-bom = "2026.03.01"
androidx-activity-compose = "1.13.0"
androidx-lifecycle-runtime-compose = "2.10.0"
-miuix = "0.8.8"
+miuix = "0.9.0"
+haze = "1.7.2"
+backdrop = "1.0.6"
+capsule = "2.1.3"
+lyricon = "0.1.70B1"
+superlyric = "2.5"
+gson = "2.13.2"
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
+android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
@@ -39,6 +46,14 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activity-compose" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime-compose" }
-miuix = { group = "top.yukonga.miuix.kmp", name = "miuix", version.ref = "miuix" }
+miuix-ui = { group = "top.yukonga.miuix.kmp", name = "miuix-ui", version.ref = "miuix" }
+miuix-preference = { group = "top.yukonga.miuix.kmp", name = "miuix-preference", version.ref = "miuix" }
miuix-icons = { group = "top.yukonga.miuix.kmp", name = "miuix-icons", version.ref = "miuix" }
-okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
\ No newline at end of file
+haze = { module = "dev.chrisbanes.haze:haze-android", version.ref = "haze" }
+backdrop = { module = "io.github.kyant0:backdrop", version.ref = "backdrop" }
+capsule = { module = "io.github.kyant0:capsule", version.ref = "capsule" }
+okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
+lyricon-provider = { group = "io.github.proify.lyricon", name = "provider", version.ref = "lyricon" }
+lyricon-central = { group = "io.github.proify.lyricon", name = "central", version.ref = "lyricon" }
+superlyric = { group = "com.github.HChenX", name = "SuperLyricApi", version.ref = "superlyric" }
+gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
diff --git a/rear-widget-api/LICENSE b/rear-widget-api/LICENSE
new file mode 100644
index 0000000..153d416
--- /dev/null
+++ b/rear-widget-api/LICENSE
@@ -0,0 +1,165 @@
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+
+ This version of the GNU Lesser General Public License incorporates
+the terms and conditions of version 3 of the GNU General Public
+License, supplemented by the additional permissions listed below.
+
+ 0. Additional Definitions.
+
+ As used herein, "this License" refers to version 3 of the GNU Lesser
+General Public License, and the "GNU GPL" refers to version 3 of the GNU
+General Public License.
+
+ "The Library" refers to a covered work governed by this License,
+other than an Application or a Combined Work as defined below.
+
+ An "Application" is any work that makes use of an interface provided
+by the Library, but which is not otherwise based on the Library.
+Defining a subclass of a class defined by the Library is deemed a mode
+of using an interface provided by the Library.
+
+ A "Combined Work" is a work produced by combining or linking an
+Application with the Library. The particular version of the Library
+with which the Combined Work was made is also called the "Linked
+Version".
+
+ The "Minimal Corresponding Source" for a Combined Work means the
+Corresponding Source for the Combined Work, excluding any source code
+for portions of the Combined Work that, considered in isolation, are
+based on the Application, and not on the Linked Version.
+
+ The "Corresponding Application Code" for a Combined Work means the
+object code and/or source code for the Application, including any data
+and utility programs needed for reproducing the Combined Work from the
+Application, but excluding the System Libraries of the Combined Work.
+
+ 1. Exception to Section 3 of the GNU GPL.
+
+ You may convey a covered work under sections 3 and 4 of this License
+without being bound by section 3 of the GNU GPL.
+
+ 2. Conveying Modified Versions.
+
+ If you modify a copy of the Library, and, in your modifications, a
+facility refers to a function or data to be supplied by an Application
+that uses the facility (other than as an argument passed when the
+facility is invoked), then you may convey a copy of the modified
+version:
+
+ a) under this License, provided that you make a good faith effort to
+ ensure that, in the event an Application does not supply the
+ function or data, the facility still operates, and performs
+ whatever part of its purpose remains meaningful, or
+
+ b) under the GNU GPL, with none of the additional permissions of
+ this License applicable to that copy.
+
+ 3. Object Code Incorporating Material from Library Header Files.
+
+ The object code form of an Application may incorporate material from
+a header file that is part of the Library. You may convey such object
+code under terms of your choice, provided that, if the incorporated
+material is not limited to numerical parameters, data structure
+layouts and accessors, or small macros, inline functions and templates
+(ten or fewer lines in length), you do both of the following:
+
+ a) Give prominent notice with each copy of the object code that the
+ Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the object code with a copy of the GNU GPL and this license
+ document.
+
+ 4. Combined Works.
+
+ You may convey a Combined Work under terms of your choice that,
+taken together, effectively do not restrict modification of the
+portions of the Library contained in the Combined Work and reverse
+engineering for debugging such modifications, if you also do each of
+the following:
+
+ a) Give prominent notice with each copy of the Combined Work that
+ the Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the Combined Work with a copy of the GNU GPL and this license
+ document.
+
+ c) For a Combined Work that displays copyright notices during
+ execution, include the copyright notice for the Library among
+ these notices, as well as a reference directing the user to the
+ copies of the GNU GPL and this license document.
+
+ d) Do one of the following:
+
+ 0) Convey the Minimal Corresponding Source under the terms of this
+ License, and the Corresponding Application Code in a form
+ suitable for, and under terms that permit, the user to
+ recombine or relink the Application with a modified version of
+ the Linked Version to produce a modified Combined Work, in the
+ manner specified by section 6 of the GNU GPL for conveying
+ Corresponding Source.
+
+ 1) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (a) uses at run time
+ a copy of the Library already present on the user's computer
+ system, and (b) will operate properly with a modified version
+ of the Library that is interface-compatible with the Linked
+ Version.
+
+ e) Provide Installation Information, but only if you would otherwise
+ be required to provide such information under section 6 of the
+ GNU GPL, and only to the extent that such information is
+ necessary to install and execute a modified version of the
+ Combined Work produced by recombining or relinking the
+ Application with a modified version of the Linked Version. (If
+ you use option 4d0, the Installation Information must accompany
+ the Minimal Corresponding Source and Corresponding Application
+ Code. If you use option 4d1, you must provide the Installation
+ Information in the manner specified by section 6 of the GNU GPL
+ for conveying Corresponding Source.)
+
+ 5. Combined Libraries.
+
+ You may place library facilities that are a work based on the
+Library side by side in a single library together with other library
+facilities that are not Applications and are not covered by this
+License, and convey such a combined library under terms of your
+choice, if you do both of the following:
+
+ a) Accompany the combined library with a copy of the same work based
+ on the Library, uncombined with any other library facilities,
+ conveyed under the terms of this License.
+
+ b) Give prominent notice with the combined library that part of it
+ is a work based on the Library, and explaining where to find the
+ accompanying uncombined form of the same work.
+
+ 6. Revised Versions of the GNU Lesser General Public License.
+
+ The Free Software Foundation may publish revised and/or new versions
+of the GNU Lesser General Public License from time to time. Such new
+versions will be similar in spirit to the present version, but may
+differ in detail to address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Library as you received it specifies that a certain numbered version
+of the GNU Lesser General Public License "or any later version"
+applies to it, you have the option of following the terms and
+conditions either of that published version or of any later version
+published by the Free Software Foundation. If the Library as you
+received it does not specify a version number of the GNU Lesser
+General Public License, you may choose any version of the GNU Lesser
+General Public License ever published by the Free Software Foundation.
+
+ If the Library as you received it specifies that a proxy can decide
+whether future versions of the GNU Lesser General Public License shall
+apply, that proxy's public statement of acceptance of any version is
+permanent authorization for you to choose that version for the
+Library.
\ No newline at end of file
diff --git a/rear-widget-api/README.md b/rear-widget-api/README.md
new file mode 100644
index 0000000..acc7505
--- /dev/null
+++ b/rear-widget-api/README.md
@@ -0,0 +1,27 @@
+# REAR Widget API
+
+- 由REAREye提供的背屏组件管理API
+- 通过此API您可以在您的应用/模块中操作小米妙想背屏的组件
+
+## 协议
+
+- REAR Widget API部分单独使用LGPLv3协议开源
+
+## 依赖
+
+```kotlin
+repositories {
+ maven("https://repo.fastmcmirror.org/content/repositories/releases/")
+}
+
+dependencies {
+ implementation("hk.uwu.reareye:rear-widget-api:1.0.1")
+}
+```
+
+## 注册权限
+
+```xml
+
+
+```
\ No newline at end of file
diff --git a/rear-widget-api/build.gradle.kts b/rear-widget-api/build.gradle.kts
new file mode 100644
index 0000000..2c42703
--- /dev/null
+++ b/rear-widget-api/build.gradle.kts
@@ -0,0 +1,107 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
+
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+ id("maven-publish")
+ signing
+}
+
+val apiVersion = "1.0.1"
+
+android {
+ namespace = "hk.uwu.reareye.widgetapi"
+ compileSdk = gropify.project.android.compileSdk
+
+ defaultConfig {
+ minSdk = gropify.project.android.minSdk
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildFeatures {
+ aidl = true
+ buildConfig = false
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ publishing {
+ singleVariant("release") {
+ withSourcesJar()
+ }
+ }
+}
+
+tasks.withType().configureEach {
+ compilerOptions {
+ jvmTarget = JvmTarget.JVM_17
+ }
+}
+
+afterEvaluate {
+ publishing {
+ publications {
+ register("release") {
+ groupId = "hk.uwu.reareye"
+ artifactId = "rear-widget-api"
+ version = apiVersion
+ from(components["release"])
+
+ pom {
+ name.set("rear-widget-api")
+ description.set("REAREye rear widget API")
+ inceptionYear.set("2026")
+ url.set("https://github.com/killerprojecte/REAREye")
+ licenses {
+ license {
+ name.set("GNU Lesser General Public License v3.0")
+ url.set("https://www.gnu.org/licenses/lgpl-3.0.html")
+ distribution.set("https://www.gnu.org/licenses/lgpl-3.0.html")
+ }
+ }
+ developers {
+ developer {
+ id.set("killerprojecte")
+ name.set("killerprojecte")
+ url.set("https://github.com/killerprojecte")
+ }
+ }
+ scm {
+ url.set("https://github.com/killerprojecte/REAREye")
+ connection.set("scm:git:git://github.com/killerprojecte/REAREye.git")
+ developerConnection.set("scm:git:ssh://git@github.com/killerprojecte/REAREye.git")
+ }
+ }
+ }
+ }
+
+ repositories {
+ maven {
+ name = "fastmcmirror"
+ url = uri("https://repo.fastmcmirror.org/content/repositories/releases/")
+ credentials {
+ username = providers.gradleProperty("fastmcmirrorUsername")
+ .orElse(providers.environmentVariable("FASTMCMIRROR_USERNAME"))
+ .orNull
+ password = providers.gradleProperty("fastmcmirrorPassword")
+ .orElse(providers.environmentVariable("FASTMCMIRROR_PASSWORD"))
+ .orNull
+ }
+ }
+ }
+ }
+
+ signing {
+ val needSign = gradle.startParameter.taskNames.any {
+ it.contains("Fastmcmirror", ignoreCase = true) ||
+ it.contains("sign", ignoreCase = true)
+ }
+ isRequired = needSign
+ useGpgCmd()
+ sign(publishing.publications)
+ }
+}
diff --git a/rear-widget-api/consumer-rules.pro b/rear-widget-api/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/rear-widget-api/src/main/AndroidManifest.xml b/rear-widget-api/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8072ee0
--- /dev/null
+++ b/rear-widget-api/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/rear-widget-api/src/main/aidl/hk/uwu/reareye/widgetapi/IRearWallpaperApiConnection.aidl b/rear-widget-api/src/main/aidl/hk/uwu/reareye/widgetapi/IRearWallpaperApiConnection.aidl
new file mode 100644
index 0000000..225451a
--- /dev/null
+++ b/rear-widget-api/src/main/aidl/hk/uwu/reareye/widgetapi/IRearWallpaperApiConnection.aidl
@@ -0,0 +1,7 @@
+package hk.uwu.reareye.widgetapi;
+
+import hk.uwu.reareye.widgetapi.IRearWallpaperApiService;
+
+interface IRearWallpaperApiConnection {
+ void onServiceConnected(IRearWallpaperApiService service);
+}
diff --git a/rear-widget-api/src/main/aidl/hk/uwu/reareye/widgetapi/IRearWallpaperApiService.aidl b/rear-widget-api/src/main/aidl/hk/uwu/reareye/widgetapi/IRearWallpaperApiService.aidl
new file mode 100644
index 0000000..7eb6a10
--- /dev/null
+++ b/rear-widget-api/src/main/aidl/hk/uwu/reareye/widgetapi/IRearWallpaperApiService.aidl
@@ -0,0 +1,13 @@
+package hk.uwu.reareye.widgetapi;
+
+import android.os.Bundle;
+
+interface IRearWallpaperApiService {
+ Bundle getCatalog();
+
+ byte[] getPreview(int wallpaperId);
+
+ boolean switchWallpaper(int wallpaperId);
+
+ boolean syncSchedule(boolean enabled, String scheduleData);
+}
diff --git a/rear-widget-api/src/main/aidl/hk/uwu/reareye/widgetapi/IRearWidgetApiConnection.aidl b/rear-widget-api/src/main/aidl/hk/uwu/reareye/widgetapi/IRearWidgetApiConnection.aidl
new file mode 100644
index 0000000..a4fd942
--- /dev/null
+++ b/rear-widget-api/src/main/aidl/hk/uwu/reareye/widgetapi/IRearWidgetApiConnection.aidl
@@ -0,0 +1,7 @@
+package hk.uwu.reareye.widgetapi;
+
+import hk.uwu.reareye.widgetapi.IRearWidgetApiService;
+
+interface IRearWidgetApiConnection {
+ void onServiceConnected(IRearWidgetApiService service);
+}
diff --git a/rear-widget-api/src/main/aidl/hk/uwu/reareye/widgetapi/IRearWidgetApiService.aidl b/rear-widget-api/src/main/aidl/hk/uwu/reareye/widgetapi/IRearWidgetApiService.aidl
new file mode 100644
index 0000000..2ae384f
--- /dev/null
+++ b/rear-widget-api/src/main/aidl/hk/uwu/reareye/widgetapi/IRearWidgetApiService.aidl
@@ -0,0 +1,42 @@
+package hk.uwu.reareye.widgetapi;
+
+import android.os.Bundle;
+
+interface IRearWidgetApiService {
+ void registerBusinessFile(String business, String filePath);
+
+ void unregisterBusinessFile(String business);
+
+ void registerBusiness(
+ String targetPackage,
+ String business,
+ String filePath,
+ int defaultIndex,
+ int defaultPriority
+ );
+
+ void registerBusinessWithoutFile(
+ String targetPackage,
+ String business,
+ int defaultIndex,
+ int defaultPriority
+ );
+
+ void unregisterBusiness(String targetPackage, String business);
+
+ void disableBusinessDisplay(String targetPackage, String business);
+
+ void postNotice(String targetPackage, String business, in Bundle payload, in Bundle options);
+
+ void updateNotice(
+ in Bundle ticket,
+ in Bundle payload,
+ in Bundle options,
+ boolean updatePayload,
+ boolean updateOptions
+ );
+
+ void removeNotice(in Bundle ticket);
+
+ void syncState();
+}
diff --git a/rear-widget-api/src/main/java/hk/uwu/reareye/widgetapi/RearWallpaperApiClient.kt b/rear-widget-api/src/main/java/hk/uwu/reareye/widgetapi/RearWallpaperApiClient.kt
new file mode 100644
index 0000000..1d0023b
--- /dev/null
+++ b/rear-widget-api/src/main/java/hk/uwu/reareye/widgetapi/RearWallpaperApiClient.kt
@@ -0,0 +1,84 @@
+package hk.uwu.reareye.widgetapi
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+open class RearWallpaperApiClient(
+ private val hookHostPackage: String = RearWallpaperApiContract.HOOK_HOST_PACKAGE,
+) {
+ @Volatile
+ private var remote: IRearWallpaperApiService? = null
+
+ open fun bind(context: Context, onConnected: (() -> Unit)? = null): Boolean {
+ remote?.let {
+ onConnected?.invoke()
+ return true
+ }
+
+ val appContext = context.applicationContext
+ if (bindOnce(appContext, forceSync = false, timeoutMs = 1500L) ||
+ bindOnce(appContext, forceSync = true, timeoutMs = 2500L)
+ ) {
+ onConnected?.invoke()
+ return true
+ }
+ return false
+ }
+
+ private fun bindOnce(context: Context, forceSync: Boolean, timeoutMs: Long): Boolean {
+ remote?.let { return true }
+
+ val latch = CountDownLatch(1)
+ val callback = object : IRearWallpaperApiConnection.Stub() {
+ override fun onServiceConnected(service: IRearWallpaperApiService?) {
+ remote = service
+ latch.countDown()
+ }
+ }
+
+ requestHookServiceBootstrap(context, callback, forceSync)
+ return runCatching { latch.await(timeoutMs, TimeUnit.MILLISECONDS) }
+ .getOrDefault(false) && remote != null
+ }
+
+ open fun unbind() {
+ remote = null
+ }
+
+ open fun isConnected(): Boolean = remote != null
+
+ open fun getCatalog(): Bundle = requireRemote().getCatalog() ?: Bundle()
+
+ open fun getPreview(wallpaperId: Int): ByteArray? = requireRemote().getPreview(wallpaperId)
+
+ open fun switchWallpaper(wallpaperId: Int): Boolean =
+ requireRemote().switchWallpaper(wallpaperId)
+
+ open fun syncSchedule(enabled: Boolean, scheduleData: String): Boolean {
+ return requireRemote().syncSchedule(enabled, scheduleData)
+ }
+
+ private fun requireRemote(): IRearWallpaperApiService {
+ return remote ?: error("RearWallpaper API service is not connected")
+ }
+
+ private fun requestHookServiceBootstrap(
+ context: Context,
+ callback: IRearWallpaperApiConnection,
+ forceSync: Boolean,
+ ) {
+ runCatching {
+ val bundle = Bundle().apply {
+ putBinder(RearWallpaperApiContract.Extras.BINDER, callback.asBinder())
+ }
+ val intent = Intent(RearWallpaperApiContract.ACTION_REQUEST_HOOK_SERVICE)
+ .setPackage(hookHostPackage)
+ .putExtra(RearWallpaperApiContract.Extras.BUNDLE, bundle)
+ .putExtra(RearWallpaperApiContract.Extras.FORCE_SYNC, forceSync)
+ context.sendBroadcast(intent)
+ }
+ }
+}
diff --git a/rear-widget-api/src/main/java/hk/uwu/reareye/widgetapi/RearWallpaperApiContract.kt b/rear-widget-api/src/main/java/hk/uwu/reareye/widgetapi/RearWallpaperApiContract.kt
new file mode 100644
index 0000000..b37e43e
--- /dev/null
+++ b/rear-widget-api/src/main/java/hk/uwu/reareye/widgetapi/RearWallpaperApiContract.kt
@@ -0,0 +1,24 @@
+package hk.uwu.reareye.widgetapi
+
+object RearWallpaperApiContract {
+ const val SERVICE_PERMISSION = RearWidgetApiContract.SERVICE_PERMISSION
+ const val HOOK_HOST_PACKAGE = RearWidgetApiContract.HOOK_HOST_PACKAGE
+ const val ACTION_REQUEST_HOOK_SERVICE = "hk.uwu.reareye.wallpaperapi.REQUEST_HOOK_SERVICE"
+
+ object Extras {
+ const val BUNDLE = "bundle"
+ const val BINDER = "binder"
+ const val FORCE_SYNC = "forceSync"
+ }
+
+ object BundleKeys {
+ const val ITEMS = "items"
+ const val CURRENT_INDEX = "currentIndex"
+ const val CURRENT_WALLPAPER_ID = "currentWallpaperId"
+ const val WALLPAPER_ID = "wallpaperId"
+ const val TITLE = "title"
+ const val NAME = "name"
+ const val PREVIEW_AVAILABLE = "previewAvailable"
+ const val PREVIEW_SIGNATURE = "previewSignature"
+ }
+}
diff --git a/rear-widget-api/src/main/java/hk/uwu/reareye/widgetapi/RearWallpaperScheduleModels.kt b/rear-widget-api/src/main/java/hk/uwu/reareye/widgetapi/RearWallpaperScheduleModels.kt
new file mode 100644
index 0000000..f8ca067
--- /dev/null
+++ b/rear-widget-api/src/main/java/hk/uwu/reareye/widgetapi/RearWallpaperScheduleModels.kt
@@ -0,0 +1,47 @@
+package hk.uwu.reareye.widgetapi
+
+import org.json.JSONArray
+import org.json.JSONObject
+
+data class RearWallpaperScheduleEntry(
+ val wallpaperId: Int,
+ val delayMs: Long,
+)
+
+object RearWallpaperScheduleCodec {
+ const val EMPTY_ARRAY = "[]"
+ const val DEFAULT_DELAY_MS = 60_000L
+ const val MIN_DELAY_MS = 100L
+
+ fun parse(raw: String): List {
+ return runCatching {
+ val array = JSONArray(raw)
+ buildList {
+ for (index in 0 until array.length()) {
+ val obj = array.optJSONObject(index) ?: continue
+ val wallpaperId = obj.optInt("wallpaperId", Int.MIN_VALUE)
+ if (wallpaperId == Int.MIN_VALUE) continue
+ add(
+ RearWallpaperScheduleEntry(
+ wallpaperId = wallpaperId,
+ delayMs = obj.optLong("delayMs", DEFAULT_DELAY_MS)
+ .coerceAtLeast(MIN_DELAY_MS),
+ )
+ )
+ }
+ }
+ }.getOrDefault(emptyList())
+ }
+
+ fun encode(entries: List): String {
+ return JSONArray().apply {
+ entries.forEach { entry ->
+ put(
+ JSONObject()
+ .put("wallpaperId", entry.wallpaperId)
+ .put("delayMs", entry.delayMs.coerceAtLeast(MIN_DELAY_MS))
+ )
+ }
+ }.toString()
+ }
+}
diff --git a/rear-widget-api/src/main/java/hk/uwu/reareye/widgetapi/RearWidgetApiClient.kt b/rear-widget-api/src/main/java/hk/uwu/reareye/widgetapi/RearWidgetApiClient.kt
new file mode 100644
index 0000000..9d170d6
--- /dev/null
+++ b/rear-widget-api/src/main/java/hk/uwu/reareye/widgetapi/RearWidgetApiClient.kt
@@ -0,0 +1,153 @@
+package hk.uwu.reareye.widgetapi
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+class RearWidgetApiClient(
+ private val hookHostPackage: String = RearWidgetApiContract.HOOK_HOST_PACKAGE,
+) {
+ @Volatile
+ private var remote: IRearWidgetApiService? = null
+
+ fun bind(
+ context: Context,
+ onConnected: (() -> Unit)? = null
+ ): Boolean {
+ remote?.let {
+ onConnected?.invoke()
+ return true
+ }
+
+ val appContext = context.applicationContext
+ if (bindOnce(appContext, forceSync = false, timeoutMs = 1500L) ||
+ bindOnce(appContext, forceSync = true, timeoutMs = 2500L)
+ ) {
+ onConnected?.invoke()
+ return true
+ }
+ return false
+ }
+
+ private fun bindOnce(context: Context, forceSync: Boolean, timeoutMs: Long): Boolean {
+ remote?.let { return true }
+
+ val latch = CountDownLatch(1)
+ val callback = object : IRearWidgetApiConnection.Stub() {
+ override fun onServiceConnected(service: IRearWidgetApiService?) {
+ remote = service
+ latch.countDown()
+ }
+ }
+
+ requestHookServiceBootstrap(context = context, callback = callback, forceSync = forceSync)
+ return runCatching { latch.await(timeoutMs, TimeUnit.MILLISECONDS) }
+ .getOrDefault(false) && remote != null
+ }
+
+ fun unbind() {
+ remote = null
+ }
+
+ fun isConnected(): Boolean = remote != null
+
+ fun registerBusinessFile(business: String, filePath: String) {
+ requireRemote().registerBusinessFile(business, filePath)
+ }
+
+ fun unregisterBusinessFile(business: String) {
+ requireRemote().unregisterBusinessFile(business)
+ }
+
+ fun registerBusiness(
+ targetPackage: String,
+ business: String,
+ filePath: String,
+ defaultIndex: Int = 0,
+ defaultPriority: Int = 500,
+ ) {
+ requireRemote().registerBusiness(
+ targetPackage,
+ business,
+ filePath,
+ defaultIndex,
+ defaultPriority,
+ )
+ }
+
+ fun registerBusinessWithoutFile(
+ targetPackage: String,
+ business: String,
+ defaultIndex: Int = 0,
+ defaultPriority: Int = 500,
+ ) {
+ requireRemote().registerBusinessWithoutFile(
+ targetPackage,
+ business,
+ defaultIndex,
+ defaultPriority,
+ )
+ }
+
+ fun unregisterBusiness(targetPackage: String, business: String) {
+ requireRemote().unregisterBusiness(targetPackage, business)
+ }
+
+ fun disableBusinessDisplay(targetPackage: String, business: String) {
+ requireRemote().disableBusinessDisplay(targetPackage, business)
+ }
+
+ fun postNotice(
+ targetPackage: String,
+ business: String,
+ payload: Bundle = Bundle(),
+ options: RearWidgetNoticeOptions = RearWidgetNoticeOptions(),
+ ) {
+ requireRemote().postNotice(targetPackage, business, payload, options.toBundle())
+ }
+
+ fun updateNotice(
+ ticket: RearWidgetNoticeTicket,
+ payload: Bundle? = null,
+ options: RearWidgetNoticeOptions? = null,
+ ) {
+ requireRemote().updateNotice(
+ ticket.toBundle(),
+ payload ?: Bundle(),
+ options?.toBundle() ?: Bundle(),
+ payload != null,
+ options != null,
+ )
+ }
+
+ fun removeNotice(ticket: RearWidgetNoticeTicket) {
+ requireRemote().removeNotice(ticket.toBundle())
+ }
+
+ fun syncState() {
+ requireRemote().syncState()
+ }
+
+ private fun requireRemote(): IRearWidgetApiService {
+ return remote ?: error("RearWidget API service is not connected")
+ }
+
+ private fun requestHookServiceBootstrap(
+ context: Context,
+ callback: IRearWidgetApiConnection,
+ forceSync: Boolean,
+ ) {
+ runCatching {
+ val bundle = Bundle().apply {
+ putBinder(RearWidgetApiContract.Extras.BINDER, callback.asBinder())
+ }
+ val intent = Intent(RearWidgetApiContract.ACTION_REQUEST_HOOK_SERVICE)
+ .setPackage(hookHostPackage)
+ .putExtra(RearWidgetApiContract.Extras.BUNDLE, bundle)
+ .putExtra(RearWidgetApiContract.Extras.FORCE_SYNC, forceSync)
+ context.sendBroadcast(intent)
+ }
+ }
+}
diff --git a/rear-widget-api/src/main/java/hk/uwu/reareye/widgetapi/RearWidgetApiContract.kt b/rear-widget-api/src/main/java/hk/uwu/reareye/widgetapi/RearWidgetApiContract.kt
new file mode 100644
index 0000000..95dbbaf
--- /dev/null
+++ b/rear-widget-api/src/main/java/hk/uwu/reareye/widgetapi/RearWidgetApiContract.kt
@@ -0,0 +1,39 @@
+package hk.uwu.reareye.widgetapi
+
+object RearWidgetApiContract {
+ const val SERVICE_PERMISSION = "hk.uwu.reareye.permission.ACCESS_REAR_WIDGET_API"
+ const val HOOK_HOST_PACKAGE = "com.xiaomi.subscreencenter"
+ const val ACTION_REQUEST_HOOK_SERVICE = "hk.uwu.reareye.widgetapi.REQUEST_HOOK_SERVICE"
+
+ object Extras {
+ const val BUNDLE = "bundle"
+ const val BINDER = "binder"
+ const val FORCE_SYNC = "forceSync"
+ }
+
+ object Operation {
+ const val REGISTER_FILE = "register_file"
+ const val UNREGISTER_FILE = "unregister_file"
+ const val REGISTER = "register"
+ const val UNREGISTER = "unregister"
+ const val DISABLE_DISPLAY = "disable_display"
+ const val POST = "post"
+ const val UPDATE = "update"
+ const val REMOVE = "remove"
+ }
+
+ object BundleKeys {
+ const val PACKAGE_NAME = "packageName"
+ const val BUSINESS = "business"
+ const val NOTIFICATION_ID = "notificationId"
+ const val COMPOSITE_KEY = "compositeKey"
+
+ const val STICKY = "sticky"
+ const val DISABLE_POPUP = "disablePopup"
+ const val FORCE_POPUP = "forcePopup"
+ const val ENABLE_FLOAT = "enableFloat"
+ const val SHOW_TIME_TIP = "showTimeTip"
+ const val INDEX = "index"
+ const val PRIORITY = "priority"
+ }
+}
diff --git a/rear-widget-api/src/main/java/hk/uwu/reareye/widgetapi/RearWidgetApiModels.kt b/rear-widget-api/src/main/java/hk/uwu/reareye/widgetapi/RearWidgetApiModels.kt
new file mode 100644
index 0000000..682f694
--- /dev/null
+++ b/rear-widget-api/src/main/java/hk/uwu/reareye/widgetapi/RearWidgetApiModels.kt
@@ -0,0 +1,103 @@
+package hk.uwu.reareye.widgetapi
+
+import android.os.Bundle
+
+data class RearWidgetBusinessSpec(
+ val packageName: String,
+ val business: String,
+ val filePath: String,
+ val defaultIndex: Int = 0,
+ val defaultPriority: Int = 500,
+)
+
+data class RearWidgetNoticeOptions(
+ val sticky: Boolean = false,
+ val disablePopup: Boolean = true,
+ val forcePopup: Boolean = false,
+ val enableFloat: Boolean = false,
+ val showTimeTip: Boolean = true,
+ val index: Int? = null,
+ val priority: Int? = null,
+) {
+ fun toBundle(): Bundle = Bundle().apply {
+ putBoolean(RearWidgetApiContract.BundleKeys.STICKY, sticky)
+ putBoolean(RearWidgetApiContract.BundleKeys.DISABLE_POPUP, disablePopup)
+ putBoolean(RearWidgetApiContract.BundleKeys.FORCE_POPUP, forcePopup)
+ putBoolean(RearWidgetApiContract.BundleKeys.ENABLE_FLOAT, enableFloat)
+ putBoolean(RearWidgetApiContract.BundleKeys.SHOW_TIME_TIP, showTimeTip)
+ if (index != null) putInt(RearWidgetApiContract.BundleKeys.INDEX, index)
+ if (priority != null) putInt(RearWidgetApiContract.BundleKeys.PRIORITY, priority)
+ }
+
+ companion object {
+ fun fromBundle(bundle: Bundle?): RearWidgetNoticeOptions {
+ if (bundle == null) return RearWidgetNoticeOptions()
+ return RearWidgetNoticeOptions(
+ sticky = bundle.getBoolean(RearWidgetApiContract.BundleKeys.STICKY, false),
+ disablePopup = bundle.getBoolean(
+ RearWidgetApiContract.BundleKeys.DISABLE_POPUP,
+ true
+ ),
+ forcePopup = bundle.getBoolean(RearWidgetApiContract.BundleKeys.FORCE_POPUP, false),
+ enableFloat = bundle.getBoolean(
+ RearWidgetApiContract.BundleKeys.ENABLE_FLOAT,
+ false
+ ),
+ showTimeTip = bundle.getBoolean(
+ RearWidgetApiContract.BundleKeys.SHOW_TIME_TIP,
+ true
+ ),
+ index = if (bundle.containsKey(RearWidgetApiContract.BundleKeys.INDEX)) {
+ bundle.getInt(RearWidgetApiContract.BundleKeys.INDEX)
+ } else {
+ null
+ },
+ priority = if (bundle.containsKey(RearWidgetApiContract.BundleKeys.PRIORITY)) {
+ bundle.getInt(RearWidgetApiContract.BundleKeys.PRIORITY)
+ } else {
+ null
+ },
+ )
+ }
+ }
+}
+
+data class RearWidgetNoticeTicket(
+ val packageName: String,
+ val business: String,
+ val notificationId: Int,
+ val compositeKey: String,
+) {
+ fun toBundle(): Bundle = Bundle().apply {
+ putString(RearWidgetApiContract.BundleKeys.PACKAGE_NAME, packageName)
+ putString(RearWidgetApiContract.BundleKeys.BUSINESS, business)
+ putInt(RearWidgetApiContract.BundleKeys.NOTIFICATION_ID, notificationId)
+ putString(RearWidgetApiContract.BundleKeys.COMPOSITE_KEY, compositeKey)
+ }
+
+ companion object {
+ fun fromBundle(bundle: Bundle?): RearWidgetNoticeTicket? {
+ if (bundle == null) return null
+ val packageName = bundle.getString(RearWidgetApiContract.BundleKeys.PACKAGE_NAME)
+ ?.trim()
+ .orEmpty()
+ val business = bundle.getString(RearWidgetApiContract.BundleKeys.BUSINESS)
+ ?.trim()
+ .orEmpty()
+ val compositeKey = bundle.getString(RearWidgetApiContract.BundleKeys.COMPOSITE_KEY)
+ ?.trim()
+ .orEmpty()
+ if (packageName.isBlank() || business.isBlank() || compositeKey.isBlank()) return null
+ if (!bundle.containsKey(RearWidgetApiContract.BundleKeys.NOTIFICATION_ID)) return null
+ val notificationId = bundle.getInt(RearWidgetApiContract.BundleKeys.NOTIFICATION_ID)
+ return RearWidgetNoticeTicket(packageName, business, notificationId, compositeKey)
+ }
+ }
+}
+
+data class RearWidgetActiveNotice(
+ val ticket: RearWidgetNoticeTicket,
+ val payload: Bundle,
+ val options: RearWidgetNoticeOptions,
+ val createdAt: Long = System.currentTimeMillis(),
+)
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 91f1e8b..42a2dcb 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -9,9 +9,12 @@ pluginManagement {
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
+ mavenLocal()
+ maven("https://repo.fastmcmirror.org/content/repositories/releases/")
google()
mavenCentral()
maven("https://api.xposed.info/")
+ maven("https://jitpack.io")
}
}
@@ -41,7 +44,7 @@ fun runGitCommand(vararg args: String): String? = runCatching {
val versionCode = gitVersionCode
val branch = gitBranch
val hash = gitHash
-val buildSuffix = providers.gradleProperty("buildSuffix").orNull as? String ?: "dev"
+val buildSuffix = providers.gradleProperty("buildSuffix").orNull ?: "dev"
gradle.extra["versionSuffix"] = "-$hash-r$versionCode-$buildSuffix"
@@ -68,4 +71,5 @@ gropify {
rootProject.name = "REAREye"
-include(":app")
\ No newline at end of file
+include(":app")
+include(":rear-widget-api")