İçeriğe geç
← Tüm yazılar
Mühendislik

Flutter'da eSIM Entegrasyonu: Kapsamlı Teknik Rehber

Berin Doğa Alaca Berin Doğa Alaca ·
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ı. 1 değ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:

  1. Uygulamanızın paket adı (örn. com.rivio.app)
  2. İ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 ':'

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:

  1. Apple ile iletişime geçin. Apple Sales Representative’inize ulaşın veya mevcut bir partner iseniz Apple MFi Program kanallarını kullanın.
  2. İş 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.
  3. Teknik değerlendirme. Apple’ın teknik ekibi entegrasyonunuzu inceler. Genellikle TestFlight üzerinden çalışan bir prototip göstermeniz istenir.
  4. Entitlement onayı. Onaylandıktan sonra entitlement, Apple Developer hesabınızdaki App ID’nize eklenir.
  5. 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:

  1. Her zaman önce cihaz desteğini kontrol edin ve QR kod fallback’i sunun.
  2. Carrier privilege kurulumuna erken yatırım yapın — sorunsuz kurulum ile hantal onay diyalogu arasındaki fark budur.
  3. Apple entitlement sürecini ilk günden başlatın — 8-12 hafta tampon değil, temel beklentidir.
  4. 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.

Berin Doğa Alaca

Berin Doğa Alaca

Frontend Teknik Lider

Berin Doğa Alaca, Rivio'nun Frontend Teknik Lideridir. Milyonlarca gezginin etkileşimde bulunduğu web ve mobil arayüzleri tasarlayıp geliştirerek, tüm platformlarda kusursuz tasarım ve yüksek performans sağlamaktadır.