diff --git a/powerbell/.gitignore b/powerbell/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/powerbell/.gitignore @@ -0,0 +1 @@ +/build diff --git a/powerbell/README.md b/powerbell/README.md new file mode 100644 index 0000000..4280ca1 --- /dev/null +++ b/powerbell/README.md @@ -0,0 +1,112 @@ +# PowerBell + +#### 介绍 +一个接收手机电量信息的应用,当电量值达到设定范围时会提醒用户。 + +#### 软件架构 +适配安卓应用 [AIDE Pro] 的 Gradle 编译结构。 +也适配安卓应用 [AndroidIDE] 的 Gradle 编译结构。 + + +#### Gradle 编译说明 +调试版编译命令 :gradle assembleBetaDebug +阶段版编译命令 :gradle assembleStageRelease + +#### 使用说明 + +在安卓系统中需要设置两个权限允许。 +1.自启动权限允许。 +2.省电策略-无限制权限允许。 +3.设置背景图片需要读写手机存储权限。 +4.要在锁屏充电的时候提醒,还需要设置允许锁屏通知权限。 + +#### 参与贡献 + +1. Fork 本仓库 +2. 新建 Feat_xxx 分支 +3. 提交代码 : ZhanGSKen(ZhanGSKen) +4. 新建 Pull Request + + +#### 特技 + +1. 使用 Readme\_XXX.md 来支持不同的语言,例如 Readme\_en.md, Readme\_zh.md +2. Gitee 官方博客 [blog.gitee.com](https://blog.gitee.com) +3. 你可以 [https://gitee.com/explore](https://gitee.com/explore) 这个地址来了解 Gitee 上的优秀开源项目 +4. [GVP](https://gitee.com/gvp) 全称是 Gitee 最有价值开源项目,是综合评定出的优秀开源项目 +5. Gitee 官方提供的使用手册 [https://gitee.com/help](https://gitee.com/help) +6. Gitee 封面人物是一档用来展示 Gitee 会员风采的栏目 [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/) + +#### 参考文档 + +AndroidManifest.xml详解 +https://www.jianshu.com/p/3b5b89d4e154 + +CrashHandler自定义异常处理 +https://www.jianshu.com/p/9a3d800a429a + +Android用Intent启动Activity的方法 +https://blog.csdn.net/huangxiaohu_coder/article/details/7105457 + +Android开发:最详细的 Toolbar 开发实践总结 +https://www.jianshu.com/p/79604c3ddcae + +Using the App Toolbar +https://guides.codepath.com/android/using-the-app-toolbar + +Android的onCreateOptionsMenu()创建菜单Menu详解 +https://www.cnblogs.com/spring87/p/4312538.html + +Android通知栏-Notification(通知消息) +https://blog.csdn.net/qq_35507234/article/details/90676587 + +android之PendingIntent的使用 +https://blog.csdn.net/qq_16628781/article/details/51548324 + +change seekbar color android” Code Answer +https://www.codegrepper.com/code-examples/whatever/change+seekbar+color+android + +如何选择开源项目许可证 +https://www.zhihu.com/question/28292322 + +Android最简单的自定义布局Notification +https://blog.csdn.net/acesheep_911/article/details/81458784?utm_medium=distribute.wap_relevant.none-task-blog-2~default~baidujs_title~default-0.wap_blog_relevant_default&spm=1001.2101.3001.4242.1&utm_relevant_index=3 + +Android中通知栏Notification详解以及自定义Notification +https://blog.csdn.net/daitu_liang/article/details/50246803 + +Android 图像系列: 将本地图片加载到Drawable +https://blog.csdn.net/qzone123222/article/details/7930035 + +android 从相册选择,Android开发从相册中选取照片 +https://blog.csdn.net/weixin_42146086/article/details/117570917 + +Android 任务栈简介 +https://blog.csdn.net/qq_34368586/article/details/107653912 + +Android用Intent启动Activity的方法 +https://blog.csdn.net/huangxiaohu_coder/article/details/7105457 + +Android中使用dimen定义尺寸 +https://blog.csdn.net/yuzhiboyi/article/details/7696174 + +declare-styleable:自定义控件的属性 +https://blog.csdn.net/congqingbin/article/details/7869730 + +安卓自定义滑动解锁控件 +https://blog.csdn.net/lp506954774/article/details/72677018 + +Android 添加菜单和返回按钮 +https://blog.csdn.net/my_tiantian/article/details/77822173 + +Android Button的基本使用 +https://www.cnblogs.com/yishaochu/p/5783605.html + +Android应用中实现系统“分享”接口 +https://blog.csdn.net/lowprofile_coding/article/details/37656255 + +Android 关于mimeType的使用 +https://blog.csdn.net/dorytmx/article/details/80972248 + +使用GitHub Actions实现Android自动打包apk +https://blog.csdn.net/ZZL23333/article/details/115798615?app_version=6.0.0&code=app_1562916241&csdn_share_tail=%7B%22type%22%3A%22blog%22%2C%22rType%22%3A%22article%22%2C%22rId%22%3A%22115798615%22%2C%22source%22%3A%22weixin_38986226%22%7D&uLinkId=usr1mkqgl919blen&utm_source=app diff --git a/powerbell/app_update_description.txt b/powerbell/app_update_description.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/powerbell/app_update_description.txt @@ -0,0 +1 @@ + diff --git a/powerbell/build.gradle b/powerbell/build.gradle new file mode 100644 index 0000000..55533e4 --- /dev/null +++ b/powerbell/build.gradle @@ -0,0 +1,95 @@ +apply plugin: 'com.android.application' +apply from: '../.winboll/winboll_app_build.gradle' +apply from: '../.winboll/winboll_lint_build.gradle' + +def genVersionName(def versionName){ + // 检查编译标志位配置 + assert (winbollBuildProps['stageCount'] != null) + assert (winbollBuildProps['baseVersion'] != null) + // 保存基础版本号 + winbollBuildProps.setProperty("baseVersion", "${versionName}"); + //保存编译标志配置 + FileOutputStream fos = new FileOutputStream(winbollBuildPropsFile) + winbollBuildProps.store(fos, "${winbollBuildPropsDesc}"); + fos.close(); + + // 返回编译版本号 + return "${versionName}." + winbollBuildProps['stageCount'] +} + +android { + + // 关键:改为你已安装的 SDK 32(≥ targetSdkVersion 30,兼容已安装环境) + compileSdkVersion 32 + + // 直接使用已安装的构建工具 33.0.3(无需修改) + buildToolsVersion "33.0.3" + + defaultConfig { + applicationId "cc.winboll.studio.powerbell" + minSdkVersion 23 + targetSdkVersion 30 + versionCode 7 + // versionName 更新后需要手动设置 + // .winboll/winbollBuildProps.properties 文件的 stageCount=0 + // Gradle编译环境下合起来的 versionName 就是 "${versionName}.0" + versionName "15.14" + if(true) { + versionName = genVersionName("${versionName}") + } + } + + // 米盟 SDK + packagingOptions { + doNotStrip "*/*/libmimo_1011.so" + } +} + +dependencies { + + // 米盟 + api 'com.miui.zeus:mimo-ad-sdk:5.3.+'//请使用最新版sdk + //注意:以下5个库必须要引入 + //api 'androidx.appcompat:appcompat:1.4.1' + api 'androidx.recyclerview:recyclerview:1.0.0' + api 'com.google.code.gson:gson:2.8.5' + api 'com.github.bumptech.glide:glide:4.9.0' + //annotationProcessor 'com.github.bumptech.glide:compiler:4.9.0' + + // uCrop 核心依赖(最新稳定版) + implementation 'com.github.yalantis:ucrop:2.2.8' + // 兼容AndroidX(若项目用AndroidX,必须添加) + //implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'androidx.exifinterface:exifinterface:1.3.6' + + // 应用介绍页类库 + api 'io.github.medyo:android-about-page:2.0.0' + // SSH + api 'com.jcraft:jsch:0.1.55' + // Html 解析 + api 'org.jsoup:jsoup:1.13.1' + // 二维码类库 + api 'com.google.zxing:core:3.4.1' + api 'com.journeyapps:zxing-android-embedded:3.6.0' + // 网络连接类库 + api 'com.squareup.okhttp3:okhttp:4.4.1' + + // AndroidX 类库 + api 'androidx.appcompat:appcompat:1.1.0' + api 'com.google.android.material:material:1.4.0' + //api 'androidx.viewpager:viewpager:1.0.0' + //api 'androidx.vectordrawable:vectordrawable:1.1.0' + //api 'androidx.vectordrawable:vectordrawable-animated:1.1.0' + //api 'androidx.fragment:fragment:1.1.0' + + // WinBoLL库 nexus.winboll.cc 地址 + api 'cc.winboll.studio:libaes:15.12.13' + api 'cc.winboll.studio:libappbase:15.14.2' + + // WinBoLL备用库 jitpack.io 地址 + //api 'com.github.ZhanGSKen:AES:aes-v15.12.9' + //api 'com.github.ZhanGSKen:APPBase:appbase-v15.14.1' + + //api fileTree(dir: 'libs', include: ['*.aar']) + api fileTree(dir: 'libs', include: ['*.jar']) +} diff --git a/powerbell/build.properties b/powerbell/build.properties new file mode 100644 index 0000000..0397356 --- /dev/null +++ b/powerbell/build.properties @@ -0,0 +1,8 @@ +#Created by .winboll/winboll_app_build.gradle +#Wed Jan 07 18:09:34 HKT 2026 +stageCount=50 +libraryProject= +baseVersion=15.14 +publishVersion=15.14.49 +buildCount=0 +baseBetaVersion=15.14.50 diff --git a/powerbell/build_copyright_pdf.sh b/powerbell/build_copyright_pdf.sh new file mode 100644 index 0000000..3c3125b --- /dev/null +++ b/powerbell/build_copyright_pdf.sh @@ -0,0 +1,279 @@ +#!/bin/bash +# PowerBell软著版本号快速修改+生成脚本 +# 无需手动改主脚本,输入版本号直接运行 + +# 颜色输出函数 +red_echo() { echo -e "\033[31m$1\033[0m"; } +green_echo() { echo -e "\033[32m$1\033[0m"; } +blue_echo() { echo -e "\033[34m$1\033[0m"; } + +# 1. 提示用户输入新版本号 +blue_echo "==== 请输入软著版本号(格式示例:V15、V15.0.1) ====" +read -p "输入版本号:" NEW_VERSION + +# 校验版本号格式(避免特殊符号) +if [[ ! $NEW_VERSION =~ ^V[0-9]+(\.[0-9]+)*$ ]]; then + red_echo "错误:版本号格式无效!请遵循「V+数字」格式(如V15、V15.0.1),不含特殊符号" + exit 1 +fi + +# 2. 定义固定配置(仅需修改这里的著作权人,其他无需动) +SOFTWARE_NAME="PowerBell" +COPYRIGHT_OWNER="张绍建陆丰东海镇云宝软件开发工作室" +LINES_PER_PAGE=55 + +# 3. 生成主脚本(自动替换新版本号) +blue_echo -e "\n==== 生成${NEW_VERSION}版本主脚本 ====" +cat > build_copyright_pdf_temp.sh << EOF +#!/bin/bash +# PowerBell软著PDF生成脚本(版本:$NEW_VERSION) +red_echo() { echo -e "\033[31m\$1\033[0m"; } +green_echo() { echo -e "\033[32m\$1\033[0m"; } +blue_echo() { echo -e "\033[34m\$1\033[0m"; } + +# 配置项(已自动替换为${NEW_VERSION}) +SOFTWARE_NAME="$SOFTWARE_NAME" +SOFTWARE_VERSION="$NEW_VERSION" +COPYRIGHT_OWNER="$COPYRIGHT_OWNER" +LINES_PER_PAGE=$LINES_PER_PAGE + +# 步骤1:检查依赖 +blue_echo "==== 1/7 检查并安装依赖 ====" +sudo apt update > /dev/null 2>&1 +REQUIRED_PKGS=("python3" "wkhtmltopdf" "fonts-wqy-microhei" "pdftk" "poppler-utils") +for pkg in "\${REQUIRED_PKGS[@]}"; do + if ! dpkg -s "\$pkg" > /dev/null 2>&1; then + green_echo "安装依赖:\$pkg" + sudo apt install -y "\$pkg" > /dev/null 2>&1 + fi +done + +# 步骤2:生成纯文本源码 +blue_echo -e "\n==== 2/7 生成纯文本核心源码 ====" +cat > generate_source.py << GEN_EOF +import os +PROJECT_PATH = "./" +OUTPUT_TXT = "PowerBell_Core_Source.txt" +INCLUDE_EXT = [".java", ".kt"] +EXCLUDE_DIRS = ["build", "libs", "test", "androidTest", ".git", ".idea", "gradle", "unittest"] +MIN_LINE_COUNT = 3 +SOFTWARE_NAME = "$SOFTWARE_NAME" +SOFTWARE_VERSION = "$NEW_VERSION" +COPYRIGHT_OWNER = "$COPYRIGHT_OWNER" + +def clean_text(text): + return ''.join(c for c in text if c.isprintable() or c in "\\n\\r\\t") + +def generate_source_txt(): + valid_files = [] + main_dir = os.path.join(PROJECT_PATH, "src", "main") + if not os.path.exists(main_dir): + print("Error: src/main directory not found!") + return + for root, dirs, files in os.walk(main_dir): + dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] + for file in files: + if os.path.splitext(file)[1] in INCLUDE_EXT: + file_path = os.path.join(root, file) + try: + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: + lines = f.readlines() + code_lines = [l for l in lines if l.strip() and not l.strip().startswith("//")] + if len(code_lines) >= MIN_LINE_COUNT: + valid_files.append(file_path) + except: + continue + valid_files.sort(key=lambda x: os.path.getsize(x), reverse=True) + with open(OUTPUT_TXT, "w", encoding="utf-8-sig") as f: + f.write(f"\{SOFTWARE_NAME} \{SOFTWARE_VERSION} 核心源码 - 著作权人:\{COPYRIGHT_OWNER}\\n\\n") + for idx, file_path in enumerate(valid_files, 1): + f.write(f"\\n{'='*60}\\n") + f.write(f"文件 \{idx}:\{file_path.replace(PROJECT_PATH, '')}\\n") + f.write(f"{'='*60}\\n\\n") + try: + try: + with open(file_path, "r", encoding="utf-8") as src_f: + content = clean_text(src_f.read()) + except UnicodeDecodeError: + with open(file_path, "r", encoding="gbk") as src_f: + content = clean_text(src_f.read()) + f.write(content) + f.write("\\n\\n") + except Exception as e: + f.write(f"文件读取失败:\{str(e)}\\n\\n") + continue + print(f"有效源码文件数:\{len(valid_files)}") + print(f"纯文本文件路径:\{os.path.abspath(OUTPUT_TXT)}") + +if __name__ == "__main__": + generate_source_txt() +GEN_EOF + +python3 generate_source.py +if [ ! -f "PowerBell_Core_Source.txt" ]; then + red_echo "纯文本源码生成失败!" + exit 1 +fi + +# 步骤3:生成带版本号页眉的HTML +blue_echo -e "\n==== 3/7 生成带${NEW_VERSION}页眉的HTML ====" +cat > txt2html.py << TXT_EOF +import os +TXT_FILE = "PowerBell_Core_Source.txt" +HTML_FILE = "PowerBell_Source.html" +SOFTWARE_NAME = "$SOFTWARE_NAME" +SOFTWARE_VERSION = "$NEW_VERSION" +COPYRIGHT_OWNER = "$COPYRIGHT_OWNER" +LINES_PER_PAGE = $LINES_PER_PAGE + +CSS_STYLE = """ + +""".format(SOFTWARE_NAME, SOFTWARE_VERSION, COPYRIGHT_OWNER) + +def txt_to_html(): + with open(TXT_FILE, "r", encoding="utf-8") as f: + content = f.read() + html_content = "" + CSS_STYLE + "" + content_lines = content.split("\\n")[2:] + content_clean = "\\n".join(content_lines) + blocks = content_clean.split("====") + + line_count = 0 + for block in blocks: + if not block.strip(): + continue + if "文件 " in block and ":" in block: + file_header = block.split("\\n")[0].strip() if "\\n" in block else block.strip() + html_content += f"
\{file_header}
" + code_part = block.split("\\n")[1:] if "\\n" in block else [] + block = "\\n".join(code_part) + code_lines = block.split("\\n") + for line in code_lines: + if line.strip() or line_count > 0: + line_count += 1 + html_content += f"
\{line}
" + if line_count >= LINES_PER_PAGE: + html_content += "
" + line_count = 0 + html_content += "" + with open(HTML_FILE, "w", encoding="utf-8") as f: + f.write(html_content) + print(f"HTML文件路径:\{os.path.abspath(HTML_FILE)}") + +if __name__ == "__main__": + txt_to_html() +TXT_EOF + +python3 txt2html.py +if [ ! -f "PowerBell_Source.html" ]; then + red_echo "HTML文件生成失败!" + exit 1 +fi + +# 步骤4:生成完整PDF +blue_echo -e "\n==== 4/7 生成完整PDF(版本:${NEW_VERSION}) ====" +wkhtmltopdf --page-size A4 \ + --margin-top 15mm --margin-bottom 15mm --margin-left 5mm --margin-right 5mm \ + --encoding utf-8 \ + --no-images --disable-javascript \ + --enable-local-file-access \ + --no-stop-slow-scripts \ + PowerBell_Source.html PowerBell_soft_full.pdf + +if [ ! -f "PowerBell_soft_full.pdf" ]; then + red_echo "完整PDF生成失败!" + exit 1 +fi + +# 步骤5:截取60页 +blue_echo -e "\n==== 5/7 截取前30+后30页 ====" +TOTAL_PAGES=\$(pdfinfo PowerBell_soft_full.pdf | grep "Pages" | awk '{print \$2}') +green_echo "源码完整PDF总页数:\$TOTAL_PAGES 页" + +if [ "\$TOTAL_PAGES" -le 60 ]; then + cp PowerBell_soft_full.pdf PowerBell_软著源码_${NEW_VERSION}_60页.pdf + green_echo "源码不足60页,直接使用完整PDF" +else + pdftk PowerBell_soft_full.pdf cat 1-30 output PowerBell_前30页.pdf + START_PAGE=\$((TOTAL_PAGES - 29)) + pdftk PowerBell_soft_full.pdf cat \$START_PAGE-\$TOTAL_PAGES output PowerBell_后30页.pdf + pdftk PowerBell_前30页.pdf PowerBell_后30页.pdf cat output PowerBell_软著源码_${NEW_VERSION}_60页.pdf + rm -f PowerBell_前30页.pdf PowerBell_后30页.pdf + green_echo "源码超过60页,已截取前30页+后30页合并为60页" +fi + +# 步骤6:验证规范 +blue_echo -e "\n==== 6/7 验证${NEW_VERSION}版本PDF规范 ====" +FINAL_PAGES=\$(pdfinfo PowerBell_软著源码_${NEW_VERSION}_60页.pdf | grep "Pages" | awk '{print \$2}') +green_echo "最终PDF页数:\$FINAL_PAGES 页" +green_echo "每页代码行数:\$LINES_PER_PAGE 行(≥50行)" +green_echo "页眉信息:$SOFTWARE_NAME $NEW_VERSION - 源代码(著作权人:$COPYRIGHT_OWNER)" + +# 步骤7:清理临时文件 +blue_echo -e "\n==== 7/7 清理临时文件 ====" +rm -f generate_source.py txt2html.py PowerBell_Core_Source.txt PowerBell_Source.html PowerBell_soft_full.pdf +green_echo "临时文件清理完成!" + +# 输出结果 +green_echo -e "\n=====================================" +green_echo "✅ $SOFTWARE_NAME $NEW_VERSION 软著PDF生成成功!🎉" +green_echo "📄 最终文件:\$(pwd)/PowerBell_软著源码_${NEW_VERSION}_60页.pdf" +green_echo "💡 可直接提交软著登记,无需手动修改!" +green_echo "=====================================" +EOF + +# 4. 赋予执行权限并运行 +chmod +x build_copyright_pdf_temp.sh +blue_echo -e "\n==== 开始生成${NEW_VERSION}版本PDF ====" +./build_copyright_pdf_temp.sh + +# 5. 删除临时主脚本(可选,保留则注释此行) +rm -f build_copyright_pdf_temp.sh + +green_echo -e "\n==== 操作完成!${NEW_VERSION}版本PDF已生成 ====" diff --git a/powerbell/proguard-rules.pro b/powerbell/proguard-rules.pro new file mode 100644 index 0000000..855b18a --- /dev/null +++ b/powerbell/proguard-rules.pro @@ -0,0 +1,143 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in C:\tools\adt-bundle-windows-x86_64-20131030\sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# ============================== 基础通用规则 ============================== +# 保留系统组件 +-keep public class * extends android.app.Activity +-keep public class * extends android.app.Service +-keep public class * extends android.content.BroadcastReceiver +-keep public class * extends android.content.ContentProvider +-keep public class * extends android.app.backup.BackupAgentHelper +-keep public class * extends android.preference.Preference + +# 保留 WinBoLL 核心包及子类(合并简化规则) +-keep class cc.winboll.studio.** { *; } +-keepclassmembers class cc.winboll.studio.** { *; } + +# 保留所有类中的 public static final String TAG 字段(便于日志定位) +-keepclassmembers class * { + public static final java.lang.String TAG; +} + +# 保留序列化类(避免Parcelable/Gson解析异常) +-keep class * implements android.os.Parcelable { + public static final android.os.Parcelable$Creator *; +} +-keepclassmembers class * implements java.io.Serializable { + static final long serialVersionUID; + private static final java.io.ObjectStreamField[] serialPersistentFields; + private void writeObject(java.io.ObjectOutputStream); + private void readObject(java.io.ObjectInputStream); + java.lang.Object writeReplace(); + java.lang.Object readResolve(); +} + +# 保留 R 文件(避免资源ID混淆) +-keepclassmembers class **.R$* { + public static ; +} + +# 保留 native 方法(避免JNI调用失败) +-keepclasseswithmembernames class * { + native ; +} + +# 保留注解和泛型(避免反射/序列化异常) +-keepattributes *Annotation* +-keepattributes Signature + +# 屏蔽 Java 8+ 警告(适配 Java 7 语法) +-dontwarn java.lang.invoke.* +-dontwarn android.support.v8.renderscript.* +-dontwarn java.util.function.** + +# ============================== 第三方框架专项规则 ============================== +# OkHttp 4.4.1(米盟广告请求依赖,完善Lambda兼容) +-keep class okhttp3.** { *; } +-keep interface okhttp3.** { *; } +-keep class okhttp3.internal.** { *; } +-keep class okio.** { *; } +-dontwarn okhttp3.internal.platform.** +-dontwarn okio.** +# ============================== 必要补充规则 ============================== +# OkHttp 4.4.1 补充规则(Java 7 兼容) +-keep class okhttp3.internal.concurrent.** { *; } +-keep class okhttp3.internal.connection.** { *; } +-dontwarn okhttp3.internal.concurrent.TaskRunner +-dontwarn okhttp3.internal.connection.RealCall + +# Glide 4.9.0(米盟广告图片加载依赖) +-keep public class * implements com.bumptech.glide.module.GlideModule +-keep public class * extends com.bumptech.glide.module.AppGlideModule +-keep public enum com.bumptech.glide.load.ImageHeaderParser$ImageType { + **[] $VALUES; + public *; +} +-keepclassmembers class * implements com.bumptech.glide.module.AppGlideModule { + (); +} +-dontwarn com.bumptech.glide.** + +# Gson 2.8.5(米盟广告数据序列化依赖) +-keep class com.google.gson.** { *; } +-keep interface com.google.gson.** { *; } +-keepclassmembers class * { + @com.google.gson.annotations.SerializedName ; +} + +# 米盟 SDK(核心广告组件,完整保留避免加载失败) +-keep class com.miui.zeus.** { *; } +-keep interface com.miui.zeus.** { *; } +# 保留米盟日志字段(便于广告加载失败排查) +-keepclassmembers class com.miui.zeus.mimo.sdk.** { + public static final java.lang.String TAG; +} + +# RecyclerView 1.0.0(米盟广告布局渲染依赖) +-keep class androidx.recyclerview.** { *; } +-keep interface androidx.recyclerview.** { *; } +-keepclassmembers class androidx.recyclerview.widget.RecyclerView$Adapter { + public *; +} + +# 其他第三方框架(按引入依赖保留,无则可删除) +# XXPermissions 18.63 +-keep class com.hjq.permissions.** { *; } +-keep interface com.hjq.permissions.** { *; } + +# ZXing 二维码(核心解析组件) +-keep class com.google.zxing.** { *; } +-keep class com.journeyapps.zxing.** { *; } + +# Jsoup HTML解析 +-keep class org.jsoup.** { *; } + +# Pinyin4j 拼音搜索 +-keep class net.sourceforge.pinyin4j.** { *; } + +# JSch SSH组件 +-keep class com.jcraft.jsch.** { *; } + +# AndroidX 基础组件 +-keep class androidx.appcompat.** { *; } +-keep interface androidx.appcompat.** { *; } + +# ============================== 优化与调试配置 ============================== +# 优化级别(平衡混淆效果与性能) +-optimizationpasses 5 +-optimizations !code/simplification/arithmetic,!code/simplification/cast,!field/*,!class/merging/* + +# 调试辅助(保留行号便于崩溃定位) +-verbose +-dontpreverify +-dontusemixedcaseclassnames +-keepattributes SourceFile,LineNumberTable + diff --git a/powerbell/src/beta/AndroidManifest.xml b/powerbell/src/beta/AndroidManifest.xml new file mode 100644 index 0000000..43d1ccf --- /dev/null +++ b/powerbell/src/beta/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/powerbell/src/beta/res/values-zh/string.xml b/powerbell/src/beta/res/values-zh/string.xml new file mode 100644 index 0000000..633b11b --- /dev/null +++ b/powerbell/src/beta/res/values-zh/string.xml @@ -0,0 +1,7 @@ + + + + 能源钟★ + 泡额呗额☆ + + diff --git a/powerbell/src/beta/res/values/strings.xml b/powerbell/src/beta/res/values/strings.xml new file mode 100644 index 0000000..45ddac5 --- /dev/null +++ b/powerbell/src/beta/res/values/strings.xml @@ -0,0 +1,6 @@ + + + + PowerBell+ + + diff --git a/powerbell/src/beta/res/xml/shortcutsmaincn1.xml b/powerbell/src/beta/res/xml/shortcutsmaincn1.xml new file mode 100644 index 0000000..d2297b8 --- /dev/null +++ b/powerbell/src/beta/res/xml/shortcutsmaincn1.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + diff --git a/powerbell/src/beta/res/xml/shortcutsmaincn2.xml b/powerbell/src/beta/res/xml/shortcutsmaincn2.xml new file mode 100644 index 0000000..1bf9db1 --- /dev/null +++ b/powerbell/src/beta/res/xml/shortcutsmaincn2.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + diff --git a/powerbell/src/beta/res/xml/shortcutsmainen1.xml b/powerbell/src/beta/res/xml/shortcutsmainen1.xml new file mode 100644 index 0000000..d56a42e --- /dev/null +++ b/powerbell/src/beta/res/xml/shortcutsmainen1.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + diff --git a/powerbell/src/main/AndroidManifest.xml b/powerbell/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4d60df8 --- /dev/null +++ b/powerbell/src/main/AndroidManifest.xml @@ -0,0 +1,329 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/powerbell/src/main/assets/images/blank100x100.png b/powerbell/src/main/assets/images/blank100x100.png new file mode 100644 index 0000000..84e1003 Binary files /dev/null and b/powerbell/src/main/assets/images/blank100x100.png differ diff --git a/powerbell/src/main/assets/unittest/unittest-miku.png b/powerbell/src/main/assets/unittest/unittest-miku.png new file mode 100644 index 0000000..ac4f4dc Binary files /dev/null and b/powerbell/src/main/assets/unittest/unittest-miku.png differ diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/App.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/App.java new file mode 100644 index 0000000..b11d3ce --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/App.java @@ -0,0 +1,320 @@ +package cc.winboll.studio.powerbell; + +import android.content.Context; +import cc.winboll.studio.libaes.utils.WinBoLLActivityManager; +import cc.winboll.studio.libappbase.GlobalApplication; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import cc.winboll.studio.powerbell.models.NotificationMessage; +import cc.winboll.studio.powerbell.receivers.GlobalApplicationReceiver; +import cc.winboll.studio.powerbell.utils.AppCacheUtils; +import cc.winboll.studio.powerbell.utils.AppConfigUtils; +import cc.winboll.studio.powerbell.utils.BackgroundSourceUtils; +import cc.winboll.studio.powerbell.utils.BitmapCacheUtils; +import cc.winboll.studio.powerbell.utils.NotificationManagerUtils; +import cc.winboll.studio.powerbell.views.MemoryCachedBackgroundView; + +/** + * 应用全局入口类 + * 适配:Java7 语法规范 | Android API30 系统版本 + * 核心策略:极致强制缓存 - 无论内存紧张程度,永不自动清理任何缓存(Bitmap/视图控件/路径记录) + * @Author 豆包&ZhanGSKen + * @Date 2025-12-29 15:30:00 + * @LastModified 2026-01-02 19:01:00 + */ +public class App extends GlobalApplication { + + // ====================================== 常量区 - 置顶排序 (按功能归类) ====================================== + // 基础日志TAG + private static final String TAG = "App"; + // 缓存保护专用TAG + private static final String CACHE_PROTECT_TAG = "FORCE_CACHE_PROTECT"; + // 电池无效值常量(修复拼写错误:INVALID_BATTERY_VALUE) + private static final int INVALID_BATTERY_VALUE = -1; + + // 组件跳转常量 + public static final String COMPONENT_EN1 = "cc.winboll.studio.powerbell.MainActivityEN1"; + public static final String COMPONENT_CN1 = "cc.winboll.studio.powerbell.MainActivityCN1"; + public static final String COMPONENT_CN2 = "cc.winboll.studio.powerbell.MainActivityCN2"; + + // 动作跳转常量 + public static final String ACTION_SWITCHTO_EN1 = "cc.winboll.studio.powerbell.App.ACTION_SWITCHTO_EN1"; + public static final String ACTION_SWITCHTO_CN1 = "cc.winboll.studio.powerbell.App.ACTION_SWITCHTO_CN1"; + public static final String ACTION_SWITCHTO_CN2 = "cc.winboll.studio.powerbell.App.ACTION_SWITCHTO_CN2"; + + // ====================================== 静态属性区 - 全局单例/状态 (按核心程度排序) ====================================== + // 应用单例 + private static App sApp; + + // 配置与缓存工具 (全局单例) + public static AppConfigUtils sAppConfigUtils; + private static AppCacheUtils sAppCacheUtils; + + // 资源与视图缓存 (强制驻留,极致缓存核心) + public static BackgroundSourceUtils sBackgroundSourceUtils; + public static BitmapCacheUtils sBitmapCacheUtils; + private static MemoryCachedBackgroundView sMemoryCachedBackgroundView; + + // 系统状态 (电池电量) + public static volatile int sQuantityOfElectricity = INVALID_BATTERY_VALUE; + + // 系统工具 (通知管理器) + private static NotificationManagerUtils sNotificationManagerUtils; + + // ====================================== 成员属性区 - 非静态成员 (广播接收器) ====================================== + private GlobalApplicationReceiver mGlobalReceiver; + + // ====================================== 公共静态方法 - 单例/工具获取 (对外入口) ====================================== + /** + * 获取应用全局单例实例 + * @return 应用单例App实例 + */ + public static App getInstance() { + LogUtils.d(TAG, "【getInstance】应用单例获取方法调用 | 当前实例:" + sApp); + return sApp; + } + + /** + * 获取配置工具类单例实例 + * @param context 上下文对象 + * @return 配置工具类AppConfigUtils实例 + */ + public static AppConfigUtils getAppConfigUtils(Context context) { + String contextClass = context != null ? context.getClass().getSimpleName() : "null"; + LogUtils.d(TAG, "【getAppConfigUtils】配置工具获取方法调用 | 入参Context类型:" + contextClass); + + if (sAppConfigUtils == null) { + sAppConfigUtils = AppConfigUtils.getInstance(context); + LogUtils.d(TAG, "【getAppConfigUtils】配置工具实例为空,已初始化新实例"); + } + return sAppConfigUtils; + } + + /** + * 获取缓存工具类单例实例 + * @param context 上下文对象 + * @return 缓存工具类AppCacheUtils实例 + */ + public static AppCacheUtils getAppCacheUtils(Context context) { + String contextClass = context != null ? context.getClass().getSimpleName() : "null"; + LogUtils.d(TAG, "【getAppCacheUtils】缓存工具获取方法调用 | 入参Context类型:" + contextClass); + + if (sAppCacheUtils == null) { + sAppCacheUtils = AppCacheUtils.getInstance(context); + LogUtils.d(TAG, "【getAppCacheUtils】缓存工具实例为空,已初始化新实例"); + } + return sAppCacheUtils; + } + + // ====================================== 公共成员方法 - 业务逻辑 (实例方法) ====================================== + /** + * 清除电池历史数据 + */ + public void clearBatteryHistory() { + LogUtils.d(TAG, "【clearBatteryHistory】清除电池历史数据方法调用"); + if (sAppCacheUtils != null) { + sAppCacheUtils.clearBatteryHistory(); + LogUtils.d(TAG, "【clearBatteryHistory】电池历史数据清除成功"); + } else { + LogUtils.w(TAG, "【clearBatteryHistory】电池历史数据清除失败 | 缓存工具实例sAppCacheUtils为空"); + } + } + + /** + * 获取视图缓存实例 + * @return 视图缓存MemoryCachedBackgroundView实例 + */ + public MemoryCachedBackgroundView getMemoryCachedBackgroundView() { + LogUtils.d(TAG, "【getMemoryCachedBackgroundView】视图缓存获取方法调用 | 当前实例:" + sMemoryCachedBackgroundView); + return sMemoryCachedBackgroundView; + } + + // ====================================== 公共静态方法 - 业务逻辑 (全局工具方法) ====================================== + /** + * 手动清理所有缓存(仅主动调用生效,符合极致缓存策略) + */ + public static void manualClearAllCache() { + LogUtils.w(CACHE_PROTECT_TAG, "【manualClearAllCache】手动清理缓存方法调用 | 仅主动触发生效"); + + // 清理Bitmap缓存 + if (sBitmapCacheUtils != null) { + sBitmapCacheUtils.clearAllCache(); + LogUtils.d(CACHE_PROTECT_TAG, "【manualClearAllCache】Bitmap缓存已清理"); + } + + // 仅置空视图缓存引用,不销毁实例(极致缓存策略) + if (sMemoryCachedBackgroundView != null) { + LogUtils.d(CACHE_PROTECT_TAG, "【manualClearAllCache】视图缓存引用已置空 | 实例保留"); + sMemoryCachedBackgroundView = null; + } + + LogUtils.w(CACHE_PROTECT_TAG, "【manualClearAllCache】手动清理缓存操作完成"); + } + + /** + * 发送通知消息(仅调试模式下生效) + * @param title 通知标题 + * @param content 通知内容 + */ + public static void notifyMessage(String title, String content) { + LogUtils.d(TAG, "【notifyMessage】发送通知消息方法调用 | 标题:" + title + " | 内容:" + content); + + boolean canSend = isDebugging() && sApp != null && sNotificationManagerUtils != null; + if (canSend) { + NotificationMessage message = new NotificationMessage(title, content, ""); + sNotificationManagerUtils.showMessageNotification(sApp, message); + LogUtils.d(TAG, "【notifyMessage】通知消息发送成功"); + } else { + LogUtils.d(TAG, "【notifyMessage】通知消息发送失败 | 条件不满足:调试模式=" + isDebugging() + " | 应用实例=" + (sApp != null) + " | 通知工具=" + (sNotificationManagerUtils != null)); + } + } + + // ====================================== 生命周期方法 - 应用全局生命周期 (重写父类方法) ====================================== + @Override + public void onCreate() { + super.onCreate(); + LogUtils.d(TAG, "【onCreate】应用启动生命周期方法调用 | 开始初始化应用..."); + + // 初始化应用单例与调试模式 + sApp = this; + setIsDebugging(BuildConfig.DEBUG); + LogUtils.d(TAG, "【onCreate】应用单例已初始化 | 调试模式:" + BuildConfig.DEBUG); + + // 初始化核心组件 + initBaseTools(); + initUtils(); + initReceiver(); + + LogUtils.d(TAG, "【onCreate】应用初始化完成 | 极致强制缓存策略已激活"); + } + + @Override + public void onTerminate() { + super.onTerminate(); + LogUtils.d(TAG, "【onTerminate】应用终止生命周期方法调用 | 开始释放非缓存资源..."); + + // 释放非缓存资源 + ToastUtils.release(); + releaseNotificationManager(); + releaseReceiver(); + + // 核心策略:不清理任何缓存 + LogUtils.w(CACHE_PROTECT_TAG, "【onTerminate】极致缓存策略生效 | 所有缓存将保留在内存中"); + LogUtils.d(TAG, "【onTerminate】非缓存资源释放完成"); + } + + @Override + public void onTrimMemory(int level) { + super.onTrimMemory(level); + LogUtils.w(CACHE_PROTECT_TAG, "【onTrimMemory】系统内存修剪回调 | 内存等级:" + level + " | 忽略修剪,缓存强制保护"); + logDetailedCacheStatus(); + } + + @Override + public void onLowMemory() { + super.onLowMemory(); + LogUtils.w(CACHE_PROTECT_TAG, "【onLowMemory】系统低内存回调 | 极致缓存策略生效 | 不执行任何缓存清理操作"); + logDetailedCacheStatus(); + } + + // ====================================== 私有初始化方法 - 组件初始化 (按依赖顺序排序) ====================================== + /** + * 初始化基础工具类(Activity管理、Toast、通知管理器) + */ + private void initBaseTools() { + LogUtils.d(TAG, "【initBaseTools】基础工具类初始化开始..."); + WinBoLLActivityManager.init(this); + ToastUtils.init(this); + sNotificationManagerUtils = new NotificationManagerUtils(this); + LogUtils.d(TAG, "【initBaseTools】基础工具类初始化完成"); + } + + /** + * 初始化核心工具与缓存(极致强制驻留,缓存核心) + */ + private void initUtils() { + LogUtils.d(TAG, "【initUtils】核心工具与缓存初始化开始 | 极致缓存策略激活"); + + // 1. 配置与基础缓存工具初始化 + sAppConfigUtils = getAppConfigUtils(this); + sAppCacheUtils = getAppCacheUtils(this); + + // 2. 资源与Bitmap缓存工具初始化(永久驻留) + sBackgroundSourceUtils = BackgroundSourceUtils.getInstance(this); + sBackgroundSourceUtils.loadSettings(); + sBitmapCacheUtils = BitmapCacheUtils.getInstance(); + LogUtils.d(TAG, "【initUtils】资源与Bitmap缓存工具初始化完成 | 永久驻留内存"); + + // 3. 视图缓存初始化(永久驻留,无实例则创建) + sMemoryCachedBackgroundView = MemoryCachedBackgroundView.getLastInstance(this); + if (sMemoryCachedBackgroundView == null) { + sMemoryCachedBackgroundView = MemoryCachedBackgroundView.getInstance(this, sBackgroundSourceUtils.getCurrentBackgroundBean(), true); + LogUtils.d(TAG, "【initUtils】视图缓存无现有实例,已创建新实例"); + } + LogUtils.d(TAG, "【initUtils】视图缓存初始化完成 | 永久驻留内存"); + } + + /** + * 注册全局广播接收器 + */ + private void initReceiver() { + LogUtils.d(TAG, "【initReceiver】全局广播接收器注册开始..."); + mGlobalReceiver = new GlobalApplicationReceiver(this); + mGlobalReceiver.registerAction(); + LogUtils.d(TAG, "【initReceiver】全局广播接收器注册完成"); + } + + // ====================================== 私有释放方法 - 资源释放 (按创建逆序排序) ====================================== + /** + * 释放全局广播接收器 + */ + private void releaseReceiver() { + LogUtils.d(TAG, "【releaseReceiver】全局广播接收器释放开始..."); + if (mGlobalReceiver != null) { + mGlobalReceiver.unregisterAction(); + mGlobalReceiver = null; + LogUtils.d(TAG, "【releaseReceiver】全局广播接收器释放完成"); + } + } + + /** + * 释放通知管理器资源 + */ + private void releaseNotificationManager() { + LogUtils.d(TAG, "【releaseNotificationManager】通知管理器资源释放开始..."); + if (sNotificationManagerUtils != null) { + sNotificationManagerUtils.release(); + sNotificationManagerUtils = null; + LogUtils.d(TAG, "【releaseNotificationManager】通知管理器资源释放完成"); + } + } + + // ====================================== 私有辅助方法 - 日志/工具 (辅助功能) ====================================== + /** + * 记录当前缓存详细状态(用于调试监控,极致缓存策略监控) + */ + private void logDetailedCacheStatus() { + LogUtils.d(TAG, "【logDetailedCacheStatus】缓存状态监控日志开始..."); + + // Bitmap缓存状态 + if (sBitmapCacheUtils != null) { + LogUtils.d(CACHE_PROTECT_TAG, "【缓存状态】BitmapCache - 有效"); + try { + LogUtils.d(CACHE_PROTECT_TAG, "【缓存详情】Bitmap缓存数量:" + sBitmapCacheUtils.getCacheCount()); + } catch (Exception e) { + LogUtils.e(CACHE_PROTECT_TAG, "【缓存详情】获取Bitmap缓存数量失败", e); + } + } else { + LogUtils.d(CACHE_PROTECT_TAG, "【缓存状态】BitmapCache - 未初始化"); + } + + // 视图缓存状态 + if (sMemoryCachedBackgroundView != null) { + LogUtils.d(CACHE_PROTECT_TAG, "【缓存状态】ViewCache - 有效"); + LogUtils.d(CACHE_PROTECT_TAG, "【缓存详情】视图实例数量:" + MemoryCachedBackgroundView.getInstanceCount()); + } else { + LogUtils.d(CACHE_PROTECT_TAG, "【缓存状态】ViewCache - 引用已置空(实例可能保留)"); + } + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/MainActivity.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/MainActivity.java new file mode 100644 index 0000000..0ad0bc2 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/MainActivity.java @@ -0,0 +1,638 @@ +package cc.winboll.studio.powerbell; + +import android.app.Activity; +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewStub; +import androidx.appcompat.widget.Toolbar; +import cc.winboll.studio.libaes.activitys.AboutActivity; +import cc.winboll.studio.libaes.models.APPInfo; +import cc.winboll.studio.libaes.utils.AESThemeUtil; +import cc.winboll.studio.libaes.utils.DevelopUtils; +import cc.winboll.studio.libaes.utils.WinBoLLActivityManager; +import cc.winboll.studio.libaes.views.ADsBannerView; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.activities.BackgroundSettingsActivity; +import cc.winboll.studio.powerbell.activities.BatteryReportActivity; +import cc.winboll.studio.powerbell.activities.ClearRecordActivity; +import cc.winboll.studio.powerbell.activities.SettingsActivity; +import cc.winboll.studio.powerbell.activities.WinBoLLActivity; +import cc.winboll.studio.powerbell.models.BackgroundBean; +import cc.winboll.studio.powerbell.models.BatteryStyle; +import cc.winboll.studio.powerbell.models.ControlCenterServiceBean; +import cc.winboll.studio.powerbell.services.ControlCenterService; +import cc.winboll.studio.powerbell.unittest.MainUnitTest2Activity; +import cc.winboll.studio.powerbell.unittest.MainUnitTestActivity; +import cc.winboll.studio.powerbell.utils.AppConfigUtils; +import cc.winboll.studio.powerbell.utils.BackgroundSourceUtils; +import cc.winboll.studio.powerbell.utils.ImageUtils; +import cc.winboll.studio.powerbell.utils.PermissionUtils; +import cc.winboll.studio.powerbell.utils.ServiceUtils; +import cc.winboll.studio.powerbell.views.BatteryStyleView; +import cc.winboll.studio.powerbell.views.MainContentView; + +/** + * 应用核心主活动 + * 功能:管理电池监控、背景设置、服务启停、权限申请等核心功能 + * 适配:Java7 | API30 | 内存泄漏防护 | UI与服务状态实时同步 + * @Author 豆包&ZhanGSKen + */ +public class MainActivity extends WinBoLLActivity implements MainContentView.OnViewActionListener { + + // ======================== 静态常量区(抽离魔法值,按功能分类)======================== + public static final String TAG = "MainActivity"; + private static final int REQUEST_BACKGROUND_SETTINGS_ACTIVITY = 1001; + public static final String EXTRA_ISRELOAD_BACKGROUNDVIEW = "EXTRA_ISRELOAD_BACKGROUNDVIEW"; + public static final String EXTRA_ISRELOAD_ACCENTCOLOR = "EXTRA_ISRELOAD_ACCENTCOLOR"; + private static final long DELAY_LOAD_NON_CRITICAL = 500L; + + // Handler 消息常量 + public static final int MSG_RELOAD_APPCONFIG = 0; + public static final int MSG_CURRENTVALUEBATTERY = 1; + public static final int MSG_LOAD_BACKGROUND = 2; + private static final int MSG_UPDATE_SERVICE_SWITCH = 3; + private static final int MSG_UPDATE_BATTERYDRAWABLE = 4; + + // ======================== 静态成员区(全局共享,管控生命周期)======================== + private static MainActivity sMainActivity; + private static Handler sGlobalHandler; + + // ======================== 工具类实例区(单例化,避免重复初始化)======================== + private PermissionUtils mPermissionUtils; + private AppConfigUtils mAppConfigUtils; + private BackgroundSourceUtils mBgSourceUtils; + + // ======================== 应用核心实例区 ========================= + private App mApplication; + private MainContentView mMainContentView; + private ControlCenterServiceBean mServiceControlBean; + + // ======================== 基础视图组件区 ========================= + private Toolbar mToolbar; + private ViewStub mAdsViewStub; + private ADsBannerView mADsBannerView; + private Drawable mFrameDrawable; + private Menu mMenu; + + // ======================== 生命周期方法区(按系统调用顺序排列)======================== + @Override + public Activity getActivity() { + return this; + } + + @Override + public String getTag() { + return TAG; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + LogUtils.d(TAG, "onCreate() 调用 | savedInstanceState: " + savedInstanceState); + + initGlobalHandler(); + setContentView(R.layout.activity_main); + initPermissionUtils(); + initMainContentView(); + initCriticalView(); + initCoreUtilsAsync(); + loadNonCriticalViewDelayed(); + + // 处理首次启动参数 + handleReloadBackgroundParam(getIntent()); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + LogUtils.d(TAG, "onNewIntent() 调用 | intent: " + intent); + // 关键:更新Activity持有的Intent,确保后续获取最新值 + setIntent(intent); + // 统一处理刷新背景参数 + handleReloadBackgroundParam(intent); + } + + @Override + protected void onPostCreate(Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + LogUtils.d(TAG, "onPostCreate() 调用 | savedInstanceState: " + savedInstanceState); + mPermissionUtils.startPermissionRequest(this); + } + + @Override + protected void onResume() { + super.onResume(); + LogUtils.d(TAG, "onResume() 调用"); + + if (mADsBannerView != null) { + mADsBannerView.resumeADs(this); + LogUtils.d(TAG, "onResume: 广告视图已恢复"); + } + } + + @Override + protected void onPause() { + super.onPause(); + LogUtils.d(TAG, "onPause() 调用"); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + LogUtils.d(TAG, "onDestroy() 调用"); + + // 释放广告资源 + if (mADsBannerView != null) { + mADsBannerView.releaseAdResources(); + mADsBannerView = null; + LogUtils.d(TAG, "onDestroy: 广告资源已释放"); + } + // 释放核心视图 + if (mMainContentView != null) { + mMainContentView.releaseResources(); + mMainContentView = null; + LogUtils.d(TAG, "onDestroy: 核心视图资源已释放"); + } + // 销毁Handler防止内存泄漏 + if (sGlobalHandler != null) { + sGlobalHandler.removeCallbacksAndMessages(null); + sGlobalHandler = null; + LogUtils.d(TAG, "onDestroy: 全局Handler已销毁"); + } + // 释放Drawable + if (mFrameDrawable != null) { + mFrameDrawable.setCallback(null); + mFrameDrawable = null; + LogUtils.d(TAG, "onDestroy: 框架Drawable已释放"); + } + // 置空所有引用,消除内存泄漏风险 + sMainActivity = null; + mPermissionUtils = null; + mAppConfigUtils = null; + mBgSourceUtils = null; + mServiceControlBean = null; + mMenu = null; + mApplication = null; + mToolbar = null; + mAdsViewStub = null; + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + LogUtils.d(TAG, "onActivityResult() 调用 | requestCode: " + requestCode + " | resultCode: " + resultCode + " | data: " + data); + mPermissionUtils.handlePermissionRequest(this, requestCode, resultCode, data); + + if (requestCode == REQUEST_BACKGROUND_SETTINGS_ACTIVITY && sGlobalHandler != null) { + sGlobalHandler.sendEmptyMessage(MSG_LOAD_BACKGROUND); + LogUtils.d(TAG, "onActivityResult: 发送背景加载消息"); + } + } + + // ======================== 菜单与导航方法区 ======================== + @Override + public boolean onCreateOptionsMenu(Menu menu) { + LogUtils.d(TAG, "onCreateOptionsMenu() 调用 | menu: " + menu); + mMenu = menu; + AESThemeUtil.inflateMenu(this, menu); + + // 调试模式加载测试菜单 + if (App.isDebugging()) { + DevelopUtils.inflateMenu(this, menu); + getMenuInflater().inflate(R.menu.toolbar_unittest, mMenu); + LogUtils.d(TAG, "onCreateOptionsMenu: 已加载测试菜单"); + } + getMenuInflater().inflate(R.menu.toolbar_main, mMenu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + LogUtils.d(TAG, "onOptionsItemSelected() 调用 | itemId: " + item.getItemId()); + // 主题切换处理 + if (AESThemeUtil.onAppThemeItemSelected(this, item)) { + recreate(); + Intent mainIntent = new Intent(MainActivity.this, MainActivity.class); + mainIntent.putExtra(MainActivity.EXTRA_ISRELOAD_BACKGROUNDVIEW, true); + mainIntent.putExtra(MainActivity.EXTRA_ISRELOAD_ACCENTCOLOR, true); + startActivity(mainIntent); + return true; + } + // 开发者功能处理 + if (DevelopUtils.onDevelopItemSelected(this, item)) { + return true; + } + // 菜单点击事件分发 + switch (item.getItemId()) { + case R.id.action_settings: + startActivity(new Intent(this, SettingsActivity.class)); + break; + case R.id.action_battery_report: + startActivity(new Intent(this, BatteryReportActivity.class)); + break; + case R.id.action_clearrecord: + startActivity(new Intent(this, ClearRecordActivity.class)); + break; + case R.id.action_changepicture: + startActivityForResult(new Intent(this, BackgroundSettingsActivity.class), REQUEST_BACKGROUND_SETTINGS_ACTIVITY); + break; + case R.id.action_unittestactivity: + startActivity(new Intent(this, MainUnitTestActivity.class)); + break; + case R.id.action_unittest2activity: + startActivity(new Intent(this, MainUnitTest2Activity.class)); + break; + case R.id.action_about: + startAboutActivity(); + break; + default: + return super.onOptionsItemSelected(item); + } + return true; + } + + @Override + public void setupToolbar() { + super.setupToolbar(); + LogUtils.d(TAG, "setupToolbar() 调用"); + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(false); + LogUtils.d(TAG, "setupToolbar: 已隐藏返回按钮"); + } + } + + @Override + public void onBackPressed() { + LogUtils.d(TAG, "onBackPressed() 调用"); + moveTaskToBack(true); + LogUtils.d(TAG, "onBackPressed: 应用已退至后台"); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + LogUtils.d(TAG, "dispatchKeyEvent() 调用 | event: " + event); + return super.dispatchKeyEvent(event); + } + + // ======================== 核心初始化方法区 ======================== + private void initPermissionUtils() { + LogUtils.d(TAG, "initPermissionUtils() 调用"); + mPermissionUtils = PermissionUtils.getInstance(); + LogUtils.d(TAG, "initPermissionUtils: 权限工具类已初始化"); + } + + private void initGlobalHandler() { + LogUtils.d(TAG, "initGlobalHandler() 调用"); + if (sGlobalHandler == null) { + sGlobalHandler = new Handler() { + @Override + public void handleMessage(Message msg) { + // Activity已销毁则跳过消息处理 + if (sMainActivity == null || sMainActivity.isFinishing() || sMainActivity.isDestroyed()) { + LogUtils.w(TAG, "handleMessage: Activity已销毁,跳过消息 | what: " + msg.what); + return; + } + LogUtils.d(TAG, "handleMessage() 调用 | what: " + msg.what); + + switch (msg.what) { + case MSG_RELOAD_APPCONFIG: + sMainActivity.updateViewData(); + break; + case MSG_CURRENTVALUEBATTERY: + if (sMainActivity.mMainContentView != null) { + sMainActivity.mMainContentView.updateCurrentBattery(msg.arg1); + LogUtils.d(TAG, "handleMessage: 更新当前电量 | value: " + msg.arg1); + } + break; + case MSG_LOAD_BACKGROUND: + sMainActivity.reloadBackground(); + break; + case MSG_UPDATE_SERVICE_SWITCH: + sMainActivity.updateServiceSwitchUI(); + break; + case MSG_UPDATE_BATTERYDRAWABLE: + sMainActivity.updateBatteryDrawable(); + break; + } + } + }; + LogUtils.d(TAG, "initGlobalHandler: 全局Handler已创建"); + } else { + LogUtils.d(TAG, "initGlobalHandler: 全局Handler已存在,无需重复创建"); + } + } + + private void initMainContentView() { + LogUtils.d(TAG, "initMainContentView() 调用"); + View rootView = findViewById(android.R.id.content); + mMainContentView = new MainContentView(this, rootView, this); + LogUtils.d(TAG, "initMainContentView: 核心内容视图已初始化"); + } + + private void initCriticalView() { + LogUtils.d(TAG, "initCriticalView() 调用"); + sMainActivity = this; + mToolbar = findViewById(R.id.toolbar); + setSupportActionBar(mToolbar); + if (mToolbar != null) { + mToolbar.setTitleTextAppearance(this, R.style.Toolbar_TitleText); + LogUtils.d(TAG, "initCriticalView: 工具栏已设置标题样式"); + } + mAdsViewStub = findViewById(R.id.stub_ads_banner); + LogUtils.d(TAG, "initCriticalView: 广告ViewStub已获取"); + } + + private void initCoreUtilsAsync() { + LogUtils.d(TAG, "initCoreUtilsAsync() 调用"); + new Thread(new Runnable() { + @Override + public void run() { + LogUtils.d(TAG, "initCoreUtilsAsync: 异步线程启动 | threadId: " + Thread.currentThread().getId()); + mApplication = (App) getApplication(); + mAppConfigUtils = AppConfigUtils.getInstance(getApplicationContext()); + mBgSourceUtils = BackgroundSourceUtils.getInstance(getActivity()); + + // 初始化服务控制配置 + mServiceControlBean = ControlCenterServiceBean.loadBean(getApplicationContext(), ControlCenterServiceBean.class); + if (mServiceControlBean == null) { + mServiceControlBean = new ControlCenterServiceBean(false); + ControlCenterServiceBean.saveBean(getApplicationContext(), mServiceControlBean); + LogUtils.d(TAG, "initCoreUtilsAsync: 服务配置不存在,已创建默认配置"); + } + + // 根据配置启停服务 + final boolean isServiceEnable = mServiceControlBean.isEnableService(); + final boolean isServiceAlive = ServiceUtils.isServiceAlive(getApplicationContext(), ControlCenterService.class.getName()); + LogUtils.d(TAG, "initCoreUtilsAsync: 服务配置状态 | isServiceEnable: " + isServiceEnable + " | isServiceAlive: " + isServiceAlive); + + if (isServiceEnable && !isServiceAlive) { + runOnUiThread(new Runnable() { + @Override + public void run() { + ControlCenterService.startControlCenterService(getApplicationContext()); + LogUtils.d(TAG, "initCoreUtilsAsync: 服务已启动"); + } + }); + } else if (!isServiceEnable && isServiceAlive) { + runOnUiThread(new Runnable() { + @Override + public void run() { + ControlCenterService.stopControlCenterService(getApplicationContext()); + LogUtils.d(TAG, "initCoreUtilsAsync: 服务已停止"); + } + }); + } + + // 主线程更新UI + runOnUiThread(new Runnable() { + @Override + public void run() { + if (isFinishing() || isDestroyed()) { + LogUtils.w(TAG, "initCoreUtilsAsync: Activity已销毁,跳过UI更新"); + return; + } + // 适配API30,兼容低版本Drawable加载 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + mFrameDrawable = getResources().getDrawable(R.drawable.bg_frame, getTheme()); + } else { + mFrameDrawable = getResources().getDrawable(R.drawable.bg_frame); + } + updateViewData(); + sGlobalHandler.sendEmptyMessage(MSG_LOAD_BACKGROUND); + sGlobalHandler.sendEmptyMessage(MSG_UPDATE_SERVICE_SWITCH); + LogUtils.d(TAG, "initCoreUtilsAsync: UI更新消息已发送"); + } + }); + } + }).start(); + } + + private void loadNonCriticalViewDelayed() { + LogUtils.d(TAG, "loadNonCriticalViewDelayed() 调用 | 延迟时长: " + DELAY_LOAD_NON_CRITICAL + "ms"); + new Handler().postDelayed(new Runnable() { + @Override + public void run() { + if (isFinishing() || isDestroyed()) { + LogUtils.w(TAG, "loadNonCriticalViewDelayed: Activity已销毁,跳过广告加载"); + return; + } + loadAdsView(); + } + }, DELAY_LOAD_NON_CRITICAL); + } + + // ======================== 视图操作方法区 ======================== + private void handleReloadBackgroundParam(Intent intent) { + LogUtils.d(TAG, "handleReloadBackgroundParam() 调用 | intent: " + intent); + if (intent == null) { + LogUtils.d(TAG, "handleReloadBackgroundParam: Intent 为空"); + return; + } + + boolean isReloadAccentColor = intent.getBooleanExtra(EXTRA_ISRELOAD_ACCENTCOLOR, false); + if (isReloadAccentColor) { + App.sBackgroundSourceUtils.getCurrentBackgroundBean().setPixelColor(ImageUtils.getColorAccent(this)); + App.sBackgroundSourceUtils.saveSettings(); + } + + boolean isReloadBackgroundView = intent.getBooleanExtra(EXTRA_ISRELOAD_BACKGROUNDVIEW, false); + if (isReloadBackgroundView) { + LogUtils.d(TAG, "handleReloadBackgroundParam: 接收到刷新背景视图指令"); + reloadBackgroundView(); + } + } + + private void reloadBackgroundView() { + LogUtils.d(TAG, "reloadBackgroundView() 调用"); + mMainContentView.reloadBackgroundView(); + } + + private void loadAdsView() { + LogUtils.d(TAG, "loadAdsView() 调用"); + if (mAdsViewStub == null) { + LogUtils.e(TAG, "loadAdsView: 广告ViewStub为空,加载失败"); + return; + } + if (mADsBannerView == null) { + View adsView = mAdsViewStub.inflate(); + mADsBannerView = adsView.findViewById(R.id.adsbanner); + LogUtils.d(TAG, "loadAdsView: 广告视图已加载"); + } else { + LogUtils.d(TAG, "loadAdsView: 广告视图已存在,无需重复加载"); + } + } + + private void updateViewData() { + LogUtils.d(TAG, "updateViewData() 调用"); + if (mMainContentView == null || mFrameDrawable == null) { + LogUtils.e(TAG, "updateViewData: 核心视图或框架背景为空,更新失败"); + return; + } + mMainContentView.updateViewData(mFrameDrawable); + LogUtils.d(TAG, "updateViewData: 视图数据已更新"); + } + + void updateBatteryDrawable() { + BatteryStyle batteryStyle = BatteryStyleView.getSavedBatteryStyle(this); + mMainContentView.updateBatteryDrawable(batteryStyle); + } + + public static void sendUpdateBatteryDrawableMessage() { + if (sGlobalHandler != null) { + sGlobalHandler.sendEmptyMessage(MSG_UPDATE_BATTERYDRAWABLE); + } + } + + private void reloadBackground() { + LogUtils.d(TAG, "reloadBackground() 调用"); + if (mMainContentView == null || mBgSourceUtils == null) { + LogUtils.e(TAG, "reloadBackground: 核心视图或背景工具类为空,加载失败"); + return; + } + BackgroundBean currentBgBean = mBgSourceUtils.getCurrentBackgroundBean(); + if (currentBgBean != null) { + mMainContentView.backgroundView.loadByBackgroundBean(currentBgBean, true); + LogUtils.d(TAG, "reloadBackground: 已加载自定义背景"); + } else { + mMainContentView.backgroundView.setBackgroundResource(R.drawable.default_background); + LogUtils.d(TAG, "reloadBackground: 已加载默认背景"); + } + } + + private void updateServiceSwitchUI() { + LogUtils.d(TAG, "updateServiceSwitchUI() 调用"); + if (mMainContentView == null || mServiceControlBean == null) { + LogUtils.e(TAG, "updateServiceSwitchUI: 核心视图或服务配置为空,更新失败"); + return; + } + boolean configEnabled = mServiceControlBean.isEnableService(); + mMainContentView.setServiceSwitchEnabled(false); + mMainContentView.setServiceSwitchChecked(configEnabled); + mMainContentView.setServiceSwitchEnabled(true); + LogUtils.d(TAG, "updateServiceSwitchUI: 服务开关已更新 | 状态: " + configEnabled); + } + + // ======================== 服务与线程管理方法区 ======================== + private void toggleServiceEnableState(boolean isEnable) { + LogUtils.d(TAG, "toggleServiceEnableState() 调用 | 目标状态: " + isEnable); + if (mServiceControlBean == null) { + LogUtils.e(TAG, "toggleServiceEnableState: 服务配置为空,切换失败"); + return; + } + mServiceControlBean.setIsEnableService(isEnable); + ControlCenterServiceBean.saveBean(getApplicationContext(), mServiceControlBean); + LogUtils.d(TAG, "toggleServiceEnableState: 服务配置已保存"); + + // UI开关联动服务启停 + if (isEnable) { + if (!ServiceUtils.isServiceAlive(getApplicationContext(), ControlCenterService.class.getName())) { + ControlCenterService.startControlCenterService(getApplicationContext()); + LogUtils.d(TAG, "toggleServiceEnableState: 服务已启动"); + } + } else { + ControlCenterService.stopControlCenterService(getApplicationContext()); + LogUtils.d(TAG, "toggleServiceEnableState: 服务已停止"); + } + + sGlobalHandler.sendEmptyMessage(MSG_UPDATE_SERVICE_SWITCH); + } + + // ======================== 页面跳转方法区 ======================== + private void startAboutActivity() { + LogUtils.d(TAG, "startAboutActivity() 调用"); + Intent aboutIntent = new Intent(getApplicationContext(), AboutActivity.class); + APPInfo appInfo = genDefaultAppInfo(); + aboutIntent.putExtra(AboutActivity.EXTRA_APPINFO, appInfo); + WinBoLLActivityManager.getInstance().startWinBoLLActivity(getApplicationContext(), aboutIntent, AboutActivity.class); + LogUtils.d(TAG, "startAboutActivity: 关于页面已启动"); + } + + // ======================== 消息发送方法区 ======================== + private void notifyServiceAppConfigChange() { + LogUtils.d(TAG, "notifyServiceAppConfigChange() 调用"); + ControlCenterService.sendAppConfigStatusUpdateMessage(this); + reloadAppConfig(); + LogUtils.d(TAG, "notifyServiceAppConfigChange: 服务配置已通知更新"); + } + + public static void reloadAppConfig() { + LogUtils.d(TAG, "reloadAppConfig() 调用"); + if (sGlobalHandler != null) { + sGlobalHandler.sendEmptyMessage(MSG_RELOAD_APPCONFIG); + LogUtils.d(TAG, "reloadAppConfig: 配置重载消息已发送"); + } else { + LogUtils.w(TAG, "reloadAppConfig: 全局Handler为空,消息发送失败"); + } + } + + public static void sendCurrentBatteryValueMessage(int value) { + LogUtils.d(TAG, "sendCurrentBatteryValueMessage() 调用 | 电量: " + value); + if (sGlobalHandler != null) { + Message msg = sGlobalHandler.obtainMessage(MSG_CURRENTVALUEBATTERY); + msg.arg1 = value; + sGlobalHandler.sendMessage(msg); + LogUtils.d(TAG, "sendCurrentBatteryValueMessage: 电量消息已发送"); + } else { + LogUtils.w(TAG, "sendCurrentBatteryValueMessage: 全局Handler为空,消息发送失败"); + } + } + + // ======================== 辅助工具方法区 ======================== + private APPInfo genDefaultAppInfo() { + LogUtils.d(TAG, "genDefaultAppInfo() 调用"); + String branchName = "powerbell"; + APPInfo appInfo = new APPInfo(); + appInfo.setAppName(getString(R.string.app_name)); + appInfo.setAppIcon(R.drawable.ic_launcher); + appInfo.setAppDescription(getString(R.string.app_description)); + appInfo.setAppGitName("WinBoLL"); + appInfo.setAppGitOwner("Studio"); + appInfo.setAppGitAPPBranch(branchName); + appInfo.setAppGitAPPSubProjectFolder(branchName); + appInfo.setAppHomePage("https://www.winboll.cc/apks/index.php?project=PowerBell"); + appInfo.setAppAPKName("PowerBell"); + appInfo.setAppAPKFolderName("PowerBell"); + LogUtils.d(TAG, "genDefaultAppInfo: 应用信息已生成"); + return appInfo; + } + + // ======================== MainContentView 事件回调区 ======================== + @Override + public void onChargeReminderSwitchChanged(boolean isChecked) { + LogUtils.d(TAG, "onChargeReminderSwitchChanged() 调用 | isChecked: " + isChecked); + notifyServiceAppConfigChange(); + } + + @Override + public void onUsageReminderSwitchChanged(boolean isChecked) { + LogUtils.d(TAG, "onUsageReminderSwitchChanged() 调用 | isChecked: " + isChecked); + notifyServiceAppConfigChange(); + } + + @Override + public void onServiceSwitchChanged(boolean isChecked) { + LogUtils.d(TAG, "onServiceSwitchChanged() 调用 | isChecked: " + isChecked); + toggleServiceEnableState(isChecked); + } + + @Override + public void onChargeReminderProgressChanged(int progress) { + LogUtils.d(TAG, "onChargeReminderProgressChanged() 调用 | progress: " + progress); + notifyServiceAppConfigChange(); + } + + @Override + public void onUsageReminderProgressChanged(int progress) { + LogUtils.d(TAG, "onUsageReminderProgressChanged() 调用 | progress: " + progress); + notifyServiceAppConfigChange(); + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/BackgroundSettingsActivity.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/BackgroundSettingsActivity.java new file mode 100644 index 0000000..8c10b6b --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/BackgroundSettingsActivity.java @@ -0,0 +1,994 @@ +package cc.winboll.studio.powerbell.activities; + +import android.Manifest; +import android.app.Activity; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.provider.MediaStore; +import android.provider.Settings; +import android.text.TextUtils; +import android.view.View; +import android.view.ViewTreeObserver; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.Toolbar; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.core.content.FileProvider; +import cc.winboll.studio.libaes.dialogs.YesNoAlertDialog; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import cc.winboll.studio.powerbell.MainActivity; +import cc.winboll.studio.powerbell.R; +import cc.winboll.studio.powerbell.dialogs.BackgroundPicturePreviewDialog; +import cc.winboll.studio.powerbell.dialogs.ColorPaletteDialog; +import cc.winboll.studio.powerbell.dialogs.NetworkBackgroundDialog; +import cc.winboll.studio.powerbell.models.BackgroundBean; +import cc.winboll.studio.powerbell.utils.AppConfigUtils; +import cc.winboll.studio.powerbell.utils.BackgroundSourceUtils; +import cc.winboll.studio.powerbell.utils.BitmapCacheUtils; +import cc.winboll.studio.powerbell.utils.FileUtils; +import cc.winboll.studio.powerbell.utils.ImageCropUtils; +import cc.winboll.studio.powerbell.utils.ImageUtils; +import cc.winboll.studio.powerbell.utils.UriUtils; +import cc.winboll.studio.powerbell.views.BackgroundView; +import java.io.File; + +/** + * 背景设置页面(支持图片选择、拍照、裁剪、像素拾取、调色板等功能) + * 核心:基于强制缓存策略,支持预览与设置提交分离,保留操作状态 + * @Author 豆包&ZhanGSKen + */ +public class BackgroundSettingsActivity extends WinBoLLActivity { + // ====================== 常量定义(按功能分类排序)====================== + public static final String TAG = "BackgroundSettingsActivity"; + + // 系统版本常量 + private static final int SDK_VERSION_TIRAMISU = 33; + + // 请求码(按功能分组,从小到大排序) + public static final int REQUEST_SELECT_PICTURE = 0; + public static final int REQUEST_TAKE_PHOTO = 1; + public static final int REQUEST_CROP_IMAGE = 2; + private static final int REQUEST_PIXELPICKER = 1001; + private static final int REQUEST_CAMERA_PERMISSION = 1004; + + // Bitmap解析常量 + private static final int BITMAP_MAX_SIZE = 2048; + private static final int BITMAP_MAX_SAMPLE_SIZE = 16; + + // ====================== 成员变量(按依赖优先级+功能分类)====================== + // 工具类实例 + private BackgroundSourceUtils mBgSourceUtils; + private BitmapCacheUtils mBitmapCache; + + // 视图组件 + private Toolbar mToolbar; + private BackgroundView mBackgroundView; + + // 状态标记(volatile保证多线程可见性) + private volatile boolean isCommitSettings = false; + private volatile boolean isPreviewBackgroundChanged = false; + + // ====================== 生命周期方法(按执行顺序排列)====================== + @Override + public Activity getActivity() { + return this; + } + + @Override + public String getTag() { + return TAG; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + LogUtils.d(TAG, "onCreate() 开始初始化"); + setContentView(R.layout.activity_background_settings); + + // 初始化核心组件 + initCoreComponents(); + // 初始化Toolbar与点击事件 + initToolbar(); + initClickListeners(); + LogUtils.d(TAG, "onCreate() 视图与事件绑定完成"); + + // 处理分享意图或初始化预览 + handleIntentOrPreview(); + // 初始化预览环境并刷新 + initPreviewEnvironment(); + + LogUtils.d(TAG, "onCreate() 初始化完成"); + } + + @Override + protected void onPostCreate(Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + LogUtils.d(TAG, "onPostCreate() 执行双重刷新预览"); + + // 监听视图布局完成事件 + mBackgroundView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + // 移除监听,避免重复回调 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + mBackgroundView.getViewTreeObserver().removeOnGlobalLayoutListener(this); + } else { + mBackgroundView.getViewTreeObserver().removeGlobalOnLayoutListener(this); + } + + // 此时已获取真实宽高 + int width = mBackgroundView.getWidth(); + int height = mBackgroundView.getHeight(); + LogUtils.d(TAG, String.format("onPostCreate() 获取视图尺寸 | width=%d | height=%d", width, height)); + if (width > 0 && height > 0) { + AppConfigUtils appConfigUtils = AppConfigUtils.getInstance(BackgroundSettingsActivity.this); + appConfigUtils.loadAppConfig(); + appConfigUtils.mAppConfigBean.setDefaultFrameWidth(width); + appConfigUtils.mAppConfigBean.setDefaultFrameHeight(height); + appConfigUtils.saveAppConfig(); + LogUtils.d(TAG, "onPostCreate() 保存默认相框尺寸成功"); + doubleRefreshPreview(); + } + } + }); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + LogUtils.d(TAG, String.format("onActivityResult() | requestCode=%d | resultCode=%d | data=%s", + requestCode, resultCode, data != null ? data.toString() : "null")); + + try { + if (resultCode != RESULT_OK) { + LogUtils.d(TAG, String.format("onActivityResult() 操作取消 | requestCode=%d", requestCode)); + handleOperationCancelOrFail(); + return; + } + handleActivityResult(requestCode, data); + } catch (Exception e) { + LogUtils.e(TAG, String.format("onActivityResult() 异常 | requestCode=%d | 异常信息=%s", + requestCode, e.getMessage())); + ToastUtils.show("操作失败"); + } + } + + @Override + public void finish() { + LogUtils.d(TAG, String.format("finish() | isCommitSettings=%b | isPreviewBackgroundChanged=%b", + isCommitSettings, isPreviewBackgroundChanged)); + if (isCommitSettings) { + super.finish(); + } else { + handleFinishConfirmation(); + } + } + + // ====================== 权限回调方法(单独分类)====================== + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + LogUtils.d(TAG, String.format("onRequestPermissionsResult() | requestCode=%d | 权限数量=%d | 结果数量=%d", + requestCode, permissions.length, grantResults.length)); + if (requestCode == REQUEST_CAMERA_PERMISSION) { + handleCameraPermissionResult(grantResults); + } + } + + // ====================== 界面初始化方法(Toolbar + 点击事件)====================== + private void initToolbar() { + LogUtils.d(TAG, "initToolbar() 开始初始化"); + mToolbar = findViewById(R.id.toolbar); + if (mToolbar == null) { + LogUtils.e(TAG, "initToolbar() | Toolbar未找到"); + return; + } + setSupportActionBar(mToolbar); + mToolbar.setSubtitle(getTag()); + mToolbar.setTitleTextAppearance(this, R.style.Toolbar_TitleText); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + mToolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "导航栏 点击返回按钮"); + finish(); + } + }); + LogUtils.d(TAG, "initToolbar() 配置完成"); + } + + private void initClickListeners() { + LogUtils.d(TAG, "initClickListeners() 开始绑定按钮点击事件"); + // 绑定所有按钮点击事件 + bindClickListener(R.id.activitybackgroundsettingsAButton1, onOriginNullClickListener); + bindClickListener(R.id.activitybackgroundsettingsAButton2, onReceivedPictureClickListener); + bindClickListener(R.id.activitybackgroundsettingsAButton3, onTakePhotoClickListener); + bindClickListener(R.id.activitybackgroundsettingsAButton4, onSelectPictureClickListener); + bindClickListener(R.id.activitybackgroundsettingsAButton5, onNetworkBackgroundDialog); + bindClickListener(R.id.activitybackgroundsettingsAButton6, onCropPictureClickListener); + bindClickListener(R.id.activitybackgroundsettingsAButton7, onCropFreePictureClickListener); + bindClickListener(R.id.activitybackgroundsettingsAButton8, onPixelPickerClickListener); + bindClickListener(R.id.activitybackgroundsettingsAButton9, onColorPaletteClickListener); + bindClickListener(R.id.activitybackgroundsettingsAButton10, onCleanPixelClickListener); + LogUtils.d(TAG, "initClickListeners() 按钮点击事件绑定完成"); + } + + // 通用按钮绑定工具方法 + private void bindClickListener(int resId, View.OnClickListener listener) { + LogUtils.d(TAG, String.format("bindClickListener() | resId=%d", resId)); + View view = findViewById(resId); + if (view != null) { + view.setOnClickListener(listener); + LogUtils.d(TAG, String.format("bindClickListener() | resId=%d 绑定成功", resId)); + } else { + LogUtils.e(TAG, String.format("bindClickListener() | 未找到视图:%d", resId)); + } + } + + // ====================== 按钮点击事件(按功能分类)====================== + private View.OnClickListener onOriginNullClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "onOriginNullClickListener() | 取消背景图片"); + BackgroundBean previewBean = mBgSourceUtils.getPreviewBackgroundBean(); + if (previewBean == null) { + LogUtils.e(TAG, "onOriginNullClickListener() | 预览Bean为空"); + return; + } + previewBean.setIsUseBackgroundFile(false); + mBgSourceUtils.saveSettings(); + doubleRefreshPreview(); + isPreviewBackgroundChanged = true; + } + }; + + private View.OnClickListener onSelectPictureClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "onSelectPictureClickListener() | 选择图片"); + launchImageSelector(); + } + }; + + private View.OnClickListener onNetworkBackgroundDialog = new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "onNetworkBackgroundDialog() | 打开网络背景对话框"); + NetworkBackgroundDialog networkBackgroundDialog = new NetworkBackgroundDialog(BackgroundSettingsActivity.this, new NetworkBackgroundDialog.OnDialogClickListener() { + @Override + public void onConfirm(String szConfirmFilePath) { + LogUtils.d(TAG, String.format("网络背景确认 onConfirm() | 文件路径=%s", szConfirmFilePath)); + // 拷贝文件到预览数据并启动裁剪 + if (putUriFileToPreviewSource(new File(szConfirmFilePath))) { + startImageCrop(false); + } + } + + @Override + public void onCancel() { + LogUtils.d(TAG, "网络背景取消 onCancel()"); + } + }); + networkBackgroundDialog.show(); + } + }; + + private View.OnClickListener onCropPictureClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "onCropPictureClickListener() | 固定比例裁剪"); + startImageCrop(false); + } + }; + + private View.OnClickListener onCropFreePictureClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "onCropFreePictureClickListener() | 自由裁剪"); + startImageCrop(true); + } + }; + + private View.OnClickListener onTakePhotoClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "onTakePhotoClickListener() | 拍照"); + // 动态申请相机权限 + if (ContextCompat.checkSelfPermission(BackgroundSettingsActivity.this, Manifest.permission.CAMERA) + != PackageManager.PERMISSION_GRANTED) { + LogUtils.d(TAG, "拍照准备 | 相机权限未授予,发起申请"); + ActivityCompat.requestPermissions( + BackgroundSettingsActivity.this, + new String[]{Manifest.permission.CAMERA}, + REQUEST_CAMERA_PERMISSION); + return; + } + handleTakePhoto(); + } + }; + + private View.OnClickListener onReceivedPictureClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "onReceivedPictureClickListener() | 恢复收到的图片"); + BackgroundBean previewBean = mBgSourceUtils.getPreviewBackgroundBean(); + if (previewBean == null) { + LogUtils.e(TAG, "onReceivedPictureClickListener() | 预览Bean为空"); + return; + } + previewBean.setIsUseBackgroundFile(true); + mBgSourceUtils.saveSettings(); + doubleRefreshPreview(); + isPreviewBackgroundChanged = true; + } + }; + + private View.OnClickListener onPixelPickerClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "onPixelPickerClickListener() | 像素拾取"); + BackgroundBean previewBean = mBgSourceUtils.getPreviewBackgroundBean(); + if (previewBean == null) { + LogUtils.e(TAG, "onPixelPickerClickListener() | 预览Bean为空"); + ToastUtils.show("无有效图片可拾取像素"); + return; + } + String targetImagePath = previewBean.getBackgroundFilePath(); + File targetFile = new File(targetImagePath); + if (targetFile == null || !targetFile.exists() || targetFile.length() <= 0) { + ToastUtils.show("无有效图片可拾取像素"); + LogUtils.e(TAG, String.format("像素拾取失败 | 文件无效:%s", targetImagePath)); + return; + } + Intent intent = new Intent(getApplicationContext(), PixelPickerActivity.class); + intent.putExtra("imagePath", targetImagePath); + startActivityForResult(intent, REQUEST_PIXELPICKER); + LogUtils.d(TAG, String.format("像素拾取启动 | 路径:%s", targetImagePath)); + } + }; + + private View.OnClickListener onCleanPixelClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "onCleanPixelClickListener() | 清空像素颜色"); + BackgroundBean previewBean = mBgSourceUtils.getPreviewBackgroundBean(); + if (previewBean == null) { + LogUtils.e(TAG, "onCleanPixelClickListener() | 预览Bean为空"); + return; + } + int oldColor = previewBean.getPixelColor(); + previewBean.setPixelColor(ImageUtils.getColorAccent(BackgroundSettingsActivity.this)); + mBgSourceUtils.saveSettings(); + doubleRefreshPreview(); + isPreviewBackgroundChanged = true; + ToastUtils.show("像素颜色已清空"); + LogUtils.d(TAG, String.format("像素清空 | 旧颜色:#%08X", oldColor)); + } + }; + + private View.OnClickListener onColorPaletteClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "onColorPaletteClickListener() | 调色板按钮"); + final BackgroundBean previewBean = mBgSourceUtils.getPreviewBackgroundBean(); + if (previewBean == null) { + LogUtils.e(TAG, "onColorPaletteClickListener() | 预览Bean为空"); + return; + } + int initialColor = previewBean.getPixelColor(); + LogUtils.d(TAG, String.format("调色板 | 初始颜色:#%08X", initialColor)); + ColorPaletteDialog dialog = new ColorPaletteDialog(BackgroundSettingsActivity.this, initialColor, new ColorPaletteDialog.OnColorSelectedListener() { + @Override + public void onColorSelected(int color) { + previewBean.setPixelColor(color); + mBgSourceUtils.saveSettings(); + doubleRefreshPreview(); + isPreviewBackgroundChanged = true; + LogUtils.d(TAG, String.format("颜色选择 | 选中颜色:#%08X", color)); + } + }); + dialog.show(); + LogUtils.d(TAG, "调色板 | 对话框已显示"); + } + }; + + // ====================== 工具方法(通用工具 + 视图工具)====================== + /** + * 生成 FileProvider Uri,适配 Android 7.0+ + * @param file 目标文件 + * @return 适配后的Uri,失败返回null + */ + public Uri getFileProviderUri(File file) { + LogUtils.d(TAG, String.format("getFileProviderUri() | 文件路径:%s", (file != null ? file.getAbsolutePath() : "null"))); + if (file == null) { + LogUtils.e(TAG, "getFileProviderUri() | 文件为空"); + return null; + } + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + String FILE_PROVIDER_AUTHORITY = getPackageName() + ".fileprovider"; + return FileProvider.getUriForFile(this, FILE_PROVIDER_AUTHORITY, file); + } else { + return Uri.fromFile(file); + } + } catch (Exception e) { + LogUtils.e(TAG, String.format("getFileProviderUri() | 生成Uri失败:%s", e.getMessage())); + return null; + } + } + + /** + * 校验 Bitmap 是否有效(未被回收且不为空) + * @param bitmap 目标Bitmap + * @return 有效返回true,否则false + */ + private boolean isBitmapValid(Bitmap bitmap) { + boolean isValid = bitmap != null && !bitmap.isRecycled(); + LogUtils.d(TAG, String.format("isBitmapValid() | Bitmap有效性校验:%b", isValid)); + return isValid; + } + + /** + * 双重刷新预览,确保背景加载最新数据 + */ + private void doubleRefreshPreview() { + LogUtils.d(TAG, "doubleRefreshPreview() 开始双重刷新预览"); + if (mBgSourceUtils == null || mBackgroundView == null || isFinishing()) { + LogUtils.w(TAG, "双重刷新 跳过:对象为空或Activity已结束"); + return; + } + + // 第一重刷新 + try { + mBgSourceUtils.loadSettings(); + BackgroundBean previewBean = mBgSourceUtils.getPreviewBackgroundBean(); + mBackgroundView.loadByBackgroundBean(previewBean, true); + LogUtils.d(TAG, "双重刷新 第一重完成"); + } catch (Exception e) { + LogUtils.e(TAG, String.format("双重刷新 第一重异常:%s", e.getMessage())); + return; + } + + // 第二重刷新(延迟执行) + new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { + @Override + public void run() { + if (mBackgroundView != null && !isFinishing() && mBgSourceUtils != null) { + try { + mBgSourceUtils.loadSettings(); + BackgroundBean previewBean = mBgSourceUtils.getPreviewBackgroundBean(); + mBackgroundView.loadByBackgroundBean(previewBean, true); + LogUtils.d(TAG, "双重刷新 第二重完成"); + } catch (Exception e) { + LogUtils.e(TAG, String.format("双重刷新 第二重异常:%s", e.getMessage())); + } + } + } + }, 200); + } + + // ====================== 业务逻辑方法(按功能分类)====================== + /** + * 初始化核心组件(工具类+视图) + */ + private void initCoreComponents() { + LogUtils.d(TAG, "initCoreComponents() 开始初始化"); + // 初始化视图 + mBackgroundView = findViewById(R.id.background_view); + if (mBackgroundView == null) { + LogUtils.e(TAG, "initCoreComponents() | BackgroundView未找到"); + } + // 初始化工具类 + mBgSourceUtils = BackgroundSourceUtils.getInstance(this); + mBgSourceUtils.loadSettings(); + mBitmapCache = BitmapCacheUtils.getInstance(); + LogUtils.d(TAG, "initCoreComponents() 视图与工具类加载完成"); + } + + /** + * 处理意图或初始化预览 + */ + private void handleIntentOrPreview() { + LogUtils.d(TAG, "handleIntentOrPreview() 开始处理"); + if (handleShareIntent()) { + ToastUtils.show("已接收分享图片"); + LogUtils.d(TAG, "handleIntentOrPreview() | 处理分享意图成功"); + } else { + mBgSourceUtils.setCurrentSourceToPreview(); + LogUtils.d(TAG, "handleIntentOrPreview() | 加载当前背景配置"); + } + } + + /** + * 初始化预览环境 + */ + private void initPreviewEnvironment() { + LogUtils.d(TAG, "initPreviewEnvironment() 开始初始化"); + BackgroundBean previewBean = mBgSourceUtils.getPreviewBackgroundBean(); + mBgSourceUtils.createAndUpdatePreviewEnvironmentForCropping(previewBean); + doubleRefreshPreview(); + LogUtils.d(TAG, "initPreviewEnvironment() 初始化完成"); + } + + /** + * 处理分享意图 + * @return 处理成功返回true,否则false + */ + private boolean handleShareIntent() { + LogUtils.d(TAG, "handleShareIntent() 开始处理"); + Intent intent = getIntent(); + if (intent != null) { + String action = intent.getAction(); + String type = intent.getType(); + LogUtils.d(TAG, String.format("分享处理 | action:%s,type:%s", action, type)); + if (Intent.ACTION_SEND.equals(action) && type != null && isImageType(type)) { + showSharePreviewDialog(); + return true; + } + } + return false; + } + + /** + * 显示分享图片预览对话框 + */ + private void showSharePreviewDialog() { + LogUtils.d(TAG, "showSharePreviewDialog() 开始显示"); + BackgroundPicturePreviewDialog dlg = new BackgroundPicturePreviewDialog(this, new BackgroundPicturePreviewDialog.IOnRecivedPictureListener() { + @Override + public void onAcceptRecivedPicture(Uri uriRecivedPicture) { + LogUtils.d(TAG, String.format("分享确认 | Uri:%s", uriRecivedPicture.toString())); + if (putUriFileToPreviewSource(uriRecivedPicture)) { + startImageCrop(false); + } + } + }); + dlg.show(); + LogUtils.d(TAG, "分享处理 | 显示图片预览对话框"); + } + + /** + * 判断是否为图片类型 + * @param mimeType MIME类型 + * @return 是图片返回true,否则false + */ + private boolean isImageType(String mimeType) { + if (mimeType == null) { + return false; + } + String lowerMimeType = mimeType.toLowerCase(); + LogUtils.d(TAG, String.format("isImageType() | mimeType: %s, lowerMimeType: %s", mimeType, lowerMimeType)); + return lowerMimeType.startsWith("image/"); + } + + /** + * 启动图片选择器 + */ + private void launchImageSelector() { + LogUtils.d(TAG, "launchImageSelector() 启动图片选择器"); + Intent[] intents = createImageSelectorIntents(); + Intent validIntent = findValidIntent(intents); + + if (validIntent != null) { + launchImageChooser(validIntent); + } else { + showNoGalleryDialog(); + } + } + + /** + * 创建图片选择器意图数组 + * @return 意图数组 + */ + private Intent[] createImageSelectorIntents() { + LogUtils.d(TAG, "createImageSelectorIntents() 开始创建"); + Intent[] intents = new Intent[3]; + // ACTION_GET_CONTENT + Intent getContentIntent = new Intent(Intent.ACTION_GET_CONTENT); + getContentIntent.setType("image/*"); + getContentIntent.addCategory(Intent.CATEGORY_OPENABLE); + getContentIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intents[0] = getContentIntent; + + // ACTION_PICK + Intent pickIntent = new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI); + pickIntent.setType("image/*"); + pickIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intents[1] = pickIntent; + + // ACTION_OPEN_DOCUMENT(API19+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + Intent openDocIntent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + openDocIntent.setType("image/*"); + openDocIntent.addCategory(Intent.CATEGORY_OPENABLE); + openDocIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); + intents[2] = openDocIntent; + } + LogUtils.d(TAG, "createImageSelectorIntents() 意图数组创建完成"); + return intents; + } + + /** + * 查找有效的意图 + * @param intents 意图数组 + * @return 有效意图,无则返回null + */ + private Intent findValidIntent(Intent[] intents) { + LogUtils.d(TAG, "findValidIntent() 开始查找"); + for (Intent intent : intents) { + if (intent != null && intent.resolveActivity(getPackageManager()) != null) { + LogUtils.d(TAG, "findValidIntent() | 找到有效意图"); + return intent; + } + } + LogUtils.d(TAG, "findValidIntent() | 无有效意图"); + return null; + } + + /** + * 启动图片选择器 + * @param validIntent 有效意图 + */ + private void launchImageChooser(Intent validIntent) { + LogUtils.d(TAG, "launchImageChooser() 启动选择器"); + Intent chooser = Intent.createChooser(validIntent, "选择图片"); + chooser.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); + startActivityForResult(chooser, REQUEST_SELECT_PICTURE); + LogUtils.d(TAG, "launchImageChooser() | 启动图片选择"); + } + + /** + * 显示无相册应用提示对话框 + */ + private void showNoGalleryDialog() { + LogUtils.d(TAG, "showNoGalleryDialog() | 无相册应用"); + runOnUiThread(new Runnable() { + @Override + public void run() { + ToastUtils.show("未找到相册应用,请安装后重试"); + new AlertDialog.Builder(BackgroundSettingsActivity.this) + .setTitle("无图片选择应用") + .setMessage("需要安装相册应用才能选择图片") + .setPositiveButton("确定", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + launchGalleryMarket(); + } + }) + .setNegativeButton("取消", null) + .show(); + } + }); + } + + /** + * 启动应用商店下载相册 + */ + private void launchGalleryMarket() { + LogUtils.d(TAG, "launchGalleryMarket() 启动应用商店"); + Intent marketIntent = new Intent(Intent.ACTION_VIEW); + marketIntent.setData(Uri.parse("market://details?id=com.android.gallery3d")); + if (marketIntent.resolveActivity(getPackageManager()) != null) { + startActivity(marketIntent); + LogUtils.d(TAG, "launchGalleryMarket() | 启动成功"); + } else { + ToastUtils.show("无法打开应用商店"); + LogUtils.e(TAG, "launchGalleryMarket() | 启动失败"); + } + } + + /** + * 处理操作取消或失败 + */ + private void handleOperationCancelOrFail() { + LogUtils.d(TAG, "handleOperationCancelOrFail() 操作取消或失败"); + mBgSourceUtils.setCurrentSourceToPreview(); + ToastUtils.show("操作取消或失败"); + doubleRefreshPreview(); + } + + /** + * 处理拍照逻辑(权限通过后执行) + */ + void handleTakePhoto() { + LogUtils.d(TAG, "handleTakePhoto() 开始处理拍照"); + BackgroundBean previewBean = mBgSourceUtils.getPreviewBackgroundBean(); + if (previewBean == null) { + LogUtils.e(TAG, "handleTakePhoto() | 预览Bean为空"); + ToastUtils.show("拍照文件创建失败"); + return; + } + + File takePhotoFile = new File(previewBean.getBackgroundFilePath()); + if (!takePhotoFile.exists()) { + ToastUtils.show("拍照文件创建失败"); + LogUtils.e(TAG, String.format("handleTakePhoto() | 文件不存在:%s", takePhotoFile.getAbsolutePath())); + return; + } + + Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); + try { + Uri photoUri = getFileProviderUri(takePhotoFile); + if (photoUri == null) { + throw new Exception("生成FileProvider Uri失败"); + } + takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri); + startActivityForResult(takePictureIntent, REQUEST_TAKE_PHOTO); + LogUtils.d(TAG, String.format("handleTakePhoto() | Uri:%s", photoUri.toString())); + } catch (Exception e) { + String errMsg = "拍照启动异常:" + e.getMessage(); + ToastUtils.show(errMsg.substring(0, 20)); + LogUtils.e(TAG, String.format("handleTakePhoto() | %s", e.getMessage())); + } + } + + /** + * 处理ActivityResult分发 + * @param requestCode 请求码 + * @param data 回调数据 + */ + private void handleActivityResult(int requestCode, Intent data) { + LogUtils.d(TAG, String.format("handleActivityResult() | 处理请求码:%d", requestCode)); + switch (requestCode) { + case REQUEST_SELECT_PICTURE: + handleSelectPictureResult(data); + break; + case REQUEST_TAKE_PHOTO: + handleTakePhotoResult(data); + break; + case REQUEST_CROP_IMAGE: + handleCropImageResult(data); + break; + case REQUEST_PIXELPICKER: + handlePixelPickerResult(); + break; + default: + LogUtils.d(TAG, String.format("handleActivityResult() | 未知requestCode:%d", requestCode)); + break; + } + } + + /** + * 处理拍照结果 + * @param data 回调数据 + */ + private void handleTakePhotoResult(Intent data) { + LogUtils.d(TAG, "handleTakePhotoResult() 处理拍照结果"); + BackgroundBean previewBean = mBgSourceUtils.getPreviewBackgroundBean(); + if (previewBean == null) { + LogUtils.e(TAG, "handleTakePhotoResult() | 预览Bean为空"); + return; + } + + previewBean.setIsUseBackgroundFile(true); + previewBean.setIsUseBackgroundScaledCompressFile(false); + mBgSourceUtils.saveSettings(); + doubleRefreshPreview(); + + startImageCrop(false); + LogUtils.d(TAG, "handleTakePhotoResult() | 已启动裁剪"); + } + + /** + * 处理选图结果 + * @param data 回调数据 + */ + private void handleSelectPictureResult(Intent data) { + LogUtils.d(TAG, "handleSelectPictureResult() 处理选图结果"); + Uri selectedImage = data.getData(); + if (selectedImage == null) { + ToastUtils.show("图片Uri为空"); + LogUtils.e(TAG, "handleSelectPictureResult() | Uri为空"); + return; + } + LogUtils.d(TAG, String.format("handleSelectPictureResult() | 系统返回Uri : %s", selectedImage.toString())); + + // 申请持久化权限(API33+) + if (Build.VERSION.SDK_INT >= SDK_VERSION_TIRAMISU) { + getContentResolver().takePersistableUriPermission( + selectedImage, + Intent.FLAG_GRANT_READ_URI_PERMISSION); + LogUtils.d(TAG, "handleSelectPictureResult() | 已添加持久化权限"); + } + + // 同步文件并启动裁剪 + if (putUriFileToPreviewSource(selectedImage)) { + LogUtils.d(TAG, "handleSelectPictureResult() | 路径绑定完成"); + startImageCrop(false); + } else { + ToastUtils.show("图片同步失败"); + LogUtils.e(TAG, "handleSelectPictureResult() | 文件复制失败"); + } + } + + /** + * 将 Uri 文件同步到预览 Bean + * @param srcUriFile 源Uri + * @return 同步成功返回true,否则false + */ + private boolean putUriFileToPreviewSource(Uri srcUriFile) { + LogUtils.d(TAG, String.format("putUriFileToPreviewSource() | 源Uri:%s", srcUriFile.toString())); + String filePath = UriUtils.getFilePathFromUri(this, srcUriFile); + if (TextUtils.isEmpty(filePath)) { + LogUtils.e(TAG, "putUriFileToPreviewSource() | Uri解析路径为空"); + return false; + } + File srcFile = new File(filePath); + return putUriFileToPreviewSource(srcFile); + } + + /** + * 将 File 同步到预览 Bean + * @param srcFile 源文件 + * @return 同步成功返回true,否则false + */ + private boolean putUriFileToPreviewSource(File srcFile) { + LogUtils.d(TAG, String.format("putUriFileToPreviewSource() | 源文件:%s", srcFile.getAbsolutePath())); + mBgSourceUtils.loadSettings(); + BackgroundBean previewBean = mBgSourceUtils.getPreviewBackgroundBean(); + File dstFile = new File(previewBean.getBackgroundFilePath()); + LogUtils.d(TAG, String.format("putUriFileToPreviewSource() | 目标文件:%s", dstFile.getAbsolutePath())); + if (FileUtils.copyFile(srcFile, dstFile)) { + LogUtils.d(TAG, "putUriFileToPreviewSource() | 文件拷贝成功"); + return true; + } + LogUtils.d(TAG, "putUriFileToPreviewSource() | 文件无法拷贝"); + return false; + } + + /** + * 处理裁剪结果 + * @param data 回调数据 + */ + private void handleCropImageResult(Intent data) { + LogUtils.d(TAG, "handleCropImageResult() 处理裁剪结果"); + BackgroundBean previewBean = mBgSourceUtils.getPreviewBackgroundBean(); + if (previewBean == null) { + LogUtils.e(TAG, "handleCropImageResult() | 预览Bean为空"); + handleOperationCancelOrFail(); + return; + } + + File cropTempFile = new File(previewBean.getBackgroundScaledCompressFilePath()); + boolean isFileExist = cropTempFile.exists(); + boolean isFileReadable = isFileExist ? cropTempFile.canRead() : false; + long fileSize = isFileExist ? cropTempFile.length() : 0; + boolean isCropSuccess = isFileExist && isFileReadable && fileSize > 100; + + if (isCropSuccess) { + handleCropSuccess(previewBean, fileSize); + } else { + handleCropFailure(isFileExist, isFileReadable, fileSize); + } + } + + /** + * 处理裁剪成功 + * @param previewBean 预览Bean + * @param fileSize 文件大小 + */ + private void handleCropSuccess(BackgroundBean previewBean, long fileSize) { + LogUtils.d(TAG, String.format("handleCropSuccess() | 裁剪成功,文件大小:%d", fileSize)); + isPreviewBackgroundChanged = true; + previewBean.setIsUseBackgroundFile(true); + previewBean.setIsUseBackgroundScaledCompressFile(true); + mBgSourceUtils.saveSettings(); + doubleRefreshPreview(); + } + + /** + * 处理裁剪失败 + * @param isFileExist 文件是否存在 + * @param isFileReadable 文件是否可读 + * @param fileSize 文件大小 + */ + private void handleCropFailure(boolean isFileExist, boolean isFileReadable, long fileSize) { + LogUtils.e(TAG, String.format("handleCropFailure() | 裁剪失败,文件状态:存在=%b,可读=%b,大小=%d", + isFileExist, isFileReadable, fileSize)); + handleOperationCancelOrFail(); + } + + /** + * 处理像素拾取结果 + */ + private void handlePixelPickerResult() { + LogUtils.d(TAG, "handlePixelPickerResult() 处理像素拾取结果"); + doubleRefreshPreview(); + isPreviewBackgroundChanged = true; + } + + /** + * 处理相机权限申请结果 + * @param grantResults 权限结果数组 + */ + private void handleCameraPermissionResult(int[] grantResults) { + LogUtils.d(TAG, "handleCameraPermissionResult() 处理相机权限结果"); + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + LogUtils.d(TAG, "handleCameraPermissionResult() | 相机权限授予成功"); + handleTakePhoto(); + } else { + LogUtils.d(TAG, "handleCameraPermissionResult() | 相机权限授予失败"); + ToastUtils.show("相机权限被拒绝,无法拍照"); + // 引导用户到设置页面开启权限(用户选择不再询问时) + if (!ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CAMERA)) { + launchAppSettings(); + } + } + } + + /** + * 启动应用设置页面 + */ + private void launchAppSettings() { + LogUtils.d(TAG, "launchAppSettings() 启动应用设置页面"); + Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + Uri uri = Uri.fromParts("package", getPackageName(), null); + intent.setData(uri); + startActivity(intent); + ToastUtils.show("请在设置中开启相机权限"); + } + + /** + * 处理Finish确认对话框 + */ + private void handleFinishConfirmation() { + LogUtils.d(TAG, "handleFinishConfirmation() 处理Finish确认"); + if (isPreviewBackgroundChanged) { + YesNoAlertDialog.show(this, "背景更换问题", "是否确定背景图片设置?", new YesNoAlertDialog.OnDialogResultListener() { + @Override + public void onYes() { + mBgSourceUtils.commitPreviewSourceToCurrent(); + isCommitSettings = true; + finish(); + Intent mainIntent = new Intent(BackgroundSettingsActivity.this, MainActivity.class); + mainIntent.putExtra(MainActivity.EXTRA_ISRELOAD_BACKGROUNDVIEW, true); + startActivity(mainIntent); + LogUtils.d(TAG, "handleFinishConfirmation() | 确认设置,启动MainActivity并刷新背景"); + } + + @Override + public void onNo() { + isCommitSettings = true; + finish(); + LogUtils.d(TAG, "handleFinishConfirmation() | 取消设置,关闭页面"); + } + }); + } else { + isCommitSettings = true; + finish(); + } + } + + /** + * 启动图片裁剪 + * @param isFreeCrop 是否自由裁剪 + */ + private void startImageCrop(boolean isFreeCrop) { + LogUtils.d(TAG, String.format("startImageCrop() | 是否自由裁剪:%b", isFreeCrop)); + BackgroundBean previewBean = mBgSourceUtils.getPreviewBackgroundBean(); + if (previewBean == null) { + LogUtils.e(TAG, "startImageCrop() | 预览Bean为空"); + ToastUtils.show("裁剪失败:无有效图片"); + return; + } + int width = isFreeCrop ? 0 : mBackgroundView.getWidth(); + int height = isFreeCrop ? 0 : mBackgroundView.getHeight(); + ImageCropUtils.startImageCrop(BackgroundSettingsActivity.this, + previewBean, + width, + height, + isFreeCrop, + REQUEST_CROP_IMAGE); + LogUtils.d(TAG, String.format("startImageCrop() | 目标尺寸:%dx%d", width, height)); + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/BatteryReportActivity.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/BatteryReportActivity.java new file mode 100644 index 0000000..dd38fe0 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/BatteryReportActivity.java @@ -0,0 +1,598 @@ +package cc.winboll.studio.powerbell.activities; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Bundle; +import android.provider.Settings; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; +import androidx.appcompat.widget.Toolbar; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.R; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 电池报告页面,统计应用24小时运行时长与电池消耗情况 + * 支持应用搜索、累计耗电计算、电池广播监听,适配 API30 + * @Author 豆包&ZhanGSKen + */ +public class BatteryReportActivity extends WinBoLLActivity implements IWinBoLLActivity { + // ======================== 静态常量(按功能分类) ========================= + public static final String TAG = "BatteryReportActivity"; + private static final long ONE_DAY_MS = 24 * 3600 * 1000; // 24小时毫秒数 + private static final long ONE_MINUTE_MS = 60 * 1000; // 1分钟毫秒数 + + // ======================== 成员变量(按依赖优先级+功能分类) ========================= + // UI组件 + private Toolbar mToolbar; + private RecyclerView rvBatteryReport; + private EditText etSearch; + + // 数据与适配器 + private BatteryReportAdapter adapter; + private List dataList = new ArrayList<>(); + private List filteredList = new ArrayList<>(); + + // 电池相关 + private BroadcastReceiver batteryReceiver; + private int batteryCapacity = 5400; // 电池容量(mAh) + private float lastBatteryPercent = 100.0f; // 上次电池百分比 + private long lastCheckTime = System.currentTimeMillis(); // 上次检查时间戳 + + // 缓存相关 + private Map appRunTimeCache = new HashMap<>(); + private Map packageToAppNameCache = new HashMap<>(); + private PackageManager mPackageManager; + + // ======================== 接口实现方法 ========================= + @Override + public Activity getActivity() { + return this; + } + + @Override + public String getTag() { + return TAG; + } + + // ======================== 生命周期方法(按执行顺序排列) ========================= + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_battery_report); + LogUtils.d(TAG, "【onCreate】BatteryReportActivity 初始化开始"); + + // 初始化UI组件 + initView(); + // 初始化PackageManager + mPackageManager = getPackageManager(); + LogUtils.d(TAG, "【onCreate】基础组件初始化完成"); + + // 权限检查(Java7 传统条件判断) + if (!hasUsageStatsPermission(this)) { + Toast.makeText(this, "请进入设置-应用-权限-特殊访问权限-使用情况访问权限,开启本应用的权限", Toast.LENGTH_LONG).show(); + startActivity(new Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)); + LogUtils.w(TAG, "【onCreate】缺少使用情况访问权限,引导用户开启"); + return; + } + + // 初始化数据流程:加载应用→缓存名称→获取运行时长→计算初始累计耗电 + loadAllAppPackage(); + preCacheAllAppNames(); + appRunTimeCache = getAppRunTime(); + updateAppRunTimeToModel(); + calculateInitial24hTotalConsumption(); + filteredList.addAll(dataList); + LogUtils.d(TAG, "【onCreate】数据初始化完成,原始数据量:" + dataList.size()); + + // 初始化适配器 + adapter = new BatteryReportAdapter(this, filteredList, mPackageManager, packageToAppNameCache); + rvBatteryReport.setAdapter(adapter); + LogUtils.d(TAG, "【onCreate】适配器初始化完成,过滤后数据量:" + filteredList.size()); + + // 绑定搜索监听 + 注册电池广播 + bindSearchListener(); + registerBatteryReceiver(); + + LogUtils.d(TAG, "【onCreate】BatteryReportActivity 初始化完成"); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + // Java7 显式非空判断 + if (batteryReceiver != null) { + unregisterReceiver(batteryReceiver); + LogUtils.d(TAG, "【onDestroy】电池广播已注销"); + } + LogUtils.d(TAG, "【onDestroy】BatteryReportActivity 销毁完成"); + } + + // ======================== UI初始化方法 ========================= + private void initView() { + // 初始化Toolbar + mToolbar = findViewById(R.id.toolbar); + setSupportActionBar(mToolbar); + mToolbar.setSubtitle(getTag()); + mToolbar.setTitleTextAppearance(this, R.style.Toolbar_TitleText); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + mToolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "【导航栏】点击返回"); + finish(); + } + }); + + // 初始化RecyclerView与搜索框 + etSearch = (EditText) findViewById(R.id.et_search); + rvBatteryReport = (RecyclerView) findViewById(R.id.rv_battery_report); + rvBatteryReport.setLayoutManager(new LinearLayoutManager(this)); + LogUtils.d(TAG, "【initView】UI组件初始化完成"); + } + + // ======================== 搜索监听绑定方法 ========================= + private void bindSearchListener() { + etSearch.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + String keyword = s.toString().trim(); + LogUtils.d(TAG, "【bindSearchListener】搜索关键词变化:" + keyword); + filterAppsByPackageAndName(keyword); + } + + @Override + public void afterTextChanged(Editable s) {} + }); + LogUtils.d(TAG, "【bindSearchListener】搜索监听绑定完成"); + } + + // ======================== 电池广播注册方法 ========================= + private void registerBatteryReceiver() { + batteryReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + int level = intent.getIntExtra("level", 100); + int scale = intent.getIntExtra("scale", 100); + float currentPercent = (float) level / scale * 100; + LogUtils.d(TAG, "【电池广播】电池百分比变化:" + lastBatteryPercent + " -> " + currentPercent); + + if (currentPercent < lastBatteryPercent) { + float dropPercent = lastBatteryPercent - currentPercent; + long duration = System.currentTimeMillis() - lastCheckTime; + LogUtils.d(TAG, "【电池广播】电池消耗:" + dropPercent + "%,时长:" + formatRunTime(duration)); + + // 更新运行时长并计算耗电 + appRunTimeCache = getAppRunTime(); + updateAppRunTimeToModel(); + calculateSingleConsumptionAndAccumulate(dropPercent, appRunTimeCache); + } + + // 刷新记录 + lastBatteryPercent = currentPercent; + lastCheckTime = System.currentTimeMillis(); + } + }; + registerReceiver(batteryReceiver, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); + LogUtils.d(TAG, "【registerBatteryReceiver】电池广播注册完成"); + } + + // ======================== 权限检查方法 ========================= + /** + * 检查是否拥有使用情况访问权限 + * @param context 上下文 + * @return 拥有权限返回true,否则返回false + */ + private boolean hasUsageStatsPermission(Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + LogUtils.w(TAG, "【hasUsageStatsPermission】系统版本低于LOLLIPOP,不支持使用情况访问权限"); + return false; + } + + android.app.usage.UsageStatsManager manager = + (android.app.usage.UsageStatsManager) context.getSystemService(Context.USAGE_STATS_SERVICE); + if (manager == null) { + LogUtils.e(TAG, "【hasUsageStatsPermission】获取UsageStatsManager失败"); + return false; + } + + long endTime = System.currentTimeMillis(); + long startTime = endTime - ONE_MINUTE_MS; + List statsList = manager.queryUsageStats( + android.app.usage.UsageStatsManager.INTERVAL_DAILY, startTime, endTime); + + boolean hasPermission = statsList != null && !statsList.isEmpty(); + LogUtils.d(TAG, "【hasUsageStatsPermission】使用情况访问权限检查结果:" + hasPermission); + return hasPermission; + } + + // ======================== 数据加载与缓存方法 ========================= + /** + * 加载所有应用包名,初始化数据模型 + */ + private void loadAllAppPackage() { + List appList = mPackageManager.getInstalledApplications(PackageManager.GET_META_DATA); + dataList.clear(); + LogUtils.d(TAG, "【loadAllAppPackage】开始加载应用包名列表,共找到" + appList.size() + "个应用"); + + for (ApplicationInfo appInfo : appList) { + String packageName = appInfo.packageName; + dataList.add(new AppBatteryModel(packageName, 0.0f, 0.0f, 0)); + } + LogUtils.d(TAG, "【loadAllAppPackage】应用包名列表加载完成,共添加" + dataList.size() + "个包名"); + } + + /** + * 预缓存所有应用名称,减少PackageManager重复调用 + */ + private void preCacheAllAppNames() { + packageToAppNameCache.clear(); + LogUtils.d(TAG, "【preCacheAllAppNames】开始预缓存包名-应用名称映射"); + + for (AppBatteryModel model : dataList) { + String packageName = model.getPackageName(); + String appName = getAppNameByPackage(packageName); + packageToAppNameCache.put(packageName, appName); + } + LogUtils.d(TAG, "【preCacheAllAppNames】预缓存完成,共缓存" + packageToAppNameCache.size() + "个应用名称"); + } + + /** + * 通过包名获取应用名称,带异常处理 + * @param packageName 应用包名 + * @return 应用名称,获取失败返回包名 + */ + private String getAppNameByPackage(String packageName) { + LogUtils.v(TAG, "【getAppNameByPackage】查询包名:" + packageName); + try { + ApplicationInfo appInfo = mPackageManager.getApplicationInfo(packageName, 0); + return mPackageManager.getApplicationLabel(appInfo).toString(); + } catch (PackageManager.NameNotFoundException e) { + LogUtils.e(TAG, "【getAppNameByPackage】包名" + packageName + "对应的应用未找到:" + e.getMessage()); + return packageName; + } catch (Exception e) { + LogUtils.e(TAG, "【getAppNameByPackage】查询应用名称失败(包名:" + packageName + "):" + e.getMessage()); + return packageName; + } + } + + /** + * 更新运行时长到数据模型 + */ + private void updateAppRunTimeToModel() { + int updateCount = 0; + for (AppBatteryModel model : dataList) { + String packageName = model.getPackageName(); + Long runTime = appRunTimeCache.containsKey(packageName) ? appRunTimeCache.get(packageName) : 0L; + model.setRunTime(runTime); + if (runTime > 0) { + updateCount++; + } + } + LogUtils.d(TAG, "【updateAppRunTimeToModel】更新完成,数据量:" + dataList.size() + ",更新运行时长应用数:" + updateCount); + } + + /** + * 获取应用24小时运行时长 + * @return 应用包名-运行时长(ms)映射 + */ + private Map getAppRunTime() { + Map runTimeMap = new HashMap<>(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + try { + android.app.usage.UsageStatsManager manager = + (android.app.usage.UsageStatsManager) getSystemService(Context.USAGE_STATS_SERVICE); + long endTime = System.currentTimeMillis(); + long startTime = endTime - ONE_DAY_MS; // 近24小时 + List statsList = manager.queryUsageStats( + android.app.usage.UsageStatsManager.INTERVAL_DAILY, startTime, endTime); + + for (android.app.usage.UsageStats stats : statsList) { + long runTimeMs = stats.getTotalTimeInForeground(); + String packageName = stats.getPackageName(); + runTimeMap.put(packageName, runTimeMs); + LogUtils.v(TAG, "【getAppRunTime】包名" + packageName + "24小时运行时长:" + formatRunTime(runTimeMs)); + if (packageName.equals("aidepro.top")) { + LogUtils.d(TAG, "【getAppRunTime】特殊查询包名" + packageName + "有结果"); + } + } + } catch (Exception e) { + LogUtils.e(TAG, "【getAppRunTime】获取应用运行时长失败:" + e.getMessage()); + } + } + LogUtils.d(TAG, "【getAppRunTime】应用运行时长列表数量:" + runTimeMap.size()); + return runTimeMap; + } + + // ======================== 核心计算方法 ========================= + /** + * 初始化时计算24小时累计耗电(赋值给totalConsumption) + * 逻辑:基于24小时运行时长占比,分配当前电池容量的理论24小时消耗 + */ + private void calculateInitial24hTotalConsumption() { + long total24hRunTime = 0; + // 1. 计算24小时内所有应用总运行时长 + for (Map.Entry entry : appRunTimeCache.entrySet()) { + total24hRunTime += entry.getValue(); + } + LogUtils.d(TAG, "【calculateInitial24hTotalConsumption】24小时内所有应用总运行时长:" + formatRunTime(total24hRunTime)); + + // 2. 按运行时长占比分配24小时累计耗电 + for (AppBatteryModel model : dataList) { + String packageName = model.getPackageName(); + Long app24hRunTime = appRunTimeCache.getOrDefault(packageName, 0L); + + float ratio = (total24hRunTime > 0) ? (float) app24hRunTime / total24hRunTime : 0; + float initialTotalConsumption = batteryCapacity * ratio; + model.setTotalConsumption(initialTotalConsumption); + LogUtils.v(TAG, "【calculateInitial24hTotalConsumption】应用包" + packageName + "24小时累计耗电初始化:" + initialTotalConsumption + " mAh"); + } + LogUtils.d(TAG, "【calculateInitial24hTotalConsumption】24小时累计耗电初始化完成"); + } + + /** + * 计算单次耗电(赋值给consumption)+ 累加至累计耗电 + * @param dropPercent 电池下降百分比 + * @param runTimeMap 应用运行时长映射 + */ + private void calculateSingleConsumptionAndAccumulate(float dropPercent, Map runTimeMap) { + LogUtils.d(TAG, "【calculateSingleConsumptionAndAccumulate】开始计算,电池下降百分比:" + dropPercent); + long totalSingleRunTime = 0; + // 1. 计算本次电池下降期间的总运行时长 + for (Map.Entry entry : runTimeMap.entrySet()) { + totalSingleRunTime += entry.getValue(); + } + LogUtils.d(TAG, "【calculateSingleConsumptionAndAccumulate】本次电池下降总运行时长:" + formatRunTime(totalSingleRunTime)); + + // 2. 遍历计算每个应用的单次耗电并累加 + for (AppBatteryModel model : dataList) { + String packageName = model.getPackageName(); + Long appSingleRunTime = runTimeMap.getOrDefault(packageName, 0L); + + float ratio = (totalSingleRunTime > 0) ? (float) appSingleRunTime / totalSingleRunTime : 0; + float singleConsumption = batteryCapacity * dropPercent / 100 * ratio; + model.setConsumption(singleConsumption); + + // 累加至累计耗电 + float newTotalConsumption = model.getTotalConsumption() + singleConsumption; + model.setTotalConsumption(newTotalConsumption); + model.setRunTime(appSingleRunTime); + + LogUtils.v(TAG, String.format("【calculateSingleConsumptionAndAccumulate】应用包%s:单次耗电%.1f mAh,累计耗电%.1f mAh", + packageName, singleConsumption, newTotalConsumption)); + } + + // 3. 按累计耗电降序排序 + Collections.sort(dataList, new Comparator() { + @Override + public int compare(AppBatteryModel m1, AppBatteryModel m2) { + return Float.compare(m2.getTotalConsumption(), m1.getTotalConsumption()); + } + }); + + // 4. 重新过滤并刷新列表 + filterAppsByPackageAndName(etSearch.getText().toString().trim()); + LogUtils.d(TAG, "【calculateSingleConsumptionAndAccumulate】单次耗电计算与累加完成,列表已刷新"); + } + + /** + * 双维度过滤(包名+应用名) + * @param keyword 搜索关键词 + */ + private void filterAppsByPackageAndName(String keyword) { + filteredList.clear(); + if (keyword == null || keyword.isEmpty()) { + filteredList.addAll(dataList); + LogUtils.d(TAG, "【filterAppsByPackageAndName】搜索关键词为空,显示全部应用,数量:" + filteredList.size()); + } else { + String lowerKeyword = keyword.toLowerCase(); + for (AppBatteryModel model : dataList) { + String packageName = model.getPackageName(); + String packageNameLower = packageName.toLowerCase(); + String appName = packageToAppNameCache.get(packageName); + String appNameLower = appName.toLowerCase(); + + boolean isMatched = packageNameLower.contains(lowerKeyword) || appNameLower.contains(lowerKeyword); + if (isMatched) { + filteredList.add(model); + } + } + LogUtils.d(TAG, "【filterAppsByPackageAndName】搜索关键词:" + keyword + ",匹配应用数量:" + filteredList.size()); + } + adapter.notifyDataSetChanged(); + } + + // ======================== 工具方法 ========================= + /** + * 格式化运行时长 + * @param runTimeMs 运行时长(ms) + * @return 格式化后的运行时长字符串 + */ + private String formatRunTime(long runTimeMs) { + if (runTimeMs <= 0) { + return "0秒"; + } + long seconds = runTimeMs / 1000; + long hours = seconds / 3600; + long minutes = (seconds % 3600) / 60; + seconds = seconds % 60; + + if (hours > 0) { + return String.format("%d时%d分%d秒", hours, minutes, seconds); + } else if (minutes > 0) { + return String.format("%d分%d秒", minutes, seconds); + } else { + return String.format("%d秒", seconds); + } + } + + // ======================== 内部类:数据模型 ========================= + /** + * 应用电池数据模型 + * - consumption:单次耗电(两次电池广播间的消耗) + * - totalConsumption:累计耗电(24小时初始化值+后续单次累加) + * - runTime:运行时长(ms) + */ + public static class AppBatteryModel { + private String packageName; // 应用包名(核心标识) + private float consumption; // 单次耗电(mAh) + private float totalConsumption;// 累计耗电(mAh) + private long runTime; // 运行时长(ms) + + // Java7 显式构造 + public AppBatteryModel(String packageName, float consumption, float totalConsumption, long runTime) { + this.packageName = packageName; + this.consumption = consumption; + this.totalConsumption = totalConsumption; + this.runTime = runTime; + } + + // Getter/Setter + public String getPackageName() { + return packageName; + } + + public float getConsumption() { + return consumption; + } + + public void setConsumption(float consumption) { + this.consumption = consumption; + } + + public float getTotalConsumption() { + return totalConsumption; + } + + public void setTotalConsumption(float totalConsumption) { + this.totalConsumption = totalConsumption; + } + + public long getRunTime() { + return runTime; + } + + public void setRunTime(long runTime) { + this.runTime = runTime; + } + } + + // ======================== 内部类:RecyclerView适配器 ========================= + /** + * 电池报告列表适配器,显示应用名称、累计耗电、运行时长 + */ + public static class BatteryReportAdapter extends RecyclerView.Adapter { + private Context mContext; + private List mDataList; + private PackageManager mPm; + private Map mPackageToNameCache; + + // Java7 显式构造 + public BatteryReportAdapter(Context context, List dataList, + PackageManager pm, Map packageToNameCache) { + this.mContext = context; + this.mDataList = dataList; + this.mPm = pm; + this.mPackageToNameCache = packageToNameCache; + LogUtils.d(TAG, "【BatteryReportAdapter】适配器构造完成,数据量:" + dataList.size()); + } + + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View itemView = LayoutInflater.from(mContext) + .inflate(android.R.layout.simple_list_item_2, parent, false); + return new ViewHolder(itemView); + } + + @Override + public void onBindViewHolder(ViewHolder holder, int position) { + // Java7 显式非空判断 + if (mDataList == null || mDataList.isEmpty() || position >= mDataList.size()) { + holder.tvAppName.setText("未知应用"); + holder.tvConsumption.setText("累计耗电:0.0 mAh | 运行时长:0秒"); + LogUtils.w(TAG, "【onBindViewHolder】数据异常,位置:" + position); + return; + } + + AppBatteryModel model = mDataList.get(position); + String packageName = model.getPackageName(); + String appName = ""; + + // 优先从缓存获取应用名 + if (mPackageToNameCache != null && mPackageToNameCache.containsKey(packageName)) { + appName = mPackageToNameCache.get(packageName); + } else { + // 缓存无数据时兜底查询 + try { + ApplicationInfo appInfo = mPm.getApplicationInfo(packageName, 0); + appName = mPm.getApplicationLabel(appInfo).toString(); + if (mPackageToNameCache != null) { + mPackageToNameCache.put(packageName, appName); + } + } catch (PackageManager.NameNotFoundException e) { + appName = packageName; + LogUtils.e("BatteryReportAdapter", "【onBindViewHolder】包名" + packageName + "对应的应用未找到:" + e.getMessage()); + } catch (Exception e) { + appName = packageName; + LogUtils.e("BatteryReportAdapter", "【onBindViewHolder】查询应用名称失败(包名:" + packageName + "):" + e.getMessage()); + } + } + + // 显示逻辑:应用名称 + 累计耗电 + 运行时长 + holder.tvAppName.setText(appName); + String runTimeStr = ((BatteryReportActivity) mContext).formatRunTime(model.getRunTime()); + String totalConsumptionText = String.format("累计耗电:%.1f mAh | 运行时长:%s", + model.getTotalConsumption(), runTimeStr); + holder.tvConsumption.setText(totalConsumptionText); + + // 显示优化 + holder.tvAppName.setTextColor(mContext.getResources().getColor(android.R.color.black)); + holder.tvConsumption.setTextColor(mContext.getResources().getColor(android.R.color.darker_gray)); + holder.tvAppName.setTextSize(16); + holder.tvConsumption.setTextSize(14); + } + + @Override + public int getItemCount() { + return mDataList == null ? 0 : mDataList.size(); + } + + /** + * ViewHolder:绑定系统布局控件 + */ + public static class ViewHolder extends RecyclerView.ViewHolder { + TextView tvAppName; // 应用名称 + TextView tvConsumption; // 累计耗电 + 运行时长 + + public ViewHolder(View itemView) { + super(itemView); + tvAppName = (TextView) itemView.findViewById(android.R.id.text1); + tvConsumption = (TextView) itemView.findViewById(android.R.id.text2); + } + } + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/ClearRecordActivity.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/ClearRecordActivity.java new file mode 100644 index 0000000..a942d55 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/ClearRecordActivity.java @@ -0,0 +1,166 @@ +package cc.winboll.studio.powerbell.activities; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.Switch; +import android.widget.TextView; +import androidx.appcompat.widget.Toolbar; +import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity; +import cc.winboll.studio.libaes.views.AOHPCTCSeekBar; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import cc.winboll.studio.powerbell.App; +import cc.winboll.studio.powerbell.R; +import cc.winboll.studio.powerbell.models.BatteryInfoBean; +import cc.winboll.studio.powerbell.receivers.ControlCenterServiceReceiver; +import cc.winboll.studio.powerbell.utils.AppCacheUtils; +import cc.winboll.studio.powerbell.utils.StringUtils; +import java.util.ArrayList; + +/** + * 电池记录清理页面,支持滑动清理记录、切换记录显示格式 + * 适配 API30,基于 Java7 开发 + * @Author 豆包&ZhanGSKen + */ +public class ClearRecordActivity extends WinBoLLActivity implements IWinBoLLActivity { + // ======================== 静态常量(按功能分类) ========================= + public static final String TAG = "ClearRecordActivity"; + private static final String TOAST_MSG_CLEAR_SUCCESS = "The APP battery record is cleaned."; + + // ======================== 成员变量(按依赖优先级+功能分类) ========================= + // UI组件 + private Toolbar mToolbar; + private TextView mtvRecordText; + private TextView tvAOHPCTCSeekBarMSG; + private AOHPCTCSeekBar aOHPCTCSeekBar; + + // 应用与配置 + private App mApplication; + private boolean mIsShowRecordWithEnter = false; // 记录是否带换行显示 + + // ======================== 接口实现方法 ========================= + @Override + public Activity getActivity() { + return this; + } + + @Override + public String getTag() { + return TAG; + } + + // ======================== 生命周期方法(按执行顺序排列) ========================= + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_clearrecord); + LogUtils.d(TAG, "【onCreate】ClearRecordActivity 初始化开始"); + + // 初始化应用实例 + mApplication = (App) getApplication(); + LogUtils.d(TAG, "【onCreate】应用实例初始化完成"); + + // 初始化核心逻辑 + initView(); + initSeekBar(); + initRecordText(); + + LogUtils.d(TAG, "【onCreate】ClearRecordActivity 初始化完成"); + } + + // ======================== UI初始化方法 ========================= + /** + * 初始化Toolbar与显示文本组件 + */ + private void initView() { + // 初始化Toolbar + mToolbar = (Toolbar) findViewById(R.id.toolbar); + setSupportActionBar(mToolbar); + mToolbar.setSubtitle(getTag()); + mToolbar.setTitleTextAppearance(this, R.style.Toolbar_TitleText); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + mToolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "【导航栏】点击返回按钮,关闭当前页面"); + finish(); + } + }); + + // 初始化显示文本组件 + tvAOHPCTCSeekBarMSG = (TextView) findViewById(R.id.activityclearrecordTextView1); + mtvRecordText = (TextView) findViewById(R.id.activityclearrecordTextView2); + tvAOHPCTCSeekBarMSG.setText(R.string.msg_AOHPCTCSeekBar_ClearRecord); + + LogUtils.d(TAG, "【initView】UI组件初始化完成"); + } + + /** + * 初始化滑动清理控件,设置回调监听 + */ + private void initSeekBar() { + aOHPCTCSeekBar = (AOHPCTCSeekBar) findViewById(R.id.activityclearrecordAOHPCTCSeekBar1); + aOHPCTCSeekBar.setThumb(getDrawable(R.drawable.cursor_pointer)); + aOHPCTCSeekBar.setThumbOffset(0); + aOHPCTCSeekBar.setOnOHPCListener(new AOHPCTCSeekBar.OnOHPCListener() { + @Override + public void onOHPCommit() { + LogUtils.d(TAG, "【onOHPCommit】滑动清理触发,开始执行记录清理逻辑"); + // 清理电池历史记录 + mApplication.clearBatteryHistory(); + // 发送广播更新前台通知 + sendBroadcast(new Intent(ControlCenterServiceReceiver.ACTION_UPDATE_FOREGROUND_NOTIFICATION)); + // 刷新记录显示 + initRecordText(); + // 提示清理成功 + ToastUtils.show(TOAST_MSG_CLEAR_SUCCESS); + LogUtils.d(TAG, "【onOHPCommit】电池记录清理完成,已发送前台通知更新广播"); + } + }); + + LogUtils.d(TAG, "【initSeekBar】滑动清理控件初始化完成,回调监听已绑定"); + } + + // ======================== 业务逻辑方法 ========================= + /** + * 初始化记录显示文本,根据配置切换带换行/不带换行格式 + */ + void initRecordText() { + ArrayList listBatteryInfo = AppCacheUtils.getInstance(this).getArrayListBatteryInfo(); + String szRecordText; + + // 判空处理:避免空列表导致异常 + if (listBatteryInfo == null || listBatteryInfo.isEmpty()) { + szRecordText = getString(R.string.msg_no_battery_record); + LogUtils.d(TAG, "【initRecordText】无电池记录数据,显示空记录提示文本"); + } else { + // 根据配置切换显示格式 + if (mIsShowRecordWithEnter) { + szRecordText = StringUtils.formatPCMListStringWithEnter(listBatteryInfo); + LogUtils.d(TAG, String.format("【initRecordText】使用带换行格式显示记录,记录数量:%d", listBatteryInfo.size())); + } else { + szRecordText = StringUtils.formatPCMListString(listBatteryInfo); + LogUtils.d(TAG, String.format("【initRecordText】使用无换行格式显示记录,记录数量:%d", listBatteryInfo.size())); + } + } + + mtvRecordText.setText(szRecordText); + LogUtils.d(TAG, "【initRecordText】记录显示文本刷新完成"); + } + + // ======================== 事件回调方法 ========================= + /** + * 切换记录显示格式(带换行/不带换行) + * @param view 触发事件的Switch控件 + */ + public void onShowRecordWithEnter(View view) { + Switch swShowRecordWithEnter = (Switch) view; + mIsShowRecordWithEnter = swShowRecordWithEnter.isChecked(); + LogUtils.d(TAG, String.format("【onShowRecordWithEnter】记录显示格式切换,带换行显示:%b", mIsShowRecordWithEnter)); + // 刷新记录显示 + initRecordText(); + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/PixelPickerActivity.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/PixelPickerActivity.java new file mode 100644 index 0000000..f5fcd5d --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/PixelPickerActivity.java @@ -0,0 +1,346 @@ +package cc.winboll.studio.powerbell.activities; + +import android.app.Activity; +import android.app.Dialog; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.os.Bundle; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.RelativeLayout; +import android.widget.TextView; +import android.widget.Toast; +import androidx.appcompat.widget.Toolbar; +import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity; +import cc.winboll.studio.libaes.views.AToolbar; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.R; +import cc.winboll.studio.powerbell.models.BackgroundBean; +import cc.winboll.studio.powerbell.utils.BackgroundSourceUtils; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; + +/** + * 像素拾取页面,支持加载图片并拾取指定位置像素颜色,同步至背景配置 + * 适配 API30,基于 Java7 开发 + * @Author ZhanGSKen + * @Date 2025/06/22 14:15 + */ +public class PixelPickerActivity extends WinBoLLActivity implements IWinBoLLActivity { + // ======================== 静态常量 ========================= + public static final String TAG = "PixelPickerActivity"; + public static final String EXTRA_IMAGE_PATH = "imagePath"; // 图片路径传递键 + // 提示文本常量 + private static final String MSG_IMAGE_LOADED = "图片已加载,点击获取像素值"; + private static final String MSG_NO_IMAGE_PATH = "未找到图片路径"; + private static final String MSG_IMAGE_LOAD_FAILED = "图片加载失败"; + private static final String MSG_FILE_NOT_EXIST = "图片文件不存在"; + private static final String MSG_FILE_NOT_FOUND = "图片文件未找到"; + private static final String MSG_PIXEL_OUT_OF_RANGE = "像素坐标超出范围"; + private static final String MSG_TOUCH_OUT_OF_IMAGE = "点击位置超出图片显示范围"; + private static final String MSG_PIXEL_CALC_FAILED = "计算像素位置失败"; + private static final String MSG_PIXEL_RECORDED = "已记录像素值"; + + // ======================== 成员变量 ========================= + // UI组件 + private Toolbar mToolbar; + private ImageView imageView; + private TextView infoText; + private ViewGroup imageContainer; + private RelativeLayout mainLayout; + // 图片与像素数据 + private Bitmap originalBitmap; // 原始图片Bitmap(用于像素拾取) + + // ======================== 接口实现方法 ========================= + @Override + public Activity getActivity() { + return this; + } + + @Override + public String getTag() { + return TAG; + } + + // ======================== 生命周期方法 ========================= + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_pixelpicker); + LogUtils.d(TAG, "【onCreate】PixelPickerActivity 初始化开始"); + + // 初始化UI组件 + initView(); + // 初始化工具栏 + initToolbar(); + // 加载传递的图片 + loadImageFromIntent(); + // 绑定图片触摸事件 + bindImageTouchListener(); + + LogUtils.d(TAG, "【onCreate】PixelPickerActivity 初始化完成"); + } + + @Override + protected void onResume() { + super.onResume(); + LogUtils.d(TAG, "【onResume】PixelPickerActivity 恢复显示"); + // 同步背景颜色 + setBackgroundColor(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + // 回收Bitmap资源,避免内存泄漏 + if (originalBitmap != null && !originalBitmap.isRecycled()) { + originalBitmap.recycle(); + originalBitmap = null; + LogUtils.d(TAG, "【onDestroy】原始图片Bitmap资源已回收"); + } + LogUtils.d(TAG, "【onDestroy】PixelPickerActivity 销毁完成"); + } + + // ======================== UI初始化方法 ========================= + /** + * 初始化所有UI组件 + */ + private void initView() { + imageView = findViewById(R.id.imageView); + infoText = findViewById(R.id.infoText); + imageContainer = findViewById(R.id.imageContainer); + mainLayout = findViewById(R.id.activitypixelpickerRelativeLayout1); + + LogUtils.d(TAG, "【initView】UI组件初始化完成"); + } + + /** + * 初始化工具栏,设置导航与标题 + */ + private void initToolbar() { + LogUtils.d(TAG, "initToolbar() 开始初始化"); + mToolbar = findViewById(R.id.toolbar); + if (mToolbar == null) { + LogUtils.e(TAG, "initToolbar() | Toolbar未找到"); + return; + } + setSupportActionBar(mToolbar); + mToolbar.setSubtitle(getTag()); + mToolbar.setTitleTextAppearance(this, R.style.Toolbar_TitleText); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + mToolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "导航栏 点击返回按钮"); + finish(); + } + }); + LogUtils.d(TAG, "initToolbar() 配置完成"); + } + + // ======================== 业务逻辑方法 ========================= + /** + * 从Intent中获取图片路径并加载图片 + */ + private void loadImageFromIntent() { + String imagePath = getIntent().getStringExtra(EXTRA_IMAGE_PATH); + LogUtils.d(TAG, "【loadImageFromIntent】获取到图片路径:" + imagePath); + + if (imagePath != null) { + loadImage(imagePath); + } else { + infoText.setText(MSG_NO_IMAGE_PATH); + LogUtils.w(TAG, "【loadImageFromIntent】未获取到图片路径"); + } + } + + /** + * 加载指定路径的图片 + * @param imagePath 图片文件路径 + */ + private void loadImage(String imagePath) { + try { + File file = new File(imagePath); + if (file.exists()) { + // 解码图片(加载原图) + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = 1; + originalBitmap = BitmapFactory.decodeStream(new FileInputStream(file), null, options); + + if (originalBitmap != null) { + imageView.setImageBitmap(originalBitmap); + infoText.setText(MSG_IMAGE_LOADED); + LogUtils.d(TAG, "【loadImage】图片加载成功,尺寸:" + originalBitmap.getWidth() + "x" + originalBitmap.getHeight()); + } else { + infoText.setText(MSG_IMAGE_LOAD_FAILED); + LogUtils.e(TAG, "【loadImage】图片解码失败"); + } + } else { + infoText.setText(MSG_FILE_NOT_EXIST); + LogUtils.w(TAG, "【loadImage】图片文件不存在:" + imagePath); + } + } catch (FileNotFoundException e) { + e.printStackTrace(); + infoText.setText(MSG_FILE_NOT_FOUND); + LogUtils.e(TAG, "【loadImage】图片文件未找到:" + e.getMessage()); + } + } + + /** + * 显示像素颜色信息对话框 + * @param pixelColor 拾取的像素颜色(ARGB) + * @param x 像素X坐标 + * @param y 像素Y坐标 + */ + private void showPixelDialog(final int pixelColor, int x, int y) { + final Dialog dialog = new Dialog(this); + dialog.setContentView(R.layout.dialog_pixel); + dialog.setCancelable(true); + + // 设置颜色预览与信息展示 + TextView colorView = dialog.findViewById(R.id.pixelColorView); + TextView infoTextView = dialog.findViewById(R.id.colorInfoText); + colorView.setBackgroundColor(pixelColor); + + String colorInfo = String.format( + "RGB: (%d, %d, %d)\n" + + "ARGB: #%08X\n" + + "实际像素位置: (%d, %d)", + Color.red(pixelColor), + Color.green(pixelColor), + Color.blue(pixelColor), + pixelColor, + x, y); + infoTextView.setText(colorInfo); + LogUtils.d(TAG, "【showPixelDialog】显示像素信息:" + colorInfo); + + // 确定按钮点击事件 + Button confirmButton = dialog.findViewById(R.id.confirmButton); + confirmButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + dialog.dismiss(); + // 保存像素颜色到背景配置 + savePixelColor(pixelColor); + Toast.makeText(PixelPickerActivity.this, MSG_PIXEL_RECORDED, Toast.LENGTH_SHORT).show(); + // 同步背景颜色 + setBackgroundColor(); + } + }); + + dialog.show(); + LogUtils.d(TAG, "【showPixelDialog】像素对话框已显示"); + } + + /** + * 保存拾取的像素颜色到背景配置 + * @param pixelColor 拾取的像素颜色(ARGB) + */ + private void savePixelColor(int pixelColor) { + BackgroundSourceUtils utils = BackgroundSourceUtils.getInstance(this); + BackgroundBean bean = utils.getPreviewBackgroundBean(); + bean.setPixelColor(pixelColor); + utils.saveSettings(); + LogUtils.d(TAG, "【savePixelColor】像素颜色已保存:#" + Integer.toHexString(pixelColor)); + } + + /** + * 同步背景颜色为拾取的像素颜色 + */ + void setBackgroundColor() { + BackgroundSourceUtils utils = BackgroundSourceUtils.getInstance(this); + BackgroundBean bean = utils.getPreviewBackgroundBean(); + int pixelColor = bean.getPixelColor(); + mainLayout.setBackgroundColor(pixelColor); + LogUtils.d(TAG, "【setBackgroundColor】背景颜色已同步:#" + Integer.toHexString(pixelColor)); + } + + // ======================== 事件回调方法 ========================= + /** + * 绑定图片容器的触摸事件,处理像素拾取逻辑 + */ + private void bindImageTouchListener() { + imageContainer.setOnTouchListener(new View.OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent event) { + if (event.getAction() == MotionEvent.ACTION_DOWN && originalBitmap != null) { + float touchX = event.getX(); + float touchY = event.getY(); + LogUtils.v(TAG, "【onTouch】触摸坐标:(" + touchX + ", " + touchY + ")"); + + try { + // 获取图片在窗口中的位置与尺寸 + int[] imageLocation = new int[2]; + imageView.getLocationInWindow(imageLocation); + int imageWidth = imageView.getWidth(); + int imageHeight = imageView.getHeight(); + LogUtils.v(TAG, "【onTouch】图片显示尺寸:" + imageWidth + "x" + imageHeight + ",位置:(" + imageLocation[0] + ", " + imageLocation[1] + ")"); + + // 计算缩放比例 + float scaleX = (float) originalBitmap.getWidth() / imageWidth; + float scaleY = (float) originalBitmap.getHeight() / imageHeight; + LogUtils.v(TAG, "【onTouch】图片缩放比例:X=" + scaleX + ",Y=" + scaleY); + + // 调整触摸坐标到图片显示区域坐标系 + float adjustedX = touchX - imageLocation[0]; + float adjustedY = touchY - imageLocation[1]; + LogUtils.v(TAG, "【onTouch】调整后触摸坐标:(" + adjustedX + ", " + adjustedY + ")"); + + // 检查是否在图片显示范围内 + if (adjustedX >= 0 && adjustedX <= imageWidth && adjustedY >= 0 && adjustedY <= imageHeight) { + // 计算原始图片的像素坐标 + int pixelX = (int) (adjustedX * scaleX); + int pixelY = (int) (adjustedY * scaleY); + LogUtils.v(TAG, "【onTouch】计算后像素坐标:(" + pixelX + ", " + pixelY + ")"); + + // 检查像素坐标是否在原始图片范围内 + if (pixelX >= 0 && pixelX < originalBitmap.getWidth() && pixelY >= 0 && pixelY < originalBitmap.getHeight()) { + int pixelColor = originalBitmap.getPixel(pixelX, pixelY); + showPixelDialog(pixelColor, pixelX, pixelY); + } else { + Toast.makeText(PixelPickerActivity.this, MSG_PIXEL_OUT_OF_RANGE, Toast.LENGTH_SHORT).show(); + LogUtils.w(TAG, "【onTouch】像素坐标超出原始图片范围"); + } + } else { + Toast.makeText(PixelPickerActivity.this, MSG_TOUCH_OUT_OF_IMAGE, Toast.LENGTH_SHORT).show(); + LogUtils.w(TAG, "【onTouch】触摸位置超出图片显示范围"); + } + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(PixelPickerActivity.this, MSG_PIXEL_CALC_FAILED, Toast.LENGTH_SHORT).show(); + LogUtils.e(TAG, "【onTouch】计算像素位置失败:" + e.getMessage()); + } + } + return true; + } + }); + LogUtils.d(TAG, "【bindImageTouchListener】图片触摸事件已绑定"); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + LogUtils.d(TAG, "【onOptionsItemSelected】点击返回菜单"); + Intent intent = new Intent(this, BackgroundSettingsActivity.class); + startActivity(intent); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onBackPressed() { + super.onBackPressed(); + setResult(RESULT_OK); + finish(); + LogUtils.d(TAG, "【onBackPressed】返回键触发,页面关闭"); + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/SettingsActivity.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/SettingsActivity.java new file mode 100644 index 0000000..e6c02e1 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/SettingsActivity.java @@ -0,0 +1,184 @@ +package cc.winboll.studio.powerbell.activities; + +import android.app.Activity; +import android.content.DialogInterface; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.provider.Settings; +import android.view.View; +import android.view.WindowManager; +import android.widget.CheckBox; +import android.widget.Toast; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.Toolbar; +import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import cc.winboll.studio.powerbell.R; +import cc.winboll.studio.powerbell.models.ThoughtfulServiceBean; +import java.lang.reflect.Field; + +/** + * 应用设置窗口,提供应用配置项的统一入口 + * 适配 API30,基于 Java7 开发 + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/11/27 14:26 + * @Describe 应用设置窗口 + */ +public class SettingsActivity extends WinBoLLActivity implements IWinBoLLActivity { + // ======================== 静态常量 ========================= + public static final String TAG = "SettingsActivity"; + // 权限请求常量(为后续读取媒体图片权限预留) + private static final int REQUEST_READ_MEDIA_IMAGES = 1001; + + // ======================== 成员变量 ========================= + private Toolbar mToolbar; // 顶部工具栏 + + // ======================== 接口实现方法 ========================= + @Override + public Activity getActivity() { + return this; + } + + @Override + public String getTag() { + return TAG; + } + + // ======================== 生命周期方法 ========================= + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_settings); + LogUtils.d(TAG, "【onCreate】SettingsActivity 初始化开始"); + + // 初始化工具栏 + initToolbar(); + + ThoughtfulServiceBean thoughtfulServiceBean = ThoughtfulServiceBean.loadBean(this, ThoughtfulServiceBean.class); + if (thoughtfulServiceBean == null) { + thoughtfulServiceBean = new ThoughtfulServiceBean(); + } + ((CheckBox)findViewById(R.id.activitysettingsCheckBox1)).setChecked(thoughtfulServiceBean.isEnableUsePowerTts()); + ((CheckBox)findViewById(R.id.activitysettingsCheckBox2)).setChecked(thoughtfulServiceBean.isEnableChargeTts()); + + LogUtils.d(TAG, "【onCreate】SettingsActivity 初始化完成"); + } + + // ======================== UI初始化方法 ========================= + /** + * 初始化顶部工具栏,设置导航返回与样式 + */ + private void initToolbar() { + mToolbar = findViewById(R.id.toolbar); + setSupportActionBar(mToolbar); + // 设置工具栏副标题与标题样式 + mToolbar.setSubtitle(getTag()); + mToolbar.setTitleTextAppearance(this, R.style.Toolbar_TitleText); + // 显示返回按钮 + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + // 绑定导航点击事件 + mToolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "【导航栏】点击返回"); + finish(); + } + }); + LogUtils.d(TAG, "【initToolbar】工具栏初始化完成"); + } + + public void onCheckTTSDrawOverlaysPermission(View view) { + canDrawOverlays(); + } + + public void onEnableChargeTts(View view) { + ThoughtfulServiceBean thoughtfulServiceBean = ThoughtfulServiceBean.loadBean(this, ThoughtfulServiceBean.class); + if (thoughtfulServiceBean == null) { + thoughtfulServiceBean = new ThoughtfulServiceBean(); + } + thoughtfulServiceBean.setIsEnableChargeTts(((CheckBox)view).isChecked()); + ThoughtfulServiceBean.saveBean(this, thoughtfulServiceBean); + } + + public void onEnableUsePowerTts(View view) { + ThoughtfulServiceBean thoughtfulServiceBean = ThoughtfulServiceBean.loadBean(this, ThoughtfulServiceBean.class); + if (thoughtfulServiceBean == null) { + thoughtfulServiceBean = new ThoughtfulServiceBean(); + } + thoughtfulServiceBean.setIsEnableUsePowerTts(((CheckBox)view).isChecked()); + ThoughtfulServiceBean.saveBean(this, thoughtfulServiceBean); + } + + /** + * 悬浮窗权限检查与请求 + */ + void canDrawOverlays() { + LogUtils.d(TAG, "onCanDrawOverlays: 检查悬浮窗权限"); + // API6.0+校验权限 + if (Build.VERSION.SDK_INT >= 23 && !Settings.canDrawOverlays(this)) { + LogUtils.d(TAG, "onCanDrawOverlays: 未开启悬浮窗权限,发起请求"); + showDrawOverlayRequestDialog(); + } else { + ToastUtils.show("悬浮窗权限已开启"); + } + } + + + /** + * 显示悬浮窗权限请求对话框 + */ + private void showDrawOverlayRequestDialog() { + AlertDialog dialog = new AlertDialog.Builder(this) + .setTitle("权限请求") + .setMessage("为保证通话监听功能正常,需开启悬浮窗权限") + .setPositiveButton("去设置", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + jumpToDrawOverlaySettings(); + } + }) + .setNegativeButton("稍后", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }) + .create(); + + // 解决对话框焦点问题 + if (dialog.getWindow() != null) { + dialog.getWindow().setFlags( + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE); + } + dialog.show(); + } + + /** + * 跳转悬浮窗权限设置页面(反射适配低版本) + */ + private void jumpToDrawOverlaySettings() { + LogUtils.d(TAG, "jumpToDrawOverlaySettings: 跳转悬浮窗权限设置"); + try { + // 反射获取设置页面Action(避免高版本API依赖) + Class settingsClazz = Settings.class; + Field actionField = settingsClazz.getDeclaredField("ACTION_MANAGE_OVERLAY_PERMISSION"); + String action = (String) actionField.get(null); + + // 跳转当前应用权限设置页 + Intent intent = new Intent(action); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.setData(Uri.parse("package:" + getPackageName())); + startActivity(intent); + } catch (Exception e) { + LogUtils.e(TAG, "jumpToDrawOverlaySettings: 跳转权限设置失败", e); + Toast.makeText(this, "请手动在设置中开启悬浮窗权限", Toast.LENGTH_LONG).show(); + } + } + +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/ShortcutActionActivity.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/ShortcutActionActivity.java new file mode 100644 index 0000000..e6e2fc4 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/ShortcutActionActivity.java @@ -0,0 +1,75 @@ +package cc.winboll.studio.powerbell.activities; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import cc.winboll.studio.powerbell.App; +import cc.winboll.studio.powerbell.R; +import cc.winboll.studio.powerbell.utils.APPPlusUtils; + +/** + * 应用快捷方式活动类,处理应用图标快捷菜单的切换请求 + * 适配 API30,基于 Java7 开发 + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/11/15 13:45 + * @Describe 应用快捷方式活动类 + */ +public class ShortcutActionActivity extends Activity { + // ======================== 静态常量 ========================= + public static final String TAG = "ShortcutActionActivity"; + // 快捷指令常量 + private static final String ACTION_SWITCH_TO_EN1 = "switchto_en1"; + private static final String ACTION_SWITCH_TO_CN1 = "switchto_cn1"; + private static final String ACTION_SWITCH_TO_CN2 = "switchto_cn2"; + + // ======================== 生命周期方法 ========================= + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + LogUtils.d(TAG, "【onCreate】ShortcutActionActivity 启动,开始处理快捷方式请求"); + + // 处理应用图标快捷菜单的切换请求 + handleSwitchRequest(); + + LogUtils.d(TAG, "【onCreate】快捷方式请求处理完成,关闭活动"); + finish(); + } + + // ======================== 业务逻辑方法 ========================= + /** + * 处理应用图标快捷菜单的请求,根据意图数据切换应用启动组件 + */ + private void handleSwitchRequest() { + Intent intent = getIntent(); + if (intent == null) { + LogUtils.w(TAG, "【handleSwitchRequest】意图为空,无法处理快捷方式请求"); + return; + } + + String dataString = intent.getDataString(); + LogUtils.d(TAG, "【handleSwitchRequest】获取到快捷指令:" + dataString); + + // 匹配快捷指令并切换组件 + if (ACTION_SWITCH_TO_EN1.equals(dataString)) { + APPPlusUtils.switchAppLauncherToComponent(this, App.COMPONENT_EN1); + String toastMsg = "切换至" + getString(R.string.app_name) + "图标"; + ToastUtils.show(toastMsg); + LogUtils.d(TAG, "【handleSwitchRequest】已切换至EN1组件:" + App.COMPONENT_EN1); + } else if (ACTION_SWITCH_TO_CN1.equals(dataString)) { + APPPlusUtils.switchAppLauncherToComponent(this, App.COMPONENT_CN1); + String toastMsg = "切换至" + getString(R.string.app_name_cn1) + "图标"; + ToastUtils.show(toastMsg); + LogUtils.d(TAG, "【handleSwitchRequest】已切换至CN1组件:" + App.COMPONENT_CN1); + } else if (ACTION_SWITCH_TO_CN2.equals(dataString)) { + APPPlusUtils.switchAppLauncherToComponent(this, App.COMPONENT_CN2); + String toastMsg = "切换至" + getString(R.string.app_name_cn2) + "图标"; + ToastUtils.show(toastMsg); + LogUtils.d(TAG, "【handleSwitchRequest】已切换至CN2组件:" + App.COMPONENT_CN2); + } else { + LogUtils.w(TAG, "【handleSwitchRequest】未匹配到有效快捷指令:" + dataString); + } + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/WinBoLLActivity.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/WinBoLLActivity.java new file mode 100644 index 0000000..ffbd227 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/activities/WinBoLLActivity.java @@ -0,0 +1,215 @@ +package cc.winboll.studio.powerbell.activities; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.graphics.Color; +import android.os.Bundle; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.widget.TextView; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity; +import cc.winboll.studio.libaes.models.AESThemeBean; +import cc.winboll.studio.libaes.utils.AESThemeUtil; +import cc.winboll.studio.libaes.utils.WinBoLLActivityManager; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.BuildConfig; +import cc.winboll.studio.powerbell.R; + +/** + * @Author ZhanGSKen + * @Date 2025/06/19 20:35 + * @Describe 应用窗口基类,提供主题设置、Activity 管理、工具栏配置、全屏切换、版本标签显示等通用功能 + * 适配 API30,基于 Java7 开发,所有子类需继承此类实现统一窗口行为 + */ +public abstract class WinBoLLActivity extends AppCompatActivity implements IWinBoLLActivity { + // ======================== 静态常量 ========================= + public static final String TAG = "WinBoLLActivity"; + private static final String VERSION_TAG_TEXT = "MIMO SDK V%s"; // 版本标签文本格式 + private static final float VERSION_TAG_TEXT_SIZE = 10f; // 版本标签字体大小(sp) + + // ======================== 成员变量 ========================= + protected volatile AESThemeBean.ThemeType mThemeType; // 当前主题类型 + protected TextView mTagView; // 版本标签显示控件 + + // ======================== 接口实现 & 抽象方法 ========================= + @Override + public abstract Activity getActivity(); + + @Override + public abstract String getTag(); + + // ======================== 生命周期方法 ========================= + @Override + protected void onCreate(Bundle savedInstanceState) { + LogUtils.d(TAG, String.format("【%s-onCreate】窗口基类初始化开始", getTag())); + // 初始化主题 + mThemeType = getThemeType(); + setThemeStyle(); + super.onCreate(savedInstanceState); + LogUtils.d(TAG, String.format("【%s-onCreate】窗口基类初始化完成,当前主题:%s", getTag(), mThemeType)); + } + + @Override + protected void onStart() { + super.onStart(); + LogUtils.d(TAG, String.format("【%s-onStart】添加版本标签到页面", getTag())); + // 添加版本标签 + addVersionNameToContentView(); + } + + @Override + protected void onPostCreate(Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + // 注册到Activity管理器 + WinBoLLActivityManager.getInstance().add(this); + LogUtils.d(TAG, String.format("【%s-onPostCreate】已注册到Activity管理器", getTag())); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + // 从Activity管理器移除 + WinBoLLActivityManager.getInstance().registeRemove(this); + LogUtils.d(TAG, String.format("【%s-onDestroy】已从Activity管理器移除", getTag())); + } + + // ======================== 主题相关方法 ========================= + /** + * 获取当前主题类型 + * @return 主题类型枚举 + */ + AESThemeBean.ThemeType getThemeType() { + int themeId = AESThemeUtil.getThemeTypeID(getApplicationContext()); + AESThemeBean.ThemeType themeType = AESThemeBean.getThemeStyleType(themeId); + LogUtils.d(TAG, String.format("【%s-getThemeType】获取主题类型,ID:%d,类型:%s", getTag(), themeId, themeType)); + return themeType; + } + + /** + * 设置主题样式 + */ + void setThemeStyle() { + int themeId = AESThemeUtil.getThemeTypeID(getApplicationContext()); + setTheme(themeId); + LogUtils.d(TAG, String.format("【%s-setThemeStyle】应用主题样式,ID:%d", getTag(), themeId)); + } + + // ======================== UI 配置方法 ========================= + /** + * 添加版本标签到页面底部 + */ + protected void addVersionNameToContentView() { + if (!isTagViewVisible()) { + LogUtils.d(TAG, String.format("【%s-addVersionNameToContentView】版本标签不可见,跳过添加", getTag())); + return; + } + + if (mTagView == null) { + mTagView = new TextView(this); + // 配置版本标签样式 + mTagView.setTextColor(Color.GRAY); + mTagView.setTextSize(TypedValue.COMPLEX_UNIT_SP, VERSION_TAG_TEXT_SIZE); + mTagView.setText(String.format(VERSION_TAG_TEXT, BuildConfig.VERSION_NAME)); + // 配置布局参数 + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + params.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL; + // 添加到根布局 + FrameLayout frameLayout = findViewById(android.R.id.content); + if (frameLayout != null) { + frameLayout.addView(mTagView, params); + LogUtils.d(TAG, String.format("【%s-addVersionNameToContentView】版本标签添加完成,版本:%s", getTag(), BuildConfig.VERSION_NAME)); + } else { + LogUtils.w(TAG, String.format("【%s-addVersionNameToContentView】根布局为空,无法添加版本标签", getTag())); + } + } + } + + /** + * 配置工具栏,显示返回按钮 + */ + public void setupToolbar() { + Toolbar mToolbar = findViewById(R.id.toolbar); + if (mToolbar != null) { + setSupportActionBar(mToolbar); + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + LogUtils.d(TAG, String.format("【%s-setupToolbar】工具栏配置完成,已显示返回按钮", getTag())); + } else { + LogUtils.w(TAG, String.format("【%s-setupToolbar】ActionBar为空,无法显示返回按钮", getTag())); + } + } else { + LogUtils.w(TAG, String.format("【%s-setupToolbar】未找到工具栏控件(ID:toolbar)", getTag())); + } + } + + /** + * 版本标签是否可见 + * @return 默认为true,子类可重写修改 + */ + protected boolean isTagViewVisible() { + return true; + } + + // ======================== 菜单 & 返回键处理 ========================= + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + LogUtils.d(TAG, String.format("【%s-onOptionsItemSelected】点击返回菜单", getTag())); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onBackPressed() { + super.onBackPressed(); + LogUtils.d(TAG, String.format("【%s-onBackPressed】触发返回键", getTag())); + } + + // ======================== 工具方法 ========================= + /** + * 切换至全屏模式,隐藏状态栏与导航栏 + * @param activity 目标Activity + */ + public void changeFullScreen(Activity activity) { + if (activity == null) { + LogUtils.w(TAG, String.format("【%s-changeFullScreen】目标Activity为空,无法切换全屏", getTag())); + return; + } + + Window window = activity.getWindow(); + if (window == null) { + LogUtils.w(TAG, String.format("【%s-changeFullScreen】窗口为空,无法切换全屏", getTag())); + return; + } + + View decorView = window.getDecorView(); + if (decorView == null) { + LogUtils.w(TAG, String.format("【%s-changeFullScreen】DecorView为空,无法切换全屏", getTag())); + return; + } + + // 配置全屏标志位 + int flag = decorView.getSystemUiVisibility(); + flag |= View.SYSTEM_UI_FLAG_FULLSCREEN; + flag |= View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN; + flag |= View.SYSTEM_UI_FLAG_HIDE_NAVIGATION; + flag |= View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION; + flag |= View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; + decorView.setSystemUiVisibility(flag); + // 配置窗口标志位 + window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); + LogUtils.d(TAG, String.format("【%s-changeFullScreen】已切换至全屏模式", getTag())); + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/adapters/BatteryAdapter.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/adapters/BatteryAdapter.java new file mode 100644 index 0000000..c87d328 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/adapters/BatteryAdapter.java @@ -0,0 +1,107 @@ +package cc.winboll.studio.powerbell.adapters; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import androidx.recyclerview.widget.RecyclerView; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.R; +import cc.winboll.studio.powerbell.models.BatteryData; +import java.util.ArrayList; +import java.util.List; + +/** + * 电池报告数据适配器,用于RecyclerView展示电池电量、充放电时间数据 + * 适配 API30,基于 Java7 开发 + * @Author ZhanGSKen + * @Date 2025/03/22 14:38:55 + * @Describe 电池报告数据适配器 + */ +public class BatteryAdapter extends RecyclerView.Adapter { + // ======================== 静态常量 ========================= + public static final String TAG = "BatteryAdapter"; + private static final String FORMAT_BATTERY_LEVEL = "%d%%"; // 电量显示格式 + private static final String PREFIX_DISCHARGE_TIME = "使用时间: "; // 放电时间前缀 + private static final String PREFIX_CHARGE_TIME = "充电时间: "; // 充电时间前缀 + + // ======================== 成员变量 ========================= + private List dataList = new ArrayList<>(); // 电池数据列表 + + // ======================== 构造方法 ========================= + public BatteryAdapter() { + LogUtils.d(TAG, "【BatteryAdapter】适配器初始化,初始数据列表为空"); + } + + // ======================== 数据操作方法 ========================= + /** + * 更新适配器数据并刷新列表 + * @param newData 新的电池数据列表 + */ + public void updateData(List newData) { + LogUtils.d(TAG, "【updateData】开始更新数据,新数据列表是否为空:" + (newData == null)); + // 判空处理,避免空指针 + if (newData != null) { + dataList = newData; + LogUtils.d(TAG, "【updateData】数据更新完成,当前数据量:" + dataList.size()); + } else { + dataList.clear(); + LogUtils.w(TAG, "【updateData】新数据列表为空,已清空本地数据"); + } + notifyDataSetChanged(); + LogUtils.d(TAG, "【updateData】已通知列表刷新"); + } + + // ======================== RecyclerView 重写方法 ========================= + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + LogUtils.d(TAG, "【onCreateViewHolder】创建ViewHolder,父容器:" + parent.getContext().getClass().getSimpleName()); + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_battery_report, parent, false); + ViewHolder viewHolder = new ViewHolder(view); + LogUtils.d(TAG, "【onCreateViewHolder】ViewHolder创建完成"); + return viewHolder; + } + + @Override + public void onBindViewHolder(ViewHolder holder, int position) { + LogUtils.d(TAG, "【onBindViewHolder】绑定ViewHolder,位置:" + position); + // 判空与越界校验 + if (dataList == null || dataList.isEmpty() || position >= dataList.size()) { + LogUtils.w(TAG, "【onBindViewHolder】数据异常,无法绑定视图,位置:" + position); + return; + } + + BatteryData item = dataList.get(position); + // 绑定数据到视图 + holder.tvLevel.setText(String.format(FORMAT_BATTERY_LEVEL, item.getCurrentLevel())); + holder.tvDischargeTime.setText(PREFIX_DISCHARGE_TIME + item.getDischargeTime()); + holder.tvChargeTime.setText(PREFIX_CHARGE_TIME + item.getChargeTime()); + + LogUtils.d(TAG, "【onBindViewHolder】视图绑定完成,位置:" + position + ",电量:" + item.getCurrentLevel() + "%"); + } + + @Override + public int getItemCount() { + int count = dataList.size(); + LogUtils.d(TAG, "【getItemCount】获取条目数量:" + count); + return count; + } + + // ======================== ViewHolder 内部类 ========================= + static class ViewHolder extends RecyclerView.ViewHolder { + TextView tvLevel; // 电量显示 + TextView tvDischargeTime; // 放电时间显示 + TextView tvChargeTime; // 充电时间显示 + + ViewHolder(View itemView) { + super(itemView); + // 初始化视图控件 + tvLevel = itemView.findViewById(R.id.tvLevel); + tvDischargeTime = itemView.findViewById(R.id.tvDischargeTime); + tvChargeTime = itemView.findViewById(R.id.tvChargeTime); + LogUtils.d(TAG, "【ViewHolder】控件初始化完成"); + } + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/dialogs/BackgroundPicturePreviewDialog.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/dialogs/BackgroundPicturePreviewDialog.java new file mode 100644 index 0000000..00bb178 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/dialogs/BackgroundPicturePreviewDialog.java @@ -0,0 +1,153 @@ +package cc.winboll.studio.powerbell.dialogs; + +import android.app.Dialog; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.text.TextUtils; +import android.view.View; +import android.widget.Button; +import android.widget.Toast; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.App; +import cc.winboll.studio.powerbell.MainActivity; +import cc.winboll.studio.powerbell.R; +import cc.winboll.studio.powerbell.activities.BackgroundSettingsActivity; +import cc.winboll.studio.powerbell.utils.BackgroundSourceUtils; +import cc.winboll.studio.powerbell.utils.UriUtils; +import cc.winboll.studio.powerbell.views.BackgroundView; + +/** + * 背景图片的接收分享文件后的预览对话框 + * 适配 API30,基于 Java7 开发,支持分享图片的Uri解析、预览与确认选择 + * @Author ZhanGSKen + * @Date 2024/04/25 16:27:53 + * @Describe 背景图片的接收分享文件后的预览对话框 + */ +public class BackgroundPicturePreviewDialog extends Dialog { + // ======================== 静态常量 ========================= + public static final String TAG = "BackgroundPicturePreviewDialog"; + private static final String TOAST_MSG_EMPTY_FILE = "接收到的文件为空。"; // 空文件提示文本 + + // ======================== 成员变量 ========================= + private Context mContext; // 上下文对象 + private IOnRecivedPictureListener mIOnRecivedPictureListener; // 图片接收监听 + private Uri mUriRecivedPicture; // 接收的图片Uri + // 控件对象 + private BackgroundView mBackgroundView; // 背景预览视图 + private Button dialogbackgroundpicturepreviewButton1; // 取消按钮 + private Button dialogbackgroundpicturepreviewButton2; // 确认按钮 + + // ======================== 接口定义 ========================= + /** + * 图片接收监听接口,用于通知确认选择的图片Uri + */ + public interface IOnRecivedPictureListener { + void onAcceptRecivedPicture(Uri uriRecivedPicture); + } + + // ======================== 构造方法 ========================= + public BackgroundPicturePreviewDialog(Context context, IOnRecivedPictureListener iOnRecivedPictureListener) { + super(context); + LogUtils.d(TAG, "【BackgroundPicturePreviewDialog】对话框初始化开始"); + // 初始化成员变量 + mContext = context; + mIOnRecivedPictureListener = iOnRecivedPictureListener; + + // 设置布局与控件 + setContentView(R.layout.dialog_backgroundpicturepreview); + initViews(); + bindButtonClickEvents(); + + // 预览接收的图片 + previewRecivedPicture(); + LogUtils.d(TAG, "【BackgroundPicturePreviewDialog】对话框初始化完成"); + } + + // ======================== 视图初始化方法 ========================= + /** + * 初始化对话框内所有控件 + */ + private void initViews() { + mBackgroundView = findViewById(R.id.backgroundview); + dialogbackgroundpicturepreviewButton1 = findViewById(R.id.dialogbackgroundpicturepreviewButton1); + dialogbackgroundpicturepreviewButton2 = findViewById(R.id.dialogbackgroundpicturepreviewButton2); + LogUtils.d(TAG, "【initViews】对话框控件初始化完成"); + } + + // ======================== 事件绑定方法 ========================= + /** + * 绑定按钮点击事件 + */ + private void bindButtonClickEvents() { + // 取消按钮:跳转到主页面并关闭对话框 + dialogbackgroundpicturepreviewButton1.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + LogUtils.d(TAG, "【onClick】点击取消按钮,跳转到主页面"); + Intent intent = new Intent(mContext, MainActivity.class); + mContext.startActivity(intent); + dismiss(); + LogUtils.d(TAG, "【onClick】对话框已关闭"); + } + }); + + // 确认按钮:通知监听并关闭对话框 + dialogbackgroundpicturepreviewButton2.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "【onClick】点击确认按钮,通知接收图片"); + if (mIOnRecivedPictureListener != null && mUriRecivedPicture != null) { + mIOnRecivedPictureListener.onAcceptRecivedPicture(mUriRecivedPicture); + LogUtils.d(TAG, "【onClick】已通知监听,图片Uri:" + mUriRecivedPicture); + } else { + LogUtils.w(TAG, "【onClick】监听为空或图片Uri无效,无法通知"); + } + dismiss(); + LogUtils.d(TAG, "【onClick】对话框已关闭"); + } + }); + LogUtils.d(TAG, "【bindButtonClickEvents】按钮点击事件绑定完成"); + } + + // ======================== 业务逻辑方法 ========================= + /** + * 预览接收的分享图片 + */ + private void previewRecivedPicture() { + LogUtils.d(TAG, "【previewRecivedPicture】开始预览接收的图片"); + // 校验上下文类型 + if (!(mContext instanceof BackgroundSettingsActivity)) { + LogUtils.e(TAG, "【previewRecivedPicture】上下文不是BackgroundSettingsActivity,无法获取图片Uri"); + Toast.makeText(mContext, TOAST_MSG_EMPTY_FILE, Toast.LENGTH_SHORT).show(); + dismiss(); + return; + } + + BackgroundSettingsActivity activity = (BackgroundSettingsActivity) mContext; + // 从Intent中获取图片Uri(优先getData,其次EXTRA_STREAM) + mUriRecivedPicture = activity.getIntent().getData(); + if (mUriRecivedPicture == null) { + mUriRecivedPicture = activity.getIntent().getParcelableExtra(Intent.EXTRA_STREAM); + LogUtils.d(TAG, "【previewRecivedPicture】从EXTRA_STREAM获取Uri:" + mUriRecivedPicture); + } else { + LogUtils.d(TAG, "【previewRecivedPicture】从getData获取Uri:" + mUriRecivedPicture); + } + + // 解析Uri为文件路径 + String szSrcImage = UriUtils.getFilePathFromUri(mContext, mUriRecivedPicture); + //App.notifyMessage(TAG, "szSrcImage : " + szSrcImage); + if (TextUtils.isEmpty(szSrcImage)) { + LogUtils.w(TAG, "【previewRecivedPicture】解析的文件路径为空"); + Toast.makeText(mContext, TOAST_MSG_EMPTY_FILE, Toast.LENGTH_SHORT).show(); + dismiss(); + return; + } + + // 加载图片到预览视图 + int nCurrentPixelColor = BackgroundSourceUtils.getInstance(mContext).getCurrentBackgroundBean().getPixelColor(); + mBackgroundView.loadImage(nCurrentPixelColor, szSrcImage, true); + LogUtils.d(TAG, "【previewRecivedPicture】图片预览完成,文件路径:" + szSrcImage); + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/dialogs/ColorPaletteDialog.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/dialogs/ColorPaletteDialog.java new file mode 100644 index 0000000..1371a4f --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/dialogs/ColorPaletteDialog.java @@ -0,0 +1,733 @@ +package cc.winboll.studio.powerbell.dialogs; + +import android.app.Dialog; +import android.content.Context; +import android.graphics.Color; +import android.graphics.drawable.GradientDrawable; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.widget.EditText; +import android.widget.HorizontalScrollView; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.SeekBar; +import android.widget.TextView; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import cc.winboll.studio.powerbell.R; +import com.a4455jkjh.colorpicker.ColorPickerDialog; +import com.a4455jkjh.colorpicker.view.OnColorChangedListener; + +/** + * 调色板对话框(支持颜色拾取、RGB输入、透明度/亮度调节,兼容 API29-30+ 小米机型) + * 适配 API30,基于 Java7 开发,返回 0xAARRGGBB 格式颜色(含透明度) + * @Author ZhanGSKen + * @Date 2025/12/16 11:47 + * @Describe 调色板对话框(支持颜色拾取、RGB输入、透明度/亮度调节,兼容 API29-30+ 小米机型) + */ +public class ColorPaletteDialog extends Dialog implements View.OnClickListener, SeekBar.OnSeekBarChangeListener { + // ====================== 静态常量(首屏可见,统一管理) ====================== + public static final String TAG = "ColorPaletteDialog"; + private static final int MAX_RGB_VALUE = 255; // RGB分量最大值(0-255) + private static final int DEFAULT_BRIGHTNESS = 100; // 默认亮度百分比(100%,无调节) + private static final int BRIGHTNESS_STEP = 5; // 亮度调节步长(每次±5%,精准流畅) + private static final int MIN_BRIGHTNESS = 10; // 亮度最小值(10%,避免全黑看不见) + private static final int MAX_BRIGHTNESS = 200; // 亮度最大值(200%,避免过曝失真) + private static final int MAX_ALPHA_PERCENT = 100; // 透明度最大值(100%=不透明) + private static final int MIN_ALPHA_PERCENT = 0; // 透明度最小值(0%=完全透明) + private static final String FORMAT_COLOR_HEX = "#%08X"; // 颜色值格式化(AARRGGBB) + private static final String FORMAT_PERCENT = "%d%%"; // 百分比格式化(X%) + + // ====================== 回调接口(紧跟常量,逻辑关联) ====================== + public interface OnColorSelectedListener { + void onColorSelected(int color); // 返回0xAARRGGBB格式颜色(含透明度) + } + + // ====================== 成员变量(按优先级排序:核心数据→控件引用) ====================== + // 核心数据:原始基准值(用户输入/选择颜色时更新)+ 实时调节值(亮度/透明度变化时更新) + private OnColorSelectedListener mListener; // 颜色选择回调(非空校验) + private int mInitialColor; // 初始颜色(传入的默认颜色) + private int mCurrentColor; // 当前最终颜色(含亮度+透明度调节) + private int mCurrentBrightnessPercent; // 当前亮度百分比(10%-200%) + // 透明度:百分比(0-100%,用户直观操作)+ 原始/实时值(0-255,颜色计算用) + private int mOriginalAlphaPercent; // 原始透明度百分比(基准值,用户输入/选色时更新) + private int mCurrentAlphaPercent; // 实时透明度百分比(调节进度条时更新) + private int mOriginalAlpha; // 原始透明度(0-255,基准值) + private int mCurrentAlpha; // 实时透明度(0-255,计算用) + // RGB:原始基准值+实时调节值 + private int mOriginalR; // 原始R分量(基准值,用户输入/选色时更新) + private int mOriginalG; // 原始G分量(基准值,用户输入/选色时更新) + private int mOriginalB; // 原始B分量(基准值,用户输入/选色时更新) + private int mCurrentR; // 实时R分量(亮度调节后,同步输入框显示) + private int mCurrentG; // 实时G分量(亮度调节后,同步输入框显示) + private int mCurrentB; // 实时B分量(亮度调节后,同步输入框显示) + // 并发控制标记:是否是应用程序自身在更新颜色(避免循环回调/重复触发) + private static volatile boolean isAppSelfUpdatingColor = false; + + // 控件引用 + private ImageView ivColorPicker; // 颜色预览拾取框 + private ImageView ivColorScaler; // 颜色渐变拾取框 + private EditText etR; // R分量输入框(显示实时调节值) + private EditText etG; // G分量输入框(显示实时调节值) + private EditText etB; // B分量输入框(显示实时调节值) + private EditText etColorValue; // 颜色值输入框(#AARRGGBB,显示最终值) + private SeekBar sbAlpha; // 透明度调节进度条(0-100%) + private TextView tvAlphaValue; // 透明度数值显示(X%) + private TextView tvBrightnessMinus;// 亮度减少按钮(-) + private TextView tvBrightnessValue;// 亮度数值显示(X%,直观易懂) + private TextView tvBrightnessPlus; // 亮度增加按钮(+) + private TextView tvConfirm; // 确认按钮 + private TextView tvCancel; // 取消按钮 + + // ====================== 构造方法(初始化核心数据,严格校验) ====================== + public ColorPaletteDialog(Context context, int initialColor, OnColorSelectedListener listener) { + super(context, R.style.CustomDialogStyle); + this.mInitialColor = initialColor; + this.mListener = listener; + + // 1. 强制回调非空,避免后续空指针(容错) + if (mListener == null) { + throw new IllegalArgumentException("OnColorSelectedListener can not be null!"); + } + + // 2. 解析初始颜色:原始基准值 = 实时值(初始无调节) + this.mOriginalAlpha = Color.alpha(initialColor); + this.mOriginalAlphaPercent = alpha2Percent(mOriginalAlpha); + this.mCurrentAlpha = mOriginalAlpha; + this.mCurrentAlphaPercent = mOriginalAlphaPercent; + + this.mOriginalR = Color.red(initialColor); + this.mOriginalG = Color.green(initialColor); + this.mOriginalB = Color.blue(initialColor); + this.mCurrentR = mOriginalR; + this.mCurrentG = mOriginalG; + this.mCurrentB = mOriginalB; + + // 3. 初始化当前状态(默认亮度100%,当前颜色=初始颜色) + this.mCurrentBrightnessPercent = DEFAULT_BRIGHTNESS; + this.mCurrentColor = initialColor; + + LogUtils.d(TAG, String.format("init dialog success | 初始颜色:%s | 原始RGB:%d,%d,%d | 原始透明度:%s | 初始亮度:%s", + String.format(FORMAT_COLOR_HEX, initialColor), + mOriginalR, mOriginalG, mOriginalB, + String.format(FORMAT_PERCENT, mOriginalAlphaPercent), + String.format(FORMAT_PERCENT, mCurrentBrightnessPercent))); + } + + // ====================== 生命周期方法(按执行顺序排列,逻辑清晰) ====================== + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + requestWindowFeature(Window.FEATURE_NO_TITLE); // 隐藏标题栏 + View view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_color_palette, null); + setContentView(view); + + // 初始化流程:控件绑定→数据赋值→监听设置→尺寸适配(小米机型优先适配) + initViewBind(view); + initData(); + initListener(); + adjustDialogSize(); + LogUtils.d(TAG, "dialog create complete | 适配小米API29-30机型"); + } + + @Override + public void dismiss() { + super.dismiss(); + // 释放资源,避免内存泄漏(回调引用置空) + mListener = null; + LogUtils.d(TAG, "dialog dismiss | 释放资源完成"); + } + + // ====================== 初始化核心方法(职责单一,便于维护) ====================== + /** + * 控件绑定 + */ + private void initViewBind(View view) { + ivColorPicker = view.findViewById(R.id.iv_color_picker); + ivColorScaler = view.findViewById(R.id.iv_color_scaler); + etR = view.findViewById(R.id.et_r); + etG = view.findViewById(R.id.et_g); + etB = view.findViewById(R.id.et_b); + etColorValue = view.findViewById(R.id.et_color_value); + sbAlpha = view.findViewById(R.id.sb_alpha); + tvAlphaValue = view.findViewById(R.id.tv_alpha_value); + tvBrightnessMinus = view.findViewById(R.id.tv_brightness_minus); + tvBrightnessValue = view.findViewById(R.id.tv_brightness_value); + tvBrightnessPlus = view.findViewById(R.id.tv_brightness_plus); + tvConfirm = view.findViewById(R.id.tv_confirm); + tvCancel = view.findViewById(R.id.tv_cancel); + + // 控件非空校验(小米低版本容错,绑定失败直接关闭对话框) + if (ivColorPicker == null || ivColorScaler == null || etR == null || etG == null || etB == null || etColorValue == null + || sbAlpha == null || tvAlphaValue == null + || tvBrightnessMinus == null || tvBrightnessValue == null || tvBrightnessPlus == null + || tvConfirm == null || tvCancel == null) { + LogUtils.e(TAG, "view bind failed | 请检查布局ID是否正确!"); + dismiss(); + return; + } + LogUtils.d(TAG, "view bind complete | 所有控件绑定成功"); + } + + /** + * 数据初始化(无监听状态下赋值,避免循环回调) + */ + private void initData() { + // 1. 颜色预览(显示当前最终颜色,初始=原始颜色) + ivColorPicker.setBackgroundColor(mCurrentColor); + + // 2. RGB输入框(显示「实时分量」,初始=原始值) + etR.setText(String.valueOf(mCurrentR)); + etG.setText(String.valueOf(mCurrentG)); + etB.setText(String.valueOf(mCurrentB)); + + // 3. 颜色值输入框(显示当前最终颜色,格式#AARRGGBB) + etColorValue.setText(String.format(FORMAT_COLOR_HEX, mCurrentColor)); + + // 4. 透明度控件(进度条+文本,初始=原始透明度) + sbAlpha.setProgress(mCurrentAlphaPercent); + tvAlphaValue.setText(String.format(FORMAT_PERCENT, mCurrentAlphaPercent)); + + // 5. 亮度控件(显示默认100%,初始化按钮状态) + tvBrightnessValue.setText(String.format(FORMAT_PERCENT, mCurrentBrightnessPercent)); + updateBrightnessBtnStatus(); // 禁用边界值按钮 + + LogUtils.d(TAG, String.format("init data complete | 原始透明度:%s", + String.format(FORMAT_PERCENT, mOriginalAlphaPercent))); + } + + /** + * 监听初始化 + */ + private void initListener() { + // 点击监听(按钮+颜色拾取框) + ivColorPicker.setOnClickListener(this); + ivColorScaler.setOnClickListener(this); + tvConfirm.setOnClickListener(this); + tvCancel.setOnClickListener(this); + tvBrightnessMinus.setOnClickListener(this); + tvBrightnessPlus.setOnClickListener(this); + // 透明度进度条监听 + sbAlpha.setOnSeekBarChangeListener(this); + // 输入框监听(RGB+颜色值,避免循环同步) + initTextWatcherListener(); + LogUtils.d(TAG, "all listener init complete | 监听绑定成功"); + } + + /** + * 对话框尺寸适配(小米全面屏+软键盘优化,避免输入框被遮挡) + */ + private void adjustDialogSize() { + Window window = getWindow(); + if (window != null) { + WindowManager.LayoutParams lp = window.getAttributes(); + // 宽度占屏幕80%,高度自适应(适配不同屏幕尺寸) + lp.width = (int) (getContext().getResources().getDisplayMetrics().widthPixels * 0.8); + lp.height = WindowManager.LayoutParams.WRAP_CONTENT; + // 软键盘适配:小米虚拟导航栏兼容 + window.setAttributes(lp); + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN + | WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN); + LogUtils.d(TAG, "dialog size adjust complete | 适配全面屏+软键盘"); + } + } + + // ====================== 监听子方法(细分类型,逻辑清晰) ====================== + /** + * 输入框文本监听(RGB+颜色值,传入触发ID避免循环同步) + */ + private void initTextWatcherListener() { + // RGB输入框监听(复用方法,减少冗余) + setEditTextWatcher(etR, R.id.et_r); + setEditTextWatcher(etG, R.id.et_g); + setEditTextWatcher(etB, R.id.et_b); + + // 颜色值输入框监听(支持#RRGGBB/#AARRGGBB格式) + etColorValue.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + + @Override + public void afterTextChanged(Editable s) { + // 关键:判断非应用自身更新,才执行解析(避免循环回调) + if (!isAppSelfUpdatingColor) { + parseColorFromStr(s.toString().trim(), R.id.et_color_value); + } + } + }); + } + + // ====================== 透明度进度条监听实现 ====================== + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + // 仅处理用户手动拖动进度条(避免应用自身更新时触发) + if (fromUser && !isAppSelfUpdatingColor) { + updateAlphaBySeekBar(progress); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) {} + + @Override + public void onStopTrackingTouch(SeekBar seekBar) {} + + /** + * 拖动透明度进度条更新颜色 + */ + private synchronized void updateAlphaBySeekBar(int alphaPercent) { + if (!isAppSelfUpdatingColor) { + isAppSelfUpdatingColor = true; // 标记为应用自身更新 + try { + // 更新实时透明度(百分比+0-255值) + mCurrentAlphaPercent = alphaPercent; + mCurrentAlpha = percent2Alpha(alphaPercent); + // 重新计算最终颜色(基于当前亮度+新透明度) + calculateBrightnessAndUpdate(); + // 同步所有控件 + updateAllViews(); + LogUtils.d(TAG, String.format("update alpha by seekbar | 透明度:%s", + String.format(FORMAT_PERCENT, mCurrentAlphaPercent))); + } finally { + isAppSelfUpdatingColor = false; // 释放标记 + } + } + } + + // ====================== 颜色核心逻辑 ====================== + /** + * 核心计算:基于原始RGB+当前亮度+当前透明度,计算实时RGB+最终颜色 + */ + private void calculateBrightnessAndUpdate() { + // 亮度百分比转调节系数(10%→0.1,100%→1.0,200%→2.0) + float brightnessFactor = mCurrentBrightnessPercent / 100.0f; + + // RGB三个分量同时调节(基于原始基准值,避免叠加失真),限制0-255 + mCurrentR = Math.min(Math.max(Math.round(mOriginalR * brightnessFactor), 0), MAX_RGB_VALUE); + mCurrentG = Math.min(Math.max(Math.round(mOriginalG * brightnessFactor), 0), MAX_RGB_VALUE); + mCurrentB = Math.min(Math.max(Math.round(mOriginalB * brightnessFactor), 0), MAX_RGB_VALUE); + + // 拼接「实时透明度」+「实时RGB」,得到最终颜色(0xAARRGGBB) + mCurrentColor = Color.argb(mCurrentAlpha, mCurrentR, mCurrentG, mCurrentB); + } + + /** + * 亮度减少(每次减5%,最低10%) + */ + private void decreaseBrightness() { + changeBrightness(false); + } + + /** + * 亮度增加(每次加5%,最高200%) + */ + private void increaseBrightness() { + changeBrightness(true); + } + + /** + * 亮度调节核心方法(统一逻辑,加并发控制) + */ + private synchronized void changeBrightness(boolean isIncrease) { + if (!isAppSelfUpdatingColor) { + isAppSelfUpdatingColor = true; + try { + if (isIncrease) { + if (mCurrentBrightnessPercent >= MAX_BRIGHTNESS) return; + mCurrentBrightnessPercent += BRIGHTNESS_STEP; + } else { + if (mCurrentBrightnessPercent <= MIN_BRIGHTNESS) return; + mCurrentBrightnessPercent -= BRIGHTNESS_STEP; + } + // 计算亮度调节后的实时RGB+最终颜色 + calculateBrightnessAndUpdate(); + // 同步所有控件 + updateAllViews(); + LogUtils.d(TAG, String.format("%s brightness | 亮度:%s | 实时RGB:%d,%d,%d", + isIncrease ? "increase" : "decrease", + String.format(FORMAT_PERCENT, mCurrentBrightnessPercent), + mCurrentR, mCurrentG, mCurrentB)); + } finally { + isAppSelfUpdatingColor = false; + } + } + } + + /** + * 解析颜色字符串(支持#RRGGBB/#AARRGGBB,更新原始基准值+实时值) + */ + private void parseColorFromStr(String colorStr, int triggerViewId) { + if (!isAppSelfUpdatingColor) { + isAppSelfUpdatingColor = true; + try { + if (TextUtils.isEmpty(colorStr)) return; + + // 补全#前缀(兼容用户输入习惯) + if (!colorStr.startsWith("#")) { + colorStr = "#" + colorStr; + } + + // 格式校验(仅支持6位RRGGBB/8位AARRGGBB) + if (colorStr.length() != 7 && colorStr.length() != 9) { + LogUtils.e(TAG, String.format("parse color failed | 格式错误(需#RRGGBB/#AARRGGBB),输入:%s", colorStr)); + return; + } + + // 解析颜色 + int parsedColor = Color.parseColor(colorStr); + + // 更新原始基准值与实时值 + mOriginalAlpha = Color.alpha(parsedColor); + mOriginalAlphaPercent = alpha2Percent(mOriginalAlpha); + mOriginalR = Color.red(parsedColor); + mOriginalG = Color.green(parsedColor); + mOriginalB = Color.blue(parsedColor); + mCurrentAlpha = mOriginalAlpha; + mCurrentAlphaPercent = mOriginalAlphaPercent; + mCurrentR = mOriginalR; + mCurrentG = mOriginalG; + mCurrentB = mOriginalB; + mCurrentBrightnessPercent = DEFAULT_BRIGHTNESS; + mCurrentColor = parsedColor; + + // 同步所有控件 + updateAllViews(); + LogUtils.d(TAG, String.format("parse color success | 解析颜色:%s | 透明度:%s | 重置亮度:%s", + String.format(FORMAT_COLOR_HEX, parsedColor), + String.format(FORMAT_PERCENT, mCurrentAlphaPercent), + String.format(FORMAT_PERCENT, DEFAULT_BRIGHTNESS))); + } catch (IllegalArgumentException e) { + LogUtils.e(TAG, String.format("parse color failed | 非法颜色格式,输入:%s", colorStr), e); + } finally { + isAppSelfUpdatingColor = false; + } + } + } + + /** + * 通过RGB输入框更新颜色(用户输入后,更新原始基准值+实时值,重置亮度为100%) + */ + private synchronized void updateColorByRGB(int triggerViewId) { + if (!isAppSelfUpdatingColor) { + isAppSelfUpdatingColor = true; + try { + // 解析用户输入的RGB值(限制0-255,非法输入设为0) + int inputR = parseInputValue(etR.getText().toString()); + int inputG = parseInputValue(etG.getText().toString()); + int inputB = parseInputValue(etB.getText().toString()); + + // 更新原始基准值与实时值 + mOriginalR = inputR; + mOriginalG = inputG; + mOriginalB = inputB; + mCurrentR = inputR; + mCurrentG = inputG; + mCurrentB = inputB; + mCurrentBrightnessPercent = DEFAULT_BRIGHTNESS; + mCurrentColor = Color.argb(mCurrentAlpha, mCurrentR, mCurrentG, mCurrentB); + + // 同步所有控件 + updateAllViews(); + LogUtils.d(TAG, String.format("update color by RGB | 新原始RGB:%d,%d,%d | 透明度:%s | 重置亮度:%s", + mOriginalR, mOriginalG, mOriginalB, + String.format(FORMAT_PERCENT, mCurrentAlphaPercent), + String.format(FORMAT_PERCENT, DEFAULT_BRIGHTNESS))); + } catch (Exception e) { + LogUtils.e(TAG, "update color by RGB failed", e); + } finally { + isAppSelfUpdatingColor = false; + } + } + } + + /** + * 核心同步:更新所有控件显示 + */ + private void updateAllViews() { + // 1. 同步颜色预览 + ivColorPicker.setBackgroundColor(mCurrentColor); + + // 2. 同步RGB输入框 + etR.setText(String.valueOf(mCurrentR)); + etG.setText(String.valueOf(mCurrentG)); + etB.setText(String.valueOf(mCurrentB)); + + // 3. 同步颜色值输入框 + etColorValue.setText(String.format(FORMAT_COLOR_HEX, mCurrentColor)); + + // 4. 同步透明度控件 + sbAlpha.setProgress(mCurrentAlphaPercent); + tvAlphaValue.setText(String.format(FORMAT_PERCENT, mCurrentAlphaPercent)); + + // 5. 同步亮度控件 + tvBrightnessValue.setText(String.format(FORMAT_PERCENT, mCurrentBrightnessPercent)); + updateBrightnessBtnStatus(); + + LogUtils.d(TAG, String.format("sync all views complete | 最终颜色:%s | 实时RGB:%d,%d,%d | 透明度:%s | 亮度:%s", + String.format(FORMAT_COLOR_HEX, mCurrentColor), + mCurrentR, mCurrentG, mCurrentB, + String.format(FORMAT_PERCENT, mCurrentAlphaPercent), + String.format(FORMAT_PERCENT, mCurrentBrightnessPercent))); + } + + /** + * 更新亮度按钮状态(边界值禁用,提升交互体验) + */ + private void updateBrightnessBtnStatus() { + boolean canMinus = mCurrentBrightnessPercent > MIN_BRIGHTNESS; + boolean canPlus = mCurrentBrightnessPercent < MAX_BRIGHTNESS; + + tvBrightnessMinus.setEnabled(canMinus); + tvBrightnessPlus.setEnabled(canPlus); + tvBrightnessMinus.setTextColor(canMinus ? Color.BLACK : Color.parseColor("#CCCCCC")); + tvBrightnessPlus.setTextColor(canPlus ? Color.BLACK : Color.parseColor("#CCCCCC")); + } + + // ====================== 工具方法 ====================== + /** + * 透明度:0-255 → 0-100% + */ + private int alpha2Percent(int alpha) { + return Math.round((float) alpha / MAX_RGB_VALUE * MAX_ALPHA_PERCENT); + } + + /** + * 透明度:0-100% → 0-255 + */ + private int percent2Alpha(int percent) { + return Math.round((float) percent / MAX_ALPHA_PERCENT * MAX_RGB_VALUE); + } + + /** + * 解析输入值(限制0-255,非法输入返回0) + */ + private int parseInputValue(String input) { + if (TextUtils.isEmpty(input)) return 0; + try { + int value = Integer.parseInt(input); + return Math.min(Math.max(value, 0), MAX_RGB_VALUE); + } catch (NumberFormatException e) { + LogUtils.e(TAG, String.format("parse input failed | 非法数字,输入:%s", input), e); + return 0; + } + } + + /** + * RGB输入框监听复用 + */ + private void setEditTextWatcher(EditText editText, final int viewId) { + editText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + + @Override + public void afterTextChanged(Editable s) { + if (!isAppSelfUpdatingColor) { + updateColorByRGB(viewId); + } + } + }); + } + + /** + * dp转px(适配小米不同分辨率) + */ + private int dp2px(float dp) { + return (int) (dp * getContext().getResources().getDisplayMetrics().density + 0.5f); + } + + /** + * 显示系统颜色选择器(兼容API29-30,无高版本依赖,小米机型适配) + */ + private void showSystemColorPicker() { + LogUtils.d(TAG, "show system color picker | 兼容小米API29-30,支持横向滚动"); + final android.app.AlertDialog.Builder builder = new android.app.AlertDialog.Builder(getContext()); + builder.setTitle("选择基础颜色"); + + // 50种常用颜色:按彩虹光谱顺序排列 + final int[] systemColors = { + 0xFFCC0000, 0xFFFF0000, 0xFFFF6666, 0xFFFF1493, 0xFF8B0000, 0xFFFF4500, + 0xFFCC6600, 0xFFFF8800, 0xFFFFAA33, 0xFFFFBB00, 0xFFF5A623, + 0xFFCCCC00, 0xFFFFFF00, 0xFFFFEE99, 0xFFFFFACD, 0xFFFFD700, + 0xFF006600, 0xFF00FF00, 0xFF99FF99, 0xFF66CC66, 0xFF98FB98, 0xFF00FF99, 0xFF003300, + 0xFF006666, 0xFF00FFFF, 0xFF99FFFF, 0xFF00CCCC, 0xFF40E0D0, + 0xFF0000CC, 0xFF00008B, 0xFF0000FF, 0xFF6666FF, 0xFF87CEEB, 0xFF0066FF, 0xFF0099FF, 0xFF4B0082, + 0xFF660099, 0xFF8800FF, 0xFFAA99FF, 0xFF9370DB, 0xFFCBC3E3, 0xFF8A2BE2, + 0xFFFF00FF, 0xFFFF99CC, 0xFFFFCCDD, 0xFFFFB6C1, 0xFFFFA5A5, + 0xFF8B4513, 0xFFA0522D, 0xFFD2B48C, 0xFFCD853F, + 0xFF333333, 0xFF666666, 0xFF888888, 0xFFAAAAAA, 0xFFCCCCCC, 0xFFE6E6E6, + 0xFF000000, 0xFFFFFFFF, 0xFFFFFAFA + }; + + // 1. 第一级:水平滚动容器 + HorizontalScrollView horizontalScrollView = new HorizontalScrollView(getContext()); + horizontalScrollView.setHorizontalScrollBarEnabled(true); + horizontalScrollView.setScrollBarStyle(View.SCROLLBARS_INSIDE_OVERLAY); + horizontalScrollView.setPadding(dp2px(5), dp2px(5), dp2px(5), dp2px(5)); + + // 2. 第二级:颜色排列容器(横向) + LinearLayout colorLayout = new LinearLayout(getContext()); + colorLayout.setOrientation(LinearLayout.HORIZONTAL); + colorLayout.setGravity(Gravity.CENTER_VERTICAL); + colorLayout.setPadding(dp2px(10), dp2px(10), dp2px(10), dp2px(10)); + + // 3. 循环添加颜色按钮(内置圆形效果) + for (int i = 0; i < systemColors.length; i++) { + final int color = systemColors[i]; + ImageView colorBtn = new ImageView(getContext()); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(dp2px(40), dp2px(40)); + if (i != systemColors.length - 1) { + lp.setMargins(0, 0, dp2px(10), 0); // 按钮间距 + } + colorBtn.setLayoutParams(lp); + + // 内置圆形背景(白色边框+圆形形状) + GradientDrawable circleBg = new GradientDrawable(); + circleBg.setShape(GradientDrawable.OVAL); + circleBg.setColor(color); + circleBg.setStroke(dp2px(2), Color.WHITE); + colorBtn.setBackground(circleBg); + + colorBtn.setClickable(true); + colorBtn.setFocusable(true); + + // 点击事件 + colorBtn.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (!isAppSelfUpdatingColor) { + isAppSelfUpdatingColor = true; + try { + mOriginalAlpha = Color.alpha(color); + mOriginalAlphaPercent = alpha2Percent(mOriginalAlpha); + mOriginalR = Color.red(color); + mOriginalG = Color.green(color); + mOriginalB = Color.blue(color); + mCurrentAlpha = mOriginalAlpha; + mCurrentAlphaPercent = mOriginalAlphaPercent; + mCurrentR = mOriginalR; + mCurrentG = mOriginalG; + mCurrentB = mOriginalB; + mCurrentBrightnessPercent = DEFAULT_BRIGHTNESS; + mCurrentColor = color; + updateAllViews(); + builder.create().dismiss(); + LogUtils.d(TAG, String.format("select system color | 选择颜色:%s | 透明度:%s", + String.format(FORMAT_COLOR_HEX, color), + String.format(FORMAT_PERCENT, mCurrentAlphaPercent))); + } finally { + isAppSelfUpdatingColor = false; + } + } + } + }); + colorLayout.addView(colorBtn); + } + + // 层级嵌套 + horizontalScrollView.addView(colorLayout); + builder.setView(horizontalScrollView).setNegativeButton("关闭", null).show(); + } + + // ====================== 点击事件实现 ====================== + @Override + public void onClick(View v) { + int id = v.getId(); + // 所有点击事件均加并发判断 + if (!isAppSelfUpdatingColor) { + if (id == R.id.iv_color_picker) { + showSystemColorPicker(); + } else if (id == R.id.iv_color_scaler) { + openColorScalerDialog(mCurrentColor); + } else if (id == R.id.tv_confirm) { + mListener.onColorSelected(mCurrentColor); + LogUtils.d(TAG, String.format("confirm color | 回调颜色:%s", + String.format(FORMAT_COLOR_HEX, mCurrentColor))); + dismiss(); + } else if (id == R.id.tv_cancel) { + dismiss(); + LogUtils.d(TAG, "cancel color | 取消选择,关闭对话框"); + } else if (id == R.id.tv_brightness_minus) { + decreaseBrightness(); + } else if (id == R.id.tv_brightness_plus) { + increaseBrightness(); + } + } + } + + /** + * 打开颜色渐变选择器 + */ + void openColorScalerDialog(int nColor) { + LogUtils.d(TAG, String.format("openColorScalerDialog | 初始颜色:%s", + String.format(FORMAT_COLOR_HEX, nColor))); + final ColorScalerDialog dlg = new ColorScalerDialog(getContext(), nColor); + dlg.setOnColorChangedListener(new OnColorChangedListener() { + @Override + public void beforeColorChanged() {} + + @Override + public void onColorChanged(int color) { + dlg.currentColorScalerDialogColor = color; + } + + @Override + public void afterColorChanged() {} + }); + dlg.show(); + } + + // ====================== 内部类 ====================== + class ColorScalerDialog extends ColorPickerDialog { + public int currentColorScalerDialogColor = 0; + + public ColorScalerDialog(Context context, int p) { + super(context, p); + this.currentColorScalerDialogColor = p; + } + + @Override + public void dismiss() { + super.dismiss(); + int color = currentColorScalerDialogColor; + ToastUtils.show(String.format("选择颜色:%s", String.format(FORMAT_COLOR_HEX, color))); + if (!isAppSelfUpdatingColor) { + isAppSelfUpdatingColor = true; + try { + mOriginalAlpha = Color.alpha(color); + mOriginalAlphaPercent = alpha2Percent(mOriginalAlpha); + mOriginalR = Color.red(color); + mOriginalG = Color.green(color); + mOriginalB = Color.blue(color); + mCurrentAlpha = mOriginalAlpha; + mCurrentAlphaPercent = mOriginalAlphaPercent; + mCurrentR = mOriginalR; + mCurrentG = mOriginalG; + mCurrentB = mOriginalB; + mCurrentBrightnessPercent = DEFAULT_BRIGHTNESS; + mCurrentColor = color; + updateAllViews(); + LogUtils.d(TAG, String.format("select scaler color | 选择颜色:%s | 透明度:%s", + String.format(FORMAT_COLOR_HEX, color), + String.format(FORMAT_PERCENT, mCurrentAlphaPercent))); + } finally { + isAppSelfUpdatingColor = false; + } + } + } + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/dialogs/NetworkBackgroundDialog.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/dialogs/NetworkBackgroundDialog.java new file mode 100644 index 0000000..b90fd56 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/dialogs/NetworkBackgroundDialog.java @@ -0,0 +1,340 @@ +package cc.winboll.studio.powerbell.dialogs; + +import android.content.Context; +import android.content.DialogInterface; +import android.os.Handler; +import android.os.Message; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import cc.winboll.studio.powerbell.R; +import cc.winboll.studio.powerbell.utils.BackgroundSourceUtils; +import cc.winboll.studio.powerbell.utils.ImageDownloader; +import cc.winboll.studio.powerbell.views.BackgroundView; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/11/19 20:11 + * @Describe 网络背景使用提示对话框 + * 继承 AndroidX AlertDialog,绑定自定义布局 dialog_networkbackground.xml + * 适配 API30,基于 Java7 开发,支持网络图片下载、预览与回调 + */ +public class NetworkBackgroundDialog extends AlertDialog { + // ====================== 静态常量(首屏可见,统一管理) ====================== + public static final String TAG = "NetworkBackgroundDialog"; + private static final int MSG_IMAGE_LOAD_SUCCESS = 1001; // 图片加载成功消息标识 + private static final int MSG_IMAGE_LOAD_FAILED = 1002; // 图片加载失败消息标识 + + // ====================== 回调接口(紧跟常量,逻辑关联) ====================== + /** + * 按钮点击回调接口(Java7 接口实现) + */ + public interface OnDialogClickListener { + void onConfirm(String szConfirmFilePath); // 确认按钮点击,返回图片路径 + void onCancel(); // 取消按钮点击 + } + + // ====================== 成员变量(按优先级排序:核心数据→控件引用) ====================== + // 核心数据 + private OnDialogClickListener listener; // 按钮点击回调 + private Context mContext; // 上下文对象 + private Handler mUiHandler; // 主线程 Handler,用于接收子线程消息更新 UI + private String mPreviewFilePath; // 预览图片文件路径 + private String mPreviewFileUrl; // 预览图片网络 URL + private String mDownloadSavedPath; // 下载图片保存路径 + // 控件引用 + private TextView tvTitle; // 对话框标题 + private TextView tvContent; // 对话框内容 + private Button btnCancel; // 取消按钮 + private Button btnConfirm; // 确认按钮 + private Button btnPreview; // 预览按钮 + private EditText etURL; // URL 输入框 + private BackgroundView mBackgroundView; // 背景预览视图 + + // ====================== 构造方法(Java7 显式构造,按参数重载排序) ====================== + /** + * 基础构造(仅传入 Context) + * @param context 上下文 + */ + public NetworkBackgroundDialog(@NonNull Context context) { + super(context); + LogUtils.d(TAG, "NetworkBackgroundDialog: 基础构造初始化"); + initHandler(); + initView(); + setDismissListener(); + } + + /** + * 带回调的构造(便于外部处理点击事件) + * @param context 上下文 + * @param listener 按钮点击回调 + */ + public NetworkBackgroundDialog(@NonNull Context context, OnDialogClickListener listener) { + super(context); + this.listener = listener; + LogUtils.d(TAG, "NetworkBackgroundDialog: 带回调构造初始化"); + initHandler(); + initView(); + setDismissListener(); + } + + // ====================== 生命周期相关方法(对话框消失监听、Handler 初始化) ====================== + /** + * 初始化主线程 Handler,用于接收子线程消息并更新 UI + */ + private void initHandler() { + mUiHandler = new Handler() { + @Override + public void handleMessage(Message msg) { + super.handleMessage(msg); + // 对话框已消失时,不再处理 UI 消息 + if (!isShowing()) { + LogUtils.d(TAG, "handleMessage: 对话框已消失,忽略消息"); + return; + } + switch (msg.what) { + case MSG_IMAGE_LOAD_SUCCESS: + // 图片加载成功,获取文件路径并设置背景 + mDownloadSavedPath = (String) msg.obj; + LogUtils.d(TAG, String.format("handleMessage: 图片加载成功,保存路径:%s", mDownloadSavedPath)); + int nCurrentPixelColor = BackgroundSourceUtils.getInstance(mContext).getCurrentBackgroundBean().getPixelColor(); + + mBackgroundView.loadImage(nCurrentPixelColor, mDownloadSavedPath, true); + break; + case MSG_IMAGE_LOAD_FAILED: + // 图片加载失败,设置默认背景 + LogUtils.e(TAG, "handleMessage: 图片加载失败"); + mBackgroundView.setBackgroundResource(R.drawable.ic_launcher); + ToastUtils.show("图片预览失败,请检查链接"); + break; + default: + break; + } + } + }; + LogUtils.d(TAG, "initHandler: 主线程 Handler 初始化完成"); + } + + /** + * 设置对话框消失监听:移除 Handler 消息,避免内存泄漏 + */ + private void setDismissListener() { + this.setOnDismissListener(new OnDismissListener() { + @Override + public void onDismiss(DialogInterface dialog) { + // 对话框消失时,移除所有未处理的消息和回调 + if (mUiHandler != null) { + mUiHandler.removeCallbacksAndMessages(null); + LogUtils.d(TAG, "onDismiss: Handler 消息已清理"); + } + LogUtils.d(TAG, "onDismiss: 对话框已消失"); + } + }); + LogUtils.d(TAG, "setDismissListener: 对话框消失监听已设置"); + } + + // ====================== 初始化方法(布局、控件、点击事件) ====================== + /** + * 初始化布局和控件 + */ + private void initView() { + mContext = this.getContext(); + // 加载自定义布局 + View dialogView = LayoutInflater.from(getContext()).inflate(R.layout.dialog_networkbackground, null); + // 设置对话框内容视图 + setView(dialogView); + + // 绑定控件 + tvTitle = (TextView) dialogView.findViewById(R.id.tv_dialog_title); + tvContent = (TextView) dialogView.findViewById(R.id.tv_dialog_content); + btnCancel = (Button) dialogView.findViewById(R.id.btn_cancel); + btnConfirm = (Button) dialogView.findViewById(R.id.btn_confirm); + btnPreview = (Button) dialogView.findViewById(R.id.btn_preview); + etURL = (EditText) dialogView.findViewById(R.id.et_url); + mBackgroundView = (BackgroundView) dialogView.findViewById(R.id.bv_background_preview); + + // 控件非空校验 + if (tvTitle == null || tvContent == null || btnCancel == null || btnConfirm == null || btnPreview == null + || etURL == null || mBackgroundView == null) { + LogUtils.e(TAG, "initView: 控件绑定失败,请检查布局ID是否正确"); + dismiss(); + return; + } + + // 加载初始图片 + mBackgroundView.setBackgroundResource(R.drawable.blank100x100); + // 设置按钮点击事件 + setButtonClickListeners(); + + LogUtils.d(TAG, "initView: 布局和控件初始化完成"); + } + + /** + * 设置按钮点击监听 + */ + private void setButtonClickListeners() { + // 取消按钮:关闭对话框 + 回调外部 + btnCancel.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "onClick: 取消按钮点击"); + BackgroundSourceUtils utils = BackgroundSourceUtils.getInstance(mContext); + utils.setCurrentSourceToPreview(); + + dismiss(); // 关闭对话框 + if (listener != null) { + listener.onCancel(); + LogUtils.d(TAG, "onClick: 取消回调已执行"); + } + } + }); + + // 确认按钮:关闭对话框 + 回调外部 + btnConfirm.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "onClick: 确认按钮点击"); + dismiss(); // 关闭对话框 + if (TextUtils.isEmpty(mDownloadSavedPath)) { + ToastUtils.show("未下载图片。"); + LogUtils.w(TAG, "onClick: 确认失败,未下载图片"); + return; + } + if (listener != null) { + listener.onConfirm(mDownloadSavedPath); + LogUtils.d(TAG, String.format("onClick: 确认回调已执行,图片路径:%s", mDownloadSavedPath)); + } + } + }); + + // 图片预览按钮:预览输入框地址图片 + btnPreview.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "onClick: 预览按钮点击"); + downloadImageToAlbumAndPreview(); + } + }); + + LogUtils.d(TAG, "setButtonClickListeners: 按钮点击监听已设置"); + } + + // ====================== 业务逻辑方法(图片下载、预览) ====================== + /** + * 下载网络图片并预览 + */ + void downloadImageToAlbumAndPreview() { + mPreviewFileUrl = etURL.getText().toString().trim(); + if (TextUtils.isEmpty(mPreviewFileUrl)) { + ToastUtils.show("请输入图片URL"); + LogUtils.w(TAG, "downloadImageToAlbumAndPreview: 图片URL为空"); + return; + } + + LogUtils.d(TAG, String.format("downloadImageToAlbumAndPreview: 开始下载图片,URL:%s", mPreviewFileUrl)); + ImageDownloader.getInstance(mContext).downloadImage(mPreviewFileUrl, new ImageDownloader.DownloadCallback() { + @Override + public void onSuccess(String savePath) { + LogUtils.d(TAG, String.format("onSuccess: 图片下载成功,保存路径:%s", savePath)); + // 发送消息到主线程,携带图片路径 + Message successMsg = mUiHandler.obtainMessage(MSG_IMAGE_LOAD_SUCCESS, savePath); + mUiHandler.sendMessage(successMsg); + } + + @Override + public void onFailure(String errorMsg) { + LogUtils.e(TAG, String.format("onFailure: 图片下载失败,错误信息:%s", errorMsg)); + ToastUtils.show("下载失败:" + errorMsg); + // 发送图片加载失败消息 + Message failMsg = mUiHandler.obtainMessage(MSG_IMAGE_LOAD_FAILED); + mUiHandler.sendMessage(failMsg); + } + }); + } + + /** + * 根据文件路径设置 BackgroundView 背景(主线程调用) + * @param previewFilePath 图片文件路径 + */ + private void previewBackground(String previewFilePath) { + if (TextUtils.isEmpty(previewFilePath)) { + LogUtils.w(TAG, "previewBackground: 预览文件路径为空"); + return; + } + + FileInputStream fis = null; + try { + File imageFile = new File(previewFilePath); + if (!imageFile.exists()) { + ToastUtils.show("图片文件不存在:" + previewFilePath); + LogUtils.e(TAG, String.format("previewBackground: 图片文件不存在,路径:%s", previewFilePath)); + mBackgroundView.setBackgroundResource(R.drawable.ic_launcher); + return; + } + + // 预览背景 + mPreviewFilePath = previewFilePath; + BackgroundSourceUtils utils = BackgroundSourceUtils.getInstance(mContext); + utils.saveFileToPreviewBean(new File(mPreviewFilePath), mPreviewFileUrl); + mBackgroundView.loadByBackgroundBean(utils.getPreviewBackgroundBean()); + + LogUtils.d(TAG, String.format("previewBackground: 图片预览成功,路径:%s", previewFilePath)); + } catch (Exception e) { + LogUtils.e(TAG, String.format("previewBackground: 图片预览失败,错误信息:%s", e.getMessage()), e); + mBackgroundView.setBackgroundResource(R.drawable.ic_launcher); + } finally { + // Java7 手动关闭流,避免资源泄漏 + if (fis != null) { + try { + fis.close(); + LogUtils.d(TAG, "previewBackground: 文件输入流已关闭"); + } catch (IOException e) { + LogUtils.e(TAG, String.format("previewBackground: 关闭文件输入流失败,错误信息:%s", e.getMessage()), e); + } + } + } + } + + // ====================== 对外提供方法(灵活适配不同场景) ====================== + /** + * 对外提供方法:修改对话框标题 + * @param title 标题文本 + */ + public void setTitle(String title) { + if (tvTitle != null && !TextUtils.isEmpty(title)) { + tvTitle.setText(title); + LogUtils.d(TAG, String.format("setTitle: 对话框标题已修改为:%s", title)); + } + } + + /** + * 对外提供方法:修改对话框内容 + * @param content 内容文本 + */ + public void setContent(String content) { + if (tvContent != null && !TextUtils.isEmpty(content)) { + tvContent.setText(content); + LogUtils.d(TAG, String.format("setContent: 对话框内容已修改为:%s", content)); + } + } + + /** + * 对外提供方法:设置按钮点击回调(替代带参构造) + * @param listener 按钮点击回调 + */ + public void setOnDialogClickListener(OnDialogClickListener listener) { + this.listener = listener; + LogUtils.d(TAG, "setOnDialogClickListener: 按钮点击回调已设置"); + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/handlers/ControlCenterServiceHandler.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/handlers/ControlCenterServiceHandler.java new file mode 100644 index 0000000..3c33ce1 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/handlers/ControlCenterServiceHandler.java @@ -0,0 +1,120 @@ +package cc.winboll.studio.powerbell.handlers; + +import android.os.Handler; +import android.os.Message; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.models.NotificationMessage; +import cc.winboll.studio.powerbell.services.ControlCenterService; +import java.lang.ref.WeakReference; + +/** + * 服务通信Handler + * 功能:处理电量提醒消息,构建并发送标准化通知 + * 特性:弱引用防泄漏、参数严格校验、通知格式统一 + * 适配:Java7 | API30 | 小米手机 + */ +public class ControlCenterServiceHandler extends Handler { + // ================================== 静态常量区(置顶归类,消除魔法值)================================= + public static final String TAG = "ControlCenterServiceHandler"; + public static final int MSG_REMIND_TEXT = 1001; // 电量提醒消息标识 + + // 提醒类型常量 + private static final String REMIND_TYPE_CHARGE = "+"; + private static final String REMIND_TYPE_USAGE = "-"; + + // 电量范围常量 + private static final int BATTERY_LEVEL_MIN = 0; + private static final int BATTERY_LEVEL_MAX = 100; + + // 通知文案常量(抽离魔法值,便于统一修改) + private static final String CHARGE_REMIND_TITLE = "充电提醒"; + private static final String USAGE_REMIND_TITLE = "耗电提醒"; + private static final String CHARGE_REMIND_CONTENT_FORMAT = "(+)电量已达额定值。当前电量%d%%,%s。"; + private static final String USAGE_REMIND_CONTENT_FORMAT = "(-)电量低于指定值。当前电量%d%%,%s。"; + private static final String CHARGE_STATE_CHARGING = "充电中"; + private static final String CHARGE_STATE_NOT_CHARGING = "未充电"; + + // ================================== 成员变量区(弱引用防泄漏,final保证不可变)================================= + private final WeakReference mwrControlCenterService; + + // ================================== 构造方法(强制传入服务,初始化弱引用)================================= + public ControlCenterServiceHandler(ControlCenterService service) { + LogUtils.d(TAG, "构造方法执行 | service=" + (service != null ? service.getClass().getSimpleName() : "null")); + this.mwrControlCenterService = new WeakReference<>(service); + } + + // ================================== 核心消息处理(重写handleMessage,解析多参数消息)================================= + @Override + public void handleMessage(Message msg) { + super.handleMessage(msg); + // 解析消息参数:obj=提醒类型(+/-),arg1=当前电量,arg2=充电状态(1=充电/0=未充电) + String remindType = (msg.obj != null) ? (String) msg.obj : ""; + int currentBattery = msg.arg1; + boolean isCharging = msg.arg2 == 1; + + LogUtils.d(TAG, "handleMessage: 接收消息 | what=" + msg.what + " | type=" + remindType + " | battery=" + currentBattery + " | isCharging=" + isCharging); + + // 弱引用获取服务,避免内存泄漏 + ControlCenterService service = mwrControlCenterService.get(); + if (service == null) { + LogUtils.e(TAG, "handleMessage: 服务实例已被GC回收,终止消息处理"); + return; + } + + // 按消息类型分发处理 + switch (msg.what) { + case MSG_REMIND_TEXT: + handleRemindMessage(service, remindType, currentBattery, isCharging); + break; + default: + LogUtils.w(TAG, "handleMessage: 未知消息类型,忽略处理 | what=" + msg.what); + break; + } + } + + // ================================== 业务辅助方法(构建通知并发送,全链路参数校验)================================= + /** + * 处理电量提醒消息,构建带电量+充电状态的通知并发送 + * @param service 控制中心服务实例(已校验非空) + * @param remindType 提醒类型(+充电/-耗电) + * @param currentBattery 当前电量(0-100) + * @param isCharging 充电状态 + */ + private void handleRemindMessage(ControlCenterService service, String remindType, int currentBattery, boolean isCharging) { + LogUtils.d(TAG, "handleRemindMessage: 开始处理提醒消息 | type=" + remindType + " | battery=" + currentBattery + " | isCharging=" + isCharging); + + // 1. 前置校验:通知工具类+参数有效性 + if (service.getNotificationManager() == null) { + LogUtils.e(TAG, "handleRemindMessage: 通知管理工具类未初始化,无法发送提醒"); + return; + } + if (!REMIND_TYPE_CHARGE.equals(remindType) && !REMIND_TYPE_USAGE.equals(remindType)) { + LogUtils.w(TAG, "handleRemindMessage: 提醒类型无效,忽略 | type=" + remindType + " | 允许值:" + REMIND_TYPE_CHARGE + "/" + REMIND_TYPE_USAGE); + return; + } + if (currentBattery < BATTERY_LEVEL_MIN || currentBattery > BATTERY_LEVEL_MAX) { + LogUtils.w(TAG, "handleRemindMessage: 电量值超出范围,忽略 | battery=" + currentBattery + " | 允许范围:" + BATTERY_LEVEL_MIN + "-" + BATTERY_LEVEL_MAX); + return; + } + + // 2. 构建通知模型,使用统一格式 + NotificationMessage remindMsg = new NotificationMessage(); + String chargeStateDesc = isCharging ? CHARGE_STATE_CHARGING : CHARGE_STATE_NOT_CHARGING; + if (REMIND_TYPE_CHARGE.equals(remindType)) { + remindMsg.setTitle(CHARGE_REMIND_TITLE); + remindMsg.setContent(String.format(CHARGE_REMIND_CONTENT_FORMAT, currentBattery, chargeStateDesc)); + remindMsg.setRemindMSG("charge_remind"); + } else { + remindMsg.setTitle(USAGE_REMIND_TITLE); + remindMsg.setContent(String.format(USAGE_REMIND_CONTENT_FORMAT, currentBattery, chargeStateDesc)); + remindMsg.setRemindMSG("usage_remind"); + } + LogUtils.d(TAG, "handleRemindMessage: 通知模型构建完成 | title=" + remindMsg.getTitle() + " | content=" + remindMsg.getContent()); + + // 3. 调用通知工具类发送提醒 + LogUtils.d(TAG, "handleRemindMessage: 调用通知工具类发送提醒 | remindMSG=" + remindMsg.getRemindMSG()); + service.getNotificationManager().showRemindNotification(service, remindMsg); + LogUtils.d(TAG, "handleRemindMessage: 提醒通知发送流程执行完毕"); + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/models/AppConfigBean.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/models/AppConfigBean.java new file mode 100644 index 0000000..0b26a0e --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/models/AppConfigBean.java @@ -0,0 +1,274 @@ +package cc.winboll.studio.powerbell.models; + +import android.os.Parcel; +import android.os.Parcelable; +import android.util.JsonReader; +import android.util.JsonWriter; +import cc.winboll.studio.libappbase.BaseBean; +import cc.winboll.studio.libappbase.LogUtils; +import java.io.IOException; +import java.io.Serializable; + +/** + * @Author 豆包&ZhanGSKen + * @Date 2024/04/29 17:24:53 + * @Describe 应用运行参数类 + * 适配 API30,支持 Serializable 持久化、Parcelable Intent 传递、JSON 序列化/反序列化 + * 包含耗电提醒、充电提醒、电量检测、铃声提醒、相框尺寸等核心配置 + */ +public class AppConfigBean extends BaseBean implements Serializable, Parcelable { + // ====================== 静态常量区(首屏可见,统一管理) ====================== + // 序列化版本号(Serializable 必备,避免反序列化失败) + private static final long serialVersionUID = 1L; + // 日志标签(全局统一) + transient public static final String TAG = "AppConfigBean"; + // 字段校验常量(统一阈值,避免硬编码) + private static final int MIN_INTERVAL = 500; // 最小检测间隔(ms) + private static final int MIN_REMIND_INTERVAL = 1000;// 最小提醒间隔(ms) + private static final int BATTERY_MIN = 0; // 电量最小值 + private static final int BATTERY_MAX = 100; // 电量最大值 + private static final int INVALID_BATTERY = -1; // 无效电量标识 + private static final int DEFAULT_FRAME_WIDTH = 500; // 默认相框宽度(px) + private static final int DEFAULT_FRAME_HEIGHT = 500;// 默认相框高度(px) + + // ====================== 成员变量区(按功能分类:提醒配置→电量状态→检测配置→相框配置) ====================== + // 耗电提醒配置 + boolean isEnableUsageReminder = false; // 耗电提醒开关 + int usageReminderValue = 45; // 耗电提醒阈值(0-100) + // 充电提醒配置 + boolean isEnableChargeReminder = false;// 充电提醒开关 + int chargeReminderValue = 100; // 充电提醒阈值(0-100) + // 铃声提醒配置 + int reminderIntervalTime = 5000; // 铃声提醒间隔(ms) + // 电量状态 + boolean isCharging = false; // 是否充电 + // 电量检测配置 + int batteryDetectInterval = 2000; // 电量检测间隔(ms,适配 RemindThread) + // 相框配置 + int defaultFrameWidth = DEFAULT_FRAME_WIDTH; // 默认相框宽度(px) + int defaultFrameHeight = DEFAULT_FRAME_HEIGHT;// 默认相框高度(px) + + // ====================== 构造方法(初始化默认配置,强化默认值校验) ====================== + public AppConfigBean() { + setChargeReminderValue(100); + setEnableChargeReminder(false); + setUsageReminderValue(10); + setEnableUsageReminder(false); + setReminderIntervalTime(5000); + setBatteryDetectInterval(1000); + setDefaultFrameWidth(DEFAULT_FRAME_WIDTH); + setDefaultFrameHeight(DEFAULT_FRAME_HEIGHT); + LogUtils.d(TAG, "AppConfigBean() 构造器执行 | 默认配置初始化完成"); + } + + // ====================== 核心业务方法(Setter/Getter,按字段功能分类,补充调试日志) ====================== + // --------------- 充电状态相关 --------------- + public void setIsCharging(boolean isCharging) { + this.isCharging = isCharging; + LogUtils.d(TAG, String.format("setIsCharging() 执行 | 充电状态=%b", isCharging)); + } + + public boolean isCharging() { + return isCharging; + } + + // --------------- 耗电提醒配置相关 --------------- + public void setEnableUsageReminder(boolean isEnableUsageReminder) { + this.isEnableUsageReminder = isEnableUsageReminder; + LogUtils.d(TAG, String.format("setEnableUsageReminder() 执行 | 耗电提醒开关=%b", isEnableUsageReminder)); + } + + public boolean isEnableUsageReminder() { + return isEnableUsageReminder; + } + + public void setUsageReminderValue(int usageReminderValue) { + this.usageReminderValue = Math.min(Math.max(usageReminderValue, BATTERY_MIN), BATTERY_MAX); + LogUtils.d(TAG, String.format("setUsageReminderValue() 执行 | 最终阈值=%d | 输入值=%d", this.usageReminderValue, usageReminderValue)); + } + + public int getUsageReminderValue() { + return usageReminderValue; + } + + // --------------- 充电提醒配置相关 --------------- + public void setEnableChargeReminder(boolean isEnableChargeReminder) { + this.isEnableChargeReminder = isEnableChargeReminder; + LogUtils.d(TAG, String.format("setEnableChargeReminder() 执行 | 充电提醒开关=%b", isEnableChargeReminder)); + } + + public boolean isEnableChargeReminder() { + return isEnableChargeReminder; + } + + public void setChargeReminderValue(int chargeReminderValue) { + this.chargeReminderValue = Math.min(Math.max(chargeReminderValue, BATTERY_MIN), BATTERY_MAX); + LogUtils.d(TAG, String.format("setChargeReminderValue() 执行 | 最终阈值=%d | 输入值=%d", this.chargeReminderValue, chargeReminderValue)); + } + + public int getChargeReminderValue() { + return chargeReminderValue; + } + + // --------------- 铃声提醒配置相关 --------------- + public void setReminderIntervalTime(int reminderIntervalTime) { + this.reminderIntervalTime = Math.max(reminderIntervalTime, MIN_REMIND_INTERVAL); + LogUtils.d(TAG, String.format("setReminderIntervalTime() 执行 | 最终间隔=%dms | 输入值=%dms", this.reminderIntervalTime, reminderIntervalTime)); + } + + public int getReminderIntervalTime() { + return reminderIntervalTime; + } + + // --------------- 电量检测配置相关 --------------- + public void setBatteryDetectInterval(int batteryDetectInterval) { + this.batteryDetectInterval = Math.max(batteryDetectInterval, MIN_INTERVAL); + LogUtils.d(TAG, String.format("setBatteryDetectInterval() 执行 | 最终间隔=%dms | 输入值=%dms", this.batteryDetectInterval, batteryDetectInterval)); + } + + public int getBatteryDetectInterval() { + return batteryDetectInterval; + } + + // --------------- 相框配置相关 --------------- + public void setDefaultFrameWidth(int defaultFrameWidth) { + this.defaultFrameWidth = defaultFrameWidth; + LogUtils.d(TAG, String.format("setDefaultFrameWidth() 执行 | 最终宽度=%dpx | 输入值=%dpx", this.defaultFrameWidth, defaultFrameWidth)); + } + + public int getDefaultFrameWidth() { + return defaultFrameWidth; + } + + public void setDefaultFrameHeight(int defaultFrameHeight) { + this.defaultFrameHeight = defaultFrameHeight; + LogUtils.d(TAG, String.format("setDefaultFrameHeight() 执行 | 最终高度=%dpx | 输入值=%dpx", this.defaultFrameHeight, defaultFrameHeight)); + } + + public int getDefaultFrameHeight() { + return defaultFrameHeight; + } + + // ====================== 父类重写方法(JSON 序列化/反序列化,兼容旧配置) ====================== + @Override + public String getName() { + return AppConfigBean.class.getName(); + } + + @Override + public void writeThisToJsonWriter(JsonWriter jsonWriter) throws IOException { + super.writeThisToJsonWriter(jsonWriter); + LogUtils.d(TAG, "writeThisToJsonWriter() 执行 | 开始JSON序列化"); + + // 原有字段序列化 + jsonWriter.name("isEnableUsageReminder").value(isEnableUsageReminder()); + jsonWriter.name("usageReminderValue").value(getUsageReminderValue()); + jsonWriter.name("isEnableChargeReminder").value(isEnableChargeReminder()); + jsonWriter.name("chargeReminderValue").value(getChargeReminderValue()); + jsonWriter.name("reminderIntervalTime").value(getReminderIntervalTime()); + jsonWriter.name("isCharging").value(isCharging()); + // 新增字段序列化(检测配置) + jsonWriter.name("batteryDetectInterval").value(getBatteryDetectInterval()); + // 新增字段序列化(相框配置) + jsonWriter.name("defaultFrameWidth").value(getDefaultFrameWidth()); + jsonWriter.name("defaultFrameHeight").value(getDefaultFrameHeight()); + + LogUtils.d(TAG, "writeThisToJsonWriter() 完成 | JSON序列化成功"); + } + + @Override + public BaseBean readBeanFromJsonReader(JsonReader jsonReader) throws IOException { + LogUtils.d(TAG, "readBeanFromJsonReader() 执行 | 开始JSON反序列化"); + AppConfigBean bean = new AppConfigBean(); + jsonReader.beginObject(); + + while (jsonReader.hasNext()) { + String name = jsonReader.nextName(); + // 兼容拼写错误字段(isEnableUsegeReminder → isEnableUsageReminder) + if (name.equals("isEnableUsageReminder") || name.equals("isEnableUsegeReminder")) { + bean.setEnableUsageReminder(jsonReader.nextBoolean()); + LogUtils.d(TAG, String.format("readBeanFromJsonReader() 读取字段 | %s=%b", name, bean.isEnableUsageReminder())); + } else if (name.equals("usageReminderValue") || name.equals("usegeReminderValue")) { + bean.setUsageReminderValue(jsonReader.nextInt()); + LogUtils.d(TAG, String.format("readBeanFromJsonReader() 读取字段 | %s=%d", name, bean.getUsageReminderValue())); + } else if (name.equals("isEnableChargeReminder")) { + bean.setEnableChargeReminder(jsonReader.nextBoolean()); + LogUtils.d(TAG, String.format("readBeanFromJsonReader() 读取字段 | %s=%b", name, bean.isEnableChargeReminder())); + } else if (name.equals("chargeReminderValue")) { + bean.setChargeReminderValue(jsonReader.nextInt()); + LogUtils.d(TAG, String.format("readBeanFromJsonReader() 读取字段 | %s=%d", name, bean.getChargeReminderValue())); + } else if (name.equals("reminderIntervalTime")) { + bean.setReminderIntervalTime(jsonReader.nextInt()); + LogUtils.d(TAG, String.format("readBeanFromJsonReader() 读取字段 | %s=%d", name, bean.getReminderIntervalTime())); + } else if (name.equals("isCharging")) { + bean.setIsCharging(jsonReader.nextBoolean()); + LogUtils.d(TAG, String.format("readBeanFromJsonReader() 读取字段 | %s=%b", name, bean.isCharging())); + } else if (name.equals("batteryDetectInterval")) { + bean.setBatteryDetectInterval(jsonReader.nextInt()); + LogUtils.d(TAG, String.format("readBeanFromJsonReader() 读取字段 | %s=%d", name, bean.getBatteryDetectInterval())); + } else if (name.equals("defaultFrameWidth")) { + bean.setDefaultFrameWidth(jsonReader.nextInt()); + LogUtils.d(TAG, String.format("readBeanFromJsonReader() 读取字段 | %s=%d", name, bean.getDefaultFrameWidth())); + } else if (name.equals("defaultFrameHeight")) { + bean.setDefaultFrameHeight(jsonReader.nextInt()); + LogUtils.d(TAG, String.format("readBeanFromJsonReader() 读取字段 | %s=%d", name, bean.getDefaultFrameHeight())); + } else { + jsonReader.skipValue(); + LogUtils.w(TAG, String.format("readBeanFromJsonReader() 跳过未知字段 | %s", name)); + } + } + + jsonReader.endObject(); + LogUtils.d(TAG, "readBeanFromJsonReader() 完成 | JSON反序列化成功"); + return bean; + } + + // ====================== Parcelable 接口实现(API30 Intent 传递必备) ====================== + @Override + public int describeContents() { + return 0; // 无特殊内容描述,固定返回0 + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + LogUtils.d(TAG, "writeToParcel() 执行 | 开始Parcel序列化"); + // 按成员变量顺序写入,boolean 转 byte 存储 + dest.writeByte((byte) (isEnableUsageReminder ? 1 : 0)); + dest.writeInt(usageReminderValue); + dest.writeByte((byte) (isEnableChargeReminder ? 1 : 0)); + dest.writeInt(chargeReminderValue); + dest.writeInt(reminderIntervalTime); + dest.writeByte((byte) (isCharging ? 1 : 0)); + dest.writeInt(batteryDetectInterval); + dest.writeInt(defaultFrameWidth); + dest.writeInt(defaultFrameHeight); + LogUtils.d(TAG, "writeToParcel() 完成 | Parcel序列化成功"); + } + + // 反序列化 Creator(必须 public static final 修饰,Java7 适配) + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public AppConfigBean createFromParcel(Parcel source) { + LogUtils.d(TAG, "createFromParcel() 执行 | 开始Parcel反序列化"); + AppConfigBean bean = new AppConfigBean(); + // 按 writeToParcel 顺序读取 + bean.isEnableUsageReminder = source.readByte() != 0; + bean.usageReminderValue = source.readInt(); + bean.isEnableChargeReminder = source.readByte() != 0; + bean.chargeReminderValue = source.readInt(); + bean.reminderIntervalTime = source.readInt(); + bean.isCharging = source.readByte() != 0; + bean.batteryDetectInterval = source.readInt(); + bean.defaultFrameWidth = source.readInt(); + bean.defaultFrameHeight = source.readInt(); + LogUtils.d(TAG, "createFromParcel() 完成 | Parcel反序列化成功"); + return bean; + } + + @Override + public AppConfigBean[] newArray(int size) { + return new AppConfigBean[size]; + } + }; +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/models/BackgroundBean.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/models/BackgroundBean.java new file mode 100644 index 0000000..1b056c2 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/models/BackgroundBean.java @@ -0,0 +1,296 @@ +package cc.winboll.studio.powerbell.models; + +import android.util.JsonReader; +import android.util.JsonWriter; +import cc.winboll.studio.libappbase.BaseBean; +import cc.winboll.studio.libappbase.LogUtils; +import java.io.IOException; +import java.io.Serializable; + +/** + * @Author ZhanGSKen + * @Date 2024/07/18 11:52:28 + * @Describe 应用背景图片数据类 + * 适配 API30,支持 Serializable 持久化、JSON 序列化/反序列化 + * 存储正式/预览背景配置,包含原图、压缩图、裁剪比例、像素颜色等核心字段 + */ +public class BackgroundBean extends BaseBean implements Serializable { + // ====================== 静态常量(首屏可见,统一管理) ====================== + // 日志标签(全局统一,替换 Log 为 LogUtils) + public static final String TAG = "BackgroundBean"; + // 兼容旧字段常量(统一管理,避免硬编码) + private static final String OLD_FIELD_USE_SCALED_COMPRESS = "isUseScaledCompress"; + // 字段默认值常量(统一管理,避免魔法值) + private static final int DEFAULT_DIMENSION = 100; // 默认宽高 + private static final int MIN_DIMENSION = 1; // 最小宽高 + + // ====================== 成员变量(按功能分类:原图配置→压缩图配置→控制字段→裁剪配置→像素颜色) ====================== + // 原图配置 + private String backgroundFileName = ""; // 背景图片文件名 + private String backgroundFilePath = ""; // 背景图片完整路径 + private String backgroundFileInfo = ""; // 图片信息(Uri、网络地址等) + // 压缩图配置 + private String backgroundScaledCompressFileName = ""; // 压缩后背景图片文件名 + private String backgroundScaledCompressFilePath = ""; // 压缩后背景图片完整路径 + // 控制字段 + private boolean isUseBackgroundFile = false; // 是否启用背景图片 + private boolean isUseBackgroundScaledCompressFile = false; // 是否启用压缩背景图(重命名:原isUseScaledCompress) + // 裁剪配置 + private int backgroundWidth = DEFAULT_DIMENSION; // 背景图宽度 + private int backgroundHeight = DEFAULT_DIMENSION; // 背景图高度 + // 像素颜色 + private int pixelColor = 0xFFFFFFFF; // 拾取的像素颜色(纯色背景用) + + // ====================== 构造方法(无参构造,JSON反序列化必备) ====================== + /** + * 无参构造器(必须,JSON反序列化时需默认构造器) + */ + public BackgroundBean() { + LogUtils.d(TAG, "BackgroundBean: 无参构造初始化完成"); + } + + // ====================== Getter/Setter 方法(按功能分类,补充调试日志,强化校验) ====================== + // --------------- 原图配置相关 --------------- + public String getBackgroundFileName() { + return backgroundFileName; + } + + public void setBackgroundFileName(String backgroundFileName) { + this.backgroundFileName = backgroundFileName == null ? "" : backgroundFileName; + LogUtils.d(TAG, String.format("setBackgroundFileName: 背景文件名设置为 %s", this.backgroundFileName)); + } + + public String getBackgroundFilePath() { + return backgroundFilePath; + } + + public void setBackgroundFilePath(String backgroundFilePath) { + this.backgroundFilePath = backgroundFilePath == null ? "" : backgroundFilePath; + LogUtils.d(TAG, String.format("setBackgroundFilePath: 背景文件路径设置为 %s", this.backgroundFilePath)); + } + + public String getBackgroundFileInfo() { + return backgroundFileInfo; + } + + public void setBackgroundFileInfo(String backgroundFileInfo) { + this.backgroundFileInfo = backgroundFileInfo == null ? "" : backgroundFileInfo; + LogUtils.d(TAG, String.format("setBackgroundFileInfo: 背景文件信息设置为 %s", this.backgroundFileInfo)); + } + + // --------------- 控制字段相关 --------------- + public boolean isUseBackgroundFile() { + return isUseBackgroundFile; + } + + public void setIsUseBackgroundFile(boolean isUseBackgroundFile) { + this.isUseBackgroundFile = isUseBackgroundFile; + LogUtils.d(TAG, String.format("setIsUseBackgroundFile: 是否启用背景图设置为 %b", isUseBackgroundFile)); + } + + // --------------- 压缩图配置相关 --------------- + public String getBackgroundScaledCompressFileName() { + return backgroundScaledCompressFileName; + } + + public void setBackgroundScaledCompressFileName(String backgroundScaledCompressFileName) { + this.backgroundScaledCompressFileName = backgroundScaledCompressFileName == null ? "" : backgroundScaledCompressFileName; + LogUtils.d(TAG, String.format("setBackgroundScaledCompressFileName: 压缩背景文件名设置为 %s", this.backgroundScaledCompressFileName)); + } + + public String getBackgroundScaledCompressFilePath() { + return backgroundScaledCompressFilePath; + } + + public void setBackgroundScaledCompressFilePath(String backgroundScaledCompressFilePath) { + this.backgroundScaledCompressFilePath = backgroundScaledCompressFilePath == null ? "" : backgroundScaledCompressFilePath; + LogUtils.d(TAG, String.format("setBackgroundScaledCompressFilePath: 压缩背景文件路径设置为 %s", this.backgroundScaledCompressFilePath)); + } + + /** + * 重命名:原isUseScaledCompress → 新isUseBackgroundScaledCompressFile(Getter/Setter同步修改) + * 语义:明确表示“是否启用背景压缩图文件”,避免与其他压缩逻辑混淆 + */ + public boolean isUseBackgroundScaledCompressFile() { + return isUseBackgroundScaledCompressFile; + } + + public void setIsUseBackgroundScaledCompressFile(boolean isUseBackgroundScaledCompressFile) { + this.isUseBackgroundScaledCompressFile = isUseBackgroundScaledCompressFile; + LogUtils.d(TAG, String.format("setIsUseBackgroundScaledCompressFile: 是否启用压缩背景图设置为 %b", isUseBackgroundScaledCompressFile)); + } + + // --------------- 裁剪配置相关 --------------- + public int getBackgroundWidth() { + return backgroundWidth; + } + + public void setBackgroundWidth(int backgroundWidth) { + this.backgroundWidth = backgroundWidth < MIN_DIMENSION ? DEFAULT_DIMENSION : backgroundWidth; + LogUtils.d(TAG, String.format("setBackgroundWidth: 背景宽度设置为 %d(输入值:%d)", this.backgroundWidth, backgroundWidth)); + } + + public int getBackgroundHeight() { + return backgroundHeight; + } + + public void setBackgroundHeight(int backgroundHeight) { + this.backgroundHeight = backgroundHeight < MIN_DIMENSION ? DEFAULT_DIMENSION : backgroundHeight; + LogUtils.d(TAG, String.format("setBackgroundHeight: 背景高度设置为 %d(输入值:%d)", this.backgroundHeight, backgroundHeight)); + } + + // --------------- 像素颜色相关 --------------- + public int getPixelColor() { + return pixelColor; + } + + public void setPixelColor(int pixelColor) { + this.pixelColor = pixelColor; + LogUtils.d(TAG, String.format("setPixelColor: 像素颜色设置为 0x%08X", pixelColor)); + } + + // ====================== 序列化/反序列化方法(适配重命名字段,兼容旧版本,补充调试日志) ====================== + @Override + public String getName() { + String className = BackgroundBean.class.getName(); + LogUtils.d(TAG, String.format("getName: 类名标识为 %s", className)); + return className; + } + + /** + * 序列化:同步重命名字段(原isUseScaledCompress → 新isUseBackgroundScaledCompressFile) + * 确保新字段能正常持久化,同时兼容旧版本JSON(保留旧字段写入,避免旧版本读取异常) + */ + @Override + public void writeThisToJsonWriter(JsonWriter jsonWriter) throws IOException { + super.writeThisToJsonWriter(jsonWriter); + BackgroundBean bean = this; + // 原图配置序列化 + jsonWriter.name("backgroundFileName").value(bean.getBackgroundFileName()); + jsonWriter.name("backgroundFilePath").value(bean.getBackgroundFilePath()); + jsonWriter.name("backgroundFileInfo").value(bean.getBackgroundFileInfo()); + // 控制字段序列化 + jsonWriter.name("isUseBackgroundFile").value(bean.isUseBackgroundFile()); + // 压缩图配置序列化 + jsonWriter.name("backgroundScaledCompressFileName").value(bean.getBackgroundScaledCompressFileName()); + jsonWriter.name("backgroundScaledCompressFilePath").value(bean.getBackgroundScaledCompressFilePath()); + // 关键:新字段序列化(核心) + jsonWriter.name("isUseBackgroundScaledCompressFile").value(bean.isUseBackgroundScaledCompressFile()); + // 兼容旧版本:保留旧字段名写入(避免旧版本Bean读取时缺失字段) + jsonWriter.name(OLD_FIELD_USE_SCALED_COMPRESS).value(bean.isUseBackgroundScaledCompressFile()); + // 裁剪配置与像素颜色序列化 + jsonWriter.name("backgroundWidth").value(bean.getBackgroundWidth()); + jsonWriter.name("backgroundHeight").value(bean.getBackgroundHeight()); + jsonWriter.name("pixelColor").value(bean.getPixelColor()); + LogUtils.d(TAG, "writeThisToJsonWriter: JSON 序列化完成,已兼容旧字段"); + } + + /** + * 反序列化:同步处理重命名字段(兼容旧版本JSON,新旧字段都能读取) + * 逻辑:优先读取新字段,若新字段不存在则读取旧字段(确保升级后旧配置仍有效) + */ + @Override + public BaseBean readBeanFromJsonReader(JsonReader jsonReader) throws IOException { + BackgroundBean bean = new BackgroundBean(); + jsonReader.beginObject(); + // 临时变量:存储旧字段值(用于兼容) + boolean tempUseScaledCompress = false; + while (jsonReader.hasNext()) { + String name = jsonReader.nextName(); + switch (name) { + case "backgroundFileName": + bean.setBackgroundFileName(jsonReader.nextString()); + break; + case "backgroundFilePath": + bean.setBackgroundFilePath(jsonReader.nextString()); + break; + case "backgroundFileInfo": + bean.setBackgroundFileInfo(jsonReader.nextString()); + break; + case "isUseBackgroundFile": + bean.setIsUseBackgroundFile(jsonReader.nextBoolean()); + break; + case "backgroundScaledCompressFileName": + bean.setBackgroundScaledCompressFileName(jsonReader.nextString()); + break; + case "backgroundScaledCompressFilePath": + bean.setBackgroundScaledCompressFilePath(jsonReader.nextString()); + break; + case "isUseBackgroundScaledCompressFile": + // 关键:读取新字段(优先) + bean.setIsUseBackgroundScaledCompressFile(jsonReader.nextBoolean()); + LogUtils.d(TAG, "readBeanFromJsonReader: 读取新字段 isUseBackgroundScaledCompressFile 完成"); + break; + case OLD_FIELD_USE_SCALED_COMPRESS: + // 兼容旧版本:读取旧字段(若新字段未读取,则用旧字段值) + tempUseScaledCompress = jsonReader.nextBoolean(); + LogUtils.d(TAG, "readBeanFromJsonReader: 读取旧字段 isUseScaledCompress 完成"); + break; + case "backgroundWidth": + bean.setBackgroundWidth(jsonReader.nextInt()); + break; + case "backgroundHeight": + bean.setBackgroundHeight(jsonReader.nextInt()); + break; + case "pixelColor": + bean.setPixelColor(jsonReader.nextInt()); + break; + default: + jsonReader.skipValue(); + LogUtils.w(TAG, String.format("readBeanFromJsonReader: 跳过未知字段 %s", name)); + break; + } + } + jsonReader.endObject(); + // 兼容逻辑:若新字段未被赋值(旧版本JSON无此字段),则用旧字段值填充 + if (!bean.isUseBackgroundScaledCompressFile()) { + bean.setIsUseBackgroundScaledCompressFile(tempUseScaledCompress); + LogUtils.d(TAG, "readBeanFromJsonReader: 旧字段值已填充到新字段"); + } + LogUtils.d(TAG, "readBeanFromJsonReader: JSON 反序列化完成"); + return bean; + } + + // ====================== 辅助方法(重置配置、配置校验,补充调试日志) ====================== + /** + * 重置背景配置(适配“取消背景”功能,同步重置重命名字段) + */ + public void resetBackgroundConfig() { + this.backgroundFileName = ""; + this.backgroundFilePath = ""; + this.backgroundScaledCompressFileName = ""; + this.backgroundScaledCompressFilePath = ""; + this.backgroundFileInfo = ""; + this.isUseBackgroundFile = false; + this.isUseBackgroundScaledCompressFile = false; + this.backgroundWidth = DEFAULT_DIMENSION; + this.backgroundHeight = DEFAULT_DIMENSION; + LogUtils.d(TAG, "resetBackgroundConfig: 背景配置已重置为默认值"); + } + + /** + * 检查背景配置是否有效(适配BackgroundSettingsActivity的预览/保存校验) + * 同步使用重命名字段判断压缩图是否启用 + * @return true-配置有效(可显示背景图),false-配置无效 + */ + public boolean isBackgroundConfigValid() { + // 启用背景图时,需确保:原图路径/文件名 或 压缩图路径/文件名 非空 + if (!isUseBackgroundFile) { + LogUtils.d(TAG, "isBackgroundConfigValid: 未启用背景图,配置无效"); + return false; + } + // 原图校验:路径非空 或 文件名非空 + boolean isOriginalValid = !backgroundFilePath.isEmpty() || !backgroundFileName.isEmpty(); + // 压缩图校验:启用压缩图时,路径/文件名需非空 + boolean isCompressValid = true; + if (isUseBackgroundScaledCompressFile()) { + isCompressValid = !backgroundScaledCompressFilePath.isEmpty() || !backgroundScaledCompressFileName.isEmpty(); + } + // 逻辑:启用压缩图则需压缩图有效;不启用压缩图则需原图有效 + boolean isValid = isUseBackgroundScaledCompressFile() ? isCompressValid : isOriginalValid; + LogUtils.d(TAG, String.format("isBackgroundConfigValid: 背景配置有效性为 %b(启用压缩图:%b,原图有效:%b,压缩图有效:%b)", + isValid, isUseBackgroundScaledCompressFile(), isOriginalValid, isCompressValid)); + return isValid; + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/models/BatteryData.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/models/BatteryData.java new file mode 100644 index 0000000..1cbd448 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/models/BatteryData.java @@ -0,0 +1,82 @@ +package cc.winboll.studio.powerbell.models; + +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author ZhanGSKen + * @Date 2025/03/22 14:30:51 + * @Describe 电池报告数据模型 + * 适配 API30,存储当前电量、放电时间、充电时间核心数据 + * 支持参数校验与调试日志输出 + */ +public class BatteryData { + // ====================== 静态常量(首屏可见,统一管理) ====================== + public static final String TAG = "BatteryData"; + // 字段校验常量(避免硬编码,统一管理) + private static final int BATTERY_MIN = 0; + private static final int BATTERY_MAX = 100; + private static final String EMPTY_TIME = "00:00:00"; + + // ====================== 成员变量(按功能分类:电量→时间) ====================== + private int currentLevel; // 当前电池电量(0-100) + private String dischargeTime; // 放电时间 + private String chargeTime; // 充电时间 + + // ====================== 构造方法(按参数重载排序,补充校验与日志) ====================== + /** + * 无参构造器(适配 JSON 反序列化、反射实例化场景) + */ + public BatteryData() { + this.currentLevel = BATTERY_MIN; + this.dischargeTime = EMPTY_TIME; + this.chargeTime = EMPTY_TIME; + LogUtils.d(TAG, "BatteryData: 无参构造初始化完成,默认值已设置"); + } + + /** + * 带参构造器(核心构造,初始化所有字段) + * @param currentLevel 当前电量(0-100) + * @param dischargeTime 放电时间 + * @param chargeTime 充电时间 + */ + public BatteryData(int currentLevel, String dischargeTime, String chargeTime) { + // 电量范围校验(0-100,异常值置为0) + this.currentLevel = currentLevel >= BATTERY_MIN && currentLevel <= BATTERY_MAX + ? currentLevel : BATTERY_MIN; + // 时间字段防 null(空值置为默认空时间) + this.dischargeTime = dischargeTime == null ? EMPTY_TIME : dischargeTime; + this.chargeTime = chargeTime == null ? EMPTY_TIME : chargeTime; + + // 调试日志:输出入参与最终赋值结果 + LogUtils.d(TAG, String.format("BatteryData: 带参构造初始化完成 | 当前电量:%d(输入:%d)| 放电时间:%s(输入:%s)| 充电时间:%s(输入:%s)", + this.currentLevel, currentLevel, + this.dischargeTime, dischargeTime, + this.chargeTime, chargeTime)); + } + + // ====================== Getter 方法(按成员变量顺序排列,补充日志可选) ====================== + /** + * 获取当前电池电量 + * @return 当前电量(0-100) + */ + public int getCurrentLevel() { + return currentLevel; + } + + /** + * 获取放电时间 + * @return 放电时间 + */ + public String getDischargeTime() { + return dischargeTime; + } + + /** + * 获取充电时间 + * @return 充电时间 + */ + public String getChargeTime() { + return chargeTime; + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/models/BatteryInfoBean.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/models/BatteryInfoBean.java new file mode 100644 index 0000000..0c2b17c --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/models/BatteryInfoBean.java @@ -0,0 +1,130 @@ +package cc.winboll.studio.powerbell.models; + +import android.util.JsonReader; +import android.util.JsonWriter; +import cc.winboll.studio.libappbase.BaseBean; +import cc.winboll.studio.libappbase.LogUtils; +import java.io.IOException; +import java.io.Serializable; + +/** + * @Author ZhanGSKen + * @Describe 电池信息数据模型 + * 适配 API30,存储电量时间戳与电量值,支持 JSON 序列化/反序列化 + * 修复字段拼写错误,补充数据校验与调试日志 + */ +public class BatteryInfoBean extends BaseBean implements Serializable { + // ====================== 静态常量(首屏可见,统一管理) ====================== + public static final String TAG = "BatteryInfoBean"; + // 字段校验常量(避免硬编码,统一管理) + private static final int BATTERY_MIN = 0; + private static final int BATTERY_MAX = 100; + private static final long DEFAULT_TIMESTAMP = 0L; + private static final int DEFAULT_BATTERY_VALUE = 0; + + // ====================== 成员变量(修复拼写错误:battetyValue → batteryValue) ====================== + private long timeStamp; // 记录电量的时间戳 + private int batteryValue; // 电量值(0-100) + + // ====================== 构造方法(按参数重载排序,补充校验与日志) ====================== + /** + * 无参构造器(JSON 反序列化、反射实例化必备) + */ + public BatteryInfoBean() { + this.timeStamp = DEFAULT_TIMESTAMP; + this.batteryValue = DEFAULT_BATTERY_VALUE; + LogUtils.d(TAG, "BatteryInfoBean: 无参构造初始化完成,默认时间戳:" + timeStamp + ",默认电量:" + batteryValue); + } + + /** + * 带参构造器(核心构造,初始化所有字段) + * @param timeStamp 电量记录时间戳 + * @param batteryValue 电量值(0-100) + */ + public BatteryInfoBean(long timeStamp, int batteryValue) { + this.timeStamp = timeStamp; + // 电量范围校验(0-100,异常值置为默认值) + this.batteryValue = batteryValue >= BATTERY_MIN && batteryValue <= BATTERY_MAX + ? batteryValue : DEFAULT_BATTERY_VALUE; + LogUtils.d(TAG, String.format("BatteryInfoBean: 带参构造初始化完成 | 时间戳:%d | 电量:%d(输入:%d)", + this.timeStamp, this.batteryValue, batteryValue)); + } + + // ====================== Setter/Getter 方法(按成员变量顺序排列,修复拼写错误,补充日志) ====================== + /** + * 设置电量记录时间戳 + * @param timeStamp 时间戳 + */ + public void setTimeStamp(long timeStamp) { + this.timeStamp = timeStamp; + LogUtils.d(TAG, "setTimeStamp: 时间戳设置为 " + timeStamp); + } + + public long getTimeStamp() { + return timeStamp; + } + + /** + * 设置电量值(修复拼写错误:battetyValue → batteryValue) + * @param batteryValue 电量值(0-100) + */ + public void setBatteryValue(int batteryValue) { + this.batteryValue = batteryValue >= BATTERY_MIN && batteryValue <= BATTERY_MAX + ? batteryValue : DEFAULT_BATTERY_VALUE; + LogUtils.d(TAG, String.format("setBatteryValue: 电量设置为 %d(输入:%d)", + this.batteryValue, batteryValue)); + } + + public int getBatteryValue() { + return batteryValue; + } + + // ====================== JSON 序列化/反序列化方法(修复字段拼写错误,补充调试日志) ====================== + @Override + public String getName() { + String className = BatteryInfoBean.class.getName(); + LogUtils.d(TAG, "getName: 类名标识为 " + className); + return className; + } + + @Override + public void writeThisToJsonWriter(JsonWriter jsonWriter) throws IOException { + super.writeThisToJsonWriter(jsonWriter); + BatteryInfoBean bean = this; + jsonWriter.name("timeStamp").value(bean.getTimeStamp()); + // 修复 JSON 字段名拼写错误:battetyValue → batteryValue + jsonWriter.name("batteryValue").value(bean.getBatteryValue()); + LogUtils.d(TAG, "writeThisToJsonWriter: JSON 序列化完成 | 时间戳:" + bean.getTimeStamp() + ",电量:" + bean.getBatteryValue()); + } + + @Override + public BaseBean readBeanFromJsonReader(JsonReader jsonReader) throws IOException { + BatteryInfoBean bean = new BatteryInfoBean(); + jsonReader.beginObject(); + while (jsonReader.hasNext()) { + String name = jsonReader.nextName(); + switch (name) { + case "timeStamp": + bean.setTimeStamp(jsonReader.nextLong()); + break; + case "batteryValue": + bean.setBatteryValue(jsonReader.nextInt()); + break; + // 兼容旧字段名(battetyValue),避免旧配置解析失败 + case "battetyValue": + int oldBatteryValue = jsonReader.nextInt(); + bean.setBatteryValue(oldBatteryValue); + LogUtils.w(TAG, "readBeanFromJsonReader: 读取旧字段 battetyValue,已兼容为 batteryValue,值:" + oldBatteryValue); + break; + default: + jsonReader.skipValue(); + LogUtils.w(TAG, "readBeanFromJsonReader: 跳过未知字段 " + name); + break; + } + } + jsonReader.endObject(); + LogUtils.d(TAG, "readBeanFromJsonReader: JSON 反序列化完成 | 时间戳:" + bean.getTimeStamp() + ",电量:" + bean.getBatteryValue()); + return bean; + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/models/BatteryStyle.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/models/BatteryStyle.java new file mode 100644 index 0000000..e0a7617 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/models/BatteryStyle.java @@ -0,0 +1,12 @@ +package cc.winboll.studio.powerbell.models; + +/** + * 电池绘制样式枚举 (单选选项) + * @Author 豆包&ZhanGSKen + */ +public enum BatteryStyle { + ENERGY_STYLE, // 能量样式 + ZEBRA_STYLE, // 条纹样式 + POINT_STYLE // 点阵样式 +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/models/ControlCenterServiceBean.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/models/ControlCenterServiceBean.java new file mode 100644 index 0000000..a499079 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/models/ControlCenterServiceBean.java @@ -0,0 +1,131 @@ +package cc.winboll.studio.powerbell.models; + +import android.os.Parcel; +import android.os.Parcelable; +import android.util.JsonReader; +import android.util.JsonWriter; +import java.io.IOException; +import java.io.Serializable; +import cc.winboll.studio.libappbase.BaseBean; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/12/17 15:55 + * @Describe 服务控制参数模型 + * 适配 API30,管理服务启用状态,支持 Serializable 持久化、Parcelable 组件传递、JSON 序列化解析 + */ +public class ControlCenterServiceBean extends BaseBean implements Parcelable, Serializable { + // ====================== 静态常量(置顶统一管理,避免魔法值) ====================== + //private static final long serialVersionUID = 1L; // Serializable 必备,保障反序列化兼容 + private static final String TAG = "ControlCenterServiceBean"; + private static final String JSON_FIELD_IS_ENABLE_SERVICE = "isEnableService"; // JSON 字段常量,避免硬编码 + + // ====================== 核心成员变量(私有封装,规范命名) ====================== + private boolean isEnableService = false; // 服务启用状态:true=启用,false=禁用 + + // ====================== Parcelable 静态创建器(必须 public static final,适配 API30 组件传递) ====================== + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public ControlCenterServiceBean createFromParcel(Parcel source) { + boolean isEnable = source.readByte() != 0; + ControlCenterServiceBean bean = new ControlCenterServiceBean(isEnable); + LogUtils.d(TAG, String.format("createFromParcel: 反序列化完成,isEnableService=%b", isEnable)); + return bean; + } + + @Override + public ControlCenterServiceBean[] newArray(int size) { + LogUtils.d(TAG, String.format("newArray: 创建数组,长度=%d", size)); + return new ControlCenterServiceBean[size]; + } + }; + + // ====================== 构造方法(无参+有参,满足不同初始化场景) ====================== + /** + * 无参构造(JSON解析、反射创建必备) + */ + public ControlCenterServiceBean() { + this.isEnableService = false; + LogUtils.d(TAG, "无参构造:初始化服务状态为禁用(false)"); + } + + /** + * 有参构造(指定服务启用状态) + * @param isEnableService 服务启用状态 + */ + public ControlCenterServiceBean(boolean isEnableService) { + this.isEnableService = isEnableService; + LogUtils.d(TAG, String.format("有参构造:初始化服务状态,isEnableService=%b", isEnableService)); + } + + // ====================== Getter/Setter 方法(封装成员变量,控制访问) ====================== + public boolean isEnableService() { + LogUtils.d(TAG, String.format("isEnableService: 当前状态=%b", isEnableService)); + return isEnableService; + } + + public void setIsEnableService(boolean isEnableService) { + LogUtils.d(TAG, String.format("setIsEnableService: 旧状态=%b,新状态=%b", this.isEnableService, isEnableService)); + this.isEnableService = isEnableService; + } + + // ====================== 父类 BaseBean 方法重写(核心业务逻辑:JSON 序列化/反序列化) ====================== + @Override + public String getName() { + String className = ControlCenterServiceBean.class.getName(); + LogUtils.d(TAG, String.format("getName: 返回类名=%s", className)); + return className; + } + + /** + * 序列化对象到 JSON(适配数据持久化/网络传输) + */ + @Override + public void writeThisToJsonWriter(JsonWriter jsonWriter) throws IOException { + super.writeThisToJsonWriter(jsonWriter); + jsonWriter.name(JSON_FIELD_IS_ENABLE_SERVICE).value(this.isEnableService); + LogUtils.d(TAG, String.format("writeThisToJsonWriter: 序列化完成,%s=%b", JSON_FIELD_IS_ENABLE_SERVICE, this.isEnableService)); + } + + /** + * 从 JSON 反序列化创建对象(适配数据恢复) + */ + @Override + public BaseBean readBeanFromJsonReader(JsonReader jsonReader) throws IOException { + ControlCenterServiceBean bean = new ControlCenterServiceBean(); + jsonReader.beginObject(); + while (jsonReader.hasNext()) { + String fieldName = jsonReader.nextName(); + if (JSON_FIELD_IS_ENABLE_SERVICE.equals(fieldName)) { + boolean isEnable = jsonReader.nextBoolean(); + bean.setIsEnableService(isEnable); + LogUtils.d(TAG, String.format("readBeanFromJsonReader: 读取字段,%s=%b", fieldName, isEnable)); + } else { + jsonReader.skipValue(); + LogUtils.w(TAG, String.format("readBeanFromJsonReader: 跳过未知字段=%s", fieldName)); + } + } + jsonReader.endObject(); + LogUtils.d(TAG, "readBeanFromJsonReader: 反序列化完成"); + return bean; + } + + // ====================== Parcelable 接口方法实现(适配 Intent 组件间传递,Java7 适配) ====================== + @Override + public int describeContents() { + LogUtils.d(TAG, "describeContents: 返回内容描述符=0"); + return 0; // 无特殊内容(如文件描述符),返回0即可(API30 标准实现) + } + + /** + * 序列化对象到 Parcel(Intent 传递必备,Java7 适配:用 byte 存储 boolean) + */ + @Override + public void writeToParcel(Parcel dest, int flags) { + byte flag = (byte) (this.isEnableService ? 1 : 0); + dest.writeByte(flag); + LogUtils.d(TAG, String.format("writeToParcel: 序列化完成,isEnableService=%b(存储为byte=%d)", this.isEnableService, flag)); + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/models/NotificationMessage.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/models/NotificationMessage.java new file mode 100644 index 0000000..fcd8436 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/models/NotificationMessage.java @@ -0,0 +1,75 @@ +package cc.winboll.studio.powerbell.models; + +import cc.winboll.studio.libappbase.LogUtils; + +/** + * 通知数据模型 + * 适配 API30,统一存储通知标题、内容、标识信息,支持各组件数据传递 + * @Author ZhanGSKen + * @Describe 通知数据模型:统一存储通知标题、内容等信息,适配各组件数据传递 + */ +public class NotificationMessage { + // ====================== 静态常量(统一管理) ====================== + private static final String TAG = "NotificationMessage"; + private static final String EMPTY_STRING = ""; + + // ====================== 核心成员变量(按业务逻辑排序) ====================== + private String title; // 通知标题 + private String content; // 通知内容 + private String remindMSG; // 通知标识(区分服务运行/充电/耗电) + + // ====================== 构造方法(无参+全参,满足不同初始化场景) ====================== + /** + * 无参构造器(反射实例化、JSON反序列化必备) + */ + public NotificationMessage() { + this.title = EMPTY_STRING; + this.content = EMPTY_STRING; + this.remindMSG = EMPTY_STRING; + LogUtils.d(TAG, "无参构造:初始化通知数据模型,默认值为空字符串"); + } + + /** + * 全参构造器(直接传参创建实例,简化调用) + * @param title 通知标题 + * @param content 通知内容 + * @param remindMSG 通知标识 + */ + public NotificationMessage(String title, String content, String remindMSG) { + this.title = title == null ? EMPTY_STRING : title; + this.content = content == null ? EMPTY_STRING : content; + this.remindMSG = remindMSG == null ? EMPTY_STRING : remindMSG; + LogUtils.d(TAG, String.format("全参构造:初始化完成 | 标题:%s | 内容:%s | 标识:%s", + this.title, this.content, this.remindMSG)); + } + + // ====================== Setter 方法(补充空值防护与调试日志) ====================== + public void setTitle(String title) { + this.title = title == null ? EMPTY_STRING : title; + LogUtils.d(TAG, String.format("setTitle:通知标题设置为「%s」", this.title)); + } + + public void setContent(String content) { + this.content = content == null ? EMPTY_STRING : content; + LogUtils.d(TAG, String.format("setContent:通知内容设置为「%s」", this.content)); + } + + public void setRemindMSG(String remindMSG) { + this.remindMSG = remindMSG == null ? EMPTY_STRING : remindMSG; + LogUtils.d(TAG, String.format("setRemindMSG:通知标识设置为「%s」", this.remindMSG)); + } + + // ====================== Getter 方法(按成员变量顺序排列) ====================== + public String getTitle() { + return title; + } + + public String getContent() { + return content; + } + + public String getRemindMSG() { + return remindMSG; + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/models/TTSSpeakTextBean.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/models/TTSSpeakTextBean.java new file mode 100644 index 0000000..d9370fc --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/models/TTSSpeakTextBean.java @@ -0,0 +1,47 @@ +package cc.winboll.studio.powerbell.models; + +import cc.winboll.studio.libappbase.LogUtils; +import java.io.Serializable; + +/** + * TTS 语音播放文本内容实体类 + * 适配:Java7 语法规范 | Android API30 系统版本 + * 特性:实现序列化接口,支持跨页面/进程传递,属性默认值初始化 + * @Author 豆包&ZhanGSKen + * @Date 2025/12/29 19:13 + */ +public class TTSSpeakTextBean implements Serializable { + + // ====================================== 常量区 - 置顶排序 ====================================== + /** 日志TAG 瞬态修饰,不参与序列化,减少序列化体积 */ + transient public static final String TAG = "TTSSpeakTextBean"; + + // ====================================== 成员属性区 - 业务属性排序 ====================================== + /** 延迟播放时长 单位:毫秒,默认值0:无延迟播放 */ + public int mnDelay = 0; + /** TTS语音播放文本内容,默认值空字符串:防止空指针 */ + public String mszSpeakContent = ""; + + // ====================================== 构造方法区 - 无参+有参 完整实现 ====================================== + /** + * 无参构造方法 + * Java7序列化规范必备 + 兼容反射实例化场景 + */ + public TTSSpeakTextBean() { + LogUtils.d(TAG, "【无参构造】TTSSpeakTextBean 实例化,使用默认值 | 延迟:" + mnDelay + " | 文本:" + mszSpeakContent); + } + + /** + * 有参构造方法【主构造】 + * @param nDelay 延迟播放时长(ms) + * @param szSpeakContent 语音播放文本内容 + */ + public TTSSpeakTextBean(int nDelay, String szSpeakContent) { + LogUtils.d(TAG, "【有参构造】TTSSpeakTextBean 实例化,入参 | 延迟:" + nDelay + " | 文本:" + szSpeakContent); + this.mnDelay = nDelay; + this.mszSpeakContent = szSpeakContent; + LogUtils.d(TAG, "【有参构造】赋值完成 | 最终延迟:" + this.mnDelay + " | 最终文本:" + this.mszSpeakContent); + } + +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/models/ThoughtfulServiceBean.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/models/ThoughtfulServiceBean.java new file mode 100644 index 0000000..7332325 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/models/ThoughtfulServiceBean.java @@ -0,0 +1,156 @@ +package cc.winboll.studio.powerbell.models; + +import android.os.Parcel; +import android.os.Parcelable; +import android.util.JsonReader; +import android.util.JsonWriter; + +import java.io.IOException; +import java.io.Serializable; + +import cc.winboll.studio.libappbase.BaseBean; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author 豆包&ZhanGSKen + * @Date 2025/12/29 20:59 + * @Describe 贴心服务配置实体类 (适配API30 / Java7) + */ +public class ThoughtfulServiceBean extends BaseBean implements Parcelable, Serializable { + + // ====================== 常量区 - 置顶统一管理 ====================== + public static final String TAG = ThoughtfulServiceBean.class.getSimpleName(); + private static final long serialVersionUID = 1L; // Serializable 序列化兼容必备 + // JSON序列化字段常量 杜绝硬编码 + public static final String JSON_FIELD_IS_ENABLE_CHARGE_TTS = "isEnableChargeTts"; + public static final String JSON_FIELD_IS_ENABLE_USE_POWER_TTS = "isEnableUsePowerTts"; + + // ====================== 核心成员变量 - 私有封装 ====================== + private boolean isEnableChargeTts = false; // 是否启用 充电TTS贴心语音服务 + private boolean isEnableUsePowerTts = false; // 是否启用 用电TTS贴心语音服务 + + // ====================== Parcelable 静态创建器 (API30标准写法 必须public static final) ====================== + public static final Creator CREATOR = new Creator() { + @Override + public ThoughtfulServiceBean createFromParcel(Parcel source) { + return new ThoughtfulServiceBean(source); + } + + @Override + public ThoughtfulServiceBean[] newArray(int size) { + LogUtils.d(TAG, "newArray: 初始化数组,size = " + size); + return new ThoughtfulServiceBean[size]; + } + }; + + // ====================== 构造方法区 (无参+有参+Parcel构造 全覆盖) ====================== + /** + * 无参构造 - JSON解析/反射实例化 必备 + */ + public ThoughtfulServiceBean() { + LogUtils.d(TAG, "ThoughtfulServiceBean: 无参构造,初始化默认禁用所有TTS服务"); + } + + /** + * 全参构造 - 手动配置所有服务状态 + * @param isEnableChargeTts 充电TTS服务开关 + * @param isEnableUsePowerTts 用电TTS服务开关 + */ + public ThoughtfulServiceBean(boolean isEnableChargeTts, boolean isEnableUsePowerTts) { + this.isEnableChargeTts = isEnableChargeTts; + this.isEnableUsePowerTts = isEnableUsePowerTts; + LogUtils.d(TAG, "ThoughtfulServiceBean: 全参构造 | isEnableChargeTts=" + isEnableChargeTts + " | isEnableUsePowerTts=" + isEnableUsePowerTts); + } + + /** + * Parcel反序列化构造 - Parcelable必备 私有私有化 + */ + private ThoughtfulServiceBean(Parcel in) { + this.isEnableChargeTts = in.readByte() != 0; + this.isEnableUsePowerTts = in.readByte() != 0; + LogUtils.d(TAG, "ThoughtfulServiceBean: Parcel构造解析完成 | isEnableChargeTts=" + isEnableChargeTts + " | isEnableUsePowerTts=" + isEnableUsePowerTts); + } + + // ====================== Getter/Setter 方法区 (封装成员变量 统一访问) ====================== + public boolean isEnableChargeTts() { + return isEnableChargeTts; + } + + public void setIsEnableChargeTts(boolean isEnableChargeTts) { + LogUtils.d(TAG, "setIsEnableChargeTts: 旧值=" + this.isEnableChargeTts + " | 新值=" + isEnableChargeTts); + this.isEnableChargeTts = isEnableChargeTts; + } + + public boolean isEnableUsePowerTts() { + return isEnableUsePowerTts; + } + + public void setIsEnableUsePowerTts(boolean isEnableUsePowerTts) { + LogUtils.d(TAG, "setIsEnableUsePowerTts: 旧值=" + this.isEnableUsePowerTts + " | 新值=" + isEnableUsePowerTts); + this.isEnableUsePowerTts = isEnableUsePowerTts; + } + + // ====================== 重写父类 BaseBean 核心方法 (JSON序列化/反序列化 业务核心) ====================== + @Override + public String getName() { + String className = ThoughtfulServiceBean.class.getName(); + LogUtils.d(TAG, "getName: 返回当前实体类名 = " + className); + return className; + } + + /** + * JSON序列化 - 写入所有字段 适配持久化/网络传输 + */ + @Override + public void writeThisToJsonWriter(JsonWriter jsonWriter) throws IOException { + super.writeThisToJsonWriter(jsonWriter); + jsonWriter.name(JSON_FIELD_IS_ENABLE_CHARGE_TTS).value(this.isEnableChargeTts); + jsonWriter.name(JSON_FIELD_IS_ENABLE_USE_POWER_TTS).value(this.isEnableUsePowerTts); + LogUtils.d(TAG, "writeThisToJsonWriter: JSON序列化完成,所有TTS服务状态已写入"); + } + + /** + * JSON反序列化 - 读取字段生成实体 适配数据恢复 + */ + @Override + public BaseBean readBeanFromJsonReader(JsonReader jsonReader) throws IOException { + ThoughtfulServiceBean bean = new ThoughtfulServiceBean(); + jsonReader.beginObject(); + while (jsonReader.hasNext()) { + String fieldName = jsonReader.nextName(); + switch (fieldName) { + case JSON_FIELD_IS_ENABLE_CHARGE_TTS: + bean.setIsEnableChargeTts(jsonReader.nextBoolean()); + break; + case JSON_FIELD_IS_ENABLE_USE_POWER_TTS: + bean.setIsEnableUsePowerTts(jsonReader.nextBoolean()); + break; + default: + jsonReader.skipValue(); + LogUtils.w(TAG, "readBeanFromJsonReader: 跳过未知JSON字段 = " + fieldName); + break; + } + } + jsonReader.endObject(); + LogUtils.d(TAG, "readBeanFromJsonReader: JSON反序列化完成,生成实体对象"); + return bean; + } + + // ====================== 实现 Parcelable 接口方法 (组件间Intent传递必备 API30/Java7完美适配) ====================== + @Override + public int describeContents() { + return 0; // 无文件描述符等特殊内容,固定返回0即可 + } + + /** + * Parcel序列化 - boolean用byte存储(Java7/API30标准写法 避免兼容性问题) + */ + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeByte((byte) (isEnableChargeTts ? 1 : 0)); + dest.writeByte((byte) (isEnableUsePowerTts ? 1 : 0)); + LogUtils.d(TAG, "writeToParcel: Parcel序列化完成,所有TTS服务状态已写入"); + } + +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/receivers/ControlCenterServiceReceiver.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/receivers/ControlCenterServiceReceiver.java new file mode 100644 index 0000000..468d317 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/receivers/ControlCenterServiceReceiver.java @@ -0,0 +1,275 @@ +package cc.winboll.studio.powerbell.receivers; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.App; +import cc.winboll.studio.powerbell.models.AppConfigBean; +import cc.winboll.studio.powerbell.models.NotificationMessage; +import cc.winboll.studio.powerbell.services.ControlCenterService; +import cc.winboll.studio.powerbell.utils.AppConfigUtils; +import cc.winboll.studio.powerbell.utils.BatteryUtils; +import cc.winboll.studio.powerbell.utils.NotificationManagerUtils; +import java.lang.ref.WeakReference; +import cc.winboll.studio.powerbell.services.ThoughtfulService; + +/** + * 控制中心广播接收器 + * 功能:监听电池状态变化、前台通知更新、配置变更指令 + * 适配:Java7 | API30 | 内存泄漏防护 | 多线程状态同步 + * @Author 豆包&ZhanGSKen + * @Date 2025/12/19 20:23 + * @Describe 统一处理系统与应用内广播,同步电池状态与配置,保障多线程数据一致性 + */ +public class ControlCenterServiceReceiver extends BroadcastReceiver { + // ====================== 静态常量区(置顶归类,消除魔法值) ====================== + public static final String TAG = "ControlCenterServiceReceiver"; + + // 广播Action常量(带包名前缀防冲突) + public static final String ACTION_UPDATE_FOREGROUND_NOTIFICATION = "cc.winboll.studio.powerbell.action.ACTION_UPDATE_FOREGROUND_NOTIFICATION"; + public static final String ACTION_APPCONFIG_CHANGED = "cc.winboll.studio.powerbell.action.ACTION_APPCONFIG_CHANGED"; + public static final String EXTRA_APP_CONFIG_BEAN = "extra_app_config_bean"; + + // 广播优先级与电量范围常量 + private static final int BROADCAST_PRIORITY = IntentFilter.SYSTEM_HIGH_PRIORITY - 10; + private static final int BATTERY_LEVEL_MIN = 0; + private static final int BATTERY_LEVEL_MAX = 100; + private static final int INVALID_BATTERY = -1; // 无效电量标识 + + // ====================== 静态状态标记(volatile保证多线程可见性) ====================== + private static volatile int sLastBatteryLevel = INVALID_BATTERY; // 上次电量(多线程可见) + private static volatile boolean sIsCharging = false; // 上次充电状态(多线程可见) + + // ====================== 成员变量区(弱引用防泄漏,按功能分层) ====================== + private WeakReference mwrControlCenterService; + private boolean isRegistered = false; // 标记广播注册状态,避免冗余操作 + + // ====================== 构造方法(初始化弱引用,避免服务强引用泄漏) ====================== + public ControlCenterServiceReceiver(ControlCenterService service) { + LogUtils.d(TAG, String.format("ControlCenterServiceReceiver() 构造 | 服务实例:%s", + service != null ? service.getClass().getSimpleName() : "null")); + this.mwrControlCenterService = new WeakReference(service); + } + + // ====================== 广播核心接收逻辑(入口方法,分Action分发处理) ====================== + @Override + public void onReceive(Context context, Intent intent) { + String action = intent != null ? intent.getAction() : "null"; + LogUtils.d(TAG, String.format("onReceive() 执行 | 接收广播 Action:%s", action)); + + // 基础参数校验 + if (context == null || intent == null || action == null) { + LogUtils.e(TAG, "onReceive() 终止 | 参数无效(context=" + context + " | intent=" + intent + ")"); + return; + } + + // 弱引用获取服务,双重校验服务有效性 + ControlCenterService service = mwrControlCenterService != null ? mwrControlCenterService.get() : null; + if (service == null || service.isDestroyed()) { + LogUtils.e(TAG, "onReceive() 终止 | 服务已销毁或为空,执行注销"); + unregisterAction(context); + return; + } + + // 分Action处理业务逻辑 + switch (action) { + case Intent.ACTION_BATTERY_CHANGED: + handleBatteryStateChanged(service, intent); + break; + case ACTION_UPDATE_FOREGROUND_NOTIFICATION: + handleUpdateForegroundNotification(service); + break; + case ACTION_APPCONFIG_CHANGED: + LogUtils.d(TAG, "onReceive() 分发 | 处理配置更新广播"); + handleNotifyAppConfigUpdate(service); + break; + default: + LogUtils.w(TAG, String.format("onReceive() 警告 | 未知Action=%s", action)); + } + + LogUtils.d(TAG, "onReceive() 完成 | 广播处理结束"); + } + + // ====================== 业务处理方法(按功能拆分,强化容错与日志) ====================== + /** + * 处理电池状态变化广播 + * @param service 控制中心服务实例 + * @param intent 电池状态广播意图 + */ + private void handleBatteryStateChanged(ControlCenterService service, Intent intent) { + LogUtils.d(TAG, "handleBatteryStateChanged() 执行 | 解析电池状态"); + try { + // 1. 解析并校验当前电池状态 + boolean currentCharging = BatteryUtils.isCharging(intent); + int currentBatteryLevel = BatteryUtils.getCurrentBatteryLevel(intent); + currentBatteryLevel = Math.min(Math.max(currentBatteryLevel, BATTERY_LEVEL_MIN), BATTERY_LEVEL_MAX); + LogUtils.d(TAG, String.format("handleBatteryStateChanged() 解析 | 充电=%b | 电量=%d%%", currentCharging, currentBatteryLevel)); + + // 2. 状态无变化则跳过,减少无效运算 + if (currentCharging == sIsCharging && currentBatteryLevel == sLastBatteryLevel) { + LogUtils.d(TAG, "handleBatteryStateChanged() 跳过 | 电池状态无变化"); + return; + } + + // 在插拔充电线时,执行贴心服务 + if(currentCharging != sIsCharging && sLastBatteryLevel != INVALID_BATTERY) { + //App.notifyMessage(TAG, String.format("sLastBatteryLevel %d", sLastBatteryLevel)); + if(currentCharging) { + ThoughtfulService.startServiceWithType(service, ThoughtfulService.ServiceType.CHARGE_STATE); + } else { + ThoughtfulService.startServiceWithType(service, ThoughtfulService.ServiceType.DISCHARGE_STATE); + } + } + + // 3. 更新静态缓存状态,保证多线程可见 + sIsCharging = currentCharging; + sLastBatteryLevel = currentBatteryLevel; + + // 4. 同步缓存状态到配置 + handleNotifyAppConfigUpdate(service); + + LogUtils.d(TAG, String.format("handleBatteryStateChanged() 完成 | 缓存电量=%d%% | 缓存充电状态=%b", + sLastBatteryLevel, sIsCharging)); + } catch (Exception e) { + LogUtils.e(TAG, "handleBatteryStateChanged() 失败", e); + } + } + + /** + * 处理配置变更通知,同步缓存状态到配置 + * @param service 控制中心服务实例 + */ + private void handleNotifyAppConfigUpdate(ControlCenterService service) { + LogUtils.d(TAG, "handleNotifyAppConfigUpdate() 执行 | 同步缓存状态到配置"); + try { + // 加载最新配置 + AppConfigBean latestConfig = AppConfigUtils.getInstance(service).loadAppConfig(); + if (latestConfig == null) { + LogUtils.e(TAG, "handleNotifyAppConfigUpdate() 终止 | 最新配置为空"); + return; + } + LogUtils.d(TAG, String.format("handleNotifyAppConfigUpdate() 加载 | 充电阈值=%d | 耗电阈值=%d", + latestConfig.getChargeReminderValue(), latestConfig.getUsageReminderValue())); + + // 同步缓存的电池状态到配置 + App.sQuantityOfElectricity = sLastBatteryLevel; + latestConfig.setIsCharging(sIsCharging); + service.notifyAppConfigUpdate(latestConfig); + + LogUtils.d(TAG, String.format("handleNotifyAppConfigUpdate() 完成 | 缓存电量=%d%% | 充电状态=%b", + sLastBatteryLevel, sIsCharging)); + } catch (Exception e) { + LogUtils.e(TAG, "handleNotifyAppConfigUpdate() 失败", e); + } + } + + /** + * 处理前台服务通知更新 + * @param service 控制中心服务实例 + */ + private void handleUpdateForegroundNotification(ControlCenterService service) { + LogUtils.d(TAG, "handleUpdateForegroundNotification() 执行 | 更新前台通知"); + try { + NotificationManagerUtils notifyUtils = service.getNotificationManager(); + NotificationMessage notifyMsg = service.getForegroundNotifyMsg(); + + // 非空校验,避免空指针 + if (notifyUtils == null || notifyMsg == null) { + LogUtils.e(TAG, String.format("handleUpdateForegroundNotification() 终止 | 通知工具类或消息为空(notifyUtils=%s | notifyMsg=%s)", + notifyUtils, notifyMsg)); + return; + } + + notifyUtils.updateForegroundServiceNotify(notifyMsg); + LogUtils.d(TAG, String.format("handleUpdateForegroundNotification() 完成 | 标题=%s", notifyMsg.getTitle())); + } catch (Exception e) { + LogUtils.e(TAG, "handleUpdateForegroundNotification() 失败", e); + } + } + + // ====================== 广播注册/注销(强化容错,避免重复操作) ====================== + /** + * 注册广播接收器 + * @param context 上下文 + */ + public void registerAction(Context context) { + LogUtils.d(TAG, "registerAction() 执行 | 注册广播接收器"); + if (context == null || isRegistered) { + LogUtils.e(TAG, "registerAction() 失败 | 上下文为空或已注册"); + return; + } + + try { + IntentFilter filter = new IntentFilter(); + filter.addAction(Intent.ACTION_BATTERY_CHANGED); + filter.addAction(ACTION_UPDATE_FOREGROUND_NOTIFICATION); + filter.addAction(ACTION_APPCONFIG_CHANGED); + filter.setPriority(BROADCAST_PRIORITY); + + context.registerReceiver(this, filter); + isRegistered = true; + LogUtils.d(TAG, String.format("registerAction() 完成 | 优先级=%d", BROADCAST_PRIORITY)); + } catch (Exception e) { + LogUtils.e(TAG, "registerAction() 失败", e); + } + } + + /** + * 注销广播接收器 + * @param context 上下文 + */ + public void unregisterAction(Context context) { + LogUtils.d(TAG, "unregisterAction() 执行 | 注销广播接收器"); + if (context == null || !isRegistered) { + LogUtils.e(TAG, "unregisterAction() 失败 | 上下文为空或未注册"); + return; + } + + try { + context.unregisterReceiver(this); + isRegistered = false; + LogUtils.d(TAG, "unregisterAction() 完成 | 广播注销成功"); + } catch (IllegalArgumentException e) { + LogUtils.w(TAG, "unregisterAction() 警告 | 广播未注册,跳过注销"); + } catch (Exception e) { + LogUtils.e(TAG, "unregisterAction() 失败", e); + } + } + + // ====================== 资源释放与Getter方法(按需开放,防泄漏) ====================== + /** + * 主动释放资源,避免内存泄漏 + */ + public void release() { + LogUtils.d(TAG, "release() 执行 | 释放广播接收器资源"); + // 清空弱引用,帮助GC回收 + if (mwrControlCenterService != null) { + mwrControlCenterService.clear(); + mwrControlCenterService = null; + LogUtils.d(TAG, "release() 步骤 | 弱引用已清空"); + } + // 重置静态状态缓存 + sLastBatteryLevel = -1; + sIsCharging = false; + LogUtils.d(TAG, "release() 完成 | 静态状态缓存已重置"); + } + + /** + * 获取上次记录的电池电量 + * @return 电量值(0-100),未初始化返回-1 + */ + public static int getLastBatteryLevel() { + return sLastBatteryLevel; + } + + /** + * 获取上次记录的充电状态 + * @return true=充电中,false=未充电 + */ + public static boolean isLastCharging() { + return sIsCharging; + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/receivers/GlobalApplicationReceiver.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/receivers/GlobalApplicationReceiver.java new file mode 100644 index 0000000..7197476 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/receivers/GlobalApplicationReceiver.java @@ -0,0 +1,180 @@ +package cc.winboll.studio.powerbell.receivers; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.App; +import cc.winboll.studio.powerbell.MainActivity; +import cc.winboll.studio.powerbell.utils.AppConfigUtils; +import cc.winboll.studio.powerbell.utils.BatteryUtils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/12/19 20:13 + * @Describe 全局应用广播接收器 + * 功能:监听系统电池状态变化,同步状态到配置工具类,通知页面更新 + * 适配:Java7 | API30 | 内存泄漏防护 + */ +public class GlobalApplicationReceiver extends BroadcastReceiver { + // ====================== 静态常量区(置顶归类,消除魔法值) ====================== + public static final String TAG = "GlobalApplicationReceiver"; + private static final int BATTERY_LEVEL_MIN = 0; + private static final int BATTERY_LEVEL_MAX = 100; + + // ====================== 静态状态标记(volatile保证多线程可见性) ====================== + private static volatile int sLastBatteryLevel = -1; // 历史电量(0-100) + private static volatile boolean sLastIsCharging = false; // 历史充电状态 + + // ====================== 成员变量区(按功能分层,移除冗余的mCurrentReceiver) ====================== + private App mGlobalApplication; + private AppConfigUtils mAppConfigUtils; + + // ====================== 构造方法(强化参数校验,初始化核心依赖) ====================== + public GlobalApplicationReceiver(App globalApplication) { + LogUtils.d(TAG, String.format("构造接收器 | App实例:%s", globalApplication)); + if (globalApplication == null) { + LogUtils.e(TAG, "构造失败:App实例为空"); + throw new IllegalArgumentException("App cannot be null"); + } + this.mGlobalApplication = globalApplication; + this.mAppConfigUtils = App.getAppConfigUtils(mGlobalApplication); + LogUtils.d(TAG, String.format("构造完成 | AppConfigUtils:%s", mAppConfigUtils)); + } + + // ====================== 广播核心接收逻辑(入口方法,过滤电池状态广播) ====================== + @Override + public void onReceive(Context context, Intent intent) { + String action = intent != null ? intent.getAction() : "null"; + LogUtils.d(TAG, String.format("onReceive: 接收广播 | 上下文:%s | Action:%s", context, action)); + + // 基础参数校验 + if (context == null || intent == null || action == null) { + LogUtils.e(TAG, "onReceive: 参数无效,终止处理"); + return; + } + + // 仅处理电池状态变化广播 + if (Intent.ACTION_BATTERY_CHANGED.equals(action)) { + handleBatteryStateChanged(context, intent); + } + + LogUtils.d(TAG, "onReceive: 广播处理完成"); + } + + // ====================== 业务逻辑方法(处理电池状态变化,同步配置+通知页面) ====================== + /** + * 处理电池状态变化广播 + * @param context 上下文 + * @param intent 电池状态广播意图 + */ + private void handleBatteryStateChanged(Context context, Intent intent) { + LogUtils.d(TAG, "handleBatteryStateChanged: 解析电池状态"); + try { + // 1. 解析当前电池状态(复用工具类,二次校验电量范围) + boolean currentIsCharging = BatteryUtils.isCharging(intent); + int currentBatteryLevel = BatteryUtils.getCurrentBatteryLevel(intent); + currentBatteryLevel = Math.min(Math.max(currentBatteryLevel, BATTERY_LEVEL_MIN), BATTERY_LEVEL_MAX); + LogUtils.d(TAG, String.format("handleBatteryStateChanged: 当前状态 | 充电=%b | 电量=%d%%", currentIsCharging, currentBatteryLevel)); + + // 2. 状态无变化则跳过,减少无效运算 + if (currentIsCharging == sLastIsCharging && currentBatteryLevel == sLastBatteryLevel) { + LogUtils.d(TAG, "handleBatteryStateChanged: 状态无变化,跳过处理"); + return; + } + + // 3. 同步最新状态到配置工具类 + if (mAppConfigUtils != null) { + if (currentIsCharging != sLastIsCharging) { + mAppConfigUtils.setCharging(currentIsCharging); + LogUtils.d(TAG, String.format("handleBatteryStateChanged: 同步充电状态 | %b", currentIsCharging)); + } + if (currentBatteryLevel != sLastBatteryLevel) { + mAppConfigUtils.setCurrentBatteryValue(currentBatteryLevel); + LogUtils.d(TAG, String.format("handleBatteryStateChanged: 同步电量 | %d%%", currentBatteryLevel)); + } + } else { + LogUtils.e(TAG, "handleBatteryStateChanged: AppConfigUtils为空,同步失败"); + } + + // 4. 执行状态变化后的业务逻辑 + // 记录电量变化时间 + if (App.getAppCacheUtils(context) != null) { + App.getAppCacheUtils(context).addChangingTime(currentBatteryLevel); + LogUtils.d(TAG, "handleBatteryStateChanged: 记录电量变化时间"); + } + // 通知MainActivity更新电量 + MainActivity.sendCurrentBatteryValueMessage(currentBatteryLevel); + LogUtils.d(TAG, String.format("handleBatteryStateChanged: 发送电量更新消息到MainActivity | %d%%", currentBatteryLevel)); + + // 5. 更新历史状态缓存 + sLastIsCharging = currentIsCharging; + sLastBatteryLevel = currentBatteryLevel; + LogUtils.d(TAG, "handleBatteryStateChanged: 更新历史状态完成"); + } catch (Exception e) { + LogUtils.e(TAG, "handleBatteryStateChanged: 处理失败", e); + } + } + + // ====================== 广播注册/注销(强化容错,避免重复操作) ====================== + /** + * 注册广播接收器 + */ + public void registerAction() { + LogUtils.d(TAG, "registerAction: 注册广播"); + if (mGlobalApplication == null) { + LogUtils.e(TAG, "注册失败:App实例为空"); + return; + } + + try { + // 先注销再注册,避免重复注册异常 + unregisterAction(); + IntentFilter filter = new IntentFilter(); + filter.addAction(Intent.ACTION_BATTERY_CHANGED); + mGlobalApplication.registerReceiver(this, filter); + LogUtils.d(TAG, "registerAction: 广播注册成功"); + } catch (Exception e) { + LogUtils.e(TAG, "registerAction: 注册失败", e); + } + } + + /** + * 注销广播接收器 + */ + public void unregisterAction() { + LogUtils.d(TAG, "unregisterAction: 注销广播"); + if (mGlobalApplication == null) { + LogUtils.e(TAG, "注销失败:App实例为空"); + return; + } + + try { + mGlobalApplication.unregisterReceiver(this); + LogUtils.d(TAG, "unregisterAction: 广播注销成功"); + } catch (IllegalArgumentException e) { + LogUtils.w(TAG, "unregisterAction: 广播未注册,跳过注销"); + } catch (Exception e) { + LogUtils.e(TAG, "unregisterAction: 注销失败", e); + } + } + + // ====================== 资源释放方法(主动释放,彻底避免内存泄漏) ====================== + /** + * 释放接收器资源,供App销毁时调用 + */ + public void release() { + LogUtils.d(TAG, "release: 释放接收器资源"); + // 注销广播 + unregisterAction(); + // 置空引用,帮助GC回收 + mGlobalApplication = null; + mAppConfigUtils = null; + // 重置静态状态缓存 + sLastBatteryLevel = -1; + sLastIsCharging = false; + LogUtils.d(TAG, "release: 资源释放完成"); + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/receivers/MainReceiver.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/receivers/MainReceiver.java new file mode 100644 index 0000000..968c979 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/receivers/MainReceiver.java @@ -0,0 +1,92 @@ +package cc.winboll.studio.powerbell.receivers; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.App; +import cc.winboll.studio.powerbell.services.ControlCenterService; +import cc.winboll.studio.powerbell.utils.ServiceUtils; + +/** + * @Author ZhanGSKen + * @Date 2024/06/06 15:01:39 + * @Describe 应用核心广播接收器 + * 功能:监听开机完成广播,实现服务开机自启 + * 适配:Java7 | API30 | 服务启动兼容性处理 + */ +public class MainReceiver extends BroadcastReceiver { + // ====================== 静态常量区(置顶归类,消除魔法值) ====================== + public static final String TAG = "MainReceiver"; + // 系统广播Action常量 + private static final String ACTION_BOOT_COMPLETED = "android.intent.action.BOOT_COMPLETED"; + // API版本常量(适配前台服务启动要求) + private static final int API_LEVEL_26 = 26; + + // ====================== 静态状态标记(volatile保证多线程可见性) ====================== + // 历史电量值,用于校验电量变化(暂未使用,保留扩展能力) + private static volatile int sLastBatteryLevel = -1; + + // ====================== 广播核心接收逻辑(入口方法,分Action处理) ====================== + @Override + public void onReceive(Context context, Intent intent) { + // 基础参数校验 + if (context == null || intent == null) { + LogUtils.e(TAG, "onReceive: 上下文或意图为空,终止处理"); + return; + } + + String action = intent.getAction(); + LogUtils.d(TAG, String.format("onReceive: 接收广播 | Action:%s", action)); + + // 仅处理开机完成广播 + if (ACTION_BOOT_COMPLETED.equals(action)) { + handleBootCompleted(context); + } else { + LogUtils.w(TAG, String.format("onReceive: 忽略未知Action:%s", action)); + } + } + + // ====================== 业务处理方法(处理开机完成广播,实现服务自启) ====================== + /** + * 处理开机完成广播,自动启动控制中心服务 + * @param context 上下文 + */ + private void handleBootCompleted(Context context) { + LogUtils.d(TAG, "handleBootCompleted: 开始处理开机完成广播"); + try { + // 1. 校验服务启用状态 + boolean isServiceEnabled = App.getAppConfigUtils(context).isServiceEnabled(); + LogUtils.d(TAG, String.format("handleBootCompleted: 服务启用状态:%b", isServiceEnabled)); + if (!isServiceEnabled) { + LogUtils.d(TAG, "handleBootCompleted: 服务未启用,跳过自启"); + return; + } + + // 2. 校验服务是否已运行 + String serviceClassName = ControlCenterService.class.getName(); + boolean isServiceAlive = ServiceUtils.isServiceAlive(context.getApplicationContext(), serviceClassName); + LogUtils.d(TAG, String.format("handleBootCompleted: 服务运行状态:%b", isServiceAlive)); + if (isServiceAlive) { + LogUtils.d(TAG, "handleBootCompleted: 服务已运行,无需重复启动"); + return; + } + + // 3. 按API版本启动服务(适配前台服务要求) + Intent serviceIntent = new Intent(context, ControlCenterService.class); + if (Build.VERSION.SDK_INT >= API_LEVEL_26) { + context.startForegroundService(serviceIntent); + LogUtils.d(TAG, "handleBootCompleted: 启动前台服务(API >= 26)"); + } else { + context.startService(serviceIntent); + LogUtils.d(TAG, "handleBootCompleted: 启动普通服务(API < 26)"); + } + + LogUtils.d(TAG, "handleBootCompleted: 服务自启处理完成"); + } catch (Exception e) { + LogUtils.e(TAG, "handleBootCompleted: 服务自启失败", e); + } + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/services/AssistantService.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/services/AssistantService.java new file mode 100644 index 0000000..9c90f2f --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/services/AssistantService.java @@ -0,0 +1,189 @@ +package cc.winboll.studio.powerbell.services; + +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Build; +import android.os.IBinder; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.App; +import cc.winboll.studio.powerbell.utils.AppConfigUtils; +import cc.winboll.studio.powerbell.utils.ServiceUtils; + +/** + * 电池提醒核心服务进程守护类 + * 功能:监听主服务 {@link ControlCenterService} 存活状态,异常断开时自动重启并绑定 + * 适配:Java7 | API30 | 前台服务启动规则 | 服务绑定稳定性保障 + * @Author ZhanGSKen + * @Describe 守护服务:保障ControlCenterService持续运行 + */ +public class AssistantService extends Service { + // ====================== 静态常量区(置顶归类,消除魔法值) ====================== + private static final String TAG = "AssistantService"; + // 服务返回策略常量 + private static final int SERVICE_RETURN_STICKY = START_STICKY; + // 服务绑定标记常量 + private static final int BIND_FLAG = Context.BIND_IMPORTANT; + // API版本常量(适配前台服务启动要求) + private static final int API_LEVEL_26 = Build.VERSION_CODES.O; + + // ====================== 成员变量区(按功能分层,volatile保证多线程可见性) ====================== + private AppConfigUtils mAppConfigUtils; + private MyServiceConnection mMyServiceConnection; + private volatile boolean mIsThreadAlive; + + // ====================== 内部类(服务连接状态监听,前置定义便于引用) ====================== + /** + * 服务连接状态监听器 + * 主服务连接成功时记录状态,断开时自动重连 + */ + private class MyServiceConnection implements ServiceConnection { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + String className = name != null ? name.getClassName() : "null"; + LogUtils.d(TAG, String.format("onServiceConnected: 主服务连接成功 | 组件名=%s | Binder=%s", className, service)); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + String className = name != null ? name.getClassName() : "null"; + LogUtils.d(TAG, String.format("onServiceDisconnected: 主服务连接断开 | 组件名=%s", className)); + // 主服务断开且配置启用时,重新唤醒绑定 + if (mAppConfigUtils != null && mAppConfigUtils.isServiceEnabled()) { + LogUtils.d(TAG, "onServiceDisconnected: 配置启用,尝试重新唤醒并绑定主服务"); + wakeupAndBindMain(); + } + } + } + + // ====================== 服务生命周期方法(按执行顺序排列:onCreate→onStartCommand→onBind→onDestroy) ====================== + @Override + public void onCreate() { + super.onCreate(); + LogUtils.d(TAG, String.format("onCreate: 守护服务启动 | 进程ID=%d", android.os.Process.myPid())); + + // 初始化配置工具类,添加空指针防护 + mAppConfigUtils = App.getAppConfigUtils(this); + if (mAppConfigUtils == null) { + LogUtils.e(TAG, "onCreate: AppConfigUtils初始化失败,守护服务无法工作"); + stopSelf(); + return; + } + + // 初始化服务连接对象 + if (mMyServiceConnection == null) { + mMyServiceConnection = new MyServiceConnection(); + LogUtils.d(TAG, "onCreate: ServiceConnection初始化完成"); + } + + // 初始化运行状态,执行核心守护逻辑 + mIsThreadAlive = false; + run(); + LogUtils.d(TAG, String.format("onCreate: 守护服务初始化完成 | 服务启用状态=%b", mAppConfigUtils.isServiceEnabled())); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + LogUtils.d(TAG, String.format("onStartCommand: 守护服务触发重启 | flags=%d | startId=%d", flags, startId)); + // 配置工具类为空时,直接返回非粘性策略 + if (mAppConfigUtils == null) { + LogUtils.e(TAG, "onStartCommand: AppConfigUtils未初始化,终止服务"); + stopSelf(); + return START_NOT_STICKY; + } + + run(); + int returnFlag = mAppConfigUtils.isServiceEnabled() ? SERVICE_RETURN_STICKY : super.onStartCommand(intent, flags, startId); + LogUtils.d(TAG, String.format("onStartCommand: 处理完成 | 返回策略=%s", returnFlag == SERVICE_RETURN_STICKY ? "START_STICKY" : "DEFAULT")); + return returnFlag; + } + + @Override + public IBinder onBind(Intent intent) { + LogUtils.d(TAG, String.format("onBind: 服务绑定请求 | intent=%s", intent)); + return null; + } + + @Override + public void onDestroy() { + super.onDestroy(); + LogUtils.d(TAG, "onDestroy: 守护服务销毁流程启动"); + + // 重置运行状态,终止守护逻辑 + mIsThreadAlive = false; + + // 解绑主服务,添加异常捕获防止重复解绑崩溃 + unbindMainService(); + + // 置空工具类引用,帮助GC回收 + mAppConfigUtils = null; + LogUtils.d(TAG, "onDestroy: 守护服务销毁完成"); + } + + // ====================== 核心业务逻辑(守护主服务存活) ====================== + /** + * 执行守护逻辑:检查主服务状态,按需唤醒并绑定 + * 前置条件:mAppConfigUtils 必须初始化完成 + */ + private void run() { + boolean isServiceEnabled = mAppConfigUtils.isServiceEnabled(); + LogUtils.d(TAG, String.format("run: 执行守护逻辑 | 配置启用=%b | 线程存活=%b", isServiceEnabled, mIsThreadAlive)); + if (isServiceEnabled) { + if (!mIsThreadAlive) { + mIsThreadAlive = true; + wakeupAndBindMain(); + } + } else { + LogUtils.d(TAG, "run: 服务未启用,跳过守护逻辑"); + // 服务未启用时,重置线程状态 + mIsThreadAlive = false; + } + } + + /** + * 唤醒主服务并建立绑定,确保主服务持续运行 + * 适配 API26+ 前台服务启动规则,避免系统限制导致启动失败 + */ + private void wakeupAndBindMain() { + // 检查主服务存活状态 + String mainServiceName = ControlCenterService.class.getName(); + boolean isMainServiceAlive = ServiceUtils.isServiceAlive(getApplicationContext(), mainServiceName); + LogUtils.d(TAG, String.format("wakeupAndBindMain: 主服务存活状态=%b", isMainServiceAlive)); + + // 主服务未存活时,按需启动(区分API版本) + if (!isMainServiceAlive) { + Intent mainServiceIntent = new Intent(AssistantService.this, ControlCenterService.class); + if (Build.VERSION.SDK_INT >= API_LEVEL_26) { + startForegroundService(mainServiceIntent); + LogUtils.d(TAG, "wakeupAndBindMain: API26+ 以前台服务方式启动主服务"); + } else { + startService(mainServiceIntent); + LogUtils.d(TAG, "wakeupAndBindMain: 以普通服务方式启动主服务"); + } + } + + // 绑定主服务,监听连接状态,添加结果日志 + Intent bindIntent = new Intent(AssistantService.this, ControlCenterService.class); + boolean bindResult = bindService(bindIntent, mMyServiceConnection, BIND_FLAG); + LogUtils.d(TAG, String.format("wakeupAndBindMain: 绑定主服务结果=%b | 绑定标记=BIND_IMPORTANT", bindResult)); + } + + // ====================== 辅助工具方法(拆分独立逻辑,提高可维护性) ====================== + /** + * 解绑主服务,包含异常捕获与状态日志 + */ + private void unbindMainService() { + if (mMyServiceConnection != null) { + try { + unbindService(mMyServiceConnection); + LogUtils.d(TAG, "unbindMainService: 已成功解绑ControlCenterService"); + } catch (IllegalArgumentException e) { + LogUtils.w(TAG, String.format("unbindMainService: 解绑服务失败,服务未绑定 | %s", e.getMessage())); + } + mMyServiceConnection = null; + } + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/services/ControlCenterService.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/services/ControlCenterService.java new file mode 100644 index 0000000..f97904d --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/services/ControlCenterService.java @@ -0,0 +1,506 @@ +package cc.winboll.studio.powerbell.services; + +import android.app.ActivityManager; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.IBinder; +import android.os.PowerManager; +import android.provider.Settings; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import cc.winboll.studio.powerbell.handlers.ControlCenterServiceHandler; +import cc.winboll.studio.powerbell.models.AppConfigBean; +import cc.winboll.studio.powerbell.models.ControlCenterServiceBean; +import cc.winboll.studio.powerbell.models.NotificationMessage; +import cc.winboll.studio.powerbell.receivers.ControlCenterServiceReceiver; +import cc.winboll.studio.powerbell.threads.RemindThread; +import cc.winboll.studio.powerbell.utils.NotificationManagerUtils; +import java.util.List; + +/** + * 电池提醒核心服务 + * 功能:管理前台服务生命周期、控制提醒线程启停、处理配置更新 + * 适配:Java7 | API30 | 前台服务超时防护 | 电池优化忽略引导 + * @Author 豆包&ZhanGSKen + * @Describe 核心服务:实现电池监测、提醒控制与前台服务保活 + */ +public class ControlCenterService extends Service { + // ====================== 静态常量区(置顶归类,消除魔法值) ====================== + public static final String TAG = "ControlCenterService"; + // 线程与服务常量 + private static final long THREAD_STOP_TIMEOUT = 1000L; + private static final int SERVICE_RETURN_STICKY = START_STICKY; + private static final int RUNNING_SERVICE_LIST_LIMIT = 100; + // 默认配置常量 + private static final int DEFAULT_CHARGE_REMINDER_VALUE = 80; + private static final int DEFAULT_USAGE_REMINDER_VALUE = 20; + private static final int DEFAULT_BATTERY_DETECT_INTERVAL = 1000; + // API版本常量 + private static final int API_LEVEL_26 = Build.VERSION_CODES.O; + private static final int API_LEVEL_30 = Build.VERSION_CODES.R; + private static final int API_LEVEL_23 = Build.VERSION_CODES.M; + + // ====================== 静态状态标记(volatile保证多线程可见性) ====================== + private static volatile boolean isServiceRunning = false; + private static volatile boolean mIsDestroyed = true; + + // ====================== 成员变量区(按功能分层:配置→核心组件→通知相关) ====================== + // 服务控制配置 + private ControlCenterServiceBean mServiceControlBean; + private AppConfigBean mCurrentConfigBean; + // 业务核心组件 + private ControlCenterServiceHandler mServiceHandler; + private ControlCenterServiceReceiver mControlCenterServiceReceiver; + // 通知相关 + private NotificationManagerUtils mNotificationManager; + private NotificationMessage mForegroundNotifyMsg; + + // ====================== 服务生命周期方法(按执行顺序:onCreate→onStartCommand→onBind→onDestroy) ====================== + @Override + public void onCreate() { + super.onCreate(); + LogUtils.d(TAG, String.format("onCreate() 执行 | 线程=%s | 进程ID=%d", Thread.currentThread().getName(), android.os.Process.myPid())); + runCoreServiceLogic(); + boolean serviceEnabled = mServiceControlBean != null && mServiceControlBean.isEnableService(); + LogUtils.d(TAG, String.format("onCreate() 完成 | 前台状态=%b | 服务启用=%b", isServiceRunning, serviceEnabled)); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + String action = intent != null ? intent.getAction() : "null"; + LogUtils.d(TAG, String.format("onStartCommand() 执行 | startId=%d | action=%s", startId, action)); + loadLatestServiceControlConfig(); + runCoreServiceLogic(); + + int returnFlag = (mServiceControlBean != null && mServiceControlBean.isEnableService()) + ? SERVICE_RETURN_STICKY + : super.onStartCommand(intent, flags, startId); + LogUtils.d(TAG, String.format("onStartCommand() 完成 | 返回策略=%s", returnFlag == SERVICE_RETURN_STICKY ? "START_STICKY" : "DEFAULT")); + return returnFlag; + } + + @Override + public IBinder onBind(Intent intent) { + LogUtils.d(TAG, String.format("onBind() 执行 | intent=%s", intent)); + return null; + } + + @Override + public void onDestroy() { + LogUtils.d(TAG, "onDestroy() 执行:服务销毁流程启动"); + super.onDestroy(); + + // 资源释放顺序:前台服务 → 线程 → 广播接收器 → Handler → 通知 → 引用(避免内存泄漏) + stopForegroundService(); + RemindThread.stopRemindThread(); + releaseBroadcastReceiver(); + destroyHandler(); + releaseNotificationResource(); + clearAllReferences(); + + // 状态重置 + mCurrentConfigBean = null; + mForegroundNotifyMsg = null; + mServiceHandler = null; + isServiceRunning = false; + mIsDestroyed = true; + + LogUtils.d(TAG, "onDestroy() 完成:服务销毁完成"); + } + + // ====================== 核心业务逻辑(独立抽取,统一调用) ====================== + /** + * 服务核心运行逻辑,在onCreate/onStartCommand复用 + * 避免重复初始化,保证前台服务优先启动 + */ + private synchronized void runCoreServiceLogic() { + LogUtils.d(TAG, "runCoreServiceLogic() 执行"); + loadLatestServiceControlConfig(); + + boolean serviceEnabled = mServiceControlBean != null && mServiceControlBean.isEnableService(); + LogUtils.d(TAG, String.format("runCoreServiceLogic() | 服务启用=%b | 已运行=%b | 已销毁=%b", serviceEnabled, isServiceRunning, mIsDestroyed)); + + if (serviceEnabled && !isServiceRunning) { + isServiceRunning = true; + mIsDestroyed = false; + + if (initForegroundNotificationImmediately()) { + loadDefaultConfig(); + initServiceBusinessLogic(); + LogUtils.d(TAG, "runCoreServiceLogic() | 核心组件初始化成功"); + } else { + LogUtils.e(TAG, "runCoreServiceLogic() | 前台通知初始化失败,终止业务"); + stopForegroundService(); + isServiceRunning = false; + } + } else { + LogUtils.d(TAG, "runCoreServiceLogic() | 无需执行核心逻辑"); + } + } + + // ====================== 前台通知管理(优先执行,防止API26+前台服务5秒超时) ====================== + /** + * 立即初始化前台通知,防止API26+前台服务超时异常 + * @return true=成功 false=失败 + */ + private boolean initForegroundNotificationImmediately() { + LogUtils.d(TAG, "initForegroundNotificationImmediately() 执行"); + try { + if (mNotificationManager == null) { + mNotificationManager = new NotificationManagerUtils(this); + LogUtils.d(TAG, "initForegroundNotificationImmediately() | 通知工具类初始化完成"); + } + + if (mForegroundNotifyMsg == null) { + mForegroundNotifyMsg = new NotificationMessage(); + mForegroundNotifyMsg.setTitle("电池监测服务"); + mForegroundNotifyMsg.setContent("后台运行中"); + mForegroundNotifyMsg.setRemindMSG("service_running"); + LogUtils.d(TAG, "initForegroundNotificationImmediately() | 通知消息构建完成"); + } + + mNotificationManager.startForegroundServiceNotify(this, mForegroundNotifyMsg); + ToastUtils.show("电池监测服务已启动"); + LogUtils.d(TAG, String.format("initForegroundNotificationImmediately() | 前台通知发送成功 | ID=%d", NotificationManagerUtils.NOTIFY_ID_FOREGROUND_SERVICE)); + return true; + } catch (Exception e) { + LogUtils.e(TAG, "initForegroundNotificationImmediately() | 通知初始化异常", e); + return false; + } + } + + /** + * 停止前台服务并取消通知 + */ + private void stopForegroundService() { + LogUtils.d(TAG, "stopForegroundService() 执行"); + try { + stopForeground(true); + LogUtils.d(TAG, "stopForegroundService() | 前台服务已停止,通知已取消"); + } catch (Exception e) { + LogUtils.e(TAG, "stopForegroundService() | 停止异常", e); + } + } + + // ====================== 配置管理(本地持久化+内存同步) ====================== + /** + * 加载本地最新服务控制配置 + */ + private void loadLatestServiceControlConfig() { + LogUtils.d(TAG, "loadLatestServiceControlConfig() 执行"); + ControlCenterServiceBean latestBean = ControlCenterServiceBean.loadBean(this, ControlCenterServiceBean.class); + if (latestBean != null) { + mServiceControlBean = latestBean; + LogUtils.d(TAG, String.format("loadLatestServiceControlConfig() | 配置读取成功 | 启用=%b", mServiceControlBean.isEnableService())); + } else { + LogUtils.w(TAG, "loadLatestServiceControlConfig() | 本地无配置,沿用内存配置"); + } + } + + /** + * 加载默认业务配置(首次启动兜底) + */ + private void loadDefaultConfig() { + LogUtils.d(TAG, "loadDefaultConfig() 执行"); + if (mCurrentConfigBean == null) { + mCurrentConfigBean = new AppConfigBean(); + mCurrentConfigBean.setEnableChargeReminder(true); + mCurrentConfigBean.setChargeReminderValue(DEFAULT_CHARGE_REMINDER_VALUE); + mCurrentConfigBean.setEnableUsageReminder(true); + mCurrentConfigBean.setUsageReminderValue(DEFAULT_USAGE_REMINDER_VALUE); + mCurrentConfigBean.setBatteryDetectInterval(DEFAULT_BATTERY_DETECT_INTERVAL); + LogUtils.d(TAG, String.format("loadDefaultConfig() | 默认配置加载完成 | 充电阈值=%d | 耗电阈值=%d | 检测间隔=%dms", + DEFAULT_CHARGE_REMINDER_VALUE, DEFAULT_USAGE_REMINDER_VALUE, DEFAULT_BATTERY_DETECT_INTERVAL)); + } else { + LogUtils.d(TAG, "loadDefaultConfig() | 内存已有配置,无需加载"); + } + } + + // ====================== 业务组件初始化与销毁(Handler/广播/线程等) ====================== + /** + * 初始化Handler等核心业务组件 + */ + private void initServiceBusinessLogic() { + LogUtils.d(TAG, "initServiceBusinessLogic() 执行"); + // 初始化Handler + if (mServiceHandler == null) { + mServiceHandler = new ControlCenterServiceHandler(this); + LogUtils.d(TAG, "initServiceBusinessLogic() | Handler初始化完成"); + } else { + LogUtils.d(TAG, "initServiceBusinessLogic() | Handler已存在"); + } + // 初始化广播接收器 + if (mControlCenterServiceReceiver == null) { + mControlCenterServiceReceiver = new ControlCenterServiceReceiver(this); + mControlCenterServiceReceiver.registerAction(this); + LogUtils.d(TAG, "initServiceBusinessLogic() | 广播接收器初始化并注册完成"); + } else { + LogUtils.d(TAG, "initServiceBusinessLogic() | 广播接收器已存在"); + } + } + + /** + * 释放广播接收器资源 + */ + private void releaseBroadcastReceiver() { + LogUtils.d(TAG, "releaseBroadcastReceiver() 执行"); + if (mControlCenterServiceReceiver != null) { + mControlCenterServiceReceiver.release(); + mControlCenterServiceReceiver = null; + LogUtils.d(TAG, "releaseBroadcastReceiver() | 广播接收器已释放"); + } else { + LogUtils.w(TAG, "releaseBroadcastReceiver() | 广播接收器实例为空"); + } + } + + /** + * 销毁Handler,移除所有消息和回调,防止内存泄漏 + */ + private void destroyHandler() { + LogUtils.d(TAG, "destroyHandler() 执行"); + if (mServiceHandler != null) { + mServiceHandler.removeCallbacksAndMessages(null); + mServiceHandler = null; + LogUtils.d(TAG, "destroyHandler() | Handler已销毁"); + } else { + LogUtils.w(TAG, "destroyHandler() | Handler实例为空"); + } + } + + /** + * 释放通知工具类资源 + */ + private void releaseNotificationResource() { + LogUtils.d(TAG, "releaseNotificationResource() 执行"); + if (mNotificationManager != null) { + mNotificationManager.release(); + mNotificationManager = null; + LogUtils.d(TAG, "releaseNotificationResource() | 通知资源已释放"); + } else { + LogUtils.w(TAG, "releaseNotificationResource() | 通知工具类实例为空"); + } + } + + /** + * 置空所有引用,防止内存泄漏 + */ + private void clearAllReferences() { + LogUtils.d(TAG, "clearAllReferences() 执行"); + mForegroundNotifyMsg = null; + mServiceControlBean = null; + LogUtils.d(TAG, "clearAllReferences() | 引用清理完成"); + } + + // ====================== 外部调用接口(静态方法,提供服务启停/配置更新入口) ====================== + /** + * 外部启动服务的统一入口 + * @param context 上下文 + */ + public static void startControlCenterService(Context context) { + LogUtils.d(TAG, String.format("startControlCenterService() 执行 | context=%s", context)); + if (context == null) { + LogUtils.e(TAG, "startControlCenterService() | Context为空,启动失败"); + return; + } + + // 保存启用配置 + ControlCenterServiceBean controlBean = new ControlCenterServiceBean(true); + ControlCenterServiceBean.saveBean(context, controlBean); + LogUtils.d(TAG, "startControlCenterService() | 服务启用配置已保存"); + + // 启动服务(区分API版本) + Intent intent = new Intent(context, ControlCenterService.class); + if (Build.VERSION.SDK_INT >= API_LEVEL_26) { + context.startForegroundService(intent); + LogUtils.d(TAG, "startControlCenterService() | 以前台服务方式启动(API26+)"); + } else { + context.startService(intent); + LogUtils.d(TAG, "startControlCenterService() | 以普通服务方式启动(API26-)"); + } + } + + /** + * 外部停止服务的统一入口 + * @param context 上下文 + */ + public static void stopControlCenterService(Context context) { + LogUtils.d(TAG, String.format("stopControlCenterService() 执行 | context=%s", context)); + if (context == null) { + LogUtils.e(TAG, "stopControlCenterService() | Context为空,停止失败"); + return; + } + + // 保存停用配置 + ControlCenterServiceBean controlBean = new ControlCenterServiceBean(false); + ControlCenterServiceBean.saveBean(context, controlBean); + LogUtils.d(TAG, "stopControlCenterService() | 服务停用配置已保存"); + + // 停止服务 + Intent intent = new Intent(context, ControlCenterService.class); + context.stopService(intent); + LogUtils.d(TAG, "stopControlCenterService() | 停止指令已发送"); + } + + /** + * 外部更新配置并触发线程重启 + * @param context 上下文 + */ + public static void sendAppConfigStatusUpdateMessage(Context context) { + LogUtils.d(TAG, String.format("sendAppConfigStatusUpdateMessage() 执行 | context=%s", context)); + if (context == null) { + LogUtils.e(TAG, "sendAppConfigStatusUpdateMessage() | 参数为空,更新失败"); + return; + } + + Intent intent = new Intent(ControlCenterServiceReceiver.ACTION_APPCONFIG_CHANGED); + intent.setPackage(context.getPackageName()); + context.sendBroadcast(intent); + LogUtils.d(TAG, String.format("sendAppConfigStatusUpdateMessage() | 配置更新广播发送 | action=%s", ControlCenterServiceReceiver.ACTION_APPCONFIG_CHANGED)); + } + + /** + * 检查并引导用户开启忽略电池优化(API23+) + * @param context 上下文 + */ + public static void checkIgnoreBatteryOptimization(Context context) { + LogUtils.d(TAG, String.format("checkIgnoreBatteryOptimization() 执行 | context=%s", context)); + if (context == null || Build.VERSION.SDK_INT < API_LEVEL_23) { + LogUtils.w(TAG, "checkIgnoreBatteryOptimization() | 无需检查(Context为空或API<23)"); + return; + } + + PowerManager powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + if (powerManager == null) { + LogUtils.e(TAG, "checkIgnoreBatteryOptimization() | PowerManager获取失败"); + return; + } + + String packageName = context.getPackageName(); + boolean isIgnored = powerManager.isIgnoringBatteryOptimizations(packageName); + LogUtils.d(TAG, String.format("checkIgnoreBatteryOptimization() | 已忽略电池优化=%b", isIgnored)); + + if (!isIgnored) { + Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); + intent.setData(Uri.parse("package:" + packageName)); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + context.startActivity(intent); + LogUtils.d(TAG, String.format("checkIgnoreBatteryOptimization() | 已跳转至系统设置页 | package=%s", packageName)); + } + } + + /** + * 检查服务是否运行(适配API30+) + * @param context 上下文 + * @param serviceClass 服务类 + * @return true=运行中 false=未运行 + */ + private static boolean isServiceRunning(Context context, Class serviceClass) { + LogUtils.d(TAG, String.format("isServiceRunning() 执行 | context=%s | service=%s", context, serviceClass != null ? serviceClass.getName() : "null")); + if (context == null || serviceClass == null) { + LogUtils.e(TAG, "isServiceRunning() | 参数为空"); + return false; + } + + ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + if (am == null) { + LogUtils.e(TAG, "isServiceRunning() | ActivityManager获取失败"); + return false; + } + + boolean isRunning = false; + String packageName = context.getPackageName(); + String serviceClassName = serviceClass.getName(); + + if (Build.VERSION.SDK_INT >= API_LEVEL_30) { + // API30+ 禁止获取其他应用服务,通过进程状态判断 + List processes = am.getRunningAppProcesses(); + if (processes != null) { + for (ActivityManager.RunningAppProcessInfo process : processes) { + if (packageName.equals(process.processName) && + (process.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND_SERVICE || + process.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND)) { + isRunning = true; + break; + } + } + } + LogUtils.d(TAG, String.format("isServiceRunning() | API30+ 判断结果=%b", isRunning)); + } else { + // API30- 通过服务列表判断 + List services = am.getRunningServices(RUNNING_SERVICE_LIST_LIMIT); + if (services != null) { + for (ActivityManager.RunningServiceInfo info : services) { + if (serviceClassName.equals(info.service.getClassName())) { + isRunning = true; + break; + } + } + } + LogUtils.d(TAG, String.format("isServiceRunning() | API30- 判断结果=%b", isRunning)); + } + + // 兜底判断:配置启用状态 + if (!isRunning) { + isRunning = isServiceStarted(context, serviceClass); + LogUtils.d(TAG, String.format("isServiceRunning() | 兜底判断结果=%b", isRunning)); + } + return isRunning; + } + + /** + * 兜底判断服务是否已启动(通过配置文件) + */ + private static boolean isServiceStarted(Context context, Class serviceClass) { + LogUtils.d(TAG, "isServiceStarted() 执行"); + try { + ControlCenterServiceBean controlBean = ControlCenterServiceBean.loadBean(context, ControlCenterServiceBean.class); + return controlBean != null && controlBean.isEnableService(); + } catch (Exception e) { + LogUtils.e(TAG, "isServiceStarted() | 兜底判断异常", e); + return false; + } + } + + // ====================== 业务方法(配置更新/电池状态回调) ====================== + /** + * 接收外部配置更新,同步到提醒线程 + * @param latestConfig 最新配置 + */ + public void notifyAppConfigUpdate(AppConfigBean latestConfig) { + int chargeThreshold = latestConfig != null ? latestConfig.getChargeReminderValue() : -1; + int usageThreshold = latestConfig != null ? latestConfig.getUsageReminderValue() : -1; + LogUtils.d(TAG, String.format("notifyAppConfigUpdate() 执行 | 充电阈值=%d | 耗电阈值=%d", chargeThreshold, usageThreshold)); + if (latestConfig != null && mServiceHandler != null) { + mCurrentConfigBean = latestConfig; + RemindThread.startRemindThreadWithAppConfig(this, mServiceHandler, latestConfig); + LogUtils.d(TAG, "notifyAppConfigUpdate() | 配置已同步到提醒线程"); + } else { + LogUtils.e(TAG, String.format("notifyAppConfigUpdate() | 参数为空,同步失败 | latestConfig=%s | mServiceHandler=%s", latestConfig, mServiceHandler)); + } + } + + // ====================== Getter 方法(按需开放,避免冗余Setter) ====================== + public ControlCenterServiceBean getServiceControlBean() { + return mServiceControlBean; + } + + public NotificationManagerUtils getNotificationManager() { + return mNotificationManager; + } + + public NotificationMessage getForegroundNotifyMsg() { + return mForegroundNotifyMsg; + } + + public AppConfigBean getCurrentConfigBean() { + return mCurrentConfigBean; + } + + public boolean isDestroyed() { + return mIsDestroyed; + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/services/TTSPlayService.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/services/TTSPlayService.java new file mode 100644 index 0000000..55c09d8 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/services/TTSPlayService.java @@ -0,0 +1,83 @@ +package cc.winboll.studio.powerbell.services; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.IBinder; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.models.TTSSpeakTextBean; +import cc.winboll.studio.powerbell.utils.TextToSpeechUtils; +import java.util.ArrayList; + +/** + * TTS 语音播放后台服务组件 + * 适配:Java7 语法规范 | Android API30 系统版本 + * 功能:后台承载TTS语音播放,解耦页面生命周期,避免页面销毁中断播放 + * @Author 豆包&ZhanGSKen + * @Date 2025/12/29 19:12 + */ +public class TTSPlayService extends Service { + + // ====================================== 常量区 - 静态全局常量 置顶排序 ====================================== + public static final String TAG = "TTSPlayService"; + public static final String EXTRA_SPEAKDATA = "EXTRA_SPEAKDATA"; + + // ====================================== 对外公开静态快捷调用方法【新增核心】====================================== + /** + * 公开静态方法:一键启动TTS播放服务,播放指定文本内容 + * @param context 上下文对象 + * @param speakText 需要播放的语音文本内容 + */ + public static void startPlayTTS(Context context, String speakText) { + LogUtils.d(TAG, "【startPlayTTS】静态快捷调用方法 | 入参Context=" + context + " | 播放文本=" + speakText); + if (context != null && speakText != null && !speakText.isEmpty()) { + // 初始化播放数据集合 + ArrayList ttsBeanList = new ArrayList<>(); + // 添加播放文本,延迟时间为0:无延迟立即播放 + ttsBeanList.add(new TTSSpeakTextBean(0, speakText)); + LogUtils.d(TAG, "【startPlayTTS】封装播放数据完成,创建启动服务意图"); + + // 创建意图并封装序列化参数 + Intent intent = new Intent(context, TTSPlayService.class); + intent.putExtra(EXTRA_SPEAKDATA, ttsBeanList); + + // 启动当前服务 + context.startService(intent); + LogUtils.d(TAG, "【startPlayTTS】已调用startService,TTS播放服务启动成功"); + } else { + LogUtils.d(TAG, "【startPlayTTS】上下文为空 或 播放文本为空/空字符串,跳过启动服务"); + } + } + + // ====================================== 生命周期方法 - 绑定服务 (无绑定逻辑) ====================================== + @Override + public IBinder onBind(Intent intent) { + LogUtils.d(TAG, "【onBind】服务绑定方法调用,入参Intent:" + intent); + return null; + } + + // ====================================== 生命周期方法 - 启动服务【核心方法】 ====================================== + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + LogUtils.d(TAG, "【onStartCommand】服务启动方法调用 | 入参Intent:" + intent + " | flags:" + flags + " | startId:" + startId); + // 解析播放数据并执行播放 + if (intent != null) { + LogUtils.d(TAG, "【onStartCommand】Intent不为空,开始解析序列化播放数据"); + ArrayList listTTSSpeakTextBean = (ArrayList) intent.getSerializableExtra(EXTRA_SPEAKDATA); + if (listTTSSpeakTextBean != null && listTTSSpeakTextBean.size() > 0) { + LogUtils.d(TAG, "【onStartCommand】解析播放数据成功,队列长度:" + listTTSSpeakTextBean.size() + ",调用TTS播放工具类"); + TextToSpeechUtils.getInstance(this).speekTTSList(listTTSSpeakTextBean); + } else { + LogUtils.d(TAG, "【onStartCommand】播放数据为空/长度0,跳过语音播放逻辑"); + } + } else { + LogUtils.d(TAG, "【onStartCommand】Intent为空,无播放数据可解析"); + } + // 返回默认值,保持原服务启动策略不变 + int result = super.onStartCommand(intent, flags, startId); + LogUtils.d(TAG, "【onStartCommand】方法执行完成,返回值:" + result); + return result; + } + +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/services/ThoughtfulService.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/services/ThoughtfulService.java new file mode 100644 index 0000000..5a257ef --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/services/ThoughtfulService.java @@ -0,0 +1,164 @@ +package cc.winboll.studio.powerbell.services; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.IBinder; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.models.AppConfigBean; +import cc.winboll.studio.powerbell.models.ThoughtfulServiceBean; +import cc.winboll.studio.powerbell.utils.AppConfigUtils; + +/** + * 智能电池服务(充电/放电状态处理) + * 适配:Java7 语法规范 | Android API30 系统版本 + * 功能:接收充电/放电状态指令,根据不同状态执行对应业务任务 + * @Author 豆包&ZhanGSKen + * @Date 2025/12/29 19:29 + */ +public class ThoughtfulService extends Service { + + // ====================================== 常量区 - 置顶排序 ====================================== + public static final String TAG = "ThoughtfulService"; + /** Intent传递 服务类型 的Key值 */ + public static final String EXTRA_SERVICE_TYPE = "EXTRA_SERVICE_TYPE"; + + // ====================================== 枚举类 - 服务类型 充电/放电状态 ====================================== + /** + * 服务执行类型枚举 + * CHARGE_STATE : 充电状态服务 + * DISCHARGE_STATE : 放电(耗电)状态服务 + */ + public enum ServiceType { + CHARGE_STATE, //充电状态服务 + DISCHARGE_STATE //放电状态服务 + } + + // ====================================== 对外公开静态启动函数【新增核心】入参Context + 枚举 ====================================== + /** + * 公开静态方法:传入上下文+服务类型枚举,一键构建意图并启动当前服务 + * @param context 上下文对象 + * @param serviceType 服务类型枚举【充电/放电】 + */ + public static void startServiceWithType(Context context, ServiceType serviceType) { + LogUtils.d(TAG, "【startServiceWithType】静态启动方法调用 | Context=" + context + " | ServiceType=" + (serviceType == null ? "null" : serviceType.name())); + + ThoughtfulServiceBean thoughtfulServiceBean = ThoughtfulServiceBean.loadBean(context, ThoughtfulServiceBean.class); + if (thoughtfulServiceBean == null) { + thoughtfulServiceBean = new ThoughtfulServiceBean(); + } + + // 对应TTS服务提醒没有启用,就退出 + if((serviceType == ServiceType.CHARGE_STATE && !thoughtfulServiceBean.isEnableChargeTts()) + ||(serviceType == ServiceType.DISCHARGE_STATE && !thoughtfulServiceBean.isEnableUsePowerTts())){ + return; + } + + + // 判空健壮性校验 + if (context != null && serviceType != null) { + // 构建意图 + 封装枚举参数 + Intent intent = new Intent(context, ThoughtfulService.class); + intent.putExtra(EXTRA_SERVICE_TYPE, serviceType); + // 启动服务 + context.startService(intent); + LogUtils.d(TAG, "【startServiceWithType】服务启动成功,执行[" + serviceType.name() + "]任务"); + } else { + LogUtils.d(TAG, "【startServiceWithType】上下文为空 或 服务类型枚举为空,跳过启动服务"); + } + } + + // ====================================== 生命周期方法 - 绑定服务 (原逻辑保留) ====================================== + @Override + public IBinder onBind(Intent intent) { + LogUtils.d(TAG, "【onBind】服务绑定方法调用,入参Intent:" + intent); + return null; + } + + // ====================================== 生命周期方法 - 启动服务【核心逻辑】接收枚举+分支执行任务 ====================================== + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + LogUtils.d(TAG, "【onStartCommand】服务启动方法调用 | intent=" + intent + " | flags=" + flags + " | startId=" + startId); + // 判断意图非空,解析服务类型参数 + if (intent != null) { + LogUtils.d(TAG, "【onStartCommand】Intent不为空,开始解析服务类型枚举参数"); + // 获取传递的服务类型枚举 + ServiceType serviceType = (ServiceType) intent.getSerializableExtra(EXTRA_SERVICE_TYPE); + // 根据服务类型,执行对应任务 + if (serviceType != null) { + LogUtils.d(TAG, "【onStartCommand】解析到服务类型:" + serviceType.name()); + switch (serviceType) { + case CHARGE_STATE: + // 执行【充电状态】对应的业务任务 + executeChargeStateTask(); + break; + case DISCHARGE_STATE: + // 执行【放电状态】对应的业务任务 + executeDischargeStateTask(); + break; + default: + LogUtils.d(TAG, "【onStartCommand】未知的服务类型,不执行任何任务"); + break; + } + } else { + LogUtils.d(TAG, "【onStartCommand】未解析到有效服务类型参数,参数为空"); + } + } else { + LogUtils.d(TAG, "【onStartCommand】启动服务的Intent为空,直接返回"); + } + + // 返回默认策略,与原生逻辑一致 + int result = super.onStartCommand(intent, flags, startId); + LogUtils.d(TAG, "【onStartCommand】服务执行完成,返回值:" + result); + return result; + } + + // ====================================== 私有业务方法 充电/放电 分任务执行 ====================================== + /** + * 执行【充电状态】的业务任务 + * 可在此方法内编写 充电时的逻辑(语音提醒/电量监控/弹窗等) + */ + private void executeChargeStateTask() { + LogUtils.d(TAG, "【executeChargeStateTask】执行【充电状态】业务任务 >>> "); + //ToastUtils.show("【executeChargeStateTask】执行【充电状态】业务任务 >>> "); + // TODO 此处添加充电状态需要执行的业务逻辑代码 + // 加载最新配置 + AppConfigBean latestConfig = AppConfigUtils.getInstance(this).loadAppConfig(); + if (latestConfig == null) { + LogUtils.e(TAG, "handleNotifyAppConfigUpdate() 终止 | 最新配置为空"); + return; + } + + if (latestConfig.isEnableChargeReminder()) { + int nChargeReminderValue = latestConfig.getChargeReminderValue(); + String szRemind = String.format("限量充电提醒已启用,限量值为百分之%d。", nChargeReminderValue); + szRemind = szRemind + szRemind + szRemind; + TTSPlayService.startPlayTTS(this, szRemind); + } + } + + /** + * 执行【放电(耗电)状态】的业务任务 + * 可在此方法内编写 放电时的逻辑(语音提醒/电量监控/弹窗等) + */ + private void executeDischargeStateTask() { + LogUtils.d(TAG, "【executeDischargeStateTask】执行【放电状态】业务任务 >>> "); + //ToastUtils.show("【executeDischargeStateTask】执行【放电状态】业务任务 >>> "); + // TODO 此处添加放电状态需要执行的业务逻辑代码 + // 加载最新配置 + AppConfigBean latestConfig = AppConfigUtils.getInstance(this).loadAppConfig(); + if (latestConfig == null) { + LogUtils.e(TAG, "handleNotifyAppConfigUpdate() 终止 | 最新配置为空"); + return; + } + + if (latestConfig.isEnableUsageReminder()) { + int nUsageReminderValue = latestConfig.getUsageReminderValue(); + String szRemind = String.format("电量不足提醒已启用,低电值为百分之%d。", nUsageReminderValue); + //szRemind = szRemind + szRemind + szRemind; + TTSPlayService.startPlayTTS(this, szRemind); + } + } + +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/threads/RemindThread.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/threads/RemindThread.java new file mode 100644 index 0000000..ad05f6f --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/threads/RemindThread.java @@ -0,0 +1,354 @@ +package cc.winboll.studio.powerbell.threads; + +import android.content.Context; +import android.os.Message; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.App; +import cc.winboll.studio.powerbell.handlers.ControlCenterServiceHandler; +import cc.winboll.studio.powerbell.models.AppConfigBean; +import java.lang.ref.WeakReference; +import java.util.ArrayList; + +/** + * 电量通知提醒线程(多实例列表管理) + * 功能:管理充电/耗电提醒逻辑,触发条件时向Handler发送提醒消息 + * 适配:Java7 | API30 | 内存泄漏防护 | 多线程状态同步 + * 对外接口:{@link #startRemindThreadWithAppConfig(Context, ControlCenterServiceHandler, AppConfigBean)}、 + * {@link #startRemindThreadWithBatteryInfo(Context, ControlCenterServiceHandler, boolean, int)}、{@link #stopRemindThread()} + * @Author 豆包&ZhanGSKen + * @Describe 电量通知提醒线程 + */ +public class RemindThread extends Thread { + // ====================== 静态常量区(置顶归类,消除魔法值) ====================== + public static final String TAG = "RemindThread"; + + // 时间常量 (ms) + private static final int MIN_SLEEP_TIME = 2000; + private static final long THREAD_JOIN_TIMEOUT = 1000L; + + // 状态常量 + private static final int BATTERY_LEVEL_MIN = 0; + private static final int BATTERY_LEVEL_MAX = 100; + + // 提醒类型常量 + private static final String REMIND_TYPE_CHARGE = "+"; + private static final String REMIND_TYPE_USAGE = "-"; + + // ====================== 静态成员(多实例列表管理) ====================== + private static volatile ArrayList sRemindThreadList; + + // ====================== 成员变量区(按功能分层,volatile保证多线程可见性) ====================== + // 并发安全锁(保护线程状态变更) + private final Object mRemindLock = new Object(); + + // 弱引用依赖(防内存泄漏,ApplicationContext 避免 Activity 引用) + private Context mContext; + private WeakReference mwrControlCenterServiceHandler; + + // 线程状态标记(volatile 确保多线程可见) + private volatile boolean isReminding; + public volatile boolean isExist; + + // 业务配置参数(volatile 确保配置变更实时生效) + private volatile boolean isEnableChargeReminder; + private volatile boolean isEnableUsageReminder; + private volatile long sleepTime; + private volatile int chargeReminderValue; + private volatile int usageReminderValue; + private volatile boolean isCharging; + + // ====================== 私有构造器(禁止外部实例化) ====================== + private RemindThread(Context context, ControlCenterServiceHandler handler) { + LogUtils.d(TAG, String.format("RemindThread() 构造器调用 | context=%s | handler=%s", context, handler)); + this.mContext = context.getApplicationContext(); + this.mwrControlCenterServiceHandler = new WeakReference<>(handler); + resetThreadStateInternal(); + LogUtils.d(TAG, String.format("RemindThread() 构造完成 | threadId=%d | 初始状态重置成功", getId())); + } + + // ====================== 对外公开静态接口(多实例列表管理) ====================== + /** + * 启动提醒线程,同步最新配置 + * 逻辑:停止所有旧线程 → 创建新线程 → 加入列表管理 + * @param context 上下文(非空) + * @param handler 服务处理器(非空) + * @param config 应用配置Bean(非空) + * @return true: 启动成功;false: 入参非法 + */ + public static boolean startRemindThreadWithAppConfig(Context context, ControlCenterServiceHandler handler, AppConfigBean config) { + LogUtils.d(TAG, String.format("startRemindThreadWithAppConfig() 调用 | context=%s | handler=%s | config=%s", context, handler, config)); + + // 入参严格校验 + if (context == null || handler == null || config == null) { + LogUtils.e(TAG, String.format("启动失败:入参为空 | context=%s | handler=%s | config=%s", context, handler, config)); + return false; + } + + // 初始化线程列表(双重校验锁) + if (sRemindThreadList == null) { + synchronized (RemindThread.class) { + if (sRemindThreadList == null) { + sRemindThreadList = new ArrayList(); + LogUtils.d(TAG, "线程列表初始化完成"); + } + } + } + + // 停止所有旧线程 + stopAllOldThreadsInternal(); + + // 创建并启动新线程 + RemindThread newRemindThread = new RemindThread(context, handler); + newRemindThread.setAppConfigBean(config); + newRemindThread.isExist = false; + newRemindThread.start(); + sRemindThreadList.add(newRemindThread); + LogUtils.d(TAG, String.format("新线程启动成功 | threadId=%d | 列表大小=%d", newRemindThread.getId(), sRemindThreadList.size())); + return true; + } + + /** + * 安全停止所有线程,清空列表 + */ + public static void stopRemindThread() { + int listSize = sRemindThreadList != null ? sRemindThreadList.size() : 0; + LogUtils.d(TAG, String.format("stopRemindThread() 调用 | 列表存在=%b | 列表大小=%d", sRemindThreadList != null, listSize)); + if (sRemindThreadList == null || sRemindThreadList.isEmpty()) { + LogUtils.w(TAG, "停止失败:线程列表为空"); + return; + } + + // 标记所有线程退出 + for (RemindThread remindThread : sRemindThreadList) { + remindThread.isExist = true; + LogUtils.d(TAG, String.format("标记线程退出 | threadId=%d", remindThread.getId())); + } + // 清空列表 + sRemindThreadList.clear(); + LogUtils.d(TAG, "所有线程已标记退出,列表已清空"); + } + + // ====================== 私有静态辅助方法(多实例管理) ====================== + /** + * 停止所有旧线程并清空列表 + */ + private static void stopAllOldThreadsInternal() { + if (sRemindThreadList == null || sRemindThreadList.isEmpty()) { + return; + } + + // 标记所有旧线程退出 + for (RemindThread remindThread : sRemindThreadList) { + remindThread.isExist = true; + LogUtils.d(TAG, String.format("标记旧线程退出 | threadId=%d", remindThread.getId())); + } + // 清空旧线程列表 + sRemindThreadList.clear(); + LogUtils.d(TAG, "旧线程已全部标记退出,列表已清空"); + } + + // ====================== 线程核心运行逻辑 ====================== + @Override + public void run() { + LogUtils.d(TAG, String.format("run() 执行 | threadId=%d | 状态=%s", getId(), getState())); + + // 初始化提醒状态(加锁保护,避免多线程竞争) + synchronized (mRemindLock) { + if (isReminding) { + LogUtils.w(TAG, String.format("线程已在提醒状态,退出运行 | threadId=%d", getId())); + return; + } + isReminding = true; + } + + // 核心电量检测循环 + LogUtils.d(TAG, String.format("进入电量检测循环 | 休眠时间=%dms | threadId=%d", sleepTime, getId())); + while (!isExist) { + try { + // 快速退出判断 + if (isExist) break; + + // 电量有效性校验(非0-100视为无效),退出电量提醒线程 + if (App.sQuantityOfElectricity < BATTERY_LEVEL_MIN || App.sQuantityOfElectricity > BATTERY_LEVEL_MAX) { + LogUtils.w(TAG, String.format("电量无效,退出电量提醒线程 | 当前电量=%d | threadId=%d", App.sQuantityOfElectricity, getId())); + break; + } + + // 充电/耗电提醒触发逻辑 + boolean chargeRemindTrigger = isCharging && isEnableChargeReminder && App.sQuantityOfElectricity >= chargeReminderValue; + boolean usageRemindTrigger = !isCharging && isEnableUsageReminder && App.sQuantityOfElectricity <= usageReminderValue; + + if (chargeRemindTrigger) { + LogUtils.d(TAG, String.format("触发充电提醒 | 当前电量=%d ≥ 阈值=%d | threadId=%d", App.sQuantityOfElectricity, chargeReminderValue, getId())); + sendNotificationMessageInternal(REMIND_TYPE_CHARGE, App.sQuantityOfElectricity, isCharging); + } else if (usageRemindTrigger) { + LogUtils.d(TAG, String.format("触发耗电提醒 | 当前电量=%d ≤ 阈值=%d | threadId=%d", App.sQuantityOfElectricity, usageReminderValue, getId())); + sendNotificationMessageInternal(REMIND_TYPE_USAGE, App.sQuantityOfElectricity, isCharging); + } else { + LogUtils.d(TAG, String.format("未有合适类型提醒,退出提醒线程 | threadId=%d", getId())); + break; + } + + // 安全休眠,保留中断标记 + safeSleepInternal(sleepTime); + + } catch (Exception e) { + LogUtils.e(TAG, String.format("循环运行异常,退出电量提醒线程 | 当前电量=%d | threadId=%d", App.sQuantityOfElectricity, getId()), e); + break; + } + } + + // 循环退出,清理状态 + cleanThreadStateInternal(); + LogUtils.d(TAG, String.format("run() 结束 | threadId=%d", getId())); + } + + // ====================== 内部业务辅助方法 ====================== + /** + * 发送提醒消息到Handler(弱引用避免内存泄漏) + * @param type 提醒类型:+充电/-耗电 + * @param battery 当前电量 + * @param isCharging 充电状态 + */ + private void sendNotificationMessageInternal(String type, int battery, boolean isCharging) { + LogUtils.d(TAG, String.format("sendNotificationMessageInternal() 调用 | 类型=%s | 电量=%d | isCharging=%b | threadId=%d", type, battery, isCharging, getId())); + // 前置状态校验 + if (isExist || !isReminding) { + LogUtils.d(TAG, String.format("消息发送跳过:线程已退出或提醒关闭 | threadId=%d", getId())); + return; + } + + // 获取弱引用的Handler(校验有效性) + ControlCenterServiceHandler handler = mwrControlCenterServiceHandler.get(); + if (handler == null) { + LogUtils.w(TAG, String.format("消息发送失败:Handler已被回收 | threadId=%d", getId())); + return; + } + + // 构建并发送消息 + Message message = Message.obtain(handler, ControlCenterServiceHandler.MSG_REMIND_TEXT); + message.obj = type; + message.arg1 = battery; + message.arg2 = isCharging ? 1 : 0; + + try { + handler.sendMessage(message); + LogUtils.d(TAG, String.format("提醒消息发送成功 | 类型=%s | 电量=%d | threadId=%d", type, battery, getId())); + } catch (Exception e) { + LogUtils.e(TAG, String.format("消息发送异常 | threadId=%d", getId()), e); + // 异常时回收Message,避免内存泄漏 + if (message != null) { + message.recycle(); + } + } + } + + /** + * 安全休眠,响应线程中断 + * @param millis 休眠时长(ms) + */ + private void safeSleepInternal(long millis) { + LogUtils.d(TAG, String.format("safeSleepInternal() 调用 | 休眠时长=%dms | threadId=%d", millis, getId())); + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LogUtils.w(TAG, String.format("休眠被中断,线程准备退出 | threadId=%d", getId())); + } + } + + /** + * 重置线程初始状态(构造器专用) + */ + private void resetThreadStateInternal() { + LogUtils.d(TAG, String.format("resetThreadStateInternal() 调用 | threadId=%d", getId())); + // 状态标记初始化 + isExist = false; + isReminding = false; + // 配置参数初始化 + isEnableChargeReminder = false; + isEnableUsageReminder = false; + sleepTime = MIN_SLEEP_TIME; + chargeReminderValue = -1; + usageReminderValue = -1; + isCharging = false; + LogUtils.d(TAG, String.format("线程初始状态重置完成 | threadId=%d", getId())); + } + + /** + * 清理线程运行状态(循环退出时调用) + */ + private void cleanThreadStateInternal() { + LogUtils.d(TAG, String.format("cleanThreadStateInternal() 调用 | threadId=%d", getId())); + isReminding = false; + isExist = true; + // 中断当前线程(如果存活) + if (isAlive()) { + interrupt(); + LogUtils.d(TAG, String.format("线程已中断 | threadId=%d", getId())); + } + LogUtils.d(TAG, String.format("线程运行状态清理完成 | threadId=%d", getId())); + } + + /** + * 同步应用配置,校验参数有效性 + * @param config 应用配置Bean + */ + public void setAppConfigBean(AppConfigBean config) { + LogUtils.d(TAG, String.format("setAppConfigBean() 调用 | config=%s | threadId=%d", config, getId())); + if (config == null) { + LogUtils.e(TAG, String.format("配置同步失败:配置Bean为空 | threadId=%d", getId())); + return; + } + + // 配置参数同步 + 范围校验(确保参数合法) + isEnableChargeReminder = config.isEnableChargeReminder(); + isEnableUsageReminder = config.isEnableUsageReminder(); + chargeReminderValue = Math.min(Math.max(config.getChargeReminderValue(), BATTERY_LEVEL_MIN), BATTERY_LEVEL_MAX); + usageReminderValue = Math.min(Math.max(config.getUsageReminderValue(), BATTERY_LEVEL_MIN), BATTERY_LEVEL_MAX); + sleepTime = Math.max(config.getBatteryDetectInterval(), MIN_SLEEP_TIME); +// sQuantityOfElectricity = (config.getCurrentBatteryValue() >= BATTERY_LEVEL_MIN && config.getCurrentBatteryValue() <= BATTERY_LEVEL_MAX) +// ? config.getCurrentBatteryValue() : INVALID_BATTERY_VALUE; + isCharging = config.isCharging(); + + LogUtils.d(TAG, String.format("配置同步完成 | 休眠时间=%dms | 充电提醒=%b | 耗电提醒=%b | 当前电量=%d | 充电阈值=%d | 耗电阈值=%d | threadId=%d", + sleepTime, isEnableChargeReminder, isEnableUsageReminder, App.sQuantityOfElectricity, chargeReminderValue, usageReminderValue, getId())); + } + + /** + * 判断线程是否处于运行状态 + * @return true: 运行中;false: 已停止 + */ + private boolean isRunning() { + boolean running = !isExist && isAlive(); + LogUtils.d(TAG, String.format("isRunning() 调用 | 运行中=%b | 退出标记=%b | 存活=%b | threadId=%d", running, isExist, isAlive(), getId())); + return running; + } + + // ====================== Getter/Setter(按需开放) ====================== + public void setIsExist(boolean isExist) { + LogUtils.d(TAG, String.format("setIsExist() 调用 | isExist=%b | threadId=%d", isExist, getId())); + this.isExist = isExist; + } + + public boolean isExist() { + return isExist; + } + + // ====================== 调试辅助方法 ====================== + @Override + public String toString() { + return "RemindThread{" + + "threadId=" + getId() + + ", threadName='" + getName() + '\'' + + ", isRunning=" + isRunning() + + ", isReminding=" + isReminding + + ", chargeThreshold=" + chargeReminderValue + + ", usageThreshold=" + usageReminderValue + + ", currentBattery=" + App.sQuantityOfElectricity + + ", isCharging=" + isCharging + + ", sleepTime=" + sleepTime + "ms" + + '}'; + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/unittest/MainUnitTest2Activity.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/unittest/MainUnitTest2Activity.java new file mode 100644 index 0000000..27e68aa --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/unittest/MainUnitTest2Activity.java @@ -0,0 +1,285 @@ +package cc.winboll.studio.powerbell.unittest; + +import android.content.Intent; +import android.os.Bundle; +import android.os.Environment; +import android.os.Handler; +import android.os.Looper; +import android.view.View; +import android.widget.Button; +import android.widget.LinearLayout; +import androidx.appcompat.app.AppCompatActivity; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import cc.winboll.studio.powerbell.MainActivity; +import cc.winboll.studio.powerbell.R; +import cc.winboll.studio.powerbell.models.BackgroundBean; +import cc.winboll.studio.powerbell.utils.BackgroundSourceUtils; +import cc.winboll.studio.powerbell.utils.FileUtils; +import cc.winboll.studio.powerbell.utils.ImageCropUtils; +import cc.winboll.studio.powerbell.utils.ImageUtils; +import cc.winboll.studio.powerbell.views.MemoryCachedBackgroundView; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +/** + * 单元测试页面2(内存缓存背景视图专用) + * 功能:测试MemoryCachedBackgroundView加载、图片裁剪、双重刷新预览等功能 + * 适配:Java7 | API30 | 私有目录文件操作 | 无Uri冲突 | 内存缓存视图 + * @Author 豆包&ZhanGSKen + * @Describe 单元测试页2:验证带内存缓存的背景视图相关逻辑 + */ +public class MainUnitTest2Activity extends AppCompatActivity { + // ====================== 静态常量区(置顶归类,消除魔法值) ====================== + public static final String TAG = "MainUnitTest2Activity"; + public static final int REQUEST_CROP_IMAGE = 0; + private static final String ASSETS_TEST_IMAGE_PATH = "unittest/unittest-miku.png"; + private static final long FILE_MIN_SIZE = 100L; + private static final long DOUBLE_REFRESH_DELAY = 200L; + + // ====================== 成员变量区(按功能分层,移除所有Uri相关) ====================== + private MemoryCachedBackgroundView mMemoryCachedBackgroundView; + private LinearLayout mllBackgroundView; + private String mAppPrivateDirPath; + private File mPrivateTestImageFile; + private File mPrivateCropImageFile; + private BackgroundBean mPreviewBackgroundBean; + + // ====================== 生命周期方法(按执行顺序:onCreate→onActivityResult) ====================== + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + LogUtils.d(TAG, "=== 页面 onCreate 启动 ==="); + + initBaseParams(); + initViewAndEvent(); + copyAssetsTestImageToPrivateDir(); + initBackgroundBean(); + doubleRefreshPreview(); + + ToastUtils.show("单元测试页面2启动完成"); + LogUtils.d(TAG, "=== 页面 onCreate 初始化结束 ==="); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + LogUtils.d(TAG, String.format("=== onActivityResult 回调 | requestCode=%d | resultCode=%d ===", requestCode, resultCode)); + if (requestCode == REQUEST_CROP_IMAGE) { + handleCropResult(resultCode); + } + } + + // ====================== 初始化相关方法(基础参数→视图→背景Bean) ====================== + /** + * 初始化基础参数:私有目录、测试文件 + */ + private void initBaseParams() { + LogUtils.d(TAG, "initBaseParams:初始化基础参数"); + // 初始化私有目录(无需权限,无UID冲突) + mAppPrivateDirPath = getExternalFilesDir(Environment.DIRECTORY_PICTURES).getAbsolutePath() + "/PowerBellTest/"; + File privateDir = new File(mAppPrivateDirPath); + if (!privateDir.exists()) { + boolean isDirCreated = privateDir.mkdirs(); + LogUtils.d(TAG, String.format("initBaseParams:创建私有目录 | 路径=%s | 结果=%b", mAppPrivateDirPath, isDirCreated)); + } + + // 初始化测试文件与裁剪文件(无Uri) + File refFile = new File(ASSETS_TEST_IMAGE_PATH); + String uniqueTestName = FileUtils.createUniqueFileName(refFile) + ".png"; + String uniqueCropName = uniqueTestName.replace(".png", "_crop.png"); + mPrivateTestImageFile = new File(mAppPrivateDirPath, uniqueTestName); + mPrivateCropImageFile = new File(mAppPrivateDirPath, uniqueCropName); + + LogUtils.d(TAG, String.format("initBaseParams:测试图路径=%s", mPrivateTestImageFile.getAbsolutePath())); + LogUtils.d(TAG, String.format("initBaseParams:裁剪图路径=%s", mPrivateCropImageFile.getAbsolutePath())); + } + + /** + * 初始化布局与控件事件(含单例视图创建) + */ + private void initViewAndEvent() { + LogUtils.d(TAG, "initViewAndEvent:初始化布局与控件事件"); + setContentView(R.layout.activity_mainunittest2); + mllBackgroundView = (LinearLayout) findViewById(R.id.ll_backgroundview); + + // 创建MemoryCachedBackgroundView单例并添加到布局 + int nCurrentPixelColor = BackgroundSourceUtils.getInstance(this).getCurrentBackgroundBean().getPixelColor(); + mMemoryCachedBackgroundView = MemoryCachedBackgroundView.getLastInstance(this); + mllBackgroundView.addView(mMemoryCachedBackgroundView); + LogUtils.d(TAG, "initViewAndEvent:内存缓存背景视图实例创建并添加完成"); + + // 跳转主页面按钮 + Button btnMain = (Button) findViewById(R.id.btn_main_activity); + btnMain.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "initViewAndEvent:点击按钮→跳转主页面"); + startActivity(new Intent(MainUnitTest2Activity.this, MainActivity.class)); + } + }); + + // 裁剪按钮(直接用File路径启动,无Uri) + Button btnCrop = (Button) findViewById(R.id.btn_test_cropimage); + btnCrop.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "initViewAndEvent:点击按钮→启动裁剪(File路径版)"); + ToastUtils.show("准备启动图片裁剪"); + + if (isFileValid(mPrivateTestImageFile)) { + startCropTestByFile(); + } else { + ToastUtils.show("测试图片未准备好,重新拷贝"); + copyAssetsTestImageToPrivateDir(); + } + } + }); + } + + /** + * 初始化背景Bean + */ + private void initBackgroundBean() { + LogUtils.d(TAG, "initBackgroundBean:初始化背景Bean"); + mPreviewBackgroundBean = new BackgroundBean(); + mPreviewBackgroundBean.setPixelColor(ImageUtils.getColorAccent(this)); + mPreviewBackgroundBean.setBackgroundFileName(mPrivateTestImageFile.getName()); + mPreviewBackgroundBean.setBackgroundFilePath(mPrivateTestImageFile.getAbsolutePath()); + mPreviewBackgroundBean.setBackgroundScaledCompressFileName(mPrivateCropImageFile.getName()); + mPreviewBackgroundBean.setBackgroundScaledCompressFilePath(mPrivateCropImageFile.getAbsolutePath()); + mPreviewBackgroundBean.setIsUseBackgroundFile(true); + LogUtils.d(TAG, "initBackgroundBean:背景Bean初始化完成"); + } + + // ====================== 核心业务方法(文件拷贝→裁剪→结果处理→预览刷新) ====================== + /** + * 从assets拷贝图片到私有目录 + */ + private void copyAssetsTestImageToPrivateDir() { + LogUtils.d(TAG, "copyAssetsTestImageToPrivateDir:开始拷贝assets图片到私有目录"); + if (isFileValid(mPrivateTestImageFile)) { + LogUtils.d(TAG, "copyAssetsTestImageToPrivateDir:图片已存在,无需拷贝"); + return; + } + + InputStream inputStream = null; + try { + inputStream = getAssets().open(ASSETS_TEST_IMAGE_PATH); + FileUtils.copyStreamToFile(inputStream, mPrivateTestImageFile); + LogUtils.d(TAG, String.format("copyAssetsTestImageToPrivateDir:图片拷贝成功 | 大小=%d字节", mPrivateTestImageFile.length())); + } catch (IOException e) { + LogUtils.e(TAG, String.format("copyAssetsTestImageToPrivateDir:图片拷贝失败 | %s", e.getMessage()), e); + ToastUtils.show("图片准备失败"); + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException e) { + LogUtils.e(TAG, String.format("copyAssetsTestImageToPrivateDir:关闭流失败 | %s", e.getMessage())); + } + } + } + } + + /** + * 直接用File启动裁剪(关键:调用ImageCropUtils的File重载方法) + */ + private void startCropTestByFile() { + LogUtils.d(TAG, String.format("startCropTestByFile:启动裁剪 | 原图=%s", mPrivateTestImageFile.getAbsolutePath())); + + // 确保输出目录存在 + File cropParent = mPrivateCropImageFile.getParentFile(); + if (!cropParent.exists()) { + boolean isDirCreated = cropParent.mkdirs(); + LogUtils.d(TAG, String.format("startCropTestByFile:创建裁剪目录 | 路径=%s | 结果=%b", cropParent.getAbsolutePath(), isDirCreated)); + } + + // 调用ImageCropUtils的File参数方法(核心:绕开Uri) + ImageCropUtils.startImageCrop( + this, + mPrivateTestImageFile, + mPrivateCropImageFile, + 0, + 0, + true, + REQUEST_CROP_IMAGE + ); + + LogUtils.d(TAG, String.format("startCropTestByFile:裁剪请求已发送 | 输出路径=%s", mPrivateCropImageFile.getAbsolutePath())); + ToastUtils.show("已启动图片裁剪"); + } + + /** + * 处理裁剪结果(直接校验输出File) + * @param resultCode 裁剪结果码 + */ + private void handleCropResult(int resultCode) { + LogUtils.d(TAG, String.format("handleCropResult:裁剪回调处理 | resultCode=%d", resultCode)); + if (resultCode == RESULT_OK) { + if (isFileValid(mPrivateCropImageFile)) { + int nCurrentPixelColor = BackgroundSourceUtils.getInstance(this).getCurrentBackgroundBean().getPixelColor(); + mMemoryCachedBackgroundView.loadImage(nCurrentPixelColor, mPrivateCropImageFile.getAbsolutePath(), true); + LogUtils.d(TAG, String.format("handleCropResult:裁剪成功 | 加载裁剪图=%s", mPrivateCropImageFile.getAbsolutePath())); + ToastUtils.show("裁剪成功"); + mPreviewBackgroundBean.setIsUseBackgroundScaledCompressFile(true); + doubleRefreshPreview(); + } else { + LogUtils.e(TAG, "handleCropResult:裁剪成功但输出文件无效"); + ToastUtils.show("裁剪失败:输出文件无效"); + } + } else if (resultCode == RESULT_CANCELED) { + LogUtils.d(TAG, "handleCropResult:裁剪取消"); + ToastUtils.show("裁剪已取消"); + } else { + LogUtils.e(TAG, String.format("handleCropResult:裁剪失败 | resultCode异常=%d", resultCode)); + ToastUtils.show("裁剪失败"); + } + } + + /** + * 双重刷新预览,确保背景加载最新数据 + */ + private void doubleRefreshPreview() { + LogUtils.d(TAG, "doubleRefreshPreview:执行双重刷新预览"); + // 第一重刷新 + try { + mMemoryCachedBackgroundView.loadByBackgroundBean(mPreviewBackgroundBean, true); + //mMemoryCachedBackgroundView.setBackgroundColor(mPreviewBackgroundBean.getPixelColor()); + LogUtils.d(TAG, "doubleRefreshPreview:【双重刷新】第一重完成"); + } catch (Exception e) { + LogUtils.e(TAG, String.format("doubleRefreshPreview:【双重刷新】第一重异常 | %s", e.getMessage())); + return; + } + + // 第二重刷新(延迟执行) + new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { + @Override + public void run() { + if (mMemoryCachedBackgroundView != null && !isFinishing()) { + try { + mMemoryCachedBackgroundView.loadByBackgroundBean(mPreviewBackgroundBean, true); + //mMemoryCachedBackgroundView.setBackgroundColor(mPreviewBackgroundBean.getPixelColor()); + LogUtils.d(TAG, "doubleRefreshPreview:【双重刷新】第二重完成"); + } catch (Exception e) { + LogUtils.e(TAG, String.format("doubleRefreshPreview:【双重刷新】第二重异常 | %s", e.getMessage())); + } + } + } + }, DOUBLE_REFRESH_DELAY); + } + + // ====================== 工具辅助方法(文件校验) ====================== + /** + * 校验文件是否有效(存在且大小达标) + * @param file 待校验文件 + * @return true=有效 false=无效 + */ + private boolean isFileValid(File file) { + boolean isValid = file != null && file.exists() && file.length() > FILE_MIN_SIZE; + LogUtils.d(TAG, String.format("isFileValid:文件校验 | 路径=%s | 结果=%b", file != null ? file.getAbsolutePath() : "null", isValid)); + return isValid; + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/unittest/MainUnitTestActivity.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/unittest/MainUnitTestActivity.java new file mode 100644 index 0000000..c66817f --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/unittest/MainUnitTestActivity.java @@ -0,0 +1,275 @@ +package cc.winboll.studio.powerbell.unittest; + +import android.content.Intent; +import android.os.Bundle; +import android.os.Environment; +import android.os.Handler; +import android.os.Looper; +import android.view.View; +import android.widget.Button; +import androidx.appcompat.app.AppCompatActivity; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import cc.winboll.studio.powerbell.MainActivity; +import cc.winboll.studio.powerbell.R; +import cc.winboll.studio.powerbell.models.BackgroundBean; +import cc.winboll.studio.powerbell.utils.FileUtils; +import cc.winboll.studio.powerbell.utils.ImageCropUtils; +import cc.winboll.studio.powerbell.utils.ImageUtils; +import cc.winboll.studio.powerbell.views.BackgroundView; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +/** + * 单元测试页面 + * 功能:测试背景图加载、图片裁剪、双重刷新预览等功能 + * 适配:Java7 | API30 | 私有目录文件操作 | 无Uri冲突 + * @Author 豆包&ZhanGSKen + * @Describe 单元测试页:验证图片处理与背景预览相关逻辑 + */ +public class MainUnitTestActivity extends AppCompatActivity { + // ====================== 静态常量区(置顶归类,消除魔法值) ====================== + public static final String TAG = "MainUnitTestActivity"; + public static final int REQUEST_CROP_IMAGE = 0; + private static final String ASSETS_TEST_IMAGE_PATH = "unittest/unittest-miku.png"; + private static final long FILE_MIN_SIZE = 100L; + private static final long DOUBLE_REFRESH_DELAY = 200L; + + // ====================== 成员变量区(按功能分层,移除所有Uri相关) ====================== + private BackgroundView mBackgroundView; + private String mAppPrivateDirPath; + private File mPrivateTestImageFile; // 仅用File,不用Uri + private File mPrivateCropImageFile; + private BackgroundBean mPreviewBackgroundBean; + + // ====================== 生命周期方法(按执行顺序:onCreate→onActivityResult) ====================== + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + LogUtils.d(TAG, "=== 页面 onCreate 启动 ==="); + + initBaseParams(); + initViewAndEvent(); + copyAssetsTestImageToPrivateDir(); + initBackgroundBean(); + doubleRefreshPreview(); + + ToastUtils.show("单元测试页面启动完成"); + LogUtils.d(TAG, "=== 页面 onCreate 初始化结束 ==="); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + LogUtils.d(TAG, String.format("=== onActivityResult 回调 | requestCode=%d | resultCode=%d ===", requestCode, resultCode)); + if (requestCode == REQUEST_CROP_IMAGE) { + handleCropResult(resultCode); + } + } + + // ====================== 初始化相关方法(基础参数→视图→背景Bean) ====================== + /** + * 初始化基础参数:私有目录、测试文件 + */ + private void initBaseParams() { + LogUtils.d(TAG, "initBaseParams:初始化基础参数"); + // 初始化私有目录(无需权限,无UID冲突) + mAppPrivateDirPath = getExternalFilesDir(Environment.DIRECTORY_PICTURES).getAbsolutePath() + "/PowerBellTest/"; + File privateDir = new File(mAppPrivateDirPath); + if (!privateDir.exists()) { + boolean isDirCreated = privateDir.mkdirs(); + LogUtils.d(TAG, String.format("initBaseParams:创建私有目录 | 路径=%s | 结果=%b", mAppPrivateDirPath, isDirCreated)); + } + + // 初始化测试文件与裁剪文件(无Uri) + File refFile = new File(ASSETS_TEST_IMAGE_PATH); + String uniqueTestName = FileUtils.createUniqueFileName(refFile) + ".png"; + String uniqueCropName = uniqueTestName.replace(".png", "_crop.png"); + mPrivateTestImageFile = new File(mAppPrivateDirPath, uniqueTestName); + mPrivateCropImageFile = new File(mAppPrivateDirPath, uniqueCropName); + + LogUtils.d(TAG, String.format("initBaseParams:测试图路径=%s", mPrivateTestImageFile.getAbsolutePath())); + LogUtils.d(TAG, String.format("initBaseParams:裁剪图路径=%s", mPrivateCropImageFile.getAbsolutePath())); + } + + /** + * 初始化布局与控件事件 + */ + private void initViewAndEvent() { + LogUtils.d(TAG, "initViewAndEvent:初始化布局与控件事件"); + setContentView(R.layout.activity_mainunittest); + mBackgroundView = (BackgroundView) findViewById(R.id.backgroundview); + + // 跳转主页面按钮 + Button btnMain = (Button) findViewById(R.id.btn_main_activity); + btnMain.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "initViewAndEvent:点击按钮→跳转主页面"); + startActivity(new Intent(MainUnitTestActivity.this, MainActivity.class)); + } + }); + + // 裁剪按钮(直接用File路径启动,无Uri) + Button btnCrop = (Button) findViewById(R.id.btn_test_cropimage); + btnCrop.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "initViewAndEvent:点击按钮→启动裁剪(File路径版)"); + ToastUtils.show("准备启动图片裁剪"); + + if (isFileValid(mPrivateTestImageFile)) { + startCropTestByFile(); + } else { + ToastUtils.show("测试图片未准备好,重新拷贝"); + copyAssetsTestImageToPrivateDir(); + } + } + }); + } + + /** + * 初始化背景Bean + */ + private void initBackgroundBean() { + LogUtils.d(TAG, "initBackgroundBean:初始化背景Bean"); + mPreviewBackgroundBean = new BackgroundBean(); + mPreviewBackgroundBean.setPixelColor(ImageUtils.getColorAccent(this)); + mPreviewBackgroundBean.setBackgroundFileName(mPrivateTestImageFile.getName()); + mPreviewBackgroundBean.setBackgroundFilePath(mPrivateTestImageFile.getAbsolutePath()); + mPreviewBackgroundBean.setBackgroundScaledCompressFileName(mPrivateCropImageFile.getName()); + mPreviewBackgroundBean.setBackgroundScaledCompressFilePath(mPrivateCropImageFile.getAbsolutePath()); + mPreviewBackgroundBean.setIsUseBackgroundFile(true); + LogUtils.d(TAG, "initBackgroundBean:背景Bean初始化完成"); + } + + // ====================== 核心业务方法(文件拷贝→裁剪→结果处理→预览刷新) ====================== + /** + * 从assets拷贝图片到私有目录 + */ + private void copyAssetsTestImageToPrivateDir() { + LogUtils.d(TAG, "copyAssetsTestImageToPrivateDir:开始拷贝assets图片到私有目录"); + if (isFileValid(mPrivateTestImageFile)) { + LogUtils.d(TAG, "copyAssetsTestImageToPrivateDir:图片已存在,无需拷贝"); + return; + } + + InputStream inputStream = null; + try { + inputStream = getAssets().open(ASSETS_TEST_IMAGE_PATH); + FileUtils.copyStreamToFile(inputStream, mPrivateTestImageFile); + LogUtils.d(TAG, String.format("copyAssetsTestImageToPrivateDir:图片拷贝成功 | 大小=%d字节", mPrivateTestImageFile.length())); + } catch (IOException e) { + LogUtils.e(TAG, String.format("copyAssetsTestImageToPrivateDir:图片拷贝失败 | %s", e.getMessage()), e); + ToastUtils.show("图片准备失败"); + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException e) { + LogUtils.e(TAG, String.format("copyAssetsTestImageToPrivateDir:关闭流失败 | %s", e.getMessage())); + } + } + } + } + + /** + * 直接用File启动裁剪(关键:调用ImageCropUtils的File重载方法) + */ + private void startCropTestByFile() { + LogUtils.d(TAG, String.format("startCropTestByFile:启动裁剪 | 原图=%s", mPrivateTestImageFile.getAbsolutePath())); + + // 确保输出目录存在 + File cropParent = mPrivateCropImageFile.getParentFile(); + if (!cropParent.exists()) { + boolean isDirCreated = cropParent.mkdirs(); + LogUtils.d(TAG, String.format("startCropTestByFile:创建裁剪目录 | 路径=%s | 结果=%b", cropParent.getAbsolutePath(), isDirCreated)); + } + + // 调用ImageCropUtils的File参数方法(核心:绕开Uri) + ImageCropUtils.startImageCrop( + this, + mPrivateTestImageFile, // 原图File + mPrivateCropImageFile, // 输出File + 0, + 0, + true, + REQUEST_CROP_IMAGE + ); + + LogUtils.d(TAG, String.format("startCropTestByFile:裁剪请求已发送 | 输出路径=%s", mPrivateCropImageFile.getAbsolutePath())); + ToastUtils.show("已启动图片裁剪"); + } + + /** + * 处理裁剪结果(直接校验输出File) + * @param resultCode 裁剪结果码 + */ + private void handleCropResult(int resultCode) { +// LogUtils.d(TAG, String.format("handleCropResult:裁剪回调处理 | resultCode=%d", resultCode)); +// if (resultCode == RESULT_OK) { +// if (isFileValid(mPrivateCropImageFile)) { +// mBackgroundView.loadImage(mPrivateCropImageFile.getAbsolutePath()); +// LogUtils.d(TAG, String.format("handleCropResult:裁剪成功 | 加载裁剪图=%s", mPrivateCropImageFile.getAbsolutePath())); +// ToastUtils.show("裁剪成功"); +// mPreviewBackgroundBean.setIsUseBackgroundScaledCompressFile(true); +// doubleRefreshPreview(); +// } else { +// LogUtils.e(TAG, "handleCropResult:裁剪成功但输出文件无效"); +// ToastUtils.show("裁剪失败:输出文件无效"); +// } +// } else if (resultCode == RESULT_CANCELED) { +// LogUtils.d(TAG, "handleCropResult:裁剪取消"); +// ToastUtils.show("裁剪已取消"); +// } else { +// LogUtils.e(TAG, String.format("handleCropResult:裁剪失败 | resultCode异常=%d", resultCode)); +// ToastUtils.show("裁剪失败"); +// } + } + + /** + * 双重刷新预览,确保背景加载最新数据 + */ + private void doubleRefreshPreview() { + LogUtils.d(TAG, "doubleRefreshPreview:执行双重刷新预览"); + // 第一重刷新 + try { + mBackgroundView.loadByBackgroundBean(mPreviewBackgroundBean, true); + mBackgroundView.setBackgroundColor(mPreviewBackgroundBean.getPixelColor()); + LogUtils.d(TAG, "doubleRefreshPreview:【双重刷新】第一重完成"); + } catch (Exception e) { + LogUtils.e(TAG, String.format("doubleRefreshPreview:【双重刷新】第一重异常 | %s", e.getMessage())); + return; + } + + // 第二重刷新(延迟执行) + new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { + @Override + public void run() { + if (mBackgroundView != null && !isFinishing()) { + try { + mBackgroundView.loadByBackgroundBean(mPreviewBackgroundBean, true); + mBackgroundView.setBackgroundColor(mPreviewBackgroundBean.getPixelColor()); + LogUtils.d(TAG, "doubleRefreshPreview:【双重刷新】第二重完成"); + } catch (Exception e) { + LogUtils.e(TAG, String.format("doubleRefreshPreview:【双重刷新】第二重异常 | %s", e.getMessage())); + } + } + } + }, DOUBLE_REFRESH_DELAY); + } + + // ====================== 工具辅助方法(文件校验) ====================== + /** + * 校验文件是否有效(存在且大小达标) + * @param file 待校验文件 + * @return true=有效 false=无效 + */ + private boolean isFileValid(File file) { + boolean isValid = file != null && file.exists() && file.length() > FILE_MIN_SIZE; + LogUtils.d(TAG, String.format("isFileValid:文件校验 | 路径=%s | 结果=%b", file != null ? file.getAbsolutePath() : "null", isValid)); + return isValid; + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/APPPlusUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/APPPlusUtils.java new file mode 100644 index 0000000..181e3d8 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/APPPlusUtils.java @@ -0,0 +1,203 @@ +package cc.winboll.studio.powerbell.utils; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Build; +import android.widget.Toast; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.App; +import cc.winboll.studio.powerbell.R; + +/** + * 应用图标切换工具类(启用组件时创建对应快捷方式) + * 适配:Java7 | API30 | 高低版本快捷方式创建兼容 + * @Author 豆包&ZhanGSKen + * @Describe 应用启动器组件切换与桌面快捷方式创建工具,支持多组件管理与版本兼容 + */ +public class APPPlusUtils { + // ======================== 静态常量区(魔法值与标签管理)======================== + public static final String TAG = "APPPlusUtils"; + private static final int SHORTCUT_ICON_DEFAULT = R.drawable.ic_launcher; // 默认快捷方式图标 + private static final String ACTION_INSTALL_SHORTCUT = "com.android.launcher.action.INSTALL_SHORTCUT"; // 旧版快捷方式广播Action + + // ======================== 公共业务方法区(对外核心接口)======================== + /** + * 切换应用启动器组件(禁用其他组件,启用目标组件) + * @param context 上下文 + * @param componentName 目标组件完整类名 + * @return 切换是否成功 + */ + public static boolean switchAppLauncherToComponent(Context context, String componentName) { + LogUtils.d(TAG, String.format("switchAppLauncherToComponent调用 | 传入组件名=%s", componentName)); + + // 参数校验 + if (context == null) { + LogUtils.e(TAG, "switchAppLauncherToComponent失败:上下文为空"); + return false; + } + if (componentName == null || componentName.isEmpty()) { + LogUtils.e(TAG, "switchAppLauncherToComponent失败:组件名为空"); + return false; + } + + PackageManager pm = context.getPackageManager(); + ComponentName targetComponent = new ComponentName(context, componentName); + ComponentName en1Component = new ComponentName(context, App.COMPONENT_EN1); + ComponentName cn1Component = new ComponentName(context, App.COMPONENT_CN1); + ComponentName cn2Component = new ComponentName(context, App.COMPONENT_CN2); + + try { + // 禁用所有其他启动器组件 + disableComponent(pm, en1Component); + disableComponent(pm, cn1Component); + disableComponent(pm, cn2Component); + // 启用目标组件 + enableComponent(pm, targetComponent); + + LogUtils.d(TAG, String.format("switchAppLauncherToComponent成功 | 目标组件=%s", componentName)); + Toast.makeText(context, context.getString(R.string.app_name) + "图标切换成功", Toast.LENGTH_SHORT).show(); + return true; + + } catch (Exception e) { + LogUtils.e(TAG, String.format("switchAppLauncherToComponent失败 | 异常信息=%s", e.getMessage()), e); + Toast.makeText(context, context.getString(R.string.app_name) + "图标切换失败:" + e.getMessage(), Toast.LENGTH_SHORT).show(); + return false; + } + } + + // ======================== 私有辅助方法区(组件状态控制)======================== + /** + * 启用组件(带状态检查,避免重复操作) + * @param pm 包管理器 + * @param component 目标组件 + */ + private static void enableComponent(PackageManager pm, ComponentName component) { + int currentState = pm.getComponentEnabledSetting(component); + String componentName = component.getClassName(); + + if (currentState != PackageManager.COMPONENT_ENABLED_STATE_ENABLED) { + pm.setComponentEnabledSetting( + component, + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP | PackageManager.SYNCHRONOUS + ); + LogUtils.d(TAG, String.format("enableComponent成功 | 组件=%s", componentName)); + } else { + LogUtils.d(TAG, String.format("enableComponent无需操作 | 组件已启用=%s", componentName)); + } + } + + /** + * 禁用组件(带状态检查,避免重复操作) + * @param pm 包管理器 + * @param component 目标组件 + */ + private static void disableComponent(PackageManager pm, ComponentName component) { + int currentState = pm.getComponentEnabledSetting(component); + String componentName = component.getClassName(); + + if (currentState != PackageManager.COMPONENT_ENABLED_STATE_DISABLED) { + pm.setComponentEnabledSetting( + component, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP | PackageManager.SYNCHRONOUS + ); + LogUtils.d(TAG, String.format("disableComponent成功 | 组件=%s", componentName)); + } else { + LogUtils.d(TAG, String.format("disableComponent无需操作 | 组件已禁用=%s", componentName)); + } + } + + // ======================== 私有辅助方法区(快捷方式创建)======================== + /** + * 创建指定组件的桌面快捷方式(自动去重,兼容 Android 8.0+) + * @param context 上下文 + * @param component 目标组件 + * @param name 快捷方式名称 + * @param iconRes 快捷方式图标资源ID + * @return 是否创建成功 + */ + private static boolean createComponentShortcut(Context context, ComponentName component, String name, int iconRes) { + // 参数校验 + String componentName = component != null ? component.getClassName() : "null"; + LogUtils.d(TAG, String.format("createComponentShortcut调用 | 组件=%s | 名称=%s", componentName, name)); + + if (context == null || component == null || name == null || name.isEmpty()) { + LogUtils.e(TAG, "createComponentShortcut失败:上下文、组件或名称为空"); + return false; + } + + // 图标资源默认值补全 + int finalIconRes = iconRes != 0 ? iconRes : SHORTCUT_ICON_DEFAULT; + + // Android 8.0+(API 26+):使用 ShortcutManager(系统推荐) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + android.content.pm.ShortcutManager shortcutManager = context.getSystemService(android.content.pm.ShortcutManager.class); + if (shortcutManager == null || !shortcutManager.isRequestPinShortcutSupported()) { + LogUtils.w(TAG, "createComponentShortcut:系统不支持创建快捷方式"); + return false; + } + + // 检查是否已存在该组件的快捷方式(去重) + for (android.content.pm.ShortcutInfo info : shortcutManager.getPinnedShortcuts()) { + if (component.getClassName().equals(info.getIntent().getComponent().getClassName())) { + LogUtils.d(TAG, String.format("createComponentShortcut:快捷方式已存在=%s", componentName)); + return true; + } + } + + // 构建启动目标组件的意图 + Intent launchIntent = new Intent(Intent.ACTION_MAIN) + .setComponent(component) + .addCategory(Intent.CATEGORY_LAUNCHER) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + + // 构建快捷方式信息 + android.content.pm.ShortcutInfo shortcutInfo = new android.content.pm.ShortcutInfo.Builder(context, component.getClassName()) + .setShortLabel(name) + .setLongLabel(name) + .setIcon(android.graphics.drawable.Icon.createWithResource(context, finalIconRes)) + .setIntent(launchIntent) + .build(); + + // 请求创建快捷方式(需用户确认) + shortcutManager.requestPinShortcut(shortcutInfo, null); + LogUtils.d(TAG, "createComponentShortcut:Android O+ 快捷方式创建请求已发送"); + return true; + + } catch (Exception e) { + LogUtils.e(TAG, String.format("createComponentShortcut失败 | Android O+ 异常=%s", e.getMessage()), e); + return false; + } + } else { + // Android 8.0 以下:使用广播(兼容旧机型) + try { + // 构建启动目标组件的意图 + Intent launchIntent = new Intent(Intent.ACTION_MAIN) + .setComponent(component) + .addCategory(Intent.CATEGORY_LAUNCHER) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + + // 构建创建快捷方式的广播意图 + Intent installIntent = new Intent(ACTION_INSTALL_SHORTCUT); + installIntent.putExtra(Intent.EXTRA_SHORTCUT_INTENT, launchIntent); + installIntent.putExtra(Intent.EXTRA_SHORTCUT_NAME, name); + installIntent.putExtra(Intent.EXTRA_SHORTCUT_ICON_RESOURCE, + Intent.ShortcutIconResource.fromContext(context, finalIconRes)); + installIntent.putExtra("duplicate", false); // 禁止重复创建 + + context.sendBroadcast(installIntent); + LogUtils.d(TAG, "createComponentShortcut:Android O- 快捷方式创建广播已发送"); + return true; + + } catch (Exception e) { + LogUtils.e(TAG, String.format("createComponentShortcut失败 | Android O- 异常=%s", e.getMessage()), e); + return false; + } + } + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/AppCacheUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/AppCacheUtils.java new file mode 100644 index 0000000..8cb769d --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/AppCacheUtils.java @@ -0,0 +1,143 @@ +package cc.winboll.studio.powerbell.utils; + +import android.content.Context; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.models.BatteryInfoBean; +import java.util.ArrayList; + +/** + * 应用缓存工具类(适配Android API 30,基于Java 7编写) + * 负责电池信息的缓存、持久化与管理 + * @Author 豆包&ZhanGSKen + * @Describe 电池信息缓存工具:实现电量变化记录、持久化存储与缓存限制 + */ +public class AppCacheUtils { + // ===================== 静态常量区(置顶归类,消除魔法值) ===================== + public static final String TAG = "AppCacheUtils"; + private static final int MAX_BATTERY_RECORD_COUNT = 180; // 电池记录最大条数限制 + + // ===================== 静态成员区(单例相关) ===================== + private static AppCacheUtils sInstance; + + // ===================== 成员变量区(按功能分层) ===================== + private Context mContext; // ApplicationContext,避免内存泄漏 + private ArrayList mBatteryInfoList; // 电池信息缓存列表 + + // ===================== 单例方法区(线程安全) ===================== + /** + * 获取单例实例 + * @param context 上下文(内部会转换为ApplicationContext) + * @return 唯一AppCacheUtils实例 + */ + public static synchronized AppCacheUtils getInstance(Context context) { + String contextType = context != null ? context.getClass().getSimpleName() : "null"; + LogUtils.d(TAG, String.format("getInstance调用 | 传入Context类型=%s", contextType)); + + if (sInstance == null) { + if (context == null) { + LogUtils.e(TAG, "getInstance失败:传入Context为null"); + throw new IllegalArgumentException("Context cannot be null"); + } + sInstance = new AppCacheUtils(context.getApplicationContext()); + LogUtils.d(TAG, "getInstance:单例实例初始化完成"); + } + return sInstance; + } + + // ===================== 私有构造方法区(禁止外部实例化) ===================== + /** + * 私有构造方法,初始化缓存列表并加载持久化数据 + * @param context ApplicationContext + */ + private AppCacheUtils(Context context) { + LogUtils.d(TAG, "AppCacheUtils构造方法调用"); + mContext = context; + mBatteryInfoList = new ArrayList(); + loadAppCacheData(); + LogUtils.d(TAG, String.format("AppCacheUtils构造完成 | 初始电池信息数量=%d", mBatteryInfoList.size())); + } + + // ===================== 公共业务方法区(对外暴露接口) ===================== + /** + * 添加电池电量变化记录(仅当电量变化时添加) + * @param batteryValue 电池电量值 + */ + public void addChangingTime(int batteryValue) { + LogUtils.d(TAG, String.format("addChangingTime调用 | 传入电量值=%d", batteryValue)); + + if (mBatteryInfoList.isEmpty()) { + addChangingTimeToList(batteryValue); + LogUtils.d(TAG, "addChangingTime:缓存列表为空,直接添加记录"); + return; + } + + // 对比最后一条记录的电量值,避免重复添加 + int lastBatteryValue = mBatteryInfoList.get(mBatteryInfoList.size() - 1).getBatteryValue(); + if (lastBatteryValue != batteryValue) { + addChangingTimeToList(batteryValue); + LogUtils.d(TAG, String.format("addChangingTime:电量变化,添加新记录 | 原电量=%d | 新电量=%d", lastBatteryValue, batteryValue)); + } else { + LogUtils.d(TAG, "addChangingTime:电量未变化,跳过添加"); + } + } + + /** + * 获取电池信息缓存列表 + * @return 完整的电池信息列表 + */ + public ArrayList getArrayListBatteryInfo() { + LogUtils.d(TAG, String.format("getArrayListBatteryInfo调用 | 当前缓存数量=%d", mBatteryInfoList.size())); + loadAppCacheData(); + return mBatteryInfoList; + } + + /** + * 清除所有电池历史记录 + */ + public void clearBatteryHistory() { + LogUtils.d(TAG, String.format("clearBatteryHistory调用 | 清除前缓存数量=%d", mBatteryInfoList.size())); + mBatteryInfoList.clear(); + saveAppCacheData(); + LogUtils.d(TAG, "clearBatteryHistory完成 | 缓存已清空"); + } + + // ===================== 私有辅助方法区(内部业务逻辑) ===================== + /** + * 内部方法:添加电量记录到列表并持久化 + * @param batteryValue 电池电量值 + */ + private void addChangingTimeToList(int batteryValue) { + LogUtils.d(TAG, String.format("addChangingTimeToList调用 | 传入电量值=%d", batteryValue)); + + // 限制列表最大长度,避免内存溢出 + if (mBatteryInfoList.size() >= MAX_BATTERY_RECORD_COUNT) { + mBatteryInfoList.remove(0); + LogUtils.d(TAG, String.format("addChangingTimeToList:列表超过%d条,移除最旧记录", MAX_BATTERY_RECORD_COUNT)); + } + + BatteryInfoBean batteryInfo = new BatteryInfoBean(System.currentTimeMillis(), batteryValue); + mBatteryInfoList.add(batteryInfo); + LogUtils.d(TAG, String.format("addChangingTimeToList:添加新记录 | 电量=%d | 时间戳=%d", batteryInfo.getBatteryValue(), batteryInfo.getTimeStamp())); + saveAppCacheData(); + } + + /** + * 从文件加载缓存数据 + */ + private void loadAppCacheData() { + LogUtils.d(TAG, "loadAppCacheData调用 | 开始加载持久化数据"); + mBatteryInfoList.clear(); + BatteryInfoBean.loadBeanList(mContext, mBatteryInfoList, BatteryInfoBean.class); + LogUtils.d(TAG, String.format("loadAppCacheData完成 | 加载数据数量=%d", mBatteryInfoList.size())); + } + + /** + * 保存缓存数据到文件 + */ + private void saveAppCacheData() { + LogUtils.d(TAG, String.format("saveAppCacheData调用 | 保存数据数量=%d", mBatteryInfoList.size())); + BatteryInfoBean.saveBeanList(mContext, mBatteryInfoList, BatteryInfoBean.class); + LogUtils.d(TAG, "saveAppCacheData完成 | 数据已持久化"); + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/AppConfigUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/AppConfigUtils.java new file mode 100644 index 0000000..6081bbb --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/AppConfigUtils.java @@ -0,0 +1,358 @@ +package cc.winboll.studio.powerbell.utils; + +import android.content.Context; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.App; +import cc.winboll.studio.powerbell.models.AppConfigBean; +import cc.winboll.studio.powerbell.models.ControlCenterServiceBean; +import cc.winboll.studio.powerbell.threads.RemindThread; + +/** + * 应用配置工具类:管理应用核心配置(服务开关、电池提醒阈值、背景设置等) + * 适配:Java7 | API30 | 小米手机,单例模式,线程安全,配置持久化 + * @Author 豆包&ZhanGSKen + * @Describe 应用配置全量管理工具,支持配置持久化、自动校准、线程安全访问 + */ +public class AppConfigUtils { + // ======================== 静态常量区(魔法值统一管理)======================== + public static final String TAG = "AppConfigUtils"; + public static final String BACKGROUND_DIR = "Background"; // 背景图片存储目录 + private static final int MIN_REMINDER_VALUE = 0; // 提醒阈值最小值 + private static final int MAX_REMINDER_VALUE = 100; // 提醒阈值最大值 + private static final int MIN_INTERVAL_TIME = 1000; // 最小提醒间隔(ms) + private static final int MIN_DETECT_INTERVAL = 500; // 最小电量检测间隔(ms) + + // ======================== 静态成员区(单例实例)======================== + private static volatile AppConfigUtils sInstance; // 单例实例(volatile保障双重校验锁有效性) + + // ======================== 成员变量区(按依赖优先级排序,final/volatile保障线程安全)======================== + private final Context mContext; // 应用上下文(ApplicationContext,避免内存泄漏) + private final App mApplication; // 应用Application实例(final保障不可变) + public volatile AppConfigBean mAppConfigBean; // 应用配置Bean(持久化核心,volatile保障线程安全) + private volatile boolean mIsServiceEnabled = false; // 服务开关缓存状态(减少Bean读取次数) + + // ======================== 单例相关方法区(双重校验锁+构造方法)======================== + /** + * 双重校验锁单例获取方法,线程安全 + * @param context 上下文(不可为null) + * @return 单例实例 + */ + public static AppConfigUtils getInstance(Context context) { + String contextType = context != null ? context.getClass().getSimpleName() : "null"; + LogUtils.d(TAG, String.format("getInstance() 调用 | 传入Context类型=%s", contextType)); + + if (context == null) { + LogUtils.e(TAG, "getInstance() 失败:Context不能为空"); + throw new IllegalArgumentException("Context cannot be null"); + } + + if (sInstance == null) { + synchronized (AppConfigUtils.class) { + if (sInstance == null) { + sInstance = new AppConfigUtils(context); + LogUtils.d(TAG, "getInstance():单例实例创建成功"); + } + } + } + + LogUtils.d(TAG, "getInstance():单例实例获取成功"); + return sInstance; + } + + /** + * 私有构造方法,禁止外部实例化 + * @param context 上下文(内部转换为ApplicationContext) + */ + private AppConfigUtils(Context context) { + LogUtils.d(TAG, "AppConfigUtils() 构造方法调用"); + this.mContext = context.getApplicationContext(); + this.mApplication = (App) context.getApplicationContext(); + mAppConfigBean = new AppConfigBean(); + loadAppConfig(); // 加载持久化配置 + LogUtils.d(TAG, "AppConfigUtils() 构造完成,配置初始化成功"); + } + + // ======================== 核心配置持久化方法区(加载+保存)======================== + /** + * 加载应用配置(初始化/重载通用入口) + * @return 加载后的应用配置Bean + */ + public AppConfigBean loadAppConfig() { + LogUtils.d(TAG, "loadAppConfig() 调用 | 开始加载应用配置"); + AppConfigBean savedAppBean = (AppConfigBean) AppConfigBean.loadBean(mContext, AppConfigBean.class); + + if (savedAppBean != null) { + mAppConfigBean = savedAppBean; + LogUtils.d(TAG, String.format("loadAppConfig() 成功 | 充电阈值=%d%% | 耗电阈值=%d%%", + mAppConfigBean.getChargeReminderValue(), mAppConfigBean.getUsageReminderValue())); + } else { + mAppConfigBean = new AppConfigBean(); + AppConfigBean.saveBean(mContext, mAppConfigBean); + LogUtils.d(TAG, "loadAppConfig():无已保存配置,使用默认值并持久化"); + } + + return mAppConfigBean; + } + + /** + * 保存应用配置(内部核心方法,直接持久化) + */ + public void saveAppConfig() { + AppConfigBean.saveBean(mContext, mAppConfigBean); + LogUtils.d(TAG, "saveAppConfig():应用配置保存成功"); + } + + // ======================== 充电提醒配置方法区(开关+阈值)======================== + /** + * 设置充电提醒开关状态 + * @param isEnabled 目标状态(true=开启,false=关闭) + */ + public void setChargeReminderEnabled(final boolean isEnabled) { + LogUtils.d(TAG, String.format("setChargeReminderEnabled() 调用 | 传入状态=%b", isEnabled)); + + if (isEnabled == mAppConfigBean.isEnableChargeReminder()) { + LogUtils.d(TAG, "setChargeReminderEnabled():充电提醒状态无变化,无需操作"); + return; + } + + mAppConfigBean.setEnableChargeReminder(isEnabled); + saveAppConfig(); + LogUtils.d(TAG, String.format("setChargeReminderEnabled() 成功 | 充电提醒状态=%s", isEnabled ? "开启" : "关闭")); + } + + /** + * 获取充电提醒开关状态 + * @return 充电提醒状态(true=开启,false=关闭) + */ + public boolean isChargeReminderEnabled() { + boolean isEnabled = mAppConfigBean.isEnableChargeReminder(); + LogUtils.d(TAG, String.format("isChargeReminderEnabled():获取充电提醒状态=%s", isEnabled ? "开启" : "关闭")); + return isEnabled; + } + + /** + * 设置充电提醒阈值(自动校准0-100) + * @param value 目标阈值 + */ + public void setChargeReminderValue(final int value) { + LogUtils.d(TAG, String.format("setChargeReminderValue() 调用 | 传入阈值=%d", value)); + final int calibratedValue = Math.min(Math.max(value, MIN_REMINDER_VALUE), MAX_REMINDER_VALUE); + + if (calibratedValue == mAppConfigBean.getChargeReminderValue()) { + LogUtils.d(TAG, "setChargeReminderValue():充电提醒阈值无变化,无需操作"); + return; + } + + mAppConfigBean.setChargeReminderValue(calibratedValue); + saveAppConfig(); + LogUtils.d(TAG, String.format("setChargeReminderValue() 成功 | 充电提醒阈值=%d%%", calibratedValue)); + } + + /** + * 获取充电提醒阈值 + * @return 充电提醒阈值(0-100) + */ + public int getChargeReminderValue() { + int value = mAppConfigBean.getChargeReminderValue(); + LogUtils.d(TAG, String.format("getChargeReminderValue():获取充电提醒阈值=%d%%", value)); + return value; + } + + // ======================== 耗电提醒配置方法区(开关+阈值)======================== + /** + * 设置耗电提醒开关状态 + * @param isEnabled 目标状态(true=开启,false=关闭) + */ + public void setUsageReminderEnabled(final boolean isEnabled) { + LogUtils.d(TAG, String.format("setUsageReminderEnabled() 调用 | 传入状态=%b", isEnabled)); + + if (isEnabled == mAppConfigBean.isEnableUsageReminder()) { + LogUtils.d(TAG, "setUsageReminderEnabled():耗电提醒状态无变化,无需操作"); + return; + } + + mAppConfigBean.setEnableUsageReminder(isEnabled); + saveAppConfig(); + LogUtils.d(TAG, String.format("setUsageReminderEnabled() 成功 | 耗电提醒状态=%s", isEnabled ? "开启" : "关闭")); + } + + /** + * 获取耗电提醒开关状态 + * @return 耗电提醒状态(true=开启,false=关闭) + */ + public boolean isUsageReminderEnabled() { + boolean isEnabled = mAppConfigBean.isEnableUsageReminder(); + LogUtils.d(TAG, String.format("isUsageReminderEnabled():获取耗电提醒状态=%s", isEnabled ? "开启" : "关闭")); + return isEnabled; + } + + /** + * 设置耗电提醒阈值(自动校准0-100) + * @param value 目标阈值 + */ + public void setUsageReminderValue(final int value) { + LogUtils.d(TAG, String.format("setUsageReminderValue() 调用 | 传入阈值=%d", value)); + final int calibratedValue = Math.min(Math.max(value, MIN_REMINDER_VALUE), MAX_REMINDER_VALUE); + + if (calibratedValue == mAppConfigBean.getUsageReminderValue()) { + LogUtils.d(TAG, "setUsageReminderValue():耗电提醒阈值无变化,无需操作"); + return; + } + + mAppConfigBean.setUsageReminderValue(calibratedValue); + saveAppConfig(); + LogUtils.d(TAG, String.format("setUsageReminderValue() 成功 | 耗电提醒阈值=%d%%", calibratedValue)); + } + + /** + * 获取耗电提醒阈值 + * @return 耗电提醒阈值(0-100) + */ + public int getUsageReminderValue() { + int value = mAppConfigBean.getUsageReminderValue(); + LogUtils.d(TAG, String.format("getUsageReminderValue():获取耗电提醒阈值=%d%%", value)); + return value; + } + + // ======================== 实时电池状态配置方法区(内存缓存,不持久化)======================== + /** + * 设置当前充电状态(仅内存缓存) + * @param isCharging 充电状态(true=充电中,false=未充电) + */ + public void setCharging(boolean isCharging) { + LogUtils.d(TAG, String.format("setCharging() 调用 | 传入状态=%b", isCharging)); + + if (isCharging == mAppConfigBean.isCharging()) { + LogUtils.d(TAG, "setCharging():充电状态无变化,无需操作"); + return; + } + + mAppConfigBean.setIsCharging(isCharging); + LogUtils.d(TAG, String.format("setCharging() 成功 | 充电状态=%s", isCharging ? "充电中" : "未充电")); + } + + /** + * 获取当前充电状态 + * @return 充电状态(true=充电中,false=未充电) + */ + public boolean isCharging() { + boolean isCharging = mAppConfigBean.isCharging(); + LogUtils.d(TAG, String.format("isCharging():获取充电状态=%s", isCharging ? "充电中" : "未充电")); + return isCharging; + } + + /** + * 设置当前电池电量(仅内存缓存,自动校准0-100) + * @param value 当前电量 + */ + public void setCurrentBatteryValue(int value) { + LogUtils.d(TAG, String.format("setCurrentBatteryValue() 调用 | 传入电量=%d", value)); + int calibratedValue = Math.min(Math.max(value, MIN_REMINDER_VALUE), MAX_REMINDER_VALUE); + + if (calibratedValue == App.sQuantityOfElectricity) { + LogUtils.d(TAG, "setCurrentBatteryValue():电池电量无变化,无需操作"); + return; + } + + App.sQuantityOfElectricity = calibratedValue; + LogUtils.d(TAG, String.format("setCurrentBatteryValue() 成功 | 电池电量=%d%%", calibratedValue)); + } + + /** + * 获取当前电池电量 + * @return 当前电池电量(0-100) + */ + public int getCurrentBatteryValue() { + int value = App.sQuantityOfElectricity; + LogUtils.d(TAG, String.format("getCurrentBatteryValue():获取电池电量=%d%%", value)); + return value; + } + + // ======================== 间隔配置方法区(持久化)======================== + /** + * 设置提醒间隔时间(自动校准最小1000ms) + * @param interval 目标间隔(单位:ms) + */ + public void setReminderIntervalTime(final int interval) { + LogUtils.d(TAG, String.format("setReminderIntervalTime() 调用 | 传入间隔=%dms", interval)); + final int calibratedInterval = Math.max(interval, MIN_INTERVAL_TIME); + + if (calibratedInterval == mAppConfigBean.getReminderIntervalTime()) { + LogUtils.d(TAG, "setReminderIntervalTime():提醒间隔无变化,无需操作"); + return; + } + + mAppConfigBean.setReminderIntervalTime(calibratedInterval); + saveAppConfig(); + LogUtils.d(TAG, String.format("setReminderIntervalTime() 成功 | 提醒间隔=%dms", calibratedInterval)); + } + + /** + * 获取提醒间隔时间 + * @return 提醒间隔(单位:ms) + */ + public int getReminderIntervalTime() { + int interval = mAppConfigBean.getReminderIntervalTime(); + LogUtils.d(TAG, String.format("getReminderIntervalTime():获取提醒间隔=%dms", interval)); + return interval; + } + + /** + * 设置电量检测间隔(自动校准最小500ms) + * @param interval 目标间隔(单位:ms) + */ + public void setBatteryDetectInterval(final int interval) { + LogUtils.d(TAG, String.format("setBatteryDetectInterval() 调用 | 传入间隔=%dms", interval)); + final int calibratedInterval = Math.max(interval, MIN_DETECT_INTERVAL); + + if (calibratedInterval == mAppConfigBean.getBatteryDetectInterval()) { + LogUtils.d(TAG, "setBatteryDetectInterval():检测间隔无变化,无需操作"); + return; + } + + mAppConfigBean.setBatteryDetectInterval(calibratedInterval); + saveAppConfig(); + LogUtils.d(TAG, String.format("setBatteryDetectInterval() 成功 | 电量检测间隔=%dms", calibratedInterval)); + } + + /** + * 获取电量检测间隔 + * @return 电量检测间隔(单位:ms) + */ + public int getBatteryDetectInterval() { + int interval = mAppConfigBean.getBatteryDetectInterval(); + LogUtils.d(TAG, String.format("getBatteryDetectInterval():获取电量检测间隔=%dms", interval)); + return interval; + } + + // ======================== 服务开关配置方法区(独立Bean)======================== + /** + * 获取服务开关状态 + * @return 服务开关状态(true=开启,false=关闭) + */ + public boolean isServiceEnabled() { + LogUtils.d(TAG, "isServiceEnabled() 调用 | 开始获取服务开关状态"); + ControlCenterServiceBean savedServiceBean = (ControlCenterServiceBean) ControlCenterServiceBean.loadBean(mContext, ControlCenterServiceBean.class); + + if (savedServiceBean != null) { + boolean isEnabled = savedServiceBean.isEnableService(); + LogUtils.d(TAG, String.format("isServiceEnabled():服务开关状态=%b", isEnabled)); + return isEnabled; + } else { + ControlCenterServiceBean.saveBean(mContext, new ControlCenterServiceBean(false)); + LogUtils.d(TAG, "isServiceEnabled():无已保存服务配置,默认关闭并持久化"); + return false; + } + } + + /** + * 设置服务开关状态 + * @param isServiceEnabled 目标状态(true=开启,false=关闭) + */ + public void setIsServiceEnabled(boolean isServiceEnabled) { + LogUtils.d(TAG, String.format("setIsServiceEnabled() 调用 | 传入状态=%b", isServiceEnabled)); + ControlCenterServiceBean.saveBean(mContext, new ControlCenterServiceBean(isServiceEnabled)); + LogUtils.d(TAG, String.format("setIsServiceEnabled() 成功 | 服务开关状态=%b", isServiceEnabled)); + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/AssetsCopyUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/AssetsCopyUtils.java new file mode 100644 index 0000000..bc479e4 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/AssetsCopyUtils.java @@ -0,0 +1,144 @@ +package cc.winboll.studio.powerbell.utils; + +import android.content.Context; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/12/11 09:14 + * @Describe Assets 目录拷贝工具类 + * 支持将 assets/images/ 下所有文件、子目录拷贝到指定路径 + * 适配:Java7 | API30 | 递归拷贝 | 覆盖写入 + */ +public class AssetsCopyUtils { + // ======================== 静态常量区 ======================== + public static final String TAG = "AssetsCopyUtils"; + private static final int BUFFER_SIZE = 1024 * 8; // 8KB 缓冲区,平衡性能与内存占用 + + // ======================== 公共快捷方法区(对外入口) ======================== + /** + * 拷贝 assets/images/ 目录到指定目标目录 + * @param context 上下文 + * @param targetDirPath 目标目录完整路径(如 /sdcard/PowerBell/assets_images) + * @return 拷贝是否成功 + */ + public static boolean copyAssetsImagesToDir(Context context, String targetDirPath) { + LogUtils.d(TAG, "copyAssetsImagesToDir() 调用,目标路径:" + targetDirPath); + // 拷贝 assets/images 根目录 + boolean result = copyAssetsDirToDir(context, "images", targetDirPath); + LogUtils.d(TAG, "copyAssetsImagesToDir() 执行完成,结果:" + result); + return result; + } + + // ======================== 公共核心方法区(递归拷贝目录) ======================== + /** + * 递归拷贝 assets 下指定目录到目标目录 + * @param context 上下文 + * @param assetsDir assets 下的源目录(如 "images"、"images/subdir") + * @param targetDirPath 目标目录完整路径 + * @return 拷贝是否成功 + */ + public static boolean copyAssetsDirToDir(Context context, String assetsDir, String targetDirPath) { + LogUtils.d(TAG, "copyAssetsDirToDir() 调用,源目录:" + assetsDir + ",目标路径:" + targetDirPath); + if (context == null) { + LogUtils.e(TAG, "copyAssetsDirToDir() 拷贝失败:上下文为空"); + return false; + } + + File targetDir = new File(targetDirPath); + // 创建目标目录(含多级父目录) + if (!targetDir.exists() && !targetDir.mkdirs()) { + LogUtils.e(TAG, "copyAssetsDirToDir() 创建目标目录失败:" + targetDirPath); + return false; + } + + try { + // 获取 assets 目录下的文件/子目录列表 + String[] fileList = context.getAssets().list(assetsDir); + if (fileList == null || fileList.length == 0) { + LogUtils.d(TAG, "copyAssetsDirToDir() assets 目录为空:" + assetsDir); + return true; + } + + for (String fileName : fileList) { + String assetsFilePath = assetsDir + File.separator + fileName; + String targetFilePath = targetDirPath + File.separator + fileName; + + // 判断当前项是文件还是子目录 + String[] subFileList = context.getAssets().list(assetsFilePath); + if (subFileList != null && subFileList.length > 0) { + // 是子目录,递归拷贝 + if (!copyAssetsDirToDir(context, assetsFilePath, targetFilePath)) { + LogUtils.e(TAG, "copyAssetsDirToDir() 递归拷贝子目录失败:" + assetsFilePath); + return false; + } + } else { + // 是文件,直接拷贝 + if (!copyAssetsFileToDir(context, assetsFilePath, targetFilePath)) { + LogUtils.e(TAG, "copyAssetsDirToDir() 拷贝文件失败:" + assetsFilePath); + return false; + } + } + } + LogUtils.d(TAG, "copyAssetsDirToDir() assets 目录拷贝完成:" + assetsDir + " -> " + targetDirPath); + return true; + } catch (IOException e) { + LogUtils.e(TAG, "copyAssetsDirToDir() 拷贝 assets 目录异常:" + e.getMessage(), e); + return false; + } + } + + // ======================== 私有辅助方法区(单个文件拷贝) ======================== + /** + * 拷贝 assets 下单个文件到指定路径 + * @param context 上下文 + * @param assetsFilePath assets 下的文件路径(如 "images/cloud.png") + * @param targetFilePath 目标文件完整路径 + * @return 拷贝是否成功 + */ + public static boolean copyAssetsFileToDir(Context context, String assetsFilePath, String targetFilePath) { + LogUtils.d(TAG, "copyAssetsFileToDir() 调用,源文件:" + assetsFilePath + ",目标文件:" + targetFilePath); + InputStream inputStream = null; + OutputStream outputStream = null; + try { + inputStream = context.getAssets().open(assetsFilePath); + File targetFile = new File(targetFilePath); + + // 覆盖已存在的文件 + if (targetFile.exists() && !targetFile.delete()) { + LogUtils.w(TAG, "copyAssetsFileToDir() 覆盖目标文件失败,跳过:" + targetFilePath); + return true; + } + + outputStream = new FileOutputStream(targetFile); + byte[] buffer = new byte[BUFFER_SIZE]; + int length; + while ((length = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, length); + } + LogUtils.d(TAG, "copyAssetsFileToDir() 文件拷贝成功:" + assetsFilePath + " -> " + targetFilePath); + return true; + } catch (IOException e) { + LogUtils.e(TAG, "copyAssetsFileToDir() 拷贝文件失败:" + assetsFilePath + ",异常:" + e.getMessage(), e); + return false; + } finally { + // 关闭流 + try { + if (inputStream != null) { + inputStream.close(); + } + if (outputStream != null) { + outputStream.close(); + } + } catch (IOException e) { + LogUtils.e(TAG, "copyAssetsFileToDir() 关闭流异常:" + e.getMessage(), e); + } + } + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/BackgroundSourceUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/BackgroundSourceUtils.java new file mode 100644 index 0000000..93b03d9 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/BackgroundSourceUtils.java @@ -0,0 +1,805 @@ +package cc.winboll.studio.powerbell.utils; + +import android.content.Context; +import android.graphics.Bitmap; +import android.media.ExifInterface; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.text.TextUtils; +import androidx.core.content.FileProvider; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import cc.winboll.studio.powerbell.BuildConfig; +import cc.winboll.studio.powerbell.models.BackgroundBean; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.UUID; + +/** + * @Author ZhanGSKen + * @Date 2024/07/18 12:07:20 + * @Describe 背景图片工具集(精简版:复用FileUtils,聚焦业务逻辑) + */ +public class BackgroundSourceUtils { + + // ====================== 常量定义(按功能分类置顶)====================== + public static final String TAG = "BackgroundSourceUtils"; + // FileProvider 授权常量 + public static final String FILE_PROVIDER_AUTHORITY = BuildConfig.APPLICATION_ID + ".fileprovider"; + // 目录名称常量 + private static final String CROP_CACHE_DIR_NAME = "cache"; + private static final String SOURCE_DIR_NAME = "BackgroundSource"; + private static final String COMPRESS_DIR_NAME = "BackgroundCompress"; + private static final String MODEL_DIR_NAME = "ModelDir"; + // 文件名称常量 + private static final String CURRENT_BEAN_FILE_NAME = "currentBackgroundBean.json"; + private static final String PREVIEW_BEAN_FILE_NAME = "previewBackgroundBean.json"; + private static final String BLANK_ASSET_PATH = "images/blank100x100.png"; + // 图片操作基础目录 + private static final String PICTURE_BASE_DIR = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) + File.separator + "PowerBell"; + // 压缩常量 + private static final int BITMAP_COMPRESS_QUALITY = 80; + private static final Bitmap.CompressFormat COMPRESS_FORMAT = Bitmap.CompressFormat.JPEG; + + // ====================== 成员变量(按依赖优先级+功能分类)====================== + // 单例实例 + private static volatile BackgroundSourceUtils sInstance; + // 上下文(应用级,避免内存泄漏) + private Context mContext; + // 配置文件对象 + private File currentBackgroundBeanFile; + private File previewBackgroundBeanFile; + // Bean实例 + private BackgroundBean currentBackgroundBean; + private BackgroundBean previewBackgroundBean; + // 目录对象 + private File fPictureBaseDir; + private File fCropCacheDir; + private File fBackgroundSourceDir; + private File fBackgroundCompressDir; + private File fUtilsDir; + private File fModelDir; + // 裁剪文件对象 + private File mCropSourceFile; + private File mCropResultFile; + + // ====================== 单例方法(双重校验锁)====================== + private BackgroundSourceUtils(Context context) { + if (sInstance != null) { + throw new RuntimeException("BackgroundSourceUtils 是单例类,禁止重复创建!"); + } + this.mContext = context.getApplicationContext(); + LogUtils.d(TAG, "【单例初始化】开始初始化必要资源"); + initNecessaryDirs(); + initAllFiles(); + loadSettings(); + LogUtils.d(TAG, "【单例初始化】资源初始化完成"); + } + + public static BackgroundSourceUtils getInstance(Context context) { + if (sInstance == null) { + synchronized (BackgroundSourceUtils.class) { + if (sInstance == null) { + sInstance = new BackgroundSourceUtils(context); + } + } + } + return sInstance; + } + + // ====================== 生命周期方法(初始化→加载→保存)====================== + /** + * 统一初始化所有必要目录 + */ + private void initNecessaryDirs() { + LogUtils.d(TAG, "【目录初始化】开始创建所有必要目录"); + initPictureDirs(); + initJsonDirs(); + LogUtils.d(TAG, "【目录初始化】所有必要目录创建完成"); + } + + /** + * 初始化图片操作目录 + */ + private void initPictureDirs() { + fPictureBaseDir = new File(PICTURE_BASE_DIR); + fBackgroundSourceDir = new File(fPictureBaseDir, SOURCE_DIR_NAME); + fCropCacheDir = new File(fPictureBaseDir, CROP_CACHE_DIR_NAME); + fBackgroundCompressDir = new File(fPictureBaseDir, COMPRESS_DIR_NAME); + + createDirWithPermission(fPictureBaseDir, "图片基础目录"); + createDirWithPermission(fBackgroundSourceDir, "图片存储目录"); + createDirWithPermission(fCropCacheDir, "裁剪缓存目录"); + createDirWithPermission(fBackgroundCompressDir, "压缩图存储目录"); + + validatePictureDirs(); + } + + /** + * 初始化JSON配置目录 + */ + private void initJsonDirs() { + fUtilsDir = mContext.getExternalFilesDir(TAG); + if (fUtilsDir == null) { + LogUtils.e(TAG, "应用外置存储不可用,切换到应用内部缓存目录"); + fUtilsDir = mContext.getDataDir(); + } + fModelDir = new File(fUtilsDir, MODEL_DIR_NAME); + createDirWithPermission(fModelDir, "JSON配置目录"); + + currentBackgroundBeanFile = new File(fModelDir, CURRENT_BEAN_FILE_NAME); + previewBackgroundBeanFile = new File(fModelDir, PREVIEW_BEAN_FILE_NAME); + LogUtils.d(TAG, "【配置文件初始化】当前Bean文件:" + currentBackgroundBeanFile.getAbsolutePath()); + LogUtils.d(TAG, "【配置文件初始化】预览Bean文件:" + previewBackgroundBeanFile.getAbsolutePath()); + } + + /** + * 初始化所有文件 + */ + private void initAllFiles() { + clearCropTempFiles(); + LogUtils.d(TAG, "【文件初始化】裁剪临时文件已清理"); + } + + /** + * 加载背景配置 + */ + public void loadSettings() { + LogUtils.d(TAG, "【配置加载】开始加载背景配置"); + // 加载当前Bean + currentBackgroundBean = BackgroundBean.loadBeanFromFile(currentBackgroundBeanFile.getAbsolutePath(), BackgroundBean.class); + if (currentBackgroundBean == null) { + currentBackgroundBean = new BackgroundBean(); + currentBackgroundBean.setPixelColor(ImageUtils.getColorAccent(mContext)); + BackgroundBean.saveBeanToFile(currentBackgroundBeanFile.getAbsolutePath(), currentBackgroundBean); + LogUtils.d(TAG, "【配置加载】正式背景Bean不存在,已创建新实例"); + } + // 加载预览Bean + previewBackgroundBean = BackgroundBean.loadBeanFromFile(previewBackgroundBeanFile.getAbsolutePath(), BackgroundBean.class); + if (previewBackgroundBean == null) { + previewBackgroundBean = new BackgroundBean(); + previewBackgroundBean.setPixelColor(ImageUtils.getColorAccent(mContext)); + BackgroundBean.saveBeanToFile(previewBackgroundBeanFile.getAbsolutePath(), previewBackgroundBean); + LogUtils.d(TAG, "【配置加载】预览背景Bean不存在,已创建新实例"); + } + LogUtils.d(TAG, "【配置加载】背景配置加载完成"); + } + + /** + * 保存配置 + */ + public void saveSettings() { + LogUtils.d(TAG, "【配置保存】开始保存背景配置"); + if (currentBackgroundBean == null || previewBackgroundBean == null) { + LogUtils.e(TAG, "【配置保存】失败:current/preview Bean存在空值"); + return; + } + BackgroundBean.saveBeanToFile(currentBackgroundBeanFile.getAbsolutePath(), currentBackgroundBean); + BackgroundBean.saveBeanToFile(previewBackgroundBeanFile.getAbsolutePath(), previewBackgroundBean); + LogUtils.d(TAG, "【配置保存】两份背景配置保存成功"); + } + + // ====================== 工具方法(目录操作→文件操作→Uri转换→图片处理)====================== + /** + * 创建目录并校验 + */ + private void createDirWithPermission(File dir, String dirDesc) { + if (dir == null) { + LogUtils.e(TAG, dirDesc + "创建失败:目录对象为null"); + return; + } + if (!dir.exists()) { + LogUtils.d(TAG, dirDesc + "不存在,开始创建:" + dir.getAbsolutePath()); + dir.mkdirs(); + } + if (!dir.exists()) { + LogUtils.e(TAG, dirDesc + "创建失败:mkdirs返回false"); + } + } + + /** + * 校验图片目录是否就绪 + */ + private void validatePictureDirs() { + boolean allReady = fPictureBaseDir.exists() && fBackgroundSourceDir.exists() + && fCropCacheDir.exists() && fBackgroundCompressDir.exists(); + if (allReady) { + LogUtils.d(TAG, "【目录校验】所有图片目录均已就绪"); + } else { + LogUtils.e(TAG, "【目录校验】部分图片目录未就绪,可能影响后续功能"); + } + } + + /** + * 清理单个旧文件 + */ + private void clearOldFile(File file, String fileDesc) { + if (file == null) { + return; + } + if (file.exists()) { + boolean isDeleted = file.delete(); + LogUtils.d(TAG, fileDesc + (isDeleted ? "已删除" : "删除失败") + ":" + file.getAbsolutePath()); + } + } + + /** + * 生成新的裁剪文件名称 + */ + String genNewCropFileName() { + String fileName = UUID.randomUUID().toString() + System.currentTimeMillis(); + LogUtils.d(TAG, "【文件命名】生成新裁剪文件名:" + fileName); + return fileName; + } + + /** + * 将File转为ContentUri + */ + public Uri getFileProviderUri(File file) { + LogUtils.d(TAG, "【Uri转换】开始生成FileProvider Uri,文件路径:" + (file != null ? file.getAbsolutePath() : "null")); + if (file == null || !file.exists()) { + LogUtils.e(TAG, "【Uri转换】失败:文件为空或不存在"); + return null; + } + try { + Uri contentUri; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + contentUri = FileProvider.getUriForFile(mContext, FILE_PROVIDER_AUTHORITY, file); + LogUtils.d(TAG, "【Uri转换】7.0+ 生成ContentUri:" + contentUri.toString()); + } else { + contentUri = Uri.fromFile(file); + LogUtils.d(TAG, "【Uri转换】7.0以下 生成FileUri:" + contentUri.toString()); + } + return contentUri; + } catch (IllegalArgumentException e) { + LogUtils.e(TAG, "【Uri转换】失败:" + e.getMessage(), e); + return null; + } + } + + /** + * 检查背景是否为空并创建空白背景Bean + */ + public boolean checkEmptyBackgroundAndCreateBlankBackgroundBean(BackgroundBean checkBackgroundBean) { + LogUtils.d(TAG, "【空白背景检查】开始检查背景Bean"); + if (checkBackgroundBean == null) { + LogUtils.e(TAG, "【空白背景检查】失败:检查Bean为空"); + return false; + } + File fCheckBackgroundFile = new File(checkBackgroundBean.getBackgroundFilePath()); + if (fCheckBackgroundFile.exists()) { + LogUtils.d(TAG, "【空白背景检查】背景Bean文件存在,无需创建空白背景"); + return false; + } + LogUtils.d(TAG, "【空白背景检查】背景Bean文件不存在,开始创建空白背景"); + return createBlankBackgroundBean(checkBackgroundBean.getPixelColor()); + } + + /** + * 获取目录类型描述 + */ + public String getDirTypeDesc(File dir) { + if (dir == null) { + return "未知目录(null)"; + } + String dirPath = dir.getAbsolutePath(); + String publicPicturePath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).getAbsolutePath(); + String externalFilesPath = mContext.getExternalFilesDir(null) != null ? mContext.getExternalFilesDir(null).getAbsolutePath() : ""; + String cachePath = mContext.getCacheDir().getAbsolutePath(); + + if (!TextUtils.isEmpty(publicPicturePath)) { + if (dirPath.contains(publicPicturePath + File.separator + "PowerBell" + File.separator + COMPRESS_DIR_NAME)) { + return "系统公共图片目录(/Pictures/PowerBell/BackgroundCompress,压缩图统一存储目录)"; + } else if (dirPath.contains(publicPicturePath + File.separator + "PowerBell")) { + return "系统公共图片目录(/Pictures/PowerBell,图片存储/裁剪目录)"; + } + } else if (!TextUtils.isEmpty(externalFilesPath) && dirPath.contains(externalFilesPath)) { + return "应用私有外部目录(getExternalFilesDir(),JSON配置目录)"; + } else if (dirPath.contains(cachePath)) { + return "应用内部缓存目录(getCacheDir(),兜底目录)"; + } else { + return "外部存储目录(非应用私有,权限受限)"; + } + return "未知目录"; + } + + /** + * 获取图片旋转角度 + */ + public int getImageRotateAngle(String imagePath) { + LogUtils.d(TAG, "【图片旋转角度】开始获取图片旋转角度,路径:" + imagePath); + if (TextUtils.isEmpty(imagePath)) { + LogUtils.e(TAG, "【图片旋转角度】失败:图片路径为空"); + return 0; + } + File imageFile = new File(imagePath); + if (!imageFile.exists() || !imageFile.isFile() || imageFile.length() <= 0) { + LogUtils.e(TAG, "【图片旋转角度】失败:图片文件无效:" + imagePath); + return 0; + } + + InputStream inputStream = null; + try { + inputStream = new FileInputStream(imageFile); + ExifInterface exifInterface = new ExifInterface(inputStream); + int orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); + switch (orientation) { + case ExifInterface.ORIENTATION_ROTATE_90: + LogUtils.d(TAG, "【图片旋转角度】90度"); + return 90; + case ExifInterface.ORIENTATION_ROTATE_180: + LogUtils.d(TAG, "【图片旋转角度】180度"); + return 180; + case ExifInterface.ORIENTATION_ROTATE_270: + LogUtils.d(TAG, "【图片旋转角度】270度"); + return 270; + default: + LogUtils.d(TAG, "【图片旋转角度】0度(正常)"); + return 0; + } + } catch (IOException e) { + LogUtils.w(TAG, "【图片旋转角度】读取EXIF异常:" + e.getMessage()); + return 0; + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException e) { + LogUtils.e(TAG, "【图片旋转角度】流关闭失败:" + e.getMessage()); + } + } + } + } + + // ====================== 核心业务方法(按功能分类)====================== + /** + * 创建空白背景Bean + */ + public boolean createBlankBackgroundBean(int nBackgroundPixelColor) { + LogUtils.d(TAG, "【空白背景创建】开始创建空白背景,像素颜色:" + String.format("#%08X", nBackgroundPixelColor)); + String newCropFileName = genNewCropFileName(); + String fileSuffix = "png"; + mCropSourceFile = new File(fCropCacheDir, newCropFileName + "." + fileSuffix); + mCropResultFile = new File(fCropCacheDir, "SelectCompress_" + newCropFileName + "." + fileSuffix); + + // 复制空白图片资源 + AssetsCopyUtils.copyAssetsFileToDir(mContext, BLANK_ASSET_PATH, mCropSourceFile.getAbsolutePath()); + LogUtils.d(TAG, "【空白背景创建】空白图片已复制到:" + mCropSourceFile.getAbsolutePath()); + + // 创建结果文件 + try { + mCropResultFile.createNewFile(); + LogUtils.d(TAG, "【空白背景创建】结果文件已创建:" + mCropResultFile.getAbsolutePath()); + } catch (IOException e) { + LogUtils.e(TAG, "【空白背景创建】结果文件创建失败:" + e.getMessage()); + return false; + } + + // 更新预览Bean + loadSettings(); + previewBackgroundBean.setPixelColor(nBackgroundPixelColor); + previewBackgroundBean.setIsUseBackgroundFile(true); + previewBackgroundBean.setIsUseBackgroundScaledCompressFile(false); + previewBackgroundBean.setBackgroundFileName(mCropSourceFile.getName()); + previewBackgroundBean.setBackgroundFilePath(mCropSourceFile.getAbsolutePath()); + previewBackgroundBean.setBackgroundScaledCompressFileName(mCropResultFile.getName()); + previewBackgroundBean.setBackgroundScaledCompressFilePath(mCropResultFile.getAbsolutePath()); + saveSettings(); + + LogUtils.d(TAG, "【空白背景创建】空白背景创建成功并更新配置"); + return true; + } + + /** + * 创建并更新预览剪裁环境 + */ + public boolean createAndUpdatePreviewEnvironmentForCropping(BackgroundBean oldPreviewBackgroundBean) { + LogUtils.d(TAG, "【预览剪裁环境】开始初始化预览剪裁环境"); + if (oldPreviewBackgroundBean == null) { + LogUtils.e(TAG, "【预览剪裁环境】失败:旧预览Bean为空"); + return false; + } + + InputStream is = null; + FileOutputStream fos = null; + try { + clearCropTempFiles(); + // 检查并创建空白背景 + if (checkEmptyBackgroundAndCreateBlankBackgroundBean(oldPreviewBackgroundBean)) { + LogUtils.d(TAG, "【预览剪裁环境】空白背景创建成功,直接返回"); + return true; + } + + // 获取Uri和文件后缀 + Uri uri = UriUtils.getUriForFile(mContext, oldPreviewBackgroundBean.getBackgroundFilePath()); + LogUtils.d(TAG, "【预览剪裁环境】原Uri:" + uri); + String fileSuffix = UriUtils.getSuffixFromUri(mContext, uri); + LogUtils.d(TAG, "【预览剪裁环境】文件后缀:" + fileSuffix); + + // 初始化裁剪文件 + String newCropFileName = genNewCropFileName(); + mCropSourceFile = new File(fCropCacheDir, newCropFileName + "." + fileSuffix); + mCropResultFile = new File(fCropCacheDir, "SelectCompress_" + newCropFileName + ".png"); + LogUtils.d(TAG, "【预览剪裁环境】裁剪数据源:" + mCropSourceFile.getAbsolutePath()); + LogUtils.d(TAG, "【预览剪裁环境】裁剪结果文件:" + mCropResultFile.getAbsolutePath()); + + // 复制压缩文件 + if (FileUtils.isFileExists(oldPreviewBackgroundBean.getBackgroundScaledCompressFilePath())) { + FileUtils.copyFile(new File(oldPreviewBackgroundBean.getBackgroundScaledCompressFilePath()), mCropResultFile); + LogUtils.d(TAG, "【预览剪裁环境】已复制旧压缩文件"); + } else { + mCropResultFile.createNewFile(); + LogUtils.d(TAG, "【预览剪裁环境】旧压缩文件不存在,已创建新文件"); + } + + // 复制源文件 + if (FileUtils.isFileExists(oldPreviewBackgroundBean.getBackgroundFilePath())) { + FileUtils.copyFile(new File(oldPreviewBackgroundBean.getBackgroundFilePath()), mCropSourceFile); + LogUtils.d(TAG, "【预览剪裁环境】已复制旧源文件"); + } else { + mCropSourceFile.createNewFile(); + is = mContext.getContentResolver().openInputStream(uri); + if (is == null) { + LogUtils.e(TAG, "【预览剪裁环境】ContentResolver打开Uri失败:" + uri.toString()); + return false; + } + fos = new FileOutputStream(mCropSourceFile); + byte[] buffer = new byte[1024 * 8]; + int readLen; + while ((readLen = is.read(buffer)) != -1) { + fos.write(buffer, 0, readLen); + } + fos.flush(); + try { + fos.getFD().sync(); + } catch (IOException e) { + LogUtils.w(TAG, "【预览剪裁环境】文件同步到磁盘失败,flush兜底:" + e.getMessage()); + fos.flush(); + } + LogUtils.d(TAG, "【预览剪裁环境】已从Uri读取并写入源文件"); + } + + // 更新预览Bean + loadSettings(); + previewBackgroundBean.setBackgroundFileName(mCropSourceFile.getName()); + previewBackgroundBean.setBackgroundFilePath(mCropSourceFile.getAbsolutePath()); + previewBackgroundBean.setBackgroundScaledCompressFileName(mCropResultFile.getName()); + previewBackgroundBean.setBackgroundScaledCompressFilePath(mCropResultFile.getAbsolutePath()); + saveSettings(); + + LogUtils.d(TAG, "【预览剪裁环境】预览剪裁环境初始化成功"); + return true; + } catch (Exception e) { + LogUtils.e(TAG, "【预览剪裁环境】初始化异常:" + e.getMessage(), e); + clearCropTempFiles(); + return false; + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException e) { + LogUtils.e(TAG, "【预览剪裁环境】输入流关闭失败:" + e.getMessage()); + } + } + if (fos != null) { + try { + fos.close(); + } catch (IOException e) { + LogUtils.e(TAG, "【预览剪裁环境】输出流关闭失败:" + e.getMessage()); + } + } + } + } + + /** + * 保存裁剪结果图到预览Bean + */ + public BackgroundBean saveFileToPreviewBean(File sourceFile, String fileInfo) { + LogUtils.d(TAG, "【裁剪结果保存】开始保存裁剪结果到预览Bean,源文件路径:" + (sourceFile != null ? sourceFile.getAbsolutePath() : "null")); + if (sourceFile == null || !sourceFile.exists() || sourceFile.length() <= 0) { + LogUtils.e(TAG, "【裁剪结果保存】失败:源文件无效"); + return previewBackgroundBean; + } + + // 检查是否为原图目录 + String originalImageDir = mContext.getExternalFilesDir(Environment.DIRECTORY_PICTURES).getAbsolutePath(); + if (sourceFile.getAbsolutePath().contains(originalImageDir)) { + LogUtils.w(TAG, "【裁剪结果保存】禁止复制原图,跳过保存"); + return previewBackgroundBean; + } + + // 确保目录存在 + if (!fBackgroundSourceDir.exists() && !fBackgroundSourceDir.mkdirs()) { + LogUtils.e(TAG, "【裁剪结果保存】失败:BackgroundSource目录创建失败"); + return previewBackgroundBean; + } + + // 生成唯一文件名并复制 + String uniqueFileName = "bg_" + System.currentTimeMillis() + "_" + sourceFile.getName(); + File targetFile = new File(fBackgroundSourceDir, uniqueFileName); + if (FileUtils.copyFile(sourceFile, targetFile)) { + LogUtils.d(TAG, "【裁剪结果保存】裁剪结果图保存成功:" + targetFile.getAbsolutePath()); + // 更新预览Bean + previewBackgroundBean.setBackgroundFileName(uniqueFileName); + previewBackgroundBean.setBackgroundFilePath(targetFile.getAbsolutePath()); + previewBackgroundBean.setBackgroundFileInfo(fileInfo); + previewBackgroundBean.setIsUseBackgroundFile(true); + saveSettings(); + } else { + LogUtils.e(TAG, "【裁剪结果保存】失败:裁剪结果图复制失败"); + } + return previewBackgroundBean; + } + + /** + * 提交预览背景到正式背景 + */ + public void commitPreviewSourceToCurrent() { + LogUtils.d(TAG, "【背景提交】开始深拷贝预览Bean到正式Bean"); + // 深拷贝Bean属性 + currentBackgroundBean = new BackgroundBean(); + currentBackgroundBean.setPixelColor(ImageUtils.getColorAccent(mContext)); + copyBackgroundBeanProperties(previewBackgroundBean, currentBackgroundBean); + + // 复制文件 + String previewFileName = previewBackgroundBean.getBackgroundFileName(); + String previewCropFileName = previewBackgroundBean.getBackgroundScaledCompressFileName(); + File previewFile = new File(previewBackgroundBean.getBackgroundFilePath()); + File previewCropFile = new File(previewBackgroundBean.getBackgroundScaledCompressFilePath()); + File currentFile = new File(fBackgroundSourceDir, previewFileName); + File currentCropFile = new File(fBackgroundCompressDir, previewCropFileName); + FileUtils.copyFile(previewFile, currentFile); + FileUtils.copyFile(previewCropFile, currentCropFile); + + // 更新文件路径 + currentBackgroundBean.setBackgroundFilePath(currentFile.getAbsolutePath()); + currentBackgroundBean.setBackgroundScaledCompressFilePath(currentCropFile.getAbsolutePath()); + + saveSettings(); + LogUtils.d(TAG, "【背景提交】预览背景提交到正式背景成功,两份实例完全独立"); + ToastUtils.show("背景图片应用成功"); + } + + /** + * 将正式背景同步到预览背景 + */ + public void setCurrentSourceToPreview() { + LogUtils.d(TAG, "【背景同步】开始深拷贝正式Bean到预览Bean"); + // 深拷贝Bean属性 + previewBackgroundBean = new BackgroundBean(); + previewBackgroundBean.setPixelColor(ImageUtils.getColorAccent(mContext)); + copyBackgroundBeanProperties(currentBackgroundBean, previewBackgroundBean); + + saveSettings(); + LogUtils.d(TAG, "【背景同步】正式背景同步到预览背景成功"); + } + + /** + * 清理裁剪临时文件 + */ + void clearCropTempFiles() { + LogUtils.d(TAG, "【裁剪文件清理】开始清理裁剪临时文件"); + File[] files = fCropCacheDir.listFiles(); + if (files == null) { + LogUtils.d(TAG, "【裁剪文件清理】裁剪缓存目录为空,无需清理"); + return; + } + for (File file : files) { + clearOldFile(file, "旧裁剪缓存文件"); + } + mCropSourceFile = null; + mCropResultFile = null; + LogUtils.d(TAG, "【裁剪文件清理】裁剪临时文件清理完成"); + } + + /** + * 复制文件 + */ + public boolean copyFile(File source, File target) { + LogUtils.d(TAG, "【文件复制】开始复制文件,源文件:" + (source != null ? source.getAbsolutePath() : "null") + " 目标:" + (target != null ? target.getAbsolutePath() : "null")); + if (source == null || TextUtils.isEmpty(source.getPath()) || (source.exists() && source.length() <= 0)) { + if (target == null) { + LogUtils.e(TAG, "【文件复制】失败:目标对象为null"); + return false; + } + File targetDir = target.isFile() ? target.getParentFile() : target; + createDirWithPermission(targetDir, "空源文件场景-目录创建"); + LogUtils.d(TAG, "【文件复制】空源文件场景,目录创建完成"); + return true; + } + boolean isSuccess = FileUtils.copyFile(source, target); + LogUtils.d(TAG, "【文件复制】" + (isSuccess ? "成功" : "失败")); + return isSuccess; + } + + /** + * 迁移旧压缩图路径到新目录 + */ + private void migrateCompressPathToNewDir(BackgroundBean bean, boolean isCurrentBean) { + LogUtils.d(TAG, "【路径迁移】开始迁移" + (isCurrentBean ? "正式" : "预览") + "Bean压缩路径"); + if (bean == null) { + LogUtils.e(TAG, "【路径迁移】失败:Bean为空"); + return; + } + String oldCompressPath = bean.getBackgroundScaledCompressFilePath(); + String beanType = isCurrentBean ? "正式Bean" : "预览Bean"; + + if (TextUtils.isEmpty(oldCompressPath) || oldCompressPath.contains(fBackgroundCompressDir.getAbsolutePath())) { + LogUtils.d(TAG, "【路径迁移】" + beanType + "无需迁移:旧路径为空或已在目标目录"); + return; + } + + File oldCompressFile = new File(oldCompressPath); + if (!oldCompressFile.exists() || !oldCompressFile.isFile() || oldCompressFile.length() <= 0) { + LogUtils.w(TAG, "【路径迁移】" + beanType + "旧压缩文件无效,无需迁移:" + oldCompressPath); + String compressFileName = bean.getBackgroundScaledCompressFileName(); + if (!TextUtils.isEmpty(compressFileName)) { + File newCompressFile = new File(fBackgroundCompressDir, compressFileName); + bean.setBackgroundScaledCompressFilePath(newCompressFile.getAbsolutePath()); + saveSettings(); + LogUtils.d(TAG, "【路径迁移】" + beanType + "压缩路径已重置到目标目录"); + } + return; + } + + String compressFileName = bean.getBackgroundScaledCompressFileName(); + if (TextUtils.isEmpty(compressFileName)) { + compressFileName = "ScaledCompress_" + System.currentTimeMillis() + ".jpg"; + } + File newCompressFile = new File(fBackgroundCompressDir, compressFileName); + + boolean copySuccess = FileUtils.copyFile(oldCompressFile, newCompressFile); + if (copySuccess) { + bean.setBackgroundScaledCompressFilePath(newCompressFile.getAbsolutePath()); + saveSettings(); + clearOldFile(oldCompressFile, beanType + "旧压缩文件(迁移后清理)"); + LogUtils.d(TAG, "【路径迁移】" + beanType + "压缩路径迁移成功:" + oldCompressPath + " → " + newCompressFile.getAbsolutePath()); + } else { + LogUtils.e(TAG, "【路径迁移】" + beanType + "压缩文件复制失败,迁移终止"); + } + } + + /** + * 压缩图片并保存(默认路径) + */ + public void compressQualityToRecivedPicture(Bitmap bitmap) { + LogUtils.d(TAG, "【图片压缩】使用默认路径压缩图片"); + String defaultCompressPath = getPreviewBackgroundScaledCompressFilePath(); + compressQualityToRecivedPicture(bitmap, defaultCompressPath); + } + + /** + * 压缩图片并保存(指定路径) + */ + public void compressQualityToRecivedPicture(Bitmap bitmap, String targetCompressPath) { + LogUtils.d(TAG, "【图片压缩】指定路径压缩图片,目标路径:" + targetCompressPath); + if (bitmap == null || bitmap.isRecycled()) { + ToastUtils.show("压缩失败:图片为空"); + LogUtils.e(TAG, "【图片压缩】失败:Bitmap为空或已回收"); + return; + } + if (TextUtils.isEmpty(targetCompressPath)) { + ToastUtils.show("压缩失败:目标路径为空"); + LogUtils.e(TAG, "【图片压缩】失败:目标路径为空"); + return; + } + + OutputStream outStream = null; + FileOutputStream fos = null; + try { + LogUtils.d(TAG, "【图片压缩】Bitmap原始大小:" + bitmap.getByteCount() / 1024 + "KB"); + File targetCompressFile = new File(targetCompressPath); + if (targetCompressFile.exists()) { + targetCompressFile.delete(); + LogUtils.d(TAG, "【图片压缩】已删除旧压缩文件"); + } + targetCompressFile.createNewFile(); + + fos = new FileOutputStream(targetCompressFile); + outStream = new BufferedOutputStream(fos); + boolean compressSuccess = bitmap.compress(COMPRESS_FORMAT, BITMAP_COMPRESS_QUALITY, outStream); + outStream.flush(); + try { + fos.getFD().sync(); + LogUtils.d(TAG, "【图片压缩】图片已强制同步到磁盘"); + } catch (IOException e) { + LogUtils.w(TAG, "【图片压缩】sync失败,flush兜底:" + e.getMessage()); + outStream.flush(); + } + + LogUtils.d(TAG, "【图片压缩】" + (compressSuccess ? "成功" : "失败") + ",大小:" + targetCompressFile.length() / 1024 + "KB"); + ToastUtils.show(compressSuccess ? "图片压缩成功" : "图片压缩失败"); + } catch (IOException e) { + LogUtils.e(TAG, "【图片压缩】IO异常:" + e.getMessage(), e); + ToastUtils.show("图片压缩失败"); + } finally { + if (outStream != null) { + try { + outStream.close(); + } catch (IOException e) { + LogUtils.e(TAG, "【图片压缩】BufferedOutputStream关闭失败:" + e.getMessage()); + } + } + if (fos != null) { + try { + fos.close(); + } catch (IOException e) { + LogUtils.e(TAG, "【图片压缩】FileOutputStream关闭失败:" + e.getMessage()); + } + } + if (bitmap != null && !bitmap.isRecycled()) { + bitmap.recycle(); + LogUtils.d(TAG, "【图片压缩】Bitmap已回收"); + } + } + } + + // ====================== 辅助方法(属性拷贝)====================== + /** + * 拷贝BackgroundBean属性(深拷贝) + */ + private void copyBackgroundBeanProperties(BackgroundBean source, BackgroundBean target) { + target.setBackgroundFileName(source.getBackgroundFileName()); + target.setBackgroundFilePath(source.getBackgroundFilePath()); + target.setBackgroundFileInfo(source.getBackgroundFileInfo()); + target.setIsUseBackgroundFile(source.isUseBackgroundFile()); + target.setBackgroundScaledCompressFileName(source.getBackgroundScaledCompressFileName()); + target.setBackgroundScaledCompressFilePath(source.getBackgroundScaledCompressFilePath()); + target.setIsUseBackgroundScaledCompressFile(source.isUseBackgroundScaledCompressFile()); + target.setBackgroundWidth(source.getBackgroundWidth()); + target.setBackgroundHeight(source.getBackgroundHeight()); + target.setPixelColor(source.getPixelColor()); + } + + // ====================== 对外提供的getter方法 ====================== + public BackgroundBean getCurrentBackgroundBean() { + return currentBackgroundBean; + } + + public BackgroundBean getPreviewBackgroundBean() { + return previewBackgroundBean; + } + + public String getPreviewBackgroundScaledCompressFilePath() { + String compressFileName = previewBackgroundBean.getBackgroundScaledCompressFileName(); + if (TextUtils.isEmpty(compressFileName)) { + LogUtils.e(TAG, "【路径获取】预览压缩背景文件名为空"); + return ""; + } + File file = new File(fBackgroundCompressDir, compressFileName); + return file.getAbsolutePath(); + } + + public String getCurrentBackgroundScaledCompressFilePath() { + String compressFileName = currentBackgroundBean.getBackgroundScaledCompressFileName(); + if (TextUtils.isEmpty(compressFileName)) { + LogUtils.e(TAG, "【路径获取】正式压缩背景文件名为空"); + return ""; + } + File file = new File(fBackgroundCompressDir, compressFileName); + return file.getAbsolutePath(); + } + + public String getBackgroundSourceDirPath() { + return fBackgroundSourceDir.getAbsolutePath(); + } + + public String getBackgroundCompressDirPath() { + return fBackgroundCompressDir.getAbsolutePath(); + } + + public String getCropCacheDir() { + return fCropCacheDir.getAbsolutePath(); + } + + public String getFileProviderAuthority() { + return FILE_PROVIDER_AUTHORITY; + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/BatteryUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/BatteryUtils.java new file mode 100644 index 0000000..c55c3e8 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/BatteryUtils.java @@ -0,0 +1,82 @@ +package cc.winboll.studio.powerbell.utils; + +import android.content.Intent; +import android.os.BatteryManager; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2024/07/18 04:32:46 + * @Describe 电池状态工具类 + * 功能:解析电池广播Intent,获取充电状态、当前电量 + * 适配:Java7 | API30 | 小米手机 + */ +public class BatteryUtils { + // ================================== 静态常量区(置顶归类,消除魔法值)================================= + public static final String TAG = "BatteryUtils"; + + // 电池电量计算常量 + private static final int BATTERY_SCALE_DEFAULT = 100; // 电量刻度默认值 + private static final int BATTERY_LEVEL_MIN = 0; // 电量百分比最小值 + private static final int BATTERY_LEVEL_MAX = 100; // 电量百分比最大值 + private static final int EXTRA_STATUS_DEFAULT = -1; // 电池状态默认值 + + // ================================== 工具方法(静态方法,无状态设计)================================= + /** + * 判断当前是否处于充电状态 + * @param intent 电池状态广播Intent(非空) + * @return true=充电中/已充满,false=未充电 + */ + public static boolean isCharging(Intent intent) { + LogUtils.d(TAG, "【isCharging】调用开始"); + // 入参非空校验 + if (intent == null) { + LogUtils.e(TAG, "【isCharging】入参异常:intent为空,返回false"); + return false; + } + + // 解析电池状态 + int status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, EXTRA_STATUS_DEFAULT); + LogUtils.d(TAG, "【isCharging】解析电池状态:status=" + status); + + // 判断充电状态(充电中/已充满均视为充电状态) + boolean isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING + || status == BatteryManager.BATTERY_STATUS_FULL; + LogUtils.d(TAG, "【isCharging】调用结束 | 充电状态=" + isCharging); + return isCharging; + } + + /** + * 获取当前电池电量百分比(0-100) + * @param intent 电池状态广播Intent(非空) + * @return 电量百分比,异常返回0 + */ + public static int getCurrentBatteryLevel(Intent intent) { + LogUtils.d(TAG, "【getCurrentBatteryLevel】调用开始"); + // 入参非空校验 + if (intent == null) { + LogUtils.e(TAG, "【getCurrentBatteryLevel】入参异常:intent为空,返回0"); + return BATTERY_LEVEL_MIN; + } + + // 解析电量原始值与刻度值 + int level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, BATTERY_LEVEL_MIN); + int scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, BATTERY_SCALE_DEFAULT); + LogUtils.d(TAG, "【getCurrentBatteryLevel】解析原始数据 | level=" + level + " | scale=" + scale); + + // 计算并校验电量百分比,避免除以0或数值越界 + int batteryLevel; + if (scale <= 0) { + LogUtils.w(TAG, "【getCurrentBatteryLevel】刻度值无效(scale=" + scale + "),直接使用level值"); + batteryLevel = level; + } else { + batteryLevel = level * BATTERY_SCALE_DEFAULT / scale; + } + + // 确保电量值在0-100范围内 + batteryLevel = Math.max(BATTERY_LEVEL_MIN, Math.min(batteryLevel, BATTERY_LEVEL_MAX)); + LogUtils.d(TAG, "【getCurrentBatteryLevel】调用结束 | 电量百分比=" + batteryLevel + "%"); + return batteryLevel; + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/BitmapCacheUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/BitmapCacheUtils.java new file mode 100644 index 0000000..b7b2ac5 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/BitmapCacheUtils.java @@ -0,0 +1,491 @@ +package cc.winboll.studio.powerbell.utils; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.Build; +import android.text.TextUtils; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.App; +import java.io.File; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/12/11 01:57 + * @Describe 单例 Bitmap 缓存工具类(Java 7 兼容)- 极致强制缓存版(无图片压缩) + * 功能:内存缓存 Bitmap,支持路径关联缓存、全局获取、缓存清空、SP 持久化最后缓存路径、构造时预加载 + * 特点:1. 单例模式 2. 硬引用唯一缓存(极致强制保持,任何情况不自动回收) 3. 路径-Bitmap 映射 4. 线程安全 + * 5. SP 持久化最后缓存路径 6. 构造时预加载 7. 引用计数防误回收 8. 无图片压缩,保留原始品质 + * 核心策略:无论内存如何紧张,强制保持已缓存的Bitmap,保留图片原始品质,永不自动清理 + */ +public class BitmapCacheUtils { + // ================================== 静态常量区(置顶归类,消除魔法值)================================= + public static final String TAG = "BitmapCacheUtils"; + + // SP 相关常量 + private static final String SP_NAME = "BitmapCacheSP"; + private static final String SP_KEY_LAST_CACHE_PATH = "last_cache_image_path"; + + // Bitmap 解码常量 + private static final int BITMAP_SAMPLE_SIZE_ORIGINAL = 1; // 无压缩采样率 + private static final Bitmap.Config BITMAP_CONFIG_DEFAULT = Bitmap.Config.ARGB_8888; // 全彩品质配置 + + // ================================== 成员变量(按功能分类,volatile 保证多线程可见性)================================= + // 单例实例 + private static volatile BitmapCacheUtils sInstance; + // 路径-Bitmap 硬引用缓存(极致强制保持,永不自动回收) + private final Map mHardCacheMap; + // 路径-引用计数 映射(仅统计,不影响缓存生命周期) + private final Map mRefCountMap; + // SP 实例(用于持久化最后缓存路径) + private final SharedPreferences mSp; + + // ================================== 单例方法(双重校验锁,线程安全)================================= + /** + * 私有构造器(单例模式) + */ + private BitmapCacheUtils() { + LogUtils.d(TAG, "【BitmapCacheUtils】单例构造开始"); + //App.notifyMessage(TAG, "【BitmapCacheUtils】单例构造开始"); + // 使用 ConcurrentHashMap 保证线程安全,避免手动同步 + mHardCacheMap = new ConcurrentHashMap<>(); + mRefCountMap = new ConcurrentHashMap<>(); + // 初始化 SP(使用 App 全局上下文,避免内存泄漏) + mSp = App.getInstance().getSharedPreferences(SP_NAME, Context.MODE_PRIVATE); + // 构造时自动预加载 SP 中保存的最后一次缓存路径的图片 + preloadLastCachedBitmap(); + // 注册内存状态监听(仅记录日志,不清理缓存) + registerMemoryStatusListener(); + LogUtils.d(TAG, "【BitmapCacheUtils】单例构造完成,极致强制缓存策略已启用"); + } + + /** + * 获取单例实例(双重校验锁,线程安全) + */ + public static BitmapCacheUtils getInstance() { + if (sInstance == null) { + synchronized (BitmapCacheUtils.class) { + if (sInstance == null) { + sInstance = new BitmapCacheUtils(); + } + } + } + return sInstance; + } + + // ================================== 对外监控接口(App 类调用专用)================================= + /** + * 获取当前缓存的 Bitmap 数量 + * @return 缓存的 Bitmap 数量 + */ + public int getCacheCount() { + int count = mHardCacheMap.size(); + LogUtils.d(TAG, "【getCacheCount】当前缓存 Bitmap 数量 - " + count); + return count; + } + + /** + * 获取当前缓存的所有图片路径集合 + * @return 路径集合 + */ + public Set getCachedPaths() { + Set paths = mHardCacheMap.keySet(); + LogUtils.d(TAG, "【getCachedPaths】当前缓存路径数量 - " + paths.size()); + return paths; + } + + /** + * 估算当前缓存的总内存占用(单位:字节) + * @return 总内存占用 + */ + public long getTotalCacheSize() { + long totalSize = 0; + for (Bitmap bitmap : mHardCacheMap.values()) { + if (isBitmapValid(bitmap)) { + if (Build.VERSION.SDK_INT >= 12) { + totalSize += bitmap.getByteCount(); + } else { + totalSize += bitmap.getRowBytes() * bitmap.getHeight(); + } + } + } + LogUtils.d(TAG, "【getTotalCacheSize】当前缓存总内存占用 - " + totalSize + " 字节"); + return totalSize; + } + + // ================================== 对外核心接口:缓存操作(无压缩)================================= + /** + * 直接缓存已解码的 Bitmap(适配 BackgroundView 改进需求) + * @param imagePath 图片绝对路径 + * @param bitmap 已解码的有效 Bitmap + * @return 缓存后的 Bitmap / null(参数无效) + */ + public Bitmap cacheBitmap(String imagePath, Bitmap bitmap) { + LogUtils.d(TAG, "【cacheBitmap】调用开始(直接缓存已解码 Bitmap)| 路径=" + imagePath); + // 入参非空校验 + if (TextUtils.isEmpty(imagePath) || !isBitmapValid(bitmap)) { + LogUtils.e(TAG, "【cacheBitmap】入参异常:路径为空或 Bitmap 无效"); + return null; + } + + // 极致强制:直接存入硬引用缓存,覆盖旧值(若存在) + mHardCacheMap.put(imagePath, bitmap); + // 初始化引用计数为1(若不存在) + mRefCountMap.putIfAbsent(imagePath, 1); + // 持久化当前路径到 SP + saveLastCachePathToSp(imagePath); + LogUtils.d(TAG, "【cacheBitmap】调用成功(直接缓存已解码 Bitmap)| 路径=" + imagePath); + return bitmap; + } + + /** + * 根据图片路径缓存 Bitmap 到内存,并持久化路径到 SP + * @param imagePath 图片绝对路径 + * @return 缓存成功的 Bitmap / null(路径无效/文件不存在/解码失败) + */ + public Bitmap cacheBitmap(String imagePath) { + LogUtils.d(TAG, "【cacheBitmap】调用开始(路径缓存)| 路径=" + imagePath); + // 入参非空校验 + if (TextUtils.isEmpty(imagePath)) { + LogUtils.e(TAG, "【cacheBitmap】入参异常:图片路径为空"); + return null; + } + + // 文件有效性校验 + File imageFile = new File(imagePath); + if (!imageFile.exists() || !imageFile.isFile() || imageFile.length() <= 0) { + LogUtils.e(TAG, "【cacheBitmap】文件无效:不存在/非文件/空文件 | 路径=" + imagePath); + return null; + } + + // 已缓存则直接返回,避免重复加载 + Bitmap hardCacheBitmap = mHardCacheMap.get(imagePath); + if (isBitmapValid(hardCacheBitmap)) { + LogUtils.d(TAG, "【cacheBitmap】硬引用缓存命中,引用计数+1 | 路径=" + imagePath); + // 引用计数+1 + increaseRefCount(imagePath); + // 持久化当前路径到 SP + saveLastCachePathToSp(imagePath); + LogUtils.d(TAG, "【cacheBitmap】调用成功(缓存命中)| 路径=" + imagePath); + return hardCacheBitmap; + } + + // 无压缩解码 Bitmap(保留原始品质) + Bitmap bitmap = decodeOriginalBitmap(imagePath); + if (bitmap != null) { + // 极致强制:存入硬引用缓存,永不自动回收 + mHardCacheMap.put(imagePath, bitmap); + // 初始化引用计数为1 + mRefCountMap.put(imagePath, 1); + // 持久化当前路径到 SP + saveLastCachePathToSp(imagePath); + LogUtils.d(TAG, "【cacheBitmap】调用成功(新缓存)| 路径=" + imagePath); + } else { + LogUtils.e(TAG, "【cacheBitmap】调用失败:图片解码失败 | 路径=" + imagePath); + } + return bitmap; + } + + /** + * 根据路径获取缓存的 Bitmap + * @param imagePath 图片绝对路径 + * @return 缓存的有效 Bitmap / null(未缓存/已回收) + */ + public Bitmap getCachedBitmap(String imagePath) { + LogUtils.d(TAG, "【getCachedBitmap】调用开始 | 路径=" + imagePath); + // 入参非空校验 + if (TextUtils.isEmpty(imagePath)) { + LogUtils.e(TAG, "【getCachedBitmap】入参异常:图片路径为空"); + return null; + } + + // 仅从硬引用缓存获取,无任何 fallback + Bitmap hardCacheBitmap = mHardCacheMap.get(imagePath); + if (isBitmapValid(hardCacheBitmap)) { + LogUtils.d(TAG, "【getCachedBitmap】调用成功(缓存命中)| 路径=" + imagePath); + return hardCacheBitmap; + } + + // 缓存未命中或 Bitmap 已失效(极致强制策略下,理论上不会出现已回收情况) + LogUtils.w(TAG, "【getCachedBitmap】调用失败:缓存未命中或 Bitmap 已失效 | 路径=" + imagePath); + return null; + } + + // ================================== 对外接口:引用计数管理(仅统计,不影响缓存)================================= + /** + * 增加指定路径 Bitmap 的引用计数 + * @param imagePath 图片绝对路径 + */ + public void increaseRefCount(String imagePath) { + LogUtils.d(TAG, "【increaseRefCount】调用开始 | 路径=" + imagePath); + if (TextUtils.isEmpty(imagePath)) { + LogUtils.e(TAG, "【increaseRefCount】入参异常:图片路径为空"); + return; + } + synchronized (mRefCountMap) { + Integer count = mRefCountMap.get(imagePath); + if (count == null) { + mRefCountMap.put(imagePath, 1); + } else { + mRefCountMap.put(imagePath, count + 1); + } + int newCount = mRefCountMap.get(imagePath); + LogUtils.d(TAG, "【increaseRefCount】调用成功 | 路径=" + imagePath + " | 引用计数=" + newCount); + } + } + + /** + * 减少指定路径 Bitmap 的引用计数,计数为0时仅标记不回收(极致强制缓存策略) + * @param imagePath 图片绝对路径 + */ + public void decreaseRefCount(String imagePath) { + LogUtils.d(TAG, "【decreaseRefCount】调用开始 | 路径=" + imagePath); + if (TextUtils.isEmpty(imagePath)) { + LogUtils.e(TAG, "【decreaseRefCount】入参异常:图片路径为空"); + return; + } + synchronized (mRefCountMap) { + Integer count = mRefCountMap.get(imagePath); + if (count == null || count <= 0) { + LogUtils.w(TAG, "【decreaseRefCount】引用计数无效:路径=" + imagePath); + return; + } + + int newCount = count - 1; + if (newCount <= 0) { + // 极致强制缓存策略:引用计数为0时仅移除计数,绝对不回收 Bitmap + mRefCountMap.remove(imagePath); + LogUtils.d(TAG, "【decreaseRefCount】调用成功 | 路径=" + imagePath + " | 引用计数为0,极致强制保持 Bitmap"); + } else { + mRefCountMap.put(imagePath, newCount); + LogUtils.d(TAG, "【decreaseRefCount】调用成功 | 路径=" + imagePath + " | 引用计数=" + newCount); + } + } + } + + // ================================== 对外接口:缓存清理(仅手动调用,永不自动执行)================================= + /** + * 清空所有 Bitmap 缓存(仅手动调用时执行,任何情况不自动执行) + */ + public void clearAllCache() { + LogUtils.w(TAG, "【clearAllCache】调用开始(极致强制缓存策略下,需谨慎使用)"); + + // 清空硬引用缓存并回收 Bitmap + for (Bitmap bitmap : mHardCacheMap.values()) { + if (isBitmapValid(bitmap)) { + bitmap.recycle(); + } + } + mHardCacheMap.clear(); + + // 清空引用计数 + mRefCountMap.clear(); + + // 清空 SP 中保存的最后缓存路径 + clearLastCachePathInSp(); + + LogUtils.d(TAG, "【clearAllCache】调用成功:所有 Bitmap 缓存已清空"); + } + + /** + * 移除指定路径的 Bitmap 缓存(仅手动调用时执行,任何情况不自动执行) + * @param imagePath 图片绝对路径 + */ + public void removeCachedBitmap(String imagePath) { + LogUtils.d(TAG, "【removeCachedBitmap】调用开始 | 路径=" + imagePath); + if (TextUtils.isEmpty(imagePath)) { + LogUtils.e(TAG, "【removeCachedBitmap】入参异常:图片路径为空"); + return; + } + + synchronized (mRefCountMap) { + // 手动移除时才回收 Bitmap + Bitmap hardBitmap = mHardCacheMap.remove(imagePath); + if (isBitmapValid(hardBitmap)) { + hardBitmap.recycle(); + LogUtils.d(TAG, "【removeCachedBitmap】手动回收硬引用缓存 | 路径=" + imagePath); + } + mRefCountMap.remove(imagePath); + + // 若移除的是最后缓存的路径,清空 SP + String lastPath = getLastCachePathFromSp(); + if (imagePath.equals(lastPath)) { + clearLastCachePathInSp(); + LogUtils.d(TAG, "【removeCachedBitmap】移除最后缓存路径,已清空 SP"); + } + } + LogUtils.d(TAG, "【removeCachedBitmap】调用成功 | 路径=" + imagePath); + } + + // ================================== 内部工具方法(无压缩解码 + Bitmap 有效性判断)================================= + /** + * 无压缩解码 Bitmap(保留原始品质) + * @param imagePath 图片绝对路径 + * @return 解码后的 Bitmap / null(文件无效/解码失败) + */ + private Bitmap decodeOriginalBitmap(String imagePath) { + LogUtils.d(TAG, "【decodeOriginalBitmap】调用开始 | 路径=" + imagePath); + // 前置校验:确保文件有效 + File imageFile = new File(imagePath); + if (!imageFile.exists() || !imageFile.isFile() || imageFile.length() <= 0) { + LogUtils.e(TAG, "【decodeOriginalBitmap】文件无效,跳过解码 | 路径=" + imagePath); + return null; + } + + BitmapFactory.Options options = new BitmapFactory.Options(); + // 仅获取尺寸用于日志记录,不参与解码逻辑 + options.inJustDecodeBounds = true; + BitmapFactory.decodeFile(imagePath, options); + + // 校验尺寸是否有效 + if (options.outWidth <= 0 || options.outHeight <= 0) { + LogUtils.e(TAG, "【decodeOriginalBitmap】图片尺寸无效 | 路径=" + imagePath); + return null; + } + + LogUtils.d(TAG, "【decodeOriginalBitmap】图片原始尺寸 | 宽=" + options.outWidth + " | 高=" + options.outHeight); + + // 无压缩解码配置 + options.inJustDecodeBounds = false; + options.inSampleSize = BITMAP_SAMPLE_SIZE_ORIGINAL; // 不缩放,采样率为1 + options.inPreferredConfig = BITMAP_CONFIG_DEFAULT; // 保留全彩品质 + options.inPurgeable = false; // 关闭可清除标志,极致强制保持内存 + options.inInputShareable = false; + options.inDither = true; // 开启抖动,保证色彩还原 + options.inScaled = false; // 关闭自动缩放,保留原始尺寸 + + try { + Bitmap bitmap = BitmapFactory.decodeFile(imagePath, options); + LogUtils.d(TAG, "【decodeOriginalBitmap】解码" + (bitmap != null ? "成功" : "失败") + " | 路径=" + imagePath); + return bitmap; + } catch (OutOfMemoryError e) { + LogUtils.e(TAG, "【decodeOriginalBitmap】OOM 异常(无压缩,图片尺寸过大)| 路径=" + imagePath); + // 极致强制缓存策略:OOM 时仅放弃当前解码,绝对不清理已缓存的 Bitmap + return null; + } catch (Exception e) { + LogUtils.e(TAG, "【decodeOriginalBitmap】解码异常 | 路径=" + imagePath, e); + return null; + } + } + + /** + * 判断 Bitmap 是否有效(非空且未被回收) + */ + private boolean isBitmapValid(Bitmap bitmap) { + boolean isValid = bitmap != null && !bitmap.isRecycled(); + if (!isValid) { + LogUtils.w(TAG, "【isBitmapValid】Bitmap 无效:空或已回收"); + } + return isValid; + } + + // ================================== 内部工具方法:SP 持久化相关 ================================== + /** + * 从 SP 中获取最后一次缓存的图片路径 + * @return 最后缓存的路径 / null(未保存) + */ + private String getLastCachePathFromSp() { + String path = mSp.getString(SP_KEY_LAST_CACHE_PATH, null); + LogUtils.d(TAG, "【getLastCachePathFromSp】获取最后缓存路径 | 路径=" + path); + return path; + } + + /** + * 将当前缓存路径持久化到 SP + * @param imagePath 图片绝对路径 + */ + private void saveLastCachePathToSp(String imagePath) { + LogUtils.d(TAG, "【saveLastCachePathToSp】调用开始 | 路径=" + imagePath); + if (TextUtils.isEmpty(imagePath)) { + LogUtils.e(TAG, "【saveLastCachePathToSp】入参异常:图片路径为空"); + return; + } + mSp.edit().putString(SP_KEY_LAST_CACHE_PATH, imagePath).commit(); // Java 7 兼容,使用 commit 而非 apply + LogUtils.d(TAG, "【saveLastCachePathToSp】调用成功 | 路径=" + imagePath); + } + + /** + * 清空 SP 中保存的最后缓存路径 + */ + private void clearLastCachePathInSp() { + mSp.edit().remove(SP_KEY_LAST_CACHE_PATH).commit(); + LogUtils.d(TAG, "【clearLastCachePathInSp】调用成功:SP 中最后缓存路径已清空"); + } + + // ================================== 内部工具方法:预加载相关 ================================== + /** + * 构造时预加载 SP 中保存的最后一次缓存路径的图片 + */ + private void preloadLastCachedBitmap() { + LogUtils.d(TAG, "【preloadLastCachedBitmap】调用开始"); + String lastPath = getLastCachePathFromSp(); + if (TextUtils.isEmpty(lastPath)) { + LogUtils.d(TAG, "【preloadLastCachedBitmap】SP 中无保存的缓存路径,跳过预加载"); + return; + } + // 调用 cacheBitmap 预加载(内部已做文件校验和缓存判断) + Bitmap bitmap = cacheBitmap(lastPath); + if (bitmap != null) { + LogUtils.d(TAG, "【preloadLastCachedBitmap】预加载成功 | 路径=" + lastPath); + } else { + LogUtils.w(TAG, "【preloadLastCachedBitmap】预加载失败,清空无效路径 | 路径=" + lastPath); + // 预加载失败,清空 SP 中无效路径 + clearLastCachePathInSp(); + } + } + + // ================================== 内部工具方法:内存状态监听(仅记录日志)================================= + /** + * 注册内存状态监听(仅记录日志,不清理缓存,极致强制缓存策略) + */ + private void registerMemoryStatusListener() { + LogUtils.d(TAG, "【registerMemoryStatusListener】调用开始"); + if (Build.VERSION.SDK_INT >= 14) { + App.getInstance().registerComponentCallbacks(new MemoryStatusCallback()); + LogUtils.d(TAG, "【registerMemoryStatusListener】内存状态监听已注册(仅记录日志,不清理缓存)"); + } else { + LogUtils.w(TAG, "【registerMemoryStatusListener】API 版本低于14,不支持内存状态监听"); + } + } + + /** + * 记录当前缓存状态(用于内存紧张时的调试) + */ + private void logCurrentCacheStatus() { + LogUtils.d(TAG, "【logCurrentCacheStatus】缓存数量 - " + getCacheCount() + ",总内存占用 - " + getTotalCacheSize() + " 字节"); + LogUtils.d(TAG, "【logCurrentCacheStatus】缓存路径 - " + getCachedPaths().toString()); + } + + // ================================== 内部类:内存状态回调(仅记录日志)================================= + /** + * 内存状态回调(仅记录日志,不清理缓存,极致强制缓存策略) + */ + private class MemoryStatusCallback implements android.content.ComponentCallbacks2 { + @Override + public void onTrimMemory(int level) { + // 极致强制缓存策略:内存紧张时仅记录日志,不清理任何缓存 + LogUtils.w(TAG, "【onTrimMemory】内存紧张级别 - " + level + ",极致强制保持所有 Bitmap 缓存(无压缩)"); + // 记录当前缓存状态 + logCurrentCacheStatus(); + } + + @Override + public void onLowMemory() { + // 极致强制缓存策略:低内存时仅记录日志,不清理任何缓存 + LogUtils.w(TAG, "【onLowMemory】系统低内存,极致强制保持所有 Bitmap 缓存(无压缩)"); + // 记录当前缓存状态 + logCurrentCacheStatus(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + // 配置变化时无需处理 + } + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/DateUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/DateUtils.java new file mode 100644 index 0000000..3ba9cac --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/DateUtils.java @@ -0,0 +1,37 @@ +package cc.winboll.studio.powerbell.utils; + +import java.text.SimpleDateFormat; +import java.util.Locale; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/12/24 + * @Describe 日期时间工具类(Java 7 兼容 | API 30 适配) + * 功能:提供当前时间的格式化字符串获取功能 + */ +public class DateUtils { + // ================================== 静态常量区(置顶归类,消除魔法值)================================= + public static final String TAG = "DateUtils"; + private static final String DATE_FORMAT_PATTERN = "yyyyMMdd_HHmmssSSS"; // 修正年份格式为小写yyyy,毫秒为SSS + private static final Locale DEFAULT_LOCALE = Locale.getDefault(); + + // ================================== 工具方法(静态方法,无状态设计)================================= + /** + * 获取当前时间的格式化字符串 + * 格式:yyyyMMdd_HHmmssSSS(年-月-日_时-分-秒-毫秒) + * @return 格式化后的当前时间字符串 + */ + public static String getDateNowString() { + LogUtils.d(TAG, "【getDateNowString】调用开始"); + // 初始化日期格式化工具(Java 7 兼容,使用小写yyyy避免周基年问题) + SimpleDateFormat sdf = new SimpleDateFormat(DATE_FORMAT_PATTERN, DEFAULT_LOCALE); + // 读取当前时间戳 + long currentTime = System.currentTimeMillis(); + // 格式化时间 + String formattedTime = sdf.format(currentTime); + LogUtils.d(TAG, "【getDateNowString】调用成功 | 格式化时间=" + formattedTime); + return formattedTime; + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/DrawableToFileUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/DrawableToFileUtils.java new file mode 100644 index 0000000..aa9b695 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/DrawableToFileUtils.java @@ -0,0 +1,125 @@ +package cc.winboll.studio.powerbell.utils; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/12/08 21:11 + * @Describe 把 R.drawable 中的图片保存为 File 对象的工具类 + * 适配 PowerBell 项目:支持指定保存路径、自动创建目录、处理PNG图片压缩 + */ +public class DrawableToFileUtils { + // ================================== 静态常量区(置顶归类,消除魔法值)================================= + public static final String TAG = "DrawableToFileUtils"; + private static final String IMAGE_FORMAT_PNG = ".png"; // 目标图片格式 + private static final Bitmap.CompressFormat COMPRESS_FORMAT = Bitmap.CompressFormat.PNG; // 压缩格式 + private static final int COMPRESS_QUALITY = 100; // PNG无损压缩质量 + private static final long MIN_FILE_SIZE = 100; // 有效文件最小字节数 + + // ================================== 核心工具方法(基础版:指定文件路径)================================= + /** + * 核心方法:将 R.drawable 图片保存为 File 对象 + * @param context 上下文(用于获取 Resources) + * @param drawableResId 图片资源ID(如 R.drawable.ic_test_png) + * @param filePath 保存的文件路径(可带/不带.png后缀) + * @return 保存成功返回 File 对象,失败返回 null + */ + public static File saveDrawableToFile(Context context, int drawableResId, String filePath) { + LogUtils.d(TAG, "【saveDrawableToFile】调用开始 | 资源ID=" + drawableResId + " | 目标路径=" + filePath); + // 1. 校验核心参数(避免空指针/无效参数) + if (context == null) { + LogUtils.e(TAG, "【saveDrawableToFile】参数异常:context为空"); + return null; + } + if (drawableResId == 0) { + LogUtils.e(TAG, "【saveDrawableToFile】参数异常:drawableResId为0"); + return null; + } + if (filePath == null || filePath.isEmpty()) { + LogUtils.e(TAG, "【saveDrawableToFile】参数异常:filePath为空"); + return null; + } + + // 2. 格式化文件路径(强制添加.png后缀) + String targetFilePath = filePath.endsWith(IMAGE_FORMAT_PNG) ? filePath : filePath + IMAGE_FORMAT_PNG; + if (!filePath.equals(targetFilePath)) { + LogUtils.d(TAG, "【saveDrawableToFile】格式适配:自动添加.png后缀 | 最终路径=" + targetFilePath); + } + + // 3. 构建目标File对象并创建父目录 + File targetFile = new File(targetFilePath); + File parentDir = targetFile.getParentFile(); + if (parentDir != null && !parentDir.exists()) { + boolean isDirCreated = parentDir.mkdirs(); + if (!isDirCreated) { + LogUtils.e(TAG, "【saveDrawableToFile】目录创建失败:" + parentDir.getAbsolutePath()); + return null; + } + LogUtils.d(TAG, "【saveDrawableToFile】目录创建成功:" + parentDir.getAbsolutePath()); + } + LogUtils.d(TAG, "【saveDrawableToFile】目标文件路径:" + targetFile.getAbsolutePath()); + + // 4. 读取drawable资源为Bitmap + Bitmap bitmap = null; + try { + bitmap = BitmapFactory.decodeResource(context.getResources(), drawableResId); + if (bitmap == null) { + LogUtils.e(TAG, "【saveDrawableToFile】读取失败:无法解析drawable资源(资源ID=" + drawableResId + ")"); + return null; + } + LogUtils.d(TAG, "【saveDrawableToFile】读取成功:Bitmap尺寸=" + bitmap.getWidth() + "x" + bitmap.getHeight()); + + // 5. 将Bitmap写入File(PNG无损保存) + FileOutputStream fos = new FileOutputStream(targetFile); + boolean isSaved = bitmap.compress(COMPRESS_FORMAT, COMPRESS_QUALITY, fos); + fos.flush(); + fos.close(); + + // 6. 校验保存结果 + if (isSaved && targetFile.exists() && targetFile.length() > MIN_FILE_SIZE) { + LogUtils.d(TAG, "【saveDrawableToFile】保存成功:" + targetFile.getAbsolutePath()); + return targetFile; + } else { + LogUtils.e(TAG, "【saveDrawableToFile】保存失败:文件无效(存在=" + targetFile.exists() + " | 大小=" + targetFile.length() + "字节)"); + // 清理无效文件 + if (targetFile.exists()) { + targetFile.delete(); + LogUtils.d(TAG, "【saveDrawableToFile】清理无效文件:" + targetFile.getAbsolutePath()); + } + return null; + } + } catch (IOException e) { + LogUtils.e(TAG, "【saveDrawableToFile】保存异常:" + e.getMessage()); + return null; + } finally { + // 回收Bitmap资源(避免内存溢出) + if (bitmap != null && !bitmap.isRecycled()) { + bitmap.recycle(); + LogUtils.d(TAG, "【saveDrawableToFile】资源回收:Bitmap已回收"); + } + } + } + + // ================================== 重载工具方法(扩展版:指定目录+文件名)================================= + /** + * 重载方法:自定义保存路径(灵活适配不同场景) + * @param context 上下文 + * @param drawableResId 图片资源ID + * @param saveDirPath 自定义保存目录路径(如 "/storage/emulated/0/PowerBell/custom/") + * @param fileName 保存的文件名(可带/不带.png后缀) + * @return 保存成功返回File对象,失败返回null + */ + public static File saveDrawableToFile(Context context, int drawableResId, String saveDirPath, String fileName) { + LogUtils.d(TAG, "【saveDrawableToFile】重载方法调用开始 | 资源ID=" + drawableResId + " | 目录=" + saveDirPath + " | 文件名=" + fileName); + // 构建完整文件路径 + File targetFile = new File(saveDirPath, fileName); + return saveDrawableToFile(context, drawableResId, targetFile.getAbsolutePath()); + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/FileUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/FileUtils.java new file mode 100644 index 0000000..ea1b0c1 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/FileUtils.java @@ -0,0 +1,366 @@ +package cc.winboll.studio.powerbell.utils; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.drawable.BitmapDrawable; +import cc.winboll.studio.libappbase.LogUtils; +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.channels.FileChannel; +import java.util.UUID; + +/** + * 文件操作工具类 + * 功能:文件读写、复制、图片转换、文件名处理等常用文件操作 + * 适配:Java 7 + Android API 30 + * 注意:调用文件操作前需确保已获取存储权限(Android 6.0+ 需动态申请) + */ +public class FileUtils { + // ================================== 静态常量区(置顶归类,消除魔法值)================================= + public static final String TAG = "FileUtils"; + /** 读取文件默认缓冲区大小(10KB) */ + private static final int BUFFER_SIZE = 10240; + /** 最大读取文件大小(1GB),防止OOM */ + private static final long MAX_READ_FILE_SIZE = 1024 * 1024 * 1024; + /** 最大文件后缀长度(避免异常文件名) */ + private static final int MAX_SUFFIX_LENGTH = 5; + /** 缓冲区大小(流复制专用) */ + private static final int STREAM_BUFFER_SIZE = 1024; + + // ================================== 文件读取相关(字符串 + 字节数组)================================= + /** + * 读取文件内容并转为字符串 + * @param filePath 文件绝对路径(非空) + * @return 文件内容字符串 + * @throws IOException 异常:文件不存在、文件过大、读取失败等 + */ + public static String readFileAsString(String filePath) throws IOException { + LogUtils.d(TAG, "【readFileAsString】调用开始 | 文件路径=" + filePath); + // 1. 校验文件合法性 + File file = new File(filePath); + if (!file.exists()) { + LogUtils.e(TAG, "【readFileAsString】文件不存在:" + filePath); + throw new FileNotFoundException("文件不存在:" + filePath); + } + if (file.length() > MAX_READ_FILE_SIZE) { + LogUtils.e(TAG, "【readFileAsString】文件过大(超过1GB):" + filePath); + throw new IOException("文件过大(超过1GB),禁止读取:" + filePath); + } + + // 2. 读取文件内容(使用StringBuilder高效拼接) + StringBuilder sb = new StringBuilder((int) file.length()); + FileInputStream fis = null; + try { + fis = new FileInputStream(file); + byte[] buffer = new byte[BUFFER_SIZE]; + int readLen; + while ((readLen = fis.read(buffer)) > 0) { + sb.append(new String(buffer, 0, readLen)); + } + } finally { + if (fis != null) { + fis.close(); + } + } + LogUtils.d(TAG, "【readFileAsString】读取成功 | 文件大小=" + file.length() + "字节"); + return sb.toString(); + } + + /** + * 读取文件内容并转为byte数组(适用于二进制文件:图片、音频等) + * @param filePath 文件绝对路径(非空) + * @return 文件内容byte数组 + * @throws IOException 异常:文件不存在、读取失败等 + */ + public static byte[] readFileByBytes(String filePath) throws IOException { + LogUtils.d(TAG, "【readFileByBytes】调用开始 | 文件路径=" + filePath); + // 1. 校验文件合法性 + File file = new File(filePath); + if (!file.exists()) { + LogUtils.e(TAG, "【readFileByBytes】文件不存在:" + filePath); + throw new FileNotFoundException("文件不存在:" + filePath); + } + + // 2. 缓冲流读取(高效,减少IO次数) + ByteArrayOutputStream bos = null; + BufferedInputStream bis = null; + try { + bos = new ByteArrayOutputStream((int) file.length()); + bis = new BufferedInputStream(new FileInputStream(file)); + byte[] buffer = new byte[BUFFER_SIZE]; + int readLen; + while ((readLen = bis.read(buffer)) != -1) { + bos.write(buffer, 0, readLen); + } + bos.flush(); + LogUtils.d(TAG, "【readFileByBytes】读取成功 | 文件大小=" + file.length() + "字节"); + return bos.toByteArray(); + } finally { + if (bis != null) { + bis.close(); + } + if (bos != null) { + bos.close(); + } + } + } + + // ================================== 文件复制相关(FileChannel + 简化版 + 流复制)================================= + /** + * 基于FileChannel复制文件(高效,适用于大文件复制) + * @param source 源文件(非空,必须存在) + * @param dest 目标文件(非空,父目录会自动创建) + * @throws IOException 异常:源文件不存在、复制失败等 + */ + public static void copyFileUsingFileChannels(File source, File dest) throws IOException { + LogUtils.d(TAG, "【copyFileUsingFileChannels】调用开始 | 源文件=" + source.getAbsolutePath() + " | 目标文件=" + dest.getAbsolutePath()); + // 1. 校验源文件合法性 + if (!source.exists() || !source.isFile()) { + LogUtils.e(TAG, "【copyFileUsingFileChannels】源文件无效:" + source.getAbsolutePath()); + throw new FileNotFoundException("源文件不存在或不是文件:" + source.getAbsolutePath()); + } + + // 2. 创建目标文件父目录 + if (!dest.getParentFile().exists()) { + dest.getParentFile().mkdirs(); + LogUtils.d(TAG, "【copyFileUsingFileChannels】创建父目录:" + dest.getParentFile().getAbsolutePath()); + } + + // 3. 通道复制(手动关闭流,兼容Java 7) + FileChannel inputChannel = null; + FileChannel outputChannel = null; + try { + inputChannel = new FileInputStream(source).getChannel(); + outputChannel = new FileOutputStream(dest).getChannel(); + outputChannel.transferFrom(inputChannel, 0, inputChannel.size()); + LogUtils.d(TAG, "【copyFileUsingFileChannels】复制成功"); + } finally { + if (inputChannel != null) { + inputChannel.close(); + } + if (outputChannel != null) { + outputChannel.close(); + } + } + } + + /** + * 简化版文件复制(基于传统IO,兼容全版本,适用于中小文件) + * @param oldFile 源文件(非空,必须存在) + * @param newFile 目标文件(非空,父目录会自动创建) + * @return 复制结果:true-成功,false-失败 + */ + public static boolean copyFile(File oldFile, File newFile) { + LogUtils.d(TAG, "【copyFile】调用开始 | 源文件=" + (oldFile != null ? oldFile.getAbsolutePath() : "null") + " | 目标文件=" + (newFile != null ? newFile.getAbsolutePath() : "null")); + // 1. 校验源文件合法性 + if (oldFile == null || !oldFile.exists() || !oldFile.isFile()) { + LogUtils.e(TAG, "【copyFile】源文件无效"); + return false; + } + + // 2. 创建目标文件父目录 + if (!newFile.getParentFile().exists()) { + newFile.getParentFile().mkdirs(); + LogUtils.d(TAG, "【copyFile】创建父目录:" + newFile.getParentFile().getAbsolutePath()); + } + + // 3. 复制文件(覆盖已有目标文件) + if (newFile.exists()) { + newFile.delete(); + LogUtils.d(TAG, "【copyFile】删除已有目标文件:" + newFile.getAbsolutePath()); + } + + try { + copyFileUsingFileChannels(oldFile, newFile); + return true; + } catch (Exception e) { + LogUtils.e(TAG, "【copyFile】复制失败:" + e.getMessage(), e); + return false; + } + } + + /** + * 复制输入流到文件(兼容Uri解析失败场景) + * @param inputStream 输入流(非空) + * @param file 目标文件(非空) + * @throws IOException 异常:流关闭失败、目录创建失败等 + */ + public static void copyStreamToFile(InputStream inputStream, File file) throws IOException { + LogUtils.d(TAG, "【copyStreamToFile】调用开始 | 目标文件=" + file.getAbsolutePath()); + // 1. 校验参数合法性 + if (inputStream == null || file == null) { + LogUtils.e(TAG, "【copyStreamToFile】参数为空:InputStream=" + (inputStream == null) + " | File=" + (file == null)); + throw new IllegalArgumentException("InputStream或File不能为空"); + } + + // 2. 创建目标文件父目录 + File parentDir = file.getParentFile(); + if (!parentDir.exists() && !parentDir.mkdirs()) { + LogUtils.e(TAG, "【copyStreamToFile】无法创建父目录:" + parentDir.getAbsolutePath()); + throw new IOException("无法创建父目录:" + parentDir.getAbsolutePath()); + } + + // 3. 流复制(手动关闭流,兼容Java 7) + OutputStream outputStream = null; + try { + outputStream = new FileOutputStream(file); + byte[] buffer = new byte[STREAM_BUFFER_SIZE]; + int length; + while ((length = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, length); + } + outputStream.flush(); + LogUtils.d(TAG, "【copyStreamToFile】复制成功"); + } finally { + try { + inputStream.close(); + } catch (IOException e) { + LogUtils.e(TAG, "【copyStreamToFile】关闭输入流失败:" + e.getMessage()); + } + if (outputStream != null) { + outputStream.close(); + } + } + } + + // ================================== 图片文件相关(BitmapDrawable 获取)================================= + /** + * 从文件路径获取BitmapDrawable(适用于Android图片显示) + * @param path 图片文件绝对路径(非空) + * @return BitmapDrawable 图片对象(文件不存在/读取失败返回null) + * @throws IOException 异常:文件读取IO错误 + */ + public static BitmapDrawable getImageDrawable(String path) throws IOException { + LogUtils.d(TAG, "【getImageDrawable】调用开始 | 图片路径=" + path); + // 1. 校验文件合法性 + File file = new File(path); + if (!file.exists() || !file.isFile()) { + LogUtils.e(TAG, "【getImageDrawable】图片文件无效:" + path); + return null; + } + + // 2. 读取文件并转为BitmapDrawable(缓冲流读取,减少内存占用) + InputStream is = null; + ByteArrayOutputStream bos = null; + try { + is = new FileInputStream(file); + bos = new ByteArrayOutputStream(); + byte[] buffer = new byte[BUFFER_SIZE]; + int readLen; + while ((readLen = is.read(buffer)) != -1) { + bos.write(buffer, 0, readLen); + } + byte[] imageBytes = bos.toByteArray(); + Bitmap bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.length); + LogUtils.d(TAG, "【getImageDrawable】转换成功 | 图片尺寸=" + bitmap.getWidth() + "x" + bitmap.getHeight()); + return new BitmapDrawable(bitmap); + } finally { + if (is != null) { + is.close(); + } + if (bos != null) { + bos.close(); + } + } + } + + // ================================== 文件名处理相关(后缀截取 + 唯一文件名)================================= + /** + * 截取文件后缀名(兼容多 "." 场景,如"image.2025.png" → ".png") + * @param file 目标文件(可为null) + * @return 文件后缀名:带点(如".jpg"),无后缀/文件无效返回空字符串 + */ + public static String getFileSuffixWithMultiDot(File file) { + LogUtils.d(TAG, "【getFileSuffixWithMultiDot】调用开始 | 文件=" + (file != null ? file.getAbsolutePath() : "null")); + // 1. 校验文件合法性 + if (file == null || !file.isFile()) { + LogUtils.d(TAG, "【getFileSuffixWithMultiDot】文件无效,返回空后缀"); + return ""; + } + + // 2. 提取文件名并查找最后一个 "." + String fileName = file.getName(); + int lastDotIndex = fileName.lastIndexOf("."); + + // 3. 校验后缀合法性(排除无后缀、以点结尾、后缀过长的异常文件) + if (lastDotIndex == -1 || lastDotIndex == fileName.length() - 1 || (fileName.length() - lastDotIndex) > MAX_SUFFIX_LENGTH) { + LogUtils.d(TAG, "【getFileSuffixWithMultiDot】无有效后缀 | 文件名=" + fileName); + return ""; + } + + // 4. 返回小写后缀(统一格式,避免大小写不一致问题) + String suffix = fileName.substring(lastDotIndex).toLowerCase(); + LogUtils.d(TAG, "【getFileSuffixWithMultiDot】获取成功 | 后缀=" + suffix); + return suffix; + } + + /** + * 获取文件后缀(不带点,忽略大小写,适配空文件名/无后缀场景) + * @param file 目标文件 + * @return 后缀字符串(无后缀返回空字符串,非空统一小写) + */ + public static String getFileSuffix(File file) { + LogUtils.d(TAG, "【getFileSuffix】调用开始 | 文件=" + (file != null ? file.getAbsolutePath() : "null")); + if (file == null || file.getName().isEmpty()) { + LogUtils.d(TAG, "【getFileSuffix】文件无效,返回空后缀"); + return ""; + } + String fileName = file.getName(); + int lastDotIndex = fileName.lastIndexOf("."); + // 无后缀(没有点,或点在开头/结尾) + if (lastDotIndex == -1 || lastDotIndex == 0 || lastDotIndex == fileName.length() - 1) { + LogUtils.d(TAG, "【getFileSuffix】无有效后缀 | 文件名=" + fileName); + return ""; + } + // 截取后缀并转小写(统一格式,避免 PNG/png 差异) + String suffix = fileName.substring(lastDotIndex + 1).toLowerCase(); + LogUtils.d(TAG, "【getFileSuffix】获取成功 | 后缀=" + suffix); + return suffix; + } + + /** + * 生成唯一文件名(优化版:唯一、合法、简洁) + * 生成规则:UUID(去掉"-") + "_" + 时间戳 + 原文件后缀 + * @param refFile 参考文件(用于提取后缀名,可为null) + * @return 唯一文件名(如"a1b2c3d4e5f6_1730000000000.jpg",无后缀则不带点) + */ + public static String createUniqueFileName(File refFile) { + LogUtils.d(TAG, "【createUniqueFileName】调用开始 | 参考文件=" + (refFile != null ? refFile.getAbsolutePath() : "null")); + // 1. 获取参考文件的后缀名(自动容错null/无效文件) + String suffix = getFileSuffixWithMultiDot(refFile); + // 2. 生成唯一标识(UUID确保全局唯一,时间戳进一步降低重复概率) + String uniqueId = UUID.randomUUID().toString().replace("-", ""); + long timeStamp = System.currentTimeMillis(); + // 3. 拼接文件名(分场景处理,避免多余点) + String fileName; + if (suffix.isEmpty()) { + fileName = String.format("%s_%d", uniqueId, timeStamp); + } else { + fileName = String.format("%s_%d%s", uniqueId, timeStamp, suffix); + } + LogUtils.d(TAG, "【createUniqueFileName】生成成功 | 文件名=" + fileName); + return fileName; + } + + // ================================== 工具辅助方法(文件存在性判断)================================= + /** + * 判断文件是否存在 + * @param path 文件绝对路径 + * @return true-存在,false-不存在 + */ + public static boolean isFileExists(String path) { + LogUtils.d(TAG, "【isFileExists】调用开始 | 文件路径=" + path); + File file = new File(path); + boolean exists = file.exists(); + LogUtils.d(TAG, "【isFileExists】判断结果 | 路径=" + path + " | 存在=" + exists); + return exists; + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/ImageCropUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/ImageCropUtils.java new file mode 100644 index 0000000..5d101a8 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/ImageCropUtils.java @@ -0,0 +1,387 @@ +package cc.winboll.studio.powerbell.utils; + +import android.app.Activity; +import android.content.Intent; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.Build; +import android.widget.Toast; +import androidx.core.content.FileProvider; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.R; +import cc.winboll.studio.powerbell.models.BackgroundBean; +import com.yalantis.ucrop.UCrop; +import java.io.File; +import java.util.regex.Pattern; + +/** + * 图片裁剪工具类(集成 uCrop 2.2.8 终极兼容版,强制输出 PNG 格式,全程保留透明通道,支持 Uri/File/BackgroundBean 多传参) + * 适配:Java 7 + Android API 30 + * 核心策略:强制 PNG 输出,保留透明通道,统一裁剪配置 + */ +public class ImageCropUtils { + // ================================== 静态常量区(置顶归类,消除魔法值)================================= + public static final String TAG = "ImageCropUtils"; + // FileProvider 授权(与 AndroidManifest 配置一致) + private static final String FILE_PROVIDER_SUFFIX = ".fileprovider"; + // 强制输出格式:固定为 PNG(保留透明通道) + private static final String FORCE_OUTPUT_SUFFIX = "png"; + private static final Bitmap.CompressFormat FORCE_COMPRESS_FORMAT = Bitmap.CompressFormat.PNG; + // 图片后缀正则(用于强制替换) + private static final Pattern IMAGE_SUFFIX_PATTERN = Pattern.compile("\\.(jpg|jpeg|png|bmp|gif)$", Pattern.CASE_INSENSITIVE); + + // ================================== 核心裁剪方法(重载:Uri/File/BackgroundBean)================================= + /** + * 【Uri 传参版】启动 uCrop 裁剪 - 强制输出 PNG,保留透明通道 + * @param activity 上下文 + * @param inputUri 输入图片 Uri(本应用 FileProvider Uri,非空) + * @param outputUri 输出图片 Uri(本应用 FileProvider Uri,非空) + * @param aspectX 固定比例 X(自由裁剪传 0) + * @param aspectY 固定比例 Y(自由裁剪传 0) + * @param isFreeCrop 是否自由裁剪 + * @param requestCode 裁剪请求码 + */ + public static void startImageCrop(Activity activity, + Uri inputUri, + Uri outputUri, + int aspectX, + int aspectY, + boolean isFreeCrop, + int requestCode) { + LogUtils.d(TAG, "【startImageCrop】调用开始(Uri 版)| 请求码=" + requestCode); + // 1. 输入参数校验 + if (activity == null || activity.isFinishing()) { + LogUtils.e(TAG, "【startImageCrop】参数异常:Activity 无效或已销毁"); + return; + } + if (inputUri == null || outputUri == null) { + LogUtils.e(TAG, "【startImageCrop】参数异常:输入/输出 Uri 为空"); + showToast(activity, "图片 Uri 无效,无法裁剪"); + return; + } + if (!isValidUri(activity, inputUri)) { + LogUtils.e(TAG, "【startImageCrop】参数异常:输入 Uri 无效 " + inputUri); + showToast(activity, "原图 Uri 无效,无法裁剪"); + return; + } + + // 2. 核心:强制修正输出为 PNG(忽略原图格式,统一转 PNG) + File outputFile = uriToFile(activity, outputUri); + if (outputFile == null) { + LogUtils.e(TAG, "【startImageCrop】转换异常:输出 Uri 转 File 失败 " + outputUri); + showToast(activity, "裁剪输出路径无效"); + return; + } + outputFile = correctFileSuffix(outputFile, FORCE_OUTPUT_SUFFIX); // 强制 .png 后缀 + outputUri = getFileProviderUri(activity, outputFile); // 重新生成 PNG 对应的 Uri + LogUtils.d(TAG, "【startImageCrop】格式修正:强制输出 PNG " + outputFile.getAbsolutePath()); + + // 3. 初始化 uCrop + 强制 PNG 配置(保留透明核心) + UCrop uCrop = UCrop.of(inputUri, outputUri); + //uCrop.withAspectRatio(aspectX, aspectY); + UCrop.Options options = initCropOptions(activity, isFreeCrop, aspectX, aspectY); + + // 4. 启动裁剪 + uCrop.withOptions(options); + uCrop.start(activity, requestCode); + LogUtils.d(TAG, "【startImageCrop】启动成功(Uri 版)| 输出路径=" + outputFile.getAbsolutePath()); + } + + /** + * 【File 传参版】启动 uCrop 裁剪 - 强制输出 PNG,保留透明通道 + * @param activity 上下文 + * @param inputFile 输入图片文件(任意格式) + * @param outputFile 输出图片文件(最终强制转为 PNG) + * @param aspectX 固定比例 X(自由裁剪传 0) + * @param aspectY 固定比例 Y(自由裁剪传 0) + * @param isFreeCrop 是否自由裁剪 + * @param requestCode 裁剪请求码 + */ + public static void startImageCrop(Activity activity, + File inputFile, + File outputFile, + int aspectX, + int aspectY, + boolean isFreeCrop, + int requestCode) { + LogUtils.d(TAG, "【startImageCrop】调用开始(File 版)| 请求码=" + requestCode); + // 1. 输入参数校验 + if (activity == null || activity.isFinishing()) { + LogUtils.e(TAG, "【startImageCrop】参数异常:Activity 无效或已销毁"); + return; + } + if (inputFile == null || !inputFile.exists() || inputFile.length() <= 100) { + LogUtils.e(TAG, "【startImageCrop】参数异常:输入图片文件无效 " + (inputFile != null ? inputFile.getAbsolutePath() : "null")); + showToast(activity, "无有效图片可裁剪"); + return; + } + if (outputFile == null) { + LogUtils.e(TAG, "【startImageCrop】参数异常:输出文件路径为空"); + showToast(activity, "裁剪输出路径无效"); + return; + } + + // 2. 核心:强制修正输出为 PNG(忽略原图格式) + Uri inputUri = getFileProviderUri(activity, inputFile); + outputFile = correctFileSuffix(outputFile, FORCE_OUTPUT_SUFFIX); // 强制 .png 后缀 + Uri outputUri = getFileProviderUri(activity, outputFile); + LogUtils.d(TAG, "【startImageCrop】格式修正:强制输出 PNG " + outputFile.getAbsolutePath()); + + // 3. 初始化 uCrop + 强制 PNG 配置 + UCrop uCrop = UCrop.of(inputUri, outputUri); + //uCrop.withAspectRatio(aspectX, aspectY); + UCrop.Options options = initCropOptions(activity, isFreeCrop, aspectX, aspectY); + + // 4. 启动裁剪 + uCrop.withOptions(options); + uCrop.start(activity, requestCode); + LogUtils.d(TAG, "【startImageCrop】启动成功(File 版)| 输出路径=" + outputFile.getAbsolutePath()); + } + + /** + * 【BackgroundBean 传参版】启动 uCrop 裁剪 - 强制输出 PNG,保留透明通道 + * @param activity 上下文 + * @param cropBean 背景图片 Bean + * @param aspectX 固定比例 X + * @param aspectY 固定比例 Y + * @param isFreeCrop 是否自由裁剪 + * @param requestCode 裁剪请求码 + */ + public static void startImageCrop(Activity activity, + BackgroundBean cropBean, + int aspectX, + int aspectY, + boolean isFreeCrop, + int requestCode) { + LogUtils.d(TAG, "【startImageCrop】调用开始(BackgroundBean 版)| 请求码=" + requestCode); + if (cropBean == null) { + LogUtils.e(TAG, "【startImageCrop】参数异常:BackgroundBean 为空"); + showToast(activity, "裁剪参数无效"); + return; + } + File inputFile = new File(cropBean.getBackgroundFilePath()); + File outputFile = new File(cropBean.getBackgroundScaledCompressFilePath()); + startImageCrop(activity, inputFile, outputFile, aspectX, aspectY, isFreeCrop, requestCode); + LogUtils.d(TAG, "【startImageCrop】启动成功(BackgroundBean 版)| 输入路径=" + inputFile.getAbsolutePath()); + } + + // ================================== 裁剪结果处理(优化日志,增强容错)================================= + /** + * 处理裁剪结果 + * @param requestCode 当前请求码 + * @param resultCode 结果码 + * @param data 结果数据 + * @param cropRequestCode 裁剪请求码 + * @return 裁剪成功返回输出路径,失败返回 null + */ + public static String handleCropResult(int requestCode, int resultCode, Intent data, int cropRequestCode) { + LogUtils.d(TAG, "【handleCropResult】调用开始 | 请求码=" + requestCode + " | 裁剪请求码=" + cropRequestCode); + if (requestCode != cropRequestCode) { + LogUtils.d(TAG, "【handleCropResult】请求码不匹配,忽略结果"); + return null; + } + + if (resultCode == Activity.RESULT_OK && data != null) { + Uri outputUri = UCrop.getOutput(data); + if (outputUri != null) { + String outputPath = uriToPath(outputUri); + LogUtils.d(TAG, "【handleCropResult】裁剪成功 | 输出路径=" + outputPath); + return outputPath; + } else { + LogUtils.e(TAG, "【handleCropResult】裁剪失败:输出 Uri 为空"); + } + } else if (resultCode == UCrop.RESULT_ERROR) { + Throwable error = UCrop.getError(data); + LogUtils.e(TAG, "【handleCropResult】裁剪异常:" + (error != null ? error.getMessage() : "未知错误。")); + } else { + LogUtils.d(TAG, "【handleCropResult】裁剪取消:用户手动取消"); + } + return null; + } + + // ================================== 私有辅助方法(参数校验 + 格式转换 + 配置初始化)================================= + /** + * 校验 Uri 有效性(确保是图片类型) + */ + private static boolean isValidUri(Activity activity, Uri uri) { + try { + String type = activity.getContentResolver().getType(uri); + boolean isValid = type != null && type.startsWith("image/"); + LogUtils.d(TAG, "【isValidUri】Uri 校验结果 | " + uri + " | 有效=" + isValid); + return isValid; + } catch (Exception e) { + LogUtils.e(TAG, "【isValidUri】Uri 校验失败 " + uri, e); + return false; + } + } + + /** + * Uri 转 File(适配 FileProvider Uri 和普通 Uri) + */ + private static File uriToFile(Activity activity, Uri uri) { + if (uri == null) { + LogUtils.e(TAG, "【uriToFile】参数异常:Uri 为空"); + return null; + } + try { + if (uri.getScheme().equals("file")) { + File file = new File(uri.getPath()); + LogUtils.d(TAG, "【uriToFile】转换成功(普通 Uri)| " + uri + " → " + file.getAbsolutePath()); + return file; + } + String filePath = uri.getPath(); + if (filePath == null) { + LogUtils.e(TAG, "【uriToFile】转换失败:Uri 路径为空 " + uri); + return null; + } + // 适配 FileProvider 路径 + if (filePath.contains("/external_files/")) { + filePath = filePath.replace("/external_files/", activity.getExternalFilesDir("").getAbsolutePath() + "/"); + } else if (filePath.contains("/cache/")) { + filePath = filePath.replace("/cache/", activity.getCacheDir().getAbsolutePath() + "/"); + } + File file = new File(filePath); + LogUtils.d(TAG, "【uriToFile】转换成功(FileProvider Uri)| " + uri + " → " + file.getAbsolutePath()); + return file; + } catch (Exception e) { + LogUtils.e(TAG, "【uriToFile】转换失败 " + uri, e); + return null; + } + } + + /** + * Uri 提取文件路径 + */ + private static String uriToPath(Uri uri) { + if (uri == null) { + LogUtils.e(TAG, "【uriToPath】参数异常:Uri 为空"); + return null; + } + try { + if (uri.getScheme().equals("file")) { + String path = uri.getPath(); + LogUtils.d(TAG, "【uriToPath】提取成功(普通 Uri)| " + uri + " → " + path); + return path; + } + String path = uri.getPath(); + if (path == null) { + LogUtils.e(TAG, "【uriToPath】提取失败:Uri 路径为空 " + uri); + return null; + } + // 适配多种 FileProvider 前缀 + String[] prefixes = {"/external/", "/external_files/", "/cache/", "/files/"}; + for (String prefix : prefixes) { + if (path.contains(prefix)) { + path = path.substring(path.indexOf(prefix) + prefix.length()); + String externalRoot = android.os.Environment.getExternalStorageDirectory().getAbsolutePath(); + path = externalRoot + "/" + path; + LogUtils.d(TAG, "【uriToPath】提取成功(FileProvider Uri)| " + uri + " → " + path); + return path; + } + } + LogUtils.d(TAG, "【uriToPath】提取成功(默认路径)| " + uri + " → " + path); + return path; + } catch (Exception e) { + LogUtils.e(TAG, "【uriToPath】提取失败 " + uri, e); + return null; + } + } + + /** + * 统一初始化裁剪配置(强制 PNG 专属配置,保留透明核心) + */ + private static UCrop.Options initCropOptions(Activity activity, boolean isFreeCrop, int aspectX, int aspectY) { + LogUtils.d(TAG, "【initCropOptions】初始化裁剪配置 | 自由裁剪=" + isFreeCrop); + UCrop.Options options = new UCrop.Options(); + + // 裁剪模式配置(自由裁剪/固定比例) + options.setFreeStyleCropEnabled(isFreeCrop); + options.withAspectRatio(aspectX, aspectY); + + // 核心:强制 PNG 保留透明(固定配置,无需判断原图格式) + options.setCompressionFormat(FORCE_COMPRESS_FORMAT); // 强制 PNG 压缩 + options.setCompressionQuality(100); // PNG 100% 质量,不损失透明 + options.setDimmedLayerColor(activity.getResources().getColor(android.R.color.transparent)); // 遮罩透明(关键) + options.setCropFrameColor(activity.getResources().getColor(R.color.colorPrimary)); // 裁剪框主题色 + options.setCropGridColor(activity.getResources().getColor(R.color.colorAccent)); // 网格线主题色 + + // 通用 UI 配置(保持原有风格) + + //options.setHideBottomControls(true); // 隐藏底部控制栏 + options.setToolbarTitle("图片裁剪"); + options.setToolbarColor(activity.getResources().getColor(R.color.colorPrimary)); + options.setToolbarWidgetColor(activity.getResources().getColor(android.R.color.white)); + options.setStatusBarColor(activity.getResources().getColor(R.color.colorPrimaryDark)); + + LogUtils.d(TAG, "【initCropOptions】配置完成:强制 PNG 输出,保留透明通道"); + return options; + } + + /** + * 修正文件后缀(强制转为指定后缀,覆盖原有任何图片后缀) + */ + private static File correctFileSuffix(File originFile, String targetSuffix) { + String originName = originFile.getName(); + // 强制替换所有图片后缀为 targetSuffix + String newName = IMAGE_SUFFIX_PATTERN.matcher(originName).replaceAll("") + "." + targetSuffix; + File newFile = new File(originFile.getParent(), newName); + LogUtils.d(TAG, "【correctFileSuffix】后缀修正 | " + originFile.getName() + " → " + newFile.getName()); + return newFile; + } + + /** + * 生成 FileProvider Uri(适配 Android 7.0+) + */ + private static Uri getFileProviderUri(Activity activity, File file) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + String authority = activity.getPackageName() + FILE_PROVIDER_SUFFIX; + Uri uri = FileProvider.getUriForFile(activity, authority, file); + LogUtils.d(TAG, "【getFileProviderUri】生成成功(Android 7.0+)| " + file.getAbsolutePath() + " → " + uri); + return uri; + } else { + Uri uri = Uri.fromFile(file); + LogUtils.d(TAG, "【getFileProviderUri】生成成功(Android 7.0-)| " + file.getAbsolutePath() + " → " + uri); + return uri; + } + } catch (Exception e) { + LogUtils.e(TAG, "【getFileProviderUri】生成失败 " + file.getAbsolutePath(), e); + return null; + } + } + + /** + * 显示 Toast(避免崩溃) + */ + private static void showToast(Activity activity, String msg) { + if (activity != null && !activity.isFinishing()) { + Toast.makeText(activity, msg, Toast.LENGTH_SHORT).show(); + LogUtils.d(TAG, "【showToast】显示提示:" + msg); + } else { + LogUtils.e(TAG, "【showToast】无法显示提示:Activity 无效"); + } + } + + // ================================== 公有辅助方法(供外部调用)================================= + /** + * 公有方法:生成 FileProvider Uri + */ + public static Uri getFileProviderUriPublic(Activity activity, File file) { + return getFileProviderUri(activity, file); + } + + /** + * 公有方法:Uri 转 File + */ + public static File getFileFromUriPublic(Activity activity, Uri uri) { + return uriToFile(activity, uri); + } + + /** + * 公有方法:Uri 提取路径 + */ + public static String getPathFromUriPublic(Uri uri) { + return uriToPath(uri); + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/ImageDownloader.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/ImageDownloader.java new file mode 100644 index 0000000..82d27a0 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/ImageDownloader.java @@ -0,0 +1,349 @@ +package cc.winboll.studio.powerbell.utils; + +import android.content.Context; +import android.text.TextUtils; +import cc.winboll.studio.libappbase.LogUtils; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * 图片下载工具类(单例模式) + * 功能:下载网络图片到缓存目录、清理过期文件、获取最新下载文件 + * 适配:Java 7 + Android API 30 + * 核心策略:OkHttp 全局复用、7天文件过期清理、UUID 唯一文件名、内置缓存目录(无需权限) + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/11/19 20:52 + */ +public class ImageDownloader { + // ================================== 静态常量区(置顶归类,消除魔法值)================================= + public static final String TAG = "ImageDownloader"; + // 缓存目录子文件夹名称 + private static final String CACHE_DIR_NAME = "networkdownload"; + // 过期时间:7天(单位:毫秒) + private static final long EXPIRE_TIME = 7 * 24 * 3600 * 1000; + // OkHttp 超时配置 + private static final int CONNECT_TIMEOUT = 10; + private static final int READ_WRITE_TIMEOUT = 15; + // 文件后缀最大长度 + private static final int MAX_EXTENSION_LENGTH = 5; + // 默认文件后缀 + private static final String DEFAULT_EXTENSION = ".jpg"; + // 缓冲区大小 + private static final int BUFFER_SIZE = 1024; + + // ================================== 成员变量(单例核心 + 全局资源)================================= + // 单例实例 + private static ImageDownloader sInstance; + // OkHttp 客户端(全局复用,提升性能) + private OkHttpClient mOkHttpClient; + // 缓存目录:/data/data/应用包名/cache/networkdownload + private File mCacheDir; + + // ================================== 单例方法(线程安全 + 应用上下文)================================= + /** + * 单例获取方法(线程安全) + * @param context 上下文(建议使用 Application 上下文避免内存泄漏) + * @return 单例实例 + */ + public static synchronized ImageDownloader getInstance(Context context) { + LogUtils.d(TAG, "【getInstance】单例获取方法调用"); + if (sInstance == null) { + // 使用 Application 上下文,防止 Activity 销毁导致的内存泄漏 + sInstance = new ImageDownloader(context.getApplicationContext()); + LogUtils.d(TAG, "【getInstance】单例实例首次创建"); + } + return sInstance; + } + + // ================================== 构造方法(私有 + 初始化逻辑)================================= + /** + * 私有构造(单例模式禁止外部实例化) + * @param context 应用上下文 + */ + private ImageDownloader(Context context) { + LogUtils.d(TAG, "【ImageDownloader】构造方法调用,开始初始化"); + // 初始化 OkHttp 客户端(设置超时时间) + initOkHttpClient(); + // 初始化缓存目录:networkdownload + initCacheDir(context); + // 初始化时清理过期文件 + clearExpiredFiles(); + LogUtils.d(TAG, "【ImageDownloader】初始化完成"); + } + + // ================================== 核心初始化方法(OkHttp + 缓存目录)================================= + /** + * 初始化 OkHttp 客户端(全局复用) + */ + private void initOkHttpClient() { + LogUtils.d(TAG, "【initOkHttpClient】开始初始化 OkHttp 客户端"); + mOkHttpClient = new OkHttpClient.Builder() + .connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS) + .readTimeout(READ_WRITE_TIMEOUT, TimeUnit.SECONDS) + .writeTimeout(READ_WRITE_TIMEOUT, TimeUnit.SECONDS) + .build(); + LogUtils.d(TAG, "【initOkHttpClient】OkHttp 客户端初始化完成"); + } + + /** + * 初始化缓存目录:若不存在则创建 + * @param context 应用上下文 + */ + private void initCacheDir(Context context) { + LogUtils.d(TAG, "【initCacheDir】开始初始化缓存目录"); + // 获取应用内置缓存目录(无需权限) + File cacheRoot = context.getCacheDir(); + mCacheDir = new File(cacheRoot, CACHE_DIR_NAME); + + // 若目录不存在则创建(包括父目录) + if (!mCacheDir.exists()) { + boolean isCreated = mCacheDir.mkdirs(); + if (isCreated) { + LogUtils.d(TAG, "【initCacheDir】缓存目录创建成功:" + mCacheDir.getAbsolutePath()); + } else { + LogUtils.e(TAG, "【initCacheDir】缓存目录创建失败"); + } + } else { + LogUtils.d(TAG, "【initCacheDir】缓存目录已存在:" + mCacheDir.getAbsolutePath()); + } + } + + // ================================== 核心业务方法(下载 + 清理 + 获取最新文件)================================= + /** + * 下载网络图片到缓存目录 + * @param imageUrl 图片网络链接 + * @param callback 下载结果回调(成功/失败) + */ + public void downloadImage(final String imageUrl, final DownloadCallback callback) { + LogUtils.d(TAG, "【downloadImage】下载方法调用 | 图片链接=" + imageUrl); + // 1. 校验参数 + if (TextUtils.isEmpty(imageUrl)) { + String errorMsg = "图片链接为空"; + LogUtils.e(TAG, "【downloadImage】参数校验失败:" + errorMsg); + if (callback != null) { + callback.onFailure(errorMsg); + } + return; + } + + if (mCacheDir == null || !mCacheDir.exists()) { + String errorMsg = "缓存目录不存在"; + LogUtils.e(TAG, "【downloadImage】参数校验失败:" + errorMsg); + if (callback != null) { + callback.onFailure(errorMsg); + } + return; + } + + // 2. 构建 OkHttp 请求 + Request request = new Request.Builder() + .url(imageUrl) + .build(); + + // 3. 异步下载(避免阻塞主线程) + mOkHttpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + String errorMsg = "下载失败:" + e.getMessage(); + LogUtils.e(TAG, "【downloadImage】OkHttp 下载失败", e); + if (callback != null) { + callback.onFailure(errorMsg); + } + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + // 3.1 响应状态校验 + if (!response.isSuccessful()) { + String errorMsg = "响应失败:" + response.code(); + LogUtils.e(TAG, "【downloadImage】响应失败,状态码:" + response.code()); + if (callback != null) { + callback.onFailure(errorMsg); + } + // 关闭响应体 + if (response.body() != null) { + response.body().close(); + } + return; + } + + // 3.2 响应成功,写入文件 + InputStream inputStream = null; + FileOutputStream outputStream = null; + try { + inputStream = response.body().byteStream(); + // 生成 UUID 唯一文件名(保留原文件后缀) + String fileExtension = getFileExtension(imageUrl); + String fileName = UUID.randomUUID().toString() + fileExtension; + File imageFile = new File(mCacheDir, fileName); + + // 写入文件 + outputStream = new FileOutputStream(imageFile); + byte[] buffer = new byte[BUFFER_SIZE]; + int len; + while ((len = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, len); + } + outputStream.flush(); + + // 下载成功,回调主线程并返回文件路径 + String filePath = imageFile.getAbsolutePath(); + LogUtils.d(TAG, "【downloadImage】图片下载成功:" + filePath); + if (callback != null) { + callback.onSuccess(filePath); + } + + } catch (IOException e) { + String errorMsg = "文件写入失败:" + e.getMessage(); + LogUtils.e(TAG, "【downloadImage】文件写入失败", e); + if (callback != null) { + callback.onFailure(errorMsg); + } + } finally { + // 关闭流(Java7 手动关闭,避免资源泄漏) + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException e) { + LogUtils.e(TAG, "【downloadImage】输入流关闭失败", e); + } + } + if (outputStream != null) { + try { + outputStream.close(); + } catch (IOException e) { + LogUtils.e(TAG, "【downloadImage】输出流关闭失败", e); + } + } + // 关闭响应体 + if (response.body() != null) { + response.body().close(); + } + } + } + }); + } + + /** + * 清理过期文件(最后修改时间超过 EXPIRE_TIME 的文件) + */ + private void clearExpiredFiles() { + LogUtils.d(TAG, "【clearExpiredFiles】开始清理过期文件"); + if (mCacheDir == null || !mCacheDir.exists()) { + LogUtils.d(TAG, "【clearExpiredFiles】缓存目录不存在,无需清理"); + return; + } + + File[] files = mCacheDir.listFiles(); + if (files == null || files.length == 0) { + LogUtils.d(TAG, "【clearExpiredFiles】缓存目录无文件,无需清理"); + return; + } + + long currentTime = System.currentTimeMillis(); + int deleteCount = 0; + + // 遍历所有文件,删除过期文件 + for (File file : files) { + long lastModifyTime = file.lastModified(); + if (currentTime - lastModifyTime > EXPIRE_TIME) { + if (file.delete()) { + deleteCount++; + LogUtils.d(TAG, "【clearExpiredFiles】删除过期文件:" + file.getName()); + } else { + LogUtils.e(TAG, "【clearExpiredFiles】删除过期文件失败:" + file.getName()); + } + } + } + + LogUtils.d(TAG, "【clearExpiredFiles】过期文件清理完成,共删除 " + deleteCount + " 个文件"); + } + + /** + * 获取 networkdownload 目录中最后下载的文件(按修改时间排序) + * @return 最后下载的文件路径(null 表示无文件) + */ + public String getLastDownloadedFile() { + LogUtils.d(TAG, "【getLastDownloadedFile】获取最新下载文件"); + if (mCacheDir == null || !mCacheDir.exists()) { + LogUtils.e(TAG, "【getLastDownloadedFile】缓存目录不存在"); + return null; + } + + File[] files = mCacheDir.listFiles(); + if (files == null || files.length == 0) { + LogUtils.d(TAG, "【getLastDownloadedFile】缓存目录无文件"); + return null; + } + + // 按最后修改时间降序排序,取第一个即为最新文件 + File lastFile = files[0]; + for (File file : files) { + if (file.lastModified() > lastFile.lastModified()) { + lastFile = file; + } + } + + String filePath = lastFile.getAbsolutePath(); + LogUtils.d(TAG, "【getLastDownloadedFile】最后下载的文件:" + filePath); + return filePath; + } + + // ================================== 辅助工具方法(文件后缀提取)================================= + /** + * 工具方法:从图片链接中提取文件后缀(如 .png、.jpg) + * @param imageUrl 图片链接 + * @return 文件后缀(含点号,若无法提取则返回 .jpg) + */ + private String getFileExtension(String imageUrl) { + LogUtils.d(TAG, "【getFileExtension】提取文件后缀 | 图片链接=" + imageUrl); + if (TextUtils.isEmpty(imageUrl)) { + LogUtils.d(TAG, "【getFileExtension】图片链接为空,返回默认后缀:" + DEFAULT_EXTENSION); + return DEFAULT_EXTENSION; + } + + int lastDotIndex = imageUrl.lastIndexOf("."); + int lastSlashIndex = imageUrl.lastIndexOf("/"); + // 确保后缀在最后一个斜杠之后,且长度合理(1-5 个字符) + if (lastDotIndex > lastSlashIndex && lastDotIndex < imageUrl.length() - 1) { + String extension = imageUrl.substring(lastDotIndex); + if (extension.length() <= MAX_EXTENSION_LENGTH) { + extension = extension.toLowerCase(); // 统一转为小写 + LogUtils.d(TAG, "【getFileExtension】提取后缀成功:" + extension); + return extension; + } + } + + // 无法提取后缀时,默认使用 .jpg + LogUtils.d(TAG, "【getFileExtension】无法提取有效后缀,返回默认后缀:" + DEFAULT_EXTENSION); + return DEFAULT_EXTENSION; + } + + // ================================== 下载结果回调接口(Java7 接口实现)================================= + /** + * 下载结果回调接口 + */ + public interface DownloadCallback { + /** + * 下载成功 + * @param filePath 图片保存路径 + */ + void onSuccess(String filePath); + + /** + * 下载失败 + * @param errorMsg 失败原因 + */ + void onFailure(String errorMsg); + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/ImageUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/ImageUtils.java new file mode 100644 index 0000000..b3f9e40 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/ImageUtils.java @@ -0,0 +1,250 @@ +package cc.winboll.studio.powerbell.utils; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Paint; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.R; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +/** + * 图片处理工具类(质量压缩专用) + * 功能:1. 图片JPEG质量压缩(覆盖源文件);2. 获取主题colorAccent颜色;3. 位图纯色背景合成 + * 适配:Java 7 + Android API 30 + * 核心逻辑: + * - 压缩:Bitmap.compress + FileChannel 高效文件复制 + * - 主题颜色:TypedArray 解析主题属性 + * - 位图合成:Canvas 绘制(支持透明通道) + * @Author 豆包&ZhanGSKen + */ +public class ImageUtils { + + // ================================== 静态常量区(置顶归类,消除魔法值)================================= + public static final String TAG = "ImageUtils"; + private static final Bitmap.CompressFormat COMPRESS_FORMAT = Bitmap.CompressFormat.JPEG; + private static final int MIN_COMPRESS_QUALITY = 0; + private static final int MAX_COMPRESS_QUALITY = 100; + private static final int[] COLOR_ACCENT_ATTR = new int[]{R.attr.colorAccent}; // colorAccent属性数组 + private static final int DEFAULT_COLOR = Color.parseColor("#FFFFFFFF"); // 默认返回颜色 + + // ================================== 主题颜色获取方法 ================================= + /** + * 从当前应用主题中获取 colorAccent 颜色值 + * @param context 上下文,用于获取主题与资源 + * @return 解析到的 colorAccent 颜色值,解析失败返回默认白色#FFFFFFFF + */ + public static int getColorAccent(Context context) { + // 方法调用日志 + LogUtils.d(TAG, "【getColorAccent】方法调用"); + // 参数校验 + if (context == null) { + LogUtils.e(TAG, "【getColorAccent】参数异常:Context为空"); + return DEFAULT_COLOR; + } + + TypedArray typedArray = null; + try { + // 从主题解析属性 + typedArray = context.obtainStyledAttributes(COLOR_ACCENT_ATTR); + int colorAccent = typedArray.getColor(0, DEFAULT_COLOR); + LogUtils.d(TAG, String.format("【getColorAccent】解析成功 | colorAccent=0x%08X", colorAccent)); + return colorAccent; + } catch (Exception e) { + LogUtils.e(TAG, "【getColorAccent】解析失败,返回默认颜色", e); + return DEFAULT_COLOR; + } finally { + // 回收资源 + if (typedArray != null) { + typedArray.recycle(); + LogUtils.d(TAG, "【getColorAccent】TypedArray资源已回收"); + } + } + } + + // ================================== 位图合成方法 ================================= + /** + * 在纯色背景上绘制前景位图,实现FIT_CENTER居中效果 + * @param bgColor 背景颜色 + * @param originalFrameW 目标画布宽度 + * @param originalFrameH 目标画布高度 + * @param fgBitmap 前景位图 + * @return 合成后的位图,失败返回null + */ + public static Bitmap drawBitmapOnSolidBackground(final int bgColor, int originalFrameW, int originalFrameH, Bitmap fgBitmap) { + // 方法调用及入参日志 + LogUtils.d(TAG, String.format("【drawBitmapOnSolidBackground】方法调用 | 背景色=0x%08X | 目标尺寸=%dx%d | 前景位图=%s", + bgColor, originalFrameW, originalFrameH, + (fgBitmap != null ? fgBitmap.getWidth() + "x" + fgBitmap.getHeight() : "null"))); + + // 1. 严格参数校验 + if (fgBitmap == null || fgBitmap.isRecycled()) { + LogUtils.e(TAG, "【drawBitmapOnSolidBackground】参数异常:前景Bitmap为空或已回收"); + return null; + } + if (fgBitmap.getWidth() <= 0 || fgBitmap.getHeight() <= 0) { + LogUtils.e(TAG, "【drawBitmapOnSolidBackground】参数异常:前景Bitmap尺寸无效"); + return null; + } + if (originalFrameW <= 0 || originalFrameH <= 0) { + LogUtils.e(TAG, "【drawBitmapOnSolidBackground】参数异常:原画框尺寸无效(宽高必须大于0)"); + return null; + } + + // 2. 强制画布尺寸为目标尺寸 + int canvasW = originalFrameW; + int canvasH = originalFrameH; + LogUtils.d(TAG, String.format("【drawBitmapOnSolidBackground】画布尺寸已确定:%dx%d", canvasW, canvasH)); + + // 3. 创建结果位图(ARGB_8888支持透明通道) + Bitmap resultBitmap = Bitmap.createBitmap(canvasW, canvasH, Bitmap.Config.ARGB_8888); + if (resultBitmap == null) { + LogUtils.e(TAG, "【drawBitmapOnSolidBackground】创建结果Bitmap失败"); + return null; + } + + // 4. 画布绘制 + Canvas canvas = new Canvas(resultBitmap); + try { + // 4.1 绘制纯色背景 + Paint bgPaint = new Paint(); + bgPaint.setColor(bgColor); + bgPaint.setStyle(Paint.Style.FILL); + canvas.drawRect(0, 0, canvasW, canvasH, bgPaint); + + // 4.2 计算前景 FIT_CENTER 变换参数(等比缩放至完全放入画布,居中显示) + float fgWidth = fgBitmap.getWidth(); + float fgHeight = fgBitmap.getHeight(); + float scaleX = (float) canvasW / fgWidth; + float scaleY = (float) canvasH / fgHeight; + float scale = Math.min(scaleX, scaleY); // 取最小比例,保证完全放入画布 + + // 4.3 计算缩放后前景尺寸 + float scaledW = fgWidth * scale; + float scaledH = fgHeight * scale; + + // 4.4 计算居中位置(缩放后居中,无裁剪) + float translateX = (canvasW - scaledW) / 2f; + float translateY = (canvasH - scaledH) / 2f; + + // 4.5 构建变换矩阵(缩放+平移,实现 FIT_CENTER 效果) + Matrix matrix = new Matrix(); + matrix.postScale(scale, scale); // 等比缩放 + matrix.postTranslate(translateX, translateY); // 居中平移 + + // 4.6 绘制前景(保留透明通道,抗锯齿) + Paint fgPaint = new Paint(); + fgPaint.setAntiAlias(true); + fgPaint.setDither(true); + canvas.drawBitmap(fgBitmap, matrix, fgPaint); + + LogUtils.d(TAG, String.format("【drawBitmapOnSolidBackground】合成成功 | 缩放比例=%.2f | 居中位置=(%.1f,%.1f) | 效果=FIT_CENTER", + scale, translateX, translateY)); + return resultBitmap; + } catch (Exception e) { + LogUtils.e(TAG, "【drawBitmapOnSolidBackground】合成失败", e); + if (resultBitmap != null && !resultBitmap.isRecycled()) { + resultBitmap.recycle(); + } + return null; + } + } + + // ================================== 核心压缩方法 ================================= + /** + * 图片质量压缩(JPEG格式),压缩后覆盖源文件 + * @param context 上下文(备用) + * @param srcImagePath 源图片文件路径(非空,文件需存在) + * @param dstImagePath 压缩后临时保存路径(非空) + * @param compressQuality 压缩质量(0-100,数值越小压缩率越高) + */ + public static void bitmapCompress(Context context, String srcImagePath, String dstImagePath, int compressQuality) { + // 方法调用及入参日志 + LogUtils.d(TAG, String.format("【bitmapCompress】方法调用 | 源路径=%s | 临时路径=%s | 压缩质量=%d", + srcImagePath, dstImagePath, compressQuality)); + + // 1. 前置参数校验 + if (srcImagePath == null || srcImagePath.isEmpty()) { + LogUtils.e(TAG, "【bitmapCompress】参数异常:源文件路径为空"); + return; + } + if (dstImagePath == null || dstImagePath.isEmpty()) { + LogUtils.e(TAG, "【bitmapCompress】参数异常:临时文件路径为空"); + return; + } + if (compressQuality < MIN_COMPRESS_QUALITY || compressQuality > MAX_COMPRESS_QUALITY) { + LogUtils.e(TAG, String.format("【bitmapCompress】参数异常:压缩质量超出范围(0-100),当前值=%d", compressQuality)); + return; + } + + File srcFile = new File(srcImagePath); + if (!srcFile.exists() || !srcFile.isFile()) { + LogUtils.e(TAG, "【bitmapCompress】源文件无效:不存在或不是文件 " + srcImagePath); + return; + } + + Bitmap compressBitmap = null; + OutputStream outputStream = null; + try { + // 2. 读取源图片为Bitmap + compressBitmap = BitmapFactory.decodeFile(srcImagePath); + if (compressBitmap == null) { + LogUtils.e(TAG, "【bitmapCompress】Bitmap解码失败:无法读取源图片 " + srcImagePath); + return; + } + LogUtils.d(TAG, String.format("【bitmapCompress】Bitmap解码成功 | 尺寸=%dx%d", + compressBitmap.getWidth(), compressBitmap.getHeight())); + + // 3. 创建临时压缩文件目录 + File dstFile = new File(dstImagePath); + File dstParentDir = dstFile.getParentFile(); + if (dstParentDir != null && !dstParentDir.exists()) { + boolean isDirCreated = dstParentDir.mkdirs(); + LogUtils.d(TAG, String.format("【bitmapCompress】临时目录创建%s:%s", + isDirCreated ? "成功" : "失败", dstParentDir.getAbsolutePath())); + } + + // 4. 写入压缩数据 + outputStream = new FileOutputStream(dstFile); + boolean isCompressSuccess = compressBitmap.compress(COMPRESS_FORMAT, compressQuality, outputStream); + if (!isCompressSuccess) { + LogUtils.e(TAG, "【bitmapCompress】压缩失败:Bitmap.compress 执行失败"); + return; + } + LogUtils.d(TAG, "【bitmapCompress】压缩成功:临时文件已生成 " + dstFile.getAbsolutePath()); + + // 5. 复制压缩文件覆盖源文件 + FileUtils.copyFileUsingFileChannels(dstFile, srcFile); + LogUtils.d(TAG, String.format("【bitmapCompress】%d%%压缩结束:已覆盖源文件 %s", + compressQuality, srcImagePath)); + + } catch (FileNotFoundException e) { + LogUtils.e(TAG, "【bitmapCompress】文件未找到异常", e); + } catch (IOException e) { + LogUtils.e(TAG, "【bitmapCompress】IO异常", e); + } finally { + // 6. 关闭输出流 + if (outputStream != null) { + try { + outputStream.close(); + } catch (IOException e) { + LogUtils.e(TAG, "【bitmapCompress】输出流关闭失败", e); + } + } + // 7. 回收Bitmap + if (compressBitmap != null && !compressBitmap.isRecycled()) { + compressBitmap.recycle(); + LogUtils.d(TAG, "【bitmapCompress】Bitmap资源已回收"); + } + } + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/NotificationManagerUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/NotificationManagerUtils.java new file mode 100644 index 0000000..5e9289a --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/NotificationManagerUtils.java @@ -0,0 +1,529 @@ +package cc.winboll.studio.powerbell.utils; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.RingtoneManager; +import android.os.Build; +import android.provider.Settings; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.MainActivity; +import cc.winboll.studio.powerbell.R; +import cc.winboll.studio.powerbell.models.NotificationMessage; + +/** + * 通知工具类:统一管理前台服务/电池提醒/应用配置信息通知 + * 适配:API19-30 | Java7 | 小米手机 + * 特性:前台服务无铃声、提醒通知系统默认铃声、配置通知系统默认铃声无振动、API分级适配、内存泄漏防护 + */ +public class NotificationManagerUtils { + // ================================== 静态常量(置顶统一管理,杜绝魔法值)================================= + public static final String TAG = "NotificationManagerUtils"; + // 通知渠道ID(API26+ 必需,区分通知类型) + public static final String CHANNEL_ID_FOREGROUND = "cc.winboll.studio.powerbell.channel.foreground"; + public static final String CHANNEL_ID_REMIND = "cc.winboll.studio.powerbell.channel.remind"; + public static final String CHANNEL_ID_CONFIG = "cc.winboll.studio.powerbell.channel.config"; // 新增:应用配置信息渠道 + // 通知ID(唯一标识,避免重复) + public static final int NOTIFY_ID_FOREGROUND_SERVICE = 1001; + public static final int NOTIFY_ID_REMIND = 1002; + public static final int NOTIFY_ID_CONFIG = 1003; // 新增:应用配置信息通知ID + // 低版本兼容:默认通知图标(API<21 避免显示异常) + private static final int NOTIFICATION_DEFAULT_ICON = R.drawable.ic_launcher; + // 通知内容兜底常量 + private static final String FOREGROUND_NOTIFY_TITLE_DEFAULT = "电池服务运行中"; + private static final String FOREGROUND_NOTIFY_CONTENT_DEFAULT = "后台监测电池状态"; + private static final String REMIND_NOTIFY_TITLE_DEFAULT = "电池状态提醒"; + private static final String REMIND_NOTIFY_CONTENT_DEFAULT = "电池状态异常,请及时处理"; + private static final String CONFIG_NOTIFY_TITLE_DEFAULT = "应用配置更新"; // 新增:配置通知默认标题 + private static final String CONFIG_NOTIFY_CONTENT_DEFAULT = "配置信息已更新,生效中"; // 新增:配置通知默认内容 + // PendingIntent请求码 + private static final int PENDING_INTENT_REQUEST_CODE_FOREGROUND = 0; + private static final int PENDING_INTENT_REQUEST_CODE_REMIND = 1; + private static final int PENDING_INTENT_REQUEST_CODE_CONFIG = 2; // 新增:配置通知请求码 + private static int snMessageNotificationID = 10000; + + // ================================== 成员变量(私有封装,按依赖优先级排序)================================= + // 核心上下文(应用级,避免内存泄漏) + private Context mContext; + // 系统通知服务(核心依赖) + private NotificationManager mNotificationManager; + // 前台服务通知实例(单独持有,便于更新/取消) + private Notification mForegroundServiceNotify; + + // ================================== 构造方法(初始化核心资源,前置校验)================================= + public NotificationManagerUtils(Context context) { + LogUtils.d(TAG, "NotificationManagerUtils() 构造 | context=" + context); + // 前置校验:Context非空 + if (context == null) { + LogUtils.e(TAG, "NotificationManagerUtils() 构造失败:context is null"); + return; + } + // 初始化核心资源 + this.mContext = context.getApplicationContext(); + this.mNotificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); + LogUtils.d(TAG, "NotificationManagerUtils() 核心资源初始化完成 | mContext=" + mContext + " | mNotificationManager=" + mNotificationManager); + // 初始化通知渠道(API26+ 必需) + initNotificationChannels(); + LogUtils.d(TAG, "NotificationManagerUtils() 构造完成"); + } + + // ================================== 核心初始化方法(通知渠道,API分级适配)================================= + /** + * 初始化通知渠道:前台服务渠道(无铃声+无振动)、提醒渠道(系统默认铃声+无振动)、配置信息渠道(系统默认铃声+无振动) + */ + private void initNotificationChannels() { + LogUtils.d(TAG, "initNotificationChannels() 执行通知渠道初始化"); + // API<26 无渠道机制,直接返回 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + LogUtils.d(TAG, "initNotificationChannels() API<26,无需创建渠道"); + return; + } + // 通知服务为空,避免空指针 + if (mNotificationManager == null) { + LogUtils.e(TAG, "initNotificationChannels() 失败:NotificationManager is null"); + return; + } + + // 1. 前台服务渠道(低优先级,后台保活无打扰) + NotificationChannel foregroundChannel = new NotificationChannel( + CHANNEL_ID_FOREGROUND, + "电池服务保活", + NotificationManager.IMPORTANCE_LOW + ); + foregroundChannel.setDescription("电池监测服务后台运行,无声音、无振动"); + foregroundChannel.enableLights(false); + foregroundChannel.enableVibration(false); + foregroundChannel.setSound(null, null); // 强制无铃声 + foregroundChannel.setShowBadge(false); + foregroundChannel.setLockscreenVisibility(Notification.VISIBILITY_SECRET); + LogUtils.d(TAG, "initNotificationChannels() 前台服务渠道配置完成"); + + // 2. 电池提醒渠道(中优先级,系统默认铃声,无振动) + NotificationChannel remindChannel = new NotificationChannel( + CHANNEL_ID_REMIND, + "电池状态提醒", + NotificationManager.IMPORTANCE_DEFAULT + ); + remindChannel.setDescription("电池满电/低电量提醒,系统默认铃声,无振动"); + remindChannel.enableLights(true); + remindChannel.enableVibration(false); + remindChannel.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), Notification.AUDIO_ATTRIBUTES_DEFAULT); + remindChannel.setShowBadge(false); + remindChannel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); + LogUtils.d(TAG, "initNotificationChannels() 电池提醒渠道配置完成"); + + // 3. 应用配置信息渠道(方案1修复:默认优先级,系统默认铃声,无振动) + NotificationChannel configChannel = new NotificationChannel( + CHANNEL_ID_CONFIG, + "应用配置信息", + NotificationManager.IMPORTANCE_DEFAULT + ); + configChannel.setDescription("应用配置更新、参数变更等提示,系统默认铃声、无振动"); + configChannel.enableLights(true); + configChannel.enableVibration(false); + configChannel.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), Notification.AUDIO_ATTRIBUTES_DEFAULT); + configChannel.setShowBadge(false); + configChannel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); + LogUtils.d(TAG, "initNotificationChannels() 应用配置信息渠道配置完成"); + + // 注册渠道到系统 + mNotificationManager.createNotificationChannel(foregroundChannel); + mNotificationManager.createNotificationChannel(remindChannel); + mNotificationManager.createNotificationChannel(configChannel); // 注册新增渠道 + LogUtils.d(TAG, "initNotificationChannels() 成功:创建前台服务+电池提醒+应用配置信息渠道"); + } + + // ================================== 对外核心方法(前台服务通知:启动/更新/取消)================================= + /** + * 启动前台服务通知(API30适配,无铃声) + */ + public void startForegroundServiceNotify(Service service, NotificationMessage message) { + LogUtils.d(TAG, "startForegroundServiceNotify() 执行 | notifyId=" + NOTIFY_ID_FOREGROUND_SERVICE + " | service=" + service + " | message=" + message); + // 前置校验:参数非空 + if (service == null || message == null || mNotificationManager == null) { + LogUtils.e(TAG, "startForegroundServiceNotify() 失败:param is null | service=" + service + " | message=" + message + " | mNotificationManager=" + mNotificationManager); + return; + } + + // 构建前台通知 + mForegroundServiceNotify = buildForegroundNotification(message); + if (mForegroundServiceNotify == null) { + LogUtils.e(TAG, "startForegroundServiceNotify() 失败:构建通知为空"); + return; + } + + // 启动前台服务(API30无FOREGROUND_SERVICE_TYPE限制,全版本通用) + try { + service.startForeground(NOTIFY_ID_FOREGROUND_SERVICE, mForegroundServiceNotify); + LogUtils.d(TAG, "startForegroundServiceNotify() 成功"); + } catch (Exception e) { + LogUtils.e(TAG, "startForegroundServiceNotify() 异常", e); + } + } + + /** + * 更新前台服务通知内容(复用通知ID,保持无铃声) + */ + public void updateForegroundServiceNotify(NotificationMessage message) { + LogUtils.d(TAG, "updateForegroundServiceNotify() 执行 | notifyId=" + NOTIFY_ID_FOREGROUND_SERVICE + " | message=" + message); + if (message == null || mNotificationManager == null) { + LogUtils.e(TAG, "updateForegroundServiceNotify() 失败:param is null | message=" + message + " | mNotificationManager=" + mNotificationManager); + return; + } + + mForegroundServiceNotify = buildForegroundNotification(message); + if (mForegroundServiceNotify == null) { + LogUtils.e(TAG, "updateForegroundServiceNotify() 失败:构建通知为空"); + return; + } + + try { + mNotificationManager.notify(NOTIFY_ID_FOREGROUND_SERVICE, mForegroundServiceNotify); + LogUtils.d(TAG, "updateForegroundServiceNotify() 成功"); + } catch (Exception e) { + LogUtils.e(TAG, "updateForegroundServiceNotify() 异常", e); + } + } + + /** + * 取消前台服务通知(Service销毁时调用) + */ + public void cancelForegroundServiceNotify() { + LogUtils.d(TAG, "cancelForegroundServiceNotify() 执行 | notifyId=" + NOTIFY_ID_FOREGROUND_SERVICE); + cancelNotification(NOTIFY_ID_FOREGROUND_SERVICE); + mForegroundServiceNotify = null; // 置空释放 + LogUtils.d(TAG, "cancelForegroundServiceNotify() 成功"); + } + + // ================================== 对外核心方法(电池提醒通知:发送)================================= + /** + * 发送电池提醒通知(系统默认铃声,无振动) + */ + public void showRemindNotification(Context context, NotificationMessage message) { + LogUtils.d(TAG, "showRemindNotification() 执行 | notifyId=" + NOTIFY_ID_REMIND + " | context=" + context + " | message=" + message); + if (context == null || message == null || mNotificationManager == null) { + LogUtils.e(TAG, "showRemindNotification() 失败:param is null | context=" + context + " | message=" + message + " | mNotificationManager=" + mNotificationManager); + return; + } + + Notification remindNotify = buildRemindNotification(context, message); + if (remindNotify == null) { + LogUtils.e(TAG, "showRemindNotification() 失败:构建通知为空"); + return; + } + + try { + mNotificationManager.notify(NOTIFY_ID_REMIND, remindNotify); + LogUtils.d(TAG, "showRemindNotification() 成功"); + } catch (Exception e) { + LogUtils.e(TAG, "showRemindNotification() 异常", e); + } + } + + public synchronized void showMessageNotification(Context context, NotificationMessage message) { + snMessageNotificationID++; + LogUtils.d(TAG, "showMessageNotification() 执行 | notifyId=" + snMessageNotificationID + " | context=" + context + " | message=" + message); + if (context == null || message == null || mNotificationManager == null) { + LogUtils.e(TAG, "showMessageNotification() 失败:param is null | context=" + context + " | message=" + message + " | mNotificationManager=" + mNotificationManager); + return; + } + + Notification configNotify = buildConfigNotification(context, message); + if (configNotify == null) { + LogUtils.e(TAG, "showMessageNotification() 失败:构建通知为空"); + return; + } + + try { + mNotificationManager.notify(snMessageNotificationID, configNotify); + LogUtils.d(TAG, "showMessageNotification() 成功"); + } catch (Exception e) { + LogUtils.e(TAG, "showMessageNotification() 异常", e); + } + } + + // ================================== 对外核心方法(应用配置信息通知:发送)================================= + /** + * 发送应用配置信息通知(方案1修复:系统默认铃声,无振动) + */ + public void showConfigNotification(Context context, NotificationMessage message) { + LogUtils.d(TAG, "showConfigNotification() 执行 | notifyId=" + NOTIFY_ID_CONFIG + " | context=" + context + " | message=" + message); + if (context == null || message == null || mNotificationManager == null) { + LogUtils.e(TAG, "showConfigNotification() 失败:param is null | context=" + context + " | message=" + message + " | mNotificationManager=" + mNotificationManager); + return; + } + + Notification configNotify = buildConfigNotification(context, message); + if (configNotify == null) { + LogUtils.e(TAG, "showConfigNotification() 失败:构建通知为空"); + return; + } + + try { + mNotificationManager.notify(NOTIFY_ID_CONFIG, configNotify); + LogUtils.d(TAG, "showConfigNotification() 成功"); + } catch (Exception e) { + LogUtils.e(TAG, "showConfigNotification() 异常", e); + } + } + + // ================================== 对外工具方法(通知取消:单个/全部)================================= + /** + * 取消指定ID的通知 + */ + public void cancelNotification(int notifyId) { + LogUtils.d(TAG, "cancelNotification() 执行 | notifyId=" + notifyId); + if (mNotificationManager == null) { + LogUtils.e(TAG, "cancelNotification() 失败:NotificationManager is null"); + return; + } + try { + mNotificationManager.cancel(notifyId); + LogUtils.d(TAG, "cancelNotification() 成功 | notifyId=" + notifyId); + } catch (Exception e) { + LogUtils.e(TAG, "cancelNotification() 异常 | notifyId=" + notifyId, e); + } + } + + /** + * 取消所有通知(兜底场景使用) + */ + public void cancelAllNotifications() { + LogUtils.d(TAG, "cancelAllNotifications() 执行"); + if (mNotificationManager == null) { + LogUtils.e(TAG, "cancelAllNotifications() 失败:NotificationManager is null"); + return; + } + try { + mNotificationManager.cancelAll(); + LogUtils.d(TAG, "cancelAllNotifications() 成功"); + } catch (Exception e) { + LogUtils.e(TAG, "cancelAllNotifications() 异常", e); + } + } + + // ================================== 内部辅助方法(通知构建:前台服务通知)================================= + /** + * 构建前台服务通知(全版本无铃声+无振动) + */ + private Notification buildForegroundNotification(NotificationMessage message) { + LogUtils.d(TAG, "buildForegroundNotification() 执行 | message=" + message); + if (message == null || mContext == null) { + LogUtils.e(TAG, "buildForegroundNotification() 失败:param is null | message=" + message + " | mContext=" + mContext); + return null; + } + + // 内容兜底 + String title = message.getTitle() != null && !message.getTitle().isEmpty() ? message.getTitle() : FOREGROUND_NOTIFY_TITLE_DEFAULT; + String content = message.getContent() != null && !message.getContent().isEmpty() ? message.getContent() : FOREGROUND_NOTIFY_CONTENT_DEFAULT; + LogUtils.d(TAG, "buildForegroundNotification() 内容兜底完成 | title=" + title + " | content=" + content); + + Notification.Builder builder; + // API分级构建 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // API26+:绑定前台渠道(渠道已配置无铃声) + builder = new Notification.Builder(mContext, CHANNEL_ID_FOREGROUND); + LogUtils.d(TAG, "buildForegroundNotification() 使用API26+渠道构建"); + } else { + // API<26:直接构建,手动禁用铃声振动 + builder = new Notification.Builder(mContext); + builder.setSound(null); + builder.setVibrate(new long[]{0}); + builder.setDefaults(0); + LogUtils.d(TAG, "buildForegroundNotification() 使用API<26手动配置"); + } + + // 通用配置 + builder.setSmallIcon(NOTIFICATION_DEFAULT_ICON) + .setContentTitle(title) + .setContentText(content) + .setAutoCancel(false) + .setOngoing(true) // 不可手动关闭 + .setWhen(System.currentTimeMillis()) + .setContentIntent(createJumpPendingIntent(mContext, PENDING_INTENT_REQUEST_CODE_FOREGROUND)); + + // API21+ 新增大图标+主题色 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + builder.setLargeIcon(getAppIcon(mContext)) + .setColor(mContext.getResources().getColor(R.color.colorPrimary)) + .setPriority(Notification.PRIORITY_LOW); + LogUtils.d(TAG, "buildForegroundNotification() 补充API21+配置"); + } + + Notification notification = builder.build(); + LogUtils.d(TAG, "buildForegroundNotification() 成功构建前台通知"); + return notification; + } + + // ================================== 内部辅助方法(通知构建:电池提醒通知)================================= + /** + * 构建电池提醒通知(全版本系统默认铃声+无振动) + */ + private Notification buildRemindNotification(Context context, NotificationMessage message) { + LogUtils.d(TAG, "buildRemindNotification() 执行 | context=" + context + " | message=" + message); + if (context == null || message == null) { + LogUtils.e(TAG, "buildRemindNotification() 失败:param is null | context=" + context + " | message=" + message); + return null; + } + + // 内容兜底 + String title = message.getTitle() != null && !message.getTitle().isEmpty() ? message.getTitle() : REMIND_NOTIFY_TITLE_DEFAULT; + String content = message.getContent() != null && !message.getContent().isEmpty() ? message.getContent() : REMIND_NOTIFY_CONTENT_DEFAULT; + LogUtils.d(TAG, "buildRemindNotification() 内容兜底完成 | title=" + title + " | content=" + content); + + Notification.Builder builder; + // API分级构建 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // API26+:绑定提醒渠道(渠道已配置默认铃声) + builder = new Notification.Builder(context, CHANNEL_ID_REMIND); + LogUtils.d(TAG, "buildRemindNotification() 使用API26+渠道构建"); + } else { + // API<26:手动配置默认铃声,关闭振动 + builder = new Notification.Builder(context); + builder.setSound(Settings.System.DEFAULT_NOTIFICATION_URI) // 显式默认铃声 + .setVibrate(new long[]{0}) + .setDefaults(Notification.DEFAULT_LIGHTS | Notification.DEFAULT_SOUND); + LogUtils.d(TAG, "buildRemindNotification() 使用API<26手动配置"); + } + + // 通用配置 + builder.setSmallIcon(NOTIFICATION_DEFAULT_ICON) + .setContentTitle(title) + .setContentText(content) + .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), Notification.AUDIO_ATTRIBUTES_DEFAULT) + .setAutoCancel(true) // 点击关闭 + .setOngoing(false) + .setWhen(System.currentTimeMillis()) + .setContentIntent(createJumpPendingIntent(context, PENDING_INTENT_REQUEST_CODE_REMIND)); + + // API21+ 新增大图标+主题色 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + builder.setLargeIcon(getAppIcon(context)) + .setColor(context.getResources().getColor(R.color.colorPrimary)) + .setPriority(Notification.PRIORITY_DEFAULT); + LogUtils.d(TAG, "buildRemindNotification() 补充API21+配置"); + } + + Notification notification = builder.build(); + LogUtils.d(TAG, "buildRemindNotification() 成功构建提醒通知"); + return notification; + } + + // ================================== 内部辅助方法(通知构建:应用配置信息通知)================================= + /** + * 构建应用配置信息通知(方案1修复:全版本系统默认铃声+无振动) + */ + private Notification buildConfigNotification(Context context, NotificationMessage message) { + LogUtils.d(TAG, "buildConfigNotification() 执行 | context=" + context + " | message=" + message); + if (context == null || message == null) { + LogUtils.e(TAG, "buildConfigNotification() 失败:param is null | context=" + context + " | message=" + message); + return null; + } + + // 内容兜底 + String title = message.getTitle() != null && !message.getTitle().isEmpty() ? message.getTitle() : CONFIG_NOTIFY_TITLE_DEFAULT; + String content = message.getContent() != null && !message.getContent().isEmpty() ? message.getContent() : CONFIG_NOTIFY_CONTENT_DEFAULT; + LogUtils.d(TAG, "buildConfigNotification() 内容兜底完成 | title=" + title + " | content=" + content); + + Notification.Builder builder; + // API分级构建 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // API26+:绑定配置渠道(渠道已配置默认铃声) + builder = new Notification.Builder(context, CHANNEL_ID_CONFIG); + LogUtils.d(TAG, "buildConfigNotification() 使用API26+渠道构建"); + } else { + // API<26:手动配置默认铃声,关闭振动(方案1修复:保留铃声配置,删除冗余DEFAULT_SOUND) + builder = new Notification.Builder(context); + builder.setSound(Settings.System.DEFAULT_NOTIFICATION_URI); + builder.setVibrate(new long[]{0}); + builder.setDefaults(Notification.DEFAULT_LIGHTS); + LogUtils.d(TAG, "buildConfigNotification() 使用API<26手动配置"); + } + + // 通用配置 + builder.setSmallIcon(NOTIFICATION_DEFAULT_ICON) + .setContentTitle(title) + .setContentText(content) + .setAutoCancel(true) // 点击关闭 + .setOngoing(false) + .setWhen(System.currentTimeMillis()) + .setContentIntent(createJumpPendingIntent(context, PENDING_INTENT_REQUEST_CODE_CONFIG)); + + // API21+ 新增大图标+主题色 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + builder.setLargeIcon(getAppIcon(context)) + .setColor(context.getResources().getColor(R.color.colorPrimary)) + .setPriority(Notification.PRIORITY_DEFAULT); + LogUtils.d(TAG, "buildConfigNotification() 补充API21+配置"); + } + + Notification notification = builder.build(); + LogUtils.d(TAG, "buildConfigNotification() 成功构建配置信息通知"); + return notification; + } + + // ================================== 内部辅助方法(创建跳转PendingIntent,API30安全适配)================================= + /** + * 创建跳转MainActivity的PendingIntent,API23+ 添加IMMUTABLE标记(避免安全异常) + */ + private PendingIntent createJumpPendingIntent(Context context, int requestCode) { + LogUtils.d(TAG, "createJumpPendingIntent() 执行 | requestCode=" + requestCode + " | context=" + context); + Intent intent = new Intent(context, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + LogUtils.d(TAG, "createJumpPendingIntent() 跳转Intent配置完成"); + + // API23+ 必需添加IMMUTABLE,适配API30安全规范 + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + flags |= PendingIntent.FLAG_IMMUTABLE; + LogUtils.d(TAG, "createJumpPendingIntent() 添加FLAG_IMMUTABLE标记(API23+)"); + } + + PendingIntent pendingIntent = PendingIntent.getActivity(context, requestCode, intent, flags); + LogUtils.d(TAG, "createJumpPendingIntent() 成功 | requestCode=" + requestCode); + return pendingIntent; + } + + // ================================== 内部辅助方法(获取APP图标,异常兜底)================================= + /** + * 获取APP图标,失败返回默认图标 + */ + private Bitmap getAppIcon(Context context) { + LogUtils.d(TAG, "getAppIcon() 执行 | context=" + context); + try { + PackageInfo pkgInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 0); + Bitmap appIcon = BitmapFactory.decodeResource(context.getResources(), pkgInfo.applicationInfo.icon); + LogUtils.d(TAG, "getAppIcon() 成功:获取应用图标"); + return appIcon; + } catch (PackageManager.NameNotFoundException e) { + LogUtils.e(TAG, "getAppIcon() 异常:获取应用图标失败,使用默认图标", e); + return BitmapFactory.decodeResource(context.getResources(), NOTIFICATION_DEFAULT_ICON); + } + } + + // ================================== 资源释放方法(避免内存泄漏)================================= + /** + * 释放资源,销毁时调用 + */ + public void release() { + LogUtils.d(TAG, "release() 执行资源释放"); + cancelForegroundServiceNotify(); + mNotificationManager = null; + mContext = null; + LogUtils.d(TAG, "release() 成功:所有资源已释放"); + } + + // ================================== 对外 getter 方法(仅前台通知实例,只读)================================= + public Notification getForegroundServiceNotify() { + return mForegroundServiceNotify; + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/PermissionUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/PermissionUtils.java new file mode 100644 index 0000000..ac17719 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/PermissionUtils.java @@ -0,0 +1,350 @@ +package cc.winboll.studio.powerbell.utils; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.ComponentName; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.os.PowerManager; +import android.provider.Settings; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import cc.winboll.studio.libaes.dialogs.YesNoAlertDialog; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import cc.winboll.studio.powerbell.App; +import cc.winboll.studio.powerbell.MainActivity; +import cc.winboll.studio.powerbell.R; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/12/14 03:05 + * @Describe 权限申请工具类(Java7兼容版) + * 适配 小米手机+API29-30,整合自启动、电池优化、全文件管理权限,专注后台保活核心权限 + */ +public class PermissionUtils { + // ====================== 常量定义(首屏可见,统一管理,避免冲突)====================== + // 日志标签 + public static final String TAG = "PermissionUtils"; + // 权限请求码(按场景分段,避免重复) + public static final int REQUEST_IGNORE_BATTERY_OPTIMIZATION = 1000; // 电池优化权限 + public static final int REQUEST_AUTO_START = 1001; // 自启动权限(小米专属) + public static final int REQUEST_ALL_FILE_MANAGE = 1002; // 全文件管理权限(API30+) + // SDK版本常量(适配API29-30,替代系统枚举,Java7兼容) + private static final int SDK_VERSION_Q = 29; // Android 10(API29) + private static final int SDK_VERSION_R = 30; // Android 11(API30) + // 小米自启动权限页面配置(专属跳转路径,精准适配) + private static final String XIAOMI_AUTO_START_PACKAGE = "com.miui.securitycenter"; + private static final String XIAOMI_AUTO_START_CLASS = "com.miui.permcenter.autostart.AutoStartManagementActivity"; + + // ====================== 单例模式(Java7标准双重校验锁,线程安全+懒加载)====================== + private static volatile PermissionUtils sInstance; + + private PermissionUtils() {} + + public static PermissionUtils getInstance() { + if (sInstance == null) { + synchronized (PermissionUtils.class) { + if (sInstance == null) { + sInstance = new PermissionUtils(); + LogUtils.d(TAG, "初始化:PermissionUtils 单例创建成功"); + } + } + } + return sInstance; + } + + // ====================== 核心权限1:全文件管理权限(API29-30适配,通用所有机型)====================== + /** + * 检查全文件管理权限(适配API30+ MANAGE_EXTERNAL_STORAGE,兼容API29-旧权限) + * @param activity 上下文Activity(不可为null) + * @return true=权限已授予,false=权限未授予 + */ + public boolean checkAllFileManagePermission(Activity activity) { + LogUtils.d(TAG, "全文件权限-检查:开始校验,系统版本=" + Build.VERSION.SDK_INT); + if (activity == null) { + LogUtils.e(TAG, "全文件权限-检查:失败,Activity为空"); + return false; + } + + // API30+:校验 MANAGE_EXTERNAL_STORAGE 特殊权限 + if (Build.VERSION.SDK_INT >= SDK_VERSION_R) { + boolean hasManagePerm = Environment.isExternalStorageManager(); + LogUtils.d(TAG, "全文件权限-检查:API30+,MANAGE_EXTERNAL_STORAGE权限=" + (hasManagePerm ? "已授予" : "未授予")); + return hasManagePerm; + } else if (Build.VERSION.SDK_INT == SDK_VERSION_Q) { + LogUtils.d(TAG, "全文件权限-检查:API29,无需申请,默认支持文件管理"); + return true; + } else { + boolean hasWritePerm = ContextCompat.checkSelfPermission(activity, + android.Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED; + LogUtils.d(TAG, "全文件权限-检查:API29以下,WRITE_EXTERNAL_STORAGE权限=" + (hasWritePerm ? "已授予" : "未授予")); + return hasWritePerm; + } + } + + /** + * 申请全文件管理权限(适配API30+特殊权限流程,兼容API29-旧权限申请) + * @param activity 申请权限的Activity(不可为null) + */ + public void requestAllFileManagePermission(Activity activity) { + LogUtils.d(TAG, "全文件权限-申请:开始处理,系统版本=" + Build.VERSION.SDK_INT); + if (activity == null || activity.isFinishing()) { + LogUtils.e(TAG, "全文件权限-申请:失败,Activity无效/已销毁"); + return; + } + + // 先检查权限,已授予直接返回 + if (checkAllFileManagePermission(activity)) { + LogUtils.d(TAG, "全文件权限-申请:已拥有权限,无需发起"); + return; + } + + // API30+:跳转系统特殊权限申请页(用户手动授权) + if (Build.VERSION.SDK_INT >= SDK_VERSION_R) { + try { + Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION); + intent.setData(Uri.parse("package:" + activity.getPackageName())); + activity.startActivityForResult(intent, REQUEST_ALL_FILE_MANAGE); + LogUtils.d(TAG, "全文件权限-申请:API30+,跳转特殊权限申请页"); + } catch (Exception e) { + // 备用跳转:系统设置首页,引导手动操作 + Intent intent = new Intent(Settings.ACTION_SETTINGS); + activity.startActivityForResult(intent, REQUEST_ALL_FILE_MANAGE); + LogUtils.w(TAG, "全文件权限-申请:跳转失败,引导手动开启"); + showAllFileManageTipsDialog(activity); + } + } else { + ActivityCompat.requestPermissions(activity, + new String[]{android.Manifest.permission.WRITE_EXTERNAL_STORAGE}, + REQUEST_ALL_FILE_MANAGE); + LogUtils.d(TAG, "全文件权限-申请:API29以下,发起WRITE_EXTERNAL_STORAGE权限申请"); + } + } + + // ====================== 核心权限2:自启动权限(小米专属,API29-30适配)====================== + /** + * 检查自启动权限(仅小米机型需要,非小米直接返回无需申请) + * @param activity 上下文Activity(不可为null) + * @return true=小米机型(需手动开启);false=非小米机型(无需申请) + */ +// public boolean checkAutoStartPermission(Activity activity) { +// LogUtils.d(TAG, "自启动权限-检查:开始,设备品牌=" + Build.BRAND); +// if (activity == null) { +// LogUtils.e(TAG, "自启动权限-检查:失败,Activity为空"); +// return false; +// } +// +// boolean isXiaomi = Build.BRAND.toLowerCase().contains("xiaomi"); +// LogUtils.d(TAG, "自启动权限-检查:结果=" + (isXiaomi ? "小米机型(需开启)" : "非小米机型(无需申请)")); +// return isXiaomi; +// } + + /** + * 请求自启动权限(小米专属,多方案跳转,适配API29-30机型差异) + * @param activity 申请权限的Activity(不可为null) + */ + public void requestAutoStartPermission(Activity activity) { + LogUtils.d(TAG, "自启动权限-申请:开始处理"); + if (activity == null || activity.isFinishing()) { + LogUtils.e(TAG, "自启动权限-申请:失败,Activity无效/已销毁"); + return; + } + + // 非小米机型,直接返回 +// if (!checkAutoStartPermission(activity)) { +// LogUtils.d(TAG, "自启动权限-申请:非小米机型,无需处理"); +// return; +// } + + // API30+ 小米:优先精准跳转自启动管理页 + if (Build.VERSION.SDK_INT >= SDK_VERSION_R) { + try { + // 方案1:组件名精准跳转(成功率最高) + Intent intent = new Intent(); + intent.setComponent(new ComponentName(XIAOMI_AUTO_START_PACKAGE, XIAOMI_AUTO_START_CLASS)); + activity.startActivityForResult(intent, REQUEST_AUTO_START); + LogUtils.d(TAG, "自启动权限-申请:API30+,组件名跳转自启动管理页"); + } catch (Exception e1) { + try { + // 方案2:Action备用跳转(兼容机型差异) + Intent intent = new Intent("miui.intent.action.OP_AUTO_START"); + intent.setClassName(XIAOMI_AUTO_START_PACKAGE, XIAOMI_AUTO_START_CLASS); + activity.startActivityForResult(intent, REQUEST_AUTO_START); + LogUtils.d(TAG, "自启动权限-申请:API30+,Action跳转自启动管理页"); + } catch (Exception e2) { + // 方案3:终极备用,跳转系统设置+提示 + Intent intent = new Intent(Settings.ACTION_SETTINGS); + activity.startActivityForResult(intent, REQUEST_AUTO_START); + LogUtils.w(TAG, "自启动权限-申请:跳转失败,引导手动操作"); + showAutoStartTipsDialog(activity); + } + } + return; + } + + // API29 小米:低版本兼容跳转 + try { + Intent intent = new Intent(XIAOMI_AUTO_START_CLASS); + intent.setPackage(XIAOMI_AUTO_START_PACKAGE); + activity.startActivityForResult(intent, REQUEST_AUTO_START); + LogUtils.d(TAG, "自启动权限-申请:API29,低版本跳转自启动管理页"); + } catch (Exception e) { + Intent intent = new Intent(Settings.ACTION_SETTINGS); + activity.startActivityForResult(intent, REQUEST_AUTO_START); + showAutoStartTipsDialog(activity); + } + } + + // ====================== 核心权限3:电池优化权限(通用所有机型,API29-30适配)====================== + /** + * 检查忽略电池优化权限(精准判断,API23+有效,低版本视为已拥有) + * @param activity 上下文Activity(不可为null) + * @return true=已忽略优化;false=未忽略(需申请) + */ + public boolean checkIgnoreBatteryOptimizationPermission(Activity activity) { + LogUtils.d(TAG, "电池优化权限-检查:开始,系统版本=" + Build.VERSION.SDK_INT); + if (activity == null) { + LogUtils.e(TAG, "电池优化权限-检查:失败,Activity为空"); + return false; + } + + // API23以下无此权限,视为已拥有 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + LogUtils.d(TAG, "电池优化权限-检查:API23以下,无需校验,视为已拥有"); + return true; + } + + // API23+ 精准校验权限状态 + PowerManager powerManager = (PowerManager) activity.getSystemService(Activity.POWER_SERVICE); + if (powerManager == null) { + LogUtils.e(TAG, "电池优化权限-检查:获取PowerManager失败,校验异常"); + return false; + } + boolean isIgnored = powerManager.isIgnoringBatteryOptimizations(activity.getPackageName()); + LogUtils.d(TAG, "电池优化权限-检查:结果=" + (isIgnored ? "已忽略优化" : "未忽略(需申请)")); + return isIgnored; + } + + /** + * 请求忽略电池优化权限(多方案跳转,适配API29-30,自动判断是否需要申请) + * @param activity 申请权限的Activity(不可为null) + */ + public void requestIgnoreBatteryOptimizationPermission(Activity activity) { + LogUtils.d(TAG, "电池优化权限-申请:开始处理"); + if (activity == null || activity.isFinishing()) { + LogUtils.e(TAG, "电池优化权限-申请:失败,Activity无效/已销毁"); + return; + } + + // 已拥有权限,直接返回 + if (checkIgnoreBatteryOptimizationPermission(activity)) { + LogUtils.d(TAG, "电池优化权限-申请:已拥有权限,无需发起"); + return; + } + + try { + // 方案1:直接跳转一键授权页(优先使用,用户操作简单) + Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); + intent.setData(Uri.parse("package:" + activity.getPackageName())); + activity.startActivityForResult(intent, REQUEST_IGNORE_BATTERY_OPTIMIZATION); + LogUtils.d(TAG, "电池优化权限-申请:跳转一键授权页"); + } catch (Exception e) { + // 方案2:备用跳转优化管理页+提示 + Intent intent = new Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS); + activity.startActivityForResult(intent, REQUEST_IGNORE_BATTERY_OPTIMIZATION); + LogUtils.w(TAG, "电池优化权限-申请:跳转失败,引导手动操作"); + showBatteryOptTipsDialog(activity); + } + } + + // ====================== 辅助方法:手动开启提示弹窗(适配跳转失败场景)====================== + /** + * 全文件管理权限手动开启提示弹窗 + */ + private void showAllFileManageTipsDialog(final Activity activity) { + new AlertDialog.Builder(activity) + .setTitle("全文件管理权限申请提示") + .setMessage("请手动开启全文件管理权限,否则文件操作功能异常:\n1. 进入设置 → 应用 → 本应用 → 权限\n2. 找到「文件管理」/「存储」权限,开启「允许管理所有文件」") + .setPositiveButton("知道了", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }) + .setCancelable(false) + .show(); + LogUtils.d(TAG, "全文件权限:显示手动开启提示弹窗"); + } + + /** + * 自启动权限手动开启提示弹窗(小米专属) + */ + private void showAutoStartTipsDialog(final Activity activity) { + new AlertDialog.Builder(activity) + .setTitle("自启动权限申请提示") + .setMessage("请手动开启自启动权限,否则应用后台保活异常:\n1. 进入小米安全中心 → 应用管理 → 自启动管理\n2. 找到本应用,开启「允许自启动」开关") + .setPositiveButton("知道了", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }) + .setCancelable(false) + .show(); + LogUtils.d(TAG, "自启动权限:显示手动开启提示弹窗"); + } + + /** + * 电池优化权限手动开启提示弹窗 + */ + private void showBatteryOptTipsDialog(final Activity activity) { + new AlertDialog.Builder(activity) + .setTitle("电池优化权限申请提示") + .setMessage("请手动忽略电池优化,否则应用后台运行被限制:\n1. 进入设置 → 电池 → 电池优化\n2. 找到本应用,选择「不优化」选项") + .setPositiveButton("知道了", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }) + .setCancelable(false) + .show(); + LogUtils.d(TAG, "电池优化权限:显示手动开启提示弹窗"); + } + + public void startPermissionRequest(final Activity activity) { + // 电池优化权限(通用所有机型) + if (!checkIgnoreBatteryOptimizationPermission(activity)) { + YesNoAlertDialog.show(activity, activity.getString(R.string.app_name) + "权限申请提示:", "本应用要正常使用,需要申请电池优化与自启动权限。是否进入权限设置步骤?", new YesNoAlertDialog.OnDialogResultListener(){ + @Override + public void onNo() { + ToastUtils.show(activity.getString(R.string.app_name) + "应用可能无法正常使用。"); + } + @Override + public void onYes() { + requestIgnoreBatteryOptimizationPermission(activity); + } + }); + } + } + + public void handlePermissionRequest(final Activity activity, int requestCode, int resultCode, Intent data) { + if (requestCode == PermissionUtils.REQUEST_IGNORE_BATTERY_OPTIMIZATION) { + // 自启动权限(小米专属) + // 小米机型,发起自启动权限申请 + requestAutoStartPermission(activity); + } else if (requestCode == PermissionUtils.REQUEST_AUTO_START) { + // 自启动权限(小米专属) + if (App.isDebugging() && !checkAllFileManagePermission(activity)) { + // 小米机型,发起自启动权限申请 + requestAllFileManagePermission(activity); + } + } + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/ServiceUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/ServiceUtils.java new file mode 100644 index 0000000..55133a3 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/ServiceUtils.java @@ -0,0 +1,70 @@ +package cc.winboll.studio.powerbell.utils; + +import android.app.ActivityManager; +import android.content.Context; +import android.text.TextUtils; +import cc.winboll.studio.libappbase.LogUtils; +import java.util.List; + +/** + * 服务状态工具类 + * 功能:判断指定服务是否处于运行状态 + * 适配:Java 7 + Android API 30 + * 注意:Android 8.0+ 对后台服务限制严格,此方法仅适用于前台服务或兼容场景 + */ +public class ServiceUtils { + // ================================== 静态常量区(置顶归类)================================= + public static final String TAG = ServiceUtils.class.getSimpleName(); + // 最大查询服务数量 + private static final int MAX_RUNNING_SERVICES = 1000; + + // ================================== 核心工具方法(判断服务是否运行)================================= + /** + * 判断指定服务是否处于运行状态 + * @param context 上下文(建议使用 Application 上下文避免内存泄漏) + * @param serviceName 服务完整类名(如:com.example.app.service.DemoService) + * @return true-服务运行中,false-服务未运行或查询失败 + */ + public static boolean isServiceAlive(Context context, String serviceName) { + LogUtils.d(TAG, "【isServiceAlive】调用开始 | 服务名称=" + serviceName); + // 1. 前置参数校验 + if (context == null) { + LogUtils.e(TAG, "【isServiceAlive】参数异常:Context 为空"); + return false; + } + if (TextUtils.isEmpty(serviceName)) { + LogUtils.e(TAG, "【isServiceAlive】参数异常:服务名称为空"); + return false; + } + + // 2. 获取 ActivityManager + ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + if (activityManager == null) { + LogUtils.e(TAG, "【isServiceAlive】获取 ActivityManager 失败"); + return false; + } + + // 3. 查询正在运行的服务 + List runningServices = activityManager.getRunningServices(MAX_RUNNING_SERVICES); + if (runningServices == null || runningServices.size() <= 0) { + LogUtils.d(TAG, "【isServiceAlive】正在运行的服务列表为空"); + return false; + } + + // 4. 遍历服务列表,匹配目标服务 + for (ActivityManager.RunningServiceInfo serviceInfo : runningServices) { + if (serviceInfo.service == null) { + continue; + } + String className = serviceInfo.service.getClassName(); + if (serviceName.equals(className)) { + LogUtils.d(TAG, "【isServiceAlive】服务运行中 | 匹配成功:" + serviceName); + return true; + } + } + + LogUtils.d(TAG, "【isServiceAlive】服务未运行 | 未匹配到:" + serviceName); + return false; + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/StringUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/StringUtils.java new file mode 100644 index 0000000..8940be5 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/StringUtils.java @@ -0,0 +1,151 @@ +package cc.winboll.studio.powerbell.utils; + +import android.text.TextUtils; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.models.BatteryInfoBean; +import java.util.ArrayList; +import java.util.Locale; + +/** + * 字符串格式化工具类 + * 功能:电量使用时间列表格式化、时间跨度计算 + * 适配:Java 7 + Android API 30 + * 核心逻辑:将电池信息列表转换为指定格式字符串,计算时间戳之间的跨度并格式化 + */ +public class StringUtils { + // ================================== 静态常量区(置顶归类,消除魔法值)================================= + public static final String TAG = StringUtils.class.getSimpleName(); + // 时间跨度单位符号 + private static final String UNIT_DAY = "☀"; + private static final String UNIT_HOUR = "★"; + private static final String UNIT_MINUTE = "✰"; + private static final String UNIT_SECOND_DEFAULT = "☆}"; + // 时间计算常量 + private static final long MILLIS_PER_DAY = 24 * 60 * 60 * 1000L; + private static final long MILLIS_PER_HOUR = 60 * 60 * 1000L; + private static final long MILLIS_PER_MINUTE = 60 * 1000L; + private static final long MILLIS_PER_SECOND = 1000L; + // 空字符串常量(替代 TextUtils.EMPTY,保证 Java 7 兼容) + private static final String EMPTY_STRING = ""; + + // ================================== 核心格式化方法(电量列表格式化)================================= + /** + * 格式化电量使用时间列表为单行字符串 + * @param batteryInfoList 电池信息列表(非空) + * @return 格式化后的单行字符串,格式:"电量% 时间跨度 电量% 时间跨度 ..." + */ + public static String formatPCMListString(ArrayList batteryInfoList) { + LogUtils.d(TAG, "【formatPCMListString】调用开始 | 列表大小=" + (batteryInfoList != null ? batteryInfoList.size() : null)); + // 1. 参数校验 + if (batteryInfoList == null || batteryInfoList.size() < 2) { + LogUtils.e(TAG, "【formatPCMListString】参数异常:列表为空或长度不足2"); + return EMPTY_STRING; + } + + String result = EMPTY_STRING; + // 2. 遍历列表,拼接字符串(倒序拼接) + for (int i = 0; i < batteryInfoList.size() - 1; i++) { + BatteryInfoBean currentBean = batteryInfoList.get(i); + BatteryInfoBean nextBean = batteryInfoList.get(i + 1); + // 空指针防护 + if (currentBean == null || nextBean == null) { + LogUtils.w(TAG, "【formatPCMListString】列表项为空,跳过当前索引:" + i); + continue; + } + // 获取电量和时间跨度 + int batteryValue = currentBean.getBatteryValue(); + String timeSpan = getTimespanDifference(currentBean.getTimeStamp(), nextBean.getTimeStamp()); + // 倒序拼接 + result = batteryValue + "% " + timeSpan + " " + result; + LogUtils.d(TAG, "【formatPCMListString】循环拼接 | 索引=" + i + " | 电量=" + batteryValue + "% | 时间跨度=" + timeSpan); + } + + LogUtils.d(TAG, "【formatPCMListString】格式化完成 | 结果长度=" + result.length()); + return result; + } + + /** + * 格式化电量使用时间列表为带换行的字符串 + * @param batteryInfoList 电池信息列表(非空) + * @return 格式化后的带换行字符串,每行一个电量和时间跨度 + */ + public static String formatPCMListStringWithEnter(ArrayList batteryInfoList) { + LogUtils.d(TAG, "【formatPCMListStringWithEnter】调用开始 | 列表大小=" + (batteryInfoList != null ? batteryInfoList.size() : null)); + // 1. 参数校验 + if (batteryInfoList == null || batteryInfoList.size() < 2) { + LogUtils.e(TAG, "【formatPCMListStringWithEnter】参数异常:列表为空或长度不足2"); + return EMPTY_STRING; + } + + String result = EMPTY_STRING; + // 2. 遍历列表,拼接字符串(倒序拼接,带换行) + for (int i = 0; i < batteryInfoList.size() - 1; i++) { + BatteryInfoBean currentBean = batteryInfoList.get(i); + BatteryInfoBean nextBean = batteryInfoList.get(i + 1); + // 空指针防护 + if (currentBean == null || nextBean == null) { + LogUtils.w(TAG, "【formatPCMListStringWithEnter】列表项为空,跳过当前索引:" + i); + continue; + } + // 获取电量和时间跨度 + int batteryValue = currentBean.getBatteryValue(); + String timeSpan = getTimespanDifference(currentBean.getTimeStamp(), nextBean.getTimeStamp()); + // 倒序拼接(带换行) + result = "\n" + batteryValue + "%\n " + timeSpan + " " + result; + LogUtils.d(TAG, "【formatPCMListStringWithEnter】循环拼接 | 索引=" + i + " | 电量=" + batteryValue + "% | 时间跨度=" + timeSpan); + } + + LogUtils.d(TAG, "【formatPCMListStringWithEnter】格式化完成 | 结果长度=" + result.length()); + return result; + } + + // ================================== 时间跨度计算方法(核心工具方法)================================= + /** + * 计算两个时间戳之间的跨度并格式化为指定字符串 + * @param start 开始时间戳(毫秒) + * @param end 结束时间戳(毫秒) + * @return 格式化的时间跨度字符串,格式:{天☀时★分✰秒} 或 {☆}(当时间差为0时) + */ + public static String getTimespanDifference(long start, long end) { + LogUtils.d(TAG, "【getTimespanDifference】调用开始 | 开始时间戳=" + start + " | 结束时间戳=" + end); + long between = end - start; + LogUtils.d(TAG, "【getTimespanDifference】时间差(毫秒)=" + between); + + // 计算天、时、分、秒 + long day = between / MILLIS_PER_DAY; + long hour = (between % MILLIS_PER_DAY) / MILLIS_PER_HOUR; + long min = (between % MILLIS_PER_HOUR) / MILLIS_PER_MINUTE; + long sec = (between % MILLIS_PER_MINUTE) / MILLIS_PER_SECOND; + + // 拼接结果字符串 + StringBuilder result = new StringBuilder("{"); + boolean hasHigherUnit = false; + + // 拼接天 + if (day > 0) { + result.append(String.format(Locale.getDefault(), "%d%s", day, UNIT_DAY)); + hasHigherUnit = true; + } + // 拼接时(当天>0或后续有单位时) + if (hour > 0 || hasHigherUnit) { + result.append(String.format(Locale.getDefault(), "%d%s", hour, UNIT_HOUR)); + hasHigherUnit = true; + } + // 拼接分(当时>0或后续有单位时) + if (min > 0 || hasHigherUnit) { + result.append(String.format(Locale.getDefault(), "%d%s", min, UNIT_MINUTE)); + hasHigherUnit = true; + } + // 拼接秒或默认值 + if (hasHigherUnit) { + result.append(String.format(Locale.getDefault(), "%d}", sec)); + } else { + result.append(UNIT_SECOND_DEFAULT); + } + + String timeSpan = result.toString(); + LogUtils.d(TAG, "【getTimespanDifference】计算完成 | 时间跨度=" + timeSpan); + return timeSpan; + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/TextToSpeechUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/TextToSpeechUtils.java new file mode 100644 index 0000000..54757eb --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/TextToSpeechUtils.java @@ -0,0 +1,251 @@ +package cc.winboll.studio.powerbell.utils; + +import android.content.Context; +import android.graphics.PixelFormat; +import android.os.Build; +import android.speech.tts.TextToSpeech; +import android.speech.tts.UtteranceProgressListener; +import android.view.Gravity; +import android.view.View; +import android.view.WindowManager; +import android.widget.LinearLayout; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.R; +import cc.winboll.studio.powerbell.models.TTSSpeakTextBean; +import java.util.ArrayList; + +/** + * TTS语音播放工具类 (单例实现) + * 适配:Java7 语法规范 | Android API36 系统版本【修复崩溃】 + * 功能:队列播放语音文本 + 播放悬浮窗展示 + 点击悬浮窗停止播放/关闭悬浮窗 + * @Author 豆包&ZhanGSKen + * @Date 2025/12/29 19:03 + */ +public class TextToSpeechUtils { + + // ====================================== 常量区 - 静态全局常量 (置顶) ====================================== + public static final String TAG = "TextToSpeechUtils"; + public static final String UNIQUE_ID = "UNIQUE_ID"; + + // ====================================== 单例实例 - 静态私有 (饿汉式优化) ====================================== + private static volatile TextToSpeechUtils sTextToSpeechUtils; + + // ====================================== 成员属性区 - 私有成员变量 (按功能归类 有序排列) ====================================== + private Context mContext; + private WindowManager mWindowManager; + private TextToSpeech mTextToSpeech; + private View mView; + private volatile boolean isExist = false; + private UtteranceProgressListener mUtteranceProgressListener; + + // ====================================== 构造方法 - 私有私有化 (单例模式) ====================================== + private TextToSpeechUtils(Context context) { + LogUtils.d(TAG, "【构造方法】初始化TextToSpeechUtil实例"); + this.mContext = context.getApplicationContext(); + this.mWindowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE); + this.initUtteranceProgressListener(); + LogUtils.d(TAG, "【构造方法】初始化完成,获取WindowManager实例:"+mWindowManager); + } + + // ====================================== 对外暴露方法 - 单例获取入口 (线程安全) ====================================== + public static synchronized TextToSpeechUtils getInstance(Context context) { + LogUtils.d(TAG, "【getInstance】获取单例实例,入参Context:" + context); + if (sTextToSpeechUtils == null) { + LogUtils.d(TAG, "【getInstance】实例为空,创建新的TextToSpeechUtil对象"); + sTextToSpeechUtils = new TextToSpeechUtils(context); + } + return sTextToSpeechUtils; + } + + // ====================================== 核心对外业务方法 - 播放TTS语音队列 【主入口】 ====================================== + public void speekTTSList(final ArrayList listTTSSpeakTextBean) { + LogUtils.d(TAG, "【speekTTSList】播放语音队列调用,入参队列长度:" + (listTTSSpeakTextBean == null ? 0 : listTTSSpeakTextBean.size())); + // 重置播放退出标志位 + isExist = false; + LogUtils.d(TAG, "【speekTTSList】重置播放退出标志位 isExist = " + isExist); + + // TTS实例为空 → 初始化TTS后重放 + if (mTextToSpeech == null) { + LogUtils.d(TAG, "【speekTTSList】TextToSpeech实例为空,开始初始化TTS"); + mTextToSpeech = new TextToSpeech(mContext, new TextToSpeech.OnInitListener() { + @Override + public void onInit(int initStatus) { + LogUtils.d(TAG, "【onInit】TTS初始化回调,初始化状态码:" + initStatus); + if (initStatus == TextToSpeech.SUCCESS) { + LogUtils.d(TAG, "【onInit】TTS初始化成功,重新调用语音播放方法"); + speekTTSList(listTTSSpeakTextBean); + } else { + LogUtils.d(TAG, "【onInit】TTS init failed : " + initStatus + ". The app [https://play.google.com/store/apps/details?id=com.google.android.tts] maybe fix this TTS probrem. "); + } + } + }); + mTextToSpeech.setOnUtteranceProgressListener(mUtteranceProgressListener); + LogUtils.d(TAG, "【speekTTSList】已为TTS绑定播放进度监听器"); + } else { + // TTS实例就绪 → 执行播放逻辑 + if (listTTSSpeakTextBean != null && listTTSSpeakTextBean.size() > 0) { + LogUtils.d(TAG, "【speekTTSList】TTS实例就绪,语音队列数据有效,开始播放逻辑处理"); + // 清理过期的悬浮窗 - 防止内存泄漏/重复添加 + clearFloatWindow(); + + // ========== 修复1:添加悬浮窗权限检查,有权限才初始化悬浮窗,无权限则只播语音不崩溃 ========== + if (checkOverlayPermission()) { + initWindow(); + LogUtils.d(TAG, "【speekTTSList】悬浮窗初始化并显示完成"); + } else { + LogUtils.d(TAG, "【speekTTSList】悬浮窗权限未授予,跳过悬浮窗显示,仅播放语音"); + } + + // 获取第一条语音的延迟时间并休眠 + int nDelay = listTTSSpeakTextBean.get(0).mnDelay; + LogUtils.d(TAG, "【speekTTSList】获取播放延迟时间:" + nDelay + "ms,开始休眠等待"); + try { + Thread.sleep(nDelay); + } catch (InterruptedException e) { + LogUtils.d(TAG, "【speekTTSList】休眠等待被中断", e); + } + LogUtils.d(TAG, "【speekTTSList】休眠等待完成,开始循环播放语音队列"); + + // 循环播放语音队列 + for (int speakPosition = 0; speakPosition < listTTSSpeakTextBean.size() && !isExist; speakPosition++) { + String szSpeakContent = listTTSSpeakTextBean.get(speakPosition).mszSpeakContent; + isExist = (listTTSSpeakTextBean.size() - 2 < speakPosition); + LogUtils.d(TAG, "【speekTTSList】播放索引:" + speakPosition + " | 播放文本:" + szSpeakContent + " | 当前退出标记位:" + isExist); + + // 第一条语音清空队列播放,后续语音追加播放 + if (speakPosition == 0) { + mTextToSpeech.speak(szSpeakContent, TextToSpeech.QUEUE_FLUSH, null, UNIQUE_ID); + LogUtils.d(TAG, "【speekTTSList】执行清空队列播放 → QUEUE_FLUSH"); + } else { + mTextToSpeech.speak(szSpeakContent, TextToSpeech.QUEUE_ADD, null, UNIQUE_ID); + LogUtils.d(TAG, "【speekTTSList】执行追加队列播放 → QUEUE_ADD"); + } + } + LogUtils.d(TAG, "【speekTTSList】语音队列循环播放逻辑执行完毕"); + } else { + LogUtils.d(TAG, "【speekTTSList】语音队列为空/长度0,跳过播放逻辑"); + } + } + } + + // ====================================== 私有工具方法 - 初始化播放监听器 ====================================== + private void initUtteranceProgressListener() { + LogUtils.d(TAG, "【initUtteranceProgressListener】初始化TTS播放进度监听器"); + mUtteranceProgressListener = new UtteranceProgressListener() { + @Override + public void onStart(String utteranceId) { + LogUtils.d(TAG, "【onStart】TTS语音播放开始,唯一标识ID:" + utteranceId); + } + + @Override + public void onDone(String utteranceId) { + LogUtils.d(TAG, "【onDone】TTS语音播放结束,唯一标识ID:" + utteranceId + " | 退出标志位:" + isExist); + // 播放完成 关闭悬浮窗 + if (isExist && mWindowManager != null && mView != null) { + LogUtils.d(TAG, "【onDone】满足关闭条件,执行悬浮窗移除操作"); + clearFloatWindow(); + } + } + + @Override + public void onError(String utteranceId) { + LogUtils.d(TAG, "【onError】TTS语音播放出错,唯一标识ID:" + utteranceId); + } + }; + } + + // ====================================== 私有核心方法 - 初始化并添加悬浮窗 【核心修复 根治崩溃】 ====================================== + private void initWindow() { + LogUtils.d(TAG, "【initWindow】开始初始化播放悬浮窗"); + // 创建Window布局参数 + WindowManager.LayoutParams params = new WindowManager.LayoutParams(); + // ========== 修复2 重中之重:Android 12(API31)+ 彻底废弃TYPE_PHONE,统一用TYPE_APPLICATION_OVERLAY 适配API36 ========== + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + params.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; + LogUtils.d(TAG, "【initWindow】系统版本>=API26,悬浮窗类型:TYPE_APPLICATION_OVERLAY"); + } else { + // 仅低版本用TYPE_PHONE,高版本不再走这里 + params.type = WindowManager.LayoutParams.TYPE_PHONE; + LogUtils.d(TAG, "【initWindow】系统版本= Build.VERSION_CODES.M) { + boolean hasPermission = android.provider.Settings.canDrawOverlays(mContext); + LogUtils.d(TAG, "【checkOverlayPermission】Android6.0+ 悬浮窗权限校验结果:" + hasPermission); + return hasPermission; + } else { + // 低版本默认有权限 + return true; + } + } + + // ====================================== ✅ 新增:释放资源方法【根治内存泄漏】建议在Service/Activity销毁时调用 ✅ ====================================== + public void release() { + LogUtils.d(TAG, "【release】释放TTS资源和悬浮窗"); + clearFloatWindow(); + if (mTextToSpeech != null) { + mTextToSpeech.stop(); + mTextToSpeech.shutdown(); + mTextToSpeech = null; + } + sTextToSpeechUtils = null; + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/UriUtils.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/UriUtils.java new file mode 100644 index 0000000..8d83187 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/utils/UriUtils.java @@ -0,0 +1,481 @@ +package cc.winboll.studio.powerbell.utils; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.MediaStore; +import androidx.core.content.FileProvider; +import cc.winboll.studio.libappbase.LogUtils; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.HashMap; +import java.util.Map; + +/** + * Uri 工具类(Java7兼容,适配API29-30+小米机型,FileProvider安全适配) + * @Author ZhanGSKen + * @Date 2024/06/28 + */ +public class UriUtils { + // ====================== 常量定义(顶部统一管理)====================== + public static final String TAG = "UriUtils"; + // FileProvider 授权后缀(与Manifest配置保持一致) + private static final String FILE_PROVIDER_SUFFIX = ".fileprovider"; + // 应用公共图片目录(API29+ 适配,替代废弃API) + private static final String APP_PUBLIC_PIC_DIR = "PowerBell/"; + // MIME类型与文件后缀映射表(覆盖常见格式,小米机型精准匹配) + private static final Map MIME_SUFFIX_MAP = new HashMap() {{ + // 图片格式(重点,含透明格式) + put("image/png", "png"); + put("image/jpeg", "jpg"); + put("image/jpg", "jpg"); + put("image/gif", "gif"); + put("image/bmp", "bmp"); + put("image/webp", "webp"); + // 音视频格式 + put("video/mp4", "mp4"); + put("video/avi", "avi"); + put("video/mkv", "mkv"); + put("audio/mp3", "mp3"); + put("audio/wav", "wav"); + // 文档格式 + put("application/pdf", "pdf"); + put("application/msword", "doc"); + put("application/vnd.openxmlformats-officedocument.wordprocessingml.document", "docx"); + put("application/vnd.ms-excel", "xls"); + put("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx"); + }}; + + // ====================== 新增核心方法:Uri 转文件后缀 ====================== + /** + * 【静态公共方法】根据 Uri 获取文件真实后缀(优先MIME类型匹配,适配所有Uri场景+小米机型) + * @param context 上下文(非空,用于获取ContentResolver) + * @param uri 待解析 Uri(支持 content:// / file:// 双Scheme) + * @return 小写文件后缀(如 png/jpg/mp4,无匹配返回空字符串) + */ + public static String getSuffixFromUri(Context context, Uri uri) { + LogUtils.d(TAG, "=== getSuffixFromUri 调用 start,Uri:" + (uri != null ? uri.toString() : "null") + " ==="); + // 1. 基础参数校验 + if (context == null) { + LogUtils.e(TAG, "getSuffixFromUri:Context 为空,获取失败"); + return ""; + } + if (uri == null) { + LogUtils.e(TAG, "getSuffixFromUri:Uri 为空,获取失败"); + return ""; + } + + String suffix = ""; + String scheme = uri.getScheme(); + // 2. 按 Uri Scheme 分类处理(优先精准匹配,再降级截取) + if (ContentResolver.SCHEME_CONTENT.equals(scheme)) { + // 场景1:content:// Uri(优先通过MIME类型获取,最精准) + suffix = getSuffixFromContentUri(context, uri); + LogUtils.d(TAG, "getSuffixFromUri:content:// Uri,MIME匹配后缀:" + suffix); + } else if (ContentResolver.SCHEME_FILE.equals(scheme)) { + // 场景2:file:// Uri(直接解析文件名截取后缀) + String filePath = new File(uri.getPath()).getAbsolutePath(); + suffix = getSuffixFromFilePath(filePath); + LogUtils.d(TAG, "getSuffixFromUri:file:// Uri,路径截取后缀:" + suffix); + } else { + // 场景3:未知Scheme(尝试解析Uri路径截取,兜底) + String uriPath = uri.getPath(); + suffix = uriPath != null ? getSuffixFromFilePath(uriPath) : ""; + LogUtils.w(TAG, "getSuffixFromUri:未知Scheme=" + scheme + ",兜底截取后缀:" + suffix); + } + + // 3. 最终结果处理(统一小写,去空) + suffix = suffix != null ? suffix.trim().toLowerCase() : ""; + LogUtils.d(TAG, "=== getSuffixFromUri 调用 end,最终后缀:" + suffix + " ==="); + return suffix; + } + + // ====================== 公有核心方法(对外提供能力,按功能排序)====================== + /** + * Uri 转真实文件路径(核心方法,适配 content:// / file:// 双 Scheme) + * @param context 上下文(非空) + * @param uri 待转换 Uri(非空) + * @return 真实文件绝对路径(转换失败返回 null) + */ + public static String getFilePathFromUri(Context context, Uri uri) { + LogUtils.d(TAG, "=== getFilePathFromUri 调用 start ==="); + if (context == null) { + LogUtils.e(TAG, "getFilePathFromUri:Context 为空,转换失败"); + return null; + } + if (uri == null) { + LogUtils.e(TAG, "getFilePathFromUri:Uri 为空,转换失败"); + return null; + } + + String scheme = uri.getScheme(); + String filePath = null; + // 按 Uri Scheme 分类处理 + if (ContentResolver.SCHEME_CONTENT.equals(scheme)) { + LogUtils.d(TAG, "getFilePathFromUri:Scheme=content,执行ContentUri转换"); + filePath = getFilePathFromContentUri(context, uri); + } else if (ContentResolver.SCHEME_FILE.equals(scheme)) { + LogUtils.d(TAG, "getFilePathFromUri:Scheme=file,直接转换路径"); + filePath = new File(uri.getPath()).getAbsolutePath(); + } else { + LogUtils.w(TAG, "getFilePathFromUri:未知Scheme=" + scheme + ",转换失败"); + } + + LogUtils.d(TAG, "=== getFilePathFromUri 调用 end,结果:" + filePath + " ==="); + return filePath; + } + + /** + * 文件路径转 Uri(核心方法,适配 Android7.0+ FileProvider,API29-30兼容) + * @param context 上下文(非空) + * @param filePath 真实文件路径(非空) + * @return 安全 Uri(转换失败返回 null) + */ + public static Uri getUriForFile(Context context, String filePath) { + LogUtils.d(TAG, "=== getUriForFile(路径版)调用 start ==="); + // 1. 基础参数校验 + if (context == null) { + LogUtils.e(TAG, "getUriForFile:Context 为空,转换失败"); + return null; + } + if (filePath == null || filePath.isEmpty()) { + LogUtils.e(TAG, "getUriForFile:文件路径为空,转换失败"); + return null; + } + + // 2. File 对象初始化与校验 + File file = new File(filePath); + LogUtils.d(TAG, "getUriForFile:文件路径=" + file.getAbsolutePath() + ",是否存在=" + file.exists()); + if (!file.exists() || file.isDirectory()) { + LogUtils.e(TAG, "getUriForFile:文件不存在或为目录,转换失败"); + return null; + } + + // 3. 合法路径校验(适配小米机型,避免FileProvider配置外路径) + if (!isPathInValidDir(context, file)) { + LogUtils.w(TAG, "getUriForFile:路径不在安全配置目录内,小米机型可能出现权限异常"); + } + + // 4. 调用重载方法生成 Uri + Uri uri = getUriForFile(context, file); + LogUtils.d(TAG, "=== getUriForFile(路径版)调用 end,结果:" + (uri != null ? uri.toString() : "null") + " ==="); + return uri; + } + + /** + * File 对象转 Uri(重载方法,直接接收File,内部安全适配) + * @param context 上下文(非空) + * @param file 待转换 File 对象(非空) + * @return 安全 Uri(转换失败返回 null) + */ + public static Uri getUriForFile(Context context, File file) { + LogUtils.d(TAG, "=== getUriForFile(File版)调用 start ==="); + // 1. 基础参数校验 + if (context == null) { + LogUtils.e(TAG, "getUriForFile:Context 为空,转换失败"); + return null; + } + if (file == null) { + LogUtils.e(TAG, "getUriForFile:File 对象为空,转换失败"); + return null; + } + LogUtils.d(TAG, "getUriForFile:文件路径=" + file.getAbsolutePath() + ",是否存在=" + file.exists()); + if (!file.exists() || file.isDirectory()) { + LogUtils.e(TAG, "getUriForFile:文件不存在或为目录,转换失败"); + return null; + } + + // 2. 按系统版本生成 Uri(API24+ 强制 FileProvider,适配小米机型) + Uri uri = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + LogUtils.d(TAG, "getUriForFile:Android7.0+,使用FileProvider生成Uri"); + String authority = context.getPackageName() + FILE_PROVIDER_SUFFIX; + LogUtils.d(TAG, "getUriForFile:FileProvider Authority=" + authority); + try { + uri = FileProvider.getUriForFile(context, authority, file); + LogUtils.d(TAG, "getUriForFile:Content Uri生成成功=" + uri.toString()); + } catch (IllegalArgumentException e) { + LogUtils.e(TAG, "getUriForFile:FileProvider生成失败(小米机型常见原因:路径未配置/Authority不匹配)", e); + } + } else { + LogUtils.d(TAG, "getUriForFile:Android7.0以下,使用Uri.fromFile生成"); + uri = Uri.fromFile(file); + LogUtils.d(TAG, "getUriForFile:File Uri生成成功=" + uri.toString()); + } + + LogUtils.d(TAG, "=== getUriForFile(File版)调用 end ==="); + return uri; + } + + // ====================== 私有辅助方法(内部逻辑封装,不对外暴露)====================== + /** + * ContentUri 转真实路径(适配 content:// 格式,处理小米机型特殊Uri) + * @param context 上下文 + * @param uri ContentUri(如:content://media/external/file/xxx) + * @return 真实文件路径(失败返回 null) + */ + private static String getFilePathFromContentUri(Context context, Uri uri) { + LogUtils.d(TAG, "getFilePathFromContentUri:Uri=" + uri.toString()); + String filePath = null; + Cursor cursor = null; + // Java7 语法:try-catch-finally 手动关闭Cursor,避免内存泄漏 + try { + // 查询字段:优先 DATA 字段,失败则通过文件名+流拷贝获取 + String[] queryColumns = {MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.DISPLAY_NAME}; + cursor = context.getContentResolver().query(uri, queryColumns, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + // 优先读取 DATA 字段(直接获取路径) + int dataIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATA); + if (dataIndex != -1) { + filePath = cursor.getString(dataIndex); + LogUtils.d(TAG, "getFilePathFromContentUri:从DATA字段获取路径=" + filePath); + } else { + // DATA 字段为空,通过流拷贝到私有目录获取路径(小米机型特殊场景适配) + int nameIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME); + String fileName = cursor.getString(nameIndex); + LogUtils.d(TAG, "getFilePathFromContentUri:DATA字段为空,通过流拷贝获取,文件名=" + fileName); + filePath = getPathFromInputStreamUri(context, uri, fileName); + } + } + } catch (Exception e) { + LogUtils.e(TAG, "getFilePathFromContentUri:查询失败", e); + } finally { + // 强制关闭Cursor,避免资源泄漏(Java7 必须手动处理) + if (cursor != null) { + try { + cursor.close(); + } catch (Exception e) { + LogUtils.e(TAG, "getFilePathFromContentUri:关闭Cursor失败", e); + } + } + } + return filePath; + } + + /** + * 流拷贝获取路径(适配无 DATA 字段的 ContentUri,小米机型特殊Uri兼容) + * 将目标文件拷贝到应用私有缓存目录,返回拷贝后的路径 + * @param context 上下文 + * @param uri ContentUri + * @param fileName 文件名 + * @return 拷贝后的文件路径(失败返回 null) + */ + private static String getPathFromInputStreamUri(Context context, Uri uri, String fileName) { + LogUtils.d(TAG, "getPathFromInputStreamUri:开始流拷贝,文件名=" + fileName); + InputStream inputStream = null; + OutputStream outputStream = null; + File targetFile = null; + try { + // 1. 打开输入流(读取Uri对应文件) + inputStream = context.getContentResolver().openInputStream(uri); + if (inputStream == null) { + LogUtils.e(TAG, "getPathFromInputStreamUri:输入流打开失败"); + return null; + } + + // 2. 创建目标文件(应用私有缓存目录,无权限限制) + targetFile = new File(context.getExternalCacheDir(), fileName); + // 若文件已存在,先删除(避免覆盖导致格式异常) + if (targetFile.exists()) { + boolean deleteSuccess = targetFile.delete(); + LogUtils.d(TAG, "getPathFromInputStreamUri:删除已存在文件,结果=" + deleteSuccess); + } + + // 3. 流拷贝(Java7 手动处理流,避免 try-with-resources) + outputStream = new FileOutputStream(targetFile); + byte[] buffer = new byte[8 * 1024]; // 8KB 缓冲区,平衡效率与内存 + int readLength; + while ((readLength = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, readLength); + } + outputStream.flush(); + LogUtils.d(TAG, "getPathFromInputStreamUri:流拷贝成功,路径=" + targetFile.getAbsolutePath()); + } catch (Exception e) { + LogUtils.e(TAG, "getPathFromInputStreamUri:流拷贝失败", e); + // 拷贝失败,删除临时文件 + if (targetFile != null && targetFile.exists()) { + targetFile.delete(); + } + targetFile = null; + } finally { + // 强制关闭流,避免资源泄漏(Java7 必须手动关闭) + try { + if (outputStream != null) { + outputStream.close(); + } + } catch (IOException e) { + LogUtils.e(TAG, "getPathFromInputStreamUri:关闭输出流失败", e); + } + try { + if (inputStream != null) { + inputStream.close(); + } + } catch (IOException e) { + LogUtils.e(TAG, "getPathFromInputStreamUri:关闭输入流失败", e); + } + } + return targetFile != null ? targetFile.getAbsolutePath() : null; + } + + /** + * 校验路径是否在安全目录内(适配API29-30+小米机型,避免FileProvider权限异常) + * 仅允许:应用私有目录、缓存目录、应用专属公共目录 + * @param context 上下文 + * @param file 待校验文件 + * @return true=安全路径,false=非安全路径 + */ + private static boolean isPathInValidDir(Context context, File file) { + String absolutePath = file.getAbsolutePath(); + // 1. 应用外部私有目录(API29+ 推荐,无权限限制) + String externalPrivateDir = context.getExternalFilesDir(null) != null + ? context.getExternalFilesDir(null).getAbsolutePath() + : ""; + // 2. 应用内部私有目录(无权限限制) + String internalPrivateDir = context.getFilesDir().getAbsolutePath(); + // 3. 应用缓存目录(无权限限制) + String cacheDir = context.getCacheDir().getAbsolutePath(); + // 4. 应用专属公共目录(API29+ 适配,替代废弃的 getExternalStoragePublicDirectory) + String appPublicDir = Environment.getExternalStorageDirectory().getAbsolutePath() + + File.separator + Environment.DIRECTORY_PICTURES + + File.separator + APP_PUBLIC_PIC_DIR; + + // 校验路径是否在安全目录内(小米机型必须严格校验,否则FileProvider会抛异常) + boolean isInValidDir = absolutePath.startsWith(externalPrivateDir) + || absolutePath.startsWith(internalPrivateDir) + || absolutePath.startsWith(cacheDir) + || absolutePath.startsWith(appPublicDir); + + LogUtils.d(TAG, "isPathInValidDir:外部私有目录=" + externalPrivateDir + + ",公共目录=" + appPublicDir + + ",校验结果=" + isInValidDir); + return isInValidDir; + } + + /** + * 流拷贝创建临时文件(内部辅助,封装拷贝逻辑) + * @param context 上下文 + * @param inputStream 输入流 + * @param fileName 文件名 + * @return 临时文件(失败返回 null) + * @throws IOException 流操作异常 + */ + private static File createTemporalFileFrom(Context context, InputStream inputStream, String fileName) throws IOException { + File targetFile = null; + if (inputStream != null) { + byte[] buffer = new byte[8 * 1024]; + int readLength; + targetFile = new File(context.getExternalCacheDir(), fileName); + if (targetFile.exists()) { + targetFile.delete(); + } + OutputStream outputStream = new FileOutputStream(targetFile); + while ((readLength = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, readLength); + } + outputStream.flush(); + outputStream.close(); + } + return targetFile; + } + + /** + * 辅助:ContentUri 通过 MIME 类型获取后缀(精准匹配,不受文件名伪造影响) + * @param context 上下文 + * @param uri ContentUri + * @return 匹配的后缀(无匹配返回空字符串) + */ + private static String getSuffixFromContentUri(Context context, Uri uri) { + String mime = null; + try { + // 通过 ContentResolver 获取 Uri 对应的 MIME 类型(系统级匹配,最精准) + mime = context.getContentResolver().getType(uri); + LogUtils.d(TAG, "getSuffixFromContentUri:获取MIME类型=" + mime); + if (mime == null || mime.isEmpty()) { + // MIME 为空,尝试解析文件名兜底 + String fileName = getFileNameFromContentUri(context, uri); + return getSuffixFromFilePath(fileName); + } + // MIME 类型匹配后缀(优先完全匹配,再模糊匹配) + if (MIME_SUFFIX_MAP.containsKey(mime)) { + return MIME_SUFFIX_MAP.get(mime); + } + // 模糊匹配(如 image/* 匹配通用图片后缀,默认png) + if (mime.startsWith("image/")) { + return "png"; + } else if (mime.startsWith("video/")) { + return "mp4"; + } else if (mime.startsWith("audio/")) { + return "mp3"; + } else if (mime.startsWith("application/")) { + return "pdf"; + } + } catch (Exception e) { + LogUtils.e(TAG, "getSuffixFromContentUri:MIME解析失败,mime=" + mime, e); + } + // 所有方式失败,解析Uri路径兜底 + return getSuffixFromFilePath(uri.getPath()); + } + + /** + * 辅助:从 ContentUri 获取文件名(MIME 解析失败时兜底) + * @param context 上下文 + * @param uri ContentUri + * @return 文件名(失败返回空字符串) + */ + private static String getFileNameFromContentUri(Context context, Uri uri) { + Cursor cursor = null; + try { + String[] queryColumns = {MediaStore.MediaColumns.DISPLAY_NAME}; + cursor = context.getContentResolver().query(uri, queryColumns, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + int nameIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME); + return cursor.getString(nameIndex); + } + } catch (Exception e) { + LogUtils.e(TAG, "getFileNameFromContentUri:查询失败", e); + } finally { + if (cursor != null) { + try { + cursor.close(); + } catch (Exception e) { + LogUtils.e(TAG, "getFileNameFromContentUri:关闭Cursor失败", e); + } + } + } + return ""; + } + + /** + * 辅助:从文件路径/文件名截取后缀(兜底方案,处理各种路径格式) + * @param path 文件路径/文件名 + * @return 截取的后缀(无后缀返回空字符串) + */ + private static String getSuffixFromFilePath(String path) { + if (path == null || path.isEmpty()) { + return ""; + } + // 处理路径中的分隔符(兼容 Windows/Android 路径格式) + path = path.replace("\\", "/"); + // 取最后一个 "/" 后的文件名(避免路径包含 "." 导致误判) + int lastSepIndex = path.lastIndexOf("/"); + if (lastSepIndex != -1 && lastSepIndex < path.length() - 1) { + path = path.substring(lastSepIndex + 1); + } + // 截取最后一个 "." 后的后缀(过滤无后缀/点开头/点结尾场景) + int lastDotIndex = path.lastIndexOf("."); + if (lastDotIndex == -1 || lastDotIndex == 0 || lastDotIndex == path.length() - 1) { + return ""; + } + // 过滤后缀中的非法字符(仅保留字母/数字,避免特殊字符干扰) + String suffix = path.substring(lastDotIndex + 1).replaceAll("[^a-zA-Z0-9]", ""); + // 限制后缀长度(1-5位,避免超长伪造后缀) + return suffix.length() >= 1 && suffix.length() <= 5 ? suffix : ""; + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/views/BackgroundView.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/views/BackgroundView.java new file mode 100644 index 0000000..2bb8f24 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/views/BackgroundView.java @@ -0,0 +1,343 @@ +package cc.winboll.studio.powerbell.views; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.App; +import cc.winboll.studio.powerbell.models.BackgroundBean; +import cc.winboll.studio.powerbell.utils.AppConfigUtils; +import cc.winboll.studio.powerbell.utils.ImageUtils; +import java.io.File; + +/** + * 基于Java7的BackgroundView(LinearLayout+ImageView,保持原图比例居中平铺) + * 核心:ImageView保持原图比例,在LinearLayout中居中平铺,无拉伸、无裁剪、无压缩 + * 改进:强制保持缓存策略,无论内存是否紧张,不自动清理任何缓存,保留图片原始品质 + * @Author 豆包&ZhanGSKen + */ +public class BackgroundView extends RelativeLayout { + // ====================================== 静态常量区(首屏可见,统一管理) ====================================== + public static final String TAG = "BackgroundView"; + // Bitmap 配置常量(原始品质) + private static final Bitmap.Config BITMAP_CONFIG = Bitmap.Config.ARGB_8888; + private static final int BITMAP_SAMPLE_SIZE = 1; // 不缩放采样率 + + // ====================================== 成员变量区(按功能分类:上下文→视图→缓存→图片属性) ====================================== + // 上下文 + private Context mContext; + // 视图组件 + private LinearLayout mLlContainer; // 主容器LinearLayout + private ImageView mIvBackground; // 图片显示控件 + // 缓存相关 + private String mCurrentCachedPath = "";// 当前缓存图片路径 + // 图片属性 + private float mImageAspectRatio = 1.0f;// 原图宽高比(宽/高) + private int mBgColor = 0xFFFFFFFF; // 当前图片背景色 + + // ====================================== 构造器(Java7兼容,按参数重载顺序排列) ====================================== + public BackgroundView(Context context) { + super(context); + LogUtils.d(TAG, String.format("【构造器1】启动 | context=%s", context.getClass().getSimpleName())); + this.mContext = context; + initView(); + } + + public BackgroundView(Context context, AttributeSet attrs) { + super(context, attrs); + LogUtils.d(TAG, String.format("【构造器2】启动 | context=%s", context.getClass().getSimpleName())); + this.mContext = context; + initView(); + } + + public BackgroundView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + LogUtils.d(TAG, String.format("【构造器3】启动 | context=%s", context.getClass().getSimpleName())); + this.mContext = context; + initView(); + } + + // ====================================== 初始化方法(按执行顺序:主视图→子容器→图片控件→默认背景) ====================================== + private void initView() { + LogUtils.d(TAG, "【initView】启动"); + // 1. 配置当前控件:全屏+透明 + setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); + // 2. 初始化主容器LinearLayout + initLinearLayout(); + // 3. 初始化ImageView + initImageView(); + // 4. 初始设置透明背景 + setDefaultEmptyBackground(); + LogUtils.d(TAG, "【initView】完成"); + } + + private void initLinearLayout() { + LogUtils.d(TAG, "【initLinearLayout】启动"); + mLlContainer = new LinearLayout(mContext); + LinearLayout.LayoutParams llParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT + ); + mLlContainer.setLayoutParams(llParams); + mLlContainer.setOrientation(LinearLayout.VERTICAL); + mLlContainer.setGravity(android.view.Gravity.CENTER); + this.addView(mLlContainer); + LogUtils.d(TAG, "【initLinearLayout】完成"); + } + + private void initImageView() { + LogUtils.d(TAG, "【initImageView】启动"); + mIvBackground = new ImageView(mContext); + LinearLayout.LayoutParams ivParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + mIvBackground.setLayoutParams(ivParams); + mIvBackground.setScaleType(ImageView.ScaleType.FIT_CENTER); + mLlContainer.addView(mIvBackground); + LogUtils.d(TAG, "【initImageView】完成"); + } + + // ====================================== 对外公开方法(按功能分类:Bean加载→图片加载) ====================================== + public void loadByBackgroundBean(BackgroundBean bean) { + loadByBackgroundBean(bean, false); + } + + public void loadByBackgroundBean(BackgroundBean bean, boolean isRefresh) { + LogUtils.d(TAG, String.format("【loadByBackgroundBean】启动 | isRefresh=%b | bean=%s", isRefresh, bean)); + // 参数校验 + if (bean == null) { + LogUtils.e(TAG, "【loadByBackgroundBean】异常:BackgroundBean为空"); + setDefaultEmptyBackground(); + return; + } + + // 设置图片背景色 + mBgColor = bean.getPixelColor(); + LogUtils.d(TAG, String.format("【loadByBackgroundBean】背景色设置为 0x%08X", mBgColor)); + + // 判断是否使用背景文件 + if (!bean.isUseBackgroundFile()) { + LogUtils.d(TAG, "【loadByBackgroundBean】不使用背景文件,设置透明背景"); + setDefaultEmptyBackground(); + return; + } + + // 获取目标路径 + String targetPath = bean.isUseBackgroundScaledCompressFile() + ? bean.getBackgroundScaledCompressFilePath() + : bean.getBackgroundFilePath(); + LogUtils.d(TAG, String.format("【loadByBackgroundBean】目标路径=%s | 使用压缩文件=%b", + targetPath, bean.isUseBackgroundScaledCompressFile())); + + // 校验文件是否存在 + File targetFile = new File(targetPath); + if (!targetFile.exists() || !targetFile.isFile()) { + LogUtils.e(TAG, String.format("【loadByBackgroundBean】异常:图片文件不存在 | path=%s", targetPath)); + setDefaultEmptyBackground(); + return; + } + + loadImage(mBgColor, targetPath, isRefresh); + } + + public void loadImage(int bgColor, String imagePath, boolean isRefresh) { + LogUtils.d(TAG, String.format("【loadImage】启动 | bgColor=0x%08X | imagePath=%s | isRefresh=%b", + bgColor, imagePath, isRefresh)); + // 隐藏ImageView防止闪烁 + mIvBackground.setVisibility(View.GONE); + + // 刷新逻辑:重新解码原始品质图片并更新缓存 + if (isRefresh) { + LogUtils.d(TAG, "【loadImage】执行刷新逻辑:重新解码原始品质图片"); + File imageFile = new File(imagePath); + Bitmap newBitmap = decodeOriginalBitmap(imageFile); + LogUtils.d(TAG, String.format("【loadImage】原始图片解码完成 | newBitmap=%s", + newBitmap != null ? newBitmap.getWidth() + "x" + newBitmap.getHeight() : "null")); + + // 合成纯色背景图片(使用配置文件中默认相框尺寸) + Bitmap combinedBitmap = ImageUtils.drawBitmapOnSolidBackground( + bgColor, + App.sAppConfigUtils.mAppConfigBean.getDefaultFrameWidth(), + App.sAppConfigUtils.mAppConfigBean.getDefaultFrameHeight(), + newBitmap + ); + + if (combinedBitmap == null) { + LogUtils.e(TAG, "【loadImage】纯色背景合成失败,使用原始Bitmap"); + combinedBitmap = newBitmap; + } else { + LogUtils.d(TAG, String.format("【loadImage】纯色背景合成成功 | combinedBitmap=%dx%d", + combinedBitmap.getWidth(), combinedBitmap.getHeight())); + // 回收原始Bitmap(避免重复缓存) + if (newBitmap != null && !newBitmap.isRecycled() && newBitmap != combinedBitmap) { + newBitmap.recycle(); + LogUtils.d(TAG, "【loadImage】原始Bitmap已回收"); + } + } + + // 更新缓存 + if (combinedBitmap != null) { + App.sBitmapCacheUtils.cacheBitmap(imagePath, combinedBitmap); + App.sBitmapCacheUtils.increaseRefCount(imagePath); + mCurrentCachedPath = imagePath; + LogUtils.d(TAG, String.format("【loadImage】刷新缓存成功 | path=%s", imagePath)); + } else { + LogUtils.e(TAG, String.format("【loadImage】刷新解码失败 | path=%s", imagePath)); + } + } + + // 加载缓存图片 + Bitmap cachedBitmap = App.sBitmapCacheUtils.getCachedBitmap(imagePath); + LogUtils.d(TAG, String.format("【loadImage】加载缓存图片 | cachedBitmap=%s", + cachedBitmap != null ? cachedBitmap.getWidth() + "x" + cachedBitmap.getHeight() : "null")); + mIvBackground.setImageBitmap(cachedBitmap); + mIvBackground.setScaleType(ImageView.ScaleType.FIT_CENTER); + mIvBackground.setVisibility(View.VISIBLE); + LogUtils.d(TAG, "【loadImage】完成"); + } + + // ====================================== 内部工具方法(按功能分类:Bitmap校验→比例计算→解码→背景设置) ====================================== + /** + * 工具方法:判断Bitmap是否有效(非空且未被回收) + */ + private boolean isBitmapValid(Bitmap bitmap) { + boolean valid = bitmap != null && !bitmap.isRecycled(); + if (!valid) { + LogUtils.w(TAG, "【isBitmapValid】无效:Bitmap为空或已回收"); + } + return valid; + } + + /** + * 计算图片宽高比 + */ + private boolean calculateImageAspectRatio(File file) { + LogUtils.d(TAG, String.format("【calculateImageAspectRatio】启动 | file=%s", file.getAbsolutePath())); + try { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeFile(file.getAbsolutePath(), options); + + // 尺寸校验 + int width = options.outWidth; + int height = options.outHeight; + if (width <= 0 || height <= 0) { + LogUtils.e(TAG, String.format("【calculateImageAspectRatio】无效尺寸 | width=%d | height=%d", width, height)); + return false; + } + + // 计算比例 + mImageAspectRatio = (float) width / height; + LogUtils.d(TAG, String.format("【calculateImageAspectRatio】完成 | 比例=%.2f", mImageAspectRatio)); + return true; + } catch (Exception e) { + LogUtils.e(TAG, String.format("【calculateImageAspectRatio】失败:%s", e.getMessage())); + return false; + } + } + + /** + * 移除压缩逻辑:解码原始品质图片(无缩放、无色彩损失) + */ + private Bitmap decodeOriginalBitmap(File file) { + LogUtils.d(TAG, String.format("【decodeOriginalBitmap】启动 | file=%s", file.getAbsolutePath())); + try { + BitmapFactory.Options options = new BitmapFactory.Options(); + // 核心配置:原始品质 + options.inSampleSize = BITMAP_SAMPLE_SIZE; + options.inPreferredConfig = BITMAP_CONFIG; + options.inPurgeable = false; + options.inInputShareable = false; + options.inDither = true; + options.inScaled = false; + + // 解码图片 + Bitmap bitmap = BitmapFactory.decodeFile(file.getAbsolutePath(), options); + if (bitmap != null) { + LogUtils.d(TAG, String.format("【decodeOriginalBitmap】成功 | width=%d | height=%d", bitmap.getWidth(), bitmap.getHeight())); + } else { + LogUtils.e(TAG, "【decodeOriginalBitmap】失败:返回null"); + } + return bitmap; + } catch (Exception e) { + LogUtils.e(TAG, String.format("【decodeOriginalBitmap】异常:%s", e.getMessage())); + return null; + } + } + + /** + * 设置默认透明背景,仅减少引用计数,不删除缓存 + */ + private void setDefaultEmptyBackground() { + LogUtils.d(TAG, "【setDefaultEmptyBackground】启动"); + // 清空ImageView + mIvBackground.setImageDrawable(null); + mImageAspectRatio = 1.0f; + + // 减少引用计数,不删除缓存 + if (!TextUtils.isEmpty(mCurrentCachedPath)) { + LogUtils.d(TAG, String.format("【setDefaultEmptyBackground】减少引用计数 | path=%s", mCurrentCachedPath)); + App.sBitmapCacheUtils.decreaseRefCount(mCurrentCachedPath); + mCurrentCachedPath = ""; + } + LogUtils.d(TAG, "【setDefaultEmptyBackground】完成"); + } + + // ====================================== 重写生命周期方法(按执行顺序:绘制→尺寸变化→窗口分离) ====================================== + /** + * 重写:绘制前强制校验Bitmap有效性,防止已回收Bitmap崩溃 + */ + @Override + protected void onDraw(Canvas canvas) { + Drawable drawable = mIvBackground.getDrawable(); + if (drawable instanceof BitmapDrawable) { + BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable; + Bitmap bitmap = bitmapDrawable.getBitmap(); + if (!isBitmapValid(bitmap)) { + LogUtils.e(TAG, "【onDraw】检测到已回收Bitmap,清空绘制"); + mIvBackground.setImageDrawable(null); + return; + } + } + super.onDraw(canvas); + } + + /** + * 重写:恢复尺寸调整逻辑,确保View尺寸变化时正确显示(无压缩) + */ + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + LogUtils.d(TAG, String.format("【onSizeChanged】尺寸变化 | newW=%d | newH=%d | oldW=%d | oldH=%d", w, h, oldw, oldh)); + } + + /** + * 重写:View从窗口移除时仅减少引用计数,不删除全局缓存(强制保持策略) + */ + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + LogUtils.d(TAG, "【onDetachedFromWindow】启动"); + // 清空ImageView的Drawable,释放本地引用 + mIvBackground.setImageDrawable(null); + + // 减少引用计数,不删除全局缓存 + if (!TextUtils.isEmpty(mCurrentCachedPath)) { + LogUtils.d(TAG, String.format("【onDetachedFromWindow】减少引用计数 | path=%s", mCurrentCachedPath)); + App.sBitmapCacheUtils.decreaseRefCount(mCurrentCachedPath); + mCurrentCachedPath = ""; + } + LogUtils.d(TAG, "【onDetachedFromWindow】完成"); + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/views/BatteryDrawable.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/views/BatteryDrawable.java new file mode 100644 index 0000000..2a5e4f3 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/views/BatteryDrawable.java @@ -0,0 +1,298 @@ +package cc.winboll.studio.powerbell.views; + +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.models.BatteryStyle; + +/** + * 电池电量Drawable:适配API30,兼容小米机型,支持能量/条纹两种绘制风格切换 + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/12/17 12:55 + */ +public class BatteryDrawable extends Drawable { + // ====================================== 静态常量区(按功能归类,消除魔法值) ====================================== + public static final String TAG = "BatteryDrawable"; + // 小米机型绘制偏移校准(适配MIUI渲染特性,避免绘制错位) + private static final int MIUI_DRAW_OFFSET = 1; + // 默认电量透明度(兼顾显示效果与API30渲染性能) + private static final int DEFAULT_BATTERY_ALPHA = 210; + // 电量范围常量 + private static final int BATTERY_MIN = 0; + private static final int BATTERY_MAX = 100; + // 条纹风格拆分数量 + private static final int STRIPE_COUNT = 100; + + // ====================================== 成员变量区(final优先,按功能归类) ====================================== + // 绘制画笔(final修饰,避免重复创建,提升性能) + private final Paint mBatteryPaint; + // 业务控制变量 + private int mBatteryValue = -1; // 当前电量(0-100,-1=未初始化) + private BatteryStyle mBatteryStyle = BatteryStyle.ENERGY_STYLE; // 绘制风格(true=能量,false=条纹) + + // ====================================== 构造方法(重载适配,优先暴露常用构造) ====================================== + /** + * 构造方法(默认能量风格,常用场景) + * @param batteryColor 电量显示颜色 + */ + public BatteryDrawable(int batteryColor) { + LogUtils.d(TAG, "【BatteryDrawable】构造器1调用 | 能量风格 | 颜色=" + Integer.toHexString(batteryColor)); + mBatteryPaint = new Paint(); + initPaintConfig(batteryColor); + } + + /** + * 构造方法(支持指定绘制风格,扩展场景) + * @param batteryColor 电量显示颜色 + * @param isEnergyStyle 是否启用能量风格 + */ + public BatteryDrawable(int batteryColor, BatteryStyle batteryStyle) { + mBatteryPaint = new Paint(); + mBatteryStyle = batteryStyle; + initPaintConfig(batteryColor); + } + + public void setIsEnergyStyle(BatteryStyle batteryStyle) { + this.mBatteryStyle = batteryStyle; + } + + // ====================================== 私有初始化方法(封装复用,隐藏内部逻辑) ====================================== + /** + * 初始化画笔配置(适配API30渲染特性,优化小米机型兼容性) + * @param color 电量显示颜色 + */ + private void initPaintConfig(int color) { + LogUtils.d(TAG, "【initPaintConfig】画笔配置开始 | 颜色=" + Integer.toHexString(color)); + mBatteryPaint.setColor(color); + mBatteryPaint.setAlpha(DEFAULT_BATTERY_ALPHA); + mBatteryPaint.setAntiAlias(true); // 抗锯齿,解决小米低分辨率锯齿问题 + mBatteryPaint.setStyle(Paint.Style.FILL); // 固定填充模式,避免混乱 + mBatteryPaint.setDither(false); // 禁用抖动,提升API30颜色显示一致性 + LogUtils.d(TAG, "【initPaintConfig】画笔配置完成"); + } + + // ====================================== 核心绘制方法(Drawable抽象方法,优先级最高) ====================================== + @Override + public void draw(Canvas canvas) { + // 未初始化/异常电量,直接跳过,避免无效绘制 + if (mBatteryValue < 0) { + LogUtils.w(TAG, "【draw】电量未初始化,跳过绘制"); + return; + } + // 强制校准电量范围(0-100),防止异常值导致绘制错误 + int validBattery = Math.max(BATTERY_MIN, Math.min(mBatteryValue, BATTERY_MAX)); + LogUtils.d(TAG, "【draw】电量校准完成 | 有效电量=" + validBattery); + + Rect drawBounds = getBounds(); + // 绘制边界空指针防护 + if (drawBounds == null) { + LogUtils.e(TAG, "【draw】绘制边界为空,跳过绘制"); + return; + } + int drawHeight = drawBounds.height(); + + // 小米机型绘制偏移校准(解决MIUI系统渲染偏移问题) + int offset = MIUI_DRAW_OFFSET; + int left = drawBounds.left + offset; + int right = drawBounds.right - offset; + LogUtils.d(TAG, "【draw】绘制参数校准 | 左边界=" + left + " | 右边界=" + right + " | 高度=" + drawHeight); + + // 按风格执行绘制 + if (mBatteryStyle == BatteryStyle.ENERGY_STYLE) { + drawEnergyStyle(canvas, validBattery, left, right, drawHeight); + } else if (mBatteryStyle == BatteryStyle.ZEBRA_STYLE) { + drawZebraStyle(canvas, validBattery, left, right, drawHeight); + } else if (mBatteryStyle == BatteryStyle.POINT_STYLE) { + drawPointStyle(canvas, validBattery, left, right, drawHeight); + } + LogUtils.d(TAG, "【draw】绘制完成"); + } + + // ====================================== 绘制风格实现(私有封装,按风格拆分) ====================================== + /** + * 能量风格绘制(整块填充,高效简洁,默认风格) + * @param canvas 绘制画布 + * @param battery 有效电量(0-100) + * @param left 左边界 + * @param right 右边界 + * @param height 绘制高度 + */ + private void drawEnergyStyle(Canvas canvas, int battery, int left, int right, int height) { + LogUtils.d(TAG, "【drawEnergyStyle】能量风格绘制开始 | 电量=" + battery); +// int top = height - (height * battery / BATTERY_MAX); // 计算电量对应顶部坐标 +// canvas.drawRect(new Rect(left, top, right, height), mBatteryPaint); +// LogUtils.d(TAG, "【drawEnergyStyle】能量风格绘制完成 | 顶部坐标=" + top); + int nWidth = getBounds().width(); + int nHeight = getBounds().height(); + int mnDx = nHeight / 203; + + // 绘制耗电电量提醒值电量 + // 能量绘图风格 + int nTop; + int nLeft = 0; + int nBottom; + int nRight = nWidth; + + //for (int i = 0; i < mnValue; i ++) { + nBottom = nHeight; + nTop = nHeight - (nHeight * mBatteryValue / 100); + canvas.drawRect(new Rect(nLeft, nTop, nRight, nBottom), mBatteryPaint); + + } + + /** + * 条纹风格绘制(分段条纹,扩展风格) + * @param canvas 绘制画布 + * @param battery 有效电量(0-100) + * @param left 左边界 + * @param right 右边界 + * @param height 绘制高度 + */ + private void drawZebraStyle(Canvas canvas, int battery, int left, int right, int height) { + LogUtils.d(TAG, "【drawStripeStyle】条纹风格绘制开始 | 电量=" + battery); +// int stripeHeight = height / STRIPE_COUNT; // 单条条纹高度(均匀拆分) +// // 从底部向上绘制对应电量条纹 +// for (int i = 0; i < battery; i++) { +// int bottom = height - (stripeHeight * i); +// int top = bottom - stripeHeight; +// canvas.drawRect(new Rect(left, top, right, bottom), mBatteryPaint); +// } + + int nWidth = getBounds().width(); + int nHeight = getBounds().height(); + int mnDx = nHeight / 203; + + + // 意兴阑珊绘图风格 + int nTop; + int nLeft = 0; + int nBottom; + int nRight = nWidth; + + for (int i = 0; i < mBatteryValue; i ++) { + nBottom = (nHeight * (100 - i) / 100) - mnDx; + nTop = nBottom + mnDx; + canvas.drawRect(new Rect(nLeft, nTop, nRight, nBottom), mBatteryPaint); + } + LogUtils.d(TAG, "【drawStripeStyle】条纹风格绘制完成 | 条纹数量=" + battery); + } + + + /** + * 点阵风格绘制 + * @param canvas 绘制画布 + * @param battery 有效电量(0-100) + * @param left 左边界 + * @param right 右边界 + * @param height 绘制高度 + */ + private void drawPointStyle(Canvas canvas, int battery, int left, int right, int height) { + LogUtils.d(TAG, "【drawStripeStyle】条纹风格绘制开始 | 电量=" + battery); + + int nWidth = getBounds().width(); + int nHeight = getBounds().height(); + int mnDx = nHeight / 203; + + + // 意兴阑珊绘图风格 + int nTop; + int nLeft = 0; + int nBottom; + int nRight = nWidth; + + int nLineWidth = nRight - nLeft; + int radius_horizontal = (nLineWidth / 10) / 2; + int radius_vertical = mnDx/2; + int radius = Math.min(radius_horizontal, radius_vertical); + + for (int i = 0; i < mBatteryValue; i ++) { + nBottom = (nHeight * (100 - i) / 100) - mnDx; + nTop = nBottom + mnDx; + //canvas.drawRect(new Rect(nLeft, nTop, nRight, nBottom), mBatteryPaint); + + for (int j = 0; j < 10; j++) { + // cx, cy 圆心坐标;radius 半径;paint 画笔 + int cx = radius_horizontal + radius_horizontal * j * 2; + int cy = nTop + radius_vertical; + canvas.drawCircle(cx, cy, radius, mBatteryPaint); + } + } + LogUtils.d(TAG, "【drawStripeStyle】条纹风格绘制完成 | 条纹数量=" + battery); + } + + // ====================================== 对外暴露方法(业务控制入口,按功能排序) ====================================== + /** + * 设置当前电量(外部核心调用入口) + * @param value 电量值(0-100) + */ + public void setBatteryValue(int value) { + LogUtils.d(TAG, "【setBatteryValue】电量更新 | 旧值=" + mBatteryValue + " | 新值=" + value); + mBatteryValue = value; + invalidateSelf(); // 触发重绘,确保UI实时更新 + LogUtils.d(TAG, "【setBatteryValue】已触发重绘"); + } + + /** + * 切换绘制风格 + * @param isEnergyStyle true=能量风格,false=条纹风格 + */ + public void setDrawStyle(BatteryStyle batteryStyle) { + mBatteryStyle = batteryStyle; + invalidateSelf(); + LogUtils.d(TAG, "【switchDrawStyle】已触发重绘"); + } + + /** + * 更新电量显示颜色 + * @param color 新颜色值 + */ + public void updateBatteryColor(int color) { + String oldColor = Integer.toHexString(mBatteryPaint.getColor()); + String newColor = Integer.toHexString(color); + LogUtils.d(TAG, "【updateBatteryColor】颜色更新 | 旧颜色=" + oldColor + " | 新颜色=" + newColor); + mBatteryPaint.setColor(color); + invalidateSelf(); + LogUtils.d(TAG, "【updateBatteryColor】已触发重绘"); + } + + // ====================================== Getter方法(按需暴露,简洁无冗余) ====================================== + /** + * 获取当前电量 + * @return 电量值(0-100,-1=未初始化) + */ + public int getBatteryValue() { + return mBatteryValue; + } + + + + public BatteryStyle getEnergyStyle() { + return mBatteryStyle; + } + + // ====================================== Drawable抽象方法(必须实现,精简逻辑) ====================================== + @Override + public void setAlpha(int alpha) { + LogUtils.d(TAG, "【setAlpha】透明度更新 | 旧值=" + mBatteryPaint.getAlpha() + " | 新值=" + alpha); + mBatteryPaint.setAlpha(alpha); + invalidateSelf(); + } + + @Override + public void setColorFilter(ColorFilter colorFilter) { + LogUtils.d(TAG, "【setColorFilter】设置颜色过滤 | filter=" + colorFilter); + mBatteryPaint.setColorFilter(colorFilter); + invalidateSelf(); + } + + @Override + public int getOpacity() { + // 固定返回半透明,适配API30透明度渲染机制,兼容小米机型 + return PixelFormat.TRANSLUCENT; + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/views/BatteryStyleView.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/views/BatteryStyleView.java new file mode 100644 index 0000000..9cd6a7c --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/views/BatteryStyleView.java @@ -0,0 +1,285 @@ +package cc.winboll.studio.powerbell.views; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.RadioButton; +import android.widget.RadioGroup; +import android.widget.RelativeLayout; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import cc.winboll.studio.powerbell.MainActivity; +import cc.winboll.studio.powerbell.R; +import cc.winboll.studio.powerbell.models.BatteryStyle; + +/** + * 电池样式单选视图,水平展示所有BatteryStyle枚举选项 + * 每个选项 = RadioButton单选按钮 + BatteryDrawable预览控件 + * 适配API30、Java7规范,联动BatteryDrawable绘制样式 + * 包含:SP持久化存储 + 公共静态方法读取SP枚举值 + 彻底修复点击不回调+单选失效 + * 默认选中:BatteryStyle.ENERGY_STYLE + * @Author 豆包&ZhanGSKen + */ +public class BatteryStyleView extends LinearLayout implements RadioGroup.OnCheckedChangeListener { + // ====================== 常量区 ====================== + public static final String TAG = "BatteryStyleView"; + private static final int DEFAULT_BATTERY_COLOR = Color.parseColor("#FF4CAF50"); + private static final int DEFAULT_CHECKED_STYLE_INDEX = 1; // ✅ 修改默认选中下标 1 = ENERGY_STYLE + public static final String SP_NAME = "sp_battery_style_config"; + public static final String SP_KEY_BATTERY_STYLE = "key_selected_battery_style"; + + // ====================== 控件变量 ====================== + private RadioGroup rgBatteryStyle; + private RadioButton rbZebraStyle; + private RadioButton rbEnergyStyle; + private RadioButton rbPointStyle; // ✅ 新增:圆点样式单选按钮 + private RelativeLayout rlZebraPreview; + private RelativeLayout rlEnergyPreview; + private RelativeLayout rlPointPreview; // ✅ 新增:圆点样式预览布局 + private BatteryDrawable mZebraDrawable; + private BatteryDrawable mEnergyDrawable; + private BatteryDrawable mPointDrawable; // ✅ 新增:圆点样式Drawable实例 + + // ====================== 业务变量 ====================== + private BatteryStyle mCurrentStyle = BatteryStyle.ENERGY_STYLE; // ✅ 修改默认样式为 能量样式 + private OnBatteryStyleSelectedListener mStyleSelectedListener; + private int mBatteryColor = DEFAULT_BATTERY_COLOR; + private int mBatteryValue = 100; + private SharedPreferences mSp; + + // ====================== 构造方法 ====================== + public BatteryStyleView(Context context) { + super(context); + initSP(context); + initView(context, null); + } + + public BatteryStyleView(Context context, AttributeSet attrs) { + super(context, attrs); + initSP(context); + initAttrs(context, attrs); + initView(context, attrs); + } + + public BatteryStyleView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initSP(context); + initAttrs(context, attrs); + initView(context, attrs); + } + + // ====================== 初始化SP持久化 ====================== + private void initSP(Context context) { + mSp = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE); + LogUtils.d(TAG, "【initSP】SharedPreferences初始化完成,文件名称 = " + SP_NAME); + } + + // ====================== 初始化方法 ====================== + private void initAttrs(Context context, AttributeSet attrs) { + if (attrs == null) return; + TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.BatteryStyleView); + mBatteryColor = typedArray.getColor(R.styleable.BatteryStyleView_batteryPreviewColor, DEFAULT_BATTERY_COLOR); + mBatteryValue = typedArray.getInt(R.styleable.BatteryStyleView_previewBatteryValue, 100); + int styleIndex = typedArray.getInt(R.styleable.BatteryStyleView_defaultSelectedStyle, DEFAULT_CHECKED_STYLE_INDEX); + mCurrentStyle = getStyleFromSP() == null ? (styleIndex == 0 ? BatteryStyle.ENERGY_STYLE : styleIndex ==1 ? BatteryStyle.ZEBRA_STYLE : BatteryStyle.POINT_STYLE) : getStyleFromSP(); + typedArray.recycle(); + LogUtils.d(TAG, "【initAttrs】解析属性完成 电量颜色=" + Integer.toHexString(mBatteryColor) + " 预览电量=" + mBatteryValue + " 默认样式=" + mCurrentStyle.name()); + } + + private void initView(Context context, AttributeSet attrs) { + LayoutInflater.from(context).inflate(R.layout.view_battery_style, this, true); + rgBatteryStyle = findViewById(R.id.rg_battery_style); + rbZebraStyle = findViewById(R.id.rb_zebra_style); + rbEnergyStyle = findViewById(R.id.rb_energy_style); + rbPointStyle = findViewById(R.id.rb_point_style); // ✅ 新增:绑定圆点样式单选按钮 + rlZebraPreview = findViewById(R.id.rl_zebra_preview); + rlEnergyPreview = findViewById(R.id.rl_energy_preview); + rlPointPreview = findViewById(R.id.rl_point_preview); // ✅ 新增:绑定圆点样式预览布局 + + initPreviewDrawable(); + rgBatteryStyle.setOnCheckedChangeListener(this); + addRadioBtnClickLister(); + setDefaultChecked(); + LogUtils.d(TAG, "【initView】视图初始化完成"); + } + + private void initPreviewDrawable() { + mZebraDrawable = new BatteryDrawable(mBatteryColor, BatteryStyle.ZEBRA_STYLE); + mZebraDrawable.setBatteryValue(mBatteryValue); + rlZebraPreview.setBackground(mZebraDrawable); + + mEnergyDrawable = new BatteryDrawable(mBatteryColor, BatteryStyle.ENERGY_STYLE); + mEnergyDrawable.setBatteryValue(mBatteryValue); + rlEnergyPreview.setBackground(mEnergyDrawable); + + // ✅ 新增:初始化圆点样式Drawable + 绑定预览布局 + 设置电量值 + mPointDrawable = new BatteryDrawable(mBatteryColor, BatteryStyle.POINT_STYLE); + mPointDrawable.setBatteryValue(mBatteryValue); + rlPointPreview.setBackground(mPointDrawable); + + LogUtils.d(TAG, "【initPreviewDrawable】Drawable预览初始化完成"); + } + + private void setDefaultChecked() { + // ✅ 新增:圆点样式的默认选中判断 + if (mCurrentStyle == BatteryStyle.ZEBRA_STYLE) { + rbZebraStyle.setChecked(true); + } else if (mCurrentStyle == BatteryStyle.POINT_STYLE) { + rbPointStyle.setChecked(true); + } else { + rbEnergyStyle.setChecked(true); + } + LogUtils.d(TAG, "【setDefaultChecked】默认选中样式 = " + mCurrentStyle.name()); + } + + private void addRadioBtnClickLister() { + rbZebraStyle.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + rbZebraStyle.setChecked(true); + rbEnergyStyle.setChecked(false); + rbPointStyle.setChecked(false); // ✅ 新增:取消圆点样式选中 + handleStyleSelect(BatteryStyle.ZEBRA_STYLE); + } + }); + + rbEnergyStyle.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + rbEnergyStyle.setChecked(true); + rbZebraStyle.setChecked(false); + rbPointStyle.setChecked(false); // ✅ 新增:取消圆点样式选中 + handleStyleSelect(BatteryStyle.ENERGY_STYLE); + } + }); + + // ✅ 新增:圆点样式单选按钮点击事件 + rbPointStyle.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + rbPointStyle.setChecked(true); + rbZebraStyle.setChecked(false); + rbEnergyStyle.setChecked(false); + handleStyleSelect(BatteryStyle.POINT_STYLE); + } + }); + } + + // ====================== RadioGroup 选中回调 (点击必触发) ====================== + @Override + public void onCheckedChanged(RadioGroup group, int checkedId) { + ToastUtils.show("onCheckedChanged"); + if (checkedId == R.id.rb_zebra_style) { + handleStyleSelect(BatteryStyle.ZEBRA_STYLE); + } else if (checkedId == R.id.rb_energy_style) { + handleStyleSelect(BatteryStyle.ENERGY_STYLE); + } else if (checkedId == R.id.rb_point_style) { // ✅ 新增:圆点样式选中回调 + handleStyleSelect(BatteryStyle.POINT_STYLE); + } + } + + private void handleStyleSelect(BatteryStyle style) { + mCurrentStyle = style; + saveStyle2SP(mCurrentStyle); + MainActivity.sendUpdateBatteryDrawableMessage(); + LogUtils.d(TAG, "【handleStyleSelect】选中样式 → " + mCurrentStyle.name() + ",已存入SP"); + if (mStyleSelectedListener != null) { + mStyleSelectedListener.onStyleSelected(mCurrentStyle); + } + } + + // ====================== SP持久化 存储+读取 封装方法 ====================== + private void saveStyle2SP(BatteryStyle style) { + mSp.edit().putString(SP_KEY_BATTERY_STYLE, style.name()).commit(); + } + + private BatteryStyle getStyleFromSP() { + String styleStr = mSp.getString(SP_KEY_BATTERY_STYLE, null); + if (styleStr == null) return null; + try { + return BatteryStyle.valueOf(styleStr); + } catch (IllegalArgumentException e) { + LogUtils.e(TAG, "【getStyleFromSP】SP读取样式异常 = " + e.getMessage()); + return null; + } + } + + // ====================== 公共静态方法 读取SP存储的枚举值 ====================== + public static BatteryStyle getSavedBatteryStyle(Context context) { + SharedPreferences sp = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE); + String styleStr = sp.getString(SP_KEY_BATTERY_STYLE, null); + if (styleStr == null) { + LogUtils.w(TAG, "【getSavedBatteryStyle】SP无存储值,返回默认样式 ENERGY_STYLE"); + return BatteryStyle.ENERGY_STYLE; // ✅ 静态方法默认值同步修改为能量样式 + } + try { + BatteryStyle style = BatteryStyle.valueOf(styleStr); + LogUtils.d(TAG, "【getSavedBatteryStyle】SP读取成功 → " + style.name()); + return style; + } catch (IllegalArgumentException e) { + LogUtils.e(TAG, "【getSavedBatteryStyle】SP读取异常 = " + e.getMessage() + ",返回默认样式 ENERGY_STYLE"); + return BatteryStyle.ENERGY_STYLE; // ✅ 异常兜底值同步修改为能量样式 + } + } + + public static BatteryStyle getSavedBatteryStyle(Context context, BatteryStyle defaultStyle) { + SharedPreferences sp = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE); + String styleStr = sp.getString(SP_KEY_BATTERY_STYLE, null); + if (styleStr == null) { + LogUtils.w(TAG, "【getSavedBatteryStyle】SP无存储值,返回自定义默认样式 → " + defaultStyle.name()); + return defaultStyle; + } + try { + BatteryStyle style = BatteryStyle.valueOf(styleStr); + LogUtils.d(TAG, "【getSavedBatteryStyle】SP读取成功 → " + style.name()); + return style; + } catch (IllegalArgumentException e) { + LogUtils.e(TAG, "【getSavedBatteryStyle】SP读取异常 = " + e.getMessage() + ",返回自定义默认样式 → " + defaultStyle.name()); + return defaultStyle; + } + } + + // ====================== 对外暴露方法 ====================== + public void setSelectedStyle(BatteryStyle style) { + mCurrentStyle = style; + // ✅ 新增:圆点样式的手动选中赋值 + rbZebraStyle.setChecked(style == BatteryStyle.ZEBRA_STYLE); + rbEnergyStyle.setChecked(style == BatteryStyle.ENERGY_STYLE); + rbPointStyle.setChecked(style == BatteryStyle.POINT_STYLE); + saveStyle2SP(style); + LogUtils.d(TAG, "【setSelectedStyle】手动设置选中样式 → " + style.name() + ",已存入SP"); + } + + public BatteryStyle getCurrentStyle() { + return mCurrentStyle; + } + + public void setPreviewBatteryValue(int batteryValue) { + this.mBatteryValue = batteryValue; + mZebraDrawable.setBatteryValue(batteryValue); + mEnergyDrawable.setBatteryValue(batteryValue); + mPointDrawable.setBatteryValue(batteryValue); // ✅ 新增:圆点样式同步电量值 + } + + public void setPreviewBatteryColor(int color) { + this.mBatteryColor = color; + mZebraDrawable.updateBatteryColor(color); + mEnergyDrawable.updateBatteryColor(color); + mPointDrawable.updateBatteryColor(color); // ✅ 新增:圆点样式同步颜色值 + } + + public void setOnBatteryStyleSelectedListener(OnBatteryStyleSelectedListener listener) { + this.mStyleSelectedListener = listener; + } + + // ====================== 选中回调接口 ====================== + public interface OnBatteryStyleSelectedListener { + void onStyleSelected(BatteryStyle batteryStyle); + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/views/MainContentView.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/views/MainContentView.java new file mode 100644 index 0000000..e09e315 --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/views/MainContentView.java @@ -0,0 +1,902 @@ +package cc.winboll.studio.powerbell.views; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.Switch; +import android.widget.TextView; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import cc.winboll.studio.powerbell.App; +import cc.winboll.studio.powerbell.R; +import cc.winboll.studio.powerbell.models.BackgroundBean; +import cc.winboll.studio.powerbell.models.BatteryStyle; +import cc.winboll.studio.powerbell.models.ControlCenterServiceBean; +import cc.winboll.studio.powerbell.services.ControlCenterService; +import cc.winboll.studio.powerbell.utils.AppConfigUtils; + +/** + * 主页面核心视图封装类:统一管理视图绑定、数据更新、事件监听,解耦 Activity 逻辑 + * 适配:Java7 | API30 | 小米手机,优化性能与资源回收,杜绝内存泄漏,配置变更确认对话框 + * 新增:拖动进度条时实时预览 sbUsageReminder 与 sbChargeReminder 比值 + * 修复:updateBatteryDrawable() 电池样式切换后重绘失效问题 + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/12/17 13:14 + */ +public class MainContentView { + // ====================================== 静态常量区(唯一标识,变更类型分类) ====================================== + public static final String TAG = "MainContentView"; + // 变更类型常量(区分不同控件,精准处理逻辑) + private static final int CHANGE_TYPE_CHARGE_SWITCH = 1; + private static final int CHANGE_TYPE_USAGE_SWITCH = 2; + private static final int CHANGE_TYPE_SERVICE_SWITCH = 3; + private static final int CHANGE_TYPE_CHARGE_SEEKBAR = 4; + private static final int CHANGE_TYPE_USAGE_SEEKBAR = 5; + // 电量范围常量 + private static final int BATTERY_MIN = 0; + private static final int BATTERY_MAX = 100; + + // ====================================== 内部缓存类(解耦,避免冗余) ====================================== + /** + * 临时配置数据实体(缓存变更信息,取消时恢复) + */ + private static class TempConfigData { + int changeType; + boolean originalBooleanValue; + int originalIntValue; + boolean newBooleanValue; + int newIntValue; + + // 构造方法(开关类型) + TempConfigData(int changeType, boolean originalValue, boolean newValue) { + this.changeType = changeType; + this.originalBooleanValue = originalValue; + this.newBooleanValue = newValue; + } + + // 构造方法(进度条类型) + TempConfigData(int changeType, int originalValue, int newValue) { + this.changeType = changeType; + this.originalIntValue = originalValue; + this.newIntValue = newValue; + } + } + + // ====================================== 事件回调接口(解耦视图与业务,提升扩展性) ====================================== + public interface OnViewActionListener { + void onChargeReminderSwitchChanged(boolean isChecked); + void onUsageReminderSwitchChanged(boolean isChecked); + void onServiceSwitchChanged(boolean isChecked); + void onChargeReminderProgressChanged(int progress); + void onUsageReminderProgressChanged(int progress); + } + + // ====================================== 成员变量区(按功能分类,final优先,避免混乱) ====================================== + // 外部依赖实例(生命周期关联,优先声明) + private Context mContext; + private AppConfigUtils mAppConfigUtils; + private OnViewActionListener mActionListener; + + // 视图控件(按「布局→开关→文本→进度条→图标」功能归类,public控件标注用途) + // 基础布局控件 + public RelativeLayout mainLayout; + public MemoryCachedBackgroundView backgroundView; + private LinearLayout mllBackgroundView; + private volatile BatteryStyle mBatteryStyle = BatteryStyle.ENERGY_STYLE; + + // 容器布局控件 + public LinearLayout llLeftSeekBar; + public LinearLayout llRightSeekBar; + // 开关控件 + public CheckBox cbEnableChargeReminder; + public CheckBox cbEnableUsageReminder; + public Switch swEnableService; + // 文本显示控件 + public TextView tvTips; + public TextView tvChargeReminderValue; + public TextView tvUsageReminderValue; + public TextView tvCurrentBatteryValue; + // 进度条控件(使用自定义 VerticalSeekBar) + public VerticalSeekBar sbChargeReminder; + public VerticalSeekBar sbUsageReminder; + // 图标显示控件 + public ImageView ivCurrentBattery; + public ImageView ivChargeReminderBattery; + public ImageView ivUsageReminderBattery; + + // 进度缓存(用于实时计算比值,避免频繁调用 getProgress()) + private int mCurrentChargeProgress; + private int mCurrentUsageProgress; + + // 内部复用资源(避免重复创建,优化性能) + private BatteryDrawable mCurrentBatteryDrawable; + private BatteryDrawable mChargeReminderBatteryDrawable; + private BatteryDrawable mUsageReminderBatteryDrawable; + + // 配置变更确认对话框(单例复用,避免重复创建) + private AlertDialog mConfigConfirmDialog; + private AlertDialog.Builder mDialogBuilder; + // 临时存储变更数据(对话框确认前缓存,取消时恢复) + private TempConfigData mTempConfigData; + // 对话框状态锁(避免快速点击重复弹窗) + private boolean isDialogShowing = false; + + // ====================================== 构造方法(初始化入口,逻辑闭环) ====================================== + public MainContentView(Context context, View rootView, OnViewActionListener actionListener) { + LogUtils.d(TAG, "【MainContentView】构造器调用 | context=" + context + " | rootView=" + rootView + " | actionListener=" + actionListener); + // 初始化外部依赖 + this.mContext = context; + this.mActionListener = actionListener; + this.mAppConfigUtils = AppConfigUtils.getInstance(context.getApplicationContext()); + mBatteryStyle = BatteryStyleView.getSavedBatteryStyle(context); + + // 执行核心初始化流程(按顺序执行,避免依赖空指针) + bindViews(rootView); + initBatteryDrawables(); + initConfirmDialog(); + bindViewListeners(); + + LogUtils.d(TAG, "【MainContentView】初始化完成"); + } + + // ====================================== 私有初始化方法(封装内部逻辑,仅暴露入口) ====================================== + /** + * 绑定视图控件(显式强转适配 Java7,适配 API30 视图加载机制) + * @param rootView 根视图 + */ + private void bindViews(View rootView) { + LogUtils.d(TAG, "【bindViews】视图绑定开始 | rootView=" + rootView); + // 基础布局绑定 + mainLayout = (RelativeLayout) rootView.findViewById(R.id.activitymainRelativeLayout1); + mllBackgroundView = (LinearLayout) rootView.findViewById(R.id.ll_backgroundview); + + backgroundView = App.getInstance().getMemoryCachedBackgroundView(); + if (backgroundView == null) { + App.sBackgroundSourceUtils.loadSettings(); + BackgroundBean backgroundBean = App.sBackgroundSourceUtils.getCurrentBackgroundBean(); + backgroundView = App.getInstance().getMemoryCachedBackgroundView().getInstance(mContext, backgroundBean, true); + } + if (backgroundView.getParent() != null) { + ((ViewGroup) backgroundView.getParent()).removeView(backgroundView); + LogUtils.d(TAG, "【bindViews】移除背景视图旧父容器"); + } + mllBackgroundView.addView(backgroundView); + + // 容器布局绑定 + llLeftSeekBar = (LinearLayout) rootView.findViewById(R.id.fragmentmainviewLinearLayout1); + llRightSeekBar = (LinearLayout) rootView.findViewById(R.id.fragmentmainviewLinearLayout2); + // 开关控件绑定 + cbEnableChargeReminder = (CheckBox) rootView.findViewById(R.id.fragmentmainviewCheckBox1); + cbEnableUsageReminder = (CheckBox) rootView.findViewById(R.id.fragmentmainviewCheckBox2); + swEnableService = (Switch) rootView.findViewById(R.id.fragmentandroidviewSwitch1); + // 文本控件绑定 + tvTips = (TextView) rootView.findViewById(R.id.fragmentandroidviewTextView1); + tvChargeReminderValue = (TextView) rootView.findViewById(R.id.fragmentandroidviewTextView2); + tvUsageReminderValue = (TextView) rootView.findViewById(R.id.fragmentandroidviewTextView3); + tvCurrentBatteryValue = (TextView) rootView.findViewById(R.id.fragmentandroidviewTextView4); + // 进度条控件绑定(自定义 VerticalSeekBar) + sbChargeReminder = (VerticalSeekBar) rootView.findViewById(R.id.fragmentandroidviewVerticalSeekBar1); + sbUsageReminder = (VerticalSeekBar) rootView.findViewById(R.id.fragmentandroidviewVerticalSeekBar2); + // 图标控件绑定 + ivCurrentBattery = (ImageView) rootView.findViewById(R.id.fragmentandroidviewImageView1); + ivChargeReminderBattery = (ImageView) rootView.findViewById(R.id.fragmentandroidviewImageView3); + ivUsageReminderBattery = (ImageView) rootView.findViewById(R.id.fragmentandroidviewImageView2); + + // 初始化进度缓存(从配置读取初始值) + mCurrentChargeProgress = mAppConfigUtils.getChargeReminderValue(); + mCurrentUsageProgress = mAppConfigUtils.getUsageReminderValue(); + LogUtils.d(TAG, "【bindViews】进度缓存初始化 | charge=" + mCurrentChargeProgress + " | usage=" + mCurrentUsageProgress); + + // 关键视图绑定校验(仅保留核心控件错误日志,精简冗余) + if (mainLayout == null) LogUtils.e(TAG, "【bindViews】mainLayout 绑定失败"); + if (backgroundView == null) LogUtils.e(TAG, "【bindViews】backgroundView 绑定失败"); + LogUtils.d(TAG, "【bindViews】视图绑定完成"); + } + + public void reloadBackgroundView() { + if (backgroundView != null) { + App.sBackgroundSourceUtils.loadSettings(); + BackgroundBean backgroundBean = App.sBackgroundSourceUtils.getCurrentBackgroundBean(); + backgroundView.loadByBackgroundBean(backgroundBean, true); + } + } + + /** + * 初始化电池 Drawable(集成 BatteryDrawable,默认能量风格,适配小米机型渲染) + */ + private void initBatteryDrawables() { + LogUtils.d(TAG, "【initBatteryDrawables】电池Drawable初始化开始 | style="+mBatteryStyle.name()); + // 当前电量 Drawable(颜色从资源读取,适配 API30 主题) + int colorCurrent = getResourceColor(R.color.colorCurrent); + mCurrentBatteryDrawable = new BatteryDrawable(colorCurrent); + mCurrentBatteryDrawable.setDrawStyle(mBatteryStyle); + // 充电提醒 Drawable + int colorCharge = getResourceColor(R.color.colorCharge); + mChargeReminderBatteryDrawable = new BatteryDrawable(colorCharge); + mChargeReminderBatteryDrawable.setDrawStyle(mBatteryStyle); + // 耗电提醒 Drawable + int colorUsage = getResourceColor(R.color.colorUsege); + mUsageReminderBatteryDrawable = new BatteryDrawable(colorUsage); + mUsageReminderBatteryDrawable.setDrawStyle(mBatteryStyle); + LogUtils.d(TAG, "【initBatteryDrawables】电池Drawable初始化完成"); + } + + /** + * ✅ 核心修复:电池样式切换+强制重绘刷新 完整版 + * 修复点1:重新创建Drawable后,重新给ImageView赋值Drawable + * 修复点2:重置所有Drawable的电量值,保证样式切换后数值不变 + * 修复点3:调用ImageView.invalidate()强制触发重绘(API30必加) + * 修复点4:Drawable.invalidateSelf() 双保险刷新绘制内容 + * @param batteryStyle 切换后的电池样式 + */ + public void updateBatteryDrawable(BatteryStyle batteryStyle) { + if(batteryStyle == null || batteryStyle == mBatteryStyle){ + LogUtils.d(TAG, "【updateBatteryDrawable】样式无变化,跳过刷新"); + return; + } + // 1. 更新样式标记 + mBatteryStyle = batteryStyle; + // 2. 重新初始化Drawable并设置新样式 + initBatteryDrawables(); + // 3. 重置所有Drawable的电量值 → 保证样式切换后数值不变 + mCurrentBatteryDrawable.setBatteryValue(mAppConfigUtils.getCurrentBatteryValue()); + mChargeReminderBatteryDrawable.setBatteryValue(mCurrentChargeProgress); + mUsageReminderBatteryDrawable.setBatteryValue(mCurrentUsageProgress); + // 4. 重新给ImageView赋值Drawable → 核心修复:之前缺失这一步 + if(ivCurrentBattery != null) ivCurrentBattery.setImageDrawable(mCurrentBatteryDrawable); + if(ivChargeReminderBattery != null) ivChargeReminderBattery.setImageDrawable(mChargeReminderBatteryDrawable); + if(ivUsageReminderBattery != null) ivUsageReminderBattery.setImageDrawable(mUsageReminderBatteryDrawable); + // 5. Drawable自身刷新 → 双保险 + mCurrentBatteryDrawable.invalidateSelf(); + mChargeReminderBatteryDrawable.invalidateSelf(); + mUsageReminderBatteryDrawable.invalidateSelf(); + // 6. ✅ API30关键修复:ImageView强制重绘,解决绘制缓存不刷新问题 + if(ivCurrentBattery != null) ivCurrentBattery.invalidate(); + if(ivChargeReminderBattery != null) ivChargeReminderBattery.invalidate(); + if(ivUsageReminderBattery != null) ivUsageReminderBattery.invalidate(); + + LogUtils.d(TAG, "【updateBatteryDrawable】样式切换完成:"+mBatteryStyle.name() + " | 重绘触发成功"); + ToastUtils.show("电池样式已切换为:"+mBatteryStyle.name()); + } + + /** + * 初始化配置变更确认对话框(核心优化:保存 Builder 实例,解决消息不生效问题) + */ + private void initConfirmDialog() { + LogUtils.d(TAG, "【initConfirmDialog】对话框初始化开始"); + if (mContext == null) { + LogUtils.e(TAG, "【initConfirmDialog】Context 为空,初始化失败"); + return; + } + + // 1. 初始化 Builder(核心:后续通过 Builder 更新消息) + mDialogBuilder = new AlertDialog.Builder(mContext); + mDialogBuilder.setTitle("配置变更确认"); + mDialogBuilder.setMessage("是否确认修改当前配置?"); + + // 确定按钮:保存配置+回调+更新视图 + mDialogBuilder.setPositiveButton("确定", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + confirmConfigChange(); + dialog.dismiss(); + } + }); + + // 取消按钮:恢复原始配置(补充物理取消按钮,提升用户体验) + mDialogBuilder.setNegativeButton("取消", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + cancelConfigChange(); + dialog.dismiss(); + } + }); + + // 对话框外部点击监听:关闭对话框+恢复原始配置 + mDialogBuilder.setOnCancelListener(new DialogInterface.OnCancelListener() { + @Override + public void onCancel(DialogInterface dialog) { + cancelConfigChange(); + dialog.dismiss(); + } + }); + + // 2. 初始化对话框实例(设置可取消,支持外部点击关闭) + mConfigConfirmDialog = mDialogBuilder.create(); + mConfigConfirmDialog.setCancelable(true); + mConfigConfirmDialog.setCanceledOnTouchOutside(true); + LogUtils.d(TAG, "【initConfirmDialog】对话框初始化完成"); + } + + /** + * 绑定视图事件监听(Java7 显式实现接口,适配 API30 事件分发,修复进度条弹窗失效) + */ + private void bindViewListeners() { + LogUtils.d(TAG, "【bindViewListeners】事件监听绑定开始"); + // 依赖校验,避免空指针 + if (mAppConfigUtils == null || mActionListener == null || mDialogBuilder == null) { + LogUtils.e(TAG, "【bindViewListeners】依赖实例为空,跳过监听绑定"); + return; + } + + // 充电提醒进度条监听(使用 VerticalSeekBar 专属接口,确保弹窗100%触发) + if (sbChargeReminder != null) { + // 原有:触摸抬起/取消监听(用于配置确认) + sbChargeReminder.setOnVerticalSeekBarTouchListener(new VerticalSeekBar.OnVerticalSeekBarTouchListener() { + @Override + public void onTouchUp(VerticalSeekBar seekBar, int progress) { + int originalValue = mAppConfigUtils.getChargeReminderValue(); + // 进度无变化,不处理 + if (originalValue == progress) { + LogUtils.d(TAG, "【bindViewListeners】ChargeReminderSeekBar: 进度无变化,跳过"); + return; + } + // 缓存变更数据,显示确认对话框 + mTempConfigData = new TempConfigData(CHANGE_TYPE_CHARGE_SEEKBAR, originalValue, progress); + updateDialogMessageByChangeType(); + showConfigConfirmDialog(); + LogUtils.d(TAG, "【bindViewListeners】ChargeReminderSeekBar触摸抬起 | 原始值=" + originalValue + " | 新进度=" + progress); + } + + @Override + public void onTouchCancel(VerticalSeekBar seekBar, int progress) { + // 触摸取消,回滚视图进度(UI 与配置保持一致) + int originalValue = mAppConfigUtils.getChargeReminderValue(); + if (tvChargeReminderValue != null && mChargeReminderBatteryDrawable != null && ivChargeReminderBattery != null) { + mChargeReminderBatteryDrawable.setBatteryValue(originalValue); + ivChargeReminderBattery.setImageDrawable(mChargeReminderBatteryDrawable); + tvChargeReminderValue.setText(originalValue + "%"); + } + seekBar.setProgress(originalValue); + // 恢复进度缓存 + mCurrentChargeProgress = originalValue; + LogUtils.d(TAG, "【bindViewListeners】ChargeReminderSeekBar触摸取消 | 进度回滚至=" + originalValue); + } + }); + + // 新增:实时进度变化监听(用于比值预览) + sbChargeReminder.setOnVerticalSeekBarChangeListener(new VerticalSeekBar.OnVerticalSeekBarChangeListener() { + @Override + public void onProgressChanged(VerticalSeekBar seekBar, int progress, boolean fromUser) { + if (fromUser) { + mCurrentChargeProgress = progress; + // 同步更新进度文本和电池图标(保持UI一致性) + if (tvChargeReminderValue != null && mChargeReminderBatteryDrawable != null && ivChargeReminderBattery != null) { + mChargeReminderBatteryDrawable.setBatteryValue(progress); + ivChargeReminderBattery.setImageDrawable(mChargeReminderBatteryDrawable); + tvChargeReminderValue.setText(progress + "%"); + } + LogUtils.d(TAG, "【bindViewListeners】ChargeReminderSeekBar实时更新 | 进度=" + progress); + } + } + + @Override + public void onStartTrackingTouch(VerticalSeekBar seekBar) {} + + @Override + public void onStopTrackingTouch(VerticalSeekBar seekBar) {} + }); + LogUtils.d(TAG, "【bindViewListeners】充电提醒进度条专属监听绑定完成"); + } + + // 充电提醒开关监听 + if (cbEnableChargeReminder != null) { + cbEnableChargeReminder.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + boolean originalValue = mAppConfigUtils.isChargeReminderEnabled(); + boolean newValue = cbEnableChargeReminder.isChecked(); + // 状态无变化,不处理 + if (originalValue == newValue) return; + // 缓存变更数据,显示确认对话框 + mTempConfigData = new TempConfigData(CHANGE_TYPE_CHARGE_SWITCH, originalValue, newValue); + updateDialogMessageByChangeType(); + showConfigConfirmDialog(); + LogUtils.d(TAG, "【bindViewListeners】cbEnableChargeReminder点击 | 原始值=" + originalValue + " | 变更后=" + newValue); + } + }); + LogUtils.d(TAG, "【bindViewListeners】充电提醒开关监听绑定完成"); + } + + // 耗电提醒进度条监听(使用 VerticalSeekBar 专属接口,确保弹窗100%触发) + if (sbUsageReminder != null) { + // 原有:触摸抬起/取消监听(用于配置确认) + sbUsageReminder.setOnVerticalSeekBarTouchListener(new VerticalSeekBar.OnVerticalSeekBarTouchListener() { + @Override + public void onTouchUp(VerticalSeekBar seekBar, int progress) { + int originalValue = mAppConfigUtils.getUsageReminderValue(); + // 进度无变化,不处理 + if (originalValue == progress) { + LogUtils.d(TAG, "【bindViewListeners】UsageReminderSeekBar: 进度无变化,跳过"); + return; + } + // 缓存变更数据,显示确认对话框 + mTempConfigData = new TempConfigData(CHANGE_TYPE_USAGE_SEEKBAR, originalValue, progress); + updateDialogMessageByChangeType(); + showConfigConfirmDialog(); + LogUtils.d(TAG, "【bindViewListeners】UsageReminderSeekBar触摸抬起 | 原始值=" + originalValue + " | 新进度=" + progress); + } + + @Override + public void onTouchCancel(VerticalSeekBar seekBar, int progress) { + // 触摸取消,回滚视图进度(UI 与配置保持一致) + int originalValue = mAppConfigUtils.getUsageReminderValue(); + if (tvUsageReminderValue != null && mUsageReminderBatteryDrawable != null && ivUsageReminderBattery != null) { + mUsageReminderBatteryDrawable.setBatteryValue(originalValue); + ivUsageReminderBattery.setImageDrawable(mUsageReminderBatteryDrawable); + tvUsageReminderValue.setText(originalValue + "%"); + } + seekBar.setProgress(originalValue); + // 恢复进度缓存 + mCurrentUsageProgress = originalValue; + LogUtils.d(TAG, "【bindViewListeners】UsageReminderSeekBar触摸取消 | 进度回滚至=" + originalValue); + } + }); + + // 新增:实时进度变化监听(用于比值预览) + sbUsageReminder.setOnVerticalSeekBarChangeListener(new VerticalSeekBar.OnVerticalSeekBarChangeListener() { + @Override + public void onProgressChanged(VerticalSeekBar seekBar, int progress, boolean fromUser) { + if (fromUser) { + mCurrentUsageProgress = progress; + // 同步更新进度文本和电池图标(保持UI一致性) + if (tvUsageReminderValue != null && mUsageReminderBatteryDrawable != null && ivUsageReminderBattery != null) { + mUsageReminderBatteryDrawable.setBatteryValue(progress); + ivUsageReminderBattery.setImageDrawable(mUsageReminderBatteryDrawable); + tvUsageReminderValue.setText(progress + "%"); + } + LogUtils.d(TAG, "【bindViewListeners】UsageReminderSeekBar实时更新 | 进度=" + progress); + } + } + + @Override + public void onStartTrackingTouch(VerticalSeekBar seekBar) {} + + @Override + public void onStopTrackingTouch(VerticalSeekBar seekBar) {} + }); + LogUtils.d(TAG, "【bindViewListeners】耗电提醒进度条专属监听绑定完成"); + } + + // 耗电提醒开关监听 + if (cbEnableUsageReminder != null) { + cbEnableUsageReminder.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + boolean originalValue = mAppConfigUtils.isUsageReminderEnabled(); + boolean newValue = cbEnableUsageReminder.isChecked(); + // 状态无变化,不处理 + if (originalValue == newValue) return; + // 缓存变更数据,显示确认对话框 + mTempConfigData = new TempConfigData(CHANGE_TYPE_USAGE_SWITCH, originalValue, newValue); + updateDialogMessageByChangeType(); + showConfigConfirmDialog(); + LogUtils.d(TAG, "【bindViewListeners】cbEnableUsageReminder点击 | 原始值=" + originalValue + " | 变更后=" + newValue); + } + }); + LogUtils.d(TAG, "【bindViewListeners】耗电提醒开关监听绑定完成"); + } + + // 服务总开关监听(核心优化:逻辑与其他控件完全对齐) + if (swEnableService != null) { + swEnableService.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + // 从服务控制Bean读取原始状态,确保与实际一致 + boolean originalValue = getServiceEnableState(); + boolean newValue = ((Switch) v).isChecked(); + // 状态无变化,不处理 + if (originalValue == newValue) return; + // 缓存变更数据 + mTempConfigData = new TempConfigData(CHANGE_TYPE_SERVICE_SWITCH, originalValue, newValue); + // 更新差异化提示语 + updateDialogMessageByChangeType(); + // 显示确认对话框 + showConfigConfirmDialog(); + LogUtils.d(TAG, "【bindViewListeners】swEnableService点击 | 原始值=" + originalValue + " | 变更后=" + newValue); + } + }); + LogUtils.d(TAG, "【bindViewListeners】服务总开关监听绑定完成"); + } + + LogUtils.d(TAG, "【bindViewListeners】所有事件监听绑定完成"); + } + + // ====================================== 对外暴露核心方法(业务入口,精简参数,明确职责) ====================================== + /** + * 更新所有视图数据(从配置读取数据,统一刷新 UI,适配 API30 视图更新规范) + * @param frameDrawable 进度条背景 Drawable(外部传入,适配主题切换) + */ + public void updateViewData(Drawable frameDrawable) { + LogUtils.d(TAG, "【updateViewData】视图数据更新开始 | frameDrawable=" + frameDrawable); + if (mAppConfigUtils == null) { + LogUtils.e(TAG, "【updateViewData】AppConfigUtils 为空,跳过更新"); + return; + } + + // 一次读取所有配置参数,减少工具类调用,提升性能 + int chargeVal = mAppConfigUtils.getChargeReminderValue(); + int usageVal = mAppConfigUtils.getUsageReminderValue(); + int currentVal = mAppConfigUtils.getCurrentBatteryValue(); + boolean chargeEnable = mAppConfigUtils.isChargeReminderEnabled(); + boolean usageEnable = mAppConfigUtils.isUsageReminderEnabled(); + // 从服务控制Bean读取状态,确保UI与实际一致 + boolean serviceEnable = getServiceEnableState(); + // 更新进度缓存 + mCurrentChargeProgress = chargeVal; + mCurrentUsageProgress = usageVal; + LogUtils.d(TAG, "【updateViewData】配置数据读取完成 | charge=" + chargeVal + " | usage=" + usageVal + " | current=" + currentVal + " | serviceEnable=" + serviceEnable); + + // 进度条背景更新 + if (frameDrawable != null) { + if (llLeftSeekBar != null) llLeftSeekBar.setBackground(frameDrawable); + if (llRightSeekBar != null) llRightSeekBar.setBackground(frameDrawable); + LogUtils.d(TAG, "【updateViewData】进度条背景更新完成"); + } + + // 当前电量更新(联动 BatteryDrawable,实时刷新图标) + if (ivCurrentBattery != null && mCurrentBatteryDrawable != null) { + mCurrentBatteryDrawable.setBatteryValue(currentVal); + ivCurrentBattery.setImageDrawable(mCurrentBatteryDrawable); + } + if (tvCurrentBatteryValue != null) { + tvCurrentBatteryValue.setTextColor(getResourceColor(R.color.colorCurrent)); + tvCurrentBatteryValue.setText(currentVal + "%"); + } + LogUtils.d(TAG, "【updateViewData】当前电量更新完成"); + + // 充电提醒视图更新 + if (ivChargeReminderBattery != null && mChargeReminderBatteryDrawable != null) { + mChargeReminderBatteryDrawable.setBatteryValue(chargeVal); + ivChargeReminderBattery.setImageDrawable(mChargeReminderBatteryDrawable); + } + if (tvChargeReminderValue != null) { + tvChargeReminderValue.setTextColor(getResourceColor(R.color.colorCharge)); + tvChargeReminderValue.setText(chargeVal + "%"); + } + if (sbChargeReminder != null) sbChargeReminder.setProgress(chargeVal); + if (cbEnableChargeReminder != null) cbEnableChargeReminder.setChecked(chargeEnable); + LogUtils.d(TAG, "【updateViewData】充电提醒视图更新完成"); + + // 耗电提醒视图更新 + if (ivUsageReminderBattery != null && mUsageReminderBatteryDrawable != null) { + mUsageReminderBatteryDrawable.setBatteryValue(usageVal); + ivUsageReminderBattery.setImageDrawable(mUsageReminderBatteryDrawable); + } + if (tvUsageReminderValue != null) { + tvUsageReminderValue.setTextColor(getResourceColor(R.color.colorUsege)); + tvUsageReminderValue.setText(usageVal + "%"); + } + if (sbUsageReminder != null) sbUsageReminder.setProgress(usageVal); + if (cbEnableUsageReminder != null) cbEnableUsageReminder.setChecked(usageEnable); + LogUtils.d(TAG, "【updateViewData】耗电提醒视图更新完成"); + + // 服务开关+提示文本更新(确保状态准确) + if (swEnableService != null) { + swEnableService.setChecked(serviceEnable); + swEnableService.setText(mContext.getString(R.string.txt_aboveswitch)); + } + if (tvTips != null) tvTips.setText(mContext.getString(R.string.txt_aboveswitchtips)); + LogUtils.d(TAG, "【updateViewData】服务开关与提示文本更新完成"); + + LogUtils.d(TAG, "【updateViewData】所有视图数据更新完成"); + } + + /** + * 实时更新当前电量(单独抽离,适配电池实时监控场景,优化 API30 UI 响应速度) + * @param value 电量值(自动校准 0-100,避免异常值) + */ + public void updateCurrentBattery(int value) { + LogUtils.d(TAG, "【updateCurrentBattery】当前电量更新开始 | 原始值=" + value); + // 核心依赖校验 + if (tvCurrentBatteryValue == null || mCurrentBatteryDrawable == null || ivCurrentBattery == null) { + LogUtils.e(TAG, "【updateCurrentBattery】视图/Drawable 为空,跳过更新"); + return; + } + + // 校准电量范围(强制 0-100,防止 API30 视图显示异常) + int validValue = Math.max(BATTERY_MIN, Math.min(value, BATTERY_MAX)); + // 联动 BatteryDrawable 更新图标,同步文本显示 + mCurrentBatteryDrawable.setBatteryValue(validValue); + ivCurrentBattery.setImageDrawable(mCurrentBatteryDrawable); + tvCurrentBatteryValue.setText(validValue + "%"); + + LogUtils.d(TAG, "【updateCurrentBattery】更新完成 | 校准后值=" + validValue); + } + + /** + * 释放资源(主动回收,适配 API30 资源管控机制,优化小米手机内存占用) + */ + public void releaseResources() { + LogUtils.d(TAG, "【releaseResources】资源释放开始"); + // 释放对话框资源(安全销毁,避免内存泄漏) + if (mConfigConfirmDialog != null) { + if (mConfigConfirmDialog.isShowing()) { + mConfigConfirmDialog.dismiss(); + } + mConfigConfirmDialog.setOnDismissListener(null); + mConfigConfirmDialog.setOnCancelListener(null); + mConfigConfirmDialog = null; + } + // 释放 Builder + mDialogBuilder = null; + // 释放临时数据 + mTempConfigData = null; + + // 释放 BatteryDrawable 资源(重点回收绘制资源,避免 OOM) + mCurrentBatteryDrawable = null; + mChargeReminderBatteryDrawable = null; + mUsageReminderBatteryDrawable = null; + + // 置空视图实例(断开视图引用,辅助 GC 回收) + mainLayout = null; + backgroundView = null; + mllBackgroundView = null; + llLeftSeekBar = null; + llRightSeekBar = null; + cbEnableChargeReminder = null; + cbEnableUsageReminder = null; + swEnableService = null; + tvTips = null; + tvChargeReminderValue = null; + tvUsageReminderValue = null; + tvCurrentBatteryValue = null; + sbChargeReminder = null; + sbUsageReminder = null; + ivCurrentBattery = null; + ivChargeReminderBattery = null; + ivUsageReminderBattery = null; + + // 置空外部依赖(断开生命周期关联,杜绝内存泄漏) + mContext = null; + mAppConfigUtils = null; + mActionListener = null; + + LogUtils.d(TAG, "【releaseResources】所有资源释放完成"); + } + + /** + * 设置服务开关启用状态(外部调用,同步 UI 与服务状态,适配 Activity 视图刷新) + * @param enabled 服务启用状态 + */ + public void setServiceSwitchChecked(boolean enabled) { + LogUtils.d(TAG, "【setServiceSwitchChecked】服务开关状态设置 | enabled=" + enabled); + if (swEnableService != null) { + swEnableService.setChecked(enabled); + } + } + + /** + * 设置服务开关点击状态(外部调用,避免更新 UI 时触发重复回调) + * @param enabled 是否允许点击 + */ + public void setServiceSwitchEnabled(boolean enabled) { + LogUtils.d(TAG, "【setServiceSwitchEnabled】服务开关点击状态设置 | enabled=" + enabled); + if (swEnableService != null) { + swEnableService.setEnabled(enabled); + } + } + + // ====================================== 内部核心逻辑方法(对话框相关,封装确认/取消逻辑) ====================================== + /** + * 显示配置变更确认对话框(确保 Activity 处于前台,避免异常,防止重复弹窗) + */ + private void showConfigConfirmDialog() { + LogUtils.d(TAG, "【showConfigConfirmDialog】对话框显示开始 | isDialogShowing=" + isDialogShowing); + // 对话框状态锁:正在显示则跳过,避免重复触发 + if (isDialogShowing) { + LogUtils.d(TAG, "【showConfigConfirmDialog】对话框已显示,跳过重复调用"); + return; + } + // 基础校验:对话框/上下文/Builder 为空 + if (mDialogBuilder == null || mContext == null) { + LogUtils.e(TAG, "【showConfigConfirmDialog】对话框Builder/上下文异常,无法显示"); + if (mTempConfigData != null) cancelConfigChange(); + return; + } + // Activity 状态校验:避免销毁后弹窗崩溃(适配 API30) + Activity activity = (Activity) mContext; + if (activity.isFinishing() || activity.isDestroyed()) { + LogUtils.e(TAG, "【showConfigConfirmDialog】Activity 已销毁,无法显示对话框"); + if (mTempConfigData != null) cancelConfigChange(); + return; + } + // 重新构建对话框(核心:确保最新消息生效) + mConfigConfirmDialog = mDialogBuilder.create(); + // 显示对话框,设置状态锁+关闭监听 + mConfigConfirmDialog.show(); + isDialogShowing = true; + // 对话框关闭时解锁 + mConfigConfirmDialog.setOnDismissListener(new DialogInterface.OnDismissListener() { + @Override + public void onDismiss(DialogInterface dialog) { + isDialogShowing = false; + mConfigConfirmDialog.setOnDismissListener(null); + } + }); + LogUtils.d(TAG, "【showConfigConfirmDialog】确认对话框显示成功"); + } + + /** + * 确认配置变更(保存数据+回调监听+更新视图) + */ + private void confirmConfigChange() { + LogUtils.d(TAG, "【confirmConfigChange】配置确认开始 | mTempConfigData=" + mTempConfigData); + if (mTempConfigData == null || mAppConfigUtils == null || mActionListener == null) { + LogUtils.e(TAG, "【confirmConfigChange】依赖数据为空,确认失败"); + return; + } + + switch (mTempConfigData.changeType) { + // 充电提醒开关 + case CHANGE_TYPE_CHARGE_SWITCH: + mAppConfigUtils.setChargeReminderEnabled(mTempConfigData.newBooleanValue); + mActionListener.onChargeReminderSwitchChanged(mTempConfigData.newBooleanValue); + LogUtils.d(TAG, "【confirmConfigChange】充电提醒开关确认 | 值=" + mTempConfigData.newBooleanValue); + break; + // 耗电提醒开关 + case CHANGE_TYPE_USAGE_SWITCH: + mAppConfigUtils.setUsageReminderEnabled(mTempConfigData.newBooleanValue); + mActionListener.onUsageReminderSwitchChanged(mTempConfigData.newBooleanValue); + LogUtils.d(TAG, "【confirmConfigChange】耗电提醒开关确认 | 值=" + mTempConfigData.newBooleanValue); + break; + // 服务总开关(核心:持久化配置+触发 Activity 回调) + case CHANGE_TYPE_SERVICE_SWITCH: + // 1. 设置服务启停 + if (mTempConfigData.newBooleanValue) { + ControlCenterService.startControlCenterService(mContext); + } else { + ControlCenterService.stopControlCenterService(mContext); + } + // 2. 强制触发 Activity 回调,执行服务启停逻辑 + mActionListener.onServiceSwitchChanged(mTempConfigData.newBooleanValue); + LogUtils.d(TAG, "【confirmConfigChange】服务开关确认 | 值=" + mTempConfigData.newBooleanValue + ",已持久化配置"); + break; + // 充电提醒进度条 + case CHANGE_TYPE_CHARGE_SEEKBAR: + mAppConfigUtils.setChargeReminderValue(mTempConfigData.newIntValue); + mActionListener.onChargeReminderProgressChanged(mTempConfigData.newIntValue); + LogUtils.d(TAG, "【confirmConfigChange】充电提醒进度确认 | 值=" + mTempConfigData.newIntValue); + break; + // 耗电提醒进度条 + case CHANGE_TYPE_USAGE_SEEKBAR: + mAppConfigUtils.setUsageReminderValue(mTempConfigData.newIntValue); + mActionListener.onUsageReminderProgressChanged(mTempConfigData.newIntValue); + LogUtils.d(TAG, "【confirmConfigChange】耗电提醒进度确认 | 值=" + mTempConfigData.newIntValue); + break; + default: + LogUtils.w(TAG, "【confirmConfigChange】未知变更类型,跳过"); + break; + } + + // 确认完成,清空临时数据 + mTempConfigData = null; + LogUtils.d(TAG, "【confirmConfigChange】配置确认完成"); + } + + /** + * 取消配置变更(恢复原始值+刷新视图,确保 UI 与配置一致) + */ + private void cancelConfigChange() { + LogUtils.d(TAG, "【cancelConfigChange】配置取消开始 | mTempConfigData=" + mTempConfigData); + if (mTempConfigData == null || mAppConfigUtils == null) { + LogUtils.e(TAG, "【cancelConfigChange】依赖数据为空,取消失败"); + return; + } + + switch (mTempConfigData.changeType) { + case CHANGE_TYPE_CHARGE_SWITCH: + if (cbEnableChargeReminder != null) { + cbEnableChargeReminder.setChecked(mTempConfigData.originalBooleanValue); + } + LogUtils.d(TAG, "【cancelConfigChange】充电提醒开关取消 | 恢复值=" + mTempConfigData.originalBooleanValue); + break; + case CHANGE_TYPE_USAGE_SWITCH: + if (cbEnableUsageReminder != null) { + cbEnableUsageReminder.setChecked(mTempConfigData.originalBooleanValue); + } + LogUtils.d(TAG, "【cancelConfigChange】耗电提醒开关取消 | 恢复值=" + mTempConfigData.originalBooleanValue); + break; + case CHANGE_TYPE_SERVICE_SWITCH: + if (swEnableService != null) { + swEnableService.setChecked(mTempConfigData.originalBooleanValue); + } + LogUtils.d(TAG, "【cancelConfigChange】服务开关取消 | 恢复值=" + mTempConfigData.originalBooleanValue); + break; + case CHANGE_TYPE_CHARGE_SEEKBAR: + if (sbChargeReminder != null) { + sbChargeReminder.setProgress(mTempConfigData.originalIntValue); + } + if (tvChargeReminderValue != null && mChargeReminderBatteryDrawable != null && ivChargeReminderBattery != null) { + mChargeReminderBatteryDrawable.setBatteryValue(mTempConfigData.originalIntValue); + ivChargeReminderBattery.setImageDrawable(mChargeReminderBatteryDrawable); + tvChargeReminderValue.setText(mTempConfigData.originalIntValue + "%"); + } + LogUtils.d(TAG, "【cancelConfigChange】充电提醒进度取消 | 恢复值=" + mTempConfigData.originalIntValue); + break; + case CHANGE_TYPE_USAGE_SEEKBAR: + if (sbUsageReminder != null) { + sbUsageReminder.setProgress(mTempConfigData.originalIntValue); + } + if (tvUsageReminderValue != null && mUsageReminderBatteryDrawable != null && ivUsageReminderBattery != null) { + mUsageReminderBatteryDrawable.setBatteryValue(mTempConfigData.originalIntValue); + ivUsageReminderBattery.setImageDrawable(mUsageReminderBatteryDrawable); + tvUsageReminderValue.setText(mTempConfigData.originalIntValue + "%"); + } + LogUtils.d(TAG, "【cancelConfigChange】耗电提醒进度取消 | 恢复值=" + mTempConfigData.originalIntValue); + break; + default: + LogUtils.w(TAG, "【cancelConfigChange】未知变更类型,跳过"); + break; + } + + // 取消完成,清空临时数据 + mTempConfigData = null; + LogUtils.d(TAG, "【cancelConfigChange】配置取消完成"); + } + + /** + * 根据变更类型更新对话框提示语(核心优化:通过 Builder 更新,确保生效) + */ + private void updateDialogMessageByChangeType() { + LogUtils.d(TAG, "【updateDialogMessageByChangeType】对话框消息更新开始 | mTempConfigData=" + mTempConfigData); + if (mDialogBuilder == null || mTempConfigData == null) return; + String message; + if (mTempConfigData.changeType == CHANGE_TYPE_SERVICE_SWITCH) { + // 服务开关差异化提示语 + message = mTempConfigData.newBooleanValue ? + "启用服务后,将后台持续监控电池状态,是否确认?" : + "禁用服务后,电池监控功能将停止,是否确认?"; + } else { + // 普通配置默认提示语 + message = "是否确认修改当前配置?"; + } + // 通过 Builder 设置消息,确保弹窗显示最新内容 + mDialogBuilder.setMessage(message); + LogUtils.d(TAG, "【updateDialogMessageByChangeType】对话框消息更新完成 | message=" + message); + } + + // ====================================== 内部工具方法(封装重复逻辑,提升复用性) ====================================== + /** + * 获取资源颜色(适配 API30 主题颜色读取机制,兼容低版本,优化小米机型颜色显示,防御空指针) + * @param colorResId 颜色资源 ID + * @return 校准后的颜色值 + */ + private int getResourceColor(int colorResId) { + LogUtils.d(TAG, "【getResourceColor】资源颜色获取 | colorResId=" + colorResId); + // 空指针防御:Context 为空返回默认黑色 + if (mContext == null) { + LogUtils.e(TAG, "【getResourceColor】Context 为空,返回默认黑色"); + return 0xFF000000; + } + // 适配 API30 主题颜色读取 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + return mContext.getResources().getColor(colorResId, mContext.getTheme()); + } else { + return mContext.getResources().getColor(colorResId); + } + } + + /** + * 获取服务启用状态(统一从服务控制Bean读取,确保全链路状态一致) + * @return 服务启用状态(true=启用,false=禁用) + */ + private boolean getServiceEnableState() { + LogUtils.d(TAG, "【getServiceEnableState】服务状态获取开始"); + ControlCenterServiceBean serviceBean = ControlCenterServiceBean.loadBean(mContext, ControlCenterServiceBean.class); + // 本地无配置时,默认禁用服务(与服务初始化逻辑对齐) + boolean state = serviceBean != null && serviceBean.isEnableService(); + LogUtils.d(TAG, "【getServiceEnableState】服务启用状态获取完成 | state=" + state); + return state; + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/views/MemoryCachedBackgroundView.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/views/MemoryCachedBackgroundView.java new file mode 100644 index 0000000..5027b2c --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/views/MemoryCachedBackgroundView.java @@ -0,0 +1,221 @@ +package cc.winboll.studio.powerbell.views; + +import android.content.Context; +import android.content.SharedPreferences; +import android.text.TextUtils; +import android.util.AttributeSet; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.powerbell.models.BackgroundBean; +import cc.winboll.studio.powerbell.utils.BackgroundSourceUtils; +import cc.winboll.studio.powerbell.App; +import cc.winboll.studio.powerbell.utils.AppConfigUtils; + +/** + * 单实例缓存版背景视图控件(基于Java7)- 强制缓存版 + * 核心:通过静态属性保存当前缓存路径和实例,支持强制重载图片 + * 新增:SP持久化最后加载路径、获取最后加载实例功能 + * 强制缓存策略:无论内存是否紧张,不自动清理任何缓存实例和路径记录 + * @Author 豆包&ZhanGSKen + */ +public class MemoryCachedBackgroundView extends BackgroundView { + // ====================================== 静态常量区(TAG + SP相关常量) ====================================== + public static final String TAG = "MemoryCachedBackgroundView"; + // SP相关常量(持久化最后加载路径) + private static final String SP_NAME = "MemoryCachedBackgroundView_SP"; + private static final String KEY_LAST_LOAD_IMAGE_PATH = "last_load_image_path"; + + // ====================================== 静态属性区(强制缓存核心:保存实例、路径、实例计数) ====================================== + // 静态属性:保存当前缓存的路径和实例(强制保持,不自动销毁) + private static String sCachedImagePath; + private static MemoryCachedBackgroundView sCachedView; + // 新增:记录所有创建过的实例数量(用于强制缓存监控) + private static int sInstanceCount = 0; + + // ====================================== 构造器(继承并兼容父类,私有构造防止外部实例化) ====================================== + private MemoryCachedBackgroundView(Context context) { + super(context); + sInstanceCount++; + LogUtils.d(TAG, String.format("构造器1启动 | 创建实例,当前实例总数=%d", sInstanceCount)); + } + + private MemoryCachedBackgroundView(Context context, AttributeSet attrs) { + super(context, attrs); + sInstanceCount++; + LogUtils.d(TAG, String.format("构造器2启动 | 创建实例,当前实例总数=%d", sInstanceCount)); + } + + private MemoryCachedBackgroundView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + sInstanceCount++; + LogUtils.d(TAG, String.format("构造器3启动 | 创建实例,当前实例总数=%d", sInstanceCount)); + } + + // ====================================== 核心静态方法:获取/创建缓存实例(强制缓存版) ====================================== + /** + * 从缓存获取或创建MemoryCachedBackgroundView实例(强制保持旧实例) + * @param context 上下文 + * @param imagePath 图片绝对路径(作为缓存标识) + * @param isReload 是否强制重新加载图片(路径匹配时仍刷新) + * @return 缓存/新创建的MemoryCachedBackgroundView实例 + */ + public static MemoryCachedBackgroundView getInstance(Context context, BackgroundBean bean, boolean isReload) { + LogUtils.d(TAG, String.format("getInstance 调用 | BackgroundBean=%s | 是否重载=%b", bean.toString(), isReload)); + //App.notifyMessage(TAG, String.format("getInstance 调用 | BackgroundBean=%s | 是否重载=%b", bean.toString(), isReload)); + sCachedView = new MemoryCachedBackgroundView(context); + sCachedView.loadByBackgroundBean(bean, isReload); + saveLastLoadImagePath(context, getBackgroundBeanImagePath(bean)); + LogUtils.d(TAG, String.format("getInstance: 已更新当前缓存实例,旧实例路径=%s(强制保持)", getBackgroundBeanImagePath(bean))); + //App.notifyMessage(TAG, String.format("getInstance: 已更新当前缓存实例,旧实例路径=%s(强制保持)", getBackgroundBeanImagePath(bean))); + return sCachedView; + } + + static String getBackgroundBeanImagePath(BackgroundBean bean) { + if (bean.isUseBackgroundFile()) { + if (bean.isUseBackgroundScaledCompressFile()) { + return bean.getBackgroundScaledCompressFilePath(); + } + return bean.getBackgroundFilePath(); + } + return ""; + } + + // ====================================== 新增功能:获取最后加载的实例(强制缓存版) ====================================== + /** + * 获取最后一次loadImage的路径对应的实例(强制保持所有实例) + * 无实例则创建并加载图片,同时更新静态缓存 + * @param context 上下文 + * @return 最后加载路径对应的实例 + */ + public static MemoryCachedBackgroundView getLastInstance(Context context) { + LogUtils.d(TAG, "getLastInstance 调用"); + //App.notifyMessage(TAG, "getLastInstance 调用"); + // 1. 从SP获取最后加载的路径(强制保持,不自动删除) + sCachedImagePath = getLastLoadImagePath(context); + String lastPath = getBackgroundBeanImagePath(App.sBackgroundSourceUtils.getCurrentBackgroundBean()); + //App.notifyMessage(TAG, String.format("sCachedImagePath : %s", sCachedImagePath)); + //App.notifyMessage(TAG, String.format("lastPath : %s", lastPath)); + if (lastPath.equals(sCachedImagePath) && sCachedView != null) { + LogUtils.d(TAG, String.format("getLastInstance: 使用最后路径缓存实例 | 路径=%s", lastPath)); + //App.notifyMessage(TAG, String.format("getLastInstance: 使用最后路径缓存实例 | 路径=%s", lastPath)); + return sCachedView; + } + //App.notifyMessage(TAG, "getLastInstance 返回 null"); + return null; + } + + // ====================================== 工具方法:SP持久化最后加载路径(强制保持版) ====================================== + /** + * 保存最后一次loadImage的路径到SP(强制保持,不自动删除) + * @param context 上下文 + * @param imagePath 图片路径 + */ + private static void saveLastLoadImagePath(Context context, String imagePath) { + if (TextUtils.isEmpty(imagePath) || context == null) { + LogUtils.w(TAG, "saveLastLoadImagePath: 路径或上下文为空,跳过保存"); + return; + } + SharedPreferences sp = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE); + sp.edit().putString(KEY_LAST_LOAD_IMAGE_PATH, imagePath).apply(); + LogUtils.d(TAG, String.format("saveLastLoadImagePath: 已保存最后路径(强制保持) | 路径=%s", imagePath)); + } + + /** + * 从SP获取最后一次loadImage的路径(强制保持,不自动删除) + * @param context 上下文 + * @return 最后加载的图片路径,空则返回null + */ + public static String getLastLoadImagePath(Context context) { + if (context == null) { + LogUtils.e(TAG, "getLastLoadImagePath: 上下文为空,返回null"); + return null; + } + SharedPreferences sp = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE); + String lastPath = sp.getString(KEY_LAST_LOAD_IMAGE_PATH, null); + LogUtils.d(TAG, String.format("getLastLoadImagePath: 获取最后路径(强制保持) | 路径=%s", lastPath)); + return lastPath; + } + + // ====================================== 工具方法:缓存管理(强制缓存版 - 仅日志,不清理) ====================================== + /** + * 清除当前缓存实例和路径(强制缓存策略:仅日志,不实际清理) + */ + public static void clearCache() { + LogUtils.w(TAG, String.format("clearCache 调用(强制缓存策略:不实际清理缓存) | 当前缓存路径=%s", sCachedImagePath)); + LogUtils.d(TAG, "clearCache: 强制缓存策略生效,未清除任何实例和路径"); + } + + /** + * 清除指定路径的缓存(强制缓存策略:仅日志,不实际清理) + * @param imagePath 图片路径 + */ + public static void removeCache(String imagePath) { + LogUtils.w(TAG, String.format("removeCache 调用(强制缓存策略:不实际清理缓存) | 图片路径=%s", imagePath)); + if (TextUtils.isEmpty(imagePath)) { + LogUtils.e(TAG, "removeCache: 图片路径为空,清除失败"); + return; + } + LogUtils.d(TAG, "removeCache: 强制缓存策略生效,未清除任何实例和路径"); + } + + /** + * 清除所有缓存(强制缓存策略:仅日志,不实际清理) + */ + public static void clearAllCache() { + LogUtils.w(TAG, "clearAllCache 调用(强制缓存策略:不实际清理缓存)"); + LogUtils.d(TAG, "clearAllCache: 强制缓存策略生效,未清除任何实例、路径和SP记录"); + } + + /** + * 判断是否存在缓存实例 + * @return 存在返回true,否则返回false + */ + public static boolean hasCache() { + boolean hasCache = sCachedView != null && !TextUtils.isEmpty(sCachedImagePath); + LogUtils.d(TAG, String.format("hasCache 调用 | 缓存存在状态=%b", hasCache)); + return hasCache; + } + + /** + * 清除SP中最后加载的路径记录(强制缓存策略:仅日志,不实际清理) + * @param context 上下文 + */ + public static void clearLastLoadImagePath(Context context) { + LogUtils.w(TAG, "clearLastLoadImagePath 调用(强制缓存策略:不实际清理SP记录)"); + LogUtils.d(TAG, "clearLastLoadImagePath: 强制缓存策略生效,未清除SP中最后路径记录"); + } + + // ====================================== 辅助方法:从缓存获取上下文 ====================================== + /** + * 从缓存实例中获取上下文(用于无外部上下文时的SP操作) + * @return 上下文实例,无则返回null + */ +// private static Context getContextFromCache() { +// Context context = sCachedView != null ? sCachedView.getContext() : null; +// LogUtils.d(TAG, String.format("getContextFromCache 调用 | 从缓存获取上下文=%s", context)); +// return context; +// } + + // ====================================== 重写父类方法:增强日志+SP持久化(强制保持版) ====================================== + @Override + public void loadByBackgroundBean(BackgroundBean bean) { + LogUtils.d(TAG, String.format("loadByBackgroundBean 调用 | BackgroundBean=%s", (bean == null ? "null" : bean.toString()))); + super.loadByBackgroundBean(bean); + } + + @Override + public void loadByBackgroundBean(BackgroundBean bean, boolean isRefresh) { + LogUtils.d(TAG, String.format("loadByBackgroundBean 调用 | BackgroundBean=%s | 是否刷新=%b", (bean == null ? "null" : bean.toString()), isRefresh)); + super.loadByBackgroundBean(bean, isRefresh); + } + + // ====================================== 新增:强制缓存监控方法 ====================================== + /** + * 获取当前所有创建过的实例总数(用于监控强制缓存状态) + * @return 实例总数 + */ + public static int getInstanceCount() { + LogUtils.d(TAG, String.format("getInstanceCount 调用 | 当前实例总数=%d", sInstanceCount)); + return sInstanceCount; + } +} + diff --git a/powerbell/src/main/java/cc/winboll/studio/powerbell/views/VerticalSeekBar.java b/powerbell/src/main/java/cc/winboll/studio/powerbell/views/VerticalSeekBar.java new file mode 100644 index 0000000..fcedfcb --- /dev/null +++ b/powerbell/src/main/java/cc/winboll/studio/powerbell/views/VerticalSeekBar.java @@ -0,0 +1,238 @@ +package cc.winboll.studio.powerbell.views; + +import android.content.Context; +import android.graphics.Canvas; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.widget.SeekBar; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * 垂直进度条控件,适配 API30,支持逆时针旋转(0在下,100在上) + * 修复滑块同步+弹窗触发bug,新增实时进度变化监听接口,支持拖动时实时回调进度 + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/12/17 14:11 + */ +public class VerticalSeekBar extends SeekBar { + // ======================== 静态常量 ========================= + private static final String TAG = VerticalSeekBar.class.getSimpleName(); + + // ======================== 接口定义(前置,便于外部调用)======================== + /** + * 垂直进度条触摸事件回调接口,解决原生 OnSeekBarChangeListener 回调失效问题 + * 直接在触摸抬起时回调,确保配置变更对话框100%触发 + */ + public interface OnVerticalSeekBarTouchListener { + /** + * 触摸抬起时回调(滑块停止滑动,触发弹窗的核心时机) + * @param seekBar 当前垂直进度条实例 + * @param progress 最终滑动进度(0~100) + */ + void onTouchUp(VerticalSeekBar seekBar, int progress); + + /** + * 触摸取消时回调(可选,用于异常场景进度回滚) + * @param seekBar 当前垂直进度条实例 + * @param progress 取消时的进度 + */ + void onTouchCancel(VerticalSeekBar seekBar, int progress); + } + + /** + * 垂直进度条实时进度变化监听接口 + * 支持拖动过程中实时回调进度,用于比值预览等实时UI更新场景 + */ + public interface OnVerticalSeekBarChangeListener { + /** + * 进度变化时回调 + * @param seekBar 当前垂直进度条实例 + * @param progress 当前进度(0~100) + * @param fromUser 是否是用户触摸导致的进度变化 + */ + void onProgressChanged(VerticalSeekBar seekBar, int progress, boolean fromUser); + + /** + * 开始触摸进度条时回调 + * @param seekBar 当前垂直进度条实例 + */ + void onStartTrackingTouch(VerticalSeekBar seekBar); + + /** + * 停止触摸进度条时回调 + * @param seekBar 当前垂直进度条实例 + */ + void onStopTrackingTouch(VerticalSeekBar seekBar); + } + + // ======================== 成员变量 ========================= + // 核心状态:当前进度缓存,修复滑块同步问题(volatile 保证多线程可见性) + private volatile int mProgress = -1; + // 监听接口:触摸事件回调(原有,用于弹窗触发) + private OnVerticalSeekBarTouchListener mTouchListener; + // 监听接口:实时进度变化回调(新增,用于比值计算) + private OnVerticalSeekBarChangeListener mProgressChangeListener; + + // ======================== 构造方法 ========================= + public VerticalSeekBar(Context context) { + super(context); + initView(); + LogUtils.d(TAG, "【构造器1】VerticalSeekBar 初始化完成"); + } + + public VerticalSeekBar(Context context, AttributeSet attrs) { + super(context, attrs); + initView(); + LogUtils.d(TAG, "【构造器2】VerticalSeekBar 初始化完成"); + } + + public VerticalSeekBar(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + initView(); + LogUtils.d(TAG, "【构造器3】VerticalSeekBar 初始化完成"); + } + + // ======================== 初始化方法 ========================= + private void initView() { + // 移除水平默认阴影,优化垂直显示效果,减少 API30 不必要的绘制开销 + setBackgroundDrawable(null); + LogUtils.d(TAG, "【initView】移除默认背景阴影,完成视图初始化"); + } + + // ======================== 对外设置方法(监听接口绑定)======================== + /** + * 设置触摸事件监听器(给外部调用,如 MainContentView 绑定) + * @param listener 触摸事件回调实例 + */ + public void setOnVerticalSeekBarTouchListener(OnVerticalSeekBarTouchListener listener) { + this.mTouchListener = listener; + LogUtils.d(TAG, "【setOnVerticalSeekBarTouchListener】触摸监听器绑定完成"); + } + + /** + * 设置实时进度变化监听器(给外部调用,如 MainContentView 绑定) + * @param listener 实时进度变化回调实例 + */ + public void setOnVerticalSeekBarChangeListener(OnVerticalSeekBarChangeListener listener) { + this.mProgressChangeListener = listener; + LogUtils.d(TAG, "【setOnVerticalSeekBarChangeListener】实时进度监听器绑定完成"); + } + + // ======================== 重写系统方法(测量/布局/绘制)======================== + @Override + protected synchronized void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(heightMeasureSpec, widthMeasureSpec); + setMeasuredDimension(getMeasuredHeight(), getMeasuredWidth()); + LogUtils.v(TAG, "【onMeasure】垂直测量完成,宽=" + getMeasuredHeight() + ",高=" + getMeasuredWidth()); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(h, w, oldh, oldw); + LogUtils.v(TAG, "【onSizeChanged】尺寸变化,新宽=" + h + ",新高=" + w); + } + + @Override + protected void onDraw(Canvas canvas) { + // 逆时针旋转90度,平移画布避免绘制偏移(0在下,100在上) + canvas.rotate(-90); + canvas.translate(-getHeight(), 0); + super.onDraw(canvas); + LogUtils.v(TAG, "【onDraw】完成垂直绘制,旋转角度=-90°"); + } + + // ======================== 重写进度设置方法(修复滑块同步+新增实时回调)======================== + /** + * 重写进度设置,调用尺寸变化方法强制刷新,解决 setProgress 滑块不跟随问题 + * 新增:支持外部调用 setProgress 时触发实时进度回调 + */ + @Override + public synchronized void setProgress(int progress) { + super.setProgress(progress); + // 强制触发尺寸变化,同步刷新滑块位置(核心bug修复逻辑) + onSizeChanged(getWidth(), getHeight(), 0, 0); + mProgress = progress; + LogUtils.d(TAG, "【setProgress】进度设置为" + progress + ",滑块同步刷新"); + // 触发实时进度监听(外部调用 setProgress 时 fromUser 为 false) + if (mProgressChangeListener != null) { + mProgressChangeListener.onProgressChanged(this, progress, false); + LogUtils.v(TAG, "【setProgress】触发实时进度回调,fromUser=false"); + } + } + + // ======================== 重写触摸事件(优化事件透传+实时进度回调)======================== + @Override + public boolean onTouchEvent(MotionEvent event) { + // 先调用父类方法,保留原生监听器兼容性,同时强制透传事件 + super.onTouchEvent(event); + boolean handled = true; // 强制消费事件,避免事件被拦截导致回调丢失 + boolean fromUser = true; // 标记是否是用户触摸导致的进度变化 + int action = event.getAction(); + + switch (action) { + case MotionEvent.ACTION_DOWN: + LogUtils.d(TAG, "【onTouchEvent】触摸按下,Y坐标=" + event.getY()); + // 触发实时进度监听:开始触摸 + if (mProgressChangeListener != null) { + mProgressChangeListener.onStartTrackingTouch(this); + LogUtils.v(TAG, "【onTouchEvent】触发开始触摸回调"); + } + break; + + case MotionEvent.ACTION_MOVE: + calculateProgress(event.getY()); + setProgress(mProgress); + LogUtils.v(TAG, "【onTouchEvent】触摸滑动,进度更新为" + mProgress); + // 触发实时进度监听:进度变化 + if (mProgressChangeListener != null) { + mProgressChangeListener.onProgressChanged(this, mProgress, fromUser); + } + break; + + case MotionEvent.ACTION_UP: + calculateProgress(event.getY()); + setProgress(mProgress); + LogUtils.d(TAG, "【onTouchEvent】触摸抬起,进度=" + mProgress + ",触发弹窗回调"); + // 触发实时进度监听:进度变化+停止触摸 + if (mProgressChangeListener != null) { + mProgressChangeListener.onProgressChanged(this, mProgress, fromUser); + mProgressChangeListener.onStopTrackingTouch(this); + LogUtils.v(TAG, "【onTouchEvent】触发停止触摸回调"); + } + // 核心:调用原有触摸接口,通知外部触发配置变更对话框 + if (mTouchListener != null) { + mTouchListener.onTouchUp(this, mProgress); + LogUtils.v(TAG, "【onTouchEvent】触发触摸抬起回调"); + } + break; + + case MotionEvent.ACTION_CANCEL: + int currentProgress = getProgress(); + LogUtils.d(TAG, "【onTouchEvent】触摸取消,当前进度=" + currentProgress); + // 触发实时进度监听:停止触摸 + if (mProgressChangeListener != null) { + mProgressChangeListener.onStopTrackingTouch(this); + } + // 可选:触摸取消时回调,外部可做进度回滚处理 + if (mTouchListener != null) { + mTouchListener.onTouchCancel(this, currentProgress); + LogUtils.v(TAG, "【onTouchEvent】触发触摸取消回调"); + } + break; + } + return handled; + } + + // ======================== 内部工具方法 ========================= + /** + * 计算垂直进度,校准范围 0~100,避免异常值 + * @param touchY 触摸点Y坐标 + */ + private void calculateProgress(float touchY) { + // 核心进度计算公式(逆时针旋转适配) + mProgress = getMax() - (int) (getMax() * touchY / getHeight()); + // 校准进度范围,防止超出 0~100(兼容 API30 进度边界校验) + mProgress = Math.max(0, Math.min(mProgress, getMax())); + LogUtils.v(TAG, "【calculateProgress】触摸Y=" + touchY + ",计算进度=" + mProgress + ",校准后=" + mProgress); + } +} + diff --git a/powerbell/src/main/res/drawable/bg_frame.xml b/powerbell/src/main/res/drawable/bg_frame.xml new file mode 100644 index 0000000..75b2b94 --- /dev/null +++ b/powerbell/src/main/res/drawable/bg_frame.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + diff --git a/powerbell/src/main/res/drawable/bg_frame_white.xml b/powerbell/src/main/res/drawable/bg_frame_white.xml new file mode 100644 index 0000000..002ffe1 --- /dev/null +++ b/powerbell/src/main/res/drawable/bg_frame_white.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + diff --git a/powerbell/src/main/res/drawable/blank100x100.png b/powerbell/src/main/res/drawable/blank100x100.png new file mode 100644 index 0000000..84e1003 Binary files /dev/null and b/powerbell/src/main/res/drawable/blank100x100.png differ diff --git a/powerbell/src/main/res/drawable/btn_bg_gray.xml b/powerbell/src/main/res/drawable/btn_bg_gray.xml new file mode 100644 index 0000000..ba883b9 --- /dev/null +++ b/powerbell/src/main/res/drawable/btn_bg_gray.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/powerbell/src/main/res/drawable/btn_bg_primary.xml b/powerbell/src/main/res/drawable/btn_bg_primary.xml new file mode 100644 index 0000000..6f78e61 --- /dev/null +++ b/powerbell/src/main/res/drawable/btn_bg_primary.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/powerbell/src/main/res/drawable/btn_brightness_bg.xml b/powerbell/src/main/res/drawable/btn_brightness_bg.xml new file mode 100644 index 0000000..3d52279 --- /dev/null +++ b/powerbell/src/main/res/drawable/btn_brightness_bg.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/powerbell/src/main/res/drawable/btn_cancel_bg.xml b/powerbell/src/main/res/drawable/btn_cancel_bg.xml new file mode 100644 index 0000000..14e0bac --- /dev/null +++ b/powerbell/src/main/res/drawable/btn_cancel_bg.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/powerbell/src/main/res/drawable/btn_common.xml b/powerbell/src/main/res/drawable/btn_common.xml new file mode 100644 index 0000000..7ccb64d --- /dev/null +++ b/powerbell/src/main/res/drawable/btn_common.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/powerbell/src/main/res/drawable/btn_confirm_bg.xml b/powerbell/src/main/res/drawable/btn_confirm_bg.xml new file mode 100644 index 0000000..e4a7a4d --- /dev/null +++ b/powerbell/src/main/res/drawable/btn_confirm_bg.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/powerbell/src/main/res/drawable/charge.png b/powerbell/src/main/res/drawable/charge.png new file mode 100644 index 0000000..39020eb Binary files /dev/null and b/powerbell/src/main/res/drawable/charge.png differ diff --git a/powerbell/src/main/res/drawable/color_scale_logo.png b/powerbell/src/main/res/drawable/color_scale_logo.png new file mode 100644 index 0000000..70123fb Binary files /dev/null and b/powerbell/src/main/res/drawable/color_scale_logo.png differ diff --git a/powerbell/src/main/res/drawable/cursor_pointer.xml b/powerbell/src/main/res/drawable/cursor_pointer.xml new file mode 100644 index 0000000..e828063 --- /dev/null +++ b/powerbell/src/main/res/drawable/cursor_pointer.xml @@ -0,0 +1,20 @@ + + + + diff --git a/powerbell/src/main/res/drawable/default_background.xml b/powerbell/src/main/res/drawable/default_background.xml new file mode 100644 index 0000000..f021b2e --- /dev/null +++ b/powerbell/src/main/res/drawable/default_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/powerbell/src/main/res/drawable/dialog_bg_radius.xml b/powerbell/src/main/res/drawable/dialog_bg_radius.xml new file mode 100644 index 0000000..773dcd8 --- /dev/null +++ b/powerbell/src/main/res/drawable/dialog_bg_radius.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + diff --git a/powerbell/src/main/res/drawable/divider_line.xml b/powerbell/src/main/res/drawable/divider_line.xml new file mode 100644 index 0000000..f9365df --- /dev/null +++ b/powerbell/src/main/res/drawable/divider_line.xml @@ -0,0 +1,7 @@ + + + + diff --git a/powerbell/src/main/res/drawable/edittext_bg.xml b/powerbell/src/main/res/drawable/edittext_bg.xml new file mode 100644 index 0000000..8b48b87 --- /dev/null +++ b/powerbell/src/main/res/drawable/edittext_bg.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/powerbell/src/main/res/drawable/ic_launcher.xml b/powerbell/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..737188b --- /dev/null +++ b/powerbell/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/powerbell/src/main/res/drawable/ic_launcher_background.xml b/powerbell/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..f021b2e --- /dev/null +++ b/powerbell/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/powerbell/src/main/res/drawable/ic_launcher_beta.xml b/powerbell/src/main/res/drawable/ic_launcher_beta.xml new file mode 100644 index 0000000..08f0a9f --- /dev/null +++ b/powerbell/src/main/res/drawable/ic_launcher_beta.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/powerbell/src/main/res/drawable/seekbar_progress.xml b/powerbell/src/main/res/drawable/seekbar_progress.xml new file mode 100644 index 0000000..4e27cf9 --- /dev/null +++ b/powerbell/src/main/res/drawable/seekbar_progress.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/powerbell/src/main/res/drawable/seekbar_thumb.xml b/powerbell/src/main/res/drawable/seekbar_thumb.xml new file mode 100644 index 0000000..fb9e7b5 --- /dev/null +++ b/powerbell/src/main/res/drawable/seekbar_thumb.xml @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/powerbell/src/main/res/drawable/speaker.xml b/powerbell/src/main/res/drawable/speaker.xml new file mode 100644 index 0000000..53e00c1 --- /dev/null +++ b/powerbell/src/main/res/drawable/speaker.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/powerbell/src/main/res/drawable/usege.png b/powerbell/src/main/res/drawable/usege.png new file mode 100644 index 0000000..591894d Binary files /dev/null and b/powerbell/src/main/res/drawable/usege.png differ diff --git a/powerbell/src/main/res/drawable/xiaobai.png b/powerbell/src/main/res/drawable/xiaobai.png new file mode 100644 index 0000000..1b053a2 Binary files /dev/null and b/powerbell/src/main/res/drawable/xiaobai.png differ diff --git a/powerbell/src/main/res/layout/activity_background_settings.xml b/powerbell/src/main/res/layout/activity_background_settings.xml new file mode 100644 index 0000000..74d18c9 --- /dev/null +++ b/powerbell/src/main/res/layout/activity_background_settings.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/powerbell/src/main/res/layout/activity_battery_report.xml b/powerbell/src/main/res/layout/activity_battery_report.xml new file mode 100644 index 0000000..cd13abd --- /dev/null +++ b/powerbell/src/main/res/layout/activity_battery_report.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + diff --git a/powerbell/src/main/res/layout/activity_clearrecord.xml b/powerbell/src/main/res/layout/activity_clearrecord.xml new file mode 100644 index 0000000..1e8522d --- /dev/null +++ b/powerbell/src/main/res/layout/activity_clearrecord.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/powerbell/src/main/res/layout/activity_main.xml b/powerbell/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..de40e2f --- /dev/null +++ b/powerbell/src/main/res/layout/activity_main.xml @@ -0,0 +1,249 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/powerbell/src/main/res/layout/activity_mainunittest.xml b/powerbell/src/main/res/layout/activity_mainunittest.xml new file mode 100644 index 0000000..583ef92 --- /dev/null +++ b/powerbell/src/main/res/layout/activity_mainunittest.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + +