We encourage all contributions to this project! This guide will help you understand the codebase architecture and how to contribute effectively.
- Write clean, well-commented code
- Follow Dart documentation guidelines
- Format your code with
dart format ./ --line-length=120 - Test your changes thoroughly before submitting
-
Install Git: Download
-
Install Java JDK: Download
-
Install Flutter Version Manager (FVM) - Recommended
FVM allows you to manage multiple Flutter versions easily:
dart pub global activate fvm
Then install and use the Flutter version specified in the project:
fvm install fvm use
Alternatively, install Flutter directly: Flutter installation guide
-
Install Android Studio: Download
- Install Flutter & Dart plugins via the Plugin Manager
- Install Command Line Tools via SDK Manager
- Configure an Android Virtual Device (AVD) via AVD Manager, or connect a physical Android device with USB debugging enabled
-
Install Visual Studio Code (Recommended): Download
Install the following extensions:
- Dart
- Flutter
- IntelliCode (optional but recommended)
Option 1: Android Virtual Device (AVD)
- Open Android Studio → AVD Manager
- Create a new virtual device
- Choose a device definition (e.g., Pixel 6)
- Select a system image (API 30 or higher recommended)
- Finish setup and launch the emulator
Option 2: Physical Android Device
- Enable Developer Options on your device
- Enable USB Debugging
- Connect via USB
- Verify connection:
flutter devices
-
Create a GitHub account if you don't have one
-
Fork the
bluebubbles-apprepository -
Clone your forked repository:
# HTTPS git clone https://github.com/YOUR_USERNAME/bluebubbles-app.git # SSH (recommended) git clone git@github.com:YOUR_USERNAME/bluebubbles-app.git
-
Set up the upstream remote:
cd bluebubbles-app git remote add upstream git@github.com:BlueBubblesApp/bluebubbles-app.git -
Fetch branches and pull latest changes:
git fetch --all git pull upstream master
# If using FVM
fvm flutter pub get
# If using global Flutter
flutter pub getBlueBubbles uses ObjectBox as its local database solution for efficient, fast data storage.
ObjectBox is a NoSQL object database optimized for mobile and IoT. Key features:
- Direct object persistence: No ORM mapping overhead
- ACID compliance: Transactions ensure data consistency
- Reactive queries: Automatic UI updates when data changes
- Relations: ToOne, ToMany, and backlinks for entity relationships
Efficient Operations:
get(id)- Direct ID lookups are O(1)getMany([ids])- Batch retrieval by IDs- Indexed queries (e.g., on
guid,dateCreated) - Limiting query results with
.limit(n) - Using
.find()sparingly with proper conditions
Inefficient Operations:
- Loading all entities without limits
- Queries without indexes on frequently searched fields
- Accessing relationships outside of transactions (causes additional queries)
- Modifying data without using transactions
Best Practice:
// Good: Efficient batch retrieval
final messages = Database.messages.getMany(messageIds).whereType<Message>().toList();
// Bad: Querying in a loop
for (final id in messageIds) {
final message = Database.messages.get(id); // Multiple separate queries
}The main ObjectBox entities are:
Message (lib/database/io/message.dart)
- Represents an iMessage/SMS message
- Relations:
ToOne<Handle>- The sender/recipient handleToMany<Attachment>- Linked attachments- Associated messages (reactions, edits) via
associatedMessageslist
Chat (lib/database/io/chat.dart)
- Represents a conversation
- Relations:
ToMany<Handle>- Participants in the chatToMany<Message>- Messages in the chat
- Maintains latest message reference
Handle (lib/database/io/handle.dart)
- Represents a contact address (phone/email)
- Relations:
ToMany<ContactV2>- New contact system (backlink)ToMany<Chat>- Chats this handle participates inToOne<Contact>- Legacy contact relation (DEPRECATED - use ContactV2)
Attachment (lib/database/io/attachment.dart)
- Represents a file attachment
- Relations:
ToOne<Message>- Parent message
ContactV2 (lib/database/io/contact_v2.dart)
- New contact system (replaces deprecated Contact model)
- Relations:
ToMany<Handle>- Linked handles for this contact
Contact (lib/database/io/contact.dart) - DEPRECATED
- Legacy contact system - DO NOT USE for new code
- Use ContactV2 instead for all contact-related operations
Important: Always access ObjectBox relationships within a transaction context to avoid performance issues.
BlueBubbles uses Dart isolates to offload heavy work from the main UI thread, ensuring smooth performance.
The GlobalIsolate (lib/services/isolates/global_isolate.dart) is a long-running background thread designed for general-purpose work. Reusing this isolate is more efficient than spawning new ones repeatedly.
Key Features:
- Persistent background thread
- Request-response pattern with UUID tracking
- Event emission from isolate to main thread
- Automatic timeout handling
- Idle shutdown to conserve resources
To leverage the GlobalIsolate, you need to create:
- Interface (
lib/services/backend/interfaces/) - Handles object hydration and determines whether to call an isolate or execute directly (if already in an isolate) - Actions (
lib/services/backend/actions/) - Contains the actual work to be executed
Example Interface Pattern:
// lib/services/backend/interfaces/example_interface.dart
import 'package:bluebubbles/env.dart';
import 'package:bluebubbles/services/backend/actions/example_actions.dart';
import 'package:bluebubbles/services/isolates/global_isolate.dart';
import 'package:get_it/get_it.dart';
class ExampleInterface {
static Future<String> doWork({required String input}) async {
final data = {'input': input};
// Check if already in isolate
if (isIsolate) {
return await ExampleActions.doWork(data);
} else {
// Send to GlobalIsolate
return await GetIt.I<GlobalIsolate>()
.send<String>(IsolateRequestType.doWork, input: data);
}
}
}Example Actions:
// lib/services/backend/actions/example_actions.dart
class ExampleActions {
static Future<String> doWork(Map<String, dynamic> data) async {
final input = data['input'] as String;
// Perform heavy computation
return "Result: $input";
}
}Add your actions to the action map in lib/services/isolates/isolate_actions.dart:
class IsolateActons {
static final Map<IsolateRequestType, dynamic> actions = {
// ... existing actions
IsolateRequestType.doWork: ExampleActions.doWork,
};
}And add the request type to GlobalIsolate enum (in global_isolate.dart):
enum IsolateRequestType {
// ... existing types
doWork,
}The following interfaces are currently available:
- AppInterface - App update checks
- ServerInterface - Server communication and updates
- ImageInterface - Image processing (PNG conversion, EXIF reading, GIF dimensions)
- PrefsInterface - Shared preferences operations
- MessageInterface - Message CRUD operations
- ChatInterface - Chat management (notifications, read/unread, transcript clearing)
- HandleInterface - Handle operations
- ContactV2Interface - Contact system operations (use this for all contact work)
- ContactInterface - DEPRECATED - Use ContactV2Interface instead
- AttachmentInterface - Attachment CRUD operations
- SyncInterface - Incremental sync operations
- TestInterface - Testing isolate functionality
For specialized workloads, you can create a custom isolate by extending GlobalIsolate:
Example: IncrementalSyncIsolate
// lib/services/isolates/incremental_sync_isolate.dart
class IncrementalSyncIsolate extends GlobalIsolate {
IncrementalSyncIsolate({
super.taskTimeout = const Duration(minutes: 5),
super.startupTimeout = const Duration(seconds: 10),
super.idleTimeout = Duration.zero,
});
@override
String get isolatePortName => 'IncrementalSyncIsolate';
@override
String get isolateDebugName => 'IncrementalSyncIsolate';
@override
Function get getIsolateEntryPoint => IncrementalSyncIsolate._syncIsolateEntryPoint;
static Future<void> _syncIsolateEntryPoint(List<dynamic> args) async {
await GlobalIsolate.sharedIsolateEntryPoint(
args,
StartupTasks.initSyncIsolateServices, // Custom service initialization
IsolateActons.actions,
);
}
}Register your custom isolate in lib/helpers/backend/startup_tasks.dart (or wherever appropriate):
GetIt.I.registerSingleton<IncrementalSyncIsolate>(IncrementalSyncIsolate());- Always ensure that heavy computations or blocking operations are offloaded to isolates to maintain UI responsiveness.
- Use the provided interfaces to interact with isolates rather than calling actions directly.
- When in doubt, refer to existing interfaces for patterns and best practices.
- Serialization & deserialization of complex objects is expensive; prefer passing primitive types or simple data structures.
- Since isolates have access to the HTTP service and the database, we recommend making large data fetches or network requests within the isolate itself rather than passing large objects back and forth. You can pass IDs back to the main thread to be hydrated, which is more efficient.
BlueBubbles uses GetIt for dependency injection and service management.
Services are registered and initialized in lib/helpers/backend/startup_tasks.dart:
// Singleton registration
GetIt.I.registerSingleton<MyService>(MyService());
// Async singleton (waits for initialization)
GetIt.I.registerSingletonAsync<MyService>(() async {
final service = MyService();
await service.init();
return service;
});
// Wait for service to be ready
await GetIt.I.isReady<MyService>();The order matters! Services are initialized in this sequence:
- FilesystemService - File system operations
- SharedPreferencesService - Persistent key-value storage
- SettingsService - App settings management
- BaseLogger - Logging infrastructure
- Database - ObjectBox initialization
- GlobalIsolate & IncrementalSyncIsolate - Background threads
- HttpService - Network requests
- MethodChannelService - Platform channel communication
- LifecycleService - App lifecycle events
- CloudMessagingService - Firebase Cloud Messaging
- ContactServiceV2 - Contact management
- IntentsService - Deep link handling
- SyncService - Data synchronization
- ThemesService - Theme management
- NavigatorService - Navigation management
- ChatsService - Chat state management
- SocketService - WebSocket communication
- NotificationsService - Local notifications
- EventDispatcher - Event bus
import 'package:get_it/get_it.dart';
// Access a service
final settingsService = GetIt.I<SettingsService>();
// Using service shortcuts (defined in service files)
import 'package:bluebubbles/services/services.dart';
SettingsSvc.settings.redactedMode.value = true;
ChatsSvc.findChatByGuid('some-guid');BlueBubbles uses GetX for reactive state management.
Instead of using database models directly in the UI, we use state wrappers like ChatState (lib/app/state/chat_state.dart) to provide granular reactivity:
class ChatState {
final Chat chat; // Underlying DB model
// Observable fields
final RxBool isPinned;
final RxnInt pinIndex;
final RxBool hasUnreadMessage;
final RxnString displayName;
final Rxn<Message> latestMessage;
// ... more fields
}Why Use State Wrappers?
- Performance: Widgets only rebuild when specific fields change
- Granular control: Subscribe to individual properties
- Separation of concerns: UI state separate from DB models
Obx Widget - Automatically rebuilds when observed values change:
Obx(() => Text(chatState.displayName.value ?? 'Unknown'))GetX Controller - For complex screen state:
class MyController extends GetxController {
final RxInt counter = 0.obs;
void increment() => counter.value++;
}
// In widget
final controller = Get.put(MyController());
Obx(() => Text('Count: ${controller.counter.value}'))DO:
- Use
ChatStateor similar state wrappers for reactive UI - Wrap only the smallest widget that needs to react in
Obx() - Use
.obsfor primitive types,Rx<T>()for objects
DON'T:
- Use DB models directly in reactive widgets (won't trigger rebuilds)
- Wrap large widget trees in
Obx()- split into smaller reactive pieces - Mutate observable values without
.valuesetter
Example:
// ❌ Bad: Won't update UI when chat changes
Obx(() => Text(chat.displayName ?? 'Unknown'))
// ✅ Good: Reacts to ChatState changes
Obx(() => Text(chatState.displayName.value ?? 'Unknown'))Run the app in debug mode:
# If using FVM
fvm flutter run
# Standard Flutter
flutter runBuild release APKs with flavor support:
# Beta flavor
flutter build apk --flavor=beta --release
# Production flavor
flutter build apk --flavor=prod --release
# Split per ABI (smaller file sizes)
flutter build apk --flavor=beta --release --split-per-abi- beta: Beta testing builds with Firebase Test Lab integration
- prod: Production builds for Google Play Store
Always format your code before committing:
dart format ./ --line-length=120This project uses a max line length of 120 characters.
- Check the issues page
- Filter by labels:
Difficulty: Easy,Difficulty: Medium,Difficulty: Hardgood first issue- Great for newcomersbug- Bug fixesenhancement- New features
- If working on something without an issue, create one first for tracking
-
Create a feature branch:
git checkout -b <your-name>/<feature|bug>/<short-description> # Example: git checkout -b john/feature/dark-mode-support
-
Make your changes
-
Format your code:
dart format ./ --line-length=120
-
Test thoroughly on both emulator and physical device if possible
-
Stage and commit:
git add <file> # or git add -A git commit -m "Clear, descriptive commit message"
-
Push to your fork:
git push origin <your-branch-name>
-
Go to your forked repository on GitHub
-
Click "Pull requests" → "New pull request"
-
Set base repository to
BlueBubblesApp/bluebubbles-appand base branch todevelopment -
Include in your PR description:
- Problem: What issue does this solve?
- Solution: How did you fix it?
- Testing: How did you test the changes?
- Screenshots: If applicable, include before/after screenshots
-
Submit and wait for review!
Join our Discord community for help and discussions!