feat: M1.1 完成 + M1.2 启动 — 全量更新
M1.1 收尾: - 24项 P0/P1/P2 bug 修复 (Rust 107 tests + Flutter 15 tests) - Flutter App v0.3: cupertino_icons 修复, 单元测试, 调试面板, APK 52.6MB - 示例插件完善: manifest.json + 请求/响应示范 + 7个测试 - API 文档重写 (以 routes.rs 为唯一权威) - MILESTONES.md 更新至 100% M1.2 启动: - P0: 插件管理 API 闭环 (handle_manager_message Custom 分支 + broadcast_plugin_states) - ServiceManager 集成测试 8/8 (tests/m1_2_service_manager.rs) - M1.2 测试计划 (docs/M1.2_TEST_PLAN.md, 18个E2E场景) - 动态插件系统: auto_rollback + version_manager GC + 路径穿越防护 总计: Rust 115/115 测试, Flutter 15/15 测试, 零 warning Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -151,6 +151,29 @@ class BleService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendCommand(String command) async {
|
||||
final characteristic = _commandCharacteristic;
|
||||
if (_connectedDevice == null) {
|
||||
throw StateError('未连接 BLE 设备');
|
||||
}
|
||||
if (characteristic == null) {
|
||||
throw StateError('未发现 BLE 命令特征值');
|
||||
}
|
||||
|
||||
await characteristic.write(
|
||||
utf8.encode(command),
|
||||
withoutResponse: characteristic.properties.writeWithoutResponse,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> play() => sendCommand('play');
|
||||
|
||||
Future<void> pause() => sendCommand('pause');
|
||||
|
||||
Future<void> next() => sendCommand('next');
|
||||
|
||||
Future<void> previous() => sendCommand('prev');
|
||||
|
||||
Future<void> disconnect() async {
|
||||
await _scanSubscription?.cancel();
|
||||
_scanSubscription = null;
|
||||
|
||||
153
clients/flutter/lib/services/device_storage_service.dart
Normal file
153
clients/flutter/lib/services/device_storage_service.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class SavedDevice {
|
||||
const SavedDevice({
|
||||
required this.ip,
|
||||
required this.port,
|
||||
required this.name,
|
||||
required this.lastUsedAt,
|
||||
});
|
||||
|
||||
final String ip;
|
||||
final int port;
|
||||
final String name;
|
||||
final DateTime lastUsedAt;
|
||||
|
||||
String get address => '$ip:$port';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'ip': ip,
|
||||
'port': port,
|
||||
'name': name,
|
||||
'lastUsedAt': lastUsedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
factory SavedDevice.fromJson(Map<String, dynamic> json) {
|
||||
return SavedDevice(
|
||||
ip: _normalizeIp(json['ip']?.toString()),
|
||||
port: _normalizePort(json['port']),
|
||||
name: _normalizeName(json['name']?.toString()),
|
||||
lastUsedAt: DateTime.tryParse(json['lastUsedAt']?.toString() ?? '') ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
}
|
||||
|
||||
static String _normalizeIp(String? value) {
|
||||
final normalized = value?.trim() ?? '';
|
||||
return normalized.isEmpty ? '127.0.0.1' : normalized;
|
||||
}
|
||||
|
||||
static int _normalizePort(dynamic value) {
|
||||
final port = int.tryParse(value?.toString() ?? '');
|
||||
if (port == null || port <= 0 || port > 65535) {
|
||||
return 5000;
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
static String _normalizeName(String? value) {
|
||||
final normalized = value?.trim() ?? '';
|
||||
return normalized.isEmpty ? 'Showen' : normalized;
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceStorageService {
|
||||
static const String _devicesKey = 'showen_device_list';
|
||||
static const int _maxDevices = 10;
|
||||
|
||||
SharedPreferences? _preferences;
|
||||
|
||||
Future<void> saveDevice(String ip, int port, String? name) async {
|
||||
final prefs = await _getPreferences();
|
||||
final devices = await getDevices();
|
||||
final normalizedIp = _normalizeIp(ip);
|
||||
final normalizedPort = _normalizePort(port);
|
||||
final normalizedName = _normalizeName(name);
|
||||
final now = DateTime.now();
|
||||
|
||||
final nextDevices = <SavedDevice>[
|
||||
SavedDevice(
|
||||
ip: normalizedIp,
|
||||
port: normalizedPort,
|
||||
name: normalizedName,
|
||||
lastUsedAt: now,
|
||||
),
|
||||
...devices.where(
|
||||
(device) => !(device.ip == normalizedIp && device.port == normalizedPort),
|
||||
),
|
||||
]
|
||||
..sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
|
||||
|
||||
await prefs.setString(
|
||||
_devicesKey,
|
||||
jsonEncode(
|
||||
nextDevices
|
||||
.take(_maxDevices)
|
||||
.map((device) => device.toJson())
|
||||
.toList(growable: false),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<SavedDevice>> getDevices() async {
|
||||
final prefs = await _getPreferences();
|
||||
final raw = prefs.getString(_devicesKey);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return const <SavedDevice>[];
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) {
|
||||
return const <SavedDevice>[];
|
||||
}
|
||||
|
||||
final devices = decoded
|
||||
.whereType<Map>()
|
||||
.map((item) => SavedDevice.fromJson(Map<String, dynamic>.from(item)))
|
||||
.toList(growable: false)
|
||||
..sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
|
||||
|
||||
return devices.take(_maxDevices).toList(growable: false);
|
||||
} on FormatException {
|
||||
return const <SavedDevice>[];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeDevice(String ip, [int? port]) async {
|
||||
final prefs = await _getPreferences();
|
||||
final normalizedIp = _normalizeIp(ip);
|
||||
final nextDevices = (await getDevices())
|
||||
.where(
|
||||
(device) =>
|
||||
device.ip != normalizedIp ||
|
||||
(port != null && device.port != _normalizePort(port)),
|
||||
)
|
||||
.map((device) => device.toJson())
|
||||
.toList(growable: false);
|
||||
|
||||
await prefs.setString(_devicesKey, jsonEncode(nextDevices));
|
||||
}
|
||||
|
||||
Future<SavedDevice?> getLastDevice() async {
|
||||
final devices = await getDevices();
|
||||
if (devices.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return devices.first;
|
||||
}
|
||||
|
||||
Future<SharedPreferences> _getPreferences() async {
|
||||
return _preferences ??= await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
String _normalizeIp(String value) => SavedDevice._normalizeIp(value);
|
||||
|
||||
int _normalizePort(dynamic value) => SavedDevice._normalizePort(value);
|
||||
|
||||
String _normalizeName(String? value) => SavedDevice._normalizeName(value);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
@@ -11,9 +12,11 @@ import '../models/video_item.dart';
|
||||
import '../models/wifi_network.dart';
|
||||
import '../models/wifi_status.dart';
|
||||
|
||||
typedef UploadProgressCallback = void Function(double progress);
|
||||
|
||||
class HttpApiService {
|
||||
HttpApiService({required String baseUrl, http.Client? client})
|
||||
: _baseUrl = _normalizeBaseUrl(baseUrl),
|
||||
: _baseUrl = normalizeBaseUrl(baseUrl),
|
||||
_client = client ?? http.Client();
|
||||
|
||||
final http.Client _client;
|
||||
@@ -22,7 +25,7 @@ class HttpApiService {
|
||||
String get baseUrl => _baseUrl;
|
||||
|
||||
set baseUrl(String value) {
|
||||
_baseUrl = _normalizeBaseUrl(value);
|
||||
_baseUrl = normalizeBaseUrl(value);
|
||||
}
|
||||
|
||||
Uri _uri(String path, [Map<String, String>? queryParameters]) {
|
||||
@@ -229,6 +232,17 @@ class HttpApiService {
|
||||
return _uploadSingleFile('/api/videos/upload', file);
|
||||
}
|
||||
|
||||
Future<ApiResponse> uploadVideoWithProgress(
|
||||
File file, {
|
||||
UploadProgressCallback? onProgress,
|
||||
}) {
|
||||
return _uploadSingleFile(
|
||||
'/api/videos/upload',
|
||||
file,
|
||||
onProgress: onProgress,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ApiResponse> uploadVideos(List<String> filePaths) async {
|
||||
if (filePaths.isEmpty) {
|
||||
throw const ApiException('未选择上传文件');
|
||||
@@ -377,17 +391,63 @@ class HttpApiService {
|
||||
String endpoint,
|
||||
File file, {
|
||||
String? directoryPath,
|
||||
UploadProgressCallback? onProgress,
|
||||
}) async {
|
||||
final request = http.MultipartRequest(
|
||||
'POST',
|
||||
_uri(endpoint, _pathQuery(directoryPath)),
|
||||
);
|
||||
request.files.add(await http.MultipartFile.fromPath('file', file.path));
|
||||
request.files.add(
|
||||
await _createMultipartFile(
|
||||
file,
|
||||
fieldName: 'file',
|
||||
onProgress: onProgress,
|
||||
),
|
||||
);
|
||||
final response = await http.Response.fromStream(await request.send());
|
||||
onProgress?.call(1);
|
||||
_ensureSuccess(response);
|
||||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
|
||||
Future<http.MultipartFile> _createMultipartFile(
|
||||
File file, {
|
||||
required String fieldName,
|
||||
UploadProgressCallback? onProgress,
|
||||
}) async {
|
||||
final length = await file.length();
|
||||
final filename = file.uri.pathSegments.isNotEmpty
|
||||
? file.uri.pathSegments.last
|
||||
: file.path;
|
||||
|
||||
if (onProgress == null) {
|
||||
return http.MultipartFile.fromPath(fieldName, file.path, filename: filename);
|
||||
}
|
||||
|
||||
var uploaded = 0;
|
||||
final stream = http.ByteStream(
|
||||
file.openRead().transform(
|
||||
StreamTransformer<List<int>, List<int>>.fromHandlers(
|
||||
handleData: (chunk, sink) {
|
||||
uploaded += chunk.length;
|
||||
if (length > 0) {
|
||||
final progress = (uploaded / length).clamp(0.0, 1.0);
|
||||
onProgress(progress);
|
||||
}
|
||||
sink.add(chunk);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return http.MultipartFile(
|
||||
fieldName,
|
||||
stream,
|
||||
length,
|
||||
filename: filename,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ApiResponse> _postCommand(String path) async {
|
||||
final response = await _client.post(_uri(path));
|
||||
_ensureSuccess(response);
|
||||
@@ -477,7 +537,7 @@ class HttpApiService {
|
||||
_client.close();
|
||||
}
|
||||
|
||||
static String _normalizeBaseUrl(String raw) {
|
||||
static String normalizeBaseUrl(String raw) {
|
||||
final trimmed = raw.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
throw const ApiException('baseUrl 不能为空');
|
||||
|
||||
@@ -5,15 +5,24 @@ import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import '../models/app_event.dart';
|
||||
|
||||
enum WsConnectionState { connected, connecting, disconnected }
|
||||
|
||||
@Deprecated('Use WsConnectionState instead')
|
||||
enum SocketConnectionStatus { disconnected, connecting, connected }
|
||||
|
||||
class WebSocketService {
|
||||
static const Duration _initialReconnectDelay = Duration(seconds: 2);
|
||||
static const Duration _maxReconnectDelay = Duration(seconds: 60);
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription<dynamic>? _subscription;
|
||||
Timer? _reconnectTimer;
|
||||
String? _deviceIp;
|
||||
int _devicePort = 5000;
|
||||
bool _manualDisconnect = false;
|
||||
SocketConnectionStatus _connectionStatus = SocketConnectionStatus.disconnected;
|
||||
WsConnectionState _connectionState = WsConnectionState.disconnected;
|
||||
int _retryCount = 0;
|
||||
Duration _nextReconnectDelay = _initialReconnectDelay;
|
||||
|
||||
final StreamController<AppEvent> _eventController =
|
||||
StreamController<AppEvent>.broadcast();
|
||||
@@ -27,8 +36,8 @@ class WebSocketService {
|
||||
StreamController<Map<String, dynamic>>.broadcast();
|
||||
final StreamController<Map<String, dynamic>> _bleController =
|
||||
StreamController<Map<String, dynamic>>.broadcast();
|
||||
final StreamController<SocketConnectionStatus> _connectionController =
|
||||
StreamController<SocketConnectionStatus>.broadcast();
|
||||
final StreamController<WsConnectionState> _connectionStateController =
|
||||
StreamController<WsConnectionState>.broadcast();
|
||||
|
||||
Stream<AppEvent> get events => _eventController.stream;
|
||||
Stream<Map<String, dynamic>> get onStatusUpdate => _statusController.stream;
|
||||
@@ -36,32 +45,76 @@ class WebSocketService {
|
||||
Stream<Map<String, dynamic>> get onConfigUpdate => _configController.stream;
|
||||
Stream<Map<String, dynamic>> get onWifiUpdate => _wifiController.stream;
|
||||
Stream<Map<String, dynamic>> get onBleUpdate => _bleController.stream;
|
||||
Stream<SocketConnectionStatus> get connectionState =>
|
||||
_connectionStateController.stream.map(_toLegacyConnectionStatus);
|
||||
Stream<WsConnectionState> get connectionStateStream =>
|
||||
_connectionStateController.stream;
|
||||
Stream<SocketConnectionStatus> get onConnectionChanged =>
|
||||
_connectionController.stream;
|
||||
_connectionStateController.stream.map(_toLegacyConnectionStatus);
|
||||
|
||||
SocketConnectionStatus get connectionStatus => _connectionStatus;
|
||||
bool get isConnected => _connectionStatus == SocketConnectionStatus.connected;
|
||||
WsConnectionState get wsConnectionState => _connectionState;
|
||||
SocketConnectionStatus get connectionStatus =>
|
||||
_toLegacyConnectionStatus(_connectionState);
|
||||
bool get isConnected => _connectionState == WsConnectionState.connected;
|
||||
int get retryCount => _retryCount;
|
||||
|
||||
Future<void> connect(String deviceIp) async {
|
||||
Future<void> connect(String deviceIp, {int port = 5000}) async {
|
||||
_manualDisconnect = false;
|
||||
_deviceIp = _normalizeDeviceIp(deviceIp);
|
||||
_devicePort = _normalizePort(port);
|
||||
_reconnectTimer?.cancel();
|
||||
|
||||
await _establishConnection(resetBackoff: true);
|
||||
}
|
||||
|
||||
Future<void> manualReconnect() async {
|
||||
final deviceIp = _deviceIp;
|
||||
if (deviceIp == null || deviceIp.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
_manualDisconnect = false;
|
||||
_reconnectTimer?.cancel();
|
||||
await _establishConnection(resetBackoff: true);
|
||||
}
|
||||
|
||||
Future<void> _establishConnection({bool resetBackoff = false}) async {
|
||||
if (resetBackoff) {
|
||||
_retryCount = 0;
|
||||
_nextReconnectDelay = _initialReconnectDelay;
|
||||
}
|
||||
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
await _channel?.sink.close();
|
||||
_channel = null;
|
||||
|
||||
_setConnectionStatus(SocketConnectionStatus.connecting);
|
||||
_setConnectionState(WsConnectionState.connecting);
|
||||
|
||||
final url = Uri.parse('ws://$_deviceIp:8080/ws');
|
||||
_channel = WebSocketChannel.connect(url);
|
||||
_subscription = _channel!.stream.listen(
|
||||
_handleMessage,
|
||||
onDone: _handleSocketClosed,
|
||||
onError: (_) => _handleSocketClosed(),
|
||||
cancelOnError: true,
|
||||
);
|
||||
try {
|
||||
final url = Uri.parse('ws://$_deviceIp:$_devicePort/ws');
|
||||
_channel = WebSocketChannel.connect(url);
|
||||
await _channel!.ready;
|
||||
_subscription = _channel!.stream.listen(
|
||||
_handleMessage,
|
||||
onDone: _handleSocketClosed,
|
||||
onError: (_) => _handleSocketClosed(),
|
||||
cancelOnError: true,
|
||||
);
|
||||
|
||||
_setConnectionStatus(SocketConnectionStatus.connected);
|
||||
_retryCount = 0;
|
||||
_nextReconnectDelay = _initialReconnectDelay;
|
||||
_setConnectionState(WsConnectionState.connected);
|
||||
} catch (_) {
|
||||
_channel = null;
|
||||
_subscription = null;
|
||||
if (_manualDisconnect) {
|
||||
_setConnectionState(WsConnectionState.disconnected);
|
||||
return;
|
||||
}
|
||||
|
||||
await reconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void sendCommand(Map<String, dynamic> command) {
|
||||
@@ -77,20 +130,38 @@ class WebSocketService {
|
||||
return;
|
||||
}
|
||||
|
||||
_setConnectionState(WsConnectionState.connecting);
|
||||
_scheduleReconnect();
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
if (_manualDisconnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(const Duration(seconds: 2), () {
|
||||
unawaited(connect(deviceIp));
|
||||
final delay = _nextReconnectDelay;
|
||||
_retryCount += 1;
|
||||
_nextReconnectDelay = _nextReconnectDelay * 2;
|
||||
if (_nextReconnectDelay > _maxReconnectDelay) {
|
||||
_nextReconnectDelay = _maxReconnectDelay;
|
||||
}
|
||||
|
||||
_reconnectTimer = Timer(delay, () {
|
||||
unawaited(_establishConnection());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_manualDisconnect = true;
|
||||
_reconnectTimer?.cancel();
|
||||
_retryCount = 0;
|
||||
_nextReconnectDelay = _initialReconnectDelay;
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
await _channel?.sink.close();
|
||||
_channel = null;
|
||||
_setConnectionStatus(SocketConnectionStatus.disconnected);
|
||||
_setConnectionState(WsConnectionState.disconnected);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
@@ -101,7 +172,7 @@ class WebSocketService {
|
||||
await _configController.close();
|
||||
await _wifiController.close();
|
||||
await _bleController.close();
|
||||
await _connectionController.close();
|
||||
await _connectionStateController.close();
|
||||
}
|
||||
|
||||
void _handleMessage(dynamic data) {
|
||||
@@ -136,13 +207,30 @@ class WebSocketService {
|
||||
void _handleSocketClosed() {
|
||||
_channel = null;
|
||||
_subscription = null;
|
||||
_setConnectionStatus(SocketConnectionStatus.disconnected);
|
||||
if (_manualDisconnect) {
|
||||
_retryCount = 0;
|
||||
_nextReconnectDelay = _initialReconnectDelay;
|
||||
_setConnectionState(WsConnectionState.disconnected);
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(reconnect());
|
||||
}
|
||||
|
||||
void _setConnectionStatus(SocketConnectionStatus status) {
|
||||
_connectionStatus = status;
|
||||
_connectionController.add(status);
|
||||
void _setConnectionState(WsConnectionState state) {
|
||||
_connectionState = state;
|
||||
_connectionStateController.add(state);
|
||||
}
|
||||
|
||||
SocketConnectionStatus _toLegacyConnectionStatus(WsConnectionState state) {
|
||||
switch (state) {
|
||||
case WsConnectionState.connected:
|
||||
return SocketConnectionStatus.connected;
|
||||
case WsConnectionState.connecting:
|
||||
return SocketConnectionStatus.connecting;
|
||||
case WsConnectionState.disconnected:
|
||||
return SocketConnectionStatus.disconnected;
|
||||
}
|
||||
}
|
||||
|
||||
String _normalizeDeviceIp(String raw) {
|
||||
@@ -169,4 +257,11 @@ class WebSocketService {
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
int _normalizePort(int value) {
|
||||
if (value <= 0 || value > 65535) {
|
||||
return 5000;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user