Implement Secrets Repository

Add proto plugin and dependencies
Create Secrets Proto
Create Secrets Proto DataStore
Replace old secrets with proto secrets
Fix importers and tests to use new proto secrets.
This commit is contained in:
Christopher O'Grady 2025-01-09 15:24:10 -05:00
parent 9871f0420f
commit c456d455ef
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,63 +31,58 @@ 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)
TEST_TAG DeviceType.VitalSeriesDeviceType -> assertBuildsCorrectPassword(keyValue.value, VBDM_TEST_TAG_PASSWORD)
)
if( result.toHexString() != BE_TEST_TAG_PASSWORD) {
throw InvalidKeyException("Secrets were loaded, but were unsuccessful at generating the test password: ${result.toHexString()}")
}
}
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()}")
}
}
} }
} }
} }
@OptIn(ExperimentalStdlibApi::class)
private fun assertBuildsCorrectPassword(cryptographicTransformer: CryptographicTransformer, expectedPassword: String) {
val result = cryptographicTransformer.createNfcPassword(
TEST_TAG
)
if( result.toHexString() != expectedPassword) {
throw InvalidKeyException("Secrets were loaded, but were unsuccessful at generating the test password")
}
}
} }
fun ByteArray.toIntArray(byteOrder: ByteOrder): IntArray { fun ByteArray.toIntArray(byteOrder: ByteOrder): IntArray {

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" }