Unlock Dual-SIM Power in Flutter: Build Smarter Android Apps for Global Markets
FlutterPulseThis article was translated specially for the channel FlutterPulseYou'll find lots of interesting things related to Flutter on this channel. Don't hesitate to subscribe!๐

A Step-by-Step Guide with Ready-to-Use Component for Effortless SIM Management
Dual SIM devices dominate the global market โ over 85% of Android devices in Asia and Africa ship with dual slots, yet most apps still treat them as a single identity. What if your Flutter apps could dynamically harness both SIMs for smarter call routing or client management? In 10 minutes, you'll master SIM detection and intelligent dialer rotation โ complete with a production-ready component.
๐Key Scenarios
E-commerce Apps๏ผ
- Prevent account bans by rotating SIMs when contacting buyers/sellers
- Separate local and global communications for international customers using dual-SIM dialing
Customer Support Tools๏ผ
- Auto-switch between corporate/personal SIMs based on time-of-day
- Balance call costs across carriers with cheaper data/SMS plans
๐ Step-by-Step Implementation
STEP 1: Configure Android Permissions
Before accessing dual-SIM information in your Flutter app, you'll need to grant specific permissions.

1. Declare Permissions
Open the android/app/src/main/AndroidManifest.xml and add the required permissions.
<!-- Required for SIM card detection and phone state -->
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<!-- Required for direct dialing from the app -->
<uses-permission android:name="android.permission.CALL_PHONE" />
2. Request Permissions at Runtime
Use the permission_handlepackage to ask users for permission access. Additionally, it's recommended to create a dedicated permission-handling service for clarity and better organization.
# Add package in pubspec.yaml
dependencies:
flutter:
sdk: flutter
permission_handler: 11.3.1
###****** Google Play enforces strict review policies ****###
###****** Properly requesting permissions can prevent app rejection ****###
// lib/services/permission_service.dart
import 'package:permission_handler/permission_handler.dart';
class PermissionService {
// Check and request phone-related permissions
static Future<bool> checkAndRequestPhonePermissions() async {
// Check current status
Map<Permission, PermissionStatus> statuses = await [
Permission.phone,
Permission.phoneNumbers,
].request();
// Check if all permissions are granted
bool allGranted = true;
statuses.forEach((permission, status) {
if (status != PermissionStatus.granted) {
allGranted = false;
}
});
return allGranted;
}
// Check if a specific permission is granted
static Future<bool> isPermissionGranted(Permission permission) async {
PermissionStatus status = await permission.status;
return status == PermissionStatus.granted;
}
}
3. Create Native Platform Channel
Next, let's create the platform channel to bridge native Android code to Flutter. Create a new Kotlin file in android/app/src/main/kotlin/com/*yourapp*/PhoneStatePlugin.kt:
package com.yourapp.name
import android.Manifest
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.telecom.PhoneAccount
import android.telecom.PhoneAccountHandle
import android.telecom.TelecomManager
import android.telephony.SubscriptionInfo
import android.telephony.SubscriptionManager
import android.util.Log
import androidx.core.content.ContextCompat
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
class PhoneStatePlugin: FlutterPlugin, MethodCallHandler {
private lateinit var channel: MethodChannel
private lateinit var context: Context
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(binding.binaryMessenger, "com.yourapp.name/phone_state")
context = binding.applicationContext
channel.setMethodCallHandler(this)
}
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"getSimCardsInfo" -> getSimCardsInfo(result)
"makePhoneCallWithSim" -> makePhoneCallWithSim(call, result)
else -> result.notImplemented()
}
}
// Get information about all SIM cards in the device
private fun getSimCardsInfo(result: Result) {
try {
val simCards = ArrayList<HashMap<String, Any>>()
val subscriptionManager = context.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE) as SubscriptionManager
// Check permission
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE)
!= PackageManager.PERMISSION_GRANTED) {
result.error("PERMISSION_DENIED", "Read phone state permission denied", null)
return
}
// Get active subscriptions (SIM cards)
val activeSubscriptions = subscriptionManager.activeSubscriptionInfoList
if (activeSubscriptions != null) {
for (subscriptionInfo in activeSubscriptions) {
val simInfo = HashMap<String, Any>()
simInfo["simSlot"] = subscriptionInfo.simSlotIndex
simInfo["carrierId"] = subscriptionInfo.carrierId.toString()
simInfo["carrierName"] = subscriptionInfo.carrierName?.toString() ?: ""
simInfo["phoneNumber"] = subscriptionInfo.number ?: ""
simInfo["isDefault"] = subscriptionInfo.simSlotIndex == 0
simCards.add(simInfo)
}
}
result.success(simCards)
} catch (e: Exception) {
result.error("SIM_INFO_ERROR", "Failed to get SIM card info: ${e.message}", null)
}
}
// Make a phone call using a specific SIM card
private fun makePhoneCallWithSim(call: MethodCall, result: Result) {
try {
val phoneNumber = call.argument<String>("phoneNumber") ?: ""
val simIndex = call.argument<Int>("simIndex") ?: 0
if (phoneNumber.isEmpty()) {
result.error("INVALID_PHONE", "Phone number cannot be empty", null)
return
}
// Check permission
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CALL_PHONE)
!= PackageManager.PERMISSION_GRANTED) {
result.error("PERMISSION_DENIED", "Call phone permission denied", null)
return
}
// Get subscription manager
val subscriptionManager = context.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE) as SubscriptionManager
val activeSubscriptions = subscriptionManager.activeSubscriptionInfoList
if (activeSubscriptions == null || activeSubscriptions.isEmpty() || simIndex >= activeSubscriptions.size) {
// Fall back to default dialing if specific SIM is not available
makeDefaultPhoneCall(phoneNumber, result)
return
}
// Get subscription ID for the specified SIM card
val subscriptionId = activeSubscriptions[simIndex].subscriptionId
// Create intent for dialing with specific SIM
val intent = Intent(Intent.ACTION_CALL).apply {
setData(Uri.parse("tel:$phoneNumber"))
putExtra("com.android.phone.extra.slot", simIndex)
putExtra("com.android.phone.extra.subscription", subscriptionId)
putExtra("android.telecom.extra.PHONE_ACCOUNT_HANDLE",
createPhoneAccountHandle(context, simIndex, subscriptionId))
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
// Start dialing
context.startActivity(intent)
result.success(true)
} catch (e: Exception) {
result.error("DIAL_ERROR", "Failed to make phone call: ${e.message}", null)
}
}
// Create phone account handle for specifying which SIM card to use
private fun createPhoneAccountHandle(context: Context, simIndex: Int, subscriptionId: Int): PhoneAccountHandle? {
try {
val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
val phoneAccounts = telecomManager.callCapablePhoneAccounts
for (handle in phoneAccounts) {
val phoneAccount = telecomManager.getPhoneAccount(handle)
if (phoneAccount.hasCapabilities(PhoneAccount.CAPABILITY_SIM_SUBSCRIPTION) &&
phoneAccount.subscriptionId == subscriptionId) {
return handle
}
}
// Use reflection to get PhoneAccountHandle
val componentName = ComponentName("com.android.phone", "com.android.services.telephony.TelephonyConnectionService")
val phoneAccountHandleClass = PhoneAccountHandle::class.java
val constructor = phoneAccountHandleClass.getDeclaredConstructor(ComponentName::class.java, String::class.java)
return constructor.newInstance(componentName, subscriptionId.toString())
} catch (e: Exception) {
Log.e("PhoneStatePlugin", "Failed to create PhoneAccountHandle: ${e.message}")
return null
}
}
// Make a regular phone call without specifying SIM card
private fun makeDefaultPhoneCall(phoneNumber: String, result: Result) {
try {
// Create intent for regular dialing
val intent = Intent(Intent.ACTION_CALL).apply {
setData(Uri.parse("tel:$phoneNumber"))
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
// Start dialing
context.startActivity(intent)
result.success(true)
} catch (e: Exception) {
result.error("DIAL_ERROR", "Failed to make default phone call: ${e.message}", null)
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
}
4. Register the Plugin in MainActivity
Edit android/app/src/main/kotlin/com/*yourapp*/MainActivity.ktto register the plugin:
package com.yourapp.name
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugins.GeneratedPluginRegistrant
class MainActivity: FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
GeneratedPluginRegistrant.registerWith(flutterEngine)
flutterEngine.plugins.add(PhoneStatePlugin())
}
}
5. Create SIM Card Information Model in Flutter
In the Flutter project, let's create a model for SIM card information:
// lib/models/sim_card_info.dart
class SimCardInfo {
final int simSlot; // SIM card slot (0-based index)
final String? carrierId; // Carrier ID
final String? carrierName;// Carrier name
final String? phoneNumber;// Phone number
final bool isDefault; // Is default SIM card
SimCardInfo({
required this.simSlot,
this.carrierId,
this.carrierName,
this.phoneNumber,
this.isDefault = false,
});
factory SimCardInfo.fromMap(Map<dynamic, dynamic> map) {
return SimCardInfo(
simSlot: map['simSlot'] ?? 0,
carrierId: map['carrierId'],
carrierName: map['carrierName'],
phoneNumber: map['phoneNumber'],
isDefault: map['isDefault'] ?? false,
);
}
Map<String, dynamic> toMap() {
return {
'simSlot': simSlot,
'carrierId': carrierId,
'carrierName': carrierName,
'phoneNumber': phoneNumber,
'isDefault': isDefault,
};
}
}
6. Create SIM Card Rotation Service
Now, create a service to manage SIM card rotation.
// lib/services/sim_card_manager.dart
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/sim_card_info.dart';
class SimCardManager {
static final SimCardManager _instance = SimCardManager._internal();
factory SimCardManager() => _instance;
SimCardManager._internal();
final MethodChannel _channel = MethodChannel('com.yourapp.name/phone_state');
List<SimCardInfo> _simCards = [];
SimCardInfo? _currentDialingSim;
// Current SIM card being used for dialing
SimCardInfo? get currentDialingSim => _currentDialingSim;
// Initialize and get SIM card information
Future<List<SimCardInfo>> getSimCards() async {
if (_simCards.isNotEmpty) return _simCards;
try {
final simCardsData = await _channel.invokeMethod('getSimCardsInfo');
if (simCardsData != null && simCardsData is List) {
_simCards = simCardsData.map<SimCardInfo>((data) =>
SimCardInfo.fromMap(data)).toList();
}
return _simCards;
} catch (e) {
print('Failed to get SIM card info: $e');
return [];
}
}
// Make a phone call with SIM card rotation
Future<bool> dialWithRotation(String phoneNumber) async {
List<SimCardInfo> simCards = await getSimCards();
// If only one SIM or no SIMs, use default dialing
if (simCards.length <= 1) {
return await dialWithSpecificSim(phoneNumber, 0);
}
// Get next SIM index from preferences
int nextSimIndex = await _getNextSimIndex(simCards.length);
// Dial using the selected SIM card
bool success = await dialWithSpecificSim(phoneNumber, nextSimIndex);
if (success) {
// Save current dialing SIM
_currentDialingSim = simCards[nextSimIndex];
// Update next SIM index for rotation
await _updateNextSimIndex(nextSimIndex, simCards.length);
}
return success;
}
// Make a phone call with a specific SIM card
Future<bool> dialWithSpecificSim(String phoneNumber, int simIndex) async {
try {
final result = await _channel.invokeMethod('makePhoneCallWithSim', {
'phoneNumber': phoneNumber,
'simIndex': simIndex,
});
return result == true;
} catch (e) {
print('Failed to dial: $e');
return false;
}
}
// Get next SIM index from SharedPreferences
Future<int> _getNextSimIndex(int totalSimCount) async {
if (totalSimCount <= 1) return 0;
final prefs = await SharedPreferences.getInstance();
int nextIndex = prefs.getInt('sim_next_index') ?? 0;
// Ensure index is valid
if (nextIndex < 0 || nextIndex >= totalSimCount) {
nextIndex = 0;
}
return nextIndex;
}
// Update next SIM index in SharedPreferences
Future<void> _updateNextSimIndex(int currentIndex, int totalSimCount) async {
if (totalSimCount <= 1) return;
final prefs = await SharedPreferences.getInstance();
int nextIndex = (currentIndex + 1) % totalSimCount;
await prefs.setInt('sim_next_index', nextIndex);
}
}
7. Create a Phone Call ViewModel
// lib/viewmodels/phone_call_viewmodel.dart
import 'package:flutter/material.dart';
import '../services/sim_card_manager.dart';
import '../services/permission_service.dart';
import 'package:permission_handler/permission_handler.dart';
class PhoneCallViewModel extends ChangeNotifier {
final SimCardManager _simCardManager = SimCardManager();
bool _isCheckingPermission = false;
String _errorMessage = '';
// Getters
bool get isCheckingPermission => _isCheckingPermission;
String get errorMessage => _errorMessage;
// Check phone permissions
Future<bool> checkPhonePermissions() async {
try {
_isCheckingPermission = true;
notifyListeners();
bool hasPermissions = await PermissionService.checkAndRequestPhonePermissions();
_isCheckingPermission = false;
notifyListeners();
if (!hasPermissions) {
_errorMessage = 'Phone permissions are required to make calls';
}
return hasPermissions;
} catch (e) {
_errorMessage = 'Error checking permissions: $e';
_isCheckingPermission = false;
notifyListeners();
return false;
}
}
// Make a phone call with SIM card rotation
Future<bool> makePhoneCall(BuildContext context, String phoneNumber) async {
try {
if (phoneNumber.isEmpty) {
_errorMessage = 'Phone number cannot be empty';
notifyListeners();
return false;
}
// Check permissions first
bool hasPermissions = await checkPhonePermissions();
if (!hasPermissions) {
return false;
}
// Make the call with SIM rotation
return await _simCardManager.dialWithRotation(phoneNumber);
} catch (e) {
_errorMessage = 'Error dialing: $e';
notifyListeners();
return false;
}
}
}
8. Create the Flutter UI screen
Finally, create the UI to demonstrate the functionality.
// lib/screens/dialer_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../viewmodels/phone_call_viewmodel.dart';
class DialerScreen extends StatefulWidget {
const DialerScreen({Key? key}) : super(key: key);
@override
_DialerScreenState createState() => _DialerScreenState();
}
class _DialerScreenState extends State<DialerScreen> {
final TextEditingController _phoneController = TextEditingController();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Dual-SIM Dialer'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: _phoneController,
decoration: InputDecoration(
labelText: 'Phone Number',
hintText: 'Enter phone number to dial',
prefixIcon: Icon(Icons.phone),
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
),
SizedBox(height: 16),
Consumer<PhoneCallViewModel>(
builder: (context, viewModel, child) {
return Column(
children: [
if (viewModel.errorMessage.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Text(
viewModel.errorMessage,
style: TextStyle(color: Colors.red),
),
),
ElevatedButton.icon(
icon: Icon(Icons.call),
label: Text('Call with SIM Rotation'),
onPressed: viewModel.isCheckingPermission
? null
: () => _makeCall(context, viewModel),
style: ElevatedButton.styleFrom(
primary: Colors.green,
minimumSize: Size(double.infinity, 50),
),
),
],
);
},
),
],
),
),
);
}
void _makeCall(BuildContext context, PhoneCallViewModel viewModel) async {
String phoneNumber = _phoneController.text.trim();
if (phoneNumber.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Please enter a phone number')),
);
return;
}
bool success = await viewModel.makePhoneCall(context, phoneNumber);
if (success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Call initiated')),
);
}
}
@override
void dispose() {
_phoneController.dispose();
super.dispose();
}
}
9. Try the ViewModel in Your App
Integrate the ViewModel in main.dart:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'viewmodels/phone_call_viewmodel.dart';
import 'screens/dialer_screen.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => PhoneCallViewModel()),
],
child: MaterialApp(
title: 'Dual-SIM Dialer App',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: DialerScreen(),
),
);
}
}
10. Manufacturer Compatibility
Each Android device manufacturer may have their own implementation of dual-SIM handling, so you should include additional compatibility checks to ensure consistent behavior across devices.
private fun addManufacturerSpecificParams(intent: Intent, simIndex: Int, subscriptionId: Int) {
val manufacturer = android.os.Build.MANUFACTURER.toLowerCase()
// Samsung specific
if (manufacturer.contains("samsung")) {
intent.putExtra("com.samsung.android.app.telephony.currentSim", simIndex)
}
// Xiaomi/Redmi specific
if (manufacturer.contains("xiaomi") || manufacturer.contains("redmi")) {
intent.putExtra("key_sim_slot", simIndex)
intent.putExtra("simSlot", simIndex)
}
// OPPO/Realme/OnePlus specific
if (manufacturer.contains("oppo") || manufacturer.contains("realme") || manufacturer.contains("oneplus")) {
intent.putExtra("simslot", simIndex)
intent.putExtra("sim_slot", simIndex)
}
// You can check other specific manufacturers
// Add common extras for better compatibility
intent.putExtra("phone_sim", simIndex)
intent.putExtra("sim_id", simIndex)
intent.putExtra("simId", simIndex)
intent.putExtra("sim_index", simIndex)
intent.putExtra("subscription", subscriptionId)
}Edge Cases & Optimization
- Handling Permission Denial: If required permissions are denied, your app should provide clear guidance to help users enable them.
- SIM Card State Changes: SIM card states can change during the app lifecycle (e.g., inserting or removing a SIM card), so ensure your app handles these scenarios gracefully.
๐ฑ Dual-SIM Rotation: Why It Matters
If you're developing apps for E-commerce, sales, or customer support teams, implementing dual-SIM rotation dialing can be a real game-changer for your users:
- Sales teams avoid number flagging when making hundreds of calls
- Support agents benefit from network redundancy
๐ What's Next?
If you found this guide helpful, feel free to clap ๐ and follow me for more practical mobile development insights!
What phone-related features would you like me to cover next? Call recording? SMS automation? Share your ideas in the comments!