76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
329 lines
10 KiB
Dart
329 lines
10 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:audioplayers/audioplayers.dart';
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:geolocator/geolocator.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:record/record.dart';
|
|
|
|
import '../api/oa_client.dart';
|
|
|
|
/// 系统截屏、高刷、录音、选图(保留 GIF)。
|
|
class DeviceBridge {
|
|
DeviceBridge._();
|
|
static const _ch = EventChannel('com.fysxkj.oa/device');
|
|
static const _m = MethodChannel('com.fysxkj.oa/ota');
|
|
static StreamSubscription<dynamic>? _sub;
|
|
static final _cbs = <void Function()>{};
|
|
|
|
static final AudioPlayer _player = AudioPlayer();
|
|
static final AudioRecorder _recorder = AudioRecorder();
|
|
static String? _recordingPath;
|
|
static DateTime? _recordingStarted;
|
|
static String? _playingPath;
|
|
static OaClient? _api;
|
|
|
|
static void bindApi(OaClient api) => _api = api;
|
|
|
|
static bool get _useNativeBridge => Platform.isAndroid;
|
|
|
|
static Future<void> preferMaxRefresh() async {
|
|
if (!_useNativeBridge) return;
|
|
try {
|
|
await _m.invokeMethod('preferMaxRefresh');
|
|
} catch (_) {}
|
|
}
|
|
|
|
static Timer? _clipPoll;
|
|
static bool _clipHadImage = false;
|
|
static int _lastClipAt = 0;
|
|
|
|
static void watchScreenshot(void Function() onShot) {
|
|
_cbs.add(onShot);
|
|
if (Platform.isAndroid || Platform.isWindows) {
|
|
_sub ??= _ch.receiveBroadcastStream().listen((event) {
|
|
if (event == 'screenshot') {
|
|
for (final cb in [..._cbs]) {
|
|
cb();
|
|
}
|
|
}
|
|
});
|
|
_m.invokeMethod('startScreenshotWatch').catchError((_) {});
|
|
} else if (Platform.isLinux) {
|
|
_startLinuxScreenshotPoll();
|
|
}
|
|
}
|
|
|
|
static void _startLinuxScreenshotPoll() {
|
|
_clipPoll ??= Timer.periodic(const Duration(milliseconds: 900), (_) async {
|
|
final now = DateTime.now().millisecondsSinceEpoch;
|
|
if (now - _lastClipAt < 1500) return;
|
|
final has = await _linuxClipboardHasImage();
|
|
if (has && !_clipHadImage) {
|
|
_clipHadImage = true;
|
|
_lastClipAt = now;
|
|
for (final cb in [..._cbs]) {
|
|
cb();
|
|
}
|
|
} else if (!has) {
|
|
_clipHadImage = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
static Future<bool> _linuxClipboardHasImage() async {
|
|
try {
|
|
final x = await Process.run('bash', [
|
|
'-c',
|
|
'command -v xclip >/dev/null && xclip -selection clipboard -t TARGETS -o 2>/dev/null | grep -qi image && echo 1; '
|
|
'command -v wl-paste >/dev/null && wl-paste --list-types 2>/dev/null | grep -qi image && echo 1',
|
|
]);
|
|
return '${x.stdout}'.contains('1');
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static void unwatchScreenshot(void Function() onShot) {
|
|
_cbs.remove(onShot);
|
|
if (_cbs.isNotEmpty) return;
|
|
if (Platform.isAndroid || Platform.isWindows) {
|
|
_m.invokeMethod('stopScreenshotWatch').catchError((_) {});
|
|
_sub?.cancel();
|
|
_sub = null;
|
|
}
|
|
_clipPoll?.cancel();
|
|
_clipPoll = null;
|
|
_clipHadImage = false;
|
|
}
|
|
|
|
static Future<void> startVoice() async {
|
|
if (_useNativeBridge) {
|
|
await _m.invokeMethod('startVoice');
|
|
return;
|
|
}
|
|
if (!await _recorder.hasPermission()) {
|
|
throw Exception('没有麦克风权限');
|
|
}
|
|
final dir = await getTemporaryDirectory();
|
|
_recordingPath = '${dir.path}/voice_${DateTime.now().millisecondsSinceEpoch}.m4a';
|
|
_recordingStarted = DateTime.now();
|
|
await _recorder.start(const RecordConfig(encoder: AudioEncoder.aacLc), path: _recordingPath!);
|
|
}
|
|
|
|
static Future<Map<String, dynamic>?> stopVoice() async {
|
|
if (_useNativeBridge) {
|
|
final raw = await _m.invokeMethod('stopVoice');
|
|
if (raw is Map) return Map<String, dynamic>.from(raw);
|
|
return null;
|
|
}
|
|
final path = await _recorder.stop();
|
|
_recordingPath = null;
|
|
if (path == null || path.isEmpty) return null;
|
|
final started = _recordingStarted;
|
|
_recordingStarted = null;
|
|
final seconds = started == null ? 1 : DateTime.now().difference(started).inSeconds.clamp(1, 60);
|
|
return {'path': path, 'seconds': seconds};
|
|
}
|
|
|
|
static Future<void> cancelVoice() async {
|
|
if (_useNativeBridge) {
|
|
try {
|
|
await _m.invokeMethod('cancelVoice');
|
|
} catch (_) {}
|
|
return;
|
|
}
|
|
try {
|
|
await _recorder.stop();
|
|
} catch (_) {}
|
|
_recordingPath = null;
|
|
_recordingStarted = null;
|
|
}
|
|
|
|
static Future<void> playVoice(String path) async {
|
|
if (_useNativeBridge) {
|
|
await _m.invokeMethod('playVoice', {'path': path});
|
|
return;
|
|
}
|
|
await _player.stop();
|
|
_playingPath = path;
|
|
await _player.play(DeviceFileSource(path));
|
|
}
|
|
|
|
static Future<void> stopPlay() async {
|
|
if (_useNativeBridge) {
|
|
try {
|
|
await _m.invokeMethod('stopPlay');
|
|
} catch (_) {}
|
|
return;
|
|
}
|
|
await _player.stop();
|
|
_playingPath = null;
|
|
}
|
|
|
|
static String? get playingPath => _playingPath;
|
|
|
|
/// 手机端本地语音转文字(不调 OA 服务端)。
|
|
static Future<String?> speechToText({required String audioPath}) async {
|
|
if (!_useNativeBridge) return null;
|
|
final value = await _m.invokeMethod('speechToText', {'path': audioPath});
|
|
final text = '$value'.trim();
|
|
return value == null || text.isEmpty || text == 'null' ? null : text;
|
|
}
|
|
|
|
static final AudioPlayer _tonePlayer = AudioPlayer();
|
|
|
|
static Future<void> startCallTone({bool incoming = false}) async {
|
|
if (_useNativeBridge) {
|
|
try {
|
|
await _m.invokeMethod('startCallTone', {'incoming': incoming});
|
|
} catch (_) {}
|
|
return;
|
|
}
|
|
try {
|
|
await _tonePlayer.setReleaseMode(ReleaseMode.loop);
|
|
await _tonePlayer.play(AssetSource('sounds/ring.wav'));
|
|
} catch (_) {}
|
|
}
|
|
|
|
static Future<void> stopCallTone() async {
|
|
if (_useNativeBridge) {
|
|
try {
|
|
await _m.invokeMethod('stopCallTone');
|
|
} catch (_) {}
|
|
return;
|
|
}
|
|
try {
|
|
await _tonePlayer.stop();
|
|
} catch (_) {}
|
|
}
|
|
|
|
static Future<void> cancelCallNotification() async {
|
|
if (!_useNativeBridge) return;
|
|
try {
|
|
await _m.invokeMethod('cancelCallNotification');
|
|
} catch (_) {}
|
|
}
|
|
|
|
static Future<void> playMessageSent() async {
|
|
if (!_useNativeBridge) return;
|
|
try {
|
|
await _m.invokeMethod('playMessageSent');
|
|
} catch (_) {}
|
|
}
|
|
|
|
static Future<Map<String, double>?> getLocation() async {
|
|
if (_useNativeBridge) {
|
|
final raw = await _m.invokeMethod('getLocation');
|
|
if (raw is Map) {
|
|
final lat = (raw['lat'] as num?)?.toDouble();
|
|
final lng = (raw['lng'] as num?)?.toDouble();
|
|
if (lat != null && lng != null) return {'lat': lat, 'lng': lng};
|
|
}
|
|
return null;
|
|
}
|
|
try {
|
|
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
|
if (!serviceEnabled) return null;
|
|
var perm = await Geolocator.checkPermission();
|
|
if (perm == LocationPermission.denied) {
|
|
perm = await Geolocator.requestPermission();
|
|
}
|
|
if (perm == LocationPermission.denied || perm == LocationPermission.deniedForever) {
|
|
return null;
|
|
}
|
|
final pos = await Geolocator.getCurrentPosition(locationSettings: const LocationSettings(accuracy: LocationAccuracy.high));
|
|
return {'lat': pos.latitude, 'lng': pos.longitude};
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static Future<Map<String, String>?> pickFile() async {
|
|
if (_useNativeBridge) {
|
|
final raw = await _m.invokeMethod('pickFile');
|
|
if (raw is Map) return raw.map((k, v) => MapEntry('$k', '$v'));
|
|
return null;
|
|
}
|
|
final picked = await FilePicker.platform.pickFiles(allowMultiple: false);
|
|
if (picked == null || picked.files.isEmpty) return null;
|
|
final f = picked.files.first;
|
|
final path = f.path;
|
|
if (path == null || path.isEmpty) return null;
|
|
return {'path': path, 'name': f.name, 'mime': f.extension ?? ''};
|
|
}
|
|
|
|
static Future<void> openFile(String path, {String? mime}) async {
|
|
if (_useNativeBridge) {
|
|
await _m.invokeMethod('openFile', {'path': path, 'mime': mime ?? ''});
|
|
return;
|
|
}
|
|
if (Platform.isLinux) {
|
|
await Process.run('xdg-open', [path]);
|
|
} else if (Platform.isWindows) {
|
|
await Process.start('cmd', ['/c', 'start', '', path], mode: ProcessStartMode.detached);
|
|
}
|
|
}
|
|
|
|
static Future<void> saveImage(String path) async {
|
|
if (_useNativeBridge) {
|
|
await _m.invokeMethod('saveImage', {'path': path});
|
|
return;
|
|
}
|
|
final name = path.split('/').last;
|
|
final picked = await FilePicker.platform.saveFile(dialogTitle: '保存图片', fileName: name.isEmpty ? 'image.jpg' : name);
|
|
if (picked == null || picked.isEmpty) return;
|
|
await File(path).copy(picked);
|
|
}
|
|
|
|
static Future<void> openUrl(String url) async {
|
|
if (_useNativeBridge) {
|
|
await _m.invokeMethod('openUrl', {'url': url});
|
|
return;
|
|
}
|
|
if (Platform.isLinux) {
|
|
await Process.run('xdg-open', [url]);
|
|
} else if (Platform.isWindows) {
|
|
await Process.start('cmd', ['/c', 'start', '', url], mode: ProcessStartMode.detached);
|
|
}
|
|
}
|
|
|
|
static Future<bool> requestCallMedia() async {
|
|
if (!_useNativeBridge) return true;
|
|
try {
|
|
final ok = await _m.invokeMethod('requestCallMedia');
|
|
return ok == true;
|
|
} catch (_) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
static Future<Map<String, String>?> pickMedia() async {
|
|
if (_useNativeBridge) {
|
|
final raw = await _m.invokeMethod('pickMedia');
|
|
if (raw is Map) return raw.map((k, v) => MapEntry('$k', '$v'));
|
|
return null;
|
|
}
|
|
final picked = await FilePicker.platform.pickFiles(
|
|
type: FileType.custom,
|
|
allowedExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'],
|
|
allowMultiple: false,
|
|
);
|
|
if (picked == null || picked.files.isEmpty) return null;
|
|
final f = picked.files.first;
|
|
final path = f.path;
|
|
if (path == null || path.isEmpty) return null;
|
|
final ext = (f.extension ?? 'jpg').toLowerCase();
|
|
final mime = ext == 'png'
|
|
? 'image/png'
|
|
: ext == 'gif'
|
|
? 'image/gif'
|
|
: ext == 'webp'
|
|
? 'image/webp'
|
|
: 'image/jpeg';
|
|
return {'path': path, 'name': f.name, 'mime': mime};
|
|
}
|
|
}
|