From 79a24eb2fff5c192703ddcff7720d831b762dc39 Mon Sep 17 00:00:00 2001 From: "sameerasw.com" Date: Wed, 8 Apr 2026 00:28:44 +0530 Subject: [PATCH 01/23] New translations strings.xml (Chinese Traditional) --- app/src/main/res/values-zh/strings.xml | 446 ++++++++++++------------- 1 file changed, 223 insertions(+), 223 deletions(-) diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 0b992b420..cb15d7c02 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -12,7 +12,7 @@ 闪光灯脉冲 检查预览版更新 可能不稳定 - Default tab + 默认选单 安全性 启用应用锁 @@ -51,7 +51,7 @@ 开关静音 AI 助理 屏幕截图 - Cycle sound modes + 循环声音模式 喜欢这首歌 喜爱歌曲设置 本功能需要通知权限以检测当前正在播放的媒体和点按喜欢按钮。请在下方启用。 @@ -59,11 +59,11 @@ 在屏幕常亮AOD上显示叠加层 闻曲知音查看 在屏幕常亮AOD上查看媒体 - Docked mode + 基座模式 当有音乐在屏幕常亮AOD时播放,使遮盖层永久可见 通知一览 当通知处于等待时,保持屏幕常亮 - Same apps as notification lighting + 与闪烁通知相同的应用程序 此功能会在收到来自选定应用的通知时动态启用屏幕常亮AOD功能,并在所有匹配的通知关闭后禁用该功能。您可以选择应用,也可以使用与通知指示灯相同的选择。 授予通知权限 切换媒体音量 @@ -111,74 +111,74 @@ 可以关闭夜间模式的应用程序 选择应用程序 - App Control - Freeze - Unfreeze - Remove - Create shortcut - App info - What is Freeze? - App freezing disables the app\'s launch activity which removes it from the app list and updates. It will prevent the app from starting at all until it\'s unfrozen which saves resources but you will need to unfreeze from here or manually re-enable. - DO NOT FREEZE COMMUNICATION APPS - What is Suspend? - Suspending an app used to pause the app activity and prevent background executions but with recent Android changes, it only pauses the notifications from appearing and that\'s pretty much it. But does allows you to unpause from the launcher app list as they will be still available as grayscale paused app icons. - Should work the same as the native app pause/ focus mode features. - More options - Freeze all apps - Unfreeze all apps - Export frozen apps list - Import frozen apps list - Pick apps to freeze - Choose which apps can be frozen - Automation - Freeze when locked - Freeze delay - Immediate - 1m - 5m - 15m - Manual - Auto freeze apps - Freeze selected apps when the device locks. Choose a delay to avoid freezing apps if you unlock the screen shortly after turning it off. - Freezing system apps might be dangerous and may cause unexpected behavior. - Enable in Settings - Don\'t freeze active apps - Usage Stats - Required to detect which apps are currently in the foreground to avoid freezing them - Required to detect playing media and active notifications to avoid freezing them - Freeze mode - Freezing - App suspension - Can not switch mode while apps are frozen. Please unfreeze all and try again. + 应用控制 + 冻结 + 解冻 + 移除 + 创建快捷方式 + 应用程序信息 + 什么是冻结? + 应用冻结会禁用应用的启动活动,将其从应用列表和更新列表中移除。这将阻止应用启动,直到其被解除冻结为止,从而节省资源。但您需要在此处解除冻结或手动重新启用应用。 + 不要冻结社交媒体应用程序 + 什么是挂起? + 以前暂停应用会暂停应用活动并阻止后台运行,但随着 Android 最近的更新,现在只会暂停通知的显示,仅此而已。不过,它允许你从启动器应用列表中取消暂停,暂停的应用仍然会以灰度暂停图标的形式显示。 + 应该与原生应用程序的暂停/专注模式功能工作方式相同。 + 更多选项 + 冻结全部软件 + 解冻全部软件 + 导出冻结软件的列表 + 导入冻结软件的列表 + 选择需要冻结的软件 + 选择可被冻结的软件 + 自动化 + 当设备锁定时冻结 + 延迟冻结 + 立刻 + 1分 + 5分 + 15分 + 手动 + 自动冻结应用程序 + 设备锁定时冻结选定的应用。设置延迟时间,避免在关机后立即解锁屏幕时应用冻结。 + 冻结系统应用程序可能很危险,并可能导致意外行为。 + 在设置中启用 + 不要冻结活跃软件 + 使用情况统计 + 需要检测哪些应用当前在前台运行,以避免应用卡死 + 需要检测正在播放的媒体和活动通知,以避免媒体卡顿 + 冻结模式 + 冻结 + 挂起 + 应用冻结时无法切换模式。请先解除所有应用的冻结状态,然后再试一次。 - Only show when screen off - Skip silent notifications - Skip persistent notifications - Flashlight Pulse - Flashlight pulse - Only while facing down - Same apps as notification lighting - Style + 仅在屏幕关闭时显示 + 跳过静音通知 + 跳过常驻通知 + 脉冲手电筒 + 脉冲手电筒 + 只有屏幕朝下时开启 + 与闪烁通知相同的应用程序 + 风格 Stroke adjustment - Corner radius + 圆角度数 Stroke thickness - Glow adjustment - Glow spread - Placement - Horizontal position - Vertical position - Indicator adjustment - Scale - Duration - Animation - Pulse count - Pulse duration + 边框调节 + 边框延展 + 位置 + 横向位置 + 纵向位置 + 指标调整 + 大小 + 持续时间 + 动画 + 闪烁次数 + 闪烁时间 颜色模式 - Ambient display - Ambient display + 环境显示 + 环境显示 如果您不使用屏幕常亮AOD,则此款产品适用。 唤醒屏幕并显示光效 - Show lock screen + 在锁屏上显示 无全黑遮盖层 添加 @@ -217,12 +217,12 @@ 开启 关闭 自定义私有DNS - 常见预设DNS - Add DNS Preset - Preset name - Reset - Delete preset - Are you sure you want to reset all DNS presets to defaults? This will remove all your custom presets. + 预设DNS + 增加DNS预设 + 预设名称 + 重置 + 删除预设 + 您确定要将所有 DNS 预设重置为默认值吗?这将删除您所有的自定义预设。 供应商主机名 AdGuard DNS dns.adguard.com @@ -234,43 +234,43 @@ dns.quad9.net CleanBrowsing adult-filter-dns.cleanbrowsing.org - Charging - Limit to 80% - Adaptive - Not optimized - Permission missing + 充电 + 限制充至80% + 自适应充电 + 不优化 + 缺少权限 - Screen locked security - Screen Locked Security - Authenticate to enable screen locked security - Authenticate to disable screen locked security - ⚠️ WARNING - This feature is not foolproof. There may be edge cases where someone still being able to interact with the tile. \nAlso keep in mind that Android will always allow to do a forced reboot and Pixels will always allow the device to be turned off from the lock screen as well. - Make sure to remove the airplane mode tile from quick settings as that is not preventable because it does not open a dialog window. - When enabled, the Quick Settings panel will be immediately closed and the device will be locked down if someone attempt to interact with Internet tiles while the device is locked. \n\nThis will also disable biometric unlock to prevent further unauthorized access. Animation scale will be reduced to 0.1x while locked to make it even harder to interact with. + 屏幕锁定安全 + 屏幕锁定安全 + 进行身份验证以启用屏幕锁定安全功能 + 进行身份验证以停用屏幕锁定安全功能 + ⚠警告 + 此功能并非万无一失。在某些特殊情况下,用户可能仍然能够与磁贴进行交互。\n此外,请注意,Android 始终允许强制重启,Pixel 也始终允许从锁定屏幕关闭设备。 + 请务必从快速设置中移除飞行模式图标,因为无法阻止它打开对话框。 + 启用此功能后,如果有人在设备锁定状态下尝试与“互联网”磁贴进行交互,“快速设置”面板将立即关闭,设备将被锁定。\n\n此外,生物识别解锁功能将被禁用,以防止进一步的未经授权访问。锁定状态下,动画缩放比例将降低至 0.1 倍,进一步增加交互难度。 - Re-order modes - Long press to toggle - Drag to reorder - Sound - Vibrate - Silent + 重新排序模式 + 长按切换 + 拖动以重新排序 + 响铃 + 震动 + 静音 - Connectivity - Phone & Network - Audio & Media - System Status - OEM Specific + 连接 + 电话与网络 + 声音与媒体 + 系统状态 + OEM特殊设置 WiFi - Bluetooth + 蓝牙 NFC / Felica VPN - Airplane Mode - Hotspot - Cast - Mobile Data - Phone Signal + 飞行模式 + 热点 + 投放 + 移动数据 + 信号 VoLTE / VoNR WiFi Calling / VoWiFi 通话状态 / 同步 @@ -279,23 +279,23 @@ 听筒 扬声器 DMB - Clock - Input Method (IME) - Alarm - Battery - Power Saving - Data Saver - Rotation Lock - Location / GPS - Sync - Managed Profile - Do Not Disturb - Privacy & Secure Folder - Security Status (SU) - OTG Mouse / Keyboard - Samsung Smart Features - Samsung Services - Ethernet + 时钟 + 输入法 + 闹钟 + 电池 + 省电 + 流量节省器 + 旋转锁定 + 位置 / GPS + 同步 + 管理配置文件 + 请勿打扰 + 隐私与安全文件夹 + 安全状态 + OTG外接鼠标/键盘 + 三星Smart特征 + 三星设备 + 以太网 在时钟里显示秒 电量百分比 @@ -318,14 +318,14 @@ 其他 时钟秒数 - Show seconds in status bar clock - Battery Percentage - Configure battery percentage visibility - Privacy Chips - Show indicator when camera or mic is in use - Toggle visibility for %1$s - Pin to Favorites - Unpin from Favorites + 在状态栏时钟上显示秒 + 电量百分比 + 配置电量百分比可见性 + 隐私芯片 + 摄像头或麦克风使用时显示指示 + 对%1$s切换可见性 + 在收藏中钉选 + 在收藏中解除钉选 Tools @@ -379,100 +379,100 @@ App lock Secure apps with biometrics Freeze - Disable rarely used apps - Watermark - Add EXIF data and logos to photos - Always on Display - Show time and info while screen off - Calendar Sync - Sync events to your watch - Overlay - Frame - Device Brand - EXIF Data - Pick Image - Image saved to gallery - Share - EXIF Settings - Focal Length - Aperture + 停用不常用应用程序 + 水印 + 向照片添加EXIF数据和logo + 屏幕常亮 + 屏幕关闭时显示时间和信息 + 日历同步 + 与您的手表同步活动 + 浮水印 + 画框水印 + 设备品牌 + EXIF数据 + 选择照片 + 图片已保存至相册 + 分享 + EXIF设置 + 焦距 + 光圈 ISO - Shutter Speed - Date & Time - Move to Top - Align Left - Brand Size - Data Size - Text Size - Font Size - Custom Text - Enter your text... - Spacing - Border Width - Round Corners - Color - Logo - Show Logo - Logo Size - Edit Watermark Texts - Device brand - Date & Time - No date information - Rotate left - Rotate right - Next - OK - Save Changes - Calendar Sync Settings - Sync specific calendars - Periodic Sync - Sync every 15 minutes if changes found - Sync Now - Trigger immediate sync to watch - No local calendars found - Calendar sync started + 快门速度 + 日期与时间 + 移动到顶部 + 左对齐 + 品牌大小 + 数据大小 + 文字大小 + 字体大小 + 自定义文字 + 输入文字… + 间距 + 边框宽度 + 圆角 + 颜色 + 图标 + 显示图标 + 图标大小 + 编辑水印文字 + 设备品牌 + 日期与时间 + 无日期数据 + 向左旋转 + 向右旋转 + 下一项 + 完成 + 保存更改 + 日期同步设置 + 同步特殊日历 + 周期同步 + 如果发现更改,则每 15 分钟同步一次 + 立刻同步 + 立刻同步至手表 + 未找到本地日历 + 日历同步已开始 - Widget Haptic feedback - Pick haptic feedback for widget taps - Smart WiFi - Hide mobile data when WiFi is connected - Smart Data - Hide mobile data in certain modes - Reset All Icons - Reset status bar icon visibility to default - Abort Caffeinate with screen off - Automatically turn off Caffeinate when manually locking the device - Lighting Style + 微件触觉反馈 + 选择微件点击的触觉反馈 + 智能WiFi + 连接 WiFi 时隐藏移动数据图标 + 智能移动网络 + 在某些模式下隐藏移动数据 + 重置所有图标 + 重置状态栏图标可见性为默认值 + 当屏幕关闭时关闭Caffeinate + 手动锁定设备时自动关闭 Caffeinate + 闪光样式 Choose between Stroke, Glow, Spinner, and more - Corner radius - Adjust the corner radius of the notification lighting - Skip silent notifications - Do not show lighting for silent notifications - Flashlight pulse - Slowly pulse flashlight for new notifications - Only while facing down - Pulse flashlight only when device is face down - No system channels discovered yet. They will appear here once detected. - UI Blur - Toggle system-wide UI blur - Bubbles - Enable floating window bubbles - Sensitive Content - Hide notification details on lockscreen - Tap to Wake - Double tap to wake control - AOD - Always On Display toggle - Caffeinate - Keep screen awake toggle - Sound Mode - Cycle sound modes (Ring/Vibrate/Silent) - Notification Lighting - Toggle notification lighting service - Dynamic Night Light - Night light automation toggle - Locked Security - Network security on lockscreen toggle + 圆角度数 + 调整通知闪烁的圆角半径 + 跳过静音通知 + 不要为静默通知显示闪光 + 脉冲手电筒 + 对于新通知缓慢闪烁手电筒 + 只有屏幕朝下时开启 + 当设备屏幕朝下时闪烁手电筒 + 尚未发现系统通道。一旦检测到,它们将显示在此处。 + 界面模糊 + 切换系统级用户界面模糊 + 气泡 + 启用浮动窗口气泡 + 敏感内容 + 在锁屏上隐藏通知详细内容 + 点按即唤醒 + 双击以唤醒控制 + 屏幕常亮AOD + AOD切换开关 + Caffeinate(屏幕保持唤醒) + 保持屏幕唤醒开关 + 声音模式 + 循环声音模式(响铃/振动/静音) + 通知光效 + 开关通知闪烁服务 + 动态夜间模式 + 夜间模式自动切换 + 锁定安全 + 锁定屏幕开关上的网络安全 Mono Audio Force mono audio output toggle Flashlight @@ -523,11 +523,11 @@ Required for App Lock, Screen off widget and other features to detect interactions Required to trigger notification lighting on new notifications Default Browser - Required to handle links efficiently - Required to intercept hardware button events - Required to intercept volume key events while the screen is off to trigger the Ambient Glance overlay. - Needed to monitor foreground applications. - Write Secure Settings + 需要高效地处理链接 + 需要拦截硬件按钮事件 + 需要在屏幕关闭时拦截音量键事件,以触发环境光概览叠加层。 + 需要监控前台软件。 + 覆写安全设置 Required for Statusbar icons and Screen Locked Security Needed to toggle Night Light. Grant via ADB or root. Modify System Settings From 5d3b09ef1499a57ce8b1add7e2dfd33e1e5f324f Mon Sep 17 00:00:00 2001 From: Kainoa Kanter Date: Tue, 7 Apr 2026 12:48:31 -0700 Subject: [PATCH 02/23] Detect Sennheiser Momentum headphones in battery widget Momentum 4 headphones show up as "MOMENTUM 4" for the device name --- .../com/sameerasw/essentials/services/widgets/BatteriesWidget.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/sameerasw/essentials/services/widgets/BatteriesWidget.kt b/app/src/main/java/com/sameerasw/essentials/services/widgets/BatteriesWidget.kt index 9c262519a..5b3f8ebdc 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/widgets/BatteriesWidget.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/widgets/BatteriesWidget.kt @@ -134,6 +134,7 @@ class BatteriesWidget : GlanceAppWidget() { device.name.contains("watch", true) -> R.drawable.rounded_watch_24 device.name.contains("bud", true) || device.name.contains("pod", true) || + device.name.contains("momentum", true) || device.name.contains( "head", true From 259381bf55893b640db1a96dd2b6cab4951413ef Mon Sep 17 00:00:00 2001 From: "sameerasw.com" Date: Wed, 8 Apr 2026 09:24:16 +0530 Subject: [PATCH 03/23] New translations strings.xml (Chinese Traditional) --- app/src/main/res/values-zh/strings.xml | 1694 ++++++++++++------------ 1 file changed, 847 insertions(+), 847 deletions(-) diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index cb15d7c02..ea6a85001 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1,6 +1,6 @@ - BETA + 测试版 Essentials 无障碍服务\n\n此服务被应用于以下增强型功能:\n\n• 物理按键映射:\n在屏幕熄灭状态下检测音量键状态以触发如手电筒等功能.\n\n• 独立应用设置:\n检测当前活动的应用以应用动态夜间光效, 通知闪光和应用锁等功能\n\n• 屏幕控制:\n允许应用通过双击或点击小部件以锁定屏幕或检测屏幕状态变化.\n\n• 安全:\n通过在设备锁定时检测窗口内容来避免未经授权的更改.\n\n输入内容和敏感用户数据不会被收集和上传. 应用冻结 禁用不常用的应用 @@ -12,7 +12,7 @@ 闪光灯脉冲 检查预览版更新 可能不稳定 - 默认选单 + 默认标签页 安全性 启用应用锁 @@ -51,7 +51,7 @@ 开关静音 AI 助理 屏幕截图 - 循环声音模式 + 循环切换声音模式 喜欢这首歌 喜爱歌曲设置 本功能需要通知权限以检测当前正在播放的媒体和点按喜欢按钮。请在下方启用。 @@ -59,11 +59,11 @@ 在屏幕常亮AOD上显示叠加层 闻曲知音查看 在屏幕常亮AOD上查看媒体 - 基座模式 + 停靠模式 当有音乐在屏幕常亮AOD时播放,使遮盖层永久可见 通知一览 当通知处于等待时,保持屏幕常亮 - 与闪烁通知相同的应用程序 + 与通知光效相同的应用 此功能会在收到来自选定应用的通知时动态启用屏幕常亮AOD功能,并在所有匹配的通知关闭后禁用该功能。您可以选择应用,也可以使用与通知指示灯相同的选择。 授予通知权限 切换媒体音量 @@ -111,74 +111,74 @@ 可以关闭夜间模式的应用程序 选择应用程序 - 应用控制 + 控制应用 冻结 - 解冻 + 取消冻结 移除 创建快捷方式 - 应用程序信息 - 什么是冻结? - 应用冻结会禁用应用的启动活动,将其从应用列表和更新列表中移除。这将阻止应用启动,直到其被解除冻结为止,从而节省资源。但您需要在此处解除冻结或手动重新启用应用。 - 不要冻结社交媒体应用程序 - 什么是挂起? - 以前暂停应用会暂停应用活动并阻止后台运行,但随着 Android 最近的更新,现在只会暂停通知的显示,仅此而已。不过,它允许你从启动器应用列表中取消暂停,暂停的应用仍然会以灰度暂停图标的形式显示。 - 应该与原生应用程序的暂停/专注模式功能工作方式相同。 + 应用信息 + 什么是应用冻结? + 应用冻结会禁用指定的应用的启动 Activity 这意味着指定应用将会从应用列表中被移除 并且应用将无法通过任何方式更新 它将会完全禁止应用启动直到它在此页面被取消冻结或被手动启用 + 不要冻结通讯应用 + 什么是暂停? + 暂停应用过去用于暂停应用活动并阻止后台执行,但随着近期 Android 的变化,它仅仅阻止通知显示,仅此而已。但它允许您从启动器应用列表中取消暂停,因为它们仍然会显示为灰显的暂停应用图标。 + 应该与原生应用暂停/专注模式功能相同。 更多选项 - 冻结全部软件 - 解冻全部软件 - 导出冻结软件的列表 - 导入冻结软件的列表 - 选择需要冻结的软件 - 选择可被冻结的软件 + 冻结所有应用 + 取消冻结所有应用 + 导出冻结应用列表 + 导入冻结应用列表 + 选择要冻结的应用 + 选择哪些应用可以被冻结 自动化 - 当设备锁定时冻结 - 延迟冻结 - 立刻 - 1分 - 5分 - 15分 + 锁定时冻结 + 冻结延迟 + 立即 + 1分钟 + 5分钟 + 15分钟 手动 - 自动冻结应用程序 - 设备锁定时冻结选定的应用。设置延迟时间,避免在关机后立即解锁屏幕时应用冻结。 - 冻结系统应用程序可能很危险,并可能导致意外行为。 + 自动冻结应用 + 设备锁定时冻结所选应用。选择延迟时间,避免在关闭屏幕后不久解锁时冻结应用。 + 冻结系统应用可能很危险,并可能导致意外行为。 在设置中启用 - 不要冻结活跃软件 + 不冻结正在使用的应用 使用情况统计 - 需要检测哪些应用当前在前台运行,以避免应用卡死 - 需要检测正在播放的媒体和活动通知,以避免媒体卡顿 + 需要检测哪些应用当前在前台,以避免冻结它们 + 需要检测正在播放的媒体和活动通知,以避免冻结它们 冻结模式 冻结 - 挂起 - 应用冻结时无法切换模式。请先解除所有应用的冻结状态,然后再试一次。 + 应用暂停 + 应用冻结时无法切换模式。请先取消冻结所有应用,然后重试。 仅在屏幕关闭时显示 跳过静音通知 - 跳过常驻通知 - 脉冲手电筒 - 脉冲手电筒 - 只有屏幕朝下时开启 - 与闪烁通知相同的应用程序 - 风格 - Stroke adjustment - 圆角度数 - Stroke thickness - 边框调节 - 边框延展 + 跳过持续通知 + 闪光灯脉冲 + 闪光灯脉冲 + 仅当屏幕朝下时 + 与通知光效相同的应用 + 样式 + 描边调整 + 圆角半径 + 描边粗细 + 发光调整 + 发光扩散 位置 - 横向位置 - 纵向位置 - 指标调整 - 大小 + 水平位置 + 垂直位置 + 指示器调整 + 缩放 持续时间 动画 - 闪烁次数 - 闪烁时间 + 脉冲次数 + 脉冲持续时间 颜色模式 环境显示 环境显示 如果您不使用屏幕常亮AOD,则此款产品适用。 唤醒屏幕并显示光效 - 在锁屏上显示 + 显示锁屏 无全黑遮盖层 添加 @@ -201,7 +201,7 @@ 脉冲手电筒 保持唤醒 Essentials 键盘 - English (US) + 英语(美国) 启用 停用 开发者选项 @@ -217,12 +217,12 @@ 开启 关闭 自定义私有DNS - 预设DNS - 增加DNS预设 + DNS预设 + 添加DNS预设 预设名称 重置 删除预设 - 您确定要将所有 DNS 预设重置为默认值吗?这将删除您所有的自定义预设。 + 确定要将所有DNS预设重置为默认值吗?这将删除您所有的自定义预设。 供应商主机名 AdGuard DNS dns.adguard.com @@ -234,33 +234,33 @@ dns.quad9.net CleanBrowsing adult-filter-dns.cleanbrowsing.org - 充电 - 限制充至80% - 自适应充电 - 不优化 + 充电优化 + 限制到80% + 自适应 + 未优化 缺少权限 - 屏幕锁定安全 - 屏幕锁定安全 - 进行身份验证以启用屏幕锁定安全功能 - 进行身份验证以停用屏幕锁定安全功能 - ⚠警告 - 此功能并非万无一失。在某些特殊情况下,用户可能仍然能够与磁贴进行交互。\n此外,请注意,Android 始终允许强制重启,Pixel 也始终允许从锁定屏幕关闭设备。 - 请务必从快速设置中移除飞行模式图标,因为无法阻止它打开对话框。 - 启用此功能后,如果有人在设备锁定状态下尝试与“互联网”磁贴进行交互,“快速设置”面板将立即关闭,设备将被锁定。\n\n此外,生物识别解锁功能将被禁用,以防止进一步的未经授权访问。锁定状态下,动画缩放比例将降低至 0.1 倍,进一步增加交互难度。 + 锁屏安全 + 锁屏安全 + 认证身份以启用锁屏安全 + 认证身份以禁用锁屏安全 + ⚠️ 警告 + 此功能并非万无一失。可能存在某些极端情况,有人仍然能够与磁贴交互。\n另外请记住,Android 始终允许强制重启,Pixel 也始终允许从锁屏关闭设备。 + 请确保从快速设置中移除飞行模式磁贴,因为该磁贴无法被阻止(它不会打开对话框窗口)。 + 启用后,如果有人尝试在设备锁定时与网络磁贴交互,快速设置面板将立即关闭,设备将被锁定。\n\n这还将禁用生物识别解锁以防止进一步的未经授权访问。锁定时动画缩放将减少到0.1倍,使交互更加困难。 重新排序模式 长按切换 拖动以重新排序 - 响铃 - 震动 + 声音 + 振动 静音 连接 电话与网络 - 声音与媒体 + 音频与媒体 系统状态 - OEM特殊设置 + OEM特定 WiFi 蓝牙 @@ -268,11 +268,11 @@ VPN 飞行模式 热点 - 投放 + 投屏 移动数据 - 信号 + 手机信号 VoLTE / VoNR - WiFi Calling / VoWiFi + WiFi通话 / VoWiFi 通话状态 / 同步 TTY 音量 @@ -280,21 +280,21 @@ 扬声器 DMB 时钟 - 输入法 + 输入法(IME) 闹钟 电池 - 省电 - 流量节省器 + 省电模式 + 数据节省 旋转锁定 位置 / GPS 同步 - 管理配置文件 - 请勿打扰 + 受管配置文件 + 勿扰模式 隐私与安全文件夹 - 安全状态 - OTG外接鼠标/键盘 - 三星Smart特征 - 三星设备 + 安全状态(SU) + OTG鼠标/键盘 + 三星智能功能 + 三星服务 以太网 在时钟里显示秒 @@ -318,930 +318,930 @@ 其他 时钟秒数 - 在状态栏时钟上显示秒 - 电量百分比 - 配置电量百分比可见性 + 在状态栏时钟中显示秒 + 电池百分比 + 配置电池百分比的可见性 隐私芯片 - 摄像头或麦克风使用时显示指示 - 对%1$s切换可见性 - 在收藏中钉选 - 在收藏中解除钉选 + 当摄像头或麦克风使用时显示指示器 + 切换 %1$s 的可见性 + 固定到收藏 + 从收藏中取消固定 - Tools - Visuals - System + 工具 + 视觉效果 + 系统 - Search for Tools, Mods and Tweaks - No results for \"%1$s\" - Search Results - %1$s requires following permissions + 搜索 Essentials + 没有找到 \"%1$s\" 的结果 + 搜索结果 + %1$s 需要以下权限 - Screen off widget - Invisible widget to turn the screen off - Statusbar icons - Control statusbar icons visibility - Caffeinate - Keep the screen awake - Maps power saving mode - For any Android device - Notification lighting - Light up for notifications - Pulse the flashlight for notifications - Sound mode tile - Call vibrations - Vibrate for call actions - Show Bluetooth devices - Display battery level of connected Bluetooth devices - Limit max devices - Adjust max devices visible in widget - Widget background - Show widget background + 熄屏小组件 + 用于关闭屏幕的隐形小组件 + 状态栏图标 + 控制状态栏图标的可见性 + 保持唤醒 + 保持屏幕常亮 + 地图省电模式 + 适用于任何 Android 设备 + 通知光效 + 收到通知时亮起 + 收到通知时脉冲闪光灯 + 声音模式磁贴 + 通话振动 + 通话操作时振动 + 显示蓝牙设备 + 显示已连接蓝牙设备的电池电量 + 限制最大设备数 + 调整小组件中显示的最大设备数量 + 小组件背景 + 显示小组件背景 - Trigger Automation - Schedule an action to trigger on an observation - State Automation - Schedule an action to execute based on the state of a condition in and out - New Automation - Edit Automation - Link actions - Handle links with multiple apps - Snooze system notifications - Snooze persistent notifications - Quick settings tiles - View all - Button remap - Remap hardware button actions - Dynamic night light - Toggle night light based on app - Screen locked security - Prevent network controls - App lock - Secure apps with biometrics - Freeze - 停用不常用应用程序 + 触发自动化 + 安排一个动作在观察到事件时触发 + 状态自动化 + 根据条件的进入/退出状态安排动作执行 + 新建自动化 + 编辑自动化 + 链接操作 + 使用多个应用处理链接 + 延迟系统通知 + 延迟持久通知 + 快速设置磁贴 + 查看全部 + 按键重映射 + 重映射硬件按钮操作 + 动态夜间模式 + 基于应用切换夜间模式 + 锁屏安全 + 防止网络控制 + 应用锁 + 使用生物识别保护应用 + 冻结 + 禁用不常用的应用 水印 - 向照片添加EXIF数据和logo - 屏幕常亮 + 向照片添加 EXIF 数据和徽标 + 始终显示 屏幕关闭时显示时间和信息 日历同步 - 与您的手表同步活动 - 浮水印 - 画框水印 + 同步事件到手表 + 叠加 + 边框 设备品牌 EXIF数据 - 选择照片 - 图片已保存至相册 + 选择图片 + 图片已保存到相册 分享 EXIF设置 焦距 光圈 ISO 快门速度 - 日期与时间 - 移动到顶部 + 日期和时间 + 移到顶部 左对齐 品牌大小 数据大小 文字大小 字体大小 - 自定义文字 - 输入文字… + 自定义文本 + 输入您的文本... 间距 边框宽度 圆角 颜色 图标 - 显示图标 - 图标大小 - 编辑水印文字 + 显示Logo + Logo大小 + 编辑水印文本 设备品牌 - 日期与时间 - 无日期数据 + 日期和时间 + 没有日期信息 向左旋转 向右旋转 - 下一项 - 完成 + 下一步 + 确定 保存更改 - 日期同步设置 - 同步特殊日历 - 周期同步 - 如果发现更改,则每 15 分钟同步一次 - 立刻同步 - 立刻同步至手表 + 日历同步设置 + 同步特定日历 + 定期同步 + 如果发现更改,每15分钟同步一次 + 立即同步 + 触发立即同步到手表 未找到本地日历 日历同步已开始 - 微件触觉反馈 - 选择微件点击的触觉反馈 + 小组件触觉反馈 + 选择小组件点击的触觉反馈 智能WiFi - 连接 WiFi 时隐藏移动数据图标 - 智能移动网络 + WiFi连接时隐藏移动数据 + 智能数据 在某些模式下隐藏移动数据 重置所有图标 - 重置状态栏图标可见性为默认值 - 当屏幕关闭时关闭Caffeinate - 手动锁定设备时自动关闭 Caffeinate - 闪光样式 - Choose between Stroke, Glow, Spinner, and more - 圆角度数 - 调整通知闪烁的圆角半径 + 将状态栏图标可见性重置为默认值 + 屏幕关闭时中止保持唤醒 + 手动锁定时自动关闭保持唤醒 + 光效样式 + 在描边、发光、旋转等之间选择 + 圆角半径 + 调整通知光效的圆角半径 跳过静音通知 - 不要为静默通知显示闪光 - 脉冲手电筒 - 对于新通知缓慢闪烁手电筒 - 只有屏幕朝下时开启 - 当设备屏幕朝下时闪烁手电筒 - 尚未发现系统通道。一旦检测到,它们将显示在此处。 + 不为静音通知显示光效 + 闪光灯脉冲 + 为新通知缓慢脉冲闪光灯 + 仅当屏幕朝下时 + 仅在设备屏幕朝下时脉冲闪光灯 + 尚未发现系统通知通道。检测到后会在此处显示。 界面模糊 - 切换系统级用户界面模糊 + 切换系统级界面模糊 气泡 启用浮动窗口气泡 敏感内容 - 在锁屏上隐藏通知详细内容 - 点按即唤醒 - 双击以唤醒控制 + 在锁屏上隐藏通知详情 + 点按唤醒 + 双击唤醒控制 屏幕常亮AOD - AOD切换开关 + 始终显示切换 Caffeinate(屏幕保持唤醒) - 保持屏幕唤醒开关 + 保持屏幕唤醒切换 声音模式 - 循环声音模式(响铃/振动/静音) + 循环切换声音模式(响铃/振动/静音) 通知光效 - 开关通知闪烁服务 + 切换通知光效服务 动态夜间模式 - 夜间模式自动切换 - 锁定安全 - 锁定屏幕开关上的网络安全 - Mono Audio - Force mono audio output toggle - Flashlight - Dedicated flashlight toggle - App Freezing - Launch app freezing grid - Flashlight Pulse - Toggle notification flashlight pulse - Toggle stay awake developer option - Private DNS - Cycle Private DNS modes (Off/Auto/Hostname) - USB Debugging - Toggle USB Debugging developer option - Enable Button Remap - Master toggle for volume button remapping - Remap Haptic Feedback - Vibration feedback when remapped button is pressed - Flashlight toggle - Toggle flashlight with volume buttons - Enable Dynamic Night Light - Master switch for dynamic night light - Enable app lock - Master toggle for app locking - Select locked apps - Choose which apps require authentication - Pick apps to freeze - Choose which apps can be frozen - Freeze all apps - Immediately freeze all picked apps - Freeze when locked - Freeze selected apps when device locks - Freeze delay - Delay before freezing after locking + 夜间模式自动化切换 + 锁屏安全 + 锁屏网络安全性切换 + 单声道音频 + 强制单声道音频输出切换 + 手电筒 + 专用手电筒切换 + 应用冻结 + 启动应用冻结网格 + 闪光灯脉冲 + 切换通知闪光灯脉冲 + 切换保持唤醒开发者选项 + 私有DNS + 循环切换私有DNS模式(关闭/自动/主机名) + USB调试 + 切换USB调试开发者选项 + 启用按键重映射 + 音量键重映射的总开关 + 重映射触觉反馈 + 按下重映射按钮时的振动反馈 + 手电筒开关 + 使用音量键切换手电筒 + 启用动态夜间模式 + 动态夜间模式总开关 + 启用应用锁 + 应用锁总开关 + 选择锁定的应用 + 选择哪些应用需要认证 + 选择要冻结的应用 + 选择哪些应用可以被冻结 + 冻结所有应用 + 立即冻结所有已选择的应用 + 锁定时冻结 + 设备锁定时冻结所选应用 + 冻结延迟 + 锁定后冻结前的延迟 Shizuku - Required for advanced commands. Install Shizuku from the Play Store. - Install Shizuku - Shizuku permission - Required to run power-saving commands while maps is navigating. - Requires Shizuku or Root - Root Access - Permissions required for system actions using Root privileges. - Notification Listener - Required to detect when Maps is navigating. - Required to detect new notifications - Required to detect and snooze notifications - Accessibility Service - Required for App Lock, Screen off widget and other features to detect interactions - Required to trigger notification lighting on new notifications - Default Browser - 需要高效地处理链接 + 需要高级命令。从Play商店安装Shizuku。 + 安装Shizuku + Shizuku权限 + 在地图导航时需要运行省电命令。 + 需要Shizuku或Root + Root权限 + 使用Root权限进行系统操作所需的权限。 + 通知监听器 + 需要检测地图何时在导航。 + 需要检测新通知 + 需要检测和延迟通知 + 无障碍服务 + 应用锁、熄屏小组件和其他功能检测交互所需 + 需要在新通知上触发通知光效 + 默认浏览器 + 需要高效处理链接 需要拦截硬件按钮事件 - 需要在屏幕关闭时拦截音量键事件,以触发环境光概览叠加层。 - 需要监控前台软件。 - 覆写安全设置 - Required for Statusbar icons and Screen Locked Security - Needed to toggle Night Light. Grant via ADB or root. - Modify System Settings - Required to toggle Adaptive Brightness and other system settings - Overlay Permission - Required to display the notification lighting overlay on the screen - Device Administrator - Required to hard-lock the device (disabling biometrics) on unauthorized access attempts - Grant Permission - Copy ADB - Check - Enable in Settings - How to grant - Battery Optimization - Ensure the service is not killed by the system to save power. + 需要拦截屏幕关闭时的音量键事件以触发环境音乐查看叠加层。 + 需要监控前台应用。 + 写入安全设置 + 状态栏图标和锁屏安全所需 + 需要切换夜间模式。通过ADB或root授予。 + 修改系统设置 + 需要切换自适应亮度和其他系统设置 + 悬浮窗权限 + 需要在屏幕上显示通知光效叠加层 + 设备管理员 + 需要在未经授权的访问尝试时硬锁定设备(禁用生物识别) + 授予权限 + 复制ADB命令 + 检查 + 在设置中启用 + 如何授予 + 电池优化 + 确保服务不会被系统为了省电而终止。 Essentials - Freeze - Frozen + 冻结 + 已冻结 DIY - Apps - Disabled apps - Do It Yourself - Find and manage apps - App Updates - App Updates - Add Repository - Edit Repository - Enter GitHub Repository URL or owner/repo - Track - No APK found in the latest release - Repository not found - Latest Release - View README - %d Stars - Installed app - Not installed - Pick app - Select app - Untrack - Pending - Up-to-date - Track and download the latest releases for your favorite apps directly from GitHub. - Invalid format. Use owner/repo or GitHub URL - An error occurred during search - Auto - Options - Check for pre-releases - Notifications - GitHub rate limit exceeded. Please try again later. + 应用 + 已禁用的应用 + 自己动手 + 查找和管理应用 + 应用更新 + 应用更新 + 添加仓库 + 编辑仓库 + 输入GitHub仓库URL或owner/repo + 跟踪 + 最新版本中未找到APK + 仓库未找到 + 最新版本 + 查看README + %d 星 + 已安装的应用 + 未安装 + 选择应用 + 选择应用 + 取消跟踪 + 等待中 + 已是最新 + 直接从GitHub跟踪和下载您最喜爱的应用的最新版本。 + 格式无效。请使用owner/repo或GitHub URL + 搜索时发生错误 + 自动 + 选项 + 检查预发布版 + 通知 + 超出GitHub速率限制。请稍后重试。 - Keyboard Setup - Enable in settings - Switch to Essentials - Enabled - Disabled - Adaptive Brightness - Maps Power Saving - Search - Stop - Search - Search frozen apps + 键盘设置 + 在设置中启用 + 切换到Essentials + 已启用 + 已禁用 + 自适应亮度 + 地图省电模式 + 搜索 + 停止 + 搜索 + 搜索已冻结的应用 - Back - Back - Settings - Report a Bug + 返回 + 返回 + 设置 + 报告Bug - Crash reporting - Off - Auto - Essentials crashed, Report sent - Simulate crash - Welcome to Essentials - A Toolbox for Android Nerds + 崩溃报告 + 关闭 + 自动 + Essentials崩溃,已发送报告 + 模拟崩溃 + 欢迎使用Essentials + 面向Android极客的工具箱 by sameerasw.com - Let\'s Begin - Acknowledgement - This app is a collection of utilities that can interact deeply with your device system. Using some features might modify system settings or behavior in unexpected ways. \n\nYou only need to grant necessary permissions which are required for selected features you are using giving you full control over the app\'s behavior. \n\nFurther more, the app does not track or store any of your personal data, I don\'t need them... Keep to yourself safe. You can refer to the source code for more information. \n\nThis app is fully open source and is and always will be free to use. Do not pay or install from unknown sources. - WARNING: Proceed with caution. The developer takes no responsibility for any system instability, data loss, or other issues caused by the use of this app. By proceeding, you acknowledge these risks. - I know you didn\'t even read this carefully but, in case you need any help, feel free to reach out the developer or the community. - I Understand - Anytime you are clueless on a feature or a Quick Settings Tile on what it does and what permissions may necessary for it, just long press it and pick \'What is this?\' to learn more. - You can report bugs or find helpful guides anytime in the app settings. - Let Me in Already - Preferences - Configure some basic settings to get started. - App Settings - Language - Haptic Feedback - Updates - Auto check for updates - Check for updates at app launch - All Set - Check What\'s New? - Done - Preview - Help Guide - What is this? - Update Available - Glance at your device\'s hardware and software specifications in detail. This information is fetched from GSMArena and system properties to provide a comprehensive overview of your Android device. - Ambient Music Glance shows a Now Playing overlay on your lock screen when music is playing and playback changes. \n\nIf your device does not support overlays over AOD, you can opt for the Ambience screensaver added in your Android settings as an alternative while charging. - Notification Lighting adds a beautiful edge lighting effect when you receive notifications.\n\nYou can customize the animation style, colors, and behavior. It works even when the screen is off (OEM dependent) or on top of your current app. Pick apps, notification priority or what behavior it should be triggering on from given controls. If your OEM does not support overlays above AOD, sue the Ambient display option found below. - Easily turn the screen off with a tap on a transparent resizable widget that does not add icons or any clutter to your home screen. - Take full control over your status bar icons.\n\nHide specific icons like WiFi, Bluetooth, or cellular data to keep your status bar clean. You can also customize the clock format and battery indicator with some smart controls as well. These are the list of available AOSP controls so your device OS might not respect all the controls. - Caffeinate prevents your screen from turning off automatically.\n\nKeep your screen awake for a specific duration or indefinitely. Useful when reading long articles or referencing a recipe. - Get the Pixel 10 series exclusive Google Maps Power Saving mode with the minimal pitch black background to display over your lock screen on any Android device. Start a navigation session, turn the screen off and back on. - Pulse the flashlight when you receive a notification.\n\nWith devices have hardware support for flashlight dimming, the pulse will be smoothly animated. - Snooze annoying persistent system notifications which can not be modified by default. \n\nPlease wait until the notification arrives and then go into this feature where it\'s notification channel will be listed. Select that to snooze from next time.\n\nAny snoozed notification can still be accessed from your notification history in Android. - Add custom tiles to your Quick Settings panel.\n\nLong press any of them to learn what they do. - Remap your hardware buttons to perform different actions and shortcuts.\n\nCustomize what happens when you long press volume buttons with certain conditions. \n\nSome behavior such as screen off trigger or flashlight controls might be OEM dependent on their implementation and may not work on all devices as expected. Some scenarios could be worked around using Shizuku permissions but may not give the same experience due to the implementations. - Automatically toggle your screen blue light filter based on the foreground app. - Enhance security when your device is locked.\n\nRestrict access to some sensitive QS tiles preventing unauthorized network modifications and further preventing them re-attempting to do so by increasing the animation speed to prevent touch spam.\n\nThis feature is not robust and may have flaws such as some tiles which allow toggling directly such as bluetooth or flight mode not being able to be prevented. - Secure your apps with a secondary authentication layer.\n\nYour device lock screen authentication method will be used as long as it meets the class 3 biometric security level by Android standards. - Get notified when you get closer to your destination to ensure you never miss the stop.\n\nGo to Google Maps, long press a pin nearby to your destination and make sure it says \"Dropped pin\" (Otherwise the distance calculation might not be accurate), And then share the location to the Essentials app and start tracking. - Add Destination - Edit Destination - Home, Office, etc. - Name - Save - Cancel - Resolving location… - Last Trip - Saved Destinations - No destinations saved yet. - Delete Destination - Tracking Now - Re-Start - Share coordinates (Dropped pin) from Google Maps to Essentials to save as a destination.\n\nThe distance shown is the direct distance to the destination, not the distance along the roads.\n\nTake all calculations of time and distance with a grain of salt as they are not always accurate. - Are we there yet? - Radius: %1$d m - Distance to target: %1$s - Last: %1$s - Never - To go - %1$d min - %1$d hr %2$d min - %1$s (%2$d%%) • %3$s to go - Freeze apps to stop them from running in the background.\n\nPrevent battery drain and data usage by completely freezing apps when you are not using them. They will be unfrozen instantly when you launch them. The apps will not show up in the app drawer and also will not show up for app updates in Play Store while frozen. - A custom input method no-one asked for.\n\nIt is just an experiment. Multiple languages may not get support as it is a very complex and time consuming implementation. - Monitor battery levels of all your connected devices.\n\nSee the battery status of your Bluetooth headphones, watch, and other accessories in one place. Connect with AirSync application to display your mac battery level as well. - Add a custom caption/ watermark to your photos with EXIF data and device information.\n\nShare an image directly from other app to Essentials to easily add a watermark. - Sync all your upcoming calendar schedule not matter the restrictions on Google accounts not letting to be added to wearOS devices due to work or school policies. \n\nMake sure to install the wearOS Essentials companion app to display the schedule in the app as well as in a tile or a complication. - Keep track of updates for your installed apps.\n\nGet notified about available updates, view changelogs and install them easily with a tap. - Add haptic feedback to your calls.\n\nVibrate when a call is connected, disconnected, or accepted, giving you tactile confirmation without looking at the screen. - Quickly toggle between Sound, Vibrate, and Silent modes.\n\nA convenient tile to change your ringer mode without using the volume buttons or settings. You can re-order the modes or disable any if not needed to customize the tile toggle to cycle behavior. - Easily toggle the system level blur depth effect across the OS. - Enable or disable floating notification bubbles.\n\nQuickly toggle the system-wide setting for conversation bubbles. - Hide sensitive content on the lock screen.\n\nToggle whether notification content is shown or hidden when your device is locked. - Toggle tap to wake functionality.\n\nEnable or disable the ability to wake your screen with a tap. - Toggle Always On Display.\n\nQuickly enable or disable the always-on display to view info at a glance. - Automatically control your Always On Display based on your notifications. When a message or alert arrives from a selected app, AOD will stay on until you dismiss the notification, ensuring you never miss important info without wasting battery when no alerts are present. - Combine audio channels into mono.\n\nUseful when using a single earbud or for accessibility purposes. - Toggle the flashlight.\n\nA Long pressing opens the controls for intensity adjustment which might need hardware implementation which some devices may lack. - Keep the screen awake while charging.\n\nPrevents the screen from sleeping as long as the device is connected to a power source which is suitable for developers during debugging. - Toggle NFC.\n\nQuickly enable or disable Near Field Communication for payments and pairing. - Toggle adaptive brightness.\n\nEnable or disable automatic screen brightness adjustment based on ambient light. - Toggle Private DNS.\n\nCycle through Off, Automatic, and Private DNS provider modes. - Toggle USB Debugging.\n\nEnable or disable ADB debugging access directly from the quick settings. - Launch the eye dropper tool to pick colors introduced in Android 17 BETA 2 - Optimize your battery life by limiting the maximum charge or using adaptive charging. This is specially designed for Pixel devices to ensure longevity and healthy charging cycles.\n\nCredits: TebbeUbben/ChargeQuickTile - Download + 开始吧 + 致谢 + 本应用是一系列能够深度交互设备系统的工具集合。使用某些功能可能会以意想不到的方式修改系统设置或行为。\n\n您只需要为您使用的选定功能授予必要的权限,从而完全控制应用的行为。\n\n此外,本应用不会跟踪或存储您的任何个人数据,我不需要它们…请保护好自己。您可以参考源代码获取更多信息。\n\n本应用完全开源,并且现在和将来都永远免费使用。请勿付费或从未知来源安装。 + 警告:请谨慎操作。开发者对使用本应用可能导致的任何系统不稳定、数据丢失或其他问题概不负责。继续操作即表示您承认这些风险。 + 我知道您甚至没有仔细阅读,但如果您需要任何帮助,请随时联系开发者或社区。 + 我理解 + 任何时候您对某个功能或快速设置磁贴的作用以及可能需要哪些权限感到困惑,只需长按它并选择“这是什么?”即可了解更多。 + 您可以随时在应用设置中报告错误或查找有用的指南。 + 让我进去 + 偏好设置 + 配置一些基本设置以开始使用。 + 应用设置 + 语言 + 触觉反馈 + 更新 + 自动检查更新 + 应用启动时检查更新 + 全部就绪 + 查看新功能? + 完成 + 预览 + 帮助指南 + 这是什么? + 有可用更新 + 快速查看您设备的硬件和软件详细规格。这些信息从GSMArena和系统属性获取,以提供您Android设备的全面概述。 + 当音乐正在播放且播放状态发生变化时,环境音乐查看会在锁屏上显示正在播放的叠加层。\n\n如果您的设备不支持在AOD上显示叠加层,您可以选择在Android设置中添加的环境屏保作为充电时的替代方案。 + 通知光效在您收到通知时添加漂亮的边缘光效。\n\n您可以自定义动画样式、颜色和行为。它甚至在屏幕关闭时(取决于OEM)或在当前应用之上工作。从给定的控件中选择应用、通知优先级或触发行为。如果您的OEM不支持在AOD上显示叠加层,请使用下方找到的环境显示选项。 + 通过点击一个透明的、可调整大小的小组件轻松关闭屏幕,该小组件不会在主屏幕上添加图标或任何杂乱。 + 完全控制您的状态栏图标。\n\n隐藏特定图标如WiFi、蓝牙或蜂窝数据,保持状态栏整洁。您还可以使用一些智能控制自定义时钟格式和电池指示器。这些是可用的AOSP控件列表,因此您的设备操作系统可能不会尊重所有控件。 + 保持唤醒可防止屏幕自动关闭。\n\n在特定时长或无限期内保持屏幕唤醒。适用于阅读长文章或参考食谱时。 + 在任何Android设备上获取Pixel 10系列独有的Google Maps省电模式,在锁屏上显示极简纯黑背景。开始导航会话,关闭屏幕再重新打开即可。 + 收到通知时脉冲闪光灯。\n\n对于硬件支持闪光灯调光的设备,脉冲将平滑动画。 + 延迟那些默认无法修改的烦人的系统持久通知。\n\n请等待通知到达,然后进入此功能,其通知通道将被列出。选择该通道以便下次延迟。\n\n任何延迟的通知仍然可以从Android的通知历史记录中访问。 + 向快速设置面板添加自定义磁贴。\n\n长按其中任何一个以了解它们的作用。 + 重映射硬件按钮以执行不同的操作和快捷方式。\n\n自定义在特定条件下长按音量键时发生的情况。\n\n某些行为,如屏幕关闭触发或手电筒控制,可能取决于OEM的实现方式,并非在所有设备上都能按预期工作。某些场景可以使用Shizuku权限解决,但由于实现方式不同,可能无法提供相同的体验。 + 根据前台应用自动切换屏幕蓝光滤除模式。 + 增强设备锁定时的安全性。\n\n限制访问某些敏感的快速设置磁贴,防止未经授权的网络修改,并通过增加动画速度防止触摸重试来阻止再次尝试。\n\n此功能并不健壮,可能存在缺陷,例如某些允许直接切换的磁贴(如蓝牙或飞行模式)无法被阻止。 + 使用第二层认证保护您的应用。\n\n只要您的设备锁屏认证方法符合Android标准的3级生物识别安全级别,就会被使用。 + 当您接近目的地时收到通知,确保您不会错过站点。\n\n前往Google Maps,长按目的地附近的图钉,确保显示“已落下的图钉”(否则距离计算可能不准确),然后将位置分享给Essentials应用并开始跟踪。 + 添加目的地 + 编辑目的地 + 家、办公室等 + 名称 + 保存 + 取消 + 正在解析位置… + 上次行程 + 已保存的目的地 + 尚未保存任何目的地。 + 删除目的地 + 正在跟踪 + 重新开始 + 从Google Maps分享坐标(已落下的图钉)到Essentials以保存为目的地。\n\n显示的距离是到目的地的直线距离,而非道路距离。\n\n请对时间和距离的所有计算持保留态度,因为它们并不总是准确的。 + 我们到了吗? + 半径:%1$d 米 + 到目标距离:%1$s + 上次:%1$s + 从未 + 剩余 + %1$d 分钟 + %1$d 小时 %2$d 分钟 + %1$s(%2$d%%) • 剩余 %3$s + 冻结应用以阻止它们在后台运行。\n\n当您不使用应用时,通过完全冻结它们来防止电池耗尽和数据使用。当您启动它们时,它们会立即解冻。冻结时,应用不会显示在应用抽屉中,也不会在Play Store中显示应用更新。 + 没人要求的自定义输入法。\n\n这只是一个实验。可能不支持多种语言,因为实现起来非常复杂且耗时。 + 监控所有已连接设备的电池电量。\n\n在一个地方查看蓝牙耳机、手表和其他配件的电池状态。连接AirSync应用以同时显示您的Mac电池电量。 + 使用EXIF数据和设备信息向照片添加自定义标题/水印。\n\n直接从其他应用分享图片到Essentials,轻松添加水印。 + 同步您即将到来的所有日历日程,无论因工作或学校政策而无法添加到wearOS设备的Google帐户限制如何。\n\n请确保安装wearOS Essentials配套应用,以便在应用以及磁贴或复杂功能中显示日程。 + 跟踪已安装应用的更新。\n\n收到可用更新通知,查看更新日志,并轻松点击安装。 + 为通话添加触觉反馈。\n\n当通话接通、挂断或接听时振动,无需查看屏幕即可获得触觉确认。 + 快速在声音、振动和静音模式之间切换。\n\n一个方便的磁贴,无需使用音量键或设置即可更改铃声模式。您可以重新排序模式或禁用不需要的模式,以自定义磁贴的循环行为。 + 轻松切换系统级模糊深度效果。 + 启用或禁用浮动通知气泡。\n\n快速切换会话气泡的系统级设置。 + 在锁屏上隐藏敏感内容。\n\n切换设备锁定时是否显示或隐藏通知内容。 + 切换点按唤醒功能。\n\n启用或禁用通过点按唤醒屏幕的功能。 + 切换始终显示。\n\n快速启用或禁用始终显示以快速查看信息。 + 根据通知自动控制您的始终显示。当来自选定应用的消息或提醒到达时,AOD将保持开启,直到您关闭通知,确保您不会错过重要信息,同时在没有提醒时不会浪费电池。 + 将音频通道合并为单声道。\n\n使用单个耳塞或出于无障碍目的时很有用。 + 切换手电筒。\n\n长按打开亮度调节控件,这可能需要某些设备缺乏的硬件实现。 + 充电时保持屏幕唤醒。\n\n只要设备连接到电源,屏幕就不会休眠,适合开发者在调试期间使用。 + 切换NFC。\n\n快速启用或禁用近场通信以进行支付和配对。 + 切换自适应亮度。\n\n根据环境光线启用或禁用自动屏幕亮度调整。 + 切换私有DNS。\n\n在关闭、自动和私有DNS提供者模式之间循环。 + 切换USB调试。\n\n直接从快速设置启用或禁用ADB调试访问。 + 启动取色器工具以拾取Android 17 BETA 2中引入的颜色 + 通过限制最大充电量或使用自适应充电来优化电池寿命。专为Pixel设备设计,以确保长寿命和健康的充电周期。\n\n致谢:TebbeUbben/ChargeQuickTile + 下载 - Screen Off - Screen On - Device Unlock - Charger Connected - Charger Disconnected - Schedule - Time Period - Select Time - Select Time Range - Start Time - End Time - Repeat on - Charging - Screen On - Vibrate - Show Notification - Remove Notification - Turn On Flashlight - Turn Off Flashlight - Toggle Flashlight - Turn On Low Power Mode - Turn Off Low Power Mode - Dim Wallpaper - This action requires Shizuku or Root to adjust system wallpaper dimming. - Select Trigger - App - Automate based on open app - Select State - Select Action - In Action - Out Action - Cancel - Save - Edit - Delete - Enable - Disable - Automation Service - Automations Active - Monitoring system events for your automations - Device Effects - Control system-level effects like grayscale, AOD suppression, wallpaper dimming, and night mode. - Grayscale - Suppress Ambient Display - Dim Wallpaper - Night Mode - This feature requires Android 15 or higher. - Enabled - Disabled - Sound Mode - This action allows switching between Sound, Vibrate, and Silent modes based on triggers. It requires Do Not Disturb access. + 屏幕关闭 + 屏幕开启 + 设备解锁 + 充电器连接 + 充电器断开 + 定时 + 时间段 + 选择时间 + 选择时间范围 + 开始时间 + 结束时间 + 重复于 + 充电中 + 屏幕开启 + 振动 + 显示通知 + 移除通知 + 打开手电筒 + 关闭手电筒 + 切换手电筒 + 开启低电量模式 + 关闭低电量模式 + 调暗壁纸 + 此操作需要Shizuku或Root权限来调整系统壁纸调暗。 + 选择触发器 + 应用 + 基于打开的应用自动化 + 选择状态 + 选择动作 + 进入动作 + 退出动作 + 取消 + 保存 + 编辑 + 删除 + 启用 + 禁用 + 自动化服务 + 自动化已激活 + 正在监控系统事件以执行您的自动化 + 设备效果 + 控制系统级效果,如灰度、AOD抑制、壁纸调暗和夜间模式。 + 灰度 + 抑制环境显示 + 调暗壁纸 + 夜间模式 + 此功能需要Android 15或更高版本。 + 已启用 + 已禁用 + 声音模式 + 此操作允许根据触发器在声音、振动和静音模式之间切换。需要勿扰权限。 Sameera Wijerathna - The all-in-one toolbox for your Pixel and Androids + 适用于Pixel和Android设备的一体化工具箱 - System - Custom - App specific + 系统 + 自定义 + 应用特定 - Authentication failed - Long press an app in the grid to add a shortcut - App not found or uninstalled + 认证失败 + 长按网格中的应用以添加快捷方式 + 应用未找到或已卸载 - App Updates - Notifications for new app updates - Update available - No devices connected - Unknown + 应用更新 + 新应用更新的通知 + 有可用更新 + 未连接设备 + 未知 5G 4G 3G Shizuku (Rikka) Shizuku (TuoZi) - Search - Required to hard-lock the device when unauthorized network changes are attempted on lock screen. - Authenticate to access settings - %1$s Settings - feature - settings - hide - show - visibility - Error loading apps: %1$s + 搜索 + 需要在锁屏上尝试未经授权的网络更改时硬锁定设备。 + 认证以访问设置 + %1$s 设置 + 功能 + 设置 + 隐藏 + 显示 + 可见性 + 加载应用时出错:%1$s - vibration - touch - feel + 振动 + 触摸 + 感觉 - network - visibility - auto - hide + 网络 + 可见性 + 自动 + 隐藏 - restore - default - icon + 恢复 + 默认 + 图标 - keyboard - height - padding - haptic - input + 键盘 + 高度 + 内边距 + 触觉 + 输入 - light - torch + + 手电筒 - light - torch - pulse - notification + + 手电筒 + 脉冲 + 通知 - awake - developer - power - charge + 唤醒 + 开发者 + 电源 + 充电 - glow - notification - led + 发光 + 通知 + LED - round - shape - edge + 圆角 + 形状 + 边缘 - secure - privacy - biometric - face - fingerprint + 安全 + 隐私 + 生物识别 + 面部 + 指纹 - sound - accessibility - hear + 声音 + 无障碍 + 听觉 - stay - on - timeout + 保持 + 开启 + 超时 - touch - wake - display + 触摸 + 唤醒 + 显示 - timer - wait - timeout + 计时器 + 等待 + 超时 - Always dark theme - Pitch black theme - Clipboard History - Long press for symbols - Accented characters + 始终深色主题 + 纯黑主题 + 剪贴板历史 + 长按显示符号 + 带重音字符 - list - picker - selection + 列表 + 选择器 + 选择 - animation - visual - look + 动画 + 视觉 + 外观 - quiet - ignore - filter + 安静 + 忽略 + 过滤 - automation - auto - lock + 自动化 + 自动 + 锁定 adb usb - debug + 调试 - blur - glass - vignette + 模糊 + 玻璃 + 渐晕 - float - window - overlay + 浮动 + 窗口 + 叠加 - always - display - clock + 始终 + 显示 + 时钟 - audio - mute - volume + 音频 + 静音 + 音量 - blue - filter - auto + 蓝光 + 滤除 + 自动 - freeze + 冻结 shizuku - manual - now + 手动 + 立即 shizuku - proximity - sensor - face - down + 接近 + 传感器 + 面部 + 朝下 - switch - master + 开关 + 总开关 - vibration - feel + 振动 + 感觉 - battery - charge - optimization + 电池 + 充电 + 优化 pixel - Invert selection - Show system apps + 反选 + 显示系统应用 - You are up to date - This is a pre-release version and might be unstable. - Release Notes v%1$s - View on GitHub - Download APK + 您已是最新版本 + 这是一个预发布版本,可能不稳定。 + 发布说明 v%1$s + 在GitHub上查看 + 下载APK - None - Subtle - Double - Click - Tick + + 轻微 + 双重 + 点击 + 滴答 - Turn Off - Flashlight Brightness + 关闭 + 手电筒亮度 - Unlock phone to change network settings + 解锁手机以更改网络设置 - Developed by %1$s\nwith ❤\uFE0F from \uD83C\uDDF1\uD83C\uDDF0 - Website - Contact + 由 %1$s 开发\n带着 ❤\uFE0F 来自 \uD83C\uDDF1\uD83C\uDDF0 + 网站 + 联系 Telegram - Support - Other Apps + 支持 + 其他应用 AirSync ZenZero Canvas Tasks Zero - Help & Guides - Need more support? Reach out, - Collapse - Expand - Support Group - Email - Send email - No email app available - Step %1$d Image + 帮助与指南 + 需要更多支持?请联系, + 折叠 + 展开 + 支持群组 + 电子邮件 + 发送邮件 + 没有可用的邮件应用 + 步骤 %1$d 图片 - Accessibility, Notification and Overlay permissions - You may get this access denied message if you try to grant sensitive permissions such as accessibility, notification listener or overlay permissions. To grant it, check the steps below. - 1. Go to app info page of Essentials. - 2. Open the 3-dot menu and select \'Allow restricted settings\'. You may have to authenticate with biometrics. Once done, Try to grant the permission again. + 无障碍、通知和悬浮窗权限 + 如果您尝试授予敏感权限(如无障碍、通知监听器或悬浮窗权限),可能会收到此访问被拒绝的消息。要授予它,请查看以下步骤。 + 1. 前往Essentials的应用信息页面。 + 2. 打开三点菜单并选择“允许受限设置”。您可能需要通过生物识别认证。完成后,再次尝试授予权限。 Shizuku - Shizuku is a powerful tool that allows apps to use system APIs directly with ADB or root permissions. It is required for features like Maps min mode, App Freezer. And willa ssist granting some permissions such as WRITE_SECURE_SETTINGS. \n\nBut the Play Store version of Shizuku might be outdated and will probably be unusable on recent Android versions so in that case, please get the latest version from the github or an up-to-date fork of it. - Maps power saving mode - This feature automatically triggers Google Maps power saving mode which is currently exclusive to the Pixel 10 series. A community member discovered that it is still usable on any Android device by launching the maps minMode activity with root privileges. \n\nAnd then, I had it automated with Tasker to automatically trigger when the screen turns off during a navigation session and then was able to achieve the same with just runtime Shizuku permissions. \n\nIt is intended to be shown over the AOD of Pixel 10 series so because of that, you may see an occasional message popping up on the display that it does not support landscape mode. That is not avoidable by the app and you can ignore. - Silent sound mode - You may have noticed that the silent mode also triggers DND. \n\nThis is due to how the Android implemented it as even if we use the same API to switch to vibrate mode, it for some reason turns on DND along with the silent mode and this is not avoidable at this moment. :( - What is freeze? - Pause and stay away from app distractions while saving a little bit of power preventing apps running in the background. Suitable for rarely used apps. \n\nNot recommended for any communication services as they will not notify you in an emergency unless you unfreeze them. \n\nHighly advised to not freeze system apps as they can lead to system instability. Proceed with caution, You were warned. \n\nInspired by Hail <3 - Are app lock and screen locked security actually secure? - Absolutely not. \n\nAny 3rd party application can not 100% interfere with regular device interactions and even the app lock is only an overlay above selected apps to prevent interacting with them. There are workarounds and it is not foolproof. \n\nSame goes with the screen locked security feature which detects someone trying to interact with the network tiles which for some reason are still accessible for anyone on Pixels. So if they try hard enough they might still be able to change them and especially if you have a flight mode QS tile added, this app can not prevent interactions with it. \n\nThese features are made just as experiments for light usage and would never recommend as strong security and privacy solutions. \n\nSecure alternatives:\n - App lock: Private Space and Secure folder on Pixels and Samsung\n - Preventing mobile networks access: Make sure your theft protection and offline/ power off find my device settings are on. You may look into Graphene OS as well. - Statusbar icons - You may notice that even after resetting the statusbar icons, Some icons such as device rotation, wired headphone icons may stay visible. This is due to how the statubar blacklist is implemented in Android and how your OEM may have customized them. \nYou may need further adjustments. \n\nAlso not all icon visibility options may work as they depend on the OEM implementations and availability. - Notification lighting does not work - It depends on the OEM. Some like OneUI does not seem to allow overlays above the AOD preventing the lighting effects being shown. In this case, try the ambient display as a workaround. - Button remap does not work while display is off - Some OEMs limit the accessibility service reporting once the display is actually off but they may still work while the AOD is on. \nIn this case, you may able to use button remaps with AOD on but not with off. \n\nAs a workaround, you will need to use Shizuku permissions and turn on the \'Use Shizuku\' toggle in button remap settings which identifies and listen to hardware input events.\nThis is not guaranteed to work on all devices and needs testing.\n\nAnd even if it\'s on, Shizuku method only will be used when it\'s needed. Otherwise it will always fallback to Accessibility which also handles the blocking of the actual input during long press. - Flashlight brightness does not work - Only a limited number of devices got hardware and software support adjusting the flashlight intensity. \n\n\'The minimum version of Android is 13 (SDK33).\nFlashlight brightness control only supports HAL version 3.8 and higher, so among the supported devices, the latest ones (For example, Pixel 6/7, Samsung S23, etc.)\'\npolodarb/Flashlight-Tiramisu - What the hell is this app? - Good question,\n\nI always wanted to extract the most out of my devices as I\'ve been a rooted user for ever since I got my first Project Treble device. And I\'ve been loving the Tasker app which is like the god when comes automation and utilizing every possible API and internal features of Android.\n\nSo I am not unrooted and back on stock Android beta experience and wanted to get the most out from what is possible with given privileges. Might as well share them. So with my beginner knowledge in Kotlin Jetpack and with the support of many research and assist tools and also the great community, I built an all-in-one app containing everything I wanted to be in my Android with given permissions. And here it is.\n\nFeature requests are welcome, I will consider and see if they are achievable with available permissions and my skills. Nowadays what is not possible. :)\n\nWhy not on Play Store?\nI don\'t wanna risk getting my Developer account banned due to the highly sensitive and internal permissions and APIs being used in the app. But with the way Android sideloading is headed, let\'s see what we have to do. I do understand the concerns of sideloaded apps being malicious.\nWhile we are at the topic, Checkout my other app AirSync if you are a mac + Android user. *shameless plug*\n\nEnjoy, Keep building! (っ◕‿◕)っ + Shizuku是一个强大的工具,允许应用通过ADB或root权限直接使用系统API。地图最小模式、应用冻结等功能需要它。它还将协助授予某些权限,例如WRITE_SECURE_SETTINGS。\n\n但Play Store版本的Shizuku可能已过时,在较新的Android版本上可能无法使用,因此在这种情况下,请从github或最新的分支获取最新版本。 + 地图省电模式 + 此功能自动触发Google Maps省电模式,该模式目前是Pixel 10系列独有的。一位社区成员发现,通过root权限启动maps minMode活动,它仍然可以在任何Android设备上使用。\n\n然后,我使用Tasker将其自动化,在导航期间屏幕关闭时自动触发,然后通过运行时Shizuku权限实现了相同的效果。\n\n它旨在显示在Pixel 10系列的AOD上,因此,您可能会偶尔看到显示屏上弹出不支持横屏模式的消息。这是应用无法避免的,您可以忽略。 + 静音声音模式 + 您可能已经注意到静音模式也会触发勿扰模式。\n\n这是由于Android实现的方式,即使我们使用相同的API切换到振动模式,由于某些原因,它也会在静音模式下同时打开勿扰模式,目前这是无法避免的。:( + 什么是冻结? + 暂停并远离应用干扰,同时通过阻止应用在后台运行来节省一点电量。适用于不常用的应用。\n\n不建议用于任何通讯服务,因为除非您解冻它们,否则它们在紧急情况下不会通知您。\n\n强烈建议不要冻结系统应用,因为它们可能导致系统不稳定。请谨慎操作,您已被警告。\n\n灵感来自Hail <3 + 应用锁和锁屏安全真的安全吗? + 绝对不安全。\n\n任何第三方应用都无法100%干预常规设备交互,即使应用锁也只是在选定应用上方的一个覆盖层,以防止与它们交互。存在解决方法,并非万无一失。\n\n锁屏安全功能也是如此,它检测到有人试图与网络磁贴交互(由于某些原因,在Pixel上这些磁贴对任何人都可访问)。因此,如果他们足够努力,仍然可能更改它们,特别是如果您添加了飞行模式QS磁贴,此应用无法阻止与其交互。\n\n这些功能只是作为轻量级使用的实验而制作,绝不推荐作为强大的安全和隐私解决方案。\n\n安全的替代方案:\n- 应用锁:Pixel和三星上的私人空间和安全文件夹\n- 防止移动网络访问:确保您的防盗保护和离线/关机查找设备设置已开启。您也可以考虑Graphene OS。 + 状态栏图标 + 您可能会注意到,即使重置了状态栏图标,某些图标如设备旋转、有线耳机图标可能仍然可见。这是由于Android中状态栏黑名单的实现方式以及您的OEM可能对它们进行了自定义。\n您可能需要进一步调整。\n\n另外,并非所有图标可见性选项都能正常工作,因为它们取决于OEM的实现和可用性。 + 通知光效不起作用 + 这取决于OEM。像OneUI似乎不允许在AOD上显示叠加层,从而阻止显示光效。在这种情况下,请尝试使用环境显示作为解决方法。 + 按键重映射在屏幕关闭时不起作用 + 一些OEM限制无障碍服务在屏幕实际关闭后报告,但它们可能在AOD开启时仍然有效。\n在这种情况下,您可能可以在AOD开启时使用按键重映射,但关闭时不行。\n\n作为解决方法,您需要使用Shizuku权限并打开按键重映射设置中的“使用Shizuku”开关,该开关识别并监听硬件输入事件。\n这不能保证在所有设备上有效,需要测试。\n\n即使开启了,Shizuku方法也仅在需要时使用。否则它将始终回退到无障碍服务,后者也处理长按期间对实际输入的阻止。 + 手电筒亮度不起作用 + 只有有限数量的设备在硬件和软件上支持调节手电筒强度。\n\n“最低Android版本为13(SDK33)。\n手电筒亮度控制仅支持HAL版本3.8及更高版本,因此在支持的设备中,最新的设备(例如Pixel 6/7、Samsung S23等)可以。”\npolodarb/Flashlight-Tiramisu + 这到底是什么应用? + 好问题,\n\n我一直想从我的设备中榨取最多功能,因为我自从拥有第一个Project Treble设备以来就一直是root用户。我一直很喜欢Tasker应用,它在自动化和利用Android的每一个可能的API和内部功能方面就像神一样。\n\n现在我未root并回到了原生Android beta体验,并希望在给定权限下尽可能多地实现可能的功能。不妨分享出来。因此,凭借我在Kotlin Jetpack方面的初级知识以及许多研究、辅助工具和优秀社区的支持,我构建了一个一体化应用,包含了我希望在Android中拥有的所有功能,并给予相应权限。这就是它。\n\n欢迎提出功能请求,我会考虑并看看是否可以在现有权限和我的技能范围内实现。如今有什么是不可能的呢。:)\n\n为什么不在Play Store上?\n我不想因为应用中使用了高度敏感和内部的权限及API而冒被封禁开发者帐户的风险。但随着Android侧载的发展,让我们看看我们必须做什么。我理解侧载应用可能是恶意的担忧。\n\n顺便说一下,如果您是mac + Android用户,请查看我的另一个应用AirSync。*无耻的广告*\n\n享受,继续构建!(っ◕‿◕)っ - Bug report copied to clipboard - Bug report - Share logs - Include logs and details - Device Info - Raw Report - Open GitHub Issue - Email Report - Copy to Clipboard - Essentials Bug Report - Send via + Bug报告已复制到剪贴板 + Bug报告 + 分享日志 + 包含日志和详细信息 + 设备信息 + 原始报告 + 在GitHub上打开问题 + 邮件报告 + 复制到剪贴板 + Essentials Bug报告 + 通过以下方式发送 - Are We There Yet? - Prepare for your destination. - Open your map app, pick a location, and share it to Essentials. - Radius: %d m - Location - Used to detect arrival at your destination. - Background Location - Required to monitor your arrival while the app is closed or the screen is off. - Destination Reached! - You have arrived at your destination. - Processing location… - DISTANCE REMAINING - Calculating… - Stop Tracking - Destination Ready - Start Tracking - View Map - Clear - No Destination - Open Maps - Full-Screen Alarm Permission - Required to wake your device upon arrival. Tap to grant. - %1$d m - %1$.1f km - Travel Alarm active - %1$s remaining - Travel Progress - Shows real-time distance to destination - Destination Nearby - Prepare to get off - Dismiss - Destination set: %1$.4f, %2$.4f - Use Root - Instead of Shizuku - Root access not available. Please check your root manager. + 我们到了吗? + 为您的目的地做好准备。 + 打开您的地图应用,选择一个位置,然后分享到Essentials。 + 半径:%d 米 + 位置 + 用于检测到达目的地。 + 后台位置 + 需要在应用关闭或屏幕关闭时监控您的到达情况。 + 已到达目的地! + 您已到达目的地。 + 正在处理位置… + 剩余距离 + 计算中… + 停止跟踪 + 目的地就绪 + 开始跟踪 + 查看地图 + 清除 + 无目的地 + 打开地图 + 全屏闹钟权限 + 需要在到达时唤醒您的设备。点击授予。 + %1$d 米 + %1$.1f 公里 + 旅行闹钟活跃 + 剩余 %1$s + 行程进度 + 显示到目的地的实时距离 + 目的地临近 + 准备下车 + 关闭 + 目的地已设置:%1$.4f,%2$.4f + 使用Root + 替代Shizuku + Root权限不可用。请检查您的root管理器。 - Keyboard - Keys - Customize layout and behavior - Keyboard Height - Adjust the total vertical size of the keyboard - Bottom Padding - Add space below the keyboard - Haptic Feedback - Vibrate on key press - Test the keyboard - Keyboard Height - Bottom Padding - Haptic Feedback - Key Roundness - Move functions to bottom - Functions side padding - Haptic feedback strength - Keyboard shape - Round - Flat - Inverse - Batteries - Monitor your device battery levels - Battery Status - Connect to AirSync - Display battery from your connected mac device in AirSync - Download AirSync App - Required for Mac battery sync - Battery notification - Persistent battery status notification - This notification displays battery levels for your connected Mac and Bluetooth devices. You can configure which devices to show in the Battery Widget settings. - Replicate the battery widget experience in your notification shade. It will show the battery levels of all your connected devices in a single persistent notification, updated in real-time. This includes your Mac (via AirSync) and Bluetooth accessories. - Battery Status Notification - Persistent notification showing connected devices battery levels - Nearby Devices - Required to detect and retrieve battery information from Bluetooth accessories + 键盘 + 按键 + 自定义布局和行为 + 键盘高度 + 调整键盘的总体垂直大小 + 底部内边距 + 在键盘下方添加空间 + 触觉反馈 + 按键时振动 + 测试键盘 + 键盘高度 + 底部内边距 + 触觉反馈 + 按键圆度 + 将功能键移到底部 + 功能键侧边距 + 触觉反馈强度 + 键盘形状 + 圆形 + 扁平 + 倒置 + 电池 + 监控您的设备电池电量 + 电池状态 + 连接到AirSync + 显示AirSync中连接的Mac设备的电池电量 + 下载AirSync应用 + Mac电池同步所需 + 电池通知 + 持久电池状态通知 + 此通知显示已连接的Mac和蓝牙设备的电池电量。您可以在电池小组件设置中配置要显示哪些设备。 + 在通知栏中复制电池小组件体验。它将在单个持久通知中显示所有已连接设备的实时电池电量。这包括您的Mac(通过AirSync)和蓝牙配件。 + 电池状态通知 + 显示已连接设备电池电量的持久通知 + 附近设备 + 需要检测和检索蓝牙配件的电池信息 - Copy code - Open login page - Sign in to extend API call limits - Waiting for authorization... - Sign in with GitHub - Sign out - Profile + 复制代码 + 打开登录页面 + 登录以扩展API调用限制 + 等待授权... + 使用GitHub登录 + 登出 + 个人资料 - Release Notes - No repositories tracked yet - No app linked - Updated %1$s + 发布说明 + 尚未跟踪任何仓库 + 未关联应用 + 更新于 %1$s - just now - Today - Yesterday - %1$dm ago - %1$dh ago - %1$dd ago - %1$d days ago - %1$d weeks ago - %1$dmo ago - %1$d months ago - %1$dy ago - Retry - Start Sign In - Requesting device code... - 1. Copy your code: - 2. Paste the code on GitHub: - Found APKs + 刚刚 + 今天 + 昨天 + %1$d分钟前 + %1$d小时前 + %1$d天前 + %1$d天前 + %1$d周前 + %1$d个月前 + %1$d个月前 + %1$d年前 + 重试 + 开始登录 + 正在请求设备代码... + 1. 复制您的代码: + 2. 在GitHub上粘贴代码: + 找到APK README - Refresh + 刷新 - Sound mode tile - QS tile to toggle sound mode - Show slider - Show volume slider in tile - Cycle Behavior - Choose modes to cycle through - Ambient music glance - Glance at media on AOD - Sound and Haptics - Volume and haptic features - Security and Privacy - Protect and secure your device - Notifications and Alerts - Never miss your priorities - Input and Actions - Control your device with ease - Widgets - At a glance on your home screen - Display - Visuals to enhance your experience - Watch - Integrations with WearOS - No Watch detected - It looks like you do not have the Essentials Wear companion app installed on your watch. - Install Companion - Interaction - Interface - Display - Protection + 声音模式磁贴 + 用于切换声音模式的QS磁贴 + 显示滑块 + 在磁贴中显示音量滑块 + 循环行为 + 选择要循环的模式 + 环境音乐查看 + 在AOD上查看媒体 + 声音和触觉 + 音量和触觉功能 + 安全和隐私 + 保护您的设备 + 通知和提醒 + 不错过您的重要事项 + 输入和操作 + 轻松控制您的设备 + 小组件 + 主屏幕上的概览 + 显示 + 增强体验的视觉效果 + 手表 + 与WearOS的集成 + 未检测到手表 + 看起来您的手表上未安装Essentials Wear配套应用。 + 安装配套应用 + 交互 + 界面 + 显示 + 保护 ABC \?#/ - Kaomoji - Joy - Love - Embarassment - Sympathy - Dissatisfaction - Anger - Apologizing - Bear - Bird - Cat - Confusion - Dog - Doubt - Enemies - Faces - Fear - Fish - Food - Friends - Games - Greeting - Hiding - Hugging - Indifference - Magic - Music - Nosebleeding - Pain - Pig - Rabbit - Running - Sadness - Sleeping - Special - Spider - Surprise - Weapons - Winking - Writing - Oi! You can check updates in app settings, No need to add here XD - Export - Import - Repositories exported successfully - Failed to export repositories - Repositories imported successfully - Failed to import repositories - Apps - Scale and Animations - Adjust system scale and animations - Text - Font Scale - Font Weight - Reset - Scale - Smallest Width - Shizuku permission required to adjust scale - Grant Permission - Animations - Animator duration scale - Transition animation scale - Window animation scale - Adjust system-wide font scale, weight, and animation speeds. Note that some settings may require advanced permissions or a device reboot for certain apps to reflect changes. \n\nAdditional shizuku or root permission may be necessary for scale adjustments - Force turn off AOD - Force turn off the AOD when no notifications. Requires accessibility permission. - Auto accessibility - Automatically grants the accessibility permission on app launch if missing using WRITE_SECURE_SETTINGS. - Help and Guides - Your Android - Storage - Memory - Use blur - Enable progressive blur elements across the UI - Blur is disabled on this device to prevent a known display bug on Samsung devices with Android 15 or below. + 颜文字 + 喜悦 + 爱意 + 尴尬 + 同情 + 不满 + 愤怒 + 道歉 + + + + 困惑 + + 怀疑 + 敌人 + 面孔 + 恐惧 + + 食物 + 朋友 + 游戏 + 问候 + 隐藏 + 拥抱 + 冷漠 + 魔法 + 音乐 + 流鼻血 + 痛苦 + + 兔子 + 奔跑 + 悲伤 + 睡觉 + 特殊 + 蜘蛛 + 惊讶 + 武器 + 眨眼 + 写作 + 喂!您可以在应用设置中检查更新,无需在此添加 XD + 导出 + 导入 + 仓库导出成功 + 导出仓库失败 + 仓库导入成功 + 导入仓库失败 + 应用 + 缩放和动画 + 调整系统缩放和动画 + 文本 + 字体缩放 + 字体粗细 + 重置 + 缩放 + 最小宽度 + 需要Shizuku权限来调整缩放 + 授予权限 + 动画 + 动画时长缩放 + 过渡动画缩放 + 窗口动画缩放 + 调整系统范围的字体缩放、粗细和动画速度。请注意,某些设置可能需要高级权限或设备重启才能让某些应用反映更改。\n\n缩放调整可能需要额外的shizuku或root权限 + 强制关闭AOD + 无通知时强制关闭AOD。需要无障碍权限。 + 自动无障碍 + 在应用启动时如果缺少无障碍权限,使用WRITE_SECURE_SETTINGS自动授予。 + 帮助和指南 + 您的Android + 存储 + 内存 + 使用模糊 + 启用界面中的渐进模糊元素 + 此设备上已禁用模糊,以防止在Android 15或更低版本的三星设备上出现已知的显示错误。 - No apps selected to freeze. - Get Started - New Automation - Add Repository + 未选择要冻结的应用。 + 开始使用 + 新建自动化 + 添加仓库 - Describe the issue or provide feedback… - Contact email - Send Feedback - Feedback sent successfully! Thanks for helping us improve the app. - Alternatively + 描述问题或提供反馈… + 联系邮箱 + 发送反馈 + 反馈已成功发送!感谢您帮助我们改进应用。 + 或者 - Diagnostics - Device Check - Get ready to be flashbanged! - Abort - Continue + 诊断 + 设备检查 + 准备好被闪光弹闪瞎吧! + 中止 + 继续 Your Essentials trial has expired Your free trial period of Essentials has ended. Access to advanced features like Button Remap, App Freezing, and DIY Automations with Premium. What\'s in Premium? From 579c11191be03f9658e44a00a915e4ae809ff041 Mon Sep 17 00:00:00 2001 From: "sameerasw.com" Date: Wed, 8 Apr 2026 14:16:35 +0530 Subject: [PATCH 04/23] New translations strings.xml (Russian) --- app/src/main/res/values-ru/strings.xml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b4c67b268..f99ee4991 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1101,15 +1101,15 @@ Обновлено %1$s прямо сейчас - Today - Yesterday + Сегодня + Вчера %1$dм назад %1$dчас назад %1$dдень назад - %1$d days ago - %1$d weeks ago + %1$d дней назад + %1$d недель назад %1$dмесяц назад - %1$d months ago + %1$d месяцев назад %1$dгод назад Повторить попытку Начать вход @@ -1237,11 +1237,11 @@ Отзыв отправлен! Спасибо, что помогаете улучшить приложение. Альтернатива - Diagnostics - Device Check - Get ready to be flashbanged! - Abort - Continue + Диагностика + Проверка устройства + Приготовься к вспышке! + Отменить + Продолжить Your Essentials trial has expired Your free trial period of Essentials has ended. Access to advanced features like Button Remap, App Freezing, and DIY Automations with Premium. What\'s in Premium? From 51f48322c927e93dd8dc7cd67faa5ac713c3ee53 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 11 Apr 2026 03:07:18 +0530 Subject: [PATCH 05/23] Update adi-registration.properties --- app/src/main/assets/adi-registration.properties | 1 + 1 file changed, 1 insertion(+) create mode 100644 app/src/main/assets/adi-registration.properties diff --git a/app/src/main/assets/adi-registration.properties b/app/src/main/assets/adi-registration.properties new file mode 100644 index 000000000..45d3acf3c --- /dev/null +++ b/app/src/main/assets/adi-registration.properties @@ -0,0 +1 @@ +DECYGOOZI3G4GAAAAAAAAAAAAA \ No newline at end of file From cb7ea60524b7c79fbdac2c24a7cea0ab40a82684 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 11 Apr 2026 15:50:50 +0530 Subject: [PATCH 06/23] feat: add option to use usage access for App Lock and implement AppLockUsageService --- app/src/main/AndroidManifest.xml | 9 + .../sameerasw/essentials/AppLockActivity.kt | 11 +- .../essentials/FeatureSettingsActivity.kt | 4 +- .../data/repository/SettingsRepository.kt | 1 + .../essentials/domain/model/Feature.kt | 2 +- .../domain/registry/FeatureRegistry.kt | 11 +- .../domain/registry/PermissionRegistry.kt | 1 + .../services/AppLockUsageService.kt | 159 ++++++++++++++++++ .../services/handlers/AppFlowHandler.kt | 41 +++-- .../composables/configs/AppLockSettingsUI.kt | 17 +- .../configs/QuickSettingsTilesSettingsUI.kt | 3 +- .../essentials/utils/PermissionUIHelper.kt | 3 +- .../essentials/viewmodels/MainViewModel.kt | 40 ++++- app/src/main/res/values/strings.xml | 6 + 14 files changed, 281 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/services/AppLockUsageService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f6e40f793..524595493 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -487,6 +487,15 @@ android:value="Automation Service" /> + + + + = 34) { overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, 0, 0) @@ -174,6 +176,11 @@ class AppLockActivity : AppCompatActivity() { } private fun notifyFailureAndFinish() { + val intent = Intent("APP_AUTHENTICATION_FAILED").apply { + `package` = packageName + } + sendBroadcast(intent) + val serviceIntent = Intent(this, ScreenOffAccessibilityService::class.java).apply { action = "APP_AUTHENTICATION_FAILED" } diff --git a/app/src/main/java/com/sameerasw/essentials/FeatureSettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/FeatureSettingsActivity.kt index 33f9457fc..697bd72d3 100644 --- a/app/src/main/java/com/sameerasw/essentials/FeatureSettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/FeatureSettingsActivity.kt @@ -245,7 +245,7 @@ class FeatureSettingsActivity : AppCompatActivity() { "Dynamic night light" -> !isAccessibilityEnabled || !isWriteSecureSettingsEnabled "Snooze system notifications" -> !isNotificationListenerEnabled "Screen locked security" -> !isAccessibilityEnabled || !isWriteSecureSettingsEnabled || !viewModel.isDeviceAdminEnabled.value - "App lock" -> !isAccessibilityEnabled + "App lock" -> if (viewModel.isAppLockUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else !isAccessibilityEnabled "Freeze" -> !com.sameerasw.essentials.utils.ShellUtils.hasPermission( context ) @@ -376,7 +376,7 @@ class FeatureSettingsActivity : AppCompatActivity() { "Dynamic night light" -> !isAccessibilityEnabled || !isWriteSecureSettingsEnabled "Snooze system notifications" -> !isNotificationListenerEnabled "Screen locked security" -> !isAccessibilityEnabled || !isWriteSecureSettingsEnabled || !viewModel.isDeviceAdminEnabled.value - "App lock" -> !isAccessibilityEnabled + "App lock" -> if (viewModel.isAppLockUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else !isAccessibilityEnabled "Freeze" -> !com.sameerasw.essentials.utils.ShellUtils.hasPermission( context ) diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index feb47ce40..cc795ad0e 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -95,6 +95,7 @@ class SettingsRepository(private val context: Context) { const val KEY_APP_LOCK_ENABLED = "app_lock_enabled" const val KEY_APP_LOCK_SELECTED_APPS = "app_lock_selected_apps" + const val KEY_APP_LOCK_USE_USAGE_ACCESS = "app_lock_use_usage_access" const val KEY_FREEZE_WHEN_LOCKED_ENABLED = "freeze_when_locked_enabled" const val KEY_FREEZE_LOCK_DELAY_INDEX = "freeze_lock_delay_index" diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/Feature.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/Feature.kt index 77c06fff9..c5c121ea4 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/model/Feature.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/Feature.kt @@ -27,7 +27,7 @@ abstract class Feature( @DrawableRes val iconRes: Int, @StringRes val category: Int, @StringRes val description: Int, - val permissionKeys: List = emptyList(), + open val permissionKeys: List = emptyList(), val searchableSettings: List = emptyList(), val showToggle: Boolean = true, val hasMoreSettings: Boolean = true, diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt index ffd570a7b..6b0a292df 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt @@ -700,7 +700,6 @@ object FeatureRegistry { category = R.string.cat_protection, description = R.string.feat_app_lock_desc, aboutDescription = R.string.about_desc_app_lock, - permissionKeys = listOf("ACCESSIBILITY"), searchableSettings = listOf( SearchSetting( R.string.search_app_lock_enable_title, @@ -717,9 +716,17 @@ object FeatureRegistry { ), parentFeatureId = "Security" ) { + override val permissionKeys: List + get() = if (com.sameerasw.essentials.data.repository.SettingsRepository(com.sameerasw.essentials.EssentialsApp.context) + .getBoolean(com.sameerasw.essentials.data.repository.SettingsRepository.KEY_APP_LOCK_USE_USAGE_ACCESS)) + listOf("USAGE_STATS") else listOf("ACCESSIBILITY") + override fun isEnabled(viewModel: MainViewModel) = viewModel.isAppLockEnabled.value override fun isToggleEnabled(viewModel: MainViewModel, context: Context) = - viewModel.isAccessibilityEnabled.value + if (viewModel.isAppLockUseUsageAccess.value) + viewModel.isUsageStatsPermissionGranted.value + else + viewModel.isAccessibilityEnabled.value override fun onToggle(viewModel: MainViewModel, context: Context, enabled: Boolean) = viewModel.setAppLockEnabled(enabled, context) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt index d42075f3f..0eda516de 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt @@ -37,6 +37,7 @@ fun initPermissionRegistry() { PermissionRegistry.register("SHIZUKU", R.string.feat_freeze_title) PermissionRegistry.register("SHIZUKU", R.string.feat_maps_power_saving_title) PermissionRegistry.register("USAGE_STATS", R.string.feat_freeze_title) + PermissionRegistry.register("USAGE_STATS", R.string.feat_app_lock_title) PermissionRegistry.register("NOTIFICATION_LISTENER", R.string.feat_freeze_title) // Root permission diff --git a/app/src/main/java/com/sameerasw/essentials/services/AppLockUsageService.kt b/app/src/main/java/com/sameerasw/essentials/services/AppLockUsageService.kt new file mode 100644 index 000000000..6e84b2ebe --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/AppLockUsageService.kt @@ -0,0 +1,159 @@ +package com.sameerasw.essentials.services + +import android.app.* +import android.app.usage.UsageStats +import android.app.usage.UsageStatsManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.util.Log +import androidx.core.app.NotificationCompat +import com.sameerasw.essentials.AppLockActivity +import com.sameerasw.essentials.R +import com.sameerasw.essentials.services.handlers.AppFlowHandler +import java.util.* + +class AppLockUsageService : Service() { + + private lateinit var appFlowHandler: AppFlowHandler + private val handler = Handler(Looper.getMainLooper()) + private var isPolling = false + private var lastPackageName: String? = null + + companion object { + private const val CHANNEL_ID = "app_lock_service_channel" + private const val NOTIFICATION_ID = 1001 + private const val POLL_INTERVAL = 500L + var isRunning = false + } + + private val authReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + "APP_AUTHENTICATED" -> { + val packageName = intent.getStringExtra("package_name") + if (packageName != null) { + appFlowHandler.onAuthenticated(packageName) + } + } + "APP_AUTHENTICATION_FAILED" -> { + goHome() + } + } + } + } + + override fun onCreate() { + super.onCreate() + isRunning = true + appFlowHandler = AppFlowHandler(this) + createNotificationChannel() + + val filter = IntentFilter().apply { + addAction("APP_AUTHENTICATED") + addAction("APP_AUTHENTICATION_FAILED") + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(authReceiver, filter, RECEIVER_EXPORTED) + } else { + registerReceiver(authReceiver, filter) + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startForeground( + NOTIFICATION_ID, + createNotification(), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE else 0 + ) + + if (!isPolling) { + isPolling = true + startPolling() + } + + return START_STICKY + } + + private fun startPolling() { + handler.postDelayed(object : Runnable { + override fun run() { + if (!isPolling) return + + val currentPackage = getForegroundPackage() + if (currentPackage != null && currentPackage != lastPackageName) { + lastPackageName = currentPackage + appFlowHandler.onPackageChanged(currentPackage, isFromUsageStats = true) + } + + handler.postDelayed(this, POLL_INTERVAL) + } + }, POLL_INTERVAL) + } + + private fun getForegroundPackage(): String? { + val usageStatsManager = getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager + val time = System.currentTimeMillis() + val stats = usageStatsManager.queryUsageStats(UsageStatsManager.INTERVAL_DAILY, time - 1000 * 10, time) + + if (stats == null || stats.isEmpty()) return null + + var recentStats: UsageStats? = null + for (usageStats in stats) { + if (recentStats == null || usageStats.lastTimeUsed > recentStats.lastTimeUsed) { + recentStats = usageStats + } + } + + return recentStats?.packageName + } + + private fun goHome() { + val homeIntent = Intent(Intent.ACTION_MAIN) + homeIntent.addCategory(Intent.CATEGORY_HOME) + homeIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + startActivity(homeIntent) + } + + override fun onDestroy() { + isRunning = false + isPolling = false + handler.removeCallbacksAndMessages(null) + try { + unregisterReceiver(authReceiver) + } catch (_: Exception) {} + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.app_lock_service_channel_name), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = getString(R.string.app_lock_service_running_desc) + } + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + } + + private fun createNotification(): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.app_lock_service_running_title)) + .setContentText(getString(R.string.app_lock_service_running_desc)) + .setSmallIcon(R.drawable.rounded_shield_lock_24) + .setOngoing(true) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .build() + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt index 2c7af4558..f576dd24e 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt @@ -18,7 +18,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class AppFlowHandler( - private val service: AccessibilityService + private val context: Context, + private val service: AccessibilityService? = null ) { private val handler = Handler(Looper.getMainLooper()) private val scope = CoroutineScope(Dispatchers.Main) @@ -44,14 +45,23 @@ class AppFlowHandler( "com.google.android.inputmethod.latin" ) - fun onPackageChanged(packageName: String) { - if (packageName != service.packageName && packageName != lockingPackage) { + fun onPackageChanged(packageName: String, isFromUsageStats: Boolean = false) { + val prefs = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) + val useUsageAccess = prefs.getBoolean("app_lock_use_usage_access", false) + + if (packageName != context.packageName && packageName != lockingPackage) { lockingPackage = null } - checkAppLock(packageName) - checkHighlightNightLight(packageName) - checkAppAutomations(packageName) + if (isFromUsageStats == useUsageAccess) { + checkAppLock(packageName) + } + + // Night light and automations still require Accessibility + if (!isFromUsageStats) { + checkHighlightNightLight(packageName) + checkAppAutomations(packageName) + } } fun onAuthenticated(packageName: String) { @@ -66,11 +76,11 @@ class AppFlowHandler( } private fun checkAppLock(packageName: String) { - val prefs = service.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) + val prefs = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) val isEnabled = prefs.getBoolean("app_lock_enabled", false) if (!isEnabled) return - if (packageName == service.packageName) { + if (packageName == context.packageName) { return } @@ -102,18 +112,18 @@ class AppFlowHandler( "App $packageName is locked and not authenticated. Showing lock screen." ) val intent = Intent().apply { - component = ComponentName(service, "com.sameerasw.essentials.AppLockActivity") + component = ComponentName(context, "com.sameerasw.essentials.AppLockActivity") putExtra("package_to_lock", packageName) flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION } - service.startActivity(intent) + context.startActivity(intent) } } private fun checkHighlightNightLight(packageName: String) { - val prefs = service.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) + val prefs = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) val isEnabled = prefs.getBoolean("dynamic_night_light_enabled", false) - if (!isEnabled) return + if (!isEnabled || service == null) return pendingNLRunnable?.let { handler.removeCallbacks(it) } @@ -130,7 +140,7 @@ class AppFlowHandler( } private fun processNightLightChange(packageName: String) { - val prefs = service.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) + val prefs = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) val json = prefs.getString("dynamic_night_light_selected_apps", null) val selectedApps: List = if (json != null) { @@ -167,7 +177,7 @@ class AppFlowHandler( private fun isNightLightEnabled(): Boolean { return try { - Settings.Secure.getInt(service.contentResolver, "night_display_activated", 0) == 1 + Settings.Secure.getInt(context.contentResolver, "night_display_activated", 0) == 1 } catch (_: Exception) { false } @@ -176,7 +186,7 @@ class AppFlowHandler( private fun setNightLightEnabled(enabled: Boolean) { try { Settings.Secure.putInt( - service.contentResolver, + context.contentResolver, "night_display_activated", if (enabled) 1 else 0 ) @@ -189,6 +199,7 @@ class AppFlowHandler( } private fun checkAppAutomations(packageName: String) { + if (service == null) return scope.launch { val automations = DIYRepository.automations.value val appAutomations = diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/AppLockSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/AppLockSettingsUI.kt index 5663fc007..07bd03f6a 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/AppLockSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/AppLockSettingsUI.kt @@ -37,7 +37,11 @@ fun AppLockSettingsUI( var isAppSelectionSheetOpen by remember { mutableStateOf(false) } val isAppLockEnabled by viewModel.isAppLockEnabled + val isAppLockUseUsageAccess by viewModel.isAppLockUseUsageAccess val isAccessibilityEnabled by viewModel.isAccessibilityEnabled + val isUsageStatsPermissionGranted by viewModel.isUsageStatsPermissionGranted + + val canEnableAppLock = if (isAppLockUseUsageAccess) isUsageStatsPermissionGranted else isAccessibilityEnabled Column( modifier = modifier @@ -75,11 +79,22 @@ fun AppLockSettingsUI( viewModel.setAppLockEnabled(enabled, context) } }, - enabled = isAccessibilityEnabled, + enabled = canEnableAppLock, onDisabledClick = {}, modifier = Modifier.highlight(highlightKey == "app_lock_enabled") ) + IconToggleItem( + iconRes = R.drawable.rounded_touch_app_24, + title = stringResource(R.string.app_lock_use_usage_access_title), + isChecked = isAppLockUseUsageAccess, + description = stringResource(R.string.app_lock_use_usage_access_desc), + onCheckedChange = { enabled -> + viewModel.setAppLockUseUsageAccess(enabled, context) + }, + modifier = Modifier.highlight(highlightKey == "app_lock_use_usage_access") + ) + FeatureCard( title = stringResource(R.string.app_lock_select_apps_title), description = stringResource(R.string.app_lock_select_apps_desc), diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt index 09f8213c7..ddec9c8e3 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt @@ -101,6 +101,7 @@ fun QuickSettingsTilesSettingsUI( var selectedHelpTile by remember { mutableStateOf(null) } + val isAppLockUseUsageStats by viewModel.isAppLockUseUsageAccess val tiles = listOf( QSTileInfo( R.string.tile_ui_blur, @@ -178,7 +179,7 @@ fun QuickSettingsTilesSettingsUI( R.string.tile_app_lock, R.drawable.rounded_shield_lock_24, AppLockTileService::class.java, - listOf("ACCESSIBILITY"), + if (isAppLockUseUsageStats) listOf("USAGE_STATS") else listOf("ACCESSIBILITY"), R.string.about_desc_app_lock ), QSTileInfo( diff --git a/app/src/main/java/com/sameerasw/essentials/utils/PermissionUIHelper.kt b/app/src/main/java/com/sameerasw/essentials/utils/PermissionUIHelper.kt index 4a42e5c94..312822a44 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/PermissionUIHelper.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/PermissionUIHelper.kt @@ -225,7 +225,8 @@ object PermissionUIHelper { "USAGE_STATS" -> PermissionItem( iconRes = R.drawable.rounded_data_usage_24, title = R.string.perm_usage_stats_title, - description = R.string.perm_usage_stats_desc, + description = if (viewModel.isAppLockUseUsageAccess.value) + R.string.perm_usage_stats_desc_app_lock else R.string.perm_usage_stats_desc, dependentFeatures = PermissionRegistry.getFeatures("USAGE_STATS"), actionLabel = R.string.perm_action_grant, action = { PermissionUtils.openUsageStatsSettings(context) }, diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index c3cb2db9c..72ebcfdd3 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -13,6 +13,7 @@ import android.content.IntentFilter import android.content.pm.PackageManager import android.database.ContentObserver import android.net.Uri +import android.os.Build import android.os.Handler import android.os.Looper import android.os.PowerManager @@ -153,6 +154,7 @@ class MainViewModel : ViewModel() { mutableStateOf(setOf(NotificationLightingSide.LEFT, NotificationLightingSide.RIGHT)) val skipPersistentNotifications = mutableStateOf(false) val isAppLockEnabled = mutableStateOf(false) + val isAppLockUseUsageAccess = mutableStateOf(false) val isFreezeWhenLockedEnabled = mutableStateOf(false) val freezeLockDelayIndex = mutableIntStateOf(1) // Default: 1 minute val freezePickedApps = mutableStateOf>(emptyList()) @@ -289,8 +291,15 @@ class MainViewModel : ViewModel() { SettingsRepository.KEY_BUTTON_REMAP_ENABLED -> isButtonRemapEnabled.value = settingsRepository.getBoolean(key) - SettingsRepository.KEY_APP_LOCK_ENABLED -> isAppLockEnabled.value = - settingsRepository.getBoolean(key) + SettingsRepository.KEY_APP_LOCK_ENABLED -> { + isAppLockEnabled.value = settingsRepository.getBoolean(key) + appContext?.let { updateAppLockService(it) } + } + + SettingsRepository.KEY_APP_LOCK_USE_USAGE_ACCESS -> { + isAppLockUseUsageAccess.value = settingsRepository.getBoolean(key) + appContext?.let { updateAppLockService(it) } + } SettingsRepository.KEY_FREEZE_WHEN_LOCKED_ENABLED -> isFreezeWhenLockedEnabled.value = settingsRepository.getBoolean(key) @@ -640,6 +649,7 @@ class MainViewModel : ViewModel() { notificationLightingStyle.value = settingsRepository.getNotificationLightingStyle() notificationLightingColorMode.value = settingsRepository.getNotificationLightingColorMode() + isAppLockUseUsageAccess.value = settingsRepository.getBoolean(SettingsRepository.KEY_APP_LOCK_USE_USAGE_ACCESS) isOnboardingCompleted.value = settingsRepository.getBoolean(SettingsRepository.KEY_ONBOARDING_COMPLETED, false) notificationLightingCustomColor.intValue = settingsRepository.getInt( SettingsRepository.KEY_EDGE_LIGHTING_CUSTOM_COLOR, @@ -1256,6 +1266,32 @@ class MainViewModel : ViewModel() { fun setAppLockEnabled(enabled: Boolean, context: Context) { isAppLockEnabled.value = enabled settingsRepository.putBoolean(SettingsRepository.KEY_APP_LOCK_ENABLED, enabled) + updateAppLockService(context) + } + + fun setAppLockUseUsageAccess(enabled: Boolean, context: Context) { + isAppLockUseUsageAccess.value = enabled + settingsRepository.putBoolean(SettingsRepository.KEY_APP_LOCK_USE_USAGE_ACCESS, enabled) + updateAppLockService(context) + } + + private fun updateAppLockService(context: Context) { + val intent = Intent(context, com.sameerasw.essentials.services.AppLockUsageService::class.java) + val shouldRun = isAppLockEnabled.value && isAppLockUseUsageAccess.value + + if (shouldRun) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } catch (e: Exception) { + e.printStackTrace() + } + } else { + context.stopService(intent) + } } val isLikeSongToastEnabled = mutableStateOf(false) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 12a5531c4..65d605848 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,6 +24,8 @@ Secure your apps with biometric authentication. Locked apps will require authentication when launching, Stays unlocked until the screen turns off. Beware that this is not a robust solution as this is only a 3rd party application. If you need strong security, consider using Private Space or other such features. Another note, the biometric authentication prompt only lets you use STRONG secure class methods. Face unlock security methods in WEAK class in devices such as Pixel 7 will only be able to utilize the available other STRONG auth methods such as fingerprint or pin. + Use usage access + Instead of accessibility Enable Button Remap @@ -150,6 +152,7 @@ Don\'t freeze active apps Usage Stats Required to detect which apps are currently in the foreground to avoid freezing them + Required to detect foreground apps for App Lock features when accessibility is not used. Required to detect playing media and active notifications to avoid freezing them Freeze mode Freezing @@ -765,6 +768,9 @@ Automation Service Automations Active Monitoring system events for your automations + App Lock Service + App Lock Active + Monitoring app activity Device Effects Control system-level effects like grayscale, AOD suppression, wallpaper dimming, and night mode. From edb968f5cf6e9284ff9cb4e52100864bf9aeece6 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 11 Apr 2026 16:13:03 +0530 Subject: [PATCH 07/23] refactor: Use a common service for app monitoring and give option for usage access for all of them --- app/src/main/AndroidManifest.xml | 4 +-- .../essentials/FeatureSettingsActivity.kt | 8 ++--- .../sameerasw/essentials/SettingsActivity.kt | 7 +++++ .../data/repository/SettingsRepository.kt | 17 +++++++++- .../domain/registry/FeatureRegistry.kt | 15 ++++++--- .../domain/registry/PermissionRegistry.kt | 1 + ...UsageService.kt => AppDetectionService.kt} | 12 +++---- .../services/handlers/AppFlowHandler.kt | 14 +++------ .../tiles/ScreenOffAccessibilityService.kt | 2 +- .../composables/configs/AppLockSettingsUI.kt | 15 ++------- .../configs/QuickSettingsTilesSettingsUI.kt | 4 +-- .../essentials/utils/PermissionUIHelper.kt | 2 +- .../essentials/viewmodels/MainViewModel.kt | 31 ++++++++++--------- app/src/main/res/values/strings.xml | 10 +++--- 14 files changed, 80 insertions(+), 62 deletions(-) rename app/src/main/java/com/sameerasw/essentials/services/{AppLockUsageService.kt => AppDetectionService.kt} (92%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 524595493..9f565ce1f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -488,12 +488,12 @@ + android:value="App Detection Service" /> !isWriteSecureSettingsEnabled "Notification lighting" -> !isOverlayPermissionGranted || !isNotificationLightingAccessibilityEnabled || !isNotificationListenerEnabled "Button remap" -> !isAccessibilityEnabled - "Dynamic night light" -> !isAccessibilityEnabled || !isWriteSecureSettingsEnabled + "Dynamic night light" -> (if (viewModel.isUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else !isAccessibilityEnabled) || !isWriteSecureSettingsEnabled "Snooze system notifications" -> !isNotificationListenerEnabled "Screen locked security" -> !isAccessibilityEnabled || !isWriteSecureSettingsEnabled || !viewModel.isDeviceAdminEnabled.value - "App lock" -> if (viewModel.isAppLockUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else !isAccessibilityEnabled + "App lock" -> if (viewModel.isUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else !isAccessibilityEnabled "Freeze" -> !com.sameerasw.essentials.utils.ShellUtils.hasPermission( context ) @@ -373,10 +373,10 @@ class FeatureSettingsActivity : AppCompatActivity() { "Statusbar icons" -> !isWriteSecureSettingsEnabled "Notification lighting" -> !isOverlayPermissionGranted || !isNotificationLightingAccessibilityEnabled || !isNotificationListenerEnabled "Button remap" -> !isAccessibilityEnabled - "Dynamic night light" -> !isAccessibilityEnabled || !isWriteSecureSettingsEnabled + "Dynamic night light" -> (if (viewModel.isUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else !isAccessibilityEnabled) || !isWriteSecureSettingsEnabled "Snooze system notifications" -> !isNotificationListenerEnabled "Screen locked security" -> !isAccessibilityEnabled || !isWriteSecureSettingsEnabled || !viewModel.isDeviceAdminEnabled.value - "App lock" -> if (viewModel.isAppLockUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else !isAccessibilityEnabled + "App lock" -> if (viewModel.isUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else !isAccessibilityEnabled "Freeze" -> !com.sameerasw.essentials.utils.ShellUtils.hasPermission( context ) diff --git a/app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt index f015a3b0d..628d9712a 100644 --- a/app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt @@ -374,6 +374,13 @@ fun SettingsContent( isChecked = viewModel.isRootEnabled.value, onCheckedChange = { viewModel.setRootEnabled(it, context) } ) + IconToggleItem( + iconRes = R.drawable.rounded_data_usage_24, + title = stringResource(R.string.setting_use_usage_access_title), + description = stringResource(R.string.setting_use_usage_access_desc), + isChecked = viewModel.isUseUsageAccess.value, + onCheckedChange = { viewModel.setUseUsageAccess(it, context) } + ) CrashReportingPicker( selectedMode = sentryMode, diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index cc795ad0e..56ba0e15a 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -22,6 +22,21 @@ class SettingsRepository(private val context: Context) { private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val gson = Gson() + + init { + migrateUsageAccessKey() + } + + private fun migrateUsageAccessKey() { + val oldKey = "app_lock_use_usage_access" + if (prefs.contains(oldKey)) { + val value = prefs.getBoolean(oldKey, false) + if (!prefs.contains(KEY_USE_USAGE_ACCESS)) { + putBoolean(KEY_USE_USAGE_ACCESS, value) + } + remove(oldKey) + } + } companion object { const val PREFS_NAME = "essentials_prefs" @@ -95,7 +110,7 @@ class SettingsRepository(private val context: Context) { const val KEY_APP_LOCK_ENABLED = "app_lock_enabled" const val KEY_APP_LOCK_SELECTED_APPS = "app_lock_selected_apps" - const val KEY_APP_LOCK_USE_USAGE_ACCESS = "app_lock_use_usage_access" + const val KEY_USE_USAGE_ACCESS = "use_usage_access" const val KEY_FREEZE_WHEN_LOCKED_ENABLED = "freeze_when_locked_enabled" const val KEY_FREEZE_LOCK_DELAY_INDEX = "freeze_lock_delay_index" diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt index 6b0a292df..4da2bfb9f 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt @@ -651,7 +651,6 @@ object FeatureRegistry { category = R.string.cat_display, description = R.string.feat_dynamic_night_light_desc, aboutDescription = R.string.about_desc_dynamic_night_light, - permissionKeys = listOf("ACCESSIBILITY", "WRITE_SECURE_SETTINGS"), searchableSettings = listOf( SearchSetting( R.string.search_night_light_enable_title, @@ -663,11 +662,19 @@ object FeatureRegistry { showToggle = true, parentFeatureId = "Display" ) { + override val permissionKeys: List + get() = if (com.sameerasw.essentials.data.repository.SettingsRepository(com.sameerasw.essentials.EssentialsApp.context) + .getBoolean(com.sameerasw.essentials.data.repository.SettingsRepository.KEY_USE_USAGE_ACCESS)) + listOf("USAGE_STATS", "WRITE_SECURE_SETTINGS") else listOf("ACCESSIBILITY", "WRITE_SECURE_SETTINGS") + override fun isEnabled(viewModel: MainViewModel) = viewModel.isDynamicNightLightEnabled.value override fun isToggleEnabled(viewModel: MainViewModel, context: Context) = - viewModel.isAccessibilityEnabled.value && viewModel.isWriteSecureSettingsEnabled.value + (if (viewModel.isUseUsageAccess.value) + viewModel.isUsageStatsPermissionGranted.value + else + viewModel.isAccessibilityEnabled.value) && viewModel.isWriteSecureSettingsEnabled.value override fun onToggle(viewModel: MainViewModel, context: Context, enabled: Boolean) = viewModel.setDynamicNightLightEnabled(enabled, context) @@ -718,12 +725,12 @@ object FeatureRegistry { ) { override val permissionKeys: List get() = if (com.sameerasw.essentials.data.repository.SettingsRepository(com.sameerasw.essentials.EssentialsApp.context) - .getBoolean(com.sameerasw.essentials.data.repository.SettingsRepository.KEY_APP_LOCK_USE_USAGE_ACCESS)) + .getBoolean(com.sameerasw.essentials.data.repository.SettingsRepository.KEY_USE_USAGE_ACCESS)) listOf("USAGE_STATS") else listOf("ACCESSIBILITY") override fun isEnabled(viewModel: MainViewModel) = viewModel.isAppLockEnabled.value override fun isToggleEnabled(viewModel: MainViewModel, context: Context) = - if (viewModel.isAppLockUseUsageAccess.value) + if (viewModel.isUseUsageAccess.value) viewModel.isUsageStatsPermissionGranted.value else viewModel.isAccessibilityEnabled.value diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt index 0eda516de..6da739440 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt @@ -38,6 +38,7 @@ fun initPermissionRegistry() { PermissionRegistry.register("SHIZUKU", R.string.feat_maps_power_saving_title) PermissionRegistry.register("USAGE_STATS", R.string.feat_freeze_title) PermissionRegistry.register("USAGE_STATS", R.string.feat_app_lock_title) + PermissionRegistry.register("USAGE_STATS", R.string.feat_dynamic_night_light_title) PermissionRegistry.register("NOTIFICATION_LISTENER", R.string.feat_freeze_title) // Root permission diff --git a/app/src/main/java/com/sameerasw/essentials/services/AppLockUsageService.kt b/app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt similarity index 92% rename from app/src/main/java/com/sameerasw/essentials/services/AppLockUsageService.kt rename to app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt index 6e84b2ebe..e8ad3ec1b 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/AppLockUsageService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt @@ -19,7 +19,7 @@ import com.sameerasw.essentials.R import com.sameerasw.essentials.services.handlers.AppFlowHandler import java.util.* -class AppLockUsageService : Service() { +class AppDetectionService : Service() { private lateinit var appFlowHandler: AppFlowHandler private val handler = Handler(Looper.getMainLooper()) @@ -27,7 +27,7 @@ class AppLockUsageService : Service() { private var lastPackageName: String? = null companion object { - private const val CHANNEL_ID = "app_lock_service_channel" + private const val CHANNEL_ID = "app_detection_service_channel" private const val NOTIFICATION_ID = 1001 private const val POLL_INTERVAL = 500L var isRunning = false @@ -137,10 +137,10 @@ class AppLockUsageService : Service() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( CHANNEL_ID, - getString(R.string.app_lock_service_channel_name), + getString(R.string.app_detection_service_channel_name), NotificationManager.IMPORTANCE_LOW ).apply { - description = getString(R.string.app_lock_service_running_desc) + description = getString(R.string.app_detection_service_running_desc) } val manager = getSystemService(NotificationManager::class.java) manager.createNotificationChannel(channel) @@ -149,8 +149,8 @@ class AppLockUsageService : Service() { private fun createNotification(): Notification { return NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle(getString(R.string.app_lock_service_running_title)) - .setContentText(getString(R.string.app_lock_service_running_desc)) + .setContentTitle(getString(R.string.app_detection_service_running_title)) + .setContentText(getString(R.string.app_detection_service_running_desc)) .setSmallIcon(R.drawable.rounded_shield_lock_24) .setOngoing(true) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt index f576dd24e..b765b0319 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt @@ -47,7 +47,7 @@ class AppFlowHandler( fun onPackageChanged(packageName: String, isFromUsageStats: Boolean = false) { val prefs = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) - val useUsageAccess = prefs.getBoolean("app_lock_use_usage_access", false) + val useUsageAccess = prefs.getBoolean("use_usage_access", false) if (packageName != context.packageName && packageName != lockingPackage) { lockingPackage = null @@ -55,13 +55,10 @@ class AppFlowHandler( if (isFromUsageStats == useUsageAccess) { checkAppLock(packageName) - } - - // Night light and automations still require Accessibility - if (!isFromUsageStats) { checkHighlightNightLight(packageName) checkAppAutomations(packageName) } + } fun onAuthenticated(packageName: String) { @@ -123,7 +120,7 @@ class AppFlowHandler( private fun checkHighlightNightLight(packageName: String) { val prefs = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) val isEnabled = prefs.getBoolean("dynamic_night_light_enabled", false) - if (!isEnabled || service == null) return + if (!isEnabled) return pendingNLRunnable?.let { handler.removeCallbacks(it) } @@ -199,7 +196,6 @@ class AppFlowHandler( } private fun checkAppAutomations(packageName: String) { - if (service == null) return scope.launch { val automations = DIYRepository.automations.value val appAutomations = @@ -214,7 +210,7 @@ class AppFlowHandler( exiting.forEach { automation -> activeAppAutomationIds.remove(automation.id) automation.exitAction?.let { action -> - CombinedActionExecutor.execute(service, action) + CombinedActionExecutor.execute(context, action) } } @@ -227,7 +223,7 @@ class AppFlowHandler( entering.forEach { automation -> activeAppAutomationIds.add(automation.id) automation.entryAction?.let { action -> - CombinedActionExecutor.execute(service, action) + CombinedActionExecutor.execute(context, action) } } } diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt index 4ff1d6a0a..836e2fb5f 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt @@ -65,7 +65,7 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene flashlightHandler = FlashlightHandler(this, serviceScope) notificationLightingHandler = NotificationLightingHandler(this) buttonRemapHandler = ButtonRemapHandler(this, flashlightHandler) - appFlowHandler = AppFlowHandler(this) + appFlowHandler = AppFlowHandler(this, this) securityHandler = SecurityHandler(this) ambientGlanceHandler = AmbientGlanceHandler(this) aodForceTurnOffHandler = AodForceTurnOffHandler(this) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/AppLockSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/AppLockSettingsUI.kt index 07bd03f6a..fd55c70d7 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/AppLockSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/AppLockSettingsUI.kt @@ -37,11 +37,11 @@ fun AppLockSettingsUI( var isAppSelectionSheetOpen by remember { mutableStateOf(false) } val isAppLockEnabled by viewModel.isAppLockEnabled - val isAppLockUseUsageAccess by viewModel.isAppLockUseUsageAccess + val isUseUsageAccess by viewModel.isUseUsageAccess val isAccessibilityEnabled by viewModel.isAccessibilityEnabled val isUsageStatsPermissionGranted by viewModel.isUsageStatsPermissionGranted - val canEnableAppLock = if (isAppLockUseUsageAccess) isUsageStatsPermissionGranted else isAccessibilityEnabled + val canEnableAppLock = if (isUseUsageAccess) isUsageStatsPermissionGranted else isAccessibilityEnabled Column( modifier = modifier @@ -84,17 +84,6 @@ fun AppLockSettingsUI( modifier = Modifier.highlight(highlightKey == "app_lock_enabled") ) - IconToggleItem( - iconRes = R.drawable.rounded_touch_app_24, - title = stringResource(R.string.app_lock_use_usage_access_title), - isChecked = isAppLockUseUsageAccess, - description = stringResource(R.string.app_lock_use_usage_access_desc), - onCheckedChange = { enabled -> - viewModel.setAppLockUseUsageAccess(enabled, context) - }, - modifier = Modifier.highlight(highlightKey == "app_lock_use_usage_access") - ) - FeatureCard( title = stringResource(R.string.app_lock_select_apps_title), description = stringResource(R.string.app_lock_select_apps_desc), diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt index ddec9c8e3..59e2d59f8 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt @@ -101,7 +101,7 @@ fun QuickSettingsTilesSettingsUI( var selectedHelpTile by remember { mutableStateOf(null) } - val isAppLockUseUsageStats by viewModel.isAppLockUseUsageAccess + val isUseUsageStats by viewModel.isUseUsageAccess val tiles = listOf( QSTileInfo( R.string.tile_ui_blur, @@ -179,7 +179,7 @@ fun QuickSettingsTilesSettingsUI( R.string.tile_app_lock, R.drawable.rounded_shield_lock_24, AppLockTileService::class.java, - if (isAppLockUseUsageStats) listOf("USAGE_STATS") else listOf("ACCESSIBILITY"), + if (isUseUsageStats) listOf("USAGE_STATS") else listOf("ACCESSIBILITY"), R.string.about_desc_app_lock ), QSTileInfo( diff --git a/app/src/main/java/com/sameerasw/essentials/utils/PermissionUIHelper.kt b/app/src/main/java/com/sameerasw/essentials/utils/PermissionUIHelper.kt index 312822a44..99ac46fe1 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/PermissionUIHelper.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/PermissionUIHelper.kt @@ -225,7 +225,7 @@ object PermissionUIHelper { "USAGE_STATS" -> PermissionItem( iconRes = R.drawable.rounded_data_usage_24, title = R.string.perm_usage_stats_title, - description = if (viewModel.isAppLockUseUsageAccess.value) + description = if (viewModel.isUseUsageAccess.value) R.string.perm_usage_stats_desc_app_lock else R.string.perm_usage_stats_desc, dependentFeatures = PermissionRegistry.getFeatures("USAGE_STATS"), actionLabel = R.string.perm_action_grant, diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index 72ebcfdd3..20827495d 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -154,7 +154,7 @@ class MainViewModel : ViewModel() { mutableStateOf(setOf(NotificationLightingSide.LEFT, NotificationLightingSide.RIGHT)) val skipPersistentNotifications = mutableStateOf(false) val isAppLockEnabled = mutableStateOf(false) - val isAppLockUseUsageAccess = mutableStateOf(false) + val isUseUsageAccess = mutableStateOf(false) val isFreezeWhenLockedEnabled = mutableStateOf(false) val freezeLockDelayIndex = mutableIntStateOf(1) // Default: 1 minute val freezePickedApps = mutableStateOf>(emptyList()) @@ -293,12 +293,12 @@ class MainViewModel : ViewModel() { SettingsRepository.KEY_APP_LOCK_ENABLED -> { isAppLockEnabled.value = settingsRepository.getBoolean(key) - appContext?.let { updateAppLockService(it) } + appContext?.let { updateAppDetectionService(it) } } - SettingsRepository.KEY_APP_LOCK_USE_USAGE_ACCESS -> { - isAppLockUseUsageAccess.value = settingsRepository.getBoolean(key) - appContext?.let { updateAppLockService(it) } + SettingsRepository.KEY_USE_USAGE_ACCESS -> { + isUseUsageAccess.value = settingsRepository.getBoolean(key) + appContext?.let { updateAppDetectionService(it) } } SettingsRepository.KEY_FREEZE_WHEN_LOCKED_ENABLED -> isFreezeWhenLockedEnabled.value = @@ -649,7 +649,7 @@ class MainViewModel : ViewModel() { notificationLightingStyle.value = settingsRepository.getNotificationLightingStyle() notificationLightingColorMode.value = settingsRepository.getNotificationLightingColorMode() - isAppLockUseUsageAccess.value = settingsRepository.getBoolean(SettingsRepository.KEY_APP_LOCK_USE_USAGE_ACCESS) + isUseUsageAccess.value = settingsRepository.getBoolean(SettingsRepository.KEY_USE_USAGE_ACCESS) isOnboardingCompleted.value = settingsRepository.getBoolean(SettingsRepository.KEY_ONBOARDING_COMPLETED, false) notificationLightingCustomColor.intValue = settingsRepository.getInt( SettingsRepository.KEY_EDGE_LIGHTING_CUSTOM_COLOR, @@ -1261,23 +1261,26 @@ class MainViewModel : ViewModel() { fun setDynamicNightLightEnabled(enabled: Boolean, context: Context) { isDynamicNightLightEnabled.value = enabled settingsRepository.putBoolean(SettingsRepository.KEY_DYNAMIC_NIGHT_LIGHT_ENABLED, enabled) + updateAppDetectionService(context) } fun setAppLockEnabled(enabled: Boolean, context: Context) { isAppLockEnabled.value = enabled settingsRepository.putBoolean(SettingsRepository.KEY_APP_LOCK_ENABLED, enabled) - updateAppLockService(context) + updateAppDetectionService(context) } - fun setAppLockUseUsageAccess(enabled: Boolean, context: Context) { - isAppLockUseUsageAccess.value = enabled - settingsRepository.putBoolean(SettingsRepository.KEY_APP_LOCK_USE_USAGE_ACCESS, enabled) - updateAppLockService(context) + fun setUseUsageAccess(enabled: Boolean, context: Context) { + isUseUsageAccess.value = enabled + settingsRepository.putBoolean(SettingsRepository.KEY_USE_USAGE_ACCESS, enabled) + updateAppDetectionService(context) } - private fun updateAppLockService(context: Context) { - val intent = Intent(context, com.sameerasw.essentials.services.AppLockUsageService::class.java) - val shouldRun = isAppLockEnabled.value && isAppLockUseUsageAccess.value + private fun updateAppDetectionService(context: Context) { + val intent = Intent(context, com.sameerasw.essentials.services.AppDetectionService::class.java) + + val hasAppAutomations = com.sameerasw.essentials.domain.diy.DIYRepository.automations.value.any { it.isEnabled && it.type == com.sameerasw.essentials.domain.diy.Automation.Type.APP } + val shouldRun = isUseUsageAccess.value && (isAppLockEnabled.value || isDynamicNightLightEnabled.value || hasAppAutomations) if (shouldRun) { try { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 65d605848..b9f977836 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,8 +24,8 @@ Secure your apps with biometric authentication. Locked apps will require authentication when launching, Stays unlocked until the screen turns off. Beware that this is not a robust solution as this is only a 3rd party application. If you need strong security, consider using Private Space or other such features. Another note, the biometric authentication prompt only lets you use STRONG secure class methods. Face unlock security methods in WEAK class in devices such as Pixel 7 will only be able to utilize the available other STRONG auth methods such as fingerprint or pin. - Use usage access - Instead of accessibility + Use usage access + Instead of accessibility (Freeze, App Lock, Dynamic Night Light) Enable Button Remap @@ -768,9 +768,9 @@ Automation Service Automations Active Monitoring system events for your automations - App Lock Service - App Lock Active - Monitoring app activity + App Detection Service + App Detection Active + Monitoring app activity Device Effects Control system-level effects like grayscale, AOD suppression, wallpaper dimming, and night mode. From 72eb332ab8eab2a1ee337eb7b3746ef82ab7892a Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 11 Apr 2026 16:38:21 +0530 Subject: [PATCH 08/23] refactor: migrate media session polling to event-driven MediaController callbacks in AmbientDreamService --- .../services/dreams/AmbientDreamService.kt | 189 ++++++++++-------- 1 file changed, 106 insertions(+), 83 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/services/dreams/AmbientDreamService.kt b/app/src/main/java/com/sameerasw/essentials/services/dreams/AmbientDreamService.kt index 3153b6171..a6bd942ef 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/dreams/AmbientDreamService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/dreams/AmbientDreamService.kt @@ -79,6 +79,51 @@ class AmbientDreamService : DreamService() { private var eventType: String? = null private var targetPackage: String? = null + private var currentController: android.media.session.MediaController? = null + + private val sessionListener = MediaSessionManager.OnActiveSessionsChangedListener { sessions -> + updateActiveSession(sessions) + } + + private val mediaCallback = object : android.media.session.MediaController.Callback() { + override fun onMetadataChanged(metadata: android.media.MediaMetadata?) { + handler.post { + val title = metadata?.getString(android.media.MediaMetadata.METADATA_KEY_TITLE) + val artist = metadata?.getString(android.media.MediaMetadata.METADATA_KEY_ARTIST) + + if (title != trackTitle || artist != artistName) { + trackTitle = title + artistName = artist + + currentController?.let { isAlreadyLiked = checkIsLiked(it) } + + var artBitmap = metadata?.getBitmap(android.media.MediaMetadata.METADATA_KEY_ALBUM_ART) + if (artBitmap == null) { + artBitmap = metadata?.getBitmap(android.media.MediaMetadata.METADATA_KEY_ART) + } + updateMetadata(artBitmap) + } + } + } + + override fun onPlaybackStateChanged(state: android.media.session.PlaybackState?) { + handler.post { + val isPlaying = state?.state == android.media.session.PlaybackState.STATE_PLAYING + if (isPlaying && !isMusicMode) { + switchToMusicMode() + } else if (!isPlaying && isMusicMode) { + // We might want to wait a bit before switching to clock mode to handle transitions + handler.postDelayed({ + val mediaSessionManager = getSystemService(MEDIA_SESSION_SERVICE) as MediaSessionManager + val sessions = getMediaSessions(mediaSessionManager) + val stillPlaying = sessions.any { it.playbackState?.state == android.media.session.PlaybackState.STATE_PLAYING } + if (!stillPlaying) switchToClockMode() + }, 2000) + } + } + } + } + private val handler = Handler(Looper.getMainLooper()) private val burnInProtectionRunnable = object : Runnable { @@ -94,81 +139,17 @@ class AmbientDreamService : DreamService() { handler.postDelayed(this, 1000) return } - - try { - val mediaSessionManager = - getSystemService(MEDIA_SESSION_SERVICE) as MediaSessionManager - val sessions = getMediaSessions(mediaSessionManager) - - // Find playing session matching target package if possible - val activeSession = sessions.firstOrNull { session -> - val isPlaying = - session.playbackState?.state == android.media.session.PlaybackState.STATE_PLAYING - if (targetPackage != null) { - try { - isPlaying && session.packageName == targetPackage - } catch (e: Exception) { - isPlaying - } - } else { - isPlaying - } - } - ?: sessions.firstOrNull { it.playbackState?.state == android.media.session.PlaybackState.STATE_PLAYING } - - if (activeSession != null) { - // Update Progress - val position = activeSession.playbackState?.position ?: 0L - val duration = - activeSession.metadata?.getLong(android.media.MediaMetadata.METADATA_KEY_DURATION) - ?: 0L + currentController?.let { controller -> + val playbackState = controller.playbackState + if (playbackState?.state == android.media.session.PlaybackState.STATE_PLAYING) { + val position = playbackState.position + val duration = controller.metadata?.getLong(android.media.MediaMetadata.METADATA_KEY_DURATION) ?: 0L if (duration > 0) { val progress = (position.toFloat() / duration.toFloat() * 100).toInt() volumeStrokeView?.updatePercentage(progress) } - - // Update Metadata Direct from Session (Real-time) - val metadata = activeSession.metadata - val currentTitle = - metadata?.getString(android.media.MediaMetadata.METADATA_KEY_TITLE) - val currentArtist = - metadata?.getString(android.media.MediaMetadata.METADATA_KEY_ARTIST) - - // Check Like Status directly - val isLikedNow = checkIsLiked(activeSession) - if (isLikedNow != isAlreadyLiked) { - isAlreadyLiked = isLikedNow - likeStatusView?.setImageResource(if (isAlreadyLiked) R.drawable.round_favorite_24 else R.drawable.rounded_favorite_24) - } - - if (currentTitle != trackTitle) { - trackTitle = currentTitle - artistName = currentArtist - - // Get Bitmap directly - var artBitmap = - metadata?.getBitmap(android.media.MediaMetadata.METADATA_KEY_ALBUM_ART) - if (artBitmap == null) { - artBitmap = - metadata?.getBitmap(android.media.MediaMetadata.METADATA_KEY_ART) - } - - updateMetadata(artBitmap) - } - - // Ensure we are in music mode - if (!isMusicMode) { - switchToMusicMode() - } - } else { - // No active playing session - if (isMusicMode) { - switchToClockMode() - } } - } catch (e: Exception) { - e.printStackTrace() } if (isDetached) return @@ -205,6 +186,16 @@ class AmbientDreamService : DreamService() { val filter = IntentFilter("SHOW_AMBIENT_GLANCE") registerReceiver(receiver, filter, RECEIVER_EXPORTED) + // Register Media Session Listener + try { + val mediaSessionManager = getSystemService(MEDIA_SESSION_SERVICE) as MediaSessionManager + val componentName = android.content.ComponentName(this, com.sameerasw.essentials.services.NotificationListener::class.java) + mediaSessionManager.addOnActiveSessionsChangedListener(sessionListener, componentName) + updateActiveSession(mediaSessionManager.getActiveSessions(componentName)) + } catch (e: Exception) { + e.printStackTrace() + } + // Request initial state val requestIntent = Intent("com.sameerasw.essentials.ACTION_REQUEST_AMBIENT_GLANCE").apply { setPackage(packageName) @@ -434,6 +425,17 @@ class AmbientDreamService : DreamService() { isDreaming = false unregisterReceiver(receiver) if (volumeReceiver != null) unregisterReceiver(volumeReceiver) + + // Unregister Media Session Listener + try { + val mediaSessionManager = getSystemService(MEDIA_SESSION_SERVICE) as MediaSessionManager + mediaSessionManager.removeOnActiveSessionsChangedListener(sessionListener) + currentController?.unregisterCallback(mediaCallback) + currentController = null + } catch (e: Exception) { + e.printStackTrace() + } + handler.removeCallbacksAndMessages(null) // Cancel all View animators @@ -482,24 +484,47 @@ class AmbientDreamService : DreamService() { volumeStrokeView?.setColor(Color.GRAY) } + private fun updateActiveSession(sessions: List?) { + if (isDetached) return + val playingSession = sessions?.firstOrNull { it.playbackState?.state == android.media.session.PlaybackState.STATE_PLAYING } + ?: sessions?.firstOrNull() + + if (playingSession != null) { + if (currentController?.sessionToken != playingSession.sessionToken) { + currentController?.unregisterCallback(mediaCallback) + currentController = playingSession + currentController?.registerCallback(mediaCallback) + + // Initial UI update for new controller + val metadata = currentController?.metadata + trackTitle = metadata?.getString(android.media.MediaMetadata.METADATA_KEY_TITLE) + artistName = metadata?.getString(android.media.MediaMetadata.METADATA_KEY_ARTIST) + isAlreadyLiked = checkIsLiked(playingSession) + + var artBitmap = metadata?.getBitmap(android.media.MediaMetadata.METADATA_KEY_ALBUM_ART) + if (artBitmap == null) { + artBitmap = metadata?.getBitmap(android.media.MediaMetadata.METADATA_KEY_ART) + } + + if (playingSession.playbackState?.state == android.media.session.PlaybackState.STATE_PLAYING) { + switchToMusicMode() + } + updateMetadata(artBitmap) + } + } else { + currentController?.unregisterCallback(mediaCallback) + currentController = null + switchToClockMode() + } + } + private fun checkDirectly() { if (isDetached) return try { val mediaSessionManager = getSystemService(MEDIA_SESSION_SERVICE) as MediaSessionManager val sessions = getMediaSessions(mediaSessionManager) - val playingSession = - sessions.firstOrNull { it.playbackState?.state == android.media.session.PlaybackState.STATE_PLAYING } - - if (playingSession != null) { - val metadata = playingSession.metadata - trackTitle = metadata?.getString(android.media.MediaMetadata.METADATA_KEY_TITLE) - artistName = metadata?.getString(android.media.MediaMetadata.METADATA_KEY_ARTIST) - switchToMusicMode() - updateMetadata() - } else { - switchToClockMode() - } + updateActiveSession(sessions) } catch (_: Exception) { switchToClockMode() } @@ -573,8 +598,6 @@ class AmbientDreamService : DreamService() { centerContainer?.animate()?.alpha(0f)?.setDuration(300)?.start() textContainer?.animate()?.alpha(0f)?.setDuration(300)?.start() - - handler.removeCallbacks(progressUpdateRunnable) } private fun updateMetadata(directBitmap: android.graphics.Bitmap? = null) { From 150c5ea03a51067e26c69c408736b698a712b175 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 11 Apr 2026 17:29:44 +0530 Subject: [PATCH 09/23] feat: integrate Material Shapes for dynamic AmbientDreamService UI and upgrade Material3 to 1.5.0-alpha17 --- app/build.gradle.kts | 5 +- .../services/dreams/AmbientDreamService.kt | 79 ++++++++------- .../services/handlers/AmbientGlanceHandler.kt | 98 +++++++++++-------- .../utils/AmbientMusicShapeHelper.kt | 72 ++++++++++++++ gradle/libs.versions.toml | 4 +- 5 files changed, 179 insertions(+), 79 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/utils/AmbientMusicShapeHelper.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1517abe6f..4d926bd07 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -79,8 +79,8 @@ dependencies { // Android 12+ SplashScreen API with backward compatibility attributes implementation("androidx.core:core-splashscreen:1.0.1") - // Force latest Material3 1.5.0-alpha16 for new ListItem expressive overloads - implementation("androidx.compose.material3:material3:1.5.0-alpha16") + // Force latest Material3 1.5.0-alpha17 for new MaterialShapes + implementation("androidx.compose.material3:material3:1.5.0-alpha17") implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) @@ -144,4 +144,5 @@ dependencies { // GSMArena Parsing implementation("org.jsoup:jsoup:1.15.3") implementation(libs.sentry.android) + implementation(libs.androidx.graphics.shapes) } \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/essentials/services/dreams/AmbientDreamService.kt b/app/src/main/java/com/sameerasw/essentials/services/dreams/AmbientDreamService.kt index a6bd942ef..152554ab8 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/dreams/AmbientDreamService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/dreams/AmbientDreamService.kt @@ -62,6 +62,7 @@ class AmbientDreamService : DreamService() { // Music UI private var musicContainer: FrameLayout? = null private var centerContainer: FrameLayout? = null + private var clipContainer: FrameLayout? = null private var textContainer: LinearLayout? = null private var imageView: ImageView? = null private var titleView: TextView? = null @@ -69,6 +70,8 @@ class AmbientDreamService : DreamService() { private var likeStatusView: ImageView? = null private var volumeIconView: ImageView? = null private var volumeStrokeView: VolumeStrokeView? = null + + private var currentShapePath: android.graphics.Path? = null // State private var isMusicMode = false @@ -112,13 +115,7 @@ class AmbientDreamService : DreamService() { if (isPlaying && !isMusicMode) { switchToMusicMode() } else if (!isPlaying && isMusicMode) { - // We might want to wait a bit before switching to clock mode to handle transitions - handler.postDelayed({ - val mediaSessionManager = getSystemService(MEDIA_SESSION_SERVICE) as MediaSessionManager - val sessions = getMediaSessions(mediaSessionManager) - val stillPlaying = sessions.any { it.playbackState?.state == android.media.session.PlaybackState.STATE_PLAYING } - if (!stillPlaying) switchToClockMode() - }, 2000) + switchToClockMode() } } } @@ -126,6 +123,10 @@ class AmbientDreamService : DreamService() { private val handler = Handler(Looper.getMainLooper()) + private val volumeHideRunnable = Runnable { + volumeStrokeView?.animate()?.alpha(0f)?.setDuration(500)?.start() + } + private val burnInProtectionRunnable = object : Runnable { override fun run() { shiftUi() @@ -135,23 +136,6 @@ class AmbientDreamService : DreamService() { private val progressUpdateRunnable = object : Runnable { override fun run() { - if (eventType == "volume") { - handler.postDelayed(this, 1000) - return - } - currentController?.let { controller -> - val playbackState = controller.playbackState - if (playbackState?.state == android.media.session.PlaybackState.STATE_PLAYING) { - val position = playbackState.position - val duration = controller.metadata?.getLong(android.media.MediaMetadata.METADATA_KEY_DURATION) ?: 0L - - if (duration > 0) { - val progress = (position.toFloat() / duration.toFloat() * 100).toInt() - volumeStrokeView?.updatePercentage(progress) - } - } - } - if (isDetached) return handler.postDelayed(this, 1000L) } @@ -261,16 +245,15 @@ class AmbientDreamService : DreamService() { val size = dpToPx(320f) - // Path for both clipping and stroke - val petalPath = createScallopPath(size.toFloat(), size.toFloat(), 12, 0.10f) + currentShapePath = com.sameerasw.essentials.utils.AmbientMusicShapeHelper.getRandomShapePath(size.toFloat()) // Container for clipping - val clipContainer = FrameLayout(this).apply { + clipContainer = FrameLayout(this).apply { layoutParams = FrameLayout.LayoutParams(size, size, Gravity.CENTER) outlineProvider = object : android.view.ViewOutlineProvider() { override fun getOutline(view: View, outline: android.graphics.Outline) { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { - outline.setPath(petalPath) + currentShapePath?.let { outline.setPath(it) } } else { outline.setOval(0, 0, view.width, view.height) } @@ -296,8 +279,8 @@ class AmbientDreamService : DreamService() { setBackgroundColor(0x40000000.toInt()) } - clipContainer.addView(imageView) - clipContainer.addView(scrim) + clipContainer?.addView(imageView) + clipContainer?.addView(scrim) centerContainer?.addView(clipContainer) // Volume Icon @@ -310,10 +293,11 @@ class AmbientDreamService : DreamService() { centerContainer?.addView(volumeIconView) // Volume Stroke - volumeStrokeView = VolumeStrokeView(this, petalPath, 0).apply { + volumeStrokeView = VolumeStrokeView(this, currentShapePath!!, 0).apply { layoutParams = FrameLayout.LayoutParams(size + dpToPx(20f), size + dpToPx(20f), Gravity.CENTER) setColor(Color.GRAY) + alpha = 0f // Hidden by default, only shown on volume change } centerContainer?.addView(volumeStrokeView) @@ -392,6 +376,12 @@ class AmbientDreamService : DreamService() { if (isMusicMode) { volumeStrokeView?.setColor(Color.WHITE) volumeStrokeView?.updatePercentage(perc) + + // Show and schedule hide + volumeStrokeView?.animate()?.alpha(1f)?.setDuration(300)?.start() + handler.removeCallbacks(volumeHideRunnable) + handler.postDelayed(volumeHideRunnable, 3000) + handler.removeCallbacks(revertToMusicRunnable) handler.postDelayed(revertToMusicRunnable, 5000) } @@ -471,7 +461,12 @@ class AmbientDreamService : DreamService() { if (eventType == "volume") { volumeStrokeView?.setColor(Color.WHITE) volumeStrokeView?.updatePercentage(volumePercentage) - // Revert logic needed + + // Show and schedule hide + volumeStrokeView?.animate()?.alpha(1f)?.setDuration(300)?.start() + handler.removeCallbacks(volumeHideRunnable) + handler.postDelayed(volumeHideRunnable, 3000) + handler.removeCallbacks(revertToMusicRunnable) handler.postDelayed(revertToMusicRunnable, 5000) } @@ -487,7 +482,6 @@ class AmbientDreamService : DreamService() { private fun updateActiveSession(sessions: List?) { if (isDetached) return val playingSession = sessions?.firstOrNull { it.playbackState?.state == android.media.session.PlaybackState.STATE_PLAYING } - ?: sessions?.firstOrNull() if (playingSession != null) { if (currentController?.sessionToken != playingSession.sessionToken) { @@ -605,6 +599,15 @@ class AmbientDreamService : DreamService() { artistView?.text = artistName ?: "Unknown Artist" likeStatusView?.setImageResource(if (isAlreadyLiked) R.drawable.round_favorite_24 else R.drawable.rounded_favorite_24) + // Update Dynamic Shape + val size = dpToPx(320f).toFloat() + currentShapePath = com.sameerasw.essentials.utils.AmbientMusicShapeHelper.getShapePath( + "${trackTitle}_${artistName}", + size + ) + volumeStrokeView?.updatePath(currentShapePath!!) + clipContainer?.invalidateOutline() + if (directBitmap != null) { imageView?.setImageBitmap(directBitmap) return @@ -701,7 +704,7 @@ class AmbientDreamService : DreamService() { // Copy of Inner Class private inner class VolumeStrokeView( context: Context, - private val petalPath: Path, + private var petalPath: android.graphics.Path, private val percentage: Int ) : View(context) { private var currentPercentage: Float = percentage.toFloat() @@ -713,10 +716,16 @@ class AmbientDreamService : DreamService() { strokeWidth = dpToPx(6f).toFloat() strokeCap = Paint.Cap.ROUND } - private val pathMeasure = PathMeasure(petalPath, false) + private var pathMeasure = PathMeasure(petalPath, false) private val progressPath = Path() private var isDetached = false + fun updatePath(newPath: android.graphics.Path) { + this.petalPath = newPath + this.pathMeasure = PathMeasure(newPath, false) + invalidate() + } + fun cleanup() { isDetached = true animator?.cancel() diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/AmbientGlanceHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/AmbientGlanceHandler.kt index 69c080cc3..25c2fa915 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/AmbientGlanceHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/AmbientGlanceHandler.kt @@ -42,9 +42,16 @@ class AmbientGlanceHandler( private var volumeReceiver: BroadcastReceiver? = null private val handler = Handler(Looper.getMainLooper()) + private val volumeHideRunnable = Runnable { + volumeStrokeView?.animate()?.alpha(0f)?.setDuration(500)?.start() + } + private var clockView: View? = null private var centerContainer: FrameLayout? = null + private var clipContainer: FrameLayout? = null private var textContainer: LinearLayout? = null + + private var currentShapePath: android.graphics.Path? = null private var imageView: ImageView? = null private var titleView: TextView? = null @@ -70,40 +77,19 @@ class AmbientGlanceHandler( private val progressUpdateRunnable = object : Runnable { override fun run() { - if (overlayView == null || eventType == EVENT_VOLUME) return - - val mediaSessionManager = - service.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager - val sessions = mediaSessionManager.getActiveSessions( - android.content.ComponentName( - service, - ScreenOffAccessibilityService::class.java - ) - ) - - // Find playing session - val activeSession = sessions.firstOrNull { - it.playbackState?.state == android.media.session.PlaybackState.STATE_PLAYING - } - - if (activeSession != null) { - val position = activeSession.playbackState?.position ?: 0L - val duration = - activeSession.metadata?.getLong(android.media.MediaMetadata.METADATA_KEY_DURATION) - ?: 0L - - if (duration > 0) { - val progress = (position * 100 / duration).toInt() - volumeStrokeView?.updatePercentage(progress) - } - } else { - // No active playing session - if (isDockedMode) { - fadeOutAndRemove() // Hide if music stops/pauses in docked mode - } + if (overlayView == null || isDetached) return + + // Dismiss if music stops/pauses + val mediaSessionManager = service.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager + val componentName = android.content.ComponentName(service, ScreenOffAccessibilityService::class.java) + val sessions = mediaSessionManager.getActiveSessions(componentName) + val anyPlaying = sessions.any { it.playbackState?.state == android.media.session.PlaybackState.STATE_PLAYING } + + if (!anyPlaying) { + fadeOutAndRemove() + return } - if (overlayView == null || isDetached) return handler.postDelayed(this, 1000L) } } @@ -187,6 +173,11 @@ class AmbientGlanceHandler( else if (volumeKey == 25) volumeIconView?.setImageResource(R.drawable.rounded_volume_down_24) volumeIconView?.animate()?.alpha(1f)?.setDuration(200)?.start() volumeStrokeView?.setColor(Color.WHITE) + + // Show and schedule hide + volumeStrokeView?.animate()?.alpha(1f)?.setDuration(300)?.start() + handler.removeCallbacks(volumeHideRunnable) + handler.postDelayed(volumeHideRunnable, 3000) } // Update like status @@ -229,6 +220,15 @@ class AmbientGlanceHandler( likeStatusView?.setImageResource(if (isAlreadyLiked) R.drawable.round_favorite_24 else R.drawable.rounded_favorite_24) + // Update Dynamic Shape + val size = dpToPx(320f).toFloat() + currentShapePath = com.sameerasw.essentials.utils.AmbientMusicShapeHelper.getShapePath( + "${trackTitle}_${artistName}", + size + ) + volumeStrokeView?.updatePath(currentShapePath!!) + clipContainer?.invalidateOutline() + // Reload Bitmap try { val artHash = kotlin.math.abs("${trackTitle}_${artistName}".hashCode()) @@ -323,16 +323,15 @@ class AmbientGlanceHandler( val size = dpToPx(320f) - // Path for both clipping and stroke - val petalPath = createScallopPath(size.toFloat(), size.toFloat(), 12, 0.10f) + currentShapePath = com.sameerasw.essentials.utils.AmbientMusicShapeHelper.getRandomShapePath(size.toFloat()) // Container for clipping - val clipContainer = FrameLayout(context).apply { + clipContainer = FrameLayout(context).apply { layoutParams = FrameLayout.LayoutParams(size, size, Gravity.CENTER) outlineProvider = object : android.view.ViewOutlineProvider() { override fun getOutline(view: View, outline: android.graphics.Outline) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - outline.setPath(petalPath) + currentShapePath?.let { outline.setPath(it) } } else { outline.setOval(0, 0, view.width, view.height) } @@ -365,8 +364,8 @@ class AmbientGlanceHandler( setBackgroundColor(0x40000000.toInt()) } - clipContainer.addView(imageView) - clipContainer.addView(scrim) + clipContainer?.addView(imageView) + clipContainer?.addView(scrim) centerContainer?.addView(clipContainer) // Volume Icon @@ -389,11 +388,17 @@ class AmbientGlanceHandler( // Volume Stroke val initialPerc = if (eventType == EVENT_VOLUME) volumePercentage else 0 - volumeStrokeView = VolumeStrokeView(context, petalPath, initialPerc) + volumeStrokeView = VolumeStrokeView(context, currentShapePath!!, initialPerc) volumeStrokeView?.layoutParams = FrameLayout.LayoutParams(size + dpToPx(20f), size + dpToPx(20f), Gravity.CENTER) volumeStrokeView?.setColor(if (eventType == EVENT_VOLUME) Color.WHITE else Color.GRAY) + volumeStrokeView?.alpha = if (eventType == EVENT_VOLUME) 1f else 0f centerContainer?.addView(volumeStrokeView) + + if (eventType == EVENT_VOLUME) { + handler.removeCallbacks(volumeHideRunnable) + handler.postDelayed(volumeHideRunnable, 3000) + } // Start progress polling if not a volume notification if (eventType != EVENT_VOLUME) { @@ -478,6 +483,11 @@ class AmbientGlanceHandler( val max = it.getStreamMaxVolume(android.media.AudioManager.STREAM_MUSIC) val perc = (current.toFloat() / max.toFloat() * 100).toInt() volumeStrokeView?.updatePercentage(perc) + + // Show and schedule hide + volumeStrokeView?.animate()?.alpha(1f)?.setDuration(300)?.start() + handler.removeCallbacks(volumeHideRunnable) + handler.postDelayed(volumeHideRunnable, 3000) } } } @@ -701,7 +711,7 @@ class AmbientGlanceHandler( private inner class VolumeStrokeView( context: Context, - private val petalPath: Path, + private var petalPath: android.graphics.Path, private val percentage: Int ) : View(context) { private var currentPercentage: Float = percentage.toFloat() @@ -713,10 +723,16 @@ class AmbientGlanceHandler( strokeWidth = dpToPx(6f).toFloat() strokeCap = Paint.Cap.ROUND } - private val pathMeasure = PathMeasure(petalPath, false) + private var pathMeasure = PathMeasure(petalPath, false) private val progressPath = Path() private var isDetached = false + fun updatePath(newPath: android.graphics.Path) { + this.petalPath = newPath + this.pathMeasure = PathMeasure(newPath, false) + invalidate() + } + fun cleanup() { isDetached = true animator?.cancel() diff --git a/app/src/main/java/com/sameerasw/essentials/utils/AmbientMusicShapeHelper.kt b/app/src/main/java/com/sameerasw/essentials/utils/AmbientMusicShapeHelper.kt new file mode 100644 index 000000000..80dc56412 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/utils/AmbientMusicShapeHelper.kt @@ -0,0 +1,72 @@ +package com.sameerasw.essentials.utils + +import android.graphics.Matrix +import android.graphics.Path +import androidx.compose.material3.MaterialShapes +import androidx.graphics.shapes.RoundedPolygon +import androidx.graphics.shapes.toPath +import java.util.Random + +object AmbientMusicShapeHelper { + + private val allShapes = listOf( + MaterialShapes.Circle, + MaterialShapes.Square, + MaterialShapes.Slanted, + MaterialShapes.Arch, + MaterialShapes.Oval, + MaterialShapes.Pill, + MaterialShapes.Triangle, + MaterialShapes.Arrow, + MaterialShapes.Diamond, + MaterialShapes.ClamShell, + MaterialShapes.Pentagon, + MaterialShapes.Gem, + MaterialShapes.Sunny, + MaterialShapes.VerySunny, + MaterialShapes.Cookie4Sided, + MaterialShapes.Cookie6Sided, + MaterialShapes.Cookie7Sided, + MaterialShapes.Cookie9Sided, + MaterialShapes.Cookie12Sided, + MaterialShapes.Clover4Leaf, + MaterialShapes.Clover8Leaf, + MaterialShapes.SoftBurst, + MaterialShapes.Flower, + MaterialShapes.PuffyDiamond, + MaterialShapes.Ghostish, + MaterialShapes.PixelCircle, + MaterialShapes.Bun, + MaterialShapes.Heart + ) + + fun getShapePath(seed: String?, size: Float): Path { + val hash = seed?.hashCode() ?: 0 + val random = Random(hash.toLong()) + val shape = allShapes[random.nextInt(allShapes.size)] + + return shape.toAndroidPath(size) + } + + fun getRandomShapePath(size: Float): Path { + val random = Random() + val shape = allShapes[random.nextInt(allShapes.size)] + return shape.toAndroidPath(size) + } + + private fun RoundedPolygon.toAndroidPath(size: Float): Path { + val path = Path() + val composePath = this.toPath() + val matrix = Matrix() + + matrix.postScale(size, size) + + val androidPath = Path() + val resultPath = this.toPath() + val matrixObj = android.graphics.Matrix() + matrixObj.postScale(size, size) + resultPath.transform(matrixObj) + + return resultPath + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 81f93dcea..f6a104729 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,11 +17,12 @@ playServicesLocation = "21.3.0" playServicesWearable = "18.1.0" gson = "2.10.1" material = "1.13.0" -material3 = "1.4.0" +material3 = "1.5.0-alpha17" foundationLayoutVersion = "1.10.1" foundationVersion = "1.10.1" work = "2.9.1" sentry = "8.14.0" +graphicsShapes = "1.0.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -54,6 +55,7 @@ androidx-foundation-layout = { group = "androidx.compose.foundation", name = "fo androidx-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundationVersion" } androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } sentry-android = { group = "io.sentry", name = "sentry-android", version.ref = "sentry" } +androidx-graphics-shapes = { group = "androidx.graphics", name = "graphics-shapes", version.ref = "graphicsShapes" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 556f13af30a80a2793ebd8d4aeffd7a9d8ae8508 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 11 Apr 2026 17:39:47 +0530 Subject: [PATCH 10/23] feat: implement shape morphing animations for ambient music display --- .../services/dreams/AmbientDreamService.kt | 66 ++++++++++++++-- .../services/handlers/AmbientGlanceHandler.kt | 76 +++++++++++++++---- .../utils/AmbientMusicShapeHelper.kt | 36 +++++---- 3 files changed, 142 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/services/dreams/AmbientDreamService.kt b/app/src/main/java/com/sameerasw/essentials/services/dreams/AmbientDreamService.kt index 152554ab8..0840861d2 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/dreams/AmbientDreamService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/dreams/AmbientDreamService.kt @@ -63,8 +63,11 @@ class AmbientDreamService : DreamService() { private var musicContainer: FrameLayout? = null private var centerContainer: FrameLayout? = null private var clipContainer: FrameLayout? = null + private var currentPolygon: androidx.graphics.shapes.RoundedPolygon? = null + private var morphAnimator: android.animation.ValueAnimator? = null private var textContainer: LinearLayout? = null private var imageView: ImageView? = null + private var nextImageView: ImageView? = null private var titleView: TextView? = null private var artistView: TextView? = null private var likeStatusView: ImageView? = null @@ -245,6 +248,7 @@ class AmbientDreamService : DreamService() { val size = dpToPx(320f) + currentPolygon = com.sameerasw.essentials.utils.AmbientMusicShapeHelper.getRandomPolygon() currentShapePath = com.sameerasw.essentials.utils.AmbientMusicShapeHelper.getRandomShapePath(size.toFloat()) // Container for clipping @@ -268,6 +272,16 @@ class AmbientDreamService : DreamService() { FrameLayout.LayoutParams.MATCH_PARENT ) scaleType = ImageView.ScaleType.CENTER_CROP + setImageDrawable(ColorDrawable(Color.DKGRAY)) + } + + nextImageView = ImageView(this).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + scaleType = ImageView.ScaleType.CENTER_CROP + alpha = 0f } // Dark overlay @@ -280,6 +294,7 @@ class AmbientDreamService : DreamService() { } clipContainer?.addView(imageView) + clipContainer?.addView(nextImageView) clipContainer?.addView(scrim) centerContainer?.addView(clipContainer) @@ -599,17 +614,52 @@ class AmbientDreamService : DreamService() { artistView?.text = artistName ?: "Unknown Artist" likeStatusView?.setImageResource(if (isAlreadyLiked) R.drawable.round_favorite_24 else R.drawable.rounded_favorite_24) - // Update Dynamic Shape + // Update Dynamic Shape with Morphing val size = dpToPx(320f).toFloat() - currentShapePath = com.sameerasw.essentials.utils.AmbientMusicShapeHelper.getShapePath( - "${trackTitle}_${artistName}", - size - ) - volumeStrokeView?.updatePath(currentShapePath!!) - clipContainer?.invalidateOutline() + val newPolygon = com.sameerasw.essentials.utils.AmbientMusicShapeHelper.getPolygon("${trackTitle}_${artistName}") + + if (currentPolygon != null && currentPolygon != newPolygon) { + val morph = androidx.graphics.shapes.Morph(currentPolygon!!, newPolygon) + morphAnimator?.cancel() + morphAnimator = android.animation.ValueAnimator.ofFloat(0f, 1f).apply { + duration = 800 + interpolator = android.view.animation.PathInterpolator(0.4f, 0f, 0.2f, 1f) + addUpdateListener { animator -> + val progress = animator.animatedValue as Float + currentShapePath?.let { path -> + com.sameerasw.essentials.utils.AmbientMusicShapeHelper.updatePathFromMorph( + morph, progress, size, path, progress * 360f + ) + volumeStrokeView?.updatePath(path) + clipContainer?.invalidateOutline() + } + + nextImageView?.alpha = progress + } + addListener(object : android.animation.AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: android.animation.Animator) { + imageView?.setImageDrawable(nextImageView?.drawable) + nextImageView?.alpha = 0f + } + }) + start() + } + } else { + // ... (rest of the else block) + currentShapePath = com.sameerasw.essentials.utils.AmbientMusicShapeHelper.getShapePath( + "${trackTitle}_${artistName}", + size + ) + volumeStrokeView?.updatePath(currentShapePath!!) + clipContainer?.invalidateOutline() + } + currentPolygon = newPolygon if (directBitmap != null) { - imageView?.setImageBitmap(directBitmap) + nextImageView?.setImageBitmap(directBitmap) + if (morphAnimator?.isRunning != true) { + imageView?.setImageBitmap(directBitmap) + } return } diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/AmbientGlanceHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/AmbientGlanceHandler.kt index 25c2fa915..bc5c8f621 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/AmbientGlanceHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/AmbientGlanceHandler.kt @@ -52,8 +52,11 @@ class AmbientGlanceHandler( private var textContainer: LinearLayout? = null private var currentShapePath: android.graphics.Path? = null + private var currentPolygon: androidx.graphics.shapes.RoundedPolygon? = null + private var morphAnimator: android.animation.ValueAnimator? = null private var imageView: ImageView? = null + private var nextImageView: ImageView? = null private var titleView: TextView? = null private var artistView: TextView? = null @@ -220,14 +223,45 @@ class AmbientGlanceHandler( likeStatusView?.setImageResource(if (isAlreadyLiked) R.drawable.round_favorite_24 else R.drawable.rounded_favorite_24) - // Update Dynamic Shape + // Update Dynamic Shape with Morphing val size = dpToPx(320f).toFloat() - currentShapePath = com.sameerasw.essentials.utils.AmbientMusicShapeHelper.getShapePath( - "${trackTitle}_${artistName}", - size - ) - volumeStrokeView?.updatePath(currentShapePath!!) - clipContainer?.invalidateOutline() + val newPolygon = com.sameerasw.essentials.utils.AmbientMusicShapeHelper.getPolygon("${trackTitle}_${artistName}") + + if (currentPolygon != null && currentPolygon != newPolygon) { + val morph = androidx.graphics.shapes.Morph(currentPolygon!!, newPolygon) + morphAnimator?.cancel() + morphAnimator = android.animation.ValueAnimator.ofFloat(0f, 1f).apply { + duration = 800 + interpolator = android.view.animation.PathInterpolator(0.4f, 0f, 0.2f, 1f) + addUpdateListener { animator -> + val progress = animator.animatedValue as Float + currentShapePath?.let { path -> + com.sameerasw.essentials.utils.AmbientMusicShapeHelper.updatePathFromMorph( + morph, progress, size, path, progress * 360f + ) + volumeStrokeView?.updatePath(path) + clipContainer?.invalidateOutline() + } + + nextImageView?.alpha = progress + } + addListener(object : android.animation.AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: android.animation.Animator) { + imageView?.setImageDrawable(nextImageView?.drawable) + nextImageView?.alpha = 0f + } + }) + start() + } + } else { + currentShapePath = com.sameerasw.essentials.utils.AmbientMusicShapeHelper.getShapePath( + "${trackTitle}_${artistName}", + size + ) + volumeStrokeView?.updatePath(currentShapePath!!) + clipContainer?.invalidateOutline() + } + currentPolygon = newPolygon // Reload Bitmap try { @@ -241,15 +275,16 @@ class AmbientGlanceHandler( } if (bitmap != null) { - imageView?.setImageBitmap(bitmap) + nextImageView?.setImageBitmap(bitmap) + if (morphAnimator?.isRunning != true) { + imageView?.setImageBitmap(bitmap) + } } else { - imageView?.setImageDrawable( - android.graphics.drawable.ColorDrawable( - getPrimaryColor( - service - ) - ) - ) + val placeholder = android.graphics.drawable.ColorDrawable(getPrimaryColor(service)) + nextImageView?.setImageDrawable(placeholder) + if (morphAnimator?.isRunning != true) { + imageView?.setImageDrawable(placeholder) + } } } catch (_: Exception) { } @@ -323,6 +358,7 @@ class AmbientGlanceHandler( val size = dpToPx(320f) + currentPolygon = com.sameerasw.essentials.utils.AmbientMusicShapeHelper.getRandomPolygon() currentShapePath = com.sameerasw.essentials.utils.AmbientMusicShapeHelper.getRandomShapePath(size.toFloat()) // Container for clipping @@ -355,6 +391,15 @@ class AmbientGlanceHandler( } } + nextImageView = ImageView(context).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + scaleType = ImageView.ScaleType.CENTER_CROP + alpha = 0f + } + // Dark overlay val scrim = View(context).apply { layoutParams = FrameLayout.LayoutParams( @@ -365,6 +410,7 @@ class AmbientGlanceHandler( } clipContainer?.addView(imageView) + clipContainer?.addView(nextImageView) clipContainer?.addView(scrim) centerContainer?.addView(clipContainer) diff --git a/app/src/main/java/com/sameerasw/essentials/utils/AmbientMusicShapeHelper.kt b/app/src/main/java/com/sameerasw/essentials/utils/AmbientMusicShapeHelper.kt index 80dc56412..caab7609c 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/AmbientMusicShapeHelper.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/AmbientMusicShapeHelper.kt @@ -41,32 +41,42 @@ object AmbientMusicShapeHelper { ) fun getShapePath(seed: String?, size: Float): Path { + return getPolygon(seed).toAndroidPath(size) + } + + fun getRandomShapePath(size: Float): Path { + return getRandomPolygon().toAndroidPath(size) + } + + fun getPolygon(seed: String?): RoundedPolygon { val hash = seed?.hashCode() ?: 0 val random = Random(hash.toLong()) - val shape = allShapes[random.nextInt(allShapes.size)] - - return shape.toAndroidPath(size) + return allShapes[random.nextInt(allShapes.size)] } - fun getRandomShapePath(size: Float): Path { + fun getRandomPolygon(): RoundedPolygon { val random = Random() - val shape = allShapes[random.nextInt(allShapes.size)] - return shape.toAndroidPath(size) + return allShapes[random.nextInt(allShapes.size)] } - private fun RoundedPolygon.toAndroidPath(size: Float): Path { - val path = Path() - val composePath = this.toPath() - val matrix = Matrix() - + fun updatePathFromMorph(morph: androidx.graphics.shapes.Morph, progress: Float, size: Float, targetPath: Path, rotation: Float = 0f) { + val rawPath = morph.toPath(progress) + val matrix = android.graphics.Matrix() matrix.postScale(size, size) + if (rotation != 0f) { + matrix.postRotate(rotation, size / 2f, size / 2f) + } - val androidPath = Path() + targetPath.reset() + targetPath.set(rawPath) + targetPath.transform(matrix) + } + + private fun RoundedPolygon.toAndroidPath(size: Float): Path { val resultPath = this.toPath() val matrixObj = android.graphics.Matrix() matrixObj.postScale(size, size) resultPath.transform(matrixObj) - return resultPath } } From 529f73e1cfbcd4c2a58a26cceff73eca2b5808c9 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 11 Apr 2026 18:09:58 +0530 Subject: [PATCH 11/23] feat: add feature to automatically close Quick Settings panel when device is locked --- .../data/repository/SettingsRepository.kt | 1 + .../domain/registry/FeatureRegistry.kt | 7 +++ .../services/handlers/SecurityHandler.kt | 57 ++++++++++++++++++- .../configs/ScreenLockedSecuritySettingsUI.kt | 25 ++++++++ .../essentials/viewmodels/MainViewModel.kt | 11 ++++ app/src/main/res/values/strings.xml | 4 ++ 6 files changed, 104 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 56ba0e15a..fd73750be 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -102,6 +102,7 @@ class SettingsRepository(private val context: Context) { const val KEY_FLASHLIGHT_PULSE_FACEDOWN_ONLY = "flashlight_pulse_facedown_only" const val KEY_SCREEN_LOCKED_SECURITY_ENABLED = "screen_locked_security_enabled" + const val KEY_DISABLE_QS_WHEN_LOCKED = "disable_qs_when_locked" const val KEY_AUTO_UPDATE_ENABLED = "auto_update_enabled" const val KEY_UPDATE_NOTIFICATION_ENABLED = "update_notification_enabled" diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt index 4da2bfb9f..f77f84c77 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt @@ -540,6 +540,13 @@ object FeatureRegistry { R.array.keywords_network_visibility, R.string.feat_qs_tiles_title ), + SearchSetting( + R.string.search_disable_qs_locked_title, + R.string.search_disable_qs_locked_desc, + "Disable QS Locked", + R.array.keywords_network_visibility, + R.string.feat_qs_tiles_title + ), SearchSetting( R.string.search_qs_mono_title, R.string.search_qs_mono_desc, diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/SecurityHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/SecurityHandler.kt index 0c5bfcc87..dba1fa8cb 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/SecurityHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/SecurityHandler.kt @@ -28,6 +28,38 @@ class SecurityHandler( val keyguardManager = service.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager if (keyguardManager.isKeyguardLocked) { + // Disable QS when locked logic + val isDisableQsWhenLocked = prefs.getBoolean("disable_qs_when_locked", false) + if (isDisableQsWhenLocked) { + // Log for debugging + // if (event.packageName == "com.android.systemui") { + // android.util.Log.d("SecurityHandler", "SystemUI Event Received: ${event.eventType}") + // } + + val source = event.source ?: service.rootInActiveWindow + var isQsVisible = false + + if (source != null && source.packageName == "com.android.systemui") { + isQsVisible = scanForQs(source) + } + + if (isQsVisible) { + setReducedAnimationScale() + service.performGlobalAction(GLOBAL_ACTION_BACK) + lockDeviceHard() + com.sameerasw.essentials.utils.HapticUtil.performHapticForService( + service, + com.sameerasw.essentials.domain.HapticFeedbackType.DOUBLE + ) + Toast.makeText( + service, + com.sameerasw.essentials.R.string.error_unlock_network_settings, + Toast.LENGTH_SHORT + ).show() + return + } + } + if (event.eventType == AccessibilityEvent.TYPE_VIEW_CLICKED || event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { val source = event.source if (source != null) { @@ -38,12 +70,35 @@ class SecurityHandler( } } + private fun scanForQs(node: AccessibilityNodeInfo): Boolean { + val nodeText = node.text?.toString() ?: "" + val nodeDesc = node.contentDescription?.toString() ?: "" + val nodeId = node.viewIdResourceName ?: "" + + if (nodeText.contains("Quick settings", ignoreCase = true) || + nodeDesc.contains("Quick settings", ignoreCase = true) || + nodeId.contains("quick_settings", ignoreCase = true) || + nodeId.contains("qs_panel", ignoreCase = true) || + nodeText.contains("QuickSettingsScene") || + nodeDesc.contains("QuickSettingsScene")) { + return true + } + + for (i in 0 until node.childCount) { + val child = node.getChild(i) + if (child != null && scanForQs(child)) { + return true + } + } + return false + } + private fun checkNetworkTileInteraction(source: AccessibilityNodeInfo) { val keywords = listOf( "Internet", "Mobile Data", "Wi-Fi", // English "Daten", "WLAN", // German "Datos", // Spanish - "Donn", // French (Données) + "Donn", // French (Donn\u00e9es) "Cellular" // Some variants ) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/ScreenLockedSecuritySettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/ScreenLockedSecuritySettingsUI.kt index 982152abd..7d3ee8018 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/ScreenLockedSecuritySettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/ScreenLockedSecuritySettingsUI.kt @@ -77,6 +77,31 @@ fun ScreenLockedSecuritySettingsUI( iconRes = R.drawable.rounded_security_24, modifier = Modifier.highlight(highlightSetting == "screen_locked_security_toggle") ) + + IconToggleItem( + title = stringResource(R.string.disable_qs_when_locked_title), + description = stringResource(R.string.disable_qs_when_locked_desc), + isChecked = viewModel.isDisableQsWhenLockedEnabled.value, + onCheckedChange = { isChecked -> + if (context is FragmentActivity) { + BiometricHelper.showBiometricPrompt( + activity = context, + title = context.getString(R.string.screen_locked_security_dialog_title), + subtitle = if (isChecked) context.getString(R.string.screen_locked_security_auth_enable) else context.getString( + R.string.screen_locked_security_auth_disable + ), + onSuccess = { + viewModel.setDisableQsWhenLockedEnabled(isChecked, context) + } + ) + } else { + viewModel.setDisableQsWhenLockedEnabled(isChecked, context) + } + }, + enabled = viewModel.isScreenLockedSecurityEnabled.value && isAccessibilityEnabled && viewModel.isWriteSecureSettingsEnabled.value && viewModel.isDeviceAdminEnabled.value, + iconRes = R.drawable.rounded_security_24, + modifier = Modifier.highlight(highlightSetting == "disable_qs_when_locked_toggle") + ) } diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index 20827495d..451e0be33 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -138,6 +138,7 @@ class MainViewModel : ViewModel() { val isScreenLockedSecurityEnabled = mutableStateOf(false) + val isDisableQsWhenLockedEnabled = mutableStateOf(false) val isDeviceAdminEnabled = mutableStateOf(false) val isDeveloperModeEnabled = mutableStateOf(false) val isNotificationPolicyAccessGranted = mutableStateOf(false) @@ -280,6 +281,9 @@ class MainViewModel : ViewModel() { SettingsRepository.KEY_SCREEN_LOCKED_SECURITY_ENABLED -> isScreenLockedSecurityEnabled.value = settingsRepository.getBoolean(key) + SettingsRepository.KEY_DISABLE_QS_WHEN_LOCKED -> isDisableQsWhenLockedEnabled.value = + settingsRepository.getBoolean(key) + SettingsRepository.KEY_MAPS_POWER_SAVING_ENABLED -> { isMapsPowerSavingEnabled.value = settingsRepository.getBoolean(key) MapsState.isEnabled = isMapsPowerSavingEnabled.value @@ -842,6 +846,8 @@ class MainViewModel : ViewModel() { isScreenLockedSecurityEnabled.value = settingsRepository.getBoolean(SettingsRepository.KEY_SCREEN_LOCKED_SECURITY_ENABLED) + isDisableQsWhenLockedEnabled.value = + settingsRepository.getBoolean(SettingsRepository.KEY_DISABLE_QS_WHEN_LOCKED, false) isDeviceAdminEnabled.value = isDeviceAdminActive(context) isAutoUpdateEnabled.value = @@ -2284,6 +2290,11 @@ class MainViewModel : ViewModel() { ) } + fun setDisableQsWhenLockedEnabled(enabled: Boolean, context: Context) { + isDisableQsWhenLockedEnabled.value = enabled + settingsRepository.putBoolean(SettingsRepository.KEY_DISABLE_QS_WHEN_LOCKED, enabled) + } + fun setNotificationLightingGlowSides(sides: Set, context: Context) { notificationLightingGlowSides.value = sides settingsRepository.saveNotificationLightingGlowSides(sides) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b9f977836..ab443170d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -260,6 +260,10 @@ This feature is not foolproof. There may be edge cases where someone still being able to interact with the tile. \nAlso keep in mind that Android will always allow to do a forced reboot and Pixels will always allow the device to be turned off from the lock screen as well. Make sure to remove the airplane mode tile from quick settings as that is not preventable because it does not open a dialog window. When enabled, the Quick Settings panel will be immediately closed and the device will be locked down if someone attempt to interact with Internet tiles while the device is locked. \n\nThis will also disable biometric unlock to prevent further unauthorized access. Animation scale will be reduced to 0.1x while locked to make it even harder to interact with. + Disable QS when locked + Immediately close the Quick Settings panel if someone tries to expand it while the device is locked. + Disable QS Locked + Prevent expanding Quick Settings when device is locked Re-order modes From bedc6191a97645b71da4c5c41c6c5bfd389a4666 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 12 Apr 2026 12:32:02 +0530 Subject: [PATCH 12/23] feat: implement sweep animation style for notification lighting --- .../data/repository/SettingsRepository.kt | 24 ++ .../domain/model/NotificationLightingStyle.kt | 3 +- .../NotificationLightingSweepPosition.kt | 7 + .../services/NotificationLightingService.kt | 62 +++-- .../handlers/NotificationLightingHandler.kt | 69 +++-- .../pickers/EdgeLightingStylePicker.kt | 6 +- .../configs/NotificationLightingSettingsUI.kt | 122 ++++++++- .../essentials/utils/OverlayHelper.kt | 259 +++++++++++++++--- .../essentials/viewmodels/MainViewModel.kt | 166 ++++++----- .../main/res/drawable/rounded_target_24.xml | 5 + app/src/main/res/values/strings.xml | 6 + 11 files changed, 573 insertions(+), 156 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/domain/model/NotificationLightingSweepPosition.kt create mode 100644 app/src/main/res/drawable/rounded_target_24.xml diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index fd73750be..c4e8337c2 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -8,6 +8,7 @@ import com.sameerasw.essentials.domain.model.AppSelection import com.sameerasw.essentials.domain.model.NotificationLightingColorMode import com.sameerasw.essentials.domain.model.NotificationLightingSide import com.sameerasw.essentials.domain.model.NotificationLightingStyle +import com.sameerasw.essentials.domain.model.NotificationLightingSweepPosition import com.sameerasw.essentials.domain.model.DnsPreset import com.sameerasw.essentials.domain.model.TrackedRepo import com.sameerasw.essentials.domain.model.github.GitHubUser @@ -66,6 +67,9 @@ class SettingsRepository(private val context: Context) { const val KEY_EDGE_LIGHTING_CORNER_RADIUS = "edge_lighting_corner_radius" const val KEY_EDGE_LIGHTING_STROKE_THICKNESS = "edge_lighting_stroke_thickness" const val KEY_EDGE_LIGHTING_SELECTED_APPS = "edge_lighting_selected_apps" + const val KEY_EDGE_LIGHTING_SWEEP_POSITION = "edge_lighting_sweep_position" + const val KEY_EDGE_LIGHTING_SWEEP_THICKNESS = "edge_lighting_sweep_thickness" + const val KEY_LOCK_SCREEN_WALLPAPER_SOURCE = "lock_screen_wallpaper_source" const val KEY_CALL_VIBRATIONS_ENABLED = "call_vibrations_enabled" const val KEY_LAST_CALL_STATE = "last_call_state" @@ -286,11 +290,30 @@ class SettingsRepository(private val context: Context) { } } + fun saveNotificationLightingGlowSides(sides: Set) { val json = gson.toJson(sides) putString(KEY_EDGE_LIGHTING_GLOW_SIDES, json) } + fun getNotificationLightingSweepPosition(): NotificationLightingSweepPosition { + val posName = prefs.getString( + KEY_EDGE_LIGHTING_SWEEP_POSITION, + NotificationLightingSweepPosition.CENTER.name + ) + return try { + NotificationLightingSweepPosition.valueOf( + posName ?: NotificationLightingSweepPosition.CENTER.name + ) + } catch (e: Exception) { + NotificationLightingSweepPosition.CENTER + } + } + + fun saveNotificationLightingSweepPosition(position: NotificationLightingSweepPosition) { + putString(KEY_EDGE_LIGHTING_SWEEP_POSITION, position.name) + } + fun getFreezeAutoExcludedApps(): Set { val json = prefs.getString(KEY_FREEZE_AUTO_EXCLUDED_APPS, null) return if (json != null) { @@ -378,6 +401,7 @@ class SettingsRepository(private val context: Context) { } // Feature specific App selections + fun loadNotificationLightingSelectedApps() = loadAppSelection(KEY_EDGE_LIGHTING_SELECTED_APPS) fun saveNotificationLightingSelectedApps(apps: List) = saveAppSelection(KEY_EDGE_LIGHTING_SELECTED_APPS, apps) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/NotificationLightingStyle.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/NotificationLightingStyle.kt index d80c0c666..5555b3f72 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/model/NotificationLightingStyle.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/NotificationLightingStyle.kt @@ -3,5 +3,6 @@ package com.sameerasw.essentials.domain.model enum class NotificationLightingStyle { STROKE, GLOW, - INDICATOR + INDICATOR, + SWEEP } diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/NotificationLightingSweepPosition.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/NotificationLightingSweepPosition.kt new file mode 100644 index 000000000..b5b11cd56 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/NotificationLightingSweepPosition.kt @@ -0,0 +1,7 @@ +package com.sameerasw.essentials.domain.model + +enum class NotificationLightingSweepPosition { + LEFT, + CENTER, + RIGHT +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/NotificationLightingService.kt b/app/src/main/java/com/sameerasw/essentials/services/NotificationLightingService.kt index 6620a903f..19923cbe2 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/NotificationLightingService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/NotificationLightingService.kt @@ -46,6 +46,8 @@ class NotificationLightingService : Service() { private var indicatorY: Float = 2f private var indicatorScale: Float = 1.0f private var isAmbientDisplay: Boolean = false + private var sweepPosition: String = "CENTER" + private var sweepThickness: Float = 8f private var screenReceiver: BroadcastReceiver? = null @@ -145,6 +147,8 @@ class NotificationLightingService : Service() { indicatorY = intent?.getFloatExtra("indicator_y", 2f) ?: 2f indicatorScale = intent?.getFloatExtra("indicator_scale", 1.0f) ?: 1.0f isAmbientDisplay = intent?.getBooleanExtra("is_ambient_display", false) ?: false + sweepPosition = intent?.getStringExtra("sweep_position") ?: "CENTER" + sweepThickness = intent?.getFloatExtra("sweep_thickness", 8f) ?: 8f val ignoreScreenState = intent?.getBooleanExtra("ignore_screen_state", false) ?: false val removePreview = intent?.getBooleanExtra("remove_preview", false) ?: false @@ -199,6 +203,8 @@ class NotificationLightingService : Service() { putExtra("indicator_x", indicatorX) putExtra("indicator_y", indicatorY) putExtra("indicator_scale", indicatorScale) + putExtra("sweep_position", sweepPosition) + putExtra("sweep_thickness", sweepThickness) if (intent?.hasExtra("resolved_color") == true) { putExtra("resolved_color", intent.getIntExtra("resolved_color", 0)) } @@ -281,9 +287,9 @@ class NotificationLightingService : Service() { } private fun showOverlay() { - // For preview mode, remove existing overlays first to update with new corner radius + // For preview mode, remove existing overlays immediately to facilitate rapid re-triggering if (isPreview && overlayViews.isNotEmpty()) { - removeOverlay() + removeOverlay(immediate = true) } if (overlayViews.isNotEmpty()) return @@ -317,24 +323,31 @@ class NotificationLightingService : Service() { if (OverlayHelper.addOverlayView(windowManager, overlay, params)) { overlayViews.add(overlay) if (isPreview) { - // For preview mode, show static preview + // For preview mode OverlayHelper.showPreview( overlay, edgeLightingStyle, strokeThicknessDp, indicatorX, indicatorY, - indicatorScale + indicatorScale, + pulseDurationMillis = pulseDuration ) } else { - // Normal mode: pulse the overlay + // Normal mode OverlayHelper.pulseOverlay( overlay, - maxPulses = pulseCount, + maxPulses = if (isPreview) 1 else pulseCount, pulseDurationMillis = pulseDuration, style = edgeLightingStyle, strokeWidthDp = strokeThicknessDp, - indicatorX = indicatorX, + indicatorX = if (edgeLightingStyle == NotificationLightingStyle.SWEEP) { + when (sweepPosition) { + "LEFT" -> 0f + "RIGHT" -> 100f + else -> 50f + } + } else indicatorX, indicatorY = indicatorY, indicatorScale = indicatorScale ) { @@ -398,21 +411,36 @@ class NotificationLightingService : Service() { } } - private fun removeOverlay() { - // Use fade-out animation for each overlay view - overlayViews.forEach { view -> - OverlayHelper.fadeOutAndRemoveOverlay(windowManager, view, overlayViews) { - // When all overlays are removed, stop foreground - if (overlayViews.isEmpty()) { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - stopForeground(true) + private fun removeOverlay(immediate: Boolean = false) { + val iterator = overlayViews.iterator() + while (iterator.hasNext()) { + val view = iterator.next() + if (immediate) { + OverlayHelper.removeOverlayView(windowManager, view) + iterator.remove() + } else { + OverlayHelper.fadeOutAndRemoveOverlay(windowManager, view, overlayViews) { + // When all overlays are removed, stop foreground + if (overlayViews.isEmpty()) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + stopForeground(true) + } + } catch (_: Exception) { } - } catch (_: Exception) { } } } } + + if (immediate && overlayViews.isEmpty()) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + stopForeground(true) + } + } catch (_: Exception) { + } + } } private fun canDrawOverlays(): Boolean { diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/NotificationLightingHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/NotificationLightingHandler.kt index 7842fddef..76c552cf7 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/NotificationLightingHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/NotificationLightingHandler.kt @@ -39,6 +39,8 @@ class NotificationLightingHandler( private var indicatorX: Float = 50f private var indicatorY: Float = 2f private var indicatorScale: Float = 1.0f + private var sweepPosition: String = "CENTER" + private var sweepThickness: Float = 8f private var isAmbientDisplayRequested: Boolean = false private var isAmbientShowLockScreen: Boolean = false @@ -79,6 +81,9 @@ class NotificationLightingHandler( indicatorScale = intent.getFloatExtra("indicator_scale", 1.0f) isAmbientDisplayRequested = intent.getBooleanExtra("is_ambient_display", false) isAmbientShowLockScreen = intent.getBooleanExtra("is_ambient_show_lock_screen", false) + sweepPosition = intent.getStringExtra("sweep_position") ?: "CENTER" + sweepThickness = intent.getFloatExtra("sweep_thickness", 8f) + sweepThickness = intent.getFloatExtra("sweep_thickness", 8f) isInterrupted = false val removePreview = intent.getBooleanExtra("remove_preview", false) @@ -102,26 +107,34 @@ class NotificationLightingHandler( val prefs = service.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) val onlyShowWhenScreenOff = prefs.getBoolean("edge_lighting_only_screen_off", true) if (onlyShowWhenScreenOff) { - removeOverlay() + removeOverlay(immediate = true) } } } - fun removeOverlay() { - for (overlay in overlayViews) { - try { - OverlayHelper.fadeOutAndRemoveOverlay(windowManager, overlay, overlayViews) - } catch (e: Exception) { - e.printStackTrace() + fun removeOverlay(immediate: Boolean = false) { + val iterator = overlayViews.iterator() + while (iterator.hasNext()) { + val overlay = iterator.next() + if (immediate) { + try { + windowManager?.removeView(overlay) + } catch (_: Exception) {} + iterator.remove() + } else { + try { + OverlayHelper.fadeOutAndRemoveOverlay(windowManager, overlay, overlayViews) + } catch (e: Exception) { + e.printStackTrace() + } } } - overlayViews.clear() } private fun showNotificationLighting() { - // For preview mode, remove existing overlays first - if (overlayViews.isNotEmpty()) { - removeOverlay() + // For preview mode, remove existing overlays + if (overlayViews.isNotEmpty() && isPreview) { + removeOverlay(immediate = true) } windowManager = service.getSystemService(Context.WINDOW_SERVICE) as WindowManager val powerManager = service.getSystemService(Context.POWER_SERVICE) as PowerManager @@ -156,11 +169,12 @@ class NotificationLightingHandler( val overlay = OverlayHelper.createOverlayView( service, color, - strokeDp = strokeThicknessDp, +// strokeDp = strokeThicknessDp, cornerRadiusDp = cornerRadiusDp, style = edgeLightingStyle, glowSides = glowSides, - indicatorScale = indicatorScale + indicatorScale = indicatorScale, + strokeDp = if (edgeLightingStyle == NotificationLightingStyle.SWEEP) sweepThickness else strokeThicknessDp, ) val params = OverlayHelper.createOverlayLayoutParams(overlayType) @@ -246,14 +260,21 @@ class NotificationLightingHandler( if (OverlayHelper.addOverlayView(windowManager, overlay, params)) { overlayViews.add(overlay) - if (isPreview) { + if (isPreview && edgeLightingStyle != NotificationLightingStyle.SWEEP) { OverlayHelper.showPreview( overlay, edgeLightingStyle, strokeThicknessDp, - indicatorX, + indicatorX = if (edgeLightingStyle == NotificationLightingStyle.SWEEP) { + when (sweepPosition) { + "LEFT" -> 0f + "RIGHT" -> 100f + else -> 50f + } + } else indicatorX, indicatorY, - indicatorScale + indicatorScale, + pulseDurationMillis = pulseDuration ) } else { startPulsing(overlay) @@ -265,16 +286,22 @@ class NotificationLightingHandler( } } - private fun startPulsing(overlay: View) { + private fun startPulsing(overlay: View, intent: Intent? = null) { OverlayHelper.pulseOverlay( overlay, - maxPulses = pulseCount, + maxPulses = if (isPreview) 1 else pulseCount, pulseDurationMillis = pulseDuration, style = edgeLightingStyle, - strokeWidthDp = strokeThicknessDp, - indicatorX = indicatorX, + strokeWidthDp = if (edgeLightingStyle == NotificationLightingStyle.SWEEP) sweepThickness else strokeThicknessDp, + indicatorX = if (edgeLightingStyle == NotificationLightingStyle.SWEEP) { + when (sweepPosition) { + "LEFT" -> 0f + "RIGHT" -> 100f + else -> 50f + } + } else indicatorX, indicatorY = indicatorY, - indicatorScale = indicatorScale + indicatorScale = indicatorScale, ) { if (isAmbientDisplayRequested && !isInterrupted && !isPreview && !isAmbientShowLockScreen) { service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_LOCK_SCREEN) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/EdgeLightingStylePicker.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/EdgeLightingStylePicker.kt index 53c446791..1c1bd6ff9 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/EdgeLightingStylePicker.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/EdgeLightingStylePicker.kt @@ -34,12 +34,14 @@ fun NotificationLightingStylePicker( val styles = listOf( NotificationLightingStyle.STROKE, NotificationLightingStyle.GLOW, - NotificationLightingStyle.INDICATOR + NotificationLightingStyle.INDICATOR, + NotificationLightingStyle.SWEEP ) val icons = listOf( R.drawable.rounded_rounded_corner_24, R.drawable.rounded_blur_linear_24, - R.drawable.rounded_circles_24 + R.drawable.rounded_circles_24, + R.drawable.rounded_target_24 ) val view = LocalView.current diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/NotificationLightingSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/NotificationLightingSettingsUI.kt index d9e5bbafc..2eb571e1e 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/NotificationLightingSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/NotificationLightingSettingsUI.kt @@ -16,9 +16,11 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button +import androidx.compose.material3.ButtonGroupDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.ToggleButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -34,10 +36,15 @@ import androidx.compose.ui.graphics.Color 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.semantics.role +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.model.NotificationLightingColorMode +import com.sameerasw.essentials.domain.model.NotificationLightingSide import com.sameerasw.essentials.domain.model.NotificationLightingStyle +import com.sameerasw.essentials.domain.model.NotificationLightingSweepPosition import com.sameerasw.essentials.ui.components.cards.IconToggleItem import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer import com.sameerasw.essentials.ui.components.pickers.GlowSidesPicker @@ -263,6 +270,61 @@ fun NotificationLightingSettingsUI( } } + // Sweep Adjustment + if (style == NotificationLightingStyle.SWEEP) { + Text( + text = stringResource(R.string.notification_lighting_sweep_position_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + RoundedCardContainer(modifier = Modifier) { + SweepPositionPicker( + selectedPosition = viewModel.notificationLightingSweepPosition.value, + onPositionSelected = { pos -> + viewModel.setNotificationLightingSweepPosition(pos, context) + viewModel.triggerNotificationLightingForSweep( + context, + pos, + viewModel.notificationLightingSweepThickness.floatValue + ) + // Auto remove preview + coroutineScope.launch { + delay(5000) + viewModel.removePreviewOverlay(context) + } + } + ) + + ConfigSliderItem( + title = stringResource(R.string.notification_lighting_sweep_thickness_title), + value = viewModel.notificationLightingSweepThickness.floatValue, + onValueChange = { newValue -> + viewModel.notificationLightingSweepThickness.floatValue = newValue + HapticUtil.performSliderHaptic(view) + viewModel.triggerNotificationLightingForSweep( + context, + viewModel.notificationLightingSweepPosition.value, + newValue + ) + }, + valueRange = 1f..50f, + valueFormatter = { "%.1f".format(it) }, + onValueChangeFinished = { + viewModel.saveNotificationLightingSweepThickness( + context, + viewModel.notificationLightingSweepThickness.floatValue + ) + coroutineScope.launch { + delay(5000) + viewModel.removePreviewOverlay(context) + } + } + ) + } + } + // Indicator Adjustment Section (For INDICATOR style) if (style == NotificationLightingStyle.INDICATOR) { Text( @@ -370,8 +432,8 @@ fun NotificationLightingSettingsUI( } - // Animation Settings (Only for STROKE and GLOW) - if (style == NotificationLightingStyle.STROKE || style == NotificationLightingStyle.GLOW) { + // Animation Settings (Only for STROKE, GLOW and SWEEP) + if (style == NotificationLightingStyle.STROKE || style == NotificationLightingStyle.GLOW || style == NotificationLightingStyle.SWEEP) { Text( text = stringResource(R.string.settings_section_animation), style = MaterialTheme.typography.titleMedium, @@ -427,6 +489,7 @@ fun NotificationLightingSettingsUI( onModeSelected = { mode -> HapticUtil.performVirtualKeyHaptic(view) viewModel.setNotificationLightingColorMode(mode, context) + viewModel.triggerNotificationLighting(context) } ) @@ -528,6 +591,7 @@ fun NotificationLightingSettingsUI( colorInt, context ) + viewModel.triggerNotificationLighting(context) } ) } @@ -663,3 +727,57 @@ fun ColorCircle( } } } + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun SweepPositionPicker( + selectedPosition: NotificationLightingSweepPosition, + onPositionSelected: (NotificationLightingSweepPosition) -> Unit, + modifier: Modifier = Modifier +) { + val positions = listOf( + NotificationLightingSweepPosition.LEFT, + NotificationLightingSweepPosition.CENTER, + NotificationLightingSweepPosition.RIGHT + ) + val labels = listOf( + stringResource(R.string.notification_lighting_sweep_pos_left), + stringResource(R.string.notification_lighting_sweep_pos_center), + stringResource(R.string.notification_lighting_sweep_pos_right) + ) + val view = LocalView.current + + val selectedIndex = positions.indexOf(selectedPosition).coerceAtLeast(0) + + Row( + modifier = modifier + .background( + color = MaterialTheme.colorScheme.surfaceBright + ) + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), + ) { + val modifiers = List(positions.size) { Modifier.weight(1f) } + + positions.forEachIndexed { index, pos -> + ToggleButton( + checked = selectedIndex == index, + onCheckedChange = { + HapticUtil.performVirtualKeyHaptic(view) + onPositionSelected(pos) + }, + modifier = modifiers[index].semantics { role = Role.RadioButton }, + shapes = when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + positions.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, + ) { + Text( + text = labels[index], + style = MaterialTheme.typography.labelLarge + ) + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/utils/OverlayHelper.kt b/app/src/main/java/com/sameerasw/essentials/utils/OverlayHelper.kt index 17e8b7b82..4baedd5ef 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/OverlayHelper.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/OverlayHelper.kt @@ -32,6 +32,7 @@ import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.sameerasw.essentials.domain.model.NotificationLightingSide import com.sameerasw.essentials.domain.model.NotificationLightingStyle +import com.sameerasw.essentials.domain.model.NotificationLightingSweepPosition import androidx.compose.ui.graphics.Color as ComposeColor /** @@ -73,6 +74,9 @@ object OverlayHelper { if (style == NotificationLightingStyle.INDICATOR) { return createIndicatorOverlayView(context, color, indicatorScale, showBackground) } + if (style == NotificationLightingStyle.SWEEP) { + return createSweepOverlayView(context, color, strokeDp, showBackground) + } val overlay = FrameLayout(context) if (showBackground) { @@ -109,71 +113,55 @@ object OverlayHelper { sides: Set, showBackground: Boolean ): FrameLayout { + val blurRadiusDp = 15f val overlay = FrameLayout(context) if (showBackground) { overlay.setBackgroundColor(Color.BLACK) } + val density = context.resources.displayMetrics.density + val glowSizePx = (80 * density).toInt() + if (sides.contains(NotificationLightingSide.LEFT)) { - val leftGlow = View(context).apply { + val leftGlow = GlowSideView(context, color, NotificationLightingSide.LEFT, blurRadiusDp).apply { tag = "left_glow" alpha = 0.5f - layoutParams = - FrameLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT).apply { - gravity = Gravity.START - } - background = GradientDrawable( - GradientDrawable.Orientation.LEFT_RIGHT, - intArrayOf(color, Color.TRANSPARENT) - ) + layoutParams = FrameLayout.LayoutParams(glowSizePx, ViewGroup.LayoutParams.MATCH_PARENT).apply { + gravity = Gravity.START + } } overlay.addView(leftGlow) } if (sides.contains(NotificationLightingSide.RIGHT)) { - val rightGlow = View(context).apply { + val rightGlow = GlowSideView(context, color, NotificationLightingSide.RIGHT, blurRadiusDp).apply { tag = "right_glow" alpha = 0.5f - layoutParams = - FrameLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT).apply { - gravity = Gravity.END - } - background = GradientDrawable( - GradientDrawable.Orientation.RIGHT_LEFT, - intArrayOf(color, Color.TRANSPARENT) - ) + layoutParams = FrameLayout.LayoutParams(glowSizePx, ViewGroup.LayoutParams.MATCH_PARENT).apply { + gravity = Gravity.END + } } overlay.addView(rightGlow) } if (sides.contains(NotificationLightingSide.TOP)) { - val topGlow = View(context).apply { + val topGlow = GlowSideView(context, color, NotificationLightingSide.TOP, blurRadiusDp).apply { tag = "top_glow" alpha = 0.5f - layoutParams = - FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0).apply { - gravity = Gravity.TOP - } - background = GradientDrawable( - GradientDrawable.Orientation.TOP_BOTTOM, - intArrayOf(color, Color.TRANSPARENT) - ) + layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, glowSizePx).apply { + gravity = Gravity.TOP + } } overlay.addView(topGlow) } if (sides.contains(NotificationLightingSide.BOTTOM)) { - val bottomGlow = View(context).apply { + val bottomGlow = GlowSideView(context, color, NotificationLightingSide.BOTTOM, blurRadiusDp).apply { tag = "bottom_glow" alpha = 0.5f - layoutParams = - FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0).apply { - gravity = Gravity.BOTTOM - } - background = GradientDrawable( - GradientDrawable.Orientation.BOTTOM_TOP, - intArrayOf(color, Color.TRANSPARENT) - ) + layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, glowSizePx).apply { + gravity = Gravity.BOTTOM + } } overlay.addView(bottomGlow) } @@ -226,6 +214,116 @@ object OverlayHelper { return overlay } + private fun createSweepOverlayView( + context: Context, + color: Int, + strokeDp: Float, + showBackground: Boolean + ): FrameLayout { + val glowRadiusDp = 15f + val overlay = FrameLayout(context) + if (showBackground) { + overlay.setBackgroundColor(Color.BLACK) + } + + val sweepView = SweepCircleView(context, color, strokeDp, glowRadiusDp).apply { + tag = "sweep_view" + alpha = 0f + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + overlay.addView(sweepView) + + return overlay + } + + private class SweepCircleView(context: Context, val color: Int, val strokeDp: Float, val glowRadiusDp: Float) : View(context) { + var centerX: Float = 0f + var centerY: Float = 0f + + private val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { + style = android.graphics.Paint.Style.STROKE + this.color = this@SweepCircleView.color + strokeWidth = context.resources.displayMetrics.density * strokeDp + + if (glowRadiusDp > 0) { + maskFilter = android.graphics.BlurMaskFilter( + context.resources.displayMetrics.density * glowRadiusDp, + android.graphics.BlurMaskFilter.Blur.NORMAL + ) + } + } + + init { + setLayerType(LAYER_TYPE_SOFTWARE, null) + } + + var currentRadius: Float = 0f + set(value) { + field = value + invalidate() + } + + override fun onDraw(canvas: android.graphics.Canvas) { + super.onDraw(canvas) + if (currentRadius > 0) { + canvas.drawCircle(centerX, centerY, currentRadius, paint) + } + } + } + + private class GlowSideView( + context: Context, + val color: Int, + val side: NotificationLightingSide, + val blurRadiusDp: Float + ) : View(context) { + private val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { + style = android.graphics.Paint.Style.FILL + if (blurRadiusDp > 0) { + maskFilter = android.graphics.BlurMaskFilter( + context.resources.displayMetrics.density * blurRadiusDp, + android.graphics.BlurMaskFilter.Blur.NORMAL + ) + } + } + + init { + setLayerType(LAYER_TYPE_SOFTWARE, null) + } + + override fun onDraw(canvas: android.graphics.Canvas) { + super.onDraw(canvas) + val w = width.toFloat() + val h = height.toFloat() + + // Define the gradient based on side + val shader = when (side) { + NotificationLightingSide.LEFT -> android.graphics.LinearGradient( + 0f, 0f, w, 0f, + color, Color.TRANSPARENT, android.graphics.Shader.TileMode.CLAMP + ) + NotificationLightingSide.RIGHT -> android.graphics.LinearGradient( + w, 0f, 0f, 0f, + color, Color.TRANSPARENT, android.graphics.Shader.TileMode.CLAMP + ) + NotificationLightingSide.TOP -> android.graphics.LinearGradient( + 0f, 0f, 0f, h, + color, Color.TRANSPARENT, android.graphics.Shader.TileMode.CLAMP + ) + NotificationLightingSide.BOTTOM -> android.graphics.LinearGradient( + 0f, h, 0f, 0f, + color, Color.TRANSPARENT, android.graphics.Shader.TileMode.CLAMP + ) + } + paint.shader = shader + + canvas.drawRect(0f, 0f, w, h, paint) + } + } + /** * A lightweight implementation of the owners required by Jetpack Compose * to run inside a WindowManager overlay. @@ -360,8 +458,10 @@ object OverlayHelper { strokeWidthDp: Float, indicatorX: Float = 50f, indicatorY: Float = 2f, - indicatorScale: Float = 1.0f + indicatorScale: Float = 1.0f, + pulseDurationMillis: Long = 3000L ) { + val sweepGlowRadiusDp = 15f if (style == NotificationLightingStyle.GLOW) { val vg = view as? ViewGroup if (vg != null) { @@ -391,6 +491,16 @@ object OverlayHelper { scaleX = indicatorScale scaleY = indicatorScale } + } else if (style == NotificationLightingStyle.SWEEP) { + pulseSweepOverlay( + view as ViewGroup, + maxPulses = 1, + pulseDurationMillis = pulseDurationMillis, + strokeWidthDp = strokeWidthDp, + sweepPositionX = indicatorX, + sweepGlowRadiusDp = sweepGlowRadiusDp, + onAnimationEnd = null + ) } fadeInOverlay(view) @@ -459,6 +569,7 @@ object OverlayHelper { indicatorX: Float = 50f, indicatorY: Float = 2f, indicatorScale: Float = 1.0f, + sweepGlowRadiusDp: Float = 0f, onAnimationEnd: (() -> Unit)? = null ) { if (style == NotificationLightingStyle.GLOW) { @@ -484,6 +595,19 @@ object OverlayHelper { return } + if (style == NotificationLightingStyle.SWEEP) { + pulseSweepOverlay( + view as ViewGroup, + maxPulses, + pulseDurationMillis, + strokeWidthDp, + indicatorX, + sweepGlowRadiusDp, + onAnimationEnd + ) + return + } + var pulseCount = 0 val durationIn = (pulseDurationMillis * 0.1).toLong() @@ -636,4 +760,65 @@ object OverlayHelper { } }).start() } + + private fun pulseSweepOverlay( + view: ViewGroup, + maxPulses: Int, + pulseDurationMillis: Long, + strokeWidthDp: Float, + sweepPositionX: Float, + sweepGlowRadiusDp: Float, + onAnimationEnd: (() -> Unit)? = null + ) { + val sweepView = view.findViewWithTag("sweep_view") ?: return + val displayMetrics = view.resources.displayMetrics + val screenWidth = displayMetrics.widthPixels + val screenHeight = displayMetrics.heightPixels + + val startX = when { + sweepPositionX < 34f -> 0f + sweepPositionX > 66f -> screenWidth.toFloat() + else -> screenWidth / 2f + } + val startY = 16f * displayMetrics.density // top gap + + sweepView.centerX = startX + sweepView.centerY = startY + + // Max radius to cover the whole screen from the start point + val maxDistX = Math.max(startX, screenWidth - startX) + val maxDistY = Math.max(startY, screenHeight - startY) + val maxRadius = Math.sqrt((maxDistX * maxDistX + maxDistY * maxDistY).toDouble()).toFloat() + (sweepGlowRadiusDp * displayMetrics.density) + + var pulseCount = 0 + + fun startPulse() { + if (pulseCount >= maxPulses) { + onAnimationEnd?.invoke() + return + } + pulseCount++ + + sweepView.alpha = 1f + sweepView.currentRadius = 0f + + val animator = ValueAnimator.ofFloat(0f, maxRadius).apply { + duration = pulseDurationMillis + interpolator = AccelerateDecelerateInterpolator() + addUpdateListener { anim -> + val radius = anim.animatedValue as Float + sweepView.currentRadius = radius + sweepView.alpha = 1f - (radius / maxRadius) + } + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + startPulse() + } + }) + } + animator.start() + } + + startPulse() + } } diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index 451e0be33..656b3118c 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -42,6 +42,7 @@ import com.sameerasw.essentials.domain.model.NotificationApp import com.sameerasw.essentials.domain.model.NotificationLightingColorMode import com.sameerasw.essentials.domain.model.NotificationLightingSide import com.sameerasw.essentials.domain.model.NotificationLightingStyle +import com.sameerasw.essentials.domain.model.NotificationLightingSweepPosition import com.sameerasw.essentials.domain.model.SearchableItem import com.sameerasw.essentials.domain.model.UpdateInfo import com.sameerasw.essentials.domain.registry.SearchRegistry @@ -153,6 +154,8 @@ class MainViewModel : ViewModel() { val notificationLightingIndicatorScale = mutableStateOf(1.0f) val notificationLightingGlowSides = mutableStateOf(setOf(NotificationLightingSide.LEFT, NotificationLightingSide.RIGHT)) + val notificationLightingSweepPosition = mutableStateOf(NotificationLightingSweepPosition.CENTER) + val notificationLightingSweepThickness = mutableFloatStateOf(8f) val skipPersistentNotifications = mutableStateOf(false) val isAppLockEnabled = mutableStateOf(false) val isUseUsageAccess = mutableStateOf(false) @@ -683,6 +686,8 @@ class MainViewModel : ViewModel() { notificationLightingIndicatorScale.value = settingsRepository.getFloat(SettingsRepository.KEY_EDGE_LIGHTING_INDICATOR_SCALE, 1.0f) notificationLightingGlowSides.value = settingsRepository.getNotificationLightingGlowSides() + notificationLightingSweepPosition.value = settingsRepository.getNotificationLightingSweepPosition() + notificationLightingSweepThickness.floatValue = settingsRepository.getFloat(SettingsRepository.KEY_EDGE_LIGHTING_SWEEP_THICKNESS, 8f) MapsState.isEnabled = isMapsPowerSavingEnabled.value hapticFeedbackType.value = settingsRepository.getHapticFeedbackType() @@ -1550,32 +1555,41 @@ class MainViewModel : ViewModel() { ) } - // Helper to show the overlay service for testing/triggering + private fun Intent.addLightingExtras( + cornerRadiusDp: Float? = null, + strokeThicknessDp: Float? = null, + isPreview: Boolean = true, + styleOverride: NotificationLightingStyle? = null + ) { + val radius = cornerRadiusDp + ?: settingsRepository.getFloat(SettingsRepository.KEY_EDGE_LIGHTING_CORNER_RADIUS, 20f) + val thickness = strokeThicknessDp + ?: settingsRepository.getFloat(SettingsRepository.KEY_EDGE_LIGHTING_STROKE_THICKNESS, 8f) + + putExtra("corner_radius_dp", radius) + putExtra("stroke_thickness_dp", thickness) + putExtra("is_preview", isPreview) + putExtra("ignore_screen_state", true) + putExtra("style", (styleOverride ?: notificationLightingStyle.value).name) + putExtra("color_mode", notificationLightingColorMode.value.name) + putExtra("custom_color", notificationLightingCustomColor.intValue) + putExtra("pulse_count", notificationLightingPulseCount.value.toInt()) + putExtra("pulse_duration", notificationLightingPulseDuration.value.toLong()) + putExtra( + "glow_sides", + notificationLightingGlowSides.value.map { it.name }.toTypedArray() + ) + putExtra("indicator_x", notificationLightingIndicatorX.value) + putExtra("indicator_y", notificationLightingIndicatorY.value) + putExtra("indicator_scale", notificationLightingIndicatorScale.value) + putExtra("sweep_position", notificationLightingSweepPosition.value.name) + putExtra("sweep_thickness", notificationLightingSweepThickness.floatValue) + } + fun triggerNotificationLighting(context: Context) { - val radius = - settingsRepository.getFloat(SettingsRepository.KEY_EDGE_LIGHTING_CORNER_RADIUS, 20f) - val thickness = - settingsRepository.getFloat(SettingsRepository.KEY_EDGE_LIGHTING_STROKE_THICKNESS, 8f) try { - val intent = Intent( - context, - NotificationLightingService::class.java - ).apply { - putExtra("corner_radius_dp", radius) - putExtra("stroke_thickness_dp", thickness) - putExtra("ignore_screen_state", true) - putExtra("style", notificationLightingStyle.value.name) - putExtra("color_mode", notificationLightingColorMode.value.name) - putExtra("custom_color", notificationLightingCustomColor.intValue) - putExtra("pulse_count", notificationLightingPulseCount.value.toInt()) - putExtra("pulse_duration", notificationLightingPulseDuration.value.toLong()) - putExtra( - "glow_sides", - notificationLightingGlowSides.value.map { it.name }.toTypedArray() - ) - putExtra("indicator_x", notificationLightingIndicatorX.value) - putExtra("indicator_y", notificationLightingIndicatorY.value) - putExtra("indicator_scale", notificationLightingIndicatorScale.value) + val intent = Intent(context, NotificationLightingService::class.java).apply { + addLightingExtras(isPreview = false) } context.startService(intent) } catch (e: Exception) { @@ -1583,26 +1597,11 @@ class MainViewModel : ViewModel() { } } - // Helper to show the overlay service with custom corner radius - fun triggerNotificationLightingWithRadius(context: Context, cornerRadiusDp: Float) { + // Helper to show the overlay service + fun triggerNotificationLightingPreview(context: Context) { try { - val intent = Intent( - context, - NotificationLightingService::class.java - ).apply { - putExtra("corner_radius_dp", cornerRadiusDp) - putExtra("is_preview", true) - putExtra("ignore_screen_state", true) - putExtra("style", notificationLightingStyle.value.name) - putExtra("color_mode", notificationLightingColorMode.value.name) - putExtra("custom_color", notificationLightingCustomColor.intValue) - putExtra( - "glow_sides", - notificationLightingGlowSides.value.map { it.name }.toTypedArray() - ) - putExtra("indicator_x", notificationLightingIndicatorX.value) - putExtra("indicator_y", notificationLightingIndicatorY.value) - putExtra("indicator_scale", notificationLightingIndicatorScale.value) + val intent = Intent(context, NotificationLightingService::class.java).apply { + addLightingExtras(isPreview = true) } context.startService(intent) } catch (e: Exception) { @@ -1610,8 +1609,6 @@ class MainViewModel : ViewModel() { } } - // Helper to show the overlay service with custom corner radius and stroke thickness - fun openImeSettings(context: Context) { val intent = Intent(Settings.ACTION_INPUT_METHOD_SETTINGS) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) @@ -1623,30 +1620,25 @@ class MainViewModel : ViewModel() { imm.showInputMethodPicker() } + fun triggerNotificationLightingWithRadius(context: Context, cornerRadiusDp: Float) { + try { + val intent = Intent(context, NotificationLightingService::class.java).apply { + addLightingExtras(cornerRadiusDp = cornerRadiusDp) + } + context.startService(intent) + } catch (e: Exception) { + // ignore + } + } + fun triggerNotificationLightingWithRadiusAndThickness( context: Context, cornerRadiusDp: Float, strokeThicknessDp: Float ) { try { - val intent = Intent( - context, - NotificationLightingService::class.java - ).apply { - putExtra("corner_radius_dp", cornerRadiusDp) - putExtra("stroke_thickness_dp", strokeThicknessDp) - putExtra("is_preview", true) - putExtra("ignore_screen_state", true) - putExtra("style", notificationLightingStyle.value.name) - putExtra("color_mode", notificationLightingColorMode.value.name) - putExtra("custom_color", notificationLightingCustomColor.intValue) - putExtra( - "glow_sides", - notificationLightingGlowSides.value.map { it.name }.toTypedArray() - ) - putExtra("indicator_x", notificationLightingIndicatorX.value) - putExtra("indicator_y", notificationLightingIndicatorY.value) - putExtra("indicator_scale", notificationLightingIndicatorScale.value) + val intent = Intent(context, NotificationLightingService::class.java).apply { + addLightingExtras(cornerRadiusDp, strokeThicknessDp) } context.startService(intent) } catch (e: Exception) { @@ -1654,26 +1646,37 @@ class MainViewModel : ViewModel() { } } - fun triggerNotificationLightingForIndicator( context: Context, x: Float, y: Float, scale: Float ) { + notificationLightingIndicatorX.value = x + notificationLightingIndicatorY.value = y + notificationLightingIndicatorScale.value = scale + + try { + val intent = Intent(context, NotificationLightingService::class.java).apply { + addLightingExtras(styleOverride = NotificationLightingStyle.INDICATOR) + } + context.startService(intent) + } catch (e: Exception) { + // ignore + } + } + + fun triggerNotificationLightingForSweep( + context: Context, + position: NotificationLightingSweepPosition, + thickness: Float + ) { + notificationLightingSweepPosition.value = position + notificationLightingSweepThickness.floatValue = thickness + try { - val intent = Intent( - context, - NotificationLightingService::class.java - ).apply { - putExtra("indicator_x", x) - putExtra("indicator_y", y) - putExtra("indicator_scale", scale) - putExtra("is_preview", true) - putExtra("ignore_screen_state", true) - putExtra("style", NotificationLightingStyle.INDICATOR.name) - putExtra("color_mode", notificationLightingColorMode.value.name) - putExtra("custom_color", notificationLightingCustomColor.intValue) + val intent = Intent(context, NotificationLightingService::class.java).apply { + addLightingExtras(styleOverride = NotificationLightingStyle.SWEEP) } context.startService(intent) } catch (e: Exception) { @@ -2315,6 +2318,17 @@ class MainViewModel : ViewModel() { settingsRepository.putFloat(SettingsRepository.KEY_EDGE_LIGHTING_INDICATOR_SCALE, scale) } + fun setNotificationLightingSweepPosition(position: NotificationLightingSweepPosition, context: Context) { + notificationLightingSweepPosition.value = position + settingsRepository.saveNotificationLightingSweepPosition(position) + } + + fun saveNotificationLightingSweepThickness(context: Context, thickness: Float) { + notificationLightingSweepThickness.floatValue = thickness + settingsRepository.putFloat(SettingsRepository.KEY_EDGE_LIGHTING_SWEEP_THICKNESS, thickness) + } + + fun exportConfigs(context: Context, outputStream: java.io.OutputStream) { settingsRepository.exportConfigs(outputStream) diff --git a/app/src/main/res/drawable/rounded_target_24.xml b/app/src/main/res/drawable/rounded_target_24.xml new file mode 100644 index 000000000..046cd58c0 --- /dev/null +++ b/app/src/main/res/drawable/rounded_target_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ab443170d..3d17a6438 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -179,6 +179,12 @@ Indicator adjustment Scale Duration + Sweep + Position + Circle thickness + Left + Center + Right Animation Pulse count Pulse duration From 13966cd2a7c6fd9e681b62fd693348ef93b1d1e3 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 12 Apr 2026 13:01:03 +0530 Subject: [PATCH 13/23] feat: add random shape support to notification lighting sweep effect --- .../data/repository/SettingsRepository.kt | 1 + .../services/NotificationLightingService.kt | 8 ++- .../handlers/NotificationLightingHandler.kt | 8 ++- .../configs/NotificationLightingSettingsUI.kt | 19 +++++++ .../essentials/utils/OverlayHelper.kt | 57 +++++++++++++------ .../essentials/viewmodels/MainViewModel.kt | 8 +++ app/src/main/res/values/strings.xml | 3 +- 7 files changed, 83 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index c4e8337c2..17c89f32a 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -69,6 +69,7 @@ class SettingsRepository(private val context: Context) { const val KEY_EDGE_LIGHTING_SELECTED_APPS = "edge_lighting_selected_apps" const val KEY_EDGE_LIGHTING_SWEEP_POSITION = "edge_lighting_sweep_position" const val KEY_EDGE_LIGHTING_SWEEP_THICKNESS = "edge_lighting_sweep_thickness" + const val KEY_EDGE_LIGHTING_SWEEP_RANDOM_SHAPES = "edge_lighting_sweep_random_shapes" const val KEY_LOCK_SCREEN_WALLPAPER_SOURCE = "lock_screen_wallpaper_source" const val KEY_CALL_VIBRATIONS_ENABLED = "call_vibrations_enabled" diff --git a/app/src/main/java/com/sameerasw/essentials/services/NotificationLightingService.kt b/app/src/main/java/com/sameerasw/essentials/services/NotificationLightingService.kt index 19923cbe2..6aea7ce67 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/NotificationLightingService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/NotificationLightingService.kt @@ -48,6 +48,7 @@ class NotificationLightingService : Service() { private var isAmbientDisplay: Boolean = false private var sweepPosition: String = "CENTER" private var sweepThickness: Float = 8f + private var randomShapes: Boolean = true private var screenReceiver: BroadcastReceiver? = null @@ -149,6 +150,7 @@ class NotificationLightingService : Service() { isAmbientDisplay = intent?.getBooleanExtra("is_ambient_display", false) ?: false sweepPosition = intent?.getStringExtra("sweep_position") ?: "CENTER" sweepThickness = intent?.getFloatExtra("sweep_thickness", 8f) ?: 8f + randomShapes = intent?.getBooleanExtra("random_shapes", false) ?: false val ignoreScreenState = intent?.getBooleanExtra("ignore_screen_state", false) ?: false val removePreview = intent?.getBooleanExtra("remove_preview", false) ?: false @@ -216,6 +218,7 @@ class NotificationLightingService : Service() { "is_ambient_show_lock_screen", intent?.getBooleanExtra("is_ambient_show_lock_screen", false) ?: false ) + putExtra("random_shapes", randomShapes) } // Use startService to request the accessibility service perform the elevated overlay. // Starting an accessibility service via startForegroundService can cause MissingForegroundServiceType @@ -316,6 +319,7 @@ class NotificationLightingService : Service() { style = edgeLightingStyle, glowSides = glowSides, indicatorScale = indicatorScale, + randomShapes = randomShapes, showBackground = isAmbientDisplay ) val params = OverlayHelper.createOverlayLayoutParams(getOverlayType()) @@ -331,6 +335,7 @@ class NotificationLightingService : Service() { indicatorX, indicatorY, indicatorScale, + randomShapes = randomShapes, pulseDurationMillis = pulseDuration ) } else { @@ -349,7 +354,8 @@ class NotificationLightingService : Service() { } } else indicatorX, indicatorY = indicatorY, - indicatorScale = indicatorScale + indicatorScale = indicatorScale, + randomShapes = randomShapes ) { // When pulsing completes, remove the overlay OverlayHelper.fadeOutAndRemoveOverlay( diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/NotificationLightingHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/NotificationLightingHandler.kt index 76c552cf7..5e64112fb 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/NotificationLightingHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/NotificationLightingHandler.kt @@ -41,6 +41,7 @@ class NotificationLightingHandler( private var indicatorScale: Float = 1.0f private var sweepPosition: String = "CENTER" private var sweepThickness: Float = 8f + private var randomShapes: Boolean = true private var isAmbientDisplayRequested: Boolean = false private var isAmbientShowLockScreen: Boolean = false @@ -83,7 +84,7 @@ class NotificationLightingHandler( isAmbientShowLockScreen = intent.getBooleanExtra("is_ambient_show_lock_screen", false) sweepPosition = intent.getStringExtra("sweep_position") ?: "CENTER" sweepThickness = intent.getFloatExtra("sweep_thickness", 8f) - sweepThickness = intent.getFloatExtra("sweep_thickness", 8f) + randomShapes = intent.getBooleanExtra("random_shapes", false) isInterrupted = false val removePreview = intent.getBooleanExtra("remove_preview", false) @@ -169,11 +170,11 @@ class NotificationLightingHandler( val overlay = OverlayHelper.createOverlayView( service, color, -// strokeDp = strokeThicknessDp, cornerRadiusDp = cornerRadiusDp, style = edgeLightingStyle, glowSides = glowSides, indicatorScale = indicatorScale, + randomShapes = randomShapes, strokeDp = if (edgeLightingStyle == NotificationLightingStyle.SWEEP) sweepThickness else strokeThicknessDp, ) val params = OverlayHelper.createOverlayLayoutParams(overlayType) @@ -192,6 +193,7 @@ class NotificationLightingHandler( style = edgeLightingStyle, glowSides = glowSides, indicatorScale = indicatorScale, + randomShapes = randomShapes, showBackground = true ) val ambientParams = @@ -274,6 +276,7 @@ class NotificationLightingHandler( } else indicatorX, indicatorY, indicatorScale, + randomShapes = randomShapes, pulseDurationMillis = pulseDuration ) } else { @@ -302,6 +305,7 @@ class NotificationLightingHandler( } else indicatorX, indicatorY = indicatorY, indicatorScale = indicatorScale, + randomShapes = randomShapes, ) { if (isAmbientDisplayRequested && !isInterrupted && !isPreview && !isAmbientShowLockScreen) { service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_LOCK_SCREEN) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/NotificationLightingSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/NotificationLightingSettingsUI.kt index 2eb571e1e..107916476 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/NotificationLightingSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/NotificationLightingSettingsUI.kt @@ -297,6 +297,25 @@ fun NotificationLightingSettingsUI( } ) + IconToggleItem( + iconRes = R.drawable.rounded_interests_24, + title = stringResource(R.string.notification_lighting_sweep_random_shapes_title), + isChecked = viewModel.notificationLightingSweepRandomShapes.value, + onCheckedChange = { checked -> + viewModel.saveNotificationLightingSweepRandomShapes(context, checked) + HapticUtil.performSliderHaptic(view) + viewModel.triggerNotificationLightingForSweep( + context, + viewModel.notificationLightingSweepPosition.value, + viewModel.notificationLightingSweepThickness.floatValue + ) + coroutineScope.launch { + delay(5000) + viewModel.removePreviewOverlay(context) + } + } + ) + ConfigSliderItem( title = stringResource(R.string.notification_lighting_sweep_thickness_title), value = viewModel.notificationLightingSweepThickness.floatValue, diff --git a/app/src/main/java/com/sameerasw/essentials/utils/OverlayHelper.kt b/app/src/main/java/com/sameerasw/essentials/utils/OverlayHelper.kt index 4baedd5ef..e8b4c5f91 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/OverlayHelper.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/OverlayHelper.kt @@ -19,6 +19,7 @@ import androidx.compose.material3.LoadingIndicator import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.view.updateLayoutParams +import androidx.graphics.shapes.toPath import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry @@ -66,6 +67,7 @@ object OverlayHelper { NotificationLightingSide.RIGHT ), indicatorScale: Float = 1.0f, + randomShapes: Boolean = false, showBackground: Boolean = false ): FrameLayout { if (style == NotificationLightingStyle.GLOW) { @@ -75,7 +77,7 @@ object OverlayHelper { return createIndicatorOverlayView(context, color, indicatorScale, showBackground) } if (style == NotificationLightingStyle.SWEEP) { - return createSweepOverlayView(context, color, strokeDp, showBackground) + return createSweepOverlayView(context, color, strokeDp, randomShapes, showBackground) } val overlay = FrameLayout(context) @@ -218,6 +220,7 @@ object OverlayHelper { context: Context, color: Int, strokeDp: Float, + randomShapes: Boolean, showBackground: Boolean ): FrameLayout { val glowRadiusDp = 15f @@ -226,7 +229,7 @@ object OverlayHelper { overlay.setBackgroundColor(Color.BLACK) } - val sweepView = SweepCircleView(context, color, strokeDp, glowRadiusDp).apply { + val sweepView = SweepShapeView(context, color, strokeDp, randomShapes).apply { tag = "sweep_view" alpha = 0f layoutParams = FrameLayout.LayoutParams( @@ -239,21 +242,28 @@ object OverlayHelper { return overlay } - private class SweepCircleView(context: Context, val color: Int, val strokeDp: Float, val glowRadiusDp: Float) : View(context) { + private class SweepShapeView( + context: Context, + val color: Int, + val strokeDp: Float, + val useRandomShapes: Boolean + ) : View(context) { var centerX: Float = 0f var centerY: Float = 0f + private val polygon = if (useRandomShapes) { + AmbientMusicShapeHelper.getRandomPolygon() + } else null + private val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { style = android.graphics.Paint.Style.STROKE - this.color = this@SweepCircleView.color + this.color = this@SweepShapeView.color strokeWidth = context.resources.displayMetrics.density * strokeDp - if (glowRadiusDp > 0) { - maskFilter = android.graphics.BlurMaskFilter( - context.resources.displayMetrics.density * glowRadiusDp, - android.graphics.BlurMaskFilter.Blur.NORMAL - ) - } + maskFilter = android.graphics.BlurMaskFilter( + context.resources.displayMetrics.density * 15f, + android.graphics.BlurMaskFilter.Blur.NORMAL + ) } init { @@ -268,7 +278,22 @@ object OverlayHelper { override fun onDraw(canvas: android.graphics.Canvas) { super.onDraw(canvas) - if (currentRadius > 0) { + if (currentRadius <= 0) return + + if (polygon != null) { + // Get path from polygon scaled to size + val shapePath = polygon.toPath() + + // Scale and move path + val matrix = android.graphics.Matrix() + // Shapes from toPath() are normalized to [0, 1] range. + // Scale to currentRadius * 2 and center it. + matrix.postScale(currentRadius * 2f, currentRadius * 2f) + matrix.postTranslate(centerX - currentRadius, centerY - currentRadius) + + shapePath.transform(matrix) + canvas.drawPath(shapePath, paint) + } else { canvas.drawCircle(centerX, centerY, currentRadius, paint) } } @@ -459,6 +484,7 @@ object OverlayHelper { indicatorX: Float = 50f, indicatorY: Float = 2f, indicatorScale: Float = 1.0f, + randomShapes: Boolean = false, pulseDurationMillis: Long = 3000L ) { val sweepGlowRadiusDp = 15f @@ -498,7 +524,6 @@ object OverlayHelper { pulseDurationMillis = pulseDurationMillis, strokeWidthDp = strokeWidthDp, sweepPositionX = indicatorX, - sweepGlowRadiusDp = sweepGlowRadiusDp, onAnimationEnd = null ) } @@ -569,7 +594,7 @@ object OverlayHelper { indicatorX: Float = 50f, indicatorY: Float = 2f, indicatorScale: Float = 1.0f, - sweepGlowRadiusDp: Float = 0f, + randomShapes: Boolean = false, onAnimationEnd: (() -> Unit)? = null ) { if (style == NotificationLightingStyle.GLOW) { @@ -602,7 +627,6 @@ object OverlayHelper { pulseDurationMillis, strokeWidthDp, indicatorX, - sweepGlowRadiusDp, onAnimationEnd ) return @@ -767,10 +791,9 @@ object OverlayHelper { pulseDurationMillis: Long, strokeWidthDp: Float, sweepPositionX: Float, - sweepGlowRadiusDp: Float, onAnimationEnd: (() -> Unit)? = null ) { - val sweepView = view.findViewWithTag("sweep_view") ?: return + val sweepView = view.findViewWithTag("sweep_view") as? SweepShapeView ?: return val displayMetrics = view.resources.displayMetrics val screenWidth = displayMetrics.widthPixels val screenHeight = displayMetrics.heightPixels @@ -788,7 +811,7 @@ object OverlayHelper { // Max radius to cover the whole screen from the start point val maxDistX = Math.max(startX, screenWidth - startX) val maxDistY = Math.max(startY, screenHeight - startY) - val maxRadius = Math.sqrt((maxDistX * maxDistX + maxDistY * maxDistY).toDouble()).toFloat() + (sweepGlowRadiusDp * displayMetrics.density) + val maxRadius = Math.sqrt((maxDistX * maxDistX + maxDistY * maxDistY).toDouble()).toFloat() + (15f * displayMetrics.density) var pulseCount = 0 diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index 656b3118c..f0a7833e8 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -156,6 +156,7 @@ class MainViewModel : ViewModel() { mutableStateOf(setOf(NotificationLightingSide.LEFT, NotificationLightingSide.RIGHT)) val notificationLightingSweepPosition = mutableStateOf(NotificationLightingSweepPosition.CENTER) val notificationLightingSweepThickness = mutableFloatStateOf(8f) + val notificationLightingSweepRandomShapes = mutableStateOf(false) val skipPersistentNotifications = mutableStateOf(false) val isAppLockEnabled = mutableStateOf(false) val isUseUsageAccess = mutableStateOf(false) @@ -688,6 +689,7 @@ class MainViewModel : ViewModel() { notificationLightingGlowSides.value = settingsRepository.getNotificationLightingGlowSides() notificationLightingSweepPosition.value = settingsRepository.getNotificationLightingSweepPosition() notificationLightingSweepThickness.floatValue = settingsRepository.getFloat(SettingsRepository.KEY_EDGE_LIGHTING_SWEEP_THICKNESS, 8f) + notificationLightingSweepRandomShapes.value = settingsRepository.getBoolean(SettingsRepository.KEY_EDGE_LIGHTING_SWEEP_RANDOM_SHAPES, true) MapsState.isEnabled = isMapsPowerSavingEnabled.value hapticFeedbackType.value = settingsRepository.getHapticFeedbackType() @@ -1584,6 +1586,7 @@ class MainViewModel : ViewModel() { putExtra("indicator_scale", notificationLightingIndicatorScale.value) putExtra("sweep_position", notificationLightingSweepPosition.value.name) putExtra("sweep_thickness", notificationLightingSweepThickness.floatValue) + putExtra("random_shapes", notificationLightingSweepRandomShapes.value) } fun triggerNotificationLighting(context: Context) { @@ -2328,6 +2331,11 @@ class MainViewModel : ViewModel() { settingsRepository.putFloat(SettingsRepository.KEY_EDGE_LIGHTING_SWEEP_THICKNESS, thickness) } + fun saveNotificationLightingSweepRandomShapes(context: Context, enabled: Boolean) { + notificationLightingSweepRandomShapes.value = enabled + settingsRepository.putBoolean(SettingsRepository.KEY_EDGE_LIGHTING_SWEEP_RANDOM_SHAPES, enabled) + } + fun exportConfigs(context: Context, outputStream: java.io.OutputStream) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3d17a6438..e2a8809b4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -181,7 +181,8 @@ Duration Sweep Position - Circle thickness + Random shapes + Stroke thickness Left Center Right From 8ae8ce505673638b76b55a17f0d42d928596cb33 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 12 Apr 2026 13:20:26 +0530 Subject: [PATCH 14/23] refactor: remove LocationReachedTileService and disable beta status for multiple features --- app/src/main/AndroidManifest.xml | 11 +-- .../domain/registry/FeatureRegistry.kt | 3 - .../tiles/LocationReachedTileService.kt | 69 ------------------- .../configs/QuickSettingsTilesSettingsUI.kt | 8 --- 4 files changed, 1 insertion(+), 90 deletions(-) delete mode 100644 app/src/main/java/com/sameerasw/essentials/services/tiles/LocationReachedTileService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9f565ce1f..d19da0939 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -582,16 +582,7 @@ - - - - - + Date: Sun, 12 Apr 2026 13:29:43 +0530 Subject: [PATCH 15/23] feat: assign TILE_CATEGORY meta-data to all Quick Settings tile services in AndroidManifest.xml --- app/src/main/AndroidManifest.xml | 57 ++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d19da0939..d91c5bd74 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -339,6 +339,8 @@ + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + Date: Sun, 12 Apr 2026 13:43:14 +0530 Subject: [PATCH 16/23] feat: add category support to quick settings tiles --- .../configs/QuickSettingsTilesSettingsUI.kt | 259 +++++++++++------- .../drawable/rounded_accessibility_new_24.xml | 5 + .../rounded_android_wifi_3_bar_24.xml | 2 +- .../res/drawable/rounded_brightness_6_24.xml | 5 + .../main/res/drawable/rounded_shield_24.xml | 5 + app/src/main/res/values/strings.xml | 4 + 6 files changed, 185 insertions(+), 95 deletions(-) create mode 100644 app/src/main/res/drawable/rounded_accessibility_new_24.xml create mode 100644 app/src/main/res/drawable/rounded_brightness_6_24.xml create mode 100644 app/src/main/res/drawable/rounded_shield_24.xml diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt index 2cac1397a..c47b5e835 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -81,7 +82,8 @@ data class QSTileInfo( val iconRes: Int, val serviceClass: Class<*>, val permissionKeys: List = emptyList(), - val aboutDescription: Int? = null + val aboutDescription: Int? = null, + val categoryRes: Int ) @Composable @@ -108,28 +110,32 @@ fun QuickSettingsTilesSettingsUI( R.drawable.rounded_blur_on_24, UiBlurTileService::class.java, listOf("WRITE_SECURE_SETTINGS"), - R.string.about_desc_ui_blur + R.string.about_desc_ui_blur, + R.string.cat_visuals ), QSTileInfo( R.string.tile_bubbles, R.drawable.rounded_bubble_24, BubblesTileService::class.java, listOf("WRITE_SECURE_SETTINGS"), - R.string.about_desc_bubbles + R.string.about_desc_bubbles, + R.string.cat_utils ), QSTileInfo( R.string.tile_sensitive_content, R.drawable.rounded_notifications_off_24, PrivateNotificationsTileService::class.java, listOf("WRITE_SECURE_SETTINGS"), - R.string.about_desc_sensitive_content + R.string.about_desc_sensitive_content, + R.string.cat_privacy ), QSTileInfo( R.string.tile_tap_to_wake, R.drawable.rounded_touch_app_24, TapToWakeTileService::class.java, listOf("WRITE_SECURE_SETTINGS"), - R.string.about_desc_tap_to_wake + R.string.about_desc_tap_to_wake, + R.string.cat_visuals ), QSTileInfo( R.string.tile_aod, @@ -138,49 +144,56 @@ fun QuickSettingsTilesSettingsUI( if (ShellUtils.isRootEnabled(context)) listOf("ROOT") else if (PermissionUtils.canWriteSecureSettings(context)) listOf("WRITE_SECURE_SETTINGS") else listOf("SHIZUKU"), - R.string.about_desc_aod + R.string.about_desc_aod, + R.string.cat_visuals ), QSTileInfo( R.string.tile_caffeinate, R.drawable.rounded_coffee_24, CaffeinateTileService::class.java, listOf("POST_NOTIFICATIONS"), - R.string.about_desc_caffeinate + R.string.about_desc_caffeinate, + R.string.cat_utils ), QSTileInfo( R.string.tile_sound_mode, R.drawable.rounded_volume_up_24, SoundModeTileService::class.java, listOf("NOTIFICATION_POLICY"), - R.string.about_desc_sound_mode_tile + R.string.about_desc_sound_mode_tile, + R.string.cat_utils ), QSTileInfo( R.string.tile_notification_lighting, R.drawable.rounded_blur_linear_24, NotificationLightingTileService::class.java, listOf("DRAW_OVERLAYS", "ACCESSIBILITY", "NOTIFICATION_LISTENER"), - R.string.about_desc_notification_lighting + R.string.about_desc_notification_lighting, + R.string.cat_utils ), QSTileInfo( R.string.tile_dynamic_night_light, R.drawable.rounded_nightlight_24, DynamicNightLightTileService::class.java, listOf("ACCESSIBILITY", "WRITE_SECURE_SETTINGS"), - R.string.about_desc_dynamic_night_light + R.string.about_desc_dynamic_night_light, + R.string.cat_visuals ), QSTileInfo( R.string.tile_locked_security, R.drawable.rounded_security_24, ScreenLockedSecurityTileService::class.java, listOf("ACCESSIBILITY", "WRITE_SECURE_SETTINGS", "DEVICE_ADMIN"), - R.string.about_desc_screen_locked_security + R.string.about_desc_screen_locked_security, + R.string.cat_privacy ), QSTileInfo( R.string.tile_app_lock, R.drawable.rounded_shield_lock_24, AppLockTileService::class.java, if (isUseUsageStats) listOf("USAGE_STATS") else listOf("ACCESSIBILITY"), - R.string.about_desc_app_lock + R.string.about_desc_app_lock, + R.string.cat_privacy ), QSTileInfo( R.string.tile_mono_audio, @@ -189,14 +202,16 @@ fun QuickSettingsTilesSettingsUI( if (ShellUtils.isRootEnabled(context)) listOf("ROOT") else if (PermissionUtils.canWriteSecureSettings(context)) listOf("WRITE_SECURE_SETTINGS") else listOf("SHIZUKU"), - R.string.about_desc_mono_audio + R.string.about_desc_mono_audio, + R.string.cat_accessibility ), QSTileInfo( R.string.tile_flashlight, R.drawable.rounded_flashlight_on_24, FlashlightTileService::class.java, emptyList(), - R.string.about_desc_flashlight_tile + R.string.about_desc_flashlight_tile, + R.string.cat_utils ), QSTileInfo( R.string.tile_app_freezing, @@ -207,21 +222,24 @@ fun QuickSettingsTilesSettingsUI( "USAGE_STATS", "NOTIFICATION_LISTENER" ) else listOf("SHIZUKU", "USAGE_STATS", "NOTIFICATION_LISTENER"), - R.string.about_desc_freeze + R.string.about_desc_freeze, + R.string.cat_utils ), QSTileInfo( R.string.tile_flashlight_pulse, R.drawable.outline_backlight_high_24, FlashlightPulseTileService::class.java, listOf("NOTIFICATION_LISTENER"), - R.string.about_desc_flashlight_pulse + R.string.about_desc_flashlight_pulse, + R.string.cat_utils ), QSTileInfo( R.string.tile_stay_awake, R.drawable.rounded_av_timer_24, StayAwakeTileService::class.java, listOf("WRITE_SECURE_SETTINGS"), - R.string.about_desc_stay_awake + R.string.about_desc_stay_awake, + R.string.cat_visuals ), QSTileInfo( R.string.nfc_tile_label, @@ -230,14 +248,16 @@ fun QuickSettingsTilesSettingsUI( if (ShellUtils.isRootEnabled(context)) listOf("ROOT") else if (PermissionUtils.canWriteSecureSettings(context)) listOf("WRITE_SECURE_SETTINGS") else listOf("SHIZUKU"), - R.string.about_desc_nfc + R.string.about_desc_nfc, + R.string.cat_connectivity ), QSTileInfo( R.string.tile_adaptive_brightness, R.drawable.rounded_brightness_auto_24, AdaptiveBrightnessTileService::class.java, listOf("WRITE_SETTINGS"), - R.string.about_desc_adaptive_brightness + R.string.about_desc_adaptive_brightness, + R.string.cat_visuals ), QSTileInfo( R.string.feat_maps_power_saving_title, @@ -247,49 +267,48 @@ fun QuickSettingsTilesSettingsUI( "ROOT", "NOTIFICATION_LISTENER" ) else listOf("SHIZUKU", "NOTIFICATION_LISTENER"), - R.string.about_desc_maps_power_saving + R.string.about_desc_maps_power_saving, + R.string.cat_utils ), QSTileInfo( R.string.tile_private_dns, R.drawable.rounded_dns_24, PrivateDnsTileService::class.java, listOf("WRITE_SECURE_SETTINGS"), - R.string.about_desc_private_dns + R.string.about_desc_private_dns, + R.string.cat_connectivity ), QSTileInfo( R.string.tile_usb_debugging, R.drawable.rounded_adb_24, UsbDebuggingTileService::class.java, listOf("WRITE_SECURE_SETTINGS"), - R.string.about_desc_usb_debugging + R.string.about_desc_usb_debugging, + R.string.cat_utils ), QSTileInfo( R.string.tile_color_picker, R.drawable.rounded_colorize_24, com.sameerasw.essentials.services.tiles.ColorPickerTileService::class.java, emptyList(), - R.string.about_desc_color_picker + R.string.about_desc_color_picker, + R.string.cat_visuals ), QSTileInfo( R.string.tile_developer_options, R.drawable.rounded_mobile_code_24, DeveloperOptionsTileService::class.java, listOf("WRITE_SECURE_SETTINGS"), - R.string.about_desc_developer_options - ), - QSTileInfo( - R.string.feat_battery_notification_title, - R.drawable.rounded_battery_charging_60_24, - BatteryNotificationTileService::class.java, - listOf("POST_NOTIFICATIONS", "BLUETOOTH_CONNECT", "BLUETOOTH_SCAN"), - R.string.feat_battery_notification_desc + R.string.about_desc_developer_options, + R.string.cat_utils ), QSTileInfo( R.string.tile_charge_optimization, R.drawable.rounded_battery_android_frame_shield_24, ChargeQuickTileService::class.java, if (ShellUtils.isRootEnabled(context)) listOf("ROOT") else listOf("SHIZUKU"), - R.string.about_desc_charge_optimization + R.string.about_desc_charge_optimization, + R.string.cat_utils ) ) @@ -342,6 +361,22 @@ fun QuickSettingsTilesSettingsUI( ) } + val categoryOrder = listOf( + R.string.cat_utils, + R.string.cat_visuals, + R.string.cat_connectivity, + R.string.cat_privacy, + R.string.cat_accessibility + ) + + val categorizedTiles = tiles.groupBy { it.categoryRes } + .toList() + .sortedBy { (category, _) -> + val index = categoryOrder.indexOf(category) + if (index != -1) index else Int.MAX_VALUE + } + + Column( modifier = modifier .fillMaxSize() @@ -351,76 +386,112 @@ fun QuickSettingsTilesSettingsUI( ) { Spacer(modifier = Modifier.height(contentPadding.calculateTopPadding())) Spacer(modifier = Modifier.height(16.dp)) - tiles.chunked(2).forEach { rowTiles -> + + categorizedTiles.forEachIndexed { index, (categoryRes, tilesInSection) -> + if (index > 0) { + Spacer(modifier = Modifier.height(8.dp)) + } + + val categoryIcon = when (categoryRes) { + R.string.cat_utils -> R.drawable.rounded_settings_24 + R.string.cat_visuals -> R.drawable.rounded_brightness_6_24 + R.string.cat_connectivity -> R.drawable.rounded_android_wifi_3_bar_24 + R.string.cat_privacy -> R.drawable.rounded_shield_24 + R.string.cat_accessibility -> R.drawable.rounded_accessibility_new_24 + else -> null + } + Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(bottom = 6.dp, start = 12.dp) ) { - rowTiles.forEach { tile -> - // Map permission keys to actual granted state - val allPermissionsGranted = tile.permissionKeys.all { key -> - PermissionUIHelper.getPermissionItem( - key, - context, - viewModel - )?.isGranted == true - } + if (categoryIcon != null) { + Icon( + painter = painterResource(id = categoryIcon), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(26.dp) + ) + } + Text( + text = stringResource(categoryRes), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + } - QSTileCard( - tile = tile, - modifier = Modifier - .weight(1f) - .highlight( - highlightSetting.equals( - context.getString(tile.titleRes), - ignoreCase = true - ) - ), - isMissingPermissions = !allPermissionsGranted, - onClick = { - if (!allPermissionsGranted) { - selectedTileForPermissions = tile - showPermissionSheet = true - } else { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val statusBarManager = - context.getSystemService(StatusBarManager::class.java) - val componentName = ComponentName(context, tile.serviceClass) + tilesInSection.chunked(2).forEach { rowTiles -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + rowTiles.forEach { tile -> + // Map permission keys to actual granted state + val allPermissionsGranted = tile.permissionKeys.all { key -> + PermissionUIHelper.getPermissionItem( + key, + context, + viewModel + )?.isGranted == true + } - statusBarManager.requestAddTileService( - componentName, + QSTileCard( + tile = tile, + modifier = Modifier + .weight(1f) + .highlight( + highlightSetting.equals( context.getString(tile.titleRes), - Icon.createWithResource(context, tile.iconRes), - context.mainExecutor - ) { result -> - if (result == StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ALREADY_ADDED) { - Toast.makeText( - context, - context.getString(R.string.qs_tile_already_added), - Toast.LENGTH_SHORT - ).show() + ignoreCase = true + ) + ), + isMissingPermissions = !allPermissionsGranted, + onClick = { + if (!allPermissionsGranted) { + selectedTileForPermissions = tile + showPermissionSheet = true + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val statusBarManager = + context.getSystemService(StatusBarManager::class.java) + val componentName = ComponentName(context, tile.serviceClass) + + statusBarManager.requestAddTileService( + componentName, + context.getString(tile.titleRes), + Icon.createWithResource(context, tile.iconRes), + context.mainExecutor + ) { result -> + if (result == StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ALREADY_ADDED) { + Toast.makeText( + context, + context.getString(R.string.qs_tile_already_added), + Toast.LENGTH_SHORT + ).show() + } } + } else { + Toast.makeText( + context, + context.getString(R.string.qs_tile_requires_android_13), + Toast.LENGTH_SHORT + ).show() } - } else { - Toast.makeText( - context, - context.getString(R.string.qs_tile_requires_android_13), - Toast.LENGTH_SHORT - ).show() } - } - }, - onHelpClick = if (tile.aboutDescription != null) { - { - selectedHelpTile = tile - showHelpSheet = true - } - } else null - ) - } - // Determine if we need a spacer for the last odd item - if (rowTiles.size < 2) { - androidx.compose.foundation.layout.Spacer(modifier = Modifier.weight(1f)) + }, + onHelpClick = if (tile.aboutDescription != null) { + { + selectedHelpTile = tile + showHelpSheet = true + } + } else null + ) + } + // Determine if we need a spacer for the last odd item + if (rowTiles.size < 2) { + androidx.compose.foundation.layout.Spacer(modifier = Modifier.weight(1f)) + } } } } diff --git a/app/src/main/res/drawable/rounded_accessibility_new_24.xml b/app/src/main/res/drawable/rounded_accessibility_new_24.xml new file mode 100644 index 000000000..79845dc90 --- /dev/null +++ b/app/src/main/res/drawable/rounded_accessibility_new_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_android_wifi_3_bar_24.xml b/app/src/main/res/drawable/rounded_android_wifi_3_bar_24.xml index 127e5660d..322082ae0 100644 --- a/app/src/main/res/drawable/rounded_android_wifi_3_bar_24.xml +++ b/app/src/main/res/drawable/rounded_android_wifi_3_bar_24.xml @@ -1,5 +1,5 @@ - + diff --git a/app/src/main/res/drawable/rounded_brightness_6_24.xml b/app/src/main/res/drawable/rounded_brightness_6_24.xml new file mode 100644 index 000000000..6811f5b34 --- /dev/null +++ b/app/src/main/res/drawable/rounded_brightness_6_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_shield_24.xml b/app/src/main/res/drawable/rounded_shield_24.xml new file mode 100644 index 000000000..d05ea62c3 --- /dev/null +++ b/app/src/main/res/drawable/rounded_shield_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e2a8809b4..cb72fddea 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1250,6 +1250,10 @@ Interface Display Protection + Accessibility + Connectivity + Privacy + Utilities ABC ?#/ Kaomoji From 03d030c7deb2aa56b0bdf1b48724e21e4d96dee4 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 12 Apr 2026 13:55:52 +0530 Subject: [PATCH 17/23] feat: add visual indicator for already added Quick Settings tiles --- .../configs/QuickSettingsTilesSettingsUI.kt | 36 +++++++++++++++---- .../essentials/viewmodels/MainViewModel.kt | 15 ++++++++ app/src/main/res/values/strings.xml | 1 + 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt index c47b5e835..54e04ceaa 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt @@ -102,6 +102,10 @@ fun QuickSettingsTilesSettingsUI( var showHelpSheet by remember { mutableStateOf(false) } var selectedHelpTile by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + viewModel.check(context) + } + val isUseUsageStats by viewModel.isUseUsageAccess val tiles = listOf( @@ -436,6 +440,14 @@ fun QuickSettingsTilesSettingsUI( )?.isGranted == true } + val addedTiles by viewModel.addedQSTiles + val componentName = ComponentName(context, tile.serviceClass) + val isAdded = addedTiles.any { + it.contains(componentName.flattenToString()) || + it.contains(componentName.flattenToShortString()) || + it.contains(tile.serviceClass.name) + } + QSTileCard( tile = tile, modifier = Modifier @@ -447,6 +459,7 @@ fun QuickSettingsTilesSettingsUI( ) ), isMissingPermissions = !allPermissionsGranted, + isAdded = isAdded, onClick = { if (!allPermissionsGranted) { selectedTileForPermissions = tile @@ -511,6 +524,7 @@ fun QuickSettingsTilesSettingsUI( fun QSTileCard( tile: QSTileInfo, isMissingPermissions: Boolean, + isAdded: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit, onHelpClick: (() -> Unit)? = null @@ -551,7 +565,11 @@ fun QSTileCard( .fillMaxWidth() .alpha(alpha) .clip(RoundedCornerShape(24.dp)) - .background(if (isMissingPermissions) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.primary) + .background( + if (isMissingPermissions) MaterialTheme.colorScheme.errorContainer + else if (isAdded) MaterialTheme.colorScheme.secondaryContainer + else MaterialTheme.colorScheme.primary + ) .combinedClickable( onClick = { com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) @@ -573,10 +591,14 @@ fun QSTileCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { + val contentColor = if (isMissingPermissions) MaterialTheme.colorScheme.onErrorContainer + else if (isAdded) MaterialTheme.colorScheme.onSecondaryContainer + else MaterialTheme.colorScheme.onPrimary + Icon( painter = painterResource(id = tile.iconRes), contentDescription = null, - tint = if (isMissingPermissions) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onPrimary, + tint = contentColor, modifier = Modifier.padding(8.dp) ) @@ -584,16 +606,16 @@ fun QSTileCard( Text( text = stringResource(tile.titleRes), style = MaterialTheme.typography.titleMedium, - color = if (isMissingPermissions) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onPrimary, + color = contentColor, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis ) Text( - text = if (isMissingPermissions) "Grant permission" else stringResource(R.string.action_add), + text = if (isMissingPermissions) "Grant permission" + else if (isAdded) stringResource(R.string.action_added) + else stringResource(R.string.action_add), style = MaterialTheme.typography.bodyMedium, - color = if (isMissingPermissions) MaterialTheme.colorScheme.onErrorContainer.copy( - alpha = 0.8f - ) else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f), + color = contentColor.copy(alpha = 0.8f), maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis ) diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index f0a7833e8..b55abd46a 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -125,6 +125,7 @@ class MainViewModel : ViewModel() { val isNotificationGlanceSameAsLightingEnabled = mutableStateOf(true) val isOnboardingCompleted = mutableStateOf(true) // Default to true so it doesn't flash on first check if not loaded val dnsPresets = mutableStateListOf() + val addedQSTiles = mutableStateOf>(emptySet()) data class CalendarAccount( @@ -265,6 +266,9 @@ class MainViewModel : ViewModel() { Settings.Secure.getUriFor("doze_always_on") -> { isAodEnabled.value = settingsRepository.isAodEnabled() } + Settings.Secure.getUriFor("sysui_qs_tiles") -> { + appContext?.let { updateAddedQSTiles(it) } + } } } } @@ -597,9 +601,15 @@ class MainViewModel : ViewModel() { false, contentObserver ) + context.contentResolver.registerContentObserver( + Settings.Secure.getUriFor("sysui_qs_tiles"), + false, + contentObserver + ) isPowerSaveModeEnabled.value = DeviceUtils.isPowerSaveMode(context) updateBlurState(context) + updateAddedQSTiles(context) if (powerSaveReceiver == null) { powerSaveReceiver = object : BroadcastReceiver() { @@ -2487,4 +2497,9 @@ class MainViewModel : ViewModel() { current.removeAll { it.id == preset.id } settingsRepository.savePrivateDnsPresets(current) } + + private fun updateAddedQSTiles(context: Context) { + val tilesString = Settings.Secure.getString(context.contentResolver, "sysui_qs_tiles") ?: "" + addedQSTiles.value = tilesString.split(",").map { it.trim() }.filter { it.isNotBlank() }.toSet() + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cb72fddea..910e7de15 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -199,6 +199,7 @@ Add + Added Already added Requires Android 13+ UI Blur From 823bfe0c7a2dc947d80af794e976d8ae37296815 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 12 Apr 2026 18:16:57 +0530 Subject: [PATCH 18/23] feat: add sweep thickness and position support to notification lighting service --- .../services/NotificationLightingService.kt | 4 ++-- .../essentials/services/NotificationListener.kt | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/services/NotificationLightingService.kt b/app/src/main/java/com/sameerasw/essentials/services/NotificationLightingService.kt index 6aea7ce67..6985ae997 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/NotificationLightingService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/NotificationLightingService.kt @@ -314,7 +314,7 @@ class NotificationLightingService : Service() { val overlay = OverlayHelper.createOverlayView( this, color, - strokeDp = strokeThicknessDp, + strokeDp = if (edgeLightingStyle == NotificationLightingStyle.SWEEP) sweepThickness else strokeThicknessDp, cornerRadiusDp = cornerRadiusDp, style = edgeLightingStyle, glowSides = glowSides, @@ -345,7 +345,7 @@ class NotificationLightingService : Service() { maxPulses = if (isPreview) 1 else pulseCount, pulseDurationMillis = pulseDuration, style = edgeLightingStyle, - strokeWidthDp = strokeThicknessDp, + strokeWidthDp = if (edgeLightingStyle == NotificationLightingStyle.SWEEP) sweepThickness else strokeThicknessDp, indicatorX = if (edgeLightingStyle == NotificationLightingStyle.SWEEP) { when (sweepPosition) { "LEFT" -> 0f diff --git a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt index e08ed0501..e2569d20f 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt @@ -684,6 +684,14 @@ class NotificationListener : NotificationListenerService() { prefs.getInt("edge_lighting_indicator_scale", 1).toFloat() } + val sweepThickness = try { + prefs.getFloat("edge_lighting_sweep_thickness", 8f) + } catch (e: ClassCastException) { + prefs.getInt("edge_lighting_sweep_thickness", 8).toFloat() + } + val sweepPosition = prefs.getString("edge_lighting_sweep_position", "CENTER") ?: "CENTER" + val randomShapes = prefs.getBoolean("edge_lighting_sweep_random_shapes", true) + fun startNotificationLighting(resolvedColor: Int? = null) { val intent = Intent( applicationContext, @@ -721,6 +729,9 @@ class NotificationListener : NotificationListenerService() { false ) ) + putExtra("sweep_position", sweepPosition) + putExtra("sweep_thickness", sweepThickness) + putExtra("random_shapes", randomShapes) } if (PermissionUtils.isAccessibilityServiceEnabled(applicationContext)) { applicationContext.startService(intent) From 6f40cc75b2cafb8b735a7dbe61b463fb43b9c8c0 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 12 Apr 2026 18:39:09 +0530 Subject: [PATCH 19/23] refactor: implement notification lighting queue and add package-aware animation handling --- .../services/NotificationLightingService.kt | 24 +-- .../services/NotificationListener.kt | 1 + .../handlers/NotificationLightingHandler.kt | 147 +++++++++++------- .../essentials/utils/OverlayHelper.kt | 7 +- 4 files changed, 106 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/services/NotificationLightingService.kt b/app/src/main/java/com/sameerasw/essentials/services/NotificationLightingService.kt index 6985ae997..a53518936 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/NotificationLightingService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/NotificationLightingService.kt @@ -99,7 +99,11 @@ class NotificationLightingService : Service() { override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Log.d("NotificationLightingSvc", "onStartCommand: action=${intent?.action}") + if (intent == null) { + stopSelf() + return START_NOT_STICKY + } + Log.d("NotificationLightingSvc", "onStartCommand: action=${intent.action}") // Accessibility service Android 12+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (!canDrawOverlays() || !isAccessibilityServiceEnabled()) { @@ -219,6 +223,7 @@ class NotificationLightingService : Service() { intent?.getBooleanExtra("is_ambient_show_lock_screen", false) ?: false ) putExtra("random_shapes", randomShapes) + putExtra("package_name", intent.getStringExtra("package_name")) } // Use startService to request the accessibility service perform the elevated overlay. // Starting an accessibility service via startForegroundService can cause MissingForegroundServiceType @@ -331,7 +336,7 @@ class NotificationLightingService : Service() { OverlayHelper.showPreview( overlay, edgeLightingStyle, - strokeThicknessDp, + if (edgeLightingStyle == NotificationLightingStyle.SWEEP) sweepThickness else strokeThicknessDp, indicatorX, indicatorY, indicatorScale, @@ -383,20 +388,7 @@ class NotificationLightingService : Service() { private fun getOverlayType(): Int { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - // Android 12+ supports TYPE_ACCESSIBILITY_OVERLAY for AOD visibility - if (isAccessibilityServiceEnabled()) { - try { - WindowManager.LayoutParams::class.java.getField("TYPE_ACCESSIBILITY_OVERLAY") - .getInt(null) - } catch (_: Exception) { - WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY - } - } else { - WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY - } - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // Android 8.0-11: Always use TYPE_APPLICATION_OVERLAY for stability + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY } else { @Suppress("DEPRECATION") diff --git a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt index e2569d20f..c89684fc4 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt @@ -732,6 +732,7 @@ class NotificationListener : NotificationListenerService() { putExtra("sweep_position", sweepPosition) putExtra("sweep_thickness", sweepThickness) putExtra("random_shapes", randomShapes) + putExtra("package_name", sbn.packageName) } if (PermissionUtils.isAccessibilityServiceEnabled(applicationContext)) { applicationContext.startService(intent) diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/NotificationLightingHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/NotificationLightingHandler.kt index 5e64112fb..495dd6542 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/NotificationLightingHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/NotificationLightingHandler.kt @@ -43,66 +43,99 @@ class NotificationLightingHandler( private var sweepThickness: Float = 8f private var randomShapes: Boolean = true - private var isAmbientDisplayRequested: Boolean = false private var isAmbientShowLockScreen: Boolean = false + private var isAmbientDisplayRequested: Boolean = false private var isInterrupted: Boolean = false + // Queue for staggered playback + private val intentQueue = java.util.ArrayDeque() + private var currentPackageShowing: String? = null + fun handleIntent(intent: Intent) { if (intent.action == "SHOW_NOTIFICATION_LIGHTING") { - cornerRadiusDp = - intent.getFloatExtra("corner_radius_dp", OverlayHelper.CORNER_RADIUS_DP.toFloat()) - strokeThicknessDp = - intent.getFloatExtra("stroke_thickness_dp", OverlayHelper.STROKE_DP.toFloat()) - isPreview = intent.getBooleanExtra("is_preview", false) - ignoreScreenState = intent.getBooleanExtra("ignore_screen_state", false) - colorMode = NotificationLightingColorMode.valueOf( - intent.getStringExtra("color_mode") ?: "SYSTEM" - ) - customColor = intent.getIntExtra("custom_color", 0) - resolvedColor = if (intent.hasExtra("resolved_color")) intent.getIntExtra( - "resolved_color", - 0 - ) else null - pulseCount = intent.getIntExtra("pulse_count", 1) - pulseDuration = intent.getLongExtra("pulse_duration", 3000) - val styleName = intent.getStringExtra("style") - edgeLightingStyle = - if (styleName != null) NotificationLightingStyle.valueOf(styleName) else NotificationLightingStyle.STROKE - val glowSidesArray = intent.getStringArrayExtra("glow_sides") - glowSides = glowSidesArray?.mapNotNull { - try { - NotificationLightingSide.valueOf(it) - } catch (_: Exception) { - null - } - }?.toSet() - ?: setOf(NotificationLightingSide.LEFT, NotificationLightingSide.RIGHT) - indicatorX = intent.getFloatExtra("indicator_x", 50f) - indicatorY = intent.getFloatExtra("indicator_y", 2f) - indicatorScale = intent.getFloatExtra("indicator_scale", 1.0f) - isAmbientDisplayRequested = intent.getBooleanExtra("is_ambient_display", false) - isAmbientShowLockScreen = intent.getBooleanExtra("is_ambient_show_lock_screen", false) - sweepPosition = intent.getStringExtra("sweep_position") ?: "CENTER" - sweepThickness = intent.getFloatExtra("sweep_thickness", 8f) - randomShapes = intent.getBooleanExtra("random_shapes", false) - isInterrupted = false - + val isPreviewIntent = intent.getBooleanExtra("is_preview", false) val removePreview = intent.getBooleanExtra("remove_preview", false) + if (removePreview) { - removeOverlay() + removeOverlay(immediate = true) + intentQueue.clear() + currentPackageShowing = null return } - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - showNotificationLighting() + if (isPreviewIntent) { + removeOverlay(immediate = true) + intentQueue.clear() + currentPackageShowing = null + extractIntentExtras(intent) + showNotificationLighting() + return + } + + val pkg = intent.getStringExtra("package_name") + if (pkg != null) { + if (pkg == currentPackageShowing) { + return // Skip if same app is already showing + } + if (intentQueue.any { it.getStringExtra("package_name") == pkg }) { + return // Skip if same app is already in queue } - } catch (e: Exception) { - Log.e("NotificationLighting", "Failed to show notification lighting", e) } + + intentQueue.add(intent) + processQueue() } } + private fun extractIntentExtras(intent: Intent) { + cornerRadiusDp = + intent.getFloatExtra("corner_radius_dp", OverlayHelper.CORNER_RADIUS_DP.toFloat()) + strokeThicknessDp = + intent.getFloatExtra("stroke_thickness_dp", OverlayHelper.STROKE_DP.toFloat()) + isPreview = intent.getBooleanExtra("is_preview", false) + ignoreScreenState = intent.getBooleanExtra("ignore_screen_state", false) + colorMode = NotificationLightingColorMode.valueOf( + intent.getStringExtra("color_mode") ?: "SYSTEM" + ) + customColor = intent.getIntExtra("custom_color", 0) + resolvedColor = if (intent.hasExtra("resolved_color")) intent.getIntExtra( + "resolved_color", + 0 + ) else null + pulseCount = intent.getIntExtra("pulse_count", 1) + pulseDuration = intent.getLongExtra("pulse_duration", 3000) + val styleName = intent.getStringExtra("style") + edgeLightingStyle = + if (styleName != null) NotificationLightingStyle.valueOf(styleName) else NotificationLightingStyle.STROKE + val glowSidesArray = intent.getStringArrayExtra("glow_sides") + glowSides = glowSidesArray?.mapNotNull { + try { + NotificationLightingSide.valueOf(it) + } catch (_: Exception) { + null + } + }?.toSet() + ?: setOf(NotificationLightingSide.LEFT, NotificationLightingSide.RIGHT) + indicatorX = intent.getFloatExtra("indicator_x", 50f) + indicatorY = intent.getFloatExtra("indicator_y", 2f) + indicatorScale = intent.getFloatExtra("indicator_scale", 1.0f) + isAmbientDisplayRequested = intent.getBooleanExtra("is_ambient_display", false) + isAmbientShowLockScreen = intent.getBooleanExtra("is_ambient_show_lock_screen", false) + sweepPosition = intent.getStringExtra("sweep_position") ?: "CENTER" + sweepThickness = intent.getFloatExtra("sweep_thickness", 8f) + randomShapes = intent.getBooleanExtra("random_shapes", false) + isInterrupted = false + } + + private fun processQueue() { + if (currentPackageShowing != null || intentQueue.isEmpty()) return + + val nextIntent = intentQueue.poll() ?: return + extractIntentExtras(nextIntent) + currentPackageShowing = nextIntent.getStringExtra("package_name") + showNotificationLighting() + } + fun onScreenOn() { if (!isPreview) { val prefs = service.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) @@ -133,10 +166,7 @@ class NotificationLightingHandler( } private fun showNotificationLighting() { - // For preview mode, remove existing overlays - if (overlayViews.isNotEmpty() && isPreview) { - removeOverlay(immediate = true) - } + // Optimization check is now handled by processQueue and currentPackageShowing windowManager = service.getSystemService(Context.WINDOW_SERVICE) as WindowManager val powerManager = service.getSystemService(Context.POWER_SERVICE) as PowerManager @@ -262,11 +292,11 @@ class NotificationLightingHandler( if (OverlayHelper.addOverlayView(windowManager, overlay, params)) { overlayViews.add(overlay) - if (isPreview && edgeLightingStyle != NotificationLightingStyle.SWEEP) { + if (isPreview) { OverlayHelper.showPreview( overlay, edgeLightingStyle, - strokeThicknessDp, + if (edgeLightingStyle == NotificationLightingStyle.SWEEP) sweepThickness else strokeThicknessDp, indicatorX = if (edgeLightingStyle == NotificationLightingStyle.SWEEP) { when (sweepPosition) { "LEFT" -> 0f @@ -278,7 +308,10 @@ class NotificationLightingHandler( indicatorScale, randomShapes = randomShapes, pulseDurationMillis = pulseDuration - ) + ) { + currentPackageShowing = null + processQueue() + } } else { startPulsing(overlay) } @@ -311,10 +344,16 @@ class NotificationLightingHandler( service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_LOCK_SCREEN) handler.postDelayed({ - OverlayHelper.fadeOutAndRemoveOverlay(windowManager, overlay, overlayViews) + OverlayHelper.fadeOutAndRemoveOverlay(windowManager, overlay, overlayViews) { + currentPackageShowing = null + processQueue() + } }, 500) } else { - OverlayHelper.fadeOutAndRemoveOverlay(windowManager, overlay, overlayViews) + OverlayHelper.fadeOutAndRemoveOverlay(windowManager, overlay, overlayViews) { + currentPackageShowing = null + processQueue() + } } } } diff --git a/app/src/main/java/com/sameerasw/essentials/utils/OverlayHelper.kt b/app/src/main/java/com/sameerasw/essentials/utils/OverlayHelper.kt index e8b4c5f91..ddaa23a94 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/OverlayHelper.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/OverlayHelper.kt @@ -485,7 +485,8 @@ object OverlayHelper { indicatorY: Float = 2f, indicatorScale: Float = 1.0f, randomShapes: Boolean = false, - pulseDurationMillis: Long = 3000L + pulseDurationMillis: Long = 3000L, + onAnimationEnd: (() -> Unit)? = null ) { val sweepGlowRadiusDp = 15f if (style == NotificationLightingStyle.GLOW) { @@ -524,11 +525,11 @@ object OverlayHelper { pulseDurationMillis = pulseDurationMillis, strokeWidthDp = strokeWidthDp, sweepPositionX = indicatorX, - onAnimationEnd = null + onAnimationEnd = onAnimationEnd ) } - fadeInOverlay(view) + fadeInOverlay(view, onAnimationEnd) } /** From 9f4f25dfb4ea4c581236f2443a0d9358dfcfe87a Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 12 Apr 2026 18:51:05 +0530 Subject: [PATCH 20/23] feat: rename Reset App Data button to Reset Onboarding in SettingsActivity --- app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt index 628d9712a..2f29784bb 100644 --- a/app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt @@ -805,7 +805,7 @@ fun SettingsContent( containerColor = MaterialTheme.colorScheme.error ) ) { - Text("Reset App Data", color = MaterialTheme.colorScheme.onError) + Text("Reset Onboarding", color = MaterialTheme.colorScheme.onError) } } From 138419833f60c7860c4099914676faf6f1e87132 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 12 Apr 2026 19:29:14 +0530 Subject: [PATCH 21/23] feat: implement What's New onboarding flow with custom subreddit card --- app/build.gradle.kts | 3 + .../com/sameerasw/essentials/MainActivity.kt | 10 +- .../data/repository/SettingsRepository.kt | 1 + .../ui/components/MadebySameeraswCard.kt | 126 +++++++++++ .../ui/components/WhatsNewCustomContent.kt | 11 + .../ui/composables/WelcomeScreen.kt | 204 +++++++++++++++++- .../essentials/viewmodels/MainViewModel.kt | 15 +- .../res/drawable/madebysameerasw_cover.jpg | Bin 0 -> 83281 bytes app/src/main/res/values/strings.xml | 4 + 9 files changed, 361 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/components/MadebySameeraswCard.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/components/WhatsNewCustomContent.kt create mode 100644 app/src/main/res/drawable/madebysameerasw_cover.jpg diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4d926bd07..8d57d14eb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,6 +24,9 @@ android { versionCode = 36 versionName = "12.5" + val whatsNewCounter = 1 + buildConfigField("int", "WHATS_NEW_COUNTER", whatsNewCounter.toString()) + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/sameerasw/essentials/MainActivity.kt b/app/src/main/java/com/sameerasw/essentials/MainActivity.kt index e80ae8e18..ed74431cd 100644 --- a/app/src/main/java/com/sameerasw/essentials/MainActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/MainActivity.kt @@ -269,6 +269,7 @@ class MainActivity : AppCompatActivity() { val gitHubToken by viewModel.gitHubToken val gitHubUser by gitHubAuthViewModel.currentUser val isOnboardingCompleted by viewModel.isOnboardingCompleted + val isWhatsNewVisible by viewModel.isWhatsNewVisible LaunchedEffect(Unit) { gitHubAuthViewModel.loadCachedUser(context) @@ -990,14 +991,19 @@ class MainActivity : AppCompatActivity() { } androidx.compose.animation.AnimatedVisibility( - visible = !isOnboardingCompleted, + visible = !isOnboardingCompleted || isWhatsNewVisible, enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.slideInVertically { it }, exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.slideOutVertically { it } ) { WelcomeScreen( viewModel = viewModel, + isWhatsNewFlow = isWhatsNewVisible, onBeginClick = { - viewModel.setOnboardingCompleted(true, context) + if (isWhatsNewVisible) { + viewModel.completeWhatsNew() + } else { + viewModel.setOnboardingCompleted(true, context) + } } ) } diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 17c89f32a..b5bac9ec3 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -193,6 +193,7 @@ class SettingsRepository(private val context: Context) { const val KEY_ONBOARDING_COMPLETED = "onboarding_completed" const val KEY_PRIVATE_DNS_PRESETS = "private_dns_presets" const val KEY_APRIL_FOOLS_SHOWN = "april_fools_shown" + const val KEY_WHATS_NEW_LAST_SHOWN_COUNTER = "whats_new_last_shown_counter" } // Observe changes diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/MadebySameeraswCard.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/MadebySameeraswCard.kt new file mode 100644 index 000000000..dd50b09d8 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/MadebySameeraswCard.kt @@ -0,0 +1,126 @@ +package com.sameerasw.essentials.ui.components + +import android.content.Intent +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import com.sameerasw.essentials.R +import com.sameerasw.essentials.ui.theme.GoogleSansFlexRounded + +/** + * A reusable promotion card for the "Made by Sameera" subreddit community. + */ +@Composable +fun MadebySameeraswCard( + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val redditUrl = "https://www.reddit.com/r/MadebySameerasw/" + val isDark = isSystemInDarkTheme() + + val brandColor = Color(0xFF49FCBB) + val brandColorDark = Color(0xFF007A54) // Darker tone for light theme + val accentColor = if (isDark) brandColor else brandColorDark + + Surface( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(32.dp)) + .clickable { + val intent = Intent(Intent.ACTION_VIEW, redditUrl.toUri()) + context.startActivity(intent) + }, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(32.dp) + ) { + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .height(140.dp) + ) { + // Banner Image + Image( + painter = painterResource(id = R.drawable.madebysameerasw_cover), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + + // Avatar Image (Overlapping) + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .offset(y = 32.dp) + .size(84.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .border(4.dp, MaterialTheme.colorScheme.surfaceContainerHigh, CircleShape) + .border(6.dp, accentColor.copy(alpha = 0.5f), CircleShape) // Subtle outer ring + .padding(4.dp) + .border(2.dp, Color(0xFF49FCBB), CircleShape) // Sharper inner stroke + ) { + Image( + painter = painterResource(id = R.drawable.avatar), + contentDescription = "Subreddit Avatar", + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + } + } + + Spacer(modifier = Modifier.height(42.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "r/MadebySameerasw", + style = MaterialTheme.typography.titleLarge.copy( + fontFamily = GoogleSansFlexRounded, + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp + ), + color = accentColor + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "A place to discuss about Essentials and other apps by me. Join the community and contribute to the projects, get help and learn a thing or two. ( ´ \u25BD ` )\uff89", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + lineHeight = 20.sp + ) + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/WhatsNewCustomContent.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/WhatsNewCustomContent.kt new file mode 100644 index 000000000..7af1166a0 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/WhatsNewCustomContent.kt @@ -0,0 +1,11 @@ +package com.sameerasw.essentials.ui.components + +import androidx.compose.runtime.Composable + +/** + * Slot for custom content to be displayed in the "What's New" screen. + */ +@Composable +fun WhatsNewCustomContent() { + MadebySameeraswCard() +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/WelcomeScreen.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/WelcomeScreen.kt index ef43311a5..915b03bcf 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/WelcomeScreen.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/WelcomeScreen.kt @@ -47,6 +47,8 @@ import com.sameerasw.essentials.utils.DeviceUtils import androidx.compose.ui.unit.sp import com.sameerasw.essentials.ui.components.pickers.CrashReportingPicker import com.sameerasw.essentials.ui.components.pickers.LanguagePicker +import com.sameerasw.essentials.ui.components.text.SimpleMarkdown +import com.sameerasw.essentials.ui.components.WhatsNewCustomContent import kotlinx.coroutines.launch import kotlin.math.PI import kotlin.math.atan2 @@ -55,13 +57,15 @@ enum class OnboardingStep { WELCOME, ACKNOWLEDGEMENT, PREFERENCES, - FEATURE_INTRODUCTION + FEATURE_INTRODUCTION, + WHATS_NEW } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun WelcomeScreen( viewModel: MainViewModel, + isWhatsNewFlow: Boolean = false, onBeginClick: () -> Unit ) { val view = LocalView.current @@ -98,6 +102,7 @@ fun WelcomeScreen( OnboardingStep.WELCOME -> { WelcomeStepContent( viewModel = viewModel, + isWhatsNewFlow = isWhatsNewFlow, rotationAnimatable = rotationAnimatable, center = center, onCenterChanged = { center = it }, @@ -105,7 +110,11 @@ fun WelcomeScreen( onEasterEggTriggered = { hasTriggeredEasterEgg = true }, onNext = { HapticUtil.performVirtualKeyHaptic(view) - currentStep = OnboardingStep.ACKNOWLEDGEMENT + if (isWhatsNewFlow) { + currentStep = OnboardingStep.WHATS_NEW + } else { + currentStep = OnboardingStep.ACKNOWLEDGEMENT + } } ) } @@ -152,6 +161,20 @@ fun WelcomeScreen( } ) } + + OnboardingStep.WHATS_NEW -> { + WhatsNewStepContent( + viewModel = viewModel, + onBack = { + HapticUtil.performVirtualKeyHaptic(view) + currentStep = OnboardingStep.WELCOME + }, + onFinish = { + HapticUtil.performVirtualKeyHaptic(view) + onBeginClick() + } + ) + } } } } @@ -161,6 +184,7 @@ fun WelcomeScreen( @Composable fun WelcomeStepContent( viewModel: MainViewModel, + isWhatsNewFlow: Boolean, rotationAnimatable: Animatable, center: Offset, onCenterChanged: (Offset) -> Unit, @@ -278,7 +302,7 @@ fun WelcomeStepContent( Spacer(modifier = Modifier.height(18.dp)) Text( - text = stringResource(R.string.welcome_title), + text = stringResource(if (isWhatsNewFlow) R.string.welcome_back_title else R.string.welcome_title), style = MaterialTheme.typography.headlineMedium.copy( fontFamily = GoogleSansFlexRounded, fontWeight = FontWeight.SemiBold @@ -328,12 +352,14 @@ fun WelcomeStepContent( Spacer(modifier = Modifier.height(16.dp)) - val appLanguage by viewModel.appLanguage - RoundedCardContainer(modifier = Modifier.padding(horizontal = 16.dp)) { - LanguagePicker( - selectedLanguageCode = appLanguage, - onLanguageSelected = { viewModel.setAppLanguage(it) } - ) + if (!isWhatsNewFlow) { + val appLanguage by viewModel.appLanguage + RoundedCardContainer(modifier = Modifier.padding(horizontal = 16.dp)) { + LanguagePicker( + selectedLanguageCode = appLanguage, + onLanguageSelected = { viewModel.setAppLanguage(it) } + ) + } } Spacer(modifier = Modifier.height(2.dp)) @@ -348,7 +374,7 @@ fun WelcomeStepContent( .height(56.dp) ) { Text( - text = stringResource(R.string.action_lets_begin), + text = stringResource(if (isWhatsNewFlow) R.string.action_see_whats_new else R.string.action_lets_begin), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold ) @@ -689,6 +715,164 @@ fun GifItem( } } +@Composable +fun WhatsNewStepContent( + viewModel: MainViewModel, + onBack: () -> Unit, + onFinish: () -> Unit +) { + val context = LocalContext.current + val view = LocalView.current + val updateInfo by viewModel.updateInfo + val isCheckingUpdate by viewModel.isCheckingUpdate + + LaunchedEffect(Unit) { + if (updateInfo == null && !isCheckingUpdate) { + viewModel.checkForUpdates(context) + } + } + + Column( + modifier = Modifier.fillMaxSize() + ) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.statusBarsPadding()) + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = "Essentials v${com.sameerasw.essentials.BuildConfig.VERSION_NAME}", + style = MaterialTheme.typography.headlineLarge.copy( + fontFamily = GoogleSansFlexRounded, + fontWeight = FontWeight.Bold + ), + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 24.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Custom content slot + Box(modifier = Modifier.padding(horizontal = 24.dp)) { + WhatsNewCustomContent() + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Release Notes / Markdown + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(16.dp) + ) { + if (isCheckingUpdate) { + Box( + modifier = Modifier.fillMaxWidth().height(200.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + val content = updateInfo?.releaseNotes + if (content.isNullOrEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.msg_error_load_release_notes), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(16.dp)) + + val webUrl = updateInfo?.releaseUrl ?: "https://github.com/sameerasw/essentials/releases" + TextButton( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + val intent = Intent(Intent.ACTION_VIEW, webUrl.toUri()) + context.startActivity(intent) + } + ) { + Text( + text = stringResource(R.string.action_view_on_web), + fontWeight = FontWeight.Bold + ) + } + } + } else { + SimpleMarkdown( + content = content, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + + Spacer(modifier = Modifier.height(32.dp)) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onBack() + }, + modifier = Modifier.size(56.dp), + shape = RoundedCornerShape(16.dp), + contentPadding = PaddingValues(0.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_arrow_back_24), + contentDescription = stringResource(R.string.action_back), + modifier = Modifier.size(24.dp) + ) + } + + Button( + onClick = onFinish, + modifier = Modifier + .weight(1f) + .height(56.dp) + ) { + Text( + text = stringResource(R.string.action_let_me_in), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.weight(1f)) + + Icon( + painter = painterResource(id = R.drawable.rounded_mobile_check_24), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + } + } +} + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun PreferencesStepContent( diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index b55abd46a..9ec627ab1 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -124,6 +124,7 @@ class MainViewModel : ViewModel() { val isAutoAccessibilityEnabled = mutableStateOf(false) val isNotificationGlanceSameAsLightingEnabled = mutableStateOf(true) val isOnboardingCompleted = mutableStateOf(true) // Default to true so it doesn't flash on first check if not loaded + val isWhatsNewVisible = mutableStateOf(false) val dnsPresets = mutableStateListOf() val addedQSTiles = mutableStateOf>(emptySet()) @@ -669,6 +670,10 @@ class MainViewModel : ViewModel() { notificationLightingColorMode.value = settingsRepository.getNotificationLightingColorMode() isUseUsageAccess.value = settingsRepository.getBoolean(SettingsRepository.KEY_USE_USAGE_ACCESS) isOnboardingCompleted.value = settingsRepository.getBoolean(SettingsRepository.KEY_ONBOARDING_COMPLETED, false) + + val lastShownCounter = settingsRepository.getInt(SettingsRepository.KEY_WHATS_NEW_LAST_SHOWN_COUNTER, 0) + isWhatsNewVisible.value = isOnboardingCompleted.value && lastShownCounter < com.sameerasw.essentials.BuildConfig.WHATS_NEW_COUNTER + notificationLightingCustomColor.intValue = settingsRepository.getInt( SettingsRepository.KEY_EDGE_LIGHTING_CUSTOM_COLOR, 0xFF6200EE.toInt() @@ -2472,8 +2477,16 @@ class MainViewModel : ViewModel() { } fun setOnboardingCompleted(completed: Boolean, context: Context) { - settingsRepository.putBoolean(SettingsRepository.KEY_ONBOARDING_COMPLETED, completed) isOnboardingCompleted.value = completed + settingsRepository.putBoolean(SettingsRepository.KEY_ONBOARDING_COMPLETED, completed) + if (completed) { + settingsRepository.putInt(SettingsRepository.KEY_WHATS_NEW_LAST_SHOWN_COUNTER, com.sameerasw.essentials.BuildConfig.WHATS_NEW_COUNTER) + } + } + + fun completeWhatsNew() { + isWhatsNewVisible.value = false + settingsRepository.putInt(SettingsRepository.KEY_WHATS_NEW_LAST_SHOWN_COUNTER, com.sameerasw.essentials.BuildConfig.WHATS_NEW_COUNTER) } fun resetOnboarding(context: Context) { diff --git a/app/src/main/res/drawable/madebysameerasw_cover.jpg b/app/src/main/res/drawable/madebysameerasw_cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..287de9edb440160f8d963ee89d7de9c69b92367d GIT binary patch literal 83281 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 C+-a-; literal 0 HcmV?d00001 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 910e7de15..17986471d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -667,6 +667,10 @@ Check for updates at app launch All Set Check What\'s New? + Welcome back to Essentials + See what\'s new + Couldn\'t load the release note + View on web Done Preview From f2259fae8c983d4d401ac6d4a529c7f40688bb0c Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 12 Apr 2026 19:40:31 +0530 Subject: [PATCH 22/23] feat: add MadebySameeraswCard to settings and implement reset functionality for update notes --- .../sameerasw/essentials/SettingsActivity.kt | 28 ++++++++++++++++--- .../ui/components/MadebySameeraswCard.kt | 6 ++-- .../essentials/viewmodels/MainViewModel.kt | 4 +++ 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt index 2f29784bb..323705ac7 100644 --- a/app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt @@ -71,6 +71,7 @@ import androidx.core.app.ActivityCompat import com.sameerasw.essentials.domain.DIYTabs import com.sameerasw.essentials.domain.registry.PermissionRegistry import com.sameerasw.essentials.ui.components.EssentialsFloatingToolbar +import com.sameerasw.essentials.ui.components.MadebySameeraswCard import com.sameerasw.essentials.ui.components.cards.IconToggleItem import com.sameerasw.essentials.ui.components.cards.PermissionCard import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer @@ -728,6 +729,10 @@ fun SettingsContent( Spacer(modifier = Modifier.height(16.dp)) + MadebySameeraswCard() + + Spacer(modifier = Modifier.height(4.dp)) + RoundedCardContainer { AboutSection( onAvatarLongClick = { @@ -791,8 +796,8 @@ fun SettingsContent( .background( color = MaterialTheme.colorScheme.surfaceBright ) - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) ) { Button( onClick = { @@ -800,12 +805,27 @@ fun SettingsContent( viewModel.resetOnboarding(context) Toast.makeText(context, "Onboarding reset", Toast.LENGTH_SHORT).show() }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Reset onboarding", color = MaterialTheme.colorScheme.onError) + } + + + Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + viewModel.resetUpdateNote(context) + Toast.makeText(context, "Update note reset", Toast.LENGTH_SHORT).show() + }, + modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.error ) ) { - Text("Reset Onboarding", color = MaterialTheme.colorScheme.onError) + Text("Reset update note", color = MaterialTheme.colorScheme.onError) } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/MadebySameeraswCard.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/MadebySameeraswCard.kt index dd50b09d8..0a1a72e1d 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/MadebySameeraswCard.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/MadebySameeraswCard.kt @@ -51,7 +51,7 @@ fun MadebySameeraswCard( val intent = Intent(Intent.ACTION_VIEW, redditUrl.toUri()) context.startActivity(intent) }, - color = MaterialTheme.colorScheme.surfaceContainerHigh, + color = MaterialTheme.colorScheme.surfaceBright, shape = RoundedCornerShape(32.dp) ) { Column { @@ -75,8 +75,8 @@ fun MadebySameeraswCard( .offset(y = 32.dp) .size(84.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .border(4.dp, MaterialTheme.colorScheme.surfaceContainerHigh, CircleShape) + .background(MaterialTheme.colorScheme.surfaceBright) + .border(4.dp, MaterialTheme.colorScheme.surfaceBright, CircleShape) .border(6.dp, accentColor.copy(alpha = 0.5f), CircleShape) // Subtle outer ring .padding(4.dp) .border(2.dp, Color(0xFF49FCBB), CircleShape) // Sharper inner stroke diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index 9ec627ab1..0110d3ff3 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -2495,6 +2495,10 @@ class MainViewModel : ViewModel() { setDefaultTab(com.sameerasw.essentials.domain.DIYTabs.ESSENTIALS, context) } + fun resetUpdateNote(context: Context) { + settingsRepository.putInt(SettingsRepository.KEY_WHATS_NEW_LAST_SHOWN_COUNTER, 0) + } + fun resetDnsPresets() { settingsRepository.resetPrivateDnsPresets() } From 2cf52a8d5ba4fb49a7ad4f980d70e543b4b63cb8 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 12 Apr 2026 19:44:33 +0530 Subject: [PATCH 23/23] version: updated to v12.6 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8d57d14eb..677b4c446 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,8 +21,8 @@ android { applicationId = "com.sameerasw.essentials" minSdk = 26 targetSdk = 36 - versionCode = 36 - versionName = "12.5" + versionCode = 37 + versionName = "12.6" val whatsNewCounter = 1 buildConfigField("int", "WHATS_NEW_COUNTER", whatsNewCounter.toString())