diff --git a/app/build.gradle b/app/build.gradle index cd5f7ea0b..b95aad802 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -35,6 +35,9 @@ android { minifyEnabled = false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } + debug { + applicationIdSuffix 'debug' //todo-op!!! disable + } } flavorDimensions "version" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index df3349851..671be37c9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,6 +20,7 @@ + () // deal with async events + private fun tagInProgress() = null != tagInProgress.toList().find { it.second } + + private fun onError(@androidx.annotation.StringRes msgId: Int, info: Boolean = false) = + Toast.makeText(that.context, msgId, Toast.LENGTH_LONG).show() + private fun onError(message: String, e: Throwable?) = + if (e is Exception) that.onGenericException(e) + else Toast.makeText(that.context, message, Toast.LENGTH_LONG).show() + + fun startIfEnabled() { + if (!PreferencesUtil.isUnlockNfcEnable(that.requireContext())) return + if (nfcService == null) nfcService = NfcService(that.requireContext().packageName, ::onError) + nfcService?.enable(that.activity, that.activity?.javaClass, ::onTapNfcTag) + checkAndSetupDebug(true) + if (true != nfcService?.isEnabled && NfcService.isDebug) onError(R.string.nfc_not_enabled) + + if (!tagInProgress()) tagPending?.let { // onTap NFC tag... + tagPending = null + onTapNfcTag(it) + } + } + + fun stop() { + checkAndSetupDebug(false) + if (that.isVisible) nfcService?.disable(that.activity) + } + + private fun checkAndSetupDebug(enable: Boolean) { // debug in emulator: simulate tap NFC tag + if (NfcService.isDebug) return + if (!enable) that.mAdvancedUnlockInfoView?.setOnClickListener(null) + else that.mAdvancedUnlockInfoView?.setOnClickListener { + nfcService?.debugTap(that.activity, ::onTapNfcTag) + } + } + + fun checkAndProcessTag(intent: Intent? = null): Boolean { + return !tagInProgress() && nfcService?.tagRead(intent) { nfcTag -> + // onTap NFC tag -> Activity.onNewIntent -> Fragment.onPause -> disconnect() + // After 'disconnect', databaseFileUri is null. Therefore process tag after resume + if (null != nfcTag) + if (null != that.databaseFileUri) onTapNfcTag(nfcTag) + else tagPending = nfcTag + } ?: false + } + + private fun onTapNfcTag(nfcTag: NfcTag) { + // Credential DB extract/store: + // biometric/device record: key = DBFileUri; value = credential + // NFC tag record: key = DBFileUri + '#nfc'; value = credential + unlockNfcTag + // unlockNfcTag = NFC tag unique data. Example: NFC tag ID ('Anti-cloning support by unique 7-byte serial number for each device') + // + // NFC tag read/write: unlockUnique + // unlockUnique = unique bytes, checksum based on credential (password) + // Needs re-write NFC tag after password change. + // Alternative - checksum based on Device or App installation? + + if (nfcTag !is NfcTagUnlock) return + + fun unlockCheck(): Boolean { + if (true != nfcTag.unlockNfcTag?.isNotEmpty()) { + onError(R.string.nfc_tag_not_supported) + return false + } else if (!nfcTag.unlockCanWrite) + onError(R.string.nfc_tag_write_not_supported, true) + return true + } + + //@RequiresApi(Build.VERSION_CODES.M) + fun extractCredential() { // Like onAuthenticationSucceeded() for Mode.EXTRACT_CREDENTIAL + that.advancedUnlockManager?.let { advancedUnlockManager -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) + that.getDatabaseKey(DatabaseKeyId.Nfc.value)?.let { databaseKey -> + tagInProgress['G'] = true // deal with async events + that.cipherDatabaseAction.getCipherDatabase(databaseKey) { cipherDatabase -> + try { + cipherDatabase?.encryptedValue?.let { encryptedValue -> + advancedUnlockManager.initDecryptData(cipherDatabase.specParameters) {} // Like initDecryptData() + if (!unlockCheck()) return@getCipherDatabase + advancedUnlockManager.decryptData(encryptedValue, + nfcTag.unlockNfcTag?.size ?: 0, DatabaseKeyId.Nfc.value) { credential, unlockData -> + unlockData == nfcTag.unlockNfcTag && (!nfcTag.unlockCanWrite || nfcTag.unlockCheck(credential)) + } + } ?: that.deleteEncryptedDatabaseKey(DatabaseKeyId.Nfc.value) + } finally { + tagInProgress['G'] = false // deal with async events + } + } + } ?: that.onAuthenticationError(-1, that.getString(R.string.error_database_uri_null)) + } //?: throw Exception("AdvancedUnlockManager not initialized") + } + + //@RequiresApi(Build.VERSION_CODES.M) + fun storeCredential(containsCipher: Boolean) { // Like onAuthenticationSucceeded() for Mode.STORE_CREDENTIAL + that.advancedUnlockManager?.let { advancedUnlockManager -> + that.mBuilderListener?.retrieveCredentialForEncryption()?.let { credential -> + tagInProgress['W'] = true // deal with async events + nfcTag.unlockWriteAsk(that.requireContext(), containsCipher, onNo = { + tagInProgress['W'] = false // deal with async events + }) { nfcNoWrite, + ndefClearMessage, ndefClearRecord, ndefIgnore, + ndefMakeReadOnly, ndefFormat, miUlClearPages, miUlClearLast, + nTagClearPass, nTagClearProtect, nTagPass, + nTagProtect, nTagProtectConfig, nTagProtectPass -> + try { + if (!unlockCheck()) return@unlockWriteAsk + val done = !nfcTag.unlockCanWrite || nfcNoWrite || nfcTag.unlockWrite(credential, + ndefClearMessage, ndefClearRecord, ndefIgnore, + ndefMakeReadOnly, ndefFormat, miUlClearPages, miUlClearLast, + nTagClearPass, nTagClearProtect, nTagPass, + nTagProtect, nTagProtectConfig, nTagProtectPass) + if (done) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + advancedUnlockManager.initEncryptData {} // Like initEncryptData() + advancedUnlockManager.encryptData(credential, nfcTag.unlockNfcTag, DatabaseKeyId.Nfc.value) + } + } finally { + tagInProgress['W'] = false // deal with async events + } + } + } + } //?: throw Exception("AdvancedUnlockManager not initialized") + } + + try { + that.getDatabaseKey(DatabaseKeyId.Nfc.value)?.also { databaseKey -> + if ((that.biometricMode == Mode.EXTRACT_CREDENTIAL || that.biometricMode == Mode.WAIT_CREDENTIAL) + && true != that.mBuilderListener?.conditionToStoreCredential()) { + tagInProgress['D'] = true // deal with async events + nfcTag.unlockInfoAsk(that.requireContext(), { + tagInProgress['D'] = false // deal with async events + }) { + try { + tagInProgress['C'] = true // deal with async events + that.cipherDatabaseAction.containsCipherDatabase(databaseKey) { containsCipher -> + try { + if (containsCipher) extractCredential() + } finally { + tagInProgress['C'] = false // deal with async events + } + } + } finally { + tagInProgress['D'] = false // deal with async events + } + } + } else if ((that.biometricMode == Mode.STORE_CREDENTIAL || that.biometricMode == Mode.WAIT_CREDENTIAL) + && true == that.mBuilderListener?.conditionToStoreCredential()) { + tagInProgress['C'] = true // deal with async events + that.cipherDatabaseAction.containsCipherDatabase(databaseKey) { containsCipher -> + try { + storeCredential(containsCipher) + } finally { + tagInProgress['C'] = false // deal with async events + } + } + } + } + } catch (e: Exception) { + that.onGenericException(e) + } + } + } + val nfc = Nfc(this) + + enum class DatabaseKeyId(val value: Int?) { None(null), Nfc(1) } + + // NFC unlock data - stored with different key, not overriding Biometric/DeviceCredential data + private fun getDatabaseKey(databaseKeyId: Int?): Uri? = + if (databaseKeyId == DatabaseKeyId.Nfc.value) + databaseFileUri?.let { Uri.parse("$it#nfc") } + else databaseFileUri + override fun onAttach(context: Context) { super.onAttach(context) - mAdvancedUnlockEnabled = PreferencesUtil.isAdvancedUnlockEnable(context) + mAdvancedUnlockEnabled = PreferencesUtil.isUnlockNfcEnable(requireContext()) + || PreferencesUtil.isAdvancedUnlockEnable(context) mAutoOpenPromptEnabled = PreferencesUtil.isAdvancedUnlockPromptAutoOpenEnable(context) try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -154,7 +335,8 @@ class AdvancedUnlockFragment: StylishFragment(), AdvancedUnlockManager.AdvancedU override fun onResume() { super.onResume() context?.let { - mAdvancedUnlockEnabled = PreferencesUtil.isAdvancedUnlockEnable(it) + mAdvancedUnlockEnabled = PreferencesUtil.isUnlockNfcEnable(requireContext()) + || PreferencesUtil.isAdvancedUnlockEnable(it) mAutoOpenPromptEnabled = PreferencesUtil.isAdvancedUnlockPromptAutoOpenEnable(it) } keepConnection = false @@ -216,6 +398,7 @@ class AdvancedUnlockFragment: StylishFragment(), AdvancedUnlockManager.AdvancedU if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { allowOpenBiometricPrompt = true if (PreferencesUtil.isBiometricUnlockEnable(context)) { + mAdvancedUnlockInfoView?.setIconBackgroundTint() mAdvancedUnlockInfoView?.setIconResource(R.drawable.fingerprint) // biometric not supported (by API level or hardware) so keep option hidden @@ -236,12 +419,23 @@ class AdvancedUnlockFragment: StylishFragment(), AdvancedUnlockManager.AdvancedU } } } else if (PreferencesUtil.isDeviceCredentialUnlockEnable(context)) { + mAdvancedUnlockInfoView?.setIconBackgroundTint() mAdvancedUnlockInfoView?.setIconResource(R.drawable.bolt) if (AdvancedUnlockManager.isDeviceSecure(context)) { selectMode() } else { toggleMode(Mode.DEVICE_CREDENTIAL_OR_BIOMETRIC_NOT_CONFIGURED) } + } else { // Only NFC unlock + mAdvancedUnlockInfoView?.setIconBackgroundTint(resources.getColor(R.color.green_light, null)) + mAdvancedUnlockInfoView?.setIconResource(R.drawable.ic_app_white_24dp) + if (advancedUnlockManager?.isKeyManagerInitialized != true) { // Like selectMode() + advancedUnlockManager = AdvancedUnlockManager { requireActivity() } + advancedUnlockManager?.advancedUnlockCallback = this // callback for fingerprint findings + } + toggleMode(Mode.WAIT_CREDENTIAL, true) + // Force setAdvancedUnlockedTitleView() - password/keyfile switch will not change the Mode + // Force invalidateBiometricMenu() - delete NFC data will not change the Mode } } } @@ -281,8 +475,8 @@ class AdvancedUnlockFragment: StylishFragment(), AdvancedUnlockManager.AdvancedU } @RequiresApi(Build.VERSION_CODES.M) - private fun toggleMode(newBiometricMode: Mode) { - if (newBiometricMode != biometricMode) { + private fun toggleMode(newBiometricMode: Mode, force: Boolean = false) { + if (force || newBiometricMode != biometricMode) { biometricMode = newBiometricMode initAdvancedUnlockMode() } @@ -336,6 +530,12 @@ class AdvancedUnlockFragment: StylishFragment(), AdvancedUnlockManager.AdvancedU context?.let { context -> mAdvancedUnlockInfoView?.setIconViewClickListener(false) { + if (!PreferencesUtil.isBiometricUnlockEnable(context) && + !PreferencesUtil.isDeviceCredentialUnlockEnable(context)) { // Only NFC unlock + context.startActivity(Intent(activity, SettingsAdvancedUnlockActivity::class.java)) + return@setIconViewClickListener + } + onAuthenticationError(BiometricPrompt.ERROR_UNABLE_TO_PROCESS, context.getString(R.string.credential_before_click_advanced_unlock_button)) } @@ -433,6 +633,19 @@ class AdvancedUnlockFragment: StylishFragment(), AdvancedUnlockManager.AdvancedU && biometricMode != Mode.KEY_MANAGER_UNAVAILABLE) mAddBiometricMenuInProgress = false activity?.invalidateOptionsMenu() + + //todo-op? 1) After mAddBiometricMenuInProgress = false; 2) Invalidate twice; Needs single call of containsCipherDatabase for all database keys + if (!mAllowAdvancedUnlockMenu) + DatabaseKeyId.values().filter { null != it.value }.forEach { + getDatabaseKey(it.value)?.let { databaseKey -> + cipherDatabaseAction.containsCipherDatabase(databaseKey) { containsCipher -> + if (!mAllowAdvancedUnlockMenu && containsCipher) { + mAllowAdvancedUnlockMenu = true + activity?.invalidateOptionsMenu() // invalidate again if needed + } + } + } + } } } } @@ -455,11 +668,15 @@ class AdvancedUnlockFragment: StylishFragment(), AdvancedUnlockManager.AdvancedU } } checkUnlockAvailability() + + nfc.startIfEnabled() // after onResume! (onPause/onResume) } @RequiresApi(Build.VERSION_CODES.M) fun disconnect(hideViews: Boolean = true, closePrompt: Boolean = true) { + nfc.stop() // before onPause! (onPause/onResume) + this.databaseFileUri = null // Close the biometric prompt allowOpenBiometricPrompt = false @@ -475,14 +692,21 @@ class AdvancedUnlockFragment: StylishFragment(), AdvancedUnlockManager.AdvancedU } @RequiresApi(Build.VERSION_CODES.M) - fun deleteEncryptedDatabaseKey() { + fun deleteEncryptedDatabaseKey(databaseKeyId: Int? = null) { mAllowAdvancedUnlockMenu = false advancedUnlockManager?.closeBiometricPrompt() - databaseFileUri?.let { databaseUri -> - cipherDatabaseAction.deleteByDatabaseUri(databaseUri) { - checkUnlockAvailability() + + //todo-op? Multiple checkUnlockAvailability; Needs single call of deleteByDatabaseUri for all database keys + var checkNow = true + DatabaseKeyId.values().filter { null == databaseKeyId || databaseKeyId == it.value }.forEach { + getDatabaseKey(it.value)?.let { databaseKey -> + checkNow = false + cipherDatabaseAction.deleteByDatabaseUri(databaseKey) { + checkUnlockAvailability() + } } - } ?: checkUnlockAvailability() + } + if (checkNow) checkUnlockAvailability() } @RequiresApi(Build.VERSION_CODES.M) @@ -537,11 +761,11 @@ class AdvancedUnlockFragment: StylishFragment(), AdvancedUnlockManager.AdvancedU } } - override fun handleEncryptedResult(encryptedValue: ByteArray, ivSpec: ByteArray) { - databaseFileUri?.let { databaseUri -> + override fun handleEncryptedResult(encryptedValue: ByteArray, ivSpec: ByteArray, databaseKeyId: Int?) { + getDatabaseKey(databaseKeyId)?.let { databaseKey -> mBuilderListener?.onCredentialEncrypted( CipherEncryptDatabase().apply { - this.databaseUri = databaseUri + this.databaseUri = databaseKey this.credentialStorage = credentialDatabaseStorage this.encryptedValue = encryptedValue this.specParameters = ivSpec @@ -550,12 +774,12 @@ class AdvancedUnlockFragment: StylishFragment(), AdvancedUnlockManager.AdvancedU } } - override fun handleDecryptedResult(decryptedValue: ByteArray) { + override fun handleDecryptedResult(decryptedValue: ByteArray, databaseKeyId: Int?) { // Load database directly with password retrieve - databaseFileUri?.let { databaseUri -> + getDatabaseKey(databaseKeyId)?.let { databaseKey -> mBuilderListener?.onCredentialDecrypted( CipherDecryptDatabase().apply { - this.databaseUri = databaseUri + this.databaseUri = databaseKey this.credentialStorage = credentialDatabaseStorage this.decryptedValue = decryptedValue } @@ -594,8 +818,26 @@ class AdvancedUnlockFragment: StylishFragment(), AdvancedUnlockManager.AdvancedU @RequiresApi(Build.VERSION_CODES.M) private fun setAdvancedUnlockedTitleView(textId: Int) { + //todo-op! Better message if Mode.WAIT_CREDENTIAL + if (PreferencesUtil.isUnlockNfcEnable(requireContext()) + && (biometricMode == Mode.EXTRACT_CREDENTIAL || biometricMode == Mode.WAIT_CREDENTIAL) + && true != mBuilderListener?.conditionToStoreCredential()) + getDatabaseKey(DatabaseKeyId.Nfc.value)?.let { databaseKey -> + mAdvancedUnlockInfoView?.setTitle(textId) // default + cipherDatabaseAction.containsCipherDatabase(databaseKey) { containsCipher -> + if (containsCipher) mAdvancedUnlockInfoView?.title = mAdvancedUnlockInfoView?.title.toString() + getString(R.string.nfc_unlock_hint) + } + return + } + lifecycleScope.launch(Dispatchers.Main) { mAdvancedUnlockInfoView?.setTitle(textId) + + if (PreferencesUtil.isUnlockNfcEnable(requireContext()) + && (biometricMode == Mode.STORE_CREDENTIAL || biometricMode == Mode.WAIT_CREDENTIAL) + && true == mBuilderListener?.conditionToStoreCredential()) { + mAdvancedUnlockInfoView?.title = mAdvancedUnlockInfoView?.title.toString() + getString(R.string.nfc_unlock_hint_write) + } } } diff --git a/app/src/main/java/com/kunzisoft/keepass/biometric/AdvancedUnlockManager.kt b/app/src/main/java/com/kunzisoft/keepass/biometric/AdvancedUnlockManager.kt index 764396dde..3d7a25938 100644 --- a/app/src/main/java/com/kunzisoft/keepass/biometric/AdvancedUnlockManager.kt +++ b/app/src/main/java/com/kunzisoft/keepass/biometric/AdvancedUnlockManager.kt @@ -74,6 +74,7 @@ class AdvancedUnlockManager(private var retrieveContext: () -> FragmentActivity) private var isKeyManagerInit = false + private val unlockNfcEnable = PreferencesUtil.isUnlockNfcEnable(retrieveContext()) private val biometricUnlockEnable = PreferencesUtil.isBiometricUnlockEnable(retrieveContext()) private val deviceCredentialUnlockEnable = PreferencesUtil.isDeviceCredentialUnlockEnable(retrieveContext()) @@ -101,7 +102,7 @@ class AdvancedUnlockManager(private var retrieveContext: () -> FragmentActivity) } init { - if (isDeviceSecure(retrieveContext()) + if (unlockNfcEnable || isDeviceSecure(retrieveContext()) && (biometricUnlockEnable || deviceCredentialUnlockEnable)) { try { this.keyStore = KeyStore.getInstance(ADVANCED_UNLOCK_KEYSTORE) @@ -213,15 +214,17 @@ class AdvancedUnlockManager(private var retrieveContext: () -> FragmentActivity) } } - @Synchronized fun encryptData(value: ByteArray) { + @Synchronized fun encryptData(value: ByteArray, unlockData: List? = null, databaseKeyId: Int? = null) { if (!isKeyManagerInitialized) { return } try { - val encrypted = cipher?.doFinal(value) ?: byteArrayOf() + val valueAndData = if (true != unlockData?.isNotEmpty()) value else + value.toMutableList().also { it.addAll(unlockData) }.toByteArray() + val encrypted = cipher?.doFinal(valueAndData) ?: byteArrayOf() // passes updated iv spec on to callback so this can be stored for decryption cipher?.parameters?.getParameterSpec(IvParameterSpec::class.java)?.let{ spec -> - advancedUnlockCallback?.handleEncryptedResult(encrypted, spec.iv) + advancedUnlockCallback?.handleEncryptedResult(encrypted, spec.iv, databaseKeyId) } } catch (e: Exception) { Log.e(TAG, "Unable to encrypt data", e) @@ -278,14 +281,19 @@ class AdvancedUnlockManager(private var retrieveContext: () -> FragmentActivity) } } - @Synchronized fun decryptData(encryptedValue: ByteArray) { + @Synchronized fun decryptData(encryptedValue: ByteArray, unlockDataSize: Int = 0, databaseKeyId: Int? = null, onUnlockData: ((ByteArray, List) -> Boolean)? = null) { if (!isKeyManagerInitialized) { return } try { // actual decryption here - cipher?.doFinal(encryptedValue)?.let { decrypted -> - advancedUnlockCallback?.handleDecryptedResult(decrypted) + cipher?.doFinal(encryptedValue)?.let { valueArray -> + val decrypted = if (valueArray.size <= unlockDataSize) valueArray else { + valueArray.take(valueArray.size - unlockDataSize).toByteArray().also { + if (true != onUnlockData?.invoke(it, valueArray.takeLast(unlockDataSize))) throw Exception("Invalid unlock data.") + } + } + advancedUnlockCallback?.handleDecryptedResult(decrypted, databaseKeyId) } } catch (badPaddingException: BadPaddingException) { Log.e(TAG, "Unable to decrypt data", badPaddingException) @@ -360,8 +368,8 @@ class AdvancedUnlockManager(private var retrieveContext: () -> FragmentActivity) fun onAuthenticationSucceeded() fun onAuthenticationFailed() fun onAuthenticationError(errorCode: Int, errString: CharSequence) - fun handleEncryptedResult(encryptedValue: ByteArray, ivSpec: ByteArray) - fun handleDecryptedResult(decryptedValue: ByteArray) + fun handleEncryptedResult(encryptedValue: ByteArray, ivSpec: ByteArray, databaseKeyId: Int?) + fun handleDecryptedResult(decryptedValue: ByteArray, databaseKeyId: Int?) } companion object { @@ -455,9 +463,9 @@ class AdvancedUnlockManager(private var retrieveContext: () -> FragmentActivity) override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {} - override fun handleEncryptedResult(encryptedValue: ByteArray, ivSpec: ByteArray) {} + override fun handleEncryptedResult(encryptedValue: ByteArray, ivSpec: ByteArray, databaseKeyId: Int?) {} - override fun handleDecryptedResult(decryptedValue: ByteArray) {} + override fun handleDecryptedResult(decryptedValue: ByteArray, databaseKeyId: Int?) {} override fun onUnrecoverableKeyException(e: Exception) { advancedCallback.onUnrecoverableKeyException(e) diff --git a/app/src/main/java/com/kunzisoft/keepass/services/NfcService.kt b/app/src/main/java/com/kunzisoft/keepass/services/NfcService.kt new file mode 100644 index 000000000..811926454 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/services/NfcService.kt @@ -0,0 +1,1045 @@ +package com.kunzisoft.keepass.services + +import android.app.Activity +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.nfc.* +import android.nfc.tech.* +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.text.InputType +import android.util.Log +import android.view.WindowManager +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.SwitchCompat +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.IOException +import java.security.InvalidAlgorithmParameterException +import java.security.InvalidKeyException +import java.security.NoSuchAlgorithmException +import java.security.SecureRandom +import java.security.spec.InvalidKeySpecException +import javax.crypto.* +import javax.crypto.spec.DESedeKeySpec +import javax.crypto.spec.IvParameterSpec +import kotlin.experimental.and +import kotlin.experimental.or + +class NfcService(private val packageName: String, private val onError: ((String, Throwable?) -> Unit)? = null) { + companion object { + private val TAG = NfcService::class.java.name + + @Suppress("SpellCheckingInspection") + private fun isEmulatorProbably() = // From device-info plugin, Flutter/Google + (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) + || Build.MANUFACTURER.contains("Genymotion") + || Build.FINGERPRINT.startsWith("generic") || Build.FINGERPRINT.startsWith("unknown") + || Build.HARDWARE.contains("goldfish") || Build.HARDWARE.contains("ranchu") + || Build.MODEL.contains("google_sdk") || Build.MODEL.contains("Emulator") + || Build.MODEL.contains("Android SDK built for x86") + || Build.PRODUCT.contains("sdk_google") || Build.PRODUCT.contains("google_sdk") + || Build.PRODUCT.contains("sdk") || Build.PRODUCT.contains("sdk_x86") + || Build.PRODUCT.contains("sdk_gphone64_arm64") || Build.PRODUCT.contains("vbox86p") + || Build.PRODUCT.contains("emulator") || Build.PRODUCT.contains("simulator") + + val isDebug = isEmulatorProbably() // debug in emulator //todo-op!!! disable + private const val withDump: Boolean = true //todo-op!!! disable + fun isSupported(context: Context): Boolean = isDebug || NfcService(context.packageName).isSupported(context) + } + + // localize + private fun errCatchEnable() = NfcErr("Start listen for Tag failed", TAG, onError) + private fun errCatchDisable() = NfcErr("Stop failed", TAG, onError) + private fun errCatchTagReader() = NfcErr("Read Tag failed", TAG, onError) + private fun errCatchTagIntent() = errCatchTagReader() + private fun errCatchTagDebug() = NfcErr("Debug Tag", TAG, onError) + + private var adapter: NfcAdapter? = null + fun isSupported(context: Context): Boolean = isDebug || null != getAdapter(context) + val isEnabled: Boolean get() = true == adapter?.isEnabled + + private fun getAdapter(context: Context?) = + adapter ?: context?.getSystemService(Context.NFC_SERVICE)?.let { nfcManager -> + (nfcManager as NfcManager).defaultAdapter.also { adapter = it } + } + + fun enable(activity: FragmentActivity?, tagActivity: Class<*>?, onTag: (NfcTag) -> Unit) { + fun enableDispatch() { + getAdapter(activity)?.enableForegroundDispatch(activity, PendingIntent.getActivity(activity, 0, + Intent(activity, tagActivity).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), PendingIntent.FLAG_MUTABLE), + null, null) + NfcErr("NFC: Dispatch mode", TAG).log() + } + + try { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) + enableDispatch() + else try { + getAdapter(activity)?.enableReaderMode(activity, { tag -> + activity?.lifecycleScope?.launch(Dispatchers.Main) { + if (null != tag) { + val nfcTag = tagGet(tag) + try { + onTag(nfcTag) + } catch (e: Throwable) { + errCatchTagReader().errorCb(e) + } + } + } + }, //NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK + // required + //NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS + // optional + NfcAdapter.FLAG_READER_NFC_A + NfcAdapter.FLAG_READER_NFC_B + NfcAdapter.FLAG_READER_NFC_BARCODE + + NfcAdapter.FLAG_READER_NFC_F + NfcAdapter.FLAG_READER_NFC_V, + Bundle().also { + it.putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 5000) //todo-op??? test read protect, write pass, key authentication + }) + NfcErr("NFC: Reader mode", TAG).log() + } catch (e: Throwable) { + errCatchEnable().err(e) + enableDispatch() + } + } catch (e: Throwable) { + errCatchEnable().error(e) + } + } + + fun disable(activity: Activity?) = + try { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) + adapter?.disableForegroundDispatch(activity) + else try { + adapter?.disableReaderMode(activity) + } catch (e: Throwable) { + errCatchDisable().err(e) + adapter?.disableForegroundDispatch(activity) + } + } catch (e: Throwable) { + errCatchDisable().error(e) + } + + private fun tagGet(tag: Tag?, ndefMessages: List? = null, dump: MutableList? = null) = + NfcTagUnlock(packageName, onError, tag, ndefMessages, dump ?: if (withDump || isDebug) mutableListOf() else null, + if (!isDebug) null else NfcTag.TagType.NTag213) + + fun tagRead(intent: Intent?, onTag: (NfcTag?) -> Unit): Boolean { + try { + if (null == intent + || !listOf(NfcAdapter.ACTION_NDEF_DISCOVERED, NfcAdapter.ACTION_TECH_DISCOVERED, NfcAdapter.ACTION_TAG_DISCOVERED) + .contains(intent.action)) + return false + val tag: Tag? = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG) + if (null == tag && !isDebug) return false + val dump = if (!withDump && !isDebug) null else + mutableListOf().also { list -> + intent.extras?.keySet()?.filter { + it != NfcAdapter.EXTRA_ID && it != NfcAdapter.EXTRA_TAG && it != NfcAdapter.EXTRA_NDEF_MESSAGES + }?.forEach { key -> + intent.extras?.get(key).let { extra -> + if (extra is ByteArray) list.add("$key: ${NfcTag.hexString(extra)}") + else list.add("$key: $extra") + } + } + } + val ndefMessages = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)?.mapNotNull { it as? NdefMessage } + val nfcTag = tagGet(tag, ndefMessages, dump) + try { + onTag(nfcTag) + } catch (e: Throwable) { + errCatchTagIntent().errorCb(e) + } + return true + } catch (e: Throwable) { + errCatchTagIntent().error(e) + return true + } + } + + fun debugTap(activity: Activity?, onTag: (NfcTag) -> Unit) { + fun ask(activity: Activity, windowToken: IBinder? = null, onYes: (String?) -> Unit) { + val input = EditText(activity) + input.inputType = InputType.TYPE_CLASS_TEXT + val dialog = AlertDialog.Builder(activity) + .setNegativeButton(activity.getString(android.R.string.cancel)) { dialog, _ -> dialog.cancel() } + //.setOnCancelListener { onNo() } + .setPositiveButton(activity.getString(android.R.string.ok)) { _, _ -> onYes(input.text?.toString()) } + .setTitle("Debug NFC") + .setMessage("Enter NFC tag data:") + .setView(LinearLayout(activity).also { layout -> + layout.orientation = LinearLayout.VERTICAL + layout.setPadding(60, 0, 60, 0) + layout.addView(input) + }).create() + windowToken?.let { + // for Magikeyboard: 'dialog.show() crashed InputMethodService' + // from: https://stackoverflow.com/questions/7244637/dialog-show-crashed-inputmethodservice + dialog.window?.also { window -> + window.attributes = window.attributes?.also { + it.token = windowToken + it.type = WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG + } + window.addFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) // FLAG_ALT_FOCUSABLE_IM = the window doesn't need input method + } + } + dialog.show() + } + + fun onYes(value: String? = null) { + try { + val unlockUnique = value?.let { NfcTagUnlock.unlockUnique(it.toByteArray())?.toByteArray() } + val withIntent = false + if (null == activity || !withIntent) { + val nfcTag = tagGet(null) + try { + onTag(nfcTag) + } catch (e: Throwable) { + errCatchTagDebug().errorCb(e) + } + } else { + activity.startActivity(Intent(activity, activity::class.java) + .putExtra(NfcAdapter.EXTRA_ID, NfcTag.toByteArr("042FD8D2286781")) + .also { + if (null != unlockUnique) it.putParcelableArrayListExtra(NfcAdapter.EXTRA_NDEF_MESSAGES, + arrayListOf(NdefMessage( + //NdefRecord.createApplicationRecord(packageName), + NdefRecord.createMime("application/$packageName", unlockUnique), + ))) + }.setAction(NfcAdapter.ACTION_TAG_DISCOVERED) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)) + } + } catch (e: Throwable) { + errCatchTagDebug().error(e) + } + } + + val debugAsk = false + if (null == activity || !debugAsk) onYes() else + try { + ask(activity, null, ::onYes) + } catch (e: Throwable) { + errCatchTagDebug().error(e) + } + } +} + +open class NfcTag(private val packageName: String, protected val onError: ((String, Throwable?) -> Unit)? = null, + private val tag: Tag?, private val ndefMessages: List? = null, + protected val dump: MutableList? = null, private val debugResponse: TagType? = null) { + //Mifare Ultralight - https://www.nxp.com/docs/en/data-sheet/MF0ICU1.pdf + //Mifare Ultralight C - https://www.nxp.com/docs/en/data-sheet/MF0ICU2.pdf + //Mifare Ultralight EV1 - https://www.nxp.com/docs/en/data-sheet/MF0ULX1.pdf + //NTag213, NTag215, NTag216 - https://www.nxp.com/docs/en/data-sheet/NTAG213_215_216.pdf + //MIFARE Classic EV1 1K (s50) - https://www.nxp.com/docs/en/data-sheet/MF1S50YYX_V1.pdf + //MIFARE Classic EV1 4K (s70) - https://www.nxp.com/docs/en/data-sheet/MF1S70YYX_V1.pdf + //MIFARE DESFire Light - https://www.nxp.com/docs/en/data-sheet/MF2DLHX0.pdf + //MIFARE DESFire EV1 - https://www.nxp.com/docs/en/data-sheet/MF3ICDX21_41_81_SDS.pdf + //MIFARE DESFire EV1 256B - https://www.nxp.com/docs/en/data-sheet/MF3ICDQ1_MF3ICDHQ1_SDS.pdf + //MIFARE DESFire EV2 - https://www.nxp.com/docs/en/data-sheet/MF3DX2_MF3DHX2_SDS.pdf + //MIFARE DESFire EV3 - https://www.nxp.com/docs/en/data-sheet/MF3DHx3_SDS.pdf + //MIFARE type identification procedure - https://www.nxp.com/docs/en/application-note/AN10833.pdf + //MIFARE ISO/IEC 14443 PICC selection - https://www.nxp.com/docs/en/application-note/AN10834.pdf + + companion object { + internal val TAG = NfcTag::class.java.name + + fun hexString(arr: ByteArray?): String? = if (null == arr || arr.isEmpty()) null else + arr.joinToString("") { String.format("%02X", it) } // Or use ByteArray.toHexString() + + fun toByteArr(hexString: String?): ByteArray? = if (hexString.isNullOrEmpty()) null else + hexString.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } + + // localize + //private fun errorInvalidPageNumber() = NfcErr("Invalid page number", TAG, onError) + private fun errThrowTranceiveUnsupportedTech() = NfcErr("This tag is not supported", TAG, onError, "Unsupported tech for tranceive") + private fun errThrowTranceiveTagLost1() = NfcErr("Tag is disconnected (?)", TAG, onError, "Transceive result is null") + private fun errThrowTranceiveTagLost2() = NfcErr("Tag is disconnected (??)", TAG, onError, "Transceive result is empty") + private fun errThrowTranceiveNak(log: String) = NfcErr("Operation failed", TAG, onError, log) + private fun errThrowInvalidData() = NfcErr("Invalid data", TAG, onError, "Invalid data size") + private fun errThrowAuthenticationInvalidKey() = NfcErr("Invalid data", TAG, onError, "Invalid key size") + private fun errThrowAuthenticationFailed(log: String) = NfcErr("Authentication failed", TAG, onError, log) + private fun errThrowAuthenticationResponse(log: String) = NfcErr("Authentication failed", TAG, onError, log) + private fun errThrowWriteUnsupportedTag(log: String) = NfcErr("This tag is not supported", TAG, onError, log) + private fun errThrowWriteInvalidData() = NfcErr("Invalid data", TAG, onError, "Invalid data size") + private fun errThrowWriteInvalidConfig() = NfcErr("Invalid data", TAG, onError, "Invalid config data") + private fun errThrowWriteInvalidPAck() = NfcErr("Invalid data", TAG, onError, "Invalid PAck size") + private fun errorWritePass(log: String) = NfcErr("Write password failed", TAG, onError, log) + private fun errorWriteProtect(log: String) = NfcErr("Read-protect data failed", TAG, onError, log) + + enum class TagType(val version: List) { + NTag213 (listOf(0, 0x4, 0x4, 0x2, 0x1, 0, 0x0F, 0x3)), // 0004040201000F03: NTag213 + NTag215 (listOf(0, 0x4, 0x4, 0x2, 0x1, 0, 0x11, 0x3)), // 0004040201001103: NTag215 + NTag216 (listOf(0, 0x4, 0x4, 0x2, 0x1, 0, 0x13, 0x3)), // 0004040201001303: NTag216 + MF0UL11 (listOf(0, 0x4, 0x3, 0x1, 0x1, 0, 0x0b, 0x3)), // 0004030101000B03: MIFARE Ultralight EV1 MF0UL11 + MF0ULH11 (listOf(0, 0x4, 0x3, 0x2, 0x1, 0, 0x0b, 0x3)), // 0004030201000B03: MIFARE Ultralight EV1 MF0ULH11 + MF0UL21 (listOf(0, 0x4, 0x3, 0x1, 0x1, 0, 0x0E, 0x3)), // 0004030101000E03: MIFARE Ultralight EV1 MF0UL21 + MF0ULH21 (listOf(0, 0x4, 0x3, 0x2, 0x1, 0, 0x0E, 0x3)), // 0004030201000E03: MIFARE Ultralight EV1 MF0ULH21 + //MF2DL1000 (listOf(0x4, 0x8, 0x01, 0x30, 0, 0x13, 0x91.toByte(), 0xAF.toByte())), // 04080130001391AF: MIFARE DESFire Light + //MF2DLH1000(listOf(0x4, 0x8, 0x02, 0x30, 0, 0x13, 0x91.toByte(), 0xAF.toByte())), // 04080230001391AF: MIFARE DESFire Light + //MF2DL1001 (listOf(0x4, 0x8, 0x81.toByte(), 0x30, 0, 0x13, 0x91.toByte(), 0xAF.toByte())), // 04088130001391AF: MIFARE DESFire Light + //MF2DLH1001(listOf(0x4, 0x8, 0x82.toByte(), 0x30, 0, 0x13, 0x91.toByte(), 0xAF.toByte())), // 04088230001391AF: MIFARE DESFire Light + } + + @Suppress("SpellCheckingInspection") + enum class AuthenticationDefault(val bytes: ByteArray) { + NTagPass (ByteArray(4) { 0xFF.toByte() }), + NTagPAck (ByteArray(2) { 0 }), + //MifareUltralightC (byteArrayOf(0x42, 0x52, 0x45, 0x41, 0x4b, 0x4D, 0x45, 0x49, 0x46, 0x59, 0x4F, 0x55, 0x43, 0x41, 0x4E, 0x21)), + MifareUltralightC ("BREAKMEIFYOUCAN!".map { it.code.toByte() }.toByteArray()), + //MifareClassic_4k (ByteArray(5) { 0xFF.toByte() }), + //MifareDESFireEV1_4k (ByteArray(8) { 0 }), + } + + @Suppress("SpellCheckingInspection") + class Tech( + val isoDep: IsoDep? = null, + val mifareClassic: MifareClassic? = null, + val mifareUltralight: MifareUltralight? = null, + val ndef: Ndef? = null, + val ndefFormatable: NdefFormatable? = null, + val nfcA: NfcA? = null, + val nfcB: NfcB? = null, + val nfcBarcode: NfcBarcode? = null, + val nfcF: NfcF? = null, + val nfcV: NfcV? = null, + ) + + //@RequiresApi(Build.VERSION_CODES.JELLY_BEAN_MR1) + protected val tech = if (null == tag) Tech() else + Tech(isoDep = IsoDep.get(tag), mifareClassic = MifareClassic.get(tag), mifareUltralight = MifareUltralight.get(tag), + ndef = Ndef.get(tag), ndefFormatable = NdefFormatable.get(tag), nfcA = NfcA.get(tag), nfcB = NfcB.get(tag), + nfcBarcode = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) null else + NfcBarcode.get(tag), nfcF = NfcF.get(tag), nfcV = NfcV.get(tag)) + + protected val tagId = // 'Anti-cloning support by unique 7-byte serial number for each device' + if (null == debugResponse) tag?.id + else byteArrayOf(0x4, 0x2F, 0xD8.toByte(), 0xD2.toByte(), 0x28, 0x67, 0x81.toByte()) + + @Throws(NfcErr::class, IOException::class) + protected fun connect(tagTech: TagTechnology, isNeeded: Boolean = true, call: () -> T): T { + val needConnect = isNeeded && !tagTech.isConnected + if (needConnect) tagTech.connect() + try { + return call() + } finally { + if (needConnect) tagTech.close() + } + } + + //=== + + @Throws(NfcErr::class, IOException::class) + private fun transceive(tagTech: TagTechnology, data: ByteArray?): ByteArray { + val response = when (tagTech) { // Exception: NAK or timeout (tag was lost) + is MifareUltralight -> tagTech.transceive(data) + is NfcA -> tagTech.transceive(data) + else -> throw errThrowTranceiveUnsupportedTech() + } + Log.d(TAG, "NFC transceive: After") //todo-op??? test read protect, write pass, key authentication + val responseAck = 0xA.toByte() + if (response == null) throw errThrowTranceiveTagLost1() // Err: NAK or timeout (tag was lost) + if (response.isEmpty()) throw errThrowTranceiveTagLost2() //? Err: NAK or timeout (tag was lost) + if ((response.size == 1) && ((response[0] and responseAck) != responseAck)) + throw errThrowTranceiveNak("Transceive result NAK 0x${hexString(response)}") + return response // Success: response ACK or response data + } + + private val tagError: Boolean + protected val tagVersion: List + private val tagType: TagType? + init { + var error = false + var version: List? = if (null == debugResponse) null else TagType.NTag213.version + try { + tech.mifareUltralight?.also { + connect(it) { + try { + version = transceive(it, byteArrayOf(0x60)).toList() // 0x60 = GET_VERSION + } catch (e: Throwable) { + error = true + NfcErr("Init version", TAG).err(e) + } + } + } + if (null == tech.mifareUltralight || null == version) tech.nfcA?.also { + connect(it) { + try { + version = transceive(it, byteArrayOf(0x60)).toList() // 0x60 = GET_VERSION + } catch (e: Throwable) { + NfcErr("Init version NfcA", TAG).err(e) + } + } + } + } catch (e: Throwable) { + error = true + NfcErr("Init connect", TAG).err(e) + } + tagError = error + tagVersion = version ?: emptyList() + + tagType = TagType.values().find { + it.version == version + //} ?: when (tagVersion.getOrElse(2) { 0 }.toUByte() and 0xF0 ) { + // 0x01 -> TagType.x // MIFARE DESFire + // 0x02 -> TagType.x // MIFARE Plus + // 0x03 -> TagType.x // MIFARE Ultralight + // 0x04 -> TagType.x // NTag + // 0x07 -> TagType.x // NTag I2C + // 0x08 -> TagType.x // MIFARE DESFire Light + // else -> null + //} ?: run { + // val reqA = tech.nfcA?.atqa + // when { + // listOf(0, 0x44) == reqA?.toList() -> TagType.x // MIFARE Ultralight C + // listOf(0, 0x44) == reqA?.toList() -> TagType.x // MIFARE Plus 2K, SE(1K) (7 Byte UID) + // listOf(0, 0x42) == reqA?.toList() -> TagType.x // MIFARE Plus 4K (7 Byte UID) + // listOf(0, 0x04) == reqA?.toList() -> TagType.x // MIFARE Plus 2K, SE(1K) (4 Byte Non-UID) + // listOf(0, 0x02) == reqA?.toList() -> TagType.x // MIFARE Plus 4K (4 Byte Non-UID) + // listOf(0, 0x04) == reqA?.let { arr -> if (arr.size < 2) null else listOf(arr[0], arr[1] and 0x0F) } -> TagType.x // MIFARE Classic EV1 1K + // listOf(0, 0x04) == reqA?.let { arr -> if (arr.size < 2) null else listOf(arr[0], arr[1] and 0x0F) } -> TagType.x // MIFARE Classic EV1 4K + // else -> null + // } + } ?: debugResponse + + //if (null == dump) NfcErr("NFC: tag error $tagError; type ${tagType}; version $tagVersion", TAG).log() + dump() + } + + //@RequiresApi(Build.VERSION_CODES.JELLY_BEAN) + private fun dump() { + if (null == dump) return + if (dump.isNotEmpty()) { + dump.add(0, "Intent:") + dump.add("") + } + + fun dumpToList(list: MutableList, prefix: String, ndefMessage: NdefMessage?) { + ndefMessage?.records?.forEachIndexed { recIdx, rec -> + list.add("$prefix$recIdx: ${rec.tnf}; ${hexString(rec.type)}; ${hexString(rec.id)}; ${hexString(rec.payload)}") + } ?: list.add("$prefix$ndefMessage") + + } + + fun addToDump(message: String, list: List) { + if (list.isNotEmpty()) { + dump.add(message) + dump.addAll(list) + dump.add("") + } + } + + addToDump("Intent NdefMessages:", mutableListOf().also { list -> + ndefMessages?.forEachIndexed { msgIdx, ndefMessage -> + dumpToList(list, "$msgIdx, ", ndefMessage) + } + }) + + addToDump("INFO:", mutableListOf().also { list -> + debugResponse?.let { list.add("debugResponse: $it") } + list.add("id: ${hexString(tagId)}") + list.add("techList: ${tag?.techList?.joinToString()}") + tech.ndef?.also { + list.add("") + list.add("Ndef.type: ${it.type}" + when (it.type) { + Ndef.NFC_FORUM_TYPE_1 -> " / Innovision Topaz" + Ndef.NFC_FORUM_TYPE_2 -> " / Mifare Ultralight" + Ndef.NFC_FORUM_TYPE_3 -> " / Sony Felica" + Ndef.NFC_FORUM_TYPE_4 -> " / Mifare DESFire" + else -> "" + }) + list.add("Ndef.maxSize: ${it.maxSize}") + list.add("Ndef.isWritable: ${it.isWritable}") + list.add("Ndef.canMakeReadOnly: ${it.canMakeReadOnly()}") + dumpToList(list, "Ndef.cachedMessage: ", it.cachedNdefMessage) + } + tech.mifareUltralight?.also { + list.add("") + list.add("MifareUltralight.type: " + when (it.type) { + MifareUltralight.TYPE_ULTRALIGHT -> "ULTRALIGHT" + MifareUltralight.TYPE_ULTRALIGHT_C -> "ULTRALIGHT_C" + MifareUltralight.TYPE_UNKNOWN -> "UNKNOWN" + else -> "UL-${it.type}" + }) + //list.add("MifareUltralight.maxTransceiveLength: ${it.maxTransceiveLength}") + //list.add("MifareUltralight.timeout: ${it.timeout}") + } + tech.nfcA?.also { + list.add("") + list.add("NfcA.atqa: ${hexString(it.atqa)}") + list.add("NfcA.sak: ${it.sak}") + //list.add("NfcA.maxTransceiveLength: ${it.maxTransceiveLength}") + //list.add("NfcA.timeout: ${it.timeout}") + } + tech.isoDep?.also { + list.add("") + list.add("IsoDep.hiLayerResponse: ${hexString(it.hiLayerResponse)}") + list.add("IsoDep.historicalBytes: ${hexString(it.historicalBytes)}") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) + list.add("IsoDep.isExtendedLengthApduSupported: ${it.isExtendedLengthApduSupported}") + list.add("IsoDep.maxTransceiveLength: ${it.maxTransceiveLength}") + list.add("IsoDep.timeout: ${it.timeout}") + } + }) + + addToDump("DETAILS:", mutableListOf().also { list -> + list.add("tagError: $tagError") + list.add("tagType: $tagType") + list.add("tagVersion: ${hexString(tagVersion.toByteArray())}") + try { + tech.ndef?.also { + connect(it) { + try { + dumpToList(list, "Ndef.message: ", it.ndefMessage) + } catch (e: Throwable) { + list.add("ERROR: Dump Ndef.message: $e") + } + } + } + tech.mifareUltralight?.also { + connect(it) { + try { + val readablePages = 0.. + if (tagType == TagType.NTag213) 0x2C // last page (roll-over): 0x2C + else if (tagType == TagType.NTag215) 0x84 // last page (roll-over): 0x86 + else if (tagType == TagType.NTag216) 0xE4 // last page (roll-over): 0xE6 + else if (listOf(TagType.MF0UL11, TagType.MF0ULH11).contains(tagType)) 0x10 // last page (roll-over): 0x13 + else if (listOf(TagType.MF0UL21, TagType.MF0ULH21).contains(tagType)) 0x28 // last page (roll-over): 0x28 + else if (it.type == MifareUltralight.TYPE_ULTRALIGHT) 0xC // last page: 0xF + else if (it.type == MifareUltralight.TYPE_ULTRALIGHT_C) 0x28 // last page (roll-over): 0x2b + else -1 + list.add("MifareUltralight.readPages: $readablePages") + for (page in readablePages step 4) { + val pages: ByteArray? = it.readPages(page) // read 4 pages + list.add("${page}..${page + 3}: " + hexString(pages)) + } + } catch (e: Throwable) { + list.add("ERROR: MifareUltralight.readPages: $e") + } + } + if (tech.mifareUltralight.type == MifareUltralight.TYPE_ULTRALIGHT_C) + connect(it) { + try { + //val key = Default.MifareUltralightC.bytes + val key = byteArrayOf(*AuthenticationDefault.MifareUltralightC.bytes.take(8).reversed().toByteArray(), + *AuthenticationDefault.MifareUltralightC.bytes.takeLast(8).reversed().toByteArray()) + it.timeout = 5000 //todo-op??? test read protect, write pass, key authentication + mifareUltralightTryAuthenticate(it, key) //todo-op!!! android.nfc.TagLostException: Tag was lost. + list.add("mifareUltralightAuthenticate: OK") + } catch (e: Throwable) { + list.add("ERROR: mifareUltralightAuthenticate: $e") + } + } + } + } catch (e: Throwable) { + list.add("ERROR: Dump: $e") + } + }) + + if (dump.isNotEmpty()) NfcErr("NFC: dump\n${dump.joinToString("\n")}", TAG).log() + } + + //=== + + //@RequiresApi(Build.VERSION_CODES.JELLY_BEAN) + private fun ndefMsgRecord(data: ByteArray?): NdefRecord? { + // Intent filter is needed in AndroidManifest.xml (for both formats?): ... + val useAppRecord = false + return if (useAppRecord) NdefRecord.createApplicationRecord(packageName) + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) + NdefRecord.createMime("application/$packageName", data) + else null + } + + protected fun ndefMsgRecordFilter(data: ByteArray? = null): List? { + val rec = ndefMsgRecord(data) + return tech.ndef?.cachedNdefMessage?.records?.filter { + rec?.tnf == it.tnf && rec.type?.toList() == it.type?.toList() && rec.id?.toList() == it.id?.toList() + && (null == rec.payload || rec.payload.isEmpty() || rec.payload?.toList() == it.payload?.toList()) + } + } + + protected fun ndefMsgRecordFind(data: ByteArray? = null): NdefRecord? = + ndefMsgRecordFilter(data)?.getOrNull(0) + + @Throws(FormatException::class, IOException::class) + protected fun ndefMessage(records: List, data: ByteArray?): NdefMessage? { + if (null != ndefMsgRecordFind(data)) return null // Ok: record exists + val rec = ndefMsgRecordFind() + return NdefMessage(records.map { if (null == rec || it != rec) it else ndefMsgRecord(data) } // replace existing record + .toMutableList().also { if (null == rec) it.add(ndefMsgRecord(data)) } // add new record + .toTypedArray()) + } + + //=== + + protected val mifareUltralightPageUserMin: Int = + when (tagType) { + TagType.NTag213 -> 6 // keep memory content at delivery? - 'data pages 04h and 05h of NTag21x are pre-programmed' + TagType.NTag215 -> 6 // keep memory content at delivery? - 'data pages 04h and 05h of NTag21x are pre-programmed' + TagType.NTag216 -> 6 // keep memory content at delivery? - 'data pages 04h and 05h of NTag21x are pre-programmed' + else -> 4 + } + protected val mifareUltralightPageUserMax: Int = + if (tagType == TagType.NTag213) 0x27 + else if (tagType == TagType.NTag215) 0x81 + else if (tagType == TagType.NTag216) 0xE1 + else if (listOf(TagType.MF0UL11, TagType.MF0ULH11).contains(tagType)) 0xF + else if (listOf(TagType.MF0UL21, TagType.MF0ULH21).contains(tagType)) 0x23 + else if (tech.mifareUltralight?.type == MifareUltralight.TYPE_ULTRALIGHT) 0xF + else if (tech.mifareUltralight?.type == MifareUltralight.TYPE_ULTRALIGHT_C) 0x27 + else mifareUltralightPageUserMin - 1 + + //@Throws(NfcErr::class) + //private fun mifareUltraLightPageCheck(page: Int?) { + // if (null == page || page < mifareUltralightPageUserMin || page > mifareUltralightPageUserMax) + // throw errorInvalidPageNumber() + //} + + @Throws(NfcErr::class, IOException::class) + protected fun mifareUltralightFindPage(tagTech: MifareUltralight, data: List, stop: List? = null, onStop: ((Int) -> Int)? = null, onData: ((Int) -> Unit)? = null): Int? { + if (data.size != 4) throw errThrowInvalidData() + return connect(tagTech) { + for (page in mifareUltralightPageUserMax downTo mifareUltralightPageUserMin step 4) { + val bytes: ByteArray? = tagTech.readPages(page - 3) // read 4 pages + val pages = bytes?.toList() + for (relative in 3 downTo 0) { + if (page - 3 + relative < mifareUltralightPageUserMin) break + pages?.chunked(4)?.getOrNull(relative)?.let { + if (it == data) { + onData?.invoke(page - 3 + relative) + return@connect page - 3 + relative + } else if (it == stop) { + return@connect onStop?.invoke(page - 3 + relative) + } + } + } + } + null + } + } + + @Throws(NfcErr::class, IOException::class) + protected fun mifareUltralightWritePage(tagTech: MifareUltralight, page: Int, data: List, useTransceive: Boolean = false, useWriteCompatible: Boolean = false) { + if (data.size != 4) throw errThrowWriteInvalidData() + if (!useTransceive) tagTech.writePage(page, data.toByteArray()) + else if (useWriteCompatible) + transceive(tagTech, byteArrayOf(0xA0.toByte(), page.toByte(), *ByteArray(12) { 0 }, data[0], data[1], data[2], data[3])) // 0xA0 = COMPATIBILITY_WRITE + else transceive(tagTech, byteArrayOf(0xA2.toByte(), page.toByte(), data[0], data[1], data[2], data[3])) // 0xA2 = WRITE + } + + @Throws(NfcErr::class, IOException::class, + NoSuchAlgorithmException::class, NoSuchPaddingException::class, + InvalidAlgorithmParameterException::class, InvalidKeyException::class, InvalidKeySpecException::class, + BadPaddingException::class, IllegalBlockSizeException::class) + private fun mifareUltralightTryAuthenticate(tagTech: TagTechnology, key: ByteArray) { + // From: https://stackoverflow.com/questions/19438554/android-authenticating-with-nxp-mifare-ultralight-c + fun rotateLeft(arr: ByteArray) = if (arr.size <= 1) arr else byteArrayOf(*arr.takeLast(arr.size - 1).toByteArray(), arr[0]) + fun check(res: ByteArray?, answer: Byte) = if (res?.size == 9 && res[0] == answer) res else + throw errThrowAuthenticationResponse("Invalid response") + fun performDes(opMode: Int, key: ByteArray, iv: ByteArray, data: ByteArray): ByteArray { + val desKeyFactory = SecretKeyFactory.getInstance("DESede") + val desKey = desKeyFactory.generateSecret(DESedeKeySpec(byteArrayOf(*key, *key.take(8).toByteArray()))) + val des = Cipher.getInstance("DESede/CBC/NoPadding") + des.init(opMode, desKey, IvParameterSpec(iv)) + return des.doFinal(data) + } + if (key.size != 16) throw errThrowAuthenticationInvalidKey() + val desRndB = check(transceive(tagTech, byteArrayOf(0x1A)), 0xAF.toByte()) // 0x1A = AUTHENTICATE; 0xAF = Ok + Log.d(TAG, "NFC mifareUltralightTryAuthenticate: After") //todo-op??? test read protect, write pass, key authentication + val rndB = performDes(Cipher.DECRYPT_MODE, key, ByteArray(8) { 0 }, desRndB.takeLast(8).toByteArray()) + val rndA = ByteArray(8).also { SecureRandom().nextBytes(it) } + val desRndARndBRot = performDes(Cipher.ENCRYPT_MODE, key, desRndB, byteArrayOf(*rndA, *rotateLeft(rndB))) + val desRndARot = check(transceive(tagTech, byteArrayOf(0xAF.toByte(), *desRndARndBRot)), 0) // 0x1A = AUTHENTICATE; 0 = Ok + val rndARot = performDes(Cipher.DECRYPT_MODE, key, desRndARndBRot.takeLast(8).toByteArray(), desRndARot.takeLast(8).toByteArray()) + if (rotateLeft(rndA).toList() != rndARot.toList()) throw errThrowAuthenticationFailed("Key authentication failed") + } + + //=== + + protected val nTagPassPage: Int? = when (tagType) { + TagType.NTag213 -> 0x2b + TagType.NTag215 -> 0x85 + TagType.NTag216 -> 0xE5 + TagType.MF0UL11 -> 0x12 + TagType.MF0ULH11 -> 0x12 + TagType.MF0UL21 -> 0x27 + TagType.MF0ULH21 -> 0x27 + else -> null + } + + protected val nTagConfigPage = when (tagType) { + TagType.NTag213 -> 0x29 + TagType.NTag215 -> 0x83 + TagType.NTag216 -> 0xE3 + TagType.MF0UL11 -> 0x10 + TagType.MF0ULH11 -> 0x10 + TagType.MF0UL21 -> 0x25 + TagType.MF0ULH21 -> 0x25 + else -> null + } + + @Throws(NfcErr::class, IOException::class) + protected fun nTagWritePassAndAck(tagTech: MifareUltralight, pagePwd: List, pwdAck: List) { + if (null == nTagPassPage) throw errThrowWriteUnsupportedTag("Unsupported NTag type (Pwd/PAck)") + if (pwdAck.size != 2) throw errThrowWriteInvalidPAck() + try { + connect(tagTech) { + tagTech.timeout = 5000 //todo-op??? test read protect, write pass, key authentication + //todo-op!!! variant 1: java.io.IOException: Transceive failed + mifareUltralightWritePage(tagTech, nTagPassPage, pagePwd) + mifareUltralightWritePage(tagTech, nTagPassPage + 1, pwdAck.toMutableList().also { it.addAll(listOf(0, 0)) }) + //todo-op!!! variant 2: java.io.IOException: Transceive failed + //mifareUltralightWritePage(tagTech, nTagPassPage, pagePwd, true) + //mifareUltralightWritePage(tagTech, nTagPassPage + 1, pwdAck.toMutableList().also { it.addAll(listOf(0, 0)) }, true) + } + } catch (e: Throwable) { + throw errorWritePass(e.toString()) + } + } + + @Throws(NfcErr::class, IOException::class) + protected fun nTagWriteProtect(tagTech: MifareUltralight, page: Int?) { + if (null == nTagConfigPage) throw errThrowWriteUnsupportedTag("Unsupported NTag type (config)") + if (null != page) try { + connect(tagTech) { + val pagesConfig: ByteArray? = tagTech.readPages(nTagConfigPage) // read 4 pages + val pageCfg0 = pagesConfig?.take(4) + val pageCfg1 = pagesConfig?.slice(4..7) + if (pageCfg0?.size != 4 || pageCfg1?.size != 4) throw errThrowWriteInvalidConfig() + val pageCfg10 = pageCfg1[0] or 0x80.toByte() // protect read and write + if (page < pageCfg0[3].toUByte().toInt() || pageCfg10 != pageCfg1[0]) + connect(tagTech) { + tagTech.timeout = 5000 //todo-op??? test read protect, write pass, key authentication + if (page < pageCfg0[3].toUByte().toInt()) // protect the page and next pages + mifareUltralightWritePage(tagTech, nTagConfigPage, listOf(pageCfg0[0], pageCfg0[1], pageCfg0[2], page.toByte())) + if (pageCfg10 != pageCfg1[0]) + mifareUltralightWritePage(tagTech, nTagConfigPage, listOf(pageCfg10, pageCfg1[1], pageCfg1[2], pageCfg1[3])) //todo-op!!! java.io.IOException: Transceive failed + } + } + } catch (e: Throwable) { + throw errorWriteProtect(e.toString()) + } + } + + @Throws(NfcErr::class, IOException::class) + protected fun nTagTryAuthenticate(tagTech: MifareUltralight, pagePwd: List, pwdAck: List, orStop: Boolean) { + connect(tagTech) { + var res: ByteArray? = null; var def: ByteArray? = null + try { + res = transceive(tagTech, byteArrayOf(0x1b.toByte(), *pagePwd.toByteArray())) // 0x1b = PWD_AUTH + } catch (e: Throwable) { + //Log.d(TAG, "NFC error: nTagTryPassAuthenticate: $e") + if (e is TagLostException) { tagTech.close(); tagTech.connect() } // reconnect is needed + try { + def = transceive(tagTech, byteArrayOf(0x1b.toByte(), *AuthenticationDefault.NTagPass.bytes)) // 0x1b = PWD_AUTH + } catch (e: Throwable) { + //Log.d(TAG, "NFC error: nTagTryPassAuthenticate (Default): $e") + if (orStop) throw errThrowAuthenticationFailed("Password authentication failed") + } + } + if (res != null) + if (res.toList() != pwdAck) throw errThrowAuthenticationResponse("Invalid PAck") + else NfcErr("NFC: Password authentication OK", TAG).log() + if (def != null) + if (def.toList() != AuthenticationDefault.NTagPAck.bytes.toList()) throw errThrowAuthenticationResponse("Invalid PAck (Default)") + else NfcErr("NFC: Password authentication OK (Default)", TAG).log() + } + } +} + +class NfcTagUnlock(packageName: String, onError: ((String, Throwable?) -> Unit)? = null, + tag: Tag?, ndefMessages: List? = null, + dump: MutableList? = null, debugResponse: TagType? = null) : NfcTag(packageName, onError, tag, ndefMessages, dump, debugResponse) { + companion object { + private const val UnlockUniqueSize = 2 // unique bytes/checksum size = 2 bytes + + fun unlockUnique(value: ByteArray): List? = if (UnlockUniqueSize == 0) null else + ByteArray(UnlockUniqueSize).also { res -> + for (index in 0 until UnlockUniqueSize) res[index] = 0 + if (UnlockUniqueSize > 0) value.forEachIndexed { index, byte -> + val data = if (index == 0) 2 * byte.toUByte().toInt() else byte.toInt() // obfuscate/hide when value.size = 1 + if (index == 0) // obfuscate/hide when value.size <= unlockUniqueSize + for (item in 0 until UnlockUniqueSize) res[item] = (res[item].toUByte().toInt() + data).toByte() + else (index % UnlockUniqueSize).let { item -> + res[item] = (res[item].toUByte().toInt() + data).toByte() + } + } + }.toList() + } + + // localize + private fun errThrowWriteReadOnly() = NfcErr("Can not make read-only", TAG, onError) + private fun errThrowWriteOutOfSpace() = NfcErr("Out of space", TAG, onError, "Empty page not found") + private fun errThrowWriteUnsupportedTech() = NfcErr("This tag is not supported", TAG, onError, "Unsupported tech for write") + private fun errCatchWrite() = NfcErr("Write failed", TAG, onError) + private fun errCatchWriteAsk() = NfcErr("Parameters failed", TAG, onError) + private fun errCatchInfoAsk() = NfcErr("Info failed", TAG, onError) + + val unlockNfcTag = + mutableListOf().let { + it.addAll(tagId?.toList().orEmpty()) // 'Anti-cloning support by unique 7-byte serial number for each device' + it.addAll(tagVersion.toList()) + if (it.isEmpty()) null else it.toList() + } + val unlockCanWrite = (null != tech.ndef && tech.ndef.isWritable) + || null != tech.mifareUltralight + + @Throws(NfcErr::class, IOException::class) + fun unlockCheck(value: ByteArray): Boolean { + val unique = unlockUnique(value) + if (null != tech.ndef) + if (unique == ndefMsgRecordFind()?.payload?.toList()) { + NfcErr("NFC: Unlock Ndef", TAG).log() + return true + } + if (null != tech.mifareUltralight) { + val arr = unique.orEmpty().toTypedArray() + val pass = listOf(*arr, *arr, *arr, *arr).take(4) + val pAck = listOf(*arr, *arr).take(2) + val data = listOf(*arr, *arr, *arr, *arr).take(4) + + if (null != nTagPassPage) nTagTryAuthenticate(tech.mifareUltralight, pass, pAck, false) + //else if (tech.mifareUltralight.type == MifareUltralight.TYPE_ULTRALIGHT_C) mifareUltralightTryAuthenticate(it, key) + + if (null != mifareUltralightFindPage(tech.mifareUltralight, data, listOf(0, 0, 0, 0))) { + NfcErr("NFC: Unlock MifareUltralight", TAG).log() + return true + } + } + return false + } + + @Throws(NfcErr::class, IOException::class) + fun unlockWrite(value: ByteArray, + ndefClearMessage: Boolean, ndefClearRecord: Boolean, ndefIgnore: Boolean, + ndefMakeReadOnly: Boolean, ndefFormat: Boolean, miUlClearPages: Boolean, miUlClearLast: Boolean, + nTagClearPass: Boolean, nTagClearProtect: Boolean, nTagPass: Boolean, + nTagProtect: Boolean, nTagProtectConfig: Boolean, nTagProtectPass: Boolean): Boolean { + try { + fun ndefClearRecord(tagTech: Ndef) { + val recs = ndefMsgRecordFilter() + if (!recs.isNullOrEmpty()) { + val msg = NdefMessage(tagTech.cachedNdefMessage?.records.orEmpty() + .filter { !recs.contains(it) }.toMutableList().also { + if (it.isEmpty()) it.add(NdefRecord(NdefRecord.TNF_EMPTY, null, null, null)) + }.toTypedArray()) + connect(tagTech) { + tagTech.writeNdefMessage(msg) + } + } + } + + if (null != tech.ndef) + if (ndefClearMessage) { + connect(tech.ndef) { + tech.ndef.writeNdefMessage(NdefMessage(NdefRecord(NdefRecord.TNF_EMPTY, null, null, null))) + } + return false // undo + } else if (ndefClearRecord) { + ndefClearRecord(tech.ndef) + return false // undo + } + + val unique = unlockUnique(value) + if (null != tech.ndef && !ndefIgnore) { + ndefMessage(tech.ndef.cachedNdefMessage?.records.orEmpty().toList(), unique?.toByteArray())?.let { + connect(tech.ndef) { + tech.ndef.writeNdefMessage(it) + } + } + if (ndefMakeReadOnly) + if (!tech.ndef.canMakeReadOnly()) throw errThrowWriteReadOnly() else + connect(tech.ndef) { + tech.ndef.makeReadOnly() + } + } else if (null != tech.ndefFormatable && ndefFormat) { + ndefMessage(listOf(), unique?.toByteArray())?.let { + connect(tech.ndefFormatable) { + if (ndefMakeReadOnly) tech.ndefFormatable.formatReadOnly(it) + else tech.ndefFormatable.format(it) + } + } + } else if (null != tech.mifareUltralight) { + //if (null != tech.ndef && ndefIgnore) ndefClearRecord(tech.ndef) // auto vs help + manual? + + val arr = unique.orEmpty().toTypedArray() + val pass = listOf(*arr, *arr, *arr, *arr).take(4) + val pAck = listOf(*arr, *arr).take(2) + val data = listOf(*arr, *arr, *arr, *arr).take(4) + + if (null != nTagPassPage) nTagTryAuthenticate(tech.mifareUltralight, pass, pAck, false) + //else if (tech.mifareUltralight.type == MifareUltralight.TYPE_ULTRALIGHT_C) mifareUltralightTryAuthenticate(it, key) + + if (miUlClearPages || miUlClearLast) connect(tech.mifareUltralight) { + for (page in (if (miUlClearPages) mifareUltralightPageUserMin else mifareUltralightPageUserMax)..mifareUltralightPageUserMax) + mifareUltralightWritePage(tech.mifareUltralight, page, listOf(0, 0, 0, 0)) + } + if (null != nTagPassPage) { + if (nTagClearProtect) nTagWriteProtect(tech.mifareUltralight, 0xFF) + if (nTagClearPass) nTagWritePassAndAck(tech.mifareUltralight, AuthenticationDefault.NTagPass.bytes.toList(), AuthenticationDefault.NTagPAck.bytes.toList()) + } + if (miUlClearPages || miUlClearLast || nTagClearProtect || nTagClearPass) return false // undo + + val page = mifareUltralightFindPage(tech.mifareUltralight, listOf(0, 0, 0, 0), data, onStop = { it }) { + //mifareUltraLightPageCheck(it) + connect(tech.mifareUltralight) { + mifareUltralightWritePage(tech.mifareUltralight, it, data) // write to last empty page + } + } ?: throw errThrowWriteOutOfSpace() + + if (null != nTagPassPage) { + if (nTagProtect) { + //mifareUltraLightPageCheck(page) + nTagWriteProtect(tech.mifareUltralight, page) + } else if (nTagProtectConfig) nTagWriteProtect(tech.mifareUltralight, nTagConfigPage) + else if (nTagProtectPass) nTagWriteProtect(tech.mifareUltralight, nTagPassPage) + if (nTagPass) nTagWritePassAndAck(tech.mifareUltralight, pass, pAck) + } + } else + throw errThrowWriteUnsupportedTech() + return true + } catch (e: Throwable) { + errCatchWrite().error(e) + return false + } + } + + fun unlockWriteAsk(context: Context, overwrite: Boolean, onNo: () -> Unit, + onYes: (Boolean, Boolean, Boolean, Boolean, Boolean, Boolean, Boolean, Boolean, Boolean, Boolean, + Boolean, Boolean, Boolean, Boolean) -> Unit) { + try { + fun switch(text: String, checked: Boolean) = SwitchCompat(context).also { + it.setPadding(10, 0, 0, 75) + it.text = text + it.isChecked = checked + } + + //val editTest = android.widget.EditText(that.context).also { it.setText("message") } + val nfcNoWrite = if (!unlockCanWrite) null else switch("nfcNoWrite", !unlockCanWrite) + val ndefClearMessage = if (!unlockCanWrite || null == tech.ndef) null else switch("ndefClearMessage", false) + val ndefClearRecord = if (!unlockCanWrite || null == tech.ndef) null else switch("ndefClearRecord", false) + val ndefIgnore = if (!unlockCanWrite || null == tech.ndef) null else switch("ndefIgnore", null == ndefMsgRecordFind()) + val ndefMakeReadOnly = if (!unlockCanWrite || null == tech.ndef && null == tech.ndefFormatable) null else switch("ndefMakeReadOnly", false) + val ndefFormat = if (!unlockCanWrite || null == tech.ndefFormatable) null else switch("ndefFormat", false) + val miUlClearPages = if (!unlockCanWrite || null == tech.mifareUltralight) null else switch("miUlClearPages", false) + val miUlClearLast = if (!unlockCanWrite || null == tech.mifareUltralight) null else switch("miUlClearLast", false) + val nTagClearPass = if (!unlockCanWrite || null == tech.mifareUltralight || null == nTagPassPage) null else switch("nTagClearPass", false) + val nTagClearProtect = if (!unlockCanWrite || null == tech.mifareUltralight || null == nTagPassPage) null else switch("nTagClearProtect", false) + val nTagPass = if (!unlockCanWrite || null == tech.mifareUltralight || null == nTagPassPage) null else switch("nTagPass", false) + val nTagProtect = if (!unlockCanWrite || null == tech.mifareUltralight || null == nTagPassPage) null else switch("nTagProtect", false) + val nTagProtectConfig = if (!unlockCanWrite || null == tech.mifareUltralight || null == nTagPassPage) null else switch("nTagProtectConfig", false) + val nTagProtectPass = if (!unlockCanWrite || null == tech.mifareUltralight || null == nTagPassPage) null else switch("nTagProtectPass", false) + + fun onYes() { + onYes(nfcNoWrite?.isChecked ?: false, + ndefClearMessage?.isChecked ?: false, ndefClearRecord?.isChecked ?: false, ndefIgnore?.isChecked ?: false, + ndefMakeReadOnly?.isChecked ?: false, ndefFormat?.isChecked ?: false, miUlClearPages?.isChecked ?: false, miUlClearLast?.isChecked ?: false, + nTagClearPass?.isChecked ?: false, nTagClearProtect?.isChecked ?: false, nTagPass?.isChecked ?: false, + nTagProtect?.isChecked ?: false, nTagProtectConfig?.isChecked ?: false, nTagProtectPass?.isChecked ?: false) + } + + if (null == dump && !unlockCanWrite) { + onYes() + return + } + AlertDialog.Builder(context) + .setNegativeButton(context.getString(android.R.string.cancel)) { dialog, _ -> dialog.cancel() } + .setOnCancelListener { onNo() } + .setPositiveButton(if (overwrite) "Overwrite" else context.getString(android.R.string.ok)) { _, _ -> onYes() } + .also { + if (!unlockCanWrite) it.setTitle("WRITE NOT SUPPORTED") + //dump?.let { dump -> it.setMessage(dump.joinToString("\n")) } + }.setView(ScrollView(context).also { scroll -> + scroll.addView(LinearLayout(context).also { layout -> + layout.orientation = LinearLayout.VERTICAL + layout.setPadding(60, 60, 60, 0) + //if (!unlockCanWrite) layout.addView(TextView(context).also { it.text = "WRITE NOT SUPPORTED" }) + layout.addView(TextView(context).also { + it.text = "NOTES: Current status\n" + + "1. Not tested because of 'java.io.IOException: Transceive failed':\n" + + "'nTagPass' - write password to NTag or MIFARE Ultralight EV1),\n" + + "'nTagProtect*' - write config to protect reading the pages\n" + + "2. Not tested and not complete because of 'android.nfc.TagLostException: Tag was lost':\n" + + "authentication for MifareUltralight C\n" + }) + dump?.let { dump -> + layout.addView(TextView(context).also { it.text = dump.joinToString("\n") }) + } + if (unlockCanWrite) listOfNotNull(ndefClearMessage, ndefClearRecord, ndefIgnore, + ndefMakeReadOnly, ndefFormat, miUlClearPages, miUlClearLast, + nTagClearPass, nTagClearProtect, nTagPass, + nTagProtect, nTagProtectConfig, nTagProtectPass, + ).forEach { layout.addView(it) } + }) + }).create().show() + } catch (e: Throwable) { + errCatchWriteAsk().error(e) + } + } + + fun unlockInfoAsk(context: Context, onNo: () -> Unit, onYes: () -> Unit) = + try { + if (dump.isNullOrEmpty()) + try { + onYes() + } catch (e: Throwable) { + errCatchInfoAsk().errorCb(e) + } + else AlertDialog.Builder(context) + .setNegativeButton(context.getString(android.R.string.cancel)) { dialog, _ -> dialog.cancel() } + .setOnCancelListener { + try { + onNo() + } catch (e: Throwable) { + errCatchInfoAsk().errorCb(e) + } + }.setPositiveButton(context.getString(android.R.string.ok)) { _, _ -> + try { + onYes() + } catch (e: Throwable) { + errCatchInfoAsk().errorCb(e) + } + }.also { + if (!unlockCanWrite) it.setTitle("WRITE NOT SUPPORTED") + //it.setMessage(dump.joinToString("\n")) + }.setView(ScrollView(context).also { scroll -> + scroll.addView(LinearLayout(context).also { layout -> + layout.orientation = LinearLayout.VERTICAL + layout.setPadding(60, 60, 60, 0) + //if (!unlockCanWrite) layout.addView(TextView(context).also { it.text = "WRITE NOT SUPPORTED" }) + layout.addView(TextView(context).also { it.text = dump.joinToString("\n") }) + }) + }).create().show() + } catch (e: Throwable) { + errCatchInfoAsk().error(e) + } +} + +open class BaseErr(message: String?, private val tag: String, + private val onError: ((String, Throwable?) -> Unit)?, val log: String = "") : Exception(message) { + open val prefixErr: String = "" + + private fun msg(prefix: String) = "$prefix$message ${if (log.isEmpty()) "" else "; "}$log" + fun log() = Log.d(tag, msg("")) + fun err(e: Throwable, isCallback: Boolean = false) = + Log.d(tag, "%s%s: %s".format(msg(prefixErr), if (!isCallback) "" else " (callback)", e)) + fun errorCb(e: Throwable) = error(e, true) + + fun error(e: Throwable, isCallback: Boolean = false) { + err(e, isCallback) + onError?.invoke("$prefixErr$message" + if (e !is NfcErr || e.message.isNullOrBlank()) "" else "; ${e.message}", + if (isCallback) e else null) + } +} + +class NfcErr(message: String?, tag: String, onError: ((String, Throwable?) -> Unit)? = null, log: String = "") : BaseErr(message, tag, onError, log) { + override val prefixErr = "NFC error: " // localize +} diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/MagikeyboardSettingsFragment.kt b/app/src/main/java/com/kunzisoft/keepass/settings/MagikeyboardSettingsFragment.kt index 92695db7c..301d71828 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/MagikeyboardSettingsFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/MagikeyboardSettingsFragment.kt @@ -19,11 +19,15 @@ */ package com.kunzisoft.keepass.settings +import android.os.Build import android.os.Bundle import androidx.fragment.app.DialogFragment import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat +import androidx.preference.SwitchPreference import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.activities.dialogs.UnavailableFeatureDialogFragment +import com.kunzisoft.keepass.services.NfcService import com.kunzisoft.keepass.settings.preferencedialogfragment.DurationDialogFragmentCompat class MagikeyboardSettingsFragment : PreferenceFragmentCompat() { @@ -31,6 +35,17 @@ class MagikeyboardSettingsFragment : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { // Load the preferences from an XML resource setPreferencesFromResource(R.xml.preferences_keyboard, rootKey) + + findPreference(getString(R.string.keyboard_selection_nfc_key))?.let { pref -> + if (!NfcService.isSupported(requireContext())) pref.isChecked = false + pref.setOnPreferenceClickListener { + if (!NfcService.isSupported(requireContext())) { + pref.isChecked = false + UnavailableFeatureDialogFragment.getInstance(Build.VERSION_CODES.M).show(parentFragmentManager, "unavailableFeatureDialog") + false + } else true + } + } } override fun onDisplayPreferenceDialog(preference: Preference) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/NestedAppSettingsFragment.kt b/app/src/main/java/com/kunzisoft/keepass/settings/NestedAppSettingsFragment.kt index 767720a21..8e41cc72d 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/NestedAppSettingsFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/NestedAppSettingsFragment.kt @@ -45,6 +45,7 @@ import com.kunzisoft.keepass.biometric.AdvancedUnlockManager import com.kunzisoft.keepass.education.Education import com.kunzisoft.keepass.icons.IconPackChooser import com.kunzisoft.keepass.services.ClipboardEntryNotificationService +import com.kunzisoft.keepass.services.NfcService import com.kunzisoft.keepass.settings.preference.IconPackListPreference import com.kunzisoft.keepass.settings.preferencedialogfragment.DurationDialogFragmentCompat import com.kunzisoft.keepass.utils.UriUtil @@ -237,6 +238,29 @@ class NestedAppSettingsFragment : NestedSettingsFragment() { setPreferencesFromResource(R.xml.preferences_advanced_unlock, rootKey) activity?.let { activity -> + findPreference(getString(R.string.unlock_nfc_enable_key))?.let { pref -> + if (!NfcService.isSupported(requireContext())) pref.isChecked = false + pref.setOnPreferenceClickListener { + if (!NfcService.isSupported(requireContext())) { + pref.isChecked = false + UnavailableFeatureDialogFragment.getInstance(Build.VERSION_CODES.M).show(parentFragmentManager, "unavailableFeatureDialog") + false + } else if (pref.isChecked) { + warningMessage(activity, keystoreWarning = true, deleteKeys = false) { + pref.isChecked = true + } + pref.isChecked = false + true + } else { + //todo-op!! Delete only NFC data! Better message for NFC unlock! + warningMessage(activity, keystoreWarning = false, deleteKeys = true) { + pref.isChecked = false + } + pref.isChecked = true + true + } + } + } val biometricUnlockEnablePreference: SwitchPreference? = findPreference(getString(R.string.biometric_unlock_enable_key)) val deviceCredentialUnlockEnablePreference: SwitchPreference? = findPreference(getString(R.string.device_credential_unlock_enable_key)) diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/PreferencesUtil.kt b/app/src/main/java/com/kunzisoft/keepass/settings/PreferencesUtil.kt index 77fcdc552..363ad285e 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/PreferencesUtil.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/PreferencesUtil.kt @@ -35,6 +35,7 @@ import com.kunzisoft.keepass.database.search.SearchParameters import com.kunzisoft.keepass.education.Education import com.kunzisoft.keepass.magikeyboard.MagikeyboardService import com.kunzisoft.keepass.password.PassphraseGenerator +import com.kunzisoft.keepass.services.NfcService import com.kunzisoft.keepass.timeout.TimeoutHelper import com.kunzisoft.keepass.utils.UriUtil import java.util.* @@ -483,6 +484,12 @@ object PreferencesUtil { return isBiometricUnlockEnable(context) || isDeviceCredentialUnlockEnable(context) } + fun isUnlockNfcEnable(context: Context): Boolean { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + return prefs.getBoolean(context.getString(R.string.unlock_nfc_enable_key), + context.resources.getBoolean(R.bool.unlock_nfc_enable_default)) + } + fun isBiometricUnlockEnable(context: Context): Boolean { val prefs = PreferenceManager.getDefaultSharedPreferences(context) val biometricSupported = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { @@ -620,6 +627,12 @@ object PreferencesUtil { context.resources.getBoolean(R.bool.keyboard_selection_entry_default)) } + fun isKeyboardEntryNfcEnable(context: Context): Boolean { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + return prefs.getBoolean(context.getString(R.string.keyboard_selection_nfc_key), + context.resources.getBoolean(R.bool.keyboard_selection_nfc_default)) + } + fun isKeyboardSaveSearchInfoEnable(context: Context): Boolean { if (!MagikeyboardService.activatedInSettings(context)) return false @@ -796,6 +809,7 @@ object PreferencesUtil { context.getString(R.string.show_recent_files_key) -> editor.putBoolean(name, value.toBoolean()) context.getString(R.string.hide_broken_locations_key) -> editor.putBoolean(name, value.toBoolean()) context.getString(R.string.remember_keyfile_locations_key) -> editor.putBoolean(name, value.toBoolean()) + context.getString(R.string.unlock_nfc_enable_key) -> editor.putBoolean(name, value.toBoolean()) context.getString(R.string.biometric_unlock_enable_key) -> editor.putBoolean(name, value.toBoolean()) context.getString(R.string.device_credential_unlock_enable_key) -> editor.putBoolean(name, value.toBoolean()) context.getString(R.string.biometric_auto_open_prompt_key) -> editor.putBoolean(name, value.toBoolean()) @@ -811,6 +825,7 @@ object PreferencesUtil { context.getString(R.string.keyboard_notification_entry_clear_close_key) -> editor.putBoolean(name, value.toBoolean()) context.getString(R.string.keyboard_entry_timeout_key) -> editor.putString(name, value.toLong().toString()) context.getString(R.string.keyboard_selection_entry_key) -> editor.putBoolean(name, value.toBoolean()) + context.getString(R.string.keyboard_selection_nfc_key) -> editor.putBoolean(name, value.toBoolean()) context.getString(R.string.keyboard_save_search_info_key) -> editor.putBoolean(name, value.toBoolean()) context.getString(R.string.keyboard_auto_go_action_key) -> editor.putBoolean(name, value.toBoolean()) context.getString(R.string.keyboard_key_vibrate_key) -> editor.putBoolean(name, value.toBoolean()) diff --git a/app/src/main/java/com/kunzisoft/keepass/view/AdvancedUnlockInfoView.kt b/app/src/main/java/com/kunzisoft/keepass/view/AdvancedUnlockInfoView.kt index c91a7a9da..bd3c566cd 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/AdvancedUnlockInfoView.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/AdvancedUnlockInfoView.kt @@ -20,6 +20,7 @@ package com.kunzisoft.keepass.view import android.content.Context +import android.content.res.ColorStateList import android.os.Build import android.util.AttributeSet import android.view.LayoutInflater @@ -72,6 +73,13 @@ class AdvancedUnlockInfoView @JvmOverloads constructor(context: Context, } } + //todo-op? How to resources.getColor(R.attr.colorAccent)? What after the theme is changed? + private var saveDefaultBackgroundTint: Int? = null + fun setIconBackgroundTint(color: Int? = null) { + if (saveDefaultBackgroundTint == null) saveDefaultBackgroundTint = unlockIconImageView?.backgroundTintList?.defaultColor + (color ?: saveDefaultBackgroundTint)?.let { unlockIconImageView?.backgroundTintList = ColorStateList.valueOf(it) } + } + fun setIconViewClickListener(animation: Boolean = true, listener: ((view: View)->Unit)?) { var animateButton = animation diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index a8fd6743e..3a696141d 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -92,6 +92,8 @@ remember_keyfile_locations_key true advanced_unlock_explanation_key + unlock_nfc_enable_key + false biometric_unlock_enable_key false device_credential_unlock_enable_key @@ -134,6 +136,8 @@ -1 keyboard_selection_entry_key false + keyboard_selection_nfc_key + false keyboard_save_search_info_key false keyboard_auto_go_action_key diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 556135f33..2f64be87e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -406,6 +406,8 @@ Advanced unlock Tap to delete advanced unlocking keys Use advanced unlocking to open a database more easily + NFC unlocking + Lets you tap NFC tag to open the database Biometric unlocking Lets you scan your biometric to open the database Device credential unlocking @@ -481,6 +483,8 @@ Entry Entry selection When viewing an entry in KeePassDX, populate Magikeyboard with that entry + NFC tag selection + Tap NFC tag and select entry associated with it Notification info Show a notification when an entry is available Save shared info @@ -679,4 +683,10 @@ Displays foreground and background colors in an entry Hide expired entries Expired entries are not shown + + Enable NFC to use this feature. + (Tap NFC tag) + (Tap NFC tag and hold it) + This NFC tag is not supported. + "Write is not supported for this NFC tag." \ No newline at end of file diff --git a/app/src/main/res/xml/preferences_advanced_unlock.xml b/app/src/main/res/xml/preferences_advanced_unlock.xml index b66b7168e..97753a0e4 100644 --- a/app/src/main/res/xml/preferences_advanced_unlock.xml +++ b/app/src/main/res/xml/preferences_advanced_unlock.xml @@ -24,6 +24,11 @@ android:key="@string/advanced_unlock_explanation_key" android:icon="@drawable/prefs_info_24dp" android:summary="@string/advanced_unlock_explanation_summary"/> + +