omicall_flutter_plugin 3.4.0
omicall_flutter_plugin: ^3.4.0 copied to clipboard
OmiKit Flutter is wrapper SDK for OmiCall for Application easy integrate features of Omicall Like Call App2App/App2Phone, Video Call.
π¦ OMICALL SDK FOR Flutter #
The OmiKit exposes the π¦ omicall_flutter_plugin library.
The most important part of the framework is :
- β Help to easy integrate with Omicall.
- β Easy custom Call UI/UX.
- β Optimize codec voip for you.
- β Full interface to interactive with core function like sound/ringtone/codec.
- β Built-in Call Quality Monitoring (MOS score tracking)
π¦ SDK Versions #
| Component | Version |
|---|---|
| omicall_flutter_plugin | 3.4.0 |
Android OmiSDK (io.omicrm.vihat:omi-sdk) |
2.9.1 |
| iOS OmiKit | 1.12.1 (CocoaPods or Swift Package Manager) |
| Android minimum | minSdk 24, compileSdk 35 |
| iOS minimum | 13.0 |
π Status #
Currently active maintenance and improve performance
π Table of Contents #
- Quick Start
- Architecture Overview
- Configuration
- Call Flow Lifecycle
- API Reference
- Event Listeners
- Error Codes
- Troubleshooting
- Migration Guide
Quick Start #
Install via pubspec.yaml:
dependencies:
omicall_flutter_plugin: ^3.4.0
Minimum setup:
// 1. Start services
await OmicallClient.instance.startServices();
// 2. Login
final login = await OmicallClient.instance.register(
userName: "your_username",
password: "your_password",
realm: "your_realm",
fcmToken: fcmToken, // FCM token (Android) / APNS token (iOS)
host: "", // optional SIP proxy; empty = OMICall default
isVideo: false,
projectId: "your_firebase_project_id",
);
if (!login.isSuccess) return; // login.message: MISSING_PARAMS | INIT_FAILED | NETWORK_UNAVAILABLE
// 3. Make a call β returns a JSON string {"status", "_id", "message"}
final result = await OmicallClient.instance.startCall(phoneNumber, false);
Architecture Overview #
π System Architecture #
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Flutter Layer β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Your Flutter App (UI/UX) β β
β β βββββββββββββββ ββββββββββββββββ ββββββββββββββββββββ β β
β β β Call Screen β β Dial Screen β β Settings Screen β β β
β β ββββββββ¬βββββββ ββββββββ¬ββββββββ ββββββββββ¬ββββββββββ β β
β βββββββββββΌββββββββββββββββββΌβββββββββββββββββββββΌββββββββββββ β
β β β β β
β βββββββββββββββββββ΄βββββββββββββββββββββ β
β β β
ββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββ€
β OmicallClient API β
β ββββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββββ β
β β β’ startCall() β’ toggleAudio() β’ getCurrentUser() β β
β β β’ endCall() β’ toggleSpeaker() β’ getGuestUser() β β
β β β’ joinCall() β’ toggleHold() β’ getUserInfo() β β
β β β’ sendDTMF() β’ toggleVideo() β’ logout() β β
β β β’ switchCamera() β’ transferCall() β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β
β ββββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββββ β
β β Event Listeners & Helpers β β
β β β’ callStateChangeEvent β’ CallQualityTracker β β
β β β’ setCallQualityListener β’ CallQualityInfo β β
β β β’ setMuteListener β’ VideoController β β
β β β’ setSpeakerListener β’ CameraView β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββ
β Method Channel
ββββββββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββ
β Flutter Plugin Bridge β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Platform: Android (Kotlin) Platform: iOS (Swift/ObjC) β β
β β β’ OmicallsdkPlugin β’ SwiftOmikitPlugin β β
β β β’ Event Broadcasting β’ Event Broadcasting β β
β β β’ Permission Handling β’ CallKit Integration β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββ
β Native SDK Layer β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Android SDK (vn.vihat.omicall.omisdk) β β
β β ββββββββββββββββ ββββββββββββββββ ββββββββββββββββββββ β β
β β β SipService β β CallManager β β AudioManager β β β
β β β (OMISIP) β β β β β β β
β β ββββββββββββββββ ββββββββββββββββ ββββββββββββββββββββ β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β iOS SDK (OmiKit) β β
β β ββββββββββββββββ ββββββββββββββββ ββββββββββββββββββββ β β
β β β OMISIPLib β β CallManager β β PushKitManager β β β
β β β (OMISIP) β β β β β β β
β β ββββββββββββββββ ββββββββββββββββ ββββββββββββββββββββ β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
ββββββββββββ΄βββββββββββ
β Network Layer β
β β’ SIP Protocol β
β β’ RTP/SRTP β
β β’ STUN/TURN β
βββββββββββββββββββββββ
π Data Flow #
User Action β Flutter UI β OmicallClient β Platform Channel
β Native SDK β SIP Server β Remote Peer
Remote Peer β Native SDK β Event Broadcast β OmicallClient
β Flutter Listeners β UI Update
Call Flow Lifecycle #
π Outgoing Call Flow #
βββββββββββ
β Flutter β startCall(phoneNumber, isVideo)
β App β ββββββββββββββββββββββββββββββββββββββββββ
βββββββββββ β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Call State Changes β
β β
β UNKNOWN (0) β
β β β
β ββββ Check permissions & account status β
β β β
β βΌ β
β CALLING (1) ββ startCallSuccess (status=8) β
β β "Initiating call..." β
β β β±οΈ Ring tone starts β
β β β
β βΌ β
β EARLY (3) β
β β "Ringing..." β
β β β±οΈ Waiting for remote peer β
β β β
β βΌ β
β CONNECTING (4) β
β β "Connecting..." β
β β π Remote peer answered β
β β β
β βΌ β
β CONFIRMED (5) β
β β "Active call" β
β β π Call quality monitoring starts β
β β β±οΈ Call timer starts β
β β ποΈ Audio/Video stream active β
β β β
β β ββββββββββββββββββββββββββββββββββββ β
β β β User can perform: β β
β β β β’ toggleAudio() - Mute/Unmute β β
β β β β’ toggleSpeaker() - Speaker on β β
β β β β’ toggleHold() - Hold/Unhold β β
β β β β’ toggleVideo() - Video on/off β β
β β β β’ sendDTMF() - Send numbers β β
β β β β’ transferCall() - Transfer β β
β β β β’ endCall() - Hang up β β
β β ββββββββββββββββββββββββββββββββββββ β
β β β
β βΌ β
β DISCONNECTED (6) β
β β "Call ended" β
β β π Call info returned β
β β β±οΈ Final duration calculated β
β β π§Ή Cleanup resources β
β β β
β βΌ β
β UNKNOWN (0) β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Timeline Example:
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββΊ
0s 2s 4s 7s 45s 47s
β β β β β β
UNKNOWN CALLING EARLY CONNECTING CONFIRMED DISCONNECTED
"Dialing" "Ringing" "Answered" "Talking" "Ended"
π² Incoming Call Flow #
βββββββββββββββ
β Push Notif β Firebase/APNS Push
β or CallKit β ββββββββββββββββββββββββββββββββββββββββ
βββββββββββββββ β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Call State Changes β
β β
β UNKNOWN (0) β
β β β
β βΌ β
β INCOMING (2) β
β β "Incoming call from XXX" β
β β π Ringtone plays β
β β π± CallKit/Notification shows β
β β β
β β ββββββββββββββββββββββββββββββββββββ β
β β β User Actions: β β
β β β β’ joinCall() βββββ β β
β β β β’ endCall() ββββββΌβββββ β β
β β ββββββββββββββββββββββββββββββββββββ β
β β β β β
β β βββββββββββββββββββββ β β
β βΌ β β
β CONNECTING (4) β β
β β "Answering..." β β
β β π Audio setup β β
β β β β
β βΌ β β
β CONFIRMED (5) β β
β β "Active call" β β
β β π Quality monitoring β
β β ποΈ Audio stream active β
β β β β
β β [Same actions as β β
β β outgoing call] β β
β β β β
β βΌ βΌ β
β DISCONNECTED (6) ββββββββββ DISCONNECTED (6) β
β β "Answered & Ended" "Rejected" β
β β π Call duration π No duration β
β β β
β βΌ β
β UNKNOWN (0) β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Timeline Example (Answer):
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββΊ
0s 1s 3s 30s 32s
β β β β β
UNKNOWN INCOMING CONNECTING CONFIRMED DISCONNECTED
"Ringing" "Answered" "Talking" "Ended"
Timeline Example (Reject):
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββΊ
0s 1s 4s
β β β
UNKNOWN INCOMING DISCONNECTED
"Ringing" "Rejected"
π Hold State Flow #
CONFIRMED (5)
β
ββ toggleHold() βββΊ setHoldListener(true) (HOLD event)
β setCallHoldChangedListener(...) (CALL_HOLD_CHANGED, per call)
β β
β ββ "Call on hold" β the customer hears nothing
β β
β βββββ toggleHold() βββ€
βΌ
setHoldListener(false) β "Call resumed"
Hold is reported through the
HOLD/CALL_HOLD_CHANGEDlisteners.CALL_STATE_CHANGEDwith status 7 is not emitted on Android, so do not rely on it.toggleHold()is ignored while a consult transfer is running (the SDK keeps the customer on hold).
Configuration #
π οΈ STEP 1: Config native file #
π Android:
π - Config gradle file
- The Android SDK is served from GitHub Packages and needs credentials (ask the OMI team for the
token). Put them in
android/local.propertiesβ never commit this file:
omicallUsername=omicall
omicallPassword=YOUR_GITHUB_PAT
android/build.gradle.kts(Flutter 3.29+ default), as in example/android/build.gradle.kts:
// Load local.properties for OMICall SDK credentials
val localProperties = java.util.Properties()
val localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.inputStream().use { localProperties.load(it) }
}
allprojects {
repositories {
google()
mavenCentral()
maven {
url = uri("https://maven.pkg.github.com/omicall/OMICall-SDK")
credentials {
username = localProperties.getProperty("omicallUsername")
?: project.findProperty("omicallUsername") as? String ?: "omicall"
password = localProperties.getProperty("omicallPassword")
?: project.findProperty("omicallPassword") as? String ?: ""
}
authentication {
create<BasicAuthentication>("basic")
}
}
}
}
- Groovy
android/build.gradleequivalent:
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withInputStream { localProperties.load(it) }
}
allprojects {
repositories {
google()
mavenCentral()
maven {
url "https://maven.pkg.github.com/omicall/OMICall-SDK"
credentials {
username = localProperties.getProperty('omicallUsername') ?: 'omicall'
password = localProperties.getProperty('omicallPassword') ?: ''
}
authentication {
basic(BasicAuthentication)
}
}
}
}
android/app/build.gradle.ktsβ apply Google Services (Firebase push), as in example/android/app/build.gradle.kts:
plugins {
id("com.android.application")
id("kotlin-android")
id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services") version "4.4.2"
}
Groovy: apply plugin: 'com.google.gms.google-services' plus
classpath 'com.google.gms:google-services:4.4.2' in the root buildscript { dependencies { } }.
A 401 Unauthorized while resolving io.omicrm.vihat:omi-sdk means the token is missing or expired.
π - Config AndroidManifest.xml file
β οΈ IMPORTANT: This configuration is required for Android 13+ (API 33+) and Android 14+ (API 34+). Missing permissions will cause crashes or prevent calls from working.
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Hardware features - telephony is optional for VoIP apps -->
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<!-- Basic permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- Android 14+ (API 34+) - REQUIRED for foreground service types -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<!-- Android 13+ (API 33+) - REQUIRED for notifications -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- CRITICAL: Explicitly remove FOREGROUND_SERVICE_CAMERA to prevent crashes on Android 14-15 -->
<!-- See CHANGELOG 3.3.2: this permission causes crashes on devices without camera -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" tools:node="remove" />
<!-- Connection Service for Android 15-16 -->
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<application
android:name="${applicationName}"
android:label="Your App Name"
android:enableOnBackInvokedCallback="true"
android:alwaysRetainTaskState="true"
android:largeHeap="true"
android:exported="true"
android:supportsRtl="true"
android:allowBackup="false"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:windowSoftInputMode="adjustResize"
android:showOnLockScreen="true"
android:launchMode="singleTask"
android:largeHeap="true"
android:alwaysRetainTaskState="true"
android:supportsPictureInPicture="true"
android:resizeableActivity="true"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation">
<!-- supportsPictureInPicture / resizeableActivity / configChanges: only needed for
video Picture-in-Picture (see "Picture-in-Picture"); use false if you do not use PiP -->
<!-- Your theme configuration -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<!-- Main launcher intent -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Incoming call intent -->
<intent-filter>
<action android:name="android.intent.action.CALL" />
<category android:name="android.intent.category.DEFAULT" />
<data
android:host="incoming_call"
android:scheme="omisdk" />
</intent-filter>
</activity>
<!-- Firebase Message Receiver -->
<receiver
android:name="vn.vihat.omicall.omisdk.receiver.FirebaseMessageReceiver"
android:exported="true"
android:enabled="true"
tools:replace="android:exported"
android:permission="com.google.android.c2dm.permission.SEND">
<intent-filter>
<action android:name="com.google.android.c2dm.intent.RECEIVE" />
</intent-filter>
</receiver>
<!-- Notification Service -->
<service
android:name="vn.vihat.omicall.omisdk.service.NotificationService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="microphone|phoneCall">
<!-- IMPORTANT: Only microphone|phoneCall, NO camera (see CHANGELOG 3.3.2) -->
</service>
<!-- Flutter plugin metadata -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>
π Critical Notes:
-
FOREGROUND_SERVICE_CAMERA removal is REQUIRED:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" tools:node="remove" />- Without
tools:node="remove", app WILL CRASH on Android 14-15 devices without camera - See CHANGELOG 3.3.2 for details
- Without
-
Android 14+ requires foregroundServiceType:
- NotificationService:
android:foregroundServiceType="microphone|phoneCall"(NOT camera) - (
foregroundServiceTypehas no effect on a<receiver>; older guides that putremoteMessagingon FirebaseMessageReceiver can drop it)
- NotificationService:
-
Android 13+ requires POST_NOTIFICATIONS:
- Request this permission at runtime for notifications to work
- Add to your permissions request flow
-
Android 15-16 requires MANAGE_OWN_CALLS:
- For connection service integration
-
Optional β Disable saving calls to device call history (
WRITE_CALL_LOG): By default the SDK may requestWRITE_CALL_LOGto log calls in the native call history. If your app does not want calls saved to the device's call history, explicitly remove this permission:<uses-permission android:name="android.permission.WRITE_CALL_LOG" tools:node="remove" />Note:
xmlns:tools="http://schemas.android.com/tools"must be declared in the<manifest>tag (already included in the manifest template above).
Runtime Permission Request Example:
// In your MainActivity or permissions handler
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestPermissions(arrayOf(
Manifest.permission.POST_NOTIFICATIONS
), REQUEST_CODE_NOTIFICATIONS)
}
π - Config MainActivity file
- In the
MainActivity.ktfile we need you to add the following configurations
import androidx.core.app.ActivityCompat.requestPermissions
import android.app.Activity
import io.flutter.embedding.android.FlutterActivity
import vn.vihat.omicall.omicallsdk.OmicallsdkPlugin
import android.Manifest
import androidx.activity.result.contract.ActivityResultContracts
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import android.os.Bundle
import android.content.Intent
import android.content.res.Configuration
import android.util.Log
import vn.vihat.omicall.omisdk.utils.SipServiceConstants
class MainActivity: FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
try {
val callPermissions = arrayOf(Manifest.permission.RECORD_AUDIO)
if(!isGrantedPermission(Manifest.permission.RECORD_AUDIO)){
requestPermissions(this,callPermissions,0)
}
val isIncomingCall = intent.getBooleanExtra(SipServiceConstants.ACTION_IS_INCOMING_CALL, false)
OmicallsdkPlugin.onOmiIntent(this, intent)
} catch (e: Throwable) {
e.printStackTrace()
}
}
override fun onNewIntent(intent: Intent){
super.onNewIntent(intent);
OmicallsdkPlugin.onOmiIntent(this, intent)
}
override fun onDestroy() {
super.onDestroy()
OmicallsdkPlugin.onDestroy()
}
override fun onResume(){
super.onResume()
OmicallsdkPlugin.onResume(this);
}
fun isGrantedPermission(permission: String): Boolean {
return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
OmicallsdkPlugin.onRequestPermissionsResult(requestCode, permissions, grantResults, this)
}
// Only for video Picture-in-Picture (see "Picture-in-Picture")
override fun onUserLeaveHint() {
super.onUserLeaveHint()
OmicallsdkPlugin.onUserLeaveHint(this)
}
override fun onPictureInPictureModeChanged(isInPip: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPip, newConfig)
OmicallsdkPlugin.onPictureInPictureModeChanged(isInPip)
}
// Your config
}
- β¨ Setup push notification : Only support Firebase for remote push notification.
-
β Add
google-service.jsoninandroid/app(For more information, you can refer firebase_core) -
β Add Fire Messaging to receive
fcm_token(You can refer firebase_messaging to setup notification for Flutter) -
β For more setting information, please refer Config Push for Android
-
Now let's continue configuring iOS, let's go π
π iOS: CocoaPods or Swift Package Manager
The plugin supports both. CocoaPods stays the default; with Swift Package Manager enabled
(Flutter's upcoming default) Flutter takes ios/omicall_flutter_plugin/Package.swift, which pulls
OmiKit from https://github.com/VIHATTeam/OmiKit.git (1.12.x).
# your app's pubspec.yaml β enable SPM for this project only
flutter:
config:
enable-swift-package-manager: true
or globally: flutter config --enable-swift-package-manager.
With SPM, the Objective-C omicall_flutter_plugin-Swift.h header does not exist. In an
Objective-C AppDelegate.m import the plugin like this (works with both):
#if __has_include(<omicall_flutter_plugin/omicall_flutter_plugin-Swift.h>)
#import <omicall_flutter_plugin/omicall_flutter_plugin-Swift.h> // CocoaPods
#else
@import omicall_flutter_plugin; // Swift Package Manager
#endif
A Swift AppDelegate keeps import omicall_flutter_plugin / import OmiKit in both cases.
The setup below (AppDelegate, Info.plist, capabilities) is the same for both.
π iOS(Object-C):
- π Assets: Add
call_imageinto assets folder to update callkit image. We only support png style (This will help show your application icon on iOS CallKit when a call comes in)
- π Add instance variables in AppDelegate.h (ivars, not file-scope globals β globals in a header cause duplicate-symbol link errors):
#import <Flutter/Flutter.h>
#import <UIKit/UIKit.h>
#import <UserNotifications/UserNotifications.h>
#import <OmiKit/OmiKit-umbrella.h>
#import <OmiKit/Constants.h>
@interface AppDelegate : FlutterAppDelegate<UIApplicationDelegate> {
PushKitManager *pushkitManager;
CallKitProviderDelegate *provider;
PKPushRegistry *voipRegistry;
}
@end
- π Edit AppDelegate.m:
#import <OmiKit/OmiKit.h>
#if __has_include(<omicall_flutter_plugin/omicall_flutter_plugin-Swift.h>)
#import <omicall_flutter_plugin/omicall_flutter_plugin-Swift.h> // CocoaPods
#else
@import omicall_flutter_plugin; // Swift Package Manager
#endif
- π Add these lines into
didFinishLaunchingWithOptions:
[OmiClient setEnviroment:KEY_OMI_APP_ENVIROMENT_SANDBOX userNameKey:@"extension" maxCall:1 callKitImage: @"call_image" typePushVoip:@"default" representName:@"OMICALL"];
provider = [[CallKitProviderDelegate alloc] initWithCallManager: [OMISIPLib sharedInstance].callManager];
voipRegistry = [[PKPushRegistry alloc] initWithQueue:dispatch_get_main_queue()];
pushkitManager = [[PushKitManager alloc] initWithVoipRegistry:voipRegistry];
if (@available(iOS 10.0, *)) {
[UNUserNotificationCenter currentNotificationCenter].delegate = (id<UNUserNotificationCenterDelegate>) self;
}
π Notes:
- To custom callkit image, you need add image into assets and paste image name into setEnviroment function.
- The variable representName is not required. If it has a value, when a call comes in, by default this name will be displayed on callKit. If nothing is transmitted, internal calls will display the Employee's name or the employee's internal
- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler {
bool value = [SwiftOmikitPlugin processUserActivityWithUserActivity:userActivity];
return value;
}
// This action is used to close ongoing calls when the user kills the app
- (void)applicationWillTerminate:(UIApplication *)application {
@try {
[OmiClient OMICloseCall];
}
@catch (NSException *exception) {
}
}
- π Add these lines into
Info.plist:
<key>NSCameraUsageDescription</key>
<string>Need camera access for video call functions</string>
<key>NSMicrophoneUsageDescription</key>
<string>Need microphone access for make Call</string>
<!-- VoIP push, call audio in background (also required for video Picture-in-Picture) -->
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>fetch</string>
<string>remote-notification</string>
<string>voip</string>
</array>
<!-- Calls started from the iOS Recents list (setCallLogListener) -->
<key>NSUserActivityTypes</key>
<array>
<string>INStartCallIntent</string>
</array>
-
π In Xcode β Signing & Capabilities add Push Notifications and Background Modes (Audio, Voice over IP, Remote notifications).
-
π‘ Save token for
OmiClient: if you addedfirebase_messagingin your project so you don't need add these lines.
- (void)application:(UIApplication*)app didRegisterForRemoteNotificationsWithDeviceToken:(NSData*)devToken
{
// parse token bytes to string
const char *data = [devToken bytes];
NSMutableString *token = [NSMutableString string];
for (NSUInteger i = 0; i < [devToken length]; i++)
{
[token appendFormat:@"%02.2hhX", data[i]];
}
// print the token in the console.
NSLog(@"Push Notification Token: %@", [token copy]);
[OmiClient setUserPushNotificationToken:[token copy]];
}
β¨ Push on iOS
- β
Incoming calls arrive through APNS VoIP (PushKit, handled by
PushKitManager) and CallKit. - β
Pass the APNS token as
fcmTokentoregister()/registerWithApiKey()(await FirebaseMessaging.instance.getAPNSToken()when you usefirebase_messaging). - β
Add
GoogleService-Info.plistonly if your app uses Firebase on iOS. - β For more setting information, please refer Config Push for iOS
π iOS(Swift):
-
π Notes: The configurations are similar to those for object C above, with only a slight difference in the syntax of the funcs
-
π Add variables in Appdelegate.swift:
import OmiKit
import PushKit
import NotificationCenter
import omicall_flutter_plugin
var pushkitManager: PushKitManager?
var provider: CallKitProviderDelegate?
var voipRegistry: PKPushRegistry?
- π Add these lines into
didFinishLaunchingWithOptions:
OmiClient.setEnviroment(KEY_OMI_APP_ENVIROMENT_SANDBOX, userNameKey: "extension", maxCall: 1, callKitImage: "call_image", typePushVoip: "default", representName: "OMICALL")
provider = CallKitProviderDelegate.init(callManager: OMISIPLib.sharedInstance().callManager)
voipRegistry = PKPushRegistry.init(queue: .main)
pushkitManager = PushKitManager.init(voipRegistry: voipRegistry)
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
return SwiftOmikitPlugin.processUserActivity(userActivity: userActivity)
}
// Close ongoing calls when the user kills the app
override func applicationWillTerminate(_ application: UIApplication) {
OmiClient.omiCloseCall()
}
-
π
Info.plistand capabilities: same as the Objective-C section above. -
π‘ Save token for
OmiClient: if you addedfirebase_messagingin your project so you don't need add these lines.
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let deviceTokenString = deviceToken.hexString
OmiClient.setUserPushNotificationToken(deviceTokenString)
}
extension Data {
var hexString: String {
let hexString = map { String(format: "%02.2hhx", $0) }.joined()
return hexString
}
}
β¨ Push on iOS
- β
Incoming calls arrive through APNS VoIP (PushKit, handled by
PushKitManager) and CallKit. - β
Pass the APNS token as
fcmTokentoregister()/registerWithApiKey()(await FirebaseMessaging.instance.getAPNSToken()when you usefirebase_messaging). - β
Add
GoogleService-Info.plistonly if your app uses Firebase on iOS. - β For more setting information, please refer Config Push for iOS
β Important release note
We support 2 environments. So you need set correct key in Appdelegate.
- KEY_OMI_APP_ENVIROMENT_SANDBOX support on debug mode
- KEY_OMI_APP_ENVIROMENT_PRODUCTION support on release mode
- Visit on web admin to select correct enviroment.
π οΈ STEP 2: Integrate into Flutter code π #
Request permission
- π Request these permissions before making a call (e.g. with
permission_handler):
+ Permission.microphone
+ Permission.camera (video calls only)
+ Permission.notification (Android 13+, incoming-call notification)
On Android,
register()also requests RECORD_AUDIO, POST_NOTIFICATIONS and β whenisVideo: trueβ CAMERA. PassisVideo: falsefor audio-only apps.
- π Set up for Firebase:
await Firebase.initializeApp();
// If you only use Firebase on Android. Add these line `if (Platform.isAndroid)`
// Because we use APNS to push notification on iOS so you don't need add Firebase for iOS.
-
π Important function.
-
π Start Serivce: OmiKit need start services and register some events.
// Call in the root widget
OmicallClient.instance.startServices();
π‘ You need to log in to OMI's switchboard system, we provide you with 2 functions with 2 different functions:
π Notes: The information below is taken from the API, you should connect with our Technical team for support
- β
register: for employees. They can call any telecommunications number allowed in your business on the OMI system.
String? token = await FirebaseMessaging.instance.getToken();
if (Platform.isIOS) {
token = await FirebaseMessaging.instance.getAPNSToken();
}
final result = await OmicallClient.instance.register(
userName: "100",
password: "******",
realm: "your_realm",
fcmToken: token ?? "",
host: "", // optional SIP proxy; empty = OMICall default
isVideo: true,
projectId: "", // optional Firebase project id
);
if (!result.isSuccess) {
// result.message: NETWORK_UNAVAILABLE | MISSING_PARAMS | INIT_FAILED
showError(result.message);
return;
}
// Login successful β proceed
- β
registerWithApiKey: usually for your customers, who only call a fixed number (for example your hotline).
final result = await OmicallClient.instance.registerWithApiKey(
fullName: "John",
userUuid: "user-uuid",
apiKey: "your_api_key",
phone: "0901234567",
fcmToken: token ?? "",
isVideo: true,
);
if (!result.isSuccess) showError(result.message);
initCallWithUserPassword/initCallWithApiKeystill work but are deprecated since 3.4.0 β they return the raw JSON string. See Migration Guide.
- β Get the ongoing call when the app is opened (e.g. from a call notification / killed state):
final result = await OmicallClient.instance.getInitialCall();
// A Map with the call info when a call exists;
// otherwise `false` (iOS) or null (Android).
final hasCall = result is Map && result.isNotEmpty;
-
β Config push notification:
OmicallClient.instance.configPushNotification( notificationIcon: "calling_face", // Android: notification icon (res/drawable) prefix: "Cuα»c gα»i tα»i tα»«: ", // Android: incoming-call text prefix userImage: "calling_face", // Android: default avatar (res/drawable) prefixMissedCallMessage: "Cuα»c gα»i nhα»‘ tα»«", // both: missed-call message prefix missedCallTitle: "Cuα»c gα»i nhα»‘", // iOS: missed-call title userNameKey: "uuid", // Android: uuid | full_name | extension channelId: "channelid.callnotification", // Android: used as <channelId>-inbound / -missed representName: "", // Android: name shown for incoming calls (optional) isUseIntentFilter: true, // Android: open the app through the intent filter );Key Android iOS notificationIcon,prefix,userImage,userNameKey,channelId,representName,isUseIntentFilterβ β (iOS takes userNameKey/representNamefromsetEnviromentin AppDelegate)prefixMissedCallMessageβ β (only when missedCallTitleis also set)missedCallTitleβ β incomingBackgroundColor,incomingAcceptButtonImage,incomingDeclineButtonImage,backImage,audioNotificationDescription,videoNotificationDescriptionaccepted, not used by the current SDK β
API Reference #
Core Functions #
π Authentication
register
Login for employees (can call any number allowed in business)
final OmiRegisterResult result = await OmicallClient.instance.register(
userName: String, // required
password: String, // required
realm: String, // required
fcmToken: String, // required β FCM token (Android) / APNS token (iOS)
host: String, // optional, default "" = OMICall default SIP proxy
isVideo: bool, // optional, default true
projectId: String, // optional, default ""
);
registerWithApiKey
Login for customers (call a fixed number, e.g., hotline)
final OmiRegisterResult result = await OmicallClient.instance.registerWithApiKey(
fullName: String, // required
userUuid: String, // required
apiKey: String, // required
phone: String, // required
fcmToken: String, // required
isVideo: bool, // optional, default true
projectId: String, // optional, default ""
);
Returns Future<OmiRegisterResult> β same on Android and iOS:
statusCode |
message |
Meaning |
|---|---|---|
200 |
INIT_SUCCESS |
Login successful (result.isSuccess == true) |
400 |
MISSING_PARAMS |
Required fields missing |
500 |
INIT_FAILED |
Wrong credentials or SDK error |
600 |
NETWORK_UNAVAILABLE |
No internet connection |
iOS: the SIP stack registers asynchronously, so
registerreturns200once the credentials are set; a wrong password shows up as a failed call, not as500.
initCallWithUserPassword / initCallWithApiKey (deprecated)
Same as register / registerWithApiKey but return the raw JSON string {"status": Int, "message": String}.
logout
Logout current user
OmicallClient.instance.logout();
Device info & backend registration (3.4.0)
Same names and behaviour as the React Native SDK β use them to check or add this device on the OMI backend yourself:
final client = OmicallClient.instance;
final deviceId = await client.getDeviceId(); // value the SDK sends when adding the device
final appId = await client.getAppId(); // bundle id / package name
final fcm = await client.getFcmToken(); // push token registered by the SDK
final voip = await client.getVoipToken(); // iOS PushKit token, null on Android
final projectId = await client.getProjectId(); // Firebase project id
final sip = await client.getSipInfo(); // current SIP account
final devices = await client.getOmiDevices(); // List<OmiDeviceInfo> on the backend (HTTP, do not poll)
final boundSip = client.findSipNumberByDeviceId(devices, deviceId);
if (await client.needsReLogin()) {
// logged in locally but the backend has no record of this device β logout + register again
}
| Function | Returns |
|---|---|
getDeviceId / getAppId / getFcmToken / getVoipToken / getProjectId / getSipInfo |
String? |
getOmiDevices |
List<OmiDeviceInfo> (empty when not logged in or on network error) |
isCurrentDeviceRegistered / needsReLogin |
bool (false when not logged in) |
findSipNumberByDeviceId(devices, deviceId) |
String? (sync helper) |
Call Control #
π Start Call (Phone Number)
Initiate outgoing call to any number
final result = await OmicallClient.instance.startCall(
phone, // phone number
_isVideoCall // if true, it's a video call; otherwise, it's an audio call.
);
Returns a JSON string {"status": int, "_id": String, "message": String} on both platforms:
final json = jsonDecode(result as String) as Map;
final started = json['status'] == 8 || json['message'] == 'START_CALL_SUCCESS';
if (started) openCallScreen(); else showError(json['message']);
The status codes differ by platform β branch on message for cross-platform handling.
message |
iOS status |
Android status |
|---|---|---|
START_CALL_SUCCESS β¬
οΈ navigate to the call screen |
8 (also 12 = waiting for endpoint) | 8 |
SWITCHBOARD_REGISTERING (call continues once registered β treat as started) |
β | 8 |
INVALID_UUID |
0 | 401 |
INVALID_PHONE_NUMBER |
1 | 402 |
SAME_PHONE_NUMBER_WITH_PHONE_REGISTER / CAN_NOT_CALL_YOURSELF |
2 | 403 |
MAX_RETRY |
3 | β |
PERMISSION_DENIED |
4 | 405 (audio) / 406 (video) |
COULD_NOT_FIND_END_POINT (log in first) |
5 | β |
SWITCHBOARD_NOT_CONNECTED |
β | 404 |
REGISTER_ACCOUNT_FAIL |
6 | β |
START_CALL_FAIL |
7 | β |
HAVE_ANOTHER_CALL |
9 | 400 |
ACCOUNT_TURN_OFF_NUMBER_INTERNAL |
10 | β |
NO_NETWORK |
11 | 408 |
OmiStartCallStatus (iOS codes 0-11) lives in package:omicall_flutter_plugin/action/action_model.dart.
π Start Call (UUID)
Call using user UUID (API key only)
final int status = await OmicallClient.instance.startCallWithUUID(
uuid, // user id
_isVideoCall // call video or audio
);
// iOS: 8 = started (codes as in the table above). Android: raw SDK code, 200 = started.
// 4 = microphone permission denied (both).
π Join Call
Answer incoming call
OmicallClient.instance.joinCall();
π End Call
Hang up current call
final Map? callInfo = await OmicallClient.instance.endCall();
// Declines a ringing incoming call, hangs up otherwise.
// Returns the call info (null when declining on Android, {} when there is no call on iOS).
/* Sample output:
{
"transaction_id": "ea7dff38-cb1e-483d-8576-xxxxxxxxxxxx",
"direction": "inbound",
"source_number": 111,
"destination_number": 110,
"time_start_to_answer": 1682858097393,
"time_end": 1682858152181, // iOS: seconds
"sip_user": 111,
"disposition": "answered",
"code_end_call": 200
}
*/
π Reject / Drop Incoming Call
// 486 Busy Here β other devices of the same extension keep ringing
await OmicallClient.instance.rejectCall();
// 603 Decline β stops ringing on every device of the extension
await OmicallClient.instance.dropCall();
Both hang up the call instead when it is already answered.
π Toggle Audio (Mute/Unmute)
Toggle microphone on/off
OmicallClient.instance.toggleAudio();
π Toggle Speaker
Switch between earpiece and speaker
OmicallClient.instance.toggleSpeaker();
π Toggle Hold
Hold/Unhold current call
OmicallClient.instance.toggleHold();
π Send DTMF
Send a key (0-9, *, #)
OmicallClient.instance.sendDTMF(value);
π Transfer Call
Forward call to another employee
final bool sent = await OmicallClient.instance.transferCall(phoneNumber: "101");
The PBX result arrives in setTransferStatusListener (see below). Since 3.4.0 (Android SDK 2.9.1)
a failed transfer no longer ends the call on Android: you get TRANSFER_STATUS with
statusCode: 500, isFinal: true and the call stays up.
π Consult (Attended) Transfer β NEW 3.4.0
Talk to a colleague before handing the customer over (answered audio calls only).
final client = OmicallClient.instance;
client.setConsultStateListener((e) {
// e.state: dialing β ringing β answered β ended; e.statusCode: SIP code
if (e.state == OmiConsultState.answered) showCompleteButton();
if (e.state == OmiConsultState.ended) hideConsultUi();
});
client.setTransferStatusListener((e) {
if (e.isFinal) e.isSuccess ? closeCallScreen() : showError("Transfer failed (${e.statusCode})");
});
// 1. Hold the customer and call extension 102
final status = await client.startConsultCall("102");
if (status != OmiConsultStartStatus.success) showError(status.name);
// 2a. Colleague answered β hand the customer over
await client.completeConsultTransfer();
// 2b. β¦or go back to the customer (taken off hold)
await client.cancelConsult();
| Function | Returns | Description |
|---|---|---|
startConsultCall(extension, {extraHeaders}) |
OmiConsultStartStatus |
Hold the customer and call extension |
completeConsultTransfer({extraHeaders}) |
bool |
Hang up the consult, transfer the customer to the same extension. false if not answered yet / customer already ended |
cancelConsult() |
bool |
End the consult, take the customer off hold |
isConsulting() |
bool |
A consult is running |
isConsultOriginalEnded() |
bool |
The customer hung up while the consult is running |
getActiveCalls() |
List<OmiCallSummary> |
Live calls with hold state; consult call has isConsult == true |
OmiConsultStartStatus: success (200), emptyExtension (402), canNotCallYourself (403),
switchboardNotConnected (404), missingAudioPermission (405), noActiveCall (409),
consultInProgress (410), videoCallNotSupported (411), sameAsCustomer (412),
unknown (-1, a code this plugin version does not know).
Rules (same on Android and iOS):
- The consult call never fires
CALL_STATE_CHANGED; follow it withsetConsultStateListener. endCall()during a consult hangs up the customer; usecancelConsult()to cancel.- Colleague rejects / does not answer β the consult ends and the customer is taken off hold automatically.
- Transfer fails or has no result within 30 s β the customer is taken off hold.
toggleHold()is ignored while consulting; mute / DTMF go to the consult call once answered.
π Get Current User
Retrieve logged-in user information
final user = await OmicallClient.instance.getCurrentUser();
// Output: { "extension": "111", "full_name": "John", "avatar_url": "", "uuid": "122aaa" }
π Get Guest User
Retrieve remote user information
final user = await OmicallClient.instance.getGuestUser();
// Output: { "extension": "111", "full_name": "Jane", "avatar_url": "", "uuid": "456bbb" }
π Get User Info (SIP)
Lookup user by phone number
final user = await OmicallClient.instance.getUserInfo(phone: "111");
// Output: { "extension": "111", "full_name": "Alice", "avatar_url": "", "uuid": "789ccc" }
π Audio Output (earpiece / speaker / Bluetooth / headset)
final outputs = await OmicallClient.instance.getOutputAudios(); // List of {name, type}
final current = await OmicallClient.instance.getCurrentAudio(); // List with the active output
await OmicallClient.instance.setOutputAudio(portType: outputs.first["type"]);
OmicallClient.instance.setAudioChangedListener((List<dynamic> audios) {
// AUDIO_CHANGE: the active output changed (Bluetooth connected, headset unplugged, ...)
});
portType is the type value from getOutputAudios(): an int on Android, a String on iOS.
π Other
| Function | Platform | Description |
|---|---|---|
changeTransport(type: "UDP" | "TCP") |
Android | Switch the SIP transport |
getHistoryCallLog() |
iOS | Number of a call started from the iOS Recents list (see Call Log Listener) |
dispose() |
both | Close the call-state stream (only when the app no longer uses the SDK) |
removeβ¦Listener() |
both | Each setβ¦Listener has a matching removeβ¦Listener |
Video Call Functions ππ #
π Note: These functions support video calls only. Make sure you enable video in the initialization functions and when starting a call.
π Switch Camera
Toggle between front/back camera
OmicallClient.instance.switchCamera();
π Toggle Video
Turn video on/off during call
OmicallClient.instance.toggleVideo();
π Remote Video Ready
setVideoListener receives REMOTE_VIDEO_READY when the first remote frame is decoded (Android
and iOS since 3.4.0). registerVideoEvent() / removeVideoEvent() are kept for compatibility and
are no longer needed.
π How video is rendered (3.4.0)
Same as the OMICall store apps:
- Android: each widget attaches its feed as soon as its surface exists and again when the call connects; the picture is fitted 9:16 and the remote ratio follows the size the peer really sends.
- iOS: the widgets are containers for
OMIVideoCallManager, which handles rendering, background/foreground, re-INVITE recovery, PiP and the "reconnecting" overlay. Video starts as soon as both widgets are laid out; it does not wait for CONFIRMED. - Show
LocalCameraView/RemoteCameraViewonly after the call is answered (or for an outgoing call): on iOS the camera starts as soon as both widgets are on screen during a video call. - Do not mirror the local preview yourself (
Transform.scale(scaleX: -1)): the SDK already mirrors the front camera.
π Local Camera Widget
Display your camera view
LocalCameraView(
width: double.infinity,
height: double.infinity,
onCameraCreated: (controller) {
_localController = controller;
},
)
π Remote Camera Widget
Display remote camera view
RemoteCameraView(
width: double.infinity,
height: double.infinity,
onCameraCreated: (controller) {
_remoteController = controller;
},
)
π Refresh Camera
Optional since 3.4.0 (views attach themselves). Safe to call at any time:
_remoteController?.refresh();
_localController?.refresh();
π Picture-in-Picture (3.4.0)
The remote video moves to a floating window when the app goes to the background during a video call; the SDK closes it at call end. Enable it once, before the call:
final supported = await OmicallClient.instance.setVideoPictureInPictureEnabled(true);
OmicallClient.instance.setPictureInPictureListener((isActive) {
setState(() => _hideControls = isActive); // hide buttons inside the PiP window
});
Android β AndroidManifest.xml (MainActivity):
<activity android:name=".MainActivity"
android:supportsPictureInPicture="true"
android:resizeableActivity="true"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" ... />
MainActivity.kt:
override fun onUserLeaveHint() {
super.onUserLeaveHint()
OmicallsdkPlugin.onUserLeaveHint(this)
}
override fun onPictureInPictureModeChanged(isInPip: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPip, newConfig)
OmicallsdkPlugin.onPictureInPictureModeChanged(isInPip)
}
iOS 15+: audio in UIBackgroundModes. To keep sending your camera while in PiP (iOS 16+), add the
entitlement com.apple.developer.avfoundation.multitasking-camera-access (explain it in App
Review notes); without it the other party sees your video paused while audio continues.
π Remote Connection State (iOS, 3.4.0)
The SDK draws a "reconnecting / weak connection" overlay on the remote video. Translate it, or follow the state yourself:
OmicallClient.instance.setRemoteConnectionOverlayTexts({
OmiRemoteConnectionState.reconnecting: "Reconnectingβ¦",
OmiRemoteConnectionState.videoPaused: "Video paused",
});
OmicallClient.instance.setRemoteConnectionListener((e) {
// e.state: connected | videoPaused | weak | reconnecting | localReconnecting | networkSwitching | uplinkPoor
});
π Call Quality Monitoring (NEW) #
The SDK provides built-in call quality tracking using MOS (Mean Opinion Score) and LCN (Loss Connect Number) metrics.
Quick Setup
import 'package:omicall_flutter_plugin/omicall.dart';
import 'package:omicall_flutter_plugin/models/call_quality_info.dart';
import 'package:omicall_flutter_plugin/utils/call_quality_tracker.dart';
class MyCallScreen extends StatefulWidget {
@override
State<MyCallScreen> createState() => _MyCallScreenState();
}
class _MyCallScreenState extends State<MyCallScreen> {
final CallQualityTracker _qualityTracker = CallQualityTracker();
String callQuality = "";
@override
void initState() {
super.initState();
// Set up call quality listener
OmicallClient.instance.setCallQualityListener((data) {
// Parse call quality data using helper
final info = _qualityTracker.parseCallQuality(data);
debugPrint("CallQualityInfo => $info");
// Handle loading indicator (network issue detection)
if (info.shouldShowLoading) {
EasyLoading.show(); // Show loading when network stuck
} else if (info.isNetworkRecovered || info.lcn == 0) {
EasyLoading.dismiss(); // Dismiss when network recovers
}
// Display MOS score
setState(() {
callQuality = info.mosDisplay; // "4.5", "3.2", etc.
});
});
}
@override
void dispose() {
_qualityTracker.reset(); // Reset tracker when screen closes
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
children: [
Text("Call Quality: $callQuality"),
// Display quality level: "Excellent", "Good", "Fair", "Poor", "Bad"
],
),
),
);
}
}
CallQualityInfo Properties
| Property | Type | Description |
|---|---|---|
mos |
double |
MOS score (1.0-5.0) - call quality metric |
mosDisplay |
String |
Formatted MOS for display (e.g., "4.5") |
qualityText |
String |
Quality level: "Excellent", "Good", "Fair", "Poor", "Bad" |
lcn |
int |
Loss Connect Number (connection loss tracking) |
quality |
int |
Quality level (0=good, 1=normal, 2=bad) |
jitter |
double |
Jitter in milliseconds |
latency |
double |
Latency in milliseconds |
packetLoss |
double |
Packet loss percentage |
shouldShowLoading |
bool |
Whether to show loading indicator |
isNetworkRecovered |
bool |
Whether network has recovered |
consecutiveSameLcnCount |
int |
Current consecutive same LCN count |
requestCount |
int |
Request counter (req) from the native quality report |
MOS Score Scale
| MOS Range | Quality Level | Description |
|---|---|---|
| β₯ 4.0 | Excellent | XuαΊ₯t sαΊ―c - Perfect call quality |
| 3.5-4.0 | Good | Tα»t - High quality |
| 3.0-3.5 | Fair | ChαΊ₯p nhαΊn Δược - Acceptable |
| 2.0-3.0 | Poor | KΓ©m - Low quality |
| < 2.0 | Bad | RαΊ₯t kΓ©m - Very poor |
Loading Logic (Network Issue Detection)
The loading indicator is automatically shown/hidden based on LCN tracking.
Android only β iOS always reports lcn: 0 / isNeedLoading: false, so shouldShowLoading never
turns on there (use setRemoteConnectionListener on iOS instead).
Show Loading:
ββ The same non-zero LCN value arrives 4 times in a row (3 repeats)
ββ Indicates network stuck/frozen
Hide Loading:
ββ LCN value changes (network recovered)
ββ LCN value is 0 (no connection loss)
Timeline Example:
Event 1: LCN=5 β Count=0 β No loading
Event 2: LCN=5 β Count=1 β No loading
Event 3: LCN=5 β Count=2 β No loading
Event 4: LCN=5 β Count=3 β β οΈ SHOW LOADING (network stuck!)
Event 5: LCN=6 β Count=0 β β
HIDE LOADING (network recovered!)
Benefits
β Clean Code: No manual parsing logic in UI code β Consistent: Same logic across all screens β Maintainable: Update logic in one place β Type Safe: Strongly typed data β Testable: Easy to unit test β Automatic: Loading logic handled automatically
For more details, see lib/utils/README.md
Event Listener β¨ #
π Call State Change Event (IMPORTANT) #
Listen to all call state changes:
OmicallClient.instance.callStateChangeEvent.listen((action) {
debugPrint("Action: ${action.actionName}, Data: ${action.data}");
});
Action Names
action.actionName is a string; filter on it β the stream carries more than call states:
actionName |
Constant | Payload |
|---|---|---|
"CALL_STATE_CHANGED" |
OmiEventList.onCallStateChanged |
see Event Data below |
"SWITCHBOARD_ANSWER" |
OmiEventList.onSwitchboardAnswer |
{sip} β switchboard answered |
"VIDEO" |
OmiEventList.onVideo |
iOS: camera on/off after toggleVideo() |
"CAMERA_STATUS" |
β | Android: sent after switchCamera() |
OmicallClient.instance.callStateChangeEvent.listen((action) {
if (action.actionName != OmiEventList.onCallStateChanged) return;
final status = action.data["status"] as int?;
});
Call States
| State | Code | Description |
|---|---|---|
unknown |
0 | Unknown state |
calling |
1 | Outgoing call initiated |
incoming |
2 | Incoming call |
early |
3 | Ringing |
connecting |
4 | Connecting |
confirmed |
5 | Active call |
disconnected |
6 | Call ended |
hold |
7 | Call on hold |
Event Data (onCallStateChanged)
{
"isVideo": bool, // true for video call
"status": int, // call state code (0-7)
"callerNumber": String, // phone number
"incoming": bool, // true if incoming
"_id": String, // iOS: call UUID; Android: ""
"transactionId": String, // iOS: every event; Android: on CONFIRMED
// When status == disconnected (6):
"code_end_call": int, // SIP/OMI end reason code β see Call End Codes table
}
DISCONNECTED arrives twice on iOS: once with the call info +
code_end_call, and once from the SDK teardown as{status: 6, message, codeEndCall}. Android sends it once. Handle DISCONNECTED idempotently (e.g. close the call screen only once). iOS forwards the raw SDK state, so ignore values you do not know.
State Lifecycle
Outgoing Call:
CALLING (1) β EARLY (3) β CONNECTING (4) β CONFIRMED (5) β DISCONNECTED (6)
Incoming Call:
INCOMING (2) β CONNECTING (4) β CONFIRMED (5) β DISCONNECTED (6)
π Other Event Listeners #
Call Quality Listener (Recommended: Use CallQualityTracker)
OmicallClient.instance.setCallQualityListener((data) {
final info = _qualityTracker.parseCallQuality(data);
// Use parsed info
print(info.mosDisplay); // "4.5"
print(info.qualityText); // "Excellent"
print(info.shouldShowLoading); // true/false
});
Raw data format (if not using helper):
{
"quality": int, // 0: GOOD, 1: NORMAL, 2: BAD
"stat": {
"req": double, // Request time
"mos": double, // MOS score (1.0-5.0)
"jitter": double, // Jitter (ms)
"latency": double, // Latency (ms)
"ppl": double, // Packet loss (%)
"lcn": int // Loss connect count
},
"isNeedLoading": bool // (Deprecated: Use CallQualityTracker instead)
}
Speaker Listener
OmicallClient.instance.setSpeakerListener((isSpeakerOn) {
setState(() {
isSpeaker = isSpeakerOn;
});
});
Mute Listener
OmicallClient.instance.setMuteListener((isMuted) {
setState(() {
this.isMuted = isMuted;
});
});
Hold Listener
OmicallClient.instance.setHoldListener((isOnHold) {
setState(() {
this.isHold = isOnHold;
});
});
Consult / Transfer / Call Hold Listeners (3.4.0)
OmicallClient.instance.setConsultStateListener((OmiConsultStateEvent e) {}); // CONSULT_STATE_CHANGED
OmicallClient.instance.setTransferStatusListener((OmiTransferStatusEvent e) {}); // TRANSFER_STATUS
OmicallClient.instance.setCallHoldChangedListener((OmiCallHoldEvent e) {}); // CALL_HOLD_CHANGED: any call, with callId
Remove with removeConsultStateListener(), removeTransferStatusListener(), removeCallHoldChangedListener().
Remote Video Ready Listener
// data: {"name": "REMOTE_VIDEO_READY", "data": {...}} β once per call, Android and iOS
OmicallClient.instance.setVideoListener((data) {
setState(() => _remoteVideoReady = true); // e.g. hide the "connecting video" placeholder
});
Missed Call Listener
Triggered when user taps missed call notification:
OmicallClient.instance.setMissedCallListener((data) {
final String callerNumber = data["callerNumber"];
final bool isVideo = data["isVideo"];
makeCallWithParams(context, callerNumber, isVideo);
});
Call Log Listener (iOS Only)
Triggered when the user starts a call from the iOS Recents list. Requires
processUserActivity in AppDelegate and NSUserActivityTypes β INStartCallIntent in Info.plist
(see iOS setup):
OmicallClient.instance.setCallLogListener((data) {
final String callerNumber = data["callerNumber"];
final bool isVideo = data["isVideo"];
makeCallWithParams(context, callerNumber, isVideo);
});
Error Codes #
Call End Codes (code_end_call) #
The code_end_call field is included in the event data when status == disconnected (6). It indicates why the call ended.
How to handle end-call reasons
import 'package:omicall_flutter_plugin/action/action_model.dart'; // OmiCallState
OmicallClient.instance.callStateChangeEvent.listen((omiAction) {
if (omiAction.actionName != OmiEventList.onCallStateChanged) return;
final data = omiAction.data;
final status = data['status'] as int;
if (status == OmiCallState.disconnected.rawValue) {
// iOS also sends a second DISCONNECTED with `codeEndCall` (see Event Data)
final code = (data['code_end_call'] ?? data['codeEndCall']) as int?;
final reason = _endCallReason(code);
if (reason != null) {
// Show toast or dialog to user
showToast(reason);
}
}
});
String? _endCallReason(int? code) {
switch (code) {
case null:
case 0:
case 200: // Normal BYE β both sides hung up
case 487: // Caller cancelled before answer
return null;
case 408: return 'No answer. The call was not answered.';
case 480: return 'Callee is temporarily unavailable.';
case 486: return 'Line busy. Please try again later.';
case 503: return 'Service unavailable.';
default: return 'Call ended (code: $code).';
}
}
Standard OMISIP Codes
| Code | Meaning | Action for user |
|---|---|---|
200 |
Normal call end (BYE) | No toast needed |
403 |
Forbidden β service plan restricts this number | Upgrade service plan |
404 |
Number not found or not allowed to call carrier | Check number |
408 |
Request timeout β callee did not answer | Notify: no answer |
480 |
Temporarily unavailable β callee offline/busy | Notify: unavailable |
486 |
Busy here β callee rejected / busy | Notify: line busy |
487 |
Request terminated β caller cancelled | No toast needed |
503 |
Service unavailable | Notify: service error |
601 |
Call ended by customer | |
602 |
Call ended by another employee | |
603 |
Call rejected β check account limit or call barring | |
850 |
Simultaneous call limit exceeded | |
851 |
Call duration limit exceeded | |
852 |
Service package not assigned | |
853 |
Internal number disabled | |
854 |
Subscriber in DNC (Do Not Call) list | |
855 |
Exceeded allowed calls for trial package | |
856 |
Exceeded allowed minutes for trial package | |
857 |
Subscriber blocked in configuration | |
858 |
Unidentified or unconfigured number | |
859 |
No available numbers for Viettel direction | |
860 |
No available numbers for VinaPhone direction | |
861 |
No available numbers for Mobifone direction | |
862 |
Temporary block on Viettel direction | |
863 |
Temporary block on VinaPhone direction | |
864 |
Temporary block on Mobifone direction | |
865 |
Advertising number outside permitted calling hours |
Troubleshooting #
Common Issues #
1. Call Not Starting (startCall returns error)
Symptom: startCall() returns status other than 8
Solutions:
// Check result and handle errors
final result = await OmicallClient.instance.startCall(phone, false);
final jsonMap = jsonDecode(result as String) as Map;
final status = jsonMap['status'];
// Use 'message' field for cross-platform handling (status code differs between Android/iOS)
final message = jsonMap['message'];
switch(message) {
case 'START_CALL_SUCCESS':
navigateToCallScreen();
break;
case 'NO_NETWORK':
// Android: status=408 / iOS: status=11
print("No network connection - check internet");
showNoNetworkDialog();
break;
case 'PERMISSION_DENIED':
print("Microphone permission denied");
await requestMicrophonePermission();
break;
case 'COULD_NOT_FIND_END_POINT': // iOS
print("Not logged in - call register() first");
break;
case 'INVALID_PHONE_NUMBER':
case 'INVALID_UUID':
case 'CAN_NOT_CALL_YOURSELF': // Android
case 'SAME_PHONE_NUMBER_WITH_PHONE_REGISTER': // iOS
print("Invalid destination");
break;
case 'MAX_RETRY': // iOS
case 'REGISTER_ACCOUNT_FAIL': // iOS
case 'START_CALL_FAIL': // iOS
print("Could not start the call - try again");
break;
case 'HAVE_ANOTHER_CALL':
print("Already in a call");
break;
case 'SWITCHBOARD_NOT_CONNECTED':
print("Switchboard not connected - check account");
break;
case 'SWITCHBOARD_REGISTERING':
// status=8 on Android, call is being registered
navigateToCallScreen();
break;
case 'ACCOUNT_TURN_OFF_NUMBER_INTERNAL':
print("Internal call feature is disabled for this account");
break;
}
2. No Incoming Call Notification
Android:
- β
Check
google-services.jsonexists inandroid/app/ - β
Verify the FCM token is registered:
OmicallClient.instance.register(..., fcmToken: token) - β
Check
AndroidManifest.xmlhasFirebaseMessageReceiver - β Ensure app has notification permissions
iOS:
- β Check APNS certificate is configured on Firebase
- β
Verify APNS token:
FirebaseMessaging.instance.getAPNSToken() - β
Check
PushKitManageris initialized inAppDelegate - β Verify VoIP push certificate in Apple Developer Portal
3. Call Quality Issues (Low MOS Score)
Symptom: MOS < 3.0 or frequent loading indicators
Debugging:
OmicallClient.instance.setCallQualityListener((data) {
final info = _qualityTracker.parseCallQuality(data);
print("MOS: ${info.mos}"); // Target: β₯ 4.0
print("Jitter: ${info.jitter}ms"); // Target: < 30ms
print("Latency: ${info.latency}ms"); // Target: < 150ms
print("Packet Loss: ${info.packetLoss}%"); // Target: < 1%
print("LCN: ${info.lcn}"); // Target: 0 or changing
if (info.mos < 3.0) {
// Poor call quality detected
if (info.jitter > 50) {
print("High jitter - check network stability");
}
if (info.latency > 200) {
print("High latency - check internet speed");
}
if (info.packetLoss > 3) {
print("Packet loss - check WiFi signal");
}
}
if (info.shouldShowLoading) {
print("Network stuck - LCN frozen at ${info.lcn}");
}
});
Solutions:
- Switch from WiFi to cellular or vice versa
- Close bandwidth-heavy apps
- Move closer to WiFi router
- Check internet speed (minimum 100kbps recommended)
4. Video Call Issues
No Remote Video:
- β
Show both
RemoteCameraViewandLocalCameraViewβ they attach to the SDK by themselves (since 3.4.0refresh()is optional). - β
setVideoListenerfiresREMOTE_VIDEO_READYonce the first remote frame is decoded; if it never comes, check the network / PBX video codec (H.264).
Camera Not Switching:
// Ensure camera is created before switching
if (_localController != null) {
OmicallClient.instance.switchCamera();
}
Black Screen:
- β Check camera permissions
- β
Ensure
isVideo: trueinregister()andstartCall() - β Keep both camera widgets on screen during the video call (they attach automatically)
5. Audio Issues
No Audio During Call:
// Check if muted
OmicallClient.instance.setMuteListener((isMuted) {
if (isMuted) {
OmicallClient.instance.toggleAudio(); // Unmute
}
});
// Check speaker status
OmicallClient.instance.setSpeakerListener((isSpeakerOn) {
print("Speaker: $isSpeakerOn");
});
Echo or Feedback:
- Use headphones/earphones
- Enable speaker phone
- Check microphone sensitivity
Migration Guide #
Upgrading from 3.3.x to 3.4.0 #
No breaking change. Replace the deprecated init functions:
// Before
final raw = await OmicallClient.instance.initCallWithUserPassword(
userName: u, password: p, realm: r, host: h, isVideo: true, fcmToken: t);
final ok = (jsonDecode(raw) as Map)['status'] == 200;
// After
final result = await OmicallClient.instance.register(
userName: u, password: p, realm: r, host: h, isVideo: true, fcmToken: t);
final ok = result.isSuccess;
| Deprecated | New | Parameter renames |
|---|---|---|
initCallWithUserPassword |
register |
β |
initCallWithApiKey |
registerWithApiKey |
usrName β fullName, usrUuid β userUuid |
Behaviour changes:
hostis now optional on Android (empty = SDK default) and honored on iOS (was alwaysvh.omicrm.com:5222).transferCallfailure no longer ends the call on Android β listen tosetTransferStatusListener.toggleHold()is ignored while a consult transfer is running (both platforms).
Video calls:
registerVideoEvent()/removeVideoEvent()are no longer needed (kept as no-ops) andrefresh()is optional β the camera widgets attach themselves.REMOTE_VIDEO_READY(setVideoListener) now also fires on Android, once per call.- iOS renders through
OMIVideoCallManager: do not mirror the local preview yourself. - Picture-in-Picture:
setVideoPictureInPictureEnabled(true); on Android add the manifest flags and forwardonUserLeaveHint/onPictureInPictureModeChangedfrom MainActivity (see Picture-in-Picture).
Native setup:
- Android: Maven credentials are
omicallUsername/omicallPasswordinandroid/local.properties. - iOS: minimum deployment target is now 13.0; run
pod installto get OmiKit 1.12.1. - iOS: Swift Package Manager is supported. If you enable it and your
AppDelegate.mimportsomicall_flutter_plugin-Swift.h, switch to the conditional import shown in iOS setup.
Upgrading from 2.x.x to 3.x.x #
Call quality: parse setCallQualityListener data with CallQualityTracker instead of reading the
raw map yourself (see Call Quality Monitoring):
final _qualityTracker = CallQualityTracker();
OmicallClient.instance.setCallQualityListener((data) {
final info = _qualityTracker.parseCallQuality(data);
if (info.shouldShowLoading) {
EasyLoading.show(); // Android only, see Loading Logic
} else if (info.isNetworkRecovered) {
EasyLoading.dismiss();
}
setState(() => callQuality = info.mosDisplay); // "4.5"
});
Support #
- π§ Email: contact@omicall.com
- π± Hotline: 0272 7777 787
- π Website: https://omicall.com
- π API Docs: https://omicrm.io/post/detail/omicall-apis-post76
License #
Copyright Β© 2021 VIHAT Team. All rights reserved.