mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-27 20:57:31 +10:00
updated ui added new features
This commit is contained in:
@@ -73,6 +73,21 @@ class AppSettingsService extends ChangeNotifier {
|
||||
await updateSettings(_settings.copyWith(mapShowMarkers: value));
|
||||
}
|
||||
|
||||
Future<void> setMapCacheBounds(Map<String, double>? value) async {
|
||||
await updateSettings(_settings.copyWith(mapCacheBounds: value));
|
||||
}
|
||||
|
||||
Future<void> setMapCacheZoomRange(int minZoom, int maxZoom) async {
|
||||
final safeMin = minZoom <= maxZoom ? minZoom : maxZoom;
|
||||
final safeMax = minZoom <= maxZoom ? maxZoom : minZoom;
|
||||
await updateSettings(
|
||||
_settings.copyWith(
|
||||
mapCacheMinZoom: safeMin,
|
||||
mapCacheMaxZoom: safeMax,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setNotificationsEnabled(bool value) async {
|
||||
await updateSettings(_settings.copyWith(notificationsEnabled: value));
|
||||
}
|
||||
@@ -81,6 +96,10 @@ class AppSettingsService extends ChangeNotifier {
|
||||
await updateSettings(_settings.copyWith(notifyOnNewMessage: value));
|
||||
}
|
||||
|
||||
Future<void> setNotifyOnNewChannelMessage(bool value) async {
|
||||
await updateSettings(_settings.copyWith(notifyOnNewChannelMessage: value));
|
||||
}
|
||||
|
||||
Future<void> setNotifyOnNewAdvert(bool value) async {
|
||||
await updateSettings(_settings.copyWith(notifyOnNewAdvert: value));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'dart:isolate';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
|
||||
class BackgroundService {
|
||||
bool _initialized = false;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (!Platform.isAndroid || _initialized) return;
|
||||
FlutterForegroundTask.init(
|
||||
androidNotificationOptions: AndroidNotificationOptions(
|
||||
channelId: 'meshcore_background',
|
||||
channelName: 'MeshCore Background',
|
||||
channelDescription: 'Keeps MeshCore running in the background.',
|
||||
channelImportance: NotificationChannelImportance.LOW,
|
||||
priority: NotificationPriority.LOW,
|
||||
iconData: const NotificationIconData(
|
||||
resType: ResourceType.mipmap,
|
||||
resPrefix: ResourcePrefix.ic,
|
||||
name: 'launcher',
|
||||
),
|
||||
),
|
||||
iosNotificationOptions: const IOSNotificationOptions(
|
||||
showNotification: false,
|
||||
playSound: false,
|
||||
),
|
||||
foregroundTaskOptions: const ForegroundTaskOptions(
|
||||
interval: 5000,
|
||||
autoRunOnBoot: false,
|
||||
allowWakeLock: true,
|
||||
allowWifiLock: false,
|
||||
),
|
||||
);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
Future<void> start() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
if (!_initialized) {
|
||||
await initialize();
|
||||
}
|
||||
final running = await FlutterForegroundTask.isRunningService;
|
||||
if (running) return;
|
||||
await FlutterForegroundTask.startService(
|
||||
notificationTitle: 'MeshCore running',
|
||||
notificationText: 'Keeping BLE connected',
|
||||
callback: startCallback,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
final running = await FlutterForegroundTask.isRunningService;
|
||||
if (!running) return;
|
||||
await FlutterForegroundTask.stopService();
|
||||
}
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void startCallback() {
|
||||
FlutterForegroundTask.setTaskHandler(_MeshCoreTaskHandler());
|
||||
}
|
||||
|
||||
class _MeshCoreTaskHandler extends TaskHandler {
|
||||
@override
|
||||
void onStart(DateTime timestamp, SendPort? sendPort) {}
|
||||
|
||||
@override
|
||||
void onRepeatEvent(DateTime timestamp, SendPort? sendPort) {}
|
||||
|
||||
@override
|
||||
void onDestroy(DateTime timestamp, SendPort? sendPort) {}
|
||||
|
||||
@override
|
||||
void onNotificationButtonPressed(String id) {}
|
||||
|
||||
@override
|
||||
void onNotificationPressed() {
|
||||
FlutterForegroundTask.launchApp('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
|
||||
const int _codec2Mode1300 = 4;
|
||||
|
||||
class Codec2Ffi {
|
||||
Codec2Ffi._(this._lib)
|
||||
: _codec2Create = _lib
|
||||
.lookupFunction<_codec2_create_c, _codec2_create_d>('codec2_create'),
|
||||
_codec2Destroy = _lib
|
||||
.lookupFunction<_codec2_destroy_c, _codec2_destroy_d>('codec2_destroy'),
|
||||
_codec2Encode = _lib
|
||||
.lookupFunction<_codec2_encode_c, _codec2_encode_d>('codec2_encode'),
|
||||
_codec2Decode = _lib
|
||||
.lookupFunction<_codec2_decode_c, _codec2_decode_d>('codec2_decode'),
|
||||
_codec2SamplesPerFrame = _lib.lookupFunction<_codec2_samples_per_frame_c,
|
||||
_codec2_samples_per_frame_d>('codec2_samples_per_frame'),
|
||||
_codec2BytesPerFrame = _lib.lookupFunction<_codec2_bytes_per_frame_c,
|
||||
_codec2_bytes_per_frame_d>('codec2_bytes_per_frame');
|
||||
|
||||
static final Codec2Ffi instance = Codec2Ffi._(_openLibrary());
|
||||
|
||||
final DynamicLibrary _lib;
|
||||
final _codec2_create_d _codec2Create;
|
||||
final _codec2_destroy_d _codec2Destroy;
|
||||
final _codec2_encode_d _codec2Encode;
|
||||
final _codec2_decode_d _codec2Decode;
|
||||
final _codec2_samples_per_frame_d _codec2SamplesPerFrame;
|
||||
final _codec2_bytes_per_frame_d _codec2BytesPerFrame;
|
||||
|
||||
Codec2Session createSession() {
|
||||
final handle = _codec2Create(_codec2Mode1300);
|
||||
if (handle == nullptr) {
|
||||
throw StateError('codec2_create returned null');
|
||||
}
|
||||
return Codec2Session._(
|
||||
handle: handle,
|
||||
destroy: _codec2Destroy,
|
||||
encode: _codec2Encode,
|
||||
decode: _codec2Decode,
|
||||
samplesPerFrame: _codec2SamplesPerFrame,
|
||||
bytesPerFrame: _codec2BytesPerFrame,
|
||||
);
|
||||
}
|
||||
|
||||
static DynamicLibrary _openLibrary() {
|
||||
if (Platform.isAndroid) {
|
||||
return DynamicLibrary.open('libcodec2.so');
|
||||
}
|
||||
if (Platform.isIOS || Platform.isMacOS) {
|
||||
return DynamicLibrary.process();
|
||||
}
|
||||
throw UnsupportedError('Codec2 is only supported on Android and iOS.');
|
||||
}
|
||||
}
|
||||
|
||||
class Codec2Session {
|
||||
Codec2Session._({
|
||||
required this.handle,
|
||||
required this.destroy,
|
||||
required this.encode,
|
||||
required this.decode,
|
||||
required this.samplesPerFrame,
|
||||
required this.bytesPerFrame,
|
||||
});
|
||||
|
||||
final Pointer<Void> handle;
|
||||
final _codec2_destroy_d destroy;
|
||||
final _codec2_encode_d encode;
|
||||
final _codec2_decode_d decode;
|
||||
final _codec2_samples_per_frame_d samplesPerFrame;
|
||||
final _codec2_bytes_per_frame_d bytesPerFrame;
|
||||
|
||||
int get samplesPerFrameValue => samplesPerFrame(handle);
|
||||
int get bytesPerFrameValue => bytesPerFrame(handle);
|
||||
|
||||
Uint8List encodePcmFrame(Int16List pcmFrame) {
|
||||
final bytesOut = calloc<Uint8>(bytesPerFrameValue);
|
||||
final pcmIn = calloc<Int16>(samplesPerFrameValue);
|
||||
try {
|
||||
final sampleCount = samplesPerFrameValue;
|
||||
final pcmBuffer = pcmIn.asTypedList(sampleCount);
|
||||
final copyLen = pcmFrame.length < sampleCount ? pcmFrame.length : sampleCount;
|
||||
pcmBuffer.setRange(0, copyLen, pcmFrame);
|
||||
if (copyLen < sampleCount) {
|
||||
for (var i = copyLen; i < sampleCount; i++) {
|
||||
pcmBuffer[i] = 0;
|
||||
}
|
||||
}
|
||||
encode(handle, bytesOut, pcmIn);
|
||||
return Uint8List.fromList(bytesOut.asTypedList(bytesPerFrameValue));
|
||||
} finally {
|
||||
calloc.free(bytesOut);
|
||||
calloc.free(pcmIn);
|
||||
}
|
||||
}
|
||||
|
||||
Int16List decodeCodecFrame(Uint8List codecFrame) {
|
||||
final pcmOut = calloc<Int16>(samplesPerFrameValue);
|
||||
final bytesIn = calloc<Uint8>(bytesPerFrameValue);
|
||||
try {
|
||||
final codecBuffer = bytesIn.asTypedList(bytesPerFrameValue);
|
||||
codecBuffer.setRange(0, bytesPerFrameValue, codecFrame);
|
||||
decode(handle, pcmOut, bytesIn);
|
||||
return Int16List.fromList(pcmOut.asTypedList(samplesPerFrameValue));
|
||||
} finally {
|
||||
calloc.free(bytesIn);
|
||||
calloc.free(pcmOut);
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
destroy(handle);
|
||||
}
|
||||
}
|
||||
|
||||
typedef _codec2_create_c = Pointer<Void> Function(Int32 mode);
|
||||
typedef _codec2_create_d = Pointer<Void> Function(int mode);
|
||||
|
||||
typedef _codec2_destroy_c = Void Function(Pointer<Void> codec2State);
|
||||
typedef _codec2_destroy_d = void Function(Pointer<Void> codec2State);
|
||||
|
||||
typedef _codec2_encode_c = Void Function(
|
||||
Pointer<Void> codec2State,
|
||||
Pointer<Uint8> bytes,
|
||||
Pointer<Int16> speechIn,
|
||||
);
|
||||
typedef _codec2_encode_d = void Function(
|
||||
Pointer<Void> codec2State,
|
||||
Pointer<Uint8> bytes,
|
||||
Pointer<Int16> speechIn,
|
||||
);
|
||||
|
||||
typedef _codec2_decode_c = Void Function(
|
||||
Pointer<Void> codec2State,
|
||||
Pointer<Int16> speechOut,
|
||||
Pointer<Uint8> bytes,
|
||||
);
|
||||
typedef _codec2_decode_d = void Function(
|
||||
Pointer<Void> codec2State,
|
||||
Pointer<Int16> speechOut,
|
||||
Pointer<Uint8> bytes,
|
||||
);
|
||||
|
||||
typedef _codec2_samples_per_frame_c = Int32 Function(Pointer<Void> codec2State);
|
||||
typedef _codec2_samples_per_frame_d = int Function(Pointer<Void> codec2State);
|
||||
|
||||
typedef _codec2_bytes_per_frame_c = Int32 Function(Pointer<Void> codec2State);
|
||||
typedef _codec2_bytes_per_frame_d = int Function(Pointer<Void> codec2State);
|
||||
@@ -0,0 +1,241 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
|
||||
const String kMapTileUrlTemplate =
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
|
||||
class MapTileCacheProgress {
|
||||
final int completed;
|
||||
final int total;
|
||||
final int failed;
|
||||
|
||||
const MapTileCacheProgress({
|
||||
required this.completed,
|
||||
required this.total,
|
||||
required this.failed,
|
||||
});
|
||||
}
|
||||
|
||||
class MapTileCacheResult {
|
||||
final int total;
|
||||
final int downloaded;
|
||||
final int failed;
|
||||
|
||||
const MapTileCacheResult({
|
||||
required this.total,
|
||||
required this.downloaded,
|
||||
required this.failed,
|
||||
});
|
||||
}
|
||||
|
||||
class MapTileCacheService {
|
||||
static const String cacheKey = 'map_tile_cache';
|
||||
static const String userAgentPackageName = 'com.meshcore.open';
|
||||
static const int defaultMinZoom = 10;
|
||||
static const int defaultMaxZoom = 15;
|
||||
|
||||
final BaseCacheManager cacheManager;
|
||||
late final TileProvider tileProvider;
|
||||
|
||||
MapTileCacheService({BaseCacheManager? cacheManager})
|
||||
: cacheManager = cacheManager ??
|
||||
CacheManager(
|
||||
Config(
|
||||
cacheKey,
|
||||
stalePeriod: const Duration(days: 365),
|
||||
maxNrOfCacheObjects: 200000,
|
||||
),
|
||||
) {
|
||||
tileProvider = CachedNetworkTileProvider(cacheManager: this.cacheManager);
|
||||
}
|
||||
|
||||
Map<String, String> get defaultHeaders => {
|
||||
'User-Agent': 'flutter_map ($userAgentPackageName)',
|
||||
};
|
||||
|
||||
Future<void> clearCache() async {
|
||||
await cacheManager.emptyCache();
|
||||
}
|
||||
|
||||
int estimateTileCount(LatLngBounds bounds, int minZoom, int maxZoom) {
|
||||
final safeMin = math.min(minZoom, maxZoom);
|
||||
final safeMax = math.max(minZoom, maxZoom);
|
||||
int total = 0;
|
||||
|
||||
for (int zoom = safeMin; zoom <= safeMax; zoom++) {
|
||||
final tileBounds = _tileBoundsForBounds(bounds, zoom);
|
||||
final xCount = tileBounds.maxX - tileBounds.minX + 1;
|
||||
final yCount = tileBounds.maxY - tileBounds.minY + 1;
|
||||
total += xCount * yCount;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
Future<MapTileCacheResult> downloadRegion({
|
||||
required LatLngBounds bounds,
|
||||
required int minZoom,
|
||||
required int maxZoom,
|
||||
int concurrentDownloads = 8,
|
||||
Map<String, String>? headers,
|
||||
void Function(MapTileCacheProgress progress)? onProgress,
|
||||
}) async {
|
||||
final safeMin = math.min(minZoom, maxZoom);
|
||||
final safeMax = math.max(minZoom, maxZoom);
|
||||
final total = estimateTileCount(bounds, safeMin, safeMax);
|
||||
final authHeaders = headers ?? defaultHeaders;
|
||||
final safeConcurrency = math.max(1, concurrentDownloads);
|
||||
int completed = 0;
|
||||
int failed = 0;
|
||||
|
||||
final pending = <Future<void>>[];
|
||||
Future<void> queueDownload(String url) async {
|
||||
final future = cacheManager
|
||||
.downloadFile(url, key: url, authHeaders: authHeaders)
|
||||
.then((_) {
|
||||
completed += 1;
|
||||
}).catchError((_) {
|
||||
completed += 1;
|
||||
failed += 1;
|
||||
}).whenComplete(() {
|
||||
onProgress?.call(MapTileCacheProgress(
|
||||
completed: completed,
|
||||
total: total,
|
||||
failed: failed,
|
||||
));
|
||||
});
|
||||
|
||||
pending.add(future);
|
||||
if (pending.length >= safeConcurrency) {
|
||||
await Future.wait(pending);
|
||||
pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
for (int zoom = safeMin; zoom <= safeMax; zoom++) {
|
||||
final tileBounds = _tileBoundsForBounds(bounds, zoom);
|
||||
for (int x = tileBounds.minX; x <= tileBounds.maxX; x++) {
|
||||
for (int y = tileBounds.minY; y <= tileBounds.maxY; y++) {
|
||||
final url = _buildTileUrl(x, y, zoom);
|
||||
await queueDownload(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pending.isNotEmpty) {
|
||||
await Future.wait(pending);
|
||||
}
|
||||
|
||||
return MapTileCacheResult(
|
||||
total: total,
|
||||
downloaded: completed - failed,
|
||||
failed: failed,
|
||||
);
|
||||
}
|
||||
|
||||
static Map<String, double> boundsToJson(LatLngBounds bounds) {
|
||||
return {
|
||||
'north': bounds.north,
|
||||
'south': bounds.south,
|
||||
'east': bounds.east,
|
||||
'west': bounds.west,
|
||||
};
|
||||
}
|
||||
|
||||
static LatLngBounds? boundsFromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) return null;
|
||||
final north = (json['north'] as num?)?.toDouble();
|
||||
final south = (json['south'] as num?)?.toDouble();
|
||||
final east = (json['east'] as num?)?.toDouble();
|
||||
final west = (json['west'] as num?)?.toDouble();
|
||||
if (north == null || south == null || east == null || west == null) {
|
||||
return null;
|
||||
}
|
||||
return LatLngBounds.unsafe(
|
||||
north: north,
|
||||
south: south,
|
||||
east: east,
|
||||
west: west,
|
||||
);
|
||||
}
|
||||
|
||||
_TileBounds _tileBoundsForBounds(LatLngBounds bounds, int zoom) {
|
||||
final north = _clampLatitude(bounds.north);
|
||||
final south = _clampLatitude(bounds.south);
|
||||
final maxIndex = (1 << zoom) - 1;
|
||||
|
||||
final minX = _lonToTileX(bounds.west, zoom, maxIndex);
|
||||
final maxX = _lonToTileX(bounds.east, zoom, maxIndex);
|
||||
final minY = _latToTileY(north, zoom, maxIndex);
|
||||
final maxY = _latToTileY(south, zoom, maxIndex);
|
||||
|
||||
return _TileBounds(
|
||||
minX: math.min(minX, maxX),
|
||||
maxX: math.max(minX, maxX),
|
||||
minY: math.min(minY, maxY),
|
||||
maxY: math.max(minY, maxY),
|
||||
);
|
||||
}
|
||||
|
||||
int _lonToTileX(double lon, int zoom, int maxIndex) {
|
||||
final n = 1 << zoom;
|
||||
final value = ((lon + 180.0) / 360.0 * n).floor();
|
||||
return value.clamp(0, maxIndex) as int;
|
||||
}
|
||||
|
||||
int _latToTileY(double lat, int zoom, int maxIndex) {
|
||||
final n = 1 << zoom;
|
||||
final rad = lat * math.pi / 180.0;
|
||||
final value = ((1 -
|
||||
math.log(math.tan(rad) + 1 / math.cos(rad)) / math.pi) /
|
||||
2 *
|
||||
n)
|
||||
.floor();
|
||||
return value.clamp(0, maxIndex) as int;
|
||||
}
|
||||
|
||||
double _clampLatitude(double lat) {
|
||||
const maxLat = 85.05112878;
|
||||
return lat.clamp(-maxLat, maxLat) as double;
|
||||
}
|
||||
|
||||
String _buildTileUrl(int x, int y, int zoom) {
|
||||
return kMapTileUrlTemplate
|
||||
.replaceAll('{z}', zoom.toString())
|
||||
.replaceAll('{x}', x.toString())
|
||||
.replaceAll('{y}', y.toString());
|
||||
}
|
||||
}
|
||||
|
||||
class CachedNetworkTileProvider extends TileProvider {
|
||||
final BaseCacheManager cacheManager;
|
||||
|
||||
CachedNetworkTileProvider({required this.cacheManager, super.headers});
|
||||
|
||||
@override
|
||||
ImageProvider getImage(TileCoordinates coordinates, TileLayer options) {
|
||||
final url = getTileUrl(coordinates, options);
|
||||
return CachedNetworkImageProvider(
|
||||
url,
|
||||
cacheManager: cacheManager,
|
||||
headers: headers,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TileBounds {
|
||||
final int minX;
|
||||
final int maxX;
|
||||
final int minY;
|
||||
final int maxY;
|
||||
|
||||
const _TileBounds({
|
||||
required this.minX,
|
||||
required this.maxX,
|
||||
required this.minY,
|
||||
required this.maxY,
|
||||
});
|
||||
}
|
||||
@@ -103,6 +103,7 @@ class MessageRetryService extends ChangeNotifier {
|
||||
latitude: contact.latitude,
|
||||
longitude: contact.longitude,
|
||||
lastSeen: contact.lastSeen,
|
||||
lastMessageAt: contact.lastMessageAt,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,55 @@ class NotificationService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showChannelMessageNotification({
|
||||
required String channelName,
|
||||
required String message,
|
||||
int? channelIndex,
|
||||
}) async {
|
||||
if (!_isInitialized) {
|
||||
await initialize();
|
||||
}
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'channel_messages',
|
||||
'Channel Messages',
|
||||
channelDescription: 'New channel message notifications',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
final preview = _truncateMessage(message, 30);
|
||||
final body = preview.isEmpty
|
||||
? 'Received new message'
|
||||
: 'Received new message: $preview';
|
||||
|
||||
await _notifications.show(
|
||||
channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
|
||||
channelName,
|
||||
body,
|
||||
notificationDetails,
|
||||
payload: 'channel:$channelIndex',
|
||||
);
|
||||
}
|
||||
|
||||
String _truncateMessage(String message, int maxLength) {
|
||||
final trimmed = message.trim();
|
||||
if (trimmed.length <= maxLength) return trimmed;
|
||||
return '${trimmed.substring(0, maxLength)}...';
|
||||
}
|
||||
|
||||
void _onNotificationTapped(NotificationResponse response) {
|
||||
final payload = response.payload;
|
||||
if (payload != null) {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'codec2_ffi.dart';
|
||||
|
||||
class VoiceMessageService {
|
||||
static const int sampleRate = 8000;
|
||||
static const int channels = 1;
|
||||
static const int bitsPerSample = 16;
|
||||
static const int maxRecordSeconds = 5;
|
||||
static const int chunkRawBytes = 90;
|
||||
static const String codecName = 'codec2_1300';
|
||||
static const String chunkPrefix = 'V1|';
|
||||
|
||||
static final VoiceMessageService instance = VoiceMessageService._();
|
||||
|
||||
VoiceMessageService._();
|
||||
|
||||
Future<Directory> ensureVoiceDir() async {
|
||||
final docs = await getApplicationDocumentsDirectory();
|
||||
final dir = Directory(path.join(docs.path, 'voice'));
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
String buildVoiceFileName({
|
||||
required String senderKeyHex,
|
||||
required int timestampSeconds,
|
||||
bool outgoing = false,
|
||||
}) {
|
||||
final suffix = outgoing ? 'out' : 'in';
|
||||
return 'voice_${senderKeyHex}_${timestampSeconds}_$suffix.wav';
|
||||
}
|
||||
|
||||
List<String> buildVoiceChunks(Uint8List codec2Bytes) {
|
||||
if (codec2Bytes.isEmpty) return [];
|
||||
final chunks = <Uint8List>[];
|
||||
for (var offset = 0; offset < codec2Bytes.length; offset += chunkRawBytes) {
|
||||
final end = (offset + chunkRawBytes).clamp(0, codec2Bytes.length).toInt();
|
||||
chunks.add(Uint8List.fromList(codec2Bytes.sublist(offset, end)));
|
||||
}
|
||||
final count = chunks.length;
|
||||
return List<String>.generate(count, (index) {
|
||||
final encoded = _base64UrlEncodeNoPad(chunks[index]);
|
||||
return '$chunkPrefix$index/$count|$encoded';
|
||||
});
|
||||
}
|
||||
|
||||
VoiceChunk? tryParseChunk(String text) {
|
||||
final trimmed = text.trim();
|
||||
if (!trimmed.startsWith(chunkPrefix)) return null;
|
||||
final match = RegExp(r'^V1\|(\d+)/(\d+)\|([A-Za-z0-9_-]+)$').firstMatch(trimmed);
|
||||
if (match == null) return null;
|
||||
final idx = int.tryParse(match.group(1) ?? '');
|
||||
final count = int.tryParse(match.group(2) ?? '');
|
||||
final payload = match.group(3);
|
||||
if (idx == null || count == null || payload == null) return null;
|
||||
if (idx < 0 || count <= 0 || idx >= count) return null;
|
||||
try {
|
||||
final bytes = _base64UrlDecode(payload);
|
||||
return VoiceChunk(index: idx, count: count, bytes: bytes);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List encodePcmToCodec2(Uint8List pcmBytes) {
|
||||
final session = Codec2Ffi.instance.createSession();
|
||||
try {
|
||||
final samplesPerFrame = session.samplesPerFrameValue;
|
||||
final pcmSamples = _toInt16(pcmBytes);
|
||||
final frameCount = (pcmSamples.length + samplesPerFrame - 1) ~/ samplesPerFrame;
|
||||
final builder = BytesBuilder(copy: false);
|
||||
|
||||
for (var frameIndex = 0; frameIndex < frameCount; frameIndex++) {
|
||||
final start = frameIndex * samplesPerFrame;
|
||||
final end = (start + samplesPerFrame).clamp(0, pcmSamples.length).toInt();
|
||||
final frame = Int16List(samplesPerFrame);
|
||||
final copyLen = end - start;
|
||||
if (copyLen > 0) {
|
||||
frame.setRange(0, copyLen, pcmSamples.sublist(start, end));
|
||||
}
|
||||
final encoded = session.encodePcmFrame(frame);
|
||||
builder.add(encoded);
|
||||
}
|
||||
|
||||
return builder.takeBytes();
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List decodeCodec2ToPcm(Uint8List codec2Bytes) {
|
||||
final session = Codec2Ffi.instance.createSession();
|
||||
try {
|
||||
final bytesPerFrame = session.bytesPerFrameValue;
|
||||
if (bytesPerFrame <= 0) return Uint8List(0);
|
||||
final frameCount = codec2Bytes.length ~/ bytesPerFrame;
|
||||
final builder = BytesBuilder(copy: false);
|
||||
|
||||
for (var frameIndex = 0; frameIndex < frameCount; frameIndex++) {
|
||||
final start = frameIndex * bytesPerFrame;
|
||||
final frameBytes = codec2Bytes.sublist(start, start + bytesPerFrame);
|
||||
final decoded = session.decodeCodecFrame(frameBytes);
|
||||
builder.add(Uint8List.view(
|
||||
decoded.buffer,
|
||||
decoded.offsetInBytes,
|
||||
decoded.lengthInBytes,
|
||||
));
|
||||
}
|
||||
|
||||
return builder.takeBytes();
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
int durationMsForCodec2Bytes(Uint8List codec2Bytes) {
|
||||
final session = Codec2Ffi.instance.createSession();
|
||||
try {
|
||||
final bytesPerFrame = session.bytesPerFrameValue;
|
||||
final samplesPerFrame = session.samplesPerFrameValue;
|
||||
if (bytesPerFrame <= 0 || samplesPerFrame <= 0) return 0;
|
||||
final frameCount = codec2Bytes.length ~/ bytesPerFrame;
|
||||
final frameDurationMs = (samplesPerFrame * 1000 / sampleRate).round();
|
||||
return frameCount * frameDurationMs;
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> writeWavFile({
|
||||
required Uint8List pcmBytes,
|
||||
required String fileName,
|
||||
}) async {
|
||||
final dir = await ensureVoiceDir();
|
||||
final filePath = path.join(dir.path, fileName);
|
||||
final wavHeader = _buildWavHeader(
|
||||
pcmDataSize: pcmBytes.length,
|
||||
sampleRate: sampleRate,
|
||||
channels: channels,
|
||||
bitsPerSample: bitsPerSample,
|
||||
);
|
||||
final file = File(filePath);
|
||||
final builder = BytesBuilder(copy: false);
|
||||
builder.add(wavHeader);
|
||||
builder.add(pcmBytes);
|
||||
await file.writeAsBytes(builder.takeBytes(), flush: true);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
Uint8List _buildWavHeader({
|
||||
required int pcmDataSize,
|
||||
required int sampleRate,
|
||||
required int channels,
|
||||
required int bitsPerSample,
|
||||
}) {
|
||||
final byteRate = sampleRate * channels * (bitsPerSample ~/ 8);
|
||||
final blockAlign = channels * (bitsPerSample ~/ 8);
|
||||
final buffer = BytesBuilder(copy: false);
|
||||
buffer.add(ascii.encode('RIFF'));
|
||||
buffer.add(_le32(36 + pcmDataSize));
|
||||
buffer.add(ascii.encode('WAVE'));
|
||||
buffer.add(ascii.encode('fmt '));
|
||||
buffer.add(_le32(16));
|
||||
buffer.add(_le16(1));
|
||||
buffer.add(_le16(channels));
|
||||
buffer.add(_le32(sampleRate));
|
||||
buffer.add(_le32(byteRate));
|
||||
buffer.add(_le16(blockAlign));
|
||||
buffer.add(_le16(bitsPerSample));
|
||||
buffer.add(ascii.encode('data'));
|
||||
buffer.add(_le32(pcmDataSize));
|
||||
return buffer.takeBytes();
|
||||
}
|
||||
|
||||
Uint8List _le16(int value) {
|
||||
final data = ByteData(2)..setUint16(0, value, Endian.little);
|
||||
return data.buffer.asUint8List();
|
||||
}
|
||||
|
||||
Uint8List _le32(int value) {
|
||||
final data = ByteData(4)..setUint32(0, value, Endian.little);
|
||||
return data.buffer.asUint8List();
|
||||
}
|
||||
|
||||
Int16List _toInt16(Uint8List bytes) {
|
||||
final evenLength = bytes.lengthInBytes - (bytes.lengthInBytes % 2);
|
||||
if (evenLength <= 0) return Int16List(0);
|
||||
return Int16List.view(bytes.buffer, bytes.offsetInBytes, evenLength ~/ 2);
|
||||
}
|
||||
|
||||
String _base64UrlEncodeNoPad(Uint8List bytes) {
|
||||
return base64Url.encode(bytes).replaceAll('=', '');
|
||||
}
|
||||
|
||||
Uint8List _base64UrlDecode(String encoded) {
|
||||
final paddedLength = (encoded.length + 3) ~/ 4 * 4;
|
||||
final padded = encoded.padRight(paddedLength, '=');
|
||||
return base64Url.decode(padded);
|
||||
}
|
||||
}
|
||||
|
||||
class VoiceChunk {
|
||||
final int index;
|
||||
final int count;
|
||||
final Uint8List bytes;
|
||||
|
||||
VoiceChunk({
|
||||
required this.index,
|
||||
required this.count,
|
||||
required this.bytes,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user