Flutter'da eSIM Entegrasyonu: Kapsamlı Teknik Rehber
eSIM Profil Formatı
Kod yazmaya başlamadan önce, cihaza tam olarak ne yüklediğimizi anlamamız gerekiyor. Her eSIM profili, LPA (Local Profile Assistant) formatında bir aktivasyon dizesiyle tanımlanır:
LPA:1$smdp.example.com$MATCHING_ID
Bu dizede $ ile ayrılmış üç bileşen var:
- LPA:1 — Protokol tanımlayıcısı.
1değeri, GSMA SGP.22 spesifikasyonunda tanımlanan tüketici RSP (Remote SIM Provisioning) protokolünü ifade eder. - SM-DP+ adresi — Şifrelenmiş profili barındıran ve cihazın eUICC’si ile karşılıklı kimlik doğrulama yapan Subscription Manager Data Preparation sunucusunun adresi.
- Matching ID — Bu kullanıcıya atanmış spesifik profili tanımlayan benzersiz token. SM-DP+ bu değeri kullanarak indirme el sıkışması sırasında doğru profili bulur ve serbest bırakır.
Rivio’da bir kullanıcı plan satın aldığında, backend sistemimiz SM-DP+ API’sinin downloadOrder endpoint’ini çağırarak bu aktivasyon dizesini oluşturur. Dize daha sonra Flutter uygulamasına iletilir ve native platform katmanına kurulum için aktarılır.
Android: eSIM Kurulumu için Üç Yaklaşım
Android, eSIM profili yüklemek için farklı yetki gereksinimleri ve kullanıcı deneyimi ödünleşimleri olan üç yöntem sunar.
Yaklaşım 1: LPA Intent (En Düşük Bariyer)
En basit yol, cihazın yerleşik LPA uygulamasını başlatmaktır. Özel izin gerektirmez ama kullanıcı deneyimi üzerinde hiçbir kontrolünüz olmaz.
// Kotlin — Sistem LPA'sını aktivasyon koduyla başlat
fun launchLpaIntent(context: Context, activationCode: String) {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(activationCode) // LPA:1$smdp.example.com$MATCHING_ID
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
Sistem LPA’sı her şeyi yönetir: eUICC tespiti, SM-DP+ kimlik doğrulaması, profil indirme ve kullanıcı onayı. Sorun şu: geri bildirim almanız mümkün değil. Kurulumun başarılı olup olmadığını, hata verip vermediğini veya kullanıcının geri dönüp dönmediğini bilemezsiniz. Production bir uygulama için bu yeterli değil.
Yaklaşım 2: ACTION_VIEW Intent
Bazı OEM’ler LPA:1$ URI şeması için özel intent handler’ları kaydeder. Samsung kendi SIM yöneticisi üzerinden yönlendirir. Geliştirici kontrolü açısından Yaklaşım 1 ile aynı sınırlamalara sahiptir.
Yaklaşım 3: EuiccManager (Production Tercihi)
Android 9 (API 28) ile tanıtılan EuiccManager API, profil yaşam döngüsü üzerinde tam programatik kontrol sağlar. Production uygulamalar için doğru seçim budur.
// Kotlin — Tam EuiccManager implementasyonu
class EsimInstaller(private val context: Context) {
private val euiccManager = context.getSystemService(Context.EUICC_SERVICE) as EuiccManager
fun isEsimSupported(): Boolean {
return euiccManager.isEnabled
}
fun downloadProfile(
activationCode: String,
onResult: (success: Boolean, errorCode: Int?) -> Unit
) {
if (!euiccManager.isEnabled) {
onResult(false, EUICC_NOT_AVAILABLE)
return
}
val sub = DownloadableSubscription
.forActivationCode(activationCode)
val callbackIntent = PendingIntent.getBroadcast(
context,
REQUEST_CODE_DOWNLOAD,
Intent(ACTION_DOWNLOAD_RESULT).setPackage(context.packageName),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
val resultCode = getResultCode()
when (resultCode) {
EuiccManager.EMBEDDED_SUBSCRIPTION_RESULT_OK -> {
onResult(true, null)
}
EuiccManager.EMBEDDED_SUBSCRIPTION_RESULT_RESOLVABLE_ERROR -> {
// Kullanıcı onayı gerekli — çözüm activity'si başlat
onResult(false, RESULT_NEEDS_USER_CONFIRMATION)
}
EuiccManager.EMBEDDED_SUBSCRIPTION_RESULT_ERROR -> {
val detailedCode = intent.getIntExtra(
EuiccManager.EXTRA_EMBEDDED_SUBSCRIPTION_DETAILED_CODE, -1
)
onResult(false, detailedCode)
}
}
context.unregisterReceiver(this)
}
}
context.registerReceiver(
receiver,
IntentFilter(ACTION_DOWNLOAD_RESULT),
Context.RECEIVER_EXPORTED
)
euiccManager.downloadSubscription(sub, true, callbackIntent)
}
companion object {
const val ACTION_DOWNLOAD_RESULT = "com.rivio.app.DOWNLOAD_RESULT"
const val REQUEST_CODE_DOWNLOAD = 1001
const val EUICC_NOT_AVAILABLE = -100
const val RESULT_NEEDS_USER_CONFIRMATION = -200
}
}
Carrier Privilege: Kritik Parça
EuiccManager.downloadSubscription() sessiz kurulum yapabilmesi için uygulamanızın carrier privilege yetkisine sahip olmasını kontrol eder. Bu yetki olmadan Android, sistem onay diyalogu gösterir veya bazı OEM implementasyonlarında çağrıyı reddeder.
Carrier privilege, eSIM profilinin içinde saklanan UICC Access Rule (ARA-M) kayıtları aracılığıyla sağlanır. Bu kayıt şunları içerir:
- Uygulamanızın paket adı (örn.
com.rivio.app) - İmzalama sertifikanızın SHA-256 hash değeri
Profil eUICC’ye indirildiğinde, sistem bu ARA-M kayıtlarını okur ve uygulamanıza örtük olarak android.permission.WRITE_EMBEDDED_SUBSCRIPTIONS yetkisi verir. Bu, SM-DP+ sağlayıcınızla koordinasyon kurarak ürettikleri her profilde sertifika hash’inizi gömmelerini sağlamanız gerektiği anlamına gelir.
İmzalama sertifikanızın hash değerini almak için:
keytool -list -v -keystore release.keystore -alias alias_adi \
| grep SHA256 | awk '{print $2}' | tr -d ':'
iOS: Universal Link ve CTCellularPlanProvisioning
Universal Link Yaklaşımı (Entitlement Gerektirmez)
En basit iOS yolu, native eSIM kurulum akışını açan Universal Link kullanmaktır. Özel entitlement gerektirmez ama kontrol imkanı sınırlıdır.
// Swift — Settings URL üzerinden eSIM kurulumu aç
import UIKit
func openEsimInstallation(smdpAddress: String, matchingId: String) {
var components = URLComponents()
components.scheme = "https"
components.host = "esimsetup.apple.com"
components.path = "/esim_download_ota"
components.queryItems = [
URLQueryItem(name: "carddata", value: "LPA:1$\(smdpAddress)$\(matchingId)")
]
guard let url = components.url else { return }
UIApplication.shared.open(url, options: [:]) { success in
if !success {
print("eSIM kurulum URL'si açılamadı")
}
}
}
Bu kod native iOS eSIM kurulum sayfasını açar. Kullanıcı “Devam Et”e dokunur, profil indirilir ve iOS her şeyi yönetir. URL’nin açılıp açılmadığına dair bir boolean alırsınız, ancak kurulumun tamamlanıp tamamlanmadığını bilemezsiniz.
CTCellularPlanProvisioning (Production Tercihi)
Tam programatik kontrol için Apple, CoreTelephony framework’ünün parçası olan CTCellularPlanProvisioning API’sini sunar. Bu API profilleri sessizce yüklemenize ve detaylı durum geri bildirimleri almanıza olanak tanır.
// Swift — CTCellularPlanProvisioning implementasyonu
import CoreTelephony
class EsimInstaller {
private let provisioning = CTCellularPlanProvisioning()
func supportsEsim() -> Bool {
return provisioning.supportsCellularPlan()
}
func installProfile(
smdpAddress: String,
matchingId: String,
iccid: String,
completion: @escaping (Result<Void, EsimError>) -> Void
) {
guard provisioning.supportsCellularPlan() else {
completion(.failure(.euiccNotAvailable))
return
}
let plan = CTCellularPlanProvisioningRequest()
plan.address = smdpAddress
plan.matchingID = matchingId
plan.iccid = iccid
provisioning.addPlan(with: plan) { result in
DispatchQueue.main.async {
switch result {
case .unknown:
completion(.failure(.unknown))
case .fail:
completion(.failure(.installationFailed))
case .success:
completion(.success(()))
@unknown default:
completion(.failure(.unknown))
}
}
}
}
}
enum EsimError: Error {
case euiccNotAvailable
case installationFailed
case profileAlreadyInstalled
case carrierLocked
case unknown
}
Apple eSIM Entitlement Süreci
Gerçek şu ki: CTCellularPlanProvisioning.addPlan() metodu com.apple.developer.carrier-account entitlement’ının arkasına kilitlenmiştir. Bunu Xcode’dan kendiniz aktive edemezsiniz. Süreç şöyle işler:
- Apple ile iletişime geçin. Apple Sales Representative’inize ulaşın veya mevcut bir partner iseniz Apple MFi Program kanallarını kullanın.
- İş modelinizi sunun. Apple’a SM-DP+ altyapınızı, operatör anlaşmalarınızı ve hedeflediğiniz kullanıcı deneyimini detaylı şekilde açıklamanız gerekir.
- Teknik değerlendirme. Apple’ın teknik ekibi entegrasyonunuzu inceler. Genellikle TestFlight üzerinden çalışan bir prototip göstermeniz istenir.
- Entitlement onayı. Onaylandıktan sonra entitlement, Apple Developer hesabınızdaki App ID’nize eklenir.
- Süre. Rivio’daki deneyimimize göre ilk iletişimden entitlement onayına kadar 8-12 hafta planlayın. Bu bir tampon süre değil, temel beklentidir.
Bu entitlement olmadan addPlan() sessizce başarısız olur veya .fail döndürür. Entitlement alınana kadar Universal Link yaklaşımı fallback çözümünüz olarak kalır.
Flutter Köprüsü: MethodChannel Mimarisi
Şimdi native katmanları MethodChannel kullanarak Flutter’a bağlıyoruz. Rivio’nun production uygulamasını besleyen mimari budur.
// Dart — eSIM Platform Channel Servisi
import 'package:flutter/services.dart';
class EsimService {
static const _channel = MethodChannel('com.rivio.app/esim');
/// Cihazın eSIM destekleyip desteklemediğini kontrol et
Future<bool> isEsimSupported() async {
try {
final result = await _channel.invokeMethod<bool>('isEsimSupported');
return result ?? false;
} on PlatformException catch (e) {
_logError('isEsimSupported', e);
return false;
}
}
/// Cihaz eUICC'sinin operatör kilitli olup olmadığını kontrol et
Future<bool> isCarrierLocked() async {
try {
final result = await _channel.invokeMethod<bool>('isCarrierLocked');
return result ?? false;
} on PlatformException {
return false;
}
}
/// eSIM profilini yükle
Future<EsimInstallResult> installProfile({
required String activationCode,
required String iccid,
}) async {
try {
final result = await _channel.invokeMethod<Map>(
'installProfile',
{
'activationCode': activationCode,
'iccid': iccid,
},
);
if (result == null) return EsimInstallResult.unknown;
return EsimInstallResult.fromMap(Map<String, dynamic>.from(result));
} on PlatformException catch (e) {
return EsimInstallResult(
success: false,
errorCode: e.code,
errorMessage: e.message,
);
}
}
/// Yüklü eSIM profil ICCID'lerini getir
Future<List<String>> getInstalledProfiles() async {
try {
final result = await _channel.invokeMethod<List>('getInstalledProfiles');
return result?.cast<String>() ?? [];
} on PlatformException {
return [];
}
}
void _logError(String method, PlatformException e) {
debugPrint('EsimService.$method başarısız: ${e.code} - ${e.message}');
}
}
class EsimInstallResult {
final bool success;
final String? errorCode;
final String? errorMessage;
final bool needsUserConfirmation;
const EsimInstallResult({
required this.success,
this.errorCode,
this.errorMessage,
this.needsUserConfirmation = false,
});
static const unknown = EsimInstallResult(
success: false,
errorCode: 'UNKNOWN',
);
factory EsimInstallResult.fromMap(Map<String, dynamic> map) {
return EsimInstallResult(
success: map['success'] as bool? ?? false,
errorCode: map['errorCode'] as String?,
errorMessage: map['errorMessage'] as String?,
needsUserConfirmation: map['needsUserConfirmation'] as bool? ?? false,
);
}
}
Android MethodChannel Handler
// Kotlin — Flutter MethodChannel handler
class EsimMethodChannel(
private val context: Context,
flutterEngine: FlutterEngine
) : MethodChannel.MethodCallHandler {
private val channel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"com.rivio.app/esim"
)
private val installer = EsimInstaller(context)
init {
channel.setMethodCallHandler(this)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"isEsimSupported" -> {
result.success(installer.isEsimSupported())
}
"installProfile" -> {
val activationCode = call.argument<String>("activationCode")
?: return result.error("MISSING_ARG", "activationCode gerekli", null)
installer.downloadProfile(activationCode) { success, errorCode ->
val response = mapOf(
"success" to success,
"errorCode" to errorCode?.toString(),
"needsUserConfirmation" to
(errorCode == EsimInstaller.RESULT_NEEDS_USER_CONFIRMATION)
)
result.success(response)
}
}
"getInstalledProfiles" -> {
result.success(emptyList<String>())
}
else -> result.notImplemented()
}
}
}
iOS MethodChannel Handler
// Swift — Flutter MethodChannel handler
import Flutter
import CoreTelephony
class EsimMethodChannel: NSObject {
private let channel: FlutterMethodChannel
private let installer = EsimInstaller()
init(messenger: FlutterBinaryMessenger) {
channel = FlutterMethodChannel(
name: "com.rivio.app/esim",
binaryMessenger: messenger
)
super.init()
channel.setMethodCallHandler(handle)
}
private func handle(call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "isEsimSupported":
result(installer.supportsEsim())
case "installProfile":
guard let args = call.arguments as? [String: Any],
let activationCode = args["activationCode"] as? String else {
result(FlutterError(code: "MISSING_ARG",
message: "activationCode gerekli",
details: nil))
return
}
// LPA:1$adres$matchingId formatını ayrıştır
let components = activationCode
.replacingOccurrences(of: "LPA:1$", with: "")
.split(separator: "$")
guard components.count >= 2 else {
result(FlutterError(code: "INVALID_FORMAT",
message: "Geçersiz aktivasyon kodu formatı",
details: nil))
return
}
let address = String(components[0])
let matchingId = String(components[1])
let iccid = args["iccid"] as? String ?? ""
installer.installProfile(
smdpAddress: address,
matchingId: matchingId,
iccid: iccid
) { installResult in
switch installResult {
case .success:
result(["success": true])
case .failure(let error):
result([
"success": false,
"errorCode": String(describing: error),
"errorMessage": error.localizedDescription
])
}
}
case "getInstalledProfiles":
result([String]())
default:
result(FlutterMethodNotImplemented)
}
}
}
Hata Yönetimi: Neyin Ters Gideceğini Bilin
Her production eSIM entegrasyonu aynı hata modlarıyla karşılaşır. Rivio’da her birini nasıl ele aldığımızı paylaşıyoruz.
eUICC Mevcut Değil
Cihazda eSIM çipi yok veya devre dışı bırakılmış. Kurulumdan önce mutlaka kontrol edin ve kullanıcıyı QR kod fallback’ine yönlendirin.
// Dart — Kurulum öncesi kontrol akışı
Future<void> startEsimInstallation(String activationCode, String iccid) async {
final esimService = EsimService();
// Adım 1: Donanım desteğini kontrol et
final supported = await esimService.isEsimSupported();
if (!supported) {
showQrCodeFallback(activationCode);
return;
}
// Adım 2: Operatör kilidini kontrol et
final locked = await esimService.isCarrierLocked();
if (locked) {
showCarrierLockError();
return;
}
// Adım 3: Profil zaten yüklü mü kontrol et
final installed = await esimService.getInstalledProfiles();
if (installed.contains(iccid)) {
showProfileAlreadyInstalledMessage();
return;
}
// Adım 4: Retry mekanizmasıyla kurulumu dene
final result = await _installWithRetry(
esimService: esimService,
activationCode: activationCode,
iccid: iccid,
maxRetries: 3,
);
if (result.success) {
trackEvent('esim_install_success');
navigateToActivationComplete();
} else if (result.needsUserConfirmation) {
trackEvent('esim_install_user_confirmation');
} else {
trackEvent('esim_install_failure', params: {
'error_code': result.errorCode,
});
showInstallationError(result);
}
}
Future<EsimInstallResult> _installWithRetry({
required EsimService esimService,
required String activationCode,
required String iccid,
required int maxRetries,
}) async {
EsimInstallResult? lastResult;
for (var attempt = 0; attempt < maxRetries; attempt++) {
if (attempt > 0) {
// Üstel geri çekilme: 2sn, 4sn, 8sn
await Future.delayed(Duration(seconds: 1 << (attempt + 1)));
}
lastResult = await esimService.installProfile(
activationCode: activationCode,
iccid: iccid,
);
if (lastResult.success || lastResult.needsUserConfirmation) {
return lastResult;
}
// Kalıcı hatalarda retry yapma
if (lastResult.errorCode == 'CARRIER_LOCKED' ||
lastResult.errorCode == 'PROFILE_ALREADY_INSTALLED') {
return lastResult;
}
}
return lastResult ?? EsimInstallResult.unknown;
}
Production Değerlendirmeleri
Arka Plan Provizyon
Android 11+ üzerinde EuiccManager.downloadSubscription(), wake lock tutuyorsanız uygulama arka plandayken çalışabilir. Biz indirme çağrısını WorkManager OneTimeWorkRequest içine sararak, kullanıcı SM-DP+ el sıkışması sırasında uygulama değiştirirse indirmenin güvenilir şekilde tamamlanmasını sağlıyoruz.
Analitik ve Gözlemlenebilirlik
Rivio’da her eSIM kurulum denemesini şu boyutlarla izliyoruz:
- Cihaz modeli ve OS sürümü — Bazı OEM’lerin hatalı EuiccManager implementasyonları var
- SM-DP+ yanıt süresi — El sıkışma gecikmesini izleyerek sunucu tarafı sorunları tespit ediyoruz
- Hata kodu dağılımı — Hangi hata modlarını iyileştireceğimizi önceliklendirmemize yardımcı oluyor
- Retry deneme numarası — Geçici hataların kurtarılabilir olup olmadığını gösteriyor
QR Kod Fallback
Programatik kurulumunuz ne kadar sağlam olursa olsun, her zaman QR kod fallback’i sunun. Bazı cihazlarda eUICC çipleri OS API’leri üzerinden düzgün şekilde açığa çıkmıyor (evet, bazı Xiaomi modellerinden bahsediyoruz). QR kod yolu, sistem LPA’sı üzerinden geçtiği için her eSIM destekli cihazda çalışır.
Rivio 150’den Fazla Ülkeyi Tek Profille Nasıl Yönetiyor
Çoğu eSIM sağlayıcısı ülke veya bölge başına bir profil çıkarır. Bu hem sağlayıcı hem de kullanıcı için yönetim yükü oluşturur. Rivio’da temelden farklı bir yaklaşım benimsedik.
Kullanıcı başına tek bir global bootstrap profil sağlıyoruz. Bu profil, 150’den fazla ülkedeki partner MNO’larla roaming anlaşmaları olan MVNO global IMSI ağımıza bağlıdır. Kullanıcı yeni bir ülkeye seyahat ettiğinde, cihaz standart GSMA roaming protokolü aracılığıyla yerel partner ağa otomatik olarak bağlanır. Backend tarafındaki Online Charging System (OCS) altyapımız, ücretlendirme, bakiye düşümü ve politika uygulamasını gerçek zamanlı olarak yönetir.
Flutter uygulaması perspektifinden bakıldığında, eSIM kurulumu onboarding sırasında tam olarak bir kez gerçekleşir. Sonrasında ülke değişiklikleri tamamen şeffaftır — kullanıcı uygulamayı açar, bakiyesini ve bulunduğu ülkeyi görür, ağ gerisini halleder. Profil değişikliği yok, yeniden provizyon yok, yeni profil yüklemek için ek API çağrısı yok.
Bu mimari aynı zamanda bakiyenin asla sona ermemesini de sağlar. Tek profil ve tek hesap olduğundan, kullanılmayan veri kredisi süresiz olarak taşınır. Kullanıcı hangi ülkede olursa olsun yalnızca tükettiği byte’lar için ödeme yapar.
Sonuç
Flutter uygulamasına eSIM kurulumu entegre etmek sıradan bir SDK entegrasyonu değil. Hem Android hem iOS tarafında native platform kodu, carrier privilege için SM-DP+ sağlayıcılarıyla koordinasyon, haftalarca süren Apple entitlement süreci ve çok çeşitli hata modları için kapsamlı hata yönetimi gerektirir.
Yukarıda gösterilen MethodChannel mimarisi, Rivio’da production ortamında kullandığımız yapının aynısı. Bu sistemi 150’den fazla ülkede gerçek kullanıcılara gönderirken öğrendiğimiz temel dersler:
- Her zaman önce cihaz desteğini kontrol edin ve QR kod fallback’i sunun.
- Carrier privilege kurulumuna erken yatırım yapın — sorunsuz kurulum ile hantal onay diyalogu arasındaki fark budur.
- Apple entitlement sürecini ilk günden başlatın — 8-12 hafta tampon değil, temel beklentidir.
- Her şeyi izleyin — OEM’e özgü hatalar gerçektir ve bunları yalnızca production analitiği aracılığıyla bulabilirsiniz.
Eğer seyahat eSIM ürünü geliştiriyorsanız, tek profilli global roaming mimarisi bütün bir karmaşıklık kategorisini ortadan kaldırır. Bir kurulum, bir profil, 150’den fazla ülke. Rivio’yu bu model üzerine kurduk ve kullanıcılarımıza gerçek anlamda sürtünmesiz bir deneyim sunmamızı sağlayan da bu: uygulamayı indir, eSIM’ini al, istediğin yere seyahat et.