diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 45bb2e46..71687992 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -48,6 +48,7 @@ Best Practices: - Handle errors gracefully and provide user feedback. - Implement proper offline support. - Ensure the user interface is intuitive and user-friendly and works seamlessly across different devices and screen sizes. +- This is an expo managed project that uses prebuild, do not make native code changes outside of expo prebuild capabilities. Additional Rules: diff --git a/app.config.ts b/app.config.ts index 19c2a102..da83d6c2 100644 --- a/app.config.ts +++ b/app.config.ts @@ -207,6 +207,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ './customManifest.plugin.js', './plugins/withForegroundNotifications.js', './plugins/withNotificationSounds.js', + './plugins/withMediaButtonModule.js', ['app-icon-badge', appIconBadgeConfig], ], extra: { diff --git a/docs/airpods-ptt-support.md b/docs/airpods-ptt-support.md new file mode 100644 index 00000000..6c73c900 --- /dev/null +++ b/docs/airpods-ptt-support.md @@ -0,0 +1,183 @@ +# AirPods/Bluetooth Earbuds PTT Support + +This document describes the implementation of Push-to-Talk (PTT) support for AirPods and other standard Bluetooth earbuds in the Resgrid Unit app. + +## Overview + +The implementation adds support for using media button presses from AirPods, Galaxy Buds, and other Bluetooth earbuds to control the microphone mute/unmute state during LiveKit voice calls. + +## Architecture + +### Components + +1. **MediaButtonService** (`src/services/media-button.service.ts`) + - Singleton service that manages media button event listeners + - Handles double-tap detection + - Provides PTT toggle/push-to-talk modes + - Integrates with LiveKit for microphone control + +2. **Native Modules** + - **iOS**: `MediaButtonModule.swift` - Uses `MPRemoteCommandCenter` to capture media control events + - **Android**: `MediaButtonModule.kt` - Uses `MediaSession` to capture media button events + +3. **Store Updates** (`src/stores/app/bluetooth-audio-store.ts`) + - Added `MediaButtonPTTSettings` interface + - Added settings management actions + +4. **LiveKit Integration** (`src/stores/app/livekit-store.ts`) + - Initializes media button service when connecting to a room + - Cleans up service when disconnecting + +## How It Works + +### iOS (AirPods) + +1. When a LiveKit room is connected, the `MediaButtonModule` sets up `MPRemoteCommandCenter` listeners +2. Play/Pause button presses on AirPods trigger the `togglePlayPauseCommand` +3. The event is sent to JavaScript via `NativeEventEmitter` +4. `MediaButtonService` processes the event and toggles the microphone state + +### Android (Bluetooth Earbuds) + +1. When a LiveKit room is connected, the `MediaButtonModule` creates a `MediaSession` +2. Button presses are captured via the `MediaSession.Callback` +3. The event is sent to JavaScript via `DeviceEventManagerModule` +4. `MediaButtonService` processes the event and toggles the microphone state + +## PTT Modes + +### Toggle Mode (Default) +- Single press toggles between muted and unmuted states +- Best for hands-free operation + +### Push-to-Talk Mode +- Press and hold to unmute +- Release to mute +- Better for traditional radio-style communication + +## Settings + +The `MediaButtonPTTSettings` interface provides the following configuration: + +```typescript +interface MediaButtonPTTSettings { + enabled: boolean; // Enable/disable media button PTT + pttMode: 'toggle' | 'push_to_talk'; + usePlayPauseForPTT: boolean; // Use play/pause button for PTT + doubleTapAction: 'none' | 'toggle_mute'; + doubleTapTimeoutMs: number; // Default: 400ms +} +``` + +## Usage + +### Enabling/Disabling +```typescript +import { useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store'; + +// Enable media button PTT +useBluetoothAudioStore.getState().setMediaButtonPTTEnabled(true); + +// Update settings +useBluetoothAudioStore.getState().setMediaButtonPTTSettings({ + pttMode: 'push_to_talk', + doubleTapAction: 'toggle_mute', +}); +``` + +### Manual Control (Advanced) +```typescript +import { mediaButtonService } from '@/services/media-button.service'; + +// Enable microphone +await mediaButtonService.enableMicrophone(); + +// Disable microphone +await mediaButtonService.disableMicrophone(); + +// Update settings +mediaButtonService.updateSettings({ + pttMode: 'toggle', +}); +``` + +## Audio Feedback + +The service provides audio feedback for PTT actions: +- `playStartTransmittingSound()` - Played when microphone is enabled +- `playStopTransmittingSound()` - Played when microphone is disabled + +## Supported Devices + +### Tested +- Apple AirPods (all generations) +- Apple AirPods Pro +- Apple AirPods Max + +### Expected to Work +- Samsung Galaxy Buds +- Sony WF/WH series +- Jabra Elite series +- Any Bluetooth earbuds with media control buttons + +## Limitations + +1. **Background Mode**: iOS requires CallKeep to be active for background audio support +2. **Button Mapping**: Some earbuds may have non-standard button mappings +3. **Double-Tap Detection**: Natural double-tap gestures on AirPods may conflict with the double-tap PTT action + +## Troubleshooting + +### Media buttons not working + +1. Ensure Bluetooth is connected and the earbuds are the active audio device +2. Check that `mediaButtonPTTSettings.enabled` is `true` +3. On iOS, ensure the app has audio session properly configured +4. On Android, check that no other app is capturing media button events + +### Delays in response + +- Adjust `doubleTapTimeoutMs` to a lower value if not using double-tap feature +- Set `doubleTapAction` to `'none'` for immediate response + +## Files Modified/Created + +### New Files +- `src/services/media-button.service.ts` - Main TypeScript service +- `src/services/__tests__/media-button.service.test.ts` - Tests +- `plugins/withMediaButtonModule.js` - Expo config plugin (generates native modules during prebuild) +- `docs/airpods-ptt-support.md` - This documentation + +### Modified Files +- `src/stores/app/bluetooth-audio-store.ts` - Added media button settings +- `src/stores/app/livekit-store.ts` - Integration with room connection/disconnection +- `app.config.ts` - Added config plugin reference + +### Generated During Prebuild (via config plugin) +The following native files are generated automatically by `withMediaButtonModule.js` during `expo prebuild`: + +**iOS:** +- `ios/ResgridUnit/MediaButtonModule.swift` - iOS native module using MPRemoteCommandCenter +- `ios/ResgridUnit/MediaButtonModule.m` - Objective-C bridge file +- Updates `ResgridUnit-Bridging-Header.h` with required React Native imports + +**Android:** +- `android/app/src/main/java/{package}/MediaButtonModule.kt` - Android native module using MediaSession +- `android/app/src/main/java/{package}/MediaButtonPackage.kt` - React Native package registration +- Updates `MainApplication.kt` to register the MediaButtonPackage + +## Build Instructions + +Since this project uses Expo with prebuild, the native modules are generated automatically: + +```bash +# Clean and regenerate native projects +npx expo prebuild --clean + +# Or for specific platform +npx expo prebuild --platform ios --clean +npx expo prebuild --platform android --clean + +# Then build normally +yarn ios # or yarn android +``` diff --git a/env.js b/env.js index ebe1e8d9..6996dc84 100644 --- a/env.js +++ b/env.js @@ -88,7 +88,6 @@ const client = z.object({ LOGGING_KEY: z.string(), APP_KEY: z.string(), UNIT_MAPBOX_PUBKEY: z.string(), - UNIT_MAPBOX_DLKEY: z.string(), IS_MOBILE_APP: z.boolean(), SENTRY_DSN: z.string(), COUNTLY_APP_KEY: z.string(), @@ -123,7 +122,6 @@ const _clientEnv = { APP_KEY: process.env.UNIT_APP_KEY || '', IS_MOBILE_APP: true, // or whatever default you want UNIT_MAPBOX_PUBKEY: process.env.UNIT_MAPBOX_PUBKEY || '', - UNIT_MAPBOX_DLKEY: process.env.UNIT_MAPBOX_DLKEY || '', SENTRY_DSN: process.env.UNIT_SENTRY_DSN || '', COUNTLY_APP_KEY: process.env.UNIT_COUNTLY_APP_KEY || '', COUNTLY_SERVER_URL: process.env.UNIT_COUNTLY_SERVER_URL || '', diff --git a/plugins/withMediaButtonModule.js b/plugins/withMediaButtonModule.js new file mode 100644 index 00000000..3e1f72d1 --- /dev/null +++ b/plugins/withMediaButtonModule.js @@ -0,0 +1,502 @@ +const { withDangerousMod, withMainApplication } = require('@expo/config-plugins'); +const fs = require('fs'); +const path = require('path'); + +/** + * iOS MediaButtonModule.swift content + */ +const IOS_SWIFT_MODULE = `import Foundation +import MediaPlayer +import React + +@objc(MediaButtonModule) +class MediaButtonModule: RCTEventEmitter { + + private var hasListeners = false + private var commandCenter: MPRemoteCommandCenter? + + override init() { + super.init() + commandCenter = MPRemoteCommandCenter.shared() + } + + override static func moduleName() -> String! { + return "MediaButtonModule" + } + + override static func requiresMainQueueSetup() -> Bool { + return true + } + + override func supportedEvents() -> [String]! { + return [ + "onMediaButtonPlayPause", + "onMediaButtonPlay", + "onMediaButtonPause", + "onMediaButtonToggle", + "onMediaButtonNext", + "onMediaButtonPrevious" + ] + } + + override func startObserving() { + hasListeners = true + } + + override func stopObserving() { + hasListeners = false + } + + @objc + func startListening() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + // Setup play/pause toggle command (primary PTT trigger) + self.commandCenter?.togglePlayPauseCommand.isEnabled = true + self.commandCenter?.togglePlayPauseCommand.addTarget { [weak self] event in + self?.sendEventIfListening("onMediaButtonToggle", body: ["timestamp": Date().timeIntervalSince1970 * 1000]) + return .success + } + + // Setup play command + self.commandCenter?.playCommand.isEnabled = true + self.commandCenter?.playCommand.addTarget { [weak self] event in + self?.sendEventIfListening("onMediaButtonPlay", body: ["timestamp": Date().timeIntervalSince1970 * 1000]) + return .success + } + + // Setup pause command + self.commandCenter?.pauseCommand.isEnabled = true + self.commandCenter?.pauseCommand.addTarget { [weak self] event in + self?.sendEventIfListening("onMediaButtonPause", body: ["timestamp": Date().timeIntervalSince1970 * 1000]) + return .success + } + + // Setup next track command (optional - can be used for other PTT actions) + self.commandCenter?.nextTrackCommand.isEnabled = true + self.commandCenter?.nextTrackCommand.addTarget { [weak self] event in + self?.sendEventIfListening("onMediaButtonNext", body: ["timestamp": Date().timeIntervalSince1970 * 1000]) + return .success + } + + // Setup previous track command (optional) + self.commandCenter?.previousTrackCommand.isEnabled = true + self.commandCenter?.previousTrackCommand.addTarget { [weak self] event in + self?.sendEventIfListening("onMediaButtonPrevious", body: ["timestamp": Date().timeIntervalSince1970 * 1000]) + return .success + } + + // Setup now playing info to enable media controls + var nowPlayingInfo = [String: Any]() + nowPlayingInfo[MPMediaItemPropertyTitle] = "PTT Active" + nowPlayingInfo[MPMediaItemPropertyArtist] = "Resgrid" + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 1.0 + nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = 0.0 + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo + + print("[MediaButtonModule] Started listening for media button events") + } + } + + @objc + func stopListening() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + self.commandCenter?.togglePlayPauseCommand.removeTarget(nil) + self.commandCenter?.playCommand.removeTarget(nil) + self.commandCenter?.pauseCommand.removeTarget(nil) + self.commandCenter?.nextTrackCommand.removeTarget(nil) + self.commandCenter?.previousTrackCommand.removeTarget(nil) + + self.commandCenter?.togglePlayPauseCommand.isEnabled = false + self.commandCenter?.playCommand.isEnabled = false + self.commandCenter?.pauseCommand.isEnabled = false + self.commandCenter?.nextTrackCommand.isEnabled = false + self.commandCenter?.previousTrackCommand.isEnabled = false + + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + + print("[MediaButtonModule] Stopped listening for media button events") + } + } + + private func sendEventIfListening(_ eventName: String, body: [String: Any]?) { + guard hasListeners else { return } + sendEvent(withName: eventName, body: body) + } +} +`; + +/** + * iOS MediaButtonModule.m (Objective-C bridge) content + */ +const IOS_OBJC_BRIDGE = `#import +#import + +@interface RCT_EXTERN_MODULE(MediaButtonModule, RCTEventEmitter) + +RCT_EXTERN_METHOD(startListening) +RCT_EXTERN_METHOD(stopListening) + +@end +`; + +/** + * Android MediaButtonModule.kt content + */ +const ANDROID_MODULE = `package {{PACKAGE_NAME}} + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.session.MediaSession +import android.media.session.PlaybackState +import android.os.Build +import android.util.Log +import android.view.KeyEvent +import com.facebook.react.bridge.* +import com.facebook.react.modules.core.DeviceEventManagerModule + +class MediaButtonModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext), LifecycleEventListener { + + companion object { + private const val TAG = "MediaButtonModule" + } + + private var mediaSession: MediaSession? = null + private var isListening = false + private var mediaButtonReceiver: BroadcastReceiver? = null + + init { + reactContext.addLifecycleEventListener(this) + } + + override fun getName(): String { + return "MediaButtonModule" + } + + @ReactMethod + fun startListening() { + if (isListening) return + + val context = reactApplicationContext ?: return + + // Create media session for capturing media button events + mediaSession = MediaSession(context, "ResgridPTT").apply { + // Set the media button callback + setCallback(object : MediaSession.Callback() { + override fun onPlay() { + sendEvent("onMediaButtonEvent", createParams(KeyEvent.KEYCODE_MEDIA_PLAY, "ACTION_DOWN")) + } + + override fun onPause() { + sendEvent("onMediaButtonEvent", createParams(KeyEvent.KEYCODE_MEDIA_PAUSE, "ACTION_DOWN")) + } + + override fun onStop() { + sendEvent("onMediaButtonEvent", createParams(KeyEvent.KEYCODE_MEDIA_STOP, "ACTION_DOWN")) + } + + override fun onSkipToNext() { + sendEvent("onMediaButtonEvent", createParams(KeyEvent.KEYCODE_MEDIA_NEXT, "ACTION_DOWN")) + } + + override fun onSkipToPrevious() { + sendEvent("onMediaButtonEvent", createParams(KeyEvent.KEYCODE_MEDIA_PREVIOUS, "ACTION_DOWN")) + } + + override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { + val keyEvent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java) + } else { + @Suppress("DEPRECATION") + mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) + } + + keyEvent?.let { event -> + val action = when (event.action) { + KeyEvent.ACTION_DOWN -> "ACTION_DOWN" + KeyEvent.ACTION_UP -> "ACTION_UP" + else -> "UNKNOWN" + } + + // Handle play/pause toggle and headset hook (primary PTT triggers) + when (event.keyCode) { + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + KeyEvent.KEYCODE_HEADSETHOOK, + KeyEvent.KEYCODE_MEDIA_PLAY, + KeyEvent.KEYCODE_MEDIA_PAUSE -> { + sendEvent("onMediaButtonEvent", createParams(event.keyCode, action)) + return true + } + } + } + return super.onMediaButtonEvent(mediaButtonEvent) + } + }) + + // Set playback state to enable media button handling + val playbackState = PlaybackState.Builder() + .setActions( + PlaybackState.ACTION_PLAY or + PlaybackState.ACTION_PAUSE or + PlaybackState.ACTION_PLAY_PAUSE or + PlaybackState.ACTION_STOP or + PlaybackState.ACTION_SKIP_TO_NEXT or + PlaybackState.ACTION_SKIP_TO_PREVIOUS + ) + .setState(PlaybackState.STATE_PLAYING, 0, 1.0f) + .build() + setPlaybackState(playbackState) + + // Activate the session + isActive = true + } + + // Register a broadcast receiver for media button events (fallback) + mediaButtonReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (Intent.ACTION_MEDIA_BUTTON == intent?.action) { + val keyEvent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) + } + + keyEvent?.let { event -> + val action = when (event.action) { + KeyEvent.ACTION_DOWN -> "ACTION_DOWN" + KeyEvent.ACTION_UP -> "ACTION_UP" + else -> "UNKNOWN" + } + sendEvent("onMediaButtonEvent", createParams(event.keyCode, action)) + } + } + } + } + + val filter = IntentFilter(Intent.ACTION_MEDIA_BUTTON) + filter.priority = IntentFilter.SYSTEM_HIGH_PRIORITY + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(mediaButtonReceiver, filter, Context.RECEIVER_EXPORTED) + } else { + context.registerReceiver(mediaButtonReceiver, filter) + } + + isListening = true + } + + @ReactMethod + fun stopListening() { + if (!isListening) return + + mediaSession?.apply { + isActive = false + release() + } + mediaSession = null + + mediaButtonReceiver?.let { + try { + reactApplicationContext.unregisterReceiver(it) + } catch (e: Exception) { + Log.d(TAG, "Failed to unregister media button receiver: \${e.message}") + } + } + mediaButtonReceiver = null + + isListening = false + } + + private fun createParams(keyCode: Int, action: String): WritableMap { + return Arguments.createMap().apply { + putInt("keyCode", keyCode) + putString("action", action) + putDouble("timestamp", System.currentTimeMillis().toDouble()) + } + } + + private fun sendEvent(eventName: String, params: WritableMap?) { + reactApplicationContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(eventName, params) + } + + override fun onHostResume() { + // App is in foreground - ensure media session is active + mediaSession?.isActive = true + } + + override fun onHostPause() { + // App is in background - keep media session active for background audio + } + + override fun onHostDestroy() { + stopListening() + } + + @ReactMethod + fun addListener(eventName: String) { + // Required for RN built-in Event Emitter Support + } + + @ReactMethod + fun removeListeners(count: Int) { + // Required for RN built-in Event Emitter Support + } +} +`; + +/** + * Android MediaButtonPackage.kt content + */ +const ANDROID_PACKAGE = `package {{PACKAGE_NAME}} + +import android.view.View +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ReactShadowNode +import com.facebook.react.uimanager.ViewManager + +class MediaButtonPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(MediaButtonModule(reactContext)) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List>> { + return emptyList() + } +} +`; + +/** + * Expo config plugin to add MediaButtonModule for AirPods/earbuds PTT support. + * + * This plugin: + * 1. Creates the MediaButtonModule.swift and MediaButtonModule.m files in the iOS project + * 2. Updates the bridging header to include necessary imports + * 3. Creates MediaButtonModule.kt and MediaButtonPackage.kt for Android + * 4. Registers the package in MainApplication.kt + */ +const withMediaButtonModule = (config) => { + // Add iOS native module files + config = withDangerousMod(config, [ + 'ios', + async (config) => { + const projectRoot = config.modRequest.projectRoot; + const projectName = config.modRequest.projectName; + const iosProjectPath = path.join(projectRoot, 'ios', projectName); + + // Ensure the directory exists + if (!fs.existsSync(iosProjectPath)) { + fs.mkdirSync(iosProjectPath, { recursive: true }); + } + + // Write MediaButtonModule.swift + const swiftPath = path.join(iosProjectPath, 'MediaButtonModule.swift'); + fs.writeFileSync(swiftPath, IOS_SWIFT_MODULE); + console.log('[withMediaButtonModule] Created MediaButtonModule.swift'); + + // Write MediaButtonModule.m (Objective-C bridge) + const objcPath = path.join(iosProjectPath, 'MediaButtonModule.m'); + fs.writeFileSync(objcPath, IOS_OBJC_BRIDGE); + console.log('[withMediaButtonModule] Created MediaButtonModule.m'); + + // Update bridging header + const bridgingHeaderPath = path.join(iosProjectPath, `${projectName}-Bridging-Header.h`); + if (fs.existsSync(bridgingHeaderPath)) { + let bridgingHeaderContents = fs.readFileSync(bridgingHeaderPath, 'utf-8'); + + const requiredImports = ['#import ', '#import ']; + + let modified = false; + for (const importLine of requiredImports) { + if (!bridgingHeaderContents.includes(importLine)) { + bridgingHeaderContents += `\n${importLine}`; + modified = true; + } + } + + if (modified) { + fs.writeFileSync(bridgingHeaderPath, bridgingHeaderContents); + console.log('[withMediaButtonModule] Updated bridging header with React Native imports'); + } + } + + return config; + }, + ]); + + // Add Android native module files + config = withDangerousMod(config, [ + 'android', + async (config) => { + const projectRoot = config.modRequest.projectRoot; + + // Read the package name from the Android manifest or build.gradle + const buildGradlePath = path.join(projectRoot, 'android', 'app', 'build.gradle'); + let packageName = 'com.resgrid.unit'; // Default fallback + + if (fs.existsSync(buildGradlePath)) { + const buildGradleContent = fs.readFileSync(buildGradlePath, 'utf-8'); + const namespaceMatch = buildGradleContent.match(/namespace\s+['"]([^'"]+)['"]/); + if (namespaceMatch) { + packageName = namespaceMatch[1]; + } + } + + const packagePath = packageName.replace(/\./g, '/'); + const androidSrcPath = path.join(projectRoot, 'android', 'app', 'src', 'main', 'java', packagePath); + + // Ensure the directory exists + if (!fs.existsSync(androidSrcPath)) { + fs.mkdirSync(androidSrcPath, { recursive: true }); + } + + // Write MediaButtonModule.kt + const modulePath = path.join(androidSrcPath, 'MediaButtonModule.kt'); + const moduleContent = ANDROID_MODULE.replace(/\{\{PACKAGE_NAME\}\}/g, packageName); + fs.writeFileSync(modulePath, moduleContent); + console.log('[withMediaButtonModule] Created MediaButtonModule.kt'); + + // Write MediaButtonPackage.kt + const packageFilePath = path.join(androidSrcPath, 'MediaButtonPackage.kt'); + const packageContent = ANDROID_PACKAGE.replace(/\{\{PACKAGE_NAME\}\}/g, packageName); + fs.writeFileSync(packageFilePath, packageContent); + console.log('[withMediaButtonModule] Created MediaButtonPackage.kt'); + + return config; + }, + ]); + + // Update MainApplication.kt to register the package + config = withMainApplication(config, (config) => { + const mainApplication = config.modResults; + + // Check if MediaButtonPackage is already imported/added + if (!mainApplication.contents.includes('MediaButtonPackage')) { + // 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'); + } + } + + return config; + }); + + return config; +}; + +module.exports = withMediaButtonModule; diff --git a/src/components/calls/call-images-modal.tsx b/src/components/calls/call-images-modal.tsx index 079d65fd..7083d90a 100644 --- a/src/components/calls/call-images-modal.tsx +++ b/src/components/calls/call-images-modal.tsx @@ -108,37 +108,49 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call }, [validImages.length, activeIndex]); const handleImageSelect = async () => { - const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (permissionResult.granted === false) { - alert(t('common.permission_denied')); - return; - } - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, - allowsEditing: true, - quality: 0.8, - }); - if (!result.canceled) { - const asset = result.assets[0]; - const filename = asset.fileName || `image_${Date.now()}.png`; - setSelectedImageInfo({ uri: asset.uri, filename }); + try { + const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (permissionResult.status !== 'granted') { + alert(t('common.permission_denied')); + return; + } + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + allowsEditing: true, + quality: 0.8, + }); + if (!result.canceled && result.assets && result.assets.length > 0) { + const asset = result.assets[0]; + const filename = asset.fileName || `image_${Date.now()}.png`; + setSelectedImageInfo({ uri: asset.uri, filename }); + } + } catch (error) { + console.error('Error selecting image from library:', error); + alert(t('callImages.error_selecting_image')); } }; const handleCameraCapture = async () => { - const permissionResult = await ImagePicker.requestCameraPermissionsAsync(); - if (permissionResult.granted === false) { - alert(t('common.permission_denied')); - return; - } - const result = await ImagePicker.launchCameraAsync({ - allowsEditing: true, - quality: 0.8, - }); - if (!result.canceled) { - const asset = result.assets[0]; - const filename = `camera_${Date.now()}.png`; - setSelectedImageInfo({ uri: asset.uri, filename }); + try { + const permissionResult = await ImagePicker.requestCameraPermissionsAsync(); + if (permissionResult.status !== 'granted') { + alert(t('common.permission_denied')); + return; + } + const result = await ImagePicker.launchCameraAsync({ + mediaTypes: ['images'], + allowsEditing: true, + quality: 0.8, + cameraType: ImagePicker.CameraType.back, + }); + if (!result.canceled && result.assets && result.assets.length > 0) { + const asset = result.assets[0]; + const filename = `camera_${Date.now()}.png`; + setSelectedImageInfo({ uri: asset.uri, filename }); + } + } catch (error) { + console.error('Error capturing image from camera:', error); + alert(t('callImages.error_capturing_image')); } }; diff --git a/src/services/__tests__/media-button.service.test.ts b/src/services/__tests__/media-button.service.test.ts new file mode 100644 index 00000000..c32d17b5 --- /dev/null +++ b/src/services/__tests__/media-button.service.test.ts @@ -0,0 +1,283 @@ +import { NativeEventEmitter, NativeModules, Platform } from 'react-native'; + +import { useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store'; +import { useLiveKitStore } from '@/stores/app/livekit-store'; + +// Mock dependencies +jest.mock('react-native', () => ({ + NativeModules: { + MediaButtonModule: { + startListening: jest.fn(), + stopListening: jest.fn(), + }, + }, + NativeEventEmitter: jest.fn().mockImplementation(() => ({ + addListener: jest.fn().mockReturnValue({ remove: jest.fn() }), + removeAllListeners: jest.fn(), + })), + DeviceEventEmitter: { + addListener: jest.fn().mockReturnValue({ remove: jest.fn() }), + removeAllListeners: jest.fn(), + }, + Platform: { + OS: 'ios', + }, +})); + +jest.mock('@/lib/logging', () => ({ + logger: { + info: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('@/services/audio.service', () => ({ + audioService: { + playStartTransmittingSound: jest.fn().mockResolvedValue(undefined), + playStopTransmittingSound: jest.fn().mockResolvedValue(undefined), + }, +})); + +// Mock the stores +const mockBluetoothAudioStore = { + addButtonEvent: jest.fn(), + setLastButtonAction: jest.fn(), + mediaButtonPTTSettings: { + enabled: true, + pttMode: 'toggle', + usePlayPauseForPTT: true, + doubleTapAction: 'toggle_mute', + doubleTapTimeoutMs: 400, + }, +}; + +const mockLiveKitStore = { + currentRoom: { + localParticipant: { + isMicrophoneEnabled: false, + setMicrophoneEnabled: jest.fn().mockResolvedValue(undefined), + }, + }, +}; + +jest.mock('@/stores/app/bluetooth-audio-store', () => ({ + useBluetoothAudioStore: { + getState: () => mockBluetoothAudioStore, + }, +})); + +jest.mock('@/stores/app/livekit-store', () => ({ + useLiveKitStore: { + getState: () => mockLiveKitStore, + }, +})); + +// Import after mocks are set up +import { mediaButtonService, type MediaButtonPTTSettings } from '../media-button.service'; +import { audioService } from '@/services/audio.service'; + +describe('MediaButtonService', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset service state + mediaButtonService.destroy(); + }); + + describe('getInstance', () => { + it('should return a singleton instance', () => { + const instance1 = mediaButtonService; + const instance2 = mediaButtonService; + expect(instance1).toBe(instance2); + }); + }); + + describe('initialize', () => { + it('should initialize the service successfully', async () => { + await mediaButtonService.initialize(); + expect(mediaButtonService.isServiceInitialized()).toBe(true); + }); + + it('should not reinitialize if already initialized', async () => { + await mediaButtonService.initialize(); + await mediaButtonService.initialize(); + expect(mediaButtonService.isServiceInitialized()).toBe(true); + }); + + it('should check for native module availability', async () => { + await mediaButtonService.initialize(); + expect(mediaButtonService.isNativeModuleAvailable()).toBe(true); + }); + }); + + describe('settings management', () => { + it('should return default settings', () => { + const settings = mediaButtonService.getSettings(); + expect(settings.enabled).toBe(true); + expect(settings.pttMode).toBe('toggle'); + expect(settings.usePlayPauseForPTT).toBe(true); + }); + + it('should update settings', () => { + const newSettings: Partial = { + enabled: false, + pttMode: 'push_to_talk', + }; + mediaButtonService.updateSettings(newSettings); + + const settings = mediaButtonService.getSettings(); + expect(settings.enabled).toBe(false); + expect(settings.pttMode).toBe('push_to_talk'); + }); + + it('should enable/disable via setEnabled', () => { + mediaButtonService.setEnabled(false); + expect(mediaButtonService.getSettings().enabled).toBe(false); + + mediaButtonService.setEnabled(true); + expect(mediaButtonService.getSettings().enabled).toBe(true); + }); + }); + + describe('destroy', () => { + it('should cleanup resources on destroy', async () => { + await mediaButtonService.initialize(); + mediaButtonService.destroy(); + expect(mediaButtonService.isServiceInitialized()).toBe(false); + }); + + it('should stop listening on native module when destroyed', async () => { + await mediaButtonService.initialize(); + mediaButtonService.destroy(); + expect(NativeModules.MediaButtonModule.stopListening).toHaveBeenCalled(); + }); + }); + + describe('PTT actions', () => { + beforeEach(async () => { + await mediaButtonService.initialize(); + mediaButtonService.setEnabled(true); + }); + + it('should enable microphone when PTT is triggered and mic is disabled', async () => { + mockLiveKitStore.currentRoom.localParticipant.isMicrophoneEnabled = false; + + await mediaButtonService.enableMicrophone(); + + expect(mockLiveKitStore.currentRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true); + expect(audioService.playStartTransmittingSound).toHaveBeenCalled(); + expect(mockBluetoothAudioStore.addButtonEvent).toHaveBeenCalledWith( + expect.objectContaining({ + button: 'ptt_start', + }) + ); + }); + + it('should not enable microphone if already enabled', async () => { + mockLiveKitStore.currentRoom.localParticipant.isMicrophoneEnabled = true; + + await mediaButtonService.enableMicrophone(); + + expect(mockLiveKitStore.currentRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalled(); + }); + + it('should disable microphone when PTT is released', async () => { + mockLiveKitStore.currentRoom.localParticipant.isMicrophoneEnabled = true; + + await mediaButtonService.disableMicrophone(); + + expect(mockLiveKitStore.currentRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false); + expect(audioService.playStopTransmittingSound).toHaveBeenCalled(); + expect(mockBluetoothAudioStore.addButtonEvent).toHaveBeenCalledWith( + expect.objectContaining({ + button: 'ptt_stop', + }) + ); + }); + + it('should not disable microphone if already disabled', async () => { + mockLiveKitStore.currentRoom.localParticipant.isMicrophoneEnabled = false; + + await mediaButtonService.disableMicrophone(); + + expect(mockLiveKitStore.currentRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalled(); + }); + }); + + describe('when no LiveKit room is active', () => { + beforeEach(async () => { + await mediaButtonService.initialize(); + mediaButtonService.setEnabled(true); + }); + + it('should not throw error when enabling mic without room', async () => { + const originalRoom = mockLiveKitStore.currentRoom; + (mockLiveKitStore as any).currentRoom = null; + + await expect(mediaButtonService.enableMicrophone()).resolves.not.toThrow(); + + (mockLiveKitStore as any).currentRoom = originalRoom; + }); + + it('should not throw error when disabling mic without room', async () => { + const originalRoom = mockLiveKitStore.currentRoom; + (mockLiveKitStore as any).currentRoom = null; + + await expect(mediaButtonService.disableMicrophone()).resolves.not.toThrow(); + + (mockLiveKitStore as any).currentRoom = originalRoom; + }); + }); + + describe('when PTT is disabled', () => { + beforeEach(async () => { + await mediaButtonService.initialize(); + mediaButtonService.setEnabled(false); + }); + + it('should not process button events when disabled', async () => { + // The handleMediaButtonEvent is private, so we test via settings check + const settings = mediaButtonService.getSettings(); + expect(settings.enabled).toBe(false); + }); + }); +}); + +describe('MediaButtonService - Platform specific', () => { + describe('iOS', () => { + beforeEach(() => { + (Platform as any).OS = 'ios'; + jest.clearAllMocks(); + mediaButtonService.destroy(); + }); + + it('should setup iOS event listeners when native module is available', async () => { + await mediaButtonService.initialize(); + expect(NativeEventEmitter).toHaveBeenCalled(); + }); + + it('should call startListening on native module', async () => { + await mediaButtonService.initialize(); + expect(NativeModules.MediaButtonModule.startListening).toHaveBeenCalled(); + }); + }); + + describe('Android', () => { + beforeEach(() => { + (Platform as any).OS = 'android'; + jest.clearAllMocks(); + mediaButtonService.destroy(); + }); + + it('should setup Android event listeners when native module is available', async () => { + await mediaButtonService.initialize(); + expect(NativeEventEmitter).toHaveBeenCalled(); + }); + + it('should call startListening on native module', async () => { + await mediaButtonService.initialize(); + expect(NativeModules.MediaButtonModule.startListening).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/services/media-button.service.ts b/src/services/media-button.service.ts new file mode 100644 index 00000000..e1b11873 --- /dev/null +++ b/src/services/media-button.service.ts @@ -0,0 +1,495 @@ +import { DeviceEventEmitter, NativeEventEmitter, NativeModules, Platform } from 'react-native'; + +import { logger } from '@/lib/logging'; +import { audioService } from '@/services/audio.service'; +import { type AudioButtonEvent, useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store'; +import { + createDefaultPTTSettings, + type MediaButtonPTTSettings, + type PTTMode, +} from '@/types/ptt'; + +// Re-export PTT types for backwards compatibility +export { type MediaButtonPTTSettings, type PTTMode }; + +// Lazy import to break dependency cycle with livekit-store +const getLiveKitStore = () => require('@/stores/app/livekit-store').useLiveKitStore; + +// Media button event types +export type MediaButtonEventType = 'play' | 'pause' | 'playPause' | 'stop' | 'next' | 'previous' | 'togglePlayPause'; + +export interface MediaButtonEvent { + type: MediaButtonEventType; + timestamp: number; + source?: 'airpods' | 'bluetooth_earbuds' | 'wired_headset' | 'unknown'; +} + +// Try to get the native module (will be null if not installed) +const { MediaButtonModule } = NativeModules; + +class MediaButtonService { + private static instance: MediaButtonService; + private isInitialized = false; + private eventListeners: { remove: () => void }[] = []; + private settings: MediaButtonPTTSettings = createDefaultPTTSettings(); + private lastPressTimestamp: number = 0; + private doubleTapTimer: ReturnType | null = null; + private pendingSingleTap: boolean = false; + + private constructor() {} + + static getInstance(): MediaButtonService { + if (!MediaButtonService.instance) { + MediaButtonService.instance = new MediaButtonService(); + } + return MediaButtonService.instance; + } + + /** + * Initialize the media button service + * Sets up event listeners for media button presses from AirPods/earbuds + */ + async initialize(): Promise { + if (this.isInitialized) { + logger.debug({ + message: 'Media button service already initialized', + }); + return; + } + + try { + logger.info({ + message: 'Initializing Media Button Service for AirPods/earbuds PTT support', + }); + + this.setupEventListeners(); + this.isInitialized = true; + + logger.info({ + message: 'Media Button Service initialized successfully', + context: { platform: Platform.OS }, + }); + } catch (error) { + logger.error({ + message: 'Failed to initialize Media Button Service', + context: { error }, + }); + throw error; + } + } + + /** + * Setup event listeners for media button events + * On iOS: Uses MPRemoteCommandCenter via native module or CallKeep + * On Android: Uses MediaSession via native module + */ + private setupEventListeners(): void { + if (Platform.OS === 'ios') { + this.setupIOSEventListeners(); + } else if (Platform.OS === 'android') { + this.setupAndroidEventListeners(); + } + } + + /** + * Setup iOS-specific event listeners + * iOS AirPods/earbuds send media control events through MPRemoteCommandCenter + */ + private setupIOSEventListeners(): void { + // If native module is available, use it + if (MediaButtonModule) { + const eventEmitter = new NativeEventEmitter(MediaButtonModule); + + const playPauseListener = eventEmitter.addListener('onMediaButtonPlayPause', () => { + this.handleMediaButtonEvent('playPause'); + }); + this.eventListeners.push(playPauseListener); + + const playListener = eventEmitter.addListener('onMediaButtonPlay', () => { + this.handleMediaButtonEvent('play'); + }); + this.eventListeners.push(playListener); + + const pauseListener = eventEmitter.addListener('onMediaButtonPause', () => { + this.handleMediaButtonEvent('pause'); + }); + this.eventListeners.push(pauseListener); + + const toggleListener = eventEmitter.addListener('onMediaButtonToggle', () => { + this.handleMediaButtonEvent('togglePlayPause'); + }); + this.eventListeners.push(toggleListener); + + // Enable the native module to start receiving events + MediaButtonModule.startListening?.(); + + logger.debug({ + message: 'iOS media button listeners setup via native module', + }); + } else { + // Fallback: Use CallKeep mute events (already handled by CallKeep service) + // This is a limited fallback since CallKeep only provides mute state changes + // from the iOS Call UI, not from AirPods button presses directly + logger.warn({ + message: 'MediaButtonModule not available on iOS - AirPods PTT may be limited', + context: { + suggestion: 'Install the MediaButtonModule native module for full AirPods PTT support', + }, + }); + + // We can still listen for generic audio session events through DeviceEventEmitter + // Some libraries emit events that we can hook into + const audioRouteListener = DeviceEventEmitter.addListener('audioRouteChanged', (event: { reason: string }) => { + logger.debug({ + message: 'Audio route changed', + context: { event }, + }); + }); + this.eventListeners.push(audioRouteListener); + } + } + + /** + * Setup Android-specific event listeners + * Android uses MediaSession callbacks for headset button events + */ + private setupAndroidEventListeners(): void { + if (MediaButtonModule) { + const eventEmitter = new NativeEventEmitter(MediaButtonModule); + + // MediaSession callback events + const mediaButtonListener = eventEmitter.addListener('onMediaButtonEvent', (event: { keyCode: number; action: string }) => { + logger.debug({ + message: 'Android media button event received', + context: { event }, + }); + + // Android KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE = 85 + // Android KeyEvent.KEYCODE_HEADSETHOOK = 79 + if (event.keyCode === 85 || event.keyCode === 79) { + if (event.action === 'ACTION_DOWN') { + this.handleMediaButtonEvent('playPause'); + } + } + }); + this.eventListeners.push(mediaButtonListener); + + // Enable the native module to start receiving events + MediaButtonModule.startListening?.(); + + logger.debug({ + message: 'Android media button listeners setup via native module', + }); + } else { + // Fallback: Listen via DeviceEventEmitter for any media button events + // Some audio libraries emit these events + logger.warn({ + message: 'MediaButtonModule not available on Android - earbuds PTT may be limited', + context: { + suggestion: 'Install the MediaButtonModule native module for full earbuds PTT support', + }, + }); + + // Generic headset hook event listener (some libraries emit this) + const headsetListener = DeviceEventEmitter.addListener('headsetButtonPressed', () => { + this.handleMediaButtonEvent('playPause'); + }); + this.eventListeners.push(headsetListener); + } + } + + /** + * Handle a media button event and convert it to PTT action + */ + private handleMediaButtonEvent(type: MediaButtonEventType): void { + if (!this.settings.enabled) { + logger.debug({ + message: 'Media button PTT is disabled, ignoring event', + context: { type }, + }); + return; + } + + const now = Date.now(); + + logger.info({ + message: 'Media button event received', + context: { type, settings: this.settings }, + }); + + // Handle double-tap detection + if (this.settings.doubleTapAction !== 'none') { + const timeSinceLastPress = now - this.lastPressTimestamp; + + if (timeSinceLastPress < this.settings.doubleTapTimeoutMs && this.pendingSingleTap) { + // This is a double tap + this.pendingSingleTap = false; + if (this.doubleTapTimer) { + clearTimeout(this.doubleTapTimer); + this.doubleTapTimer = null; + } + + logger.info({ + message: 'Double-tap detected on media button', + }); + + this.handleDoubleTap(); + this.lastPressTimestamp = now; + return; + } + + // Potential single tap - wait to see if it becomes a double tap + this.lastPressTimestamp = now; + this.pendingSingleTap = true; + + this.doubleTapTimer = setTimeout(() => { + if (this.pendingSingleTap) { + this.pendingSingleTap = false; + this.handleSingleTap(type); + } + }, this.settings.doubleTapTimeoutMs); + } else { + // No double-tap detection, handle immediately + this.handleSingleTap(type); + } + } + + /** + * Handle single tap action based on PTT mode + */ + private handleSingleTap(type: MediaButtonEventType): void { + // Only handle play/pause type events for PTT + if (!this.settings.usePlayPauseForPTT) { + logger.debug({ + message: 'Play/Pause PTT disabled, ignoring', + context: { type }, + }); + return; + } + + if (type === 'playPause' || type === 'play' || type === 'pause' || type === 'togglePlayPause') { + this.handlePTTAction(); + } + } + + /** + * Handle double tap action + */ + private handleDoubleTap(): void { + if (this.settings.doubleTapAction === 'toggle_mute') { + this.handlePTTAction(); + } + } + + /** + * Execute the PTT action (toggle or push-to-talk based on mode) + */ + private async handlePTTAction(): Promise { + const liveKitStore = getLiveKitStore().getState(); + + if (!liveKitStore.currentRoom) { + logger.debug({ + message: 'No active LiveKit room, cannot handle PTT action', + }); + return; + } + + try { + const currentMicEnabled = liveKitStore.currentRoom.localParticipant.isMicrophoneEnabled; + const newMicEnabled = !currentMicEnabled; + + await liveKitStore.currentRoom.localParticipant.setMicrophoneEnabled(newMicEnabled); + + // Create button event for store + const buttonEvent: AudioButtonEvent = { + type: 'press', + button: newMicEnabled ? 'ptt_start' : 'ptt_stop', + timestamp: Date.now(), + }; + + useBluetoothAudioStore.getState().addButtonEvent(buttonEvent); + useBluetoothAudioStore.getState().setLastButtonAction({ + action: newMicEnabled ? 'unmute' : 'mute', + timestamp: Date.now(), + }); + + // Play audio feedback + if (newMicEnabled) { + await audioService.playStartTransmittingSound(); + } else { + await audioService.playStopTransmittingSound(); + } + + logger.info({ + message: 'PTT action executed via media button (AirPods/earbuds)', + context: { + micEnabled: newMicEnabled, + pttMode: this.settings.pttMode, + }, + }); + } catch (error) { + logger.error({ + message: 'Failed to execute PTT action via media button', + context: { error }, + }); + } + } + + /** + * Enable microphone (for push-to-talk mode) + */ + async enableMicrophone(): Promise { + const liveKitStore = getLiveKitStore().getState(); + + if (!liveKitStore.currentRoom) { + return; + } + + const currentMicEnabled = liveKitStore.currentRoom.localParticipant.isMicrophoneEnabled; + if (currentMicEnabled) { + return; // Already enabled + } + + try { + await liveKitStore.currentRoom.localParticipant.setMicrophoneEnabled(true); + + useBluetoothAudioStore.getState().addButtonEvent({ + type: 'press', + button: 'ptt_start', + timestamp: Date.now(), + }); + + useBluetoothAudioStore.getState().setLastButtonAction({ + action: 'unmute', + timestamp: Date.now(), + }); + + await audioService.playStartTransmittingSound(); + + logger.info({ + message: 'Microphone enabled via media button', + }); + } catch (error) { + logger.error({ + message: 'Failed to enable microphone via media button', + context: { error }, + }); + } + } + + /** + * Disable microphone (for push-to-talk mode) + */ + async disableMicrophone(): Promise { + const liveKitStore = getLiveKitStore().getState(); + + if (!liveKitStore.currentRoom) { + return; + } + + const currentMicEnabled = liveKitStore.currentRoom.localParticipant.isMicrophoneEnabled; + if (!currentMicEnabled) { + return; // Already disabled + } + + try { + await liveKitStore.currentRoom.localParticipant.setMicrophoneEnabled(false); + + useBluetoothAudioStore.getState().addButtonEvent({ + type: 'press', + button: 'ptt_stop', + timestamp: Date.now(), + }); + + useBluetoothAudioStore.getState().setLastButtonAction({ + action: 'mute', + timestamp: Date.now(), + }); + + await audioService.playStopTransmittingSound(); + + logger.info({ + message: 'Microphone disabled via media button', + }); + } catch (error) { + logger.error({ + message: 'Failed to disable microphone via media button', + context: { error }, + }); + } + } + + /** + * Update PTT settings + */ + updateSettings(settings: Partial): void { + this.settings = { ...this.settings, ...settings }; + + logger.info({ + message: 'Media button PTT settings updated', + context: { settings: this.settings }, + }); + } + + /** + * Get current PTT settings + */ + getSettings(): MediaButtonPTTSettings { + return { ...this.settings }; + } + + /** + * Enable/disable media button PTT + */ + setEnabled(enabled: boolean): void { + this.settings.enabled = enabled; + + logger.info({ + message: `Media button PTT ${enabled ? 'enabled' : 'disabled'}`, + }); + } + + /** + * Check if service is initialized + */ + isServiceInitialized(): boolean { + return this.isInitialized; + } + + /** + * Check if native module is available + */ + isNativeModuleAvailable(): boolean { + return MediaButtonModule !== null && MediaButtonModule !== undefined; + } + + /** + * Cleanup and destroy the service + */ + destroy(): void { + logger.info({ + message: 'Destroying Media Button Service', + }); + + // Clear any pending timers + if (this.doubleTapTimer) { + clearTimeout(this.doubleTapTimer); + this.doubleTapTimer = null; + } + + // Stop native module if available + if (MediaButtonModule?.stopListening) { + MediaButtonModule.stopListening(); + } + + // Remove all event listeners + this.eventListeners.forEach((listener) => listener.remove()); + this.eventListeners = []; + + this.isInitialized = false; + this.pendingSingleTap = false; + this.lastPressTimestamp = 0; + } +} + +export const mediaButtonService = MediaButtonService.getInstance(); diff --git a/src/stores/app/__tests__/livekit-store.test.ts b/src/stores/app/__tests__/livekit-store.test.ts index d82ca057..04969860 100644 --- a/src/stores/app/__tests__/livekit-store.test.ts +++ b/src/stores/app/__tests__/livekit-store.test.ts @@ -42,6 +42,27 @@ jest.mock('../../../services/callkeep.service.ios', () => ({ }, })); +// Mock media button service +jest.mock('../../../services/media-button.service', () => ({ + mediaButtonService: { + initialize: jest.fn().mockResolvedValue(undefined), + destroy: jest.fn(), + updateSettings: jest.fn(), + getSettings: jest.fn().mockReturnValue({ + enabled: true, + pttMode: 'toggle', + usePlayPauseForPTT: true, + doubleTapAction: 'toggle_mute', + doubleTapTimeoutMs: 400, + }), + setEnabled: jest.fn(), + isServiceInitialized: jest.fn().mockReturnValue(true), + isNativeModuleAvailable: jest.fn().mockReturnValue(true), + enableMicrophone: jest.fn().mockResolvedValue(undefined), + disableMicrophone: jest.fn().mockResolvedValue(undefined), + }, +})); + import { Platform } from 'react-native'; import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio'; diff --git a/src/stores/app/bluetooth-audio-store.ts b/src/stores/app/bluetooth-audio-store.ts index 5845001e..e4784bea 100644 --- a/src/stores/app/bluetooth-audio-store.ts +++ b/src/stores/app/bluetooth-audio-store.ts @@ -1,6 +1,16 @@ import { type Peripheral } from 'react-native-ble-manager'; import { create } from 'zustand'; +import { + createDefaultPTTSettings, + DEFAULT_MEDIA_BUTTON_PTT_SETTINGS, + type MediaButtonPTTSettings, + type PTTMode, +} from '@/types/ptt'; + +// Re-export PTT types for backwards compatibility +export { DEFAULT_MEDIA_BUTTON_PTT_SETTINGS, type MediaButtonPTTSettings, type PTTMode }; + // Re-export Peripheral as Device for compatibility export type Device = Peripheral; @@ -70,6 +80,9 @@ interface BluetoothAudioState { buttonEvents: AudioButtonEvent[]; lastButtonAction: ButtonAction | null; + // Media button PTT settings (for AirPods/earbuds) + mediaButtonPTTSettings: MediaButtonPTTSettings; + // Actions setBluetoothState: (state: State) => void; setIsScanning: (isScanning: boolean) => void; @@ -100,6 +113,10 @@ interface BluetoothAudioState { addButtonEvent: (event: AudioButtonEvent) => void; clearButtonEvents: () => void; setLastButtonAction: (action: ButtonAction | null) => void; + + // Media button PTT settings (for AirPods/earbuds) + setMediaButtonPTTSettings: (settings: Partial) => void; + setMediaButtonPTTEnabled: (enabled: boolean) => void; } export const useBluetoothAudioStore = create((set, get) => ({ @@ -122,6 +139,7 @@ export const useBluetoothAudioStore = create((set, get) => isAudioRoutingActive: false, buttonEvents: [], lastButtonAction: null, + mediaButtonPTTSettings: createDefaultPTTSettings(), // Bluetooth state actions setBluetoothState: (state) => set({ bluetoothState: state }), @@ -225,4 +243,25 @@ export const useBluetoothAudioStore = create((set, get) => const updatedDevices = availableAudioDevices.map((device) => (device.id === deviceId ? { ...device, isAvailable } : device)); set({ availableAudioDevices: updatedDevices }); }, + + // Media button PTT settings actions + setMediaButtonPTTSettings: (settings) => { + const { mediaButtonPTTSettings } = get(); + set({ + mediaButtonPTTSettings: { + ...mediaButtonPTTSettings, + ...settings, + }, + }); + }, + + setMediaButtonPTTEnabled: (enabled) => { + const { mediaButtonPTTSettings } = get(); + set({ + mediaButtonPTTSettings: { + ...mediaButtonPTTSettings, + enabled, + }, + }); + }, })); diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index 0ac901b0..d533846c 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -9,6 +9,7 @@ import { logger } from '../../lib/logging'; import { type DepartmentVoiceChannelResultData } from '../../models/v4/voice/departmentVoiceResultData'; import { audioService } from '../../services/audio.service'; import { callKeepService } from '../../services/callkeep.service'; +import { mediaButtonService } from '../../services/media-button.service'; import { useBluetoothAudioStore } from './bluetooth-audio-store'; // Helper function to setup audio routing based on selected devices @@ -257,6 +258,24 @@ export const useLiveKitStore = create((set, get) => ({ } } + // Initialize media button service for AirPods/earbuds PTT support + try { + await mediaButtonService.initialize(); + // Apply stored settings from the Bluetooth audio store + const { mediaButtonPTTSettings } = useBluetoothAudioStore.getState(); + mediaButtonService.updateSettings(mediaButtonPTTSettings); + logger.info({ + message: 'Media button service initialized for PTT support', + context: { settings: mediaButtonPTTSettings }, + }); + } catch (mediaButtonError) { + logger.warn({ + message: 'Failed to initialize media button service - AirPods/earbuds PTT may not work', + context: { error: mediaButtonError }, + }); + // Don't fail the connection if media button service fails + } + set({ currentRoom: room, currentRoomInfo: roomInfo, @@ -301,6 +320,20 @@ export const useLiveKitStore = create((set, get) => ({ context: { error }, }); } + + // Cleanup media button service + try { + mediaButtonService.destroy(); + logger.debug({ + message: 'Media button service cleaned up', + }); + } catch (mediaButtonError) { + logger.warn({ + message: 'Failed to cleanup media button service', + context: { error: mediaButtonError }, + }); + } + set({ currentRoom: null, currentRoomInfo: null, diff --git a/src/translations/ar.json b/src/translations/ar.json index 3e22b0ac..c3ed0882 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -90,6 +90,8 @@ "add_new": "إضافة صورة جديدة", "default_name": "صورة بدون عنوان", "error": "خطأ في الحصول على الصور", + "error_capturing_image": "فشل في التقاط الصورة. يرجى المحاولة مرة أخرى.", + "error_selecting_image": "فشل في اختيار الصورة. يرجى المحاولة مرة أخرى.", "failed_to_load": "فشل في تحميل الصورة", "image_alt": "صورة المكالمة", "image_name": "اسم الصورة", diff --git a/src/translations/en.json b/src/translations/en.json index 8f5c9bbe..748cb6b7 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -90,6 +90,8 @@ "add_new": "Add New Image", "default_name": "Untitled Image", "error": "Error getting images", + "error_capturing_image": "Failed to capture image. Please try again.", + "error_selecting_image": "Failed to select image. Please try again.", "failed_to_load": "Failed to load image", "image_alt": "Call image", "image_name": "Image Name", diff --git a/src/translations/es.json b/src/translations/es.json index aa650563..10ef2527 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -90,6 +90,8 @@ "add_new": "Añadir nueva imagen", "default_name": "Imagen sin título", "error": "Error al obtener imágenes", + "error_capturing_image": "Error al capturar la imagen. Por favor, inténtalo de nuevo.", + "error_selecting_image": "Error al seleccionar la imagen. Por favor, inténtalo de nuevo.", "failed_to_load": "Error al cargar la imagen", "image_alt": "Imagen de la llamada", "image_name": "Nombre de la imagen", diff --git a/src/types/ptt.ts b/src/types/ptt.ts new file mode 100644 index 00000000..7b0de5e4 --- /dev/null +++ b/src/types/ptt.ts @@ -0,0 +1,51 @@ +/** + * PTT (Push-to-Talk) types and settings for media button functionality. + * Used by bluetooth-audio-store and media-button.service. + */ + +/** + * PTT mode configuration for media buttons (AirPods/earbuds) + * - 'toggle': Single press toggles mute state on/off + * - 'push_to_talk': Press and hold to unmute, release to mute + */ +export type PTTMode = 'toggle' | 'push_to_talk'; + +/** + * Settings for media button PTT functionality + */ +export interface MediaButtonPTTSettings { + /** Whether media button PTT functionality is enabled */ + enabled: boolean; + /** The PTT mode to use */ + pttMode: PTTMode; + /** + * Whether to use play/pause button for PTT + * - For toggle mode: single press toggles mute state + * - For push_to_talk mode: press to unmute, release to mute + */ + usePlayPauseForPTT: boolean; + /** Double tap action behavior */ + doubleTapAction: 'none' | 'toggle_mute'; + /** Timeout in milliseconds to detect double tap */ + doubleTapTimeoutMs: number; +} + +/** + * Default media button PTT settings. + * This object is frozen to prevent accidental mutations. + */ +export const DEFAULT_MEDIA_BUTTON_PTT_SETTINGS: Readonly = Object.freeze({ + enabled: true, + pttMode: 'toggle', + usePlayPauseForPTT: true, + doubleTapAction: 'toggle_mute', + doubleTapTimeoutMs: 400, +}); + +/** + * Creates a mutable copy of the default PTT settings. + * Use this when initializing state that needs to be modified. + */ +export const createDefaultPTTSettings = (): MediaButtonPTTSettings => ({ + ...DEFAULT_MEDIA_BUTTON_PTT_SETTINGS, +});