Compare commits

...

6 Commits

10 changed files with 735 additions and 62 deletions

View File

@@ -1,8 +1,8 @@
#Created by .winboll/winboll_app_build.gradle #Created by .winboll/winboll_app_build.gradle
#Thu Apr 09 01:41:37 HKT 2026 #Sat Apr 18 16:47:18 HKT 2026
stageCount=8 stageCount=11
libraryProject= libraryProject=
baseVersion=15.14 baseVersion=15.14
publishVersion=15.14.7 publishVersion=15.14.10
buildCount=0 buildCount=0
baseBetaVersion=15.14.8 baseBetaVersion=15.14.11

View File

@@ -9,54 +9,73 @@
<!-- 拨打电话 --> <!-- 拨打电话 -->
<uses-permission android:name="android.permission.CALL_PHONE"/> <uses-permission android:name="android.permission.CALL_PHONE"/>
<!-- 读取手机状态和身份API 30+ 需细化权限) --> <!-- 读取手机状态和身份 -->
<uses-permission android:name="android.permission.READ_PHONE_STATE"/> <uses-permission android:name="android.permission.READ_PHONE_STATE"/>
<!-- 读取电话号码 -->
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS"/> <uses-permission android:name="android.permission.READ_PHONE_NUMBERS"/>
<!-- 修改系统设置(移除无效的 protectionLevel 声明,该属性由系统定义) --> <!-- 修改系统设置 -->
<uses-permission android:name="android.permission.WRITE_SETTINGS"/> <uses-permission android:name="android.permission.WRITE_SETTINGS"/>
<!-- 联系人权限(适配 Android 13+ 细分权限) --> <!-- 读取联系人 -->
<uses-permission android:name="android.permission.READ_CONTACTS"/> <uses-permission android:name="android.permission.READ_CONTACTS"/>
<!-- 修改您的通讯录 -->
<uses-permission android:name="android.permission.WRITE_CONTACTS"/> <uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<!-- GET_CONTACTS -->
<uses-permission android:name="android.permission.GET_CONTACTS"/> <uses-permission android:name="android.permission.GET_CONTACTS"/>
<!-- 悬浮窗权限(需动态申请) --> <!-- 此应用可显示在其他应用上方 -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<!-- 更改音频设置 --> <!-- 更改您的音频设置 -->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<!-- 通话记录权限(适配 Android 13+ 细分权限) --> <!-- 读取通话记录 -->
<uses-permission android:name="android.permission.READ_CALL_LOG"/> <uses-permission android:name="android.permission.READ_CALL_LOG"/>
<!-- 新建/修改/删除通话记录 -->
<uses-permission android:name="android.permission.WRITE_CALL_LOG"/> <uses-permission android:name="android.permission.WRITE_CALL_LOG"/>
<!-- GET_CALL_LOG -->
<uses-permission android:name="android.permission.GET_CALL_LOG"/> <uses-permission android:name="android.permission.GET_CALL_LOG"/>
<!-- 录音权限 --> <!-- 录音 -->
<uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/>
<!-- 前台服务权限(按业务类型声明) --> <!-- 运行前台服务 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<!-- 运行“dataSync”类型的前台服务 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<!-- 运行“phoneCall”类型的前台服务 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL"/>
<!-- 运行“microphone”类型的前台服务 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
<!-- API 30+ 通话筛选服务权限(替代 PROCESS_OUTGOING_CALLS --> <!-- BIND_CALL_SCREENING_SERVICE -->
<uses-permission android:name="android.permission.BIND_CALL_SCREENING_SERVICE"/> <uses-permission android:name="android.permission.BIND_CALL_SCREENING_SERVICE"/>
<!-- 读取您共享存储空间中的内容 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<!-- 修改或删除您共享存储空间中的内容 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<!-- MANAGE_EXTERNAL_STORAGE -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<application <application
android:name=".App" android:name=".App"
android:allowBackup="true" android:allowBackup="true"
android:icon="@drawable/ic_winboll" android:icon="@drawable/ic_winboll"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/MyAppTheme" android:theme="@style/MyAppTheme"
android:requestLegacyExternalStorage="true" android:requestLegacyExternalStorage="true"
android:supportsRtl="true" android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"> android:networkSecurityConfig="@xml/network_security_config">
@@ -66,8 +85,11 @@
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
@@ -77,6 +99,7 @@
android:label="CallActivity" android:label="CallActivity"
android:launchMode="singleTask" android:launchMode="singleTask"
android:exported="true"> android:exported="true">
</activity> </activity>
<activity <activity
@@ -85,53 +108,60 @@
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.DIAL"/> <action android:name="android.intent.action.DIAL"/>
<action android:name="android.intent.action.VIEW"/> <action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/> <category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="tel"/> <data android:scheme="tel"/>
</intent-filter> </intent-filter>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.DIAL"/> <action android:name="android.intent.action.DIAL"/>
<category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.DEFAULT"/>
</intent-filter> </intent-filter>
</activity> </activity>
<activity android:name="cc.winboll.studio.contacts.activities.SettingsActivity"/> <activity android:name="cc.winboll.studio.contacts.activities.SettingsActivity"/>
<!-- 主服务:仅 dataSync 类型(与代码中 0x00000008 匹配) -->
<service <service
android:name=".services.MainService" android:name=".services.MainService"
android:foregroundServiceType="dataSync" android:foregroundServiceType="dataSync"
android:exported="false" android:exported="false"
android:stopWithTask="false"/> android:stopWithTask="false"/>
<!-- 辅助服务dataSync 类型 -->
<service <service
android:name=".services.AssistantService" android:name=".services.AssistantService"
android:exported="false" android:exported="false"
android:stopWithTask="false"/> android:stopWithTask="false"/>
<!-- 通话UI服务系统绑定 -->
<service <service
android:name=".phonecallui.PhoneCallService" android:name=".phonecallui.PhoneCallService"
android:permission="android.permission.BIND_INCALL_SERVICE" android:permission="android.permission.BIND_INCALL_SERVICE"
android:exported="false" android:exported="false"
android:stopWithTask="false"> android:stopWithTask="false">
<meta-data <meta-data
android:name="android.telecom.IN_CALL_SERVICE_UI" android:name="android.telecom.IN_CALL_SERVICE_UI"
android:value="true"/> android:value="true"/>
<intent-filter> <intent-filter>
<action android:name="android.telecom.InCallService"/> <action android:name="android.telecom.InCallService"/>
</intent-filter> </intent-filter>
</service> </service>
<!-- 通话监听服务phoneCall 类型(与代码中 0x00000020 匹配) -->
<service <service
android:name=".listenphonecall.CallListenerService" android:name=".listenphonecall.CallListenerService"
android:enabled="true" android:enabled="true"
@@ -139,37 +169,52 @@
android:stopWithTask="false"> android:stopWithTask="false">
<intent-filter android:priority="1000"> <intent-filter android:priority="1000">
<action android:name=".service.CallShowService"/> <action android:name=".service.CallShowService"/>
</intent-filter> </intent-filter>
</service> </service>
<!-- API 30+ 通话筛选服务(替代 PROCESS_OUTGOING_CALLS 权限) --> <service
<service android:name=".services.MyCallScreeningService"
android:name=".services.MyCallScreeningService" android:permission="android.permission.BIND_CALL_SCREENING_SERVICE"
android:permission="android.permission.BIND_CALL_SCREENING_SERVICE"
android:exported="true" android:exported="true"
android:stopWithTask="false"> android:stopWithTask="false">
<intent-filter> <intent-filter>
<action android:name="android.telecom.CallScreeningService"/> <action android:name="android.telecom.CallScreeningService"/>
</intent-filter> </intent-filter>
</service> </service>
<receiver android:name=".receivers.MainReceiver" <receiver
android:stopWithTask="false"> android:name=".receivers.MainReceiver"
android:stopWithTask="false">
<intent-filter> <intent-filter>
<action android:name="cc.winboll.studio.contacts.receivers.MainReceiver"/> <action android:name="cc.winboll.studio.contacts.receivers.MainReceiver"/>
</intent-filter> </intent-filter>
</receiver> </receiver>
<receiver <receiver
android:name=".widgets.APPStatusWidget" android:name=".widgets.APPStatusWidget"
android:exported="true" android:exported="true"
android:stopWithTask="false"> android:stopWithTask="false">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="cc.winboll.studio.contacts.widgets.APPStatusWidget.ACTION_STATUS_ACTIVE"/> <action android:name="cc.winboll.studio.contacts.widgets.APPStatusWidget.ACTION_STATUS_ACTIVE"/>
<action android:name="cc.winboll.studio.contacts.widgets.APPStatusWidget.ACTION_STATUS_NOACTIVE"/> <action android:name="cc.winboll.studio.contacts.widgets.APPStatusWidget.ACTION_STATUS_NOACTIVE"/>
</intent-filter> </intent-filter>
<meta-data <meta-data
@@ -178,11 +223,16 @@
</receiver> </receiver>
<receiver android:name=".widgets.APPStatusWidgetClickListener" <receiver
android:stopWithTask="false"> android:name=".widgets.APPStatusWidgetClickListener"
android:stopWithTask="false">
<intent-filter> <intent-filter>
<action android:name="cc.winboll.studio.contacts.widgets.APPStatusWidgetClickListener.ACTION_APPICON_CLICK"/> <action android:name="cc.winboll.studio.contacts.widgets.APPStatusWidgetClickListener.ACTION_APPICON_CLICK"/>
</intent-filter> </intent-filter>
</receiver> </receiver>
<provider <provider
@@ -201,7 +251,8 @@
<activity android:name="cc.winboll.studio.contacts.activities.AboutActivity"/> <activity android:name="cc.winboll.studio.contacts.activities.AboutActivity"/>
<service android:name="cc.winboll.studio.contacts.services.LimitedTimeSpecialChannelService"/>
</application> </application>
</manifest> </manifest>

View File

@@ -5,7 +5,9 @@ import android.view.View;
import android.widget.EditText; import android.widget.EditText;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import cc.winboll.studio.contacts.R; import cc.winboll.studio.contacts.R;
import cc.winboll.studio.contacts.activities.UnitTestActivity;
import cc.winboll.studio.contacts.dun.Rules; import cc.winboll.studio.contacts.dun.Rules;
import cc.winboll.studio.contacts.services.LimitedTimeSpecialChannelService;
import cc.winboll.studio.contacts.utils.IntUtils; import cc.winboll.studio.contacts.utils.IntUtils;
import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity; import cc.winboll.studio.libaes.interfaces.IWinBoLLActivity;
import cc.winboll.studio.libappbase.LogUtils; import cc.winboll.studio.libappbase.LogUtils;
@@ -116,6 +118,13 @@ public class UnitTestActivity extends WinBollActivity implements IWinBoLLActivit
LogUtils.d(TAG, String.format("onTestMain: 测试号码: %s | 匹配结果: %s", phone, isAllowed)); LogUtils.d(TAG, String.format("onTestMain: 测试号码: %s | 匹配结果: %s", phone, isAllowed));
} }
LogUtils.d(TAG, "onTestMain: 批量号码规则测试完成"); LogUtils.d(TAG, "onTestMain: 批量号码规则测试完成");
new Thread(new Runnable(){
@Override
public void run() {
LimitedTimeSpecialChannelService.unitTest(UnitTestActivity.this);
}
}).start();
} }
// ====================== 私有工具函数区 ====================== // ====================== 私有工具函数区 ======================

View File

@@ -22,6 +22,7 @@ import cc.winboll.studio.libappbase.ToastUtils;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import cc.winboll.studio.contacts.dun.Rules;
/** /**
* @Author ZhanGSKen&豆包大模型<zhangsken@qq.com> * @Author ZhanGSKen&豆包大模型<zhangsken@qq.com>
@@ -119,6 +120,14 @@ public class CallLogAdapter extends RecyclerView.Adapter<CallLogAdapter.CallLogV
clipboard.setPrimaryClip(clip); clipboard.setPrimaryClip(clip);
Toast.makeText(mContext, "Copy to clipboard.", Toast.LENGTH_SHORT).show(); Toast.makeText(mContext, "Copy to clipboard.", Toast.LENGTH_SHORT).show();
LogUtils.d(TAG, "showPhonePopupMenu: 号码" + callLog.getPhoneNumber() + "已复制到剪贴板"); LogUtils.d(TAG, "showPhonePopupMenu: 号码" + callLog.getPhoneNumber() + "已复制到剪贴板");
} else if (itemId == R.id.item_calllog_phonenumber_yundun_test) {
// 跳转到添加联系人页面
//if (Rules.getInstance(mContext).isAllowed(callLog.getPhoneNumber(), false)) {
if (Rules.getInstance(mContext).isAllowed(callLog.getPhoneNumber(), true)) {
ToastUtils.show("(✔)" + callLog.getPhoneNumber() + " Is Allowed By YunDun.");
} else {
ToastUtils.show("(✘)YunDun Defense The Phone " + callLog.getPhoneNumber() + "");
}
} else if (itemId == R.id.item_calllog_phonenumber_add_contact) { } else if (itemId == R.id.item_calllog_phonenumber_add_contact) {
// 跳转到添加联系人页面 // 跳转到添加联系人页面
ContactUtils.jumpToAddContact(mContext, callLog.getPhoneNumber()); ContactUtils.jumpToAddContact(mContext, callLog.getPhoneNumber());

View File

@@ -5,6 +5,7 @@ import cc.winboll.studio.contacts.activities.SettingsActivity;
import cc.winboll.studio.contacts.bobulltoon.TomCat; import cc.winboll.studio.contacts.bobulltoon.TomCat;
import cc.winboll.studio.contacts.model.PhoneConnectRuleBean; import cc.winboll.studio.contacts.model.PhoneConnectRuleBean;
import cc.winboll.studio.contacts.model.SettingsBean; import cc.winboll.studio.contacts.model.SettingsBean;
import cc.winboll.studio.contacts.services.LimitedTimeSpecialChannelService;
import cc.winboll.studio.contacts.services.MainService; import cc.winboll.studio.contacts.services.MainService;
import cc.winboll.studio.contacts.utils.ContactUtils; import cc.winboll.studio.contacts.utils.ContactUtils;
import cc.winboll.studio.contacts.utils.IntUtils; import cc.winboll.studio.contacts.utils.IntUtils;
@@ -91,7 +92,7 @@ public class Rules {
saveDun(); saveDun();
// 一键更新所有 DunTemperatureView 实例的盾值 // 一键更新所有 DunTemperatureView 实例的盾值
DunTemperatureView.updateDunValue(mSettingsModel.getDunTotalCount(), mSettingsModel.getDunCurrentCount()); DunTemperatureView.updateDunValue(mSettingsModel.getDunTotalCount(), mSettingsModel.getDunCurrentCount());
SettingsActivity.notifyDunInfoUpdate(); SettingsActivity.notifyDunInfoUpdate();
} }
} }
@@ -135,7 +136,11 @@ public class Rules {
SettingsBean.saveBean(mContext, mSettingsModel); SettingsBean.saveBean(mContext, mSettingsModel);
} }
public boolean isAllowed(String phoneNumber) { public boolean isAllowed(String phoneNumber) {
return isAllowed(phoneNumber, false);
}
public boolean isAllowed(String phoneNumber, boolean isTest) {
// 没有启用云盾,默认允许接通任何电话 // 没有启用云盾,默认允许接通任何电话
if (!mSettingsModel.isEnableDun()) { if (!mSettingsModel.isEnableDun()) {
LogUtils.d(TAG, String.format("没有启用云盾默认允许接通任何电话。isAllowed(...) return true")); LogUtils.d(TAG, String.format("没有启用云盾默认允许接通任何电话。isAllowed(...) return true"));
@@ -166,6 +171,14 @@ public class Rules {
isConnect = false; isConnect = false;
LogUtils.d(TAG, String.format("isDefend == %s\nisConnect == %s", isDefend, isConnect)); LogUtils.d(TAG, String.format("isDefend == %s\nisConnect == %s", isDefend, isConnect));
} }
// 限时特殊通道打开时返回连接
if (!isDefend && LimitedTimeSpecialChannelService.isServiceRunning()) {
LogUtils.d(TAG, String.format("PhoneNumber %s\n and Limited Time Special Channel Service Is Running.", phoneNumber));
isDefend = true;
isConnect = true;
LogUtils.d(TAG, String.format("isDefend == %s\nisConnect == %s", isDefend, isConnect));
}
// 检验拨不通号码群 // 检验拨不通号码群
if (!isDefend && MainService.isPhoneInBoBullToon(phoneNumber)) { if (!isDefend && MainService.isPhoneInBoBullToon(phoneNumber)) {
@@ -204,37 +217,40 @@ public class Rules {
} }
} }
if (isConnect) { // 如果不是规则测试时,就执行云盾防御机能。
// 如果防御结果为连接,则恢复防御盾牌最大值层数 if (isTest == false) {
mSettingsModel.setDunCurrentCount(mSettingsModel.getDunTotalCount()); if (isConnect) {
LogUtils.d(TAG, String.format("防御结果为连接,恢复防御盾牌最大值层数 %d", mSettingsModel.getDunTotalCount())); // 如果防御结果为连接,恢复防御盾牌最大值层数
saveDun(); mSettingsModel.setDunCurrentCount(mSettingsModel.getDunTotalCount());
SettingsActivity.notifyDunInfoUpdate(); LogUtils.d(TAG, String.format("防御结果为连接,恢复防御盾牌最大值层数 %d", mSettingsModel.getDunTotalCount()));
} else if (isDefend) { saveDun();
// 如果触发了以上某个防御模块,减少防御盾牌层数 SettingsActivity.notifyDunInfoUpdate();
int newDunCount = nDunCurrentCount; } else if (isDefend) {
LogUtils.d(TAG, String.format("新的防御层数预计为 %d", newDunCount)); // 如果触发了以上某个防御模块,减少防御盾牌层数
int newDunCount = nDunCurrentCount;
LogUtils.d(TAG, String.format("新的防御层数预计为 %d", newDunCount));
// 保证盾值在[1DunTotalCount]之内其他值一律重置为 DunTotalCount。 // 保证盾值在[1DunTotalCount]之内其他值一律重置为 DunTotalCount。
if (newDunCount > 0 && newDunCount < mSettingsModel.getDunTotalCount()) { if (newDunCount > 0 && newDunCount < mSettingsModel.getDunTotalCount()) {
mSettingsModel.setDunCurrentCount(newDunCount); mSettingsModel.setDunCurrentCount(newDunCount);
LogUtils.d(TAG, String.format("设置防御层数为 %d", newDunCount)); LogUtils.d(TAG, String.format("设置防御层数为 %d", newDunCount));
} else { } else {
mSettingsModel.setDunCurrentCount(mSettingsModel.getDunTotalCount()); mSettingsModel.setDunCurrentCount(mSettingsModel.getDunTotalCount());
LogUtils.d(TAG, String.format("盾值不在[0%d]区间,恢复防御最大值%d", mSettingsModel.getDunTotalCount(), mSettingsModel.getDunTotalCount())); LogUtils.d(TAG, String.format("盾值不在[0%d]区间,恢复防御最大值%d", mSettingsModel.getDunTotalCount(), mSettingsModel.getDunTotalCount()));
} }
saveDun(); saveDun();
SettingsActivity.notifyDunInfoUpdate(); SettingsActivity.notifyDunInfoUpdate();
} }
// 返回校验结果 // 一键更新所有 DunTemperatureView 实例的盾值
LogUtils.d(TAG, String.format("返回校验结果 isConnect == %s", isConnect)); DunTemperatureView.updateDunValue(mSettingsModel.getDunTotalCount(), mSettingsModel.getDunCurrentCount());
// 一键更新所有 DunTemperatureView 实例的盾值 }
DunTemperatureView.updateDunValue(mSettingsModel.getDunTotalCount(), mSettingsModel.getDunCurrentCount());
return isConnect; // 返回校验结果
} LogUtils.d(TAG, String.format("返回校验结果 isConnect == %s", isConnect));
return isConnect;
}
public void add(String szPhoneConnectRule, boolean isAllowConnection, boolean isEnable) { public void add(String szPhoneConnectRule, boolean isAllowConnection, boolean isEnable) {
_PhoneConnectRuleModelList.add(new PhoneConnectRuleBean(szPhoneConnectRule, isAllowConnection, isEnable)); _PhoneConnectRuleModelList.add(new PhoneConnectRuleBean(szPhoneConnectRule, isAllowConnection, isEnable));

View File

@@ -0,0 +1,285 @@
package cc.winboll.studio.contacts.services;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import cc.winboll.studio.libappbase.LogUtils;
/**
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
* @Date 2026/04/18 15:00:00 (香港时区)
* @LastEditTime 2026/04/18 22:15:00 (香港时区)
* @Describe 限时特殊通道服务
* 提供安全的服务启动、令牌校验及循环任务管理
* 新增功能:
* 1. 计时过程中实时输出剩余秒数
* 2. 服务销毁前,发送本地广播通知应用内其他组件
*/
public class LimitedTimeSpecialChannelService extends Service {
// ========================= 常量定义 =========================
public static final String TAG = "LimitedTimeSpecialChannelService";
public static final String EXTRA_DELAY_MILLIS = "EXTRA_DELAY_MILLIS";
private static final String EXTRA_SECURITY_TOKEN = "EXTRA_SECURITY_TOKEN";
// 新增:本地广播 Action 常量
public static final String ACTION_SERVICE_DESTROYED = "cc.winboll.studio.contacts.services.ACTION_SERVICE_DESTROYED";
// ========================= 静态变量 =========================
/**
* 公共静态私有字段:安全校验令牌
* 确保全局可访问且值不可变更
*/
public static final String mValidToken = "VALID_TOKEN_" + System.currentTimeMillis();
private static volatile LimitedTimeSpecialChannelService sInstance = null;
private static volatile boolean sIsServiceRunning = false;
// ========================= 成员变量 =========================
private final Handler mHandler = new Handler(Looper.getMainLooper());
private long mTotalMillis = 0; // 总定时时长
private long mRemainingMillis = 0; // 剩余时长
private LocalBroadcastManager mLocalBroadcastManager; // 本地广播管理器实例
// ========================= 公共静态方法 =========================
/**
* 公共静态方法:启动服务
* @param context 上下文
* @param delayMillis 定时时长(毫秒)
*/
public static void startService(Context context, long delayMillis) {
LogUtils.i(TAG, "调用 startService 入口");
LogUtils.i(TAG, "入参 - context: " + context + ", delayMillis: " + delayMillis);
if (context == null) {
LogUtils.w(TAG, "启动失败上下文为null");
return;
}
if (isServiceRunning()) {
LogUtils.i(TAG, "服务已运行,忽略重复启动");
return;
}
// 构建Intent传递参数
Intent intent = new Intent(context, LimitedTimeSpecialChannelService.class);
intent.putExtra(EXTRA_SECURITY_TOKEN, mValidToken);
intent.putExtra(EXTRA_DELAY_MILLIS, delayMillis);
context.startService(intent);
LogUtils.i(TAG, "服务启动命令已发出");
}
/**
* 公共静态方法:停止服务
* @param context 上下文
*/
public static void stopService(Context context) {
LogUtils.i(TAG, "调用 stopService 入口");
LogUtils.i(TAG, "入参 - context: " + context);
if (context == null) {
LogUtils.w(TAG, "停止失败上下文为null");
return;
}
Intent intent = new Intent(context, LimitedTimeSpecialChannelService.class);
context.stopService(intent);
LogUtils.i(TAG, "服务停止命令已发出");
}
/**
* 公共静态方法:查询服务运行状态
* @return true if running
*/
public static boolean isServiceRunning() {
return sIsServiceRunning;
}
/**
* 【核心单元测试方法】
* 执行完整的单元测试流程
* @param context 上下文
*/
public static void unitTest(Context context) {
LogUtils.i(TAG, "=== 开始执行单元测试 ===");
// 测试1: 初始状态应为未运行
boolean initialState = isServiceRunning();
LogUtils.i(TAG, "测试1 - 初始状态检查: " + (initialState ? "失败(不应运行)" : "成功(未运行)"));
// 启动服务设置5秒定时
startService(context, 5000L);
// 核心修复:同步等待服务启动完成
try {
Thread.sleep(1200);
} catch (InterruptedException e) {
LogUtils.e(TAG, "单元测试等待被中断", e);
}
// 测试3: 验证服务已启动
boolean runningState = isServiceRunning();
LogUtils.i(TAG, "测试3 - 运行状态检查: " + (runningState ? "成功(运行中)" : "失败(未运行)"));
// 测试4: 尝试重复启动
LogUtils.i(TAG, "测试4 - 尝试重复启动(预期忽略)");
startService(context, 5000L);
// 测试5: 等待服务自动停止
LogUtils.i(TAG, "测试5 - 等待服务自动停止(5秒)...");
try {
// 等待超过服务的5秒运行时间确保能观测到销毁状态
Thread.sleep(6000);
} catch (InterruptedException e) {
LogUtils.e(TAG, "测试等待被中断", e);
}
// 验证最终状态
boolean finalState = isServiceRunning();
LogUtils.i(TAG, "测试5 - 最终状态检查: " + (!finalState ? "成功(已销毁)" : "失败(仍运行)"));
LogUtils.i(TAG, "=== 单元测试执行完成 ===");
}
// ========================= 生命周期 =========================
@Override
public void onCreate() {
super.onCreate();
LogUtils.i(TAG, "服务 onCreate创建实例");
sInstance = this;
sIsServiceRunning = true;
// 初始化本地广播管理器
mLocalBroadcastManager = LocalBroadcastManager.getInstance(this);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
LogUtils.i(TAG, "服务 onStartCommand处理启动请求");
LogUtils.i(TAG, "入参 - intent: " + intent + ", flags: " + flags + ", startId: " + startId);
if (!isValidToken(intent)) {
LogUtils.w(TAG, "安全校验失败,拒绝启动");
stopSelf();
return START_NOT_STICKY;
}
long delayMillis = intent.getLongExtra(EXTRA_DELAY_MILLIS, 0);
if (delayMillis <= 0) {
LogUtils.w(TAG, "无效的定时时长: " + delayMillis + ",服务将退出");
stopSelf();
return START_NOT_STICKY;
}
// 初始化总时长和剩余时长
mTotalMillis = delayMillis;
mRemainingMillis = delayMillis;
LogUtils.i(TAG, "初始化定时时长: " + (mTotalMillis / 1000) + "");
startLoopTask();
// 设置自动停止定时器
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
LogUtils.i(TAG, "定时结束,准备停止服务");
stopSelf();
}
}, delayMillis);
return START_STICKY;
}
@Override
public void onDestroy() {
LogUtils.i(TAG, "服务 onDestroy准备销毁");
// 新增:发送本地广播,通知应用内其他组件服务即将销毁
sendServiceDestroyedBroadcast();
// 原有逻辑:重置状态
sIsServiceRunning = false;
sInstance = null;
// 原有逻辑:清理所有回调
mHandler.removeCallbacksAndMessages(null);
// 原有逻辑:执行父类销毁
super.onDestroy();
LogUtils.i(TAG, "服务销毁完成");
}
/**
* 新增:发送服务销毁的本地广播
* 确保应用内所有注册了该广播接收器的组件都能收到通知
*/
private void sendServiceDestroyedBroadcast() {
try {
Intent intent = new Intent(ACTION_SERVICE_DESTROYED);
// 可在此处添加额外数据,例如剩余时长等
intent.putExtra("REMAINING_SECONDS", mRemainingMillis / 1000);
// 使用本地广播管理器发送
LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
LogUtils.i(TAG, "已发送本地广播 - 服务销毁通知: " + ACTION_SERVICE_DESTROYED);
} catch (Exception e) {
LogUtils.e(TAG, "发送服务销毁广播失败", e);
}
}
@Override
public IBinder onBind(Intent intent) {
LogUtils.i(TAG, "服务 onBind不支持绑定");
// 不支持绑定
return null;
}
// ========================= 私有辅助方法 =========================
/**
* 校验令牌有效性
*/
private boolean isValidToken(Intent intent) {
LogUtils.i(TAG, "调用 isValidToken 校验");
LogUtils.i(TAG, "入参 - intent: " + intent);
if (intent == null) {
return false;
}
String incomingToken = intent.getStringExtra(EXTRA_SECURITY_TOKEN);
LogUtils.i(TAG, "接收到外部传入令牌: " + incomingToken);
LogUtils.i(TAG, "比对本地存储令牌: " + mValidToken);
return mValidToken.equals(incomingToken);
}
/**
* 启动循环任务
*/
private void startLoopTask() {
mHandler.removeCallbacks(mLoopTaskRunnable);
mHandler.postDelayed(mLoopTaskRunnable, 1000);
}
/**
* 循环任务心跳
* 新增:实时计算并输出剩余秒数
*/
private final Runnable mLoopTaskRunnable = new Runnable() {
@Override
public void run() {
if (sIsServiceRunning) {
// 计算剩余秒数
long remainingSeconds = mRemainingMillis / 1000;
// 输出剩余秒数信息
LogUtils.i(TAG, "循环任务心跳:服务运行中,剩余 " + remainingSeconds + "");
// 剩余时长递减
mRemainingMillis -= 1000;
// 递归调用,形成循环
startLoopTask();
}
}
};
}

View File

@@ -0,0 +1,252 @@
package cc.winboll.studio.contacts.views;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.widget.CompoundButton;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.Switch;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import cc.winboll.studio.contacts.R;
import cc.winboll.studio.contacts.services.LimitedTimeSpecialChannelService;
import cc.winboll.studio.libappbase.LogUtils;
import cc.winboll.studio.libappbase.ToastUtils;
/**
* @Author ZhanGSKen<zhangsken@qq.com>
* @Date 2026/04/18 15:05
* @LastEditTime 2026/04/18 22:30:00 (香港时区)
* @Describe 限时特殊通道视图
* 功能更新:
* 1. Switch 打开时EditText 不可编辑,布局背景变为绿色
* 2. Switch 关闭时EditText 可编辑,布局背景恢复为白色
* 3. 监听服务销毁本地广播,到达后自动关闭 Switch 并恢复背景色
*/
public class LimitedTimeSpecialChannelView extends LinearLayout {
// ====================== 常量定义 =========================
public static final String TAG = "LimitedTimeSpecialChannelView";
// ====================== 成员变量 =========================
private Context mContext;
private EditText mEtSeconds;
private Switch mSwEnable;
private LinearLayout mLlMain;
// ====================== 构造函数 =========================
/**
* 代码创建View时调用
* @param context 上下文
*/
public LimitedTimeSpecialChannelView(Context context) {
super(context);
initView(context);
}
/**
* XML布局中引用时调用
* @param context 上下文
* @param attrs 属性集合
*/
public LimitedTimeSpecialChannelView(Context context, AttributeSet attrs) {
super(context, attrs);
initView(context);
}
/**
* 指定样式属性
* @param context 上下文
* @param attrs 属性集合
* @param defStyleAttr 默认样式属性
*/
public LimitedTimeSpecialChannelView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initView(context);
}
/**
* 指定样式资源
* @param context 上下文
* @param attrs 属性集合
* @param defStyleAttr 默认样式属性
* @param defStyleRes 默认样式资源
*/
public LimitedTimeSpecialChannelView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
initView(context);
}
// ====================== 初始化方法 ========================
/**
* 初始化视图
* 加载布局文件,绑定控件,并注册广播接收器
* @param context 上下文
*/
private void initView(Context context) {
LogUtils.i(TAG, "初始化限时特殊通道视图");
this.mContext = context;
// 1. 加载布局文件
LayoutInflater.from(context)
.inflate(R.layout.view_limitedtimespecialchannel, this, true);
LogUtils.i(TAG, "布局加载完成view_limitedtimespecialchannel.xml");
// 2. 绑定核心控件引用
bindViews();
// 3. 初始化开关状态与背景色关联
initSwitchState();
// 4. 注册服务销毁本地广播监听
registerServiceDestroyedListener();
}
/**
* 绑定布局中的控件ID
*/
private void bindViews() {
mEtSeconds = (EditText) findViewById(R.id.et_seconds);
mSwEnable = (Switch) findViewById(R.id.sw_enable);
mLlMain = (LinearLayout) findViewById(R.id.ll_main);
// 健壮性检查
if (mEtSeconds == null) {
LogUtils.e(TAG, "未找到ID为 et_seconds 的EditText");
}
if (mSwEnable == null) {
LogUtils.e(TAG, "未找到ID为 sw_enable 的Switch");
}
if (mLlMain == null) {
LogUtils.w(TAG, "未找到ID为 ll_main 的LinearLayout将无法设置背景色");
}
}
/**
* 初始化开关状态
* 设置Switch的监听事件以及初始背景色
*/
private void initSwitchState() {
// 设置初始状态
boolean isChecked = mSwEnable.isChecked();
mEtSeconds.setEnabled(!isChecked);
if (mLlMain != null) {
mLlMain.setBackgroundColor(isChecked ? android.graphics.Color.GREEN : android.graphics.Color.WHITE);
}
LogUtils.i(TAG, "初始状态 - 开关: " + (isChecked ? "启用" : "关闭") + ", 背景色: " + (isChecked ? "绿色" : "白色"));
// 为Switch设置切换监听
mSwEnable.setOnCheckedChangeListener(new Switch.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
LogUtils.i(TAG, "开关状态变更: " + (isChecked ? "启用" : "关闭"));
// 核心逻辑控制EditText的可编辑状态
mEtSeconds.setEnabled(!isChecked);
// 控制布局背景色
if (mLlMain != null) {
mLlMain.setBackgroundColor(isChecked ? android.graphics.Color.GREEN : android.graphics.Color.WHITE);
}
// 控制服务启动与停止
if (isChecked) {
// 打开开关时,启动服务
startLimitedTimeSpecialChannelService();
} else {
// 关闭开关时,停止服务
LimitedTimeSpecialChannelService.stopService(mContext);
}
}
});
}
/**
* 注册服务销毁本地广播监听
* 当服务销毁消息到达时,自动关闭开关并恢复背景色
*/
private void registerServiceDestroyedListener() {
// 创建广播接收器
LocalBroadcastManager.getInstance(mContext)
.registerReceiver(new android.content.BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
// 校验广播Action
if (LimitedTimeSpecialChannelService.ACTION_SERVICE_DESTROYED.equals(intent.getAction())) {
LogUtils.i(TAG, "收到服务销毁本地广播,准备关闭开关并恢复背景");
// 核心逻辑关闭Switch
if (mSwEnable != null && mSwEnable.isChecked()) {
mSwEnable.setChecked(false);
}
// 恢复布局背景色为白色
if (mLlMain != null) {
mLlMain.setBackgroundColor(android.graphics.Color.WHITE);
}
// 可选:通知用户服务已销毁
ToastUtils.show("服务已销毁,恢复初始状态");
}
}
}, new IntentFilter(LimitedTimeSpecialChannelService.ACTION_SERVICE_DESTROYED));
}
/**
* 启动限时特殊通道服务
* 读取输入框中的秒数,转换为毫秒后启动服务
*/
private void startLimitedTimeSpecialChannelService() {
LogUtils.i(TAG, "检测到秒数输入框点击,准备启动服务");
// 1. 获取输入的秒数
String secondsStr = mEtSeconds.getText().toString().trim();
if (secondsStr.isEmpty()) {
LogUtils.w(TAG, "输入框为空,使用默认值 3600 秒");
secondsStr = "3600";
}
try {
// 2. 转换为毫秒数 (1秒 = 1000毫秒)
long seconds = Long.parseLong(secondsStr);
long delayMillis = seconds * 1000;
LogUtils.i(TAG, "输入秒数: " + seconds + " 秒,转换为毫秒: " + delayMillis);
// 3. 调用服务启动方法
ToastUtils.show("调用 LimitedTimeSpecialChannelService 服务");
LimitedTimeSpecialChannelService.startService(mContext, delayMillis);
} catch (NumberFormatException e) {
LogUtils.e(TAG, "输入的秒数格式错误: " + secondsStr, e);
}
}
/**
*【可选】视图销毁时反注册广播,防止内存泄漏
*/
// @Override
// protected void onDetachedFromWindow() {
// super.onDetachedFromWindow();
// // 反注册本地广播
// LocalBroadcastManager.getInstance(mContext).unregisterregisterReceiver(mServiceDestroyedReceiver);
// LogUtils.i(TAG, "反注册服务销毁本地广播完成");
// }
// //【可选】定义广播接收器成员变量,方便反注册
// private android.content.BroadcastReceiver mServiceDestroyedReceiver;
//
// private void registerServiceDestroyedListener() {
// mServiceDestroyedReceiver = new android.content.BroadcastReceiver() {
// @Override
// public void onReceive(Context context, Intent intent) {
// // ... 原有onReceive逻辑 ...
// }
// };
// LocalBroadcastManager.getInstance(mContext).registerReceiver(mServiceDestroyedReceiver, new IntentFilter(LimitedTimeSpecialChannelService.ACTION_SERVICE_DESTROYED));
// }
}

View File

@@ -57,6 +57,24 @@
android:id="@+id/tv_DunInfo"/> android:id="@+id/tv_DunInfo"/>
</LinearLayout> </LinearLayout>
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="特殊通道设置:"/>
<cc.winboll.studio.contacts.views.LimitedTimeSpecialChannelView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/view_LimitedTimeSpecialChannel"/>
</LinearLayout>
<LinearLayout <LinearLayout
android:orientation="horizontal" android:orientation="horizontal"

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical|center_horizontal"
android:id="@+id/ll_main">
<EditText
android:layout_width="80dp"
android:inputType="number"
android:layout_height="wrap_content"
android:ems="10"
android:text="3600"
android:id="@+id/et_seconds"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="秒"/>
<Switch
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/sw_enable"/>
</LinearLayout>

View File

@@ -5,6 +5,9 @@
<item <item
android:id="@+id/item_calllog_phonenumber_copy" android:id="@+id/item_calllog_phonenumber_copy"
android:title="Copy"/> android:title="Copy"/>
<item
android:id="@+id/item_calllog_phonenumber_yundun_test"
android:title="YunDun Test"/>
<item <item
android:id="@+id/item_calllog_phonenumber_add_contact" android:id="@+id/item_calllog_phonenumber_add_contact"
android:title="Add Contact"/> android:title="Add Contact"/>