Merge pull request #11 from cfogrady/SecretsRepo

Add Secrets Repo Using a Proto DataStore
This commit is contained in:
nacabaro 2025-01-10 19:38:13 +01:00 committed by GitHub
commit da5e6c6628
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 221 additions and 62 deletions

View File

@ -3,6 +3,7 @@ plugins {
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
id("com.google.devtools.ksp") version "2.0.21-1.0.27" id("com.google.devtools.ksp") version "2.0.21-1.0.27"
id("com.google.protobuf")
} }
android { android {
@ -38,6 +39,29 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
} }
lint {
baseline = file("lint-baseline.xml")
}
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:4.27.0"
}
// Generates the java Protobuf-lite code for the Protobufs in this project. See
// https://github.com/google/protobuf-gradle-plugin#customizing-protobuf-compilation
// for more information.
generateProtoTasks {
all().forEach { task ->
task.builtins {
create("java") {
option("lite")
}
}
}
}
} }
dependencies { dependencies {
@ -51,6 +75,7 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.datastore)
implementation(libs.androidx.ui) implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.ui.tooling.preview)
@ -64,5 +89,6 @@ dependencies {
debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.test.manifest)
implementation("androidx.navigation:navigation-compose:2.7.0") implementation("androidx.navigation:navigation-compose:2.7.0")
implementation("com.google.android.material:material:1.2.0") implementation("com.google.android.material:material:1.2.0")
implementation(libs.protobuf.javalite)
implementation("androidx.compose.material:material") implementation("androidx.compose.material:material")
} }

View File

@ -1,7 +1,9 @@
package com.github.nacabaro.vbhelper.di package com.github.nacabaro.vbhelper.di
import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
interface AppContainer { interface AppContainer {
val db: AppDatabase val db: AppDatabase
val dataStoreSecretsRepository: DataStoreSecretsRepository
} }

View File

@ -1,9 +1,22 @@
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.dataStore
import androidx.room.Room import androidx.room.Room
import com.github.nacabaro.vbhelper.database.AppDatabase import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.di.AppContainer import com.github.nacabaro.vbhelper.di.AppContainer
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
import com.github.nacabaro.vbhelper.source.SecretsSerializer
import com.github.nacabaro.vbhelper.source.proto.Secrets
private const val SECRETS_DATA_STORE_NAME = "secrets.pb"
val Context.secretsStore: DataStore<Secrets> by dataStore(
fileName = SECRETS_DATA_STORE_NAME,
serializer = SecretsSerializer
)
class DefaultAppContainer(private val context: Context) : AppContainer { class DefaultAppContainer(private val context: Context) : AppContainer {
override val db: AppDatabase by lazy { override val db: AppDatabase by lazy {
Room.databaseBuilder( Room.databaseBuilder(
context = context, context = context,
@ -11,4 +24,8 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
"internalDb" "internalDb"
).build() ).build()
} }
override val dataStoreSecretsRepository = DataStoreSecretsRepository(context.secretsStore)
} }

View File

@ -1,5 +1,6 @@
package com.github.nacabaro.vbhelper.source package com.github.nacabaro.vbhelper.source
import com.github.nacabaro.vbhelper.source.proto.Secrets
import java.io.InputStream import java.io.InputStream
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
@ -10,7 +11,7 @@ class ApkSecretsImporter(private val dexFileSecretsImporter: SecretsImporter = D
} }
// importSecrets imports the secrets from the apk input stream, and validates them. // importSecrets imports the secrets from the apk input stream, and validates them.
override fun importSecrets(inputStream: InputStream): Map<UShort, Secrets> { override fun importSecrets(inputStream: InputStream): Secrets {
ZipInputStream(inputStream).use { zip -> ZipInputStream(inputStream).use { zip ->
var zipEntry = zip.nextEntry var zipEntry = zip.nextEntry
while(zipEntry != null) { while(zipEntry != null) {

View File

@ -0,0 +1,46 @@
package com.github.nacabaro.vbhelper.source
import androidx.datastore.core.DataStore
import com.github.cfogrady.vbnfc.CryptographicTransformer
import com.github.cfogrady.vbnfc.data.DeviceType
import com.github.nacabaro.vbhelper.source.proto.Secrets
import com.github.nacabaro.vbhelper.source.proto.Secrets.HmacKeys
import kotlinx.coroutines.flow.single
class DataStoreSecretsRepository(
private val secretsDataStore: DataStore<Secrets>,
): SecretsRepository {
override val secretsFlow = secretsDataStore.data
override suspend fun updateSecrets(secrets: Secrets) {
secretsDataStore.updateData {
secrets
}
}
override suspend fun getSecrets(): Secrets {
return secretsFlow.single()
}
}
private fun Secrets.getHmacKeys(deviceTypeId: UShort): HmacKeys {
return when(deviceTypeId) {
DeviceType.VitalBraceletBEDeviceType -> this.beHmacKeys
DeviceType.VitalCharactersDeviceType -> this.vbcHmacKeys
DeviceType.VitalSeriesDeviceType -> this.vbdmHmacKeys
else -> throw IllegalArgumentException("Unknown DeviceTypeId")
}
}
fun Secrets.getCryptographicTransformerMap(): Map<UShort, CryptographicTransformer> {
val cipher = this.vbCipherList.toIntArray()
val beCipher = this.beCipherList.toIntArray()
val vbdmHmacKeys = this.getHmacKeys(DeviceType.VitalSeriesDeviceType)
val vbcHmacKeys = this.getHmacKeys(DeviceType.VitalCharactersDeviceType)
val beHmacKeys = this.getHmacKeys(DeviceType.VitalBraceletBEDeviceType)
return mapOf(
Pair(DeviceType.VitalSeriesDeviceType, CryptographicTransformer(vbdmHmacKeys.hmacKey1, vbdmHmacKeys.hmacKey2, this.aesKey, cipher)),
Pair(DeviceType.VitalCharactersDeviceType, CryptographicTransformer(vbcHmacKeys.hmacKey1, vbcHmacKeys.hmacKey2, this.aesKey, cipher)),
Pair(DeviceType.VitalBraceletBEDeviceType, CryptographicTransformer(beHmacKeys.hmacKey1, beHmacKeys.hmacKey2, this.aesKey, beCipher)),
)
}

View File

@ -1,6 +1,9 @@
package com.github.nacabaro.vbhelper.source package com.github.nacabaro.vbhelper.source
import com.github.cfogrady.vbnfc.CryptographicTransformer
import com.github.cfogrady.vbnfc.data.DeviceType import com.github.cfogrady.vbnfc.data.DeviceType
import com.github.nacabaro.vbhelper.source.proto.Secrets
import com.github.nacabaro.vbhelper.source.proto.Secrets.HmacKeys
import java.io.InputStream import java.io.InputStream
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
@ -28,61 +31,56 @@ class DexFileSecretsImporter: SecretsImporter {
const val VBC_TEST_TAG_PASSWORD = "a71dfb22" const val VBC_TEST_TAG_PASSWORD = "a71dfb22"
} }
override fun importSecrets(inputStream: InputStream): Map<UShort, Secrets> { override fun importSecrets(inputStream: InputStream): Secrets {
val deviceToSecrets = readSecrets(inputStream) val deviceToSecrets = readSecrets(inputStream)
verifySecretCorrectness(deviceToSecrets) verifySecretCorrectness(deviceToSecrets)
return deviceToSecrets return deviceToSecrets
} }
private fun readSecrets(inputStream: InputStream): Map<UShort, Secrets> { private fun readSecrets(inputStream: InputStream): Secrets {
val dexFile = inputStream.readBytes() val dexFile = inputStream.readBytes()
val byteOrder = ByteOrder.BIG_ENDIAN val byteOrder = ByteOrder.BIG_ENDIAN
val vbdmSubstitutionCipher = dexFile.sliceArray(VBDM_SUBSTITUTION_CIPHER_IDX until VBDM_SUBSTITUTION_CIPHER_IDX+(16*4)).toIntArray(byteOrder) val vbdmSubstitutionCipher = dexFile.sliceArray(VBDM_SUBSTITUTION_CIPHER_IDX until VBDM_SUBSTITUTION_CIPHER_IDX+(16*4)).toIntArray(byteOrder)
val beSubstitutionCipher = dexFile.sliceArray(BE_SUBSTITUTION_CIPHER_IDX until BE_SUBSTITUTION_CIPHER_IDX+(16*4)).toIntArray(byteOrder) val beSubstitutionCipher = dexFile.sliceArray(BE_SUBSTITUTION_CIPHER_IDX until BE_SUBSTITUTION_CIPHER_IDX+(16*4)).toIntArray(byteOrder)
val aesKey = dexFile.sliceArray(AES_KEY_IDX until AES_KEY_IDX+24).toString(StandardCharsets.UTF_8) val aesKey = dexFile.sliceArray(AES_KEY_IDX until AES_KEY_IDX+24).toString(StandardCharsets.UTF_8)
val secretsByDevices = mapOf( return Secrets.newBuilder()
Pair(DeviceType.VitalSeriesDeviceType, buildSecrets(dexFile, VBDM_HMAC_KEY_1_IDX, VBDM_HMAC_KEY_2_IDX, aesKey, vbdmSubstitutionCipher)), .setAesKey(aesKey)
Pair(DeviceType.VitalBraceletBEDeviceType, buildSecrets(dexFile, BE_HMAC_KEY_1_IDX, BE_HMAC_KEY_2_IDX, aesKey, beSubstitutionCipher)), .addAllVbCipher(vbdmSubstitutionCipher.toList())
Pair(DeviceType.VitalCharactersDeviceType, buildSecrets(dexFile, VBC_HMAC_KEY_1_IDX, VBC_HMAC_KEY_2_IDX, aesKey, vbdmSubstitutionCipher)), .addAllBeCipher(beSubstitutionCipher.toList())
) .setVbdmHmacKeys(getHmac(dexFile, VBDM_HMAC_KEY_1_IDX, VBDM_HMAC_KEY_2_IDX))
return secretsByDevices .setVbcHmacKeys(getHmac(dexFile, VBC_HMAC_KEY_1_IDX, VBC_HMAC_KEY_2_IDX))
.setBeHmacKeys(getHmac(dexFile, BE_HMAC_KEY_1_IDX, BE_HMAC_KEY_2_IDX))
.build()
} }
private fun buildSecrets(dexFile: ByteArray, hmacKeyIdx1: Int, hmacKeyIdx2: Int, aesKey: String, substitutionCipher: IntArray): Secrets { private fun getHmac(dexFile: ByteArray, hmacKeyIdx1: Int, hmacKeyIdx2: Int): HmacKeys {
val hmacKey1 = dexFile.sliceArray(hmacKeyIdx1 until hmacKeyIdx1+24).toString(StandardCharsets.UTF_8) val hmacKey1 = dexFile.sliceArray(hmacKeyIdx1 until hmacKeyIdx1+24).toString(StandardCharsets.UTF_8)
val hmacKey2 = dexFile.sliceArray(hmacKeyIdx2 until hmacKeyIdx2+24).toString(StandardCharsets.UTF_8) val hmacKey2 = dexFile.sliceArray(hmacKeyIdx2 until hmacKeyIdx2+24).toString(StandardCharsets.UTF_8)
return Secrets(hmacKey1, hmacKey2, aesKey, substitutionCipher) return HmacKeys.newBuilder()
.setHmacKey1(hmacKey1)
.setHmacKey2(hmacKey2)
.build()
} }
@OptIn(ExperimentalStdlibApi::class) @OptIn(ExperimentalStdlibApi::class)
private fun verifySecretCorrectness(deviceToSecrets: Map<UShort, Secrets>) { private fun verifySecretCorrectness(secrets: Secrets) {
for (keyValue in deviceToSecrets) { val deviceToCryptographicTransformers = secrets.getCryptographicTransformerMap()
for (keyValue in deviceToCryptographicTransformers) {
when(keyValue.key) { when(keyValue.key) {
DeviceType.VitalBraceletBEDeviceType -> { DeviceType.VitalBraceletBEDeviceType -> assertBuildsCorrectPassword(keyValue.value, BE_TEST_TAG_PASSWORD)
val result = keyValue.value.toCryptographicTransformer().createNfcPassword( DeviceType.VitalCharactersDeviceType -> assertBuildsCorrectPassword(keyValue.value, VBC_TEST_TAG_PASSWORD)
DeviceType.VitalSeriesDeviceType -> assertBuildsCorrectPassword(keyValue.value, VBDM_TEST_TAG_PASSWORD)
}
}
}
@OptIn(ExperimentalStdlibApi::class)
private fun assertBuildsCorrectPassword(cryptographicTransformer: CryptographicTransformer, expectedPassword: String) {
val result = cryptographicTransformer.createNfcPassword(
TEST_TAG TEST_TAG
) )
if( result.toHexString() != BE_TEST_TAG_PASSWORD) { if( result.toHexString() != expectedPassword) {
throw InvalidKeyException("Secrets were loaded, but were unsuccessful at generating the test password: ${result.toHexString()}") throw InvalidKeyException("Secrets were loaded, but were unsuccessful at generating the test password")
}
}
DeviceType.VitalCharactersDeviceType -> {
val result = keyValue.value.toCryptographicTransformer().createNfcPassword(
TEST_TAG
)
if( result.toHexString() != VBC_TEST_TAG_PASSWORD) {
throw InvalidKeyException("Secrets were loaded, but were unsuccessful at generating the test password: ${result.toHexString()}")
}
}
DeviceType.VitalSeriesDeviceType -> {
val result = keyValue.value.toCryptographicTransformer().createNfcPassword(
TEST_TAG
)
if( result.toHexString() != VBDM_TEST_TAG_PASSWORD) {
throw InvalidKeyException("Secrets were loaded, but were unsuccessful at generating the test password: ${result.toHexString()}")
}
}
}
} }
} }
} }

View File

@ -1,9 +0,0 @@
package com.github.nacabaro.vbhelper.source
import com.github.cfogrady.vbnfc.CryptographicTransformer
data class Secrets(val hmacKey1: String, val hmacKey2: String, val aesKey: String, val substitutionCipher: IntArray) {
fun toCryptographicTransformer(): CryptographicTransformer {
return CryptographicTransformer(hmacKey1, hmacKey2, aesKey, substitutionCipher)
}
}

View File

@ -1,7 +1,8 @@
package com.github.nacabaro.vbhelper.source package com.github.nacabaro.vbhelper.source
import com.github.nacabaro.vbhelper.source.proto.Secrets
import java.io.InputStream import java.io.InputStream
fun interface SecretsImporter { fun interface SecretsImporter {
fun importSecrets(inputStream: InputStream): Map<UShort, Secrets> fun importSecrets(inputStream: InputStream): Secrets
} }

View File

@ -0,0 +1,11 @@
package com.github.nacabaro.vbhelper.source
import com.github.nacabaro.vbhelper.source.proto.Secrets
import kotlinx.coroutines.flow.Flow
interface SecretsRepository {
val secretsFlow: Flow<Secrets>
suspend fun getSecrets(): Secrets
suspend fun updateSecrets(secrets: Secrets)
}

View File

@ -0,0 +1,24 @@
package com.github.nacabaro.vbhelper.source
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.github.nacabaro.vbhelper.source.proto.Secrets
import com.google.protobuf.InvalidProtocolBufferException
import java.io.InputStream
import java.io.OutputStream
object SecretsSerializer: Serializer<Secrets> {
override val defaultValue = Secrets.getDefaultInstance()
override suspend fun readFrom(input: InputStream): Secrets {
try {
return Secrets.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(t: Secrets, output: OutputStream) {
t.writeTo(output)
}
}

View File

@ -0,0 +1,19 @@
syntax = "proto3";
option java_package = "com.github.nacabaro.vbhelper.source.proto";
option java_multiple_files = true;
message Secrets {
string aes_key = 1;
repeated int32 vb_cipher = 2;
repeated int32 be_cipher = 3;
message HmacKeys {
string hmac_key_1 = 1;
string hmac_key_2 = 2;
}
HmacKeys vbdm_hmac_keys = 4;
HmacKeys vbc_hmac_keys = 5;
HmacKeys be_hmac_keys = 6;
}

View File

@ -1,6 +1,6 @@
package com.github.nacabaro.vbhelper.source package com.github.nacabaro.vbhelper.source
import com.github.cfogrady.vbnfc.data.DeviceType import com.github.nacabaro.vbhelper.source.proto.Secrets
import org.junit.Assert import org.junit.Assert
import org.junit.Test import org.junit.Test
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
@ -14,15 +14,18 @@ import java.util.zip.ZipOutputStream
class ApkSecretsImporterTest { class ApkSecretsImporterTest {
@Test @Test
fun testThatRealImportSecretsHasAllDeviceTypes() { fun testThatRealImportSecretsPasses() {
val apkFileSecretsImporter = ApkSecretsImporter() val apkFileSecretsImporter = ApkSecretsImporter()
val url = getAndAssertApkFile() val url = getAndAssertApkFile()
val file = File(url.path) val file = File(url.path)
file.inputStream().use { file.inputStream().use {
val deviceIdToSecrets = apkFileSecretsImporter.importSecrets(it) val secrets = apkFileSecretsImporter.importSecrets(it)
Assert.assertNotNull("BE Device Type", deviceIdToSecrets[DeviceType.VitalBraceletBEDeviceType]) Assert.assertEquals("Cipher size isn't correct", 16, secrets.vbCipherCount)
Assert.assertNotNull("VBDM Device Type", deviceIdToSecrets[DeviceType.VitalSeriesDeviceType]) Assert.assertEquals("BE Cipher size isn't correct", 16, secrets.beCipherCount)
Assert.assertNotNull("VBC Device Type", deviceIdToSecrets[DeviceType.VitalCharactersDeviceType]) Assert.assertFalse("AES Key is empty", secrets.aesKey.isEmpty())
assertHmacKeysArePopulated("VBDM", secrets.vbdmHmacKeys)
assertHmacKeysArePopulated("VBC", secrets.vbcHmacKeys)
assertHmacKeysArePopulated("BE", secrets.beHmacKeys)
} }
} }
@ -34,7 +37,7 @@ class ApkSecretsImporterTest {
val inputStreamContents = it.readAllBytes() val inputStreamContents = it.readAllBytes()
Assert.assertTrue("Unexpected file contents received by DexSecretsImporter", inputStreamContents.contentEquals(expectedDexContents)) Assert.assertTrue("Unexpected file contents received by DexSecretsImporter", inputStreamContents.contentEquals(expectedDexContents))
foundFile = true foundFile = true
emptyMap() Secrets.newBuilder().build()
} }
val apkBytes = constructTestApk(expectedDexContents) val apkBytes = constructTestApk(expectedDexContents)
ByteArrayInputStream(apkBytes).use { ByteArrayInputStream(apkBytes).use {

View File

@ -1,6 +1,6 @@
package com.github.nacabaro.vbhelper.source package com.github.nacabaro.vbhelper.source
import com.github.cfogrady.vbnfc.data.DeviceType import com.github.nacabaro.vbhelper.source.proto.Secrets.HmacKeys
import org.junit.Assert import org.junit.Assert
import org.junit.Test import org.junit.Test
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
@ -10,18 +10,26 @@ import java.nio.ByteOrder
import java.security.InvalidKeyException import java.security.InvalidKeyException
fun assertHmacKeysArePopulated(msg: String, hmacKeys: HmacKeys) {
Assert.assertFalse("$msg hmacKey1 is empty", hmacKeys.hmacKey1.isEmpty())
Assert.assertFalse("$msg hmacKey2 is empty", hmacKeys.hmacKey2.isEmpty())
}
class DexFileSecretsImporterTest { class DexFileSecretsImporterTest {
@Test @Test
fun testThatImportSecretsHasAllDeviceTypes() { fun testThatImportSecretsIsPopulated() {
val dexFileSecretsImporter = DexFileSecretsImporter() val dexFileSecretsImporter = DexFileSecretsImporter()
val url = getAndAssertClassesDexFile() val url = getAndAssertClassesDexFile()
val file = File(url.path) val file = File(url.path)
file.inputStream().use { file.inputStream().use {
val deviceIdToSecrets = dexFileSecretsImporter.importSecrets(it) val secrets = dexFileSecretsImporter.importSecrets(it)
Assert.assertNotNull("BE Device Type", deviceIdToSecrets[DeviceType.VitalBraceletBEDeviceType]) Assert.assertEquals("Cipher size isn't correct", 16, secrets.vbCipherCount)
Assert.assertNotNull("VBDM Device Type", deviceIdToSecrets[DeviceType.VitalSeriesDeviceType]) Assert.assertEquals("BE Cipher size isn't correct", 16, secrets.beCipherCount)
Assert.assertNotNull("VBC Device Type", deviceIdToSecrets[DeviceType.VitalCharactersDeviceType]) Assert.assertFalse("AES Key is empty", secrets.aesKey.isEmpty())
assertHmacKeysArePopulated("VBDM", secrets.vbdmHmacKeys)
assertHmacKeysArePopulated("VBC", secrets.vbcHmacKeys)
assertHmacKeysArePopulated("BE", secrets.beHmacKeys)
} }
} }

View File

@ -4,3 +4,9 @@ plugins {
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
} }
buildscript {
dependencies {
classpath(libs.protobuf.gradle.plugin)
}
}

View File

@ -1,5 +1,6 @@
[versions] [versions]
agp = "8.7.3" agp = "8.7.3"
datastore = "1.1.1"
kotlin = "2.0.0" kotlin = "2.0.0"
coreKtx = "1.15.0" coreKtx = "1.15.0"
junit = "4.13.2" junit = "4.13.2"
@ -8,12 +9,15 @@ espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.8.7" lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3" activityCompose = "1.9.3"
composeBom = "2024.04.01" composeBom = "2024.04.01"
protobufGradlePlugin = "0.9.4"
protobufJavalite = "4.27.0"
roomRuntime = "2.6.1" roomRuntime = "2.6.1"
vbNfcReader = "0.1.0" vbNfcReader = "0.1.0"
dimReader = "2.1.0" dimReader = "2.1.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomRuntime" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomRuntime" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
@ -29,6 +33,8 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
protobuf-gradle-plugin = { module = "com.google.protobuf:protobuf-gradle-plugin", version.ref = "protobufGradlePlugin" }
protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobufJavalite" }
vb-nfc-reader = { module = "com.github.cfogrady:vb-nfc-reader", version.ref = "vbNfcReader" } vb-nfc-reader = { module = "com.github.cfogrady:vb-nfc-reader", version.ref = "vbNfcReader" }
dim-reader = { module = "com.github.cfogrady:vb-dim-reader", version.ref = "dimReader" } dim-reader = { module = "com.github.cfogrady:vb-dim-reader", version.ref = "dimReader" }