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
14 changes: 8 additions & 6 deletions docs/bluetooth-audio-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,11 +212,13 @@ The system identifies audio devices using:

### Button Control Support

Button events are monitored on these characteristic UUIDs:
Button events are routed by characteristic:

- `0000FE59-0000-1000-8000-00805F9B34FB` (Common button control)
- `0000180F-0000-1000-8000-00805F9B34FB` (Battery Service)
- `00001812-0000-1000-8000-00805F9B34FB` (HID Service)
- Vendor button characteristics for AINA, B01 Inrico and HYS headsets are parsed with each vendor's protocol. Once one is subscribed, every other characteristic on that headset is ignored for button input (e.g. the AINA event counter).
- `0000FE59-0000-1000-8000-00805F9B34FB` (Common button control) is parsed with the generic parser.
- Standard GATT telemetry is never treated as a button: Battery Level (`2A19`) and the HID characteristics (`2A4A`–`2A4D`). A battery level of 100% would otherwise decode as a mute press.

Characteristics whose notifications never arrive are read-polled as a fallback. Polling stops for a characteristic once it delivers a notification, and after three consecutive failed reads (e.g. write-only characteristics).

**Note**: Button control implementation varies by manufacturer and may require device-specific customization.

Expand Down Expand Up @@ -289,8 +291,8 @@ try {

### Android

- Requires `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT`, and `ACCESS_FINE_LOCATION` permissions
- Permissions are requested automatically by the service
- Android 12+ requires `BLUETOOTH_SCAN` and `BLUETOOTH_CONNECT`; Android 11 and lower require `ACCESS_FINE_LOCATION` for BLE scanning
- Permissions are requested automatically by the service, only when not already granted
- Some devices may require location services to be enabled

### iOS
Expand Down
103 changes: 103 additions & 0 deletions plugins/__tests__/register-android-package.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { registerAndroidPackage } from '../utils/register-android-package';

// Expo SDK 57 template (what `expo prebuild` generates today)
const applyTemplate = `package com.resgrid.unit.development

import android.app.Application

import com.facebook.react.PackageList
import com.facebook.react.ReactApplication

class MainApplication : Application(), ReactApplication {

override val reactHost: ReactHost by lazy {
ExpoReactHostFactory.getDefaultReactHost(
context = applicationContext,
packageList =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
}
)
}
}
`;

// Older template that declares the package list as a local
const valTemplate = `package com.example

import com.facebook.react.PackageList

class MainApplication : Application(), ReactApplication {
override fun getPackages(): List<ReactPackage> {
val packages = PackageList(this).packages
return packages
}
}
`;

const countOf = (contents: string, text: string) => contents.split(text).length - 1;

describe('registerAndroidPackage', () => {
it('adds the package inside the packages.apply block of the current Expo template', () => {
const result = registerAndroidPackage(applyTemplate, 'com.resgridunit', 'MediaButtonPackage');
const lines = result.split('\n');

expect(lines[1]).toBe('import com.resgridunit.MediaButtonPackage');
const applyLine = lines.findIndex((line) => line.includes('PackageList(this).packages.apply {'));
expect(lines[applyLine + 1]).toBe(' add(MediaButtonPackage())');
});

it('adds the package to a val packages list, making it mutable', () => {
const result = registerAndroidPackage(valTemplate, 'com.example', 'MediaButtonPackage');

expect(result).toContain('import com.example.MediaButtonPackage');
expect(result).toContain(' val packages = PackageList(this).packages.toMutableList()\n packages.add(MediaButtonPackage())');
});

it('registers several packages once each', () => {
for (const template of [applyTemplate, valTemplate]) {
const result = registerAndroidPackage(registerAndroidPackage(template, 'com.resgridunit', 'InCallAudioPackage'), 'com.resgridunit', 'MediaButtonPackage');

expect(countOf(result, 'add(InCallAudioPackage())')).toBe(1);
expect(countOf(result, 'add(MediaButtonPackage())')).toBe(1);
expect(countOf(result, 'toMutableList()')).toBeLessThanOrEqual(1);
}
});

it('leaves an already registered package unchanged across repeated prebuilds', () => {
const once = registerAndroidPackage(applyTemplate, 'com.resgridunit', 'MediaButtonPackage');
const twice = registerAndroidPackage(once, 'com.resgridunit', 'MediaButtonPackage');

expect(twice).toBe(once);
});

it('registers a package that is imported but not yet registered', () => {
const importedOnly = applyTemplate.replace('package com.resgrid.unit.development\n', 'package com.resgrid.unit.development\nimport com.resgridunit.InCallAudioPackage\n');

const result = registerAndroidPackage(importedOnly, 'com.resgridunit', 'InCallAudioPackage');

expect(countOf(result, 'import com.resgridunit.InCallAudioPackage')).toBe(1);
expect(result).toContain('add(InCallAudioPackage())');
});

it('registers a package whose import and registration are only present as comments', () => {
const commentedOut = applyTemplate
.replace('package com.resgrid.unit.development\n', 'package com.resgrid.unit.development\n// import com.resgridunit.MediaButtonPackage\n')
.replace('// add(MyReactNativePackage())', '// add(MediaButtonPackage())');

const result = registerAndroidPackage(commentedOut, 'com.resgridunit', 'MediaButtonPackage');
const lines = result.split('\n');

expect(lines[1]).toBe('import com.resgridunit.MediaButtonPackage');
const applyLine = lines.findIndex((line) => line.includes('PackageList(this).packages.apply {'));
expect(lines[applyLine + 1]).toBe(' add(MediaButtonPackage())');
expect(countOf(result, '// add(MediaButtonPackage())')).toBe(1);
});

it('throws when MainApplication.kt has no recognised package list', () => {
const unknown = 'package com.example\n\nclass MainApplication : Application()\n';

expect(() => registerAndroidPackage(unknown, 'com.example', 'MediaButtonPackage')).toThrow('Could not find where to register MediaButtonPackage in MainApplication.kt');
});
});
50 changes: 50 additions & 0 deletions plugins/utils/register-android-package.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/* eslint-env node */

/**
* Registers a manually linked ReactPackage in MainApplication.kt.
*
* The import uses the BASE package name (where our plugins write the native module files), not the
* variant-specific package of MainApplication.kt (e.g. `.development`).
*
* Handles both MainApplication shapes:
* - Current Expo templates: `PackageList(this).packages.apply { ... }` gets `add(Package())` inside the block
* - Older templates: `val packages = PackageList(this).packages` gets `packages.add(Package())`
*
* Throws when neither shape is found, so a template change fails the prebuild instead of shipping a
* build where the native module is silently missing.
*
* @param {string} contents - MainApplication.kt source
* @param {string} basePackageName - Package the native module files are written to
* @param {string} packageClass - ReactPackage class name, e.g. `MediaButtonPackage`
* @returns {string} Updated MainApplication.kt source
*/
function registerAndroidPackage(contents, basePackageName, packageClass) {
let result = contents;
// Line comments are stripped so a commented-out `// add(Package())` (like the template's example) doesn't count
const activeLines = () => result.split('\n').map((line) => line.replace(/\/\/.*$/, '').trim());

const importStatement = `import ${basePackageName}.${packageClass}`;
if (!activeLines().includes(importStatement)) {
result = result.replace(/^(package\s+[^\n]+\n)/, `$1${importStatement}\n`);
}

// Checked separately from the import: a MainApplication.kt that already imports the package
// without registering it must still get the registration
if (activeLines().some((line) => line.includes(`add(${packageClass}())`))) {
return result;
}

const applyPattern = /^([ \t]*)(.*PackageList\(this\)\.packages\.apply\s*\{)/m;
if (applyPattern.test(result)) {
return result.replace(applyPattern, (_match, indent, line) => `${indent}${line}\n${indent} add(${packageClass}())`);
}

const packagesPattern = /^([ \t]*)val packages = PackageList\(this\)\.packages(\.toMutableList\(\))?/m;
if (packagesPattern.test(result)) {
return result.replace(packagesPattern, (_match, indent) => `${indent}val packages = PackageList(this).packages.toMutableList()\n${indent}packages.add(${packageClass}())`);
}

throw new Error(`Could not find where to register ${packageClass} in MainApplication.kt`);
}

module.exports = { registerAndroidPackage };
34 changes: 7 additions & 27 deletions plugins/withInCallAudioModule.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ const { withDangerousMod, withMainApplication } = require('expo/config-plugins')
const fs = require('fs');
const path = require('path');

const { registerAndroidPackage } = require('./utils/register-android-package');

/**
* Android InCallAudioModule.kt content
* Uses SoundPool to play sounds on the VOICE_COMMUNICATION stream.
Expand Down Expand Up @@ -277,34 +279,12 @@ const withInCallAudioModule = (config) => {

// 3. Register Package in MainApplication.kt
config = withMainApplication(config, (config) => {
const mainApplication = config.modResults;
const projectRoot = config.modRequest.projectRoot;

if (!mainApplication.contents.includes('InCallAudioPackage')) {
const basePackageName = resolveBasePackageName(projectRoot);
const importStatement = `import ${basePackageName}.InCallAudioPackage`;

if (!mainApplication.contents.includes(importStatement)) {
mainApplication.contents = mainApplication.contents.replace(/^(package\s+[^\n]+\n)/, `$1${importStatement}\n`);
}

const packagesPattern = /val packages = PackageList\(this\)\.packages(\.toMutableList\(\))?/;
const packagesMatch = mainApplication.contents.match(packagesPattern);
const basePackageName = resolveBasePackageName(config.modRequest.projectRoot);
const contents = config.modResults.contents;

if (packagesMatch) {
// Using the simplest replacement that ensures toMutableList()
const replacement = `val packages = PackageList(this).packages.toMutableList()\n packages.add(InCallAudioPackage())`;

// Avoid double adding if MediaButtonPackage logic already changed it to mutable
if (mainApplication.contents.includes('packages.add(MediaButtonPackage()')) {
// Add ours after MediaButtonPackage
mainApplication.contents = mainApplication.contents.replace('packages.add(MediaButtonPackage())', 'packages.add(MediaButtonPackage())\n packages.add(InCallAudioPackage())');
} else {
// Standard replacement
mainApplication.contents = mainApplication.contents.replace(packagesPattern, replacement);
}
console.log('[withInCallAudioModule] Registered InCallAudioPackage in MainApplication.kt');
}
config.modResults.contents = registerAndroidPackage(contents, basePackageName, 'InCallAudioPackage');
if (config.modResults.contents !== contents) {
console.log('[withInCallAudioModule] Registered InCallAudioPackage in MainApplication.kt');
}

return config;
Expand Down
38 changes: 10 additions & 28 deletions plugins/withMediaButtonModule.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ const { withDangerousMod, withMainApplication } = require('expo/config-plugins')
const fs = require('fs');
const path = require('path');

const { registerAndroidPackage } = require('./utils/register-android-package');

/**
* iOS MediaButtonModule.swift content
*/
Expand Down Expand Up @@ -505,34 +507,14 @@ const withMediaButtonModule = (config) => {

// Update MainApplication.kt to register the package
config = withMainApplication(config, (config) => {
const mainApplication = config.modResults;
const projectRoot = config.modRequest.projectRoot;

// Check if MediaButtonPackage is already imported/added
if (!mainApplication.contents.includes('MediaButtonPackage')) {
// Resolve the BASE package name from build.gradle or build.gradle.kts
// This is where the native module files are actually created
const basePackageName = resolveBasePackageName(projectRoot);

// Add import statement using the BASE package name (not the variant-specific package)
// The native module files are created in the base package, not in variant packages like 'development'
const importStatement = `import ${basePackageName}.MediaButtonPackage`;
if (!mainApplication.contents.includes(importStatement)) {
// Add import after the package declaration line
mainApplication.contents = mainApplication.contents.replace(/^(package\s+[^\n]+\n)/, `$1${importStatement}\n`);
console.log(`[withMediaButtonModule] Added MediaButtonPackage import from base package: ${basePackageName}`);
}

// Add the package to getPackages()
// Find the packages list and add our package
const packagesPattern = /val packages = PackageList\(this\)\.packages(\.toMutableList\(\))?/;
const packagesMatch = mainApplication.contents.match(packagesPattern);

if (packagesMatch) {
// Replace the packages declaration, ensuring toMutableList() is present so we can add our package
mainApplication.contents = mainApplication.contents.replace(packagesPattern, `val packages = PackageList(this).packages.toMutableList()\n packages.add(MediaButtonPackage())`);
console.log('[withMediaButtonModule] Registered MediaButtonPackage in MainApplication.kt');
}
// Resolve the BASE package name from build.gradle or build.gradle.kts
// This is where the native module files are actually created
const basePackageName = resolveBasePackageName(config.modRequest.projectRoot);
const contents = config.modResults.contents;

config.modResults.contents = registerAndroidPackage(contents, basePackageName, 'MediaButtonPackage');
if (config.modResults.contents !== contents) {
console.log(`[withMediaButtonModule] Registered MediaButtonPackage from base package ${basePackageName} in MainApplication.kt`);
}

return config;
Expand Down
8 changes: 2 additions & 6 deletions src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,8 @@ import { loadKeepAliveState } from '@/lib/hooks/use-keep-alive';
import { loadSelectedTheme, useSelectedTheme } from '@/lib/hooks/use-selected-theme';
import { logger } from '@/lib/logging';
import { registerNavigationReadyCheck } from '@/lib/navigation';
import { getDeviceUuid, setDeviceUuid } from '@/lib/storage/app';
import { getOrCreateDeviceUuid } from '@/lib/storage/app';
import { loadBackgroundGeolocationState } from '@/lib/storage/background-geolocation';
import { uuidv4 } from '@/lib/utils';
import { appInitializationService } from '@/services/app-initialization.service';

// A web sign-in popup returns to the page its redirect names (/auth/callback), and a production web build loads a route's
Expand Down Expand Up @@ -117,10 +116,7 @@ SplashScreen.setOptions({
fade: true,
});

const deviceUuid = getDeviceUuid();
if (!deviceUuid) {
setDeviceUuid(uuidv4());
}
getOrCreateDeviceUuid();

LogBox.ignoreLogs([
//Mapbox errors
Expand Down
25 changes: 25 additions & 0 deletions src/components/settings/__tests__/server-url-bottom-sheet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,31 @@ describe('ServerUrlBottomSheet', () => {
unmount();
});

it('ends the session on the old server before the new url applies', async () => {
// Sign-out still talks to the server the session belongs to (taking the device's push token off it).
const order: string[] = [];
mockOnUrlChanged.mockImplementationOnce(async () => {
order.push('signOut');
});
mockSetUrl.mockImplementationOnce(async () => {
order.push('setUrl');
});

const { unmount } = render(<ServerUrlBottomSheet {...defaultProps} />);

await waitFor(() => {
expect(screen.getByTestId('select-item-EU-Central')).toBeTruthy();
});

fireEvent.press(screen.getByTestId('select-item-EU-Central'));
fireEvent.press(screen.getByText('Save'));

await waitFor(() => {
expect(order).toEqual(['signOut', 'setUrl']);
});
unmount();
});

it('does not report a change when the same server is saved again', async () => {
mockGetUrl.mockResolvedValue('https://api.resgrid.com/api/v4');

Expand Down
9 changes: 6 additions & 3 deletions src/components/settings/server-url-bottom-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ interface ServerUrlForm {
interface ServerUrlBottomSheetProps {
isOpen: boolean;
onClose: () => void;
/** Called after a save that actually switched servers (e.g. to log out of the old one). */
/** Called when a save is about to switch servers, before the new URL applies (e.g. to log out of the old one). */
onUrlChanged?: () => Promise<void>;
}

Expand Down Expand Up @@ -142,12 +142,15 @@ export function ServerUrlBottomSheet({ isOpen, onClose, onUrlChanged }: ServerUr
const nextApiUrl = buildApiUrl(resolvedBaseUrl);
const previousApiUrl = buildApiUrl(await getUrl());

await setUrl(nextApiUrl);

// End the session on the server it belongs to before pointing the app at the new one. Signing out still talks to
// the old server (taking this device's web push token off it); with the URL switched first, those calls went to
// the new server carrying the old server's token.
if (nextApiUrl !== previousApiUrl && onUrlChanged) {
await onUrlChanged();
}

await setUrl(nextApiUrl);

logger.info({
message: 'Server URL updated successfully',
context: { url: nextApiUrl },
Expand Down
Loading
Loading