Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/framework-ish'
Browse files Browse the repository at this point in the history
  • Loading branch information
TheLastGimbus committed Feb 23, 2024
2 parents df92f29 + e02da40 commit df87f78
Show file tree
Hide file tree
Showing 40 changed files with 1,054 additions and 280 deletions.
25 changes: 11 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,30 @@
[![resolved Github issues](https://img.shields.io/github/issues-closed/TheLastGimbus/FreeBuddy?label=resolved%20issues)](https://github.com/TheLastGimbus/FreeBuddy/issues)
[![commit activity](https://img.shields.io/github/commit-activity/y/TheLastGimbus/FreeBuddy)](https://github.com/TheLastGimbus/FreeBuddy/graphs/contributors)

Free and open source app for Huawei Freebuds 4i headphones 🎧
Free <sup>(and open source)</sup> buddy app for bluetooth headphones 🎧

## Why 🧐

I wanted to quickly switch ANC modes on my headphones...
I wanted to quickly switch ANC modes on my headphones... but official apps are usually bloated as hell 😑 (at least Huawei's was - enough to motivate me to create *this*)

but official app is... well, bloated as hell 😑 - it weights 100mb, and takes (on my phone) 10 seconds to open...

Also, it doesn't have features like "find lost headphones", etc...
Also, they sometimes lack features that *are possible* to implement - like "find lost headphones", etc...

So I got mad and decided to make my own... and here it is 🌈

<img alt="Freebuddy main screen light mode" src="https://github.com/TheLastGimbus/FreeBuddy/assets/40139196/e463717e-f427-48f3-91fc-79f9a313359b" width=250px> <img alt="Freebuddy main screen dark mode" src="https://github.com/TheLastGimbus/FreeBuddy/assets/40139196/15914349-921a-4c59-b1cf-4ed443c09823" width=250px>

<img alt="Freebuddy home widget light mode" src="https://github.com/TheLastGimbus/FreeBuddy/assets/40139196/a7c46a10-ebb6-45c8-a4c3-5b2597ab2d3f" width=250px> <img alt="Freebuddy home widget dark mode" src="https://github.com/TheLastGimbus/FreeBuddy/assets/40139196/348ae011-ab18-4ff4-a497-30d3f2fecc8b" width=250px>

## Supported headphones 🗂️

Right now FreeBuddy only supports handful of models, but this is ment to expand in future 🤞 (psst, if you would like to help, head over to [the.lastgimbus.com/freebuddy/dev/](https://the.lastgimbus.com/freebuddy/dev/))

- Huawei FreeBuds 4i

## Features 🔨

- [x] Show battery levels
- [x] Switch ANC modes
- [x] Change settings
- [x] Smart wear
- [x] Tap actions
- [x] Cool features:
- [x] Home screen widget with battery charge 🔋
- [ ] ANC lock - lock on certain ANC mode no matter what
- [ ] Find lost
FreeBuddy can already do everything that stock apps can, while adding own features:
- Home screen widget with battery charge 🔋

## Download 📦

Expand Down
30 changes: 30 additions & 0 deletions lib/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Hi!!!!!! 👋👋👋👋👋
You're coding and want to help out/explore around here? That's great!!!

Okay, so - UI code is as pure-flutter-future-stream as possible, and most stuff has comments/documentation, so just look around and read those 👍

Interesting rev-eng stuff is in `headphones/` folder, and there is dedicated [headphones/README.md](headphones/README.md) - head there 🫡

# Development notes - philosophies, TODOs etc

## Migration to framework-ish organisation
TODO:
- [X] Implement basic stuff (ANC + battery)
- [X] Change ui to work at all with new stuff
- [ ] Implement hp settings
This is non trivial cause we have to decide how to make this universal when almost all headphones have this different
- [X] Change ui BIG to dynamically support *all* headphones by their features instead of concrete model -> pretty much done?
- [ ] Support multiple diff/same headphones
- [ ] Basic fix in connection loops to show them at all
- [ ] Basic fix in widget to maybe show their name to distinguish which ones are shown now
Currently, widget just creates the cubit, wait until it connects, and gets the battery. What if there are
multiple headphones? Right now it just... gets the first one 🤷
- [ ] Some database stuf... ehhhh... to distinguish between them, remember their last time etc
This is potentially waaayyy ahead todo as it requires *serious* decisions that will affect *everything* wayyy ahead in later development, so better not fuck it up
- [ ] Selecting headphones when making new widget
- [ ] Tests for everything

## Future features:
- [ ] Detect hp colors and assign proper image
- [ ] ANC control widget
This will require whole big background stuff, so that's far offs
100 changes: 100 additions & 0 deletions lib/headphones/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Hello!

Nice to see you! This readme describes how freebuddy structures *the whole headphone stuff*, so it's a must-read for anyone wanting to implement new ones, or just looking around ^_^

Detecting paired headphones, managing serial/BLE connection with them, parsing their magic protocols, detecting available features - EVERYTHING is in here!! I did my **BEST** to organise that it's easy to understand, implement new models, find bugs and test 💪💪💪

## Quick wrap
Here's a quick roadmap of how headphones get connected:
1. Proper cubit watches over bluetooth itself - is it enabled? Is it even available? The ui listens to this cubit and displays proper info (for example, button leading to system bt settings if it's disabled)

(This qubit may be a fake one to ease up testing without physical headphones - it then emits fake HeadphoneClass and we don't worry about any steps below)
2. TODO: Proper function/switch-case-loop watches over connected devices, distinguishes which ones are headphones that we support, then creates their object by passing them their bluetoothDevice + serial/BLE objects

Note that this step already manages their serial connection - headphone classes assume they are connected
3. Given headphone class listens to all serial data, parses it and exposes all recognized info though it's streams. Almost every value - battery, name, anc state - has it's own `Stream`, and UI can be split to re-usable `StreamBuilder`s
4. When headphones get disconnected, function from step 2. closes the `StreamChannel` passed to HeadphoneClass, HpClass closes all it's streams and gets forgotten 💀

# `framework/`

This folder contains **only** abstract classes with **no** logic - the framework for all implementations to base on

They will essentially contain all methods and properties that other classes need to implement.

Let's take `bluetooth_headphones.dart` as an example - it contains stuff that *all* bluetooth headphones (smart, or not smart) - share in common and thus *need* to implement:
- macAddress
- bluetoothName
- bluetoothAlias
- batteryLevel

Now, some concrete `GenericBluetoothHeaphones` class could implement `batteryLevel` based on classic bluetooth standards, while some `SmartHeadphones` could share the average value of left and right bud. `SmartHeadphones` would probably implement a bunch of other classes, but still have to provide all generic properties of `BluetoothHeadphones`.

## `headphones_info.dart`

At some point, the freebuddy's code will contain some information about select headphones models - I want to avoid this as much as possible, since:
1. This stuff is language-specific, and implementing whole damn localisation for this is a no-no
2. Vendors often can't decide what is the freaking name/brand of their product (it is Xiaomi?? or Redmi? or Mi?? or both!)
3. Many more surprizes like different color of buds available on different markets etc
4. I want freebuddy to be very easy to extend - manually filling their names instead of getting them from bluetooth device name is another boilerplate step

So, I want to be careful with this, but for now adding their model and vendor name doesn't seem that bad 👀

# Vendor folders
Folders named by vendor - `huawei/`, `samsung/` etc contain headphones base classes, their implementations and simulators

## Base classes
Most basically named classes - f.e. `freebuds4i.dart` contain "blueprints/specifiactions" for headphones - have a look:
```dart
abstract base class HuaweiFreeBuds4i
implements BluetoothHeadphones, HeadphonesModelInfo, LRCBattery, Anc {...}
```

We clearly see what features this model has - we use this class across UI, not caring how they are implemented

### Discoverability stuff in base classes
I don't have a better idea for where to put identifiers - that is, name regexes, bluetooth uuids etc (stuff that distinguishes given model from other bluetooth devices)

...so I'm putting it in static fields plainly in base classes 🤷 and later some big-ass switch will be like:

```dart
// example pseudocode
switch(device.btName) {
case HuaweiFreeBuds4i.btName:
return HuaweiFreeBuds4iImpl(device.rfComm);
// ... etc
}
```

## Implementation classes
Classes named `SomeModelImpl` contain *THE* implementation - all bits and bytes parsing etc. Of course, you can extract some common-vendor stuff outside to share between models - this is up to you

These classes typically get already-connected bluetooth serial in form of `StreamChannel`

## Simulator classes
They are ready-to-use fake implementations of base classes - they are made with mixins from `simulators/`, and can be passed to ui instead of actual implementations 👍👍

# `simulators/`

`simulator` is my name for a mock - they are here to help you test the app without connecting the headphones, or even without Android/Bluetooth itself

The folder contains `mixin`s that can help you to quickly bake a simulator of a device. The simulators themselves are actually located besides actual implementations of given headphones, not here

One headphones have ANC, others show battery for separate buds, others have both - but all of them implement classes for these properties one-by-one. Luckily, `simulators/` will contain simulating mixins for all these properties 🎉

```dart
// DONE!
final class AirPodsMaxSim extends AirPodsMax with ANCSim {}
// Also done!
final class CheapBudsSim extends CheapBuds with LRCBatterySim {}
// Even more done!!!
final class PoshBudsSim extends PoshBuds with ANCSim, LRCBatterySim{}
}
```

This is still kiiiiiinda boilerplate, but there may be properties that cannot be easily simulated with one-fits-all simulator, or you may want to do that manually

> One could still say that original/base `ANC` and `LRCBattery` classes could be simulations themselves, and just re-implement them in actual headphones' implementations - we would then avoid re-writing all of that `with`s - however, I just think it would be confusing, and could lead to weird bugs by someone forgetting to re-implement them.
And, this also allows us to make and mix multiple `Sim`s - for example, there would be just one fake `ANC`, while having `AlwaysFull`, `DischargingSlowly` and `ChargingFast` for the batteries 👀
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import 'dart:typed_data';
import 'package:rxdart/rxdart.dart';
import 'package:stream_channel/stream_channel.dart';

import '../../../logger.dart';
import '../../headphones_base.dart';
import '../../headphones_data_objects.dart';
import '../mbb.dart';
import '../../logger.dart';
import '../huawei/mbb.dart';
import 'headphones_base.dart';
import 'headphones_data_objects.dart';

class HeadphonesImplOtter extends HeadphonesBase {
final StreamChannel<Uint8List> connection;
Expand Down
File renamed without changes.
32 changes: 17 additions & 15 deletions lib/headphones/cubit/headphones_connection_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import 'package:permission_handler/permission_handler.dart';
import 'package:the_last_bluetooth/the_last_bluetooth.dart';

import '../../logger.dart';
import '../huawei/otter/headphones_impl_otter.dart';
import '../huawei/otter/otter_constants.dart';
import '../huawei/freebuds4i.dart';
import '../huawei/freebuds4i_impl.dart';
import '../huawei/freebuds4i_sim.dart';
import 'headphones_cubit_objects.dart';

class HeadphonesConnectionCubit extends Cubit<HeadphonesConnectionState> {
Expand Down Expand Up @@ -60,24 +61,26 @@ class HeadphonesConnectionCubit extends Cubit<HeadphonesConnectionState> {

Future<void> connect() async => _connect(await _bluetooth.pairedDevices);

// TODO/MIGRATION: This whole big-ass connection/detection loop 🤯
// for example, all placeholders assume we have 4i... not good
Future<void> _connect(List<BluetoothDevice> devices) async {
if (!await _bluetooth.isEnabled()) {
emit(HeadphonesBluetoothDisabled());
emit(const HeadphonesBluetoothDisabled());
return;
}
if (_connection != null) return; // already connected and working, skip
final otter = devices
.firstWhereOrNull((d) => OtterConst.btDevNameRegex.hasMatch(d.name));
.firstWhereOrNull((d) => HuaweiFreeBuds4i.idNameRegex.hasMatch(d.name));
if (otter == null) {
emit(HeadphonesNotPaired());
emit(const HeadphonesNotPaired());
return;
}
if (!otter.isConnected) {
// not connected to device at all
emit(HeadphonesDisconnected());
emit(const HeadphonesDisconnected(HuaweiFreeBuds4iSimPlaceholder()));
return;
}
emit(HeadphonesConnecting());
emit(const HeadphonesConnecting(HuaweiFreeBuds4iSimPlaceholder()));
try {
// when Ai Life takes over our socket, the connecting always succeeds at
// 2'nd try 🤔
Expand All @@ -89,8 +92,7 @@ class HeadphonesConnectionCubit extends Cubit<HeadphonesConnectionState> {
if (i + 1 >= connectTries) rethrow;
}
}
emit(HeadphonesConnectedOpen(
HeadphonesImplOtter(_connection!.io, otter.alias)));
emit(HeadphonesConnectedOpen(HuaweiFreeBuds4iImpl(_connection!.io)));
await _connection!.io.stream.listen((event) {}).asFuture();
// when device disconnects, future completes and we free the
// hopefully this happens *before* next stream event with data 🤷
Expand All @@ -107,17 +109,17 @@ class HeadphonesConnectionCubit extends Cubit<HeadphonesConnectionState> {
emit(
((await _bluetooth.pairedDevices)
.firstWhereOrNull(
(d) => OtterConst.btDevNameRegex.hasMatch(d.name))
(d) => HuaweiFreeBuds4i.idNameRegex.hasMatch(d.name))
?.isConnected ??
false)
? HeadphonesConnectedClosed()
: HeadphonesDisconnected(),
? const HeadphonesConnectedClosed(HuaweiFreeBuds4iSimPlaceholder())
: const HeadphonesDisconnected(HuaweiFreeBuds4iSimPlaceholder()),
);
}

HeadphonesConnectionCubit({required TheLastBluetooth bluetooth})
: _bluetooth = bluetooth,
super(HeadphonesNotPaired()) {
super(const HeadphonesNotPaired()) {
IsolateNameServer.removePortNameMapping(pingReceivePortName);
IsolateNameServer.registerPortWithName(
_pingReceivePort.sendPort, pingReceivePortName);
Expand All @@ -131,12 +133,12 @@ class HeadphonesConnectionCubit extends Cubit<HeadphonesConnectionState> {
Future<void> _init() async {
// it's down here to be sure that we do have device connected so
if (!await Permission.bluetoothConnect.isGranted) {
emit(HeadphonesNoPermission());
emit(const HeadphonesNoPermission());
return;
}
_btStream = _bluetooth.adapterInfoStream.listen((event) {
_btEnabledCache = event.isEnabled;
if (!event.isEnabled) emit(HeadphonesBluetoothDisabled());
if (!event.isEnabled) emit(const HeadphonesBluetoothDisabled());
});
// logic of connect() is so universal we can use it on every change
_devStream = _bluetooth.pairedDevicesStream.listen(_connect);
Expand Down
40 changes: 30 additions & 10 deletions lib/headphones/cubit/headphones_cubit_objects.dart
Original file line number Diff line number Diff line change
@@ -1,21 +1,41 @@
import '../headphones_base.dart';
import '../framework/bluetooth_headphones.dart';

abstract class HeadphonesConnectionState {}
abstract class HeadphonesConnectionState {
const HeadphonesConnectionState();
}

class HeadphonesNoPermission extends HeadphonesConnectionState {
const HeadphonesNoPermission();
}

class HeadphonesBluetoothDisabled extends HeadphonesConnectionState {
const HeadphonesBluetoothDisabled();
}

class HeadphonesNoPermission extends HeadphonesConnectionState {}
class HeadphonesNotPaired extends HeadphonesConnectionState {
const HeadphonesNotPaired();
}

class HeadphonesBluetoothDisabled extends HeadphonesConnectionState {}
class HeadphonesDisconnected extends HeadphonesConnectionState {
final BluetoothHeadphones placeholder;

class HeadphonesNotPaired extends HeadphonesConnectionState {}
const HeadphonesDisconnected(this.placeholder);
}

class HeadphonesDisconnected extends HeadphonesConnectionState {}
class HeadphonesConnecting extends HeadphonesConnectionState {
final BluetoothHeadphones placeholder;

class HeadphonesConnecting extends HeadphonesConnectionState {}
const HeadphonesConnecting(this.placeholder);
}

class HeadphonesConnectedOpen extends HeadphonesConnectionState {
final HeadphonesBase headphones;
final BluetoothHeadphones headphones;

HeadphonesConnectedOpen(this.headphones);
const HeadphonesConnectedOpen(this.headphones);
}

class HeadphonesConnectedClosed extends HeadphonesConnectionState {}
class HeadphonesConnectedClosed extends HeadphonesConnectionState {
final BluetoothHeadphones placeholder;

const HeadphonesConnectedClosed(this.placeholder);
}
7 changes: 4 additions & 3 deletions lib/headphones/cubit/headphones_mock_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@ import 'dart:async';

import 'package:flutter_bloc/flutter_bloc.dart';

import '../headphones_mocks.dart';
import '../huawei/freebuds4i_sim.dart';
import 'headphones_connection_cubit.dart';
import 'headphones_cubit_objects.dart';

class HeadphonesMockCubit extends Cubit<HeadphonesConnectionState>
implements HeadphonesConnectionCubit {
HeadphonesMockCubit() : super(HeadphonesDisconnected()) {
HeadphonesMockCubit()
: super(const HeadphonesDisconnected(HuaweiFreeBuds4iSimPlaceholder())) {
// i do this because otherwise initial data isn't even emitted and
// [BlocListener]s don't work >:(
Future.microtask(
() => emit(HeadphonesConnectedOpen(HeadphonesMockPrettyFake())));
() => emit(HeadphonesConnectedOpen(HuaweiFreeBuds4iSim())));
}

@override
Expand Down
13 changes: 13 additions & 0 deletions lib/headphones/framework/anc.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import 'package:rxdart/rxdart.dart';

abstract class Anc {
ValueStream<AncMode> get ancMode;

Future<void> setAncMode(AncMode mode);
}

enum AncMode {
noiseCancelling,
off,
transparency,
}
Loading

0 comments on commit df87f78

Please sign in to comment.