commit 1632c3329d335bb7f13f88406852abff42bcd1ed
Author: NuanRMxi <2308425927@qq.com>
Date: Mon Jul 14 00:42:12 2025 +0800
first commit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5930028
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+bin/
+obj/
+/packages/
+riderModule.iml
+/_ReSharper.Caches/
+token.txt
\ No newline at end of file
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
new file mode 100644
index 0000000..5f28270
--- /dev/null
+++ b/AndroidManifest.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/App.xaml b/App.xaml
new file mode 100644
index 0000000..dd58ff5
--- /dev/null
+++ b/App.xaml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App.xaml.cs b/App.xaml.cs
new file mode 100644
index 0000000..7091be9
--- /dev/null
+++ b/App.xaml.cs
@@ -0,0 +1,14 @@
+namespace HeartRateMonitorAndroid;
+
+public partial class App : Application
+{
+ public App()
+ {
+ InitializeComponent();
+ }
+
+ protected override Window CreateWindow(IActivationState? activationState)
+ {
+ return new Window(new AppShell());
+ }
+}
\ No newline at end of file
diff --git a/AppShell.xaml b/AppShell.xaml
new file mode 100644
index 0000000..6df46e2
--- /dev/null
+++ b/AppShell.xaml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/AppShell.xaml.cs b/AppShell.xaml.cs
new file mode 100644
index 0000000..5fb8711
--- /dev/null
+++ b/AppShell.xaml.cs
@@ -0,0 +1,9 @@
+namespace HeartRateMonitorAndroid;
+
+public partial class AppShell : Shell
+{
+ public AppShell()
+ {
+ InitializeComponent();
+ }
+}
\ No newline at end of file
diff --git a/HeartRateMonitorAndroid.csproj b/HeartRateMonitorAndroid.csproj
new file mode 100644
index 0000000..55e2f4d
--- /dev/null
+++ b/HeartRateMonitorAndroid.csproj
@@ -0,0 +1,73 @@
+
+
+
+ net8.0-android;net8.0-ios;net8.0-maccatalyst
+ $(TargetFrameworks);net8.0-windows10.0.19041.0
+
+
+
+
+
+
+ Exe
+ HeartRateMonitorAndroid
+ true
+ true
+ enable
+ enable
+
+
+ HeartRateMonitorAndroid
+
+
+ com.companyname.heartratemonitorandroid
+
+
+ 1.0
+ 1
+
+
+ None
+
+ 15.0
+ 15.0
+ 21.0
+ 10.0.17763.0
+ 10.0.17763.0
+ 6.5
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/HeartRateMonitorAndroid.sln b/HeartRateMonitorAndroid.sln
new file mode 100644
index 0000000..6219115
--- /dev/null
+++ b/HeartRateMonitorAndroid.sln
@@ -0,0 +1,16 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HeartRateMonitorAndroid", "HeartRateMonitorAndroid.csproj", "{D7F7A858-2624-403B-A2AE-34C0F127AE63}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {D7F7A858-2624-403B-A2AE-34C0F127AE63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D7F7A858-2624-403B-A2AE-34C0F127AE63}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D7F7A858-2624-403B-A2AE-34C0F127AE63}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D7F7A858-2624-403B-A2AE-34C0F127AE63}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/MainPage.xaml b/MainPage.xaml
new file mode 100644
index 0000000..d7bc002
--- /dev/null
+++ b/MainPage.xaml
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/MainPage.xaml.cs b/MainPage.xaml.cs
new file mode 100644
index 0000000..930df83
--- /dev/null
+++ b/MainPage.xaml.cs
@@ -0,0 +1,923 @@
+using System.Diagnostics;
+using System.Net.WebSockets;
+using System.Text;
+using HeartRateMonitorAndroid.Services;
+using Newtonsoft.Json;
+using Plugin.BLE;
+using Plugin.BLE.Abstractions.Contracts;
+using Plugin.BLE.Abstractions.EventArgs;
+using Plugin.BLE.Abstractions;
+
+namespace HeartRateMonitorAndroid;
+
+// 心率数据点类
+public class HeartRateDataPoint
+{
+ public DateTime Timestamp { get; set; }
+ public int HeartRate { get; set; }
+}
+
+// 心率图表绘制类
+public class HeartRateGraphDrawable : IDrawable
+{
+ private List _dataPoints = [];
+ private int _maxPoints = 100; // 最多显示100个数据点
+ private int _minHeartRate = 40;
+ private int _maxHeartRate = 180;
+
+ // 图表配色方案
+ private readonly Color _backgroundColor = Color.FromArgb("#F8F9FA"); // 浅灰背景色
+ private readonly Color _gridLineColor = Color.FromArgb("#E9ECEF"); // 网格线颜色
+ private readonly Color _axisColor = Color.FromArgb("#CED4DA"); // 坐标轴颜色
+ private readonly Color _textColor = Color.FromArgb("#6C757D"); // 文本颜色
+ private readonly Color _heartRateLineColor = Color.FromArgb("#FF4757"); // 心率线颜色
+ private readonly Color _heartRateAreaColor = Color.FromRgba(255, 71, 87, 0.2); // 心率区域填充颜色
+ private readonly Color _heartRatePointColor = Color.FromArgb("#FF4757"); // 数据点颜色
+ private readonly Color _accentColor = Color.FromArgb("#2E86DE"); // 强调色
+
+ public void UpdateData(List dataPoints)
+ {
+ _dataPoints = dataPoints.ToList();
+ // 如果有数据,动态调整Y轴范围
+ if (_dataPoints.Count > 0)
+ {
+ _minHeartRate = Math.Max(40, _dataPoints.Min(p => p.HeartRate) - 10);
+ _maxHeartRate = Math.Min(200, _dataPoints.Max(p => p.HeartRate) + 10);
+
+ // 确保Y轴范围合理
+ int range = _maxHeartRate - _minHeartRate;
+ if (range < 30) // 如果范围太小,扩大它
+ {
+ _minHeartRate = Math.Max(40, _minHeartRate - (30 - range) / 2);
+ _maxHeartRate = Math.Min(200, _maxHeartRate + (30 - range) / 2);
+ }
+
+ // 圆整到最接近的10
+ _minHeartRate = (_minHeartRate / 10) * 10;
+ _maxHeartRate = ((_maxHeartRate + 9) / 10) * 10;
+ }
+ }
+
+ public void Draw(ICanvas canvas, RectF dirtyRect)
+ {
+ // 设置背景色
+ canvas.FillColor = _backgroundColor;
+ canvas.FillRectangle(dirtyRect);
+
+ if (_dataPoints.Count < 2) return; // 至少需要两个点才能绘制线条
+
+ // 计算绘图区域,增加左侧留白以放置y轴标签
+ float leftPadding = 45;
+ float rightPadding = 20;
+ float topPadding = 30;
+ float bottomPadding = 40;
+
+ float graphWidth = dirtyRect.Width - leftPadding - rightPadding;
+ float graphHeight = dirtyRect.Height - topPadding - bottomPadding;
+ float graphBottom = dirtyRect.Height - bottomPadding;
+ float graphTop = topPadding;
+ float graphLeft = leftPadding;
+ float graphRight = dirtyRect.Width - rightPadding;
+
+ // 绘制背景和边框
+ canvas.FillColor = Colors.White;
+ canvas.FillRoundedRectangle(graphLeft - 5, graphTop - 5, graphWidth + 10, graphHeight + 10, 8);
+ canvas.StrokeColor = _gridLineColor;
+ canvas.StrokeSize = 1;
+ canvas.DrawRoundedRectangle(graphLeft - 5, graphTop - 5, graphWidth + 10, graphHeight + 10, 8);
+
+ // 绘制网格线
+ canvas.StrokeColor = _gridLineColor;
+ canvas.StrokeSize = 1;
+ canvas.StrokeDashPattern = new float[] { 4, 4 }; // 虚线网格
+
+ // 水平网格线和心率刻度
+ int yStep = (_maxHeartRate - _minHeartRate) > 100 ? 40 : 20; // 根据范围动态调整步长
+ for (int hr = _minHeartRate; hr <= _maxHeartRate; hr += yStep)
+ {
+ float y = graphBottom - ((hr - _minHeartRate) * graphHeight / (_maxHeartRate - _minHeartRate));
+
+ // 绘制网格线
+ canvas.DrawLine(graphLeft, y, graphRight, y);
+
+ // 绘制心率刻度
+ canvas.FontSize = 12;
+ canvas.FontColor = _textColor;
+ canvas.DrawString(hr.ToString(), graphLeft - 25, y, HorizontalAlignment.Center);
+ }
+
+ // 重置虚线模式
+ canvas.StrokeDashPattern = null;
+
+ // 时间刻度线和标签
+ if (_dataPoints.Count > 0)
+ {
+ int pointCount = _dataPoints.Count;
+ int xStep = Math.Max(1, pointCount / 5); // 大约显示5个时间点
+
+ for (int i = 0; i < pointCount; i += xStep)
+ {
+ if (i >= pointCount) break;
+ float x = graphLeft + (i * graphWidth / (pointCount - 1));
+
+ // 绘制垂直网格线
+ canvas.StrokeColor = _gridLineColor;
+ canvas.StrokeDashPattern = [4, 4];
+ canvas.DrawLine(x, graphTop, x, graphBottom);
+ canvas.StrokeDashPattern = null;
+
+ // 绘制时间刻度(分钟:秒)
+ canvas.FontSize = 12;
+ canvas.FontColor = _textColor;
+ string timeLabel = _dataPoints[i].Timestamp.ToString("mm:ss");
+ canvas.DrawString(timeLabel, x, graphBottom + 15, HorizontalAlignment.Center);
+ }
+ }
+
+ // 绘制坐标轴
+ canvas.StrokeColor = _axisColor;
+ canvas.StrokeSize = 2;
+ canvas.DrawLine(graphLeft, graphBottom, graphRight, graphBottom); // X轴
+ canvas.DrawLine(graphLeft, graphTop, graphLeft, graphBottom); // Y轴
+
+ // 添加标题
+ canvas.FontColor = _accentColor;
+ canvas.FontSize = 14;
+ canvas.DrawString("心率监测图表", dirtyRect.Width / 2, graphTop - 15, HorizontalAlignment.Center);
+
+ // 创建心率曲线路径
+ PathF linePath = new PathF();
+ PathF areaPath = new PathF();
+ bool isFirst = true;
+
+ // 添加区域填充起始点
+ areaPath.MoveTo(graphLeft, graphBottom);
+
+ for (int i = 0; i < _dataPoints.Count; i++)
+ {
+ float x = graphLeft + (i * graphWidth / (_dataPoints.Count - 1));
+ float y = graphBottom - ((_dataPoints[i].HeartRate - _minHeartRate) * graphHeight /
+ (_maxHeartRate - _minHeartRate));
+
+ if (isFirst)
+ {
+ linePath.MoveTo(x, y);
+ areaPath.LineTo(x, y);
+ isFirst = false;
+ }
+ else
+ {
+ // 使用曲线而不是直线,使图表更平滑
+ if (i > 0 && i < _dataPoints.Count - 1)
+ {
+ float prevX = graphLeft + ((i - 1) * graphWidth / (_dataPoints.Count - 1));
+ float prevY = graphBottom - ((_dataPoints[i - 1].HeartRate - _minHeartRate) * graphHeight /
+ (_maxHeartRate - _minHeartRate));
+ float nextX = graphLeft + ((i + 1) * graphWidth / (_dataPoints.Count - 1));
+ float nextY = graphBottom - ((_dataPoints[i + 1].HeartRate - _minHeartRate) * graphHeight /
+ (_maxHeartRate - _minHeartRate));
+
+ float cpx1 = prevX + (x - prevX) * 0.5f;
+ float cpy1 = prevY;
+ float cpx2 = x - (x - prevX) * 0.5f;
+ float cpy2 = y;
+
+ linePath.CurveTo(cpx1, cpy1, cpx2, cpy2, x, y);
+ areaPath.CurveTo(cpx1, cpy1, cpx2, cpy2, x, y);
+ }
+ else
+ {
+ linePath.LineTo(x, y);
+ areaPath.LineTo(x, y);
+ }
+ }
+ }
+
+ // 完成区域填充路径
+ areaPath.LineTo(graphLeft + graphWidth, graphBottom);
+ areaPath.LineTo(graphLeft, graphBottom);
+ areaPath.Close();
+
+ // 绘制区域填充
+ canvas.FillColor = _heartRateAreaColor;
+ canvas.FillPath(areaPath);
+
+ // 绘制曲线
+ canvas.StrokeColor = _heartRateLineColor;
+ canvas.StrokeSize = 3;
+ canvas.DrawPath(linePath);
+
+ // 只绘制最新数据点
+ if (_dataPoints.Count > 0)
+ {
+ // 获取最新数据点的位置
+ int lastIndex = _dataPoints.Count - 1;
+ float x = graphLeft + (lastIndex * graphWidth / (_dataPoints.Count - 1));
+ float y = graphBottom - ((_dataPoints[lastIndex].HeartRate - _minHeartRate) * graphHeight /
+ (_maxHeartRate - _minHeartRate));
+
+ // 绘制最新点的标记
+ canvas.FillColor = _heartRatePointColor;
+ canvas.FillCircle(x, y, 6);
+ canvas.StrokeSize = 2;
+ canvas.StrokeColor = Colors.White;
+ canvas.DrawCircle(x, y, 6);
+
+ // 显示最新心率值
+ canvas.FontSize = 12;
+ canvas.FontColor = _heartRateLineColor;
+ //canvas.Font = FontAttributes.Bold;
+ canvas.DrawString(_dataPoints[lastIndex].HeartRate + " bpm",
+ x, y - 15, HorizontalAlignment.Center);
+ }
+ }
+}
+
+public partial class MainPage : ContentPage
+{
+ const string TAG = "HeartRateMonitor";
+
+ // 心率服务和特征的UUID常量
+ private static readonly Guid HEART_RATE_SERVICE_UUID = Guid.Parse("0000180D-0000-1000-8000-00805F9B34FB");
+
+ private static readonly Guid HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID =
+ Guid.Parse("00002A37-0000-1000-8000-00805F9B34FB");
+
+ // 通知相关常量
+ private const int NOTIFICATION_ID = 100;
+ private const string CHANNEL_ID = "HeartRateMonitorChannel";
+
+ // 图表更新定时器相关
+ private IDispatcherTimer _graphUpdateTimer;
+ private const int GRAPH_UPDATE_INTERVAL_MS = 1000; // 每秒更新一次图表
+ private int _latestHeartRate = 0; // 保存最新心率值
+ private bool _hasNewHeartRateData = false; // 标记是否有新数据
+
+ // WebSocket相关
+ private Services.WebSocketService.HeartRateWebSocketClient _webSocketClient;
+ private bool _isWebSocketEnabled = false; // 是否启用WebSocket上报
+ private const string DEFAULT_WEBSOCKET_URL = "wss://ws.nuanr-mxi.com/ws"; // 默认WebSocket服务器地址
+ private string _webSocketUrl = DEFAULT_WEBSOCKET_URL;
+
+ IAdapter _adapter;
+ IBluetoothLE _ble;
+ private bool _isConnecting = false; // 添加连接状态标志
+ private bool _isRunningInBackground = false;
+
+ // 心率数据相关
+ private List _heartRateData = new List();
+ private HeartRateGraphDrawable _heartRateGraph = new HeartRateGraphDrawable();
+ private int _minHeartRate = 0;
+ private int _maxHeartRate = 0;
+ private double _avgHeartRate = 0;
+ private DateTime _sessionStartTime;
+ private IDevice _connectedDevice = null;
+ private object _heartRateDataLock = new object(); // 添加锁对象,用于线程安全操作
+
+ public MainPage()
+ {
+ InitializeComponent();
+
+ _ble = CrossBluetoothLE.Current;
+ _adapter = CrossBluetoothLE.Current.Adapter;
+
+ _adapter.DeviceDiscovered += OnDeviceDiscovered;
+
+ // 初始化图表
+ heartRateGraphicsView.Drawable = _heartRateGraph;
+
+ // 初始化定时器,用于固定频率更新图表
+ InitializeGraphUpdateTimer();
+
+ // 初始化通知服务
+ NotificationService.Initialize();
+
+ CheckBluetoothState();
+ }
+
+
+ // 初始化图表更新定时器
+ private void InitializeGraphUpdateTimer()
+ {
+ _graphUpdateTimer = Dispatcher.CreateTimer();
+ _graphUpdateTimer.Interval = TimeSpan.FromMilliseconds(GRAPH_UPDATE_INTERVAL_MS);
+ _graphUpdateTimer.Tick += async (s, e) => await UpdateGraph();
+ _graphUpdateTimer.Start();
+ }
+
+ // 定时更新图表
+ private async Task UpdateGraph()
+ {
+ await SendHeartRateToServerAsync(_latestHeartRate);
+ lock (_heartRateDataLock)
+ {
+ // 无论是否有新数据,都更新图表
+ _heartRateGraph.UpdateData(_heartRateData);
+ heartRateGraphicsView.Invalidate();
+
+ // 重置新数据标记
+ _hasNewHeartRateData = false;
+
+ // 如果在后台运行且有数据,更新通知
+ if (_isRunningInBackground && _heartRateData.Count > 0)
+ {
+ TimeSpan duration = DateTime.Now - _sessionStartTime;
+ NotificationService.ShowHeartRateNotification(
+ _latestHeartRate,
+ _avgHeartRate,
+ _minHeartRate,
+ _maxHeartRate,
+ duration);
+ }
+ }
+ }
+
+ async void CheckBluetoothState()
+ {
+ Debug.WriteLine($"{TAG}: 检查 BLE 状态...");
+ if (!_ble.IsAvailable)
+ {
+ Debug.WriteLine($"{TAG}: 设备不支持 BLE");
+ statusLabel.Text = "设备不支持 BLE";
+ return;
+ }
+
+ if (!_ble.IsOn)
+ {
+ Debug.WriteLine($"{TAG}: 蓝牙未开启");
+ statusLabel.Text = "请开启蓝牙后再试";
+ return;
+ }
+
+ Debug.WriteLine($"{TAG}: BLE 可用且已开启");
+ statusLabel.Text = "准备就绪,点击开始扫描";
+ }
+
+ async void OnScanClicked(object sender, EventArgs e)
+ {
+ Debug.WriteLine($"{TAG}: 开始扫描附近设备...");
+ statusLabel.Text = "正在扫描...";
+
+ try
+ {
+ // 先停止之前的扫描
+ if (_adapter.IsScanning)
+ {
+ Debug.WriteLine($"{TAG}: 停止之前的扫描");
+ await _adapter.StopScanningForDevicesAsync();
+ // 短暂延迟确保扫描完全停止
+ await Task.Delay(200);
+ }
+
+ // 设置扫描参数
+ _adapter.ScanMode = ScanMode.LowLatency; // 使用低延迟模式提高响应速度
+
+ // 先尝试不带服务UUID过滤来扫描,这样可以捕获更多设备
+ Debug.WriteLine($"{TAG}: 开始全扫描模式");
+ await _adapter.StartScanningForDevicesAsync();
+
+ Debug.WriteLine($"{TAG}: 扫描已启动,将自动超时或在发现心率设备时停止");
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"{TAG}: 扫描出错: {ex.Message}");
+ statusLabel.Text = $"扫描出错: {ex.Message}";
+ }
+ }
+
+ void OnDeviceDiscovered(object sender, DeviceEventArgs args)
+ {
+ var device = args.Device;
+ Debug.WriteLine($"{TAG}: 发现设备: {device.Name ?? "未知设备"} ({device.Id})");
+
+ foreach (var adv in device.AdvertisementRecords)
+ {
+ Debug.WriteLine($"{TAG}: Adv Type: {adv.Type}, Data: {BitConverter.ToString(adv.Data)}");
+ }
+
+ // 检查广播数据是否包含心率服务 UUID (0x180D)
+ bool hasHeartRateService = false;
+
+ // 检查16位UUID服务列表
+ var serviceUuids16Bit = device.AdvertisementRecords.FirstOrDefault(r =>
+ r.Type == AdvertisementRecordType.UuidsComplete16Bit ||
+ r.Type == AdvertisementRecordType.UuidsIncomple16Bit);
+
+ if (serviceUuids16Bit != null)
+ {
+ // 心率服务UUID是0x180D,根据日志,数据存储顺序为18-0D
+ string dataString = BitConverter.ToString(serviceUuids16Bit.Data);
+ Debug.WriteLine($"{TAG}: 16位UUID数据: {dataString}");
+ hasHeartRateService = dataString.Contains("18-0D");
+
+ if (hasHeartRateService)
+ {
+ Debug.WriteLine($"{TAG}: 在16位UUID中找到心率服务(0x180D)");
+ }
+ }
+
+ // 如果16位列表中未找到,则检查128位UUID列表
+ if (!hasHeartRateService)
+ {
+ var serviceUuids128Bit = device.AdvertisementRecords.FirstOrDefault(r =>
+ r.Type == AdvertisementRecordType.UuidsComplete128Bit ||
+ r.Type == AdvertisementRecordType.UuidsIncomplete128Bit);
+
+ if (serviceUuids128Bit != null)
+ {
+ // 心率服务在128位UUID中的格式通常是0000180D-0000-1000-8000-00805F9B34FB
+ // 检查两种可能的排列方式
+ string dataString = BitConverter.ToString(serviceUuids128Bit.Data);
+ Debug.WriteLine($"{TAG}: 128位UUID数据: {dataString}");
+ hasHeartRateService = dataString.Contains("18-0D") || dataString.Contains("0D-18");
+
+ if (hasHeartRateService)
+ {
+ Debug.WriteLine($"{TAG}: 在128位UUID中找到心率服务(0x180D)");
+ }
+ }
+ }
+
+ // 检查设备名称,有些心率设备名称中包含相关信息
+ if (!hasHeartRateService && !string.IsNullOrEmpty(device.Name))
+ {
+ string name = device.Name.ToLower();
+ if (name.Contains("heart") || name.Contains("hr") || name.Contains("pulse") ||
+ name.Contains("cardiac") || name.Contains("心率"))
+ {
+ hasHeartRateService = true;
+ }
+ }
+
+ if (hasHeartRateService)
+ {
+ Debug.WriteLine($"{TAG}: 检测到心率设备: {device.Name ?? "未知设备"}");
+
+ // 立即停止扫描 - 这是重要的,必须先停止扫描再连接
+ if (_adapter.IsScanning)
+ {
+ try
+ {
+ Debug.WriteLine($"{TAG}: 停止扫描以准备连接设备");
+ _adapter.StopScanningForDevicesAsync().ContinueWith(t =>
+ {
+ if (t.IsCompleted && !t.IsFaulted)
+ {
+ Debug.WriteLine($"{TAG}: 扫描已停止,准备连接设备");
+ MainThread.BeginInvokeOnMainThread(async () =>
+ {
+ statusLabel.Text = $"检测到心率设备: {device.Name ?? "未知设备"}";
+ // 连接设备
+ await ConnectToHeartRateDeviceAsync(device);
+ });
+ }
+ else if (t.IsFaulted && t.Exception != null)
+ {
+ Debug.WriteLine($"{TAG}: 停止扫描失败: {t.Exception.Message}");
+ }
+ });
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"{TAG}: 停止扫描时出错: {ex.Message}");
+ }
+ }
+ else
+ {
+ // 如果没有扫描,直接连接
+ MainThread.BeginInvokeOnMainThread(async () =>
+ {
+ statusLabel.Text = $"检测到心率设备: {device.Name ?? "未知设备"}";
+ await ConnectToHeartRateDeviceAsync(device);
+ });
+ }
+
+ // 尝试从广播数据中解析心率值(有些设备可能在广播中包含数据)
+ var manufacturerData = device.AdvertisementRecords.FirstOrDefault(r =>
+ r.Type == AdvertisementRecordType.ManufacturerSpecificData);
+
+ if (manufacturerData != null && manufacturerData.Data.Length > 1)
+ {
+ int heartRate = manufacturerData.Data[1];
+ Debug.WriteLine($"{TAG}: 广播心率值: {heartRate} bpm");
+ MainThread.BeginInvokeOnMainThread(() => { heartRateLabel.Text = $"心率: {heartRate} bpm"; });
+ }
+ else
+ {
+ Debug.WriteLine($"{TAG}: 未在广播中找到心率值,将尝试连接设备读取");
+ }
+
+ // 返回true表示找到心率设备,不再继续处理其他设备
+ return;
+ }
+ }
+
+ // 更新通知
+ private void UpdateNotification(int heartRate)
+ {
+ if (DeviceInfo.Platform != DevicePlatform.Android) return;
+
+#if ANDROID
+ var context = Android.App.Application.Context;
+
+ // 创建PendingIntent用于点击通知时打开应用
+ var intent = context.PackageManager.GetLaunchIntentForPackage(context.PackageName);
+ var pendingIntent =
+ Android.App.PendingIntent.GetActivity(context, 0, intent, Android.App.PendingIntentFlags.Immutable);
+
+ // 创建通知内容
+ var notificationBuilder = new AndroidX.Core.App.NotificationCompat.Builder(context, CHANNEL_ID)
+ .SetContentTitle("心率监测")
+ .SetContentText($"当前心率: {heartRate} bpm 平均: {_avgHeartRate:0} bpm")
+ .SetSmallIcon(Resource.Drawable.notification_icon_background) // 使用Android自带图标,实际应用中应替换为自定义图标
+ .SetOngoing(true)
+ .SetContentIntent(pendingIntent)
+ .SetPriority(AndroidX.Core.App.NotificationCompat.PriorityHigh);
+
+ // 如果有统计数据,添加更多信息
+ if (_heartRateData.Count > 1)
+ {
+ TimeSpan duration = DateTime.Now - _sessionStartTime;
+ string timeInfo = $"监测时长: {duration.Hours:00}:{duration.Minutes:00}:{duration.Seconds:00}";
+ string statsInfo = $"最低: {_minHeartRate} bpm | 最高: {_maxHeartRate} bpm";
+
+ notificationBuilder.SetStyle(new AndroidX.Core.App.NotificationCompat.BigTextStyle()
+ .BigText($"当前心率: {heartRate} bpm\n{timeInfo}\n{statsInfo}"));
+ }
+
+ // 显示通知
+ var notificationManager = AndroidX.Core.App.NotificationManagerCompat.From(context);
+ notificationManager.Notify(NOTIFICATION_ID, notificationBuilder.Build());
+#endif
+ }
+
+ // 切换后台模式
+ async void OnBackgroundClicked(object sender, EventArgs e)
+ {
+ if (_connectedDevice == null || _connectedDevice.State != DeviceState.Connected)
+ {
+ await DisplayAlert("提示", "请先连接心率设备", "确定");
+ return;
+ }
+
+ _isRunningInBackground = !_isRunningInBackground;
+
+ if (_isRunningInBackground)
+ {
+ backgroundButton.Text = "停止后台运行";
+
+ // 显示通知
+ if (_heartRateData.Count > 0)
+ {
+ TimeSpan duration = DateTime.Now - _sessionStartTime;
+ Services.NotificationService.ShowHeartRateNotification(
+ _latestHeartRate,
+ _avgHeartRate,
+ _minHeartRate,
+ _maxHeartRate,
+ duration);
+ }
+ else
+ {
+ Services.NotificationService.ShowHeartRateNotification(0, 0, 0, 0, TimeSpan.Zero);
+ }
+
+ // 通知用户应用将在后台运行
+ await DisplayAlert("后台运行", "应用将在后台继续监测心率。可以通过通知栏查看实时数据。", "确定");
+ }
+ else
+ {
+ backgroundButton.Text = "后台运行";
+
+ // 退出后台模式,如果应用在前台则恢复图表更新
+
+ // 取消通知
+ Services.NotificationService.CancelNotification();
+ }
+ }
+
+ // 处理WebSocket设置按钮点击
+ async void OnWebSocketSettingsClicked(object sender, EventArgs e)
+ {
+ string result = await DisplayPromptAsync(
+ "配置数据上传",
+ "请输入WebSocket服务器地址:\n格式:wss://example.com/ws",
+ "确定",
+ "取消",
+ _webSocketUrl,
+ maxLength: 100,
+ keyboard: Keyboard.Url);
+
+ //if (string.IsNullOrWhiteSpace(result)) return;
+
+ if (!result.StartsWith("ws://") && !result.StartsWith("wss://"))
+ {
+ // fallback到默认websocket服务器
+ result = _webSocketUrl;
+ }
+
+ try
+ {
+ // 更新按钮状态,显示正在连接
+ webSocketButton.Text = "正在连接...";
+ webSocketButton.IsEnabled = false;
+
+ // 初始化WebSocket客户端
+ await InitializeWebSocketClientAsync(result);
+
+ if (_isWebSocketEnabled)
+ {
+ webSocketButton.Text = "数据上传已启用";
+ webSocketButton.BackgroundColor = Color.FromArgb("#28A745"); // 绿色
+ await DisplayAlert("连接成功", "心率数据将会实时上传到服务器", "确定");
+ // 禁用按钮
+ webSocketButton.IsEnabled = false;
+ }
+ else
+ {
+ webSocketButton.Text = "配置数据上传";
+ webSocketButton.BackgroundColor = Color.FromArgb("#6C757D"); // 灰色
+ await DisplayAlert("连接失败", "无法连接到指定的WebSocket服务器,请检查地址或网络连接", "确定");
+ }
+ }
+ catch (Exception ex)
+ {
+ webSocketButton.Text = "配置数据上传";
+ webSocketButton.BackgroundColor = Color.FromArgb("#6C757D"); // 灰色
+ await DisplayAlert("错误", $"配置WebSocket时出错: {ex.Message}", "确定");
+ }
+ finally
+ {
+ webSocketButton.IsEnabled = true;
+ }
+ }
+
+ async Task ConnectToHeartRateDeviceAsync(IDevice device)
+ {
+ // 防止重复连接
+ if (_isConnecting)
+ {
+ Debug.WriteLine($"{TAG}: 已有连接请求正在进行中,忽略此次连接");
+ return;
+ }
+
+ _isConnecting = true;
+
+ try
+ {
+ // 确保扫描已停止 - 这一步非常重要
+ if (_adapter.IsScanning)
+ {
+ Debug.WriteLine($"{TAG}: 连接前确保扫描已停止");
+ await _adapter.StopScanningForDevicesAsync();
+ // 短暂延迟确保扫描完全停止
+ await Task.Delay(300);
+ }
+
+ statusLabel.Text = $"正在连接到 {device.Name ?? "未知设备"}...";
+ Debug.WriteLine($"{TAG}: 正在连接到设备: {device.Name ?? "未知设备"}...");
+
+ // 使用CancellationToken添加超时控制
+ var cancelSource = new CancellationTokenSource();
+ cancelSource.CancelAfter(TimeSpan.FromSeconds(15)); // 15秒连接超时
+
+ // 连接到设备
+ try
+ {
+ await _adapter.ConnectToDeviceAsync(device,
+ new ConnectParameters(autoConnect: false, forceBleTransport: true), cancelSource.Token);
+ Debug.WriteLine($"{TAG}: 连接命令已发送,等待连接完成");
+ }
+ catch (OperationCanceledException)
+ {
+ Debug.WriteLine($"{TAG}: 连接操作超时");
+ MainThread.BeginInvokeOnMainThread(() => { statusLabel.Text = "连接超时,请重试"; });
+ return;
+ }
+
+ if (device.State == DeviceState.Connected)
+ {
+ statusLabel.Text = $"已连接到 {device.Name ?? "未知设备"}";
+ Debug.WriteLine($"{TAG}: 已连接到设备: {device.Name ?? "未知设备"}");
+
+ // 保存连接的设备引用
+ _connectedDevice = device;
+
+ // 重置心率数据
+ _heartRateData.Clear();
+ _minHeartRate = 0;
+ _maxHeartRate = 0;
+ _avgHeartRate = 0;
+ minHeartRateLabel.Text = "--";
+ maxHeartRateLabel.Text = "--";
+ avgHeartRateLabel.Text = "--";
+ noDataLabel.IsVisible = true;
+ heartRateGraphicsView.Invalidate();
+
+ // 获取心率服务
+ Debug.WriteLine($"{TAG}: 尝试获取心率服务 {HEART_RATE_SERVICE_UUID}");
+ var heartRateService = await device.GetServiceAsync(HEART_RATE_SERVICE_UUID);
+ if (heartRateService == null)
+ {
+ Debug.WriteLine($"{TAG}: 未找到心率服务");
+ statusLabel.Text = "未找到心率服务";
+ return;
+ }
+
+ Debug.WriteLine($"{TAG}: 已获取心率服务,尝试获取心率特征");
+ // 获取心率特征
+ var heartRateCharacteristic =
+ await heartRateService.GetCharacteristicAsync(HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID);
+ if (heartRateCharacteristic == null)
+ {
+ Debug.WriteLine($"{TAG}: 未找到心率特征");
+ statusLabel.Text = "未找到心率特征";
+ return;
+ }
+
+ // 订阅心率通知
+ heartRateCharacteristic.ValueUpdated += (s, e) =>
+ {
+ // 解析心率数据
+ var data = e.Characteristic.Value;
+ if (data == null || data.Length == 0)
+ return;
+
+ byte flags = data[0];
+ bool isHeartRateValueFormat16Bit = ((flags & 0x01) != 0);
+ int heartRate;
+
+ if (isHeartRateValueFormat16Bit && data.Length >= 3)
+ {
+ heartRate = BitConverter.ToUInt16(data, 1);
+ }
+ else if (data.Length >= 2)
+ {
+ heartRate = data[1];
+ }
+ else
+ {
+ return; // 数据不完整
+ }
+
+ //Debug.WriteLine($"{TAG}: 收到心率值: {heartRate} bpm");
+ MainThread.BeginInvokeOnMainThread(() => { UpdateHeartRateData(heartRate); });
+ };
+
+ // 开始接收通知
+ await heartRateCharacteristic.StartUpdatesAsync();
+ statusLabel.Text = "正在监测心率...";
+ }
+ else
+ {
+ Debug.WriteLine($"{TAG}: 连接失败");
+ statusLabel.Text = "连接失败,请重试";
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"{TAG}: 连接错误: {ex.Message}");
+ statusLabel.Text = $"连接错误: {ex.Message}";
+ }
+ finally
+ {
+ // 重置连接状态标志
+ _isConnecting = false;
+ }
+ }
+
+ // 释放资源
+ ~MainPage()
+ {
+ if (_webSocketClient != null)
+ {
+ _webSocketClient.Dispose();
+ _webSocketClient = null;
+ }
+ }
+
+ // 更新心率数据和图表
+ private void UpdateHeartRateData(int heartRate)
+ {
+ // 更新UI上的心率显示
+ heartRateLabel.Text = $"心率: {heartRate} bpm";
+
+ // 保存最新心率值,用于通知
+ _latestHeartRate = heartRate;
+
+ lock (_heartRateDataLock) // 使用锁确保线程安全
+ {
+ // 添加新的数据点
+ var dataPoint = new HeartRateDataPoint
+ {
+ Timestamp = DateTime.Now,
+ HeartRate = heartRate
+ };
+
+ // 如果是第一个数据点,记录会话开始时间
+ if (_heartRateData.Count == 0)
+ {
+ _sessionStartTime = DateTime.Now;
+ MainThread.BeginInvokeOnMainThread(() => { noDataLabel.IsVisible = false; });
+ }
+
+ _heartRateData.Add(dataPoint);
+
+ // 限制数据点数量
+ if (_heartRateData.Count > 100)
+ {
+ _heartRateData.RemoveAt(0);
+ }
+
+ // 更新统计信息
+ if (_heartRateData.Count > 0)
+ {
+ _minHeartRate = _heartRateData.Min(p => p.HeartRate);
+ _maxHeartRate = _heartRateData.Max(p => p.HeartRate);
+ _avgHeartRate = _heartRateData.Average(p => p.HeartRate);
+
+ MainThread.BeginInvokeOnMainThread(() =>
+ {
+ minHeartRateLabel.Text = _minHeartRate.ToString();
+ maxHeartRateLabel.Text = _maxHeartRate.ToString();
+ avgHeartRateLabel.Text = _avgHeartRate.ToString("0");
+ });
+ }
+
+ // 标记有新数据,等待定时器更新图表
+ _hasNewHeartRateData = true;
+ }
+ }
+
+
+ // 创建通知渠道(Android特有)
+ private void CreateNotificationChannel()
+ {
+#if ANDROID
+ if (OperatingSystem.IsAndroidVersionAtLeast(26))
+ {
+ var context = Android.App.Application.Context;
+ var channel =
+ new Android.App.NotificationChannel(CHANNEL_ID, "心率监测", Android.App.NotificationImportance.High)
+ {
+ Description = "显示实时心率数据"
+ };
+
+ var notificationManager =
+ context.GetSystemService(Android.Content.Context
+ .NotificationService) as Android.App.NotificationManager;
+ notificationManager?.CreateNotificationChannel(channel);
+ }
+#endif
+ }
+
+ // 初始化WebSocket客户端
+ private async Task InitializeWebSocketClientAsync(string url = null)
+ {
+ // 释放现有的WebSocket客户端
+ if (_webSocketClient != null)
+ {
+ _webSocketClient.Dispose();
+ _webSocketClient = null;
+ }
+
+ // 使用提供的URL或默认URL
+ _webSocketUrl = string.IsNullOrEmpty(url) ? DEFAULT_WEBSOCKET_URL : url;
+
+ try
+ {
+ _webSocketClient = new Services.WebSocketService.HeartRateWebSocketClient(_webSocketUrl);
+ await _webSocketClient.ConnectAsync();
+ _isWebSocketEnabled = true;
+ Debug.WriteLine($"{TAG}: WebSocket客户端已初始化,连接到 {_webSocketUrl}");
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"{TAG}: 初始化WebSocket客户端失败: {ex.Message}");
+ _isWebSocketEnabled = false;
+ }
+ }
+
+ // 发送心率数据到WebSocket服务器
+ private async Task SendHeartRateToServerAsync(int heartRate)
+ {
+ if (!_isWebSocketEnabled || _webSocketClient == null) return;
+
+ try
+ {
+ var data = new WebSocketService.HeartRateData
+ {
+ HeartRate = heartRate,
+ Timestamp = DateTime.Now,
+ DeviceName = _connectedDevice?.Name ?? "未知设备"
+ };
+
+ await _webSocketClient.SendHeartRateDataAsync(data);
+ //Debug.WriteLine($"已发送心率数据 {heartRate} bpm 到服务器");
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"发送心率数据失败: {ex.Message}");
+ }
+ }
+}
\ No newline at end of file
diff --git a/MauiProgram.cs b/MauiProgram.cs
new file mode 100644
index 0000000..f19da0a
--- /dev/null
+++ b/MauiProgram.cs
@@ -0,0 +1,24 @@
+using Microsoft.Extensions.Logging;
+
+namespace HeartRateMonitorAndroid;
+
+public static class MauiProgram
+{
+ public static MauiApp CreateMauiApp()
+ {
+ var builder = MauiApp.CreateBuilder();
+ builder
+ .UseMauiApp()
+ .ConfigureFonts(fonts =>
+ {
+ fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
+ fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
+ });
+
+#if DEBUG
+ builder.Logging.AddDebug();
+#endif
+
+ return builder.Build();
+ }
+}
\ No newline at end of file
diff --git a/Platforms/Android/AndroidManifest.xml b/Platforms/Android/AndroidManifest.xml
new file mode 100644
index 0000000..b212c9f
--- /dev/null
+++ b/Platforms/Android/AndroidManifest.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Platforms/Android/AndroidNotificationHelper.cs b/Platforms/Android/AndroidNotificationHelper.cs
new file mode 100644
index 0000000..b3a9c46
--- /dev/null
+++ b/Platforms/Android/AndroidNotificationHelper.cs
@@ -0,0 +1,102 @@
+using Android.App;
+using Android.Content;
+using AndroidX.Core.App;
+using Application = Android.App.Application;
+namespace HeartRateMonitorAndroid.Platforms.Android
+{
+ // Android平台特定的通知帮助类
+ public static class AndroidNotificationHelper
+ {
+ // 创建通知渠道
+ public static void CreateNotificationChannel(string channelId, string channelName, string description)
+ {
+ if (OperatingSystem.IsAndroidVersionAtLeast(26))
+ {
+ var context = Application.Context;
+ var channel = new NotificationChannel(channelId, channelName, NotificationImportance.High)
+ {
+ Description = description
+ };
+
+ var notificationManager = context.GetSystemService(Context.NotificationService) as NotificationManager;
+ notificationManager?.CreateNotificationChannel(channel);
+ }
+ }
+
+ // 显示通知
+ public static void ShowNotification(string channelId, int notificationId, string title, string content, int iconResourceId, bool ongoing = true)
+ {
+ var context = Application.Context;
+
+ // 创建PendingIntent用于点击通知时打开应用
+ var intent = context.PackageManager.GetLaunchIntentForPackage(context.PackageName);
+ var pendingIntent = PendingIntent.GetActivity(context, 0, intent, PendingIntentFlags.Immutable);
+
+ // 创建通知内容
+ var notificationBuilder = new NotificationCompat.Builder(context, channelId)
+ .SetContentTitle(title)
+ .SetContentText(content)
+ .SetSmallIcon(iconResourceId)
+ .SetOngoing(ongoing)
+ .SetContentIntent(pendingIntent)
+ .SetPriority(NotificationCompat.PriorityHigh);
+
+ // 显示通知
+ var notificationManager = NotificationManagerCompat.From(context);
+ notificationManager.Notify(notificationId, notificationBuilder.Build());
+ }
+ // 显示普通通知 - 用于重连提示等
+ public static void ShowNormalNotification(string channelId, int notificationId, string title, string content, int iconResourceId, bool isForeground)
+ {
+ var context = Application.Context;
+ var intent = context.PackageManager.GetLaunchIntentForPackage(context.PackageName);
+ intent.SetFlags(ActivityFlags.NewTask | ActivityFlags.ClearTask);
+ var pendingIntent = PendingIntent.GetActivity(context, 0, intent, PendingIntentFlags.Immutable);
+
+ var builder = new NotificationCompat.Builder(context, channelId)
+ .SetContentTitle(title)
+ .SetContentText(content)
+ .SetSmallIcon(iconResourceId)
+ .SetContentIntent(pendingIntent)
+ .SetAutoCancel(true);
+
+ var notification = builder.Build();
+ // 普通通知
+ var notificationManager = NotificationManagerCompat.From(context);
+ notificationManager.Notify(notificationId, notification);
+
+ }
+
+ // 显示带有扩展内容的通知
+ public static void ShowBigTextNotification(string channelId, int notificationId, string title, string content, string bigText, int iconResourceId, bool ongoing = true)
+ {
+ var context = Application.Context;
+
+ // 创建PendingIntent用于点击通知时打开应用
+ var intent = context.PackageManager.GetLaunchIntentForPackage(context.PackageName);
+ var pendingIntent = PendingIntent.GetActivity(context, 0, intent, PendingIntentFlags.Immutable);
+
+ // 创建通知内容
+ var notificationBuilder = new NotificationCompat.Builder(context, channelId)
+ .SetContentTitle(title)
+ .SetContentText(content)
+ .SetSmallIcon(iconResourceId)
+ .SetOngoing(ongoing)
+ .SetContentIntent(pendingIntent)
+ .SetPriority(NotificationCompat.PriorityHigh)
+ .SetStyle(new NotificationCompat.BigTextStyle().BigText(bigText));
+
+ // 显示通知
+ var notificationManager = NotificationManagerCompat.From(context);
+ notificationManager.Notify(notificationId, notificationBuilder.Build());
+ }
+
+ // 取消通知
+ public static void CancelNotification(int notificationId)
+ {
+ var context = Application.Context;
+ var notificationManager = NotificationManagerCompat.From(context);
+ notificationManager.Cancel(notificationId);
+ }
+ }
+}
diff --git a/Platforms/Android/MainActivity.cs b/Platforms/Android/MainActivity.cs
new file mode 100644
index 0000000..57f474c
--- /dev/null
+++ b/Platforms/Android/MainActivity.cs
@@ -0,0 +1,12 @@
+using Android.App;
+using Android.Content.PM;
+using Android.OS;
+
+namespace HeartRateMonitorAndroid;
+
+[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop,
+ ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode |
+ ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
+public class MainActivity : MauiAppCompatActivity
+{
+}
\ No newline at end of file
diff --git a/Platforms/Android/MainApplication.cs b/Platforms/Android/MainApplication.cs
new file mode 100644
index 0000000..209e336
--- /dev/null
+++ b/Platforms/Android/MainApplication.cs
@@ -0,0 +1,15 @@
+using Android.App;
+using Android.Runtime;
+
+namespace HeartRateMonitorAndroid;
+
+[Application]
+public class MainApplication : MauiApplication
+{
+ public MainApplication(IntPtr handle, JniHandleOwnership ownership)
+ : base(handle, ownership)
+ {
+ }
+
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
\ No newline at end of file
diff --git a/Platforms/Android/Resources/values/colors.xml b/Platforms/Android/Resources/values/colors.xml
new file mode 100644
index 0000000..c04d749
--- /dev/null
+++ b/Platforms/Android/Resources/values/colors.xml
@@ -0,0 +1,6 @@
+
+
+ #512BD4
+ #2B0B98
+ #2B0B98
+
\ No newline at end of file
diff --git a/Platforms/MacCatalyst/AppDelegate.cs b/Platforms/MacCatalyst/AppDelegate.cs
new file mode 100644
index 0000000..3764a59
--- /dev/null
+++ b/Platforms/MacCatalyst/AppDelegate.cs
@@ -0,0 +1,9 @@
+using Foundation;
+
+namespace HeartRateMonitorAndroid;
+
+[Register("AppDelegate")]
+public class AppDelegate : MauiUIApplicationDelegate
+{
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
\ No newline at end of file
diff --git a/Platforms/MacCatalyst/Entitlements.plist b/Platforms/MacCatalyst/Entitlements.plist
new file mode 100644
index 0000000..de4adc9
--- /dev/null
+++ b/Platforms/MacCatalyst/Entitlements.plist
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ com.apple.security.app-sandbox
+
+
+ com.apple.security.network.client
+
+
+
+
diff --git a/Platforms/MacCatalyst/Info.plist b/Platforms/MacCatalyst/Info.plist
new file mode 100644
index 0000000..7268977
--- /dev/null
+++ b/Platforms/MacCatalyst/Info.plist
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ UIDeviceFamily
+
+ 2
+
+ UIRequiredDeviceCapabilities
+
+ arm64
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ XSAppIconAssets
+ Assets.xcassets/appicon.appiconset
+
+
diff --git a/Platforms/MacCatalyst/Program.cs b/Platforms/MacCatalyst/Program.cs
new file mode 100644
index 0000000..6fb4a2f
--- /dev/null
+++ b/Platforms/MacCatalyst/Program.cs
@@ -0,0 +1,15 @@
+using ObjCRuntime;
+using UIKit;
+
+namespace HeartRateMonitorAndroid;
+
+public class Program
+{
+ // This is the main entry point of the application.
+ static void Main(string[] args)
+ {
+ // if you want to use a different Application Delegate class from "AppDelegate"
+ // you can specify it here.
+ UIApplication.Main(args, null, typeof(AppDelegate));
+ }
+}
\ No newline at end of file
diff --git a/Platforms/Tizen/Main.cs b/Platforms/Tizen/Main.cs
new file mode 100644
index 0000000..cfba3a2
--- /dev/null
+++ b/Platforms/Tizen/Main.cs
@@ -0,0 +1,16 @@
+using System;
+using Microsoft.Maui;
+using Microsoft.Maui.Hosting;
+
+namespace HeartRateMonitorAndroid;
+
+class Program : MauiApplication
+{
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+
+ static void Main(string[] args)
+ {
+ var app = new Program();
+ app.Run(args);
+ }
+}
\ No newline at end of file
diff --git a/Platforms/Tizen/tizen-manifest.xml b/Platforms/Tizen/tizen-manifest.xml
new file mode 100644
index 0000000..d253d32
--- /dev/null
+++ b/Platforms/Tizen/tizen-manifest.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+ maui-appicon-placeholder
+
+
+
+
+ http://tizen.org/privilege/internet
+
+
+
+
\ No newline at end of file
diff --git a/Platforms/Windows/App.xaml b/Platforms/Windows/App.xaml
new file mode 100644
index 0000000..60ef737
--- /dev/null
+++ b/Platforms/Windows/App.xaml
@@ -0,0 +1,8 @@
+
+
+
diff --git a/Platforms/Windows/App.xaml.cs b/Platforms/Windows/App.xaml.cs
new file mode 100644
index 0000000..02d34c0
--- /dev/null
+++ b/Platforms/Windows/App.xaml.cs
@@ -0,0 +1,23 @@
+using Microsoft.UI.Xaml;
+
+// To learn more about WinUI, the WinUI project structure,
+// and more about our project templates, see: http://aka.ms/winui-project-info.
+
+namespace HeartRateMonitorAndroid.WinUI;
+
+///
+/// Provides application-specific behavior to supplement the default Application class.
+///
+public partial class App : MauiWinUIApplication
+{
+ ///
+ /// Initializes the singleton application object. This is the first line of authored code
+ /// executed, and as such is the logical equivalent of main() or WinMain().
+ ///
+ public App()
+ {
+ this.InitializeComponent();
+ }
+
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
\ No newline at end of file
diff --git a/Platforms/Windows/Package.appxmanifest b/Platforms/Windows/Package.appxmanifest
new file mode 100644
index 0000000..1f00567
--- /dev/null
+++ b/Platforms/Windows/Package.appxmanifest
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+ $placeholder$
+ User Name
+ $placeholder$.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Platforms/Windows/WindowsNotificationHelper.cs b/Platforms/Windows/WindowsNotificationHelper.cs
new file mode 100644
index 0000000..d63fee1
--- /dev/null
+++ b/Platforms/Windows/WindowsNotificationHelper.cs
@@ -0,0 +1,24 @@
+using Microsoft.UI.Xaml;
+
+namespace HeartRateMonitorAndroid.Platforms.Windows
+{
+ // Windows平台特定的通知帮助类
+ public static class WindowsNotificationHelper
+ {
+ // 显示通知(Windows实现)
+ public static void ShowNotification(string title, string content)
+ {
+ // Windows平台的通知实现
+ // 注意:在实际应用中,你需要使用Windows.UI.Notifications命名空间
+ // 或Microsoft.Toolkit.Uwp.Notifications库来实现
+ Console.WriteLine($"Windows通知: {title} - {content}");
+ }
+
+ // 取消通知
+ public static void CancelNotification(string tag = null)
+ {
+ // 取消Windows平台通知的实现
+ Console.WriteLine("取消Windows通知");
+ }
+ }
+}
diff --git a/Platforms/Windows/app.manifest b/Platforms/Windows/app.manifest
new file mode 100644
index 0000000..6b5a66d
--- /dev/null
+++ b/Platforms/Windows/app.manifest
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+ true/PM
+ PerMonitorV2, PerMonitor
+
+
+
diff --git a/Platforms/iOS/AppDelegate.cs b/Platforms/iOS/AppDelegate.cs
new file mode 100644
index 0000000..3764a59
--- /dev/null
+++ b/Platforms/iOS/AppDelegate.cs
@@ -0,0 +1,9 @@
+using Foundation;
+
+namespace HeartRateMonitorAndroid;
+
+[Register("AppDelegate")]
+public class AppDelegate : MauiUIApplicationDelegate
+{
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
\ No newline at end of file
diff --git a/Platforms/iOS/Info.plist b/Platforms/iOS/Info.plist
new file mode 100644
index 0000000..0004a4f
--- /dev/null
+++ b/Platforms/iOS/Info.plist
@@ -0,0 +1,32 @@
+
+
+
+
+ LSRequiresIPhoneOS
+
+ UIDeviceFamily
+
+ 1
+ 2
+
+ UIRequiredDeviceCapabilities
+
+ arm64
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ XSAppIconAssets
+ Assets.xcassets/appicon.appiconset
+
+
diff --git a/Platforms/iOS/IosNotificationHelper.cs b/Platforms/iOS/IosNotificationHelper.cs
new file mode 100644
index 0000000..6b530cc
--- /dev/null
+++ b/Platforms/iOS/IosNotificationHelper.cs
@@ -0,0 +1,63 @@
+using Foundation;
+using UserNotifications;
+
+namespace HeartRateMonitorAndroid.Platforms.iOS
+{
+ // iOS平台特定的通知帮助类
+ public static class IosNotificationHelper
+ {
+ // 请求通知权限
+ public static async Task RequestNotificationPermission()
+ {
+ var options = UNAuthorizationOptions.Alert | UNAuthorizationOptions.Sound;
+ var center = UNUserNotificationCenter.Current;
+ var result = await center.RequestAuthorizationAsync(options);
+
+ // 权限请求结果处理
+ if (result.Item1)
+ {
+ // 已获得权限
+ Console.WriteLine("通知权限获取成功");
+ }
+ else
+ {
+ // 权限被拒绝
+ Console.WriteLine("通知权限被拒绝");
+ }
+ }
+
+ // 显示本地通知
+ public static void ShowNotification(string title, string body, double timeIntervalSeconds = 0.1)
+ {
+ var content = new UNMutableNotificationContent
+ {
+ Title = title,
+ Body = body,
+ Sound = UNNotificationSound.Default
+ };
+
+ // 设置触发器
+ var trigger = UNTimeIntervalNotificationTrigger.CreateTrigger(timeIntervalSeconds, false);
+
+ // 创建请求
+ var requestId = Guid.NewGuid().ToString();
+ var request = UNNotificationRequest.FromIdentifier(requestId, content, trigger);
+
+ // 添加请求
+ UNUserNotificationCenter.Current.AddNotificationRequest(request, (error) =>
+ {
+ if (error != null)
+ {
+ Console.WriteLine($"通知发送失败: {error}");
+ }
+ });
+ }
+
+ // 取消所有通知
+ public static void CancelAllNotifications()
+ {
+ UNUserNotificationCenter.Current.RemoveAllPendingNotificationRequests();
+ UNUserNotificationCenter.Current.RemoveAllDeliveredNotifications();
+ }
+ }
+}
diff --git a/Platforms/iOS/Program.cs b/Platforms/iOS/Program.cs
new file mode 100644
index 0000000..6fb4a2f
--- /dev/null
+++ b/Platforms/iOS/Program.cs
@@ -0,0 +1,15 @@
+using ObjCRuntime;
+using UIKit;
+
+namespace HeartRateMonitorAndroid;
+
+public class Program
+{
+ // This is the main entry point of the application.
+ static void Main(string[] args)
+ {
+ // if you want to use a different Application Delegate class from "AppDelegate"
+ // you can specify it here.
+ UIApplication.Main(args, null, typeof(AppDelegate));
+ }
+}
\ No newline at end of file
diff --git a/Platforms/iOS/Resources/PrivacyInfo.xcprivacy b/Platforms/iOS/Resources/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000..24ab3b4
--- /dev/null
+++ b/Platforms/iOS/Resources/PrivacyInfo.xcprivacy
@@ -0,0 +1,51 @@
+
+
+
+
+
+ NSPrivacyAccessedAPITypes
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryFileTimestamp
+ NSPrivacyAccessedAPITypeReasons
+
+ C617.1
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategorySystemBootTime
+ NSPrivacyAccessedAPITypeReasons
+
+ 35F9.1
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryDiskSpace
+ NSPrivacyAccessedAPITypeReasons
+
+ E174.1
+
+
+
+
+
+
diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json
new file mode 100644
index 0000000..4f85793
--- /dev/null
+++ b/Properties/launchSettings.json
@@ -0,0 +1,8 @@
+{
+ "profiles": {
+ "Windows Machine": {
+ "commandName": "Project",
+ "nativeDebugging": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4ff540f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,10 @@
+# Heart Rate Monitor Android
+本程序理论可以在Android/iOS设备上运行,请自行编译,编译前请修改如下部分:
+
+1. 请新建 `./Resources/Raw/token.txt` 来存储 `token`,请确保与服务端 `token` 一致。
+2. 请修改位于 `./MainPage.xaml.cs` 的默认 `websocket` 服务器地址,除非你想每次启动软件时都手动输入一次服务器地址。
+
+## 编译须知
+- 本程序依赖dotnet 8.0.400 版本,请确认你有对应版本的 dotnetSDK。
+- 本程序需要maui工作负载,请使用 `dotnet workload install maui` 来安装此负载。
+- 本程序无法在Windows下运行,本程序不是UWP应用。
\ No newline at end of file
diff --git a/Resources/AppIcon/appicon.svg b/Resources/AppIcon/appicon.svg
new file mode 100644
index 0000000..9d63b65
--- /dev/null
+++ b/Resources/AppIcon/appicon.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/Resources/AppIcon/appiconfg.svg b/Resources/AppIcon/appiconfg.svg
new file mode 100644
index 0000000..21dfb25
--- /dev/null
+++ b/Resources/AppIcon/appiconfg.svg
@@ -0,0 +1,8 @@
+
+
+
\ No newline at end of file
diff --git a/Resources/Fonts/OpenSans-Regular.ttf b/Resources/Fonts/OpenSans-Regular.ttf
new file mode 100644
index 0000000..33b3e0d
Binary files /dev/null and b/Resources/Fonts/OpenSans-Regular.ttf differ
diff --git a/Resources/Fonts/OpenSans-Semibold.ttf b/Resources/Fonts/OpenSans-Semibold.ttf
new file mode 100644
index 0000000..a1f8571
Binary files /dev/null and b/Resources/Fonts/OpenSans-Semibold.ttf differ
diff --git a/Resources/Images/dotnet_bot.png b/Resources/Images/dotnet_bot.png
new file mode 100644
index 0000000..1d1b981
Binary files /dev/null and b/Resources/Images/dotnet_bot.png differ
diff --git a/Resources/Raw/AboutAssets.txt b/Resources/Raw/AboutAssets.txt
new file mode 100644
index 0000000..89dc758
--- /dev/null
+++ b/Resources/Raw/AboutAssets.txt
@@ -0,0 +1,15 @@
+Any raw assets you want to be deployed with your application can be placed in
+this directory (and child directories). Deployment of the asset to your application
+is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
+
+
+
+These files will be deployed with your package and will be accessible using Essentials:
+
+ async Task LoadMauiAsset()
+ {
+ using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
+ using var reader = new StreamReader(stream);
+
+ var contents = reader.ReadToEnd();
+ }
diff --git a/Resources/Splash/splash.svg b/Resources/Splash/splash.svg
new file mode 100644
index 0000000..21dfb25
--- /dev/null
+++ b/Resources/Splash/splash.svg
@@ -0,0 +1,8 @@
+
+
+
\ No newline at end of file
diff --git a/Resources/Styles/Colors.xaml b/Resources/Styles/Colors.xaml
new file mode 100644
index 0000000..30307a5
--- /dev/null
+++ b/Resources/Styles/Colors.xaml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+ #512BD4
+ #ac99ea
+ #242424
+ #DFD8F7
+ #9880e5
+ #2B0B98
+
+ White
+ Black
+ #D600AA
+ #190649
+ #1f1f1f
+
+ #E1E1E1
+ #C8C8C8
+ #ACACAC
+ #919191
+ #6E6E6E
+ #404040
+ #212121
+ #141414
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Resources/Styles/Styles.xaml b/Resources/Styles/Styles.xaml
new file mode 100644
index 0000000..86f574d
--- /dev/null
+++ b/Resources/Styles/Styles.xaml
@@ -0,0 +1,451 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Services/NotificationService.cs b/Services/NotificationService.cs
new file mode 100644
index 0000000..8863f95
--- /dev/null
+++ b/Services/NotificationService.cs
@@ -0,0 +1,120 @@
+namespace HeartRateMonitorAndroid.Services
+{
+ // 跨平台通知服务
+ public static class NotificationService
+ {
+ // 常量定义
+ private const string CHANNEL_ID = "HeartRateMonitorChannel";
+ private const int NOTIFICATION_ID = 100;
+
+ // 初始化通知服务
+ public static void Initialize()
+ {
+ // 根据平台初始化
+ if (DeviceInfo.Platform == DevicePlatform.Android)
+ {
+#if ANDROID
+ Platforms.Android.AndroidNotificationHelper.CreateNotificationChannel(
+ CHANNEL_ID,
+ "心率监测",
+ "显示实时心率数据");
+#endif
+ }
+ else if (DeviceInfo.Platform == DevicePlatform.iOS)
+ {
+#if IOS
+ // 请求iOS通知权限
+ Platforms.iOS.IosNotificationHelper.RequestNotificationPermission().ConfigureAwait(false);
+#endif
+ }
+ }
+
+ // 显示心率通知
+ public static void ShowHeartRateNotification(int currentHeartRate, double avgHeartRate, int minHeartRate, int maxHeartRate, TimeSpan duration)
+ {
+ string title = "心率监测";
+ string content = $"当前心率: {currentHeartRate} bpm 平均: {avgHeartRate:0} bpm";
+ string bigText = $"当前心率: {currentHeartRate} bpm\n监测时长: {duration.Hours:00}:{duration.Minutes:00}:{duration.Seconds:00}\n最低: {minHeartRate} bpm | 最高: {maxHeartRate} bpm";
+
+ if (DeviceInfo.Platform == DevicePlatform.Android)
+ {
+#if ANDROID
+ Platforms.Android.AndroidNotificationHelper.ShowBigTextNotification(
+ CHANNEL_ID,
+ NOTIFICATION_ID,
+ title,
+ content,
+ bigText,
+ Resource.Drawable.notification_icon_background,
+ true);
+#endif
+ }
+ else if (DeviceInfo.Platform == DevicePlatform.iOS)
+ {
+#if IOS
+ Platforms.iOS.IosNotificationHelper.ShowNotification(title, content);
+#endif
+ }
+ else if (DeviceInfo.Platform == DevicePlatform.WinUI)
+ {
+#if WINDOWS
+ Platforms.Windows.WindowsNotificationHelper.ShowNotification(title, content);
+#endif
+ }
+ }
+
+ // 取消通知
+ public static void CancelNotification()
+ {
+ if (DeviceInfo.Platform == DevicePlatform.Android)
+ {
+#if ANDROID
+ Platforms.Android.AndroidNotificationHelper.CancelNotification(NOTIFICATION_ID);
+#endif
+ }
+ else if (DeviceInfo.Platform == DevicePlatform.iOS)
+ {
+#if IOS
+ Platforms.iOS.IosNotificationHelper.CancelAllNotifications();
+#endif
+ }
+ else if (DeviceInfo.Platform == DevicePlatform.WinUI)
+ {
+#if WINDOWS
+ Platforms.Windows.WindowsNotificationHelper.CancelNotification();
+#endif
+ }
+ }
+
+ // 显示重连通知
+ public static void ShowReconnectionNotification(string title, string message, int attemptCount)
+ {
+ const int RECONNECTION_NOTIFICATION_ID = 101; // 使用不同的ID,避免覆盖心率通知
+
+ if (DeviceInfo.Platform == DevicePlatform.Android)
+ {
+#if ANDROID
+ Platforms.Android.AndroidNotificationHelper.ShowNormalNotification(
+ CHANNEL_ID,
+ RECONNECTION_NOTIFICATION_ID,
+ title,
+ message,
+ Resource.Drawable.notification_icon_background,
+ false); // 不使用前台服务,只显示普通通知
+#endif
+ }
+ else if (DeviceInfo.Platform == DevicePlatform.iOS)
+ {
+#if IOS
+ Platforms.iOS.IosNotificationHelper.ShowNotification(title, message);
+#endif
+ }
+ else if (DeviceInfo.Platform == DevicePlatform.WinUI)
+ {
+#if WINDOWS
+ Platforms.Windows.WindowsNotificationHelper.ShowNotification(title, message);
+#endif
+ }
+ }
+ }
+}
diff --git a/Services/WebSocketService.cs b/Services/WebSocketService.cs
new file mode 100644
index 0000000..666db36
--- /dev/null
+++ b/Services/WebSocketService.cs
@@ -0,0 +1,294 @@
+using System.Net.WebSockets;
+using System.Text;
+using Newtonsoft.Json;
+
+namespace HeartRateMonitorAndroid.Services;
+
+public class WebSocketService
+{
+ // 心率上报
+ public class HeartRateWebSocketClient : IDisposable
+ {
+ private readonly string _serverUrl;
+ private ClientWebSocket _webSocket;
+ private CancellationTokenSource _cts;
+ private bool _isConnected = false;
+ private int _reconnectDelayMs = 5000; // 初始重连延时5秒
+ private readonly int _maxReconnectDelayMs = 60000; // 最大重连延时60秒
+ private readonly object _lockObject = new object();
+ private bool _isReconnecting = false; // 是否正在重连
+ private int _reconnectAttempts = 0; // 重连尝试次数
+
+ public HeartRateWebSocketClient(string serverUrl)
+ {
+ _serverUrl = serverUrl;
+ _webSocket = new ClientWebSocket();
+
+ // 配置WebSocket客户端选项,增强后台运行稳定性
+ _webSocket.Options.KeepAliveInterval = TimeSpan.FromSeconds(20);
+ _webSocket.Options.SetBuffer(8192, 8192); // 增加缓冲区大小
+
+ // 在某些Android设备上,默认SubProtocol可能导致连接问题
+ // _webSocket.Options.AddSubProtocol("json"); // 可以根据服务器要求添加子协议
+
+ _cts = new CancellationTokenSource();
+ }
+
+ public async Task ConnectAsync()
+ {
+ if (_isConnected) return;
+
+ try
+ {
+ Console.WriteLine($"正在连接到 WebSocket 服务器: {_serverUrl}");
+ await _webSocket.ConnectAsync(new Uri(_serverUrl), _cts.Token);
+
+ _isConnected = true;
+ Console.WriteLine("已成功连接到 WebSocket 服务器");
+
+ // 连接成功,重置重连参数
+ _reconnectAttempts = 0;
+ _reconnectDelayMs = 5000; // 重置为初始值
+
+ // 启动接收消息的任务
+ _ = ReceiveMessagesAsync();
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"连接 WebSocket 服务器失败: {ex.Message}");
+ if (!_isReconnecting) // 只有在不是重连过程中才触发重连
+ {
+ await ReconnectAsync();
+ }
+ }
+ }
+
+ private async Task ReceiveMessagesAsync()
+ {
+ var buffer = new byte[4096];
+ try
+ {
+ while (_webSocket.State == WebSocketState.Open && !_cts.Token.IsCancellationRequested)
+ {
+ var result = await _webSocket.ReceiveAsync(new ArraySegment(buffer), _cts.Token);
+
+ if (result.MessageType == WebSocketMessageType.Close)
+ {
+ await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
+ _isConnected = false;
+ await ReconnectAsync();
+ break;
+ }
+
+ if (result.MessageType == WebSocketMessageType.Text)
+ {
+ var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
+ Console.WriteLine($"收到服务器消息: {message}");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"接收消息时出错: {ex.Message}");
+ _isConnected = false;
+ await ReconnectAsync();
+ }
+ }
+
+ private async Task ReconnectAsync()
+ {
+ if (_cts.IsCancellationRequested) return;
+
+ // 检查是否已经在重连中,避免多次重连
+ lock (_lockObject)
+ {
+ if (_isReconnecting)
+ {
+ Console.WriteLine("已经有重连任务在进行中,跳过此次重连请求");
+ return;
+ }
+ _isReconnecting = true;
+ }
+
+ try
+ {
+ // 增加重连次数
+ _reconnectAttempts++;
+
+ // 使用指数退避策略增加等待时间
+ // 每次重连失败后,等待时间翻倍,但不超过最大值
+ _reconnectDelayMs = Math.Min(_reconnectDelayMs * 2, _maxReconnectDelayMs);
+
+ Console.WriteLine($"第{_reconnectAttempts}次重连尝试,等待{_reconnectDelayMs / 1000}秒...");
+
+ // 如果重连次数超过特定阈值,显示通知提醒用户
+ if (_reconnectAttempts == 3 || _reconnectAttempts == 5 || _reconnectAttempts % 10 == 0)
+ {
+ await ShowReconnectionNotification();
+ }
+
+ lock (_lockObject)
+ {
+ if (_webSocket.State != WebSocketState.Open && _webSocket.State != WebSocketState.Connecting)
+ {
+ _webSocket.Dispose();
+ _webSocket = new ClientWebSocket();
+
+ // 设置WebSocket选项以提高后台连接可靠性
+ _webSocket.Options.KeepAliveInterval = TimeSpan.FromSeconds(20);
+ _webSocket.Options.SetBuffer(8192, 8192); // 增加缓冲区大小
+ }
+ }
+
+ await Task.Delay(_reconnectDelayMs);
+ await ConnectAsync();
+
+ // 连接成功,重置重连计数和延迟
+ if (_isConnected)
+ {
+ _reconnectAttempts = 0;
+ _reconnectDelayMs = 5000; // 重置为初始值
+ Console.WriteLine("重连成功,重置重连参数");
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"重连过程中发生错误: {ex.Message}");
+ // 使用当前的延迟时间再次尝试
+ await Task.Delay(_reconnectDelayMs);
+ // 释放重连锁,允许下次重连
+ lock (_lockObject) { _isReconnecting = false; }
+ await ReconnectAsync();
+ }
+ finally
+ {
+ // 确保重连锁被释放
+ lock (_lockObject) { _isReconnecting = false; }
+ }
+ }
+
+ // 显示重连通知
+ private async Task ShowReconnectionNotification()
+ {
+ try
+ {
+ await MainThread.InvokeOnMainThreadAsync(() =>
+ {
+ var title = "连接中断";
+ var message = $"服务器连接已断开,正在尝试第{_reconnectAttempts}次重连。";
+
+ // 使用应用程序的通知服务显示通知
+ HeartRateMonitorAndroid.Services.NotificationService.ShowReconnectionNotification(
+ title,
+ message,
+ _reconnectAttempts);
+ });
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"显示重连通知失败: {ex.Message}");
+ }
+ }
+
+ public async Task SendHeartRateDataAsync(HeartRateData data)
+ {
+ // 检查WebSocket状态
+ if (_webSocket.State != WebSocketState.Open || !_isConnected)
+ {
+ Console.WriteLine($"WebSocket未连接,当前状态: {_webSocket.State},尝试重新连接");
+ _isConnected = false;
+
+ // 如果已经在重连过程中,不要再次尝试连接
+ if (!_isReconnecting)
+ {
+ await ConnectAsync();
+ }
+
+ if (!_isConnected)
+ {
+ Console.WriteLine("重连失败,无法发送数据");
+
+ // 如果重连次数超过阈值,显示连接失败通知
+ if (_reconnectAttempts >= 3 && !_isReconnecting)
+ {
+ await ShowReconnectionNotification();
+ // 触发重连
+ await ReconnectAsync();
+ }
+ return;
+ }
+ }
+
+ try
+ {
+ var json = JsonConvert.SerializeObject(data);
+ var buffer = Encoding.UTF8.GetBytes(json);
+
+ // 设置发送超时
+ var sendCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token);
+ sendCts.CancelAfter(TimeSpan.FromSeconds(5)); // 5秒超时
+
+ await _webSocket.SendAsync(
+ new ArraySegment(buffer),
+ WebSocketMessageType.Text,
+ true,
+ sendCts.Token);
+
+ //Console.WriteLine($"成功发送心率数据: {data.HeartRate} bpm");
+ }
+ catch (OperationCanceledException)
+ {
+ Console.WriteLine("发送数据超时");
+ _isConnected = false;
+ await ReconnectAsync();
+ }
+ catch (WebSocketException ex) when (ex.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely)
+ {
+ Console.WriteLine("WebSocket连接已关闭,尝试重新连接");
+ _isConnected = false;
+ await ReconnectAsync();
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"发送心率数据失败: {ex.Message}, 类型: {ex.GetType().Name}");
+ _isConnected = false;
+ await ReconnectAsync();
+ }
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ _cts.Cancel();
+ if (_webSocket.State == WebSocketState.Open)
+ {
+ _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "客户端关闭", CancellationToken.None)
+ .Wait(TimeSpan.FromSeconds(2));
+ }
+ _webSocket.Dispose();
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"关闭WebSocket客户端时出错: {ex.Message}");
+ }
+ }
+ }
+ public class HeartRateData
+ {
+ public int HeartRate { get; set; }
+ public DateTime Timestamp { get; set; }
+ public string DeviceName { get; set; }
+ public string Token
+ {
+ get
+ {
+ using var stream = FileSystem.OpenAppPackageFileAsync("token.txt").Result;
+ using var reader = new StreamReader(stream);
+
+ var contents = reader.ReadToEnd();
+ return contents;
+ }
+ }
+ }
+}
\ No newline at end of file