Skip to content
Merged
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
8 changes: 7 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,13 @@ jobs:
# hence the long timeout.
macos-apk:
runs-on: macos-15-intel
timeout-minutes: 120
# 180 rather than 120: cross packages missing from
# nix-cache.jappie.me get built inside the TCG-emulated VM on
# every run (its store is ephemeral, the host cache cannot help),
# and the new uuid-types closure (PR #239) blew the 120-minute
# budget at exactly 2h00m (run 29272233710). Once the cache
# carries the closure this job returns to ~45 minutes.
timeout-minutes: 180
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
Expand Down
19 changes: 19 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@

### Added

- Scan results now carry the advertisement's service data and
manufacturer data (issue #238): `BleScanResult` gained
`bsrAdvertisement :: Either BleAdvertisementWithErrors
BleAdvertisement` (the Left carries every defect found alongside
the salvaged partial advertisement, since AD structures are
independent), parsed by the new `Hatter.BleAdvertisement`
module (re-exported from `Hatter.Ble`) with `serviceDataForUuid`
for keyed lookup; service data is keyed by the binary `UUID` from
`uuid-types` (constants build with the total `fromWords`), so
platform case differences are unrepresentable. Malformed advertisements report
every defect with its byte offset instead of being silently
dropped, and the scan dispatch logs them while still delivering
the result. UUID rendering uses the `uuid-types` package (new
dependency, also in the cross builds). Android passes `ScanRecord.getBytes()` through
the bridge; iOS re-encodes CoreBluetooth's parsed dictionary into
the same AD structure format. This unblocks identifying devices
that only advertise service data (e.g. KBeacons' 0x2080) or
manufacturer data (iBeacon) without connecting to them.

- `Hatter.Ble` GATT operations, completing the core of issue #108 and
unblocking the kbeacon OTA tool: `discoverBleServices`,
`readBleCharacteristic`, `writeBleCharacteristic` (with or without
Expand Down
9 changes: 7 additions & 2 deletions android/java/me/jappie/hatter/HatterActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ public class HatterActivity extends Activity implements View.OnClickListener {
private native void onLifecycleLowMemory();
private native void onPermissionResult(int requestCode, int statusCode);
private native void onSecureStorageResult(int requestId, int statusCode, String value);
private native void onBleScanResult(String deviceName, String deviceAddress, int rssi);
private native void onBleScanResult(String deviceName, String deviceAddress, int rssi,
byte[] advertisement);
private native void onBleConnectionEvent(int event);
private native void onBleCharacteristicDiscovered(String serviceUuid,
String characteristicUuid,
Expand Down Expand Up @@ -309,8 +310,12 @@ public void onScanResult(int callbackType, ScanResult result) {
String name = record != null ? record.getDeviceName() : null;
String address = result.getDevice().getAddress();
int rssi = result.getRssi();
// The raw AD structures (advertisement + scan response as
// received); Haskell parses service data and manufacturer
// data out of them, see Hatter.BleAdvertisement.
byte[] advertisement = record != null ? record.getBytes() : null;
bleScannedDevices.put(address, result.getDevice());
onBleScanResult(name, address, rssi);
onBleScanResult(name, address, rssi, advertisement);
}
};

Expand Down
20 changes: 16 additions & 4 deletions cbits/ble_bridge_android.c
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)

/* Haskell FFI exports (dispatch results back to Haskell callbacks) */
extern void haskellOnBleScanResult(void *ctx, const char *name, const char *address, int32_t rssi);
extern void haskellOnBleScanResult(void *ctx, const char *name, const char *address, int32_t rssi,
const uint8_t *advertisement, int32_t advertisement_length);
extern void haskellOnBleConnectionEvent(void *ctx, int32_t event);
extern void haskellOnBleCharacteristicDiscovered(void *ctx, const char *serviceUuid,
const char *characteristicUuid,
Expand Down Expand Up @@ -350,23 +351,34 @@ void setup_android_ble_bridge(JNIEnv *env, jobject activity, void *haskellCtx)
/* ---- JNI callback from Java BLE scan result ---- */

JNIEXPORT void JNICALL
JNI_METHOD(onBleScanResult)(JNIEnv *env, jobject thiz, jstring jname, jstring jaddr, jint rssi)
JNI_METHOD(onBleScanResult)(JNIEnv *env, jobject thiz, jstring jname, jstring jaddr, jint rssi,
jbyteArray jadvertisement)
{
g_env = env;

const char *cname = NULL;
const char *caddr = NULL;
jbyte *advertisement = NULL;
jsize advertisement_length = 0;

if (jname) {
cname = (*env)->GetStringUTFChars(env, jname, NULL);
}
if (jaddr) {
caddr = (*env)->GetStringUTFChars(env, jaddr, NULL);
}
if (jadvertisement) {
advertisement_length = (*env)->GetArrayLength(env, jadvertisement);
advertisement = (*env)->GetByteArrayElements(env, jadvertisement, NULL);
}

LOGI("onBleScanResult(name=%s, addr=%s, rssi=%d)", cname ? cname : "(null)", caddr ? caddr : "(null)", rssi);
haskellOnBleScanResult(g_haskell_ctx, cname, caddr, (int32_t)rssi);
LOGI("onBleScanResult(name=%s, addr=%s, rssi=%d, advLen=%d)", cname ? cname : "(null)", caddr ? caddr : "(null)", rssi, (int)advertisement_length);
haskellOnBleScanResult(g_haskell_ctx, cname, caddr, (int32_t)rssi,
(const uint8_t *)advertisement, (int32_t)advertisement_length);

if (advertisement) {
(*env)->ReleaseByteArrayElements(env, jadvertisement, advertisement, JNI_ABORT);
}
if (cname) {
(*env)->ReleaseStringUTFChars(env, jname, cname);
}
Expand Down
3 changes: 2 additions & 1 deletion cbits/jni_bridge.c
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ extern void haskellOnUITextChange(void *ctx, int callbackId, const char *text);
extern void haskellOnPermissionResult(void *ctx, int32_t requestId, int32_t statusCode);
extern void haskellOnSecureStorageResult(void *ctx, int32_t requestId,
int32_t statusCode, const char *value);
extern void haskellOnBleScanResult(void *ctx, const char *name, const char *address, int32_t rssi);
extern void haskellOnBleScanResult(void *ctx, const char *name, const char *address, int32_t rssi,
const uint8_t *advertisement, int32_t advertisement_length);
extern void haskellOnDialogResult(void *ctx, int32_t requestId, int32_t actionCode);
extern void haskellOnLocationUpdate(void *ctx, double lat, double lon, double alt, double acc);
extern void haskellOnAuthSessionResult(void *ctx, int32_t requestId,
Expand Down
7 changes: 5 additions & 2 deletions hatter.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ library
Hatter.Permission
Hatter.SecureStorage
Hatter.Ble
Hatter.BleAdvertisement
Hatter.Dialog
Hatter.Location
Hatter.AuthSession
Expand All @@ -120,7 +121,8 @@ library
bytestring < 1,
transformers < 0.7,
time,
unwitch >= 3.0.0 && < 4
unwitch >= 3.0.0 && < 4,
uuid-types >= 1.0.4 && < 2
c-sources:
cbits/android_stubs.c
cbits/platform_log.c
Expand Down Expand Up @@ -253,4 +255,5 @@ test-suite unit
bytestring,
directory,
filepath,
unwitch >= 3.0.0 && < 4
unwitch >= 3.0.0 && < 4,
uuid-types >= 1.0.4 && < 2
3 changes: 2 additions & 1 deletion include/Hatter.h
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ void haskellOnSecureStorageResult(void *ctx, int32_t requestId,
* address: device address string.
* rssi: received signal strength indicator.
* ctx must be a pointer returned by haskellRunMain(). */
void haskellOnBleScanResult(void *ctx, const char *name, const char *address, int32_t rssi);
void haskellOnBleScanResult(void *ctx, const char *name, const char *address, int32_t rssi,
const uint8_t *advertisement, int32_t advertisement_length);

/* Dispatch a BLE connection event from native code back to Haskell.
* event: one of the BLE_CONNECTION_* codes from BleBridge.h.
Expand Down
63 changes: 61 additions & 2 deletions ios/Hatter/BleBridgeIOS.m
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
#define LOGE(fmt, ...) os_log_error(g_log, fmt, ##__VA_ARGS__)

/* Haskell FFI exports (dispatch results back to Haskell callbacks) */
extern void haskellOnBleScanResult(void *ctx, const char *name, const char *address, int32_t rssi);
extern void haskellOnBleScanResult(void *ctx, const char *name, const char *address, int32_t rssi,
const uint8_t *advertisement, int32_t advertisement_length);
extern void haskellOnBleConnectionEvent(void *ctx, int32_t event);
extern void haskellOnBleCharacteristicDiscovered(void *ctx, const char *serviceUuid,
const char *characteristicUuid,
Expand Down Expand Up @@ -94,6 +95,61 @@ - (void)centralManagerDidUpdateState:(CBCentralManager *)central {
}
}

/* Re-encode CoreBluetooth's parsed advertisement dictionary into the
* raw AD structure format (length : type : payload) that Android
* delivers as-is, so the Haskell side (Hatter.BleAdvertisement) has a
* single decoding path for both platforms. CoreBluetooth never
* exposes the original bytes, only parsed fields; the two fields
* Haskell surfaces are encoded back: service data (AD 0x16/0x20/0x21
* by UUID width, UUID little-endian like on air) and manufacturer
* data (AD 0xFF, whose NSData already starts with the little-endian
* company id). */
static NSData *encodeAdvertisementData(NSDictionary<NSString *, id> *advertisementData)
{
NSMutableData *encoded = [NSMutableData data];

NSDictionary<CBUUID *, NSData *> *serviceData =
advertisementData[CBAdvertisementDataServiceDataKey];
for (CBUUID *uuid in serviceData) {
NSData *payload = serviceData[uuid];
NSData *uuidBytes = uuid.data; /* big-endian */
uint8_t adType;
if (uuidBytes.length == 2) {
adType = 0x16;
} else if (uuidBytes.length == 4) {
adType = 0x20;
} else if (uuidBytes.length == 16) {
adType = 0x21;
} else {
continue;
}
NSUInteger bodyLength = 1 + uuidBytes.length + payload.length;
if (bodyLength > 0xFF) {
continue;
}
uint8_t lengthByte = (uint8_t)bodyLength;
[encoded appendBytes:&lengthByte length:1];
[encoded appendBytes:&adType length:1];
const uint8_t *bigEndian = uuidBytes.bytes;
for (NSUInteger i = uuidBytes.length; i > 0; i--) {
[encoded appendBytes:&bigEndian[i - 1] length:1];
}
[encoded appendData:payload];
}

NSData *manufacturerData =
advertisementData[CBAdvertisementDataManufacturerDataKey];
if (manufacturerData.length >= 2 && manufacturerData.length + 1 <= 0xFF) {
uint8_t lengthByte = (uint8_t)(manufacturerData.length + 1);
uint8_t adType = 0xFF;
[encoded appendBytes:&lengthByte length:1];
[encoded appendBytes:&adType length:1];
[encoded appendData:manufacturerData];
}

return encoded;
}

- (void)centralManager:(CBCentralManager *)central
didDiscoverPeripheral:(CBPeripheral *)peripheral
advertisementData:(NSDictionary<NSString *, id> *)advertisementData
Expand All @@ -104,9 +160,12 @@ - (void)centralManager:(CBCentralManager *)central
const char *name = peripheral.name ? [peripheral.name UTF8String] : NULL;
const char *address = [identifier UTF8String];
int32_t rssi = [RSSI intValue];
NSData *advertisement = encodeAdvertisementData(advertisementData);

dispatch_async(dispatch_get_main_queue(), ^{
haskellOnBleScanResult(self.haskellCtx, name, address, rssi);
haskellOnBleScanResult(self.haskellCtx, name, address, rssi,
advertisement.length ? (const uint8_t *)advertisement.bytes : NULL,
(int32_t)advertisement.length);
});
}

Expand Down
11 changes: 9 additions & 2 deletions nix/collect-deps.nix
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,20 @@ in pkgs.runCommand "hatter-collected-deps" {
for pkg in ${depsList}; do
echo "Processing: $pkg"

# Copy .conf files, skipping benchmark/test sub-libraries
# Copy .conf files, skipping benchmark/test sub-libraries.
# Skip .confs already collected: a package can be listed twice
# (once through a consumer's resolved deps and once through
# hatterOwnDeps), and the first copy is read-only, so copying the
# same .conf again fails with "Permission denied".
for conf in $(find "$pkg" -name "*.conf" -path "*/package.conf.d/*"); do
LIB_NAME=$(grep '^lib-name:' "$conf" | sed 's/^lib-name: *//' || true)
case "$LIB_NAME" in
*benchmark*|*test*) echo " skip sub-lib: $LIB_NAME"; continue ;;
esac
cp "$conf" $out/pkgdb/
confName=$(basename "$conf")
if [ ! -f "$out/pkgdb/$confName" ]; then
cp "$conf" $out/pkgdb/
fi
done

# Copy all non-profiling .a files from this package, skipping
Expand Down
12 changes: 10 additions & 2 deletions nix/cross-deps.nix
Original file line number Diff line number Diff line change
Expand Up @@ -293,8 +293,16 @@ WRAPPER
hatterDep = if hatterSrc != null then [ crossHaskellPkgs.hatter ] else [];

# Hatter's own non-boot dependencies — must be collected so hatter's
# .conf can resolve them (collect-deps doesn't follow propagatedBuildInputs).
hatterOwnDeps = [ crossHaskellPkgs.unwitch ];
# .conf can resolve them (collect-deps doesn't follow
# propagatedBuildInputs), including uuid-types' transitive non-boot
# dependencies for the same reason.
hatterOwnDeps = [
crossHaskellPkgs.unwitch
crossHaskellPkgs.uuid-types
crossHaskellPkgs.hashable
crossHaskellPkgs.random
crossHaskellPkgs.splitmix
];

in import ./collect-deps.nix {
inherit pkgs ghc ghcPkgCmd;
Expand Down
8 changes: 7 additions & 1 deletion nix/ios-deps.nix
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,13 @@ let

# Hatter's own non-boot dependencies — always included so mkIOSLib's
# raw GHC invocation can find them even without a consumer cabal file.
hatterOwnDeps = [ nativeHaskellPkgs.unwitch ];
hatterOwnDeps = [
nativeHaskellPkgs.unwitch
nativeHaskellPkgs.uuid-types
nativeHaskellPkgs.hashable
nativeHaskellPkgs.random
nativeHaskellPkgs.splitmix
];

in import ./collect-deps.nix {
inherit pkgs ghc ghcPkgCmd;
Expand Down
10 changes: 6 additions & 4 deletions src/Hatter.hs
Original file line number Diff line number Diff line change
Expand Up @@ -366,13 +366,15 @@ foreign export ccall haskellOnPermissionResult :: Ptr AppContext -> CInt -> CInt

-- | Handle a BLE scan result from native code. Dispatches to the
-- callback registered by 'Hatter.Ble.startBleScan'.
haskellOnBleScanResult :: Ptr AppContext -> CString -> CString -> CInt -> IO ()
haskellOnBleScanResult ctxPtr cName cAddr cRssi =
haskellOnBleScanResult
:: Ptr AppContext -> CString -> CString -> CInt -> Ptr Word8 -> CInt -> IO ()
haskellOnBleScanResult ctxPtr cName cAddr cRssi advPtr advLength =
withExceptionHandler ctxPtr $ do
appCtx <- derefAppContext ctxPtr
dispatchBleScanResult (acBleState appCtx) cName cAddr cRssi
dispatchBleScanResult (acBleState appCtx) cName cAddr cRssi advPtr advLength

foreign export ccall haskellOnBleScanResult :: Ptr AppContext -> CString -> CString -> CInt -> IO ()
foreign export ccall haskellOnBleScanResult
:: Ptr AppContext -> CString -> CString -> CInt -> Ptr Word8 -> CInt -> IO ()

-- | Handle a BLE connection event from native code. Decodes the C
-- event code here at the FFI boundary so 'dispatchBleConnectionEvent'
Expand Down
Loading
Loading