diff --git a/appbase/build.properties b/appbase/build.properties index d8de658a..c5ebd7b0 100644 --- a/appbase/build.properties +++ b/appbase/build.properties @@ -1,8 +1,8 @@ #Created by .winboll/winboll_app_build.gradle -#Sat Nov 29 13:38:56 GMT 2025 +#Sat Nov 29 17:16:46 GMT 2025 stageCount=3 libraryProject=libappbase baseVersion=15.11 publishVersion=15.11.2 -buildCount=2 +buildCount=4 baseBetaVersion=15.11.3 diff --git a/appbase/src/main/java/cc/winboll/studio/appbase/App.java b/appbase/src/main/java/cc/winboll/studio/appbase/App.java index 62daf512..76615aa8 100644 --- a/appbase/src/main/java/cc/winboll/studio/appbase/App.java +++ b/appbase/src/main/java/cc/winboll/studio/appbase/App.java @@ -22,7 +22,7 @@ public class App extends GlobalApplication { @Override public void onCreate() { super.onCreate(); // 调用父类初始化逻辑(如基础库配置、全局上下文设置) - setIsDebugging(BuildConfig.DEBUG); + setIsDebugging(!BuildConfig.DEBUG); // 初始化 Toast 工具类(传入应用全局上下文,确保 Toast 可在任意地方调用) ToastUtils.init(getApplicationContext()); } diff --git a/libappbase/build.properties b/libappbase/build.properties index d8de658a..c5ebd7b0 100644 --- a/libappbase/build.properties +++ b/libappbase/build.properties @@ -1,8 +1,8 @@ #Created by .winboll/winboll_app_build.gradle -#Sat Nov 29 13:38:56 GMT 2025 +#Sat Nov 29 17:16:46 GMT 2025 stageCount=3 libraryProject=libappbase baseVersion=15.11 publishVersion=15.11.2 -buildCount=2 +buildCount=4 baseBetaVersion=15.11.3 diff --git a/libappbase/src/main/AndroidManifest.xml b/libappbase/src/main/AndroidManifest.xml index 61a602be..28162c61 100644 --- a/libappbase/src/main/AndroidManifest.xml +++ b/libappbase/src/main/AndroidManifest.xml @@ -3,8 +3,9 @@ xmlns:android="http://schemas.android.com/apk/res/android" package="cc.winboll.studio.libappbase"> + + - * @Date 2025/11/29 21:12 * @Describe 应用崩溃处理通知实用工具集 - * 核心功能:应用崩溃时捕获错误日志,发送通知到系统通知栏,方便用户查看崩溃信息 + * 核心功能:应用崩溃时捕获错误日志,发送通知到系统通知栏(3行内容省略+复制按钮),点击复制按钮可将完整崩溃日志复制到剪贴板 */ public class CrashHandleNotifyUtils { @@ -30,11 +32,22 @@ public class CrashHandleNotifyUtils { private static final int API_LEVEL_ANDROID_12 = 31; /** PendingIntent.FLAG_IMMUTABLE 常量值(API 31+),避免依赖高版本 SDK */ private static final int FLAG_IMMUTABLE = 0x00000040; + /** 通知内容最大行数(控制在3行,超出部分省略) */ + private static final int NOTIFICATION_MAX_LINES = 3; + /** 复制按钮 Action(用于区分通知按钮点击事件) */ + private static final String ACTION_COPY_CRASH_LOG = "cc.winboll.studio.action.COPY_CRASH_LOG"; + /** 复制按钮请求码(区分多个 PendingIntent) */ + private static final int REQUEST_CODE_COPY = 0x002; + /** 复制成功提示文本 */ + private static final String COPY_SUCCESS_TIP = "崩溃日志已复制到剪贴板"; + + // 静态广播接收器(避免重复注册,确保崩溃后仍能接收点击事件) + private static CopyCrashLogReceiver sCopyReceiver; /** * 处理未捕获异常(核心方法) * 1. 提取应用名称和崩溃日志; - * 2. 创建并发送系统通知(标题:应用名称,内容:崩溃日志); + * 2. 创建并发送系统通知(3行内容省略+复制按钮); * 3. 兼容 Android 8.0+ 通知渠道机制,适配低版本系统。 * @param app 应用全局 Application 实例(用于获取上下文、应用信息) * @param intent 存储崩溃信息的意图(extra 中携带崩溃日志) @@ -51,7 +64,10 @@ public class CrashHandleNotifyUtils { return; } - // 3. 发送崩溃通知到通知栏 + // 3. 注册静态广播接收器(仅注册一次,确保崩溃后能接收点击事件) + registerCopyReceiver(app); + + // 4. 发送崩溃通知到通知栏(3行省略+复制按钮) sendCrashNotification(app, appName, errorLog); } @@ -74,7 +90,7 @@ public class CrashHandleNotifyUtils { } /** - * 发送崩溃通知到系统通知栏 + * 发送崩溃通知到系统通知栏(核心修改:3行内容+复制按钮) * @param context 上下文(Application 实例,确保后台也能发送) * @param title 通知标题(应用名称) * @param content 通知内容(崩溃日志) @@ -92,11 +108,12 @@ public class CrashHandleNotifyUtils { createCrashNotifyChannel(notificationManager); } - // 3. 构建通知意图(点击通知时可跳转,此处默认跳转应用主界面,可自定义) - PendingIntent pendingIntent = getNotificationPendingIntent(context); + // 3. 构建通知意图(点击通知跳转主界面 + 点击复制按钮复制日志) + PendingIntent launchPendingIntent = getLaunchPendingIntent(context); // 主界面跳转意图 + PendingIntent copyPendingIntent = getCopyPendingIntent(context, content); // 复制日志意图 - // 4. 构建通知实例(兼容低版本,使用 Notification.Builder 构建) - Notification notification = buildNotification(context, title, content, pendingIntent); + // 4. 构建通知实例(核心修复:3行内容省略+复制按钮,修复setLines报错) + Notification notification = buildNotification(context, title, content, launchPendingIntent, copyPendingIntent); // 5. 发送通知(指定通知ID,重复发送同ID会覆盖原通知) notificationManager.notify(CRASH_NOTIFY_ID, notification); @@ -118,7 +135,7 @@ public class CrashHandleNotifyUtils { NotificationManager.IMPORTANCE_DEFAULT // 重要性:默认(不会弹窗,有声音提示) ); // 可选:设置渠道描述(用户在设置中可见) - channel.setDescription("用于显示应用崩溃信息,帮助定位问题"); + channel.setDescription("用于显示应用崩溃信息,支持复制日志"); // 注册通知渠道到系统 notificationManager.createNotificationChannel(channel); LogUtils.d(TAG, "崩溃通知渠道创建成功:" + CRASH_NOTIFY_CHANNEL_ID); @@ -126,12 +143,11 @@ public class CrashHandleNotifyUtils { } /** - * 构建通知点击意图(PendingIntent) - * 点击通知后跳转应用主界面(可根据需求修改为跳转崩溃日志详情页) + * 构建通知点击跳转意图(跳转应用主界面) * @param context 上下文 - * @return 封装好的 PendingIntent(用于通知点击跳转) + * @return 主界面跳转 PendingIntent */ - private static PendingIntent getNotificationPendingIntent(Context context) { + private static PendingIntent getLaunchPendingIntent(Context context) { // 1. 获取应用主界面 Intent(从包名启动默认 launcher Activity) Intent launchIntent = context.getPackageManager().getLaunchIntentForPackage( context.getPackageName() @@ -141,55 +157,223 @@ public class CrashHandleNotifyUtils { launchIntent = new Intent(); } - // 2. 构建 PendingIntent(延迟执行的意图,FLAG_UPDATE_CURRENT 表示更新已存在的意图) + // 2. 构建 PendingIntent(延迟执行的意图) int flags = PendingIntent.FLAG_UPDATE_CURRENT; - // 适配 Android 12+(API 31+):添加 FLAG_IMMUTABLE 避免安全警告(用常量值替代高版本 API) + // 适配 Android 12+(API 31+):添加 FLAG_IMMUTABLE 避免安全警告 if (Build.VERSION.SDK_INT >= API_LEVEL_ANDROID_12) { flags |= FLAG_IMMUTABLE; } return PendingIntent.getActivity( context, - 0, // 请求码(可忽略,用于区分多个 PendingIntent) + 0, // 请求码(可忽略) launchIntent, flags ); } /** - * 构建通知实例(兼容 Android 4.0+ 所有版本) + * 构建复制按钮意图(点击复制崩溃日志到剪贴板) * @param context 上下文 - * @param title 通知标题 - * @param content 通知内容 - * @param pendingIntent 通知点击意图 + * @param errorLog 崩溃日志(需要复制的内容) + * @return 复制日志 PendingIntent + */ + private static PendingIntent getCopyPendingIntent(Context context, String errorLog) { + // 1. 构建复制日志的隐式意图(指定 Action,用于 BroadcastReceiver 接收) + Intent copyIntent = new Intent(ACTION_COPY_CRASH_LOG); + copyIntent.putExtra("EXTRA_CRASH_LOG", errorLog); // 携带崩溃日志 + copyIntent.setPackage(context.getPackageName()); // 限制仅当前应用接收,避免安全问题 + + // 2. 构建 PendingIntent(使用广播类型,崩溃后仍能触发) + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= API_LEVEL_ANDROID_12) { + flags |= FLAG_IMMUTABLE; + } + + return PendingIntent.getBroadcast( + context, + REQUEST_CODE_COPY, // 唯一请求码,区分主界面意图 + copyIntent, + flags + ); + } + + /** + * 注册静态广播接收器(仅注册一次,确保崩溃后能接收点击事件) + * 解决动态广播崩溃后被销毁的问题 + * @param context 上下文(Application 实例) + */ + private static void registerCopyReceiver(Context context) { + // 避免重复注册(静态接收器仅注册一次) + if (sCopyReceiver != null) { + return; + } + + // 构建广播过滤器(仅接收复制日志的 Action) + IntentFilter filter = new IntentFilter(); + filter.addAction(ACTION_COPY_CRASH_LOG); + filter.setPriority(IntentFilter.SYSTEM_HIGH_PRIORITY); + + // 初始化静态广播接收器 + sCopyReceiver = new CopyCrashLogReceiver(); + // 注册广播(使用 Application 上下文,确保生命周期与应用一致) + context.registerReceiver(sCopyReceiver, filter); + LogUtils.d(TAG, "复制日志广播接收器注册成功"); + } + + /** + * 静态广播接收器(处理复制按钮点击事件) + * 静态内部类避免内存泄漏,且崩溃后仍能接收系统发送的广播 + */ + private static class CopyCrashLogReceiver extends android.content.BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + // 验证 Action,确保是复制日志的点击事件 + if (ACTION_COPY_CRASH_LOG.equals(intent.getAction())) { + // 从意图中获取完整崩溃日志 + String crashLog = intent.getStringExtra("EXTRA_CRASH_LOG"); + if (crashLog != null && !crashLog.isEmpty()) { + // 复制日志到剪贴板 + copyTextToClipboard(context, "崩溃日志", crashLog); + // 复制成功后显示提示(可选,提升用户体验) + showCopySuccessTip(context); + LogUtils.d(TAG, "崩溃日志复制成功,长度:" + crashLog.length() + "字符"); + } else { + LogUtils.e(TAG, "复制崩溃日志失败:日志为空"); + } + } + } + } + + /** + * 复制文本到系统剪贴板(修复适配逻辑,确保全版本可用) + * @param context 上下文 + * @param label 剪贴板文本标签(用户不可见,用于区分剪贴板内容) + * @param text 需要复制的文本(崩溃日志) + */ + private static void copyTextToClipboard(Context context, String label, String text) { + try { + // 适配 Android 11+(API 30+)剪贴板 API,兼容低版本 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + android.content.ClipboardManager clipboard = (android.content.ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + android.content.ClipData clipData = android.content.ClipData.newPlainText(label, text); + clipboard.setPrimaryClip(clipData); + } else { + // 低版本剪贴板 API(Android 10 及以下) + android.text.ClipboardManager clipboard = (android.text.ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + clipboard.setText(text); + } + } catch (Exception e) { + LogUtils.e(TAG, "复制文本到剪贴板失败", e); + } + } + + /** + * 显示复制成功提示(Toast,提升用户体验) + * @param context 上下文 + */ + private static void showCopySuccessTip(Context context) { + try { + // 若项目中 ToastUtils 支持后台显示,用 ToastUtils;否则用系统 Toast + if (cc.winboll.studio.libappbase.ToastUtils.isInited()) { + cc.winboll.studio.libappbase.ToastUtils.show(COPY_SUCCESS_TIP); + } else { + // 系统 Toast 适配(确保后台能显示) + android.widget.Toast.makeText(context, COPY_SUCCESS_TIP, android.widget.Toast.LENGTH_SHORT).show(); + } + } catch (Exception e) { + LogUtils.e(TAG, "显示复制成功提示失败", e); + } + } + + /** + * 构建通知实例(核心修复:3行内容省略+复制按钮,修复setLines报错) + * @param context 上下文 + * @param title 通知标题(应用名称) + * @param content 通知内容(崩溃日志) + * @param launchPendingIntent 通知点击跳转意图 + * @param copyPendingIntent 复制按钮点击意图 * @return 构建完成的 Notification 对象 */ - private static Notification buildNotification(Context context, String title, String content, PendingIntent pendingIntent) { + private static Notification buildNotification(Context context, String title, String content, PendingIntent launchPendingIntent, PendingIntent copyPendingIntent) { // 兼容 Android 8.0+:指定通知渠道ID Notification.Builder builder = new Notification.Builder(context); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { builder.setChannelId(CRASH_NOTIFY_CHANNEL_ID); } - // 配置通知核心参数 - builder - .setSmallIcon(context.getApplicationInfo().icon) // 通知小图标(必需,从应用图标获取) - .setContentTitle(title) // 通知标题(应用名称) - .setContentText(content) // 通知内容(崩溃日志) - .setContentIntent(pendingIntent) // 通知点击意图 - .setAutoCancel(true) // 点击通知后自动取消 - .setWhen(System.currentTimeMillis()) // 通知创建时间(当前时间) - .setPriority(Notification.PRIORITY_DEFAULT); // 通知优先级(默认) + // 核心修复1:用BigTextStyle控制“默认3行省略,下拉显示完整” + Notification.BigTextStyle bigTextStyle = new Notification.BigTextStyle(); + bigTextStyle.setSummaryText("日志已省略,下拉查看完整内容"); // 底部省略提示 + bigTextStyle.bigText(content); // 完整日志(下拉时显示) + bigTextStyle.setBigContentTitle(title); // 下拉后的标题(与主标题一致) + builder.setStyle(bigTextStyle); - // 可选:长文本适配(当崩溃日志过长时,显示完整文本) - if (content.length() > 100) { // 超过100字符时,设置长文本样式 - Notification.BigTextStyle bigTextStyle = new Notification.BigTextStyle(); - bigTextStyle.bigText(content); // 显示完整崩溃日志 - builder.setStyle(bigTextStyle); + // 核心修改2:添加复制按钮(Android 4.1+ 支持通知按钮) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + // 复制按钮:自定义图标+文本+点击意图(确保图标存在) + builder.addAction( + R.drawable.ic_content_copy, // 自定义复制图标(需确保drawable目录下存在,否则替换为系统图标) + "复制日志", // 按钮文本 + copyPendingIntent // 按钮点击意图(绑定复制广播) + ); } - // 构建通知并返回(getNotification() 兼容低版本,build() 是 Android 4.1+ 方法) - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN ? builder.build() : builder.getNotification(); + // 配置通知核心参数(移除setLines,避免报错) + builder + .setSmallIcon(context.getApplicationInfo().icon) // 通知小图标(必需,否则通知不显示) + .setContentTitle(title) // 通知主标题(应用名称) + .setContentText(getShortContent(content)) // 核心:3行内缩略文本 + .setContentIntent(launchPendingIntent) // 通知主体点击跳转主界面 + .setAutoCancel(true) // 点击通知后自动取消 + .setWhen(System.currentTimeMillis()) // 通知创建时间 + .setPriority(Notification.PRIORITY_DEFAULT); // 通知优先级 + + // 适配 Android 4.1+:确保文本显示正常 + // 构建通知并返回(兼容低版本,区分 API 等级避免报错) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + return builder.build(); + } else { + // Android 4.0 及以下版本,使用 getNotification() 方法 + return builder.getNotification(); + } + } + + /** + * 辅助方法:截取日志文本,确保显示在3行内(按字符数估算,适配大多数设备) + * 一行约20-30字符,3行约80字符(留冗余,取80字符,超出加省略号) + * @param content 完整崩溃日志 + * @return 3行内的缩略文本 + */ + private static String getShortContent(String content) { + if (content == null || content.isEmpty()) { + return "无崩溃日志"; + } + // 估算3行字符数(80字符,可根据设备屏幕调整,避免因字符过长导致换行超3行) + int maxLength = 80; + if (content.length() <= maxLength) { + return content; // 不足3行,直接返回完整文本 + } else { + // 超出3行,截取前80字符并加省略号(确保视觉上仅显示3行) + return content.substring(0, maxLength) + "..."; + } + } + + /** + * 释放资源(可选,在 Application 销毁时调用,避免内存泄漏) + * @param context 上下文(Application 实例) + */ + public static void release(Context context) { + // 注销静态广播接收器,避免内存泄漏 + if (sCopyReceiver != null && context != null) { + try { + context.unregisterReceiver(sCopyReceiver); + sCopyReceiver = null; + LogUtils.d(TAG, "复制日志广播接收器已注销"); + } catch (Exception e) { + LogUtils.e(TAG, "注销广播接收器失败", e); + } + } } } diff --git a/libappbase/src/main/res/drawable/ic_content_copy.xml b/libappbase/src/main/res/drawable/ic_content_copy.xml new file mode 100644 index 00000000..0a8394fc --- /dev/null +++ b/libappbase/src/main/res/drawable/ic_content_copy.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file