Compare commits

..

5 Commits

8 changed files with 327 additions and 52 deletions

View File

@@ -1,8 +1,8 @@
#Created by .winboll/winboll_app_build.gradle
#Sat Nov 29 21:41:11 HKT 2025
stageCount=4
#Sun Nov 30 03:42:05 HKT 2025
stageCount=5
libraryProject=libappbase
baseVersion=15.11
publishVersion=15.11.3
publishVersion=15.11.4
buildCount=0
baseBetaVersion=15.11.4
baseBetaVersion=15.11.5

View File

@@ -2,7 +2,7 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="cc.winboll.studio.appbase">
<application
android:name=".App"
android:icon="@drawable/ic_winboll"
@@ -11,7 +11,7 @@
android:resizeableActivity="true"
android:process=":App">
<activity
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:exported="true"
@@ -29,13 +29,15 @@
</intent-filter>
</activity>
<activity android:name=".GlobalApplication$CrashActivity"/>
<meta-data
android:name="android.max_aspect"
android:value="4.0"/>
<activity android:name="cc.winboll.studio.libappbase.activities.CrashCopyReceiverActivity"/>
</application>
</manifest>
</manifest>

View File

@@ -1,8 +1,8 @@
#Created by .winboll/winboll_app_build.gradle
#Sat Nov 29 21:41:11 HKT 2025
stageCount=4
#Sun Nov 30 03:42:05 HKT 2025
stageCount=5
libraryProject=libappbase
baseVersion=15.11
publishVersion=15.11.3
publishVersion=15.11.4
buildCount=0
baseBetaVersion=15.11.4
baseBetaVersion=15.11.5

View File

@@ -3,8 +3,9 @@
xmlns:android="http://schemas.android.com/apk/res/android"
package="cc.winboll.studio.libappbase">
<application>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<activity
android:name=".CrashHandler$CrashActivity"
android:label="CrashActivity"
@@ -28,6 +29,21 @@
</activity>
<!-- 崩溃通知复制动作接收活动(透明无界面) -->
<activity
android:name="cc.winboll.studio.libappbase.CrashCopyReceiverActivity"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:launchMode="singleTask"
android:excludeFromRecents="true"
android:taskAffinity=""
android:exported="true">
<!-- 注册复制动作的意图过滤器,接收通知按钮点击 -->
<intent-filter>
<action android:name="cc.winboll.studio.action.COPY_CRASH_LOG" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -121,6 +121,18 @@ public class ToastUtils {
LogUtils.d(TAG, "ToastUtils 初始化完成,上下文已设置");
}
// ===================================== 新增isInited() 方法 =====================================
/**
* 判断 ToastUtils 是否已初始化(供外部调用,如 CrashHandleNotifyUtils 中的复制提示)
* @return true已初始化可正常显示吐司false未初始化/已释放(无法正常显示)
*/
public static boolean isInited() {
ToastUtils instance = getInstance();
// 双重校验1. 未释放 2. 上下文已设置(确保初始化完成)
return !instance.isReleased && instance.mContext != null;
}
// ===================================== 新增结束 =====================================
/**
* 外部接口:显示短时长吐司
* @param message 吐司内容
@@ -243,7 +255,6 @@ public class ToastUtils {
instance.mWorkerThread.join(1000);
} catch (InterruptedException e) {
LogUtils.d(TAG, e, Thread.currentThread().getStackTrace());
//LogUtils.e(TAG, "线程退出异常", e);
Thread.currentThread().interrupt();
}
instance.mWorkerThread = null;

View File

@@ -0,0 +1,151 @@
package cc.winboll.studio.libappbase.activities;
import android.app.Activity;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.widget.Toast;
import cc.winboll.studio.libappbase.LogUtils;
import cc.winboll.studio.libappbase.ToastUtils;
/**
* @Author ZhanGSKen<zhangsken@qq.com>
* @Date 2025/12/01 10:00
* @Describe 崩溃通知复制动作接收活动(透明无界面)
* 专门接收崩溃通知中“复制日志”按钮的点击事件,处理崩溃日志复制到剪贴板逻辑
* 优势:相比广播接收器,活动在应用崩溃后仍能被系统唤醒,确保复制功能稳定生效
*/
public class CrashCopyReceiverActivity extends Activity {
/** 日志 TAG标识当前类日志来源 */
public static final String TAG = "CrashCopyReceiverActivity";
/** 复制动作 Action需与 CrashHandleNotifyUtils 中一致) */
public static final String ACTION_COPY_CRASH_LOG = "cc.winboll.studio.action.COPY_CRASH_LOG";
/** 崩溃日志 Extra 键(需与 CrashHandleNotifyUtils 中一致) */
public static final String EXTRA_CRASH_LOG = "EXTRA_CRASH_LOG";
/** 复制成功提示文本 */
private static final String COPY_SUCCESS_TIP = "崩溃日志已复制到剪贴板";
/** 活动自动关闭延迟(毫秒):避免占用资源,复制完成后快速关闭 */
private static final long AUTO_FINISH_DELAY = 500;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 强制设置活动透明(兼容不同主题配置,避免出现白色界面)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
getWindow().setFlags(
android.view.WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS,
android.view.WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS
);
getWindow().setBackgroundDrawable(new android.graphics.drawable.ColorDrawable(android.graphics.Color.TRANSPARENT));
}
// 处理复制按钮点击事件
handleCopyAction(getIntent());
}
/**
* 处理崩溃日志复制逻辑
* @param intent 接收的意图(携带崩溃日志)
*/
private void handleCopyAction(Intent intent) {
if (intent == null || !ACTION_COPY_CRASH_LOG.equals(intent.getAction())) {
LogUtils.e(TAG, "非崩溃日志复制意图,直接关闭活动");
finish();
return;
}
// 从意图中获取崩溃日志
String crashLog = intent.getStringExtra(EXTRA_CRASH_LOG);
if (crashLog == null || crashLog.isEmpty()) {
LogUtils.e(TAG, "崩溃日志为空,无法复制");
showTip("复制失败:崩溃日志为空");
finish();
return;
}
// 复制日志到剪贴板
if (copyTextToClipboard(crashLog)) {
LogUtils.d(TAG, "崩溃日志复制成功,长度:" + crashLog.length() + "字符");
showTip(COPY_SUCCESS_TIP);
} else {
LogUtils.e(TAG, "崩溃日志复制失败");
showTip("复制失败,请重试");
}
// 延迟关闭活动(确保提示能正常显示,用户有感知)
new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
@Override
public void run() {
finish(); // 关闭透明活动,释放资源
// 适配 Android 12+API 31+):避免活动留在任务栈中(修复 S 常量报错)
if (Build.VERSION.SDK_INT >= 31) { // 核心修复:用 31 替代 Build.VERSION_CODES.S
finishAndRemoveTask();
}
}
}, AUTO_FINISH_DELAY);
}
/**
* 复制文本到系统剪贴板兼容全Android版本
* @param text 需复制的文本(崩溃日志)
* @return true复制成功false复制失败
*/
private boolean copyTextToClipboard(String text) {
try {
// 适配 Android 11+API 30+)剪贴板 API
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
android.content.ClipboardManager clipboard = (android.content.ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
android.content.ClipData clipData = android.content.ClipData.newPlainText("崩溃日志", text);
clipboard.setPrimaryClip(clipData);
} else {
// 适配 Android 10 及以下版本剪贴板 API
android.text.ClipboardManager clipboard = (android.text.ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
clipboard.setText(text);
}
return true;
} catch (Exception e) {
LogUtils.e(TAG, "复制文本到剪贴板失败", e);
return false;
}
}
/**
* 显示提示信息(优先使用 ToastUtils失败则降级为系统 Toast
* @param tip 提示内容
*/
private void showTip(String tip) {
try {
// 优先使用项目封装的 ToastUtils确保样式统一
if (ToastUtils.isInited()) {
ToastUtils.show(tip);
} else {
// 降级使用系统 Toast确保提示能正常显示
Toast.makeText(this, tip, Toast.LENGTH_SHORT).show();
}
} catch (Exception e) {
LogUtils.e(TAG, "显示提示失败", e);
}
}
/**
* 处理重复意图(避免多次触发复制)
*/
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent); // 更新当前意图
handleCopyAction(intent); // 重新处理复制动作
}
/**
* 禁止活动旋转时重建(避免复制逻辑重复执行)
*/
@Override
public void onConfigurationChanged(android.content.res.Configuration newConfig) {
super.onConfigurationChanged(newConfig);
}
}

View File

@@ -7,14 +7,16 @@ import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import cc.winboll.studio.libappbase.activities.CrashCopyReceiverActivity; // 导入新增的复制活动
import cc.winboll.studio.libappbase.CrashHandler;
import cc.winboll.studio.libappbase.LogUtils;
import cc.winboll.studio.libappbase.R;
/**
* @Author ZhanGSKen&豆包大模型<zhangsken@qq.com>
* @Date 2025/11/29 21:12
* @Describe 应用崩溃处理通知实用工具集
* 核心功能:应用崩溃时捕获错误日志,发送通知到系统通知栏,方便用户查看崩溃信息
* 核心功能:应用崩溃时捕获错误日志,发送通知到系统通知栏3行内容省略+复制按钮点击复制按钮唤醒CrashCopyReceiverActivity完成日志拷贝
*/
public class CrashHandleNotifyUtils {
@@ -30,11 +32,15 @@ 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;
/** 复制按钮请求码(区分多个 PendingIntent */
private static final int REQUEST_CODE_COPY = 0x002;
/**
* 处理未捕获异常(核心方法)
* 1. 提取应用名称和崩溃日志;
* 2. 创建并发送系统通知(标题:应用名称,内容:崩溃日志
* 2. 创建并发送系统通知(3行内容省略+复制按钮
* 3. 兼容 Android 8.0+ 通知渠道机制,适配低版本系统。
* @param app 应用全局 Application 实例(用于获取上下文、应用信息)
* @param intent 存储崩溃信息的意图extra 中携带崩溃日志)
@@ -51,7 +57,7 @@ public class CrashHandleNotifyUtils {
return;
}
// 3. 发送崩溃通知到通知栏
// 3. 发送崩溃通知到通知栏3行省略+复制按钮点击唤醒CrashCopyReceiverActivity
sendCrashNotification(app, appName, errorLog);
}
@@ -74,7 +80,7 @@ public class CrashHandleNotifyUtils {
}
/**
* 发送崩溃通知到系统通知栏
* 发送崩溃通知到系统通知栏(核心修改:替换为活动唤醒方案)
* @param context 上下文Application 实例,确保后台也能发送)
* @param title 通知标题(应用名称)
* @param content 通知内容(崩溃日志)
@@ -92,11 +98,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行内容省略+复制按钮
Notification notification = buildNotification(context, title, content, launchPendingIntent, copyPendingIntent);
// 5. 发送通知指定通知ID重复发送同ID会覆盖原通知
notificationManager.notify(CRASH_NOTIFY_ID, notification);
@@ -118,7 +125,7 @@ public class CrashHandleNotifyUtils {
NotificationManager.IMPORTANCE_DEFAULT // 重要性:默认(不会弹窗,有声音提示)
);
// 可选:设置渠道描述(用户在设置中可见)
channel.setDescription("用于显示应用崩溃信息,帮助定位问题");
channel.setDescription("用于显示应用崩溃信息,支持复制日志");
// 注册通知渠道到系统
notificationManager.createNotificationChannel(channel);
LogUtils.d(TAG, "崩溃通知渠道创建成功:" + CRASH_NOTIFY_CHANNEL_ID);
@@ -126,12 +133,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 +147,133 @@ 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+ 所有版本
* 构建复制按钮意图(核心修改:点击唤醒 CrashCopyReceiverActivity 完成日志拷贝
* 替代原广播意图,确保应用崩溃后仍能稳定触发复制
* @param context 上下文
* @param title 通知标题
* @param content 通知内容
* @param pendingIntent 通知点击意图
* @param errorLog 崩溃日志(需要复制的内容)
* @return 唤醒复制活动的 PendingIntent
*/
private static PendingIntent getCopyPendingIntent(Context context, String errorLog) {
// 1. 构建唤醒 CrashCopyReceiverActivity 的显式意图(指定目标活动,避免意图匹配失败)
Intent copyIntent = new Intent(context, CrashCopyReceiverActivity.class);
// 设置动作与Activity中匹配可选但建议保留便于扩展
copyIntent.setAction(CrashCopyReceiverActivity.ACTION_COPY_CRASH_LOG);
// 携带完整崩溃日志键与Activity中一致
copyIntent.putExtra(CrashCopyReceiverActivity.EXTRA_CRASH_LOG, errorLog);
// 设置标志:确保活动在应用崩溃后仍能被唤醒,且不重复创建
copyIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
// 2. 构建 PendingIntent活动类型优先级高于广播
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
// 适配 Android 12+API 31+):添加 FLAG_IMMUTABLE 避免安全警告
if (Build.VERSION.SDK_INT >= API_LEVEL_ANDROID_12) {
flags |= FLAG_IMMUTABLE;
}
// 3. 返回活动类型的 PendingIntent替代原广播类型
return PendingIntent.getActivity(
context,
REQUEST_CODE_COPY, // 唯一请求码,区分主界面意图
copyIntent,
flags
);
}
/**
* 构建通知实例核心修复3行内容省略+复制按钮)
* @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 // 按钮点击意图唤醒CrashCopyReceiverActivity
);
}
// 构建通知并返回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+:确保文本显示正常,构建并返回通知
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) + "...";
}
}
/**
* 释放资源(删除原广播注销逻辑,仅保留空实现便于兼容旧代码调用)
* @param context 上下文Application 实例)
*/
public static void release(Context context) {
// 因已移除广播接收器,此处仅保留空实现,避免调用方报错
LogUtils.d(TAG, "CrashHandleNotifyUtils 资源释放完成(无广播接收器需注销)");
}
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportHeight="24"
android:viewportWidth="24">
<path
android:fillColor="#ff000000"
android:pathData="M19,21H8V7H19M19,5H8A2,2 0,0 0,6 7V21A2,2 0,0 0,8 23H19A2,2 0,0 0,21 21V7A2,2 0,0 0,19 5M16,1H4A2,2 0,0 0,2 3V17H4V3H16V1Z"/>
</vector>