diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cc8948c..949fdbf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,6 +9,7 @@ plugins { // New alias(libs.plugins.kotlin.ksp) alias(libs.plugins.dagger.hilt.android) + alias(libs.plugins.google.protobuf) } android { @@ -22,7 +23,7 @@ android { versionCode = 1 versionName = "1.0" - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "com.debatetimer.app.HiltTestRunner" } buildTypes { @@ -56,6 +57,23 @@ kotlin { } } + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:4.31.1" + } + + generateProtoTasks { + all().configureEach { + builtins { + maybeCreate("java").apply { + option("lite") // Use lite runtime for Android + } + } + } + } +} + dependencies { // Default implementation(libs.androidx.core.ktx) @@ -83,5 +101,9 @@ dependencies { implementation(libs.squareup.retrofit2) implementation(libs.squareup.retrofit2.converter.gson) implementation(libs.google.gson) + implementation(libs.protobuf.kotlin.lite) + implementation(libs.datastore.core) + implementation(libs.datastore.preferences) + androidTestImplementation(libs.dagger.hilt.android.testing) lintChecks(libs.slack.lint) } \ No newline at end of file diff --git a/app/src/androidTest/java/com/debatetimer/app/HiltTestRunner.kt b/app/src/androidTest/java/com/debatetimer/app/HiltTestRunner.kt new file mode 100644 index 0000000..bd16331 --- /dev/null +++ b/app/src/androidTest/java/com/debatetimer/app/HiltTestRunner.kt @@ -0,0 +1,20 @@ +package com.debatetimer.app + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +// 안드로이드의 기본 테스트 러너를 상속받는 커스텀 러너 +class HiltTestRunner : AndroidJUnitRunner() { + + // 테스트 프로세스를 위한 새로운 Application 객체를 만드는 함수를 오버라이드 + override fun newApplication( + cl: ClassLoader?, + className: String?, + context: Context? + ): Application { + // 기본 Application 클래스 대신, Hilt 테스트 전용 Application 클래스를 사용하도록 강제 + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/debatetimer/app/data/repo/TokenRepoImplHiltTest.kt b/app/src/androidTest/java/com/debatetimer/app/data/repo/TokenRepoImplHiltTest.kt new file mode 100644 index 0000000..8dcc7b5 --- /dev/null +++ b/app/src/androidTest/java/com/debatetimer/app/data/repo/TokenRepoImplHiltTest.kt @@ -0,0 +1,47 @@ +package com.debatetimer.app.data.repo + +import com.debatetimer.app.data.model.TokenBundle +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.runBlocking +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class TokenRepoImplHiltTest { + + @get:Rule + var hiltRule = HiltAndroidRule(this) + + // Hilt가 FakeDataModule을 기반으로 의존성을 주입해줍니다. + // 이 tokenRepo는 내부에 FakeCryptoManagerImpl을 가지고 있습니다. + @Inject + lateinit var tokenRepo: TokenRepo + + @Before + fun setUp() { + hiltRule.inject() + } + + @Test + fun saveAndGetTokens_withFakeCryptoManager_returnsCorrectBundle() = runBlocking { + // Arrange (준비) + val originalBundle = TokenBundle( + accessToken = "access_token_hilt_test", + refreshToken = "refresh_token_hilt_test" + ) + + // Act (실행) + tokenRepo.saveTokens(originalBundle) + val result = tokenRepo.getTokens() + + // Assert (검증) + assertThat(result.isSuccess, equalTo(true)) + val retrievedBundle = result.getOrNull() + assertThat(retrievedBundle, equalTo(originalBundle)) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/debatetimer/app/di/FakeDataModule.kt b/app/src/androidTest/java/com/debatetimer/app/di/FakeDataModule.kt new file mode 100644 index 0000000..9c574a1 --- /dev/null +++ b/app/src/androidTest/java/com/debatetimer/app/di/FakeDataModule.kt @@ -0,0 +1,33 @@ +package com.debatetimer.app.di + +import com.debatetimer.app.data.repo.TokenRepo +import com.debatetimer.app.data.repo.TokenRepoImpl +import com.debatetimer.app.util.crypto.CryptoManager +import com.debatetimer.app.util.crypto.FakeCryptoManagerImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DataModule::class] +) +abstract class FakeDataModule { + // 2. CryptoManager 요청 시 FakeCryptoManagerImpl을 주입하도록 바인딩합니다. + @Binds + @Singleton + abstract fun bindCryptoManager( + fakeCryptoManagerImpl: FakeCryptoManagerImpl + ): CryptoManager + + // 3. TokenRepo는 기존과 동일하게 TokenRepoImpl을 주입하도록 바인딩합니다. + // (DataModule을 통째로 비활성화했으므로, 필요한 다른 바인딩도 여기에 다시 선언해줘야 합니다.) + @Binds + @Singleton + abstract fun bindTokenRepo( + tokenRepoImpl: TokenRepoImpl + ): TokenRepo +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/debatetimer/app/util/crypto/CryptoManagerImplTest.kt b/app/src/androidTest/java/com/debatetimer/app/util/crypto/CryptoManagerImplTest.kt new file mode 100644 index 0000000..0b9e1dc --- /dev/null +++ b/app/src/androidTest/java/com/debatetimer/app/util/crypto/CryptoManagerImplTest.kt @@ -0,0 +1,35 @@ +package com.debatetimer.app.util.crypto + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +// 안드로이드 환경에서 JUnit4 테스트를 실행하기 위한 Runner +@RunWith(AndroidJUnit4::class) +class CryptoManagerImplTest { + private lateinit var cryptoManager: CryptoManagerImpl + + // @Before: 각 테스트(@Test)가 실행되기 직전에 항상 먼저 실행되는 함수 + @Before + fun setUp() { + // Hilt를 사용하지 않고, 테스트 대상 클래스를 직접 생성합니다. + cryptoManager = CryptoManagerImpl() + } + + // @Test: 이 함수가 테스트 케이스임을 나타냅니다. + @Test + fun encryptionAndDecryption_roundTrip_returnsOriginalText() { + // Arrange (준비): 테스트에 필요한 변수와 상태를 설정합니다. + val originalText = "This is a secret message for a round-trip test." + + // Act (실행): 테스트하려는 실제 동작을 수행합니다. + val (ciphertext, iv) = cryptoManager.encrypt(originalText) + val decryptedText = cryptoManager.decrypt(ciphertext, iv) + + // Assert (검증): 실행 결과가 예상과 일치하는지 확인합니다. + assertThat(decryptedText, equalTo(originalText)) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/debatetimer/app/util/crypto/FakeCryptoManagerImpl.kt b/app/src/androidTest/java/com/debatetimer/app/util/crypto/FakeCryptoManagerImpl.kt new file mode 100644 index 0000000..c1da671 --- /dev/null +++ b/app/src/androidTest/java/com/debatetimer/app/util/crypto/FakeCryptoManagerImpl.kt @@ -0,0 +1,18 @@ +package com.debatetimer.app.util.crypto + +import javax.inject.Inject + +class FakeCryptoManagerImpl @Inject constructor() : CryptoManager { + private val dummyIv = "dummy_iv".toByteArray() + private val suffix = "_encrypted" + + override fun encrypt(plainText: String): Pair { + val fakeCiphertext = (plainText + suffix).toByteArray(Charsets.UTF_8) + return Pair(fakeCiphertext, dummyIv) + } + + override fun decrypt(encryptedText: ByteArray, iv: ByteArray): String { + val fakePlaintext = String(encryptedText, Charsets.UTF_8) + return fakePlaintext.removeSuffix(suffix) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/debatetimer/app/data/model/TokenBundle.kt b/app/src/main/java/com/debatetimer/app/data/model/TokenBundle.kt new file mode 100644 index 0000000..997e80e --- /dev/null +++ b/app/src/main/java/com/debatetimer/app/data/model/TokenBundle.kt @@ -0,0 +1,13 @@ +package com.debatetimer.app.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class TokenBundle( + val accessToken: String, + val refreshToken: String, +) { + override fun toString(): String { + return "TokenBundle(accessToken='***', refreshToken='***')" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/debatetimer/app/data/repo/TokenRepo.kt b/app/src/main/java/com/debatetimer/app/data/repo/TokenRepo.kt new file mode 100644 index 0000000..3cc2879 --- /dev/null +++ b/app/src/main/java/com/debatetimer/app/data/repo/TokenRepo.kt @@ -0,0 +1,8 @@ +package com.debatetimer.app.data.repo + +import com.debatetimer.app.data.model.TokenBundle + +interface TokenRepo { + suspend fun getTokens(): Result + suspend fun saveTokens(bundle: TokenBundle): Result +} \ No newline at end of file diff --git a/app/src/main/java/com/debatetimer/app/data/repo/TokenRepoImpl.kt b/app/src/main/java/com/debatetimer/app/data/repo/TokenRepoImpl.kt new file mode 100644 index 0000000..e7679ae --- /dev/null +++ b/app/src/main/java/com/debatetimer/app/data/repo/TokenRepoImpl.kt @@ -0,0 +1,79 @@ +package com.debatetimer.app.data.repo + +import android.content.Context +import androidx.datastore.core.DataStore +import com.debatetimer.app.EncryptedTokens +import com.debatetimer.app.data.model.TokenBundle +import com.debatetimer.app.data.serializer.tokenDataStore +import com.debatetimer.app.util.crypto.CryptoManager +import com.google.gson.Gson +import com.google.gson.JsonSyntaxException +import com.google.protobuf.kotlin.toByteString +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +class TokenRepoImpl @Inject constructor( + @ApplicationContext context: Context, + private val cryptoManager: CryptoManager +) : + TokenRepo { + companion object { + private val gson = Gson().newBuilder() + .disableHtmlEscaping() + .create() + } + + private val tokenDataStore: DataStore = context.tokenDataStore + + override suspend fun getTokens(): Result { + try { + val encryptedTokens = tokenDataStore.data.first() + + if (encryptedTokens.encryptedTokenBundle.isEmpty || encryptedTokens.initializationVector.isEmpty) { + return Result.success(null) + } + + val ciphertext = encryptedTokens.encryptedTokenBundle.toByteArray() + val iv = encryptedTokens.initializationVector.toByteArray() + val decryptedTokens = cryptoManager.decrypt(ciphertext, iv) + + val bundle = gson.fromJson(decryptedTokens, TokenBundle::class.java) + + return if (bundle != null) { + Result.success(bundle) + } else { + Result.failure(NoSuchElementException()) + } + } catch (e: JsonSyntaxException) { + return Result.failure(IllegalStateException("Corrupted token data", e)) + } catch (e: Exception) { + return Result.failure(e) + } + } + + override suspend fun saveTokens(bundle: TokenBundle): Result { + try { + require(bundle.accessToken.isNotBlank()) { "Access token cannot be blank" } + require(bundle.refreshToken.isNotBlank()) { "Refresh token cannot be blank" } + + tokenDataStore.updateData { current -> + val serializedBundle = try { + gson.toJson(bundle) + } catch (e: Exception) { + throw IllegalStateException("Failed to serialize token bundle", e) + } + val encryptionOutput = cryptoManager.encrypt(serializedBundle) + + current.toBuilder() + .setEncryptedTokenBundle(encryptionOutput.first.toByteString()) + .setInitializationVector(encryptionOutput.second.toByteString()) + .build() + } + + return Result.success(Unit) + } catch (e: Exception) { + return Result.failure(e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/debatetimer/app/data/serializer/TokenSerializer.kt b/app/src/main/java/com/debatetimer/app/data/serializer/TokenSerializer.kt new file mode 100644 index 0000000..403a3c8 --- /dev/null +++ b/app/src/main/java/com/debatetimer/app/data/serializer/TokenSerializer.kt @@ -0,0 +1,34 @@ +package com.debatetimer.app.data.serializer + +import android.content.Context +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.DataStore +import androidx.datastore.core.Serializer +import androidx.datastore.dataStore +import com.debatetimer.app.EncryptedTokens +import com.google.protobuf.InvalidProtocolBufferException +import java.io.InputStream +import java.io.OutputStream + +const val FILE_NAME = "encrypted_tokens.pb" + +object TokenSerializer : Serializer { + override val defaultValue: EncryptedTokens = EncryptedTokens.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): EncryptedTokens { + try { + return EncryptedTokens.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto.", exception) + } + } + + override suspend fun writeTo(t: EncryptedTokens, output: OutputStream) { + t.writeTo(output) + } +} + +val Context.tokenDataStore: DataStore by dataStore( + fileName = FILE_NAME, + serializer = TokenSerializer +) \ No newline at end of file diff --git a/app/src/main/java/com/debatetimer/app/di/DataModule.kt b/app/src/main/java/com/debatetimer/app/di/DataModule.kt new file mode 100644 index 0000000..fc01e97 --- /dev/null +++ b/app/src/main/java/com/debatetimer/app/di/DataModule.kt @@ -0,0 +1,27 @@ +package com.debatetimer.app.di + +import com.debatetimer.app.data.repo.TokenRepo +import com.debatetimer.app.data.repo.TokenRepoImpl +import com.debatetimer.app.util.crypto.CryptoManager +import com.debatetimer.app.util.crypto.CryptoManagerImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class DataModule { + @Binds + @Singleton + abstract fun bindCryptoManager( + cryptoManagerImpl: CryptoManagerImpl + ): CryptoManager + + @Binds + @Singleton + abstract fun bindTokenRepo( + tokenRepoImpl: TokenRepoImpl + ): TokenRepo +} \ No newline at end of file diff --git a/app/src/main/java/com/debatetimer/app/structure.txt b/app/src/main/java/com/debatetimer/app/structure.txt new file mode 100644 index 0000000..6f3648c --- /dev/null +++ b/app/src/main/java/com/debatetimer/app/structure.txt @@ -0,0 +1,40 @@ +└── app/ + └── src/ + └── main/ + └── java/ + └── com/yourcompany/yourapp/ + ├── data/ + │ ├── remote/ + │ │ ├── api/ + │ │ │ └── YourApiService.kt // Retrofit interface for API endpoints + │ │ ├── client/ + │ │ │ └── YourApiClient.kt // Custom Retrofit client and setup + │ │ └── model/ + │ │ │ └── LoginResponse.kt // API-specific data classes (e.g., JSON response) + │ └── repository/ + │ │ └── YourRepository.kt // Abstracts data sources (API, local cache) + │ │ └── YourRepositoryImpl.kt // Implementation of the repository + │ └── model/ + │ │ └── User.kt // Domain-specific data classes for the app + │ + ├── domain/ + │ └── usecase/ + │ └── LoginUseCase.kt // Optional: for complex business logic + │ + ├── common/ + │ └── UiState.kt // The sealed class you provided + │ + ├── ui/ + │ ├── login/ + │ │ ├── LoginScreen.kt // The Composable for the login page + │ │ ├── LoginViewModel.kt // ViewModel for the login page + │ │ └── component/ + │ │ │ └── GoogleLoginButton.kt // UI components specific to the login page + │ └── home/ + │ ├── HomeScreen.kt + │ └── HomeViewModel.kt + │ + └── di/ + ├── AppModule.kt // Hilt module for app-level dependencies + ├── DataModule.kt // Hilt module for data layer dependencies + └── ViewModelModule.kt // Hilt module for ViewModel dependencies \ No newline at end of file diff --git a/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManager.kt b/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManager.kt new file mode 100644 index 0000000..041b556 --- /dev/null +++ b/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManager.kt @@ -0,0 +1,21 @@ +package com.debatetimer.app.util.crypto + +/** + * 토큰의 암호화 및 복호화를 담당하는 인터페이스 + */ +interface CryptoManager { + /** + * 평문을 암호화합니다. + * @param plainText 암호화 할 평문 + * @return 암호화된 암호문와 IV의 Pair + */ + fun encrypt(plainText: String): Pair + + /** + * 암호문을 평문으로 복호화합니다. + * @param encryptedText 복호화 할 암호문 + * @param iv IV + * @return 복호화된 평문 + */ + fun decrypt(encryptedText: ByteArray, iv: ByteArray): String +} \ No newline at end of file diff --git a/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManagerImpl.kt b/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManagerImpl.kt new file mode 100644 index 0000000..4e6271f --- /dev/null +++ b/app/src/main/java/com/debatetimer/app/util/crypto/CryptoManagerImpl.kt @@ -0,0 +1,97 @@ +package com.debatetimer.app.util.crypto + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.inject.Inject + +class CryptoManagerImpl @Inject constructor() : CryptoManager { + companion object { + // Define the constants for the encryption algorithm and key alias + private const val KEY_ALIAS = "debate_timer_oauth_key" + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES + private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM + private const val PADDING = KeyProperties.ENCRYPTION_PADDING_NONE + private const val KEY_SIZE = 256 + private const val TRANSFORMATION = "AES/GCM/NoPadding" + } + + // Keystore instance + private val keyStore by lazy { + try { + KeyStore.getInstance(ANDROID_KEYSTORE).apply { + load(null) + } + } catch (e: Exception) { + throw IllegalStateException("Failed to load Android KeyStore", e) + } + } + + /** + * Android Keystore에 저장된 키를 가져오거나, 없다면 새로 생성합니다. + */ + private fun getOrCreateKey(): SecretKey { + // 1. Try to get the key + val existingKey = keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry + + // 2. If key exists, return it + if (existingKey != null) { + return existingKey.secretKey + } + + // 3. Else, prepare key generation spec + val paramsBuilder = KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + paramsBuilder.apply { + setBlockModes(BLOCK_MODE) + setEncryptionPaddings(PADDING) + setKeySize(KEY_SIZE) + } + + // 4. Generate key with the spec + val keyGenerator = KeyGenerator.getInstance(ALGORITHM, ANDROID_KEYSTORE) + keyGenerator.init(paramsBuilder.build()) + return keyGenerator.generateKey() + } + + override fun encrypt(plainText: String): Pair { + // 0. Check whether plainText is not empty + require(plainText.isNotEmpty()) { "Plain text cannot be empty" } + + // 1. Load cipher instance + val cipher = Cipher.getInstance(TRANSFORMATION) + + // 2. Init cipher with encryption mode + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()) + + // 3. Return encrypted text and IV + return Pair(cipher.doFinal(plainText.toByteArray(charset = Charsets.UTF_8)), cipher.iv) + } + + override fun decrypt(encryptedText: ByteArray, iv: ByteArray): String { + require(encryptedText.isNotEmpty()) { "Encrypted text cannot be empty" } + require(iv.isNotEmpty()) { "IV cannot be empty" } + require(iv.size == 12) { "IV must be 12 bytes" } + + try { + // 1. Load cipher instance and prepare variables + val cipher = Cipher.getInstance(TRANSFORMATION) + val spec = GCMParameterSpec(128, iv) + + // 2. Init cipher with decryption mode + cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec) + + // 3. Return decrypted text + return String(cipher.doFinal(encryptedText), Charsets.UTF_8) + } catch (e: Exception) { + throw IllegalStateException("Failed to decrypt data", e) + } + } +} \ No newline at end of file diff --git a/app/src/main/proto/encrypted_tokens.proto b/app/src/main/proto/encrypted_tokens.proto new file mode 100644 index 0000000..22ea148 --- /dev/null +++ b/app/src/main/proto/encrypted_tokens.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package com.debatetimer.app.data; + +option java_package = "com.debatetimer.app"; +option java_multiple_files = true; + +message EncryptedTokens { + bytes encrypted_token_bundle = 1; + bytes initialization_vector = 2; +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 7177efd..b217889 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,4 +8,5 @@ plugins { // New alias(libs.plugins.kotlin.ksp) apply false alias(libs.plugins.dagger.hilt.android) apply false + alias(libs.plugins.google.protobuf) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1f60695..48c3f74 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,9 @@ androidxHilt = "1.2.0" retrofit2 = "3.0.0" gson = "2.13.1" lint = "1.4.2" +protobufKotlin = "4.31.1" +protobuf = "0.9.5" +datastore = "1.1.7" [libraries] # Default @@ -42,10 +45,14 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHilt" } dagger-hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } dagger-hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +dagger-hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } squareup-retrofit2 = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit2" } squareup-retrofit2-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit2" } google-gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } slack-lint = { group = "com.slack.lint.compose", name = "compose-lint-checks", version.ref = "lint" } +protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobufKotlin" } +datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastore" } +datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } [plugins] # Default @@ -55,4 +62,5 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko # New kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } dagger-hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +google-protobuf = { id = "com.google.protobuf", version.ref = "protobuf" }