diff --git a/PERSONAL_INTERACTION.md b/PERSONAL_INTERACTION.md new file mode 100644 index 00000000..17afa80b --- /dev/null +++ b/PERSONAL_INTERACTION.md @@ -0,0 +1,29 @@ +# OneUIX 个人交互增强分支 + +本分支基于上游 `main` 的 `78e2ef1d10320aab293631f97ec636cf64d4cbea`,保留原有包名、作者信息和 AGPL-3.0 License。新增功能集中在一级分类“交互增强”,所有会改变系统交互的功能默认关闭。 + +## 实现边界 + +- 锁屏距离:在 `KeyguardTouchBase` 的唯一安全 `initDimens*` 初始化点完成后,仅缩放 `swipeUnlockRadius`,范围为 10%~100%,按 10% 分档。 +- 锁屏导航条:在三星 `SecStatusBarKeyguardViewManager` 认定导航条不可见时,仅对“锁屏正在显示、未被遮挡、非 AOD、非熄屏动画”的状态强制显示导航栏窗口;同时在 `SamsungNavigationBarView` 中放开中间手势提示条的可见性,左右提示保持系统原逻辑。锁屏上滑时,小白条按实际解锁进度向上移动、横向拉伸并轻微增强不透明度;取消手势后用 220ms 动画回到底部。关闭后恢复三星默认行为。 +- 最近任务按钮:在 One UI Home 创建 `CloseAllButton` 时将其隐藏,并持续阻止布局刷新重新显示。仅移除“全部关闭”入口,不影响逐个上滑关闭应用,不结束或锁定任何后台应用;关闭选项并重启 One UI Home 后恢复三星默认界面。 +- 通知直达:只在通知行点击、Keyguard 仍显示、`canDismissLockScreen` 为真、系统已不再判定设备为锁定时,取消三星额外的 swipe bouncer;通知点击和 `PendingIntent` 仍走 SystemUI 原生流程。 +- 主屏搜索:在 One UI 8.5 `HomeView` 上识别从下方 75% 空白区域开始的单指向下手势,复用 `SearchScreenController.startSearch(SEARCH_FROM_KEY, true)`,并聚焦 Finder 原生输入框、请求显示输入法;不启动或伪造 Finder Activity。 +- 通知亮屏:模块自身的 `NotificationListenerService` 过滤自身通知、常驻通知、group summary 和已亮屏状态;SystemUI 负责点亮,并在 1~10 秒滑轨设定的时长到达且仍处于锁屏时主动熄屏,不保存通知正文。 +- 滑轨触感:交互增强内的亮屏时长、主屏下拉阈值和锁屏上滑距离均在跨越离散档位时调用系统级触感反馈,并遵循系统触感开关。 +- 滑轨指引:三个滑轨的顶部数值均跟随拖动位置实时更新,松手后再写入配置,避免拖动过程中看不到目标档位。 +- 菜单精简:主界面固定保留“管理主界面菜单”入口,可逐项隐藏不常用分类或一键全部恢复;隐藏仅影响菜单显示,不关闭功能,状态随配置备份和恢复。 +- 软重启:交互增强页面提供二次确认按钮,通过 Root 执行与 KernelSU 管理器“软重启”相同的 `/system/bin/setprop ctl.restart zygote`,重新启动 Android 应用运行环境;不重启内核、不清除数据,但会关闭 SystemUI、主屏幕及所有正在运行的应用。KernelSU 中范围更大的“用户空间重启”是另一项功能,本按钮不调用该功能。 +- 应用自启:只列出带桌面入口的用户安装应用,可多选并设置总开关。普通 `BOOT_COMPLETED` 或本应用发起软重启前会登记持久 Job,系统恢复约 15 秒后通过 Root `am start` 逐个打开;不修改目标应用权限或系统后台策略。多个应用依次启动,最后一个留在前台;冷启动后 Root 尚未加载时记录失败且不循环请求。 + +任何类、字段或方法匹配失败都会保留三星原行为并写入 `OneUIX.Interaction` / LSPosed 日志。SystemUI 和 One UI Home 的 Hook 配置在对应进程启动时读取,修改后需重启对应进程。 + +## 首次真机测试顺序 + +1. 仅开启“自定义锁屏上滑距离”,设为 70%,重启 SystemUI;比较锁屏上滑行程。 +2. 开启“认证后点击通知直接打开”,重启 SystemUI;依次验证未认证不能绕过、认证后点击直达、重启后首次 PIN 前不能绕过。 +3. 开启“屏幕中部下拉打开搜索”,重启 One UI Home;验证顶部下拉仍打开通知栏、中部空白下拉进入原生搜索、横滑和图标拖动不误触。 +4. 授权通知访问并开启自动亮屏;验证息屏普通消息亮屏、亮屏时不重复、常驻通知和 group summary 不触发、仍显示三星原生锁屏。 +5. 四项同时开启,验证完整链路:通知到达 → 自动亮屏 → 生物认证 → 主动点击通知 → 对应应用。 + +测试期间不得关闭锁屏安全,不修改 PIN、密码、Strong Auth 或 `canDismissLockScreen` 判定。出现 SystemUI 或桌面异常时先在 LSPosed 停用本模块作用域并重启,所有新增功能关闭后均回到三星原行为。 diff --git a/README.md b/README.md index 00d13801..2db6747c 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@ ## 释放三星 One UI 的无限可能 | Unleash the full potential of Samsung One UI -[![Stars](https://img.shields.io/github/stars/SoClear/OneUIX?style=for-the-badge&logo=github)](https://github.com/SoClear/OneUIX) [![Release](https://img.shields.io/github/v/release/SoClear/OneUIX?style=for-the-badge)](https://github.com/SoClear/OneUIX/releases/latest) +[![Stars](https://img.shields.io/github/stars/xrzcc/OneUIX?style=for-the-badge&logo=github)](https://github.com/xrzcc/OneUIX) [![Personal Release](https://img.shields.io/github/v/release/xrzcc/OneUIX?include_prereleases&style=for-the-badge&label=Personal%20Release)](https://github.com/xrzcc/OneUIX/releases/tag/v1.7.0-personal.2) ![LSPosed](https://img.shields.io/badge/Powered_by-LSPosed-blue?style=flat-square&logo=android) -[![License](https://img.shields.io/badge/License-AGPL%20v3-blue.svg?style=flat-square)](https://github.com/SoClear/OneUIX/blob/main/LICENSE.txt) +[![License](https://img.shields.io/badge/License-AGPL%20v3-blue.svg?style=flat-square)](https://github.com/xrzcc/OneUIX/blob/main/LICENSE.txt) [![Min SDK](https://img.shields.io/badge/Min_SDK-33-brightgreen?style=flat-square&logo=android)](https://developer.android.com/about/versions) -[![Language](https://img.shields.io/github/languages/top/SoClear/OneUIX?style=flat-square&color=blueviolet&logo=kotlin)](https://github.com/SoClear/OneUIX) -[![GitHub Downloads](https://img.shields.io/github/downloads/SoClear/OneUIX/total?style=flat-square&color=blue&label=GitHub%20Downloads)](https://github.com/SoClear/OneUIX/releases) +[![Language](https://img.shields.io/github/languages/top/xrzcc/OneUIX?style=flat-square&color=blueviolet&logo=kotlin)](https://github.com/xrzcc/OneUIX) +[![GitHub Downloads](https://img.shields.io/github/downloads/xrzcc/OneUIX/total?style=flat-square&color=blue&label=Personal%20Downloads)](https://github.com/xrzcc/OneUIX/releases) [![LSPosed Downloads](https://img.shields.io/github/downloads/Xposed-Modules-Repo/io.github.soclear.oneuix/total?style=flat-square&color=orange&label=LSPosed%20Downloads)](https://github.com/Xposed-Modules-Repo/io.github.soclear.oneuix/releases) One UI X Logo @@ -21,12 +21,32 @@ --- +> [!IMPORTANT] +> **个人增强分支 / Personal Enhanced Fork** +> +> 本仓库是基于 [SoClear/OneUIX](https://github.com/SoClear/OneUIX) 的个人增强版本,重点适配已 Root、已启用 LSPosed 的 Samsung One UI 8.5 / Android 16 设备。新增交互功能全部默认关闭,可按需启用;使用前请阅读对应功能说明并保留系统锁屏安全验证。 +> +> This repository is a personal enhanced fork of [SoClear/OneUIX](https://github.com/SoClear/OneUIX), primarily tested on rooted Samsung devices running One UI 8.5 / Android 16 with LSPosed. Every added interaction feature is disabled by default and can be enabled individually without bypassing Android lock-screen authentication. +> +> **[下载当前个人版 APK / Download the current personal build](https://github.com/xrzcc/OneUIX/releases/tag/v1.7.0-personal.2)** + ## 简体中文 **One UI X** 是一个专为三星设备设计的强大 LSPosed 模块。它旨在提供高度可定制的系统体验,解除烦人的限制,并为状态栏、快捷设置以及系统自带应用注入强大的增强功能。 ### ✨ 功能 +#### 交互增强(One UI 8.5 / Android 16) + +- 在主屏幕中部空白区域向下滑动即可打开三星查找器,并自动聚焦搜索框和显示键盘;屏幕顶部的通知栏下拉手势保持不变 +- 将锁屏上滑解锁行程设置为三星默认距离的 10%~100%,仅调整手势距离,不绕过人脸、指纹、图案、PIN 或密码验证 +- 系统已经完成人脸、指纹等认证且允许解除锁定时,点击锁屏通知直接进入对应应用,无需再手动上滑 +- 普通新通知到达时自动点亮屏幕,并请求三星原生人脸识别(仍受密码、锁定及摄像头隐私等系统规则约束);可设置 1~10 秒亮屏时长,常驻通知、分组摘要和屏幕已亮场景不会重复触发 +- 交互增强滑轨实时显示当前数值,并在跨越档位时调用遵循系统设置的震动反馈 +- 可隐藏主界面中不常使用的功能分类;隐藏只影响菜单显示,不会关闭功能或删除原有配置 +- 提供与 KernelSU 管理器相同的 zygote 软重启操作,并在执行前显示确认提示 +- 可选择带桌面入口的用户安装应用,在完整开机或软重启后通过 Root 依次打开;不会修改目标应用权限或系统后台策略 + #### Android 系统 - 禁用每 72 小时验证锁屏密码 @@ -60,6 +80,7 @@ - 修改状态栏最大通知图标数量 - 自定义运营商名称 - 隐藏锁屏界面的状态栏 +- 在亮屏锁屏界面显示当前系统导航条;手势导航的小白条会随上滑解锁进度上移、拉伸并在取消后回弹,不影响 AOD 或解锁后的导航逻辑 ##### 快捷设置 @@ -124,6 +145,7 @@ - 应用分身支持所有用户应用 - 天气源设为中国天气 - 最近任务页面显示内存信息 +- 隐藏最近任务页面的“全部关闭”按钮,保留逐个上滑关闭应用 - 视频播放器添加 3.0 和 4.0 倍速 - 强制链接跳转外部三星浏览器 - 显示相册的所有设置 @@ -171,6 +193,17 @@ ### ✨ Features +#### Interaction Enhancements (One UI 8.5 / Android 16) + +- Swipe down on an empty area in the middle of Home to open Samsung Finder with its search field focused and keyboard shown, while keeping the top-edge notification-shade gesture unchanged +- Set the lock-screen upward swipe distance to 10%–100% of Samsung's default without bypassing Face, fingerprint, pattern, PIN, or password authentication +- Open a lock-screen notification directly after Face, fingerprint, or another system authentication has already made the lock screen dismissible, without requiring an additional swipe +- Wake the screen for eligible new notifications with a configurable 1–10 second duration, excluding ongoing notifications, group summaries, and already-screen-on cases +- Show live values above interaction sliders and provide system-respecting haptic feedback when crossing discrete steps +- Hide rarely used categories from the main menu without disabling their features or deleting saved settings +- Provide a confirmed zygote soft restart using the same operation as KernelSU Manager +- Select launchable user-installed apps to open sequentially through root after a full boot or soft restart without changing their permissions or Android background policies + #### Android System - Disable PIN verification every 72 hours @@ -204,6 +237,7 @@ - Modify maximum number of notification icons in status bar - Set custom carrier name - Hide status bar on lock screen +- Show the system navigation bar on the active lock screen; in gesture mode, the handle follows upward swipe progress, stretches slightly, and springs back when the gesture is cancelled without affecting AOD or unlocked navigation ##### Quick Settings @@ -268,6 +302,7 @@ - Make all user apps available for app cloning - Set weather provider to China Weather - Show memory usage in Recents page +- Hide the “Close all” button in Recents while keeping per-app swipe dismissal available - Add 3.0x and 4.0x playback speeds to video player - Force links to open in external Samsung Internet - Show all Gallery settings diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d8ec05f1..7f65da0b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,8 +17,8 @@ android { applicationId = "io.github.soclear.oneuix" minSdk = 33 targetSdk = 37 - versionCode = 9 - versionName = "1.7.0" + versionCode = 15 + versionName = "1.7.0-personal.6-test" ndk { // ABI过滤,只加载 arm 架构 64 位动态链接库 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index acb66273..39825511 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,8 +15,14 @@ + + + + + + @@ -74,6 +80,27 @@ + + + + + + + + + + + = emptySet(), + val interaction: Interaction = Interaction(), val android: Android = Android(), val systemUI: SystemUI = SystemUI(), val settings: Settings = Settings(), @@ -12,6 +14,21 @@ data class Preference( val camera: Camera = Camera(), val other: Other = Other(), ) { + @Serializable + data class Interaction( + val wakeOnNotification: Boolean = false, + val wakeDurationSeconds: Int = 8, + val wakeOnlyWhenScreenOff: Boolean = true, + val notificationDirectOpenAfterAuth: Boolean = false, + val homeSwipeDownSearch: Boolean = false, + val keepOriginalSwipeUpSearch: Boolean = true, + val homeSwipeDownThresholdDp: Float = 96f, + val customizeLockscreenSwipeDistance: Boolean = false, + val lockscreenSwipeDistanceScale: Float = 1.0f, + val autoStartEnabled: Boolean = false, + val autoStartPackages: Set = emptySet(), + ) + @Serializable data class Android( val disablePinVerifyPer72h: Boolean = false, @@ -94,6 +111,7 @@ data class Preference( @Serializable data class Other( + val showNavigationBarOnLockscreen: Boolean = false, val disableScreenshotCaptureSound: Boolean = false, val customPowerMenu: Boolean = false, val powerMenuActions: List = @@ -138,6 +156,7 @@ data class Preference( val makeAllUserAppsAvailable: Boolean = true, val setWeatherProviderCN: Boolean = false, val showMemoryUsageInRecents: Boolean = false, + val hideRecentsCloseAllButton: Boolean = false, val showMorePlaybackSpeeds: Boolean = false, val redirectCustomTab: Boolean = false, val supportAllGallerySettings: Boolean = true, diff --git a/app/src/main/java/io/github/soclear/oneuix/hook/Launcher.kt b/app/src/main/java/io/github/soclear/oneuix/hook/Launcher.kt index 2797d819..d181fdae 100644 --- a/app/src/main/java/io/github/soclear/oneuix/hook/Launcher.kt +++ b/app/src/main/java/io/github/soclear/oneuix/hook/Launcher.kt @@ -31,6 +31,32 @@ import kotlin.math.roundToInt object Launcher { + fun hideRecentsCloseAllButton(loadPackageParam: LoadPackageParam) { + if (loadPackageParam.packageName != Package.LAUNCHER) return + + try { + findAndHookConstructor( + "com.honeyspace.ui.honeypots.tasklist.presentation.CloseAllButton", + loadPackageParam.classLoader, + Context::class.java, + AttributeSet::class.java, + object : XC_MethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + val button = param.thisObject as View + button.visibility = View.GONE + button.addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> + if (view.visibility != View.GONE) view.visibility = View.GONE + } + } + } + ) + XposedBridge.log("OneUIX [Launcher] Recents close-all button hidden") + } catch (t: Throwable) { + XposedBridge.log("OneUIX [Launcher] Failed to hide Recents close-all button") + XposedBridge.log(t) + } + } + fun showMemoryUsageInRecents(loadPackageParam: LoadPackageParam) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { showMemoryUsageInRecentsTargetSdk36(loadPackageParam) diff --git a/app/src/main/java/io/github/soclear/oneuix/hook/Main.kt b/app/src/main/java/io/github/soclear/oneuix/hook/Main.kt index 229358fc..289d1c41 100644 --- a/app/src/main/java/io/github/soclear/oneuix/hook/Main.kt +++ b/app/src/main/java/io/github/soclear/oneuix/hook/Main.kt @@ -8,10 +8,16 @@ import de.robv.android.xposed.callbacks.XC_InitPackageResources.InitPackageResou import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam import io.github.soclear.oneuix.BuildConfig import io.github.soclear.oneuix.data.Package +import io.github.soclear.oneuix.data.ONE_UI_VERSION +import io.github.soclear.oneuix.hook.launcher.HomeGesture import io.github.soclear.oneuix.hook.systemui.AOD import io.github.soclear.oneuix.hook.systemui.ESIM import io.github.soclear.oneuix.hook.systemui.HideBatteryIcon import io.github.soclear.oneuix.hook.systemui.Notification +import io.github.soclear.oneuix.hook.systemui.KeyguardGesture +import io.github.soclear.oneuix.hook.systemui.LockscreenNavigationBar +import io.github.soclear.oneuix.hook.systemui.NotificationQuickLaunch +import io.github.soclear.oneuix.hook.systemui.SystemUiWakeBridge import io.github.soclear.oneuix.hook.systemui.Other import io.github.soclear.oneuix.hook.systemui.QS import io.github.soclear.oneuix.hook.systemui.StatusBar @@ -149,10 +155,26 @@ class Main : IXposedHookLoadPackage, IXposedHookInitPackageResources, IXposedHoo } Package.LAUNCHER -> { + if (ONE_UI_VERSION >= 80500 && + (preference.interaction.homeSwipeDownSearch || + !preference.interaction.keepOriginalSwipeUpSearch) + ) { + HomeGesture.enable( + lpparam = lpparam, + enableMiddleSwipeDown = preference.interaction.homeSwipeDownSearch, + keepOriginalSwipeUpSearch = preference.interaction.keepOriginalSwipeUpSearch, + requestedThresholdDp = preference.interaction.homeSwipeDownThresholdDp, + ) + } + if (preference.other.showMemoryUsageInRecents) { Launcher.showMemoryUsageInRecents(lpparam) } + if (preference.other.hideRecentsCloseAllButton) { + Launcher.hideRecentsCloseAllButton(lpparam) + } + if (preference.other.hideAppsSearchBar) { Launcher.hideAppsSearchBar(lpparam) } @@ -251,6 +273,25 @@ class Main : IXposedHookLoadPackage, IXposedHookInitPackageResources, IXposedHoo } Package.SYSTEMUI -> { + if (ONE_UI_VERSION >= 80500) { + SystemUiWakeBridge.enable(lpparam) + } + + if (ONE_UI_VERSION >= 80500 && + preference.interaction.customizeLockscreenSwipeDistance + ) { + KeyguardGesture.customizeSwipeDistance( + lpparam, + preference.interaction.lockscreenSwipeDistanceScale + ) + } + + if (ONE_UI_VERSION >= 80500 && + preference.interaction.notificationDirectOpenAfterAuth + ) { + NotificationQuickLaunch.enable(lpparam) + } + if (preference.android.setBlockableNotificationChannel) { Android.setBlockableNotificationChannel() } @@ -258,6 +299,10 @@ class Main : IXposedHookLoadPackage, IXposedHookInitPackageResources, IXposedHoo Notification.autoExpandNotifications(lpparam) } + if (preference.systemUI.other.showNavigationBarOnLockscreen) { + LockscreenNavigationBar.showOnLockscreen(lpparam) + } + run { val leftPaddingDp = if (preference.systemUI.statusBar.modifyStatusBarLeftPadding) { diff --git a/app/src/main/java/io/github/soclear/oneuix/hook/launcher/HomeGesture.kt b/app/src/main/java/io/github/soclear/oneuix/hook/launcher/HomeGesture.kt new file mode 100644 index 00000000..e68026ac --- /dev/null +++ b/app/src/main/java/io/github/soclear/oneuix/hook/launcher/HomeGesture.kt @@ -0,0 +1,262 @@ +package io.github.soclear.oneuix.hook.launcher + +import android.graphics.PointF +import android.view.MotionEvent +import android.view.View +import android.view.inputmethod.InputMethodManager +import android.widget.EditText +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XC_MethodReplacement +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.XposedHelpers.callMethod +import de.robv.android.xposed.XposedHelpers.findAndHookMethod +import de.robv.android.xposed.XposedHelpers.findClassIfExists +import de.robv.android.xposed.XposedHelpers.getObjectField +import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam +import io.github.soclear.oneuix.hook.util.InteractionHookLog +import java.util.WeakHashMap +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.abs + +object HomeGesture { + private const val HOME_VIEW = + "com.honeyspace.ui.honeypots.homescreen.presentation.HomeView" + private const val SEARCH_LAUNCH_FROM = "com.honeyspace.common.search.SearchLaunchFrom" + private val states = WeakHashMap() + private val finderGateHooked = AtomicBoolean(false) + private val statusBarGateHooked = AtomicBoolean(false) + + fun enable( + lpparam: LoadPackageParam, + enableMiddleSwipeDown: Boolean, + keepOriginalSwipeUpSearch: Boolean, + requestedThresholdDp: Float, + ) { + try { + InteractionHookLog.environment("HomeGesture", lpparam) + val homeViewClass = findClassIfExists(HOME_VIEW, lpparam.classLoader) ?: run { + InteractionHookLog.info("HomeGesture", "HomeView not found; launcher behavior retained") + return + } + val launchFromClass = findClassIfExists(SEARCH_LAUNCH_FROM, lpparam.classLoader) ?: run { + InteractionHookLog.info("HomeGesture", "SearchLaunchFrom not found; launcher behavior retained") + return + } + // SEARCH_FROM_GESTURE is only metadata in this One UI 8.5 build: passing it to + // startSearch() returns without navigating. SEARCH_FROM_KEY uses the same native + // search controller and standard Finder screen, and performs the required transition. + val nativeLaunchSource = launchFromClass.enumConstants?.firstOrNull { + (it as? Enum<*>)?.name == "SEARCH_FROM_KEY" + } ?: run { + InteractionHookLog.info("HomeGesture", "SEARCH_FROM_KEY not found; launcher behavior retained") + return + } + val thresholdDp = requestedThresholdDp.coerceIn(64f, 160f) + + findAndHookMethod( + homeViewClass, + "dispatchTouchEvent", + MotionEvent::class.java, + object : XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + val view = param.thisObject as? View ?: return + val event = param.args[0] as? MotionEvent ?: return + try { + if (!keepOriginalSwipeUpSearch) { + disableNativeFinderGate(view) + } + if (!enableMiddleSwipeDown) return + disableLauncherStatusBarGate(view) + handleGesture(view, event, thresholdDp, nativeLaunchSource)?.let { + param.result = it + } + } catch (error: Throwable) { + synchronized(states) { states.remove(view) } + InteractionHookLog.failure("HomeGesture", error) + } + } + } + ) + InteractionHookLog.info( + "HomeGesture", + "HomeView hook installed middleDown=$enableMiddleSwipeDown keepSwipeUp=$keepOriginalSwipeUpSearch threshold=${thresholdDp}dp" + ) + } catch (error: Throwable) { + InteractionHookLog.failure("HomeGesture", error) + } + } + + private fun handleGesture( + view: View, + event: MotionEvent, + thresholdDp: Float, + nativeLaunchSource: Any, + ): Boolean? { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + val candidate = event.pointerCount == 1 && + view.isShown && view.height > 0 && + event.y > view.height * 0.25f && + isSafeHomeState(view) && + isVacantCell(view, event) + synchronized(states) { + states[view] = GestureState(event.x, event.y, candidate) + } + } + + MotionEvent.ACTION_POINTER_DOWN -> synchronized(states) { + states[view]?.candidate = false + } + + MotionEvent.ACTION_MOVE -> { + val state = synchronized(states) { states[view] } ?: return null + if (state.consuming) return true + if (!state.candidate || event.pointerCount != 1 || !isSafeHomeState(view)) { + state.candidate = false + return null + } + val deltaX = event.x - state.downX + val deltaY = event.y - state.downY + val thresholdPx = thresholdDp * view.resources.displayMetrics.density + if (deltaY >= thresholdPx && abs(deltaY) > abs(deltaX) * 1.2f) { + val controller = callMethod(view, "getSearchScreenController") + callMethod(controller, "startSearch", nativeLaunchSource, true) + focusFinderInput(view) + state.consuming = true + InteractionHookLog.info( + "HomeGesture", + "middle swipe recognized deltaY=${deltaY.toInt()}px -> native Finder via SEARCH_FROM_KEY" + ) + return true + } + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + val state = synchronized(states) { states.remove(view) } + if (state?.consuming == true) return true + } + } + return null + } + + private fun focusFinderInput(view: View, attempt: Int = 0) { + val delays = longArrayOf(120L, 220L, 400L) + if (attempt !in delays.indices) return + view.postDelayed({ + try { + val inputId = view.resources.getIdentifier( + "search_src_text", + "id", + "com.sec.android.app.launcher", + ) + val input = if (inputId != 0) { + view.rootView.findViewById(inputId) as? EditText + } else { + null + } + if (input == null || !input.isShown) { + focusFinderInput(view, attempt + 1) + return@postDelayed + } + input.isFocusableInTouchMode = true + input.requestFocus() + input.setSelection(input.text?.length ?: 0) + val inputMethodManager = + view.context.getSystemService(InputMethodManager::class.java) + val requested = inputMethodManager.showSoftInput( + input, + 0, + ) + if (requested || attempt == delays.lastIndex) { + InteractionHookLog.info( + "HomeGesture", + "Finder input focused keyboardRequested=$requested attempt=${attempt + 1}", + ) + } else { + focusFinderInput(view, attempt + 1) + } + } catch (error: Throwable) { + if (attempt == delays.lastIndex) { + InteractionHookLog.failure("HomeGestureKeyboard", error) + } else { + focusFinderInput(view, attempt + 1) + } + } + }, delays[attempt]) + } + + private fun isSafeHomeState(view: View): Boolean = try { + if ((callMethod(view, "isDragAnimRunning") as? Boolean) == true) return false + val quickOption = callMethod(view, "getQuickOptionController") + if ((callMethod(quickOption, "isDragging") as? Boolean) == true) return false + if ((callMethod(quickOption, "isShowQuickOption") as? Boolean) == true) return false + val honeyScreen = getObjectField(view, "h") ?: return false + val current = callMethod(honeyScreen, "getCurrentHoneyState") + val changing = callMethod(honeyScreen, "getCurrentChangeState") + current?.javaClass?.name?.endsWith("HomeScreen\$Normal") == true && + changing?.javaClass?.name?.endsWith("HomeScreen\$Normal") == true + } catch (_: Throwable) { + false + } + + private fun isVacantCell(view: View, event: MotionEvent): Boolean = try { + val callback = getObjectField(view, "i") + (callMethod(callback, "invoke", PointF(event.rawX, event.rawY)) as? Boolean) == true + } catch (_: Throwable) { + false + } + + private fun disableNativeFinderGate(view: View) { + disableControllerGate( + view = view, + controllerName = "FinderTouchController", + hooked = finderGateHooked, + successMessage = "original Finder swipe-up gate disabled", + failureMessage = "FinderTouchController gate ambiguous; swipe-up retained", + ) + } + + private fun disableLauncherStatusBarGate(view: View) { + disableControllerGate( + view = view, + controllerName = "StatusBarTouchController", + hooked = statusBarGateHooked, + successMessage = + "launcher anywhere-swipe notification gate disabled; top status bar retained by SystemUI", + failureMessage = + "StatusBarTouchController gate ambiguous; launcher notification gesture retained", + ) + } + + private fun disableControllerGate( + view: View, + controllerName: String, + hooked: AtomicBoolean, + successMessage: String, + failureMessage: String, + ) { + if (hooked.get()) return + val controllers = getObjectField(view, "e") as? Iterable<*> ?: return + val controller = controllers.firstOrNull { candidate -> + runCatching { callMethod(candidate, "getName") as? String }.getOrNull() == + controllerName + } ?: return + val gate = controller.javaClass.declaredMethods.singleOrNull { + it.name == "a" && it.parameterCount == 0 && it.returnType == Boolean::class.javaPrimitiveType + } ?: run { + InteractionHookLog.info("HomeGesture", failureMessage) + return + } + if (hooked.compareAndSet(false, true)) { + XposedBridge.hookMethod(gate, XC_MethodReplacement.returnConstant(false)) + InteractionHookLog.info("HomeGesture", successMessage) + } + } + + private data class GestureState( + val downX: Float, + val downY: Float, + var candidate: Boolean, + var consuming: Boolean = false, + ) +} diff --git a/app/src/main/java/io/github/soclear/oneuix/hook/systemui/KeyguardGesture.kt b/app/src/main/java/io/github/soclear/oneuix/hook/systemui/KeyguardGesture.kt new file mode 100644 index 00000000..ced0bd07 --- /dev/null +++ b/app/src/main/java/io/github/soclear/oneuix/hook/systemui/KeyguardGesture.kt @@ -0,0 +1,61 @@ +package io.github.soclear.oneuix.hook.systemui + +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.XposedHelpers.findClassIfExists +import de.robv.android.xposed.XposedHelpers.findFieldIfExists +import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam +import io.github.soclear.oneuix.hook.util.InteractionHookLog +import kotlin.math.roundToInt + +object KeyguardGesture { + fun customizeSwipeDistance(lpparam: LoadPackageParam, requestedScale: Float) { + val scale = requestedScale.coerceIn(0.1f, 1.0f) + try { + InteractionHookLog.environment("KeyguardGesture", lpparam) + val keyguardTouchBase = findClassIfExists( + "com.android.systemui.keyguard.animator.KeyguardTouchBase", + lpparam.classLoader + ) ?: run { + InteractionHookLog.info("KeyguardGesture", "KeyguardTouchBase not found; Samsung behavior retained") + return + } + val radiusField = findFieldIfExists(keyguardTouchBase, "swipeUnlockRadius") ?: run { + InteractionHookLog.info("KeyguardGesture", "swipeUnlockRadius not found; Samsung behavior retained") + return + } + val candidates = keyguardTouchBase.declaredMethods.filter { + it.parameterCount == 0 && it.name.startsWith("initDimens") + } + val initMethod = candidates.firstOrNull { it.name == "initDimens\$5" } + ?: candidates.singleOrNull() + ?: run { + InteractionHookLog.info( + "KeyguardGesture", + "safe initDimens match unavailable (${candidates.size} candidates); Samsung behavior retained" + ) + return + } + + XposedBridge.hookMethod(initMethod, object : XC_MethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + try { + val originalRadius = radiusField.getInt(param.thisObject) + if (originalRadius <= 0) return + val result = (originalRadius * scale).roundToInt().coerceAtLeast(1) + radiusField.setInt(param.thisObject, result) + InteractionHookLog.info( + "KeyguardGesture", + "swipeUnlockRadius original=$originalRadius scale=$scale result=$result" + ) + } catch (error: Throwable) { + InteractionHookLog.failure("KeyguardGesture", error) + } + } + }) + InteractionHookLog.info("KeyguardGesture", "hooked=${initMethod.name}") + } catch (error: Throwable) { + InteractionHookLog.failure("KeyguardGesture", error) + } + } +} diff --git a/app/src/main/java/io/github/soclear/oneuix/hook/systemui/LockscreenNavigationBar.kt b/app/src/main/java/io/github/soclear/oneuix/hook/systemui/LockscreenNavigationBar.kt new file mode 100644 index 00000000..85dd57b9 --- /dev/null +++ b/app/src/main/java/io/github/soclear/oneuix/hook/systemui/LockscreenNavigationBar.kt @@ -0,0 +1,177 @@ +package io.github.soclear.oneuix.hook.systemui + +import android.content.Context +import android.graphics.PointF +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XposedHelpers.findAndHookConstructor +import de.robv.android.xposed.XposedHelpers.findAndHookMethod +import de.robv.android.xposed.XposedHelpers.findClassIfExists +import de.robv.android.xposed.XposedHelpers.getBooleanField +import de.robv.android.xposed.XposedHelpers.getIntField +import de.robv.android.xposed.XposedHelpers.getObjectField +import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam +import io.github.soclear.oneuix.hook.util.InteractionHookLog +import java.lang.ref.WeakReference +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.max + +object LockscreenNavigationBar { + private const val COMPONENT = "LockscreenNavBar" + private const val CLASS_NAME = + "com.android.systemui.statusbar.phone.SecStatusBarKeyguardViewManager" + private val stateReadFailureLogged = AtomicBoolean(false) + private var managerRef: WeakReference? = null + private val navigationHandles = CopyOnWriteArrayList>() + + fun showOnLockscreen(lpparam: LoadPackageParam) { + try { + val managerClass = findClassIfExists(CLASS_NAME, lpparam.classLoader) ?: run { + InteractionHookLog.info(COMPONENT, "Samsung keyguard view manager unavailable") + return + } + findAndHookMethod( + managerClass, + "isNavBarVisible", + object : XC_MethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + managerRef = WeakReference(param.thisObject) + if (param.result == true) return + try { + if (shouldShowOnLockscreen(param.thisObject)) { + param.result = true + } + } catch (error: Throwable) { + if (stateReadFailureLogged.compareAndSet(false, true)) { + InteractionHookLog.failure(COMPONENT, error) + } + } + } + }, + ) + findAndHookMethod( + "com.android.systemui.navigationbar.views.SamsungNavigationBarView", + lpparam.classLoader, + "updateHintVisibility", + Boolean::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + object : XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + val manager = managerRef?.get() ?: return + try { + if (shouldShowOnLockscreen(manager)) { + param.args[1] = true + } + } catch (error: Throwable) { + if (stateReadFailureLogged.compareAndSet(false, true)) { + InteractionHookLog.failure(COMPONENT, error) + } + } + } + }, + ) + installGestureHandleMotion(lpparam) + InteractionHookLog.info( + COMPONENT, + "lockscreen navigation bar window, gesture handle, and swipe motion overrides installed", + ) + } catch (error: Throwable) { + InteractionHookLog.failure(COMPONENT, error) + } + } + + private fun shouldShowOnLockscreen(manager: Any): Boolean { + val keyguardStateController = getObjectField(manager, "mKeyguardStateController") + return getBooleanField(keyguardStateController, "mShowing") && + !getBooleanField(keyguardStateController, "mOccluded") && + !getBooleanField(manager, "mDozing") && + !getBooleanField(manager, "mScreenOffAnimationPlaying") + } + + private fun installGestureHandleMotion(lpparam: LoadPackageParam) { + findAndHookConstructor( + "com.android.systemui.navigationbar.gestural.NavigationHandle", + lpparam.classLoader, + Context::class.java, + AttributeSet::class.java, + object : XC_MethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + navigationHandles.removeAll { it.get() == null } + navigationHandles += WeakReference(param.thisObject as View) + } + }, + ) + + val touchBaseClass = findClassIfExists( + "com.android.systemui.keyguard.animator.KeyguardTouchBase", + lpparam.classLoader, + ) ?: return + findAndHookMethod( + touchBaseClass, + "updateDistance", + MotionEvent::class.java, + Boolean::class.javaPrimitiveType, + object : XC_MethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + val manager = managerRef?.get() ?: return + if (!shouldShowOnLockscreen(manager)) return + val event = param.args[0] as MotionEvent + val down = getObjectField(param.thisObject, "touchDownPos") as PointF + val upwardDistance = (down.y - event.rawY).coerceAtLeast(0f) + val unlockRadius = max(getIntField(param.thisObject, "swipeUnlockRadius"), 1) + val progress = (upwardDistance / unlockRadius).coerceIn(0f, 1f) + updateHandles(progress) + } + }, + ) + findAndHookMethod( + touchBaseClass, + "setTouch", + Boolean::class.javaPrimitiveType, + object : XC_MethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + if (param.args[0] == false) resetHandles(animated = true) + } + }, + ) + } + + private fun updateHandles(progress: Float) { + navigationHandles.removeAll { it.get() == null } + navigationHandles.forEach { reference -> + reference.get()?.let { handle -> + if (!handle.isAttachedToWindow || handle.visibility != View.VISIBLE) return@let + handle.animate().cancel() + val maxTravel = 28f * handle.resources.displayMetrics.density + handle.translationY = -maxTravel * progress + handle.scaleX = 1f + (0.35f * progress) + handle.alpha = 0.82f + (0.18f * progress) + } + } + } + + private fun resetHandles(animated: Boolean) { + navigationHandles.removeAll { it.get() == null } + navigationHandles.forEach { reference -> + reference.get()?.let { handle -> + handle.animate().cancel() + if (animated && handle.isAttachedToWindow) { + handle.animate() + .translationY(0f) + .scaleX(1f) + .alpha(1f) + .setDuration(220L) + .start() + } else { + handle.translationY = 0f + handle.scaleX = 1f + handle.alpha = 1f + } + } + } + } +} diff --git a/app/src/main/java/io/github/soclear/oneuix/hook/systemui/NotificationQuickLaunch.kt b/app/src/main/java/io/github/soclear/oneuix/hook/systemui/NotificationQuickLaunch.kt new file mode 100644 index 00000000..9064750c --- /dev/null +++ b/app/src/main/java/io/github/soclear/oneuix/hook/systemui/NotificationQuickLaunch.kt @@ -0,0 +1,183 @@ +package io.github.soclear.oneuix.hook.systemui + +import android.app.KeyguardManager +import android.os.SystemClock +import android.view.View +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.XposedHelpers.callMethod +import de.robv.android.xposed.XposedHelpers.findAndHookMethod +import de.robv.android.xposed.XposedHelpers.findClassIfExists +import de.robv.android.xposed.XposedHelpers.getObjectField +import de.robv.android.xposed.XposedHelpers.getBooleanField +import de.robv.android.xposed.XposedHelpers.setBooleanField +import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam +import io.github.soclear.oneuix.hook.util.InteractionHookLog +import java.util.concurrent.atomic.AtomicLong + +object NotificationQuickLaunch { + private const val DIRECT_OPEN_WINDOW_MS = 3_000L + private val directOpenUntil = AtomicLong(0) + + fun enable(lpparam: LoadPackageParam) { + try { + InteractionHookLog.environment("NotifyQuickLaunch", lpparam) + val notificationClicker = findClassIfExists( + "com.android.systemui.statusbar.notification.NotificationClicker", + lpparam.classLoader + ) ?: return logMissing("NotificationClicker") + val starterClass = findClassIfExists( + "com.android.systemui.statusbar.phone.StatusBarNotificationActivityStarter", + lpparam.classLoader + ) ?: return logMissing("StatusBarNotificationActivityStarter") + val keyguardViewManagerClass = findClassIfExists( + "com.android.systemui.statusbar.phone.SecStatusBarKeyguardViewManager", + lpparam.classLoader, + ) ?: return logMissing("SecStatusBarKeyguardViewManager") + val onDismissActionClass = findClassIfExists( + "com.android.systemui.plugins.ActivityStarter\$OnDismissAction", + lpparam.classLoader, + ) ?: return logMissing("ActivityStarter.OnDismissAction") + + findAndHookMethod( + notificationClicker, + "onClick", + View::class.java, + object : XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + val result = evaluateEligibility(param.thisObject, param.args.firstOrNull()) + if (result.eligible) { + directOpenUntil.set( + SystemClock.elapsedRealtime() + DIRECT_OPEN_WINDOW_MS, + ) + clearSwipeBouncer(param.thisObject) + } else { + directOpenUntil.set(0) + } + InteractionHookLog.info( + "NotifyQuickLaunch", + "keyguardShowing=${result.showing} canDismiss=${result.canDismiss} " + + "deviceLocked=${result.deviceLocked} sourceNotification=${result.notificationSource} " + + "-> ${if (result.eligible) "direct-open" else "Samsung behavior"}" + ) + } + + } + ) + + hookSwipeBouncerSetter(starterClass, "activity starter") + hookSwipeBouncerSetter(keyguardViewManagerClass, "keyguard view manager") + + findAndHookMethod( + keyguardViewManagerClass, + "dismissWithAction", + onDismissActionClass, + Runnable::class.java, + Boolean::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + object : XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + if (!isDirectOpenWindow()) return + param.args[3] = true + InteractionHookLog.info( + "NotifyQuickLaunch", + "authenticated notification click: instant dismiss forced", + ) + } + }, + ) + InteractionHookLog.info("NotifyQuickLaunch", "hooks installed") + } catch (error: Throwable) { + directOpenUntil.set(0) + InteractionHookLog.failure("NotifyQuickLaunch", error) + } + } + + private fun hookSwipeBouncerSetter(targetClass: Class<*>, source: String) { + findAndHookMethod( + targetClass, + "setShowSwipeBouncer", + Boolean::class.javaPrimitiveType, + object : XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + if (param.args[0] == true && isDirectOpenWindow()) { + param.args[0] = false + InteractionHookLog.info( + "NotifyQuickLaunch", + "authenticated notification click: swipe bouncer suppressed at $source", + ) + } + } + }, + ) + } + + private fun clearSwipeBouncer(clicker: Any) { + try { + val starter = getObjectField(clicker, "mNotificationActivityStarter") + val viewManager = getObjectField(starter, "mStatusBarKeyguardViewManager") + callMethod(viewManager, "setShowSwipeBouncer", false) + val controller = getObjectField(starter, "mKeyguardStateController") + runCatching { setBooleanField(controller, "mIsSwipeBouncer", false) } + InteractionHookLog.info( + "NotifyQuickLaunch", + "authenticated notification click: existing swipe bouncer cleared", + ) + } catch (error: Throwable) { + InteractionHookLog.failure("NotifyQuickLaunchClearBouncer", error) + } + } + + private fun isDirectOpenWindow(): Boolean = + SystemClock.elapsedRealtime() <= directOpenUntil.get() + + private fun evaluateEligibility(clicker: Any, source: Any?): Eligibility = try { + val notificationSource = source?.javaClass?.name?.endsWith("ExpandableNotificationRow") == true + if (!notificationSource) return Eligibility(notificationSource = false) + val starter = getObjectField(clicker, "mNotificationActivityStarter") + val controller = getObjectField(starter, "mKeyguardStateController") + // One UI 8.5's implementation exposes these security states as fields rather than + // the AOSP interface-style getters. Reading the actual controller state preserves + // Samsung's Strong Auth and biometric decisions without forcing either value. + val showing = readBooleanState(controller, "mShowing", "isShowing") + val canDismiss = readBooleanState( + controller, + "mCanDismissLockScreen", + "canDismissLockScreen" + ) + val manager = getObjectField(starter, "mKeyguardManager") as? KeyguardManager + val keyguardLocked = manager?.isKeyguardLocked == true + val deviceLocked = manager?.isDeviceLocked != false + Eligibility( + notificationSource = true, + showing = showing, + canDismiss = canDismiss, + deviceLocked = deviceLocked, + eligible = showing && canDismiss && keyguardLocked && !deviceLocked, + ) + } catch (error: Throwable) { + InteractionHookLog.failure("NotifyQuickLaunch", error) + Eligibility() + } + + private fun readBooleanState(target: Any, fieldName: String, methodName: String): Boolean { + return try { + getBooleanField(target, fieldName) + } catch (_: Throwable) { + (callMethod(target, methodName) as? Boolean) == true + } + } + + private fun logMissing(name: String) { + InteractionHookLog.info("NotifyQuickLaunch", "$name not found; Samsung behavior retained") + } + + private data class Eligibility( + val notificationSource: Boolean = false, + val showing: Boolean = false, + val canDismiss: Boolean = false, + val deviceLocked: Boolean = true, + val eligible: Boolean = false, + ) +} diff --git a/app/src/main/java/io/github/soclear/oneuix/hook/systemui/SystemUiWakeBridge.kt b/app/src/main/java/io/github/soclear/oneuix/hook/systemui/SystemUiWakeBridge.kt new file mode 100644 index 00000000..93ff1eeb --- /dev/null +++ b/app/src/main/java/io/github/soclear/oneuix/hook/systemui/SystemUiWakeBridge.kt @@ -0,0 +1,236 @@ +package io.github.soclear.oneuix.hook.systemui + +import android.app.KeyguardManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Handler +import android.os.Looper +import android.os.PowerManager +import android.os.SystemClock +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XposedBridge.hookAllConstructors +import de.robv.android.xposed.XposedHelpers.callMethod +import de.robv.android.xposed.XposedHelpers.findClassIfExists +import de.robv.android.xposed.XposedHelpers.findAndHookMethod +import de.robv.android.xposed.XposedHelpers.getStaticObjectField +import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam +import io.github.soclear.oneuix.BuildConfig +import io.github.soclear.oneuix.hook.util.InteractionHookLog +import java.lang.ref.WeakReference +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +object SystemUiWakeBridge { + const val ACTION_WAKE_SCREEN = "io.github.soclear.oneuix.action.WAKE_SCREEN" + const val EXTRA_DURATION_SECONDS = "duration_seconds" + const val SYSTEM_UI_PACKAGE = "com.android.systemui" + private const val SYSTEM_UI_APPLICATION = + "com.android.systemui.application.impl.SystemUIApplicationImpl" + private const val FACE_AUTH_INTERACTOR = + "com.android.systemui.deviceentry.domain.interactor.SystemUIDeviceEntryFaceAuthInteractor" + private const val FACE_AUTH_UI_EVENT = + "com.android.systemui.deviceentry.shared.FaceAuthUiEvent" + private const val FACE_AUTH_WAKE_EVENT = "FACE_AUTH_UPDATED_STARTED_WAKING_UP" + private const val WAKE_REASON_GESTURE = 4 + private const val GO_TO_SLEEP_REASON_TIMEOUT = 2 + private const val FACE_AUTH_REQUEST_DELAY_MS = 300L + private const val WAKE_DETAILS = "OneUIX:NotificationWake" + private const val SENDER_PERMISSION = + BuildConfig.APPLICATION_ID + ".DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" + private val registered = AtomicBoolean(false) + private val faceAuthCaptureInstalled = AtomicBoolean(false) + private val wakeGeneration = AtomicLong(0) + private val mainHandler = Handler(Looper.getMainLooper()) + + @Volatile + private var faceAuthInteractor: WeakReference? = null + + fun enable(lpparam: LoadPackageParam) { + if (lpparam.processName != SYSTEM_UI_PACKAGE) return + try { + installFaceAuthCapture(lpparam.classLoader) + val applicationClass = + findClassIfExists(SYSTEM_UI_APPLICATION, lpparam.classLoader) ?: run { + InteractionHookLog.info( + "WakeBridge", + "SystemUIApplicationImpl not found; wake bridge unavailable", + ) + return + } + findAndHookMethod( + applicationClass, + "onCreate", + object : XC_MethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + if (!registered.compareAndSet(false, true)) return + val context = param.thisObject as? Context ?: return + try { + context.registerReceiver( + WakeReceiver(), + IntentFilter(ACTION_WAKE_SCREEN), + SENDER_PERMISSION, + null, + Context.RECEIVER_EXPORTED, + ) + InteractionHookLog.info( + "WakeBridge", + "SystemUI receiver registered with signature sender permission", + ) + } catch (error: Throwable) { + registered.set(false) + InteractionHookLog.failure("WakeBridge", error) + } + } + }, + ) + } catch (error: Throwable) { + InteractionHookLog.failure("WakeBridge", error) + } + } + + private class WakeReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ACTION_WAKE_SCREEN) return + try { + val powerManager = context.getSystemService(PowerManager::class.java) + val now = SystemClock.uptimeMillis() + val durationSeconds = intent.getIntExtra(EXTRA_DURATION_SECONDS, 8) + .coerceIn(1, 10) + val generation = wakeGeneration.incrementAndGet() + runCatching { + callMethod( + powerManager, + "wakeUp", + now, + WAKE_REASON_GESTURE, + WAKE_DETAILS, + ) + }.getOrElse { + callMethod(powerManager, "wakeUp", now, WAKE_DETAILS) + } + mainHandler.postDelayed( + { requestNativeFaceAuth(context) }, + FACE_AUTH_REQUEST_DELAY_MS, + ) + mainHandler.postDelayed( + { + sleepIfStillOnKeyguard(context, powerManager, generation) + }, + durationSeconds * 1_000L, + ) + InteractionHookLog.info( + "WakeBridge", + "screen wake requested by SystemUI duration=${durationSeconds}s", + ) + } catch (error: Throwable) { + InteractionHookLog.failure("WakeBridge", error) + } + } + } + + private fun installFaceAuthCapture(classLoader: ClassLoader) { + if (!faceAuthCaptureInstalled.compareAndSet(false, true)) return + val interactorClass = findClassIfExists(FACE_AUTH_INTERACTOR, classLoader) + if (interactorClass == null) { + faceAuthCaptureInstalled.set(false) + InteractionHookLog.info( + "WakeFaceAuth", + "native face-auth interactor unavailable", + ) + return + } + hookAllConstructors( + interactorClass, + object : XC_MethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + faceAuthInteractor = WeakReference(param.thisObject) + InteractionHookLog.info( + "WakeFaceAuth", + "native face-auth interactor captured", + ) + } + }, + ) + } + + private fun requestNativeFaceAuth(context: Context) { + val keyguardManager = context.getSystemService(KeyguardManager::class.java) + if (!keyguardManager.isKeyguardLocked) return + val interactor = faceAuthInteractor?.get() ?: run { + InteractionHookLog.info( + "WakeFaceAuth", + "face-auth request skipped: interactor not ready", + ) + return + } + try { + val enabledAndEnrolled = callMethod( + interactor, + "isFaceAuthEnabledAndEnrolled", + ) as? Boolean ?: false + if (!enabledAndEnrolled) { + InteractionHookLog.info( + "WakeFaceAuth", + "face-auth request skipped: not enabled or enrolled", + ) + return + } + val isRunning = callMethod(interactor, "isAuthRunning") as? Boolean ?: false + if (isRunning) { + InteractionHookLog.info( + "WakeFaceAuth", + "native wake pipeline already started face authentication", + ) + return + } + val eventClass = findClassIfExists( + FACE_AUTH_UI_EVENT, + interactor.javaClass.classLoader, + ) ?: error("FaceAuthUiEvent unavailable") + val event = getStaticObjectField(eventClass, FACE_AUTH_WAKE_EVENT) + callMethod(event, "setExtraInfo", WAKE_REASON_GESTURE) + callMethod(interactor, "runFaceAuth", event, true) + InteractionHookLog.info( + "WakeFaceAuth", + "native face-auth request sent after notification wake", + ) + } catch (error: Throwable) { + InteractionHookLog.failure("WakeFaceAuth", error) + } + } + + private fun sleepIfStillOnKeyguard( + context: Context, + powerManager: PowerManager, + generation: Long, + ) { + if (wakeGeneration.get() != generation || !powerManager.isInteractive) return + val keyguardManager = context.getSystemService(KeyguardManager::class.java) + if (!keyguardManager.isKeyguardLocked) { + InteractionHookLog.info( + "WakeBridge", + "wake timeout retained because keyguard was dismissed", + ) + return + } + try { + val now = SystemClock.uptimeMillis() + runCatching { + callMethod( + powerManager, + "goToSleep", + now, + GO_TO_SLEEP_REASON_TIMEOUT, + 0, + ) + }.getOrElse { + callMethod(powerManager, "goToSleep", now) + } + InteractionHookLog.info("WakeBridge", "wake timeout reached -> screen off") + } catch (error: Throwable) { + InteractionHookLog.failure("WakeBridgeSleep", error) + } + } +} diff --git a/app/src/main/java/io/github/soclear/oneuix/hook/util/InteractionHookLog.kt b/app/src/main/java/io/github/soclear/oneuix/hook/util/InteractionHookLog.kt new file mode 100644 index 00000000..c0b1056f --- /dev/null +++ b/app/src/main/java/io/github/soclear/oneuix/hook/util/InteractionHookLog.kt @@ -0,0 +1,25 @@ +package io.github.soclear.oneuix.hook.util + +import android.os.Build +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam +import io.github.soclear.oneuix.data.ONE_UI_VERSION + +object InteractionHookLog { + fun environment(component: String, lpparam: LoadPackageParam) { + info( + component, + "package=${lpparam.packageName} Android=${Build.VERSION.RELEASE} " + + "API=${Build.VERSION.SDK_INT} OneUI=$ONE_UI_VERSION" + ) + } + + fun info(component: String, message: String) { + XposedBridge.log("OneUIX [$component] $message") + } + + fun failure(component: String, error: Throwable) { + info(component, "failed=${error.javaClass.simpleName}: ${error.message.orEmpty()}") + XposedBridge.log(error) + } +} diff --git a/app/src/main/java/io/github/soclear/oneuix/service/AutoStartJobService.kt b/app/src/main/java/io/github/soclear/oneuix/service/AutoStartJobService.kt new file mode 100644 index 00000000..730d84a5 --- /dev/null +++ b/app/src/main/java/io/github/soclear/oneuix/service/AutoStartJobService.kt @@ -0,0 +1,132 @@ +package io.github.soclear.oneuix.service + +import android.app.job.JobInfo +import android.app.job.JobParameters +import android.app.job.JobScheduler +import android.app.job.JobService +import android.content.ComponentName +import android.content.Context +import android.os.PersistableBundle +import io.github.soclear.oneuix.ui.dataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import java.util.concurrent.TimeUnit + +class AutoStartJobService : JobService() { + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var runningJob: Job? = null + + override fun onStartJob(params: JobParameters): Boolean { + runningJob = serviceScope.launch { + val reason = params.extras.getString(EXTRA_REASON, REASON_UNKNOWN) + val result = runSelectedApps(reason) + InteractionDiagnostics.recordAutoStart(this@AutoStartJobService, result) + jobFinished(params, false) + } + return true + } + + override fun onStopJob(params: JobParameters): Boolean { + runningJob?.cancel() + runningJob = null + // This is a one-shot launch request. Never let JobScheduler repeat it after + // an interruption, otherwise the same apps could unexpectedly open twice. + return false + } + + override fun onDestroy() { + serviceScope.cancel() + super.onDestroy() + } + + private suspend fun runSelectedApps(reason: String): String { + val setting = dataStore.data.first().interaction + if (!setting.autoStartEnabled) return "reason=$reason skipped=disabled" + if (setting.autoStartPackages.isEmpty()) return "reason=$reason skipped=noSelection" + + var launched = 0 + var failed = 0 + setting.autoStartPackages.sorted().forEach { packageName -> + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + val component = launchIntent?.component + if (component == null) { + failed += 1 + InteractionDiagnostics.log( + "AutoStart", + "package=$packageName skipped=noLauncherActivity", + ) + return@forEach + } + val succeeded = launchAsRoot(component) + if (succeeded) launched += 1 else failed += 1 + InteractionDiagnostics.log( + "AutoStart", + "package=$packageName launched=$succeeded", + ) + delay(APP_LAUNCH_INTERVAL_MILLIS) + } + return "reason=$reason selected=${setting.autoStartPackages.size} " + + "launched=$launched failed=$failed" + } + + private fun launchAsRoot(component: ComponentName): Boolean { + val flattenedComponent = component.flattenToShortString() + if (!SAFE_COMPONENT.matches(flattenedComponent)) return false + return try { + val command = + "exec /system/bin/am start --user 0 -n '$flattenedComponent'" + val process = ProcessBuilder("su", "-c", command) + .redirectErrorStream(true) + .start() + if (!process.waitFor(ROOT_COMMAND_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + process.destroy() + false + } else { + process.exitValue() == 0 + } + } catch (error: Throwable) { + InteractionDiagnostics.log( + "AutoStart", + "package=${component.packageName} failed=${error.javaClass.simpleName}", + ) + false + } + } + + companion object { + const val REASON_SYSTEM_BOOT = "systemBoot" + const val REASON_SOFT_RESTART = "softRestart" + private const val REASON_UNKNOWN = "unknown" + private const val EXTRA_REASON = "reason" + private const val JOB_ID = 0x4F4E4558 + private const val START_DELAY_MILLIS = 15_000L + private const val APP_LAUNCH_INTERVAL_MILLIS = 1_500L + private const val ROOT_COMMAND_TIMEOUT_SECONDS = 15L + private val SAFE_COMPONENT = Regex("^[A-Za-z0-9_.$]+/[A-Za-z0-9_.$]+$") + + fun schedule(context: Context, reason: String): Boolean { + val extras = PersistableBundle().apply { putString(EXTRA_REASON, reason) } + val job = JobInfo.Builder( + JOB_ID, + ComponentName(context, AutoStartJobService::class.java), + ) + .setMinimumLatency(START_DELAY_MILLIS) + .setOverrideDeadline(START_DELAY_MILLIS + 45_000L) + .setPersisted(true) + .setExtras(extras) + .build() + return context.getSystemService(JobScheduler::class.java).schedule(job) == + JobScheduler.RESULT_SUCCESS + } + + fun cancel(context: Context) { + context.getSystemService(JobScheduler::class.java).cancel(JOB_ID) + } + } +} diff --git a/app/src/main/java/io/github/soclear/oneuix/service/BootAutoStartReceiver.kt b/app/src/main/java/io/github/soclear/oneuix/service/BootAutoStartReceiver.kt new file mode 100644 index 00000000..ec7b3136 --- /dev/null +++ b/app/src/main/java/io/github/soclear/oneuix/service/BootAutoStartReceiver.kt @@ -0,0 +1,13 @@ +package io.github.soclear.oneuix.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +class BootAutoStartReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_BOOT_COMPLETED) return + InteractionDiagnostics.log("AutoStart", "boot completed -> schedule") + AutoStartJobService.schedule(context, AutoStartJobService.REASON_SYSTEM_BOOT) + } +} diff --git a/app/src/main/java/io/github/soclear/oneuix/service/InteractionDiagnostics.kt b/app/src/main/java/io/github/soclear/oneuix/service/InteractionDiagnostics.kt new file mode 100644 index 00000000..fb6c37d6 --- /dev/null +++ b/app/src/main/java/io/github/soclear/oneuix/service/InteractionDiagnostics.kt @@ -0,0 +1,44 @@ +package io.github.soclear.oneuix.service + +import android.content.Context +import android.util.Log +import androidx.core.content.edit + +object InteractionDiagnostics { + private const val TAG = "OneUIX.Interaction" + private const val STORE = "interaction_diagnostics" + private const val LAST_WAKE = "last_wake_reason" + private const val LAST_AUTO_START = "last_auto_start_result" + + fun log(component: String, message: String) { + Log.i(TAG, "[$component] $message") + } + + fun recordWake(context: Context, reason: String) { + context.getSharedPreferences(STORE, Context.MODE_PRIVATE) + .edit { putString(LAST_WAKE, reason) } + log("Wake", reason) + } + + fun recordPermissionStatus(context: Context, granted: Boolean) { + log("Wake", "notificationListenerGranted=$granted") + } + + fun lastWakeReason(context: Context): String = context + .getSharedPreferences(STORE, Context.MODE_PRIVATE) + .getString(LAST_WAKE, null) + ?.let { "Last wake: $it" } + ?: "Last wake: none" + + fun recordAutoStart(context: Context, result: String) { + context.getSharedPreferences(STORE, Context.MODE_PRIVATE) + .edit { putString(LAST_AUTO_START, result) } + log("AutoStart", result) + } + + fun lastAutoStartResult(context: Context): String = context + .getSharedPreferences(STORE, Context.MODE_PRIVATE) + .getString(LAST_AUTO_START, null) + ?.let { "Last auto-start: $it" } + ?: "Last auto-start: none" +} diff --git a/app/src/main/java/io/github/soclear/oneuix/service/NotificationWakeService.kt b/app/src/main/java/io/github/soclear/oneuix/service/NotificationWakeService.kt new file mode 100644 index 00000000..8f23b00b --- /dev/null +++ b/app/src/main/java/io/github/soclear/oneuix/service/NotificationWakeService.kt @@ -0,0 +1,92 @@ +package io.github.soclear.oneuix.service + +import android.app.Notification +import android.service.notification.NotificationListenerService +import android.service.notification.StatusBarNotification +import io.github.soclear.oneuix.BuildConfig +import io.github.soclear.oneuix.data.Preference +import io.github.soclear.oneuix.ui.dataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class NotificationWakeService : NotificationListenerService() { + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val wakeController by lazy { ScreenWakeController(this) } + @Volatile + private var currentSetting: Preference.Interaction = Preference.Interaction() + @Volatile + private var preferenceReady = false + + override fun onCreate() { + super.onCreate() + serviceScope.launch { + dataStore.data + .catch { + InteractionDiagnostics.log( + "Wake", + "preferenceFlowFailed=${it.javaClass.simpleName}" + ) + } + .collectLatest { + currentSetting = it.interaction + preferenceReady = true + InteractionDiagnostics.log( + "Wake", + "preferenceReady wakeOnNotification=${it.interaction.wakeOnNotification}" + ) + } + } + } + + override fun onListenerConnected() { + super.onListenerConnected() + InteractionDiagnostics.recordPermissionStatus(this, true) + } + + override fun onListenerDisconnected() { + InteractionDiagnostics.recordPermissionStatus(this, false) + super.onListenerDisconnected() + } + + override fun onNotificationPosted(sbn: StatusBarNotification?) { + val notification = sbn?.notification ?: return + val setting = currentSetting + if (!preferenceReady || !setting.wakeOnNotification) { + InteractionDiagnostics.log( + "Wake", + "skip=preferenceNotReadyOrDisabled ready=$preferenceReady" + ) + return + } + + val reason = when { + sbn.packageName == BuildConfig.APPLICATION_ID -> "skip=self" + notification.flags and Notification.FLAG_ONGOING_EVENT != 0 -> "skip=ongoing" + notification.flags and Notification.FLAG_GROUP_SUMMARY != 0 -> "skip=groupSummary" + setting.wakeOnlyWhenScreenOff && wakeController.isInteractive -> "skip=screenInteractive" + else -> null + } + if (reason != null) { + InteractionDiagnostics.log("Wake", "$reason package=${sbn.packageName}") + return + } + + val durationSeconds = setting.wakeDurationSeconds.coerceIn(1, 10) + val woke = wakeController.wake(durationSeconds) + InteractionDiagnostics.recordWake( + this, + "package=${sbn.packageName} screenOff=true notificationEligible=true " + + "duration=${durationSeconds}s woke=$woke" + ) + } + + override fun onDestroy() { + serviceScope.cancel() + super.onDestroy() + } +} diff --git a/app/src/main/java/io/github/soclear/oneuix/service/ScreenWakeController.kt b/app/src/main/java/io/github/soclear/oneuix/service/ScreenWakeController.kt new file mode 100644 index 00000000..65b09389 --- /dev/null +++ b/app/src/main/java/io/github/soclear/oneuix/service/ScreenWakeController.kt @@ -0,0 +1,38 @@ +package io.github.soclear.oneuix.service + +import android.content.Context +import android.content.Intent +import android.os.PowerManager +import io.github.soclear.oneuix.hook.systemui.SystemUiWakeBridge + +class ScreenWakeController(context: Context) { + private val appContext = context.applicationContext + private val powerManager = context.getSystemService(PowerManager::class.java) + + val isInteractive: Boolean + get() = powerManager.isInteractive + + @Suppress("DEPRECATION") + fun wake(durationSeconds: Int): Boolean = try { + val effectiveDurationSeconds = durationSeconds.coerceIn(1, 10) + val timeoutMillis = effectiveDurationSeconds * 1_000L + val wakeLock = powerManager.newWakeLock( + PowerManager.FULL_WAKE_LOCK, + "OneUIX:NotificationWake" + ) + wakeLock.acquire(timeoutMillis) + appContext.sendBroadcast( + Intent(SystemUiWakeBridge.ACTION_WAKE_SCREEN) + .setPackage(SystemUiWakeBridge.SYSTEM_UI_PACKAGE) + .addFlags(Intent.FLAG_RECEIVER_FOREGROUND) + .putExtra( + SystemUiWakeBridge.EXTRA_DURATION_SECONDS, + effectiveDurationSeconds, + ) + ) + true + } catch (error: Throwable) { + InteractionDiagnostics.log("Wake", "failed=${error.javaClass.simpleName}") + false + } +} diff --git a/app/src/main/java/io/github/soclear/oneuix/service/SoftRestartController.kt b/app/src/main/java/io/github/soclear/oneuix/service/SoftRestartController.kt new file mode 100644 index 00000000..a0edfa69 --- /dev/null +++ b/app/src/main/java/io/github/soclear/oneuix/service/SoftRestartController.kt @@ -0,0 +1,45 @@ +package io.github.soclear.oneuix.service + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.TimeUnit + +object SoftRestartController { + private const val RESTART_COMMAND = + "exec /system/bin/setprop ctl.restart zygote" + + suspend fun softRestartLikeKernelSu(context: Context): Boolean = withContext(Dispatchers.IO) { + val scheduled = AutoStartJobService.schedule( + context.applicationContext, + AutoStartJobService.REASON_SOFT_RESTART, + ) + if (!scheduled) { + InteractionDiagnostics.log("SoftRestart", "failed=autoStartJobSchedule") + } + try { + val process = ProcessBuilder("su", "-c", RESTART_COMMAND) + .redirectErrorStream(true) + .start() + if (!process.waitFor(30, TimeUnit.SECONDS)) { + process.destroy() + if (scheduled) AutoStartJobService.cancel(context.applicationContext) + InteractionDiagnostics.log("SoftRestart", "failed=rootRequestTimeout") + false + } else { + val succeeded = process.exitValue() == 0 + if (!succeeded && scheduled) { + AutoStartJobService.cancel(context.applicationContext) + } + succeeded + } + } catch (error: Throwable) { + if (scheduled) AutoStartJobService.cancel(context.applicationContext) + InteractionDiagnostics.log( + "SoftRestart", + "failed=${error.javaClass.simpleName}", + ) + false + } + } +} diff --git a/app/src/main/java/io/github/soclear/oneuix/ui/SettingScreen.kt b/app/src/main/java/io/github/soclear/oneuix/ui/SettingScreen.kt index 632c9feb..bcc78490 100644 --- a/app/src/main/java/io/github/soclear/oneuix/ui/SettingScreen.kt +++ b/app/src/main/java/io/github/soclear/oneuix/ui/SettingScreen.kt @@ -36,6 +36,7 @@ import io.github.soclear.oneuix.ui.category.DetailPaneGalaxyStore import io.github.soclear.oneuix.ui.category.DetailPaneGallery import io.github.soclear.oneuix.ui.category.DetailPaneHealthMonitor import io.github.soclear.oneuix.ui.category.DetailPaneLauncher +import io.github.soclear.oneuix.ui.category.DetailPaneInteraction import io.github.soclear.oneuix.ui.category.DetailPaneMessaging import io.github.soclear.oneuix.ui.category.DetailPaneNotes import io.github.soclear.oneuix.ui.category.DetailPanePhotoRetouching @@ -58,6 +59,7 @@ import io.github.soclear.oneuix.ui.category.onGalaxyStoreEvent import io.github.soclear.oneuix.ui.category.onGalleryEvent import io.github.soclear.oneuix.ui.category.onHealthMonitorEvent import io.github.soclear.oneuix.ui.category.onLauncherEvent +import io.github.soclear.oneuix.ui.category.onInteractionEvent import io.github.soclear.oneuix.ui.category.onMessagingEvent import io.github.soclear.oneuix.ui.category.onNotesEvent import io.github.soclear.oneuix.ui.category.onPhotoRetouchingEvent @@ -79,6 +81,7 @@ fun SettingScreen(viewModel: SettingViewModel, modifier: Modifier = Modifier) { val scope = rememberCoroutineScope() val context = LocalContext.current val categoryAppInfoList by viewModel.categoryAppInfoList.collectAsStateWithLifecycle() + val userLaunchableApps by viewModel.userLaunchableApps.collectAsStateWithLifecycle() val preference by viewModel.preference.collectAsStateWithLifecycle() val backupLauncher = rememberLauncherForActivityResult( @@ -114,6 +117,7 @@ fun SettingScreen(viewModel: SettingViewModel, modifier: Modifier = Modifier) { AnimatedPane { ListPaneCategory( categoryAppInfoList = categoryAppInfoList, + hiddenCategoryNames = preference.hiddenMainMenuCategories, onItemClick = { category -> scope.launch { scaffoldNavigator.navigateTo( @@ -122,6 +126,8 @@ fun SettingScreen(viewModel: SettingViewModel, modifier: Modifier = Modifier) { ) } }, + onCategoryVisibilityChange = viewModel::setCategoryVisible, + onShowAllCategories = viewModel::showAllCategories, onBackup = { val name = "OneUIX_backup_${ LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")) @@ -136,6 +142,12 @@ fun SettingScreen(viewModel: SettingViewModel, modifier: Modifier = Modifier) { AnimatedPane { scaffoldNavigator.currentDestination?.contentKey?.let { when (it) { + Category.Interaction -> DetailPaneInteraction( + uiState = preference.interaction, + userLaunchableApps = userLaunchableApps, + onEvent = viewModel::onInteractionEvent + ) + Category.Android -> DetailPaneAndroid( uiState = preference.android, onEvent = viewModel::onAndroidEvent diff --git a/app/src/main/java/io/github/soclear/oneuix/ui/SettingViewModel.kt b/app/src/main/java/io/github/soclear/oneuix/ui/SettingViewModel.kt index 52219842..dd39180a 100644 --- a/app/src/main/java/io/github/soclear/oneuix/ui/SettingViewModel.kt +++ b/app/src/main/java/io/github/soclear/oneuix/ui/SettingViewModel.kt @@ -1,6 +1,8 @@ package io.github.soclear.oneuix.ui import android.app.Application +import android.content.Intent +import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import androidx.compose.ui.graphics.asImageBitmap import androidx.core.graphics.drawable.toBitmap @@ -18,10 +20,13 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import io.github.soclear.oneuix.data.IgnoreUnknownKeysJson import io.github.soclear.oneuix.data.Preference +import io.github.soclear.oneuix.R import io.github.soclear.oneuix.ui.category.Category import io.github.soclear.oneuix.ui.category.CategoryAppInfo +import io.github.soclear.oneuix.ui.category.UserLaunchableApp import java.io.InputStream import java.io.OutputStream +import java.text.Collator class SettingViewModel(application: Application) : ViewModel() { val categoryAppInfoList: StateFlow> = flow { @@ -36,8 +41,11 @@ class SettingViewModel(application: Application) : ViewModel() { } catch (_: PackageManager.NameNotFoundException) { null } - val label = applicationInfo?.loadLabel(packageManager)?.toString() - ?: category.packageName + val label = if (category == Category.Interaction) { + application.getString(R.string.interaction_category) + } else { + applicationInfo?.loadLabel(packageManager)?.toString() ?: category.packageName + } val icon = applicationInfo?.loadIcon(packageManager)?.toBitmap()?.asImageBitmap() ?: fallbackIcon (applicationInfo != null) to CategoryAppInfo(category, label, icon) @@ -46,6 +54,40 @@ class SettingViewModel(application: Application) : ViewModel() { emit(categoryAppInfoList) }.flowOn(Dispatchers.IO).stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + val userLaunchableApps: StateFlow> = flow { + val packageManager = application.packageManager + val launcherIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + @Suppress("DEPRECATION") + val apps = packageManager.queryIntentActivities(launcherIntent, 0) + .asSequence() + .mapNotNull { resolveInfo -> + val applicationInfo = resolveInfo.activityInfo?.applicationInfo ?: return@mapNotNull null + val isSystemApp = applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0 || + applicationInfo.flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0 + if ( + isSystemApp || + !applicationInfo.enabled || + applicationInfo.packageName == application.packageName + ) { + return@mapNotNull null + } + val packageName = applicationInfo.packageName + UserLaunchableApp( + packageName = packageName, + label = applicationInfo.loadLabel(packageManager).toString(), + icon = applicationInfo.loadIcon(packageManager) + .toBitmap(width = 96, height = 96) + .asImageBitmap(), + ) + } + .distinctBy { it.packageName } + .sortedWith { left, right -> + Collator.getInstance().compare(left.label, right.label) + } + .toList() + emit(apps) + }.flowOn(Dispatchers.IO).stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + private val dataStore: DataStore = application.dataStore val preference = dataStore.data.stateIn( @@ -60,6 +102,18 @@ class SettingViewModel(application: Application) : ViewModel() { } } + fun setCategoryVisible(category: Category, visible: Boolean) { + updateData { current -> + val hidden = current.hiddenMainMenuCategories.toMutableSet() + if (visible) hidden.remove(category.name) else hidden.add(category.name) + current.copy(hiddenMainMenuCategories = hidden) + } + } + + fun showAllCategories() { + updateData { current -> current.copy(hiddenMainMenuCategories = emptySet()) } + } + suspend fun backupTo(output: OutputStream) = withContext(Dispatchers.IO) { output.write( IgnoreUnknownKeysJson.encodeToString( diff --git a/app/src/main/java/io/github/soclear/oneuix/ui/category/Category.kt b/app/src/main/java/io/github/soclear/oneuix/ui/category/Category.kt index 5c26b83b..4f87ef4a 100644 --- a/app/src/main/java/io/github/soclear/oneuix/ui/category/Category.kt +++ b/app/src/main/java/io/github/soclear/oneuix/ui/category/Category.kt @@ -3,6 +3,7 @@ package io.github.soclear.oneuix.ui.category import io.github.soclear.oneuix.data.Package enum class Category(val packageName: String) { + Interaction("io.github.soclear.oneuix"), Android(Package.ANDROID), SystemUI(Package.SYSTEMUI), Settings(Package.SETTINGS), diff --git a/app/src/main/java/io/github/soclear/oneuix/ui/category/DetailPaneInteraction.kt b/app/src/main/java/io/github/soclear/oneuix/ui/category/DetailPaneInteraction.kt new file mode 100644 index 00000000..eea75a82 --- /dev/null +++ b/app/src/main/java/io/github/soclear/oneuix/ui/category/DetailPaneInteraction.kt @@ -0,0 +1,492 @@ +package io.github.soclear.oneuix.ui.category + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.Build +import android.provider.Settings +import android.view.HapticFeedbackConstants +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.Button +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import io.github.soclear.oneuix.R +import io.github.soclear.oneuix.data.ONE_UI_VERSION +import io.github.soclear.oneuix.data.Preference +import io.github.soclear.oneuix.service.InteractionDiagnostics +import io.github.soclear.oneuix.service.NotificationWakeService +import io.github.soclear.oneuix.service.SoftRestartController +import io.github.soclear.oneuix.ui.SettingViewModel +import io.github.soclear.oneuix.ui.component.SwitchItem +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@Composable +fun DetailPaneInteraction( + uiState: Preference.Interaction, + userLaunchableApps: List, + onEvent: (InteractionEvent) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var showSoftRestartConfirmation by remember { mutableStateOf(false) } + var showAutoStartAppPicker by remember { mutableStateOf(false) } + var notificationAccessGranted by remember { + mutableStateOf(isNotificationAccessGranted(context)) + } + val notificationSettings = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + notificationAccessGranted = isNotificationAccessGranted(context) + InteractionDiagnostics.recordPermissionStatus(context, notificationAccessGranted) + } + + PackagePane(modifier) { + SectionTitle(stringResource(R.string.interaction_apply_section)) + ListItem( + headlineContent = { Text(stringResource(R.string.soft_restart_title)) }, + supportingContent = { Text(stringResource(R.string.soft_restart_summary)) }, + trailingContent = { + Button(onClick = { showSoftRestartConfirmation = true }) { + Text(stringResource(R.string.soft_restart_button)) + } + }, + ) + + SectionTitle(stringResource(R.string.interaction_auto_start_section)) + SwitchItem( + title = stringResource(R.string.auto_start_enabled_title), + summary = stringResource(R.string.auto_start_enabled_summary), + checked = uiState.autoStartEnabled, + onCheckedChange = { onEvent(InteractionEvent.AutoStartEnabled(it)) }, + ) + val availablePackageNames = remember(userLaunchableApps) { + userLaunchableApps.mapTo(mutableSetOf()) { it.packageName } + } + val selectedInstalledCount = uiState.autoStartPackages.count { + it in availablePackageNames + } + ListItem( + headlineContent = { Text(stringResource(R.string.auto_start_select_apps_title)) }, + supportingContent = { + Text( + stringResource( + R.string.auto_start_select_apps_summary, + selectedInstalledCount, + ) + ) + }, + trailingContent = { + Button(onClick = { showAutoStartAppPicker = true }) { + Text(stringResource(R.string.auto_start_select_apps_button)) + } + }, + ) + + SectionTitle(stringResource(R.string.interaction_notification_section)) + ListItem( + headlineContent = { Text(stringResource(R.string.notification_access_title)) }, + supportingContent = { + val permissionStatus = stringResource( + if (notificationAccessGranted) R.string.permission_granted + else R.string.permission_not_granted + ) + Text( + stringResource( + R.string.notification_access_summary, + permissionStatus, + ) + ) + }, + trailingContent = { + Button(onClick = { + notificationSettings.launch(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) + }) { + Text(stringResource(R.string.open_notification_access)) + } + } + ) + SwitchItem( + title = stringResource(R.string.wake_on_notification_title), + summary = stringResource(R.string.wake_on_notification_summary), + checked = uiState.wakeOnNotification, + onCheckedChange = { onEvent(InteractionEvent.WakeOnNotification(it)) } + ) + SliderItem( + title = stringResource(R.string.wake_duration_title), + summary = stringResource(R.string.wake_duration_summary), + valueLabelResource = R.string.seconds_value, + valueToLabelNumber = { it.roundToInt().coerceIn(1, 10) }, + value = uiState.wakeDurationSeconds.coerceIn(1, 10).toFloat(), + valueRange = 1f..10f, + steps = 8, + onValueChangeFinished = { + onEvent(InteractionEvent.WakeDuration(it.roundToInt().coerceIn(1, 10))) + } + ) + SwitchItem( + title = stringResource(R.string.wake_only_screen_off_title), + summary = stringResource(R.string.wake_only_screen_off_summary), + checked = uiState.wakeOnlyWhenScreenOff, + onCheckedChange = { onEvent(InteractionEvent.WakeOnlyWhenScreenOff(it)) } + ) + SwitchItem( + title = stringResource(R.string.notification_direct_open_title), + summary = stringResource(R.string.notification_direct_open_summary), + checked = uiState.notificationDirectOpenAfterAuth, + onCheckedChange = { onEvent(InteractionEvent.NotificationDirectOpen(it)) } + ) + + SectionTitle(stringResource(R.string.interaction_home_section)) + SwitchItem( + title = stringResource(R.string.home_swipe_down_search_title), + summary = stringResource(R.string.home_swipe_down_search_summary), + checked = uiState.homeSwipeDownSearch, + onCheckedChange = { onEvent(InteractionEvent.HomeSwipeDownSearch(it)) } + ) + SwitchItem( + title = stringResource(R.string.keep_original_swipe_up_title), + summary = stringResource(R.string.keep_original_swipe_up_summary), + checked = uiState.keepOriginalSwipeUpSearch, + onCheckedChange = { onEvent(InteractionEvent.KeepOriginalSwipeUpSearch(it)) } + ) + SliderItem( + title = stringResource(R.string.home_swipe_threshold_title), + summary = stringResource(R.string.home_swipe_threshold_summary), + valueLabelResource = R.string.dp_value, + valueToLabelNumber = { it.roundToInt() }, + value = uiState.homeSwipeDownThresholdDp.coerceIn(64f, 160f), + valueRange = 64f..160f, + steps = 5, + onValueChangeFinished = { + onEvent(InteractionEvent.HomeSwipeThreshold(it.coerceIn(64f, 160f))) + } + ) + + SectionTitle(stringResource(R.string.interaction_lockscreen_section)) + SwitchItem( + title = stringResource(R.string.customize_lockscreen_swipe_title), + summary = stringResource(R.string.customize_lockscreen_swipe_summary), + checked = uiState.customizeLockscreenSwipeDistance, + onCheckedChange = { onEvent(InteractionEvent.CustomizeLockscreenSwipe(it)) } + ) + SliderItem( + title = stringResource(R.string.lockscreen_swipe_distance_title), + summary = stringResource(R.string.lockscreen_swipe_distance_summary), + valueLabelResource = R.string.percent_value, + valueToLabelNumber = { (it * 100).roundToInt() }, + value = uiState.lockscreenSwipeDistanceScale.coerceIn(0.1f, 1.0f), + valueRange = 0.1f..1.0f, + steps = 8, + onValueChangeFinished = { + onEvent(InteractionEvent.LockscreenSwipeScale(it.coerceIn(0.1f, 1.0f))) + } + ) + + SectionTitle(stringResource(R.string.interaction_diagnostics_section)) + val systemUiVersion = remember { packageVersion(context, "com.android.systemui") } + val launcherVersion = remember { packageVersion(context, "com.sec.android.app.launcher") } + ListItem( + headlineContent = { Text(stringResource(R.string.environment_title)) }, + supportingContent = { + Text( + stringResource(R.string.environment_summary) + "\n" + + "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT}) · " + + "One UI $ONE_UI_VERSION\nSystemUI $systemUiVersion · One UI Home $launcherVersion\n" + + InteractionDiagnostics.lastWakeReason(context) + "\n" + + InteractionDiagnostics.lastAutoStartResult(context) + ) + } + ) + } + + if (showSoftRestartConfirmation) { + AlertDialog( + onDismissRequest = { showSoftRestartConfirmation = false }, + title = { Text(stringResource(R.string.soft_restart_confirm_title)) }, + text = { Text(stringResource(R.string.soft_restart_confirm_message)) }, + confirmButton = { + TextButton( + onClick = { + showSoftRestartConfirmation = false + scope.launch { + val succeeded = SoftRestartController.softRestartLikeKernelSu(context) + if (!succeeded) { + Toast.makeText( + context, + R.string.soft_restart_failed, + Toast.LENGTH_LONG, + ).show() + } + } + }, + ) { + Text(stringResource(R.string.soft_restart_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { showSoftRestartConfirmation = false }) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } + + if (showAutoStartAppPicker) { + AutoStartAppDialog( + apps = userLaunchableApps, + selectedPackageNames = uiState.autoStartPackages, + onSelectionChange = { packageName, selected -> + onEvent(InteractionEvent.AutoStartPackage(packageName, selected)) + }, + onClear = { onEvent(InteractionEvent.ClearAutoStartPackages) }, + onDismiss = { showAutoStartAppPicker = false }, + ) + } +} + +@Composable +private fun AutoStartAppDialog( + apps: List, + selectedPackageNames: Set, + onSelectionChange: (String, Boolean) -> Unit, + onClear: () -> Unit, + onDismiss: () -> Unit, +) { + var query by remember { mutableStateOf("") } + val filteredApps = remember(apps, query) { + if (query.isBlank()) { + apps + } else { + apps.filter { + it.label.contains(query, ignoreCase = true) || + it.packageName.contains(query, ignoreCase = true) + } + } + } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.auto_start_picker_title)) }, + text = { + Column { + Text(stringResource(R.string.auto_start_picker_summary)) + OutlinedTextField( + value = query, + onValueChange = { query = it }, + label = { Text(stringResource(R.string.search_apps)) }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + if (filteredApps.isEmpty()) { + Text(stringResource(R.string.no_user_apps_found)) + } else { + LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) { + items(filteredApps, key = { it.packageName }) { app -> + val selected = app.packageName in selectedPackageNames + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = selected, + onValueChange = { + onSelectionChange(app.packageName, it) + }, + role = Role.Checkbox, + ) + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + bitmap = app.icon, + contentDescription = null, + modifier = Modifier.size(40.dp), + contentScale = ContentScale.Fit, + ) + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp), + ) { + Text(app.label) + Text( + text = app.packageName, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } + Checkbox(checked = selected, onCheckedChange = null) + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.done)) + } + }, + dismissButton = { + TextButton(onClick = onClear) { + Text(stringResource(R.string.clear_selection)) + } + }, + ) +} + +@Composable +private fun SectionTitle(title: String) { + Text(title, modifier = Modifier.padding(start = 16.dp, top = 20.dp, end = 16.dp, bottom = 4.dp)) +} + +@Composable +private fun SliderItem( + title: String, + summary: String, + valueLabelResource: Int, + valueToLabelNumber: (Float) -> Int, + value: Float, + valueRange: ClosedFloatingPointRange, + steps: Int, + onValueChangeFinished: (Float) -> Unit, +) { + val hapticView = LocalView.current + val intervalCount = steps + 1 + fun stepIndex(sliderValue: Float): Int { + val fraction = if (valueRange.endInclusive == valueRange.start) { + 0f + } else { + (sliderValue - valueRange.start) / + (valueRange.endInclusive - valueRange.start) + } + return (fraction * intervalCount).roundToInt().coerceIn(0, intervalCount) + } + var pendingValue by remember(value) { mutableFloatStateOf(value) } + var lastHapticStep by remember(value, valueRange, steps) { + mutableIntStateOf(stepIndex(value)) + } + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + val liveValueLabel = stringResource( + valueLabelResource, + valueToLabelNumber(pendingValue), + ) + Text("$title · $liveValueLabel") + Text( + text = summary, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 4.dp), + ) + Slider( + value = pendingValue, + onValueChange = { + pendingValue = it + val currentStep = stepIndex(it) + if (currentStep != lastHapticStep) { + val feedback = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + HapticFeedbackConstants.SEGMENT_TICK + } else { + HapticFeedbackConstants.CLOCK_TICK + } + hapticView.performHapticFeedback(feedback) + lastHapticStep = currentStep + } + }, + onValueChangeFinished = { onValueChangeFinished(pendingValue) }, + valueRange = valueRange, + steps = steps, + ) + } +} + +private fun isNotificationAccessGranted(context: Context): Boolean { + val component = ComponentName(context, NotificationWakeService::class.java).flattenToString() + return Settings.Secure.getString(context.contentResolver, "enabled_notification_listeners") + ?.split(':') + ?.any { it.equals(component, ignoreCase = true) } == true +} + +private fun packageVersion(context: Context, packageName: String): String = try { + context.packageManager.getPackageInfo(packageName, 0).versionName ?: "?" +} catch (_: Throwable) { + "?" +} + +sealed interface InteractionEvent { + @JvmInline value class WakeOnNotification(val value: Boolean) : InteractionEvent + @JvmInline value class WakeDuration(val value: Int) : InteractionEvent + @JvmInline value class WakeOnlyWhenScreenOff(val value: Boolean) : InteractionEvent + @JvmInline value class NotificationDirectOpen(val value: Boolean) : InteractionEvent + @JvmInline value class HomeSwipeDownSearch(val value: Boolean) : InteractionEvent + @JvmInline value class KeepOriginalSwipeUpSearch(val value: Boolean) : InteractionEvent + @JvmInline value class HomeSwipeThreshold(val value: Float) : InteractionEvent + @JvmInline value class CustomizeLockscreenSwipe(val value: Boolean) : InteractionEvent + @JvmInline value class LockscreenSwipeScale(val value: Float) : InteractionEvent + @JvmInline value class AutoStartEnabled(val value: Boolean) : InteractionEvent + data class AutoStartPackage(val packageName: String, val selected: Boolean) : InteractionEvent + data object ClearAutoStartPackages : InteractionEvent +} + +fun SettingViewModel.onInteractionEvent(event: InteractionEvent) { + updateData { preference -> + val current = preference.interaction + val next = when (event) { + is InteractionEvent.WakeOnNotification -> current.copy(wakeOnNotification = event.value) + is InteractionEvent.WakeDuration -> current.copy(wakeDurationSeconds = event.value.coerceIn(1, 10)) + is InteractionEvent.WakeOnlyWhenScreenOff -> current.copy(wakeOnlyWhenScreenOff = event.value) + is InteractionEvent.NotificationDirectOpen -> current.copy(notificationDirectOpenAfterAuth = event.value) + is InteractionEvent.HomeSwipeDownSearch -> current.copy(homeSwipeDownSearch = event.value) + is InteractionEvent.KeepOriginalSwipeUpSearch -> current.copy(keepOriginalSwipeUpSearch = event.value) + is InteractionEvent.HomeSwipeThreshold -> current.copy(homeSwipeDownThresholdDp = event.value.coerceIn(64f, 160f)) + is InteractionEvent.CustomizeLockscreenSwipe -> current.copy(customizeLockscreenSwipeDistance = event.value) + is InteractionEvent.LockscreenSwipeScale -> current.copy(lockscreenSwipeDistanceScale = event.value.coerceIn(0.1f, 1.0f)) + is InteractionEvent.AutoStartEnabled -> current.copy(autoStartEnabled = event.value) + is InteractionEvent.AutoStartPackage -> { + val packages = current.autoStartPackages.toMutableSet() + if (event.selected) packages.add(event.packageName) else packages.remove(event.packageName) + current.copy(autoStartPackages = packages) + } + InteractionEvent.ClearAutoStartPackages -> current.copy(autoStartPackages = emptySet()) + } + preference.copy(interaction = next) + } +} diff --git a/app/src/main/java/io/github/soclear/oneuix/ui/category/DetailPaneLauncher.kt b/app/src/main/java/io/github/soclear/oneuix/ui/category/DetailPaneLauncher.kt index 42aad057..8944dc7a 100644 --- a/app/src/main/java/io/github/soclear/oneuix/ui/category/DetailPaneLauncher.kt +++ b/app/src/main/java/io/github/soclear/oneuix/ui/category/DetailPaneLauncher.kt @@ -25,6 +25,13 @@ fun DetailPaneLauncher( checked = uiState.showMemoryUsageInRecents, onCheckedChange = { onEvent(LauncherEvent.ShowMemoryUsageInRecents(it)) } ) + SwitchItem( + icon = ImageVector.vectorResource(id = R.drawable.apps), + title = stringResource(id = R.string.hideRecentsCloseAllButton_title), + summary = stringResource(id = R.string.hideRecentsCloseAllButton_summary), + checked = uiState.hideRecentsCloseAllButton, + onCheckedChange = { onEvent(LauncherEvent.HideRecentsCloseAllButton(it)) } + ) } SwitchItem( icon = ImageVector.vectorResource(id = R.drawable.apps), @@ -46,6 +53,9 @@ sealed interface LauncherEvent { @JvmInline value class ShowMemoryUsageInRecents(val value: Boolean) : LauncherEvent + @JvmInline + value class HideRecentsCloseAllButton(val value: Boolean) : LauncherEvent + @JvmInline value class HideAppsSearchBar(val value: Boolean) : LauncherEvent @@ -62,6 +72,12 @@ fun SettingViewModel.onLauncherEvent(event: LauncherEvent) { ) ) + is LauncherEvent.HideRecentsCloseAllButton -> preference.copy( + other = preference.other.copy( + hideRecentsCloseAllButton = event.value + ) + ) + is LauncherEvent.HideAppsSearchBar -> preference.copy( other = preference.other.copy( hideAppsSearchBar = event.value diff --git a/app/src/main/java/io/github/soclear/oneuix/ui/category/DetailPaneSystemUI.kt b/app/src/main/java/io/github/soclear/oneuix/ui/category/DetailPaneSystemUI.kt index 28e9589e..fd256e53 100644 --- a/app/src/main/java/io/github/soclear/oneuix/ui/category/DetailPaneSystemUI.kt +++ b/app/src/main/java/io/github/soclear/oneuix/ui/category/DetailPaneSystemUI.kt @@ -821,6 +821,15 @@ fun DetailPaneSystemUI( ) DividerText(R.string.other) + SwitchItem( + icon = ImageVector.vectorResource(id = R.drawable.lock_open), + title = stringResource(id = R.string.showNavigationBarOnLockscreen_title), + summary = stringResource(id = R.string.showNavigationBarOnLockscreen_summary), + checked = uiState.other.showNavigationBarOnLockscreen, + onCheckedChange = { + onEvent(SystemUIEvent.Other.ShowNavigationBarOnLockscreen(it)) + } + ) Column { var expanded by rememberSaveable { mutableStateOf(false) } SwitchItem( @@ -1097,6 +1106,9 @@ sealed interface SystemUIEvent { } sealed interface Other : SystemUIEvent { + @JvmInline + value class ShowNavigationBarOnLockscreen(val value: Boolean) : Other + @JvmInline value class CustomPowerMenu(val value: Boolean) : Other @@ -1634,6 +1646,16 @@ private fun SettingViewModel.onAODEvent(event: SystemUIEvent.AOD) { private fun SettingViewModel.onOtherEvent(event: SystemUIEvent.Other) { updateData { preference -> when (event) { + is SystemUIEvent.Other.ShowNavigationBarOnLockscreen -> { + preference.copy( + systemUI = preference.systemUI.copy( + other = preference.systemUI.other.copy( + showNavigationBarOnLockscreen = event.value + ) + ) + ) + } + is SystemUIEvent.Other.CustomPowerMenu -> { preference.copy( systemUI = preference.systemUI.copy( diff --git a/app/src/main/java/io/github/soclear/oneuix/ui/category/ListPaneCategory.kt b/app/src/main/java/io/github/soclear/oneuix/ui/category/ListPaneCategory.kt index b38edfcb..6a60e343 100644 --- a/app/src/main/java/io/github/soclear/oneuix/ui/category/ListPaneCategory.kt +++ b/app/src/main/java/io/github/soclear/oneuix/ui/category/ListPaneCategory.kt @@ -6,17 +6,32 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ListItem +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.github.soclear.oneuix.R @@ -24,11 +39,19 @@ import io.github.soclear.oneuix.R @Composable fun ListPaneCategory( categoryAppInfoList: List, + hiddenCategoryNames: Set, onItemClick: (Category) -> Unit, + onCategoryVisibilityChange: (Category, Boolean) -> Unit, + onShowAllCategories: () -> Unit, onBackup: () -> Unit, onRestore: () -> Unit, modifier: Modifier = Modifier ) { + var showMenuManager by rememberSaveable { mutableStateOf(false) } + val visibleCategories = categoryAppInfoList.filterNot { + it.category.name in hiddenCategoryNames + } + Column( modifier = modifier.verticalScroll(rememberScrollState()) ) { @@ -51,7 +74,21 @@ fun ListPaneCategory( Text(text = stringResource(id = R.string.restore_config)) } } - categoryAppInfoList.forEach { + OutlinedButton( + onClick = { showMenuManager = true }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text(text = stringResource(R.string.manage_main_menu)) + } + if (visibleCategories.isEmpty()) { + Text( + text = stringResource(R.string.all_main_menu_categories_hidden), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp), + ) + } + visibleCategories.forEach { ListItem( headlineContent = { Text(text = it.label, fontSize = 20.sp) @@ -70,4 +107,79 @@ fun ListPaneCategory( ) } } + + if (showMenuManager) { + MenuVisibilityDialog( + categoryAppInfoList = categoryAppInfoList, + hiddenCategoryNames = hiddenCategoryNames, + onCategoryVisibilityChange = onCategoryVisibilityChange, + onShowAllCategories = onShowAllCategories, + onDismiss = { showMenuManager = false }, + ) + } +} + +@Composable +private fun MenuVisibilityDialog( + categoryAppInfoList: List, + hiddenCategoryNames: Set, + onCategoryVisibilityChange: (Category, Boolean) -> Unit, + onShowAllCategories: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.manage_main_menu)) }, + text = { + Column { + Text(stringResource(R.string.manage_main_menu_summary)) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) { + items( + items = categoryAppInfoList, + key = { it.category.name }, + ) { appInfo -> + val visible = appInfo.category.name !in hiddenCategoryNames + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = visible, + onValueChange = { + onCategoryVisibilityChange(appInfo.category, it) + }, + role = Role.Checkbox, + ) + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + bitmap = appInfo.icon, + contentDescription = null, + modifier = Modifier.size(36.dp), + contentScale = ContentScale.Fit, + ) + Text( + text = appInfo.label, + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp), + ) + Checkbox(checked = visible, onCheckedChange = null) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.done)) + } + }, + dismissButton = { + TextButton(onClick = onShowAllCategories) { + Text(stringResource(R.string.show_all_categories)) + } + }, + ) } diff --git a/app/src/main/java/io/github/soclear/oneuix/ui/category/UserLaunchableApp.kt b/app/src/main/java/io/github/soclear/oneuix/ui/category/UserLaunchableApp.kt new file mode 100644 index 00000000..08655b46 --- /dev/null +++ b/app/src/main/java/io/github/soclear/oneuix/ui/category/UserLaunchableApp.kt @@ -0,0 +1,9 @@ +package io.github.soclear.oneuix.ui.category + +import androidx.compose.ui.graphics.ImageBitmap + +data class UserLaunchableApp( + val packageName: String, + val label: String, + val icon: ImageBitmap, +) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index fe928a6c..dccbd21b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -67,6 +67,8 @@ Son de l\'obturateur, Auto HDR, Log, Filigrane avec cadre Désactiver la vérification de surchauffe de l\'appareil photo Désactiver le son des captures d\'écran + Afficher la barre de navigation sur l’écran de verrouillage + Lorsqu’elle est activée, la barre de navigation du système reste visible lorsque l’écran de verrouillage est allumé, avec les boutons ou les gestes. Elle ne s’affiche pas sur l’écran toujours actif ni pendant l’extinction. La désactivation rétablit le comportement Samsung par défaut. Redémarrez l’interface système après toute modification. Désactiver le regroupement des notifications Empêcher la fusion des icônes dans la barre d\'état et le regroupement dans le centre de notifications pour les notifications d\'une même application Développer automatiquement les notifications @@ -113,6 +115,8 @@ Afficher le niveau de luminosité (Paramètres rapides) Afficher le niveau de volume (Paramètres rapides) Afficher l\'utilisation de la RAM dans les applications récentes + Masquer « Tout fermer » dans les applications récentes + Supprime uniquement le bouton « Tout fermer ». Les applications peuvent toujours être fermées individuellement. Cette option ne ferme ni ne protège automatiquement les applications en arrière-plan. Redémarrez l’accueil One UI après modification. Afficher l\'option Forcer le taux de rafraîchissement maximal Rechercher \"Force peak refresh rate\" dans les paramètres Déverrouiller le mode Extérieur @@ -166,4 +170,65 @@ Sauvegarder la config Restaurer la config Échec de la restauration, le fichier est peut-être corrompu + Interaction enhancements + Manage main menu + Select the categories to show on the app’s main screen and clear a selection to hide a category you rarely use. Hiding affects only the menu entry: enabled system features and saved settings remain unchanged, and every category can be restored here. + All feature categories are hidden. Use “Manage main menu” above to show the categories you need. + Show all + Done + Cancel + Apply interaction settings + Auto-start after boot and soft restart + Open selected apps after restart + After a normal boot completes or this page performs a KernelSU-style soft restart, the selected apps open in order through root when Android allows the background job to run, normally after at least 15 seconds. Grant this app root access in KernelSU first. If root has not loaded after a cold boot, that run fails without repeatedly requesting access. + Select apps to open automatically + %1$d selected apps are still installed. Only user-installed apps that can be opened from the launcher are shown. When several apps are selected, they open in order and the last app remains in the foreground. + Select apps + Select auto-start apps + Select the apps to open after a boot or soft restart. This feature does not change their permissions, background policy, or system auto-start settings; clear a selection to stop this app from launching it. + Search app name or package name + No eligible user-installed apps were found. + Clear selection + KernelSU-style soft restart + Uses the same system command as the “Soft restart” option in KernelSU Manager to restart the Android application runtime. System UI, Home, and every running app reload so module settings can take effect. It does not reboot the kernel or hardware and does not erase data. + Soft restart + Soft restart now? + This has the same effect as “Soft restart” in KernelSU Manager. It closes System UI, Home, and all running apps, then returns to the lock screen. Unsaved work is lost and the device may disconnect temporarily, so save important work first. + Restart now + Soft restart failed to start. Grant this app root access in KernelSU and try again. + Notification screen wake + Notifications + Home screen + Lock screen + Diagnostics + Notification access + Granted + Not granted + Open settings + Wake screen on notification + When enabled, an eligible new notification wakes the screen and continues to use Samsung’s normal lock screen and notification view. Ongoing notifications, group summaries, and this app’s own notifications do not wake the screen. + Current status: %1$s\nThis permission detects when a new notification arrives and is required by “Wake screen on notification.” Notification titles, message text, and images are never saved. + Sets how long the screen remains on after a notification wakes it, from 1 to 10 seconds. The screen turns off at the timeout only if the device is still on the lock screen; it is not forced off after you unlock. Each slider step uses system haptic feedback when enabled. + When enabled, notification wake runs only while the screen is fully off. Notifications received while you are using the phone or while the screen is already on do not restart the wake timer. + If Face, fingerprint, or another system authentication has already made the lock screen dismissible, tapping a lock-screen notification opens its app without an extra swipe. Samsung’s normal security flow remains in place when authentication is incomplete or a password is required. Takes effect after System UI restarts. + Swipe down on an empty area in the middle of the Home screen to open Samsung Finder, focus its search field, and show the keyboard. The swipe-down gesture at the top for notifications remains unchanged. Takes effect after One UI Home restarts. + Keeps Samsung’s original swipe-up gesture for opening search. Turning this off removes only that search entry and does not disable swiping up to open the Apps screen. Takes effect after One UI Home restarts. + Controls how far you must swipe down from the middle of the Home screen before search opens. A smaller value responds sooner; a larger value helps prevent accidental activation. Each slider step uses system haptic feedback when enabled. + Allows the swipe distance required to complete the lock-screen upward gesture to be shortened. This changes only the gesture travel distance and never bypasses Face, fingerprint, pattern, PIN, or password verification. Takes effect after System UI restarts. + Sets the gesture distance to 10%%–100%% of Samsung’s original distance in ten steps. A lower percentage needs a shorter swipe; 100%% restores the system distance. This slider applies only when the option above is enabled, and each step uses system haptic feedback when enabled. + Shows the current system, Samsung Home, and System UI versions together with the latest notification-wake result to help identify where a feature stopped working. + Wake duration + Only wake when screen is off + Open notification directly after authentication + Swipe down from middle to search + Keep original swipe-up search + Swipe-down trigger distance + Customize lock-screen swipe distance + Unlock distance (system default: 100%%) + Takes effect after System UI restarts + Takes effect after One UI Home restarts + Environment and latest result + %1$d s + %1$d dp + %1$d%% diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 6ada93e5..794d1219 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -67,6 +67,8 @@ 셔터음, 자동 HDR, Log 촬영, 프레임 워터마크 카메라 온도 체크 비활성화 스크린샷 캡처음 비활성화 + 잠금 화면에 내비게이션 바 표시 + 활성화하면 잠금 화면이 켜져 있는 동안 버튼 또는 제스처 내비게이션 바를 표시합니다. AOD와 화면이 꺼지는 동안에는 표시하지 않으며, 비활성화하면 Samsung 기본 동작으로 복원됩니다. 변경 후 시스템 UI를 다시 시작하세요. 알림 그룹화 비활성화 동일한 앱의 알림이 상태바 아이콘에서 합쳐지거나 알림창에서 하나로 묶이는 것을 방지합니다 알림 자동으로 확장 @@ -113,6 +115,8 @@ 빠른 설정창에 밝기 조절 바 표시 빠른 설정창에 볼륨 조절 바 표시 최근 앱 화면에 메모리(RAM) 사용량 표시 + 최근 앱에서 ‘모두 닫기’ 숨기기 + 최근 앱 화면의 ‘모두 닫기’ 버튼만 제거합니다. 앱을 하나씩 밀어서 닫는 기능은 그대로 사용할 수 있으며, 백그라운드 앱을 자동으로 종료하거나 보호하지 않습니다. 변경 후 One UI 홈을 다시 시작하세요. 개발자 옵션에 \'최고 주사율 강제 적용\' 메뉴 표시 설정에서 \'최고 주사율 강제 적용\' 검색 가능 야외 모드 잠금 해제 @@ -166,4 +170,65 @@ 설정 백업 설정 복원 복원에 실패했습니다. 파일이 손상되었을 수 있습니다 + Interaction enhancements + Manage main menu + Select the categories to show on the app’s main screen and clear a selection to hide a category you rarely use. Hiding affects only the menu entry: enabled system features and saved settings remain unchanged, and every category can be restored here. + All feature categories are hidden. Use “Manage main menu” above to show the categories you need. + Show all + Done + Cancel + Apply interaction settings + Auto-start after boot and soft restart + Open selected apps after restart + After a normal boot completes or this page performs a KernelSU-style soft restart, the selected apps open in order through root when Android allows the background job to run, normally after at least 15 seconds. Grant this app root access in KernelSU first. If root has not loaded after a cold boot, that run fails without repeatedly requesting access. + Select apps to open automatically + %1$d selected apps are still installed. Only user-installed apps that can be opened from the launcher are shown. When several apps are selected, they open in order and the last app remains in the foreground. + Select apps + Select auto-start apps + Select the apps to open after a boot or soft restart. This feature does not change their permissions, background policy, or system auto-start settings; clear a selection to stop this app from launching it. + Search app name or package name + No eligible user-installed apps were found. + Clear selection + KernelSU-style soft restart + Uses the same system command as the “Soft restart” option in KernelSU Manager to restart the Android application runtime. System UI, Home, and every running app reload so module settings can take effect. It does not reboot the kernel or hardware and does not erase data. + Soft restart + Soft restart now? + This has the same effect as “Soft restart” in KernelSU Manager. It closes System UI, Home, and all running apps, then returns to the lock screen. Unsaved work is lost and the device may disconnect temporarily, so save important work first. + Restart now + Soft restart failed to start. Grant this app root access in KernelSU and try again. + Notification screen wake + Notifications + Home screen + Lock screen + Diagnostics + Notification access + Granted + Not granted + Open settings + Wake screen on notification + When enabled, an eligible new notification wakes the screen and continues to use Samsung’s normal lock screen and notification view. Ongoing notifications, group summaries, and this app’s own notifications do not wake the screen. + Current status: %1$s\nThis permission detects when a new notification arrives and is required by “Wake screen on notification.” Notification titles, message text, and images are never saved. + Sets how long the screen remains on after a notification wakes it, from 1 to 10 seconds. The screen turns off at the timeout only if the device is still on the lock screen; it is not forced off after you unlock. Each slider step uses system haptic feedback when enabled. + When enabled, notification wake runs only while the screen is fully off. Notifications received while you are using the phone or while the screen is already on do not restart the wake timer. + If Face, fingerprint, or another system authentication has already made the lock screen dismissible, tapping a lock-screen notification opens its app without an extra swipe. Samsung’s normal security flow remains in place when authentication is incomplete or a password is required. Takes effect after System UI restarts. + Swipe down on an empty area in the middle of the Home screen to open Samsung Finder, focus its search field, and show the keyboard. The swipe-down gesture at the top for notifications remains unchanged. Takes effect after One UI Home restarts. + Keeps Samsung’s original swipe-up gesture for opening search. Turning this off removes only that search entry and does not disable swiping up to open the Apps screen. Takes effect after One UI Home restarts. + Controls how far you must swipe down from the middle of the Home screen before search opens. A smaller value responds sooner; a larger value helps prevent accidental activation. Each slider step uses system haptic feedback when enabled. + Allows the swipe distance required to complete the lock-screen upward gesture to be shortened. This changes only the gesture travel distance and never bypasses Face, fingerprint, pattern, PIN, or password verification. Takes effect after System UI restarts. + Sets the gesture distance to 10%%–100%% of Samsung’s original distance in ten steps. A lower percentage needs a shorter swipe; 100%% restores the system distance. This slider applies only when the option above is enabled, and each step uses system haptic feedback when enabled. + Shows the current system, Samsung Home, and System UI versions together with the latest notification-wake result to help identify where a feature stopped working. + Wake duration + Only wake when screen is off + Open notification directly after authentication + Swipe down from middle to search + Keep original swipe-up search + Swipe-down trigger distance + Customize lock-screen swipe distance + Unlock distance (system default: 100%%) + Takes effect after System UI restarts + Takes effect after One UI Home restarts + Environment and latest result + %1$d s + %1$d dp + %1$d%% diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 37e365b7..f5d2cb58 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -67,6 +67,8 @@ Звук затвора, Auto HDR, Log, Рамка-водяной знак Отключить проверку температуры камеры Отключить звук скриншота + Показывать панель навигации на экране блокировки + Когда включено, системная панель навигации отображается на активном экране блокировки в режиме кнопок или жестов. На AOD и при выключении экрана она не показывается. Отключение возвращает стандартное поведение Samsung. После изменения перезапустите System UI. Отключить группировку уведомлений Запретить объединение значков в строке состояния и группировку в центре уведомлений для уведомлений одного приложения Автоматически разворачивать уведомления @@ -113,6 +115,8 @@ Показывать уровень яркости в панели быстрых настроек Показывать уровень громкости в панели быстрых настроек Использование памяти в меню запущенных приложений + Скрыть «Закрыть все» в недавних приложениях + Удаляет только кнопку «Закрыть все». Отдельные приложения по-прежнему можно смахивать. Функция не закрывает и не закрепляет фоновые приложения автоматически. После изменения перезапустите главный экран One UI. Пункт «Частота обновления экрана» в параметрах разработчика Появится в поиске настроек как «Частота обновления экрана» Разблокировать режим “На улице” @@ -166,4 +170,65 @@ Резервное копирование Восстановить конфиг Ошибка восстановления, файл может быть повреждён + Interaction enhancements + Manage main menu + Select the categories to show on the app’s main screen and clear a selection to hide a category you rarely use. Hiding affects only the menu entry: enabled system features and saved settings remain unchanged, and every category can be restored here. + All feature categories are hidden. Use “Manage main menu” above to show the categories you need. + Show all + Done + Cancel + Apply interaction settings + Auto-start after boot and soft restart + Open selected apps after restart + After a normal boot completes or this page performs a KernelSU-style soft restart, the selected apps open in order through root when Android allows the background job to run, normally after at least 15 seconds. Grant this app root access in KernelSU first. If root has not loaded after a cold boot, that run fails without repeatedly requesting access. + Select apps to open automatically + %1$d selected apps are still installed. Only user-installed apps that can be opened from the launcher are shown. When several apps are selected, they open in order and the last app remains in the foreground. + Select apps + Select auto-start apps + Select the apps to open after a boot or soft restart. This feature does not change their permissions, background policy, or system auto-start settings; clear a selection to stop this app from launching it. + Search app name or package name + No eligible user-installed apps were found. + Clear selection + KernelSU-style soft restart + Uses the same system command as the “Soft restart” option in KernelSU Manager to restart the Android application runtime. System UI, Home, and every running app reload so module settings can take effect. It does not reboot the kernel or hardware and does not erase data. + Soft restart + Soft restart now? + This has the same effect as “Soft restart” in KernelSU Manager. It closes System UI, Home, and all running apps, then returns to the lock screen. Unsaved work is lost and the device may disconnect temporarily, so save important work first. + Restart now + Soft restart failed to start. Grant this app root access in KernelSU and try again. + Notification screen wake + Notifications + Home screen + Lock screen + Diagnostics + Notification access + Granted + Not granted + Open settings + Wake screen on notification + When enabled, an eligible new notification wakes the screen and continues to use Samsung’s normal lock screen and notification view. Ongoing notifications, group summaries, and this app’s own notifications do not wake the screen. + Current status: %1$s\nThis permission detects when a new notification arrives and is required by “Wake screen on notification.” Notification titles, message text, and images are never saved. + Sets how long the screen remains on after a notification wakes it, from 1 to 10 seconds. The screen turns off at the timeout only if the device is still on the lock screen; it is not forced off after you unlock. Each slider step uses system haptic feedback when enabled. + When enabled, notification wake runs only while the screen is fully off. Notifications received while you are using the phone or while the screen is already on do not restart the wake timer. + If Face, fingerprint, or another system authentication has already made the lock screen dismissible, tapping a lock-screen notification opens its app without an extra swipe. Samsung’s normal security flow remains in place when authentication is incomplete or a password is required. Takes effect after System UI restarts. + Swipe down on an empty area in the middle of the Home screen to open Samsung Finder, focus its search field, and show the keyboard. The swipe-down gesture at the top for notifications remains unchanged. Takes effect after One UI Home restarts. + Keeps Samsung’s original swipe-up gesture for opening search. Turning this off removes only that search entry and does not disable swiping up to open the Apps screen. Takes effect after One UI Home restarts. + Controls how far you must swipe down from the middle of the Home screen before search opens. A smaller value responds sooner; a larger value helps prevent accidental activation. Each slider step uses system haptic feedback when enabled. + Allows the swipe distance required to complete the lock-screen upward gesture to be shortened. This changes only the gesture travel distance and never bypasses Face, fingerprint, pattern, PIN, or password verification. Takes effect after System UI restarts. + Sets the gesture distance to 10%%–100%% of Samsung’s original distance in ten steps. A lower percentage needs a shorter swipe; 100%% restores the system distance. This slider applies only when the option above is enabled, and each step uses system haptic feedback when enabled. + Shows the current system, Samsung Home, and System UI versions together with the latest notification-wake result to help identify where a feature stopped working. + Wake duration + Only wake when screen is off + Open notification directly after authentication + Swipe down from middle to search + Keep original swipe-up search + Swipe-down trigger distance + Customize lock-screen swipe distance + Unlock distance (system default: 100%%) + Takes effect after System UI restarts + Takes effect after One UI Home restarts + Environment and latest result + %1$d s + %1$d dp + %1$d%% diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 3b1591e1..6bd8bf88 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -67,6 +67,8 @@ 快门音、自动 HDR、Log、边框叠加水印 禁用相机过热检查 禁用截图声音 + 锁屏显示导航条 + 开启后,在锁屏已经亮起时强制显示当前系统导航条,三键导航和手势导航均适用;息屏显示、正在熄屏以及解锁后的界面仍按三星原有逻辑处理。关闭后恢复三星默认的锁屏导航条行为。修改此选项后需要重启系统界面才能生效。 禁用通知分组 禁止同一应用的多个通知在状态栏合并图标、在通知中心分组显示 自动展开通知 @@ -113,6 +115,8 @@ 快捷设置面板显示亮度级别 快捷设置面板显示音量级别 最近任务页面显示内存信息 + 隐藏最近任务的“全部关闭”按钮 + 仅移除后台多任务界面底部的“全部关闭”按钮,仍可逐个上滑关闭应用;此功能不会自动关闭后台应用,也不会将应用锁定在后台。修改后需重启 One UI 主屏。 开发者选项显示强制最大刷新率选项 设置中搜索 Force peak refresh rate 解锁户外模式 @@ -166,4 +170,65 @@ 备份配置 恢复配置 恢复失败,文件可能已损坏 + 交互增强 + 管理主界面菜单 + 勾选需要在软件主界面显示的分类,取消勾选即可隐藏不常用分类。隐藏只影响菜单入口,不会关闭已经启用的系统功能,也不会删除设置;可随时返回这里恢复显示。 + 所有功能分类均已隐藏。点击上方“管理主界面菜单”可以重新显示需要的分类。 + 全部显示 + 完成 + 取消 + 应用交互设置 + 开机与软重启自动启动 + 重启后自动打开所选应用 + 开启后,普通开机完成或使用本页 KernelSU 同款软重启后,会在系统恢复并允许后台任务运行时,通过 Root 按顺序打开所选应用,通常至少等待 15 秒。必须先在 KernelSU 中授予本应用 Root 权限;如果整机开机后 Root 尚未加载,本次自动启动会失败但不会反复弹出授权。 + 选择需要自动启动的应用 + 当前已选择 %1$d 个仍安装在手机上的应用。只显示用户自行安装且能够从桌面打开的应用;选择多个时会依次打开,最后一个应用会停留在前台。 + 选择应用 + 选择自动启动应用 + 勾选需要在开机或软重启后自动打开的应用。此功能不会修改这些应用的权限、后台策略或系统自启动设置,取消勾选即可停止由本应用启动。 + 搜索应用名称或软件包名 + 没有找到符合条件的用户安装应用。 + 清空选择 + KernelSU 同款软重启 + 使用 KernelSU 管理器“软重启”选项相同的系统命令,重新启动 Android 应用运行环境。系统界面、主屏幕和所有正在运行的应用会重新加载,适合让模块设置重新生效;不会重启内核或硬件,也不会清除数据。 + 软重启 + 确认立即软重启? + 这与 KernelSU 管理器中的“软重启”作用相同,会关闭系统界面、主屏幕和当前运行的所有应用,然后重新进入锁屏。未保存的内容会丢失,设备可能暂时断开连接,请先保存重要工作。 + 立即软重启 + 软重启启动失败,请确认已在 KernelSU 中授予本应用 Root 权限后重试。 + 通知自动亮屏 + 通知与自动亮屏 + 主屏幕搜索手势 + 锁屏解锁手势 + 运行环境与诊断 + 通知访问权限 + 已授权 + 未授权 + 管理通知权限 + 通知到达自动亮屏 + 当前状态:%1$s\n此权限用于识别新通知是否到达,是“通知到达自动亮屏”的必要条件。应用不会保存通知标题、正文或图片。 + 开启后,收到普通新通知时会点亮屏幕,并请求三星系统启动人脸识别;只有已录入并启用人脸、系统当前允许认证时才会运行,不会绕过密码、锁定或摄像头隐私限制。常驻通知、通知分组摘要和本应用自己的通知不会触发亮屏。 + 亮屏时长 + 设置通知自动亮屏后保持点亮的时间,可选 1 至 10 秒。到时仍停留在锁屏界面才会自动息屏;如果已经解锁进入手机,则不会强制息屏。拖动每个档位时会跟随系统设置产生震动反馈。 + 仅息屏时亮屏 + 开启后,只有屏幕完全关闭时收到通知才会触发自动亮屏;正在使用手机或屏幕已经点亮时,通知不会重复触发亮屏计时。 + 认证后点击通知直接打开 + 当锁屏已经通过人脸、指纹或其他系统认证,且系统允许解除锁定时,点击锁屏通知会直接进入对应应用,不再要求额外上滑。未通过认证或系统要求密码时,仍按三星原有安全流程处理。重启系统界面后生效。 + 屏幕中部下拉打开搜索 + 在主屏幕空白区域,从屏幕中部向下滑动达到设定距离后,打开三星查找器,并自动聚焦搜索框和弹出键盘。顶部下拉通知栏手势保持不变。重启 One UI 主屏幕后生效。 + 保留原上滑搜索 + 开启后,保留三星主屏幕原有的上滑打开搜索手势;关闭后只移除这个原有上滑搜索入口,不影响上滑进入应用列表。重启 One UI 主屏幕后生效。 + 下拉触发距离 + 决定从主屏幕中部向下滑多远才打开搜索。数值越小越灵敏,轻轻下拉即可触发;数值越大越不容易误触。拖动每个档位时会跟随系统设置产生震动反馈。 + 自定义锁屏上滑距离 + 开启后,可缩短锁屏界面完成上滑解锁所需的手指移动距离,只改变手势行程,不跳过人脸、指纹、图案、个人识别码或密码验证。重启系统界面后生效。 + 解锁距离(系统默认:100%%) + 按三星原始上滑距离的百分比设置,共 10%% 至 100%% 十个档位。百分比越低,需要上滑的距离越短;100%% 恢复系统原始距离。只有开启上方“自定义锁屏上滑距离”后才会生效,拖动档位时会跟随系统设置产生震动反馈。 + 重启 SystemUI 后生效 + 重启 One UI Home 后生效 + 环境与最近一次结果 + 这里显示当前系统、三星主屏幕和系统界面的版本,以及最近一次通知亮屏处理结果,便于判断功能未生效时卡在哪一步。 + %1$d 秒 + %1$d 距离单位 + %1$d%% diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af8fe509..371390c5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -67,6 +67,8 @@ Shutter sound, Auto HDR, Log, Frame watermark Disable camera temperature check Disable screenshot sound + Show navigation bar on lock screen + When enabled, the current system navigation bar is forced visible while the lock screen is awake, for both button and gesture navigation. It is not shown on the always-on display or while the screen is turning off, and unlocked screens keep Samsung’s original behavior. When disabled, Samsung’s default lock-screen behavior is restored. Restart System UI after changing this option. Disable notification grouping Prevent status bar icon merging and notification center grouping for same-app notifications Auto expand notifications @@ -113,6 +115,8 @@ Show brightness level in QS panel Show volume level in QS panel Show memory usage in Recents page + Hide “Close all” in Recents + Removes only the “Close all” button from the recent-apps screen. You can still swipe away individual apps, and this option does not close or protect any background app. Restart One UI Home after changing it. Show \'Force peak refresh rate\' in Developer options Search for \'Force peak refresh rate\' in Settings Unlock Outdoor mode @@ -166,4 +170,65 @@ Back up config Restore config Restore failed, the file may be corrupted + Interaction enhancements + Manage main menu + Select the categories to show on the app’s main screen and clear a selection to hide a category you rarely use. Hiding affects only the menu entry: enabled system features and saved settings remain unchanged, and every category can be restored here. + All feature categories are hidden. Use “Manage main menu” above to show the categories you need. + Show all + Done + Cancel + Apply interaction settings + Auto-start after boot and soft restart + Open selected apps after restart + After a normal boot completes or this page performs a KernelSU-style soft restart, the selected apps open in order through root when Android allows the background job to run, normally after at least 15 seconds. Grant this app root access in KernelSU first. If root has not loaded after a cold boot, that run fails without repeatedly requesting access. + Select apps to open automatically + %1$d selected apps are still installed. Only user-installed apps that can be opened from the launcher are shown. When several apps are selected, they open in order and the last app remains in the foreground. + Select apps + Select auto-start apps + Select the apps to open after a boot or soft restart. This feature does not change their permissions, background policy, or system auto-start settings; clear a selection to stop this app from launching it. + Search app name or package name + No eligible user-installed apps were found. + Clear selection + KernelSU-style soft restart + Uses the same system command as the “Soft restart” option in KernelSU Manager to restart the Android application runtime. System UI, Home, and every running app reload so module settings can take effect. It does not reboot the kernel or hardware and does not erase data. + Soft restart + Soft restart now? + This has the same effect as “Soft restart” in KernelSU Manager. It closes System UI, Home, and all running apps, then returns to the lock screen. Unsaved work is lost and the device may disconnect temporarily, so save important work first. + Restart now + Soft restart failed to start. Grant this app root access in KernelSU and try again. + Notification screen wake + Notifications + Home screen + Lock screen + Diagnostics + Notification access + Granted + Not granted + Open settings + Wake screen on notification + Current status: %1$s\nThis permission detects when a new notification arrives and is required by “Wake screen on notification.” Notification titles, message text, and images are never saved. + When enabled, an eligible new notification wakes the screen and asks Samsung’s native face authentication to start. It runs only when face unlock is enrolled, enabled, and currently permitted, and never bypasses password, lockout, or camera privacy restrictions. Ongoing notifications, group summaries, and this app’s own notifications do not wake the screen. + Wake duration + Sets how long the screen remains on after a notification wakes it, from 1 to 10 seconds. The screen turns off at the timeout only if the device is still on the lock screen; it is not forced off after you unlock. Each slider step uses system haptic feedback when enabled. + Only wake when screen is off + When enabled, notification wake runs only while the screen is fully off. Notifications received while you are using the phone or while the screen is already on do not restart the wake timer. + Open notification directly after authentication + If Face, fingerprint, or another system authentication has already made the lock screen dismissible, tapping a lock-screen notification opens its app without an extra swipe. Samsung’s normal security flow remains in place when authentication is incomplete or a password is required. Takes effect after System UI restarts. + Swipe down from middle to search + Swipe down on an empty area in the middle of the Home screen to open Samsung Finder, focus its search field, and show the keyboard. The swipe-down gesture at the top for notifications remains unchanged. Takes effect after One UI Home restarts. + Keep original swipe-up search + Keeps Samsung’s original swipe-up gesture for opening search. Turning this off removes only that search entry and does not disable swiping up to open the Apps screen. Takes effect after One UI Home restarts. + Swipe-down trigger distance + Controls how far you must swipe down from the middle of the Home screen before search opens. A smaller value responds sooner; a larger value helps prevent accidental activation. Each slider step uses system haptic feedback when enabled. + Customize lock-screen swipe distance + Allows the swipe distance required to complete the lock-screen upward gesture to be shortened. This changes only the gesture travel distance and never bypasses Face, fingerprint, pattern, PIN, or password verification. Takes effect after System UI restarts. + Unlock distance (system default: 100%%) + Sets the gesture distance to 10%%–100%% of Samsung’s original distance in ten steps. A lower percentage needs a shorter swipe; 100%% restores the system distance. This slider applies only when the option above is enabled, and each step uses system haptic feedback when enabled. + Takes effect after System UI restarts + Takes effect after One UI Home restarts + Environment and latest result + Shows the current system, Samsung Home, and System UI versions together with the latest notification-wake result to help identify where a feature stopped working. + %1$d s + %1$d density-independent pixels + %1$d%% diff --git a/app/src/test/java/io/github/soclear/oneuix/data/PreferenceCompatibilityTest.kt b/app/src/test/java/io/github/soclear/oneuix/data/PreferenceCompatibilityTest.kt new file mode 100644 index 00000000..e4e758ed --- /dev/null +++ b/app/src/test/java/io/github/soclear/oneuix/data/PreferenceCompatibilityTest.kt @@ -0,0 +1,95 @@ +package io.github.soclear.oneuix.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PreferenceCompatibilityTest { + @Test + fun oldPreferenceWithoutInteractionUsesSafeDefaults() { + val oldJson = """{"android":{"allowAllRotation":true}}""" + + val preference = IgnoreUnknownKeysJson.decodeFromString(oldJson) + + assertTrue(preference.android.allowAllRotation) + assertTrue(preference.hiddenMainMenuCategories.isEmpty()) + assertFalse(preference.interaction.wakeOnNotification) + assertFalse(preference.interaction.notificationDirectOpenAfterAuth) + assertFalse(preference.interaction.homeSwipeDownSearch) + assertTrue(preference.interaction.keepOriginalSwipeUpSearch) + assertFalse(preference.interaction.customizeLockscreenSwipeDistance) + assertFalse(preference.interaction.autoStartEnabled) + assertFalse(preference.systemUI.other.showNavigationBarOnLockscreen) + assertFalse(preference.other.hideRecentsCloseAllButton) + assertTrue(preference.interaction.autoStartPackages.isEmpty()) + assertEquals(8, preference.interaction.wakeDurationSeconds) + assertEquals(96f, preference.interaction.homeSwipeDownThresholdDp) + assertEquals(1f, preference.interaction.lockscreenSwipeDistanceScale) + } + + @Test + fun hiddenMainMenuCategoriesRoundTripWithoutChangingFeatureSettings() { + val original = Preference( + hiddenMainMenuCategories = setOf("Camera", "Weather"), + interaction = Preference.Interaction(homeSwipeDownSearch = true), + ) + + val restored = IgnoreUnknownKeysJson.decodeFromString( + IgnoreUnknownKeysJson.encodeToString(Preference.serializer(), original) + ) + + assertEquals(setOf("Camera", "Weather"), restored.hiddenMainMenuCategories) + assertTrue(restored.interaction.homeSwipeDownSearch) + } + + @Test + fun autoStartSelectionRoundTrips() { + val original = Preference( + interaction = Preference.Interaction( + autoStartEnabled = true, + autoStartPackages = setOf("com.example.alpha", "com.example.beta"), + ) + ) + + val restored = IgnoreUnknownKeysJson.decodeFromString( + IgnoreUnknownKeysJson.encodeToString(Preference.serializer(), original) + ) + + assertTrue(restored.interaction.autoStartEnabled) + assertEquals( + setOf("com.example.alpha", "com.example.beta"), + restored.interaction.autoStartPackages, + ) + } + + @Test + fun lockscreenNavigationBarSettingRoundTrips() { + val original = Preference( + systemUI = Preference.SystemUI( + other = Preference.SystemUI.Other( + showNavigationBarOnLockscreen = true, + ) + ) + ) + + val restored = IgnoreUnknownKeysJson.decodeFromString( + IgnoreUnknownKeysJson.encodeToString(Preference.serializer(), original) + ) + + assertTrue(restored.systemUI.other.showNavigationBarOnLockscreen) + } + + @Test + fun hideRecentsCloseAllButtonSettingRoundTrips() { + val original = Preference( + other = Preference.Other(hideRecentsCloseAllButton = true) + ) + + val restored = IgnoreUnknownKeysJson.decodeFromString( + IgnoreUnknownKeysJson.encodeToString(Preference.serializer(), original) + ) + + assertTrue(restored.other.hideRecentsCloseAllButton) + } +}