Android'de NFC okuma hatalarının çoğu parse hatasından değil, radyo bağlantısından gelir. Çip ile telefonun anteni bütün veri alışverişi boyunca bağlı kalmak zorundadır. Bu bağ bir komutun ortasında koparsa o komut başarısız olur ve Android genellikle TagLostException fırlatır. Bunu kabul edince "benim telefonumda çalışıyor, kullanıcıda çalışmıyor" şikayetlerinin çoğu anlam kazanıyor. Çözüm de çoğu zaman yeni bir okuma stratejisi değil, daha iyi toparlanma ve daha iyi kullanıcı yönlendirmesi oluyor.
Bu yazı, okuma ve doğrulama akışının tamamını anlattığım Android'de eID çipi okuma yazısının devamı gibi. Burada işin ters giden kısmına odaklanıyorum: okuma neden başarısız olur ve güvenli kanalı bozmadan nasıl toparlanırız.
Okuma nerede başarısız olabilir
Okuma üç farklı noktada başarısız olabilir ve bunları hem kodda hem log'larda ayrı tutmak işe yarar:
- Keşif ve bağlantı. Tag algılanır ama
IsoDep.get(tag)null döner (kart ISO-DEP değildir) ya da daha hiçbir komut gönderilmedenconnect()hata fırlatır. Telefon çiple düzgün eşleşememiştir. - Okumanın ortası. Erişim kontrolü tamamdır, dosyalar geliyordur ve bir
transceiveçağrısı hata fırlatır. Telefon çiple eşleşmiş, sonra bağı kaybetmiştir. - Erişim kontrolü veya doğrulama. Bağlantı sağlamdır ama çip erişim bilgilerini reddetmiştir (yanlış MRZ veya CAN) ya da her şey okunmuş, Passive Authentication gibi bir doğrulama adımı başarısız olmuştur.
Bunların her biri farklı bir mesaj ve farklı bir çözüm ister. Bu yüzden tek bir "okuma başarısız" boolean'ı döndürmek yerine durumları açıkça modelliyorum:
sealed interface ReadFailure {
data object NotIsoDep : ReadFailure // IsoDep.get() returned null
data object ConnectFailed : ReadFailure // connect() threw
data object CardLost : ReadFailure // TagLostException during the read
data object IoError : ReadFailure // other IOException: cancel, link error
data object AccessDenied : ReadFailure // BAC/PACE rejected the MRZ or CAN
data object VerificationFailed : ReadFailure // data read, but PA/AA/CA check failed
}
Erişim kontrolünün kendine ait hata biçimleri de var; BAC ve PACE el sıkışmalarını BAC ve PACE yazısında anlattım.
Alan neden kopar
Aynı kart ve aynı telefonla aralıklı hata alıyorsanız sebep genelde fizikseldir:
- Anten her telefonda başka yerde. Arka yüzün üst kısmında, kamera çevresinde ya da daha aşağıda olabilir. Kullanıcı bunu bilmez; kartı yanlış noktada tutar, bağ ya zayıf olur ya da hiç kurulmaz.
- Kılıf ve mesafe alanı zayıflatır. Kalın bir kılıf, içinde metal olan cüzdan kılıf ya da kartı birkaç milimetre fazla uzakta tutmak yetebilir. Yakın alan iletişiminde küçük bir boşluk ciddi sinyal kaybı demektir.
- Çip alanla çalışır. Temassız çipin bataryası yoktur, enerjisini okuyucunun alanından alır. Kriptografik işlemler daha çok enerji ister ve daha uzun sürer. Bu yüzden zayıf bir alanda hata, basit bir dosya okumasından çok erişim kontrolü veya Chip Authentication sırasında çıkar.
- Kullanıcı hareket eder. Ekrana bakmak için telefonu kaldırır, spinner görünce kartı kaydırır ya da tam okumanın sürdüğü birkaç saniye boyunca sabit duramaz. DG2 (yüz fotoğrafı) genelde en büyük dosyadır, dolayısıyla harekete en uzun süre açık kalan okuma odur.
Mantıksal hatalar da var tabii: yanlış anahtar, desteklenmeyen bir komut, parser'daki bir bug. Bunlar genelde her seferinde aynı adımda, aynı şekilde patlar. Aralıklı hatalar da bu yüzden önce alana bakmak için iyi bir sebep, ama tek başına kanıt değil.
Foreground dispatch yerine reader mode
Foreground dispatch'te tag'i platform keşfeder, normal dispatch akışını (NDEF kontrolü dahil) çalıştırır, keşif sesini çalar ve size bir intent verir. Bu sırada card emulation da açık kalır. enableReaderMode ise activity ön plandayken NFC controller'ı yalnız okuyucu moduna alır: card emulation kapanır ve Tag doğrudan callback'e gelir. eID okuması için daha uygun olan bu.
import android.app.Activity
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.os.Bundle
class EidReaderMode(
private val activity: Activity,
private val onTag: (Tag) -> Unit,
) {
private val adapter: NfcAdapter? = NfcAdapter.getDefaultAdapter(activity)
// Call from onResume(); reader mode is tied to the foreground activity.
fun enable() {
val options = Bundle().apply {
putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
}
adapter?.enableReaderMode(
activity,
{ tag -> onTag(tag) },
NfcAdapter.FLAG_READER_NFC_A or
NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK or
NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS,
options,
)
}
// Call from onPause().
fun disable() {
adapter?.disableReaderMode(activity)
}
}
Bayrakların yaptığı iş:
FLAG_READER_NFC_AveFLAG_READER_NFC_B, polling'i kimlik belgelerinin kullandığı iki teknolojiyle sınırlar; controller başka teknolojileri aramakla vakit kaybetmez.FLAG_READER_SKIP_NDEF_CHECK, platformun kartta NDEF araması yapmasını engeller. eID çipinde orada bulunacak bir şey yok; bu kontrolü atlayınca sizin komutlarınızdan önce çipe fazladan komut gitmez.FLAG_READER_NO_PLATFORM_SOUNDSkeşif sesini kapatır. Bu tamamen UX ile ilgili: ses okuma bittiğinde değil, kart algılandığında çalar ve birçok kullanıcı bunu kartı çekme işareti sanar. Okuma gerçekten bittiğinde kendi sesinizi veya titreşiminizi çalın.
EXTRA_READER_PRESENCE_CHECK_DELAY, platformun boşta duran tag'in hâlâ orada olup olmadığını ne sıklıkla kontrol edeceğini belirler. Referans dokümanda birim yazmıyor; AOSP değeri milisaniye olarak yorumluyor ve siz vermezseniz 125 kullanıyor. Daha uzun bir aralık, komutlarınızın arasına daha az presence-check frame'i girmesi demek; bazı çip ve controller kombinasyonları bununla daha iyi anlaşıyor. Fazla uzatırsanız da kartın çekildiğini daha geç fark edersiniz. 250'yi başlangıç noktası olarak alın ve hedef cihazlarda ölçün.
Referans doküman callback'in hangi thread'de çalıştığını söylemiyor. AOSP'de callback main thread'e değil bir binder thread'ine gelir; okumayı orada yapabilir ya da bir background dispatcher'a devredebilirsiniz. Her iki durumda da connect() ve transceive() bloklar ve asla main thread'de çağrılmamalı.
IsoDep: timeout ve APDU boyutu
import android.nfc.Tag
import android.nfc.tech.IsoDep
import android.util.Log
const val ISO_DEP_TIMEOUT_MS = 5_000
fun openIsoDep(tag: Tag): IsoDep? {
val isoDep = IsoDep.get(tag) ?: return null
isoDep.connect()
// On-chip crypto (PACE, Chip Authentication, Active Authentication) can be slow.
isoDep.timeout = ISO_DEP_TIMEOUT_MS
Log.d(
"EidReader",
"maxTransceiveLength=${isoDep.maxTransceiveLength}, " +
"extendedLength=${isoDep.isExtendedLengthApduSupported}",
)
return isoDep
}
setTimeout, transceive için timeout'u milisaniye cinsinden ayarlar ve close() bu değeri varsayılana döndürür. Yani timeout'u uygulama başına bir kez değil, her connect() sonrasında ayarlamak gerekiyor. Süresi dolan bir timeout'un nasıl göründüğüne de dikkat edin: AOSP'nin NCI stack'inde zamanında gelmeyen yanıt, tag kaybolmuş gibi raporlanır ve TagLostException alırsınız. Çip üzerindeki kriptografiye yetmeyen bir timeout bu yüzden kartı oynatan bir kullanıcıdan farksız görünür. CardLost hep aynı adımda, örneğin Chip Authentication sırasında çıkıyorsa önce timeout'u kontrol edin.
APDU boyutunda da frame uzunluğunu sabit yazmayın. Kısa bir APDU en fazla 255 byte komut verisi taşır (toplamda 261 byte). Extended-length APDU'lar çok daha fazlasına izin verir ama yalnız telefon ve çip ikisi birden destekliyorsa. Telefon tarafını isExtendedLengthApduSupported söyler; tek bir transceive ile gönderebileceğiniz en büyük boyutu da maxTransceiveLength. Frame'leri kendiniz bölmenize gerek yok, platform ISO-DEP katmanında parçalıyor. Secure Messaging'de yanıt ayrıca padding ve MAC taşıdığı için okuyucular READ BINARY ile genelde ham üst sınırdan biraz küçük bloklar ister.
TagLostException size ne söyler
TagLostException, IOException'ın bir alt sınıfı. transceive bunu tag alandan çıktığında fırlatır. Pratikte üç sonucu var:
- Eski
Tagartık işe yaramaz. IsoDep referansı, tag alandan çıktıktan sonra tag nesnesi tekrar kullanılırsaSecurityExceptionfırlatılabileceğini söylüyor. Kart geri geldiğinde reader mode callback'e yeni birTagverir; çalışacağınız nesne odur. - Çipin resetlendiğini varsayın. Alandan çıkan kartın enerjisi kesilmiştir. Seçili uygulama, Secure Messaging anahtarları ve send sequence counter (SSC) gitmiştir.
- Exception türüne güvenmeyin. Cihaza ve kartın çekildiği ana göre kartın gitmesi düz bir
IOExceptionolarak da gelebilir; yukarıda gördüğümüz gibi timeout daTagLostExceptionolarak gelebilir. Toparlanma kodunuz ikisini de karşılamalı.
Bu da akla ilk gelen "yakala, yeniden bağlan, aynı APDU'yu tekrar gönder" döngüsünü eler. BAC veya PACE oturumunda tekrar gönderilen komutun ait olacağı bir oturum kalmamıştır: komut, resetlenen çipin artık bilmediği oturum anahtarları ve SSC değeriyle şifrelenip MAC'lenmiştir. ICAO Doc 9303 Part 11, §9.8 ayrıca çipin her Secure Messaging hatasında, oturum ortasında gelen her düz APDU'da da Secure Messaging'i sonlandırıp oturum anahtarlarını silmesini şart koşar. Yani "kart hâlâ orada mı?" diye gönderilen tek bir korumasız komut bile oturumu bitirir.
Aynı bağlantı üzerinde tek bir komutu tekrar denemek yalnız korumasız trafikte, örneğin erişim kontrolünden önceki uygulama SELECT'inde ve yalnız tag kaybolmadıysa anlamlıdır.
Oturum seviyesinde toparlanma
Güvenli retry birimi APDU değil, oturumun tamamı. Hatadan sonra reader mode'u açık tutun, bir sonraki Tag'i bekleyin, yeni bağlantı açın, kimlik doğrulamayı baştan yapın ve bitmemiş dosyalarla devam edin. Aşağıdaki kodda AccessKey, DataGroup, openSecureChannel, readFile ve EidAuthException sizin BAC/PACE ve dosya okuma kodunuzun yerini tutuyor:
import android.nfc.Tag
import android.nfc.TagLostException
import android.nfc.tech.IsoDep
import java.io.IOException
sealed interface SessionResult {
data class Done(val files: Map<DataGroup, ByteArray>) : SessionResult
data class TryAgain(val failure: ReadFailure) : SessionResult
data class GiveUp(val failure: ReadFailure) : SessionResult
}
class EidReadSession(
private val accessKey: AccessKey, // derived from the MRZ or the CAN
private val groups: List<DataGroup>,
private val maxAttempts: Int = 3,
) {
private val completed = linkedMapOf<DataGroup, ByteArray>()
private var attempts = 0
// Call for every Tag that reader mode delivers, one at a time, off the main thread.
fun run(tag: Tag): SessionResult {
attempts++
val isoDep = IsoDep.get(tag)
?: return SessionResult.GiveUp(ReadFailure.NotIsoDep)
var connected = false
try {
isoDep.connect()
connected = true
isoDep.timeout = ISO_DEP_TIMEOUT_MS
// New Tag, new chip session: authenticate from scratch
// (BAC or PACE, then Chip Authentication if you use it).
val channel = openSecureChannel(isoDep, accessKey)
for (group in groups) {
if (group !in completed) {
// A partially read file is dropped and read again in full.
completed[group] = channel.readFile(group)
}
}
return SessionResult.Done(completed.toMap())
} catch (e: EidAuthException) {
// A wrong MRZ or CAN won't get better by retrying.
return SessionResult.GiveUp(ReadFailure.AccessDenied)
} catch (e: TagLostException) {
return retryOrGiveUp(ReadFailure.CardLost)
} catch (e: IOException) {
return retryOrGiveUp(
if (connected) ReadFailure.IoError else ReadFailure.ConnectFailed,
)
} finally {
runCatching { isoDep.close() }
}
}
private fun retryOrGiveUp(failure: ReadFailure): SessionResult =
if (attempts < maxAttempts) {
SessionResult.TryAgain(failure)
} else {
SessionResult.GiveUp(failure)
}
}
Burada önemli olan birkaç ayrıntı:
- Sleep yok, busy loop yok. Bir sonraki deneme, kullanıcı kartı geri getirip reader mode yeni bir
Tagverdiğinde başlar.TryAgaingeldiğinde arayüzü güncelleyip beklersiniz. - Bitmiş dosyalar saklanır. Her biri baştan sona okunmuş ve her yanıtı Secure Messaging altında MAC kontrolünden geçmiştir. Passive Authentication sırasında her data group hash'ini zaten SOD ile karşılaştırıyorsunuz; bozuk bir dosya orada da yakalanır. Yarım kalan dosyaya kaldığı offset'ten devam etmek mümkün, çünkü dosya içeriği değişmez; ama o tek dosyayı baştan okumak daha basit ve maliyeti düşük.
- Araya başka kart giremez. BAC/PACE anahtarı bu belgenin MRZ'sinden veya CAN'inden türetilir. Kullanıcı retry'da başka bir kart okutursa o kartın verisi sizinkine karışmaz, kimlik doğrulama başarısız olur.
- Deneme sınırı kullanıcı için var. Birkaç bağlantı kopmasından sonra bir retry daha nadiren işe yarar; bunun yerine daha net bir yönlendirme gösterin.
- İptal hazır. Kullanıcı okuma sırasında "İptal"e basarsa başka bir thread'den
close()çağırın. BloklanmıştransceivebirIOExceptionile döner.
Kullanıcıyı yönlendirmek
Yanlış noktada tutulan kartı kod düzeltemez, işin bir kısmını ekran yapmak zorunda:
- Kartı nerede ve sabit tutacağını söyleyin. Yaygın cihazlar için anten konumunu biliyorsanız gösterin. Android 14 (API 34) ve sonrasında
NfcAdapter.getNfcAntennaInfo()anten konumlarını bildirebilir ama null dönebilir; bunu bir ipucu olarak görün. Bilmiyorsanız kullanıcıdan kartı telefonun arkasında yavaşça gezdirmesini ve okuma başlayınca sabit tutmasını isteyin. - Okurken ilerlemeyi gösterin. Data group başına bir ilerleme göstergesiyle "Okunuyor, kartı çekmeyin" mesajı, kullanıcının kontrol etmek için telefonu kaldırmasını engeller.
- Bitişi kendiniz bildirin. Platform sesi kapalıyken okuma tamamlandığında çalan bir ses veya titreşim, kullanıcıya artık kartı çekebileceğini söyler.
- Hataları sade bir dille anlatın. "Bağlantı koptu. Kartı aynı noktada sabit tutun" uygulanabilir bir mesaj; "Okuma başarısız" değil.
- Başlamadan NFC'nin açık olduğunu kontrol edin.
NfcAdapter.isEnabled()false ise hiç gelmeyecek bir tag'i beklemek yerine kullanıcıyıSettings.ACTION_NFC_SETTINGSekranına yönlendirin.
Her hata türü kendi mesajına karşılık geliyor:
fun ReadFailure.userMessage(): String = when (this) {
ReadFailure.NotIsoDep -> "Bu kart okunamıyor. Kimlik kartınızı veya pasaportunuzu kullanın."
ReadFailure.ConnectFailed -> "Çipe ulaşılamadı. Kartı telefonun arkasında yavaşça gezdirin."
ReadFailure.CardLost -> "Bağlantı koptu. Okuma bitene kadar kartı aynı noktada sabit tutun."
ReadFailure.IoError -> "Çip yanıt vermeyi bıraktı. Kartı uzaklaştırıp tekrar yaklaştırın."
ReadFailure.AccessDenied -> "Çip erişim bilgilerini reddetti. Girdiğiniz bilgileri kontrol edin."
ReadFailure.VerificationFailed -> "Belge okundu ancak doğrulanamadı."
}
Gerçek uygulamada bu metinler elbette resource'lara gider. Fikir, SecureCheck'teki yaklaşımla aynı: neden başarısız olduğunu söyleyen bir sonuç, tek bir başarılı/başarısız bayrağından daha kullanışlıdır.
Sık sorulan sorular
- Android'de NFC okuma neden başarısız olur?
- Çoğunlukla fiziksel sebeplerle: anten her telefonda farklı yerdedir, kılıf ve mesafe alanı zayıflatır, çip enerjisini bu alandan alır ve kullanıcı okuma sırasında telefonu oynatır. DG2 yüz fotoğrafı gibi uzun okumalar harekete en açık olanlardır.
- TagLostException ne demek, ondan nasıl toparlanılır?
- Tag alandan çıktığında transceive'ın fırlattığı bir IOException alt sınıfıdır. Eski Tag'i kullanılmaz kabul edin ve çipin resetlendiğini varsayın. Reader mode'u açık tutup yeni Tag'i bekleyin, yeni oturum açın, bitmiş dosyaları saklayın ve birkaç denemeden sonra durun.
- IsoDep timeout'unu neden her bağlantıda ayarlamak gerekir?
- close() timeout'u varsayılana döndürür ve her yeni Tag yeni bir IsoDep nesnesi demektir. Varsayılan değer çip üzerindeki kriptografik işlemlere yetmeyebilir; AOSP'nin NCI stack'inde süresi dolan timeout TagLostException olarak gelir, yani kart çekilmiş gibi görünür.
- Alan koptuktan sonra BAC veya PACE ile korunan bir APDU tekrar gönderilebilir mi?
- Hayır. Oturum anahtarları ve send sequence counter oturuma aittir; enerjisi kesilen çip bunları unutmuştur. Kanalı bırakın, kartın tekrar okutulmasını bekleyin ve BAC veya PACE'i (kullanıyorsanız Chip Authentication'ı da) baştan çalıştırın.