Разблокируйте мощь Dual-SIM в Flutter: создавайте умные Android-приложения для глобальных рынков
FlutterPulseЭта статья переведена специально для канала FlutterPulse. В этом канале вы найдёте много интересных вещей, связанных с Flutter. Не забывайте подписываться! 🚀

Пошаговое руководство с готовым компонентом для удобного управления SIM-картами
Устройства с двумя SIM-картами доминируют на мировом рынке — более 85% устройств Android в Азии и Африке поставляются с двумя слотами, но большинство приложений по-прежнему рассматривают их как одну идентичность. А что, если ваши приложения на Flutter могли бы динамически использовать обе SIM-карты для более умного маршрутизации звонков или управления клиентами? За 10 минут вы освоите обнаружение SIM-карт и интеллектуальное вращение диалеров — с готовым к производству компонентом.
📌Ключевые сценарии
Приложения для электронной коммерции:
- Предотвращайте блокировки аккаунтов, вращая SIM-карты при общении с покупателями/продавцами
- Разделяйте местные и глобальные коммуникации для международных клиентов с помощью двойного набора SIM-карт
Инструменты поддержки клиентов:
- Автоматическое переключение между корпоративными/личными SIM-картами в зависимости от времени суток
- Балансируйте затраты на звонки между операторами с более дешевыми тарифами на данные/SMS
🔐 Пошаговая реализация
ШАГ 1: Настройка разрешений Android
Перед доступом к информации о двойных SIM-картах в вашем приложении Flutter вам нужно предоставить определенные разрешения.

Схема реализации набора номера с двумя SIM-картами
1. Объявление разрешений
Откройте android/app/src/main/AndroidManifest.xml и добавьте необходимые разрешения.
<!-- Требуется для обнаружения SIM-карт и состояния телефона --> <uses-permission android:name="android.permission.READ_PHONE_NUMBERS" /> <uses-permission android:name="android.permission.READ_PHONE_STATE" /> <!-- Требуется для прямого набора номера из приложения --> <uses-permission android:name="android.permission.CALL_PHONE" />
2. Запрос разрешений во время выполнения
Используйте пакет permission_handle для запроса у пользователей доступа к разрешениям. Кроме того, рекомендуется создать отдельную службу обработки разрешений для ясности и лучшей организации.
# Добавьте пакет в pubspec.yaml
dependencies:
flutter:
sdk: flutter
permission_handler: 11.3.1
###****** Google Play требует строгих политик проверки ****###
###****** Правильный запрос разрешений может предотвратить отклонение приложения ****###
// lib/services/permission_service.dart
import 'package:permission_handler/permission_handler.dart';
class PermissionService {
// Проверка и запрос телефонных разрешений
static Future<bool> checkAndRequestPhonePermissions() async {
// Проверка текущего статуса
Map<Permission, PermissionStatus> statuses = await [
Permission.phone,
Permission.phoneNumbers,
].request();
// Проверка, предоставлены ли все разрешения
bool allGranted = true;
statuses.forEach((permission, status) {
if (status != PermissionStatus.granted) {
allGranted = false;
}
});
return allGranted;
}
// Проверка, предоставлено ли конкретное разрешение
static Future<bool> isPermissionGranted(Permission permission) async {
PermissionStatus status = await permission.status;
return status == PermissionStatus.granted;
}
}
3. Создание нативного канала платформы
Далее создадим канал платформы для связи нативного кода Android с Flutter. Создайте новый файл Kotlin в 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()
}
}
// Получение информации о всех SIM-картах на устройстве
private fun getSimCardsInfo(result: Result) {
try {
val simCards = ArrayList<HashMap<String, Any>>()
val subscriptionManager = context.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE) as SubscriptionManager
// Проверка разрешения
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE)
!= PackageManager.PERMISSION_GRANTED) {
result.error("PERMISSION_DENIED", "Разрешение на чтение состояния телефона не предоставлено", null)
return
}
// Получение активных подписок (SIM-карт)
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", "Не удалось получить информацию о SIM-карте: ${e.message}", null)
}
}
// Звонок с использованием конкретной SIM-карты
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", "Номер телефона не может быть пустым", null)
return
}
// Проверка разрешения
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CALL_PHONE)
!= PackageManager.PERMISSION_GRANTED) {
result.error("PERMISSION_DENIED", "Разрешение на звонок не предоставлено", null)
return
}
// Получение менеджера подписок
val subscriptionManager = context.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE) as SubscriptionManager
val activeSubscriptions = subscriptionManager.activeSubscriptionInfoList
if (activeSubscriptions == null || activeSubscriptions.isEmpty() || simIndex >= activeSubscriptions.size) {
// Возврат к стандартному набору номера, если конкретная SIM-карта недоступна
makeDefaultPhoneCall(phoneNumber, result)
return
}
// Получение идентификатора подписки для указанной SIM-карты
val subscriptionId = activeSubscriptions[simIndex].subscriptionId
// Создание намерения для набора номера с конкретной 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)
}
// Начало набора номера
context.startActivity(intent)
result.success(true)
} catch (e: Exception) {
result.error("DIAL_ERROR", "Не удалось совершить звонок: ${e.message}", null)
}
}
// Создание обработчика учетной записи телефона для указания, какую SIM-карту использовать
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
}
}
// Использование рефлексии для получения 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", "Не удалось создать PhoneAccountHandle: ${e.message}")
return null
}
}
// Совершение обычного телефонного звонка без указания SIM-карты
private fun makeDefaultPhoneCall(phoneNumber: String, result: Result) {
try {
// Создание намерения для обычного набора номера
val intent = Intent(Intent.ACTION_CALL).apply {
setData(Uri.parse("tel:$phoneNumber"))
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
// Начало набора номера
context.startActivity(intent)
result.success(true)
} catch (e: Exception) {
result.error("DIAL_ERROR", "Не удалось совершить стандартный звонок: ${e.message}", null)
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
}
4. Регистрация плагина в MainActivity
Отредактируйте android/app/src/main/kotlin/com/*yourapp*/MainActivity.kt для регистрации плагина:
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. Создание модели информации о SIM-карте в Flutter
В проекте Flutter создадим модель для информации о SIM-карте:
// lib/models/sim_card_info.dart
class SimCardInfo {
final int simSlot; // Слот SIM-карты (индекс с нуля)
final String? carrierId; // Идентификатор оператора
final String? carrierName;// Название оператора
final String? phoneNumber;// Номер телефона
final bool isDefault; // Является ли SIM-карта основной
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. Создание сервиса ротации SIM-карт
Теперь создайте сервис для управления ротацией SIM-карт.
// 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;
// Текущая SIM-карта, используемая для набора
SimCardInfo? get currentDialingSim => _currentDialingSim;
// Инициализация и получение информации о SIM-картах
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('Не удалось получить информацию о SIM-карте: $e');
return [];
}
}
// Вызов с ротацией SIM-карт
Future<bool> dialWithRotation(String phoneNumber) async {
List<SimCardInfo> simCards = await getSimCards();
// Если одна SIM-карта или нет SIM-карт, используйте стандартный набор
if (simCards.length <= 1) {
return await dialWithSpecificSim(phoneNumber, 0);
}
// Получение следующего индекса SIM-карты из настроек
int nextSimIndex = await _getNextSimIndex(simCards.length);
// Вызов с использованием выбранной SIM-карты
bool success = await dialWithSpecificSim(phoneNumber, nextSimIndex);
if (success) {
// Сохранение текущей SIM-карты для набора
_currentDialingSim = simCards[nextSimIndex];
// Обновление следующего индекса SIM-карты для ротации
await _updateNextSimIndex(nextSimIndex, simCards.length);
}
return success;
}
// Вызов с использованием конкретной SIM-карты
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('Не удалось совершить вызов: $e');
return false;
}
}
// Получение следующего индекса SIM-карты из 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;
// Убедитесь, что индекс действителен
if (nextIndex < 0 || nextIndex >= totalSimCount) {
nextIndex = 0;
}
return nextIndex;
}
// Обновление следующего индекса SIM-карты в 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. Создание 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 = '';
// Геттеры
bool get isCheckingPermission => _isCheckingPermission;
String get errorMessage => _errorMessage;
// Проверка разрешений на телефон
Future<bool> checkPhonePermissions() async {
try {
_isCheckingPermission = true;
notifyListeners();
bool hasPermissions = await PermissionService.checkAndRequestPhonePermissions();
_isCheckingPermission = false;
notifyListeners();
if (!hasPermissions) {
_errorMessage = 'Для совершения звонков требуются разрешения на телефон';
}
return hasPermissions;
} catch (e) {
_errorMessage = 'Ошибка при проверке разрешений: $e';
_isCheckingPermission = false;
notifyListeners();
return false;
}
}
// Вызов с ротацией SIM-карт
Future<bool> makePhoneCall(BuildContext context, String phoneNumber) async {
try {
if (phoneNumber.isEmpty) {
_errorMessage = 'Номер телефона не может быть пустым';
notifyListeners();
return false;
}
// Сначала проверьте разрешения
bool hasPermissions = await checkPhonePermissions();
if (!hasPermissions) {
return false;
}
// Совершите вызов с ротацией SIM-карт
return await _simCardManager.dialWithRotation(phoneNumber);
} catch (e) {
_errorMessage = 'Ошибка при наборе: $e';
notifyListeners();
return false;
}
}
}
8. Создание экрана Flutter
Наконец, создайте интерфейс для демонстрации функциональности.
// 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. Попробуйте ViewModel в вашем приложении
Интегрируйте ViewModel в 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. Совместимость с производителями
Каждый производитель Android-устройств может иметь свою собственную реализацию обработки двойных SIM-карт, поэтому вам следует включить дополнительные проверки совместимости, чтобы обеспечить согласованное поведение на различных устройствах.
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)
}
Особые случаи и оптимизация
- Обработка отказа в разрешении: Если требуемые разрешения отклонены, ваше приложение должно предоставить четкие инструкции, чтобы помочь пользователям включить их.
- Изменение состояния SIM-карты: Состояния SIM-карт могут изменяться в течение жизненного цикла приложения (например, при вставке или удалении SIM-карты), поэтому убедитесь, что ваше приложение обрабатывает эти сценарии корректно.
📱 Ротация Dual-SIM: Почему это важно
Если вы разрабатываете приложения для электронной коммерции, продаж или служб поддержки клиентов, реализация ротации вызова с двойной SIM-картой может стать настоящим прорывом для ваших пользователей:
- Команды продаж избегают флаговки номеров при совершении сотен звонков
- Агенты поддержки получают преимущество от резервирования сети
👋 Что дальше?
Если вам понравился этот гайд, не стесняйтесь поставить лайк 👏 для получения дополнительных практических инсайтов по разработке мобильных приложений!