using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Reflection; using System.Security.Cryptography; using System.Text; using UnityEditor; using UnityEngine; using Debug = UnityEngine.Debug; namespace CCFramework.CrashReport.Editor { public static class BuglyAndroidSymbolUtility { public delegate void LogHandler(string message, LogType type = LogType.Log); public const string BuglyDownloadUrl = "https://bugly.tds.tencent.com/docs/assets/files/bugly_symbol_oversea_v3.4.23-a449c7a62d7792de76fca1af623584f5.zip"; public const string BuglyToolGuideUrl = "https://bugly.tds.tencent.com/docs/tutorial/symbol/tool/"; private const string SymbolFolderSuffix = "_bugly_symbols"; private const string SymbolPackageSuffix = "_bugly_symbols.zip"; private const string UploadStagingDirectory = "Library/BuglySymbolUpload"; public static void ApplyAndroidSymbolSettings(BuildProfile profile, CrashReportBuglyProfileSettings settings, LogHandler log) { if (!IsAndroidProfile(profile) || settings == null || !settings.enableAndroidSymbolArchive) { return; } EditorUserBuildSettings.androidCreateSymbols = AndroidCreateSymbols.Debugging; WriteLog(log, "已启用 Android Debugging 符号表生成,构建后会产出 native-debug-symbols.zip。"); } public static BuglySymbolArchiveResult ArchiveAfterBuild( BuildProfile profile, CrashReportBuglyProfileSettings settings, string buildOutputPath, LogHandler log) { BuglySymbolArchiveResult result = new BuglySymbolArchiveResult(); if (!IsAndroidProfile(profile) || settings == null || !settings.enableAndroidSymbolArchive) { result.message = "当前不是 Android 构建或未启用 Bugly 符号表归档。"; WriteLog(log, result.message); return result; } string resolvedBuildOutputPath = ResolveFullPath(buildOutputPath); string buildDirectory = Path.GetDirectoryName(resolvedBuildOutputPath); string buildName = Path.GetFileNameWithoutExtension(resolvedBuildOutputPath); string archiveDirectory = Path.Combine(buildDirectory, buildName + SymbolFolderSuffix); Directory.CreateDirectory(archiveDirectory); string nativeSymbolPath = FindNativeDebugSymbols(profile); string mappingPath = FindMappingFile(profile, resolvedBuildOutputPath); if (!string.IsNullOrWhiteSpace(nativeSymbolPath)) { result.nativeSymbolPath = Path.Combine(archiveDirectory, buildName + "_native-debug-symbols.zip"); File.Copy(nativeSymbolPath, result.nativeSymbolPath, true); WriteLog(log, $"已归档 Bugly SO 符号表:{result.nativeSymbolPath}"); } else { WriteLog(log, "未找到 native-debug-symbols.zip,Native Crash 可能无法还原。", LogType.Warning); } if (!string.IsNullOrWhiteSpace(mappingPath)) { result.mappingPath = Path.Combine(archiveDirectory, buildName + "_mapping.txt"); File.Copy(mappingPath, result.mappingPath, true); WriteLog(log, $"已归档 Bugly mapping 文件:{result.mappingPath}"); } result.archiveDirectory = archiveDirectory; result.manifestPath = WriteManifest(profile, resolvedBuildOutputPath, archiveDirectory, result); result.packagePath = CreatePackage(archiveDirectory, Path.Combine(buildDirectory, buildName + SymbolPackageSuffix)); result.hasSymbols = File.Exists(result.nativeSymbolPath) || File.Exists(result.mappingPath); result.message = result.hasSymbols ? "Bugly 符号表归档完成。" : "Bugly 符号表归档完成,但没有找到可上传的符号表文件。"; WriteLog(log, $"Bugly 符号表包:{result.packagePath}"); return result; } public static BuglySymbolUploadResult UploadPreparedSymbols( BuildProfile profile, CrashReportBuglyProfileSettings settings, BuglySymbolArchiveResult archive, LogHandler log) { if (profile == null || settings == null) { return FailUpload("构建配置为空,无法上传 Bugly 符号表。", log); } if (archive == null || !archive.hasSymbols) { return FailUpload("没有可上传的 Bugly 符号表文件。", log); } string javaPath = string.IsNullOrWhiteSpace(settings.buglyJavaPath) ? "java" : settings.buglyJavaPath.Trim(); string toolPath = ResolveSymbolToolPath(settings); if (string.IsNullOrWhiteSpace(settings.buglyAppId)) { return FailUpload("Bugly App ID 为空。", log); } if (string.IsNullOrWhiteSpace(settings.buglyAppKey)) { return FailUpload("Bugly App Key 为空。", log); } if (string.IsNullOrWhiteSpace(toolPath) || !File.Exists(toolPath)) { return FailUpload($"Bugly 符号表工具不存在:{toolPath}", log); } BuglySymbolArchiveResult uploadArchive = PrepareUploadInputs(profile, archive, log); List arguments = new List { "-jar", Quote(toolPath), "-appid", Quote(settings.buglyAppId.Trim()), "-appkey", Quote(settings.buglyAppKey.Trim()), "-version", Quote(profile.version), "-buildNo", Quote(profile.buildNumber.ToString()), "-platform", "Android" }; if (PathExists(uploadArchive.nativeSymbolPath)) { arguments.Add("-inputSymbol"); arguments.Add(Quote(uploadArchive.nativeSymbolPath)); } if (File.Exists(uploadArchive.mappingPath)) { arguments.Add("-inputMapping"); arguments.Add(Quote(uploadArchive.mappingPath)); } string argumentText = string.Join(" ", arguments); WriteLog(log, $"开始上传 Bugly 符号表:{javaPath} {MaskUploadArguments(argumentText)}"); ProcessStartInfo startInfo = new ProcessStartInfo { FileName = javaPath, Arguments = argumentText, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, StandardOutputEncoding = GetBuglyToolOutputEncoding(), StandardErrorEncoding = GetBuglyToolOutputEncoding() }; try { using (Process process = Process.Start(startInfo)) { string output = process.StandardOutput.ReadToEnd(); string error = process.StandardError.ReadToEnd(); process.WaitForExit(); BuglySymbolUploadResult result = new BuglySymbolUploadResult { success = process.ExitCode == 0 && IsBuglyUploadOutputSuccessful(output, error), exitCode = process.ExitCode, output = output, error = error, command = javaPath + " " + MaskUploadArguments(argumentText) }; result.logPath = WriteUploadLog(archive.archiveDirectory, result); if (result.success) { WriteLog(log, $"Bugly 符号表上传完成,日志:{result.logPath}"); } else { WriteLog(log, $"Bugly 符号表上传失败,ExitCode={result.exitCode},日志:{result.logPath}", LogType.Error); } return result; } } catch (Exception e) { return FailUpload($"执行 Bugly 符号表上传失败:{e.Message}", log); } } public static string BuildUploadCommandTemplate(BuildProfile profile, CrashReportBuglyProfileSettings settings, bool maskSecret) { if (profile == null || settings == null) { return string.Empty; } string buildOutputPath = ResolveFullPath(BuildPipelineCore.GetExpectedBuildOutputPath(profile)); string buildDirectory = Path.GetDirectoryName(buildOutputPath); string buildName = Path.GetFileNameWithoutExtension(buildOutputPath); string archiveDirectory = Path.Combine(buildDirectory, buildName + SymbolFolderSuffix); string appKey = string.IsNullOrWhiteSpace(settings.buglyAppKey) ? "" : settings.buglyAppKey.Trim(); if (maskSecret && !string.IsNullOrWhiteSpace(settings.buglyAppKey)) { appKey = "******"; } return (string.IsNullOrWhiteSpace(settings.buglyJavaPath) ? "java" : settings.buglyJavaPath.Trim()) + " -jar " + Quote(ResolveSymbolToolPath(settings)) + " -appid " + Quote(string.IsNullOrWhiteSpace(settings.buglyAppId) ? "" : settings.buglyAppId.Trim()) + " -appkey " + Quote(appKey) + " -version " + Quote(profile.version) + " -buildNo " + Quote(profile.buildNumber.ToString()) + " -platform Android" + " -inputSymbol " + Quote(Path.Combine(archiveDirectory, buildName + "_native-debug-symbols.zip")) + " -inputMapping " + Quote(Path.Combine(archiveDirectory, buildName + "_mapping.txt")); } public static string ResolveSymbolToolPath(CrashReportBuglyProfileSettings settings) { if (settings != null && !string.IsNullOrWhiteSpace(settings.buglySymbolToolPath)) { string configuredPath = ResolveFullPath(settings.buglySymbolToolPath); if (File.Exists(configuredPath)) { return configuredPath; } } string bundledRoot = Path.Combine(GetProjectRoot(), "Tools", "BuglySymbolTool"); if (!Directory.Exists(bundledRoot)) { return string.Empty; } string newestPath = string.Empty; DateTime newestWriteTime = DateTime.MinValue; foreach (string jarPath in Directory.GetFiles(bundledRoot, "*.jar", SearchOption.AllDirectories)) { DateTime writeTime = File.GetLastWriteTimeUtc(jarPath); if (writeTime > newestWriteTime) { newestPath = jarPath; newestWriteTime = writeTime; } } return newestPath; } public static string ToProfilePath(string path) { if (string.IsNullOrWhiteSpace(path)) { return string.Empty; } string fullPath = ResolveFullPath(path); string projectRoot = GetProjectRoot().TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); string rootPrefix = projectRoot + Path.DirectorySeparatorChar; if (fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase)) { return fullPath.Substring(rootPrefix.Length).Replace(Path.DirectorySeparatorChar, '/'); } return path; } private static bool IsAndroidProfile(BuildProfile profile) { if (profile == null) { return false; } Type profileType = profile.GetType(); string targetPlatform = GetStringField(profileType, profile, "targetPlatform"); string buildTargetGroup = GetStringField(profileType, profile, "buildTargetGroup"); string buildTarget = GetStringField(profileType, profile, "buildTarget"); bool hasExplicitPlatformValue = !string.IsNullOrWhiteSpace(targetPlatform) || !string.IsNullOrWhiteSpace(buildTargetGroup) || !string.IsNullOrWhiteSpace(buildTarget); return IsPlatformValue(targetPlatform, "Android") || IsPlatformValue(buildTargetGroup, "Android") || IsPlatformValue(buildTarget, "Android") || (!hasExplicitPlatformValue && EditorUserBuildSettings.activeBuildTarget == BuildTarget.Android); } private static string GetStringField(Type ownerType, object instance, string fieldName) { FieldInfo field = ownerType.GetField(fieldName, BindingFlags.Public | BindingFlags.Instance); return field?.GetValue(instance) as string ?? string.Empty; } private static bool IsPlatformValue(string value, string expectedValue) { return !string.IsNullOrWhiteSpace(value) && string.Equals(value, expectedValue, StringComparison.OrdinalIgnoreCase); } private static string FindNativeDebugSymbols(BuildProfile profile) { string buildType = profile != null && profile.isDevelopment ? "debug" : "release"; string preferredPath = Path.Combine( GetProjectRoot(), "Library", "Bee", "Android", "Prj", "IL2CPP", "Gradle", "launcher", "build", "outputs", "native-debug-symbols", buildType, "native-debug-symbols.zip"); return File.Exists(preferredPath) ? preferredPath : FindNewestFile(Path.Combine(GetProjectRoot(), "Library", "Bee", "Android"), "native-debug-symbols.zip"); } private static string FindMappingFile(BuildProfile profile, string buildOutputPath) { string buildDirectory = Path.GetDirectoryName(buildOutputPath); string newestOutputMapping = FindNewestFile(buildDirectory, "*mapping*.txt"); if (!string.IsNullOrEmpty(newestOutputMapping)) { return newestOutputMapping; } string buildType = profile != null && profile.isDevelopment ? "debug" : "release"; string preferredRoot = Path.Combine( GetProjectRoot(), "Library", "Bee", "Android", "Prj", "IL2CPP", "Gradle", "launcher", "build", "outputs", "mapping", buildType); string preferredMapping = FindNewestFile(preferredRoot, "mapping.txt"); return !string.IsNullOrEmpty(preferredMapping) ? preferredMapping : FindNewestFile(Path.Combine(GetProjectRoot(), "Library", "Bee", "Android"), "mapping.txt"); } private static string FindNewestFile(string root, string fileName) { if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) { return string.Empty; } string newestPath = string.Empty; DateTime newestWriteTime = DateTime.MinValue; foreach (string file in Directory.GetFiles(root, fileName, SearchOption.AllDirectories)) { DateTime writeTime = File.GetLastWriteTimeUtc(file); if (writeTime > newestWriteTime) { newestPath = file; newestWriteTime = writeTime; } } return newestPath; } private static string WriteManifest(BuildProfile profile, string buildOutputPath, string archiveDirectory, BuglySymbolArchiveResult result) { BuglySymbolManifest manifest = new BuglySymbolManifest { productName = profile.productName, bundleIdentifier = profile.bundleIdentifier, version = profile.version, buildNumber = profile.buildNumber, buildOutputPath = buildOutputPath, buildOutputSha256 = File.Exists(buildOutputPath) ? GetSHA256Hash(buildOutputPath) : string.Empty, nativeSymbolPath = result.nativeSymbolPath, nativeSymbolSha256 = File.Exists(result.nativeSymbolPath) ? GetSHA256Hash(result.nativeSymbolPath) : string.Empty, mappingPath = result.mappingPath, mappingSha256 = File.Exists(result.mappingPath) ? GetSHA256Hash(result.mappingPath) : string.Empty, createdAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") }; string manifestPath = Path.Combine(archiveDirectory, "bugly-symbol-manifest.json"); File.WriteAllText(manifestPath, JsonUtility.ToJson(manifest, true), Encoding.UTF8); return manifestPath; } private static string CreatePackage(string archiveDirectory, string packagePath) { if (File.Exists(packagePath)) { File.Delete(packagePath); } ZipFile.CreateFromDirectory( archiveDirectory, packagePath, System.IO.Compression.CompressionLevel.Optimal, false); return packagePath; } private static BuglySymbolArchiveResult PrepareUploadInputs(BuildProfile profile, BuglySymbolArchiveResult archive, LogHandler log) { string stagingRoot = Path.Combine(GetProjectRoot(), UploadStagingDirectory.Replace("/", Path.DirectorySeparatorChar.ToString())); string stagingDirectory = Path.Combine(stagingRoot, BuildSafeStagingName(profile) + "_" + DateTime.Now.ToString("yyyyMMddHHmmss")); Directory.CreateDirectory(stagingDirectory); BuglySymbolArchiveResult uploadArchive = new BuglySymbolArchiveResult { hasSymbols = archive.hasSymbols, archiveDirectory = archive.archiveDirectory, packagePath = archive.packagePath, manifestPath = archive.manifestPath, message = archive.message }; if (File.Exists(archive.nativeSymbolPath)) { string symbolDirectory = Path.Combine(stagingDirectory, "symbols"); Directory.CreateDirectory(symbolDirectory); ZipFile.ExtractToDirectory(archive.nativeSymbolPath, symbolDirectory); uploadArchive.nativeSymbolPath = symbolDirectory; WriteLog(log, $"已解压 Bugly SO 符号表到英文临时目录:{symbolDirectory}"); } if (File.Exists(archive.mappingPath)) { uploadArchive.mappingPath = Path.Combine(stagingDirectory, "mapping.txt"); File.Copy(archive.mappingPath, uploadArchive.mappingPath, true); } return uploadArchive; } private static string BuildSafeStagingName(BuildProfile profile) { string rawName = profile == null ? "bugly" : profile.profileName + "_" + profile.version + "_" + profile.buildNumber; StringBuilder builder = new StringBuilder(rawName.Length); foreach (char ch in rawName) { builder.Append(char.IsLetterOrDigit(ch) || ch == '_' || ch == '-' || ch == '.' ? ch : '_'); } return builder.ToString(); } private static string WriteUploadLog(string archiveDirectory, BuglySymbolUploadResult result) { string logDirectory = Directory.Exists(archiveDirectory) ? archiveDirectory : GetProjectRoot(); string logPath = Path.Combine(logDirectory, "bugly-symbol-upload.log"); File.WriteAllText( logPath, $"Time: {DateTime.Now:yyyy-MM-dd HH:mm:ss}\nCommand: {result.command}\nExitCode: {result.exitCode}\n--- Output ---\n{result.output}\n--- Error ---\n{result.error}", Encoding.UTF8); return logPath; } private static BuglySymbolUploadResult FailUpload(string message, LogHandler log) { WriteLog(log, message, LogType.Error); return new BuglySymbolUploadResult { success = false, exitCode = -1, error = message }; } private static bool PathExists(string path) { return File.Exists(path) || Directory.Exists(path); } private static Encoding GetBuglyToolOutputEncoding() { try { return Encoding.GetEncoding("GB18030"); } catch { return Encoding.Default; } } private static bool IsBuglyUploadOutputSuccessful(string output, string error) { string combinedText = (output ?? string.Empty) + "\n" + (error ?? string.Empty); return combinedText.IndexOf("plugin execute result status: failure", StringComparison.OrdinalIgnoreCase) < 0 && combinedText.IndexOf("event_res:failure", StringComparison.OrdinalIgnoreCase) < 0 && combinedText.IndexOf("##[error]", StringComparison.OrdinalIgnoreCase) < 0 && combinedText.IndexOf("errorMsg is", StringComparison.OrdinalIgnoreCase) < 0; } private static string ResolveFullPath(string path) { if (string.IsNullOrWhiteSpace(path)) { return string.Empty; } string normalizedPath = path.Replace("{inproject}: ", string.Empty); return Path.IsPathRooted(normalizedPath) ? Path.GetFullPath(normalizedPath) : Path.GetFullPath(Path.Combine(GetProjectRoot(), normalizedPath)); } private static string GetProjectRoot() { return Directory.GetParent(Application.dataPath).FullName; } private static string Quote(string value) { return string.IsNullOrEmpty(value) ? "\"\"" : "\"" + value.Replace("\"", "\\\"") + "\""; } private static string MaskUploadArguments(string arguments) { return MaskArgumentValue(arguments, "-appkey"); } private static string MaskArgumentValue(string arguments, string key) { int keyIndex = arguments.IndexOf(key, StringComparison.OrdinalIgnoreCase); if (keyIndex < 0) { return arguments; } int valueStart = keyIndex + key.Length; while (valueStart < arguments.Length && char.IsWhiteSpace(arguments[valueStart])) { valueStart++; } if (valueStart >= arguments.Length) { return arguments; } int tokenEnd = valueStart; if (arguments[valueStart] == '"') { tokenEnd = arguments.IndexOf('"', valueStart + 1); return tokenEnd > valueStart ? arguments.Substring(0, valueStart + 1) + "******" + arguments.Substring(tokenEnd) : arguments; } while (tokenEnd < arguments.Length && !char.IsWhiteSpace(arguments[tokenEnd])) { tokenEnd++; } return arguments.Substring(0, valueStart) + "******" + arguments.Substring(tokenEnd); } private static string GetSHA256Hash(string filePath) { using (SHA256 sha256 = SHA256.Create()) using (FileStream stream = File.OpenRead(filePath)) { return BitConverter.ToString(sha256.ComputeHash(stream)).Replace("-", "").ToLowerInvariant(); } } private static void WriteLog(LogHandler log, string message, LogType type = LogType.Log) { if (log != null) { log(message, type); return; } switch (type) { case LogType.Error: case LogType.Exception: Debug.LogError(message); break; case LogType.Warning: Debug.LogWarning(message); break; default: Debug.Log(message); break; } } } [Serializable] public class BuglySymbolArchiveResult { public bool hasSymbols; public string archiveDirectory; public string packagePath; public string nativeSymbolPath; public string mappingPath; public string manifestPath; public string message; } [Serializable] public class BuglySymbolUploadResult { public bool success; public int exitCode; public string command; public string output; public string error; public string logPath; } [Serializable] public class BuglySymbolManifest { public string productName; public string bundleIdentifier; public string version; public int buildNumber; public string buildOutputPath; public string buildOutputSha256; public string nativeSymbolPath; public string nativeSymbolSha256; public string mappingPath; public string mappingSha256; public string createdAt; } }