Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,24 +69,41 @@ The demo application (`demoapp/app`) provides a complete reference implementatio

#### Building the AAR

Requirements:

* JDK 21 or newer — the libraries are compiled to Java 21 bytecode, both for building the AARs and for compiling an application against them.
* Application `minSdk` 34 or higher

```shell
./gradlew -p libs assemble
```

* The `.aar` files are located in:
* `libs/id-card-lib/build/outputs/aar`
* `libs/smart-card-reader-lib/build/outputs/aar`
* `libs/card-utils-lib/build/outputs/aar`
* Move the resulting `.aar` files to your project's `/libs` directory.
* Add the dependencies to your application's `build.gradle` file:
* `implementation files('app/libs/id-card-lib.aar')`
* `implementation files('app/libs/smart-card-reader-lib.aar')`
* Add the dependencies to your application's `build.gradle.kts` file:
* `implementation(files("app/libs/id-card-lib.aar"))`
* `implementation(files("app/libs/smart-card-reader-lib.aar"))`
* `implementation(files("app/libs/card-utils-lib.aar"))`

All three `.aar` files are required.

Your application must declare these dependencies:

```kotlin
implementation("org.bouncycastle:bcprov-jdk18on:1.86")
implementation("org.jetbrains.kotlin:kotlin-stdlib")
```

## Overview

ID card support for Android applications is based on two libraries: `id-card-lib` and `smart-card-reader-lib`.
ID card support for Android applications is based on three libraries: `id-card-lib`, `smart-card-reader-lib` and `card-utils-lib`.

* `smart-card-reader-lib` enables the use of the ID card over NFC. It provides the NFC smart card reader interface and communication layer.
* `id-card-lib` implements the APDU-based communication protocols required to use the core functionality across different types of ID cards (IDEMIA and Thales).
* `card-utils-lib` provides the logging utility used by the other two libraries.

Integrating ID card support into an Android application proceeds as follows:

Expand Down
10 changes: 6 additions & 4 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
// SPDX-FileCopyrightText: Estonian Information System Authority
// SPDX-License-Identifier: LGPL-2.1-or-later

buildscript {
dependencies {
classpath(libs.kotlin.gradle.plugin)
}
}

// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.jetbrains.kotlin.android) apply false
alias(libs.plugins.jetbrains.kotlin.jvm) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.google.dagger) apply false
}
24 changes: 11 additions & 13 deletions demoapp/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,16 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
}

android {
namespace = "ee.ria.DigiDoc.smartcardreader.nfc.example"
compileSdk = 36
compileSdk = 37

defaultConfig {
applicationId = "ee.ria.DigiDoc.smartcardreader.nfc.example"
minSdk = 26
targetSdk = 36
minSdk = 34
targetSdk = 37
versionCode = 3
versionName = "1.0.1"

Expand All @@ -32,22 +31,22 @@ android {
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
}
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
buildFeatures {
viewBinding = true
}
}

kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_21
}
}

dependencies {
implementation(libs.androidx.activity)
implementation(libs.androidx.annotation)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.core)
Expand All @@ -56,7 +55,6 @@ dependencies {
implementation(libs.androidx.navigation.runtime)
implementation(libs.bcprov.jdk18on)
implementation(libs.fragment.ktx)
implementation(libs.guava)
implementation(libs.material)
implementation(libs.navigation.fragment.ktx)
implementation(libs.recyclerview)
Expand Down
6 changes: 2 additions & 4 deletions demoapp/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-FileCopyrightText: Estonian Information System Authority -->
<!-- SPDX-License-Identifier: LGPL-2.1-or-later -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.NFC" />
Expand All @@ -16,8 +15,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.NFCApp"
android:enableOnBackInvokedCallback="true"
tools:targetApi="31">
android:enableOnBackInvokedCallback="true">
<activity
android:name="ee.ria.DigiDoc.smartcardreader.nfc.example.MainActivity"
android:exported="true">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,24 +84,13 @@ class MainActivity : AppCompatActivity() {
private fun getAppVersion(context: Context): StringBuilder {
val versionName = StringBuilder()
try {
versionName.append(
context.packageManager.getPackageInfo(
context.packageName,
0
).versionName
val packageInfo = context.packageManager.getPackageInfo(
context.packageName,
PackageManager.PackageInfoFlags.of(0)
)
versionName.append(packageInfo.versionName)
.append(".")

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
versionName.append(
context.packageManager.getPackageInfo(context.packageName, 0).longVersionCode
)
} else {
@Suppress("DEPRECATION")
versionName.append(
context.packageManager.getPackageInfo(context.packageName, 0).versionCode
)
}
.append(packageInfo.longVersionCode)
} catch (e: PackageManager.NameNotFoundException) {
e.printStackTrace()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,16 @@ import ee.ria.DigiDoc.smartcardreader.nfc.example.util.Utils.filesToCache
import ee.ria.DigiDoc.smartcardreader.nfc.example.util.Utils.getContainerFilesList
import ee.ria.DigiDoc.smartcardreader.nfc.example.util.Utils.signatureIsAdded
import ee.ria.DigiDoc.smartcardreader.nfc.example.viewmodel.DataViewModel
import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog
import ee.ria.libdigidocpp.Container
import java.io.File
import java.io.IOException
import java.util.Locale

class ContainerFragment : Fragment() {

private val logTag = javaClass.simpleName

private val dataViewModel: DataViewModel by activityViewModels()
private lateinit var binding: FragmentContainerBinding
private lateinit var backButton: Button
Expand Down Expand Up @@ -105,7 +109,17 @@ class ContainerFragment : Fragment() {
}
addFileButton.setOnClickListener { addFile() }
addSignatureButton.setOnClickListener {
filesToCache(requireContext().cacheDir.absolutePath)
try {
filesToCache(requireContext().cacheDir.absolutePath)
} catch (ex: IOException) {
errorLog(logTag, "Unable to prepare data files", ex)
Toast.makeText(
requireContext(),
"Unable to prepare data files.",
Toast.LENGTH_SHORT
).show()
return@setOnClickListener
}
createContainer()
dataViewModel.setContainerName(containerNameEditText.text.toString())
val bundle = Bundle()
Expand Down Expand Up @@ -197,7 +211,7 @@ class ContainerFragment : Fragment() {
throw Exception("Not enough free space.")
}
} catch (ex: Exception) {
ex.printStackTrace()
errorLog(logTag, "Unable to create container", ex)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,21 @@ import android.app.Activity
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.database.Cursor
import android.net.Uri
import android.provider.OpenableColumns
import com.google.common.io.Files
import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.debugLog
import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog
import ee.ria.libdigidocpp.Container
import ee.ria.libdigidocpp.Signature
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream

object Utils {

val origin = "ivxv.valimised.ee:443"
private val logTag = javaClass.simpleName
const val SIGNATURE_CONTAINER_EXTENSION = "asice"
private const val DEFAULT_MIME_TYPE = "application/octet-stream"
private val containerFiles: MutableList<FileData> = mutableListOf()
lateinit var container: Container
lateinit var signature: Signature
Expand Down Expand Up @@ -54,94 +51,91 @@ object Utils {
}

fun getFileNameAndSize(uri: Uri, context: Context): Pair<String, Long> {
var fileName: String = uri.lastPathSegment!!
var fileName: String = uri.lastPathSegment ?: ""
var fileSize: Long = 0
if (uri.scheme.equals("content")) {
try {
val cursor: Cursor? = context.contentResolver.query(uri, null, null, null, null)!!
if (cursor != null && cursor.moveToFirst()) {
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0) {
fileName = cursor.getString(nameIndex)
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0) {
fileName = cursor.getString(nameIndex)
}
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
if (sizeIndex >= 0) {
fileSize = cursor.getLong(sizeIndex)
}
}
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
if (sizeIndex >= 0) {
fileSize = cursor.getLong(sizeIndex)
}
cursor.close()
}
} catch (exception: Exception) {
exception.printStackTrace()
errorLog(logTag, "Unable to read file name and size", exception)
}
}
return Pair(fileName, fileSize)
}

fun removeFileExtension(fileName: String): String {
val index = fileName.lastIndexOf(".")
return fileName.substring(0, index)
}
fun removeFileExtension(fileName: String): String = fileName.substringBeforeLast('.')

fun signatureContainerFile(fileName: String, filesDir: File): File {
val containerDir = createContainersDir(filesDir)
val containerFile = File(containerDir, fileName)
if (containerFile.canonicalPath.startsWith(containerDir.canonicalPath)) {
Files.createParentDirs(containerFile)
} else {
if (!containerFile.canonicalFile.toPath().startsWith(containerDir.canonicalFile.toPath())) {
throw IOException("Invalid file path")
}
val parent = containerFile.parentFile
if (parent != null && !parent.isDirectory && !parent.mkdirs()) {
throw IOException("Unable to create container parent directories")
}
return containerFile
}

private fun createContainersDir(filesDir: File): File {
val dir = File(filesDir, "containers")
if (dir.mkdirs()) {
debugLog(logTag, String.format("Directories created for %s", dir.path))
if (!dir.isDirectory && !dir.mkdirs()) {
throw IOException("Unable to create containers directory")
}
return dir
}

fun getFilesFromCache(cacheDir: String): MutableList<File> {
val fileList: MutableList<File> = mutableListOf()
for (file in File("$cacheDir/datafiles").listFiles()!!) {
fileList.add(file)
}
return fileList
}
fun getFilesFromCache(cacheDir: String): MutableList<File> =
File("$cacheDir/datafiles").listFiles()?.toMutableList() ?: mutableListOf()

fun filesToCache(cacheDir: String) {
val datafilesDir = createDataFilesDir(File(cacheDir))
Files.createParentDirs(datafilesDir)
for (fileData in containerFiles) {
val cacheFile = File(datafilesDir, fileData.getFileName())
if (cacheFile.canonicalPath.startsWith(File(cacheDir).canonicalPath)) {
try {
val inp: InputStream =
fileData.getContentResolver()
.openInputStream(fileData.getFileIntent().data!!)!!
val outputStream: OutputStream = FileOutputStream(cacheFile)
inp.copyTo(outputStream, DEFAULT_BUFFER_SIZE)
inp.close()
outputStream.close()
} catch (ex: Exception) {
ex.printStackTrace()
}
if (!cacheFile.canonicalFile.toPath().startsWith(File(cacheDir).canonicalFile.toPath())) {
errorLog(logTag, "Data file resolves outside the cache directory, skipping")
continue
}
val uri = fileData.getFileIntent().data
if (uri == null) {
errorLog(logTag, "Data file has no URI, skipping")
continue
}
try {
fileData.getContentResolver().openInputStream(uri)?.use { input ->
FileOutputStream(cacheFile).use { output ->
input.copyTo(output, DEFAULT_BUFFER_SIZE)
}
} ?: errorLog(logTag, "Could not open data file, skipping")
} catch (ex: Exception) {
errorLog(logTag, "Unable to cache data file", ex)
}
}
}

private fun createDataFilesDir(cacheDir: File): File {
val dir = File(cacheDir, "datafiles")
if (dir.mkdirs()) {
debugLog(logTag, String.format("Directories created for %s", dir.path))
if (!dir.isDirectory && !dir.mkdirs()) {
throw IOException("Unable to create $dir")
}
return dir
}

fun getMimeType(index: Int): String {
val uri = containerFiles[index].getFileIntent().data
val contentResolver = containerFiles[index].getContentResolver()
return contentResolver.getType(uri!!)!!
val uri = containerFiles[index].getFileIntent().data ?: return DEFAULT_MIME_TYPE
return containerFiles[index].getContentResolver().getType(uri) ?: DEFAULT_MIME_TYPE
}

fun checkFreeSpace(container: Container, activity: Activity): Boolean {
Expand Down
Loading