Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />

<!-- Background tasks -->
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>

<uses-feature android:name="android.hardware.usb.host" android:required="false" />
<uses-permission android:name="android.hardware.usb.host" />
Expand Down
2 changes: 2 additions & 0 deletions ios/Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ post_install do |installer|
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
'$(inherited)',
'PERMISSION_CAMERA=1',
'PERMISSION_CAMERA=1',
'PERMISSION_PHOTOS=1',
'PERMISSION_BLUETOOTH=1',
]
end
end
Expand Down
6 changes: 4 additions & 2 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to your photos and files to export and import wallet files like psbts, xpubs and recovery files, and to attach files in support chat</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>We need access to Bluetooth in order to connect to your hardware wallet when needed</string>
<string>We need access to Bluetooth to connect to hardware wallets and broadcast offline transactions via Bull Mesh</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>We need access to Bluetooth in order to connect to your hardware wallet when needed</string>
<string>We need access to Bluetooth to connect to hardware wallets and broadcast offline transactions via Bull Mesh</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
Expand All @@ -72,6 +72,8 @@
<array>
<string>fetch</string>
<string>processing</string>
<string>bluetooth-central</string>
<string>bluetooth-peripheral</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
Expand Down
2 changes: 2 additions & 0 deletions lib/core/core_locator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:bb_mobile/core/fees/fees_locator.dart';
import 'package:bb_mobile/core/labels/labels_locator.dart';
import 'package:bb_mobile/core/ledger/ledger_locator.dart';
import 'package:bb_mobile/core/mempool/mempool_locator.dart';
import 'package:bb_mobile/core/mesh/mesh_locator.dart';
import 'package:bb_mobile/core/payjoin/payjoin_locator.dart';
import 'package:bb_mobile/core/recoverbull/recoverbull_locator.dart';
import 'package:bb_mobile/core/seed/seed_locator.dart';
Expand Down Expand Up @@ -74,6 +75,7 @@ class CoreLocator {
static void registerServices(GetIt locator) {
MempoolLocator.registerServices(locator);
SeedLocator.registerServices(locator);
MeshLocator.registerServices(locator);
SwapsLocator.registerServices(locator);
}

Expand Down
136 changes: 136 additions & 0 deletions lib/core/mesh/mesh_background_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import 'dart:async';
import 'dart:ui';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:bb_mobile/core/mesh/mesh_service.dart';
import 'package:bb_mobile/locator.dart';

/// Manages the long-running "Sentinel" service for Android.
/// On iOS, this mostly relies on native background modes, but we can use this
/// to register Background Fetch if needed.
class MeshBackgroundService {
static Future<void> initialize() async {
final service = FlutterBackgroundService();

// Android Notification Channel
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'bull_mesh_sentinel', // id
'Bull Mesh Sentinel', // title
description: 'Keeps Bull Mesh Relay active in background',
importance: Importance.low, // No sound
);

final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();

if (Platform.isAndroid) {
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
}

await service.configure(
androidConfiguration: AndroidConfiguration(
// This will be executed in the separate isolate
onStart: onStart,

// Auto start service
autoStart: false,
isForegroundMode: true,

notificationChannelId: 'bull_mesh_sentinel',
initialNotificationTitle: 'Bull Mesh Relay Active',
initialNotificationContent: 'Scanning for offline transactions...',
foregroundServiceNotificationId: 888,
),
iosConfiguration: IosConfiguration(
// Auto start service
autoStart: false,
// this will be executed when app is in foreground in separated isolate
onForeground: onStart,
// you have to enable background fetch capability on xcode project
onBackground: onIosBackground,
),
);

// Bridge: Listen for events from Background Isolate
service.on('incomingTx').listen((event) {
if (event != null && event['hex'] != null) {
final String txHex = event['hex'] as String;
// Inject into Main Isolate's MeshService to trigger UI
locator<MeshService>().injectIncomingTx(txHex);
}
});

service.on('updateProgress').listen((event) {
if (event != null && event['progress'] != null) {
final double progress = event['progress'] as double;
locator<MeshService>().injectDownloadProgress(progress);
}
});
}

static Future<void> start() async {
final service = FlutterBackgroundService();
if (!await service.isRunning()) {
service.startService();
}
}

static Future<void> stop() async {
final service = FlutterBackgroundService();
service.invoke("stopService");
}

// PRAGMA VM: ENTRY POINT
@pragma('vm:entry-point')
static void onStart(ServiceInstance service) async {
// Only available for flutter 3.0.0 and later
DartPluginRegistrant.ensureInitialized();

// NOTE: This runs in a SEPARATE ISOLATE.
// Usage of 'locator' here requires re-initialization or avoiding it.
// We will instantiate a fresh MeshService.

final meshService = MeshService();

// Listen to events from Main Isolate
service.on('stopService').listen((event) {
service.stopSelf();
});

// Start Mesh Scanning Logic
try {
print("MeshSentinel: Starting Scan in Background Isolate...");

// Setup Bridge: Background Mesh -> Main Isolate
meshService.incomingTransactions.listen((txHex) {
service.invoke('incomingTx', {'hex': txHex});
});

meshService.downloadProgressNotifier.addListener(() {
service.invoke('updateProgress', {'progress': meshService.downloadProgressNotifier.value});
});

// We only want to SCAN (Relay) in background, not Advertise (usually)
await meshService.startScanningForRelay();

} catch (e) {
print("MeshSentinel: Error starting scan: $e");
}
}

@pragma('vm:entry-point')
static Future<bool> onIosBackground(ServiceInstance service) async {
// WidgetsFlutterBinding.ensureInitialized();
// DartPluginRegistrant.ensureInitialized();
return true;
}
}

import 'dart:io';

// ... class Platform removed code replacement ...
8 changes: 8 additions & 0 deletions lib/core/mesh/mesh_locator.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import 'package:bb_mobile/core/mesh/mesh_service.dart';
import 'package:get_it/get_it.dart';

class MeshLocator {
static void registerServices(GetIt locator) {
locator.registerLazySingleton<MeshService>(() => MeshService());
}
}
96 changes: 96 additions & 0 deletions lib/core/mesh/mesh_protocol.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import 'dart:typed_data';
import 'dart:math';

/// Implements the "Whale Protocol" for fragmenting large data packets
/// over BLE limits.
///
/// Header Structure (2 Bytes):
/// [TotalChunks (1 Byte) | CurrentIndex (1 Byte)]
class MeshProtocol {
// Standard BLE MTU is often 512, but safe payload size is smaller.
// We'll use 500 bytes for payload + 2 bytes header = 502 bytes, well within 512.
static const int MAX_CHUNK_PAYLOAD_SIZE = 500;

/// Splits a large payload into smaller chunks with headers.
static List<Uint8List> fragment(Uint8List data) {
if (data.isEmpty) return [];

int totalSize = data.length;
int totalChunks = (totalSize / MAX_CHUNK_PAYLOAD_SIZE).ceil();

if (totalChunks > 255) {
throw Exception("Payload too large: Max 255 chunks supported");
}

List<Uint8List> chunks = [];

for (int i = 0; i < totalChunks; i++) {
int start = i * MAX_CHUNK_PAYLOAD_SIZE;
int end = min(start + MAX_CHUNK_PAYLOAD_SIZE, totalSize);

Uint8List payload = data.sublist(start, end);

// Construct Header: [TotalChunks, CurrentIndex]
// Index is 0-based
BytesBuilder builder = BytesBuilder();
builder.addByte(totalChunks);
builder.addByte(i);
builder.add(payload);

chunks.add(builder.toBytes());
}

return chunks;
}

/// Attempts to reassemble a complete payload from a set of chunks.
/// Returns null if chunks are missing.
static Uint8List? reassemble(Map<int, Uint8List> chunks) {
if (chunks.isEmpty) return null;

// Get metadata from the first distinct chunk we have
// (We assume all chunks belong to the same transmission for now in this simple protocol)
final firstChunk = chunks.values.first;
if (firstChunk.length < 2) return null; // Invalid chunk

int totalChunks = firstChunk[0];

// Do we have all chunks?
if (chunks.length != totalChunks) {
return null;
}

// sort by index to be safe
var sortedKeys = chunks.keys.toList()..sort();

BytesBuilder fullPayload = BytesBuilder();

for (int i = 0; i < totalChunks; i++) {
if (!chunks.containsKey(i)) return null; // Should be covered by length check, but safety first

Uint8List chunk = chunks[i]!;
// Strip header (first 2 bytes)
fullPayload.add(chunk.sublist(2));
}

return fullPayload.toBytes();
}

/// Parses a raw chunk to extract its metadata
static MeshChunkHeader parseHeader(Uint8List chunk) {
if (chunk.length < 2) throw Exception("Invalid chunk size");
return MeshChunkHeader(
totalChunks: chunk[0],
index: chunk[1],
payload: chunk.sublist(2)
);
}
}

class MeshChunkHeader {
final int totalChunks;
final int index;
final Uint8List payload;

MeshChunkHeader({required this.totalChunks, required this.index, required this.payload});
}
Loading
Loading