From 32c25f1b0a2a68815adf38ca2337c9ae59be95b9 Mon Sep 17 00:00:00 2001 From: ZhanGSKen Date: Wed, 8 Apr 2026 20:38:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=B7=E8=B4=9D=E4=B8=8A=E4=B8=AA=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E4=BB=93=E5=BA=93=E6=8F=90=E4=BA=A4=E7=82=B9=E4=B8=BA?= =?UTF-8?q?d805fe8ebe3fc4157a4b8c7464635a84798106bc=E7=9A=84contacts?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=A8=A1=E5=9D=97=E6=BA=90=E7=A0=81=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contacts/README.md | 40 ++ contacts/app_update_description.txt | 1 + contacts/build.gradle | 102 +++ contacts/build.properties | 8 + contacts/proguard-rules.pro | 143 ++++ contacts/src/beta/AndroidManifest.xml | 13 + contacts/src/beta/res/values/strings.xml | 6 + contacts/src/main/AndroidManifest.xml | 207 ++++++ .../studio/contacts/ActivityStack.java | 313 +++++++++ .../java/cc/winboll/studio/contacts/App.java | 33 + .../winboll/studio/contacts/MainActivity.java | 529 +++++++++++++++ .../contacts/activities/AboutActivity.java | 116 ++++ .../contacts/activities/CallActivity.java | 159 +++++ .../contacts/activities/DialerActivity.java | 80 +++ .../contacts/activities/SettingsActivity.java | 613 ++++++++++++++++++ .../contacts/activities/UnitTestActivity.java | 145 +++++ .../contacts/activities/WinBollActivity.java | 84 +++ .../contacts/adapters/CallLogAdapter.java | 174 +++++ .../contacts/adapters/ContactAdapter.java | 157 +++++ .../adapters/PhoneConnectRuleAdapter.java | 257 ++++++++ .../studio/contacts/bobulltoon/TomCat.java | 260 ++++++++ .../cc/winboll/studio/contacts/dun/Rules.java | 264 ++++++++ .../contacts/fragments/CallLogFragment.java | 258 ++++++++ .../contacts/fragments/ContactsFragment.java | 401 ++++++++++++ .../contacts/fragments/LogFragment.java | 118 ++++ .../contacts/handlers/MainServiceHandler.java | 38 ++ .../listenphonecall/CallListenerService.java | 392 +++++++++++ .../studio/contacts/model/CallLogModel.java | 43 ++ .../studio/contacts/model/ContactModel.java | 135 ++++ .../contacts/model/MainServiceBean.java | 91 +++ .../contacts/model/PhoneConnectRuleBean.java | 148 +++++ .../studio/contacts/model/RingTongBean.java | 107 +++ .../studio/contacts/model/SettingsBean.java | 216 ++++++ .../phonecallui/PhoneCallActivity.java | 362 +++++++++++ .../phonecallui/PhoneCallManager.java | 204 ++++++ .../phonecallui/PhoneCallService.java | 284 ++++++++ .../contacts/receivers/MainReceiver.java | 98 +++ .../contacts/services/AssistantService.java | 251 +++++++ .../studio/contacts/services/MainService.java | 593 +++++++++++++++++ .../services/MyCallScreeningService.java | 246 +++++++ .../contacts/threads/MainServiceThread.java | 104 +++ .../contacts/utils/AppGoToSettingsUtil.java | 268 ++++++++ .../studio/contacts/utils/ContactUtils.java | 351 ++++++++++ .../contacts/utils/EditTextIntUtils.java | 51 ++ .../studio/contacts/utils/IntUtils.java | 64 ++ .../utils/NotificationManagerUtils.java | 505 +++++++++++++++ .../contacts/utils/PermissionUtils.java | 254 ++++++++ .../studio/contacts/utils/PhoneUtils.java | 58 ++ .../studio/contacts/utils/RegexPPiUtils.java | 42 ++ .../studio/contacts/views/DuInfoTextView.java | 117 ++++ .../contacts/views/DunTemperatureView.java | 393 +++++++++++ .../studio/contacts/views/LeftScrollView.java | 306 +++++++++ .../contacts/widgets/APPStatusWidget.java | 75 +++ .../widgets/APPStatusWidgetClickListener.java | 32 + contacts/src/main/res/drawable/ic_call.xml | 11 + .../src/main/res/drawable/ic_launcher.xml | 11 + .../res/drawable/ic_launcher_background.xml | 170 +++++ .../main/res/drawable/ic_launcher_disable.xml | 11 + .../res/drawable/ic_launcher_foreground.xml | 10 + .../ic_launcher_foreground_disable.xml | 10 + .../main/res/drawable/ic_phone_call_in.xml | 9 + .../main/res/drawable/ic_phone_call_out.xml | 9 + .../main/res/drawable/ic_phone_hang_up.xml | 9 + .../main/res/drawable/ic_phone_pick_up.xml | 30 + .../res/drawable/recycler_view_border.xml | 9 + .../src/main/res/drawable/shape_gradient.xml | 10 + .../src/main/res/layout/activity_about.xml | 21 + .../src/main/res/layout/activity_call.xml | 28 + .../src/main/res/layout/activity_dialer.xml | 22 + .../src/main/res/layout/activity_main.xml | 63 ++ .../main/res/layout/activity_phone_call.xml | 98 +++ .../src/main/res/layout/activity_settings.xml | 364 +++++++++++ .../src/main/res/layout/activity_unittest.xml | 60 ++ .../src/main/res/layout/fragment_call_log.xml | 15 + .../src/main/res/layout/fragment_contacts.xml | 36 + contacts/src/main/res/layout/fragment_log.xml | 12 + .../src/main/res/layout/item_call_log.xml | 78 +++ contacts/src/main/res/layout/item_contact.xml | 68 ++ .../src/main/res/layout/view_left_scroll.xml | 52 ++ .../src/main/res/layout/view_phone_call.xml | 33 + .../res/layout/view_phone_connect_rule.xml | 44 ++ .../layout/view_phone_connect_rule_simple.xml | 16 + ...view_phone_connect_rule_simple_content.xml | 35 + contacts/src/main/res/layout/view_toast.xml | 32 + .../src/main/res/layout/widget_layout.xml | 15 + .../res/menu/toolbar_calllog_phonenumber.xml | 12 + .../res/menu/toolbar_contact_phonenumber.xml | 12 + contacts/src/main/res/menu/toolbar_main.xml | 8 + contacts/src/main/res/values/colors.xml | 13 + contacts/src/main/res/values/strings.xml | 7 + contacts/src/main/res/values/styles.xml | 48 ++ .../main/res/xml/appwidget_provider_info.xml | 8 + contacts/src/main/res/xml/file_provider.xml | 25 + .../main/res/xml/network_security_config.xml | 9 + contacts/src/stage/AndroidManifest.xml | 12 + contacts/src/stage/res/values/strings.xml | 6 + 96 files changed, 11860 insertions(+) create mode 100644 contacts/README.md create mode 100644 contacts/app_update_description.txt create mode 100644 contacts/build.gradle create mode 100644 contacts/build.properties create mode 100644 contacts/proguard-rules.pro create mode 100644 contacts/src/beta/AndroidManifest.xml create mode 100644 contacts/src/beta/res/values/strings.xml create mode 100644 contacts/src/main/AndroidManifest.xml create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/ActivityStack.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/App.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/MainActivity.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/activities/AboutActivity.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/activities/CallActivity.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/activities/DialerActivity.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/activities/SettingsActivity.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/activities/UnitTestActivity.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/activities/WinBollActivity.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/adapters/CallLogAdapter.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/adapters/ContactAdapter.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/adapters/PhoneConnectRuleAdapter.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/bobulltoon/TomCat.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/dun/Rules.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/fragments/CallLogFragment.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/fragments/ContactsFragment.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/fragments/LogFragment.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/handlers/MainServiceHandler.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/listenphonecall/CallListenerService.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/model/CallLogModel.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/model/ContactModel.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/model/MainServiceBean.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/model/PhoneConnectRuleBean.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/model/RingTongBean.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/model/SettingsBean.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/phonecallui/PhoneCallActivity.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/phonecallui/PhoneCallManager.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/phonecallui/PhoneCallService.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/receivers/MainReceiver.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/services/AssistantService.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/services/MainService.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/services/MyCallScreeningService.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/threads/MainServiceThread.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/utils/AppGoToSettingsUtil.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/utils/ContactUtils.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/utils/EditTextIntUtils.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/utils/IntUtils.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/utils/NotificationManagerUtils.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/utils/PermissionUtils.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/utils/PhoneUtils.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/utils/RegexPPiUtils.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/views/DuInfoTextView.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/views/DunTemperatureView.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/views/LeftScrollView.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/widgets/APPStatusWidget.java create mode 100644 contacts/src/main/java/cc/winboll/studio/contacts/widgets/APPStatusWidgetClickListener.java create mode 100644 contacts/src/main/res/drawable/ic_call.xml create mode 100644 contacts/src/main/res/drawable/ic_launcher.xml create mode 100644 contacts/src/main/res/drawable/ic_launcher_background.xml create mode 100644 contacts/src/main/res/drawable/ic_launcher_disable.xml create mode 100644 contacts/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 contacts/src/main/res/drawable/ic_launcher_foreground_disable.xml create mode 100644 contacts/src/main/res/drawable/ic_phone_call_in.xml create mode 100644 contacts/src/main/res/drawable/ic_phone_call_out.xml create mode 100644 contacts/src/main/res/drawable/ic_phone_hang_up.xml create mode 100644 contacts/src/main/res/drawable/ic_phone_pick_up.xml create mode 100644 contacts/src/main/res/drawable/recycler_view_border.xml create mode 100644 contacts/src/main/res/drawable/shape_gradient.xml create mode 100644 contacts/src/main/res/layout/activity_about.xml create mode 100644 contacts/src/main/res/layout/activity_call.xml create mode 100644 contacts/src/main/res/layout/activity_dialer.xml create mode 100644 contacts/src/main/res/layout/activity_main.xml create mode 100644 contacts/src/main/res/layout/activity_phone_call.xml create mode 100644 contacts/src/main/res/layout/activity_settings.xml create mode 100644 contacts/src/main/res/layout/activity_unittest.xml create mode 100644 contacts/src/main/res/layout/fragment_call_log.xml create mode 100644 contacts/src/main/res/layout/fragment_contacts.xml create mode 100644 contacts/src/main/res/layout/fragment_log.xml create mode 100644 contacts/src/main/res/layout/item_call_log.xml create mode 100644 contacts/src/main/res/layout/item_contact.xml create mode 100644 contacts/src/main/res/layout/view_left_scroll.xml create mode 100644 contacts/src/main/res/layout/view_phone_call.xml create mode 100644 contacts/src/main/res/layout/view_phone_connect_rule.xml create mode 100644 contacts/src/main/res/layout/view_phone_connect_rule_simple.xml create mode 100644 contacts/src/main/res/layout/view_phone_connect_rule_simple_content.xml create mode 100644 contacts/src/main/res/layout/view_toast.xml create mode 100644 contacts/src/main/res/layout/widget_layout.xml create mode 100644 contacts/src/main/res/menu/toolbar_calllog_phonenumber.xml create mode 100644 contacts/src/main/res/menu/toolbar_contact_phonenumber.xml create mode 100644 contacts/src/main/res/menu/toolbar_main.xml create mode 100644 contacts/src/main/res/values/colors.xml create mode 100644 contacts/src/main/res/values/strings.xml create mode 100644 contacts/src/main/res/values/styles.xml create mode 100644 contacts/src/main/res/xml/appwidget_provider_info.xml create mode 100644 contacts/src/main/res/xml/file_provider.xml create mode 100644 contacts/src/main/res/xml/network_security_config.xml create mode 100644 contacts/src/stage/AndroidManifest.xml create mode 100644 contacts/src/stage/res/values/strings.xml diff --git a/contacts/README.md b/contacts/README.md new file mode 100644 index 0000000..3a2e48f --- /dev/null +++ b/contacts/README.md @@ -0,0 +1,40 @@ +# Contacts +源码参考自: +https://github.com/aJIEw/PhoneCallApp.git + +#### 介绍 +这是可以根据正则表达式匹配拦截骚扰电话的手机拨号应用。 + +#### 软件架构 +适配安卓应用 [AIDE Pro] 的 Gradle 编译结构。 +也适配安卓应用 [AndroidIDE] 的 Gradle 编译结构。 + + +#### Gradle 编译说明 +调试版编译命令 :gradle assembleBetaDebug +阶段版编译命令 :gradle assembleStageRelease + +#### 使用说明 + +在安卓系统中需要设置两个权限允许。 +1.自启动权限允许。 +2.省电策略-无限制权限允许。 + +#### 参与贡献 + +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/) + +#### 参考文档 diff --git a/contacts/app_update_description.txt b/contacts/app_update_description.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/contacts/app_update_description.txt @@ -0,0 +1 @@ + diff --git a/contacts/build.gradle b/contacts/build.gradle new file mode 100644 index 0000000..6af0c27 --- /dev/null +++ b/contacts/build.gradle @@ -0,0 +1,102 @@ +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.contacts" + minSdkVersion 23 + targetSdkVersion 30 + versionCode 2 + // versionName 更新后需要手动设置 + // 项目模块目录的 build.gradle 文件的 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' + + // 权限请求框架:https://github.com/getActivity/XXPermissions + api 'com.github.getActivity:XXPermissions:18.63' + // 下拉控件 + api 'com.baoyz.pullrefreshlayout:library:1.2.0' + // 拼音搜索 + // https://mvnrepository.com/artifact/com.github.open-android/pinyin4j + api 'com.github.open-android:pinyin4j:2.5.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 'io.github.medyo:android-about-page:2.0.0' + // 网络连接类库 + api 'com.squareup.okhttp3:okhttp:4.4.1' + + // AndroidX 类库 + /*implementation 'androidx.appcompat:appcompat:1.1.0' + implementation 'androidx.viewpager:viewpager:1.0.0' + implementation 'androidx.vectordrawable:vectordrawable:1.1.0' + implementation 'androidx.vectordrawable:vectordrawable-animated:1.1.0' + implementation 'androidx.fragment:fragment:1.1.0' + implementation 'com.google.android.material:material:1.4.0' + */ + 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: ['*.jar']) +} diff --git a/contacts/build.properties b/contacts/build.properties new file mode 100644 index 0000000..b30b231 --- /dev/null +++ b/contacts/build.properties @@ -0,0 +1,8 @@ +#Created by .winboll/winboll_app_build.gradle +#Fri Jan 09 20:22:05 HKT 2026 +stageCount=7 +libraryProject= +baseVersion=15.14 +publishVersion=15.14.6 +buildCount=0 +baseBetaVersion=15.14.7 diff --git a/contacts/proguard-rules.pro b/contacts/proguard-rules.pro new file mode 100644 index 0000000..855b18a --- /dev/null +++ b/contacts/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/contacts/src/beta/AndroidManifest.xml b/contacts/src/beta/AndroidManifest.xml new file mode 100644 index 0000000..c598f4f --- /dev/null +++ b/contacts/src/beta/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/contacts/src/beta/res/values/strings.xml b/contacts/src/beta/res/values/strings.xml new file mode 100644 index 0000000..dc3720a --- /dev/null +++ b/contacts/src/beta/res/values/strings.xml @@ -0,0 +1,6 @@ + + + + Contacts+ + + diff --git a/contacts/src/main/AndroidManifest.xml b/contacts/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a201cb5 --- /dev/null +++ b/contacts/src/main/AndroidManifest.xml @@ -0,0 +1,207 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/ActivityStack.java b/contacts/src/main/java/cc/winboll/studio/contacts/ActivityStack.java new file mode 100644 index 0000000..59a12dc --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/ActivityStack.java @@ -0,0 +1,313 @@ +package cc.winboll.studio.contacts; + +import android.app.Activity; +import android.os.Handler; +import android.os.Looper; +import cc.winboll.studio.libappbase.LogUtils; +import java.util.ArrayList; +import java.util.List; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/02/13 06:58:04 + * @Describe Activity 栈管理工具,统一管理应用内 Activity 生命周期 + * 适配:Java7 + Android API29-30 + 小米机型,优化并发安全与通话场景稳定性 + */ +public class ActivityStack { + // 常量定义(核心标识+版本兼容常量) + public static final String TAG = "ActivityStack"; + private static final int API_VERSION_O = 26; // Android 8.0 API26(isDestroyed适配用) + + // 单例与核心成员变量(按优先级排序) + private static final ActivityStack INSTANCE = new ActivityStack(); + // 替换为ArrayList+同步锁:解决CopyOnWriteArrayList迭代器不能删除的崩溃,兼顾并发安全 + private final List mActivityList = new ArrayList(); + private final Handler mMainHandler = new Handler(Looper.getMainLooper()); // 复用主线程Handler,避免内存泄漏 + + // 单例对外暴露方法 + public static ActivityStack getInstance() { + return INSTANCE; + } + + // 私有构造,禁止外部实例化 + private ActivityStack() { + LogUtils.d(TAG, "ActivityStack 初始化完成"); + } + + // ====================== 栈基础操作(添加/移除) ====================== + /** + * 添加Activity到栈中,避免重复入栈 + * @param activity 待添加的Activity + */ + public void addActivity(Activity activity) { + if (activity == null) { + LogUtils.w(TAG, "addActivity: activity is null, skip"); + return; + } + // 同步锁:解决多线程并发添加冲突(小米机型多线程场景适配) + synchronized (mActivityList) { + if (!mActivityList.contains(activity)) { + mActivityList.add(activity); + LogUtils.d(TAG, "addActivity: " + activity.getClass().getSimpleName() + ", stack size: " + mActivityList.size()); + } + } + } + + /** + * 移除Activity(不销毁,用于正常退出场景) + * @param activity 待移除的Activity + */ + public void removeActivity(Activity activity) { + if (activity == null) { + LogUtils.w(TAG, "removeActivity: activity is null, skip"); + return; + } + synchronized (mActivityList) { + if (mActivityList.remove(activity)) { + LogUtils.d(TAG, "removeActivity: " + activity.getClass().getSimpleName() + ", stack size: " + mActivityList.size()); + } + } + } + + // ====================== Activity状态查询(获取/判断存活) ====================== + /** + * 获取栈顶有效Activity(迭代遍历替代递归,避免栈溢出,适配小米多页面场景) + * @return 栈顶有效Activity,无则返回null + */ + public Activity getTopActivity() { + synchronized (mActivityList) { + if (mActivityList.isEmpty()) { + LogUtils.w(TAG, "getTopActivity: stack is empty, return null"); + return null; + } + + Activity validTopActivity = null; + // 倒序遍历,优先取最顶层有效Activity,同时清理无效残留 + for (int i = mActivityList.size() - 1; i >= 0; i--) { + Activity activity = mActivityList.get(i); + // 版本兼容校验:API26+才支持isDestroyed + if (activity != null && !activity.isFinishing() && (getSdkVersion() < API_VERSION_O || !activity.isDestroyed())) { + validTopActivity = activity; + break; + } else { + mActivityList.remove(i); + String className = (activity != null) ? activity.getClass().getSimpleName() : "null"; + LogUtils.w(TAG, "getTopActivity: remove invalid activity: " + className); + } + } + + if (validTopActivity != null) { + LogUtils.d(TAG, "getTopActivity: top activity: " + validTopActivity.getClass().getSimpleName()); + } + return validTopActivity; + } + } + + /** + * 获取指定类的有效Activity实例(通话场景核心方法,判断页面是否存活) + * @param activityClass 目标Activity类 + * @return 有效实例,无则返回null + */ + public Activity getActivity(Class activityClass) { + if (activityClass == null) { + LogUtils.w(TAG, "getActivity: activityClass is null, return null"); + return null; + } + synchronized (mActivityList) { + if (mActivityList.isEmpty()) { + LogUtils.w(TAG, "getActivity: stack empty, return null"); + return null; + } + + for (Activity activity : mActivityList) { + if (activity != null && activity.getClass().equals(activityClass) && !activity.isFinishing() && (getSdkVersion() < API_VERSION_O || !activity.isDestroyed())) { + LogUtils.d(TAG, "getActivity: find valid activity: " + activityClass.getSimpleName()); + return activity; + } + } + LogUtils.w(TAG, "getActivity: no valid activity: " + activityClass.getSimpleName()); + return null; + } + } + + /** + * 判断指定Activity是否存活(简化通话场景调用,避免重复判空) + * @param activityClass 目标Activity类 + * @return true:存活,false:未存活 + */ + public boolean isActivityAlive(Class activityClass) { + boolean isAlive = getActivity(activityClass) != null; + LogUtils.d(TAG, "isActivityAlive: " + activityClass.getSimpleName() + ", result: " + isAlive); + return isAlive; + } + + // ====================== Activity销毁操作(单/批量/全部) ====================== + /** + * 销毁栈顶Activity(主线程执行,适配小米机型线程限制) + */ + public void finishTopActivity() { + runOnMainThread(new Runnable() { + @Override + public void run() { + synchronized (mActivityList) { + if (mActivityList.isEmpty()) { + LogUtils.w(TAG, "finishTopActivity: stack is empty, skip"); + return; + } + + // 先移除再校验,避免并发冲突(小米多线程场景适配) + Activity topActivity = mActivityList.remove(mActivityList.size() - 1); + if (topActivity == null) { + LogUtils.w(TAG, "finishTopActivity: top activity is null, skip"); + return; + } + + if (!topActivity.isFinishing() && (getSdkVersion() < API_VERSION_O || !topActivity.isDestroyed())) { + topActivity.finish(); + LogUtils.d(TAG, "finishTopActivity: destroy top activity: " + topActivity.getClass().getSimpleName() + ", stack size: " + mActivityList.size()); + } + } + } + }); + } + + /** + * 销毁指定Activity(主线程执行,避免跨线程异常) + * @param activity 待销毁的Activity + */ + public void finishActivity(final Activity activity) { + runOnMainThread(new Runnable() { + @Override + public void run() { + if (activity == null) { + LogUtils.w(TAG, "finishActivity: activity is null, skip"); + return; + } + synchronized (mActivityList) { + if (mActivityList.contains(activity) && !activity.isFinishing() && (getSdkVersion() < API_VERSION_O || !activity.isDestroyed())) { + mActivityList.remove(activity); + activity.finish(); + LogUtils.d(TAG, "finishActivity: destroy activity: " + activity.getClass().getSimpleName() + ", stack size: " + mActivityList.size()); + } + } + } + }); + } + + /** + * 销毁指定类的所有Activity(核心修复:迭代器删除崩溃,通话场景核心) + * @param activityClass 目标Activity类 + */ + public void finishActivity(final Class activityClass) { + runOnMainThread(new Runnable() { + @Override + public void run() { + if (activityClass == null) { + LogUtils.w(TAG, "finishActivity: activityClass is null, skip"); + return; + } + synchronized (mActivityList) { + if (mActivityList.isEmpty()) { + LogUtils.w(TAG, "finishActivity: stack empty, skip"); + return; + } + + // 核心修复:用索引遍历+倒序删除,替代迭代器删除(避免UnsupportedOperationException) + for (int i = mActivityList.size() - 1; i >= 0; i--) { + Activity activity = mActivityList.get(i); + if (activity != null && activity.getClass().equals(activityClass)) { + if (!activity.isFinishing() && (getSdkVersion() < API_VERSION_O || !activity.isDestroyed())) { + mActivityList.remove(i); // 索引删除,支持ArrayList + activity.finish(); + LogUtils.d(TAG, "finishActivity: destroy class activity: " + activityClass.getSimpleName() + ", stack size: " + mActivityList.size()); + } else { + mActivityList.remove(i); // 清理无效残留 + } + } + } + } + } + }); + } + + /** + * 销毁栈中所有Activity(退出应用/清空栈场景用) + */ + public void finishAllActivity() { + runOnMainThread(new Runnable() { + @Override + public void run() { + synchronized (mActivityList) { + if (mActivityList.isEmpty()) { + LogUtils.w(TAG, "finishAllActivity: stack is empty, skip"); + return; + } + + // 遍历销毁所有有效Activity,逐个状态校验(小米机型稳定性适配) + for (Activity activity : mActivityList) { + if (activity != null && !activity.isFinishing() && (getSdkVersion() < API_VERSION_O || !activity.isDestroyed())) { + activity.finish(); + LogUtils.d(TAG, "finishAllActivity: destroy activity: " + activity.getClass().getSimpleName()); + } + } + mActivityList.clear(); + LogUtils.d(TAG, "finishAllActivity: all activity destroyed, stack cleared"); + } + } + }); + } + + // ====================== 栈优化与工具方法 ====================== + /** + * 清理栈中所有无效Activity(null/已销毁/已结束),优化小米机型内存占用 + */ + public void clearInvalidActivities() { + runOnMainThread(new Runnable() { + @Override + public void run() { + synchronized (mActivityList) { + if (mActivityList.isEmpty()) { + return; + } + + // 倒序索引删除,避免遍历过程中索引错乱 + for (int i = mActivityList.size() - 1; i >= 0; i--) { + Activity activity = mActivityList.get(i); + if (activity == null || activity.isFinishing() || (getSdkVersion() >= API_VERSION_O && activity.isDestroyed())) { + mActivityList.remove(i); + String className = (activity != null) ? activity.getClass().getSimpleName() : "null"; + LogUtils.d(TAG, "clearInvalidActivities: remove invalid activity: " + className); + } + } + LogUtils.d(TAG, "clearInvalidActivities: done, stack size: " + mActivityList.size()); + } + } + }); + } + + /** + * 确保任务在主线程执行(Activity操作必须主线程,小米机型严格限制) + * @param runnable 待执行任务 + */ + private void runOnMainThread(Runnable runnable) { + if (runnable == null) { + return; + } + // 避免不必要的线程切换,优化性能(小米机型流畅度适配) + if (Looper.getMainLooper() == Looper.myLooper()) { + runnable.run(); + } else { + mMainHandler.post(runnable); + LogUtils.d(TAG, "runOnMainThread: post task to main thread"); + } + } + + /** + * 辅助方法:获取当前系统SDK版本(简化版本判断逻辑,统一调用) + * @return SDK版本号 + */ + private int getSdkVersion() { + return android.os.Build.VERSION.SDK_INT; + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/App.java b/contacts/src/main/java/cc/winboll/studio/contacts/App.java new file mode 100644 index 0000000..05e51ac --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/App.java @@ -0,0 +1,33 @@ +package cc.winboll.studio.contacts; + +/** + * @Author ZhanGSKen + * @Date 2024/12/08 15:10:51 + * @Describe 全局应用类 + */ +import cc.winboll.studio.libaes.utils.WinBoLLActivityManager; +import cc.winboll.studio.libappbase.GlobalApplication; +import cc.winboll.studio.libappbase.ToastUtils; + +public class App extends GlobalApplication { + + public static final String TAG = "App"; + + @Override + public void onCreate() { + super.onCreate(); + // 设置应用调试标志 + setIsDebugging(BuildConfig.DEBUG); + + // 初始化窗口管理类 + WinBoLLActivityManager.init(this); + // 初始化 Toast 框架 + ToastUtils.init(this); + } + + @Override + public void onTerminate() { + super.onTerminate(); + ToastUtils.release(); + } +} diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/MainActivity.java b/contacts/src/main/java/cc/winboll/studio/contacts/MainActivity.java new file mode 100644 index 0000000..b997cf5 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/MainActivity.java @@ -0,0 +1,529 @@ +package cc.winboll.studio.contacts; + +import android.Manifest; +import android.app.Activity; +import android.app.ActivityManager; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.graphics.Color; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.telecom.TelecomManager; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.CheckBox; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.Toast; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentPagerAdapter; +import androidx.viewpager.widget.ViewPager; +import cc.winboll.studio.contacts.activities.SettingsActivity; +import cc.winboll.studio.contacts.activities.WinBollActivity; +import cc.winboll.studio.contacts.dun.Rules; +import cc.winboll.studio.contacts.fragments.CallLogFragment; +import cc.winboll.studio.contacts.fragments.ContactsFragment; +import cc.winboll.studio.contacts.fragments.LogFragment; +import cc.winboll.studio.contacts.model.MainServiceBean; +import cc.winboll.studio.contacts.services.MainService; +import cc.winboll.studio.contacts.utils.PermissionUtils; +import cc.winboll.studio.contacts.views.DunTemperatureView; +import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity; +import cc.winboll.studio.libaes.views.ADsBannerView; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.LogView; +import com.google.android.material.tabs.TabLayout; +import java.util.ArrayList; +import java.util.List; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/08/30 14:32 + * @Describe Contacts 主窗口(完全适配 API 30 + Java 7 语法) + * 核心优化:1. 移除电话状态监听 2. 移除通话筛选服务 3. 移除 MainService 所有相关逻辑 4. ViewPager 实现 Fragment 懒加载(仅首屏初始化) + * 问题修复:解决首屏 Fragment 空白问题(删除 setPrimaryItem 冲突逻辑+延迟首屏初始化) + */ +public final class MainActivity extends WinBollActivity implements IWinBoLLActivity, ViewPager.OnPageChangeListener, View.OnClickListener { + + // ====================== 1. 常量定义区(硬编码API版本,避免高版本依赖) ====================== + public static final String TAG = "MainActivity"; + public static final int REQUEST_HOME_ACTIVITY = 0; + public static final int REQUEST_ABOUT_ACTIVITY = 1; + public static final int REQUEST_APP_SETTINGS = 2; + public static final String ACTION_SOS = "cc.winboll.studio.libappbase.WinBoLL.ACTION_SOS"; + private static final int DIALER_REQUEST_CODE = 1; + private static final int REQUEST_REQUIRED_PERMISSIONS = 1002; + private static final int REQUEST_OVERLAY_PERMISSION = 1003; + + // API版本硬编码常量(Java 7兼容,杜绝Build.VERSION_CODES高版本引用) + private static final int ANDROID_6_API = 23; + private static final int ANDROID_8_API = 26; + private static final int ANDROID_10_API = 29; + private static final int ANDROID_14_API = 34; + + // ====================== 2. 静态成员区 ====================== + static MainActivity _MainActivity; + + // ====================== 3. 权限常量区 ====================== + private final String[] REQUIRED_PERMISSIONS = PermissionUtils.BASE_PERMISSIONS; + + // ====================== 4. UI控件成员区 ====================== + private ADsBannerView mADsBannerView; + private LogView mLogView; + private Toolbar mToolbar; + private CheckBox cbMainService; + private TabLayout tabLayout; + private ViewPager viewPager; + private List views; + private ImageView[] imageViews; + private LinearLayout linearLayout; + + // ====================== 5. 业务逻辑成员区 ====================== + private int currentPoint = 0; + private List fragmentList; + private List tabTitleList; + // 记录已初始化的Fragment位置(避免重复初始化) + private boolean[] isFragmentInit; + + // ====================== 6. 接口实现区 ====================== + @Override + public Activity getActivity() { + return this; + } + + @Override + public String getTag() { + return TAG; + } + + // ====================== 7. 生命周期函数区 ====================== + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + LogUtils.d(TAG, "===== onCreate: 主Activity开始创建 ====="); + _MainActivity = this; + + // 直接初始化UI(原权限检查逻辑注释保留,按需启用) + initUIAndLogic(savedInstanceState); + + MainServiceBean mainServiceBean = MainServiceBean.loadBean(this, MainServiceBean.class); + if (mainServiceBean != null && mainServiceBean.isEnable()) { + Intent intent = new Intent(this, MainService.class); + // 根据应用前后台状态选择启动方式(Android 12+ 后台用 startForegroundService) + if (Build.VERSION.SDK_INT >= 31) { + startForegroundService(intent); + } else { + startService(intent); + } + } + LogUtils.d(TAG, "===== onCreate: 主Activity创建流程结束 ====="); + } + + @Override + protected void onPostCreate(Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + LogUtils.d(TAG, "onPostCreate: 主Activity创建完成"); + } + + @Override + protected void onResume() { + super.onResume(); + if (mADsBannerView != null) { + mADsBannerView.resumeADs(MainActivity.this); + LogUtils.d(TAG, "onResume: 广告栏资源已恢复"); + } + } + + @Override + protected void onPause() { + super.onPause(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + LogUtils.d(TAG, "===== onDestroy: 主Activity开始销毁 ====="); + // 释放广告资源 + if (mADsBannerView != null) { + mADsBannerView.releaseAdResources(); + LogUtils.d(TAG, "onDestroy: 广告栏资源已释放"); + } + // 清空Fragment相关引用,避免内存泄漏 + if (fragmentList != null) { + fragmentList.clear(); + fragmentList = null; + } + if (tabTitleList != null) { + tabTitleList.clear(); + tabTitleList = null; + } + isFragmentInit = null; + LogUtils.d(TAG, "===== onDestroy: 主Activity销毁完成 ====="); + } + + // ====================== 8. 权限相关回调函数区 ====================== + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + LogUtils.d(TAG, "onRequestPermissionsResult: 权限请求回调,requestCode=" + requestCode); + + if (requestCode == REQUEST_REQUIRED_PERMISSIONS) { + String deniedPerms = PermissionUtils.getDeniedPermissions(this, permissions); + if (deniedPerms.length() == 0) { + LogUtils.d(TAG, "onRequestPermissionsResult: 所有危险权限授予成功"); + checkAndRequestRemainingPermissions(); + } else { + LogUtils.e(TAG, "onRequestPermissionsResult: 被拒权限:" + deniedPerms); + showPermissionDeniedDialogAndExit("应用需要「" + deniedPerms + "」权限才能正常运行,请授予权限后重新打开应用。"); + } + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + LogUtils.d(TAG, "onActivityResult: 页面回调触发,requestCode=" + requestCode + ",resultCode=" + resultCode); + + switch (requestCode) { + case DIALER_REQUEST_CODE: + if (resultCode == Activity.RESULT_OK) { + LogUtils.d(TAG, "onActivityResult: 设为默认拨号应用成功"); + Toast.makeText(MainActivity.this, getString(R.string.app_name) + " 已成为默认电话应用", Toast.LENGTH_SHORT).show(); + } + break; + case REQUEST_APP_SETTINGS: + LogUtils.d(TAG, "onActivityResult: 从设置页返回,重建Activity"); + recreate(); + break; + case REQUEST_OVERLAY_PERMISSION: + handleOverlayPermissionResult(); + break; + default: + LogUtils.w(TAG, "onActivityResult: 未知requestCode=" + requestCode); + break; + } + } + + /** + * 处理悬浮窗权限申请结果 + */ + private void handleOverlayPermissionResult() { + if (PermissionUtils.isOverlayPermissionGranted(this)) { + LogUtils.d(TAG, "handleOverlayPermissionResult: 悬浮窗权限申请成功"); + LogUtils.d(TAG, "handleOverlayPermissionResult: 所有权限已授予"); + initUIAndLogic(null); + } else { + LogUtils.e(TAG, "handleOverlayPermissionResult: 悬浮窗权限申请失败"); + showPermissionDeniedDialogAndExit("应用需要悬浮窗权限才能展示来电弹窗,请授予后重新打开应用。"); + } + } + + /** + * 检查并申请剩余权限(仅保留悬浮窗) + */ + private void checkAndRequestRemainingPermissions() { + if (!PermissionUtils.isOverlayPermissionGranted(this)) { + LogUtils.d(TAG, "checkAndRequestRemainingPermissions: 悬浮窗权限未授予,跳转设置页"); + PermissionUtils.requestOverlayPermission(this, REQUEST_OVERLAY_PERMISSION); + } else { + LogUtils.d(TAG, "checkAndRequestRemainingPermissions: 所有权限已授予"); + initUIAndLogic(null); + } + } + + /** + * 权限拒绝提示对话框(Java 7 匿名内部类实现,禁止Lambda) + */ + private void showPermissionDeniedDialogAndExit(String tip) { + LogUtils.d(TAG, "showPermissionDeniedDialogAndExit: 弹出权限不足提示框"); + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("权限不足,无法使用"); + builder.setMessage(tip); + builder.setCancelable(false); + + builder.setNegativeButton("去设置", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + LogUtils.d(TAG, "showPermissionDeniedDialogAndExit: 用户选择去设置权限"); + PermissionUtils.goAppDetailsSettings(MainActivity.this); + } + }); + + builder.setPositiveButton("确定退出", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + LogUtils.d(TAG, "showPermissionDeniedDialogAndExit: 用户选择退出应用"); + finishAndRemoveTask(); + } + }); + + builder.show(); + } + + // ====================== 9. UI与业务逻辑初始化区 ====================== + private void initUIAndLogic(Bundle savedInstanceState) { + if (mToolbar != null) { + LogUtils.d(TAG, "initUIAndLogic: UI已初始化,无需重复执行"); + return; + } + + LogUtils.d(TAG, "===== initUIAndLogic: 开始初始化UI与业务逻辑 ====="); + setContentView(R.layout.activity_main); + + // 1. 工具栏初始化 + mToolbar = (Toolbar) findViewById(R.id.activitymainToolbar1); + setSupportActionBar(mToolbar); + getSupportActionBar().setSubtitle(TAG); + LogUtils.d(TAG, "initUIAndLogic: 工具栏初始化完成"); + + // 2. TabLayout与ViewPager初始化 + tabLayout = (TabLayout) findViewById(R.id.tabLayout); + viewPager = (ViewPager) findViewById(R.id.viewPager); + initViewPagerAndTabs(); + tabLayout.setupWithViewPager(viewPager); + LogUtils.d(TAG, "initUIAndLogic: ViewPager与TabLayout初始化完成"); + + // 3. 广告栏初始化 + mADsBannerView = (ADsBannerView) findViewById(R.id.adsbanner); + LogUtils.d(TAG, "initUIAndLogic: 广告栏控件初始化完成"); + + // 左边盾值视图初始化(Java7分步写法,禁止链式调用) + DunTemperatureView tempViewLeft = (DunTemperatureView) findViewById(R.id.dun_temp_view_left); + tempViewLeft.setMaxValue(Rules.getInstance(this).getSettingsModel().getDunTotalCount()); + tempViewLeft.setCurrentValue(Rules.getInstance(this).getSettingsModel().getDunCurrentCount()); + + int[] customColors = new int[2]; + customColors[0] = Color.parseColor("#FF3366FF"); + customColors[1] = Color.parseColor("#FF9900CC"); + float[] positions = new float[2]; + positions[0] = 0.0f; + positions[1] = 1.0f; + tempViewLeft.setGradientColors(customColors, positions); + // 文本放在温度条右侧(默认,可省略) + tempViewLeft.setTextPosition(true); + // 右边盾值视图初始化(Java7分步写法,禁止链式调用) + DunTemperatureView tempViewRight = (DunTemperatureView) findViewById(R.id.dun_temp_view_right); + tempViewRight.setMaxValue(Rules.getInstance(this).getSettingsModel().getDunTotalCount()); + tempViewRight.setCurrentValue(Rules.getInstance(this).getSettingsModel().getDunCurrentCount()); + + tempViewRight.setGradientColors(customColors, positions); + // 文本放在温度条左侧 + tempViewRight.setTextPosition(false); + LogUtils.d(TAG, "initUIAndLogic: 盾值视图初始化完成"); + LogUtils.d(TAG, "===== initUIAndLogic: 初始化流程全部结束 ====="); + } + + /** + * 初始化ViewPager与Tab数据(Java7规范,泛型完整声明),添加懒加载标记 + * 关键修改:延迟50ms初始化首屏,确保Fragment控件就绪;删除setPrimaryItem冲突逻辑 + */ + private void initViewPagerAndTabs() { + LogUtils.d(TAG, "initViewPagerAndTabs: 开始初始化ViewPager数据"); + fragmentList = new ArrayList(); + tabTitleList = new ArrayList(); + + // 添加Fragment实例(仅创建对象,不初始化业务逻辑) + fragmentList.add(CallLogFragment.newInstance(0)); + fragmentList.add(ContactsFragment.newInstance(1)); + fragmentList.add(LogFragment.newInstance(2)); + tabTitleList.add("通话记录"); + tabTitleList.add("联系人"); + tabTitleList.add("应用日志"); + + // 初始化懒加载标记数组(默认均未初始化) + int fragmentCount = fragmentList.size(); + isFragmentInit = new boolean[fragmentCount]; + for (int i = 0; i < fragmentCount; i++) { + isFragmentInit[i] = false; + } + + // 设置自定义适配器(已删除setPrimaryItem,避免初始化冲突) + LazyLoadPagerAdapter adapter = new LazyLoadPagerAdapter(getSupportFragmentManager(), fragmentList, tabTitleList); + viewPager.setAdapter(adapter); + // 关闭预加载(设为0仅加载当前页,关键) + viewPager.setOffscreenPageLimit(0); + viewPager.addOnPageChangeListener(this); + + // 关键优化:延迟50ms初始化首屏(确保Fragment已完成onCreateView,控件绑定就绪) + new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { + @Override + public void run() { + initFragmentByPosition(0); + LogUtils.d(TAG, "initViewPagerAndTabs: 延迟初始化首屏Fragment,位置=0"); + } + }, 50); + + LogUtils.d(TAG, "initViewPagerAndTabs: ViewPager初始化完成,等待延迟初始化首屏"); + } + + /** + * 根据位置初始化Fragment(调用Fragment的初始化逻辑,避免重复执行) + * 优化:添加isAdded判断,确保Fragment已附加到Activity,防止上下文空指针 + */ + private void initFragmentByPosition(int position) { + // 校验位置合法性 + 避免重复初始化 + 确保Fragment已附加到Activity + if (position < 0 || position >= fragmentList.size() || isFragmentInit[position]) { + return; + } + Fragment targetFragment = fragmentList.get(position); + if (targetFragment != null && targetFragment.isAdded()) { + // 触发Fragment初始化(调用各Fragment的initData方法) + if (targetFragment instanceof CallLogFragment) { + ((CallLogFragment) targetFragment).initData(); + } else if (targetFragment instanceof ContactsFragment) { + ((ContactsFragment) targetFragment).initData(); + } else if (targetFragment instanceof LogFragment) { + ((LogFragment) targetFragment).initData(); + } + // 标记为已初始化 + isFragmentInit[position] = true; + LogUtils.d(TAG, "initFragmentByPosition: 初始化Fragment,位置=" + position + ",标题=" + tabTitleList.get(position)); + } else { + LogUtils.w(TAG, "initFragmentByPosition: Fragment未附加到Activity/实例为空,位置=" + position); + } + } + + // ====================== 10. 菜单相关函数区 ====================== + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.toolbar_main, menu); + LogUtils.d(TAG, "onCreateOptionsMenu: 菜单加载完成"); + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.item_settings) { + LogUtils.d(TAG, "onOptionsItemSelected: 用户点击设置菜单"); + startActivity(new Intent(this, SettingsActivity.class)); + return true; + } + return super.onOptionsItemSelected(item); + } + + // ====================== 11. ViewPager页面回调区(切换时初始化对应Fragment) ====================== + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {} + + @Override + public void onPageSelected(int position) { + currentPoint = position; + LogUtils.d(TAG, "onPageSelected: 页面切换至[" + position + "],标题=" + tabTitleList.get(position)); + // 切换页面时,初始化当前页Fragment(未初始化过才执行) + initFragmentByPosition(position); + } + + @Override + public void onPageScrollStateChanged(int state) {} + + @Override + public void onClick(View v) {} + + // ====================== 12. 工具函数区 ====================== + /** + * 拨号工具方法(添加空指针防护) + */ + public static void dialPhoneNumber(String phoneNumber) { + if (_MainActivity == null) { + LogUtils.e(TAG, "dialPhoneNumber: MainActivity实例为空,无法拨号"); + return; + } + if (phoneNumber == null || phoneNumber.trim().isEmpty()) { + LogUtils.e(TAG, "dialPhoneNumber: 拨号号码为空"); + return; + } + if (PermissionUtils.checkPermission(_MainActivity, Manifest.permission.CALL_PHONE)) { + Intent intent = new Intent(Intent.ACTION_DIAL); + intent.setData(Uri.parse("tel:" + phoneNumber)); + LogUtils.d(TAG, "dialPhoneNumber: 发起拨号,号码=" + phoneNumber); + _MainActivity.startActivity(intent); + } else { + LogUtils.e(TAG, "dialPhoneNumber: 拨号权限不足,无法发起拨号"); + Toast.makeText(_MainActivity, "拨号权限不足", Toast.LENGTH_SHORT).show(); + } + } + + /** + * 判断是否为默认拨号应用(适配API30,硬编码版本判断) + */ + public boolean isDefaultPhoneCallApp() { + if (Build.VERSION.SDK_INT >= ANDROID_6_API) { + TelecomManager manager = (TelecomManager) getSystemService(Context.TELECOM_SERVICE); + if (manager != null && manager.getDefaultDialerPackage() != null) { + boolean isDefault = manager.getDefaultDialerPackage().equals(getPackageName()); + LogUtils.d(TAG, "isDefaultPhoneCallApp: 是否为默认拨号应用=" + isDefault); + return isDefault; + } + } + LogUtils.d(TAG, "isDefaultPhoneCallApp: 系统版本低于Android 6,无法判断"); + return false; + } + + /** + * 检查服务是否正在运行(通用工具方法,添加空指针防护) + */ + public boolean isServiceRunning(Class serviceClass) { + if (serviceClass == null) { + LogUtils.e(TAG, "isServiceRunning: 服务类参数为null"); + return false; + } + ActivityManager manager = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE); + if (manager == null) { + LogUtils.w(TAG, "isServiceRunning: ActivityManager获取失败"); + return false; + } + + for (ActivityManager.RunningServiceInfo service : manager.getRunningServices(Integer.MAX_VALUE)) { + if (serviceClass.getName().equals(service.service.getClassName())) { + LogUtils.d(TAG, "isServiceRunning: 服务[" + serviceClass.getSimpleName() + "]正在运行"); + return true; + } + } + LogUtils.d(TAG, "isServiceRunning: 服务[" + serviceClass.getSimpleName() + "]未运行"); + return false; + } + + // ====================== 13. 内部类定义区(Java 7 规范,禁止Lambda) ====================== + /** + * 自定义懒加载ViewPager适配器(删除setPrimaryItem方法,解决首屏初始化冲突) + */ + private class LazyLoadPagerAdapter extends FragmentPagerAdapter { + private final List fragmentList; + private final List tabTitleList; + + public LazyLoadPagerAdapter(FragmentManager fm, List fragmentList, List tabTitleList) { + super(fm); + this.fragmentList = fragmentList; + this.tabTitleList = tabTitleList; + LogUtils.d(MainActivity.TAG, "LazyLoadPagerAdapter: 初始化完成,Fragment数量=" + fragmentList.size()); + } + + @Override + public Fragment getItem(int position) { + return fragmentList.get(position); + } + + @Override + public int getCount() { + return fragmentList.size(); + } + + @Override + public CharSequence getPageTitle(int position) { + return tabTitleList.get(position); + } + + // 【已删除】移除setPrimaryItem方法,避免与手动初始化+onPageSelected回调冲突 + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/activities/AboutActivity.java b/contacts/src/main/java/cc/winboll/studio/contacts/activities/AboutActivity.java new file mode 100644 index 0000000..792d59d --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/activities/AboutActivity.java @@ -0,0 +1,116 @@ +package cc.winboll.studio.contacts.activities; + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.view.ViewGroup; +import android.widget.LinearLayout; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; + +import cc.winboll.studio.contacts.R; +import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity; +import cc.winboll.studio.libaes.models.APPInfo; +import cc.winboll.studio.libaes.utils.WinBoLLActivityManager; +import cc.winboll.studio.libaes.views.AboutView; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/03/31 15:15:54 + * @Describe 应用介绍窗口 + */ +public class AboutActivity extends WinBollActivity implements IWinBoLLActivity { + + // ====================== 常量定义区 ====================== + public static final String TAG = "AboutActivity"; + private static final String BRANCH_NAME = "contacts"; + + // ====================== 成员变量区 ====================== + private Context mContext; + private Toolbar mToolbar; + + // ====================== 接口实现区 ====================== + @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: 关于页面开始创建"); + + mContext = this; + setContentView(R.layout.activity_about); + + // 初始化工具栏 + initToolbar(); + // 初始化关于页面视图 + initAboutView(); + // 注册Activity管理 + WinBoLLActivityManager.getInstance().add(this); + + LogUtils.d(TAG, "onCreate: 关于页面初始化完成"); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + LogUtils.d(TAG, "onDestroy: 关于页面开始销毁"); + WinBoLLActivityManager.getInstance().registeRemove(this); + LogUtils.d(TAG, "onDestroy: 关于页面销毁完成"); + } + + // ====================== 控件初始化函数区 ====================== + private void initToolbar() { + LogUtils.d(TAG, "initToolbar: 初始化工具栏"); + // Java7 适配:添加强制类型转换 + mToolbar = (Toolbar) findViewById(R.id.toolbar); + setSupportActionBar(mToolbar); + mToolbar.setSubtitle(TAG); + // 非空判断,避免空指针异常 + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + } + + private void initAboutView() { + LogUtils.d(TAG, "initAboutView: 初始化关于页面内容视图"); + AboutView aboutView = createAboutView(); + LinearLayout layout = (LinearLayout) findViewById(R.id.aboutviewroot_ll); + + ViewGroup.LayoutParams params = new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ); + layout.addView(aboutView, params); + LogUtils.d(TAG, "initAboutView: AboutView已添加到布局"); + } + + // ====================== 业务逻辑函数区 ====================== + private AboutView createAboutView() { + LogUtils.d(TAG, "createAboutView: 构建APP信息并创建AboutView"); + APPInfo appInfo = new APPInfo(); + appInfo.setAppName("Contacts"); + appInfo.setAppIcon(cc.winboll.studio.libaes.R.drawable.ic_winboll); + appInfo.setAppDescription("这是可以根据正则表达式匹配拦截骚扰电话的手机拨号应用。"); + appInfo.setAppGitName("WinBoLL"); + appInfo.setAppGitOwner("Studio"); + appInfo.setAppGitAPPBranch(BRANCH_NAME); + appInfo.setAppGitAPPSubProjectFolder(BRANCH_NAME); + appInfo.setAppHomePage("https://www.winboll.cc/apks/index.php?project=Contacts"); + appInfo.setAppAPKName("Contacts"); + appInfo.setAppAPKFolderName("Contacts"); + + return new AboutView(mContext, appInfo); + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/activities/CallActivity.java b/contacts/src/main/java/cc/winboll/studio/contacts/activities/CallActivity.java new file mode 100644 index 0000000..b06356d --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/activities/CallActivity.java @@ -0,0 +1,159 @@ +package cc.winboll.studio.contacts.activities; + +import android.Manifest; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.telephony.PhoneStateListener; +import android.telephony.TelephonyManager; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import cc.winboll.studio.contacts.R; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/02/20 17:15:46 + * @Describe 拨号窗口 + */ +public class CallActivity extends AppCompatActivity { + + // ====================== 常量定义区 ====================== + public static final String TAG = "CallActivity"; + private static final int REQUEST_CALL_PHONE = 1; + + // ====================== UI控件区 ====================== + private EditText phoneNumberEditText; + private TextView callStatusTextView; + private Button dialButton; + + // ====================== 业务成员区 ====================== + private TelephonyManager telephonyManager; + private MyPhoneStateListener phoneStateListener; + + // ====================== 生命周期函数区 ====================== + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + LogUtils.d(TAG, "onCreate: 拨号页面开始创建"); + setContentView(R.layout.activity_call); + + // 初始化控件 + initViews(); + // 初始化电话状态监听 + initPhoneStateListener(); + LogUtils.d(TAG, "onCreate: 拨号页面初始化完成"); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + LogUtils.d(TAG, "onDestroy: 拨号页面开始销毁"); + // 取消电话状态监听,避免内存泄漏 + if (telephonyManager != null && phoneStateListener != null) { + telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE); + LogUtils.d(TAG, "onDestroy: 电话状态监听已取消"); + } + LogUtils.d(TAG, "onDestroy: 拨号页面销毁完成"); + } + + // ====================== 权限回调函数区 ====================== + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + LogUtils.d(TAG, "onRequestPermissionsResult: 权限请求回调,requestCode=" + requestCode); + if (requestCode == REQUEST_CALL_PHONE) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + LogUtils.d(TAG, "onRequestPermissionsResult: 拨打电话权限授予成功"); + String phoneNumber = phoneNumberEditText.getText().toString().trim(); + dialPhoneNumber(phoneNumber); + } else { + LogUtils.w(TAG, "onRequestPermissionsResult: 拨打电话权限被拒绝"); + Toast.makeText(this, "未授予拨打电话权限", Toast.LENGTH_SHORT).show(); + } + } + } + + // ====================== 控件初始化函数区 ====================== + private void initViews() { + LogUtils.d(TAG, "initViews: 初始化UI控件"); + // Java7 适配:添加强制类型转换 + phoneNumberEditText = (EditText) findViewById(R.id.phone_number); + dialButton = (Button) findViewById(R.id.dial_button); + callStatusTextView = (TextView) findViewById(R.id.call_status); + + // 设置拨号按钮点击事件 + dialButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + String phoneNumber = phoneNumberEditText.getText().toString().trim(); + LogUtils.d(TAG, "initViews: 拨号按钮点击,号码=" + phoneNumber); + if (phoneNumber.isEmpty()) { + Toast.makeText(CallActivity.this, "请输入电话号码", Toast.LENGTH_SHORT).show(); + return; + } + + // 权限检查 + if (ContextCompat.checkSelfPermission(CallActivity.this, Manifest.permission.CALL_PHONE) + != PackageManager.PERMISSION_GRANTED) { + LogUtils.w(TAG, "initViews: 拨打电话权限未授予,发起权限申请"); + ActivityCompat.requestPermissions(CallActivity.this, + new String[]{Manifest.permission.CALL_PHONE}, + REQUEST_CALL_PHONE); + } else { + dialPhoneNumber(phoneNumber); + } + } + }); + } + + // ====================== 电话状态监听初始化函数区 ====================== + private void initPhoneStateListener() { + LogUtils.d(TAG, "initPhoneStateListener: 初始化电话状态监听"); + telephonyManager = (TelephonyManager) getSystemService(TELEPHONY_SERVICE); + phoneStateListener = new MyPhoneStateListener(); + telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE); + } + + // ====================== 核心业务函数区 ====================== + private void dialPhoneNumber(String phoneNumber) { + LogUtils.d(TAG, "dialPhoneNumber: 发起拨号,号码=" + phoneNumber); + Intent intent = new Intent(Intent.ACTION_CALL); + intent.setData(android.net.Uri.parse("tel:" + phoneNumber)); + if (ActivityCompat.checkSelfPermission(this, Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) { + LogUtils.e(TAG, "dialPhoneNumber: 拨打电话权限缺失,拨号失败"); + return; + } + startActivity(intent); + } + + // ====================== 内部电话状态监听类 ====================== + private class MyPhoneStateListener extends PhoneStateListener { + @Override + public void onCallStateChanged(int state, String incomingNumber) { + super.onCallStateChanged(state, incomingNumber); + switch (state) { + case TelephonyManager.CALL_STATE_IDLE: + callStatusTextView.setText("电话已挂断"); + LogUtils.d(TAG, "MyPhoneStateListener: 通话状态-挂断"); + break; + case TelephonyManager.CALL_STATE_OFFHOOK: + callStatusTextView.setText("正在通话中"); + LogUtils.d(TAG, "MyPhoneStateListener: 通话状态-通话中"); + break; + case TelephonyManager.CALL_STATE_RINGING: + callStatusTextView.setText("来电: " + incomingNumber); + LogUtils.d(TAG, "MyPhoneStateListener: 通话状态-来电,号码=" + incomingNumber); + break; + } + } + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/activities/DialerActivity.java b/contacts/src/main/java/cc/winboll/studio/contacts/activities/DialerActivity.java new file mode 100644 index 0000000..0fc4134 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/activities/DialerActivity.java @@ -0,0 +1,80 @@ +package cc.winboll.studio.contacts.activities; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.Toast; +import androidx.appcompat.app.AppCompatActivity; +import cc.winboll.studio.contacts.R; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/02/20 20:18:26 + * @Describe 拨号盘窗口(跳转到系统拨号界面) + */ +public class DialerActivity extends AppCompatActivity { + + // ====================== 常量定义区 ====================== + public static final String TAG = "DialerActivity"; + + // ====================== UI控件区 ====================== + private EditText phoneNumberEditText; + private Button dialButton; + + // ====================== 生命周期函数区 ====================== + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + LogUtils.d(TAG, "onCreate: 拨号盘页面开始创建"); + setContentView(R.layout.activity_dialer); + + // 初始化UI控件与点击事件 + initViews(); + LogUtils.d(TAG, "onCreate: 拨号盘页面初始化完成"); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + LogUtils.d(TAG, "onDestroy: 拨号盘页面已销毁"); + } + + // ====================== 控件初始化函数区 ====================== + private void initViews() { + LogUtils.d(TAG, "initViews: 初始化UI控件"); + // Java7 适配:添加强制类型转换 + phoneNumberEditText = (EditText) findViewById(R.id.phone_number_edit_text); + dialButton = (Button) findViewById(R.id.dial_button); + + // 设置拨号按钮点击事件 + dialButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + String phoneNumber = phoneNumberEditText.getText().toString().trim(); + LogUtils.d(TAG, "initViews: 拨号按钮点击,输入号码=" + phoneNumber); + + // 空号码校验 + if (phoneNumber.isEmpty()) { + LogUtils.w(TAG, "initViews: 拨号失败,号码为空"); + Toast.makeText(DialerActivity.this, "请输入有效电话号码", Toast.LENGTH_SHORT).show(); + return; + } + + // 跳转到系统拨号界面 + Intent intent = new Intent(Intent.ACTION_DIAL, Uri.parse("tel:" + phoneNumber)); + if (intent.resolveActivity(getPackageManager()) != null) { + startActivity(intent); + LogUtils.d(TAG, "initViews: 成功跳转到系统拨号界面"); + } else { + LogUtils.e(TAG, "initViews: 跳转失败,无可用拨号应用"); + Toast.makeText(DialerActivity.this, "未找到可用拨号应用", Toast.LENGTH_SHORT).show(); + } + } + }); + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/activities/SettingsActivity.java b/contacts/src/main/java/cc/winboll/studio/contacts/activities/SettingsActivity.java new file mode 100644 index 0000000..4ca030b --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/activities/SettingsActivity.java @@ -0,0 +1,613 @@ +package cc.winboll.studio.contacts.activities; + +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.media.AudioManager; +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.EditText; +import android.widget.SeekBar; +import android.widget.Switch; +import android.widget.TextView; +import android.widget.Toast; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import cc.winboll.studio.contacts.R; +import cc.winboll.studio.contacts.adapters.PhoneConnectRuleAdapter; +import cc.winboll.studio.contacts.bobulltoon.TomCat; +import cc.winboll.studio.contacts.dun.Rules; +import cc.winboll.studio.contacts.model.MainServiceBean; +import cc.winboll.studio.contacts.model.PhoneConnectRuleBean; +import cc.winboll.studio.contacts.model.RingTongBean; +import cc.winboll.studio.contacts.model.SettingsBean; +import cc.winboll.studio.contacts.services.MainService; +import cc.winboll.studio.contacts.views.DuInfoTextView; +import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity; +import cc.winboll.studio.libaes.utils.WinBoLLActivityManager; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import java.lang.reflect.Field; +import java.util.List; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/02/21 05:37:42 + * @Describe Contacts 设置页面(完全适配 API 30 + Java 7 语法) + * 核心优化:1. 移除高版本API依赖 2. Java7规范写法 3. 强化内存泄漏防护 4. 版本判断硬编码 5. LogUtils统一日志管理 + */ +public class SettingsActivity extends WinBollActivity implements IWinBoLLActivity { + + // ====================== 常量定义区(置顶,统一管理) ====================== + public static final String TAG = "SettingsActivity"; + // API版本硬编码(替代Build.VERSION_CODES,适配Java7) + private static final int ANDROID_6_API = 23; + + // ====================== 静态成员属性区 ====================== + private static DuInfoTextView sDuInfoTextView; // 规范命名:静态属性加s前缀 + + // ====================== 数据业务属性区 ====================== + private int mStreamMaxVolume; // 铃音最大音量 + private int mStreamVolume; // 当前铃音音量 + private List mRuleList; // 通话规则列表 + private PhoneConnectRuleAdapter mRuleAdapter; // 规则列表适配器 + + // ====================== UI控件属性区(统一归类,规范命名) ====================== + private Toolbar mToolbar; // 顶部工具栏 + private Switch mSwMainService; // 主服务开关 + private SeekBar mSbVolume; // 音量调节条 + private TextView mTvVolume; // 音量显示文本 + private Switch mSwEnableDun; // 云盾功能开关 + private EditText mEtDunTotalCount; // 云盾总次数输入框 + private EditText mEtDunResumeSecondCount; // 云盾恢复秒数输入框 + private EditText mEtDunResumeCount; // 云盾恢复次数输入框 + private RecyclerView mRvRuleList; // 规则列表RecyclerView + private EditText mEtBoBullToonUrl; // BoBullToon地址输入框 + private EditText mEtSearchPhone; // 号码查询输入框 + + // ====================== 接口实现区(IWinBoLLActivity规范实现) ====================== + @Override + public AppCompatActivity 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_settings); + + // 初始化核心流程(按优先级执行) + initToolbar(); // 工具栏初始化(优先) + initMainServiceSwitch();// 主服务开关初始化 + initVolumeControl(); // 音量控制初始化 + initRuleRecyclerView(); // 规则列表初始化 + initDunSettings(); // 云盾设置初始化 + initBoBullToonViews(); // BoBullToon功能初始化 + + LogUtils.d(TAG, "onCreate: 设置页面初始化完成"); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + LogUtils.d(TAG, "onDestroy: 设置页面销毁"); + // 内存泄漏防护:清空所有引用(静态+成员+UI) + sDuInfoTextView = null; + mRuleList = null; + mRuleAdapter = null; + mToolbar = null; + mSwMainService = null; + mSbVolume = null; + mTvVolume = null; + mSwEnableDun = null; + mEtDunTotalCount = null; + mEtDunResumeSecondCount = null; + mEtDunResumeCount = null; + mRvRuleList = null; + mEtBoBullToonUrl = null; + mEtSearchPhone = null; + LogUtils.d(TAG, "onDestroy: 设置页面资源清理完成"); + } + + // ====================== 初始化函数区(按功能模块归类) ====================== + /** + * 初始化顶部工具栏(后退按钮+标题) + */ + private void initToolbar() { + LogUtils.d(TAG, "initToolbar: 初始化工具栏"); + mToolbar = (Toolbar) findViewById(R.id.activitymainToolbar1); + setSupportActionBar(mToolbar); + + // 显示后退按钮(空指针防护) + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setSubtitle(TAG); + } + + // 后退按钮点击事件(Java7匿名内部类) + mToolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "initToolbar: 点击后退按钮,关闭页面"); + finish(); + } + }); + } + + /** + * 初始化主服务开关(联动MainService启停) + */ + private void initMainServiceSwitch() { + LogUtils.d(TAG, "initMainServiceSwitch: 初始化主服务开关"); + mSwMainService = (Switch) findViewById(R.id.sw_mainservice); + MainServiceBean serviceBean = MainServiceBean.loadBean(this, MainServiceBean.class); + + // 加载开关状态(空指针防护) + boolean isServiceEnable = serviceBean != null && serviceBean.isEnable(); + mSwMainService.setChecked(isServiceEnable); + LogUtils.d(TAG, "initMainServiceSwitch: 主服务当前状态:" + (isServiceEnable ? "启用" : "禁用")); + + // 开关点击事件 + mSwMainService.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + boolean isChecked = mSwMainService.isChecked(); + LogUtils.d(TAG, "initMainServiceSwitch: 主服务开关切换:" + (isChecked ? "启用" : "禁用")); + if (isChecked) { + MainService.startMainServiceAndSaveStatus(SettingsActivity.this); + } else { + MainService.stopMainServiceAndSaveStatus(SettingsActivity.this); + } + } + }); + } + + /** + * 初始化音量控制(SeekBar+音量显示+配置保存) + */ + private void initVolumeControl() { + LogUtils.d(TAG, "initVolumeControl: 初始化音量控制"); + mSbVolume = (SeekBar) findViewById(R.id.bellvolume); + mTvVolume = (TextView) findViewById(R.id.tv_volume); + final AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + + // 空指针防护:AudioManager获取失败直接返回 + if (audioManager == null) { + LogUtils.e(TAG, "initVolumeControl: AudioManager获取失败,音量控制初始化失败"); + return; + } + + // 初始化音量参数 + mStreamMaxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_RING); + mStreamVolume = audioManager.getStreamVolume(AudioManager.STREAM_RING); + mSbVolume.setMax(mStreamMaxVolume); + mSbVolume.setProgress(mStreamVolume); + updateVolumeDisplay(); // 更新音量文本显示 + + // 音量调节监听 + mSbVolume.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (fromUser) { + LogUtils.d(TAG, "initVolumeControl: 音量调节至:" + progress + "/" + mStreamMaxVolume); + // 实时更新系统音量+保存配置 + audioManager.setStreamVolume(AudioManager.STREAM_RING, progress, 0); + RingTongBean ringBean = RingTongBean.loadBean(SettingsActivity.this, RingTongBean.class); + if (ringBean == null) { + ringBean = new RingTongBean(); + } + ringBean.setStreamVolume(progress); + RingTongBean.saveBean(SettingsActivity.this, ringBean); + mStreamVolume = progress; + updateVolumeDisplay(); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) {} + + @Override + public void onStopTrackingTouch(SeekBar seekBar) {} + }); + } + + /** + * 初始化通话规则列表(加载黑白名单规则) + */ + private void initRuleRecyclerView() { + LogUtils.d(TAG, "initRuleRecyclerView: 初始化规则列表"); + mRvRuleList = (RecyclerView) findViewById(R.id.recycler_view); + mRvRuleList.setLayoutManager(new LinearLayoutManager(this)); + + // 加载规则数据 + Rules rules = Rules.getInstance(this); + if (rules == null) { + LogUtils.e(TAG, "initRuleRecyclerView: Rules实例获取失败,列表初始化失败"); + return; + } + mRuleList = rules.getPhoneBlacRuleBeanList(); + mRuleAdapter = new PhoneConnectRuleAdapter(this, mRuleList); + mRvRuleList.setAdapter(mRuleAdapter); + LogUtils.d(TAG, "initRuleRecyclerView: 规则列表加载完成,共" + mRuleList.size() + "条规则"); + } + + /** + * 初始化云盾设置(参数加载+开关联动) + */ + private void initDunSettings() { + LogUtils.d(TAG, "initDunSettings: 初始化云盾设置"); + sDuInfoTextView = (DuInfoTextView) findViewById(R.id.tv_DunInfo); + mSwEnableDun = (Switch) findViewById(R.id.sw_IsEnableDun); + mEtDunTotalCount = (EditText) findViewById(R.id.et_DunTotalCount); + mEtDunResumeSecondCount = (EditText) findViewById(R.id.et_DunResumeSecondCount); + mEtDunResumeCount = (EditText) findViewById(R.id.et_DunResumeCount); + + // 加载云盾配置 + Rules rules = Rules.getInstance(this); + if (rules == null) { + LogUtils.e(TAG, "initDunSettings: Rules实例获取失败,云盾初始化失败"); + return; + } + SettingsBean dunSettings = rules.getSettingsModel(); + if (dunSettings == null) { + LogUtils.e(TAG, "initDunSettings: 云盾配置获取失败"); + return; + } + + // 填充配置参数 + mEtDunTotalCount.setText(String.valueOf(dunSettings.getDunTotalCount())); + mEtDunResumeSecondCount.setText(String.valueOf(dunSettings.getDunResumeSecondCount())); + mEtDunResumeCount.setText(String.valueOf(dunSettings.getDunResumeCount())); + mSwEnableDun.setChecked(dunSettings.isEnableDun()); + + // 开关联动:启用云盾时禁用参数编辑 + boolean isDunEnable = dunSettings.isEnableDun(); + mEtDunTotalCount.setEnabled(!isDunEnable); + mEtDunResumeSecondCount.setEnabled(!isDunEnable); + mEtDunResumeCount.setEnabled(!isDunEnable); + LogUtils.d(TAG, "initDunSettings: 云盾当前状态:" + (isDunEnable ? "启用" : "禁用")); + } + + /** + * 初始化BoBullToon功能(地址配置+号码查询) + */ + private void initBoBullToonViews() { + LogUtils.d(TAG, "initBoBullToonViews: 初始化BoBullToon功能"); + mEtBoBullToonUrl = (EditText) findViewById(R.id.bobulltoonurl_et); + mEtSearchPhone = (EditText) findViewById(R.id.activitysettingsEditText1); + + // 加载保存的地址 + Rules rules = Rules.getInstance(this); + if (rules != null) { + mEtBoBullToonUrl.setText(rules.getBoBullToonURL()); + LogUtils.d(TAG, "initBoBullToonViews: 加载BoBullToon地址完成"); + } else { + LogUtils.e(TAG, "initBoBullToonViews: Rules实例获取失败,地址加载失败"); + } + } + + // ====================== 点击事件回调区(按功能模块归类) ====================== + /** + * 云盾开关点击事件(联动参数编辑权限+配置保存) + */ + public void onSW_IsEnableDun(View view) { + boolean isChecked = mSwEnableDun.isChecked(); + LogUtils.d(TAG, "onSW_IsEnableDun: 云盾开关切换:" + (isChecked ? "启用" : "禁用")); + + // 联动参数编辑权限 + mEtDunTotalCount.setEnabled(!isChecked); + mEtDunResumeSecondCount.setEnabled(!isChecked); + mEtDunResumeCount.setEnabled(!isChecked); + + // 保存配置 + Rules rules = Rules.getInstance(this); + if (rules == null) { + LogUtils.e(TAG, "onSW_IsEnableDun: Rules实例获取失败,配置保存失败"); + mSwEnableDun.setChecked(false); + return; + } + SettingsBean dunSettings = rules.getSettingsModel(); + if (dunSettings == null) { + LogUtils.e(TAG, "onSW_IsEnableDun: 云盾配置获取失败,保存失败"); + mSwEnableDun.setChecked(false); + return; + } + + // 启用云盾时校验参数合法性 + if (isChecked) { + try { + String totalCountStr = mEtDunTotalCount.getText().toString().trim(); + String resumeSecStr = mEtDunResumeSecondCount.getText().toString().trim(); + String resumeCountStr = mEtDunResumeCount.getText().toString().trim(); + + // 空参数校验 + if (totalCountStr.isEmpty() || resumeSecStr.isEmpty() || resumeCountStr.isEmpty()) { + throw new NumberFormatException("参数不能为空"); + } + + // 转换参数并保存 + int totalCount = Integer.parseInt(totalCountStr); + int resumeSec = Integer.parseInt(resumeSecStr); + int resumeCount = Integer.parseInt(resumeCountStr); + dunSettings.setDunTotalCount(totalCount); + dunSettings.setDunResumeSecondCount(resumeSec); + dunSettings.setDunResumeCount(resumeCount); + LogUtils.d(TAG, "onSW_IsEnableDun: 云盾参数保存完成,总次数:" + totalCount + ",恢复秒数:" + resumeSec); + + // 提示信息 + String toastMsg = totalCount == 1 ? "电话骚扰防御力几乎为0" : "连拨" + totalCount + "次后接通电话"; + ToastUtils.show(toastMsg); + } catch (NumberFormatException e) { + LogUtils.e(TAG, "onSW_IsEnableDun: 云盾参数格式错误", e); + ToastUtils.show("参数格式错误,请输入整数"); + mSwEnableDun.setChecked(false); + return; + } + } + + // 保存开关状态并刷新配置 + dunSettings.setIsEnableDun(isChecked); + rules.saveDun(); + rules.reload(); + LogUtils.d(TAG, "onSW_IsEnableDun: 云盾配置保存完成"); + } + + /** + * 添加新通话规则(黑白名单) + */ + public void onAddNewConnectionRule(View view) { + LogUtils.d(TAG, "onAddNewConnectionRule: 添加新通话规则"); + Rules rules = Rules.getInstance(this); + if (rules == null) { + LogUtils.e(TAG, "onAddNewConnectionRule: Rules实例获取失败,添加失败"); + return; + } + mRuleList.add(new PhoneConnectRuleBean()); + rules.saveRules(); + mRuleAdapter.notifyDataSetChanged(); + LogUtils.d(TAG, "onAddNewConnectionRule: 规则添加完成,当前共" + mRuleList.size() + "条规则"); + } + + /** + * 跳转默认电话应用设置 + */ + public void onDefaultPhone(View view) { + LogUtils.d(TAG, "onDefaultPhone: 跳转默认电话应用设置"); + startActivity(new Intent(Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS)); + } + + /** + * 悬浮窗权限检查与请求 + */ + public void onCanDrawOverlays(View view) { + LogUtils.d(TAG, "onCanDrawOverlays: 检查悬浮窗权限"); + // API6.0+校验权限 + if (Build.VERSION.SDK_INT >= ANDROID_6_API && !Settings.canDrawOverlays(this)) { + LogUtils.d(TAG, "onCanDrawOverlays: 未开启悬浮窗权限,发起请求"); + showDrawOverlayRequestDialog(); + } else { + ToastUtils.show("悬浮窗权限已开启"); + } + } + + /** + * 清理BoBullToon本地数据 + */ + public void onCleanBoBullToonData(View view) { + LogUtils.d(TAG, "onCleanBoBullToonData: 清理BoBullToon数据"); + TomCat tomCat = TomCat.getInstance(this); + if (tomCat != null) { + tomCat.cleanBoBullToon(); + ToastUtils.show("BoBullToon数据已清理"); + LogUtils.d(TAG, "onCleanBoBullToonData: 数据清理完成"); + } else { + LogUtils.e(TAG, "onCleanBoBullToonData: TomCat实例获取失败,清理失败"); + } + } + + /** + * 重置BoBullToon默认地址 + */ + public void onResetBoBullToonURL(View view) { + LogUtils.d(TAG, "onResetBoBullToonURL: 重置BoBullToon地址"); + Rules rules = Rules.getInstance(this); + if (rules == null) { + LogUtils.e(TAG, "onResetBoBullToonURL: Rules实例获取失败,重置失败"); + return; + } + rules.resetDefaultBoBullToonURL(); + mEtBoBullToonUrl.setText(rules.getBoBullToonURL()); + ToastUtils.show("BoBullToon地址已重置为默认"); + LogUtils.d(TAG, "onResetBoBullToonURL: 地址重置完成"); + } + + /** + * 下载BoBullToon数据(子线程执行,避免阻塞UI) + */ + public void onDownloadBoBullToon(View view) { + LogUtils.d(TAG, "onDownloadBoBullToon: 开始下载BoBullToon数据"); + Rules rules = Rules.getInstance(this); + if (rules == null) { + LogUtils.e(TAG, "onDownloadBoBullToon: Rules实例获取失败,下载失败"); + return; + } + + // 校验并更新地址 + String inputUrl = mEtBoBullToonUrl.getText().toString().trim(); + String savedUrl = rules.getBoBullToonURL(); + if (!inputUrl.equals(savedUrl)) { + rules.setBoBullToonURL(inputUrl); + LogUtils.d(TAG, "onDownloadBoBullToon: BoBullToon地址更新为:" + inputUrl); + } + + // 子线程下载(Java7匿名内部类) + final TomCat tomCat = TomCat.getInstance(this); + new Thread(new Runnable() { + @Override + public void run() { + boolean downloadSuccess = tomCat != null && tomCat.downloadBoBullToon(); + if (downloadSuccess) { + LogUtils.d(TAG, "onDownloadBoBullToon: 数据下载成功"); + // 主线程更新UI + runOnUiThread(new Runnable() { + @Override + public void run() { + ToastUtils.show("BoBullToon下载成功"); + } + }); + // 重启主服务+刷新配置 + MainService.restartMainService(SettingsActivity.this); + Rules.getInstance(SettingsActivity.this).reload(); + } else { + LogUtils.e(TAG, "onDownloadBoBullToon: 数据下载失败"); + runOnUiThread(new Runnable() { + @Override + public void run() { + ToastUtils.show("BoBullToon下载失败"); + } + }); + } + } + }).start(); + } + + /** + * 查询号码是否为BoBullToon号码 + */ + public void onSearchBoBullToonPhone(View view) { + LogUtils.d(TAG, "onSearchBoBullToonPhone: 执行号码查询"); + String phone = mEtSearchPhone.getText().toString().trim(); + // 空号码校验 + if (phone.isEmpty()) { + LogUtils.w(TAG, "onSearchBoBullToonPhone: 查询号码为空,取消查询"); + ToastUtils.show("请输入查询号码"); + return; + } + + // 执行查询 + TomCat tomCat = TomCat.getInstance(this); + if (tomCat == null || !tomCat.loadPhoneBoBullToon()) { + LogUtils.w(TAG, "onSearchBoBullToonPhone: BoBullToon数据未加载,查询失败"); + ToastUtils.show("请先下载BoBullToon数据"); + return; + } + + boolean isBoBullToon = tomCat.isPhoneBoBullToon(phone); + String resultMsg = isBoBullToon ? "是BoBullToon号码" : "非BoBullToon号码"; + ToastUtils.show(resultMsg); + LogUtils.d(TAG, "onSearchBoBullToonPhone: 号码" + phone + "查询结果:" + resultMsg); + } + + /** + * 跳转单元测试页面 + */ + public void onUnitTest(View view) { + LogUtils.d(TAG, "onUnitTest: 跳转单元测试页面"); + startActivity(new Intent(this, UnitTestActivity.class)); + } + + /** + * 跳转关于页面 + */ + public void onAbout(View view) { + LogUtils.d(TAG, "onAbout: 跳转关于页面"); + WinBoLLActivityManager.getInstance().startWinBoLLActivity(this, AboutActivity.class); + } + + /** + * 跳转日志查看页面 + */ + public void onLogView(View view) { + LogUtils.d(TAG, "onLogView: 跳转日志页面"); + WinBoLLActivityManager.getInstance().startLogActivity(this); + } + + // ====================== 工具方法区(通用功能+权限相关) ====================== + /** + * 更新音量显示文本(当前音量/最大音量) + */ + private void updateVolumeDisplay() { + mTvVolume.setText(mStreamVolume + "/" + mStreamMaxVolume); + } + + /** + * 显示悬浮窗权限请求对话框 + */ + 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(); + } + } + + // ====================== 静态通知方法区(云盾信息更新) ====================== + /** + * 通知云盾信息刷新(外部调用) + */ + public static void notifyDunInfoUpdate() { + if (sDuInfoTextView != null) { + LogUtils.d(TAG, "notifyDunInfoUpdate: 刷新云盾信息显示"); + sDuInfoTextView.notifyInfoUpdate(); + } else { + LogUtils.w(TAG, "notifyDunInfoUpdate: 云盾信息控件未初始化,刷新失败"); + } + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/activities/UnitTestActivity.java b/contacts/src/main/java/cc/winboll/studio/contacts/activities/UnitTestActivity.java new file mode 100644 index 0000000..7a60d28 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/activities/UnitTestActivity.java @@ -0,0 +1,145 @@ +package cc.winboll.studio.contacts.activities; + +import android.os.Bundle; +import android.view.View; +import android.widget.EditText; +import androidx.appcompat.app.AppCompatActivity; +import cc.winboll.studio.contacts.R; +import cc.winboll.studio.contacts.dun.Rules; +import cc.winboll.studio.contacts.utils.IntUtils; +import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.LogView; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/03/02 16:07:04 + * @Describe 规则单元测试页面 + */ +public class UnitTestActivity extends WinBollActivity implements IWinBoLLActivity { + + // ====================== 常量定义区 ====================== + public static final String TAG = "UnitTestActivity"; + + // ====================== UI控件区 ====================== + private LogView logView; + private EditText etPhone; + + // ====================== 接口实现区 ====================== + @Override + public AppCompatActivity 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_unittest); + + // 初始化控件 + initViews(); + LogUtils.d(TAG, "onCreate: 单元测试页面初始化完成"); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + LogUtils.d(TAG, "onDestroy: 单元测试页面开始销毁"); + if (logView != null) { + // 若LogView有停止方法,建议调用避免资源泄漏 + // logView.stop(); + LogUtils.d(TAG, "onDestroy: LogView资源已处理"); + } + LogUtils.d(TAG, "onDestroy: 单元测试页面销毁完成"); + } + + // ====================== 控件初始化函数区 ====================== + private void initViews() { + LogUtils.d(TAG, "initViews: 初始化UI控件"); + // Java7 适配:添加强制类型转换 + logView = (LogView) findViewById(R.id.logview); + etPhone = (EditText) findViewById(R.id.phone_et); + + // 启动日志视图 + logView.start(); + LogUtils.d(TAG, "initViews: LogView已启动"); + } + + // ====================== 点击事件测试函数区 ====================== + /** + * 测试单个号码匹配规则 + */ + public void onTestPhone(View view) { + LogUtils.d(TAG, "onTestPhone: 开始测试单个号码规则匹配"); + String phone = etPhone.getText().toString().trim(); + if (phone.isEmpty()) { + LogUtils.w(TAG, "onTestPhone: 测试号码为空,跳过匹配"); + return; + } + + Rules rules = Rules.getInstance(this); + boolean isAllowed = rules.isAllowed(phone); + LogUtils.d(TAG, String.format("onTestPhone: 测试号码: %s | 匹配结果: %s", phone, isAllowed)); + } + + /** + * 批量测试预设号码规则匹配 + */ + public void onTestMain(View view) { + LogUtils.d(TAG, "onTestMain: 开始批量测试号码规则匹配"); + // 测试IntUtils工具类方法 + LogUtils.d(TAG, "onTestMain: 执行 IntUtils.unittest_getIntInRange() 测试"); + IntUtils.unittest_getIntInRange(); + + // 初始化规则实例 + Rules rules = Rules.getInstance(this); + // 无规则时添加测试规则集 + initTestRulesIfEmpty(rules); + + // 预设测试号码列表 + String[] testPhones = { + "16769764848", "16856582777", "17519703124", + "0205658955", "0108965253", "+8616769764848", + "4005816769764848", "95566" + }; + + // 遍历测试号码并输出结果 + for (String phone : testPhones) { + boolean isAllowed = rules.isAllowed(phone); + LogUtils.d(TAG, String.format("onTestMain: 测试号码: %s | 匹配结果: %s", phone, isAllowed)); + } + LogUtils.d(TAG, "onTestMain: 批量号码规则测试完成"); + } + + // ====================== 私有工具函数区 ====================== + /** + * 规则集为空时初始化测试规则 + */ + private void initTestRulesIfEmpty(Rules rules) { + if (rules.getPhoneBlacRuleBeanList().size() == 0) { + LogUtils.d(TAG, "initTestRulesIfEmpty: 当前无规则,添加测试规则集"); + // 规则1:中国手机号允许 + rules.add("^1[3-9]\\d{9}$", true, true); + // 规则2:0660区号号码允许 + rules.add("^0660\\d+$", true, true); + // 规则3:020区号号码允许 + rules.add("^020\\d+$", true, true); + // 规则4:默认拒接所有号码 + rules.add(".*", false, true); + + // 保存规则到本地 + rules.saveRules(); + LogUtils.d(TAG, "initTestRulesIfEmpty: 测试规则集已保存"); + } else { + LogUtils.d(TAG, "initTestRulesIfEmpty: 当前已有规则,跳过初始化"); + } + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/activities/WinBollActivity.java b/contacts/src/main/java/cc/winboll/studio/contacts/activities/WinBollActivity.java new file mode 100644 index 0000000..8b640eb --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/activities/WinBollActivity.java @@ -0,0 +1,84 @@ +package cc.winboll.studio.contacts.activities; + +import android.app.Activity; +import android.os.Bundle; +import android.view.MenuItem; +import androidx.appcompat.app.AppCompatActivity; +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.libappbase.LogUtils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/03/31 15:16:45 + * @Describe 应用窗口基类,统一处理主题设置与导航返回 + */ +public class WinBollActivity extends AppCompatActivity implements IWinBoLLActivity { + + // ====================== 常量定义区 ====================== + public static final String TAG = "WinBollActivity"; + + // ====================== 成员变量区 ====================== + protected volatile AESThemeBean.ThemeType mThemeType; + + // ====================== 接口实现区 ====================== + @Override + public Activity getActivity() { + return this; + } + + @Override + public String getTag() { + return TAG; + } + + // ====================== 生命周期函数区 ====================== + @Override + protected void onCreate(Bundle savedInstanceState) { + //LogUtils.d(TAG, "onCreate: 基类页面开始创建"); + // 优先设置主题,再执行父类初始化 +// mThemeType = getThemeType(); +// setThemeStyle(); + super.onCreate(savedInstanceState); + //LogUtils.d(TAG, "onCreate: 基类主题设置完成,当前主题类型=" + mThemeType); + } + + // ====================== 主题相关函数区 ====================== + /** + * 获取当前应用主题类型 + */ + AESThemeBean.ThemeType getThemeType() { + LogUtils.d(TAG, "getThemeType: 获取应用主题类型"); + // 注释的SharedPreferences逻辑保留,便于后续扩展 + /*SharedPreferences sharedPreferences = getSharedPreferences( + SHAREDPREFERENCES_NAME, MODE_PRIVATE); + return AESThemeBean.ThemeType.values()[((sharedPreferences.getInt(DRAWER_THEME_TYPE, AESThemeBean.ThemeType.DEFAULT.ordinal())))]; + */ + return AESThemeBean.getThemeStyleType(AESThemeUtil.getThemeTypeID(getApplicationContext())); + } + + /** + * 应用当前主题样式 + */ + void setThemeStyle() { + LogUtils.d(TAG, "setThemeStyle: 开始设置应用主题"); + // 替换原注释逻辑,使用AESThemeUtil获取的主题ID + setTheme(AESThemeUtil.getThemeTypeID(getApplicationContext())); + LogUtils.d(TAG, "setThemeStyle: 主题设置完成"); + } + + // ====================== 菜单与导航函数区 ====================== + @Override + public boolean onOptionsItemSelected(MenuItem item) { + LogUtils.d(TAG, "onOptionsItemSelected: 菜单选项点击,itemId=" + item.getItemId()); + // 处理导航栏返回按钮点击事件 +// if (item.getItemId() == android.R.id.home) { +// LogUtils.d(TAG, "onOptionsItemSelected: 点击导航返回按钮,关闭当前页面"); +// finish(); +// return true; +// } + return super.onOptionsItemSelected(item); + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/adapters/CallLogAdapter.java b/contacts/src/main/java/cc/winboll/studio/contacts/adapters/CallLogAdapter.java new file mode 100644 index 0000000..a8539a0 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/adapters/CallLogAdapter.java @@ -0,0 +1,174 @@ +package cc.winboll.studio.contacts.adapters; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.PopupMenu; +import android.widget.TextView; +import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import cc.winboll.studio.contacts.R; +import cc.winboll.studio.contacts.model.CallLogModel; +import cc.winboll.studio.contacts.utils.ContactUtils; +import cc.winboll.studio.libaes.views.AOHPCTCSeekBar; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import java.text.SimpleDateFormat; +import java.util.List; +import java.util.Locale; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/02/26 13:09:32 + * @Describe 通话记录列表适配器 + */ +public class CallLogAdapter extends RecyclerView.Adapter { + + // ====================== 常量定义区 ====================== + public static final String TAG = "CallLogAdapter"; + private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()); + + // ====================== 成员变量区 ====================== + private Context mContext; + private List callLogList; + private ContactUtils mContactUtils; + + // ====================== 构造函数区 ====================== + public CallLogAdapter(Context context, List callLogList) { + LogUtils.d(TAG, "CallLogAdapter: 初始化适配器,数据量=" + callLogList.size()); + this.mContext = context; + this.callLogList = callLogList; + this.mContactUtils = ContactUtils.getInstance(mContext); + } + + // ====================== 公共方法区 ====================== + /** + * 重新加载联系人数据 + */ + public void relaodContacts() { + LogUtils.d(TAG, "relaodContacts: 开始重新加载联系人数据"); + this.mContactUtils.reloadContacts(); + notifyDataSetChanged(); + LogUtils.d(TAG, "relaodContacts: 联系人数据加载完成,列表已刷新"); + } + + // ====================== RecyclerView 重写方法区 ====================== + @NonNull + @Override + public CallLogViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + LogUtils.d(TAG, "onCreateViewHolder: 创建列表项ViewHolder"); + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_call_log, parent, false); + return new CallLogViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull CallLogViewHolder holder, int position) { + LogUtils.d(TAG, "onBindViewHolder: 绑定列表项数据,position=" + position); + final CallLogModel callLog = callLogList.get(position); + + // 绑定通话号码与联系人名称 + String contactName = mContactUtils.getContactName(callLog.getPhoneNumber()); + String phoneText = callLog.getPhoneNumber() + "☎" + (contactName == null ? "" : contactName); + holder.phoneNumber.setText(phoneText); + + // 号码长按弹出菜单事件 + holder.phoneNumber.setOnLongClickListener(new View.OnLongClickListener() { + @Override + public boolean onLongClick(View p1) { + showPhonePopupMenu(holder.phoneNumber, callLog); + return true; + } + }); + + // 绑定通话状态与时间 + holder.callStatus.setText(callLog.getCallStatus()); + holder.callDate.setText(DATE_FORMAT.format(callLog.getCallDate())); + + // 初始化滑动拨号SeekBar + initDialSeekBar(holder.dialAOHPCTCSeekBar, callLog); + } + + @Override + public int getItemCount() { + return callLogList == null ? 0 : callLogList.size(); + } + + // ====================== 私有工具方法区 ====================== + /** + * 显示号码操作弹窗菜单 + */ + private void showPhonePopupMenu(View anchorView, final CallLogModel callLog) { + LogUtils.d(TAG, "showPhonePopupMenu: 弹出号码操作菜单"); + PopupMenu menu = new PopupMenu(mContext, anchorView); + menu.getMenuInflater().inflate(R.menu.toolbar_calllog_phonenumber, menu.getMenu()); + + menu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem menuItem) { + int itemId = menuItem.getItemId(); + if (itemId == R.id.item_calllog_phonenumber_copy) { + // 复制号码到剪贴板 + ClipboardManager clipboard = (ClipboardManager) mContext.getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText("call_log_phone", callLog.getPhoneNumber()); + clipboard.setPrimaryClip(clip); + Toast.makeText(mContext, "Copy to clipboard.", Toast.LENGTH_SHORT).show(); + LogUtils.d(TAG, "showPhonePopupMenu: 号码" + callLog.getPhoneNumber() + "已复制到剪贴板"); + } else if (itemId == R.id.item_calllog_phonenumber_add_contact) { + // 跳转到添加联系人页面 + ContactUtils.jumpToAddContact(mContext, callLog.getPhoneNumber()); + LogUtils.d(TAG, "showPhonePopupMenu: 跳转添加联系人页面,号码=" + callLog.getPhoneNumber()); + } + return true; + } + }); + menu.show(); + } + + /** + * 初始化滑动拨号SeekBar + */ + private void initDialSeekBar(AOHPCTCSeekBar seekBar, final CallLogModel callLog) { + LogUtils.d(TAG, "initDialSeekBar: 初始化滑动拨号控件"); + seekBar.setThumb(seekBar.getContext().getDrawable(R.drawable.ic_call)); + seekBar.setBlurRightDP(80); + seekBar.setThumbOffset(0); + + seekBar.setOnOHPCListener(new AOHPCTCSeekBar.OnOHPCListener() { + @Override + public void onOHPCommit() { + String phoneNumber = callLog.getPhoneNumber().replaceAll("\\s", ""); + LogUtils.d(TAG, "initDialSeekBar: 滑动拨号触发,号码=" + phoneNumber); + ToastUtils.show(phoneNumber); + + Intent intent = new Intent(Intent.ACTION_CALL); + intent.setData(android.net.Uri.parse("tel:" + phoneNumber)); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + mContext.startActivity(intent); + } + }); + } + + // ====================== ViewHolder 内部类 ====================== + public class CallLogViewHolder extends RecyclerView.ViewHolder { + TextView phoneNumber; + TextView callStatus; + TextView callDate; + AOHPCTCSeekBar dialAOHPCTCSeekBar; + + public CallLogViewHolder(@NonNull View itemView) { + super(itemView); + // Java7 适配:添加强制类型转换 + phoneNumber = (TextView) itemView.findViewById(R.id.phone_number); + callStatus = (TextView) itemView.findViewById(R.id.call_status); + callDate = (TextView) itemView.findViewById(R.id.call_date); + dialAOHPCTCSeekBar = (AOHPCTCSeekBar) itemView.findViewById(R.id.aohpctcseekbar_dial); + } + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/adapters/ContactAdapter.java b/contacts/src/main/java/cc/winboll/studio/contacts/adapters/ContactAdapter.java new file mode 100644 index 0000000..f213121 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/adapters/ContactAdapter.java @@ -0,0 +1,157 @@ +package cc.winboll.studio.contacts.adapters; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.PopupMenu; +import android.widget.TextView; +import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import cc.winboll.studio.contacts.R; +import cc.winboll.studio.contacts.model.ContactModel; +import cc.winboll.studio.contacts.utils.ContactUtils; +import cc.winboll.studio.libaes.views.AOHPCTCSeekBar; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import java.util.List; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/02/26 13:35:44 + * @Describe 联系人列表适配器 + */ +public class ContactAdapter extends RecyclerView.Adapter { + + // ====================== 常量定义区 ====================== + public static final String TAG = "ContactAdapter"; + // 移除未使用的 REQUEST_CALL_PHONE 常量,精简冗余代码 + + // ====================== 成员变量区 ====================== + private Context mContext; + private List contactList; + + // ====================== 构造函数区 ====================== + public ContactAdapter(Context context, List contactList) { + LogUtils.d(TAG, "ContactAdapter: 初始化适配器,联系人数量=" + contactList.size()); + this.mContext = context; + this.contactList = contactList; + } + + // ====================== RecyclerView 重写方法区 ====================== + @NonNull + @Override + public ContactViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + LogUtils.d(TAG, "onCreateViewHolder: 创建联系人列表项ViewHolder"); + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_contact, parent, false); + return new ContactViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ContactViewHolder holder, int position) { + LogUtils.d(TAG, "onBindViewHolder: 绑定联系人列表项数据,position=" + position); + final ContactModel contact = contactList.get(position); + + // 绑定联系人名称与号码 + holder.contactName.setText(contact.getName()); + holder.contactNumber.setText(contact.getNumber()); + + // 长按联系人条目弹出操作菜单 + holder.llPhoneNumberMain.setOnLongClickListener(new View.OnLongClickListener() { + @Override + public boolean onLongClick(View v) { + showContactPopupMenu(holder.llPhoneNumberMain, contact); + return true; + } + }); + + // 初始化滑动拨号SeekBar + initDialSeekBar(holder.dialAOHPCTCSeekBar, contact); + } + + @Override + public int getItemCount() { + // 增加空指针判断,避免空列表崩溃 + return contactList == null ? 0 : contactList.size(); + } + + // ====================== 私有工具方法区 ====================== + /** + * 显示联系人操作弹窗菜单 + */ + private void showContactPopupMenu(View anchorView, final ContactModel contact) { + LogUtils.d(TAG, "showContactPopupMenu: 弹出联系人操作菜单"); + PopupMenu menu = new PopupMenu(mContext, anchorView); + menu.getMenuInflater().inflate(R.menu.toolbar_contact_phonenumber, menu.getMenu()); + + menu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem menuItem) { + int itemId = menuItem.getItemId(); + if (itemId == R.id.item_contact_phonenumber_copy) { + // 复制联系人号码到剪贴板 + ClipboardManager clipboard = (ClipboardManager) mContext.getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText("contact_phone", contact.getNumber()); + clipboard.setPrimaryClip(clip); + Toast.makeText(mContext, "Copy to clipboard.", Toast.LENGTH_SHORT).show(); + LogUtils.d(TAG, "showContactPopupMenu: 联系人号码" + contact.getNumber() + "已复制到剪贴板"); + } else if (itemId == R.id.item_calllog_phonenumber_edit_contact) { + // 跳转到编辑联系人页面 + Long contactId = ContactUtils.getContactIdByPhone(mContext, contact.getNumber()); + ContactUtils.jumpToEditContact(mContext, contact.getNumber(), contactId); + LogUtils.d(TAG, "showContactPopupMenu: 跳转编辑联系人页面,号码=" + contact.getNumber() + ",ID=" + contactId); + } + return true; + } + }); + menu.show(); + } + + /** + * 初始化滑动拨号SeekBar + */ + private void initDialSeekBar(AOHPCTCSeekBar seekBar, final ContactModel contact) { + LogUtils.d(TAG, "initDialSeekBar: 初始化滑动拨号控件"); + seekBar.setThumb(seekBar.getContext().getDrawable(R.drawable.ic_call)); + seekBar.setBlurRightDP(80); + seekBar.setThumbOffset(0); + + seekBar.setOnOHPCListener(new AOHPCTCSeekBar.OnOHPCListener() { + @Override + public void onOHPCommit() { + String phoneNumber = contact.getNumber().replaceAll("\\s", ""); + LogUtils.d(TAG, "initDialSeekBar: 滑动拨号触发,号码=" + phoneNumber); + ToastUtils.show(phoneNumber); + + Intent intent = new Intent(Intent.ACTION_CALL); + intent.setData(android.net.Uri.parse("tel:" + phoneNumber)); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + mContext.startActivity(intent); + } + }); + } + + // ====================== ViewHolder 内部类 ====================== + public class ContactViewHolder extends RecyclerView.ViewHolder { + LinearLayout llPhoneNumberMain; + TextView contactName; + TextView contactNumber; + AOHPCTCSeekBar dialAOHPCTCSeekBar; + + public ContactViewHolder(@NonNull View itemView) { + super(itemView); + // Java7 适配:添加强制类型转换 + llPhoneNumberMain = (LinearLayout) itemView.findViewById(R.id.itemcontactLinearLayout1); + contactName = (TextView) itemView.findViewById(R.id.contact_name); + contactNumber = (TextView) itemView.findViewById(R.id.contact_number); + dialAOHPCTCSeekBar = (AOHPCTCSeekBar) itemView.findViewById(R.id.aohpctcseekbar_dial); + } + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/adapters/PhoneConnectRuleAdapter.java b/contacts/src/main/java/cc/winboll/studio/contacts/adapters/PhoneConnectRuleAdapter.java new file mode 100644 index 0000000..276d729 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/adapters/PhoneConnectRuleAdapter.java @@ -0,0 +1,257 @@ +package cc.winboll.studio.contacts.adapters; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import cc.winboll.studio.contacts.R; +import cc.winboll.studio.contacts.model.PhoneConnectRuleBean; +import cc.winboll.studio.contacts.dun.Rules; +import cc.winboll.studio.contacts.views.LeftScrollView; +import cc.winboll.studio.libaes.dialogs.YesNoAlertDialog; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import java.util.ArrayList; +import java.util.List; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/03/02 17:27:41 + * @Describe 通话规则列表适配器,支持简单查看/编辑两种视图切换 + */ +public class PhoneConnectRuleAdapter extends RecyclerView.Adapter { + + // ====================== 常量定义区 ====================== + public static final String TAG = "PhoneConnectRuleAdapter"; + private static final int VIEW_TYPE_SIMPLE = 0; + private static final int VIEW_TYPE_EDIT = 1; + private static final String NULL_RULE_TEXT = "[NULL]"; + + // ====================== 成员变量区 ====================== + private Context mContext; + private List mRuleList; + + // ====================== 构造函数区 ====================== + public PhoneConnectRuleAdapter(Context context, List ruleList) { + LogUtils.d(TAG, "PhoneConnectRuleAdapter: 初始化适配器,规则数量=" + ruleList.size()); + this.mContext = context; + this.mRuleList = ruleList; + } + + // ====================== RecyclerView 重写方法区 ====================== + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + LayoutInflater inflater = LayoutInflater.from(mContext); + if (viewType == VIEW_TYPE_SIMPLE) { + LogUtils.d(TAG, "onCreateViewHolder: 创建简单视图ViewHolder"); + View view = inflater.inflate(R.layout.view_phone_connect_rule_simple, parent, false); + return new SimpleViewHolder(parent, view); + } else { + LogUtils.d(TAG, "onCreateViewHolder: 创建编辑视图ViewHolder"); + View view = inflater.inflate(R.layout.view_phone_connect_rule, parent, false); + return new EditViewHolder(view); + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, final int position) { + final PhoneConnectRuleBean model = mRuleList.get(position); + LogUtils.d(TAG, "onBindViewHolder: 绑定规则数据,position=" + position + ",视图类型=" + getItemViewType(position)); + + if (holder instanceof SimpleViewHolder) { + bindSimpleViewHolder((SimpleViewHolder) holder, model, position); + } else if (holder instanceof EditViewHolder) { + bindEditViewHolder((EditViewHolder) holder, model, position); + } + } + + @Override + public int getItemCount() { + return mRuleList == null ? 0 : mRuleList.size(); + } + + @Override + public int getItemViewType(int position) { + return mRuleList.get(position).isSimpleView() ? VIEW_TYPE_SIMPLE : VIEW_TYPE_EDIT; + } + + // ====================== 私有视图绑定方法区 ====================== + /** + * 绑定简单视图数据 + */ + private void bindSimpleViewHolder(final SimpleViewHolder holder, final PhoneConnectRuleBean model, final int position) { + // 绑定规则文本,空值显示[NULL] + String ruleText = model.getRuleText().trim().isEmpty() ? NULL_RULE_TEXT : model.getRuleText().trim(); + holder.tvRuleText.setText(ruleText); + // 设置复选框状态并禁用编辑 + holder.checkBoxAllow.setChecked(model.isAllowConnection()); + holder.checkBoxAllow.setEnabled(false); + holder.checkBoxEnable.setChecked(model.isEnable()); + holder.checkBoxEnable.setEnabled(false); + + // 设置左滑操作监听 + holder.scrollView.setOnActionListener(new LeftScrollView.OnActionListener() { + @Override + public void onUp() { + LogUtils.d(TAG, "onUp: 规则上移,position=" + position); + moveRuleUp(position); + holder.scrollView.smoothScrollTo(0, 0); + } + + @Override + public void onDown() { + LogUtils.d(TAG, "onDown: 规则下移,position=" + position); + moveRuleDown(position); + holder.scrollView.smoothScrollTo(0, 0); + } + + @Override + public void onEdit() { + LogUtils.d(TAG, "onEdit: 切换到编辑视图,position=" + position); + model.setIsSimpleView(false); + notifyItemChanged(position); + holder.scrollView.smoothScrollTo(0, 0); + } + + @Override + public void onDelete() { + LogUtils.d(TAG, "onDelete: 触发规则删除确认,position=" + position); + showDeleteConfirmDialog(holder.scrollView.getContext(), model, position); + } + }); + } + + /** + * 绑定编辑视图数据 + */ + private void bindEditViewHolder(final EditViewHolder holder, final PhoneConnectRuleBean model, final int position) { + // 绑定规则文本到输入框 + holder.editText.setText(model.getRuleText()); + // 绑定复选框状态 + holder.checkBoxAllow.setChecked(model.isAllowConnection()); + holder.checkBoxEnable.setChecked(model.isEnable()); + + // 确认按钮点击事件 + holder.buttonConfirm.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + String newRuleText = holder.editText.getText().toString().trim(); + model.setRuleText(newRuleText); + model.setIsAllowConnection(holder.checkBoxAllow.isChecked()); + model.setIsEnable(holder.checkBoxEnable.isChecked()); + model.setIsSimpleView(true); + + // 保存规则并刷新视图 + Rules.getInstance(mContext).saveRules(); + notifyItemChanged(position); + Toast.makeText(mContext, "保存成功", Toast.LENGTH_SHORT).show(); + LogUtils.d(TAG, "bindEditViewHolder: 规则保存成功,position=" + position + ",规则内容=" + newRuleText); + } + }); + } + + // ====================== 私有业务工具方法区 ====================== + /** + * 规则上移 + */ + private void moveRuleUp(int position) { + if (position <= 0) { + ToastUtils.show("已到顶部,无法上移"); + return; + } + ArrayList ruleList = Rules.getInstance(mContext).getPhoneBlacRuleBeanList(); + swapRulePosition(ruleList, position, position - 1); + } + + /** + * 规则下移 + */ + private void moveRuleDown(int position) { + ArrayList ruleList = Rules.getInstance(mContext).getPhoneBlacRuleBeanList(); + if (position >= ruleList.size() - 1) { + ToastUtils.show("已到底部,无法下移"); + return; + } + swapRulePosition(ruleList, position, position + 1); + } + + /** + * 交换规则位置 + */ + private void swapRulePosition(ArrayList list, int fromPos, int toPos) { + PhoneConnectRuleBean temp = list.get(fromPos); + list.set(fromPos, list.get(toPos)); + list.set(toPos, temp); + Rules.getInstance(mContext).saveRules(); + notifyDataSetChanged(); + LogUtils.d(TAG, "swapRulePosition: 规则位置交换完成,from=" + fromPos + ",to=" + toPos); + } + + /** + * 显示删除确认弹窗 + */ + private void showDeleteConfirmDialog(Context dialogContext, final PhoneConnectRuleBean model, final int position) { + YesNoAlertDialog.show(dialogContext, "删除确认", "是否删除该通话规则?", new YesNoAlertDialog.OnDialogResultListener() { + @Override + public void onYes() { + ArrayList ruleList = Rules.getInstance(mContext).getPhoneBlacRuleBeanList(); + ruleList.remove(position); + Rules.getInstance(mContext).saveRules(); + notifyDataSetChanged(); + LogUtils.d(TAG, "showDeleteConfirmDialog: 规则删除成功,position=" + position); + } + + @Override + public void onNo() { + LogUtils.d(TAG, "showDeleteConfirmDialog: 用户取消删除规则,position=" + position); + } + }); + } + + // ====================== ViewHolder 内部类区 ====================== + static class SimpleViewHolder extends RecyclerView.ViewHolder { + LeftScrollView scrollView; + TextView tvRuleText; + CheckBox checkBoxAllow; + CheckBox checkBoxEnable; + + public SimpleViewHolder(@NonNull ViewGroup parent, @NonNull View itemView) { + super(itemView); + scrollView = (LeftScrollView) itemView.findViewById(R.id.scrollView); + // 初始化简单视图内容布局 + LayoutInflater inflater = LayoutInflater.from(itemView.getContext()); + View viewContent = inflater.inflate(R.layout.view_phone_connect_rule_simple_content, parent, false); + tvRuleText = (TextView) viewContent.findViewById(R.id.ruletext_tv); + checkBoxAllow = (CheckBox) viewContent.findViewById(R.id.checkbox_allow); + checkBoxEnable = (CheckBox) viewContent.findViewById(R.id.checkbox_enable); + // 设置内容宽度并添加到滚动视图 + scrollView.setContentWidth(parent.getWidth()); + scrollView.addContentLayout(viewContent); + } + } + + static class EditViewHolder extends RecyclerView.ViewHolder { + EditText editText; + CheckBox checkBoxAllow; + CheckBox checkBoxEnable; + Button buttonConfirm; + + public EditViewHolder(@NonNull View itemView) { + super(itemView); + // Java7 适配:添加强制类型转换 + editText = (EditText) itemView.findViewById(R.id.edit_text); + checkBoxAllow = (CheckBox) itemView.findViewById(R.id.checkbox_allow); + checkBoxEnable = (CheckBox) itemView.findViewById(R.id.checkbox_enable); + buttonConfirm = (Button) itemView.findViewById(R.id.button_confirm); + } + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/bobulltoon/TomCat.java b/contacts/src/main/java/cc/winboll/studio/contacts/bobulltoon/TomCat.java new file mode 100644 index 0000000..da6cdf0 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/bobulltoon/TomCat.java @@ -0,0 +1,260 @@ +package cc.winboll.studio.contacts.bobulltoon; + +/** + * @Author ZhanGSKen + * @Date 2025/03/02 13:47:48 + * @Describe 汤姆猫管家 :使用 BoBullToon 项目,对通讯地址进行筛选判断的好朋友。 + */ +import android.content.Context; +import cc.winboll.studio.contacts.R; +import cc.winboll.studio.contacts.dun.Rules; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import java.io.File; +import java.io.FileFilter; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +public class TomCat { + + public static final String TAG = "TomCat"; + + List listPhoneBoBullToon = new ArrayList(); + String mszBoBullToon_URL; + + static volatile TomCat _TomCat; + Context mContext; + TomCat(Context context) { + mContext = context; + } + + public static synchronized TomCat getInstance(Context context) { + if (_TomCat == null) { + _TomCat = new TomCat(context); + } + return _TomCat; + } + + public String getDefaultBobulltoonUrl() { + return mContext.getString(R.string.default_bobulltoon_url); + } + + boolean downloadAndExtractZip(String zipUrl, String destinationFolder) throws IOException { + OkHttpClient client = new OkHttpClient(); + Request request = new Request.Builder() + .url(zipUrl) + .build(); + + try { + Response response = client.newCall(request).execute(); + if (!response.isSuccessful()) { + throw new IOException("Unexpected code " + response); + } + + // 下载 ZIP 文件到临时位置 + File tempZipFile = File.createTempFile("temp", ".zip"); + try { + InputStream inputStream = response.body().byteStream(); + FileOutputStream outputStream = new FileOutputStream(tempZipFile); + byte[] buffer = new byte[1024]; + int length; + while ((length = inputStream.read(buffer)) > 0) { + outputStream.write(buffer, 0, length); + } + } catch (Exception e) { + LogUtils.d(TAG, e, Thread.currentThread().getStackTrace()); + } + + // 解压 ZIP 文件到指定文件夹 + try { + ZipInputStream zipInputStream = new ZipInputStream(Files.newInputStream(tempZipFile.toPath())); + ZipEntry zipEntry; + while ((zipEntry = zipInputStream.getNextEntry()) != null) { + Path targetFilePath = Paths.get(destinationFolder, zipEntry.getName()); + if (zipEntry.isDirectory()) { + Files.createDirectories(targetFilePath); + } else { + Files.createDirectories(targetFilePath.getParent()); + try (FileOutputStream fos = new FileOutputStream(targetFilePath.toFile())) { + byte[] buffer = new byte[1024]; + int len; + while ((len = zipInputStream.read(buffer)) > 0) { + fos.write(buffer, 0, len); + } + } + } + zipInputStream.closeEntry(); + } + } catch (Exception e) { + LogUtils.d(TAG, e, Thread.currentThread().getStackTrace()); + } + + // 删除临时 ZIP 文件 + tempZipFile.delete(); + LogUtils.d(TAG, "已更新 BoBullToon 数据"); + return true; + } catch (Exception e) { + ToastUtils.show(e.getMessage()); + LogUtils.d(TAG, e, Thread.currentThread().getStackTrace()); + return false; + } + } + + public boolean downloadBoBullToon() { + String zipUrl = Rules.getInstance(mContext).getBoBullToonURL(); // 替换为实际的 ZIP 文件 URL + String destinationFolder = getWorkingFolder().getPath(); // 替换为实际的目标文件夹路径 + try { + // 删除旧文件 + File fOldFolder = new File(destinationFolder); + if (fOldFolder.exists()) { + deleteFolderRecursive(fOldFolder); + fOldFolder.mkdirs(); + LogUtils.d(TAG, "已清空 BoBullToon 数据"); + } + + // 更新新文件 + if (downloadAndExtractZip(zipUrl, destinationFolder)) { + LogUtils.d(TAG, "ZIP 文件下载并解压成功。"); + return true; + } + return false; + } catch (IOException e) { + LogUtils.d(TAG, e, Thread.currentThread().getStackTrace()); + } + return false; + } + + // 递归删除文件夹及其内容的方法 + public static void deleteFolderRecursive(File file) { + // 判断是否为文件夹 + if (file.isDirectory()) { + // 列出文件夹中的所有文件和子文件夹 + File[] files = file.listFiles(); + if (files != null) { + // 遍历并递归删除每个文件和子文件夹 + for (File f : files) { + deleteFolderRecursive(f); + } + } + } + // 删除文件或空文件夹 + file.delete(); + } + + File getWorkingFolder() { + return mContext.getExternalFilesDir(TAG); + } + + public File getBoBullToonDataFolder() { + File fCheckRoot = getWorkingFolder(); + if (fCheckRoot == null || !fCheckRoot.exists()) { + return fCheckRoot; + } + + // 递归查找符合条件的文件夹 + File targetFolder = findTargetFolder(fCheckRoot); + return targetFolder != null ? targetFolder : fCheckRoot; + } + + /** + * 递归查找同时包含LICENSE和README.md文件的文件夹 + */ + private File findTargetFolder(File currentFolder) { + // 检查当前文件夹是否符合条件 + if (hasRequiredFiles(currentFolder)) { + return currentFolder; + } + + // 查找子文件夹(Java 7不支持方法引用,用匿名内部类过滤) + File[] subFolders = currentFolder.listFiles(new FileFilter() { + @Override + public boolean accept(File file) { + return file.isDirectory(); // 仅保留子文件夹 + } + }); + + if (subFolders != null) { + for (File subFolder : subFolders) { + File result = findTargetFolder(subFolder); + if (result != null) { + return result; + } + } + } + + return null; + } + + /** + * 检查文件夹中是否同时存在LICENSE和README.md文件 + */ + private boolean hasRequiredFiles(File folder) { + if (folder == null || !folder.isDirectory()) { + return false; + } + + // 检查两个文件是否同时存在且均为文件(非文件夹) + File licenseFile = new File(folder, "LICENSE"); + File readmeFile = new File(folder, "README.md"); + + return licenseFile.exists() && licenseFile.isFile() + && readmeFile.exists() && readmeFile.isFile(); + } + + public void cleanBoBullToon() { + String destinationFolder = getWorkingFolder().getPath(); // 替换为实际的目标文件夹路径 + // 删除旧文件 + File fOldFolder = new File(destinationFolder); + if (fOldFolder.exists()) { + deleteFolderRecursive(fOldFolder); + fOldFolder.mkdirs(); + } + + ToastUtils.show("已清空 BoBullToon 数据!"); + LogUtils.d(TAG, "已清空 BoBullToon 数据"); + } + + public boolean loadPhoneBoBullToon() { + listPhoneBoBullToon.clear(); + File fBoBullToon = getBoBullToonDataFolder(); + if (fBoBullToon.exists()) { + LogUtils.d(TAG, String.format("getBoBullToonDataFolder() %s", getWorkingFolder())); + for (File userFolder : fBoBullToon.listFiles()) { + if (userFolder.isDirectory()) { + for (File recordFile : userFolder.listFiles()) { + listPhoneBoBullToon.add(recordFile.getName()); + } + } + } + + for (int i = 0; i < listPhoneBoBullToon.size(); i++) { + LogUtils.d(TAG, String.format("listPhoneBoBullToon add : %s", listPhoneBoBullToon.get(i))); + } + return true; + } else { + LogUtils.d(TAG, "fBoBullToon not exists。"); + } + return false; + } + + public boolean isPhoneBoBullToon(String phone) { + for (int i = 0; i < listPhoneBoBullToon.size(); i++) { + LogUtils.d(TAG, String.format("isPhoneBoBullToon(...) get(i) phone : %s", listPhoneBoBullToon.get(i))); + if (listPhoneBoBullToon.get(i).equals(phone)) { + return true; + } + } + return false; + } +} diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/dun/Rules.java b/contacts/src/main/java/cc/winboll/studio/contacts/dun/Rules.java new file mode 100644 index 0000000..782d9db --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/dun/Rules.java @@ -0,0 +1,264 @@ +package cc.winboll.studio.contacts.dun; + +import android.content.Context; +import cc.winboll.studio.contacts.activities.SettingsActivity; +import cc.winboll.studio.contacts.bobulltoon.TomCat; +import cc.winboll.studio.contacts.model.PhoneConnectRuleBean; +import cc.winboll.studio.contacts.model.SettingsBean; +import cc.winboll.studio.contacts.services.MainService; +import cc.winboll.studio.contacts.utils.ContactUtils; +import cc.winboll.studio.contacts.utils.IntUtils; +import cc.winboll.studio.contacts.utils.RegexPPiUtils; +import cc.winboll.studio.contacts.views.DunTemperatureView; +import cc.winboll.studio.libappbase.LogUtils; +import java.util.ArrayList; +import java.util.Timer; +import java.util.TimerTask; +import java.util.regex.Pattern; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/02/21 06:15:10 + * @Describe 云盾防御规则(双重校验锁单例模式) + */ +public class Rules { + + public static final String TAG = "Rules"; + + // 单例核心:volatile 保证多线程可见性,禁止指令重排 + private static volatile Rules sInstance; + // 上下文需使用 ApplicationContext 避免内存泄漏 + private static Context sApplicationContext; + + ArrayList _PhoneConnectRuleModelList; + Context mContext; + SettingsBean mSettingsModel; + Timer mDunResumeTimer; + + /** + * 私有化构造方法,禁止外部 new 实例 + */ + private Rules(Context context) { + mContext = context.getApplicationContext(); + _PhoneConnectRuleModelList = new ArrayList(); + reload(); + } + + /** + * 获取单例实例(双重校验锁,线程安全) + * @param context 上下文,建议传入 ApplicationContext + * @return Rules 唯一实例 + */ + public static Rules getInstance(Context context) { + // 第一次校验:无锁,提高性能 + if (sInstance == null) { + // 加锁:保证多线程下仅初始化一次 + synchronized (Rules.class) { + // 第二次校验:防止多线程并发时重复创建 + if (sInstance == null) { + sInstance = new Rules(context); + } + } + } + return sInstance; + } + + public void reload() { + LogUtils.d(TAG, "reload()"); + loadRules(); + loadDun(); + setDunResumTimer(); + } + + public void setDunResumTimer() { + if (mDunResumeTimer != null) { + mDunResumeTimer.cancel(); + } + + // 盾牌恢复定时器 + mDunResumeTimer = new Timer(); + int ss = IntUtils.getIntInRange(mSettingsModel.getDunResumeSecondCount() * 1000, SettingsBean.MIN_INTRANGE, SettingsBean.MAX_INTRANGE); + mDunResumeTimer.schedule(new TimerTask() { + @Override + public void run() { + if (mSettingsModel.getDunCurrentCount() != mSettingsModel.getDunTotalCount()) { + LogUtils.d(TAG, String.format("当前防御值为%d,最大防御值为%d", mSettingsModel.getDunCurrentCount(), mSettingsModel.getDunTotalCount())); + int newDunCount = mSettingsModel.getDunCurrentCount() + mSettingsModel.getDunResumeCount(); + // 设置盾值在[0,DunTotalCount]之内其他值一律重置为 DunTotalCount。 + newDunCount = (newDunCount > mSettingsModel.getDunTotalCount()) ?mSettingsModel.getDunTotalCount(): newDunCount; + mSettingsModel.setDunCurrentCount(newDunCount); + LogUtils.d(TAG, String.format("设置防御值为%d", newDunCount)); + saveDun(); + // 一键更新所有 DunTemperatureView 实例的盾值 + DunTemperatureView.updateDunValue(mSettingsModel.getDunTotalCount(), mSettingsModel.getDunCurrentCount()); + + SettingsActivity.notifyDunInfoUpdate(); + } + } + }, 1000, ss); + } + + public void loadRules() { + _PhoneConnectRuleModelList.clear(); + PhoneConnectRuleBean.loadBeanList(mContext, _PhoneConnectRuleModelList, PhoneConnectRuleBean.class); + } + + public void saveRules() { + LogUtils.d(TAG, String.format("saveRules()")); + PhoneConnectRuleBean.saveBeanList(mContext, _PhoneConnectRuleModelList, PhoneConnectRuleBean.class); + } + + public void resetDefaultBoBullToonURL() { + mSettingsModel.setBoBullToon_URL(TomCat.getInstance(mContext).getDefaultBobulltoonUrl()); + saveDun(); + } + + public void setBoBullToonURL(String szUrl) { + mSettingsModel.setBoBullToon_URL(szUrl); + saveDun(); + } + + public String getBoBullToonURL() { + return mSettingsModel.getBoBullToon_URL(); + } + + public void loadDun() { + mSettingsModel = SettingsBean.loadBean(mContext, SettingsBean.class); + if (mSettingsModel == null) { + mSettingsModel = new SettingsBean(); + SettingsBean.saveBean(mContext, mSettingsModel); + } + } + + public void saveDun() { + LogUtils.d(TAG, String.format("saveDun()")); + SettingsBean.saveBean(mContext, mSettingsModel); + } + + public boolean isAllowed(String phoneNumber) { + // 没有启用云盾,默认允许接通任何电话 + if (!mSettingsModel.isEnableDun()) { + LogUtils.d(TAG, String.format("没有启用云盾,默认允许接通任何电话。isAllowed(...) return true")); + return true; + } + + // 云盾防御体系 + boolean isDefend = false; // 盾牌是否生效 + boolean isConnect = true; // 防御结果是否连接 + + // 进行盾牌层数预计缩减计算 + int nDunCurrentCount = mSettingsModel.getDunCurrentCount() - 1; + LogUtils.d(TAG, String.format("nDunCurrentCount : %d", nDunCurrentCount)); + + // 如果盾值小于1,则解除防御 + if (!isDefend && nDunCurrentCount < 1) { + // 盾层为1以下,防御解除 + LogUtils.d(TAG, "盾层为1以下,防御解除"); + isDefend = true; + isConnect = true; + LogUtils.d(TAG, String.format("isDefend == %s\nisConnect == %s", isDefend, isConnect)); + } + + // 正则运算预防针 + if (!isDefend && !RegexPPiUtils.isPPiOK(phoneNumber)) { + LogUtils.d(TAG, "正则运算预防针生效。"); + isDefend = true; + isConnect = false; + LogUtils.d(TAG, String.format("isDefend == %s\nisConnect == %s", isDefend, isConnect)); + } + + // 检验拨不通号码群 + if (!isDefend && MainService.isPhoneInBoBullToon(phoneNumber)) { + LogUtils.d(TAG, String.format("PhoneNumber %s\n Is In BoBullToon", phoneNumber)); + isDefend = true; + isConnect = false; + LogUtils.d(TAG, String.format("isDefend == %s\nisConnect == %s", isDefend, isConnect)); + } + + // 查询通讯录是否有该联系人 + boolean isPhoneInContacts = ContactUtils.getInstance(mContext).isPhoneInContacts(mContext, phoneNumber); + if (!isDefend) { + if (isPhoneInContacts) { + LogUtils.d(TAG, String.format("Phone %s is in contacts.", phoneNumber)); + isDefend = true; + isConnect = true; + LogUtils.d(TAG, String.format("isDefend == %s\nisConnect == %s", isDefend, isConnect)); + } else { + LogUtils.d(TAG, String.format("Phone %s is not in contacts.", phoneNumber)); + } + } + + // 正则匹配规则名单校验 + if (!isDefend) { + for (int i = 0; i < _PhoneConnectRuleModelList.size(); i++) { + if (_PhoneConnectRuleModelList.get(i).isEnable()) { + String regex = _PhoneConnectRuleModelList.get(i).getRuleText(); + if (Pattern.matches(regex, phoneNumber)) { + LogUtils.d(TAG, String.format("Phone Number [%s] is matched by rule : %s", phoneNumber, _PhoneConnectRuleModelList.get(i))); + isDefend = true; + isConnect = _PhoneConnectRuleModelList.get(i).isAllowConnection(); + LogUtils.d(TAG, String.format("isDefend == %s\nisConnect == %s", isDefend, isConnect)); + break; + } + } + } + } + + if (isConnect) { + // 如果防御结果为连接,则恢复防御盾牌最大值层数 + mSettingsModel.setDunCurrentCount(mSettingsModel.getDunTotalCount()); + LogUtils.d(TAG, String.format("防御结果为连接,恢复防御盾牌最大值层数 %d", mSettingsModel.getDunTotalCount())); + saveDun(); + SettingsActivity.notifyDunInfoUpdate(); + } else if (isDefend) { + // 如果触发了以上某个防御模块,减少防御盾牌层数 + int newDunCount = nDunCurrentCount; + LogUtils.d(TAG, String.format("新的防御层数预计为 %d", newDunCount)); + + // 保证盾值在[1,DunTotalCount]之内其他值一律重置为 DunTotalCount。 + if (newDunCount > 0 && newDunCount < mSettingsModel.getDunTotalCount()) { + mSettingsModel.setDunCurrentCount(newDunCount); + LogUtils.d(TAG, String.format("设置防御层数为 %d", newDunCount)); + } else { + mSettingsModel.setDunCurrentCount(mSettingsModel.getDunTotalCount()); + LogUtils.d(TAG, String.format("盾值不在[0,%d]区间,恢复防御最大值%d", mSettingsModel.getDunTotalCount(), mSettingsModel.getDunTotalCount())); + } + + saveDun(); + SettingsActivity.notifyDunInfoUpdate(); + } + + // 返回校验结果 + LogUtils.d(TAG, String.format("返回校验结果 isConnect == %s", isConnect)); + // 一键更新所有 DunTemperatureView 实例的盾值 + DunTemperatureView.updateDunValue(mSettingsModel.getDunTotalCount(), mSettingsModel.getDunCurrentCount()); + + return isConnect; + } + + public void add(String szPhoneConnectRule, boolean isAllowConnection, boolean isEnable) { + _PhoneConnectRuleModelList.add(new PhoneConnectRuleBean(szPhoneConnectRule, isAllowConnection, isEnable)); + } + + public ArrayList getPhoneBlacRuleBeanList() { + return _PhoneConnectRuleModelList; + } + + public SettingsBean getSettingsModel() { + return mSettingsModel; + } + + /** + * 可选:释放单例资源(如退出应用时调用) + */ + public static void releaseInstance() { + if (sInstance != null) { + sInstance.mDunResumeTimer.cancel(); + sInstance._PhoneConnectRuleModelList.clear(); + sInstance.mSettingsModel = null; + sInstance.mContext = null; + sInstance = null; + } + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/fragments/CallLogFragment.java b/contacts/src/main/java/cc/winboll/studio/contacts/fragments/CallLogFragment.java new file mode 100644 index 0000000..9e2ca6a --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/fragments/CallLogFragment.java @@ -0,0 +1,258 @@ +package cc.winboll.studio.contacts.fragments; + +import android.Manifest; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.provider.CallLog; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.ActivityCompat; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import cc.winboll.studio.contacts.R; +import cc.winboll.studio.contacts.adapters.CallLogAdapter; +import cc.winboll.studio.contacts.model.CallLogModel; +import cc.winboll.studio.libappbase.LogUtils; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/02/20 12:57:00 + * @Describe 通话记录区域视图(支持懒加载,仅切换到当前页才加载数据) + */ +public class CallLogFragment extends Fragment { + + // ====================== 常量定义区 ====================== + public static final String TAG = "CallLogFragment"; + public static final int MSG_UPDATE = 1; + private static final String ARG_PAGE = "ARG_PAGE"; + private static final int REQUEST_READ_CALL_LOG = 1; + + // ====================== 静态成员区 ====================== + static volatile CallLogFragment _CallLogFragment; + + // ====================== 页面参数区 ====================== + private int mPage; + + // ====================== UI控件与适配器区 ====================== + private RecyclerView recyclerView; + private CallLogAdapter callLogAdapter; + private List callLogList = new ArrayList(); + + // ====================== 业务逻辑成员区 ====================== + private Handler mHandler; + // 懒加载标记:记录当前Fragment是否已初始化数据(避免重复加载) + private boolean isDataInited = false; + + // ====================== 单例与实例化函数区 ====================== + CallLogFragment() { + super(); + } + + public static CallLogFragment newInstance(int page) { + LogUtils.d(TAG, "newInstance: 创建通话记录Fragment实例,页码=" + page); + Bundle args = new Bundle(); + args.putInt(ARG_PAGE, page); + CallLogFragment fragment = new CallLogFragment(); + fragment.setArguments(args); + _CallLogFragment = fragment; + return fragment; + } + + // ====================== 生命周期函数区 ====================== + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + LogUtils.d(TAG, "onCreate: Fragment创建开始"); + if (getArguments() != null) { + mPage = getArguments().getInt(ARG_PAGE); + LogUtils.d(TAG, "onCreate: 读取页面参数,mPage=" + mPage); + } + // Java7 兼容:移除Lambda,使用匿名内部类初始化Handler + mHandler = new Handler(Looper.getMainLooper()) { + @Override + public void handleMessage(Message msg) { + if (msg.what == MSG_UPDATE) { + LogUtils.d(TAG, "handleMessage: 收到更新消息,开始读取通话记录"); + readCallLog(); + } + } + }; + LogUtils.d(TAG, "onCreate: Fragment创建完成"); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + LogUtils.d(TAG, "onCreateView: 加载Fragment布局"); + return inflater.inflate(R.layout.fragment_call_log, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + LogUtils.d(TAG, "onViewCreated: 视图创建完成,仅初始化控件(不加载数据)"); + // 初始化RecyclerView(仅绑定控件、设置布局管理器,不设置数据/发起请求) + recyclerView = (RecyclerView) view.findViewById(R.id.recyclerView); + recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + // 初始化适配器(传入空列表,后续懒加载时更新数据) + callLogAdapter = new CallLogAdapter(getContext(), callLogList); + recyclerView.setAdapter(callLogAdapter); + LogUtils.d(TAG, "onViewCreated: RecyclerView控件初始化完成(未加载数据)"); + } + + @Override + public void onResume() { + super.onResume(); + LogUtils.d(TAG, "onResume: Fragment进入前台"); + // 已初始化过数据 → 仅刷新(避免重复初始化,优化性能) + if (isDataInited && callLogAdapter != null) { + LogUtils.d(TAG, "onResume: 数据已初始化,仅刷新列表"); + callLogAdapter.relaodContacts(); + readCallLog(); // 刷新最新通话记录 + LogUtils.d(TAG, "onResume: 通话记录数据刷新完成"); + } + // 未初始化 → 不操作(等待MainActivity调用initData触发初始化) + } + + @Override + public void onDestroy() { + super.onDestroy(); + LogUtils.d(TAG, "onDestroy: Fragment开始销毁"); + if (mHandler != null) { + mHandler.removeCallbacksAndMessages(null); + LogUtils.d(TAG, "onDestroy: Handler消息已清空"); + } + // 释放资源,避免内存泄漏 + if (callLogList != null) { + callLogList.clear(); + callLogList = null; + } + callLogAdapter = null; + recyclerView = null; + _CallLogFragment = null; + isDataInited = false; + LogUtils.d(TAG, "onDestroy: Fragment销毁完成"); + } + + // ====================== 权限回调函数区 ====================== + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + LogUtils.d(TAG, "onRequestPermissionsResult: 权限请求回调,requestCode=" + requestCode); + if (requestCode == REQUEST_READ_CALL_LOG) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + LogUtils.d(TAG, "onRequestPermissionsResult: 通话记录权限授予成功,开始加载数据"); + mHandler.sendEmptyMessage(MSG_UPDATE); + } else { + LogUtils.e(TAG, "onRequestPermissionsResult: 通话记录权限被拒绝,无法加载数据"); + } + } + } + + // ====================== 懒加载核心方法(供MainActivity调用) ====================== + public void initData() { + // 避免重复初始化(双重防护:标记+判断) + if (isDataInited || getContext() == null) { + LogUtils.d(TAG, "initData: 数据已初始化/上下文为空,跳过"); + return; + } + LogUtils.d(TAG, "initData: 开始懒加载初始化通话记录数据"); + // 权限检查与数据加载(原onViewCreated中的核心逻辑迁移至此) + if (ActivityCompat.checkSelfPermission(requireContext(), Manifest.permission.READ_CALL_LOG) != PackageManager.PERMISSION_GRANTED) { + LogUtils.w(TAG, "initData: 读取通话记录权限未授予,发起权限申请"); + ActivityCompat.requestPermissions(requireActivity(), new String[]{Manifest.permission.READ_CALL_LOG}, REQUEST_READ_CALL_LOG); + } else { + LogUtils.d(TAG, "initData: 权限已授予,发送更新消息加载数据"); + mHandler.sendEmptyMessage(MSG_UPDATE); + } + // 标记为已初始化(后续仅刷新,不重复初始化) + isDataInited = true; + LogUtils.d(TAG, "initData: 懒加载初始化流程完成"); + } + + // ====================== 业务核心函数区 ====================== + private void readCallLog() { + LogUtils.d(TAG, "readCallLog: 开始读取系统通话记录"); + // 避免空指针(懒加载场景下,控件可能未初始化完成) + if (callLogList == null || callLogAdapter == null || getContext() == null) { + LogUtils.w(TAG, "readCallLog: 控件/列表为空,跳过读取"); + return; + } + callLogList.clear(); + Cursor cursor = null; + try { + cursor = requireContext().getContentResolver().query( + CallLog.Calls.CONTENT_URI, + null, + null, + null, + CallLog.Calls.DATE + " DESC" + ); + if (cursor != null) { + LogUtils.d(TAG, "readCallLog: 成功获取通话记录游标,数据条数=" + cursor.getCount()); + while (cursor.moveToNext()) { + String phoneNumber = cursor.getString(cursor.getColumnIndex(CallLog.Calls.NUMBER)); + int callType = cursor.getInt(cursor.getColumnIndex(CallLog.Calls.TYPE)); + long callDateLong = cursor.getLong(cursor.getColumnIndex(CallLog.Calls.DATE)); + Date callDate = new Date(callDateLong); + String callStatus = getCallStatus(callType); + + callLogList.add(new CallLogModel(phoneNumber, callStatus, callDate)); + } + callLogAdapter.notifyDataSetChanged(); + LogUtils.d(TAG, "readCallLog: 通话记录数据解析完成,共" + callLogList.size() + "条"); + } else { + LogUtils.w(TAG, "readCallLog: 通话记录游标为空"); + } + } catch (Exception e) { + LogUtils.e(TAG, "readCallLog: 读取通话记录异常", e); + } finally { + if (cursor != null) { + cursor.close(); + LogUtils.d(TAG, "readCallLog: 游标已关闭"); + } + } + } + + private String getCallStatus(int callType) { + switch (callType) { + case CallLog.Calls.OUTGOING_TYPE: + return "Outgoing"; + case CallLog.Calls.INCOMING_TYPE: + return "Incoming"; + case CallLog.Calls.MISSED_TYPE: + return "Missed"; + default: + return "Unknown"; + } + } + + // ====================== 外部调用函数区 ====================== + public void triggerUpdate() { + LogUtils.d(TAG, "triggerUpdate: 外部触发通话记录更新"); + if (isDataInited) { // 已初始化才触发更新(避免未加载时调用) + mHandler.sendEmptyMessage(MSG_UPDATE); + } + } + + public static void updateCallLogFragment() { + if (_CallLogFragment != null) { + LogUtils.d(TAG, "updateCallLogFragment: 静态方法触发Fragment更新"); + _CallLogFragment.triggerUpdate(); + } else { + LogUtils.w(TAG, "updateCallLogFragment: Fragment实例为空,无法更新"); + } + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/fragments/ContactsFragment.java b/contacts/src/main/java/cc/winboll/studio/contacts/fragments/ContactsFragment.java new file mode 100644 index 0000000..1d177ed --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/fragments/ContactsFragment.java @@ -0,0 +1,401 @@ +package cc.winboll.studio.contacts.fragments; + +import android.Manifest; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.provider.ContactsContract; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.ActivityCompat; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import cc.winboll.studio.contacts.R; +import cc.winboll.studio.contacts.adapters.ContactAdapter; +import cc.winboll.studio.contacts.model.ContactModel; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/08/30 14:32 + * @Describe 联系人区域视图(支持懒加载,仅切换到当前页才加载数据) + */ +public class ContactsFragment extends Fragment { + + // ====================== 常量定义区 ====================== + public static final String TAG = "ContactsFragment"; + private static final String ARG_PAGE = "ARG_PAGE"; + private static final int REQUEST_READ_CONTACTS = 1; + private static final long DEBOUNCE_DELAY = 300; // 搜索防抖延迟 + + // ====================== 静态缓存区 ====================== + // 全局复用联系人数据,减少重复查询 + private static List sCachedOriginalList = new ArrayList(); + private static List sCachedFilteredList = new ArrayList(); + + // ====================== 页面参数区 ====================== + private int mPage; + private boolean isViewInitialized = false; // 视图初始化标记(控件绑定完成) + private boolean isDataLoaded = false; // 数据加载标记(数据+功能初始化完成) + private boolean isLazyInitCompleted = false; // 懒加载总标记(供MainActivity判断) + + // ====================== UI控件区 ====================== + private RecyclerView recyclerView; + private ContactAdapter contactAdapter; + private EditText searchEditText; + private Button btnDial; + + // ====================== 数据容器区 ====================== + private List contactList = new ArrayList(); + private List originalContactList = new ArrayList(); + + // ====================== 异步工具区 ====================== + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + // ====================== 实例化函数区 ====================== + public static ContactsFragment newInstance(int page) { + LogUtils.d(TAG, "newInstance: 创建联系人Fragment实例,页码=" + page); + Bundle args = new Bundle(); + args.putInt(ARG_PAGE, page); + ContactsFragment fragment = new ContactsFragment(); + fragment.setArguments(args); + return fragment; + } + + // ====================== 生命周期函数区 ====================== + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + LogUtils.d(TAG, "onCreate: Fragment创建开始"); + if (getArguments() != null) { + mPage = getArguments().getInt(ARG_PAGE); + LogUtils.d(TAG, "onCreate: 读取页面参数,mPage=" + mPage); + } + LogUtils.d(TAG, "onCreate: Fragment创建完成"); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + LogUtils.d(TAG, "onCreateView: 加载Fragment布局"); + return inflater.inflate(R.layout.fragment_contacts, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + LogUtils.d(TAG, "onViewCreated: 开始初始化UI控件(仅绑定,不加载数据/功能)"); + // 初始化RecyclerView(仅绑定控件、设适配器,隐藏列表) + recyclerView = (RecyclerView) view.findViewById(R.id.contacts_recycler_view); + recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + contactAdapter = new ContactAdapter(getActivity(), contactList); + recyclerView.setAdapter(contactAdapter); + recyclerView.setVisibility(View.GONE); + + // 绑定搜索框和拨号按钮(仅赋值,不显示、不绑定事件) + searchEditText = (EditText) view.findViewById(R.id.search_edit_text); + btnDial = (Button) view.findViewById(R.id.btn_dial); + searchEditText.setVisibility(View.GONE); + btnDial.setVisibility(View.GONE); + + // 标记视图控件绑定完成 + isViewInitialized = true; + LogUtils.d(TAG, "onViewCreated: UI控件初始化完成(未加载数据/功能)"); + } + + @Override + public void onResume() { + super.onResume(); + LogUtils.d(TAG, "onResume: Fragment进入前台"); + // 已完成懒加载 → 仅恢复缓存数据(切回页面时刷新) + if (isLazyInitCompleted && isDataLoaded) { + LogUtils.d(TAG, "onResume: 懒加载已完成,恢复缓存数据"); + contactList.clear(); + contactList.addAll(sCachedFilteredList); + contactAdapter.notifyDataSetChanged(); + recyclerView.setVisibility(View.VISIBLE); + } + // 未完成懒加载 → 不操作(等待MainActivity调用initData触发) + } + + @Override + public void onDestroy() { + super.onDestroy(); + LogUtils.d(TAG, "onDestroy: Fragment开始销毁"); + executor.shutdown(); // 关闭线程池 + mainHandler.removeCallbacksAndMessages(null); // 清空Handler任务 + // 释放本地数据引用(保留静态缓存,全局复用) + if (contactList != null) { + contactList.clear(); + contactList = null; + } + if (originalContactList != null) { + originalContactList.clear(); + originalContactList = null; + } + // 重置标记 + isViewInitialized = false; + isDataLoaded = false; + isLazyInitCompleted = false; + LogUtils.d(TAG, "onDestroy: 异步工具+本地资源已释放"); + } + + @Override + public void onHiddenChanged(boolean hidden) { + super.onHiddenChanged(hidden); + LogUtils.d(TAG, "onHiddenChanged: Fragment隐藏状态变更,hidden=" + hidden); + // 已完成懒加载+显示状态 → 恢复缓存数据(兼容Tab切换场景) + if (!hidden && isLazyInitCompleted && isDataLoaded) { + contactList.clear(); + contactList.addAll(sCachedFilteredList); + contactAdapter.notifyDataSetChanged(); + recyclerView.setVisibility(View.VISIBLE); + LogUtils.d(TAG, "onHiddenChanged: 恢复缓存数据,列表已显示"); + } + } + + // ====================== 权限相关函数区 ====================== + private void checkContactPermission() { + LogUtils.d(TAG, "checkContactPermission: 检查联系人读取权限"); + if (ActivityCompat.checkSelfPermission(requireContext(), Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) { + LogUtils.w(TAG, "checkContactPermission: 权限未授予,发起申请"); + ActivityCompat.requestPermissions(requireActivity(), new String[]{Manifest.permission.READ_CONTACTS}, REQUEST_READ_CONTACTS); + } else { + LogUtils.d(TAG, "checkContactPermission: 权限已授予,开始加载数据"); + loadContacts(); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + LogUtils.d(TAG, "onRequestPermissionsResult: 权限回调触发,requestCode=" + requestCode); + if (requestCode == REQUEST_READ_CONTACTS) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + LogUtils.d(TAG, "onRequestPermissionsResult: 联系人权限授予成功"); + loadContacts(); + } else { + LogUtils.e(TAG, "onRequestPermissionsResult: 联系人权限被拒绝"); + ToastUtils.show("请授予联系人权限以查看联系人列表"); + recyclerView.setVisibility(View.VISIBLE); + // 权限拒绝也标记懒加载完成(避免重复触发) + isLazyInitCompleted = true; + } + } + } + + // ====================== 懒加载核心方法(供MainActivity调用) ====================== + public void initData() { + // 双重防护:避免重复初始化(标记+视图就绪判断) + if (isLazyInitCompleted || !isViewInitialized || getContext() == null) { + LogUtils.d(TAG, "initData: 懒加载已完成/视图未就绪,跳过"); + return; + } + LogUtils.d(TAG, "initData: 开始懒加载初始化(功能+数据)"); + // 1. 初始化搜索、拨号功能(原onResume首次进入逻辑迁移至此) + initSearchAndDial(); + // 2. 检查权限+加载数据(原onResume首次进入逻辑迁移至此) + checkContactPermission(); + // 标记懒加载总流程完成(无论权限是否授予,仅执行一次) + isLazyInitCompleted = true; + LogUtils.d(TAG, "initData: 懒加载初始化流程启动完成"); + } + + // ====================== UI功能初始化区 ====================== + private void initSearchAndDial() { + LogUtils.d(TAG, "initSearchAndDial: 初始化搜索和拨号功能"); + // 显示控件 + searchEditText.setVisibility(View.VISIBLE); + btnDial.setVisibility(View.VISIBLE); + + // 搜索防抖监听 + searchEditText.addTextChangedListener(new DebounceTextWatcher(DEBOUNCE_DELAY) { + @Override + public void onDebounceTextChanged(String query) { + filterContacts(query); + } + }); + + // 拨号按钮点击事件 + btnDial.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + String phoneNumber = searchEditText.getText().toString().replaceAll("\\s", ""); + if (phoneNumber.isEmpty()) { + ToastUtils.show("请输入号码"); + return; + } + LogUtils.d(TAG, "initSearchAndDial: 发起拨号,号码=" + phoneNumber); + Intent intent = new Intent(Intent.ACTION_CALL); + intent.setData(android.net.Uri.parse("tel:" + phoneNumber)); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + } + }); + LogUtils.d(TAG, "initSearchAndDial: 功能初始化完成"); + } + + // ====================== 数据加载与处理区 ====================== + private void loadContacts() { + // 优先使用缓存数据(保留原有缓存逻辑,提升性能) + if (!sCachedOriginalList.isEmpty() && !sCachedFilteredList.isEmpty()) { + LogUtils.d(TAG, "loadContacts: 存在缓存数据,直接复用"); + originalContactList.clear(); + originalContactList.addAll(sCachedOriginalList); + contactList.clear(); + contactList.addAll(sCachedFilteredList); + contactAdapter.notifyDataSetChanged(); + recyclerView.setVisibility(View.VISIBLE); + isDataLoaded = true; + return; + } + + // 无缓存时异步加载(保留原有异步逻辑,避免主线程阻塞) + if (!isDataLoaded) { + LogUtils.d(TAG, "loadContacts: 无缓存,异步读取联系人数据"); + recyclerView.setVisibility(View.GONE); + executor.execute(new Runnable() { + @Override + public void run() { + final List tempList = readContactsInBackground(); + // 主线程更新UI和缓存 + mainHandler.post(new Runnable() { + @Override + public void run() { + sCachedOriginalList.clear(); + sCachedOriginalList.addAll(tempList); + sCachedFilteredList.clear(); + sCachedFilteredList.addAll(tempList); + + originalContactList.clear(); + originalContactList.addAll(sCachedOriginalList); + contactList.clear(); + contactList.addAll(sCachedFilteredList); + + contactAdapter.notifyDataSetChanged(); + recyclerView.setVisibility(View.VISIBLE); + isDataLoaded = true; + + LogUtils.d(TAG, "loadContacts: 联系人数据加载完成,共" + contactList.size() + "条"); + } + }); + } + }); + } + } + + private List readContactsInBackground() { + LogUtils.d(TAG, "readContactsInBackground: 子线程读取联系人"); + List tempList = new ArrayList(); + Cursor cursor = null; + try { + cursor = requireContext().getContentResolver().query( + ContactsContract.CommonDataKinds.Phone.CONTENT_URI, + new String[]{ + ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, + ContactsContract.CommonDataKinds.Phone.NUMBER + }, + null, + null, + ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " ASC" + ); + + if (cursor != null && cursor.moveToFirst()) { + int nameIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME); + int numberIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER); + do { + String name = cursor.getString(nameIndex); + String number = cursor.getString(numberIndex).replaceAll("\\s", ""); + tempList.add(new ContactModel(name, number)); + } while (cursor.moveToNext()); + LogUtils.d(TAG, "readContactsInBackground: 成功读取" + tempList.size() + "条联系人数据"); + } else { + LogUtils.w(TAG, "readContactsInBackground: 未读取到联系人数据"); + } + } catch (Exception e) { + LogUtils.e(TAG, "readContactsInBackground: 读取联系人异常", e); + } finally { + if (cursor != null) { + cursor.close(); + LogUtils.d(TAG, "readContactsInBackground: 游标已关闭"); + } + } + return tempList; + } + + private void filterContacts(String query) { + LogUtils.d(TAG, "filterContacts: 搜索过滤,关键词=" + query); + contactList.clear(); + sCachedFilteredList.clear(); + if (query.isEmpty()) { + contactList.addAll(originalContactList); + sCachedFilteredList.addAll(originalContactList); + } else { + String lowerQuery = query.toLowerCase(); + for (ContactModel contact : originalContactList) { + boolean matchName = contact.getName().toLowerCase().contains(lowerQuery); + boolean matchPinyin = contact.getPinyin().toLowerCase().contains(lowerQuery); + boolean matchFirstLetter = contact.getPinyinFirstLetter().toLowerCase().contains(lowerQuery); + boolean matchNumber = contact.getNumber().contains(lowerQuery); + if (matchName || matchPinyin || matchFirstLetter || matchNumber) { + contactList.add(contact); + } + } + sCachedFilteredList.addAll(contactList); + } + contactAdapter.notifyDataSetChanged(); + recyclerView.setVisibility(View.VISIBLE); + LogUtils.d(TAG, "filterContacts: 过滤完成,显示" + contactList.size() + "条数据"); + } + + // ====================== 内部防抖监听类 ====================== + public abstract static class DebounceTextWatcher implements TextWatcher { + private final long debounceDelay; + private Handler handler = new Handler(Looper.getMainLooper()); + private Runnable pendingRunnable; + + public DebounceTextWatcher(long debounceDelay) { + this.debounceDelay = debounceDelay; + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(final CharSequence s, int start, int before, int count) { + if (pendingRunnable != null) { + handler.removeCallbacks(pendingRunnable); + } + pendingRunnable = new Runnable() { + @Override + public void run() { + onDebounceTextChanged(s.toString()); + } + }; + handler.postDelayed(pendingRunnable, debounceDelay); + } + + @Override + public void afterTextChanged(Editable s) {} + + public abstract void onDebounceTextChanged(String query); + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/fragments/LogFragment.java b/contacts/src/main/java/cc/winboll/studio/contacts/fragments/LogFragment.java new file mode 100644 index 0000000..625e327 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/fragments/LogFragment.java @@ -0,0 +1,118 @@ +package cc.winboll.studio.contacts.fragments; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import cc.winboll.studio.contacts.R; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.LogView; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/02/20 12:58:15 + * @Describe 应用日志区域视图(支持懒加载,仅切换到当前页才启动日志) + */ +public class LogFragment extends Fragment { + + // ====================== 常量定义区 ====================== + public static final String TAG = "LogFragment"; + private static final String ARG_PAGE = "ARG_PAGE"; + + // ====================== 页面参数区 ====================== + private int mPage; + + // ====================== UI控件区 ====================== + private LogView mLogView; + + // ====================== 懒加载标记区 ====================== + private boolean isViewInitialized = false; // 视图控件绑定完成标记 + private boolean isLazyInitCompleted = false; // 懒加载总流程完成标记 + private boolean isLogViewStarted = false; // LogView启动状态标记 + + // ====================== 实例化函数区 ====================== + public static LogFragment newInstance(int page) { + LogUtils.d(TAG, "newInstance: 创建日志Fragment实例,页码=" + page); + Bundle args = new Bundle(); + args.putInt(ARG_PAGE, page); + LogFragment fragment = new LogFragment(); + fragment.setArguments(args); + return fragment; + } + + // ====================== 生命周期函数区 ====================== + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + LogUtils.d(TAG, "onCreate: Fragment创建开始"); + if (getArguments() != null) { + mPage = getArguments().getInt(ARG_PAGE); + LogUtils.d(TAG, "onCreate: 读取页面参数,mPage=" + mPage); + } + LogUtils.d(TAG, "onCreate: Fragment创建完成"); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + LogUtils.d(TAG, "onCreateView: 加载Fragment布局"); + View view = inflater.inflate(R.layout.fragment_log, container, false); + // Java7 适配:添加强制类型转换,仅初始化LogView控件(不启动) + mLogView = (LogView) view.findViewById(R.id.logview); + LogUtils.d(TAG, "onCreateView: LogView控件初始化完成(未启动)"); + // 标记视图控件绑定完成 + isViewInitialized = true; + return view; + } + + @Override + public void onResume() { + super.onResume(); + LogUtils.d(TAG, "onResume: Fragment进入前台"); + // 已完成懒加载 → 仅重启LogView(切回页面时恢复日志显示) + if (isLazyInitCompleted && mLogView != null && !isLogViewStarted) { + mLogView.start(); + isLogViewStarted = true; + LogUtils.d(TAG, "onResume: LogView已重启,恢复日志显示"); + } + // 未完成懒加载 → 不操作(等待MainActivity调用initData触发) + } + + @Override + public void onDestroy() { + super.onDestroy(); + LogUtils.d(TAG, "onDestroy: Fragment开始销毁"); + if (mLogView != null) { + // 若LogView有停止方法,必须调用(避免后台持续占用资源,根据实际API调整) + // mLogView.stop(); // 关键:释放LogView资源,防止内存泄漏 + LogUtils.d(TAG, "onDestroy: LogView资源已释放"); + } + // 重置所有标记,避免重建时状态异常 + mLogView = null; + isViewInitialized = false; + isLazyInitCompleted = false; + isLogViewStarted = false; + LogUtils.d(TAG, "onDestroy: Fragment销毁完成"); + } + + // ====================== 懒加载核心方法(供MainActivity调用) ====================== + public void initData() { + // 双重防护:避免重复初始化(标记+视图就绪+控件非空) + if (isLazyInitCompleted || !isViewInitialized || mLogView == null || getContext() == null) { + LogUtils.d(TAG, "initData: 懒加载已完成/视图未就绪,跳过"); + return; + } + LogUtils.d(TAG, "initData: 开始懒加载初始化,启动LogView"); + // 核心:启动LogView(原onCreateView中的start逻辑迁移至此) + mLogView.start(); + isLogViewStarted = true; + // 标记懒加载总流程完成(仅执行一次) + isLazyInitCompleted = true; + LogUtils.d(TAG, "initData: 懒加载初始化完成,LogView正常启动"); + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/handlers/MainServiceHandler.java b/contacts/src/main/java/cc/winboll/studio/contacts/handlers/MainServiceHandler.java new file mode 100644 index 0000000..bd08e58 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/handlers/MainServiceHandler.java @@ -0,0 +1,38 @@ +package cc.winboll.studio.contacts.handlers; + +/** + * @Author ZhanGSKen + * @Date 2025/02/14 03:51:40 + */ +import android.os.Handler; +import android.os.Message; +import cc.winboll.studio.contacts.services.MainService; +import java.lang.ref.WeakReference; + +public class MainServiceHandler extends Handler { + public static final String TAG = "MainServiceHandler"; + + public static final int MSG_REMINDTHREAD = 0; + + WeakReference serviceWeakReference; + public MainServiceHandler(MainService service) { + serviceWeakReference = new WeakReference(service); + } + + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case MSG_REMINDTHREAD: // 处理下载完成消息,更新UI + { + // 显示提醒消息 + // + //LogUtils.d(TAG, "显示提醒消息"); + MainService mainService = serviceWeakReference.get(); + if (mainService != null) { + mainService.appenMessage((String)msg.obj); + } + break; + } + } + } +} diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/listenphonecall/CallListenerService.java b/contacts/src/main/java/cc/winboll/studio/contacts/listenphonecall/CallListenerService.java new file mode 100644 index 0000000..9230eb4 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/listenphonecall/CallListenerService.java @@ -0,0 +1,392 @@ +package cc.winboll.studio.contacts.listenphonecall; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.graphics.PixelFormat; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.telephony.PhoneStateListener; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.TextView; +import cc.winboll.studio.contacts.R; +import cc.winboll.studio.contacts.model.MainServiceBean; +import cc.winboll.studio.contacts.phonecallui.PhoneCallActivity; +import cc.winboll.studio.contacts.phonecallui.PhoneCallService; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Describe 通话监听服务(无前台服务),负责监听通话状态、显示通话悬浮窗、跳转通话界面 + * 严格适配 Java7 语法 + Android API29-30 | 轻量稳定 | 避免内存泄漏 + */ +public class CallListenerService extends Service { + + // ====================== 常量定义区(精准适配API29-30,无冗余) ====================== + public static final String TAG = "CallListenerService"; + + // Android版本常量(仅保留适配必需版本,精简无用定义) + private static final int ANDROID_8_API = 26; // 悬浮窗类型适配(API26+必需) + private static final int ANDROID_10_API = 29; // API29+ 悬浮窗权限/参数适配 + private static final int ANDROID_19_API = 19; // 透明状态栏/导航栏适配 + + // 延迟初始化参数(让出主线程,避免启动阻塞) + private static final long DELAY_INIT_MS = 100L; + + // ====================== 成员属性区(按功能归类,命名规范) ====================== + // 延迟初始化核心 + private Handler mDelayHandler; // 延迟处理器(避免onCreate阻塞) + + // 通话监听核心 + private TelephonyManager mTelephonyManager; // 电话管理器(监听通话状态) + private PhoneStateListener mPhoneStateListener;// 通话状态监听回调 + private String mCallNumber; // 当前通话号码 + private boolean mIsCallingIn; // 是否为来电(true=来电,false=去电) + + // 悬浮窗核心 + private WindowManager mWindowManager; // 窗口管理器(添加/移除悬浮窗) + private WindowManager.LayoutParams mWindowParams;// 悬浮窗参数配置 + private View mPhoneCallView; // 通话悬浮窗根视图 + private TextView mTvCallNumber; // 悬浮窗号码显示控件 + private Button mBtnOpenApp; // 悬浮窗跳转APP按钮 + private boolean mHasShown; // 悬浮窗显示状态标记(避免重复操作) + + // ====================== Service生命周期方法区(按执行顺序排列) ====================== + @Override + public void onCreate() { + super.onCreate(); + LogUtils.d(TAG, "===== onCreate: 通话监听服务启动 ====="); + + // 延迟初始化所有逻辑(让出主线程,避免启动阻塞,提升启动速度) + initDelayHandlerAndLogic(); + + LogUtils.d(TAG, "===== onCreate: 通话监听服务启动完成 ====="); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + LogUtils.d(TAG, "onStartCommand: 服务被启动,startId=" + startId); + + // 加载服务配置,决定重启策略(启用则自动重启,禁用则默认) + MainServiceBean serviceConfig = MainServiceBean.loadBean(this, MainServiceBean.class); + int startMode = (serviceConfig != null && serviceConfig.isEnable()) ? START_STICKY : super.onStartCommand(intent, flags, startId); + LogUtils.d(TAG, "onStartCommand: 服务启动模式:" + (startMode == START_STICKY ? "START_STICKY(自动重启)" : "默认模式")); + + return startMode; + } + + @Override + public IBinder onBind(Intent intent) { + LogUtils.d(TAG, "onBind: 服务无需绑定,返回null"); + return null; + } + + @Override + public void onDestroy() { + super.onDestroy(); + LogUtils.d(TAG, "===== onDestroy: 通话监听服务开始销毁 ====="); + + // 全量清理资源,彻底避免内存泄漏 + dismissFloatWindow(); // 移除悬浮窗 + unregisterPhoneStateListener();// 注销通话监听 + clearDelayHandler(); // 清空延迟任务 + resetAllReferences(); // 置空所有成员属性 + + LogUtils.d(TAG, "===== onDestroy: 通话监听服务销毁完成 ====="); + } + + // ====================== 延迟初始化方法区(非阻塞启动,提升稳定性) ====================== + /** + * 初始化延迟处理器,执行核心逻辑(通话监听+悬浮窗) + */ + private void initDelayHandlerAndLogic() { + mDelayHandler = new Handler(Looper.getMainLooper()); + mDelayHandler.postDelayed(new Runnable() { + @Override + public void run() { + LogUtils.d(TAG, "initDelayHandlerAndLogic: 开始延迟初始化核心逻辑"); + initPhoneStateListener(); // 初始化通话状态监听 + initFloatWindow(); // 初始化通话悬浮窗 + LogUtils.d(TAG, "initDelayHandlerAndLogic: 延迟初始化完成,服务就绪"); + } + }, DELAY_INIT_MS); + } + + /** + * 初始化通话状态监听(注册TelephonyManager,响应通话状态变化) + */ + private void initPhoneStateListener() { + // 1. 创建通话状态监听回调 + mPhoneStateListener = new PhoneStateListener() { + @Override + public void onCallStateChanged(int callState, String incomingNumber) { + super.onCallStateChanged(callState, incomingNumber); + mCallNumber = incomingNumber; + LogUtils.d(TAG, "onCallStateChanged: 通话状态变化,状态=" + getCallStateDesc(callState) + ",号码=" + incomingNumber); + + // 响应不同通话状态 + switch (callState) { + case TelephonyManager.CALL_STATE_IDLE: + // 通话空闲(挂断/未通话):隐藏悬浮窗 + dismissFloatWindow(); + break; + case TelephonyManager.CALL_STATE_RINGING: + // 来电响铃:标记来电状态,更新UI并显示悬浮窗 + mIsCallingIn = true; + updateFloatWindowUI(); + showFloatWindow(); + break; + case TelephonyManager.CALL_STATE_OFFHOOK: + // 通话中(接听/拨号):更新UI并显示悬浮窗 + updateFloatWindowUI(); + showFloatWindow(); + break; + } + } + }; + + // 2. 注册通话监听(非空校验,避免崩溃) + mTelephonyManager = (TelephonyManager) getSystemService(TELEPHONY_SERVICE); + if (mTelephonyManager != null) { + mTelephonyManager.listen(mPhoneStateListener, PhoneStateListener.LISTEN_CALL_STATE); + LogUtils.d(TAG, "initPhoneStateListener: 通话状态监听注册成功"); + } else { + LogUtils.e(TAG, "initPhoneStateListener: TelephonyManager获取失败,监听注册失败"); + } + } + + /** + * 初始化通话悬浮窗(配置参数+加载布局+绑定事件,适配API29-30) + */ + private void initFloatWindow() { + // 1. 获取窗口管理器(非空校验,避免后续崩溃) + mWindowManager = (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE); + if (mWindowManager == null) { + LogUtils.e(TAG, "initFloatWindow: WindowManager获取失败,悬浮窗初始化失败"); + return; + } + + // 2. 配置悬浮窗参数(精准适配API29+,兼容悬浮窗权限) + initFloatWindowParams(); + + // 3. 加载悬浮窗布局(添加返回键拦截,避免误关闭) + FrameLayout keyInterceptorLayout = new FrameLayout(this) { + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + // 拦截返回键,保障通话时悬浮窗正常显示 + if (event.getAction() == KeyEvent.ACTION_DOWN && event.getKeyCode() == KeyEvent.KEYCODE_BACK) { + LogUtils.d(TAG, "dispatchKeyEvent: 拦截悬浮窗返回键事件"); + return true; + } + return super.dispatchKeyEvent(event); + } + }; + mPhoneCallView = LayoutInflater.from(this).inflate(R.layout.view_phone_call, keyInterceptorLayout); + + // 4. 绑定悬浮窗控件,设置跳转按钮事件 + bindFloatWindowViews(); + + LogUtils.d(TAG, "initFloatWindow: 悬浮窗初始化完成"); + } + + /** + * 配置悬浮窗参数(适配API29+窗口类型,确保正常显示) + */ + private void initFloatWindowParams() { + mWindowParams = new WindowManager.LayoutParams(); + // 窗口位置:顶部居中 + mWindowParams.gravity = Gravity.CENTER_HORIZONTAL | Gravity.TOP; + // 窗口大小:宽度全屏,高度自适应 + mWindowParams.width = WindowManager.LayoutParams.MATCH_PARENT; + mWindowParams.height = WindowManager.LayoutParams.WRAP_CONTENT; + // 固定竖屏显示 + mWindowParams.screenOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; + // 窗口格式:半透明 + mWindowParams.format = PixelFormat.TRANSLUCENT; + + // 窗口类型(API29+ 强制用 TYPE_APPLICATION_OVERLAY,需悬浮窗权限) + if (Build.VERSION.SDK_INT >= ANDROID_10_API) { + mWindowParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; + LogUtils.d(TAG, "initFloatWindowParams: API29+ 悬浮窗类型=TYPE_APPLICATION_OVERLAY(需开启悬浮窗权限)"); + } else if (Build.VERSION.SDK_INT >= ANDROID_8_API) { + mWindowParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; + } else { + mWindowParams.type = WindowManager.LayoutParams.TYPE_PHONE; + } + + // 窗口标志:无焦点(不抢占输入)、全屏布局、兼容透明状态栏/导航栏 + mWindowParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN; + if (Build.VERSION.SDK_INT >= ANDROID_19_API) { + mWindowParams.flags |= WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS + | WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION; + } + } + + /** + * 绑定悬浮窗控件,设置跳转通话详情页事件 + */ + private void bindFloatWindowViews() { + mTvCallNumber = (TextView) mPhoneCallView.findViewById(R.id.tv_call_number); + mBtnOpenApp = (Button) mPhoneCallView.findViewById(R.id.btn_open_app); + + // 跳转按钮点击事件 + mBtnOpenApp.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + if (TextUtils.isEmpty(mCallNumber)) { + LogUtils.w(TAG, "bindFloatWindowViews: 通话号码为空,跳过跳转"); + return; + } + LogUtils.d(TAG, "bindFloatWindowViews: 点击跳转通话详情页,号码=" + mCallNumber); + PhoneCallService.CallType callType = mIsCallingIn ? PhoneCallService.CallType.CALL_IN : PhoneCallService.CallType.CALL_OUT; + PhoneCallActivity.actionStart(CallListenerService.this, mCallNumber, callType); + } + }); + } + + // ====================== 悬浮窗功能逻辑区(显示/隐藏/更新UI) ====================== + /** + * 显示通话悬浮窗(避免重复添加,防止窗口泄露) + */ + private void showFloatWindow() { + if (!mHasShown && mPhoneCallView != null && mWindowManager != null) { + try { + mWindowManager.addView(mPhoneCallView, mWindowParams); + mHasShown = true; + LogUtils.d(TAG, "showFloatWindow: 悬浮窗显示成功"); + } catch (SecurityException e) { + LogUtils.e(TAG, "showFloatWindow: 悬浮窗显示失败(无悬浮窗权限,需引导用户开启)", e); + } catch (Exception e) { + LogUtils.e(TAG, "showFloatWindow: 悬浮窗显示异常", e); + } + } else { + LogUtils.d(TAG, "showFloatWindow: 悬浮窗已显示/组件未初始化,跳过显示"); + } + } + + /** + * 隐藏通话悬浮窗(避免重复移除,防止崩溃) + */ + private void dismissFloatWindow() { + if (mHasShown && mPhoneCallView != null && mWindowManager != null) { + try { + mWindowManager.removeView(mPhoneCallView); + LogUtils.d(TAG, "dismissFloatWindow: 悬浮窗隐藏成功"); + } catch (Exception e) { + LogUtils.e(TAG, "dismissFloatWindow: 悬浮窗隐藏异常", e); + } finally { + mHasShown = false; + mIsCallingIn = false; // 重置来电状态标记 + } + } else { + LogUtils.d(TAG, "dismissFloatWindow: 悬浮窗已隐藏/组件未初始化,跳过隐藏"); + } + } + + /** + * 更新悬浮窗UI(显示格式化号码+通话类型图标) + */ + private void updateFloatWindowUI() { + if (mTvCallNumber == null || TextUtils.isEmpty(mCallNumber)) { + LogUtils.w(TAG, "updateFloatWindowUI: 控件未初始化/号码为空,更新失败"); + return; + } + + // 格式化11位手机号(3-4-4分隔,提升可读性) + String formattedNumber = formatPhoneNumber(mCallNumber); + mTvCallNumber.setText(formattedNumber); + + // 设置通话类型图标(来电/去电区分) + int iconResId = mIsCallingIn ? R.drawable.ic_phone_call_in : R.drawable.ic_phone_call_out; + mTvCallNumber.setCompoundDrawablesWithIntrinsicBounds( + null, null, getResources().getDrawable(iconResId), null + ); + LogUtils.d(TAG, "updateFloatWindowUI: 悬浮窗UI更新完成,号码=" + formattedNumber + ",类型=" + (mIsCallingIn ? "来电" : "去电")); + } + + // ====================== 资源清理方法区(服务销毁时全量释放) ====================== + /** + * 注销通话状态监听(释放TelephonyManager资源) + */ + private void unregisterPhoneStateListener() { + if (mTelephonyManager != null && mPhoneStateListener != null) { + mTelephonyManager.listen(mPhoneStateListener, PhoneStateListener.LISTEN_NONE); + LogUtils.d(TAG, "unregisterPhoneStateListener: 通话监听已注销"); + } + mTelephonyManager = null; + mPhoneStateListener = null; + } + + /** + * 清空延迟处理器(移除未执行任务,避免内存泄漏) + */ + private void clearDelayHandler() { + if (mDelayHandler != null) { + mDelayHandler.removeCallbacksAndMessages(null); + mDelayHandler = null; + LogUtils.d(TAG, "clearDelayHandler: 延迟处理器已清空"); + } + } + + /** + * 置空所有成员属性(彻底释放引用,避免内存泄漏) + */ + private void resetAllReferences() { + mCallNumber = null; + mPhoneCallView = null; + mWindowParams = null; + mWindowManager = null; + mTvCallNumber = null; + mBtnOpenApp = null; + } + + // ====================== 工具方法区(通用辅助功能,独立归类) ====================== + /** + * 格式化手机号(11位手机号:3-4-4分隔,非11位保持原格式) + * @param phoneNum 待格式化的手机号 + * @return 格式化后的号码 + */ + public static String formatPhoneNumber(String phoneNum) { + if (!TextUtils.isEmpty(phoneNum) && phoneNum.length() == 11) { + String formatted = phoneNum.substring(0, 3) + "-" + + phoneNum.substring(3, 7) + "-" + + phoneNum.substring(7); + LogUtils.d(TAG, "formatPhoneNumber: 号码格式化,原=" + phoneNum + ",新=" + formatted); + return formatted; + } + LogUtils.d(TAG, "formatPhoneNumber: 非11位号码,无需格式化,号码=" + phoneNum); + return phoneNum; + } + + /** + * 转换通话状态为文字描述(便于日志查看,快速定位问题) + * @param callState 通话状态(TelephonyManager常量) + * @return 状态描述文字 + */ + private String getCallStateDesc(int callState) { + switch (callState) { + case TelephonyManager.CALL_STATE_IDLE: + return "空闲(挂断/未通话)"; + case TelephonyManager.CALL_STATE_RINGING: + return "响铃(来电)"; + case TelephonyManager.CALL_STATE_OFFHOOK: + return "通话中(接听/拨号)"; + default: + return "未知状态"; + } + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/model/CallLogModel.java b/contacts/src/main/java/cc/winboll/studio/contacts/model/CallLogModel.java new file mode 100644 index 0000000..377fbb7 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/model/CallLogModel.java @@ -0,0 +1,43 @@ +package cc.winboll.studio.contacts.model; + +import cc.winboll.studio.libappbase.LogUtils; +import java.util.Date; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/02/26 13:10:57 + * @Describe 通话记录数据模型 + */ +public class CallLogModel { + // ====================== 常量定义区 ====================== + public static final String TAG = "CallLogModel"; + + // ====================== 成员变量区 ====================== + private String phoneNumber; + private String callStatus; + private Date callDate; + + // ====================== 构造函数区 ====================== + public CallLogModel(String phoneNumber, String callStatus, Date callDate) { + // 去除号码中的空格并初始化 + this.phoneNumber = phoneNumber.replaceAll("\\s", ""); + this.callStatus = callStatus; + this.callDate = callDate; + + LogUtils.d(TAG, "CallLogModel: 初始化通话记录模型 | 号码=" + this.phoneNumber + + " | 状态=" + this.callStatus + " | 时间=" + this.callDate); + } + + // ====================== Getter 方法区 ====================== + public String getPhoneNumber() { + return phoneNumber; + } + + public String getCallStatus() { + return callStatus; + } + + public Date getCallDate() { + return callDate; + } +} diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/model/ContactModel.java b/contacts/src/main/java/cc/winboll/studio/contacts/model/ContactModel.java new file mode 100644 index 0000000..e83c1f7 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/model/ContactModel.java @@ -0,0 +1,135 @@ +package cc.winboll.studio.contacts.model; + +import net.sourceforge.pinyin4j.PinyinHelper; +import net.sourceforge.pinyin4j.format.HanyuPinyinCaseType; +import net.sourceforge.pinyin4j.format.HanyuPinyinOutputFormat; +import net.sourceforge.pinyin4j.format.HanyuPinyinToneType; +import net.sourceforge.pinyin4j.format.exception.BadHanyuPinyinOutputFormatCombination; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/08/30 14:32 + * @Describe 联系人信息数据模型,支持姓名转全拼和拼音首字母 + */ +public class ContactModel { + // ====================== 常量定义区 ====================== + public static final String TAG = "ContactModel"; + // 汉字匹配正则常量,避免重复创建 + private static final String CHINESE_CHAR_REGEX = "[\\u4e00-\\u9fa5]"; + + // ====================== 成员变量区 ====================== + private String name; + private String number; + private String pinyin; + private String pinyinFirstLetter; + + // ====================== 构造函数区 ====================== + public ContactModel(String name, String number) { + LogUtils.d(TAG, "ContactModel: 开始初始化联系人模型"); + this.name = name == null ? "" : name; + // 去除号码空格,空值处理为"" + this.number = number == null ? "" : number.replaceAll("\\s", ""); + // 初始化拼音和拼音首字母 + this.pinyin = convertToPinyin(this.name); + this.pinyinFirstLetter = convertToPinyinFirstLetter(this.name); + + LogUtils.d(TAG, "ContactModel: 联系人初始化完成 | 姓名=" + this.name + + " | 号码=" + this.number + " | 全拼=" + this.pinyin + + " | 拼音首字母=" + this.pinyinFirstLetter); + } + + // ====================== 拼音转换工具方法区 ====================== + /** + * 姓名转为全拼(多音字默认取首个拼音) + */ + private String convertToPinyin(String chinese) { + LogUtils.d(TAG, "convertToPinyin: 开始转换姓名为全拼,姓名=" + chinese); + HanyuPinyinOutputFormat format = getPinyinOutputFormat(); + StringBuilder pinyinSb = new StringBuilder(); + + for (int i = 0; i < chinese.length(); i++) { + char ch = chinese.charAt(i); + // 仅处理汉字 + if (Character.toString(ch).matches(CHINESE_CHAR_REGEX)) { + try { + String[] pinyinArray = PinyinHelper.toHanyuPinyinStringArray(ch, format); + if (pinyinArray != null && pinyinArray.length > 0) { + pinyinSb.append(pinyinArray[0]); + LogUtils.v(TAG, "convertToPinyin: 字符[" + ch + "]转为拼音[" + pinyinArray[0] + "]"); + } + } catch (BadHanyuPinyinOutputFormatCombination e) { + LogUtils.e(TAG, "convertToPinyin: 拼音转换异常,字符=" + ch, e); + } + } else { + pinyinSb.append(ch); + LogUtils.v(TAG, "convertToPinyin: 非汉字字符直接拼接,字符=" + ch); + } + } + + String result = pinyinSb.toString(); + LogUtils.d(TAG, "convertToPinyin: 全拼转换完成,结果=" + result); + return result; + } + + /** + * 姓名转为拼音首字母(多音字默认取首个拼音首字母) + */ + private String convertToPinyinFirstLetter(String chinese) { + LogUtils.d(TAG, "convertToPinyinFirstLetter: 开始转换姓名为拼音首字母,姓名=" + chinese); + HanyuPinyinOutputFormat format = getPinyinOutputFormat(); + StringBuilder firstLetterSb = new StringBuilder(); + + for (int i = 0; i < chinese.length(); i++) { + char ch = chinese.charAt(i); + if (Character.toString(ch).matches(CHINESE_CHAR_REGEX)) { + try { + String[] pinyinArray = PinyinHelper.toHanyuPinyinStringArray(ch, format); + if (pinyinArray != null && pinyinArray.length > 0) { + char firstChar = pinyinArray[0].charAt(0); + firstLetterSb.append(firstChar); + LogUtils.v(TAG, "convertToPinyinFirstLetter: 字符[" + ch + "]转为首字母[" + firstChar + "]"); + } + } catch (BadHanyuPinyinOutputFormatCombination e) { + LogUtils.e(TAG, "convertToPinyinFirstLetter: 拼音首字母转换异常,字符=" + ch, e); + } + } else { + firstLetterSb.append(ch); + LogUtils.v(TAG, "convertToPinyinFirstLetter: 非汉字字符直接拼接,字符=" + ch); + } + } + + String result = firstLetterSb.toString(); + LogUtils.d(TAG, "convertToPinyinFirstLetter: 拼音首字母转换完成,结果=" + result); + return result; + } + + /** + * 获取统一的拼音输出格式(小写、无音调) + * 抽离为公共方法,避免重复创建对象 + */ + private HanyuPinyinOutputFormat getPinyinOutputFormat() { + HanyuPinyinOutputFormat format = new HanyuPinyinOutputFormat(); + format.setCaseType(HanyuPinyinCaseType.LOWERCASE); + format.setToneType(HanyuPinyinToneType.WITHOUT_TONE); + return format; + } + + // ====================== Getter 方法区 ====================== + public String getName() { + return name; + } + + public String getNumber() { + return number; + } + + public String getPinyin() { + return pinyin; + } + + public String getPinyinFirstLetter() { + return pinyinFirstLetter; + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/model/MainServiceBean.java b/contacts/src/main/java/cc/winboll/studio/contacts/model/MainServiceBean.java new file mode 100644 index 0000000..63b24ff --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/model/MainServiceBean.java @@ -0,0 +1,91 @@ +package cc.winboll.studio.contacts.model; + +import android.util.JsonReader; +import android.util.JsonWriter; +import cc.winboll.studio.libappbase.BaseBean; +import cc.winboll.studio.libappbase.LogUtils; +import java.io.IOException; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/02/13 07:06:13 + * @Describe 主服务配置实体类,支持JSON序列化与反序列化 + */ +public class MainServiceBean extends BaseBean { + + // ====================== 常量定义区 ====================== + public static final String TAG = "MainServiceBean"; + private static final String JSON_KEY_IS_ENABLE = "isEnable"; + + // ====================== 成员变量区 ====================== + private boolean isEnable; + + // ====================== 构造函数区 ====================== + public MainServiceBean() { + this.isEnable = false; + LogUtils.d(TAG, "MainServiceBean: 初始化实体类,默认状态为禁用"); + } + + // ====================== Getter & Setter 方法区 ====================== + public void setIsEnable(boolean isEnable) { + LogUtils.d(TAG, "setIsEnable: 服务状态设置为" + isEnable); + this.isEnable = isEnable; + } + + public boolean isEnable() { + return isEnable; + } + + // ====================== 重写 BaseBean 抽象方法区 ====================== + @Override + public String getName() { + String className = MainServiceBean.class.getName(); + LogUtils.v(TAG, "getName: 获取类名=" + className); + return className; + } + + @Override + public void writeThisToJsonWriter(JsonWriter jsonWriter) throws IOException { + LogUtils.d(TAG, "writeThisToJsonWriter: 开始将实体类写入JSON"); + super.writeThisToJsonWriter(jsonWriter); + // 写入服务启用状态字段 + jsonWriter.name(JSON_KEY_IS_ENABLE).value(this.isEnable); + LogUtils.d(TAG, "writeThisToJsonWriter: JSON写入完成,isEnable=" + this.isEnable); + } + + @Override + public boolean initObjectsFromJsonReader(JsonReader jsonReader, String name) throws IOException { + // 优先调用父类方法处理通用字段 + if (super.initObjectsFromJsonReader(jsonReader, name)) { + LogUtils.v(TAG, "initObjectsFromJsonReader: 父类已处理字段=" + name); + return true; + } + + // 处理当前类专属字段 + if (JSON_KEY_IS_ENABLE.equals(name)) { + this.isEnable = jsonReader.nextBoolean(); + LogUtils.d(TAG, "initObjectsFromJsonReader: 读取字段[" + name + "]值=" + this.isEnable); + return true; + } + + LogUtils.w(TAG, "initObjectsFromJsonReader: 未识别字段=" + name); + return false; + } + + @Override + public BaseBean readBeanFromJsonReader(JsonReader jsonReader) throws IOException { + LogUtils.d(TAG, "readBeanFromJsonReader: 开始从JSON读取实体类数据"); + jsonReader.beginObject(); + while (jsonReader.hasNext()) { + String name = jsonReader.nextName(); + if (!initObjectsFromJsonReader(jsonReader, name)) { + LogUtils.w(TAG, "readBeanFromJsonReader: 跳过未识别字段=" + name); + jsonReader.skipValue(); + } + } + jsonReader.endObject(); + LogUtils.d(TAG, "readBeanFromJsonReader: JSON读取完成,当前实体状态=" + this.isEnable); + return this; + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/model/PhoneConnectRuleBean.java b/contacts/src/main/java/cc/winboll/studio/contacts/model/PhoneConnectRuleBean.java new file mode 100644 index 0000000..6d3c765 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/model/PhoneConnectRuleBean.java @@ -0,0 +1,148 @@ +package cc.winboll.studio.contacts.model; + +import android.util.JsonReader; +import android.util.JsonWriter; +import cc.winboll.studio.libappbase.BaseBean; +import cc.winboll.studio.libappbase.LogUtils; +import java.io.IOException; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/02/21 09:52:10 + * @Describe 电话黑名单规则实体类,支持JSON序列化与反序列化 + */ +public class PhoneConnectRuleBean extends BaseBean { + // ====================== 常量定义区 ====================== + public static final String TAG = "PhoneConnectRuleModel"; + // JSON字段名常量,避免硬编码错误 + private static final String JSON_KEY_RULE_TEXT = "ruleText"; + private static final String JSON_KEY_ALLOW_CONNECTION = "isAllowConnection"; + private static final String JSON_KEY_IS_ENABLE = "isEnable"; + + // ====================== 成员变量区 ====================== + private String ruleText; + private boolean isAllowConnection; + private boolean isEnable; + private boolean isSimpleView; + + // ====================== 构造函数区 ====================== + /** + * 默认构造,初始化默认值 + */ + public PhoneConnectRuleBean() { + this.ruleText = ""; + this.isAllowConnection = false; + this.isEnable = false; + this.isSimpleView = true; + LogUtils.d(TAG, "PhoneConnectRuleModel: 默认构造初始化完成 | 规则文本空串,默认禁用状态"); + } + + /** + * 带参构造,初始化核心规则参数 + */ + public PhoneConnectRuleBean(String ruleText, boolean isAllowConnection, boolean isEnable) { + this.ruleText = ruleText == null ? "" : ruleText; + this.isAllowConnection = isAllowConnection; + this.isEnable = isEnable; + this.isSimpleView = true; + LogUtils.d(TAG, "PhoneConnectRuleModel: 带参构造初始化完成 | 规则文本=" + this.ruleText + + " | 允许连接=" + this.isAllowConnection + " | 规则启用=" + this.isEnable); + } + + // ====================== Getter & Setter 方法区 ====================== + public String getRuleText() { + return ruleText; + } + + public void setRuleText(String ruleText) { + String oldValue = this.ruleText; + this.ruleText = ruleText == null ? "" : ruleText; + LogUtils.d(TAG, "setRuleText: 规则文本更新 | 旧值=" + oldValue + " | 新值=" + this.ruleText); + } + + public boolean isAllowConnection() { + return isAllowConnection; + } + + public void setIsAllowConnection(boolean isAllowConnection) { + LogUtils.d(TAG, "setIsAllowConnection: 允许连接状态更新为" + isAllowConnection); + this.isAllowConnection = isAllowConnection; + } + + public boolean isEnable() { + return isEnable; + } + + public void setIsEnable(boolean isEnable) { + LogUtils.d(TAG, "setIsEnable: 规则启用状态更新为" + isEnable); + this.isEnable = isEnable; + } + + public boolean isSimpleView() { + return isSimpleView; + } + + public void setIsSimpleView(boolean isSimpleView) { + LogUtils.d(TAG, "setIsSimpleView: 视图模式更新 | 简洁模式=" + isSimpleView); + this.isSimpleView = isSimpleView; + } + + // ====================== 重写 BaseBean 抽象方法区 ====================== + @Override + public String getName() { + String className = PhoneConnectRuleBean.class.getName(); + LogUtils.v(TAG, "getName: 获取当前类名=" + className); + return className; + } + + @Override + public void writeThisToJsonWriter(JsonWriter jsonWriter) throws IOException { + LogUtils.d(TAG, "writeThisToJsonWriter: 开始JSON序列化规则数据"); + super.writeThisToJsonWriter(jsonWriter); + // 序列化核心字段 + jsonWriter.name(JSON_KEY_RULE_TEXT).value(getRuleText()); + jsonWriter.name(JSON_KEY_ALLOW_CONNECTION).value(isAllowConnection()); + jsonWriter.name(JSON_KEY_IS_ENABLE).value(isEnable()); + LogUtils.d(TAG, "writeThisToJsonWriter: JSON序列化完成"); + } + + @Override + public boolean initObjectsFromJsonReader(JsonReader jsonReader, String name) throws IOException { + // 优先让父类处理通用字段 + if (super.initObjectsFromJsonReader(jsonReader, name)) { + LogUtils.v(TAG, "initObjectsFromJsonReader: 父类已处理字段=" + name); + return true; + } + + // 处理当前类专属字段 + if (JSON_KEY_RULE_TEXT.equals(name)) { + setRuleText(jsonReader.nextString()); + } else if (JSON_KEY_ALLOW_CONNECTION.equals(name)) { + setIsAllowConnection(jsonReader.nextBoolean()); + } else if (JSON_KEY_IS_ENABLE.equals(name)) { + setIsEnable(jsonReader.nextBoolean()); + } else { + LogUtils.w(TAG, "initObjectsFromJsonReader: 未识别的JSON字段=" + name); + return false; + } + LogUtils.v(TAG, "initObjectsFromJsonReader: 成功解析字段=" + name); + return true; + } + + @Override + public BaseBean readBeanFromJsonReader(JsonReader jsonReader) throws IOException { + LogUtils.d(TAG, "readBeanFromJsonReader: 开始从JSON解析规则数据"); + jsonReader.beginObject(); + while (jsonReader.hasNext()) { + String fieldName = jsonReader.nextName(); + if (!initObjectsFromJsonReader(jsonReader, fieldName)) { + LogUtils.w(TAG, "readBeanFromJsonReader: 跳过未识别字段=" + fieldName); + jsonReader.skipValue(); + } + } + jsonReader.endObject(); + LogUtils.d(TAG, "readBeanFromJsonReader: JSON解析完成 | 解析后规则=" + getRuleText()); + return this; + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/model/RingTongBean.java b/contacts/src/main/java/cc/winboll/studio/contacts/model/RingTongBean.java new file mode 100644 index 0000000..ab633ed --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/model/RingTongBean.java @@ -0,0 +1,107 @@ +package cc.winboll.studio.contacts.model; + +import android.util.JsonReader; +import android.util.JsonWriter; +import cc.winboll.studio.libappbase.BaseBean; +import cc.winboll.studio.libappbase.LogUtils; +import java.io.IOException; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/02/24 18:47:11 + * @Describe 手机铃声设置参数类,支持JSON序列化与反序列化 + */ +public class RingTongBean extends BaseBean { + + // ====================== 常量定义区 ====================== + public static final String TAG = "AudioRingTongBean"; + private static final String JSON_KEY_STREAM_VOLUME = "streamVolume"; + // 铃声音量范围常量(参考AudioManager标准) + private static final int VOLUME_MIN = 0; + private static final int VOLUME_MAX = 100; + + // ====================== 成员变量区 ====================== + private int streamVolume; + + // ====================== 构造函数区 ====================== + /** + * 默认构造,铃声音量初始化为最大值 + */ + public RingTongBean() { + this.streamVolume = VOLUME_MAX; + LogUtils.d(TAG, "RingTongBean: 默认构造初始化 | 铃声音量=" + this.streamVolume); + } + + /** + * 带参构造,初始化指定铃声音量 + */ + public RingTongBean(int streamVolume) { + // 音量值范围校验,避免非法值 + this.streamVolume = Math.max(VOLUME_MIN, Math.min(VOLUME_MAX, streamVolume)); + LogUtils.d(TAG, "RingTongBean: 带参构造初始化 | 原始音量=" + streamVolume + " | 校正后=" + this.streamVolume); + } + + // ====================== Getter & Setter 方法区 ====================== + public int getStreamVolume() { + return streamVolume; + } + + public void setStreamVolume(int streamVolume) { + int oldVolume = this.streamVolume; + // 音量值范围校验 + this.streamVolume = Math.max(VOLUME_MIN, Math.min(VOLUME_MAX, streamVolume)); + LogUtils.d(TAG, "setStreamVolume: 铃声音量更新 | 旧值=" + oldVolume + " | 新值=" + this.streamVolume); + } + + // ====================== 重写 BaseBean 抽象方法区 ====================== + @Override + public String getName() { + String className = RingTongBean.class.getName(); + LogUtils.v(TAG, "getName: 获取当前类名=" + className); + return className; + } + + @Override + public void writeThisToJsonWriter(JsonWriter jsonWriter) throws IOException { + LogUtils.d(TAG, "writeThisToJsonWriter: 开始JSON序列化铃声音量参数"); + super.writeThisToJsonWriter(jsonWriter); + jsonWriter.name(JSON_KEY_STREAM_VOLUME).value(getStreamVolume()); + LogUtils.d(TAG, "writeThisToJsonWriter: JSON序列化完成 | 音量值=" + getStreamVolume()); + } + + @Override + public boolean initObjectsFromJsonReader(JsonReader jsonReader, String name) throws IOException { + // 优先调用父类处理通用字段 + if (super.initObjectsFromJsonReader(jsonReader, name)) { + LogUtils.v(TAG, "initObjectsFromJsonReader: 父类已处理字段=" + name); + return true; + } + + // 处理当前类专属字段 + if (JSON_KEY_STREAM_VOLUME.equals(name)) { + setStreamVolume(jsonReader.nextInt()); + LogUtils.v(TAG, "initObjectsFromJsonReader: 解析字段[" + name + "]值=" + this.streamVolume); + return true; + } + + LogUtils.w(TAG, "initObjectsFromJsonReader: 未识别的JSON字段=" + name); + return false; + } + + @Override + public BaseBean readBeanFromJsonReader(JsonReader jsonReader) throws IOException { + LogUtils.d(TAG, "readBeanFromJsonReader: 开始从JSON解析铃声音量参数"); + jsonReader.beginObject(); + while (jsonReader.hasNext()) { + String fieldName = jsonReader.nextName(); + if (!initObjectsFromJsonReader(jsonReader, fieldName)) { + LogUtils.w(TAG, "readBeanFromJsonReader: 跳过未识别字段=" + fieldName); + jsonReader.skipValue(); + } + } + jsonReader.endObject(); + LogUtils.d(TAG, "readBeanFromJsonReader: JSON解析完成 | 最终音量值=" + this.streamVolume); + return this; + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/model/SettingsBean.java b/contacts/src/main/java/cc/winboll/studio/contacts/model/SettingsBean.java new file mode 100644 index 0000000..2b40c8c --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/model/SettingsBean.java @@ -0,0 +1,216 @@ +package cc.winboll.studio.contacts.model; + +import android.util.JsonReader; +import android.util.JsonWriter; + +import java.io.IOException; + +import cc.winboll.studio.contacts.utils.IntUtils; +import cc.winboll.studio.libappbase.BaseBean; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/03/02 19:51:40 + * @Describe 应用设置数据模型,支持云盾防御配置与JSON序列化 + */ +public class SettingsBean extends BaseBean { + + // ====================== 常量定义区 ====================== + public static final String TAG = "SettingsModel"; + // 数值范围常量 + public static final int MAX_INTRANGE = 666666; + public static final int MIN_INTRANGE = 1; + // JSON字段名常量,消除硬编码 + private static final String JSON_KEY_DUN_TOTAL = "dunTotalCount"; + private static final String JSON_KEY_DUN_CURRENT = "dunCurrentCount"; + private static final String JSON_KEY_DUN_RESUME_SECOND = "dunResumeSecondCount"; + private static final String JSON_KEY_DUN_RESUME_COUNT = "dunResumeCount"; + private static final String JSON_KEY_DUN_ENABLE = "isEnableDun"; + private static final String JSON_KEY_URL = "szBoBullToon_URL"; + + // ====================== 成员变量区 ====================== + // 云盾防御层数量 + private int dunTotalCount; + // 当前云盾防御层 + private int dunCurrentCount; + // 防御层恢复时间间隔(秒钟) + private int dunResumeSecondCount; + // 每次恢复防御层数 + private int dunResumeCount; + // 是否启用云盾 + private boolean isEnableDun; + // BoBullToon 应用模块数据请求地址 + private String szBoBullToon_URL; + + // ====================== 构造函数区 ====================== + /** + * 默认构造,初始化默认配置 + */ + public SettingsBean() { + this.dunTotalCount = 6; + this.dunCurrentCount = 6; + this.dunResumeSecondCount = 60; + this.dunResumeCount = 1; + this.isEnableDun = false; + this.szBoBullToon_URL = ""; + LogUtils.d(TAG, "SettingsModel: 默认构造初始化完成 | 云盾默认配置加载完毕"); + } + + /** + * 带参构造,初始化自定义配置并校验数值范围 + */ + public SettingsBean(int dunTotalCount, int dunCurrentCount, int dunResumeSecondCount, + int dunResumeCount, boolean isEnableDun, String szBoBullToon_URL) { + this.dunTotalCount = getSettingsModelRangeInt(dunTotalCount); + this.dunCurrentCount = getSettingsModelRangeInt(dunCurrentCount); + this.dunResumeSecondCount = getSettingsModelRangeInt(dunResumeSecondCount); + this.dunResumeCount = getSettingsModelRangeInt(dunResumeCount); + this.isEnableDun = isEnableDun; + this.szBoBullToon_URL = szBoBullToon_URL == null ? "" : szBoBullToon_URL; + + LogUtils.d(TAG, "SettingsModel: 带参构造初始化完成 | 总层数=" + this.dunTotalCount + + " | 当前层数=" + this.dunCurrentCount + " | 恢复间隔=" + this.dunResumeSecondCount + + " | 恢复层数=" + this.dunResumeCount + " | 云盾启用=" + this.isEnableDun); + } + + // ====================== 私有工具方法区 ====================== + /** + * 数值范围校验,确保参数在 MIN~MAX 区间内 + */ + private int getSettingsModelRangeInt(int origin) { + int result = IntUtils.getIntInRange(origin, MIN_INTRANGE, MAX_INTRANGE); + if (result != origin) { + LogUtils.w(TAG, "getSettingsModelRangeInt: 数值校正 | 原始值=" + origin + " | 校正后=" + result); + } + return result; + } + + // ====================== Getter & Setter 方法区 ====================== + public int getDunTotalCount() { + return dunTotalCount; + } + + public void setDunTotalCount(int dunTotalCount) { + int oldValue = this.dunTotalCount; + this.dunTotalCount = getSettingsModelRangeInt(dunTotalCount); + LogUtils.d(TAG, "setDunTotalCount: 总防御层数更新 | 旧值=" + oldValue + " | 新值=" + this.dunTotalCount); + } + + public int getDunCurrentCount() { + return dunCurrentCount; + } + + public void setDunCurrentCount(int dunCurrentCount) { + int oldValue = this.dunCurrentCount; + this.dunCurrentCount = getSettingsModelRangeInt(dunCurrentCount); + LogUtils.d(TAG, "setDunCurrentCount: 当前防御层数更新 | 旧值=" + oldValue + " | 新值=" + this.dunCurrentCount); + } + + public int getDunResumeSecondCount() { + return dunResumeSecondCount; + } + + public void setDunResumeSecondCount(int dunResumeSecondCount) { + int oldValue = this.dunResumeSecondCount; + this.dunResumeSecondCount = getSettingsModelRangeInt(dunResumeSecondCount); + LogUtils.d(TAG, "setDunResumeSecondCount: 恢复间隔更新 | 旧值=" + oldValue + " | 新值=" + this.dunResumeSecondCount); + } + + public int getDunResumeCount() { + return dunResumeCount; + } + + public void setDunResumeCount(int dunResumeCount) { + int oldValue = this.dunResumeCount; + this.dunResumeCount = getSettingsModelRangeInt(dunResumeCount); + LogUtils.d(TAG, "setDunResumeCount: 恢复层数更新 | 旧值=" + oldValue + " | 新值=" + this.dunResumeCount); + } + + public boolean isEnableDun() { + return isEnableDun; + } + + public void setIsEnableDun(boolean isEnableDun) { + LogUtils.d(TAG, "setIsEnableDun: 云盾启用状态更新为" + isEnableDun); + this.isEnableDun = isEnableDun; + } + + public String getBoBullToon_URL() { + return szBoBullToon_URL; + } + + public void setBoBullToon_URL(String boBullToon_URL) { + String oldValue = this.szBoBullToon_URL; + this.szBoBullToon_URL = boBullToon_URL == null ? "" : boBullToon_URL; + LogUtils.d(TAG, "setBoBullToon_URL: 请求地址更新 | 旧值=" + oldValue + " | 新值=" + this.szBoBullToon_URL); + } + + // ====================== 重写 BaseBean 抽象方法区 ====================== + @Override + public String getName() { + String className = SettingsBean.class.getName(); + LogUtils.v(TAG, "getName: 获取当前类名=" + className); + return className; + } + + @Override + public void writeThisToJsonWriter(JsonWriter jsonWriter) throws IOException { + LogUtils.d(TAG, "writeThisToJsonWriter: 开始JSON序列化设置数据"); + super.writeThisToJsonWriter(jsonWriter); + // 写入所有配置字段 + jsonWriter.name(JSON_KEY_DUN_TOTAL).value(getDunTotalCount()); + jsonWriter.name(JSON_KEY_DUN_CURRENT).value(getDunCurrentCount()); + jsonWriter.name(JSON_KEY_DUN_RESUME_SECOND).value(getDunResumeSecondCount()); + jsonWriter.name(JSON_KEY_DUN_RESUME_COUNT).value(getDunResumeCount()); + jsonWriter.name(JSON_KEY_DUN_ENABLE).value(isEnableDun()); + jsonWriter.name(JSON_KEY_URL).value(getBoBullToon_URL()); + LogUtils.d(TAG, "writeThisToJsonWriter: JSON序列化完成"); + } + + @Override + public boolean initObjectsFromJsonReader(JsonReader jsonReader, String name) throws IOException { + // 优先调用父类处理通用字段 + if (super.initObjectsFromJsonReader(jsonReader, name)) { + LogUtils.v(TAG, "initObjectsFromJsonReader: 父类已处理字段=" + name); + return true; + } + + // 处理当前类专属配置字段 + if (JSON_KEY_DUN_TOTAL.equals(name)) { + setDunTotalCount(getSettingsModelRangeInt(jsonReader.nextInt())); + } else if (JSON_KEY_DUN_CURRENT.equals(name)) { + setDunCurrentCount(getSettingsModelRangeInt(jsonReader.nextInt())); + } else if (JSON_KEY_DUN_RESUME_SECOND.equals(name)) { + setDunResumeSecondCount(getSettingsModelRangeInt(jsonReader.nextInt())); + } else if (JSON_KEY_DUN_RESUME_COUNT.equals(name)) { + setDunResumeCount(getSettingsModelRangeInt(jsonReader.nextInt())); + } else if (JSON_KEY_DUN_ENABLE.equals(name)) { + setIsEnableDun(jsonReader.nextBoolean()); + } else if (JSON_KEY_URL.equals(name)) { + setBoBullToon_URL(jsonReader.nextString()); + } else { + LogUtils.w(TAG, "initObjectsFromJsonReader: 未识别的JSON字段=" + name); + return false; + } + LogUtils.v(TAG, "initObjectsFromJsonReader: 成功解析字段=" + name); + return true; + } + + @Override + public BaseBean readBeanFromJsonReader(JsonReader jsonReader) throws IOException { + LogUtils.d(TAG, "readBeanFromJsonReader: 开始从JSON解析设置数据"); + jsonReader.beginObject(); + while (jsonReader.hasNext()) { + String fieldName = jsonReader.nextName(); + if (!initObjectsFromJsonReader(jsonReader, fieldName)) { + LogUtils.w(TAG, "readBeanFromJsonReader: 跳过未识别字段=" + fieldName); + jsonReader.skipValue(); + } + } + jsonReader.endObject(); + LogUtils.d(TAG, "readBeanFromJsonReader: JSON解析完成 | 云盾配置加载完毕"); + return this; + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/phonecallui/PhoneCallActivity.java b/contacts/src/main/java/cc/winboll/studio/contacts/phonecallui/PhoneCallActivity.java new file mode 100644 index 0000000..4da49e7 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/phonecallui/PhoneCallActivity.java @@ -0,0 +1,362 @@ +package cc.winboll.studio.contacts.phonecallui; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.widget.TextView; +import android.widget.Toast; +import cc.winboll.studio.contacts.ActivityStack; +import cc.winboll.studio.contacts.R; +import cc.winboll.studio.libappbase.LogUtils; +import java.util.Timer; +import java.util.TimerTask; + +import static cc.winboll.studio.contacts.listenphonecall.CallListenerService.formatPhoneNumber; + +/** + * @Author aJIEw, ZhanGSKen&豆包大模型 + * @Date 2025/12/14 21:01 + * @Describe 接打电话界面(单例模式 + 适配API29 - 30 + 小米机型兼容性优化) + * 功能:单例通话窗口、来电/去电显示、通话计时、免提控制、锁屏显示 + */ +public class PhoneCallActivity extends Activity implements View.OnClickListener { + // 常量定义区(核心常量+小米适配标识) + public static final String TAG = "PhoneCallActivity"; + private static final int MSG_CLOSE_ACTIVITY = 0x001; + private static final String MI_ADAPT_TAG = "MiAdapt"; + private static final String TOAST_CALLING = "通话进行中,无法重复创建通话窗口"; + private static final long CLOSE_DELAY_MS = 100; // 小米机型关闭延迟时间 + + // 静态属性区(单例核心+全局工具对象) + private static volatile boolean sIsActivityAlive = false; + private static Handler sCloseHandler; + + // 控件属性区(按界面布局顺序排列) + private TextView mTvCallNumberLabel; + private TextView mTvCallNumber; + private TextView mTvPickUp; + private TextView mTvCallingTime; + private TextView mTvHangUp; + + // 业务属性区(按依赖优先级排列) + private PhoneCallManager mPhoneCallManager; + private PhoneCallService.CallType mCallType; + private String mPhoneNumber; + private Timer mOnGoingCallTimer; + private int mCallingTime; + private boolean isClosing = false; // 新增:避免重复关闭页面 + + // 对外静态接口(单例启动+外部关闭) + public static void actionStart(Context context, String phoneNumber, PhoneCallService.CallType callType) { + if (context == null || phoneNumber == null || callType == null) { + LogUtils.e(TAG, "actionStart: 入参为空,启动失败"); + return; + } + + if (sIsActivityAlive) { + LogUtils.w(TAG, MI_ADAPT_TAG + " 已有活跃通话窗口,拒绝重复启动"); + Toast.makeText(context, TOAST_CALLING, Toast.LENGTH_SHORT).show(); + return; + } + + LogUtils.d(TAG, MI_ADAPT_TAG + " 启动通话界面,号码=" + phoneNumber + ",类型=" + callType.name()); + Intent intent = new Intent(context, PhoneCallActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); + intent.putExtra("call_type", callType); + intent.putExtra(Intent.EXTRA_PHONE_NUMBER, phoneNumber); + context.startActivity(intent); + } + + public static void closePhoneCallActivity() { + LogUtils.d(TAG, "closePhoneCallActivity: 收到外部关闭指令"); + if (sIsActivityAlive && sCloseHandler != null) { + sCloseHandler.sendEmptyMessage(MSG_CLOSE_ACTIVITY); + LogUtils.d(TAG, "closePhoneCallActivity: 关闭消息已发送"); + } else { + LogUtils.w(TAG, "closePhoneCallActivity: 页面已销毁或Handler未初始化,关闭跳过"); + } + } + + // 生命周期方法区(按执行流程排序) + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + LogUtils.d(TAG, MI_ADAPT_TAG + " 通话界面开始创建,SDK版本=" + Build.VERSION.SDK_INT); + + // 单例双重校验,防止异常场景多实例 + if (sIsActivityAlive) { + Toast.makeText(this, TOAST_CALLING, Toast.LENGTH_SHORT).show(); + LogUtils.w(TAG, MI_ADAPT_TAG + " 拦截重复创建,即将关闭当前实例"); + finish(); + return; + } + sIsActivityAlive = false; + + setContentView(R.layout.activity_phone_call); + ActivityStack.getInstance().addActivity(this); + adaptLockScreenAndXiaomi(); + initHandler(); + initData(); + initView(); + LogUtils.d(TAG, MI_ADAPT_TAG + " 通话界面创建完成"); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + LogUtils.d(TAG, MI_ADAPT_TAG + " 通话界面开始销毁"); + + sIsActivityAlive = false; + isClosing = false; + stopTimer(); + // 销毁通话管理器 + if (mPhoneCallManager != null) { + mPhoneCallManager.destroy(); + mPhoneCallManager = null; + LogUtils.d(TAG, "销毁通话管理器资源"); + } + // 销毁Handler避免内存泄漏 + if (sCloseHandler != null) { + sCloseHandler.removeCallbacksAndMessages(null); + sCloseHandler = null; + LogUtils.d(TAG, "销毁关闭Handler"); + } + ActivityStack.getInstance().removeActivity(this); + LogUtils.d(TAG, MI_ADAPT_TAG + " 通话界面销毁完成"); + } + + @Override + protected void onStop() { + super.onStop(); + if (isFinishing()) { + sIsActivityAlive = false; + LogUtils.d(TAG, MI_ADAPT_TAG + " 页面即将关闭,重置单例标记"); + } + } + + // 点击事件回调 + @Override + public void onClick(View v) { + if (v == null) { + LogUtils.w(TAG, "onClick: 点击控件为空,忽略操作"); + return; + } + switch (v.getId()) { + case R.id.tv_phone_pick_up: + LogUtils.d(TAG, "onClick: 触发接听操作"); + answerCall(); + break; + case R.id.tv_phone_hang_up: + LogUtils.d(TAG, "onClick: 触发挂断操作,当前通话时长=" + mCallingTime + "秒"); + hangUpCall(); + break; + default: + LogUtils.w(TAG, "onClick: 未知点击事件,控件ID=" + v.getId()); + } + } + + // 初始化方法区(按初始化顺序排列) + private void initHandler() { + sCloseHandler = new Handler(Looper.getMainLooper()) { + @Override + public void handleMessage(Message msg) { + super.handleMessage(msg); + if (msg.what == MSG_CLOSE_ACTIVITY) { + LogUtils.d(TAG, "handleMessage: 收到关闭消息,执行挂断逻辑"); + hangUpCall(); + } + } + }; + LogUtils.d(TAG, "initHandler: 关闭Handler初始化完成"); + } + + private void initData() { + LogUtils.d(TAG, "initData: 开始初始化业务数据"); + mPhoneCallManager = PhoneCallManager.getInstance(this); + Intent intent = getIntent(); + + if (intent == null) { + LogUtils.e(TAG, "initData: 启动Intent为空,终止初始化"); + removeFromRecentsAndFinish(); + return; + } + + mPhoneNumber = intent.getStringExtra(Intent.EXTRA_PHONE_NUMBER); + mCallType = (PhoneCallService.CallType) intent.getSerializableExtra("call_type"); + if (mPhoneNumber == null || mCallType == null) { + LogUtils.e(TAG, "initData: 通话号码或类型解析失败"); + removeFromRecentsAndFinish(); + return; + } + + mOnGoingCallTimer = new Timer(); + mCallingTime = 0; + LogUtils.d(TAG, "initData: 业务数据初始化完成,号码=" + mPhoneNumber); + } + + private void initView() { + LogUtils.d(TAG, "initView: 开始初始化界面控件"); + // 修复沉浸式导航栏语法,适配小米全面屏 + int uiOptions = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE; + getWindow().getDecorView().setSystemUiVisibility(uiOptions); + + // 绑定控件 + mTvCallNumberLabel = findViewById(R.id.tv_call_number_label); + mTvCallNumber = findViewById(R.id.tv_call_number); + mTvPickUp = findViewById(R.id.tv_phone_pick_up); + mTvCallingTime = findViewById(R.id.tv_phone_calling_time); + mTvHangUp = findViewById(R.id.tv_phone_hang_up); + + // 设置控件属性 + mTvCallNumber.setText(formatPhoneNumber(mPhoneNumber)); + mTvPickUp.setOnClickListener(this); + mTvHangUp.setOnClickListener(this); + + // 区分来电/去电UI样式 + if (PhoneCallService.CallType.CALL_IN == mCallType) { + mTvCallNumberLabel.setText("来电号码"); + mTvPickUp.setVisibility(View.VISIBLE); + mTvCallingTime.setVisibility(View.GONE); + } else if (PhoneCallService.CallType.CALL_OUT == mCallType) { + mTvCallNumberLabel.setText("呼叫号码"); + mTvPickUp.setVisibility(View.GONE); + mTvCallingTime.setVisibility(View.VISIBLE); + mTvCallingTime.setText("通话中:00:00"); + if (mPhoneCallManager != null) { + mPhoneCallManager.openSpeaker(); + LogUtils.d(TAG, MI_ADAPT_TAG + " 去电模式自动开启免提"); + } + startCallTimer(); + } + LogUtils.d(TAG, "initView: 界面控件初始化完成"); + } + + // 小米机型专属适配方法 + private void adaptLockScreenAndXiaomi() { + LogUtils.d(TAG, MI_ADAPT_TAG + " 执行锁屏适配逻辑"); + Window window = getWindow(); + if (window == null) { + LogUtils.e(TAG, MI_ADAPT_TAG + " Window对象为空,适配失败"); + return; + } + + int flags = WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + | WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED; + + // 小米机型额外添加解锁屏标志,解决MIUI锁屏拦截问题 + if (Build.MANUFACTURER.equalsIgnoreCase("Xiaomi")) { + flags |= WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD; + LogUtils.d(TAG, MI_ADAPT_TAG + " 已添加小米机型专属锁屏适配标志"); + } + window.addFlags(flags); + + // 适配API29+锁屏新接口 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + setShowWhenLocked(true); + setTurnScreenOn(true); + LogUtils.d(TAG, MI_ADAPT_TAG + " 适配API29+锁屏接口完成"); + } + } + + // 通话核心业务方法 + private void answerCall() { + LogUtils.d(TAG, "answerCall: 执行接听操作"); + if (mPhoneCallManager == null) { + LogUtils.e(TAG, "answerCall: 通话管理器为空,接听失败"); + return; + } + mPhoneCallManager.answer(); + mTvPickUp.setVisibility(View.GONE); + mTvCallingTime.setVisibility(View.VISIBLE); + mTvCallingTime.setText("通话中:00:00"); + startCallTimer(); + LogUtils.d(TAG, "answerCall: 接听操作完成,启动通话计时"); + } + + private void hangUpCall() { + if (isClosing) { + LogUtils.w(TAG, "hangUpCall: 挂断操作已执行,无需重复调用"); + return; + } + LogUtils.d(TAG, "hangUpCall: 执行挂断操作,当前时长=" + mCallingTime + "秒"); + isClosing = true; + stopTimer(); + if (mPhoneCallManager != null) { + mPhoneCallManager.disconnect(); + LogUtils.d(TAG, "hangUpCall: 通话连接已断开"); + } + // 延迟关闭页面,适配小米机型通话时序 + new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { + @Override + public void run() { + removeFromRecentsAndFinish(); + } + }, CLOSE_DELAY_MS); + } + + // 任务栈清理方法 + private void removeFromRecentsAndFinish() { + if (isFinishing()) { + LogUtils.d(TAG, "removeFromRecentsAndFinish: 页面已在关闭中,无需重复操作"); + return; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + finishAndRemoveTask(); + LogUtils.d(TAG, MI_ADAPT_TAG + " 移除任务栈并关闭页面"); + } else { + finish(); + LogUtils.d(TAG, "兼容低版本,关闭页面"); + } + } + + // 计时工具方法 + private void startCallTimer() { + LogUtils.d(TAG, "startCallTimer: 启动通话计时器"); + if (mOnGoingCallTimer == null) { + mOnGoingCallTimer = new Timer(); + } + mOnGoingCallTimer.schedule(new TimerTask() { + @Override + public void run() { + runOnUiThread(new Runnable() { + @Override + public void run() { + mCallingTime++; + mTvCallingTime.setText("通话中:" + formatCallingTime(mCallingTime)); + } + }); + } + }, 0, 1000); + } + + private void stopTimer() { + LogUtils.d(TAG, "stopTimer: 停止通话计时器"); + if (mOnGoingCallTimer != null) { + mOnGoingCallTimer.cancel(); + mOnGoingCallTimer = null; + } + mCallingTime = 0; + } + + // 辅助工具方法:格式化通话时长 + private String formatCallingTime(int seconds) { + int minute = seconds / 60; + int second = seconds % 60; + String minuteStr = minute < 10 ? "0" + minute : String.valueOf(minute); + String secondStr = second < 10 ? "0" + second : String.valueOf(second); + return minuteStr + ":" + secondStr; + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/phonecallui/PhoneCallManager.java b/contacts/src/main/java/cc/winboll/studio/contacts/phonecallui/PhoneCallManager.java new file mode 100644 index 0000000..9307cdf --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/phonecallui/PhoneCallManager.java @@ -0,0 +1,204 @@ +package cc.winboll.studio.contacts.phonecallui; + +import android.content.Context; +import android.media.AudioManager; +import android.os.Build; +import android.telecom.Call; +import android.telecom.VideoProfile; +import androidx.annotation.RequiresApi; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/12/15 20:11 + * @Describe 通话核心管理类 + * 功能:接听/挂断通话、免提控制、资源释放,适配API29-30及小米机型 + */ +@RequiresApi(api = Build.VERSION_CODES.Q) // 匹配目标适配区间API29 +public class PhoneCallManager { + // 常量定义区 + public static final String TAG = "PhoneCallManager"; + private static final String MI_ADAPT_TAG = "MiDeviceAdapt"; // 小米适配标识 + private static final int VIDEO_PROFILE_AUDIO_ONLY = VideoProfile.STATE_AUDIO_ONLY; + private static final int AUDIO_MODE_BACKUP = -1; // 音频模式备份默认值 + + // 成员属性区(按依赖优先级排序,移除静态call避免跨组件冲突) + private Context mContext; + private AudioManager mAudioManager; + private int mAudioModeBackup; // 备份原始音频模式,避免影响其他应用 + private boolean mIsSpeakerOpened; // 免提状态标记,防止重复切换 + + // 构造方法(单例化改造,避免多实例冲突) + private static volatile PhoneCallManager sInstance; + public static PhoneCallManager getInstance(Context context) { + if (context == null) { + LogUtils.e(TAG, "getInstance: 上下文为空,初始化失败"); + return null; + } + if (sInstance == null) { + synchronized (PhoneCallManager.class) { + if (sInstance == null) { + sInstance = new PhoneCallManager(context.getApplicationContext()); // 用应用上下文,避免内存泄漏 + } + } + } + return sInstance; + } + + // 私有构造,禁止外部实例化 + private PhoneCallManager(Context context) { + LogUtils.d(TAG, MI_ADAPT_TAG + " 初始化通话管理类"); + this.mContext = context; + this.mAudioModeBackup = AUDIO_MODE_BACKUP; + this.mIsSpeakerOpened = false; + initAudioManager(); + LogUtils.d(TAG, MI_ADAPT_TAG + " 通话管理类初始化完成"); + } + + // 初始化辅助方法 + private void initAudioManager() { + mAudioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE); + if (mAudioManager != null) { + // 备份原始音频模式(小米机型切换后需恢复,避免外放异常) + mAudioModeBackup = mAudioManager.getMode(); + LogUtils.d(TAG, "音频管理器初始化成功,原始模式备份:" + mAudioModeBackup); + } else { + LogUtils.e(TAG, "音频管理器初始化失败,将影响通话音频控制"); + } + } + + // 核心业务方法(按使用场景排序,强化小米适配+容错) + /** + * 接听电话,默认音频通话模式 + */ + public void answer() { + LogUtils.d(TAG, "执行接听通话操作"); + // 从PhoneCallService的静态管理器获取通话对象,统一数据源 + Call currentCall = PhoneCallService.PhoneCallManager.call; + if (currentCall == null) { + LogUtils.e(TAG, "接听失败:通话对象为空"); + return; + } + + // 校验通话状态,避免重复接听(小米机型状态变更延迟) + if (currentCall.getState() != Call.STATE_RINGING) { + LogUtils.w(TAG, MI_ADAPT_TAG + " 非响铃状态,无需接听,当前状态:" + currentCall.getState()); + return; + } + + try { + currentCall.answer(VIDEO_PROFILE_AUDIO_ONLY); + openSpeaker(); // 接听后自动开免提 + LogUtils.d(TAG, "通话接听成功,自动开启免提"); + } catch (SecurityException e) { + LogUtils.e(TAG, MI_ADAPT_TAG + " 接听权限不足(需android.permission.ANSWER_PHONE_CALLS)", e); + } catch (IllegalStateException e) { + LogUtils.e(TAG, MI_ADAPT_TAG + " 通话状态异常,无法接听", e); + } catch (Exception e) { + LogUtils.e(TAG, "接听通话异常", e); + } + } + + /** + * 断开通话(支持来电拒接、通话中挂断) + */ + public void disconnect() { + LogUtils.d(TAG, "执行断开通话操作"); + Call currentCall = PhoneCallService.PhoneCallManager.call; + if (currentCall == null) { + LogUtils.e(TAG, "挂断失败:通话对象为空"); + return; + } + + // 校验通话状态,避免重复挂断 + if (currentCall.getState() == Call.STATE_DISCONNECTED) { + LogUtils.w(TAG, MI_ADAPT_TAG + " 通话已断开,无需重复操作"); + return; + } + + try { + currentCall.disconnect(); + closeSpeaker(); // 挂断后关闭免提+恢复音频模式 + LogUtils.d(TAG, "通话断开成功"); + } catch (SecurityException e) { + LogUtils.e(TAG, MI_ADAPT_TAG + " 挂断权限不足(需android.permission.CALL_PHONE)", e); + } catch (IllegalStateException e) { + LogUtils.e(TAG, MI_ADAPT_TAG + " 通话状态异常,无法挂断", e); + } catch (Exception e) { + LogUtils.e(TAG, "断开通话异常", e); + } + } + + /** + * 打开免提,适配小米机型音频通道切换(解决MIUI音频混乱) + */ + public void openSpeaker() { + LogUtils.d(TAG, "执行打开免提操作"); + if (mAudioManager == null) { + LogUtils.e(TAG, "打开免提失败:音频管理器未初始化"); + return; + } + if (mIsSpeakerOpened) { + LogUtils.w(TAG, "免提已开启,无需重复操作"); + return; + } + + try { + // 小米机型适配步骤:1. 设置通话模式 2. 关闭静音 3. 开启免提(固定顺序) + mAudioManager.setMode(AudioManager.MODE_IN_CALL); + mAudioManager.setStreamMute(AudioManager.STREAM_VOICE_CALL, false); // 确保通话音频不静音 + mAudioManager.setSpeakerphoneOn(true); + + mIsSpeakerOpened = true; + LogUtils.d(TAG, MI_ADAPT_TAG + " 免提开启成功,当前模式:" + mAudioManager.getMode()); + } catch (SecurityException e) { + LogUtils.e(TAG, MI_ADAPT_TAG + " 音频控制权限不足", e); + } catch (Exception e) { + LogUtils.e(TAG, "打开免提异常", e); + } + } + + /** + * 新增:关闭免提(挂断/切换场景调用,修复小米音频残留) + */ + public void closeSpeaker() { + LogUtils.d(TAG, "执行关闭免提操作"); + if (mAudioManager == null || !mIsSpeakerOpened) { + LogUtils.w(TAG, "免提未开启或音频管理器为空,无需操作"); + return; + } + + try { + mAudioManager.setSpeakerphoneOn(false); + // 恢复原始音频模式(关键:小米机型不恢复会导致其他应用外放异常) + if (mAudioModeBackup != AUDIO_MODE_BACKUP) { + mAudioManager.setMode(mAudioModeBackup); + LogUtils.d(TAG, MI_ADAPT_TAG + " 恢复原始音频模式:" + mAudioModeBackup); + } + mIsSpeakerOpened = false; + LogUtils.d(TAG, "免提关闭成功"); + } catch (Exception e) { + LogUtils.e(TAG, MI_ADAPT_TAG + " 关闭免提异常", e); + } + } + + /** + * 销毁资源,避免内存泄漏+音频残留(适配小米内存管理) + */ + public void destroy() { + LogUtils.d(TAG, "开始销毁通话管理资源"); + closeSpeaker(); // 销毁前强制关闭免提+恢复音频模式 + // 释放资源(应用上下文无需主动置空,避免空指针) + mAudioManager = null; + sInstance = null; // 单例置空,下次重新初始化 + LogUtils.d(TAG, MI_ADAPT_TAG + " 通话管理资源销毁完成"); + } + + /** + * 新增:获取当前免提状态(供UI层同步显示) + */ + public boolean isSpeakerOpened() { + return mIsSpeakerOpened; + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/phonecallui/PhoneCallService.java b/contacts/src/main/java/cc/winboll/studio/contacts/phonecallui/PhoneCallService.java new file mode 100644 index 0000000..e5beede --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/phonecallui/PhoneCallService.java @@ -0,0 +1,284 @@ +package cc.winboll.studio.contacts.phonecallui; + +import android.media.AudioManager; +import android.telecom.Call; +import android.telecom.InCallService; +import android.telephony.TelephonyManager; +import androidx.annotation.RequiresApi; +import cc.winboll.studio.contacts.ActivityStack; +import cc.winboll.studio.contacts.dun.Rules; +import cc.winboll.studio.contacts.fragments.CallLogFragment; +import cc.winboll.studio.contacts.model.RingTongBean; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * 监听电话通信状态的服务,实现该类的同时必须提供电话管理的 UI + * @author aJIEw, ZhanGSKen&豆包大模型 + * @see PhoneCallActivity + * @see android.telecom.InCallService + * 适配:Java7 语法 + Android API29 - 30 | 移除录音功能 | 强化小米设备稳定性与容错性 + */ +@RequiresApi(api = 29) +public class PhoneCallService extends InCallService { + // 常量定义区 + public static final String TAG = "PhoneCallService"; + // 小米设备适配标识,便于日志区分 + private static final String MI_DEVICE_TAG = "MiDeviceAdapt"; + + // 成员属性区(按依赖顺序排列) + private Call.Callback mCallCallback; + private AudioManager mAudioManager; + + // 内部枚举类(通话类型定义) + public enum CallType { + CALL_IN, // 来电 + CALL_OUT // 去电 + } + + // Service生命周期方法区(按执行流程排序) + @Override + public void onCreate() { + super.onCreate(); + LogUtils.d(TAG, MI_DEVICE_TAG + " 通话监听服务启动"); + initAudioManager(); + initCallCallback(); + LogUtils.d(TAG, MI_DEVICE_TAG + " 服务初始化完成"); + } + + @Override + public void onCallAdded(Call call) { + super.onCallAdded(call); + LogUtils.d(TAG, "检测到新通话"); + if (call == null) { + LogUtils.e(TAG, "通话对象为空,跳过处理"); + return; + } + + // 双重校验回调,避免重复注册 + if (mCallCallback != null) { + call.registerCallback(mCallCallback); + } + // 绑定通话对象到管理器,供UI层调用 + PhoneCallManager.call = call; + LogUtils.d(TAG, MI_DEVICE_TAG + " 通话回调注册成功,对象绑定完成"); + + CallType callType = judgeCallType(call); + if (callType != null) { + handleValidCall(call, callType); + } else { + LogUtils.w(TAG, "无法识别通话类型,状态码:" + call.getState()); + } + } + + @Override + public void onCallRemoved(Call call) { + super.onCallRemoved(call); + LogUtils.d(TAG, "通话结束,开始清理资源"); + if (call != null && mCallCallback != null) { + call.unregisterCallback(mCallCallback); + LogUtils.d(TAG, "通话回调已注销"); + } + + // 延迟置空通话对象,避免UI层挂断时对象已被释放(适配小米机型时序) + new Thread(new Runnable() { + @Override + public void run() { + try { + // 延迟200ms,确保PhoneCallActivity挂断逻辑执行完成 + Thread.sleep(200); + PhoneCallManager.call = null; + } catch (InterruptedException e) { + LogUtils.e(TAG, MI_DEVICE_TAG + " 延迟置空通话对象异常", e); + } + } + }).start(); + + PhoneCallActivity.closePhoneCallActivity(); + LogUtils.d(TAG, MI_DEVICE_TAG + " 通话资源清理完成"); + } + + @Override + public void onDestroy() { + super.onDestroy(); + LogUtils.d(TAG, "服务开始销毁"); + CallLogFragment.updateCallLogFragment(); + // 释放资源,适配小米设备内存管理,避免内存泄漏 + mCallCallback = null; + mAudioManager = null; + LogUtils.d(TAG, MI_DEVICE_TAG + " 服务销毁完成"); + } + + // 初始化方法区 + private void initAudioManager() { + mAudioManager = (AudioManager) getSystemService(AUDIO_SERVICE); + if (mAudioManager == null) { + LogUtils.e(TAG, MI_DEVICE_TAG + " 获取音频管理器失败"); + } else { + LogUtils.d(TAG, MI_DEVICE_TAG + " 音频管理器初始化成功"); + } + } + + private void initCallCallback() { + mCallCallback = new Call.Callback() { + @Override + public void onStateChanged(Call call, int state) { + super.onStateChanged(call, state); + if (call == null) { + LogUtils.e(TAG, "onStateChanged: 通话对象为空"); + return; + } + String stateDesc = getCallStateDesc(state); + LogUtils.d(TAG, "通话状态变更:" + stateDesc + "(状态码:" + state + ")"); + + switch (state) { + case Call.STATE_DISCONNECTED: + // 双重校验,避免重复关闭页面 + if (ActivityStack.getInstance().getActivity(PhoneCallActivity.class) != null) { + ActivityStack.getInstance().finishActivity(PhoneCallActivity.class); + LogUtils.d(TAG, "通话界面已关闭"); + } + break; + case Call.STATE_ACTIVE: + LogUtils.d(TAG, MI_DEVICE_TAG + " 通话进入活跃状态,适配音频通道"); + break; + default: + break; + } + } + }; + LogUtils.d(TAG, "通话状态回调初始化完成"); + } + + // 核心业务处理方法区 + private CallType judgeCallType(Call call) { + if (call == null) { + LogUtils.e(TAG, "judgeCallType: 通话对象为空"); + return null; + } + int callState = call.getState(); + if (callState == Call.STATE_RINGING) { + LogUtils.d(TAG, "识别为来电"); + return CallType.CALL_IN; + } else if (callState == Call.STATE_CONNECTING) { + LogUtils.d(TAG, "识别为去电"); + return CallType.CALL_OUT; + } + return null; + } + + private boolean handleValidCall(Call call, CallType callType) { + if (call == null || callType == null) { + LogUtils.e(TAG, "handleValidCall: 通话对象或类型为空"); + return false; + } + + Call.Details callDetails = call.getDetails(); + if (callDetails == null || callDetails.getHandle() == null) { + LogUtils.e(TAG, "通话详情缺失,处理终止"); + return false; + } + + String phoneNumber = callDetails.getHandle().getSchemeSpecificPart(); + LogUtils.d(TAG, "处理通话:号码=" + phoneNumber + ",类型=" + callType.name()); + + if (mAudioManager == null) { + LogUtils.e(TAG, "音频管理器未初始化"); + PhoneCallActivity.actionStart(this, phoneNumber, callType); + return true; + } + + if (checkRulesAndHandleRingerVolumeControl(phoneNumber, call)) { + PhoneCallActivity.actionStart(this, phoneNumber, callType); + LogUtils.d(TAG, MI_DEVICE_TAG + " 通话界面启动成功"); + return true; + } + return false; + } + + private boolean checkRulesAndHandleRingerVolumeControl(String phoneNumber, Call call) { + if (mAudioManager == null || phoneNumber == null || call == null) { + LogUtils.e(TAG, "checkRulesAndHandleRingerVolumeControl: 入参为空"); + return false; + } + + int currentVolume = mAudioManager.getStreamVolume(AudioManager.STREAM_RING); + LogUtils.d(TAG, "当前铃声音量:" + currentVolume); + + RingTongBean ringTongBean = RingTongBean.loadBean(this, RingTongBean.class); + if (ringTongBean == null) { + ringTongBean = new RingTongBean(); + RingTongBean.saveBean(this, ringTongBean); + LogUtils.d(TAG, "初始化默认铃音配置"); + } + final int configVolume = ringTongBean.getStreamVolume(); + + try { + // 小米机型适配:调整音量时添加权限校验 + if (currentVolume != configVolume) { + mAudioManager.setStreamVolume(AudioManager.STREAM_RING, configVolume, 0); + LogUtils.d(TAG, MI_DEVICE_TAG + " 铃声音量调整为配置值:" + configVolume); + } + } catch (SecurityException e) { + LogUtils.e(TAG, "音量调整失败,权限不足", e); + return false; + } + + // 校验拦截规则 + if (!Rules.getInstance(this).isAllowed(phoneNumber)) { + LogUtils.d(TAG, "号码" + phoneNumber + "命中拦截规则"); + try { + // 拦截时静音并挂断 + mAudioManager.setStreamVolume(AudioManager.STREAM_RING, 0, 0); + call.disconnect(); + LogUtils.d(TAG, MI_DEVICE_TAG + " 拦截通话已挂断并静音"); + + // 延迟恢复音量,适配小米机型音频通道延迟 + new Thread(new Runnable() { + @Override + public void run() { + try { + Thread.sleep(500); + if (mAudioManager != null) { + mAudioManager.setStreamVolume(AudioManager.STREAM_RING, configVolume, 0); + LogUtils.d(TAG, MI_DEVICE_TAG + " 延迟恢复铃音配置"); + } + } catch (InterruptedException e) { + LogUtils.e(TAG, "恢复音量线程中断", e); + } + } + }).start(); + } catch (SecurityException e) { + LogUtils.e(TAG, "拦截静音失败", e); + return false; + } + return false; + } + return true; + } + + // 辅助工具方法区:解析通话状态描述 + private String getCallStateDesc(int state) { + switch (state) { + case TelephonyManager.CALL_STATE_RINGING: + return "响铃中"; + case TelephonyManager.CALL_STATE_OFFHOOK: + return "通话中"; + case TelephonyManager.CALL_STATE_IDLE: + return "空闲"; + case Call.STATE_ACTIVE: + return "通话活跃"; + case Call.STATE_CONNECTING: + return "连接中"; + case Call.STATE_DISCONNECTED: + return "已断开"; + default: + return "未知状态"; + } + } + + // 静态内部类:统一管理通话对象,避免跨组件对象混乱 + public static class PhoneCallManager { + public static Call call; + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/receivers/MainReceiver.java b/contacts/src/main/java/cc/winboll/studio/contacts/receivers/MainReceiver.java new file mode 100644 index 0000000..e135fa5 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/receivers/MainReceiver.java @@ -0,0 +1,98 @@ +package cc.winboll.studio.contacts.receivers; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import cc.winboll.studio.contacts.services.MainService; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; +import java.lang.ref.WeakReference; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/02/13 06:58:04 + * @Describe 主要广播接收器,监听系统开机广播并自动启动主服务 + */ +public class MainReceiver extends BroadcastReceiver { + // ====================== 常量定义区 ====================== + public static final String TAG = "MainReceiver"; + // 监听的系统广播 Action + private static final String ACTION_BOOT_COMPLETED = "android.intent.action.BOOT_COMPLETED"; + + // ====================== 成员变量区 ====================== + // 使用弱引用关联 MainService,避免内存泄漏 + private WeakReference mMainServiceWeakRef; + + // ====================== 构造函数区 ====================== + public MainReceiver(MainService service) { + this.mMainServiceWeakRef = new WeakReference<>(service); + LogUtils.d(TAG, "MainReceiver: 初始化完成,已关联 MainService 实例"); + } + + // ====================== 重写 BroadcastReceiver 核心方法 ====================== + @Override + public void onReceive(Context context, Intent intent) { + // 空值校验,避免空指针异常 + if (context == null) { + LogUtils.e(TAG, "onReceive: Context 为 null,无法处理广播"); + return; + } + if (intent == null || intent.getAction() == null) { + LogUtils.w(TAG, "onReceive: 接收到空 Intent 或空 Action"); + return; + } + + String action = intent.getAction(); + LogUtils.d(TAG, "onReceive: 接收到广播 | Action=" + action); + + // 处理开机完成广播 + if (ACTION_BOOT_COMPLETED.equals(action)) { + LogUtils.i(TAG, "onReceive: 监听到开机完成广播,自动启动 MainService"); + ToastUtils.show("设备开机,启动拨号主服务"); + MainService.startMainService(context); + } else { + LogUtils.i(TAG, "onReceive: 接收到未处理的广播 | Action=" + action); + ToastUtils.show("收到广播:" + action); + } + } + + // ====================== 广播注册/注销方法区 ====================== + /** + * 注册广播接收器,监听指定系统广播 + * @param context 上下文对象 + */ + public void registerAction(Context context) { + if (context == null) { + LogUtils.e(TAG, "registerAction: Context 为 null,注册失败"); + return; + } + + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(ACTION_BOOT_COMPLETED); + // 可按需添加其他监听的 Action + // intentFilter.addAction(AudioManager.RINGER_MODE_CHANGED_ACTION); + + context.registerReceiver(this, intentFilter); + LogUtils.d(TAG, "registerAction: 广播接收器注册成功 | 监听 Action=" + ACTION_BOOT_COMPLETED); + } + + /** + * 注销广播接收器,释放资源(解决 mMainReceiver.unregisterAction(this) 调用缺失问题) + * @param context 上下文对象 + */ + public void unregisterAction(Context context) { + if (context == null) { + LogUtils.e(TAG, "unregisterAction: Context 为 null,注销失败"); + return; + } + + try { + context.unregisterReceiver(this); + LogUtils.d(TAG, "unregisterAction: 广播接收器注销成功"); + } catch (IllegalArgumentException e) { + LogUtils.w(TAG, "unregisterAction: 广播接收器未注册,无需注销", e); + } + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/services/AssistantService.java b/contacts/src/main/java/cc/winboll/studio/contacts/services/AssistantService.java new file mode 100644 index 0000000..efb2ced --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/services/AssistantService.java @@ -0,0 +1,251 @@ +package cc.winboll.studio.contacts.services; + +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Binder; +import android.os.IBinder; +import cc.winboll.studio.contacts.model.MainServiceBean; +import cc.winboll.studio.contacts.utils.NotificationManagerUtils; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/02/14 03:38:31 + * @Describe 守护进程服务,用于监控并保活主服务 MainService + * 适配 Android 12+ 后台服务启动限制,支持前台服务运行 + * 兼容 Java 7 语法 & 低版本 SDK 编译 + * 移除无关的 microphone 类型配置,修复前台服务类型不匹配崩溃 + */ +public class AssistantService extends Service { + // ====================== 常量定义区 ====================== + public static final String TAG = "AssistantService"; + // 前台服务通知配置 + private static final String FOREGROUND_CHANNEL_ID = "assistant_service_foreground_channel"; + private static final int FOREGROUND_NOTIFICATION_ID = 1002; + // 修复:前台服务类型改为 dataSync(0x00000001),与 Manifest 保持一致,移除 microphone 类型 + private static final int FOREGROUND_SERVICE_TYPE_DATA_SYNC = 0x00000001; + // Android 版本常量硬编码(Java 7 兼容) + private static final int ANDROID_8_API = 26; // 通知渠道最低版本 + private static final int ANDROID_10_API = 29; // 前台服务类型最低支持版本 + private static final int ANDROID_12_API = 31; // 后台启动限制最低版本 + // 重试延迟时间(避免频繁触发后台启动限制) + private static final long RETRY_DELAY_MS = 3000L; + + // ====================== 成员变量区 ====================== + private MainServiceBean mMainServiceBean; + private MyServiceConnection mMyServiceConnection; + private MainService mMainService; + private boolean mIsBound = false; + private volatile boolean mIsThreadAlive = false; + + // ====================== Binder 内部类 ====================== + /** + * 对外暴露服务实例的 Binder + */ + public class MyBinder extends Binder { + public AssistantService getService() { + LogUtils.d(TAG, "MyBinder.getService: 获取 AssistantService 实例"); + return AssistantService.this; + } + } + + // ====================== ServiceConnection 内部类 ====================== + /** + * 主服务连接状态监听回调 + */ + private class MyServiceConnection implements ServiceConnection { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + if (service == null) { + LogUtils.w(TAG, "MyServiceConnection.onServiceConnected: 绑定的 IBinder 为 null"); + mIsBound = false; + return; + } + + try { + MainService.MyBinder binder = (MainService.MyBinder) service; + mMainService = binder.getService(); + mIsBound = true; + LogUtils.d(TAG, "MyServiceConnection.onServiceConnected: 主服务绑定成功 | MainService=" + mMainService); + } catch (ClassCastException e) { + LogUtils.e(TAG, "MyServiceConnection.onServiceConnected: IBinder 类型转换失败", e); + mIsBound = false; + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + LogUtils.w(TAG, "MyServiceConnection.onServiceDisconnected: 主服务连接断开"); + mMainService = null; + mIsBound = false; + + // 尝试重新绑定主服务(如果配置为启用) + reloadMainServiceConfig(); + if (mMainServiceBean != null && mMainServiceBean.isEnable()) { + LogUtils.d(TAG, "MyServiceConnection.onServiceDisconnected: 延迟重试绑定主服务"); + wakeupAndBindMain(); + } + } + } + + // ====================== 对外方法区 ====================== + /** + * 设置线程存活状态 + */ + public synchronized void setIsThreadAlive(boolean isThreadAlive) { + this.mIsThreadAlive = isThreadAlive; + LogUtils.d(TAG, "setIsThreadAlive: 线程存活状态变更 | " + isThreadAlive); + } + + /** + * 获取线程存活状态 + */ + public boolean isThreadAlive() { + return mIsThreadAlive; + } + + // ====================== 前台服务辅助方法 ====================== + /** + * 创建前台服务通知(Android 8.0+ 必须配置渠道) + */ +// private Notification createForegroundNotification() { +// // 1. 创建通知渠道(API 26+ 必需) +// if (Build.VERSION.SDK_INT >= ANDROID_8_API) { +// NotificationChannel channel = new NotificationChannel( +// FOREGROUND_CHANNEL_ID, +// "守护服务", +// NotificationManager.IMPORTANCE_LOW +// ); +// channel.setDescription("守护服务后台运行,保障主服务存活"); +// // 空指针防护 +// NotificationManager manager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); +// if (manager != null) { +// manager.createNotificationChannel(channel); +// LogUtils.d(TAG, "createForegroundNotification: 通知渠道创建成功"); +// } +// } +// +// // 2. 构建通知(Java 7 分步设置,取消链式调用简化) +// Notification.Builder builder; +// if (Build.VERSION.SDK_INT >= ANDROID_8_API) { +// builder = new Notification.Builder(this, FOREGROUND_CHANNEL_ID); +// } else { +// builder = new Notification.Builder(this); +// } +// builder.setSmallIcon(R.drawable.ic_launcher); +// builder.setContentTitle("守护服务运行中"); +// builder.setContentText("正在监控主服务状态"); +// builder.setPriority(Notification.PRIORITY_LOW); +// builder.setOngoing(true); // 不可手动取消 +// +// return builder.build(); +// } + + // ====================== Service 生命周期方法区 ====================== + @Override + public void onCreate() { + super.onCreate(); + LogUtils.d(TAG, "onCreate: 守护服务创建"); + + // 初始化主服务连接回调 + if (mMyServiceConnection == null) { + mMyServiceConnection = new MyServiceConnection(); + LogUtils.d(TAG, "onCreate: 初始化 MyServiceConnection 完成"); + } + + // 初始化运行状态 + setIsThreadAlive(false); + // 启动守护逻辑 + assistantService(); + } + + @Override + public IBinder onBind(Intent intent) { + LogUtils.d(TAG, "onBind: 服务被绑定 | Intent=" + intent); + return new MyBinder(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + LogUtils.d(TAG, "onStartCommand: 服务被启动 | startId=" + startId); + // 每次启动都执行守护逻辑,确保主服务存活 + assistantService(); + // START_STICKY:服务被杀死后系统尝试重启 + return START_STICKY; + } + + @Override + public void onDestroy() { + super.onDestroy(); + LogUtils.d(TAG, "onDestroy: 守护服务销毁"); + + // 停止线程并解除主服务绑定 + setIsThreadAlive(false); + if (mIsBound && mMyServiceConnection != null) { + try { + unbindService(mMyServiceConnection); + LogUtils.d(TAG, "onDestroy: 解除主服务绑定成功"); + } catch (IllegalArgumentException e) { + LogUtils.w(TAG, "onDestroy: 解除绑定失败,服务未绑定", e); + } + mIsBound = false; + } + mMainService = null; + } + + // ====================== 核心守护逻辑方法区 ====================== + /** + * 守护服务核心逻辑:检查配置并保活主服务 + */ + private void assistantService() { + LogUtils.d(TAG, "assistantService: 执行守护逻辑"); + + // 加载主服务配置 + reloadMainServiceConfig(); + if (mMainServiceBean == null) { + LogUtils.e(TAG, "assistantService: 主服务配置加载失败,终止守护逻辑"); + return; + } + + LogUtils.d(TAG, "assistantService: 主服务启用状态 | " + mMainServiceBean.isEnable()); + // 配置启用且线程未存活时,唤醒并绑定主服务 + if (mMainServiceBean.isEnable() && !isThreadAlive()) { + setIsThreadAlive(true); + wakeupAndBindMain(); + } else if (!mMainServiceBean.isEnable()) { + setIsThreadAlive(false); + LogUtils.d(TAG, "assistantService: 主服务已禁用,停止保活"); + } + } + + /** + * 唤醒并绑定主服务 MainService(适配后台启动限制) + */ + private void wakeupAndBindMain() { + if (mMyServiceConnection == null) { + LogUtils.e(TAG, "wakeupAndBindMain: MyServiceConnection 未初始化,绑定失败"); + return; + } + + Intent intent = new Intent(this, MainService.class); + // 根据应用前后台状态选择启动方式(Android 12+ 后台用 startForegroundService) + startForegroundService(intent); + + // BIND_IMPORTANT:提高绑定优先级,主服务被杀时会回调断开 + bindService(intent, mMyServiceConnection, Context.BIND_IMPORTANT); + LogUtils.d(TAG, "wakeupAndBindMain: 已启动并绑定主服务 MainService"); + } + + // ====================== 辅助方法区 ====================== + /** + * 重新加载主服务配置 + */ + private void reloadMainServiceConfig() { + mMainServiceBean = MainServiceBean.loadBean(this, MainServiceBean.class); + LogUtils.d(TAG, "reloadMainServiceConfig: 主服务配置重新加载完成 | " + mMainServiceBean); + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/services/MainService.java b/contacts/src/main/java/cc/winboll/studio/contacts/services/MainService.java new file mode 100644 index 0000000..1b7d469 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/services/MainService.java @@ -0,0 +1,593 @@ +package cc.winboll.studio.contacts.services; + +import android.app.ActivityManager; +import android.app.Notification; +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.media.AudioManager; +import android.os.Binder; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import cc.winboll.studio.contacts.bobulltoon.TomCat; +import cc.winboll.studio.contacts.dun.Rules; +import cc.winboll.studio.contacts.handlers.MainServiceHandler; +import cc.winboll.studio.contacts.listenphonecall.CallListenerService; +import cc.winboll.studio.contacts.model.MainServiceBean; +import cc.winboll.studio.contacts.model.RingTongBean; +import cc.winboll.studio.contacts.receivers.MainReceiver; +import cc.winboll.studio.contacts.utils.NotificationManagerUtils; +import cc.winboll.studio.libappbase.LogUtils; +import java.util.Timer; +import java.util.TimerTask; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/02/13 06:56:41 + * @Describe 拨号主服务,负责核心业务逻辑、守护进程绑定、铃声音量监控及通话监听启动 + * 严格适配 Android API 30 + Java 7 语法规范 | 解决前台服务启动超时崩溃 + * 核心优化:1. 移除延迟启动逻辑 2. 标准化日志管理 3. 强化资源清理 4. 结构分层重构 + */ +public class MainService extends Service { + + // ====================== 常量定义区(全硬编码,无高版本API依赖) ====================== + public static final String TAG = "MainService"; + public static final int MSG_UPDATE_STATUS = 0; + + // 铃声音量监控参数(定时检查+恢复) + private static final long VOLUME_CHECK_DELAY = 1000L; // 首次检查延迟1s + private static final long VOLUME_CHECK_PERIOD = 60000L; // 后续每60s检查一次 + + // 前台服务配置(固定ID+渠道,避免重复创建) + private static final String FOREGROUND_CHANNEL_ID = "main_service_foreground_channel"; + private static final int FOREGROUND_NOTIFICATION_ID = 1001; + private static final int FOREGROUND_SERVICE_TYPE_DATA_SYNC = 0x00000001; // dataSync类型硬编码 + + // Android版本常量(替代Build.VERSION_CODES,适配Java7) + private static final int ANDROID_8_API = 26; // Android 8.0 + private static final int ANDROID_10_API = 29; // Android 10 + private static final int ANDROID_12_API = 31; // Android 12 + + // 守护服务重绑定延迟(仅保留核心重试逻辑) + private static final long RETRY_DELAY_MS = 3000L; + + // ====================== 静态成员属性区(全局共享实例,统一前缀s) ====================== + private static MainService sMainServiceInstance; // 主服务全局实例 + private static volatile TomCat sTomCatInstance; // 号码识别核心实例(volatile保证可见性) + + // ====================== 成员属性区(业务+UI+资源,统一前缀m) ====================== + private volatile boolean mIsServiceRunning; // 服务运行状态标记(volatile防指令重排) + private MainServiceBean mMainServiceBean; // 服务配置实体(启用状态存储) + private MainServiceHandler mMainServiceHandler; // 服务消息处理器(主线程通信) + private MyServiceConnection mServiceConnection; // 守护服务连接实例 + private AssistantService mAssistantService; // 绑定的守护服务实例 + private boolean mIsAssistantBound; // 守护服务绑定状态标记 + private MainReceiver mMainReceiver; // 全局广播接收器(监听系统事件) + private Timer mVolumeCheckTimer; // 铃声音量检查定时器(定时恢复配置) + + // ====================== 内部类:Binder(服务绑定通信,优先定义) ====================== + public class MyBinder extends Binder { + /** + * 外部组件绑定服务时,获取主服务实例 + * @return MainService 主服务实例 + */ + public MainService getService() { + LogUtils.d(TAG, "MyBinder.getService: 外部获取主服务实例"); + return MainService.this; + } + } + + // ====================== 内部类:ServiceConnection(守护服务绑定回调) ====================== + private class MyServiceConnection implements ServiceConnection { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + if (service == null) { + LogUtils.w(TAG, "MyServiceConnection.onServiceConnected: 绑定的IBinder为空,绑定失败"); + mIsAssistantBound = false; + return; + } + + try { + // 类型转换获取守护服务实例 + AssistantService.MyBinder binder = (AssistantService.MyBinder) service; + mAssistantService = binder.getService(); + mIsAssistantBound = true; + LogUtils.d(TAG, "MyServiceConnection.onServiceConnected: 守护服务绑定成功"); + } catch (ClassCastException e) { + LogUtils.e(TAG, "MyServiceConnection.onServiceConnected: IBinder类型转换失败", e); + mIsAssistantBound = false; + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + LogUtils.w(TAG, "MyServiceConnection.onServiceDisconnected: 守护服务连接断开"); + mAssistantService = null; + mIsAssistantBound = false; + + // 服务启用状态下,重试绑定守护服务(主服务存活核心保障) + if (mMainServiceBean != null && mMainServiceBean.isEnable()) { + LogUtils.d(TAG, "MyServiceConnection.onServiceDisconnected: " + RETRY_DELAY_MS + "ms后重试绑定守护服务"); + new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { + @Override + public void run() { + wakeupAndBindAssistantService(); + } + }, RETRY_DELAY_MS); + } else { + LogUtils.w(TAG, "MyServiceConnection.onServiceDisconnected: 主服务已禁用,跳过重试绑定"); + } + } + } + + // ====================== 对外静态方法区(服务启停/重启/状态查询,全局调用) ====================== + /** + * 检查号码是否在BoBullToon库中(外部组件调用,静态入口) + * @param phone 待查询号码 + * @return true=是BoBullToon号码,false=否/初始化失败 + */ + public static boolean isPhoneInBoBullToon(String phone) { + if (sTomCatInstance != null && phone != null && !phone.isEmpty()) { + boolean result = sTomCatInstance.isPhoneBoBullToon(phone); + LogUtils.d(TAG, "isPhoneInBoBullToon: 号码" + phone + "查询结果:" + (result ? "是" : "否")); + return result; + } + LogUtils.w(TAG, "isPhoneInBoBullToon: TomCat未初始化或号码为空,查询失败"); + return false; + } + + /** + * 停止主服务(仅停止,不修改配置) + * @param context 上下文(非空校验) + */ + public static void stopMainService(Context context) { + if (context == null) { + LogUtils.e(TAG, "stopMainService: 上下文为空,无法停止服务"); + return; + } + LogUtils.d(TAG, "stopMainService: 执行停止主服务操作"); + context.stopService(new Intent(context, MainService.class)); + } + + /** + * 启动主服务(仅启动,不修改配置) + * @param context 上下文(非空校验) + */ + public static void startMainService(Context context) { + if (context == null) { + LogUtils.e(TAG, "startMainService: 上下文为空,无法启动服务"); + return; + } + LogUtils.d(TAG, "startMainService: 执行启动主服务操作(前台服务模式)"); + Intent intent = new Intent(context, MainService.class); + context.startForegroundService(intent); + } + + /** + * 重启主服务(先停后启,需服务已启用) + * @param context 上下文(非空校验) + */ + public static void restartMainService(Context context) { + if (context == null) { + LogUtils.e(TAG, "restartMainService: 上下文为空,无法重启服务"); + return; + } + LogUtils.d(TAG, "restartMainService: 执行主服务重启流程"); + + MainServiceBean config = MainServiceBean.loadBean(context, MainServiceBean.class); + if (config != null && config.isEnable()) { + stopMainService(context); + startMainService(context); + LogUtils.i(TAG, "restartMainService: 主服务重启完成"); + } else { + LogUtils.w(TAG, "restartMainService: 服务未启用或配置为空,跳过重启"); + } + } + + /** + * 停止服务并保存禁用状态(更新配置+停止服务) + * @param context 上下文(非空校验) + */ + public static void stopMainServiceAndSaveStatus(Context context) { + if (context == null) { + LogUtils.e(TAG, "stopMainServiceAndSaveStatus: 上下文为空,操作失败"); + return; + } + LogUtils.d(TAG, "stopMainServiceAndSaveStatus: 保存禁用状态并停止服务"); + MainServiceBean config = new MainServiceBean(); + config.setIsEnable(false); + MainServiceBean.saveBean(context, config); + stopMainService(context); + } + + /** + * 启动服务并保存启用状态(更新配置+启动服务,先停后启避免重复) + * @param context 上下文(非空校验) + */ + public static void startMainServiceAndSaveStatus(Context context) { + if (context == null) { + LogUtils.e(TAG, "startMainServiceAndSaveStatus: 上下文为空,操作失败"); + return; + } + LogUtils.d(TAG, "startMainServiceAndSaveStatus: 保存启用状态并启动服务"); + MainServiceBean config = new MainServiceBean(); + config.setIsEnable(true); + MainServiceBean.saveBean(context, config); + stopMainService(context); // 先停止旧服务,避免冲突 + startMainService(context); + } + + // ====================== 核心工具方法区(服务状态检查+前台通知创建,通用功能) ====================== + /** + * 补充消息追加方法(外部组件向服务发送消息) + * @param message 待追加消息(空值防护) + */ + public void appenMessage(String message) { + String msg = message == null ? "null" : message; + LogUtils.d(TAG, "appenMessage: 接收外部消息:" + msg); + + if (mMainServiceHandler != null) { + android.os.Message handlerMsg = android.os.Message.obtain(); + handlerMsg.what = MSG_UPDATE_STATUS; + handlerMsg.obj = msg; + mMainServiceHandler.sendMessage(handlerMsg); + LogUtils.d(TAG, "appenMessage: 消息已发送至Handler处理"); + } else { + LogUtils.w(TAG, "appenMessage: MainServiceHandler未初始化,消息发送失败"); + } + } + + /** + * 创建前台服务通知(Android8.0+需渠道,低版本兼容) + * @return Notification 前台服务通知实例 + */ +// private Notification createForegroundNotification() { +// // 1. Android8.0+创建通知渠道(必需,否则通知不显示) +// if (Build.VERSION.SDK_INT >= ANDROID_8_API) { +// NotificationChannel channel = new NotificationChannel( +// FOREGROUND_CHANNEL_ID, +// "拨号主服务", +// NotificationManager.IMPORTANCE_LOW +// ); +// channel.setDescription("主服务后台运行,保障通话监听与号码识别功能正常"); +// channel.setSound(null, null); // 关闭通知声音 +// channel.enableVibration(false); // 关闭振动 +// +// NotificationManager notificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); +// if (notificationManager != null) { +// notificationManager.createNotificationChannel(channel); +// LogUtils.d(TAG, "createForegroundNotification: Android8.0+通知渠道创建成功"); +// } else { +// LogUtils.e(TAG, "createForegroundNotification: NotificationManager获取失败,渠道创建失败"); +// } +// } +// +// // 2. 构建通知实例(分版本兼容Builder) +// Notification.Builder builder; +// if (Build.VERSION.SDK_INT >= ANDROID_8_API) { +// builder = new Notification.Builder(this, FOREGROUND_CHANNEL_ID); +// } else { +// builder = new Notification.Builder(this); +// } +// builder.setSmallIcon(R.drawable.ic_launcher); +// builder.setContentTitle("拨号服务运行中"); +// builder.setContentText("后台保障通话监听与号码识别,请勿手动关闭"); +// builder.setPriority(Notification.PRIORITY_LOW); // 低优先级,不打扰用户 +// builder.setOngoing(true); // 不可手动清除,保障服务存活 +// +// LogUtils.d(TAG, "createForegroundNotification: 前台服务通知构建完成"); +// return builder.build(); +// } + + /** + * 检查指定服务是否正在运行(通过ActivityManager查询) + * @param serviceClass 待检查服务类 + * @return true=运行中,false=未运行/查询失败 + */ + private boolean isServiceRunning(Class serviceClass) { + if (serviceClass == null) { + LogUtils.e(TAG, "isServiceRunning: 服务类为空,检查失败"); + return false; + } + + ActivityManager activityManager = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE); + if (activityManager == null) { + LogUtils.w(TAG, "isServiceRunning: ActivityManager获取失败,检查失败"); + return false; + } + + // 遍历运行中服务,匹配类名 + for (ActivityManager.RunningServiceInfo serviceInfo : activityManager.getRunningServices(Integer.MAX_VALUE)) { + if (serviceClass.getName().equals(serviceInfo.service.getClassName())) { + LogUtils.d(TAG, "isServiceRunning: 服务" + serviceClass.getSimpleName() + "正在运行"); + return true; + } + } + LogUtils.d(TAG, "isServiceRunning: 服务" + serviceClass.getSimpleName() + "未运行"); + return false; + } + + // ====================== Service生命周期方法区(按执行顺序:创建→绑定→启动→销毁) ====================== + @Override + public void onCreate() { + super.onCreate(); + LogUtils.d(TAG, "===== onCreate: 主服务开始创建 ====="); + sMainServiceInstance = this; + mIsServiceRunning = false; + + // 初始化核心组件(无延迟,直接初始化) + mMainServiceBean = MainServiceBean.loadBean(this, MainServiceBean.class); + mServiceConnection = new MyServiceConnection(); + mMainServiceHandler = new MainServiceHandler(this); + + // 初始化音量监控定时器(服务启动即开启,保障音量配置) + initVolumeCheckTimer(); + + // 执行核心业务启动逻辑(无延迟,优先启动) + startCoreBusiness(); + + LogUtils.d(TAG, "===== onCreate: 主服务创建完成 ====="); + } + + @Override + public IBinder onBind(Intent intent) { + LogUtils.d(TAG, "onBind: 主服务被外部组件绑定,Intent=" + (intent != null ? intent.getAction() : "null")); + return new MyBinder(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + LogUtils.d(TAG, "onStartCommand: 主服务被启动,startId=" + startId); + // 重复启动时再次执行核心业务(避免服务被杀死后重启失败) + startCoreBusiness(); + + // 服务启用则返回START_STICKY(系统杀死后自动重启),否则默认行为 + int result = (mMainServiceBean != null && mMainServiceBean.isEnable()) ? START_STICKY : super.onStartCommand(intent, flags, startId); + LogUtils.d(TAG, "onStartCommand: 服务启动模式:" + (result == START_STICKY ? "START_STICKY(自动重启)" : "默认模式")); + return result; + } + + @Override + public void onDestroy() { + super.onDestroy(); + LogUtils.d(TAG, "===== onDestroy: 主服务开始销毁,全量清理资源 ====="); + + // 1. 停止音量监控定时器(释放线程资源) + cancelVolumeCheckTimer(); + + // 2. 解除守护服务绑定(避免内存泄漏) + if (mIsAssistantBound && mServiceConnection != null) { + try { + unbindService(mServiceConnection); + LogUtils.d(TAG, "onDestroy: 守护服务绑定已解除"); + } catch (IllegalArgumentException e) { + LogUtils.w(TAG, "onDestroy: 解除守护服务绑定失败(服务未绑定)", e); + } + mIsAssistantBound = false; + mServiceConnection = null; + } + + // 3. 注销广播接收器(释放系统资源) + if (mMainReceiver != null) { + mMainReceiver.unregisterAction(this); + mMainReceiver = null; + LogUtils.d(TAG, "onDestroy: 全局广播接收器已注销"); + } + + // 4. 停止所有子服务(强制停止,避免子服务残留) + stopService(new Intent(this, AssistantService.class)); + stopService(new Intent(this, CallListenerService.class)); + stopService(new Intent(this, MyCallScreeningService.class)); + LogUtils.d(TAG, "onDestroy: 所有子服务已强制停止"); + + // 5. 清空所有引用(静态+成员,彻底避免内存泄漏,不修改配置Bean) + sMainServiceInstance = null; + sTomCatInstance = null; + mMainServiceHandler = null; + mAssistantService = null; + mMainServiceBean = null; + + // 标记服务为未运行 + mIsServiceRunning = false; + LogUtils.d(TAG, "===== onDestroy: 主服务销毁完成,资源全清理 ====="); + } + + // ====================== 核心业务逻辑区(服务启动核心流程,无延迟) ====================== + /** + * 启动核心业务(主服务核心入口,避免重复启动) + */ + private synchronized void startCoreBusiness() { + // 服务已运行则直接返回,避免重复执行启动流程 + if (mIsServiceRunning) { + LogUtils.d(TAG, "startCoreBusiness: 主服务已运行,跳过重复启动"); + return; + } + mIsServiceRunning = true; + + // 重新加载最新配置(避免配置修改后未生效) + mMainServiceBean = MainServiceBean.loadBean(this, MainServiceBean.class); + if (mMainServiceBean == null || !mMainServiceBean.isEnable()) { + LogUtils.w(TAG, "startCoreBusiness: 服务配置未启用或配置为空,启动流程终止"); + mIsServiceRunning = false; + stopSelf(); // 未启用则主动停止服务 + return; + } + + LogUtils.i(TAG, "startCoreBusiness: 服务配置已启用,启动核心流程"); + + // 1. 优先启动前台服务(避免前台服务启动超时崩溃,核心优先级) + try { + NotificationManagerUtils notificationManagerUtils = new NotificationManagerUtils(this); + notificationManagerUtils.startForegroundServiceNotify(this, "主要拨号服务已启动。"); + LogUtils.i(TAG, "startCoreBusiness: 前台服务启动成功,通知ID=" + FOREGROUND_NOTIFICATION_ID); + } catch (IllegalArgumentException e) { + LogUtils.e(TAG, "startCoreBusiness: 前台服务启动失败(服务类型不匹配)", e); + mIsServiceRunning = false; + stopSelf(); + return; + } catch (Exception e) { + LogUtils.e(TAG, "startCoreBusiness: 前台服务启动异常", e); + mIsServiceRunning = false; + stopSelf(); + return; + } + + // 2. 绑定守护服务(保障主服务存活,防系统杀死) + wakeupAndBindAssistantService(); + + // 3. 初始化核心业务组件(号码识别+黑白名单规则) + initTomCatComponent(); + initRulesConfig(); + + // 4. 启动通话监听相关服务(无延迟,直接启动,保障功能生效) + startCallRelatedServices(); + + LogUtils.i(TAG, "startCoreBusiness: 主服务核心流程启动完成,服务正常运行"); + } + + /** + * 唤醒并绑定守护服务(分版本适配启动方式,Android10+前台服务启动) + */ + private void wakeupAndBindAssistantService() { + if (mServiceConnection == null) { + LogUtils.e(TAG, "wakeupAndBindAssistantService: 服务连接实例未初始化,绑定失败"); + return; + } + + Intent assistantIntent = new Intent(this, AssistantService.class); + // Android10+应用后台启动服务需用前台服务模式 + LogUtils.d(TAG, "wakeupAndBindAssistantService: Android10+,前台服务模式启动守护服务"); + startService(assistantIntent); + + // 绑定守护服务(BIND_IMPORTANT:高优先级绑定,断开时回调) + bindService(assistantIntent, mServiceConnection, Context.BIND_IMPORTANT); + LogUtils.d(TAG, "wakeupAndBindAssistantService: 守护服务启动并发起绑定请求"); + } + + // ====================== 铃声音量监控方法区(定时检查+恢复配置) ====================== + /** + * 初始化音量检查定时器(先取消旧定时器,避免重复创建) + */ + private void initVolumeCheckTimer() { + cancelVolumeCheckTimer(); + mVolumeCheckTimer = new Timer(); + mVolumeCheckTimer.schedule(new TimerTask() { + @Override + public void run() { + checkAndRestoreRingerVolume(); + } + }, VOLUME_CHECK_DELAY, VOLUME_CHECK_PERIOD); + LogUtils.d(TAG, "initVolumeCheckTimer: 铃声音量监控定时器启动,周期" + VOLUME_CHECK_PERIOD + "ms"); + } + + /** + * 检查并恢复铃声音量(对比配置值,不一致则恢复,保障用户配置生效) + */ + private void checkAndRestoreRingerVolume() { + AudioManager audioManager = (AudioManager) getSystemService(AUDIO_SERVICE); + if (audioManager == null) { + LogUtils.e(TAG, "checkAndRestoreRingerVolume: AudioManager获取失败,音量检查终止"); + return; + } + + // 加载音量配置(无配置则初始化默认值) + RingTongBean ringConfig = RingTongBean.loadBean(this, RingTongBean.class); + if (ringConfig == null) { + ringConfig = new RingTongBean(); + RingTongBean.saveBean(this, ringConfig); + LogUtils.d(TAG, "checkAndRestoreRingerVolume: 音量配置未存在,初始化默认配置"); + return; + } + + try { + int currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_RING); + int configVolume = ringConfig.getStreamVolume(); + // 音量不一致则恢复配置值 + if (currentVolume != configVolume) { + audioManager.setStreamVolume(AudioManager.STREAM_RING, configVolume, 0); + LogUtils.d(TAG, "checkAndRestoreRingerVolume: 铃声音量已恢复,配置值=" + configVolume + ",原当前值=" + currentVolume); + } else { + LogUtils.v(TAG, "checkAndRestoreRingerVolume: 铃声音量与配置一致,无需调整"); + } + } catch (SecurityException e) { + LogUtils.e(TAG, "checkAndRestoreRingerVolume: 音量设置权限不足,恢复失败", e); + } + } + + /** + * 取消音量检查定时器(释放Timer资源,避免内存泄漏) + */ + private void cancelVolumeCheckTimer() { + if (mVolumeCheckTimer != null) { + mVolumeCheckTimer.cancel(); + mVolumeCheckTimer = null; + LogUtils.d(TAG, "cancelVolumeCheckTimer: 铃声音量监控定时器已取消"); + } + } + + // ====================== 辅助初始化方法区(业务组件初始化,统一归类) ====================== + /** + * 初始化TomCat组件(号码识别核心,加载本地数据) + */ + private void initTomCatComponent() { + sTomCatInstance = TomCat.getInstance(this); + if (sTomCatInstance.loadPhoneBoBullToon()) { + LogUtils.d(TAG, "initTomCatComponent: BoBullToon号码库加载成功"); + } else { + LogUtils.w(TAG, "initTomCatComponent: BoBullToon号码库未下载,加载失败(不影响服务运行)"); + } + } + + /** + * 初始化黑白名单规则配置(加载本地规则,保障通话筛选生效) + */ + private void initRulesConfig() { + Rules rules = Rules.getInstance(this); + if (rules != null) { + rules.loadRules(); + LogUtils.d(TAG, "initRulesConfig: 黑白名单通话规则加载完成"); + } else { + LogUtils.e(TAG, "initRulesConfig: Rules实例获取失败,通话规则加载失败"); + } + } + + /** + * 初始化广播接收器(监听系统事件,如开机启动、网络变化) + */ + private void initMainReceiver() { + if (mMainReceiver == null) { + mMainReceiver = new MainReceiver(this); + mMainReceiver.registerAction(this); + LogUtils.d(TAG, "initMainReceiver: 全局广播接收器注册完成"); + } else { + LogUtils.d(TAG, "initMainReceiver: 广播接收器已初始化,跳过重复注册"); + } + } + + /** + * 启动通话相关服务(通话监听+通话筛选,核心功能服务) + */ + private void startCallRelatedServices() { + // 启动通话监听服务 + try { + Intent callListenerIntent = new Intent(this, CallListenerService.class); + startService(callListenerIntent); + LogUtils.d(TAG, "startCallRelatedServices: CallListenerService启动成功"); + } catch (Exception e) { + LogUtils.e(TAG, "startCallRelatedServices: CallListenerService启动失败", e); + } + + // 启动通话筛选服务(API10+生效,低版本兼容) + try { + Intent screeningIntent = new Intent(this, MyCallScreeningService.class); + startService(screeningIntent); + LogUtils.d(TAG, "startCallRelatedServices: MyCallScreeningService启动成功"); + } catch (Exception e) { + LogUtils.e(TAG, "startCallRelatedServices: MyCallScreeningService启动失败", e); + } + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/services/MyCallScreeningService.java b/contacts/src/main/java/cc/winboll/studio/contacts/services/MyCallScreeningService.java new file mode 100644 index 0000000..95a0884 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/services/MyCallScreeningService.java @@ -0,0 +1,246 @@ +package cc.winboll.studio.contacts.services; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.telecom.CallScreeningService; +import android.telephony.TelephonyManager; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import cc.winboll.studio.contacts.model.MainServiceBean; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Describe 通话筛选服务(无前台服务),负责识别通话类型、拦截指定号码、处理正常通话逻辑 + * 严格适配 Java7 语法 + Android API29-30 | 轻量稳定 | 强化空指针防护 | 无冗余代码 + */ +@RequiresApi(api = 29) // 父类 CallScreeningService 最低要求 API29,明确标注 +public class MyCallScreeningService extends CallScreeningService { + + // ====================== 常量定义区(全硬编码,无高版本API依赖) ====================== + public static final String TAG = "MyCallScreeningService"; + + // 通话方向常量(硬编码替代 Call.Details 高版本字段,适配API29-30) + private static final int CALL_DIRECTION_INCOMING = 1; // 来电 + private static final int CALL_DIRECTION_OUTGOING = 2; // 外拨 + + // ====================== 成员属性区(精简必要属性,命名规范) ====================== + private Context mContext; // 上下文对象,避免重复调用 getApplicationContext() + + // ====================== Service生命周期方法区(按执行顺序排列) ====================== + @Override + public void onCreate() { + super.onCreate(); + mContext = this; + LogUtils.d(TAG, "===== onCreate: 通话筛选服务启动 ====="); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + LogUtils.d(TAG, "onStartCommand: 服务被启动,startId=" + startId); + + // 加载服务配置,决定重启策略(启用则自动重启,禁用则默认) + MainServiceBean serviceConfig = MainServiceBean.loadBean(this, MainServiceBean.class); + int startMode = (serviceConfig != null && serviceConfig.isEnable()) ? START_STICKY : super.onStartCommand(intent, flags, startId); + LogUtils.d(TAG, "onStartCommand: 服务启动模式:" + (startMode == START_STICKY ? "START_STICKY(自动重启)" : "默认模式")); + + return startMode; + } + + @Override + public void onDestroy() { + super.onDestroy(); + // 置空上下文,释放引用,避免内存泄漏 + mContext = null; + LogUtils.d(TAG, "===== onDestroy: 通话筛选服务销毁完成 ====="); + } + + // ====================== 核心通话筛选方法区(父类抽象方法实现) ====================== + /** + * 核心:通话筛选入口(API29-30标准方法,100%兼容) + * 功能:识别通话号码/类型、拦截指定号码、处理正常通话 + */ + @Override + @RequiresApi(api = 29) + public void onScreenCall(@NonNull android.telecom.Call.Details details) { + LogUtils.d(TAG, "===== onScreenCall: 开始筛选通话 ====="); + + // 1. 安全获取通话号码(多层空指针防护,Java7规范写法) + String phoneNumber = getSafePhoneNumber(details); + // 2. 识别通话方向(来电/外拨/未知) + int callDirection = details.getCallDirection(); + String callTypeDesc = getCallDirectionDesc(callDirection); + int callState = getCallStateByDirection(callDirection); + + LogUtils.d(TAG, "筛选结果:通话类型=" + callTypeDesc + ",号码=" + phoneNumber); + + // 3. 自定义拦截逻辑(示例:拦截指定号码10086,可按需扩展黑白名单) + boolean isNeedBlock = isTargetBlockNumber(phoneNumber); + + // 4. 构建筛选响应(Java7分步调用,不使用链式写法,避免兼容问题) + CallResponse callResponse = buildCallScreeningResponse(isNeedBlock); + + // 5. 提交筛选结果(必须调用父类方法,完成拦截/放行逻辑) + respondToCall(details, callResponse); + + // 6. 分场景处理后续逻辑(拦截日志/正常通话业务) + handleCallAfterScreening(phoneNumber, callTypeDesc, isNeedBlock); + + LogUtils.d(TAG, "===== onScreenCall: 通话筛选完成 ====="); + } + + // ====================== 业务逻辑方法区(按功能拆分,低耦合) ====================== + /** + * 安全获取通话号码(多层空指针+空字符串防护,避免崩溃) + */ + private String getSafePhoneNumber(android.telecom.Call.Details details) { + String phoneNumber = "未知号码"; + if (details == null) { + LogUtils.w(TAG, "getSafePhoneNumber: 通话详情为空,无法获取号码"); + return phoneNumber; + } + + Uri handle = details.getHandle(); + if (handle != null) { + String schemePart = handle.getSchemeSpecificPart(); + if (schemePart != null && !schemePart.trim().isEmpty()) { + phoneNumber = schemePart.trim(); + LogUtils.d(TAG, "getSafePhoneNumber: 成功获取号码,原始值=" + schemePart + ",处理后=" + phoneNumber); + } else { + LogUtils.w(TAG, "getSafePhoneNumber: 号码格式异常,schemePart=" + schemePart); + } + } else { + LogUtils.w(TAG, "getSafePhoneNumber: 通话 handle 为空,无法获取号码"); + } + return phoneNumber; + } + + /** + * 判断是否为目标拦截号码(可扩展黑白名单逻辑,当前示例拦截10086) + */ + private boolean isTargetBlockNumber(String phoneNumber) { + if (phoneNumber == null || phoneNumber.trim().isEmpty()) { + LogUtils.w(TAG, "isTargetBlockNumber: 号码为空,不拦截"); + return false; + } + + // 示例拦截逻辑:拦截 10086(实际可替换为黑白名单查询) + boolean isBlock = "10086".equals(phoneNumber.trim()); + if (isBlock) { + LogUtils.d(TAG, "isTargetBlockNumber: 命中拦截规则,号码=" + phoneNumber); + } else { + LogUtils.d(TAG, "isTargetBlockNumber: 未命中拦截规则,号码=" + phoneNumber); + } + return isBlock; + } + + /** + * 构建通话筛选响应(按需配置拦截/放行参数,适配API29-30) + */ + private CallResponse buildCallScreeningResponse(boolean isNeedBlock) { + CallResponse.Builder responseBuilder = new CallResponse.Builder(); + // 拦截配置:是否禁止通话+是否拒绝通话(两者配合实现拦截) + responseBuilder.setDisallowCall(isNeedBlock); + responseBuilder.setRejectCall(isNeedBlock); + // 日志/通知配置:拦截的通话跳过日志和通知,正常通话保留 + responseBuilder.setSkipCallLog(isNeedBlock); + responseBuilder.setSkipNotification(isNeedBlock); + + CallResponse response = responseBuilder.build(); + LogUtils.d(TAG, "buildCallScreeningResponse: 响应构建完成,拦截状态=" + isNeedBlock); + return response; + } + + /** + * 筛选后分场景处理(拦截日志/正常通话业务扩展) + */ + private void handleCallAfterScreening(String phoneNumber, String callTypeDesc, boolean isNeedBlock) { + if (isNeedBlock) { + // 拦截场景:仅打日志(可扩展:添加拦截记录、本地存储等) + LogUtils.d(TAG, "handleCallAfterScreening: 已拦截通话,类型=" + callTypeDesc + ",号码=" + phoneNumber); + } else { + // 正常通话场景:处理业务逻辑(可扩展:通话记录、广播通知、号码识别等) + int callState = getCallStateByDirection(getCallDirectionFromDesc(callTypeDesc)); + handleNormalCallBusiness(phoneNumber, callState); + } + } + + /** + * 正常通话业务处理(核心业务扩展入口,强化空指针防护) + */ + private void handleNormalCallBusiness(String phoneNumber, int callState) { + if (phoneNumber == null || phoneNumber.trim().isEmpty()) { + LogUtils.w(TAG, "handleNormalCallBusiness: 号码为空,跳过业务处理"); + return; + } + + String callStateDesc = getCallStateDesc(callState); + LogUtils.d(TAG, "handleNormalCallBusiness: 处理正常通话业务,号码=" + phoneNumber + ",状态=" + callStateDesc); + + // 此处可扩展业务逻辑(示例): + // 1. 保存通话记录到本地 + // 2. 发送广播通知其他组件(如通话监听服务) + // 3. 调用号码识别接口,匹配联系人信息 + } + + // ====================== 工具辅助方法区(统一归类,复用性强) ====================== + /** + * 通话方向转文字描述(便于日志查看,快速定位场景) + */ + private String getCallDirectionDesc(int callDirection) { + switch (callDirection) { + case CALL_DIRECTION_INCOMING: + return "来电"; + case CALL_DIRECTION_OUTGOING: + return "外拨"; + default: + return "未知通话"; + } + } + + /** + * 文字描述转通话方向(配合业务逻辑反向匹配,避免重复判断) + */ + private int getCallDirectionFromDesc(String callTypeDesc) { + if ("来电".equals(callTypeDesc)) { + return CALL_DIRECTION_INCOMING; + } else if ("外拨".equals(callTypeDesc)) { + return CALL_DIRECTION_OUTGOING; + } else { + return -1; // 未知方向 + } + } + + /** + * 通话方向转 TelephonyManager 状态(统一状态标准,便于业务复用) + */ + private int getCallStateByDirection(int callDirection) { + switch (callDirection) { + case CALL_DIRECTION_INCOMING: + return TelephonyManager.CALL_STATE_RINGING; // 来电=响铃中 + case CALL_DIRECTION_OUTGOING: + return TelephonyManager.CALL_STATE_OFFHOOK; // 外拨=通话中 + default: + return TelephonyManager.CALL_STATE_IDLE; // 未知=空闲 + } + } + + /** + * TelephonyManager 状态转文字描述(统一日志格式,提升可读性) + */ + private String getCallStateDesc(int callState) { + switch (callState) { + case TelephonyManager.CALL_STATE_RINGING: + return "响铃中"; + case TelephonyManager.CALL_STATE_OFFHOOK: + return "通话中"; + case TelephonyManager.CALL_STATE_IDLE: + return "空闲"; + default: + return "未知状态"; + } + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/threads/MainServiceThread.java b/contacts/src/main/java/cc/winboll/studio/contacts/threads/MainServiceThread.java new file mode 100644 index 0000000..5f0e01b --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/threads/MainServiceThread.java @@ -0,0 +1,104 @@ +package cc.winboll.studio.contacts.threads; + +import android.content.Context; +import cc.winboll.studio.contacts.handlers.MainServiceHandler; +import cc.winboll.studio.libappbase.LogUtils; +import java.lang.ref.WeakReference; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/02/14 03:46:44 + * @Describe 主服务后台工作线程,负责定时轮询与消息调度 + */ +public class MainServiceThread extends Thread { + // ====================== 常量定义区 ====================== + public static final String TAG = "MainServiceThread"; + // 线程休眠周期(1秒) + private static final long THREAD_SLEEP_INTERVAL = 1000L; + + // ====================== 静态成员变量区 ====================== + private static volatile MainServiceThread sInstance; + + // ====================== 成员变量区 ====================== + // 线程运行控制标记 + private volatile boolean mIsExit; + private volatile boolean mIsStarted; + // 弱引用持有上下文和Handler,避免内存泄漏 + private WeakReference mContextWeakRef; + private WeakReference mHandlerWeakRef; + + // ====================== 私有构造函数 ====================== + private MainServiceThread(Context context, MainServiceHandler handler) { + this.mContextWeakRef = new WeakReference<>(context); + this.mHandlerWeakRef = new WeakReference<>(handler); + this.mIsExit = false; + this.mIsStarted = false; + LogUtils.d(TAG, "MainServiceThread: 线程实例初始化完成"); + } + + // ====================== 单例获取方法 ====================== + public static MainServiceThread getInstance(Context context, MainServiceHandler handler) { + // 若已有实例,先标记退出并销毁旧实例 + if (sInstance != null) { + LogUtils.d(TAG, "getInstance: 存在旧线程实例,标记退出"); + sInstance.setIsExit(true); + sInstance = null; + } + // 创建新线程实例 + sInstance = new MainServiceThread(context, handler); + LogUtils.d(TAG, "getInstance: 新线程实例已创建"); + return sInstance; + } + + // ====================== 运行状态控制方法 ====================== + public void setIsExit(boolean isExit) { + this.mIsExit = isExit; + LogUtils.d(TAG, "setIsExit: 线程退出标记已更新 | " + isExit); + } + + public boolean isExit() { + return mIsExit; + } + + public void setIsStarted(boolean isStarted) { + this.mIsStarted = isStarted; + } + + public boolean isStarted() { + return mIsStarted; + } + + // ====================== 线程核心执行方法 ====================== + @Override + public void run() { + // 防止重复启动 + if (mIsStarted) { + LogUtils.w(TAG, "run: 线程已启动,避免重复执行"); + return; + } + + // 标记线程启动状态 + mIsStarted = true; + LogUtils.i(TAG, "run: 线程开始运行"); + + // 线程主循环 + while (!mIsExit) { + try { + // 此处可添加业务逻辑(如定时任务、消息分发) + Thread.sleep(THREAD_SLEEP_INTERVAL); + } catch (InterruptedException e) { + LogUtils.e(TAG, "run: 线程休眠被中断", e); + // 恢复线程中断状态 + Thread.currentThread().interrupt(); + } + } + + // 线程退出清理 + mIsStarted = false; + mContextWeakRef.clear(); + mHandlerWeakRef.clear(); + sInstance = null; + LogUtils.i(TAG, "run: 线程正常退出"); + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/utils/AppGoToSettingsUtil.java b/contacts/src/main/java/cc/winboll/studio/contacts/utils/AppGoToSettingsUtil.java new file mode 100644 index 0000000..839b342 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/utils/AppGoToSettingsUtil.java @@ -0,0 +1,268 @@ +package cc.winboll.studio.contacts.utils; + +import android.app.Activity; +import android.content.ComponentName; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.provider.Settings; + +import cc.winboll.studio.contacts.MainActivity; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/09/27 14:27 + * @Describe 应用权限设置页跳转工具类,适配主流手机厂商的权限页路径,跳转失败时降级到应用详情页 + */ +public class AppGoToSettingsUtil { + // ====================== 常量定义区 ====================== + public static final String TAG = "AppGoToSettingsUtil"; + // 跳转设置页的 Activity 结果码,复用 MainActivity 的请求码 + public static final int ACTIVITY_RESULT_APP_SETTINGS = MainActivity.REQUEST_APP_SETTINGS; + + // 主流手机厂商品牌常量 + private static final String MANUFACTURER_HUAWEI = "Huawei"; + private static final String MANUFACTURER_MEIZU = "Meizu"; + private static final String MANUFACTURER_XIAOMI = "Xiaomi"; + private static final String MANUFACTURER_SONY = "Sony"; + private static final String MANUFACTURER_OPPO = "OPPO"; + private static final String MANUFACTURER_LG = "LG"; + private static final String MANUFACTURER_VIVO = "vivo"; + private static final String MANUFACTURER_SAMSUNG = "samsung"; + private static final String MANUFACTURER_LETV = "Letv"; + private static final String MANUFACTURER_ZTE = "ZTE"; + private static final String MANUFACTURER_YULONG = "YuLong"; + private static final String MANUFACTURER_LENOVO = "LENOVO"; + + // ====================== 成员变量区 ====================== + // 标记当前跳转的是应用详情页(true)还是厂商权限页(false) + public static boolean isAppSettingOpen = false; + + // ====================== 核心跳转方法区 ====================== + /** + * 跳转到对应品牌手机的系统权限设置页,跳转失败则降级到应用详情页 + * @param activity 上下文 Activity + */ + public static void goToSetting(Activity activity) { + // 空值校验,避免空指针异常 + if (activity == null) { + LogUtils.e(TAG, "goToSetting: Activity 为 null,无法跳转设置页"); + return; + } + + String manufacturer = Build.MANUFACTURER; + LogUtils.d(TAG, "goToSetting: 当前设备厂商 | " + manufacturer); + + // 根据厂商跳转对应权限页 + switch (manufacturer) { + case MANUFACTURER_HUAWEI: + gotoHuaweiSetting(activity); + break; + case MANUFACTURER_MEIZU: + gotoMeizuSetting(activity); + break; + case MANUFACTURER_XIAOMI: + gotoXiaomiSetting(activity); + break; + case MANUFACTURER_SONY: + gotoSonySetting(activity); + break; + case MANUFACTURER_OPPO: + gotoOppoSetting(activity); + break; + case MANUFACTURER_LG: + gotoLgSetting(activity); + break; + case MANUFACTURER_LETV: + gotoLetvSetting(activity); + break; + default: + LogUtils.w(TAG, "goToSetting: 未适配当前厂商,跳转应用详情页"); + openAppDetailSetting(activity); + break; + } + } + + // ====================== 各厂商权限页跳转方法区 ====================== + /** + * 跳转华为手机权限设置页 + */ + private static void gotoHuaweiSetting(Activity activity) { + try { + Intent intent = new Intent(); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra("packageName", activity.getPackageName()); + intent.setComponent(new ComponentName("com.huawei.systemmanager", + "com.huawei.permissionmanager.ui.MainActivity")); + activity.startActivityForResult(intent, ACTIVITY_RESULT_APP_SETTINGS); + isAppSettingOpen = false; + LogUtils.d(TAG, "gotoHuaweiSetting: 跳转华为权限设置页成功"); + } catch (Exception e) { + LogUtils.e(TAG, "gotoHuaweiSetting: 跳转失败,降级到应用详情页", e); + openAppDetailSetting(activity); + } + } + + /** + * 跳转魅族手机权限设置页 + */ + private static void gotoMeizuSetting(Activity activity) { + try { + Intent intent = new Intent("com.meizu.safe.security.SHOW_APPSEC"); + intent.addCategory(Intent.CATEGORY_DEFAULT); + intent.putExtra("packageName", activity.getPackageName()); + activity.startActivity(intent); + isAppSettingOpen = false; + LogUtils.d(TAG, "gotoMeizuSetting: 跳转魅族权限设置页成功"); + } catch (Exception e) { + LogUtils.e(TAG, "gotoMeizuSetting: 跳转失败,降级到应用详情页", e); + openAppDetailSetting(activity); + } + } + + /** + * 跳转小米手机权限设置页 + */ + private static void gotoXiaomiSetting(Activity activity) { + try { + // 适配 MIUI 8/9 及以上版本 + Intent intent = new Intent("miui.intent.action.APP_PERM_EDITOR"); + intent.setClassName("com.miui.securitycenter", + "com.miui.permcenter.permissions.PermissionsEditorActivity"); + intent.putExtra("extra_pkgname", activity.getPackageName()); + activity.startActivityForResult(intent, ACTIVITY_RESULT_APP_SETTINGS); + isAppSettingOpen = false; + LogUtils.d(TAG, "gotoXiaomiSetting: 跳转小米权限设置页(MIUI8+)成功"); + } catch (Exception e) { + try { + // 适配 MIUI 5/6/7 版本 + Intent intent = new Intent("miui.intent.action.APP_PERM_EDITOR"); + intent.setClassName("com.miui.securitycenter", + "com.miui.permcenter.permissions.AppPermissionsEditorActivity"); + intent.putExtra("extra_pkgname", activity.getPackageName()); + activity.startActivityForResult(intent, ACTIVITY_RESULT_APP_SETTINGS); + isAppSettingOpen = false; + LogUtils.d(TAG, "gotoXiaomiSetting: 跳转小米权限设置页(MIUI5-7)成功"); + } catch (Exception e1) { + LogUtils.e(TAG, "gotoXiaomiSetting: 所有版本适配失败,降级到应用详情页", e1); + openAppDetailSetting(activity); + } + } + } + + /** + * 跳转索尼手机权限设置页 + */ + private static void gotoSonySetting(Activity activity) { + try { + Intent intent = new Intent(); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra("packageName", activity.getPackageName()); + intent.setComponent(new ComponentName("com.sonymobile.cta", + "com.sonymobile.cta.SomcCTAMainActivity")); + activity.startActivity(intent); + isAppSettingOpen = false; + LogUtils.d(TAG, "gotoSonySetting: 跳转索尼权限设置页成功"); + } catch (Exception e) { + LogUtils.e(TAG, "gotoSonySetting: 跳转失败,降级到应用详情页", e); + openAppDetailSetting(activity); + } + } + + /** + * 跳转OPPO手机权限设置页 + */ + private static void gotoOppoSetting(Activity activity) { + try { + Intent intent = new Intent(); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra("packageName", activity.getPackageName()); + intent.setComponent(new ComponentName("com.color.safecenter", + "com.color.safecenter.permission.PermissionManagerActivity")); + activity.startActivity(intent); + isAppSettingOpen = false; + LogUtils.d(TAG, "gotoOppoSetting: 跳转OPPO权限设置页成功"); + } catch (Exception e) { + LogUtils.e(TAG, "gotoOppoSetting: 跳转失败,降级到应用详情页", e); + openAppDetailSetting(activity); + } + } + + /** + * 跳转LG手机权限设置页 + */ + private static void gotoLgSetting(Activity activity) { + try { + Intent intent = new Intent("android.intent.action.MAIN"); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra("packageName", activity.getPackageName()); + intent.setComponent(new ComponentName("com.android.settings", + "com.android.settings.Settings$AccessLockSummaryActivity")); + activity.startActivity(intent); + isAppSettingOpen = false; + LogUtils.d(TAG, "gotoLgSetting: 跳转LG权限设置页成功"); + } catch (Exception e) { + LogUtils.e(TAG, "gotoLgSetting: 跳转失败,降级到应用详情页", e); + openAppDetailSetting(activity); + } + } + + /** + * 跳转乐视手机权限设置页 + */ + private static void gotoLetvSetting(Activity activity) { + try { + Intent intent = new Intent(); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra("packageName", activity.getPackageName()); + intent.setComponent(new ComponentName("com.letv.android.letvsafe", + "com.letv.android.letvsafe.PermissionAndApps")); + activity.startActivity(intent); + isAppSettingOpen = false; + LogUtils.d(TAG, "gotoLetvSetting: 跳转乐视权限设置页成功"); + } catch (Exception e) { + LogUtils.e(TAG, "gotoLetvSetting: 跳转失败,降级到应用详情页", e); + openAppDetailSetting(activity); + } + } + + // ====================== 降级跳转方法区 ====================== + /** + * 跳转系统设置主界面 + */ + public static void gotoSystemConfig(Activity activity) { + if (activity == null) { + LogUtils.e(TAG, "gotoSystemConfig: Activity 为 null,无法跳转"); + return; + } + Intent intent = new Intent(Settings.ACTION_SETTINGS); + activity.startActivity(intent); + LogUtils.d(TAG, "gotoSystemConfig: 跳转系统设置主界面成功"); + } + + /** + * 获取应用详情页的 Intent + */ + private static Intent getAppDetailSettingIntent(Activity activity) { + Intent intent = new Intent(); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.setAction("android.settings.APPLICATION_DETAILS_SETTINGS"); + intent.setData(Uri.fromParts("package", activity.getPackageName(), null)); + return intent; + } + + /** + * 打开应用详情设置页 + */ + public static void openAppDetailSetting(Activity activity) { + if (activity == null) { + LogUtils.e(TAG, "openAppDetailSetting: Activity 为 null,无法跳转"); + return; + } + activity.startActivityForResult(getAppDetailSettingIntent(activity), ACTIVITY_RESULT_APP_SETTINGS); + isAppSettingOpen = true; + LogUtils.d(TAG, "openAppDetailSetting: 跳转应用详情设置页成功"); + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/utils/ContactUtils.java b/contacts/src/main/java/cc/winboll/studio/contacts/utils/ContactUtils.java new file mode 100644 index 0000000..9d2fc6c --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/utils/ContactUtils.java @@ -0,0 +1,351 @@ +package cc.winboll.studio.contacts.utils; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract; +import cc.winboll.studio.libappbase.LogUtils; +import java.util.HashMap; +import java.util.Map; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/08/30 14:32 + * @Describe 联系人工具集:提供联系人查询、添加、编辑、号码格式化等功能,适配主流机型 + */ +public class ContactUtils { + // ====================== 常量定义区 ====================== + public static final String TAG = "ContactUtils"; + // 手机号正则(11位中国大陆手机号) + private static final String REGEX_CHINA_MOBILE = "^1[0-9]{10}$"; + + // ====================== 单例与成员变量区 ====================== + // 单例实例(volatile 保证多线程可见性) + private static volatile ContactUtils sInstance; + // 上下文(弱引用避免内存泄漏,Java7 兼容) + private final Context mContext; + // 缓存联系人:key=纯数字号码,value=联系人姓名 + private final Map mContactMap = new HashMap<>(); + + // ====================== 单例构造区 ====================== + /** + * 私有构造器:初始化上下文并加载联系人 + */ + private ContactUtils(Context context) { + // 传入应用上下文,避免Activity上下文泄漏 + this.mContext = context.getApplicationContext(); + LogUtils.d(TAG, "ContactUtils 初始化,开始加载联系人"); + reloadContacts(); + } + + /** + * 获取单例实例(双重校验锁,Java7 安全) + */ + public static ContactUtils getInstance(Context context) { + if (context == null) { + LogUtils.e(TAG, "getInstance: 上下文为null,无法创建实例"); + throw new IllegalArgumentException("Context cannot be null"); + } + if (sInstance == null) { + synchronized (ContactUtils.class) { + if (sInstance == null) { + sInstance = new ContactUtils(context); + } + } + } + return sInstance; + } + + // ====================== 联系人缓存与查询区 ====================== + /** + * 重新加载联系人到缓存 + */ + public void reloadContacts() { + LogUtils.d(TAG, "reloadContacts: 开始刷新联系人缓存"); + mContactMap.clear(); + readContactsFromSystem(); + LogUtils.d(TAG, "reloadContacts: 联系人缓存刷新完成,共缓存 " + mContactMap.size() + " 个联系人"); + } + + /** + * 从系统通讯录读取所有联系人(核心方法) + */ + private void readContactsFromSystem() { + ContentResolver resolver = mContext.getContentResolver(); + // 只查询姓名和号码字段,减少IO开销 + String[] projection = { + ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, + ContactsContract.CommonDataKinds.Phone.NUMBER + }; + + Cursor cursor = null; + try { + cursor = resolver.query( + ContactsContract.CommonDataKinds.Phone.CONTENT_URI, + projection, + null, + null, + null + ); + + if (cursor == null) { + LogUtils.w(TAG, "readContactsFromSystem: 通讯录查询Cursor为null,可能缺少权限"); + return; + } + + while (cursor.moveToNext()) { + String name = cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)); + String phone = cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)); + + if (phone != null) { + String simplePhone = formatToSimplePhoneNumber(phone); + mContactMap.put(simplePhone, name != null ? name : "[UnknownName]"); + LogUtils.v(TAG, "readContactsFromSystem: 缓存联系人 - 号码:" + simplePhone + ",姓名:" + name); + } + } + } catch (SecurityException e) { + LogUtils.e(TAG, "readContactsFromSystem: 读取通讯录失败,缺少 READ_CONTACTS 权限", e); + } catch (Exception e) { + LogUtils.e(TAG, "readContactsFromSystem: 读取通讯录异常", e); + } finally { + if (cursor != null) { + cursor.close(); // 确保游标关闭,避免内存泄漏 + } + } + } + + /** + * 从缓存中获取联系人姓名 + */ + public String getContactName(String phone) { + if (phone == null) { + LogUtils.w(TAG, "getContactName: 输入号码为null"); + return "[NotInContacts]"; + } + String simplePhone = formatToSimplePhoneNumber(phone); + String name = mContactMap.get(simplePhone); + LogUtils.d(TAG, "getContactName: 查询号码 " + simplePhone + ",姓名:" + (name == null ? "[NotInContacts]" : name)); + return name == null ? "[NotInContacts]" : name; + } + + // ====================== 号码格式化工具区 ====================== + /** + * 格式化号码为纯数字(去除所有非数字字符) + */ + public static String formatToSimplePhoneNumber(String number) { + if (number == null || number.isEmpty()) { + LogUtils.w(TAG, "formatToSimplePhoneNumber: 输入号码为空"); + return ""; + } + String simpleNumber = number.replaceAll("[^0-9]", ""); + LogUtils.v(TAG, "formatToSimplePhoneNumber: 原号码 " + number + " → 纯数字号码 " + simpleNumber); + return simpleNumber; + } + + /** + * 格式化11位手机号为带空格格式(如:138 0000 1234) + */ + public static String formatToSpacePhoneNumber(String simpleNumber) { + if (simpleNumber == null || !simpleNumber.matches(REGEX_CHINA_MOBILE)) { + LogUtils.v(TAG, "formatToSpacePhoneNumber: 号码不符合11位手机号格式,无需格式化"); + return simpleNumber; + } + + StringBuilder sb = new StringBuilder(); + sb.append(simpleNumber.substring(0, 3)) + .append(" ") + .append(simpleNumber.substring(3, 7)) + .append(" ") + .append(simpleNumber.substring(7, 11)); + + String formatted = sb.toString(); + LogUtils.v(TAG, "formatToSpacePhoneNumber: 纯数字号码 " + simpleNumber + " → 带空格号码 " + formatted); + return formatted; + } + + // ====================== 联系人查询(直接查系统,不走缓存)区 ====================== + /** + * 直接查询系统通讯录获取联系人姓名(按原始号码匹配) + */ + public static String getDisplayNameByPhone(Context context, String phoneNumber) { + if (context == null || phoneNumber == null) { + LogUtils.w(TAG, "getDisplayNameByPhone: 上下文或号码为空"); + return null; + } + + ContentResolver resolver = context.getContentResolver(); + String[] projection = {ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME}; + Cursor cursor = null; + String displayName = null; + + try { + cursor = resolver.query( + ContactsContract.CommonDataKinds.Phone.CONTENT_URI, + projection, + ContactsContract.CommonDataKinds.Phone.NUMBER + "=?", + new String[]{phoneNumber}, + null + ); + + if (cursor != null && cursor.moveToFirst()) { + displayName = cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)); + } + LogUtils.d(TAG, "getDisplayNameByPhone: 按原始号码 " + phoneNumber + " 查询,姓名:" + displayName); + } catch (SecurityException e) { + LogUtils.e(TAG, "getDisplayNameByPhone: 缺少 READ_CONTACTS 权限", e); + } catch (Exception e) { + LogUtils.e(TAG, "getDisplayNameByPhone: 查询异常", e); + } finally { + if (cursor != null) { + cursor.close(); + } + } + return displayName; + } + + /** + * 直接查询系统通讯录获取联系人姓名(按纯数字号码匹配) + */ + public static String getDisplayNameByPhoneSimple(Context context, String phoneNumber) { + if (phoneNumber == null) { + LogUtils.w(TAG, "getDisplayNameByPhoneSimple: 输入号码为null"); + return null; + } + String simplePhone = formatToSimplePhoneNumber(phoneNumber); + LogUtils.d(TAG, "getDisplayNameByPhoneSimple: 按纯数字号码 " + simplePhone + " 查询"); + return getDisplayNameByPhone(context, simplePhone); + } + + /** + * 判断号码是否在系统通讯录中 + */ + public static boolean isPhoneInContacts(Context context, String phoneNumber) { + if (context == null || phoneNumber == null) { + LogUtils.w(TAG, "isPhoneInContacts: 上下文或号码为空"); + return false; + } + + String simplePhone = formatToSimplePhoneNumber(phoneNumber); + String displayName = getDisplayNameByPhone(context, simplePhone); + + if (displayName == null) { + LogUtils.d(TAG, "isPhoneInContacts: 号码 " + simplePhone + " 未找到联系人(纯数字匹配)"); + String spacePhone = formatToSpacePhoneNumber(simplePhone); + displayName = getDisplayNameByPhone(context, spacePhone); + if (displayName == null) { + LogUtils.d(TAG, "isPhoneInContacts: 号码 " + spacePhone + " 未找到联系人(带空格匹配)"); + return false; + } + } + + LogUtils.d(TAG, "isPhoneInContacts: 号码 " + simplePhone + " 已在联系人中,姓名:" + displayName); + return true; + } + + /** + * 通过电话号码查询联系人ID(适配定制机型) + */ + public static Long getContactIdByPhone(Context context, String phoneNumber) { + if (context == null || phoneNumber == null || phoneNumber.isEmpty()) { + LogUtils.w(TAG, "getContactIdByPhone: 上下文或号码为空"); + return -1L; + } + + ContentResolver resolver = context.getContentResolver(); + Uri queryUri = Uri.withAppendedPath(ContactsContract.CommonDataKinds.Phone.CONTENT_FILTER_URI, Uri.encode(phoneNumber)); + String[] projection = {ContactsContract.CommonDataKinds.Phone.CONTACT_ID}; + Cursor cursor = null; + Long contactId = -1L; + + try { + cursor = resolver.query(queryUri, projection, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + contactId = cursor.getLong(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID)); + } + LogUtils.d(TAG, "getContactIdByPhone: 号码 " + phoneNumber + " 对应的联系人ID:" + contactId); + } catch (SecurityException e) { + LogUtils.e(TAG, "getContactIdByPhone: 缺少 READ_CONTACTS 权限", e); + } catch (Exception e) { + LogUtils.e(TAG, "getContactIdByPhone: 查询异常", e); + } finally { + if (cursor != null) { + cursor.close(); + } + } + return contactId; + } + + // ====================== 联系人跳转工具区 ====================== + /** + * 跳转至系统添加联系人界面 + * @param context 上下文 + * @param phoneNumber 预填号码(可为null) + */ + public static void jumpToAddContact(Context context, String phoneNumber) { + if (context == null) { + LogUtils.e(TAG, "jumpToAddContact: 上下文为null"); + return; + } + + Intent intent = new Intent(Intent.ACTION_INSERT); + intent.setType("vnd.android.cursor.dir/person"); + if (phoneNumber != null) { + intent.putExtra(ContactsContract.Intents.Insert.PHONE, phoneNumber); + LogUtils.d(TAG, "jumpToAddContact: 跳转添加联系人,预填号码:" + phoneNumber); + } else { + LogUtils.d(TAG, "jumpToAddContact: 跳转添加联系人,无预填号码"); + } + + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // 支持非Activity上下文调用 + context.startActivity(intent); + } + + /** + * 跳转至系统编辑联系人界面(适配小米等定制机型) + * @param context 上下文 + * @param phoneNumber 待编辑号码(必传) + * @param contactId 联系人ID(可选,优先使用) + */ + public static void jumpToEditContact(Context context, String phoneNumber, Long contactId) { + if (context == null) { + LogUtils.e(TAG, "jumpToEditContact: 上下文为null"); + return; + } + + // 校验必要参数 + if (contactId == null || contactId <= 0) { + if (phoneNumber == null || phoneNumber.isEmpty()) { + LogUtils.e(TAG, "jumpToEditContact: 联系人ID和号码均为空,无法编辑"); + return; + } + } + + Intent intent = new Intent(Intent.ACTION_EDIT); + intent.setType(ContactsContract.Contacts.CONTENT_ITEM_TYPE); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + // 优先通过ID定位(精准) + if (contactId != null && contactId > 0) { + Uri contactUri = ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, contactId); + intent.setData(contactUri); + LogUtils.d(TAG, "jumpToEditContact: 通过ID " + contactId + " 定位联系人,准备编辑"); + } else { + // 通过号码定位 + Uri phoneUri = Uri.withAppendedPath(ContactsContract.CommonDataKinds.Phone.CONTENT_FILTER_URI, Uri.encode(phoneNumber)); + intent.setData(phoneUri); + LogUtils.d(TAG, "jumpToEditContact: 通过号码 " + phoneNumber + " 定位联系人,准备编辑"); + } + + // 预填最新号码 + if (phoneNumber != null && !phoneNumber.isEmpty()) { + intent.putExtra(ContactsContract.CommonDataKinds.Phone.NUMBER, phoneNumber); + intent.putExtra(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE); + } + + context.startActivity(intent); + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/utils/EditTextIntUtils.java b/contacts/src/main/java/cc/winboll/studio/contacts/utils/EditTextIntUtils.java new file mode 100644 index 0000000..b0a44d5 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/utils/EditTextIntUtils.java @@ -0,0 +1,51 @@ +package cc.winboll.studio.contacts.utils; + +import android.widget.EditText; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/04/13 00:59:13 + * @Describe Int类型数字输入框工具集:安全读取 EditText 中的整数内容 + */ +public class EditTextIntUtils { + // ====================== 常量定义区 ====================== + public static final String TAG = "EditTextIntUtils"; + // 默认返回值:读取失败时返回 + private static final int DEFAULT_INT_VALUE = 0; + + // ====================== 工具方法区 ====================== + /** + * 从 EditText 中安全读取整数 + * @param editText 目标输入框 + * @return 输入框中的整数,读取失败返回 0 + */ + public static int getIntFromEditText(EditText editText) { + // 空值校验:防止 EditText 为 null 导致空指针 + if (editText == null) { + LogUtils.w(TAG, "getIntFromEditText: EditText 实例为 null,返回默认值 " + DEFAULT_INT_VALUE); + return DEFAULT_INT_VALUE; + } + + // 获取并去除首尾空格 + String inputStr = editText.getText().toString().trim(); + LogUtils.d(TAG, "getIntFromEditText: 输入框原始内容 | " + inputStr); + + // 校验空字符串 + if (inputStr.isEmpty()) { + LogUtils.w(TAG, "getIntFromEditText: 输入框内容为空,返回默认值 " + DEFAULT_INT_VALUE); + return DEFAULT_INT_VALUE; + } + + // 安全转换整数,捕获格式异常 + try { + int result = Integer.parseInt(inputStr); + LogUtils.d(TAG, "getIntFromEditText: 转换成功 | 结果=" + result); + return result; + } catch (NumberFormatException e) { + LogUtils.e(TAG, "getIntFromEditText: 内容不是有效整数 | 输入内容=" + inputStr, e); + return DEFAULT_INT_VALUE; + } + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/utils/IntUtils.java b/contacts/src/main/java/cc/winboll/studio/contacts/utils/IntUtils.java new file mode 100644 index 0000000..a5564c8 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/utils/IntUtils.java @@ -0,0 +1,64 @@ +package cc.winboll.studio.contacts.utils; + +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author ZhanGSKen + * @Date 2025/04/13 01:16:28 + * @Describe Int数字操作工具集:提供整数范围限制、数值边界校准功能 + */ +public class IntUtils { + // ====================== 常量定义区 ====================== + public static final String TAG = "IntUtils"; + + // ====================== 核心工具方法区 ====================== + /** + * 将整数限制在指定区间内,自动校准超出边界的数值 + * @param origin 原始整数 + * @param range_a 区间端点1(无需区分大小) + * @param range_b 区间端点2(无需区分大小) + * @return 校准后的整数,结果始终在 [min(range_a,range_b), max(range_a,range_b)] 内 + */ + public static int getIntInRange(int origin, int range_a, int range_b) { + int min = Math.min(range_a, range_b); + int max = Math.max(range_a, range_b); + int res = Math.min(origin, max); + res = Math.max(res, min); + + // 打印调试日志,记录参数与计算结果 + LogUtils.d(TAG, String.format("getIntInRange: 原始值=%d, 区间=[%d,%d], 校准后=%d", + origin, min, max, res)); + return res; + } + + // ====================== 单元测试方法区 ====================== + /** + * 单元测试:验证 getIntInRange 方法在不同场景下的正确性 + */ + public static void unittest_getIntInRange() { + LogUtils.i(TAG, "unittest_getIntInRange: 开始执行单元测试"); + + // 正数区间测试 + LogUtils.d(TAG, String.format("测试1: getIntInRange(-100, 5, 10) = %d", getIntInRange(-100, 5, 10))); + LogUtils.d(TAG, String.format("测试2: getIntInRange(8, 5, 10) = %d", getIntInRange(8, 5, 10))); + LogUtils.d(TAG, String.format("测试3: getIntInRange(200, 5, 10) = %d", getIntInRange(200, 5, 10))); + + // 跨正负区间测试 + LogUtils.d(TAG, String.format("测试4: getIntInRange(-100, -5, 10) = %d", getIntInRange(-100, -5, 10))); + LogUtils.d(TAG, String.format("测试5: getIntInRange(9, -5, 10) = %d", getIntInRange(9, -5, 10))); + LogUtils.d(TAG, String.format("测试6: getIntInRange(100, -5, 10) = %d", getIntInRange(100, -5, 10))); + + // 端点顺序颠倒测试 + LogUtils.d(TAG, String.format("测试7: getIntInRange(500, 5, -10) = %d", getIntInRange(500, 5, -10))); + LogUtils.d(TAG, String.format("测试8: getIntInRange(4, 5, -10) = %d", getIntInRange(4, 5, -10))); + LogUtils.d(TAG, String.format("测试9: getIntInRange(-20, 5, -10) = %d", getIntInRange(-20, 5, -10))); + + // 大数区间测试 + LogUtils.d(TAG, String.format("测试10: getIntInRange(500, 50, 10) = %d", getIntInRange(500, 50, 10))); + LogUtils.d(TAG, String.format("测试11: getIntInRange(30, 50, 10) = %d", getIntInRange(30, 50, 10))); + LogUtils.d(TAG, String.format("测试12: getIntInRange(6, 50, 10) = %d", getIntInRange(6, 50, 10))); + + LogUtils.i(TAG, "unittest_getIntInRange: 单元测试执行完毕"); + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/utils/NotificationManagerUtils.java b/contacts/src/main/java/cc/winboll/studio/contacts/utils/NotificationManagerUtils.java new file mode 100644 index 0000000..1cc3bae --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/utils/NotificationManagerUtils.java @@ -0,0 +1,505 @@ +package cc.winboll.studio.contacts.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.SharedPreferences; +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.contacts.MainActivity; +import cc.winboll.studio.contacts.R; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * 通知工具类:统一管理前台服务/临时通知 + * @Author 豆包&ZhanGSKen + * @CreateTime 2025/12/14 21:01:00 + * @LastEditTime 2026/01/07 22:00:00 + * @Describe 适配API30,支持拨号服务前台保活通知、临时通知提醒,提供通知的创建、更新、取消功能;支持通知版本管理,版本变更时自动清理旧渠道 + */ +public class NotificationManagerUtils { + // ================================== 静态常量(置顶统一管理,杜绝魔法值)================================= + public static final String TAG = "NotificationManagerUtils"; + // ********** 新增:通知版本管理常量 ********** + /** 通知版本标识(源码标记版本,变更时会清理旧渠道) */ + public static final String NOTIFICATION_VERSION = "v1.0.3"; + /** SP存储键:已保存的通知版本 */ + private static final String SP_KEY_NOTIFICATION_VERSION = "sp_key_notification_version"; + /** SP文件名 */ + private static final String SP_NAME_NOTIFICATION = "sp_notification_manager"; + // ****************************************** + // 通知渠道ID(API26+ 必需,区分通知类型) + public static final String CHANNEL_ID_FOREGROUND = "cc.winboll.studio.contacts.channel.foreground"; + public static final String CHANNEL_ID_TEMPORARY = "cc.winboll.studio.contacts.channel.temporary"; + // 通知ID(唯一标识,避免重复) + public static final int NOTIFY_ID_FOREGROUND_SERVICE = 1001; + public static final int NOTIFY_ID_TEMPORARY = 1002; + // 低版本兼容:默认通知图标(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 TEMPORARY_NOTIFY_TITLE_DEFAULT = "拨号服务临时通知"; + private static final String TEMPORARY_NOTIFY_CONTENT_DEFAULT = "临时通知内容"; + // PendingIntent请求码 + private static final int PENDING_INTENT_REQUEST_CODE_FOREGROUND = 0; + private static final int PENDING_INTENT_REQUEST_CODE_TEMPORARY = 1; + // 消息通知自增ID(起始值) + private static int snMessageNotificationID = 10000; + + // ================================== 成员变量(私有封装,按依赖优先级排序)================================= + // 核心上下文(应用级,避免内存泄漏) + private Context mContext; + // 系统通知服务(核心依赖) + private NotificationManager mNotificationManager; + // 前台服务通知实例(单独持有,便于更新/取消) + private Notification mForegroundServiceNotify; + // ********** 新增:SP实例(用于版本存储) ********** + private SharedPreferences mSp; + + // ================================== 构造方法(初始化核心资源,前置校验)================================= + 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); + // ********** 新增:初始化SP ********** + this.mSp = mContext.getSharedPreferences(SP_NAME_NOTIFICATION, Context.MODE_PRIVATE); + LogUtils.d(TAG, "NotificationManagerUtils() 核心资源初始化完成 | mContext=" + mContext + " | mNotificationManager=" + mNotificationManager + " | mSp=" + mSp); + + // ********** 新增:版本检查与旧渠道清理 ********** + checkNotificationVersionAndCleanOldChannels(); + + // 初始化通知渠道(API26+ 必需) + initNotificationChannels(); + LogUtils.d(TAG, "NotificationManagerUtils() 构造完成"); + } + + // ================================== 新增核心方法:通知版本检查与旧渠道清理 ================================= + /** + * 检查当前通知版本与SP中保存的版本是否一致 + * 若不一致,清理所有旧通知渠道,并更新SP中的版本记录 + */ + private void checkNotificationVersionAndCleanOldChannels() { + LogUtils.d(TAG, "checkNotificationVersionAndCleanOldChannels() 方法调用 | 源码版本=" + NOTIFICATION_VERSION + " | SP版本=" + getSavedNotificationVersion()); + // 1. 版本一致,无需处理 + if (NOTIFICATION_VERSION.equals(getSavedNotificationVersion())) { + LogUtils.d(TAG, "checkNotificationVersionAndCleanOldChannels() 版本一致,无需清理渠道"); + return; + } + + // 2. 版本不一致,清理所有旧渠道 + LogUtils.w(TAG, "checkNotificationVersionAndCleanOldChannels() 版本不一致,开始清理所有旧通知渠道"); + cleanAllNotificationChannels(); + + // 3. 取消所有旧通知 + cancelAllNotifications(); + LogUtils.d(TAG, "checkNotificationVersionAndCleanOldChannels() 已取消所有旧通知"); + + // 4. 更新SP中的版本记录 + saveNotificationVersion(NOTIFICATION_VERSION); + LogUtils.d(TAG, "checkNotificationVersionAndCleanOldChannels() 已更新SP版本记录为:" + NOTIFICATION_VERSION); + } + + /** + * 从SP中获取已保存的通知版本 + * @return 已保存的版本号,无则返回空字符串 + */ + private String getSavedNotificationVersion() { + return mSp.getString(SP_KEY_NOTIFICATION_VERSION, ""); + } + + /** + * 将当前通知版本保存到SP + * @param version 要保存的版本号 + */ + private void saveNotificationVersion(String version) { + mSp.edit().putString(SP_KEY_NOTIFICATION_VERSION, version).commit(); + } + + /** + * 清理所有已创建的通知渠道(API26+ 有效) + */ + private void cleanAllNotificationChannels() { + LogUtils.d(TAG, "cleanAllNotificationChannels() 方法调用"); + // API<26 无渠道机制,直接返回 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || mNotificationManager == null) { + LogUtils.d(TAG, "cleanAllNotificationChannels() API<26 或 NotificationManager为空,无需清理"); + return; + } + + // 遍历所有渠道并删除 + for (NotificationChannel channel : mNotificationManager.getNotificationChannels()) { + LogUtils.d(TAG, "cleanAllNotificationChannels() 正在删除渠道:" + channel.getId() + " | " + channel.getName()); + mNotificationManager.deleteNotificationChannel(channel.getId()); + } + LogUtils.d(TAG, "cleanAllNotificationChannels() 所有旧渠道清理完成"); + } + + // ================================== 核心初始化方法(通知渠道,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 temporaryChannel = new NotificationChannel( + CHANNEL_ID_TEMPORARY, + "临时通知", + NotificationManager.IMPORTANCE_DEFAULT + ); + temporaryChannel.setDescription("其他临时通知,系统默认铃声,无振动"); + temporaryChannel.enableLights(true); + temporaryChannel.enableVibration(false); + temporaryChannel.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), Notification.AUDIO_ATTRIBUTES_DEFAULT); + temporaryChannel.setShowBadge(false); + temporaryChannel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); + LogUtils.d(TAG, "initNotificationChannels() 其他临时通知渠道配置完成"); + + // 注册渠道到系统 + mNotificationManager.createNotificationChannel(foregroundChannel); + mNotificationManager.createNotificationChannel(temporaryChannel); + LogUtils.d(TAG, "initNotificationChannels() 成功:创建前台服务+其他临时通知渠道"); + } + + // ================================== 对外核心方法(前台服务通知:启动/更新/取消)================================= + /** + * 启动前台服务通知(API30适配,无铃声) + * @param service 前台服务实例 + * @param message 通知内容 + */ + public void startForegroundServiceNotify(Service service, String message) { + LogUtils.d(TAG, "startForegroundServiceNotify() 方法调用 | notifyId=" + NOTIFY_ID_FOREGROUND_SERVICE + " | service=" + service + " | message=" + message); + // 前置校验:参数非空 + if (service == null || mNotificationManager == null) { + LogUtils.e(TAG, "startForegroundServiceNotify() 失败:param is null | service=" + service + " | 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,保持无铃声) + * @param message 新的通知内容 + */ + public void updateForegroundServiceNotify(String message) { + LogUtils.d(TAG, "updateForegroundServiceNotify() 方法调用 | notifyId=" + NOTIFY_ID_FOREGROUND_SERVICE + " | message=" + message); + if (mNotificationManager == null) { + LogUtils.e(TAG, "updateForegroundServiceNotify() 失败:mNotificationManager is null"); + 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() 成功"); + } + + // ================================== 对外核心方法(临时通知:发送)================================= + /** + * 发送临时通知(自增ID,避免覆盖,系统默认铃声) + * @param context 上下文 + * @param message 通知内容 + */ + public synchronized void showTemporaryNotification(Context context, String message) { + snMessageNotificationID++; + LogUtils.d(TAG, "showTemporaryNotification() 方法调用 | notifyId=" + snMessageNotificationID + " | context=" + context + " | message=" + message); + // 前置校验:参数非空 + if (context == null || mNotificationManager == null) { + LogUtils.e(TAG, "showTemporaryNotification() 失败:param is null | context=" + context + " | mNotificationManager=" + mNotificationManager); + return; + } + + Notification temporaryNotify = buildTemporaryNotification(context, message); + if (temporaryNotify == null) { + LogUtils.e(TAG, "showTemporaryNotification() 失败:构建通知为空"); + return; + } + + try { + mNotificationManager.notify(snMessageNotificationID, temporaryNotify); + LogUtils.d(TAG, "showTemporaryNotification() 成功 | notifyId=" + snMessageNotificationID); + } catch (Exception e) { + LogUtils.e(TAG, "showTemporaryNotification() 异常 | notifyId=" + snMessageNotificationID, e); + } + } + + // ================================== 对外工具方法(通知取消:单个/全部)================================= + /** + * 取消指定ID的通知 + * @param notifyId 通知唯一标识 + */ + 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); + } + } + + // ================================== 内部辅助方法(通知构建:前台服务通知)================================= + /** + * 构建前台服务通知(全版本无铃声+无振动) + * @param message 通知内容 + * @return 构建完成的前台通知实例 + */ + private Notification buildForegroundNotification(String message) { + LogUtils.d(TAG, "buildForegroundNotification() 方法调用 | message=" + message); + if (mContext == null) { + LogUtils.e(TAG, "buildForegroundNotification() 失败:mContext is null"); + return null; + } + + // 内容兜底 + String title = FOREGROUND_NOTIFY_TITLE_DEFAULT; + String content = (message != null && !message.isEmpty()) ? message : 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) { + builder = new Notification.Builder(mContext, CHANNEL_ID_FOREGROUND); + LogUtils.d(TAG, "buildForegroundNotification() 使用API26+渠道构建"); + } else { + 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; + } + + // ================================== 内部辅助方法(通知构建:临时通知)================================= + /** + * 构建临时通知(全版本系统默认铃声+无振动) + * @param context 上下文 + * @param message 通知内容 + * @return 构建完成的临时通知实例 + */ + private Notification buildTemporaryNotification(Context context, String message) { + LogUtils.d(TAG, "buildTemporaryNotification() 方法调用 | context=" + context + " | message=" + message); + if (context == null) { + LogUtils.e(TAG, "buildTemporaryNotification() 失败:context is null"); + return null; + } + + // 内容兜底 + String title = TEMPORARY_NOTIFY_TITLE_DEFAULT; + String content = (message != null && !message.isEmpty()) ? message : TEMPORARY_NOTIFY_CONTENT_DEFAULT; + LogUtils.d(TAG, "buildTemporaryNotification() 内容兜底完成 | title=" + title + " | content=" + content); + + Notification.Builder builder; + // API分级构建 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + builder = new Notification.Builder(context, CHANNEL_ID_TEMPORARY); + LogUtils.d(TAG, "buildTemporaryNotification() 使用API26+渠道构建"); + } else { + builder = new Notification.Builder(context); + builder.setSound(Settings.System.DEFAULT_NOTIFICATION_URI); + builder.setVibrate(new long[]{0}); + builder.setDefaults(Notification.DEFAULT_LIGHTS | Notification.DEFAULT_SOUND); + LogUtils.d(TAG, "buildTemporaryNotification() 使用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_TEMPORARY)); + + // 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, "buildTemporaryNotification() 补充API21+配置"); + } + + Notification notification = builder.build(); + LogUtils.d(TAG, "buildTemporaryNotification() 成功构建临时通知"); + return notification; + } + + // ================================== 内部辅助方法(创建跳转PendingIntent,API30安全适配)================================= + /** + * 创建跳转MainActivity的PendingIntent,API23+ 添加IMMUTABLE标记 + * @param context 上下文 + * @param requestCode 请求码 + * @return 构建完成的PendingIntent + */ + 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图标,失败返回默认图标 + * @param context 上下文 + * @return APP图标Bitmap实例 + */ + 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; + mSp = null; // ********** 新增:释放SP实例 ********** + LogUtils.d(TAG, "release() 成功:所有资源已释放"); + } + + // ================================== 对外 getter 方法(仅前台通知实例,只读)================================= + public Notification getForegroundServiceNotify() { + return mForegroundServiceNotify; + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/utils/PermissionUtils.java b/contacts/src/main/java/cc/winboll/studio/contacts/utils/PermissionUtils.java new file mode 100644 index 0000000..6b3e774 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/utils/PermissionUtils.java @@ -0,0 +1,254 @@ +package cc.winboll.studio.contacts.utils; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.provider.Settings; +import android.telecom.TelecomManager; +import androidx.annotation.NonNull; +import androidx.core.app.ActivityCompat; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/12/12 16:28 + * @Describe 敏感权限申请工具类(完全适配 Android API 30 + Java 7 语法) + * 修复 ACTION_CHANGE_DEFAULT_CALL_SCREENING_APP / EXTRA_PACKAGE_NAME 未定义问题 + */ +public class PermissionUtils { + public static final String TAG = "PermissionUtils"; + + // API 版本硬编码常量(Java 7 兼容,不依赖 Build.VERSION_CODES 高版本字段) + private static final int ANDROID_6_API = 23; + private static final int ANDROID_10_API = 29; + private static final int ANDROID_13_API = 33; + private static final int ANDROID_14_API = 34; + + // 硬编码系统常量字符串,解决 API 30 下未定义问题 + private static final String ACTION_CHANGE_DEFAULT_CALL_SCREENING_APP = + "android.telecom.action.CHANGE_DEFAULT_CALL_SCREENING_APP"; + private static final String EXTRA_PACKAGE_NAME = + "android.telecom.extra.PACKAGE_NAME"; + + // 基础权限组(严格适配 API 30,移除废弃/不存在的权限) + public static final String[] BASE_PERMISSIONS = { + android.Manifest.permission.READ_CONTACTS, + android.Manifest.permission.WRITE_CONTACTS, + android.Manifest.permission.READ_CALL_LOG, + android.Manifest.permission.CALL_PHONE, + android.Manifest.permission.RECORD_AUDIO, + android.Manifest.permission.MODIFY_AUDIO_SETTINGS + }; + + /** + * 获取所有需要申请的权限(Java 7 传统 for 循环,无菱形运算符) + */ + public static String[] getAllNeedPermissions() { + List permissions = new ArrayList(); + // Java 7 传统循环遍历数组 + for (int i = 0; i < BASE_PERMISSIONS.length; i++) { + permissions.add(BASE_PERMISSIONS[i]); + } + // 显式创建数组并转换,避免 Java 7 泛型转换警告 + String[] permissionArray = new String[permissions.size()]; + return permissions.toArray(permissionArray); + } + + /** + * 检查单个权限是否授予(使用 PackageManager 标准常量) + */ + public static boolean checkPermission(@NonNull Context context, @NonNull String permission) { + return ActivityCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED; + } + + /** + * 检查权限组是否全部授予(Java 7 传统循环) + */ + public static boolean checkPermissions(@NonNull Context context, @NonNull String[] permissions) { + // Java 7 遍历数组,避免增强 for 循环的语法糖问题 + for (int i = 0; i < permissions.length; i++) { + String permission = permissions[i]; + if (!checkPermission(context, permission)) { + return false; + } + } + return true; + } + + /** + * 申请权限组(Activity 中调用,Java 7 兼容) + */ + public static void requestPermissions(@NonNull FragmentActivity activity, + @NonNull String[] permissions, + int requestCode) { + ActivityCompat.requestPermissions(activity, permissions, requestCode); + } + + /** + * 申请权限组(Fragment 中调用,Java 7 兼容) + */ + public static void requestPermissions(@NonNull Fragment fragment, + @NonNull String[] permissions, + int requestCode) { + fragment.requestPermissions(permissions, requestCode); + } + + /** + * 检查悬浮窗权限(API 30 适配) + */ + public static boolean isOverlayPermissionGranted(@NonNull Context context) { + if (Build.VERSION.SDK_INT >= ANDROID_6_API) { + return Settings.canDrawOverlays(context); + } + // 6.0 以下默认授予 + return true; + } + + /** + * 申请悬浮窗权限(Java 7 规范,拆分 Intent 创建步骤) + */ + public static void requestOverlayPermission(@NonNull Context context, int requestCode) { + if (Build.VERSION.SDK_INT >= ANDROID_6_API && !isOverlayPermissionGranted(context)) { + Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION); + Uri uri = Uri.parse("package:" + context.getPackageName()); + intent.setData(uri); + if (context instanceof FragmentActivity) { + ((FragmentActivity) context).startActivityForResult(intent, requestCode); + } + } + } + + /** + * 检查修改系统设置权限(API 30 适配) + */ + public static boolean isWriteSettingsPermissionGranted(@NonNull Context context) { + if (Build.VERSION.SDK_INT >= ANDROID_6_API) { + return Settings.System.canWrite(context); + } + // 6.0 以下默认授予 + return true; + } + + /** + * 申请修改系统设置权限(Java 7 规范) + */ + public static void requestWriteSettingsPermission(@NonNull Context context, int requestCode) { + if (Build.VERSION.SDK_INT >= ANDROID_6_API && !isWriteSettingsPermissionGranted(context)) { + Intent intent = new Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS); + Uri uri = Uri.parse("package:" + context.getPackageName()); + intent.setData(uri); + if (context instanceof FragmentActivity) { + ((FragmentActivity) context).startActivityForResult(intent, requestCode); + } + } + } + + /** + * 检查通话筛选权限(适配 API 30,优化反射逻辑 + 异常捕获) + */ + public static boolean isCallScreeningPermissionGranted(@NonNull Context context) { + if (Build.VERSION.SDK_INT >= ANDROID_10_API) { + TelecomManager telecomManager = (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE); + if (telecomManager == null) { + return false; + } + String defaultPackage = null; + // 反射调用高版本方法,捕获所有异常避免崩溃(Java 7 必须显式捕获 Exception) + try { + Method method = TelecomManager.class.getMethod("getDefaultCallScreeningAppPackage"); + defaultPackage = (String) method.invoke(telecomManager); + } catch (NoSuchMethodException e) { + // API 30-32 无此方法,返回 false + return false; + } catch (Exception e) { + // 其他反射异常,返回 false + return false; + } + return defaultPackage != null && defaultPackage.equals(context.getPackageName()); + } + // 10.0 以下无此权限,默认返回 true + return true; + } + + /** + * 申请通话筛选权限(完全适配 API 30,解决 ActivityNotFoundException 崩溃) + */ + public static void requestCallScreeningPermission(@NonNull Context context, int requestCode) { + if (Build.VERSION.SDK_INT >= ANDROID_10_API && !isCallScreeningPermissionGranted(context)) { + FragmentActivity activity = null; + if (context instanceof FragmentActivity) { + activity = (FragmentActivity) context; + } + if (activity == null) { + return; + } + + Intent intent = null; + // 版本分级处理:避免高版本 ACTION 失效 + if (Build.VERSION.SDK_INT >= ANDROID_14_API) { + // Android 14+:跳转默认应用设置页 + intent = new Intent(Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS); + Uri uri = Uri.parse("package:" + context.getPackageName()); + intent.setData(uri); + } else if (Build.VERSION.SDK_INT >= ANDROID_13_API) { + // Android 13:使用硬编码 ACTION + intent = new Intent(ACTION_CHANGE_DEFAULT_CALL_SCREENING_APP); + intent.putExtra(EXTRA_PACKAGE_NAME, context.getPackageName()); + } else { + // API 30-32:直接跳转应用详情页 + goAppDetailsSettings(context); + return; + } + + // 捕获 Activity 找不到异常,兜底处理(Java 7 必须显式捕获) + try { + activity.startActivityForResult(intent, requestCode); + } catch (android.content.ActivityNotFoundException e) { + // 兜底:跳转应用详情页 + goAppDetailsSettings(context); + } + } + } + + /** + * 跳转应用详情页(权限兜底引导,Java 7 规范) + */ + public static void goAppDetailsSettings(@NonNull Context context) { + Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + Uri uri = Uri.fromParts("package", context.getPackageName(), null); + intent.setData(uri); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } + + /** + * 解析被拒绝的权限(Java 7 字符串操作,无 Lambda) + */ + public static String getDeniedPermissions(@NonNull Context context, @NonNull String[] permissions) { + StringBuilder deniedPerms = new StringBuilder(); + // Java 7 传统循环遍历权限数组 + for (int i = 0; i < permissions.length; i++) { + String permission = permissions[i]; + if (!checkPermission(context, permission)) { + // 截取权限名称,优化展示 + int lastDotIndex = permission.lastIndexOf("."); + if (lastDotIndex != -1 && lastDotIndex < permission.length() - 1) { + String permName = permission.substring(lastDotIndex + 1); + deniedPerms.append(permName).append("、"); + } + } + } + // 移除最后一个分隔符(Java 7 字符串操作) + if (deniedPerms.length() > 0) { + deniedPerms.deleteCharAt(deniedPerms.length() - 1); + } + return deniedPerms.toString(); + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/utils/PhoneUtils.java b/contacts/src/main/java/cc/winboll/studio/contacts/utils/PhoneUtils.java new file mode 100644 index 0000000..34641de --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/utils/PhoneUtils.java @@ -0,0 +1,58 @@ +package cc.winboll.studio.contacts.utils; + +import android.Manifest; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import androidx.core.app.ActivityCompat; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/02/26 15:21:48 + * @Describe 拨打电话工具类:封装拨打电话逻辑与权限校验 + */ +public class PhoneUtils { + // ====================== 常量定义区 ====================== + public static final String TAG = "PhoneUtils"; + // 拨打电话 Action 与 Uri 前缀 + private static final String CALL_ACTION = Intent.ACTION_CALL; + private static final String TEL_URI_PREFIX = "tel:"; + + // ====================== 核心工具方法区 ====================== + /** + * 直接拨打电话(需申请 CALL_PHONE 权限) + * @param context 上下文对象 + * @param phoneNumber 目标电话号码 + */ + public static void call(Context context, String phoneNumber) { + // 空值校验:防止上下文或号码为空导致异常 + if (context == null) { + LogUtils.e(TAG, "call: Context 为 null,无法执行拨打电话操作"); + return; + } + if (phoneNumber == null || phoneNumber.trim().isEmpty()) { + LogUtils.e(TAG, "call: 电话号码为空,无法执行拨打电话操作"); + return; + } + String targetPhone = phoneNumber.trim(); + LogUtils.d(TAG, "call: 准备拨打号码 | " + targetPhone); + + // 权限校验:检查是否持有拨打电话权限 + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) { + LogUtils.w(TAG, "call: 缺少 CALL_PHONE 权限,无法直接拨打电话"); + return; + } + + // 构建拨打电话 Intent 并启动 + Intent callIntent = new Intent(CALL_ACTION); + callIntent.setData(Uri.parse(TEL_URI_PREFIX + targetPhone)); + // 添加 FLAG 支持非 Activity 上下文启动 + callIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + context.startActivity(callIntent); + LogUtils.i(TAG, "call: 拨打电话 Intent 已发送 | 号码=" + targetPhone); + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/utils/RegexPPiUtils.java b/contacts/src/main/java/cc/winboll/studio/contacts/utils/RegexPPiUtils.java new file mode 100644 index 0000000..1f91c60 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/utils/RegexPPiUtils.java @@ -0,0 +1,42 @@ +package cc.winboll.studio.contacts.utils; + +import cc.winboll.studio.libappbase.LogUtils; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2024/12/09 19:00:21 + * @Describe 正则前置校验工具类(RegexPPi):检验文本是否满足基础正则匹配要求 + */ +public class RegexPPiUtils { + // ====================== 常量定义区 ====================== + public static final String TAG = "RegexPPiUtils"; + // 基础匹配正则:匹配任意文本(包括空字符串) + private static final String BASE_REGEX = ".*"; + // 预编译正则 Pattern,提升重复调用效率 + private static final Pattern BASE_PATTERN = Pattern.compile(BASE_REGEX); + + // ====================== 核心校验方法区 ====================== + /** + * 检验文本是否满足基础正则表达式模式(.*)匹配要求 + * @param text 待校验的文本内容 + * @return 匹配结果,文本为null时返回false + */ + public static boolean isPPiOK(String text) { + // 空值校验,避免空指针异常 + if (text == null) { + LogUtils.w(TAG, "isPPiOK: 待校验文本为 null,返回 false"); + return false; + } + + // 执行正则匹配 + Matcher matcher = BASE_PATTERN.matcher(text); + boolean isMatch = matcher.matches(); + + // 打印调试日志,记录校验结果 + LogUtils.d(TAG, String.format("isPPiOK: 文本=[%s],匹配结果=%b", text, isMatch)); + return isMatch; + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/views/DuInfoTextView.java b/contacts/src/main/java/cc/winboll/studio/contacts/views/DuInfoTextView.java new file mode 100644 index 0000000..3b4a40d --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/views/DuInfoTextView.java @@ -0,0 +1,117 @@ +package cc.winboll.studio.contacts.views; + +import android.content.Context; +import android.os.Handler; +import android.os.Message; +import android.util.AttributeSet; +import android.widget.TextView; +import cc.winboll.studio.contacts.dun.Rules; +import cc.winboll.studio.contacts.model.SettingsBean; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author ZhanGSKen + * @Date 2025/03/02 21:11:03 + * @Describe 云盾防御信息视图控件:展示云盾防御值统计,并支持消息驱动更新 + */ +public class DuInfoTextView extends TextView { + // ====================== 常量定义区 ====================== + public static final String TAG = "DuInfoTextView"; + public static final int MSG_NOTIFY_INFO_UPDATE = 0; + + // ====================== 成员变量区 ====================== + private Context mContext; + private Handler mHandler; + + // ====================== 构造函数区 ====================== + public DuInfoTextView(Context context) { + super(context); + initView(context); + } + + public DuInfoTextView(Context context, AttributeSet attrs) { + super(context, attrs); + initView(context); + } + + public DuInfoTextView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initView(context); + } + + public DuInfoTextView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + initView(context); + } + + // ====================== 初始化方法区 ====================== + private void initView(Context context) { + LogUtils.d(TAG, "initView: 开始初始化云盾信息控件"); + this.mContext = context; + initHandler(); + updateInfo(); + LogUtils.d(TAG, "initView: 云盾信息控件初始化完成"); + } + + /** + * 初始化 Handler,处理信息更新消息 + */ + private void initHandler() { + mHandler = new Handler() { + @Override + public void handleMessage(Message msg) { + super.handleMessage(msg); + if (msg.what == MSG_NOTIFY_INFO_UPDATE) { + LogUtils.d(TAG, "handleMessage: 收到信息更新消息,开始刷新视图"); + updateInfo(); + } + } + }; + } + + // ====================== 视图更新方法区 ====================== + /** + * 更新云盾防御信息显示 + */ + private void updateInfo() { + LogUtils.d(TAG, "updateInfo: 开始更新云盾防御信息"); + // 空值校验,避免上下文为空导致异常 + if (mContext == null) { + LogUtils.w(TAG, "updateInfo: 上下文为空,跳过信息更新"); + setText("(云盾防御值【--/--】)"); + return; + } + + try { + SettingsBean settingsModel = Rules.getInstance(mContext).getSettingsModel(); + // 校验 SettingsBean 非空,防止空指针 + if (settingsModel == null) { + LogUtils.w(TAG, "updateInfo: SettingsBean 为空,显示默认值"); + setText("(云盾防御值【--/--】)"); + return; + } + + int currentCount = settingsModel.getDunCurrentCount(); + int totalCount = settingsModel.getDunTotalCount(); + String info = String.format("(云盾防御值【%d/%d】)", currentCount, totalCount); + setText(info); + LogUtils.d(TAG, "updateInfo: 云盾防御信息更新完成 | " + info); + } catch (Exception e) { + LogUtils.e(TAG, "updateInfo: 信息更新异常", e); + setText("(云盾防御值【--/--】)"); + } + } + + /** + * 对外提供的信息更新通知方法 + */ + public void notifyInfoUpdate() { + LogUtils.d(TAG, "notifyInfoUpdate: 发送信息更新通知"); + if (mHandler != null) { + mHandler.sendMessage(mHandler.obtainMessage(MSG_NOTIFY_INFO_UPDATE)); + } else { + LogUtils.w(TAG, "notifyInfoUpdate: Handler 未初始化,无法发送更新消息"); + } + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/views/DunTemperatureView.java b/contacts/src/main/java/cc/winboll/studio/contacts/views/DunTemperatureView.java new file mode 100644 index 0000000..7cce563 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/views/DunTemperatureView.java @@ -0,0 +1,393 @@ +package cc.winboll.studio.contacts.views; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.Shader; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.View; +import cc.winboll.studio.libappbase.LogUtils; +import java.util.WeakHashMap; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/03/19 14:04:20 + * @Describe 云盾华氏度热力视图,垂直盾值温度视图控件(带颜色渐变+静态Handler更新) + * 采用绘图方式展示盾值温度,填充色随盾值比例渐变,支持设置文本在温度条左侧/右侧,底部对齐竖排显示 + * 温度条宽度=5dp,文本区宽度固定=5dp,整体左右边距=0,无任何多余空白间距 + */ +public class DunTemperatureView extends View { + // ====================== 常量定义区 ====================== + public static final String TAG = "DunTemperatureView"; + // 控件默认高度 + private static final int DEFAULT_HEIGHT = 200; + // 温度条宽度(5dp)、文本区宽度(固定5dp) + private static final int THERMOMETER_WIDTH_DP = 5; + private static final int TEXT_AREA_WIDTH_DP = 5; + // 填充区域内边距(左右=0,上下=2避免贴边) + private static final int FILL_PADDING_HORIZONTAL = 0; + private static final int FILL_PADDING_VERTICAL = 2; + // 竖排文本字间距 + private static final float TEXT_CHAR_SPACING = 8f; + // Handler消息标识 + public static final int MSG_UPDATE_DUN_VALUE = 0x01; + // 消息参数Key + public static final String KEY_MAX_VALUE = "max_value"; + public static final String KEY_CURRENT_VALUE = "current_value"; + + // ====================== 静态成员区 ====================== + // 弱引用缓存控件实例,避免内存泄漏 + private static WeakHashMap sViewCache = new WeakHashMap<>(); + // 静态Handler,处理跨线程更新消息 + private static Handler sStaticHandler; + + // ====================== 成员变量区 ====================== + // 画笔相关 + private Paint mThermometerPaint; + private Paint mFillPaint; + private Paint mTextPaint; + // 尺寸参数(dp转px后的值) + private int mThermometerWidth; + private int mTextAreaWidth; + private int mMaxValue = 100; // 最高盾值 + private int mCurrentValue = 0; // 当前盾值 + private RectF mThermometerRect; // 温度条矩形区域 + // 渐变颜色配置(低→中→高 对应绿→黄→红) + private int[] mGradientColors = {Color.GREEN, Color.YELLOW, Color.RED}; + private float[] mGradientPositions = {0.0f, 0.5f, 1.0f}; + // 布局配置:true=文本在温度条右侧(默认),false=文本在温度条左侧 + private boolean isTextOnRight = true; + // 其他颜色配置 + private int mBorderColor = Color.parseColor("#FF444444"); + private int mTextColor = Color.parseColor("#FF000000"); + + // ====================== 静态代码块 ====================== + static { + // 初始化静态Handler,绑定主线程Looper + sStaticHandler = new Handler(Looper.getMainLooper()) { + @Override + public void handleMessage(Message msg) { + super.handleMessage(msg); + if (msg.what == MSG_UPDATE_DUN_VALUE) { + // 获取消息中的盾值参数 + int maxValue = msg.getData().getInt(KEY_MAX_VALUE, 100); + int currentValue = msg.getData().getInt(KEY_CURRENT_VALUE, 0); + LogUtils.d(TAG, "sStaticHandler: 收到更新消息,max=" + maxValue + ", current=" + currentValue); + + // 遍历缓存的控件实例,更新所有实例 + for (DunTemperatureView view : sViewCache.keySet()) { + if (view != null && view.isShown()) { + view.setMaxValue(maxValue); + view.setCurrentValue(currentValue); + } + } + } + } + }; + } + + // ====================== 构造函数区 ====================== + public DunTemperatureView(Context context) { + super(context); + init(); + } + + public DunTemperatureView(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public DunTemperatureView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + // ====================== 初始化方法区 ====================== + /** + * 初始化画笔和参数 + */ + private void init() { + LogUtils.d(TAG, "init: 开始初始化云盾温度视图控件"); + // dp转px(适配不同分辨率) + mThermometerWidth = dp2px(getContext(), THERMOMETER_WIDTH_DP); + mTextAreaWidth = dp2px(getContext(), TEXT_AREA_WIDTH_DP); + LogUtils.d(TAG, "init: 温度条宽度5dp转px=" + mThermometerWidth + ",文本区宽度5dp转px=" + mTextAreaWidth); + + // 温度条边框画笔(宽度1px,适配5dp宽度) + mThermometerPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + mThermometerPaint.setColor(mBorderColor); + mThermometerPaint.setStyle(Paint.Style.STROKE); + mThermometerPaint.setStrokeWidth(1); + + // 温度条填充画笔(支持渐变) + mFillPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + mFillPaint.setStyle(Paint.Style.FILL); + + // 文本画笔(适配5dp窄文本区,文字居中绘制) + mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + mTextPaint.setColor(mTextColor); + mTextPaint.setTextSize(18); // 缩小字号适配5dp窄文本区(避免文字超出) + mTextPaint.setTextAlign(Paint.Align.CENTER); + mTextPaint.setFakeBoldText(true); // 文字加粗,提升窄区域可读性 + + // 初始化温度条矩形 + mThermometerRect = new RectF(); + + // 将当前实例加入静态缓存 + sViewCache.put(this, null); + LogUtils.d(TAG, "init: 云盾温度视图控件初始化完成,实例已加入缓存"); + } + + // ====================== 工具方法区 ====================== + /** + * dp 转 px(适配不同屏幕分辨率) + */ + private int dp2px(Context context, float dpValue) { + return (int) TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + dpValue, + context.getResources().getDisplayMetrics() + ); + } + + // ====================== 对外控制方法区 ====================== + /** + * 设置文本相对于温度条的位置 + * @param isOnRight true=文本在温度条右侧(默认),false=文本在温度条左侧 + */ + public void setTextPosition(boolean isOnRight) { + this.isTextOnRight = isOnRight; + invalidate(); // 刷新布局绘制 + } + + /** + * 获取当前文本位置配置 + * @return true=右侧,false=左侧 + */ + public boolean isTextOnRight() { + return isTextOnRight; + } + + // ====================== 对外静态方法区 ====================== + /** + * 静态外部方法:发送消息更新所有 DunTemperatureView 实例的盾值 + * 可在子线程中调用 + * @param maxValue 最高盾值 + * @param currentValue 当前盾值 + */ + public static void updateDunValue(int maxValue, int currentValue) { + if (sStaticHandler == null) { + LogUtils.w(TAG, "updateDunValue: 静态Handler未初始化"); + return; + } + // 封装参数到消息 + Message msg = sStaticHandler.obtainMessage(MSG_UPDATE_DUN_VALUE); + msg.getData().putInt(KEY_MAX_VALUE, maxValue); + msg.getData().putInt(KEY_CURRENT_VALUE, currentValue); + // 发送消息 + sStaticHandler.sendMessage(msg); + } + + // ====================== 对外实例方法区 ====================== + /** + * 设置最高盾值 + * @param maxValue 最高盾值(需大于0) + */ + public void setMaxValue(int maxValue) { + if (maxValue <= 0) { + LogUtils.w(TAG, "setMaxValue: 最高盾值必须大于0,当前值=" + maxValue); + return; + } + this.mMaxValue = maxValue; + // 限制当前值不超过最大值 + mCurrentValue = Math.min(mCurrentValue, maxValue); + invalidate(); // 重绘控件 + } + + /** + * 设置当前盾值 + * @param currentValue 当前盾值(范围 0~maxValue) + */ + public void setCurrentValue(int currentValue) { + int oldValue = this.mCurrentValue; + this.mCurrentValue = Math.max(0, Math.min(currentValue, mMaxValue)); + if (oldValue != this.mCurrentValue) { + LogUtils.d(TAG, "setCurrentValue: 当前盾值从" + oldValue + "更新为" + mCurrentValue); + invalidate(); // 重绘控件 + } + } + + /** + * 获取当前盾值 + */ + public int getCurrentValue() { + return mCurrentValue; + } + + /** + * 获取最高盾值 + */ + public int getMaxValue() { + return mMaxValue; + } + + /** + * 设置自定义渐变颜色 + * @param colors 渐变颜色数组(至少2种颜色) + * @param positions 颜色位置数组(与colors长度一致,0.0~1.0) + */ + public void setGradientColors(int[] colors, float[] positions) { + if (colors == null || colors.length < 2 || positions == null || positions.length != colors.length) { + LogUtils.w(TAG, "setGradientColors: 渐变颜色参数不合法,颜色数组长度=" + (colors == null ? "null" : colors.length)); + return; + } + this.mGradientColors = colors; + this.mGradientPositions = positions; + LogUtils.d(TAG, "setGradientColors: 自定义渐变颜色已设置"); + invalidate(); + } + + /** + * 设置温度条边框颜色 + */ + public void setBorderColor(int color) { + this.mBorderColor = color; + mThermometerPaint.setColor(color); + LogUtils.d(TAG, "setBorderColor: 边框颜色已更新"); + invalidate(); + } + + /** + * 设置文本颜色 + */ + public void setTextColor(int color) { + this.mTextColor = color; + mTextPaint.setColor(color); + LogUtils.d(TAG, "setTextColor: 文本颜色已更新"); + invalidate(); + } + + // ====================== 生命周期方法 ====================== + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + // 控件从窗口移除时,从缓存中清除,避免内存泄漏 + sViewCache.remove(this); + LogUtils.d(TAG, "onDetachedFromWindow: 控件实例已从缓存移除"); + } + + // ====================== 测量与绘制区 ====================== + /** + * 测量辅助函数 + */ + private int measureSize(int defaultSize, int measureSpec) { + int result = defaultSize; + int specMode = MeasureSpec.getMode(measureSpec); + int specSize = MeasureSpec.getSize(measureSpec); + if (specMode == MeasureSpec.EXACTLY) { + result = specSize; + } else if (specMode == MeasureSpec.AT_MOST) { + result = Math.min(defaultSize, specSize); + } + return result; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + // 强制控件整体左右内边距=0,彻底消除外部边距 + setPadding(0, getPaddingTop(), 0, getPaddingBottom()); + + // 控件宽度=温度条宽度 + 文本区宽度(均为5dp转px,无额外空白) + int defaultWidth = mThermometerWidth + mTextAreaWidth; + int width = measureSize(defaultWidth, widthMeasureSpec); + int height = measureSize(DEFAULT_HEIGHT, heightMeasureSpec); + setMeasuredDimension(width, height); + + // 根据文本位置配置,计算温度条矩形坐标 + float thermometerLeft, thermometerRight; + if (isTextOnRight) { + // 文本在右侧:温度条靠左,右接文本区 + thermometerLeft = 0; + thermometerRight = thermometerLeft + mThermometerWidth; + } else { + // 文本在左侧:温度条靠右,左接文本区 + thermometerLeft = width - mThermometerWidth; + thermometerRight = width; + } + float thermometerTop = getPaddingTop(); + float thermometerBottom = height - getPaddingBottom(); + mThermometerRect.set(thermometerLeft, thermometerTop, thermometerRight, thermometerBottom); + + LogUtils.v(TAG, "onMeasure: 文本位置=" + (isTextOnRight ? "右侧" : "左侧") + ",控件尺寸=" + width + "x" + height + ",温度条区域=" + mThermometerRect.toShortString()); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + // 1. 绘制温度条边框(5dp宽度,小圆角适配) + canvas.drawRoundRect(mThermometerRect, 3, 3, mThermometerPaint); + + // 2. 计算填充高度(根据当前值占最大值的比例) + float fillRatio = (float) mCurrentValue / mMaxValue; + float fillHeight = mThermometerRect.height() * fillRatio; + float fillTop = mThermometerRect.bottom - fillHeight; + + // 3. 绘制渐变填充部分(左右贴边框,无间距) + if (fillHeight > 0) { + RectF fillRect = new RectF( + mThermometerRect.left + FILL_PADDING_HORIZONTAL, + fillTop + FILL_PADDING_VERTICAL, + mThermometerRect.right - FILL_PADDING_HORIZONTAL, + mThermometerRect.bottom - FILL_PADDING_VERTICAL + ); + LinearGradient gradient = new LinearGradient( + fillRect.centerX(), fillRect.bottom, + fillRect.centerX(), fillRect.top, + mGradientColors, + mGradientPositions, + Shader.TileMode.CLAMP + ); + mFillPaint.setShader(gradient); + canvas.drawRoundRect(fillRect, 2, 2, mFillPaint); + mFillPaint.setShader(null); + } + + // 4. 绘制文本(5dp固定宽度文本区,底部对齐竖排,文字居中) + String text = String.format("%d/%d", mCurrentValue, mMaxValue); + if (text.isEmpty()) return; + + float textBaseX; + if (isTextOnRight) { + // 文本在右侧:X=温度条右边缘 + 文本区宽度的一半(紧贴温度条) + textBaseX = mThermometerRect.right + (mTextAreaWidth / 2f); + } else { + // 文本在左侧:X=文本区宽度的一半(紧贴控件左边缘) + textBaseX = mTextAreaWidth / 2f; + } + + // 文本绘制参数(适配5dp窄区域,缩小字间距提升紧凑度) + float singleCharHeight = mTextPaint.getTextSize() + (TEXT_CHAR_SPACING - 2f); // 字间距减为6f + float totalTextHeight = (singleCharHeight * text.length()) - (TEXT_CHAR_SPACING - 2f); + Paint.FontMetrics fontMetrics = mTextPaint.getFontMetrics(); + float charBottomOffset = fontMetrics.bottom; + + // 文本起始Y(底部对齐控件底部,无间距) + float startTextY = getHeight() - getPaddingBottom() - charBottomOffset; + + // 逐字竖排绘制(文字居中于5dp文本区,无超出) + for (int i = 0; i < text.length(); i++) { + char singleChar = text.charAt(i); + float currentTextY = startTextY - (i * singleCharHeight); + canvas.drawText(String.valueOf(singleChar), textBaseX, currentTextY, mTextPaint); + } + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/views/LeftScrollView.java b/contacts/src/main/java/cc/winboll/studio/contacts/views/LeftScrollView.java new file mode 100644 index 0000000..0cbc2b1 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/views/LeftScrollView.java @@ -0,0 +1,306 @@ +package cc.winboll.studio.contacts.views; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.widget.Button; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; +import android.widget.TextView; +import cc.winboll.studio.contacts.R; +import cc.winboll.studio.libappbase.LogUtils; + +/** + * @Author ZhanGSKen&豆包大模型 + * @Date 2025/03/04 10:51:50 + * @Describe 左滑显示操作按钮的自定义滚动视图,支持编辑、删除、上移、下移功能 + */ +public class LeftScrollView extends HorizontalScrollView { + // ====================== 常量定义区 ====================== + public static final String TAG = "LeftScrollView"; + + // ====================== 成员变量区 ====================== + // 布局控件 + private LinearLayout contentLayout; + private LinearLayout toolLayout; + private TextView textView; + private Button editButton; + private Button deleteButton; + private Button upButton; + private Button downButton; + // 滑动事件相关 + private float mStartX; + private float mEndX; + private boolean isScrolling = false; + private int nScrollAcceptSize; + // 回调接口 + private OnActionListener onActionListener; + + // ====================== 构造函数区 ====================== + public LeftScrollView(Context context) { + super(context); + init(); + } + + public LeftScrollView(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public LeftScrollView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + // ====================== 初始化方法区 ====================== + private void init() { + LogUtils.d(TAG, "init: 开始初始化左滑滚动视图"); + // 加载布局 + View viewMain = inflate(getContext(), R.layout.view_left_scroll, null); + if (viewMain == null) { + LogUtils.e(TAG, "init: 布局加载失败,无法初始化控件"); + return; + } + + // 绑定布局控件 + contentLayout = viewMain.findViewById(R.id.content_layout); + toolLayout = viewMain.findViewById(R.id.action_layout); + editButton = viewMain.findViewById(R.id.edit_btn); + deleteButton = viewMain.findViewById(R.id.delete_btn); + upButton = viewMain.findViewById(R.id.up_btn); + downButton = viewMain.findViewById(R.id.down_btn); + + // 校验控件是否绑定成功 + if (contentLayout == null || toolLayout == null) { + LogUtils.e(TAG, "init: 核心布局控件绑定失败"); + return; + } + + // 添加主布局到当前视图 + addView(viewMain); + // 设置按钮点击事件 + setButtonClickListener(); + + LogUtils.d(TAG, "init: 左滑滚动视图初始化完成"); + } + + /** + * 设置操作按钮的点击事件 + */ + private void setButtonClickListener() { + // 编辑按钮 + if (editButton != null) { + editButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "onClick: 点击编辑按钮"); + if (onActionListener != null) { + onActionListener.onEdit(); + } + } + }); + } + + // 删除按钮 + if (deleteButton != null) { + deleteButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "onClick: 点击删除按钮"); + if (onActionListener != null) { + onActionListener.onDelete(); + } + } + }); + } + + // 上移按钮 + if (upButton != null) { + upButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "onClick: 点击上移按钮"); + if (onActionListener != null) { + onActionListener.onUp(); + } + } + }); + } + + // 下移按钮 + if (downButton != null) { + downButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + LogUtils.d(TAG, "onClick: 点击下移按钮"); + if (onActionListener != null) { + onActionListener.onDown(); + } + } + }); + } + } + + // ====================== 对外提供的方法区 ====================== + /** + * 添加内容视图到容器 + * @param viewContent 待添加的内容视图 + */ + public void addContentLayout(View viewContent) { + if (contentLayout == null) { + LogUtils.w(TAG, "addContentLayout: 内容布局未初始化,无法添加视图"); + return; + } + if (viewContent == null) { + LogUtils.w(TAG, "addContentLayout: 待添加视图为null"); + return; + } + contentLayout.addView(viewContent, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT); + LogUtils.d(TAG, "addContentLayout: 内容视图添加成功"); + } + + /** + * 设置内容布局的宽度 + * @param contentWidth 目标宽度 + */ + public void setContentWidth(int contentWidth) { + if (contentLayout == null) { + LogUtils.w(TAG, "setContentWidth: 内容布局未初始化,无法设置宽度"); + return; + } + LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) contentLayout.getLayoutParams(); + layoutParams.width = contentWidth; + contentLayout.setLayoutParams(layoutParams); + LogUtils.d(TAG, "setContentWidth: 内容布局宽度设置为 " + contentWidth); + } + + /** + * 设置文本内容(原代码未初始化textView,添加空校验) + * @param text 待显示的文本 + */ + public void setText(CharSequence text) { + if (textView == null) { + LogUtils.w(TAG, "setText: 文本控件未初始化,无法设置文本"); + return; + } + textView.setText(text); + LogUtils.d(TAG, "setText: 文本设置为 " + text); + } + + /** + * 设置事件回调监听器 + * @param listener 回调接口实例 + */ + public void setOnActionListener(OnActionListener listener) { + this.onActionListener = listener; + LogUtils.d(TAG, "setOnActionListener: 事件监听器已设置"); + } + + // ====================== 滑动事件处理区 ====================== + @Override + public boolean onTouchEvent(MotionEvent event) { + if (event == null) { + return super.onTouchEvent(event); + } + + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + mStartX = event.getX(); + LogUtils.d(TAG, "onTouchEvent: ACTION_DOWN,起始X坐标 = " + mStartX); + break; + case MotionEvent.ACTION_MOVE: + // 可根据需求添加滑动中逻辑 + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + mEndX = event.getX(); + int scrollX = getScrollX(); + LogUtils.d(TAG, String.format("onTouchEvent: ACTION_UP/CANCEL,起始X=%f 结束X=%f 滚动距离=%d", + mStartX, mEndX, scrollX)); + + if (scrollX > 0) { + handleScrollLogic(); + } + break; + } + return super.onTouchEvent(event); + } + + /** + * 处理滑动结束后的逻辑,判断滑动方向并执行滚动 + */ + private void handleScrollLogic() { + float deltaX = Math.abs(mStartX - mEndX); + // 校验按钮是否存在,避免空指针 + float threshold = editButton != null ? editButton.getWidth() : 50; + + if (mEndX < mStartX) { + // 向左滑,显示操作按钮 + if (deltaX > threshold) { + smoothScrollToRight(); + } else { + smoothScrollToLeft(); + } + } else { + // 向右滑,隐藏操作按钮 + if (deltaX > threshold) { + smoothScrollToLeft(); + } else { + smoothScrollToRight(); + } + } + } + + /** + * 平滑滚动到右侧(显示操作按钮) + */ + private void smoothScrollToRight() { + post(new Runnable() { + @Override + public void run() { + View childView = getChildAt(0); + if (childView != null) { + int scrollToX = childView.getWidth() - getWidth(); + int targetX = Math.max(0, scrollToX); + smoothScrollTo(targetX, 0); + LogUtils.d(TAG, "smoothScrollToRight: 滚动到右侧,目标X坐标 = " + targetX); + } + } + }); + // 重置坐标 + resetScrollCoordinate(); + } + + /** + * 平滑滚动到左侧(隐藏操作按钮) + */ + private void smoothScrollToLeft() { + post(new Runnable() { + @Override + public void run() { + smoothScrollTo(0, 0); + LogUtils.d(TAG, "smoothScrollToLeft: 滚动到左侧"); + } + }); + // 重置坐标 + resetScrollCoordinate(); + } + + /** + * 重置滑动坐标 + */ + private void resetScrollCoordinate() { + mStartX = 0; + mEndX = 0; + } + + // ====================== 回调接口定义区 ====================== + public interface OnActionListener { + void onEdit(); + void onDelete(); + void onUp(); + void onDown(); + } +} + diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/widgets/APPStatusWidget.java b/contacts/src/main/java/cc/winboll/studio/contacts/widgets/APPStatusWidget.java new file mode 100644 index 0000000..edd6b26 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/widgets/APPStatusWidget.java @@ -0,0 +1,75 @@ +package cc.winboll.studio.contacts.widgets; + +/** + * @Author ZhanGSKen + * @Date 2025/02/17 14:49:31 + * @Describe APPStatusWidget + */ +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.appwidget.AppWidgetProvider; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.widget.RemoteViews; +import cc.winboll.studio.contacts.R; +import cc.winboll.studio.libappbase.LogUtils; +import cc.winboll.studio.libappbase.ToastUtils; + +public class APPStatusWidget extends AppWidgetProvider { + + public static final String TAG = "APPSOSReportWidget"; + + public static final String ACTION_STATUS_ACTIVE = "cc.winboll.studio.contacts.widgets.APPStatusWidget.ACTION_STATUS_ACTIVE"; + public static final String ACTION_STATUS_NOACTIVE = "cc.winboll.studio.contacts.widgets.APPStatusWidget.ACTION_STATUS_NOACTIVE"; + + @Override + public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { + for (int appWidgetId : appWidgetIds) { + updateAppWidget(context, appWidgetManager, appWidgetId); + } + } + + @Override + public void onReceive(Context context, Intent intent) { + super.onReceive(context, intent); + if (intent.getAction().equals(ACTION_STATUS_ACTIVE)) { + AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context); + int[] appWidgetIds = appWidgetManager.getAppWidgetIds(new ComponentName(context, APPStatusWidget.class)); + for (int appWidgetId : appWidgetIds) { + updateAppWidget(context, appWidgetManager, appWidgetId); + } + } else if (intent.getAction().equals(ACTION_STATUS_NOACTIVE)) { + AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context); + int[] appWidgetIds = appWidgetManager.getAppWidgetIds(new ComponentName(context, APPStatusWidget.class)); + for (int appWidgetId : appWidgetIds) { + updateAppWidget(context, appWidgetManager, appWidgetId); + } + } + + } + + private void updateAppWidget(Context context, AppWidgetManager appWidgetManager, int appWidgetId) { + LogUtils.d(TAG, "updateAppWidget(...)"); + + RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_layout); + //设置按钮点击事件 + Intent intentAppButton = new Intent(context, APPStatusWidgetClickListener.class); + intentAppButton.setAction(APPStatusWidgetClickListener.ACTION_APPICON_CLICK); + PendingIntent pendingIntentAppButton = PendingIntent.getBroadcast(context, 0, intentAppButton, PendingIntent.FLAG_UPDATE_CURRENT); + views.setOnClickPendingIntent(R.id.widgetlayoutImageView1, pendingIntentAppButton); + +// boolean isActive = !MainServiceThread.isExist(); +// if (isActive) { +// views.setImageViewResource(R.id.widgetlayoutImageView1, R.drawable.ic_launcher); +// } else { +// views.setImageViewResource(R.id.widgetlayoutImageView1, R.drawable.ic_launcher_disable); +// +// } + appWidgetManager.updateAppWidget(appWidgetId, views); + } + + public static void onAPPStatusWidgetClick(Context context) { + ToastUtils.show("onAPPStatusWidgetClick"); + } +} diff --git a/contacts/src/main/java/cc/winboll/studio/contacts/widgets/APPStatusWidgetClickListener.java b/contacts/src/main/java/cc/winboll/studio/contacts/widgets/APPStatusWidgetClickListener.java new file mode 100644 index 0000000..3525067 --- /dev/null +++ b/contacts/src/main/java/cc/winboll/studio/contacts/widgets/APPStatusWidgetClickListener.java @@ -0,0 +1,32 @@ +package cc.winboll.studio.contacts.widgets; + +/** + * @Author ZhanGSKen + * @Date 2025/02/17 14:59:55 + * @Describe WidgetButtonClickListener + */ +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.widget.Toast; +import cc.winboll.studio.libappbase.LogUtils; + +public class APPStatusWidgetClickListener extends BroadcastReceiver { + + public static final String TAG = "APPStatusWidgetClickListener"; + + public static final String ACTION_APPICON_CLICK = "cc.winboll.studio.contacts.widgets.APPStatusWidgetClickListener.ACTION_APPICON_CLICK"; + + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (action == null) { + LogUtils.d(TAG, String.format("action %s", action)); + return; + } + if (action.equals(ACTION_APPICON_CLICK)) { + LogUtils.d(TAG, "ACTION_APPICON_CLICK"); + Toast.makeText(context, "ACTION_APPICON_CLICK", Toast.LENGTH_SHORT).show(); + } + } +} diff --git a/contacts/src/main/res/drawable/ic_call.xml b/contacts/src/main/res/drawable/ic_call.xml new file mode 100644 index 0000000..c5802bb --- /dev/null +++ b/contacts/src/main/res/drawable/ic_call.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/contacts/src/main/res/drawable/ic_launcher.xml b/contacts/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..d4d1eaf --- /dev/null +++ b/contacts/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/contacts/src/main/res/drawable/ic_launcher_background.xml b/contacts/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..9486190 --- /dev/null +++ b/contacts/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/contacts/src/main/res/drawable/ic_launcher_disable.xml b/contacts/src/main/res/drawable/ic_launcher_disable.xml new file mode 100644 index 0000000..9a31905 --- /dev/null +++ b/contacts/src/main/res/drawable/ic_launcher_disable.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/contacts/src/main/res/drawable/ic_launcher_foreground.xml b/contacts/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..872b04e --- /dev/null +++ b/contacts/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,10 @@ + + + + diff --git a/contacts/src/main/res/drawable/ic_launcher_foreground_disable.xml b/contacts/src/main/res/drawable/ic_launcher_foreground_disable.xml new file mode 100644 index 0000000..763b72c --- /dev/null +++ b/contacts/src/main/res/drawable/ic_launcher_foreground_disable.xml @@ -0,0 +1,10 @@ + + + + diff --git a/contacts/src/main/res/drawable/ic_phone_call_in.xml b/contacts/src/main/res/drawable/ic_phone_call_in.xml new file mode 100644 index 0000000..792377d --- /dev/null +++ b/contacts/src/main/res/drawable/ic_phone_call_in.xml @@ -0,0 +1,9 @@ + + + diff --git a/contacts/src/main/res/drawable/ic_phone_call_out.xml b/contacts/src/main/res/drawable/ic_phone_call_out.xml new file mode 100644 index 0000000..833d25a --- /dev/null +++ b/contacts/src/main/res/drawable/ic_phone_call_out.xml @@ -0,0 +1,9 @@ + + + diff --git a/contacts/src/main/res/drawable/ic_phone_hang_up.xml b/contacts/src/main/res/drawable/ic_phone_hang_up.xml new file mode 100644 index 0000000..98b57b2 --- /dev/null +++ b/contacts/src/main/res/drawable/ic_phone_hang_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/contacts/src/main/res/drawable/ic_phone_pick_up.xml b/contacts/src/main/res/drawable/ic_phone_pick_up.xml new file mode 100644 index 0000000..23080f1 --- /dev/null +++ b/contacts/src/main/res/drawable/ic_phone_pick_up.xml @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/contacts/src/main/res/drawable/recycler_view_border.xml b/contacts/src/main/res/drawable/recycler_view_border.xml new file mode 100644 index 0000000..d538cab --- /dev/null +++ b/contacts/src/main/res/drawable/recycler_view_border.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/contacts/src/main/res/drawable/shape_gradient.xml b/contacts/src/main/res/drawable/shape_gradient.xml new file mode 100644 index 0000000..c164fe9 --- /dev/null +++ b/contacts/src/main/res/drawable/shape_gradient.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/contacts/src/main/res/layout/activity_about.xml b/contacts/src/main/res/layout/activity_about.xml new file mode 100644 index 0000000..425769a --- /dev/null +++ b/contacts/src/main/res/layout/activity_about.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/contacts/src/main/res/layout/activity_call.xml b/contacts/src/main/res/layout/activity_call.xml new file mode 100644 index 0000000..460b416 --- /dev/null +++ b/contacts/src/main/res/layout/activity_call.xml @@ -0,0 +1,28 @@ + + + + + +