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 logo +

+ +

REAREye

+ +

+ 适用于小米 17 Pro / 17 Pro Max 的背屏增强模块。
+ 基于 LSPosed / Xposed,为系统框架、背屏中心和主题管理提供更细粒度的可配置能力。 +

+ +

+ GitHub release + GitHub license + GitHub issues + Android + Framework +

+ +## 功能概览 + +- 放行指定应用在背屏启动,并支持活动白名单控制 +- 自定义音乐控件白名单,兼容更多媒体类应用 +- 增强歌词显示,支持原文、翻译、罗马音与不同歌词提供器 +- 管理背屏组件模板、常驻卡片与组件额外显示选项 +- 管理背屏壁纸、定时轮播、拖拽排序与轮播间隔 +- 解除主题管理中的多项限制,包括视频壁纸时长、帧率和模板数量限制 +- 移除国行 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")