using System; using System.Collections.Generic; using System.IO; using System.Text; using UnityEngine; using System.Threading.Tasks; using System.Linq; using System.Globalization; /// /// 跨平台数据导出服务 /// 支持BFI测试记录、折线图和历史数据的导出 /// public class DataExportService : IDataExportService { private const string EXPORT_FOLDER = "DCX_Export"; private const string HISTORY_FOLDER = "History"; private const float ANDROID_USB_CACHE_SECONDS = 2f; private string _lastExportedFilePath; private string _lastErrorMessage; private string _cachedAndroidUsbDirectory; private bool _cachedAndroidUsbDirectoryWritable; private float _cachedAndroidUsbDirectoryTime; public string GetLastErrorMessage() { return _lastErrorMessage; } private void ClearLastError() { _lastErrorMessage = null; } private void SetLastError(string message) { _lastErrorMessage = message; if (!string.IsNullOrEmpty(message)) { Debug.LogWarning(message); } } /// /// 导出数据到USB(跨平台实现),content为要写入的文件内容 /// public bool ExportToUsb(string suggestedFilename, string content = null) { try { ClearLastError(); #if UNITY_ANDROID && !UNITY_EDITOR return ExportToAndroidStorage(suggestedFilename, content); #else return ExportToEditorPath(suggestedFilename, content); #endif } catch (Exception ex) { SetLastError($"数据导出失败: {ex.Message}"); return false; } } /// /// 导出多个历史记录 /// public bool ExportHistoryRecords(List records, ExportFormat format = ExportFormat.TXT) { try { ClearLastError(); if (records == null || records.Count == 0) { SetLastError("没有可导出的历史记录"); return false; } var emptyDetailRecords = records .Where(record => record == null || record.DataPoints == null || record.DataPoints.Count == 0) .ToList(); if (emptyDetailRecords.Count > 0) { string names = string.Join("、", emptyDetailRecords .Select(GetRecordDisplayName) .Take(3)); if (emptyDetailRecords.Count > 3) { names += "等"; } SetLastError($"所选BFI记录缺少明细数据,无法导出:{names}"); return false; } if (records.Sum(record => GetValidDataPoints(record).Count) == 0) { SetLastError("所选BFI记录没有有效数据点,无法导出"); return false; } if (!CheckExportPermissions()) { return false; } #if UNITY_ANDROID && !UNITY_EDITOR if (HasAndroidSafUsbDirectoryPermission()) { if (TryExportHistoryRecordsWithAndroidSaf(records, format)) { return true; } if (!string.IsNullOrEmpty(_lastErrorMessage)) { return false; } } #endif string filename = $"BFI_History_{format}_{DateTime.Now:yyyyMMdd_HHmmss_fff}"; string exportPath = GetExportPath(filename); if (string.IsNullOrEmpty(exportPath)) { SetLastError("未找到可写入的U盘导出目录"); return false; } switch (format) { case ExportFormat.TXT: return ExportHistoryToTXT(records, exportPath); case ExportFormat.CSV: return ExportHistoryToCSV(records, exportPath); case ExportFormat.PDF: return ExportHistoryToPDF(records, exportPath); default: return ExportHistoryToTXT(records, exportPath); } } catch (Exception ex) { SetLastError($"历史记录导出失败: {ex.Message}"); return false; } } #if UNITY_ANDROID && !UNITY_EDITOR private bool TryExportHistoryRecordsWithAndroidSaf(List records, ExportFormat format) { try { if (!HasAndroidSafUsbDirectoryPermission()) { if (RequestAndroidSafUsbDirectoryPermission()) { SetLastError("请在弹出的文件选择器中选择 U S B 根目录并授权,然后重新点击导出"); } return false; } string fileName = $"BFI_History_{format}_{DateTime.Now:yyyyMMdd_HHmmss_fff}{GetExportFileExtension(format)}"; bool success; if (format == ExportFormat.PDF) { byte[] pdfBytes = BuildSimplePdfBytes(BuildPdfReportLines(records)); success = AndroidSafWriteBytesFile(fileName, pdfBytes); } else { string content = BuildHistoryExportContent(records, format); if (string.IsNullOrEmpty(content)) { SetLastError("导出内容为空"); return false; } success = AndroidSafWriteTextFile(fileName, content); } if (success) { _lastExportedFilePath = $"U S B/DCX_Export/{fileName}"; Debug.Log($"历史记录已通过U盘目录授权导出: {_lastExportedFilePath}"); return true; } ClearAndroidSafUsbDirectoryPermission(); if (RequestAndroidSafUsbDirectoryPermission()) { SetLastError("U盘目录授权已失效或当前U盘不可写,已重新打开授权窗口。请选择 U S B 根目录并授权后再次导出"); } return false; } catch (Exception ex) { SetLastError($"U盘授权写入失败: {ex.Message}"); return false; } } private string GetExportFileExtension(ExportFormat format) { switch (format) { case ExportFormat.CSV: return "_history.csv"; case ExportFormat.PDF: return "_history.pdf"; case ExportFormat.TXT: default: return "_history.txt"; } } private string BuildHistoryExportContent(List records, ExportFormat format) { switch (format) { case ExportFormat.CSV: return BuildHistoryCsvContent(records); case ExportFormat.TXT: default: return BuildHistoryTxtContent(records); } } private string BuildHistoryTxtContent(List records) { var reportBuilder = new StringBuilder(); reportBuilder.AppendLine("BFI测试历史报告"); reportBuilder.AppendLine($"生成时间: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); reportBuilder.AppendLine($"记录数量: {records.Count}"); reportBuilder.AppendLine(); foreach (var record in records) { reportBuilder.AppendLine(GenerateBFIReportContent(record)); reportBuilder.AppendLine("" + new string('=', 50)); } return reportBuilder.ToString(); } private string BuildHistoryCsvContent(List records) { var csvBuilder = new StringBuilder(); csvBuilder.AppendLine("测试名称,患者姓名,住院号,性别,年龄,身高(cm),体重(kg),开始时间,结束时间,时间戳,BFI值,状态,备注"); foreach (var record in records) { DateTime startTime = GetEffectiveStartTime(record); DateTime endTime = GetEffectiveEndTime(record, startTime); var validPoints = GetValidDataPoints(record); string patientName = EscapeCsv(GetPatientName(record)); string patientId = EscapeCsv(GetPatientId(record)); string patientGender = EscapeCsv(GetPatientGender(record)); string patientAge = FormatPatientAge(record.PatientAge); string patientHeight = FormatPatientHeight(record.PatientHeight); string patientWeight = FormatPatientWeight(record.PatientWeight); if (validPoints.Count == 0) { csvBuilder.AppendLine($"{EscapeCsv(record.TestName)},{patientName},{patientId},{patientGender},{patientAge},{patientHeight},{patientWeight},{startTime:yyyy-MM-dd HH:mm:ss},{endTime:yyyy-MM-dd HH:mm:ss},,,无有效数据点,"); continue; } foreach (var dataPoint in validPoints) { csvBuilder.AppendLine($"{EscapeCsv(record.TestName)},{patientName},{patientId},{patientGender},{patientAge},{patientHeight},{patientWeight},{startTime:yyyy-MM-dd HH:mm:ss},{endTime:yyyy-MM-dd HH:mm:ss},{dataPoint.Timestamp:yyyy-MM-dd HH:mm:ss},{dataPoint.BFI:F2},{EscapeCsv(dataPoint.Status)},{EscapeCsv(dataPoint.Notes)}"); } } return csvBuilder.ToString(); } private bool HasAndroidSafUsbDirectoryPermission() { using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var currentActivity = unityClass.GetStatic("currentActivity")) using (var usbAccess = new AndroidJavaClass("com.dcx.ruiyiweiux.UsbStorageAccess")) { return usbAccess.CallStatic("hasPersistedUsbDirectory", currentActivity); } } private bool RequestAndroidSafUsbDirectoryPermission() { try { using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var currentActivity = unityClass.GetStatic("currentActivity")) using (var usbAccess = new AndroidJavaClass("com.dcx.ruiyiweiux.UsbStorageAccess")) { usbAccess.CallStatic("requestUsbDirectoryPermission", currentActivity); return true; } } catch (Exception ex) { SetLastError($"无法打开U盘目录授权窗口: {ex.Message}"); return false; } } private void ClearAndroidSafUsbDirectoryPermission() { using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var currentActivity = unityClass.GetStatic("currentActivity")) using (var usbAccess = new AndroidJavaClass("com.dcx.ruiyiweiux.UsbStorageAccess")) { usbAccess.CallStatic("clearPersistedUsbDirectory", currentActivity); } } private bool AndroidSafWriteTextFile(string fileName, string content) { using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var currentActivity = unityClass.GetStatic("currentActivity")) using (var usbAccess = new AndroidJavaClass("com.dcx.ruiyiweiux.UsbStorageAccess")) { return usbAccess.CallStatic("writeTextFile", currentActivity, fileName, content); } } private bool AndroidSafWriteBytesFile(string fileName, byte[] bytes) { using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var currentActivity = unityClass.GetStatic("currentActivity")) using (var usbAccess = new AndroidJavaClass("com.dcx.ruiyiweiux.UsbStorageAccess")) { return usbAccess.CallStatic("writeBytesFile", currentActivity, fileName, bytes); } } private int GetAndroidSdkInt() { using (var buildVersion = new AndroidJavaClass("android.os.Build$VERSION")) { return buildVersion.GetStatic("SDK_INT"); } } #endif #region 平台特定实现 #if UNITY_ANDROID && !UNITY_EDITOR /// /// Android平台导出到存储 /// 导出路径:/sdcard/Download/DCX_Export/ /// private bool ExportToAndroidStorage(string filename, string content = null) { try { if (GetAndroidSdkInt() >= 29) { if (HasAndroidSafUsbDirectoryPermission()) { bool safSuccess = AndroidSafWriteTextFile(filename, content ?? string.Empty); if (safSuccess) { _lastExportedFilePath = $"U S B/DCX_Export/{filename}"; Debug.Log($"Android: 文件已通过U盘目录授权写入 {_lastExportedFilePath}"); return true; } ClearAndroidSafUsbDirectoryPermission(); if (RequestAndroidSafUsbDirectoryPermission()) { SetLastError("U盘目录授权已失效或当前U盘不可写,已重新打开授权窗口。请选择 U S B 根目录并授权后再次导出"); } return false; } } string exportDir = GetExportDirectory(); if (string.IsNullOrEmpty(exportDir)) { if (GetAndroidSdkInt() >= 29 && RequestAndroidSafUsbDirectoryPermission()) { SetLastError("未检测到可直接写入的U盘路径。请在弹出的文件选择器中选择 U S B 根目录并授权,然后重新点击导出"); } else { SetLastError("未检测到可写入的U盘,请插入U盘后重试"); } return false; } exportDir = EnsureExportDirectoryWithRootFolder(exportDir); if (!Directory.Exists(exportDir)) { Directory.CreateDirectory(exportDir); } string fullPath = Path.Combine(exportDir, filename); // 写入文件内容 if (content != null) { File.WriteAllText(fullPath, content, Encoding.UTF8); Debug.Log($"Android: 文件已写入 {fullPath}"); _lastExportedFilePath = fullPath; } else { Debug.Log($"Android: 导出目录已准备 {fullPath}(无内容写入)"); } // 通知媒体扫描器更新文件,使其在文件管理器中可见 using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var currentActivity = unityClass.GetStatic("currentActivity")) { NotifyMediaScanner(currentActivity, fullPath); } return true; } catch (Exception ex) { SetLastError($"Android导出失败: {ex.Message}"); return false; } } /// /// 通知Android媒体扫描器更新文件 /// private void NotifyMediaScanner(AndroidJavaObject activity, string filePath) { try { var intent = new AndroidJavaObject("android.content.Intent"); intent.Call("setAction", "android.intent.action.MEDIA_SCANNER_SCAN_FILE"); var uri = new AndroidJavaClass("android.net.Uri"); var fileUri = uri.CallStatic("parse", $"file://{filePath}"); intent.Call("setData", fileUri); activity.Call("sendBroadcast", intent); } catch (Exception ex) { Debug.LogWarning($"媒体扫描器通知失败: {ex.Message}"); } } #else /// /// 编辑器/Windows模式导出 /// private bool ExportToEditorPath(string filename, string content = null) { try { string exportDir = EnsureExportDirectoryWithRootFolder(GetExportDirectory()); if (!Directory.Exists(exportDir)) { Directory.CreateDirectory(exportDir); } string fullPath = Path.Combine(exportDir, filename); // 写入文件内容 if (content != null) { File.WriteAllText(fullPath, content, Encoding.UTF8); Debug.Log($"编辑器: 文件已写入 {fullPath}"); _lastExportedFilePath = fullPath; } else { Debug.Log($"编辑器: 导出目录已准备 {fullPath}(无内容写入)"); } return true; } catch (Exception ex) { Debug.LogError($"编辑器导出失败: {ex.Message}"); return false; } } /// /// 获取Windows平台USB设备驱动器列表 /// private List GetUsbDrives() { var usbDrives = new List(); #if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN try { foreach (var drive in System.IO.DriveInfo.GetDrives()) { if (!drive.IsReady) { continue; } string root = drive.RootDirectory.FullName; bool isRemovable = drive.DriveType == System.IO.DriveType.Removable; bool hasDcxExportFolder = Directory.Exists(Path.Combine(root, EXPORT_FOLDER)); bool isSystemDrive = string.Equals( Path.GetPathRoot(Environment.GetFolderPath(Environment.SpecialFolder.Windows)), root, StringComparison.OrdinalIgnoreCase); if (isRemovable || (!isSystemDrive && hasDcxExportFolder)) { usbDrives.Add(root); } } } catch (Exception ex) { Debug.LogWarning($"获取USB驱动器失败: {ex.Message}"); } #endif return usbDrives; } #endif #endregion #if UNITY_ANDROID && !UNITY_EDITOR private string GetAndroidUsbExportDirectory(bool requireWritable) { if (Application.platform == RuntimePlatform.Android && Time.unscaledTime - _cachedAndroidUsbDirectoryTime <= ANDROID_USB_CACHE_SECONDS && !string.IsNullOrEmpty(_cachedAndroidUsbDirectory)) { if (!requireWritable || _cachedAndroidUsbDirectoryWritable) { return _cachedAndroidUsbDirectory; } } string firstCandidate = null; foreach (var root in GetAndroidUsbCandidateRoots()) { if (string.IsNullOrEmpty(root)) { continue; } string exportDir = EnsureExportDirectoryWithRootFolder(root); if (string.IsNullOrEmpty(firstCandidate)) { firstCandidate = exportDir; } if (IsWritableDirectory(exportDir)) { Debug.Log($"检测到可写U盘导出目录: {exportDir}"); _cachedAndroidUsbDirectory = exportDir; _cachedAndroidUsbDirectoryWritable = true; _cachedAndroidUsbDirectoryTime = Time.unscaledTime; return exportDir; } } _cachedAndroidUsbDirectory = firstCandidate; _cachedAndroidUsbDirectoryWritable = false; _cachedAndroidUsbDirectoryTime = Time.unscaledTime; return requireWritable ? null : firstCandidate; } private IEnumerable GetAndroidUsbCandidateRoots() { var roots = new List(); AddAndroidUsbRootsFromStorageManager(roots); AddAndroidUsbRootsFromEnvironment(roots); AddAndroidUsbRoot(roots, "/storage/USB"); AddAndroidUsbRoot(roots, "/storage/U S B"); AddAndroidUsbRoot(roots, "/storage/usbotg"); AddAndroidUsbRoot(roots, "/storage/usb"); AddAndroidUsbRoot(roots, "/storage/udisk"); AddAndroidUsbRoot(roots, "/storage/udisk1"); AddAndroidUsbRoot(roots, "/mnt/usb"); AddAndroidUsbRoot(roots, "/mnt/udisk"); AddAndroidUsbRoot(roots, "/mnt/usb_storage/USB_DISK0"); AddAndroidUsbRoot(roots, "/mnt/usb_storage/USB_DISK1"); return roots; } private void AddAndroidUsbRootsFromStorageManager(List roots) { try { using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var currentActivity = unityClass.GetStatic("currentActivity")) using (var context = currentActivity.Call("getApplicationContext")) { var storageManager = context.Call("getSystemService", "storage"); if (storageManager == null) { return; } var volumes = storageManager.Call("getStorageVolumes"); if (volumes == null) { return; } int count = volumes.Call("size"); for (int i = 0; i < count; i++) { using (var volume = volumes.Call("get", i)) { if (volume == null) { continue; } bool isPrimary = SafeCallBool(volume, "isPrimary"); bool isRemovable = SafeCallBool(volume, "isRemovable"); if (isPrimary || !isRemovable) { continue; } string description = TryGetStorageVolumeDescription(volume, context); string path = TryGetStorageVolumeDirectory(volume); if (!string.IsNullOrEmpty(path) && IsLikelyAndroidUsbRoot(description, path)) { Debug.Log($"检测到可移动存储卷: {description}, 路径: {path}"); AddAndroidUsbRoot(roots, path); } } } } } catch (Exception ex) { Debug.LogWarning($"通过StorageManager扫描U盘失败: {ex.Message}"); } } private bool SafeCallBool(AndroidJavaObject target, string methodName) { try { return target != null && target.Call(methodName); } catch { return false; } } private string TryGetStorageVolumeDescription(AndroidJavaObject volume, AndroidJavaObject context) { try { return volume.Call("getDescription", context); } catch { return null; } } private string TryGetStorageVolumeDirectory(AndroidJavaObject volume) { try { using (var directory = volume.Call("getDirectory")) { string path = directory?.Call("getAbsolutePath"); if (!string.IsNullOrEmpty(path)) { return path; } } } catch { // Android 10及以下没有公开getDirectory,继续尝试厂商/旧系统常见方法。 } try { string path = volume.Call("getPath"); if (!string.IsNullOrEmpty(path)) { return path; } } catch { return null; } return null; } private void AddAndroidUsbRootsFromEnvironment(List roots) { AddAndroidUsbRootsFromEnvironmentValue(roots, Environment.GetEnvironmentVariable("SECONDARY_STORAGE")); AddAndroidUsbRootsFromEnvironmentValue(roots, Environment.GetEnvironmentVariable("EXTERNAL_STORAGE")); } private void AddAndroidUsbRootsFromEnvironmentValue(List roots, string value) { if (string.IsNullOrEmpty(value)) { return; } foreach (var path in value.Split(new[] { ':' }, StringSplitOptions.RemoveEmptyEntries)) { AddAndroidUsbRoot(roots, path); } } private bool IsLikelyAndroidUsbRoot(string name, string path) { if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(path)) { return false; } string lowerName = name.ToLowerInvariant(); string lowerPath = path.ToLowerInvariant(); string normalizedName = lowerName.Replace(" ", string.Empty) .Replace("_", string.Empty) .Replace("-", string.Empty); if (lowerName == "emulated" || lowerName == "self" || lowerName == "sdcard0" || lowerName == "sdcard") { return false; } if (lowerPath.Contains("/android/data") || lowerPath.Contains("/obb")) { return false; } return lowerName.Contains("usb") || normalizedName.Contains("usb") || lowerName.Contains("udisk") || lowerName.Contains("otg") || lowerName.Contains("disk") || System.Text.RegularExpressions.Regex.IsMatch(name, "^[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}$"); } private void AddAndroidUsbRoot(List roots, string root) { if (string.IsNullOrEmpty(root)) { return; } if (IsAndroidInternalStorageRoot(root)) { return; } try { string fullPath = Path.GetFullPath(root); #if UNITY_ANDROID && !UNITY_EDITOR if (!roots.Any(existing => string.Equals(existing, fullPath, StringComparison.OrdinalIgnoreCase))) { roots.Add(fullPath); } #else if (Directory.Exists(fullPath) && !roots.Any(existing => string.Equals(existing, fullPath, StringComparison.OrdinalIgnoreCase))) { roots.Add(fullPath); } #endif } catch { #if UNITY_ANDROID && !UNITY_EDITOR if (!roots.Contains(root)) { roots.Add(root); } #else if (Directory.Exists(root) && !roots.Contains(root)) { roots.Add(root); } #endif } } private bool IsAndroidInternalStorageRoot(string root) { if (string.IsNullOrEmpty(root)) { return true; } string normalized = root.Replace('\\', '/').TrimEnd('/').ToLowerInvariant(); return normalized == "/sdcard" || normalized == "/storage/emulated" || normalized.StartsWith("/storage/emulated/", StringComparison.OrdinalIgnoreCase) || normalized == "/storage/self" || normalized.StartsWith("/storage/self/", StringComparison.OrdinalIgnoreCase); } #endif #region BFI数据导出实现 /// /// 导出BFI折线图为图片 /// private bool ExportHistoryToImage(List records, string exportPath) { try { // 这里需要图表生成库 // 临时实现:生成数据点描述 foreach (var record in records) { DateTime startTime = GetEffectiveStartTime(record); DateTime endTime = GetEffectiveEndTime(record, startTime); var validPoints = GetValidDataPoints(record); var imageDataBuilder = new StringBuilder(); imageDataBuilder.AppendLine($"BFI折线图数据 - {record.TestName}"); imageDataBuilder.AppendLine($"患者姓名: {GetPatientName(record)}"); imageDataBuilder.AppendLine($"住院号: {GetPatientId(record)}"); imageDataBuilder.AppendLine($"性别: {GetPatientGender(record)}"); imageDataBuilder.AppendLine($"年龄: {FormatPatientAge(record.PatientAge)}"); imageDataBuilder.AppendLine($"身高(cm): {FormatPatientHeight(record.PatientHeight)}"); imageDataBuilder.AppendLine($"体重(kg): {FormatPatientWeight(record.PatientWeight)}"); imageDataBuilder.AppendLine($"开始时间: {startTime:yyyy-MM-dd HH:mm:ss}"); imageDataBuilder.AppendLine($"结束时间: {endTime:yyyy-MM-dd HH:mm:ss}"); imageDataBuilder.AppendLine($"测试时长: {GetEffectiveDurationMinutes(startTime, endTime):F1} 分钟"); imageDataBuilder.AppendLine($"有效数据点: {validPoints.Count}/{record?.DataPoints?.Count ?? 0}"); imageDataBuilder.AppendLine("数据点:"); foreach (var point in validPoints) { imageDataBuilder.AppendLine($" {FormatPointTime(point.Timestamp)} - BFI: {point.BFI:F2}"); } string txtPath = exportPath + "_chart.txt"; File.WriteAllText(txtPath, imageDataBuilder.ToString(), Encoding.UTF8); Debug.Log($"BFI图表数据已导出: {txtPath}"); } return true; } catch (Exception ex) { Debug.LogError($"图表导出失败: {ex.Message}"); return false; } } #endregion #region 历史记录导出 /// /// 导出历史记录为TXT /// private bool ExportHistoryToTXT(List records, string exportPath) { try { var reportBuilder = new StringBuilder(); reportBuilder.AppendLine("BFI测试历史报告"); reportBuilder.AppendLine($"生成时间: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); reportBuilder.AppendLine($"记录数量: {records.Count}"); reportBuilder.AppendLine(); foreach (var record in records) { reportBuilder.AppendLine(GenerateBFIReportContent(record)); reportBuilder.AppendLine("" + new string('=', 50)); } string txtPath = GetUniqueFilePath(exportPath + "_history.txt"); string content = reportBuilder.ToString(); if (!WriteTextFileVerified(txtPath, content)) { return false; } _lastExportedFilePath = txtPath; NotifyExportedFile(txtPath); Debug.Log($"历史记录报告已生成: {txtPath}"); return true; } catch (Exception ex) { Debug.LogError($"历史记录TXT导出失败: {ex.Message}"); return false; } } /// /// 导出历史记录为CSV /// private bool ExportHistoryToCSV(List records, string exportPath) { try { var csvBuilder = new StringBuilder(); csvBuilder.AppendLine("测试名称,患者姓名,住院号,性别,年龄,身高(cm),体重(kg),开始时间,结束时间,时间戳,BFI值,状态,备注"); foreach (var record in records) { DateTime startTime = GetEffectiveStartTime(record); DateTime endTime = GetEffectiveEndTime(record, startTime); var validPoints = GetValidDataPoints(record); string patientName = EscapeCsv(GetPatientName(record)); string patientId = EscapeCsv(GetPatientId(record)); string patientGender = EscapeCsv(GetPatientGender(record)); string patientAge = FormatPatientAge(record.PatientAge); string patientHeight = FormatPatientHeight(record.PatientHeight); string patientWeight = FormatPatientWeight(record.PatientWeight); if (validPoints.Count == 0) { csvBuilder.AppendLine($"{EscapeCsv(record.TestName)},{patientName},{patientId},{patientGender},{patientAge},{patientHeight},{patientWeight},{startTime:yyyy-MM-dd HH:mm:ss},{endTime:yyyy-MM-dd HH:mm:ss},,,无有效数据点,"); continue; } foreach (var dataPoint in validPoints) { csvBuilder.AppendLine($"{EscapeCsv(record.TestName)},{patientName},{patientId},{patientGender},{patientAge},{patientHeight},{patientWeight},{startTime:yyyy-MM-dd HH:mm:ss},{endTime:yyyy-MM-dd HH:mm:ss},{dataPoint.Timestamp:yyyy-MM-dd HH:mm:ss},{dataPoint.BFI:F2},{EscapeCsv(dataPoint.Status)},{EscapeCsv(dataPoint.Notes)}"); } } string csvPath = GetUniqueFilePath(exportPath + "_history.csv"); if (!WriteTextFileVerified(csvPath, csvBuilder.ToString())) { return false; } _lastExportedFilePath = csvPath; NotifyExportedFile(csvPath); Debug.Log($"历史记录CSV已导出: {csvPath}"); return true; } catch (Exception ex) { Debug.LogError($"历史记录CSV导出失败: {ex.Message}"); return false; } } /// /// 导出历史记录为PDF /// private bool ExportHistoryToPDF(List records, string exportPath) { try { var lines = BuildPdfReportLines(records); string pdfPath = GetUniqueFilePath(exportPath + "_history.pdf"); byte[] pdfBytes = BuildSimplePdfBytes(lines); if (!WriteBytesFileVerified(pdfPath, pdfBytes)) { return false; } _lastExportedFilePath = pdfPath; NotifyExportedFile(pdfPath); Debug.Log($"历史记录PDF已导出: {pdfPath}"); return true; } catch (Exception ex) { Debug.LogError($"历史记录PDF导出失败: {ex.Message}"); return false; } } #endregion #region 辅助方法 /// /// 生成BFI文件名 /// private string GenerateBFIFilename(BFITestRecord record) { return $"BFI_{record.TestName}_{record.StartTime:yyyyMMdd_HHmmss}"; } /// /// 获取导出路径 /// private string GetExportPath(string filename) { string exportDirectory = EnsureExportDirectoryWithRootFolder(GetExportDirectory()); if (!Directory.Exists(exportDirectory)) { Directory.CreateDirectory(exportDirectory); } return Path.Combine(exportDirectory, filename); } private string GetUniqueFilePath(string filePath) { if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) { return filePath; } string directory = Path.GetDirectoryName(filePath); string fileName = Path.GetFileNameWithoutExtension(filePath); string extension = Path.GetExtension(filePath); for (int i = 1; i < 10000; i++) { string candidate = Path.Combine(directory, $"{fileName}_{i}{extension}"); if (!File.Exists(candidate)) { return candidate; } } return Path.Combine(directory, $"{fileName}_{Guid.NewGuid():N}{extension}"); } /// /// 生成BFI报告内容 /// private string GenerateBFIReportContent(BFITestRecord record) { var builder = new StringBuilder(); DateTime startTime = GetEffectiveStartTime(record); DateTime endTime = GetEffectiveEndTime(record, startTime); var validPoints = GetValidDataPoints(record); int totalPoints = record?.DataPoints?.Count ?? 0; int ignoredPoints = totalPoints - validPoints.Count; builder.AppendLine($"BFI测试报告 - {record.TestName}"); builder.AppendLine($"患者姓名: {GetPatientName(record)}"); builder.AppendLine($"住院号: {GetPatientId(record)}"); builder.AppendLine($"性别: {GetPatientGender(record)}"); builder.AppendLine($"年龄: {FormatPatientAge(record.PatientAge)}"); builder.AppendLine($"身高(cm): {FormatPatientHeight(record.PatientHeight)}"); builder.AppendLine($"体重(kg): {FormatPatientWeight(record.PatientWeight)}"); builder.AppendLine($"开始时间: {startTime:yyyy-MM-dd HH:mm:ss}"); builder.AppendLine($"结束时间: {endTime:yyyy-MM-dd HH:mm:ss}"); builder.AppendLine($"测试时长: {GetEffectiveDurationMinutes(startTime, endTime):F1} 分钟"); builder.AppendLine($"数据点数量: {totalPoints}(有效: {validPoints.Count})"); if (record.EndTime <= DateTime.MinValue || record.EndTime < startTime) { builder.AppendLine("说明: 结束时间未正常记录,已按最后有效数据点时间进行估算。"); } if (ignoredPoints > 0) { builder.AppendLine($"说明: 已过滤 {ignoredPoints} 条异常BFI数据点(超范围或非数值)。"); } builder.AppendLine(); if (validPoints.Count > 0) { builder.AppendLine("BFI数据统计:"); var bfiValues = validPoints.ConvertAll(dp => dp.BFI); builder.AppendLine($" 最大值: {bfiValues.Max():F2}"); builder.AppendLine($" 最小值: {bfiValues.Min():F2}"); builder.AppendLine($" 平均值: {bfiValues.Average():F2}"); builder.AppendLine(); builder.AppendLine("详细数据:"); foreach (var point in validPoints) { builder.AppendLine($" {FormatPointTime(point.Timestamp)} | BFI: {point.BFI:F2} | 状态: {point.Status}"); } } else { builder.AppendLine("无有效BFI数据点"); } return builder.ToString(); } private bool IsValidBfiValue(float value) { return !float.IsNaN(value) && !float.IsInfinity(value) && value >= 0f && value <= 1000f; } private List GetValidDataPoints(BFITestRecord record) { var validPoints = new List(); if (record?.DataPoints == null) { return validPoints; } foreach (var point in record.DataPoints) { if (point == null || !IsValidBfiValue(point.BFI)) { continue; } validPoints.Add(point); } return validPoints; } private string GetRecordDisplayName(BFITestRecord record) { if (!string.IsNullOrEmpty(record?.TestName)) { return record.TestName; } if (!string.IsNullOrEmpty(record?.TestId)) { return record.TestId; } return "未知记录"; } private DateTime GetEffectiveStartTime(BFITestRecord record) { if (record.StartTime > DateTime.MinValue) { return record.StartTime; } if (record?.DataPoints != null) { foreach (var point in record.DataPoints) { if (point != null && point.Timestamp > DateTime.MinValue) { return point.Timestamp; } } } return DateTime.Now; } private DateTime GetEffectiveEndTime(BFITestRecord record, DateTime startTime) { if (record.EndTime > startTime) { return record.EndTime; } DateTime lastPointTime = DateTime.MinValue; if (record?.DataPoints != null) { foreach (var point in record.DataPoints) { if (point != null && point.Timestamp > lastPointTime) { lastPointTime = point.Timestamp; } } } if (lastPointTime > startTime) { return lastPointTime; } return startTime; } private double GetEffectiveDurationMinutes(DateTime startTime, DateTime endTime) { double duration = (endTime - startTime).TotalMinutes; return duration < 0 ? 0 : duration; } private string FormatPointTime(DateTime timestamp) { return timestamp > DateTime.MinValue ? timestamp.ToString("HH:mm:ss") : "--:--:--"; } private string GetPatientName(BFITestRecord record) { return string.IsNullOrWhiteSpace(record?.PatientName) ? "未知患者" : record.PatientName; } private string GetPatientId(BFITestRecord record) { return string.IsNullOrWhiteSpace(record?.PatientId) ? "-" : record.PatientId; } private string GetPatientGender(BFITestRecord record) { return string.IsNullOrWhiteSpace(record?.PatientGender) ? "-" : record.PatientGender; } private string FormatPatientAge(int age) { return age > 0 ? age.ToString() : "-"; } private string FormatPatientHeight(float height) { return height > 0f ? height.ToString("F1") : "-"; } private string FormatPatientWeight(float weight) { return weight > 0f ? weight.ToString("F1") : "-"; } private string EscapeCsv(string value) { if (string.IsNullOrEmpty(value)) { return string.Empty; } string escaped = value.Replace("\"", "\"\""); if (escaped.Contains(",") || escaped.Contains("\n") || escaped.Contains("\r") || escaped.Contains("\"")) { return $"\"{escaped}\""; } return escaped; } private List BuildPdfReportLines(List records) { var lines = new List { "BFI测试历史报告", $"生成时间: {DateTime.Now:yyyy-MM-dd HH:mm:ss}", $"记录数量: {records.Count}", string.Empty }; foreach (var record in records) { DateTime startTime = GetEffectiveStartTime(record); DateTime endTime = GetEffectiveEndTime(record, startTime); var validPoints = GetValidDataPoints(record); int totalPoints = record?.DataPoints?.Count ?? 0; lines.Add($"测试名称: {record.TestName}"); lines.Add($"患者姓名: {GetPatientName(record)} 住院号: {GetPatientId(record)}"); lines.Add($"性别: {GetPatientGender(record)} 年龄: {FormatPatientAge(record.PatientAge)} 身高(cm): {FormatPatientHeight(record.PatientHeight)} 体重(kg): {FormatPatientWeight(record.PatientWeight)}"); lines.Add($"开始时间: {startTime:yyyy-MM-dd HH:mm:ss}"); lines.Add($"结束时间: {endTime:yyyy-MM-dd HH:mm:ss}"); lines.Add($"测试时长: {GetEffectiveDurationMinutes(startTime, endTime):F1} 分钟 数据点: {totalPoints}(有效: {validPoints.Count})"); if (validPoints.Count > 0) { var bfiValues = validPoints.ConvertAll(dp => dp.BFI); lines.Add($"统计: 最大值 {bfiValues.Max():F2} 最小值 {bfiValues.Min():F2} 平均值 {bfiValues.Average():F2}"); lines.Add("详细数据(时间 / BFI / 状态):"); foreach (var point in validPoints.Take(120)) { lines.Add($" {FormatPointTime(point.Timestamp)} {point.BFI:F2} {point.Status}"); } if (validPoints.Count > 120) { lines.Add($" …… 其余 {validPoints.Count - 120} 个数据点请导出CSV查看。"); } } else { lines.Add("无有效BFI数据点"); } lines.Add(new string('-', 48)); lines.Add(string.Empty); } return lines; } private void WriteSimplePdf(string filePath, List lines) { if (!WriteBytesFileVerified(filePath, BuildSimplePdfBytes(lines))) { throw new IOException(_lastErrorMessage ?? "PDF文件写入失败"); } } private bool WriteTextFileVerified(string filePath, string content) { byte[] bytes = Encoding.UTF8.GetBytes(content ?? string.Empty); return WriteBytesFileVerified(filePath, bytes); } private bool WriteBytesFileVerified(string filePath, byte[] bytes) { try { if (string.IsNullOrEmpty(filePath)) { SetLastError("导出路径为空"); return false; } bytes = bytes ?? new byte[0]; string directory = Path.GetDirectoryName(filePath); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { Directory.CreateDirectory(directory); } using (var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read, 8192)) { stream.Write(bytes, 0, bytes.Length); FlushFileStream(stream); } var info = new FileInfo(filePath); if (!info.Exists || info.Length < bytes.Length) { SetLastError($"导出文件写入不完整: {filePath}, 已写入 {info.Length} / {bytes.Length} 字节"); return false; } return true; } catch (Exception ex) { SetLastError($"写入导出文件失败: {filePath}, {ex.Message}"); return false; } } private void FlushFileStream(FileStream stream) { try { stream.Flush(true); } catch { stream.Flush(); } } private byte[] BuildSimplePdfBytes(List lines) { const float pageWidth = 595f; const float pageHeight = 842f; const float marginLeft = 48f; const float marginTop = 54f; const float lineHeight = 17f; const int maxCharsPerLine = 46; var wrappedLines = new List(); foreach (var line in lines) { wrappedLines.AddRange(WrapTextForPdf(line, maxCharsPerLine)); } int linesPerPage = Mathf.Max(1, Mathf.FloorToInt((pageHeight - marginTop - 48f) / lineHeight)); var pages = new List>(); for (int i = 0; i < wrappedLines.Count; i += linesPerPage) { pages.Add(wrappedLines.Skip(i).Take(linesPerPage).ToList()); } if (pages.Count == 0) { pages.Add(new List { "无可导出内容" }); } var objects = new List(); objects.Add(PdfAscii("<< /Type /Catalog /Pages 2 0 R >>")); var kids = new StringBuilder(); for (int i = 0; i < pages.Count; i++) { int pageObjectNumber = 5 + i * 2; kids.Append($"{pageObjectNumber} 0 R "); } objects.Add(PdfAscii($"<< /Type /Pages /Kids [{kids}] /Count {pages.Count} >>")); objects.Add(PdfAscii("<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [4 0 R] >>")); objects.Add(PdfAscii("<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> >>")); for (int i = 0; i < pages.Count; i++) { int pageObjectNumber = 5 + i * 2; int contentObjectNumber = pageObjectNumber + 1; objects.Add(PdfAscii($"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pageWidth.ToString(CultureInfo.InvariantCulture)} {pageHeight.ToString(CultureInfo.InvariantCulture)}] /Resources << /Font << /F1 3 0 R >> >> /Contents {contentObjectNumber} 0 R >>")); byte[] content = BuildPdfPageContent(pages[i], pageHeight, marginLeft, marginTop, lineHeight); objects.Add(PdfStream(content)); } using (var stream = new MemoryStream()) { WriteAscii(stream, "%PDF-1.4\n%\u00e2\u00e3\u00cf\u00d3\n"); var offsets = new List { 0 }; for (int i = 0; i < objects.Count; i++) { offsets.Add(stream.Position); WriteAscii(stream, $"{i + 1} 0 obj\n"); stream.Write(objects[i], 0, objects[i].Length); WriteAscii(stream, "\nendobj\n"); } long xrefOffset = stream.Position; WriteAscii(stream, $"xref\n0 {objects.Count + 1}\n"); WriteAscii(stream, "0000000000 65535 f \n"); for (int i = 1; i < offsets.Count; i++) { WriteAscii(stream, $"{offsets[i]:D10} 00000 n \n"); } WriteAscii(stream, $"trailer\n<< /Size {objects.Count + 1} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF"); return stream.ToArray(); } } private IEnumerable WrapTextForPdf(string text, int maxChars) { if (string.IsNullOrEmpty(text)) { yield return string.Empty; yield break; } for (int index = 0; index < text.Length; index += maxChars) { int length = Math.Min(maxChars, text.Length - index); yield return text.Substring(index, length); } } private byte[] BuildPdfPageContent(List lines, float pageHeight, float marginLeft, float marginTop, float lineHeight) { var builder = new StringBuilder(); builder.AppendLine("BT"); builder.AppendLine("/F1 11 Tf"); builder.AppendLine($"{marginLeft.ToString(CultureInfo.InvariantCulture)} {(pageHeight - marginTop).ToString(CultureInfo.InvariantCulture)} Td"); bool firstLine = true; foreach (var line in lines) { if (!firstLine) { builder.AppendLine($"0 -{lineHeight.ToString(CultureInfo.InvariantCulture)} Td"); } builder.AppendLine($"{PdfUnicodeHexString(line)} Tj"); firstLine = false; } builder.AppendLine("ET"); return Encoding.ASCII.GetBytes(builder.ToString()); } private string PdfUnicodeHexString(string value) { byte[] bytes = Encoding.BigEndianUnicode.GetBytes(value ?? string.Empty); var builder = new StringBuilder(bytes.Length * 2 + 2); builder.Append('<'); foreach (byte b in bytes) { builder.Append(b.ToString("X2")); } builder.Append('>'); return builder.ToString(); } private byte[] PdfStream(byte[] content) { using (var memory = new MemoryStream()) { WriteAscii(memory, $"<< /Length {content.Length} >>\nstream\n"); memory.Write(content, 0, content.Length); WriteAscii(memory, "\nendstream"); return memory.ToArray(); } } private byte[] PdfAscii(string value) { return Encoding.ASCII.GetBytes(value); } private void WriteAscii(Stream stream, string value) { byte[] bytes = Encoding.ASCII.GetBytes(value); stream.Write(bytes, 0, bytes.Length); } /// /// 规范化导出目录,保证最终目录以 DCX_Export 结尾 /// private string EnsureExportDirectoryWithRootFolder(string baseDirectory) { if (string.IsNullOrEmpty(baseDirectory)) { return Path.Combine(Application.persistentDataPath, EXPORT_FOLDER); } string normalizedPath = baseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); string folderName = Path.GetFileName(normalizedPath); if (string.Equals(folderName, EXPORT_FOLDER, StringComparison.OrdinalIgnoreCase)) { return normalizedPath; } return Path.Combine(normalizedPath, EXPORT_FOLDER); } private bool IsWritableDirectory(string directory) { try { if (string.IsNullOrEmpty(directory)) { return false; } if (!Directory.Exists(directory)) { Directory.CreateDirectory(directory); } string testFile = Path.Combine(directory, ".dcx_write_test"); File.WriteAllText(testFile, DateTime.Now.ToString("O"), Encoding.UTF8); File.Delete(testFile); #if !UNITY_ANDROID || UNITY_EDITOR if (!HasEnoughFreeSpace(directory, 10L * 1024L * 1024L)) { return false; } #endif return true; } catch (Exception ex) { Debug.LogWarning($"目录不可写: {directory}, {ex.Message}"); return false; } } private bool HasEnoughFreeSpace(string directory, long requiredBytes) { try { string root = Path.GetPathRoot(Path.GetFullPath(directory)); if (string.IsNullOrEmpty(root)) { return true; } var drive = new DriveInfo(root); if (!drive.IsReady) { return true; } if (drive.AvailableFreeSpace < requiredBytes) { Debug.LogWarning($"导出目录空间不足: {directory}, 剩余 {drive.AvailableFreeSpace / 1024 / 1024}MB"); return false; } return true; } catch (Exception ex) { Debug.LogWarning($"检查导出目录剩余空间失败: {directory}, {ex.Message}"); return true; } } private void NotifyExportedFile(string filePath) { #if UNITY_ANDROID && !UNITY_EDITOR try { using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var currentActivity = unityClass.GetStatic("currentActivity")) { NotifyMediaScanner(currentActivity, filePath); } } catch (Exception ex) { Debug.LogWarning($"导出文件媒体扫描通知失败: {ex.Message}"); } #endif } #endregion #region IDataExportService接口实现 /// /// 检查是否可以导出 /// public bool CanExport() { #if UNITY_ANDROID && !UNITY_EDITOR int sdkInt = GetAndroidSdkInt(); if (sdkInt >= 29) { if (HasAndroidSafUsbDirectoryPermission()) { return true; } return !string.IsNullOrEmpty(GetAndroidUsbExportDirectory(requireWritable: true)); } if (!HasAndroidUsbWritePermission(sdkInt)) { return false; } return !string.IsNullOrEmpty(GetAndroidUsbExportDirectory(requireWritable: true)); #elif UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN string exportDir = EnsureExportDirectoryWithRootFolder(GetExportDirectory()); return IsWritableDirectory(exportDir); #else return true; // 编辑器模式总是可以导出 #endif } /// /// 获取导出目录 /// public string GetExportDirectory() { #if UNITY_ANDROID && !UNITY_EDITOR if (GetAndroidSdkInt() >= 29) { string directUsbDirectory = GetAndroidUsbExportDirectory(requireWritable: true); if (!string.IsNullOrEmpty(directUsbDirectory)) { return directUsbDirectory; } return HasAndroidSafUsbDirectoryPermission() ? "U S B/DCX_Export" : null; } return GetAndroidUsbExportDirectory(requireWritable: true); #elif UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN var usbDrives = GetUsbDrives(); if (usbDrives.Count > 0) { return Path.Combine(usbDrives[0], EXPORT_FOLDER); } else { string desktopPath = Environment.GetFolderPath(Environment.SpecialFolder.Desktop); return Path.Combine(desktopPath, EXPORT_FOLDER); } #else return Path.Combine(Application.dataPath, "..", "Exports"); #endif } public string GetLastExportedFilePath() { return _lastExportedFilePath; } /// /// 检查导出权限 /// public bool CheckExportPermissions() { try { #if UNITY_ANDROID && !UNITY_EDITOR int sdkInt = GetAndroidSdkInt(); if (sdkInt >= 29) { if (HasAndroidSafUsbDirectoryPermission()) { return true; } string androidScopedWritableUsbDirectory = GetAndroidUsbExportDirectory(requireWritable: true); if (!string.IsNullOrEmpty(androidScopedWritableUsbDirectory)) { return true; } string androidScopedDetectedUsbDirectory = GetAndroidUsbExportDirectory(requireWritable: false); if (!string.IsNullOrEmpty(androidScopedDetectedUsbDirectory)) { if (RequestAndroidSafUsbDirectoryPermission()) { SetLastError($"检测到U盘路径 {androidScopedDetectedUsbDirectory},但系统不允许直接写入。请在弹出的文件选择器中选择 U S B 根目录并授权,然后重新点击导出"); } } else if (RequestAndroidSafUsbDirectoryPermission()) { SetLastError("未检测到可直接写入的U盘路径。请在弹出的文件选择器中选择 U S B 根目录并授权,然后重新点击导出"); } return false; } if (!HasAndroidUsbWritePermission(sdkInt)) { RequestAndroidUsbPermission(sdkInt); return false; } string writableUsbDirectory = GetAndroidUsbExportDirectory(requireWritable: true); if (!string.IsNullOrEmpty(writableUsbDirectory)) { return true; } string detectedUsbDirectory = GetAndroidUsbExportDirectory(requireWritable: false); if (!string.IsNullOrEmpty(detectedUsbDirectory)) { SetLastError($"检测到U盘但无法写入: {detectedUsbDirectory}。请检查U盘格式、只读状态或系统存储权限"); } else { SetLastError("未检测到可写入的U盘,请确认U盘已插入并重新导出"); } return false; #elif UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN string exportDir = GetExportDirectory(); string finalExportDir = EnsureExportDirectoryWithRootFolder(exportDir); if (!IsWritableDirectory(finalExportDir)) { Debug.LogWarning($"导出目录不可访问: {finalExportDir}"); return false; } return true; #endif } catch (Exception ex) { Debug.LogError($"权限检查失败: {ex.Message}"); return false; } } private bool HasAndroidUsbWritePermission() { #if UNITY_ANDROID && !UNITY_EDITOR return HasAndroidUsbWritePermission(new AndroidJavaClass("android.os.Build$VERSION").GetStatic("SDK_INT")); #else return true; #endif } private bool HasAndroidUsbWritePermission(int sdkInt) { if (sdkInt <= 28) { return AndroidExportPermissionManager.HasWriteExternalStoragePermission(); } return AndroidExportPermissionManager.HasManageExternalStoragePermission(); } private void RequestAndroidUsbPermission(int sdkInt) { if (sdkInt <= 28) { SetLastError("缺少存储权限,已请求授权,请允许后重新导出"); AndroidExportPermissionManager.RequestWriteExternalStoragePermission(); } else { SetLastError("缺少所有文件访问权限,已打开权限设置,请授权后重新导出"); AndroidExportPermissionManager.RequestManageExternalStoragePermission(); } } #endregion } /// /// 导出格式枚举 /// public enum ExportFormat { TXT, // TXT报告 CSV, // CSV数据 PDF, // PDF报告 }