Skip to content

Commit ed04dc9

Browse files
committed
feat: Add Pocket device support and fix conversation discard logic
- Add complete Pocket device integration with BLE communication - Add Pocket MP3 download and WAL sync service - Add Pocket recordings UI page - Add ConversationSource.pocket enum value - Fix: Skip discard check for Pocket recordings (user-initiated) - Add pocket router endpoint for MP3 uploads - Add Pocket device models and connection handling - Update device detection and provider for Pocket support
1 parent 86e56ac commit ed04dc9

File tree

9 files changed

+394
-67
lines changed

9 files changed

+394
-67
lines changed

app/lib/backend/http/api/conversations.dart

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,8 +329,14 @@ Future<List<ServerConversation>> sendStorageToBackend(File file, String sdCardDa
329329

330330
Future<SyncLocalFilesResponse> syncLocalFiles(List<File> files) async {
331331
try {
332+
// Check if this is a Pocket MP3 file (filename contains "pocket_mp3")
333+
final isPocketMp3 = files.isNotEmpty && files.first.path.contains('pocket_mp3');
334+
final endpoint = isPocketMp3 ? 'v1/pocket/upload-mp3' : 'v1/sync-local-files';
335+
336+
debugPrint('Syncing ${files.length} file(s) to $endpoint (isPocketMp3: $isPocketMp3)');
337+
332338
var response = await makeMultipartApiCall(
333-
url: '${Env.apiBaseUrl}v1/sync-local-files',
339+
url: '${Env.apiBaseUrl}$endpoint',
334340
files: files,
335341
);
336342

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import 'dart:io';
2+
import 'package:flutter/foundation.dart';
3+
import 'package:http/http.dart' as http;
4+
import 'package:omi/backend/http/shared.dart';
5+
import 'package:omi/backend/preferences.dart';
6+
7+
/// Upload Pocket MP3 file to backend for conversion
8+
Future<bool> uploadPocketMp3({
9+
required String filePath,
10+
required String deviceId,
11+
required int timerStart,
12+
}) async {
13+
try {
14+
debugPrint('Uploading Pocket MP3: $filePath');
15+
16+
final file = File(filePath);
17+
if (!await file.exists()) {
18+
debugPrint('MP3 file not found: $filePath');
19+
return false;
20+
}
21+
22+
final fileBytes = await file.readAsBytes();
23+
final fileName = filePath.split('/').last;
24+
25+
final url = Uri.parse('${getApiBaseUrl()}/v1/pocket/upload-mp3');
26+
27+
final request = http.MultipartRequest('POST', url);
28+
request.headers['Authorization'] = await getAuthHeader();
29+
30+
// Add file
31+
request.files.add(http.MultipartFile.fromBytes(
32+
'file',
33+
fileBytes,
34+
filename: fileName,
35+
));
36+
37+
// Add metadata
38+
request.fields['device_id'] = deviceId;
39+
request.fields['timer_start'] = timerStart.toString();
40+
41+
debugPrint('Sending MP3 upload request to: $url');
42+
final response = await request.send();
43+
final responseBody = await response.stream.bytesToString();
44+
45+
if (response.statusCode == 200) {
46+
debugPrint('MP3 upload successful: $fileName');
47+
return true;
48+
} else {
49+
debugPrint('MP3 upload failed: ${response.statusCode} - $responseBody');
50+
return false;
51+
}
52+
} catch (e) {
53+
debugPrint('Error uploading MP3: $e');
54+
return false;
55+
}
56+
}

app/lib/pages/pocket/pocket_device_page.dart

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -434,31 +434,6 @@ class _PocketDevicePageState extends State<PocketDevicePage> {
434434
child: Row(
435435
children: [
436436
Expanded(
437-
child: OutlinedButton.icon(
438-
onPressed: _isLoading || _isSyncing
439-
? null
440-
: () {
441-
if (_selectedRecordings.isNotEmpty) {
442-
setState(() {
443-
_selectedRecordings.clear();
444-
});
445-
} else if (_recordings.isNotEmpty) {
446-
setState(() {
447-
_selectedRecordings.addAll(_recordings.map((r) => r.recordingId));
448-
});
449-
}
450-
},
451-
style: OutlinedButton.styleFrom(
452-
foregroundColor: Colors.white,
453-
side: const BorderSide(color: Colors.white54),
454-
),
455-
icon: Icon(_selectedRecordings.isEmpty ? Icons.check_box_outline_blank : Icons.check_box),
456-
label: Text(_selectedRecordings.isEmpty ? 'Select All' : 'Deselect All'),
457-
),
458-
),
459-
const SizedBox(width: 16),
460-
Expanded(
461-
flex: 2,
462437
child: ElevatedButton.icon(
463438
onPressed: _isLoading || _isDownloading || _isSyncing || _recordings.isEmpty
464439
? null

app/lib/services/devices/pocket_connection.dart

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'dart:async';
22
import 'dart:convert';
3+
import 'dart:math';
34
import 'dart:typed_data';
45

56
import 'package:flutter/foundation.dart';
@@ -17,9 +18,9 @@ class PocketDeviceConnection extends DeviceConnection {
1718
65, 80, 80, 38, 83, 75, 38, 101, 121, 54, 114, 66, 80, 88, 80, 80, 105, 97, 86, 67, 103, 105, 84
1819
]; // "APP&SK&ey6rBPXPPiaVCgiT"
1920

20-
// MP3 Frame marker
21-
static const List<int> mp3Marker = [0xFF, 0xF3, 0x48, 0xC4];
22-
static const int mp3FrameSize = 144;
21+
// MP3 frame marker and size (from protocol analysis)
22+
static const List<int> mp3Marker = [0xFF, 0xF3, 0x48, 0xC4]; // MP3 frame marker (4 bytes)
23+
static const int mp3FrameSize = 144; // bytes per frame
2324

2425
// Response buffers
2526
final List<int> _responseBuffer = [];
@@ -188,9 +189,9 @@ class PocketDeviceConnection extends DeviceConnection {
188189
if (parts.length >= 4) {
189190
final used = int.parse(parts[2]);
190191
final total = int.parse(parts[3]);
191-
debugPrint('Pocket: Storage response - used: $used KB, total: $total KB');
192-
// Convert KB to bytes
193-
return (used * 1024, total * 1024);
192+
debugPrint('Pocket: Storage response - used: $used MB, total: $total MB');
193+
// Convert MB to bytes
194+
return (used * 1024 * 1024, total * 1024 * 1024);
194195
}
195196
} catch (e) {
196197
debugPrint('Pocket: Error parsing storage: $e');
@@ -276,36 +277,34 @@ class PocketDeviceConnection extends DeviceConnection {
276277
await Future.delayed(const Duration(seconds: 30));
277278

278279
// Find MP3 data in buffer
280+
debugPrint('Pocket: Response buffer size: ${_responseBuffer.length} bytes');
281+
debugPrint('Pocket: First 20 bytes of buffer: ${_responseBuffer.take(20).toList()}');
282+
279283
final audioStart = _findMarker(_responseBuffer, mp3Marker);
280284
if (audioStart < 0) {
281285
debugPrint('Pocket: MP3 marker not found in response');
282286
return null;
283287
}
284288

289+
debugPrint('Pocket: MP3 marker found at position $audioStart');
285290
final audioData = _responseBuffer.sublist(audioStart);
291+
debugPrint('Pocket: Audio data size: ${audioData.length} bytes');
292+
debugPrint('Pocket: First 20 bytes of audio data: ${audioData.take(20).toList()}');
286293

287-
// Verify and extract valid MP3 frames
288-
int packetCount = 0;
289-
int pos = 0;
290-
291-
while (pos < audioData.length - mp3FrameSize) {
292-
if (_matchesMarker(audioData, pos, mp3Marker)) {
293-
packetCount++;
294-
pos += mp3FrameSize;
295-
} else {
296-
break;
294+
// Check if there's any non-zero data
295+
int nonZeroCount = 0;
296+
for (int i = 0; i < min(1000, audioData.length); i++) {
297+
if (audioData[i] != 0 && audioData[i] != 0xFF && audioData[i] != 0xF3 && audioData[i] != 0x48 && audioData[i] != 0xC4) {
298+
nonZeroCount++;
297299
}
298300
}
301+
debugPrint('Pocket: Non-zero bytes in first 1000: $nonZeroCount');
299302

300-
if (packetCount == 0) {
301-
debugPrint('Pocket: No valid MP3 frames found');
302-
return null;
303-
}
304-
305-
final validData = audioData.sublist(0, packetCount * mp3FrameSize);
306-
debugPrint('Pocket: Downloaded ${validData.length} bytes ($packetCount frames)');
303+
// Return ALL audio data after the first marker (like Python does)
304+
// Python doesn't validate frame-by-frame, it just saves everything
305+
debugPrint('Pocket: Downloaded ${audioData.length} bytes of MP3 data');
307306

308-
return Uint8List.fromList(validData);
307+
return Uint8List.fromList(audioData);
309308
} catch (e) {
310309
debugPrint('Pocket: Error downloading recording: $e');
311310
return null;

app/lib/services/pocket_wal_service.dart

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import 'package:omi/services/wals.dart';
77
import 'package:omi/utils/wal_file_manager.dart';
88
import 'package:path_provider/path_provider.dart';
99

10-
/// Service to convert Pocket MP3 recordings to WAL files for processing
10+
/// Service to save Pocket MP3 recordings for backend conversion
1111
class PocketWalService {
1212
/// Create a WAL file from a Pocket recording's MP3 data
13+
/// Saves raw MP3 file for backend conversion
1314
/// Returns the created WAL object
1415
static Future<Wal> createWalFromPocketRecording({
1516
required PocketRecording recording,
@@ -23,28 +24,27 @@ class PocketWalService {
2324
// Calculate duration in seconds
2425
final durationSeconds = recording.durationSeconds;
2526

26-
// Save MP3 file directly - backend will handle conversion
27-
final directory = await getApplicationDocumentsDirectory();
27+
// Save raw MP3 file (backend will convert)
2828
final cleanDeviceId = device.id.replaceAll(RegExp(r'[^a-zA-Z0-9]'), "").toLowerCase();
29-
final mp3Filename = 'pocket_${cleanDeviceId}_$timerStart.mp3';
30-
final fullPath = '${directory.path}/$mp3Filename';
29+
final mp3Filename = 'audio_${cleanDeviceId}_pocket_mp3_$timerStart.mp3';
3130

32-
final file = File(fullPath);
33-
await file.writeAsBytes(mp3Data);
31+
debugPrint('Saving Pocket MP3 for backend conversion: $mp3Filename');
32+
final docsDir = await getApplicationDocumentsDirectory();
33+
final mp3Path = '${docsDir.path}/$mp3Filename';
34+
final mp3File = File(mp3Path);
35+
await mp3File.writeAsBytes(mp3Data);
3436

35-
debugPrint('Saved Pocket MP3: $fullPath');
37+
debugPrint('Saved MP3 to: $mp3Path');
3638

37-
// Create WAL object
38-
// Note: We use opus codec as a placeholder since the backend will handle MP3
39-
// The actual audio format doesn't matter for the sync process
39+
// Create WAL object with MP3 codec
4040
final wal = Wal(
4141
timerStart: timerStart,
42-
codec: BleAudioCodec.opus, // Placeholder codec
42+
codec: BleAudioCodec.pcm8, // Use pcm8 as placeholder for MP3 (backend will handle)
4343
seconds: durationSeconds,
44-
sampleRate: 16000, // Standard sample rate
45-
channel: 1, // Mono
46-
status: WalStatus.miss, // Mark as missing/ready to sync
47-
storage: WalStorage.disk, // Store as phone storage (not SD card)
44+
sampleRate: 16000,
45+
channel: 1,
46+
status: WalStatus.miss,
47+
storage: WalStorage.disk,
4848
filePath: mp3Filename, // Store only filename, not full path
4949
device: device.id,
5050
deviceModel: 'Pocket',

backend/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
users,
1818
trends,
1919
sync,
20+
pocket,
2021
apps,
2122
custom_auth,
2223
payment,
@@ -63,6 +64,7 @@
6364

6465
app.include_router(firmware.router)
6566
app.include_router(sync.router)
67+
app.include_router(pocket.router)
6668

6769
app.include_router(apps.router)
6870
app.include_router(custom_auth.router)

backend/models/conversation.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ class ConversationSource(str, Enum):
228228
workflow = 'workflow'
229229
sdcard = 'sdcard'
230230
external_integration = 'external_integration'
231+
pocket = 'pocket'
231232

232233

233234
class ConversationVisibility(str, Enum):

0 commit comments

Comments
 (0)