LibAppBase: 添加崩溃通知分享日志功能

- 在崩溃通知中添加"分享日志"按钮,点击可分享崩溃日志
- 新增 ShareLogActivity 窗口类处理分享逻辑
- 崩溃日志先保存到缓存文件,再读取分享
- 移除广播接收器方案,简化实现
- 更新 AndroidManifest 注册新 Activity

修改文件:
- libappbase/src/main/AndroidManifest.xml
- libappbase/src/main/java/.../utils/CrashHandleNotifyUtils.java
- libappbase/src/main/java/.../utils/ShareLogActivity.java (新增)

影响范围: 崩溃通知功能
This commit is contained in:
2026-05-11 20:27:40 +08:00
parent f3114a8121
commit 42112eb677
5 changed files with 286 additions and 72 deletions

View File

@@ -1,8 +1,8 @@
#Created by .winboll/winboll_app_build.gradle
#Mon May 11 19:10:52 HKT 2026
#Mon May 11 20:19:17 CST 2026
stageCount=8
libraryProject=libappbase
baseVersion=15.20
publishVersion=15.20.7
buildCount=0
buildCount=8
baseBetaVersion=15.20.8

View File

@@ -1,8 +1,8 @@
#Created by .winboll/winboll_app_build.gradle
#Mon May 11 19:10:34 HKT 2026
#Mon May 11 20:19:17 CST 2026
stageCount=8
libraryProject=libappbase
baseVersion=15.20
publishVersion=15.20.7
buildCount=0
buildCount=8
baseBetaVersion=15.20.8

View File

@@ -47,6 +47,13 @@
<activity android:name="cc.winboll.studio.libappbase.activities.FTPBackupsActivity"/>
<activity
android:name=".utils.ShareLogActivity"
android:label="ShareLogActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@android:style/Theme.NoDisplay"/>
</application>
</manifest>

View File

@@ -1,11 +1,11 @@
package cc.winboll.studio.libappbase.utils;
import android.app.Application;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.text.TextUtils;
@@ -13,13 +13,19 @@ import cc.winboll.studio.libappbase.CrashHandler;
import cc.winboll.studio.libappbase.LogUtils;
import cc.winboll.studio.libappbase.R;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStreamReader;
/**
* 应用崩溃处理通知实用工具集(类库兼容版)
* 核心功能:作为独立类库使用,发送崩溃通知,点击跳转宿主应用的 GlobalCrashActivity 并传递日志
* 适配说明:移除固定包名依赖,通过外部传入宿主包名,支持任意应用集成使用
* @Author 豆包&ZhanGSKen<zhangsken@qq.com>
* @CreateTime 2025/11/29 21:12:00
* @EditTime 2026/05/11 15:38:21
* @EditTime 2026/05/11 21:55:00
*/
public class CrashHandleNotifyUtils {
@@ -35,17 +41,18 @@ public class CrashHandleNotifyUtils {
private static final int API_LEVEL_ANDROID_12 = 31;
/** PendingIntent.FLAG_IMMUTABLE 常量值API 31+ */
private static final int FLAG_IMMUTABLE = 0x00000040;
/** 通知内容最大行数控制在3行超出部分省略 */
private static final int NOTIFICATION_MAX_LINES = 3;
/** 通知摘要最大长度 */
private static final int SUMMARY_MAX_LENGTH = 200;
/** 展开按钮广播Action */
private static final String ACTION_EXPAND = "cc.winboll.studio.libappbase.ACTION_EXPAND_CRASH";
/** 分享日志请求码 */
private static final int REQUEST_CODE_SHARE_LOG = 0x002;
/** 缓存崩溃日志子目录 */
private static final String CRASH_LOG_CACHE_SUBDIR = "crashnotify";
/** 缓存崩溃日志文件名 */
private static final String CRASH_LOG_CACHE_FILENAME = "crash_log.txt";
private static Context sHostContext = null;
// ====================== 静态成员 ======================
private static String sHostPackageName = "";
private static String sHostAppName = "";
private static Class<?> sReportCrashActivity = null;
private static String sCrashLogCacheFilePath = "";
// ====================== 正则表达式定义 ======================
private static final String REGEX_EXCEPTION_TYPE = "([\\w.]+Exception|[\\w.]+Error)";
@@ -61,18 +68,29 @@ public class CrashHandleNotifyUtils {
* @param errorLog 崩溃日志内容
* @param reportCrashActivity 崩溃详情跳转Activity类
*/
public static void handleUncaughtException(final Application hostApp,
final String hostPackageName,
final String errorLog,
final Class<?> reportCrashActivity) {
public static void handleUncaughtException(final android.app.Application hostApp,
final String hostPackageName,
final String errorLog,
final Class<?> reportCrashActivity) {
LogUtils.d(TAG, "handleUncaughtException 进入方法");
// 校验入参
if (hostApp == null || TextUtils.isEmpty(hostPackageName) || TextUtils.isEmpty(errorLog)) {
LogUtils.e(TAG, "handleUncaughtException 参数为空校验不通过");
return;
}
sHostPackageName = hostPackageName;
final String hostAppName = getHostAppName(hostApp, hostPackageName);
sendCrashNotification(hostApp, hostPackageName, hostAppName, errorLog, reportCrashActivity);
final String crashLogFilePath = saveCrashLogToCache(hostApp, errorLog);
if (TextUtils.isEmpty(crashLogFilePath)) {
LogUtils.e(TAG, "保存崩溃日志到缓存文件失败");
return;
}
sCrashLogCacheFilePath = crashLogFilePath;
final Intent shareIntent = new Intent(hostApp, ShareLogActivity.class);
shareIntent.putExtra(ShareLogActivity.EXTRA_CRASH_LOG_FILEPATH, crashLogFilePath);
shareIntent.putExtra(ShareLogActivity.EXTRA_CRASH_LOG_SUBJECT, "崩溃日志");
shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
final PendingIntent sharePendingIntent = createSharePendingIntent(hostApp, shareIntent);
sendCrashNotification(hostApp, hostPackageName, hostAppName, errorLog, reportCrashActivity, sharePendingIntent);
}
/**
@@ -81,9 +99,9 @@ public class CrashHandleNotifyUtils {
* @param intent 携带崩溃信息Intent
* @param reportCrashActivity 崩溃详情Activity
*/
public static void handleUncaughtException(final Application hostApp,
final Intent intent,
final Class<?> reportCrashActivity) {
public static void handleUncaughtException(final android.app.Application hostApp,
final Intent intent,
final Class<?> reportCrashActivity) {
LogUtils.d(TAG, "handleUncaughtException 重载方法进入");
String hostPackageName = intent.getStringExtra("EXTRA_HOST_PACKAGE_NAME");
if (TextUtils.isEmpty(hostPackageName)) {
@@ -94,6 +112,16 @@ public class CrashHandleNotifyUtils {
handleUncaughtException(hostApp, hostPackageName, errorLog, reportCrashActivity);
}
/**
* 资源释放
* @param hostContext 宿主上下文
*/
public static void release(final Context hostContext) {
LogUtils.d(TAG, "release 执行资源释放");
sHostPackageName = "";
sCrashLogCacheFilePath = "";
}
// ====================== 内部工具方法 ======================
/**
* 获取宿主应用名称
@@ -104,14 +132,87 @@ public class CrashHandleNotifyUtils {
private static String getHostAppName(final Context hostContext, final String hostPackageName) {
try {
return hostContext.getPackageManager()
.getApplicationLabel(hostContext.getPackageManager()
.getApplicationInfo(hostPackageName, 0)).toString();
.getApplicationLabel(hostContext.getPackageManager()
.getApplicationInfo(hostPackageName, 0)).toString();
} catch (Exception e) {
LogUtils.e(TAG, "获取宿主应用名称失败", e);
return "未知应用";
}
}
/**
* 保存崩溃日志到缓存文件
* @param hostContext 宿主上下文
* @param crashLog 崩溃日志内容
* @return 缓存文件路径,失败返回空字符串
*/
private static String saveCrashLogToCache(final Context hostContext, final String crashLog) {
if (hostContext == null || TextUtils.isEmpty(crashLog)) {
return "";
}
BufferedReader reader = null;
FileOutputStream fos = null;
try {
final File cacheDir = new File(hostContext.getCacheDir(), CRASH_LOG_CACHE_SUBDIR);
if (!cacheDir.exists()) {
cacheDir.mkdirs();
}
final File cacheFile = new File(cacheDir, CRASH_LOG_CACHE_FILENAME);
if (cacheFile.exists()) {
cacheFile.delete();
}
cacheFile.createNewFile();
fos = new FileOutputStream(cacheFile);
fos.write(crashLog.getBytes("UTF-8"));
fos.flush();
LogUtils.d(TAG, "saveCrashLogToCache 保存崩溃日志到缓存: " + cacheFile.getAbsolutePath());
return cacheFile.getAbsolutePath();
} catch (Exception e) {
LogUtils.e(TAG, "saveCrashLogToCache 异常", e);
return "";
} finally {
if (reader != null) {
try { reader.close(); } catch (Exception e) {}
}
if (fos != null) {
try { fos.close(); } catch (Exception e) {}
}
}
}
/**
* 读取缓存的崩溃日志文件内容
* @param hostContext 宿主上下文
* @return 日志内容,失败返回空字符串
*/
private static String readCachedCrashLog(final Context hostContext) {
if (hostContext == null || TextUtils.isEmpty(sCrashLogCacheFilePath)) {
return "";
}
BufferedReader reader = null;
try {
final File cacheFile = new File(sCrashLogCacheFilePath);
if (!cacheFile.exists()) {
LogUtils.w(TAG, "readCachedCrashLog 缓存文件不存在");
return "";
}
reader = new BufferedReader(new InputStreamReader(new FileInputStream(cacheFile), "UTF-8"));
final StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append("\n");
}
return sb.toString();
} catch (Exception e) {
LogUtils.e(TAG, "readCachedCrashLog 异常", e);
return "";
} finally {
if (reader != null) {
try { reader.close(); } catch (Exception e) {}
}
}
}
/**
* 发送崩溃系统通知
* @param hostContext 宿主上下文
@@ -119,45 +220,65 @@ public class CrashHandleNotifyUtils {
* @param hostAppName 宿主应用名
* @param errorLog 崩溃日志
* @param reportCrashActivity 跳转Activity
* @param sharePendingIntent 分享日志PendingIntent
*/
private static void sendCrashNotification(final Context hostContext,
final String hostPackageName,
final String hostAppName,
final String errorLog,
final Class<?> reportCrashActivity) {
private static void sendCrashNotification(final Context hostContext,
final String hostPackageName,
final String hostAppName,
final String errorLog,
final Class<?> reportCrashActivity,
final PendingIntent sharePendingIntent) {
final NotificationManager notificationManager =
(NotificationManager) hostContext.getSystemService(Context.NOTIFICATION_SERVICE);
(NotificationManager) hostContext.getSystemService(Context.NOTIFICATION_SERVICE);
if (notificationManager == null) {
LogUtils.e(TAG, "获取NotificationManager失败");
return;
}
// 8.0以上创建通知渠道
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createCrashNotifyChannel(hostContext, notificationManager);
}
final PendingIntent jumpIntent = getGlobalCrashPendingIntent(hostContext,
hostPackageName, errorLog, reportCrashActivity);
hostPackageName, errorLog, reportCrashActivity);
if (jumpIntent == null) {
LogUtils.e(TAG, "构建跳转PendingIntent失败");
return;
}
final Notification notification = buildNotification(hostContext, hostPackageName, hostAppName, errorLog, jumpIntent);
final Notification notification = buildNotification(hostContext, hostPackageName, hostAppName, errorLog, jumpIntent, sharePendingIntent);
notificationManager.notify(CRASH_NOTIFY_ID, notification);
LogUtils.d(TAG, "崩溃通知发送成功,宿主包名:" + hostPackageName);
}
/**
* 创建分享日志PendingIntent
* @param hostContext 宿主上下文
* @param shareIntent 分享意图
* @return PendingIntent实例
*/
private static PendingIntent createSharePendingIntent(final Context hostContext, final Intent shareIntent) {
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
if (Build.VERSION.SDK_INT >= API_LEVEL_ANDROID_12) {
flags |= FLAG_IMMUTABLE;
}
return PendingIntent.getActivity(
hostContext,
REQUEST_CODE_SHARE_LOG,
shareIntent,
flags
);
}
/**
* 创建通知渠道适配Android O及以上
* @param hostContext 宿主上下文
* @param notificationManager 通知管理器
*/
private static void createCrashNotifyChannel(final Context hostContext,
final NotificationManager notificationManager) {
final NotificationManager notificationManager) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
android.app.NotificationChannel channel = new android.app.NotificationChannel(
CRASH_NOTIFY_CHANNEL_ID,
CRASH_NOTIFY_CHANNEL_NAME,
NotificationManager.IMPORTANCE_DEFAULT
CRASH_NOTIFY_CHANNEL_ID,
CRASH_NOTIFY_CHANNEL_NAME,
NotificationManager.IMPORTANCE_DEFAULT
);
channel.setDescription("应用崩溃通知(由 WinBoLL Studio 类库提供,点击查看详情)");
notificationManager.createNotificationChannel(channel);
@@ -174,9 +295,9 @@ private static void sendCrashNotification(final Context hostContext,
* @return PendingIntent实例
*/
private static PendingIntent getGlobalCrashPendingIntent(final Context hostContext,
final String hostPackageName,
final String errorLog,
final Class<?> reportCrashActivity) {
final String hostPackageName,
final String errorLog,
final Class<?> reportCrashActivity) {
try {
final Intent crashIntent = new Intent(hostContext, reportCrashActivity);
crashIntent.setPackage(hostPackageName);
@@ -187,11 +308,11 @@ private static void sendCrashNotification(final Context hostContext,
if (Build.VERSION.SDK_INT >= API_LEVEL_ANDROID_12) {
flags |= FLAG_IMMUTABLE;
}
return PendingIntent.getActivity(
hostContext,
CRASH_NOTIFY_ID,
crashIntent,
flags
return PendingIntent.getActivity(
hostContext,
CRASH_NOTIFY_ID,
crashIntent,
flags
);
} catch (Exception e) {
LogUtils.e(TAG, "构建跳转Intent异常", e);
@@ -206,32 +327,36 @@ return PendingIntent.getActivity(
* @param hostAppName 宿主应用名
* @param errorLog 崩溃日志
* @param jumpIntent 点击跳转意图
* @param shareIntent 分享日志意图
* @return 构建好的Notification
*/
@SuppressWarnings("deprecation")
private static Notification buildNotification(final Context hostContext,
final String hostPackageName,
final String hostAppName,
final String errorLog,
final PendingIntent jumpIntent) {
final String hostPackageName,
final String hostAppName,
final String errorLog,
final PendingIntent jumpIntent,
final PendingIntent shareIntent) {
Notification.Builder builder = new Notification.Builder(hostContext);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
builder.setChannelId(CRASH_NOTIFY_CHANNEL_ID);
}
String briefInfo = extractBriefInfo(errorLog);
Notification.BigTextStyle bigTextStyle = new Notification.BigTextStyle();
final String briefInfo = extractBriefInfo(errorLog);
final Notification.BigTextStyle bigTextStyle = new Notification.BigTextStyle();
bigTextStyle.setBigContentTitle(hostAppName + " 崩溃");
bigTextStyle.bigText(briefInfo);
bigTextStyle.setSummaryText("点击查看详情");
builder.setStyle(bigTextStyle);
builder.setSmallIcon(hostContext.getApplicationInfo().icon)
.setContentTitle(hostAppName + " 崩溃")
.setContentText(briefInfo.split("\n")[0])
.setContentIntent(jumpIntent)
.setAutoCancel(true)
.setWhen(System.currentTimeMillis())
.setPriority(Notification.PRIORITY_DEFAULT);
.setContentTitle(hostAppName + " 崩溃")
.setContentText(briefInfo.split("\n")[0])
.setContentIntent(jumpIntent)
.setAutoCancel(true)
.setWhen(System.currentTimeMillis())
.setPriority(Notification.PRIORITY_DEFAULT);
if (shareIntent != null) {
builder.addAction(android.R.drawable.ic_menu_send, "分享日志", shareIntent);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
return builder.build();
} else {
@@ -248,10 +373,10 @@ return PendingIntent.getActivity(
if (errorLog == null || errorLog.isEmpty()) {
return "无崩溃日志";
}
String brief = extractBriefInfo(errorLog);
String firstLine = brief.split("\n")[0];
final String brief = extractBriefInfo(errorLog);
final String firstLine = brief.split("\n")[0];
if (firstLine.length() > 80) {
firstLine = firstLine.substring(0, 80) + "...";
return firstLine.substring(0, 80) + "...";
}
return firstLine;
}
@@ -265,7 +390,7 @@ return PendingIntent.getActivity(
if (crashLog == null || crashLog.isEmpty()) {
return "无崩溃日志";
}
StringBuilder brief = new StringBuilder();
final StringBuilder brief = new StringBuilder();
try {
java.util.regex.Pattern exceptionPattern = java.util.regex.Pattern.compile(REGEX_EXCEPTION_TYPE);
java.util.regex.Matcher exceptionMatcher = exceptionPattern.matcher(crashLog);
@@ -315,13 +440,4 @@ return PendingIntent.getActivity(
}
return brief.toString();
}
/**
* 资源释放预留方法
* @param hostContext 宿主上下文
*/
public static void release(final Context hostContext) {
LogUtils.d(TAG, "CrashHandleNotifyUtils 执行资源释放");
}
}
}

View File

@@ -0,0 +1,91 @@
package cc.winboll.studio.libappbase.utils;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import android.widget.Toast;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStreamReader;
/**
* 分享崩溃日志窗口类
* @Author ZhanGSKen<zhangsken@qq.com>
* @CreateTime 2026/05/11 22:30:00
*/
public class ShareLogActivity extends Activity {
public static final String TAG = "ShareLogActivity";
public static final String EXTRA_CRASH_LOG_FILEPATH = "crash_log_filepath";
public static final String EXTRA_CRASH_LOG_SUBJECT = "crash_log_subject";
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Log.d(TAG, "onCreate 进入方法");
final Intent intent = getIntent();
if (intent == null) {
Log.e(TAG, "onCreate intent 为空");
finish();
return;
}
final String crashLogFilePath = intent.getStringExtra(EXTRA_CRASH_LOG_FILEPATH);
if (crashLogFilePath == null || crashLogFilePath.isEmpty()) {
Log.e(TAG, "onCreate crashLogFilePath 为空");
Toast.makeText(this, "日志文件路径无效", Toast.LENGTH_SHORT).show();
finish();
return;
}
final String subject = intent.getStringExtra(EXTRA_CRASH_LOG_SUBJECT);
handleShareCrashLog(crashLogFilePath, subject);
}
private void handleShareCrashLog(final String crashLogFilePath, final String subject) {
Log.d(TAG, "handleShareCrashLog crashLogFilePath = " + crashLogFilePath);
final File crashLogFile = new File(crashLogFilePath);
if (!crashLogFile.exists()) {
Log.e(TAG, "handleShareCrashLog 文件不存在");
Toast.makeText(this, "日志文件不存在", Toast.LENGTH_SHORT).show();
finish();
return;
}
BufferedReader reader = null;
try {
reader = new BufferedReader(new InputStreamReader(new FileInputStream(crashLogFile), "UTF-8"));
final StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append("\n");
}
final String logContent = sb.toString();
final Intent shareIntent = new Intent(Intent.ACTION_SEND);
shareIntent.setType("text/plain");
shareIntent.putExtra(Intent.EXTRA_TEXT, logContent);
if (subject != null && !subject.isEmpty()) {
shareIntent.putExtra(Intent.EXTRA_SUBJECT, subject);
} else {
shareIntent.putExtra(Intent.EXTRA_SUBJECT, "崩溃日志");
}
startActivity(Intent.createChooser(shareIntent, "分享日志到"));
Log.d(TAG, "handleShareCrashLog 分享成功");
} catch (Exception e) {
Log.e(TAG, "handleShareCrashLog 异常", e);
Toast.makeText(this, "分享失败: " + e.getMessage(), Toast.LENGTH_SHORT).show();
} finally {
if (reader != null) {
try { reader.close(); } catch (Exception e) {}
}
finish();
}
}
}