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
12 changes: 10 additions & 2 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,14 @@ jobs:
fingerprint:infra:nec-bio-sdk
fingerprint:infra:image-distortion-config
reportsId: fingerprint

testing-tools:
name: Testing Tools unit tests
uses: ./.github/workflows/reusable-run-unit-tests.yml
secrets: inherit
with:
modules: |
testing:data-generator
reportsId: testing-tools
sonarqube:
name: SonarQube
secrets: inherit
Expand All @@ -134,5 +141,6 @@ jobs:
feature-unit-tests2,
feature-dashboard-unit-tests,
face-unit-tests,
fingerprint-unit-tests ]
fingerprint-unit-tests,
testing-tools ]
uses: ./.github/workflows/reusable-sonar-scan.yml
1 change: 1 addition & 0 deletions .github/workflows/reusable-sonar-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ jobs:
rsync -arv test-reports/dashboard/* . || true
rsync -arv test-reports/face/* . || true
rsync -arv test-reports/fingerprint/* . || true
rsync -arv test-reports/testing-tools/* . || true

# list all reports for Id module
- name: list reports
Expand Down
2 changes: 2 additions & 0 deletions feature/dashboard/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ dependencies {
implementation(project(":feature:consent"))
implementation(project(":feature:login"))
implementation(project(":feature:troubleshooting"))
// Data Generator is a test-only feature, only included in debug builds
debugImplementation(project(":testing:data-generator"))

implementation(libs.fuzzywuzzy.core)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ internal class CreateEnrolResponseUseCase @Inject constructor(
suspend operator fun invoke(
request: ActionRequest.EnrolActionRequest,
results: List<Serializable>,
project: Project
project: Project,
): AppResponse {
val fingerprintCapture = results.filterIsInstance(FingerprintCaptureResult::class.java).lastOrNull()
val faceCapture = results.filterIsInstance(FaceCaptureResult::class.java).lastOrNull()
Expand Down
4 changes: 4 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,7 @@ include(
":infra:sync",
":infra:event-sync",
)
// Test modules
include(
":testing:data-generator",
)
1 change: 1 addition & 0 deletions testing/data-generator/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
Binary file added testing/data-generator/FACE-IMAGE.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 49 additions & 0 deletions testing/data-generator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# 📦 Debug-Only Biometric Data Generator

This module allows developers to insert **bulk biometric enrollment records** directly into the local SID database for **developer testing**, **performance testing**, and **E2E test setup**. It is only available in **debug builds** and should never be shipped in production.

---

## 🚀 How to Use

Use the following ADB command to trigger bulk record generation via an explicit `Intent`. This will insert fingerprint and face templates using pre-configured logic.

### ✅ Example Command

```bash
adb shell am start \
-a com.simprints.test.GENERATE_ENROLLMENT_RECORDS \
--es EXTRA_PROJECT_ID "oPru9XTAI2hE2nDFD5vZ" \
--es EXTRA_MODULE_ID "module-abc" \
--es EXTRA_ATTENDANT_ID "user-xyz" \
--ei EXTRA_NUM_RECORDS 5000 \
--ei EXTRA_TEMPLATES_PER_FORMAT.RANK_ONE_3_1 2 \
--ei EXTRA_TEMPLATES_PER_FORMAT.NEC_1_5 2 \
--es EXTRA_FINGER_ORDER.NEC_1 "RIGHT_INDEX_FINGER,LEFT_THUMB" \
--es EXTRA_FIRST_SUBJECT_ID "d9a6c3f7-a6c3-d9a6-c3f7-a6c3d9a6c3f7"
```

---

## 🧠 Parameters

| Key | Description |
| ------------------------------ |----------------------------------------------|
| `EXTRA_PROJECT_ID` | Project ID to assign to generated records |
| `EXTRA_MODULE_ID` | Module ID for the records |
| `EXTRA_ATTENDANT_ID` | User ID associated with the records |
| `EXTRA_NUM_RECORDS` | Number of enrollment records to insert |
| `EXTRA_TEMPLATES_PER_FORMAT.*` | Number of templates per biometric format |
| `EXTRA_FINGER_ORDER.*` | Comma-separated finger order for each format |
| `EXTRA_FIRST_SUBJECT_ID` | UUID of the first subject |

---

## 🖼️ Face Template Image

This generator uses a static face image to create biometric templates.
📍![Face image](./FACE-IMAGE.png)



---
17 changes: 17 additions & 0 deletions testing/data-generator/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
plugins {
id("simprints.feature")
id("kotlin-parcelize")
alias(libs.plugins.kotlin.android)
}

android {
namespace = "com.simprints.feature.datagenerator"
}
dependencies {

implementation(project(":infra:events"))
implementation(project(":infra:event-sync"))
implementation(project(":infra:config-store"))
implementation(project(":infra:auth-store"))
implementation(project(":infra:enrolment-records:repository"))
}
21 changes: 21 additions & 0 deletions testing/data-generator/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html

# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}

# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable

# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
24 changes: 24 additions & 0 deletions testing/data-generator/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.simprints.feature.datagenerator">

<application>

<activity
android:name=".DataGeneratorActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<category android:name="android.intent.category.DEFAULT" />

<!-- Action to trigger the generation of enrollment records -->
<action android:name="com.simprints.test.GENERATE_ENROLLMENT_RECORDS" />

<!-- Action to trigger the generation of session events -->
<action android:name="com.simprints.test.GENERATE_SESSION_EVENTS" />

</intent-filter>
</activity>
</application>

</manifest>
3 changes: 3 additions & 0 deletions testing/data-generator/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Leave this file empty to avoid exposing this feature in release and staging builds -->
<manifest/>
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.simprints.feature.datagenerator

import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.simprints.feature.datagenerator.databinding.ActivityDataGeneratorBinding
import com.simprints.infra.logging.Simber
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

@AndroidEntryPoint
class DataGeneratorActivity : AppCompatActivity() {
private val viewModel: DataGeneratorViewModel by viewModels()
private lateinit var binding: ActivityDataGeneratorBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (!BuildConfig.DEBUG_MODE) {
Simber.i("DataGenerator is only for debug builds.")
throw IllegalStateException("DataGenerator is only for debug builds.")
}
setContentView(R.layout.activity_data_generator)
binding = ActivityDataGeneratorBinding.inflate(layoutInflater)
setContentView(binding.root)

viewModel.statusMessage.observe(this) {
binding.statusText.text = it
}

lifecycleScope.launch {
try {
viewModel.handleIntent(intent)
// add a delay to let the user see the status message
delay(SMALL_DELAY_MS)
// Set result to indicate success
setResult(RESULT_OK)
} catch (e: Exception) {
Simber.e("Error handling intent: ${e.message}", e)
// Set result to indicate failure
setResult(RESULT_CANCELED)
}
finish()
}
}

companion object {
private const val SMALL_DELAY_MS = 1000L // 1 second
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package com.simprints.feature.datagenerator

import android.content.Intent
import android.os.Bundle
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.simprints.feature.datagenerator.enrollmentrecords.InsertEnrollmentRecordsUseCase
import com.simprints.infra.authstore.AuthStore
import com.simprints.infra.logging.Simber
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

@HiltViewModel
internal class DataGeneratorViewModel @Inject constructor(
private val insertEnrollmentRecords: InsertEnrollmentRecordsUseCase,
private val authStore: AuthStore,
) : ViewModel() {
companion object {
private const val TAG = "DataGeneratorViewModel"

// Intent Actions
private const val ACTION_GENERATE_ENROLLMENT_RECORDS = "com.simprints.test.GENERATE_ENROLLMENT_RECORDS"
private const val ACTION_GENERATE_SESSION_EVENTS = "com.simprints.test.GENERATE_SESSION_EVENTS"

// Common Extras
private const val EXTRA_PROJECT_ID = "EXTRA_PROJECT_ID"
private const val EXTRA_MODULE_ID = "EXTRA_MODULE_ID"
private const val EXTRA_ATTENDANT_ID = "EXTRA_ATTENDANT_ID"

// Enrollment Record Extras
private const val EXTRA_NUM_RECORDS = "EXTRA_NUM_RECORDS"
private const val EXTRA_TEMPLATES_PER_FORMAT = "EXTRA_TEMPLATES_PER_FORMAT"
private const val EXTRA_FIRST_SUBJECT_ID = "EXTRA_FIRST_SUBJECT_ID"
private const val EXTRA_FINGER_ORDER = "EXTRA_FINGER_ORDER"

// Session Event Extras
private const val EXTRA_ENROL_COUNT = "EXTRA_ENROL_COUNT"
private const val EXTRA_IDENTIFY_COUNT = "EXTRA_IDENTIFY_COUNT"
private const val EXTRA_CONFIRM_IDENTIFY_COUNT = "EXTRA_CONFIRM_IDENTIFY_COUNT"
private const val EXTRA_ENROL_LAST_COUNT = "EXTRA_ENROL_LAST_COUNT"
private const val EXTRA_VERIFY_COUNT = "EXTRA_VERIFY_COUNT"
}

private val _statusMessage = MutableLiveData<String>()
val statusMessage: LiveData<String> = _statusMessage

suspend fun handleIntent(intent: Intent?) {
Simber.i("Handling intent: ${intent?.action}", tag = TAG)
if (intent?.action == null) {
Simber.i("Cannot handle null intent or action.", tag = TAG)
throw IllegalArgumentException("Intent or action cannot be null")
}
if (authStore.signedInProjectId.isEmpty()) {
Simber.i("No project signed in, cannot handle intent.", tag = TAG)
throw IllegalStateException("No project signed in")
}

when (intent.action) {
ACTION_GENERATE_ENROLLMENT_RECORDS -> parseAndGenerateEnrollmentRecords(intent)
ACTION_GENERATE_SESSION_EVENTS -> parseAndGenerateSessionEvents(intent)
else -> {
Simber.i("Unknown action received: ${intent.action}", tag = TAG)
throw IllegalArgumentException("Unknown action: ${intent.action}")
}
}
}

/**
* Parses extras for generating enrollment records and calls the data creation function.
*/
private suspend fun parseAndGenerateEnrollmentRecords(intent: Intent) {
val projectId = intent.getStringExtra(EXTRA_PROJECT_ID)
val moduleId = intent.getStringExtra(EXTRA_MODULE_ID)
val attendantId = intent.getStringExtra(EXTRA_ATTENDANT_ID)
val numRecords = intent.getIntExtra(EXTRA_NUM_RECORDS, 0)
val templatesPerFormat =
intent.getBundleExtra(EXTRA_TEMPLATES_PER_FORMAT) ?: extractBundleFromFlatExtras(intent, EXTRA_TEMPLATES_PER_FORMAT)
val firstSubjectId = intent.getStringExtra(EXTRA_FIRST_SUBJECT_ID)
val fingerOrder = intent.getBundleExtra(EXTRA_FINGER_ORDER) ?: extractBundleFromFlatExtras(intent, EXTRA_FINGER_ORDER)

if (projectId == null || moduleId == null || attendantId == null || numRecords <= 0) {
Simber.i("Missing required extras for generating enrollment records.", tag = TAG)
throw IllegalArgumentException(
"Required extras missing: projectId, moduleId, attendantId, or numRecords",
)
}

Simber.i("Calling generateEnrollmentRecordsInDb with $numRecords records.", tag = TAG)
insertEnrollmentRecords(
projectId = projectId,
moduleId = moduleId,
attendantId = attendantId,
numRecords = numRecords,
templatesPerFormat = templatesPerFormat,
firstSubjectId = firstSubjectId ?: "",
fingerOrder = fingerOrder,
).collect {
_statusMessage.postValue(it)
}
}

/**
* Parses extras for generating session events and calls the data creation function.
*/
private fun parseAndGenerateSessionEvents(intent: Intent) {
val projectId = intent.getStringExtra(EXTRA_PROJECT_ID)
val moduleId = intent.getStringExtra(EXTRA_MODULE_ID)
val attendantId = intent.getStringExtra(EXTRA_ATTENDANT_ID)
val enrolCount = intent.getIntExtra(EXTRA_ENROL_COUNT, 0)
val identifyCount = intent.getIntExtra(EXTRA_IDENTIFY_COUNT, 0)
val confirmIdentifyCount = intent.getIntExtra(EXTRA_CONFIRM_IDENTIFY_COUNT, 0)
val enrolLastCount = intent.getIntExtra(EXTRA_ENROL_LAST_COUNT, 0)
val verifyCount = intent.getIntExtra(EXTRA_VERIFY_COUNT, 0)

if (projectId == null || moduleId == null || attendantId == null) {
Simber.i("Missing required extras for generating session events.", tag = TAG)
throw IllegalArgumentException(
"Required extras missing: projectId, moduleId, or attendantId",
)
}

// Todo to be added later
}

private fun extractBundleFromFlatExtras(
intent: Intent,
prefix: String,
): Bundle {
val bundle = Bundle()
intent.extras?.keySet()?.forEach { key ->
if (key.startsWith("$prefix.")) {
val subKey = key.removePrefix("$prefix.")
// Only expecting Int or String values
when (val value = intent.extras?.get(key)) {
is Int -> bundle.putInt(subKey, value)
is String -> bundle.putString(subKey, value)
}
}
}
return bundle
}
}
Loading