Mobile Call Optimizations - nself-org/nchat GitHub Wiki
Complete guide for mobile-optimized voice and video calling with native integrations.
- Overview
- Architecture
- iOS CallKit Integration
- Android Telecom Integration
- Picture-in-Picture Mode
- Background Call Support
- Push Notifications
- Battery Optimization
- Network Optimization
- Touch-Optimized UI
- Usage Guide
- Troubleshooting
nself-chat v0.4.0 provides a complete native mobile calling experience with:
- iOS CallKit - Native iOS call integration with system call screen
- Android Telecom - Native Android call integration with system dialer
- Picture-in-Picture - Continue calls while using other apps
- Background Calls - Maintain calls when app is backgrounded
- VoIP Push - Wake app for incoming calls
- Battery Optimization - Automatic quality adjustment based on battery level
- Network Optimization - Adapt to WiFi vs cellular connectivity
src/
├── components/
│ └── calls/
│ └── mobile/
│ ├── MobileCallScreen.tsx # Full-screen call UI
│ ├── MobileCallControls.tsx # Touch-friendly controls
│ ├── MobileVideoGrid.tsx # Optimized video layout
│ ├── MobilePiPOverlay.tsx # PiP floating window
│ └── MobileIncomingCall.tsx # Incoming call screen
├── hooks/
│ ├── use-mobile-pip.ts # PiP functionality
│ ├── use-mobile-orientation.ts # Orientation handling
│ ├── use-battery-status.ts # Battery monitoring
│ └── use-voip-push.ts # VoIP push integration
└── lib/
└── voip-push.ts # Push notification handler
platforms/capacitor/
├── ios/
│ └── Plugin/
│ └── CallKitPlugin.swift # iOS CallKit implementation
├── android/
│ └── src/main/java/
│ └── CallKitPlugin.kt # Android Telecom implementation
└── src/native/
└── call-kit.ts # TypeScript wrapper
CallKit provides native iOS call integration, including:
- System-level call screen
- Lock screen call UI
- Call history integration
- CarPlay support
- VoIP push notifications
In Xcode, go to your project settings:
- Select your target
- Go to "Signing & Capabilities"
- Add "Voice over IP" background mode
- Add "Audio, AirPlay, and Picture in Picture" background mode
<key>UIBackgroundModes</key>
<array>
<string>voip</string>
<string>audio</string>
</array>
<key>NSMicrophoneUsageDescription</key>
<string>We need access to your microphone for voice calls</string>
<key>NSCameraUsageDescription</key>
<string>We need access to your camera for video calls</string>import { callKitManager } from '@/platforms/capacitor/src/native/call-kit'
// Initialize on app start
await callKitManager.initialize('nChat')// When receiving an incoming call notification
const callUuid = await callKitManager.reportIncomingCall({
uuid: callId,
handle: callerId,
handleType: 'generic',
hasVideo: true,
callerDisplayName: 'John Doe',
callerImageUrl: 'https://example.com/avatar.jpg',
})const callUuid = await callKitManager.startOutgoingCall({
uuid: callId,
handle: targetUserId,
hasVideo: true,
contactIdentifier: '[email protected]',
})// When call connects
await callKitManager.reportCallConnected(callUuid)
// When call ends
await callKitManager.endCall('completed', callUuid)
// Update mute state
await callKitManager.setMuted(true, callUuid)
// Update hold state
await callKitManager.setOnHold(false, callUuid)Listen for CallKit events in your app:
import { CallKit } from '@/platforms/capacitor/src/native/call-kit'
// Call answered
CallKit.addListener('callAnswered', (data) => {
console.log('Call answered:', data.uuid)
// Start WebRTC connection
})
// Call ended
CallKit.addListener('callEnded', (data) => {
console.log('Call ended:', data.uuid)
// Clean up WebRTC connection
})
// Mute changed
CallKit.addListener('callMuteChanged', (data) => {
console.log('Mute changed:', data.muted)
// Update local audio track
})Android Telecom Framework provides:
- System-level call UI
- Integration with system dialer
- Call history
- Bluetooth and car integration
In AndroidManifest.xml:
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
<uses-permission android:name="android.permission.USE_SIP" />
<!-- For Android 12+ -->
<uses-permission android:name="android.permission.READ_PRECISE_PHONE_STATE" /><service
android:name="io.nself.chat.plugins.CallConnectionService"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
</service>import { callKitManager } from '@/platforms/capacitor/src/native/call-kit'
// Request permissions first
const { granted } = await CallKit.requestPermissions()
if (granted) {
await callKitManager.initialize('nChat')
}Usage is identical to iOS CallKit - the platform-specific implementation is handled automatically.
Picture-in-Picture (PiP) allows users to continue video calls in a small floating window while using other apps.
import { useMobilePiP } from '@/hooks/use-mobile-pip'
function CallScreen() {
const { isPiPSupported, enablePiP, disablePiP, isPiPActive } = useMobilePiP()
const handleMinimize = async () => {
if (isPiPSupported) {
await enablePiP()
}
}
return (
<div>
{isPiPSupported && (
<button onClick={handleMinimize}>
Minimize to PiP
</button>
)}
</div>
)
}The native implementations use:
-
iOS:
AVPictureInPictureController -
Android:
enterPictureInPictureMode()
// Automatically handled by the hook
const { enablePiP } = useMobilePiP()
// Will use native API if available, fallback to Web API
await enablePiP()For platforms without native PiP support, use the floating overlay:
import { MobilePiPOverlay } from '@/components/calls/mobile/MobilePiPOverlay'
<MobilePiPOverlay
isActive={isPiPActive}
onExpand={() => setFullscreen(true)}
onEndCall={() => endCall()}
/>- Draggable - Move PiP window anywhere on screen
- Snap to Edges - Automatically snaps to screen edges
- Touch Controls - Mute, video toggle, end call
- Tap to Expand - Double-tap to return to full screen
Keep calls active when the app is in the background or screen is locked.
Configured in Info.plist:
<key>UIBackgroundModes</key>
<array>
<string>voip</string>
<string>audio</string>
</array>Automatically started during active calls:
// In CallConnectionService
private fun showCallNotification() {
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Ongoing Call")
.setContentText("Call in progress")
.setSmallIcon(R.drawable.ic_call)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
startForeground(NOTIFICATION_ID, notification)
}// Keep WebRTC connection alive
import { App } from '@capacitor/app'
App.addListener('appStateChange', ({ isActive }) => {
if (!isActive && activeCall) {
// App backgrounded - maintain WebRTC
console.log('App backgrounded, maintaining call')
} else if (isActive && activeCall) {
// App foregrounded - resume UI
console.log('App foregrounded, resuming call UI')
}
})import { Network } from '@capacitor/network'
Network.addListener('networkStatusChange', async (status) => {
if (!status.connected && activeCall) {
// Network lost - attempt reconnection
await attemptReconnection()
} else if (status.connected && reconnecting) {
// Network restored
await resumeCall()
}
})VoIP push notifications wake the app for incoming calls even when terminated.
- Go to Apple Developer Portal
- Create VoIP Services Certificate
- Download and import to Keychain
- Export as .p12 file
// Server-side (Node.js)
import apn from 'apn'
const provider = new apn.Provider({
token: {
key: 'path/to/AuthKey.p8',
keyId: 'YOUR_KEY_ID',
teamId: 'YOUR_TEAM_ID',
},
production: false,
})
const notification = new apn.Notification({
topic: 'com.yourapp.voip',
payload: {
type: 'incoming_call',
callId: 'call-123',
callerId: 'user-456',
callerName: 'John Doe',
callType: 'video',
},
pushType: 'voip',
priority: 10,
})
await provider.send(notification, deviceToken)// Add google-services.json to android/app/// Server-side (Node.js)
import admin from 'firebase-admin'
await admin.messaging().send({
token: deviceToken,
notification: {
title: 'Incoming Call',
body: 'John Doe is calling',
},
data: {
type: 'incoming_call',
callId: 'call-123',
callerId: 'user-456',
callerName: 'John Doe',
callType: 'video',
},
android: {
priority: 'high',
ttl: 3600000,
notification: {
channelId: 'voip_calls',
priority: 'high',
sound: 'default',
},
},
})import { voipPushManager } from '@/lib/voip-push'
// Initialize on app start
await voipPushManager.initialize()
// Push notifications are automatically handled
// and integrated with CallKit/Telecomimport { useBatteryStatus } from '@/hooks/use-battery-status'
function CallScreen() {
const {
batteryLevel,
isCharging,
isLowBattery,
suggestedVideoQuality,
} = useBatteryStatus()
useEffect(() => {
// Adjust video quality based on battery
if (suggestedVideoQuality === 'audio-only') {
// Disable video
toggleVideo(false)
} else {
// Adjust video constraints
updateVideoConstraints(suggestedVideoQuality)
}
}, [suggestedVideoQuality])
return (
<>
{isLowBattery && (
<div className="warning">
Low battery ({batteryLevel}%). Switch to audio-only?
</div>
)}
</>
)
}| Battery Level | Video Quality | Frame Rate | Resolution |
|---|---|---|---|
| > 30% (or charging) | High | 30 fps | 720p |
| 20-30% | Medium | 24 fps | 480p |
| 10-20% | Low | 20 fps | 360p |
| < 10% | Audio Only | N/A | N/A |
- Automatic Video Disable - Below 10% battery
- Reduced Frame Rate - Lower FPS on low battery
- Lower Resolution - Reduce video quality
- Background Blur Disable - Turn off effects
- Screen Brightness Warning - Suggest reducing brightness
import { Network } from '@capacitor/network'
const status = await Network.getStatus()
if (status.connectionType === 'wifi') {
// Use high quality
setVideoQuality('high')
} else {
// Use lower quality on cellular
setVideoQuality('medium')
// Warn user about data usage
showDataWarning()
}// Adjust based on network conditions
peerConnection.getSenders().forEach((sender) => {
const parameters = sender.getParameters()
if (isWiFi) {
parameters.encodings[0].maxBitrate = 2500000 // 2.5 Mbps
} else {
parameters.encodings[0].maxBitrate = 1000000 // 1 Mbps
}
sender.setParameters(parameters)
})let dataUsed = 0
peerConnection.getStats().then((stats) => {
stats.forEach((report) => {
if (report.type === 'outbound-rtp') {
dataUsed += report.bytesSent
}
})
// Convert to MB
const dataMB = dataUsed / (1024 * 1024)
console.log(`Data used: ${dataMB.toFixed(2)} MB`)
})All interactive elements meet minimum touch target size:
- Minimum Size: 44x44 pixels (Apple HIG)
- Preferred Size: 48x48 pixels (Material Design)
- Spacing: 8px minimum between targets
<motion.div
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
onDragEnd={(_, info) => {
if (info.offset.y > 100) {
onMinimize()
}
}}
>
{/* Call content */}
</motion.div>const handleTouchStart = () => {
longPressTimer = setTimeout(() => {
showOptions()
navigator.vibrate(50) // Haptic feedback
}, 500)
}<motion.div
onDoubleTap={() => {
toggleFullscreen()
}}
>
{/* Video */}
</motion.div>import { Haptics, ImpactStyle } from '@capacitor/haptics'
// Light feedback for button taps
await Haptics.impact({ style: ImpactStyle.Light })
// Medium feedback for important actions
await Haptics.impact({ style: ImpactStyle.Medium })
// Heavy feedback for errors
await Haptics.impact({ style: ImpactStyle.Heavy })import { MobileCallScreen } from '@/components/calls/mobile/MobileCallScreen'
import { MobilePiPOverlay } from '@/components/calls/mobile/MobilePiPOverlay'
import { callKitManager } from '@/platforms/capacitor/src/native/call-kit'
import { voipPushManager } from '@/lib/voip-push'
function App() {
useEffect(() => {
// Initialize CallKit
callKitManager.initialize('nChat')
// Initialize VoIP Push
voipPushManager.initialize()
}, [])
return (
<>
<MobileCallScreen
isVisible={isCallActive}
onMinimize={() => setMinimized(true)}
/>
<MobilePiPOverlay
isActive={isMinimized}
onExpand={() => setMinimized(false)}
/>
</>
)
}// When VoIP push received
voipPushManager.addListener('incoming_call', async (payload) => {
// Report to CallKit
const callUuid = await callKitManager.reportIncomingCall({
uuid: payload.callId,
handle: payload.callerId,
callerDisplayName: payload.callerName,
hasVideo: payload.callType === 'video',
})
// Update app state
updateIncomingCall(payload)
})async function initiateCall(targetUser, callType) {
const callId = generateCallId()
// Start call in CallKit
await callKitManager.startOutgoingCall({
uuid: callId,
handle: targetUser.id,
hasVideo: callType === 'video',
})
// Initiate WebRTC connection
await startWebRTCCall(callId, targetUser, callType)
}Issue: CallKit UI not appearing
Solutions:
- Check background modes are enabled in Xcode
- Verify CallKit initialization
- Check for correct entitlements
- Ensure app has microphone permissions
// Check permissions
const status = await Permissions.query({ name: 'microphone' })
if (status.state !== 'granted') {
await Permissions.request({ name: 'microphone' })
}Issue: Cannot make calls on Android
Solutions:
- Request permissions at runtime
- Add all required permissions to manifest
- For Android 12+, add
READ_PHONE_NUMBERS
const { granted } = await CallKit.requestPermissions()
if (!granted) {
// Show settings screen
openAppSettings()
}Issue: Picture-in-Picture mode fails
Solutions:
- Check browser/OS support
- Verify video element exists
- Check PiP permissions
const { isPiPSupported } = useMobilePiP()
if (!isPiPSupported) {
console.warn('PiP not supported')
// Use fallback overlay
}Issue: Calls end when app is backgrounded
Solutions:
- Verify background modes configured
- Check WebRTC connection keepalive
- Implement reconnection logic
// Reconnection logic
peerConnection.addEventListener('connectionstatechange', () => {
if (peerConnection.connectionState === 'disconnected') {
attemptReconnection()
}
})Issue: Calls drain battery quickly
Solutions:
- Enable battery optimization
- Reduce video quality
- Use audio-only mode
- Disable background blur
const { isLowBattery } = useBatteryStatus()
if (isLowBattery) {
// Switch to audio only
toggleVideo(false)
// Reduce frame rate
setFrameRate(15)
}- Minimum Version: iOS 13.0+
- CallKit: Available on all devices
- PiP: Requires iPadOS 9+ or iOS 14+ (iPhone)
- Background Audio: Unlimited while call active
- VoIP Push: Uses APNs with VoIP certificate
- Minimum Version: Android 6.0+ (API 23)
- Telecom Framework: API 23+
- PiP: Android 8.0+ (API 26)
- Background: Requires foreground service
- Push: Uses FCM high-priority messages
- PiP: Chrome 71+, Edge 79+
- Background: Service Workers for push
- Limited: No native call integration
- Fallback: Use notification API
- Call Setup Time: < 2 seconds
- Video Start Time: < 1 second
- Audio Latency: < 150ms
- Frame Rate: 24-30 fps (battery dependent)
- Resolution: 480p-720p (network dependent)
- Battery Life: > 2 hours continuous video call
- Data Usage: ~500 MB/hour video, ~50 MB/hour audio
- Test on physical devices (iOS and Android)
- Test VoIP push notifications
- Test PiP mode across platforms
- Optimize battery usage
- Test network switching (WiFi to cellular)
- Test long-duration calls (> 1 hour)
- Load test with multiple participants
- Apple CallKit Documentation
- Android Telecom Framework
- WebRTC Native Code
- Capacitor Plugins
- Push Notifications Guide
Last Updated: January 30, 2026