diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b7850f0..93ee649 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,7 +23,7 @@ android { versionCode = 1 versionName = "1.0" - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "kr.ksw.visitkorea.HiltTestRunner" vectorDrawables { useSupportLibrary = true } @@ -74,11 +74,6 @@ dependencies { implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) - // hilt - implementation(libs.hilt.android) - ksp(libs.hilt.compiler) - ksp(libs.androidx.hilt.compiler) - // room implementation(libs.androidx.room.ktx) ksp(libs.androidx.room.compiler) @@ -88,6 +83,17 @@ dependencies { implementation(libs.retrofit.converter.gson) implementation(libs.okhttp) + // hilt + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + ksp(libs.androidx.hilt.compiler) + androidTestImplementation(libs.androidx.core.testing) + + // workmanager + implementation(libs.androidx.hilt.work) + implementation(libs.androidx.work) + + // test testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) @@ -100,4 +106,5 @@ dependencies { androidTestImplementation(libs.hilt.android.testing) kspAndroidTest(libs.hilt.compiler) + kspAndroidTest(libs.androidx.hilt.compiler) } \ No newline at end of file diff --git a/app/src/androidTest/java/kr/ksw/visitkorea/HiltTestRunner.kt b/app/src/androidTest/java/kr/ksw/visitkorea/HiltTestRunner.kt new file mode 100644 index 0000000..7117502 --- /dev/null +++ b/app/src/androidTest/java/kr/ksw/visitkorea/HiltTestRunner.kt @@ -0,0 +1,16 @@ +package kr.ksw.visitkorea + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +class HiltTestRunner: AndroidJUnitRunner() { + override fun newApplication( + cl: ClassLoader?, + className: String?, + context: Context? + ): Application { + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/kr/ksw/visitkorea/data/local/dao/AreaCodeDaoTest.kt b/app/src/androidTest/java/kr/ksw/visitkorea/data/local/dao/AreaCodeDaoTest.kt new file mode 100644 index 0000000..e656f17 --- /dev/null +++ b/app/src/androidTest/java/kr/ksw/visitkorea/data/local/dao/AreaCodeDaoTest.kt @@ -0,0 +1,83 @@ +package kr.ksw.visitkorea.data.local.dao + +import androidx.test.filters.SmallTest +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.test.runTest +import kr.ksw.visitkorea.data.local.databases.AreaCodeDatabase +import kr.ksw.visitkorea.data.local.entity.AreaCodeEntity +import kr.ksw.visitkorea.data.local.entity.SigunguCodeEntity +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +@SmallTest +class AreaCodeDaoTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var areaCodeDatabase: AreaCodeDatabase + + private lateinit var dao: AreaCodeDao + + @Before + fun setUp() { + hiltRule.inject() + dao = areaCodeDatabase.areaCodeDao + } + + @After + fun tearDown() { + areaCodeDatabase.close() + } + + @Test + fun getAllAreaCodeEntities_areaCodeListIsEmpty() = runTest { + assert(dao.getAllAreaCodeEntities().isEmpty()) + } + + @Test + fun upsertAreaCodeEntity_areaCodeIsUpserted() = runTest { + val areaCodeEntity = AreaCodeEntity( + code = "1", name = "서울" + ) + dao.upsertAreaCodeEntity(areaCodeEntity) + assert(dao.getAllAreaCodeEntities().isNotEmpty()) + } + + @Test + fun upsertSigunguCodeEntity_sigunguCodeIsUpserted() = runTest { + val sigunguCodeEntity = SigunguCodeEntity( + areaCode = "1", code = "1", name = "강남구" + ) + dao.upsertSigunguCodeEntity(sigunguCodeEntity) + assert(dao.getSigunguCodeByAreaCode("1").isNotEmpty()) + } + + @Test + fun `getSigunguCodeByAreaCode_sigunguCodeListByAreaCode1`() = runTest { + for(i in 1..4) { + dao.upsertSigunguCodeEntity(SigunguCodeEntity( + id = i, + areaCode = "1", + code = "$i", + name = "시군구 $i" + )) + } + dao.upsertSigunguCodeEntity(SigunguCodeEntity( + id = 5, + areaCode = "2", + code = "1", + name = "가평군" + )) + + val sigunguCodeEntities = dao.getSigunguCodeByAreaCode("1") + assert(sigunguCodeEntities.isNotEmpty()) + assert(sigunguCodeEntities.size == 4) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/kr/ksw/visitkorea/data/repository/FakeAndroidAreaCodeRepository.kt b/app/src/androidTest/java/kr/ksw/visitkorea/data/repository/FakeAndroidAreaCodeRepository.kt new file mode 100644 index 0000000..5f25e2b --- /dev/null +++ b/app/src/androidTest/java/kr/ksw/visitkorea/data/repository/FakeAndroidAreaCodeRepository.kt @@ -0,0 +1,82 @@ +package kr.ksw.visitkorea.data.repository + +import kr.ksw.visitkorea.data.mapper.toItems +import kr.ksw.visitkorea.data.remote.dto.AreaCodeDTO +import kr.ksw.visitkorea.data.remote.model.ApiResponse +import kr.ksw.visitkorea.data.remote.model.CommonBody +import kr.ksw.visitkorea.data.remote.model.CommonHeader +import kr.ksw.visitkorea.data.remote.model.CommonItem +import kr.ksw.visitkorea.data.remote.model.CommonResponse + +class FakeAndroidAreaCodeRepository : AreaCodeRepository { + private var areaCodeResponse = ApiResponse( + CommonResponse( + CommonHeader("0000", "OK"), + CommonBody( + 10, + 1, + 17, + CommonItem(listOf( + AreaCodeDTO("1","서울"), + AreaCodeDTO("2","인천"), + AreaCodeDTO("3","대전"), + AreaCodeDTO("4","대구"), + AreaCodeDTO("5","광주"), + AreaCodeDTO("6","부산"), + AreaCodeDTO("7","울산"), + AreaCodeDTO("8","세종특별자치시"), + AreaCodeDTO("31","경기도"), + AreaCodeDTO("32","강원특별자치도") + )) + ) + ) + ) + + private var sigunguCodeResponse1 = ApiResponse( + CommonResponse( + CommonHeader("0000", "OK"), + CommonBody( + 5, + 1, + 25, + CommonItem(listOf( + AreaCodeDTO("1","강남구"), + AreaCodeDTO("2","강동구"), + AreaCodeDTO("3","강북구"), + AreaCodeDTO("4","강서구"), + AreaCodeDTO("5","관악구") + )) + ) + ) + ) + private var sigunguCodeResponse2 = ApiResponse( + CommonResponse( + CommonHeader("0000", "OK"), + CommonBody( + 5, + 1, + 31, + CommonItem(listOf( + AreaCodeDTO("1","가평군"), + AreaCodeDTO("2","고양시"), + AreaCodeDTO("3","과천시"), + AreaCodeDTO("4","광명시"), + AreaCodeDTO("5","광주시") + )) + ) + ) + ) + + private var sigunguRequest = mapOf( + "1" to sigunguCodeResponse1, + "31" to sigunguCodeResponse2 + ) + + override suspend fun getAreaCode(): Result> = runCatching { + areaCodeResponse.toItems() + } + + override suspend fun getSigunguCode(areaCode: String): Result> = runCatching { + sigunguRequest[areaCode]!!.toItems() + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/kr/ksw/visitkorea/di/TestAppModule.kt b/app/src/androidTest/java/kr/ksw/visitkorea/di/TestAppModule.kt new file mode 100644 index 0000000..911e4b6 --- /dev/null +++ b/app/src/androidTest/java/kr/ksw/visitkorea/di/TestAppModule.kt @@ -0,0 +1,38 @@ +package kr.ksw.visitkorea.di + +import android.app.Application +import androidx.room.Room +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import kr.ksw.visitkorea.data.di.DataModule +import kr.ksw.visitkorea.data.local.databases.AreaCodeDatabase +import kr.ksw.visitkorea.data.repository.AreaCodeRepository +import kr.ksw.visitkorea.data.repository.FakeAndroidAreaCodeRepository +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DataModule::class] +) +object TestAppModule { + + @Provides + @Singleton + fun provideAreaCodeDatabase(application: Application): AreaCodeDatabase { + return Room.inMemoryDatabaseBuilder( + application, + AreaCodeDatabase::class.java + ).build() + } + + @Provides + @Singleton + fun provideAreaCodeRepository(): AreaCodeRepository { + return FakeAndroidAreaCodeRepository() + } + +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 93d89f5..f32747c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,6 +16,18 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.VisitKorea"> + + + + + + + @Query("SELECT * FROM sigungu_code WHERE areaCode = :areaCode") + suspend fun getSigunguCodeByAreaCode(areaCode: String): List +} \ No newline at end of file diff --git a/app/src/main/java/kr/ksw/visitkorea/data/local/databases/AreaCodeDatabase.kt b/app/src/main/java/kr/ksw/visitkorea/data/local/databases/AreaCodeDatabase.kt new file mode 100644 index 0000000..495a7c3 --- /dev/null +++ b/app/src/main/java/kr/ksw/visitkorea/data/local/databases/AreaCodeDatabase.kt @@ -0,0 +1,15 @@ +package kr.ksw.visitkorea.data.local.databases + +import androidx.room.Database +import androidx.room.RoomDatabase +import kr.ksw.visitkorea.data.local.dao.AreaCodeDao +import kr.ksw.visitkorea.data.local.entity.AreaCodeEntity +import kr.ksw.visitkorea.data.local.entity.SigunguCodeEntity + +@Database( + entities = [AreaCodeEntity::class, SigunguCodeEntity::class], + version = 1 +) +abstract class AreaCodeDatabase: RoomDatabase() { + abstract val areaCodeDao: AreaCodeDao +} \ No newline at end of file diff --git a/app/src/main/java/kr/ksw/visitkorea/data/local/entity/AreaCodeEntity.kt b/app/src/main/java/kr/ksw/visitkorea/data/local/entity/AreaCodeEntity.kt new file mode 100644 index 0000000..e616a73 --- /dev/null +++ b/app/src/main/java/kr/ksw/visitkorea/data/local/entity/AreaCodeEntity.kt @@ -0,0 +1,12 @@ +package kr.ksw.visitkorea.data.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity("area_code") +data class AreaCodeEntity( + @PrimaryKey(autoGenerate = true) + val id: Int? = null, + val code: String, + val name: String, +) \ No newline at end of file diff --git a/app/src/main/java/kr/ksw/visitkorea/data/local/entity/SigunguCodeEntity.kt b/app/src/main/java/kr/ksw/visitkorea/data/local/entity/SigunguCodeEntity.kt new file mode 100644 index 0000000..ed76965 --- /dev/null +++ b/app/src/main/java/kr/ksw/visitkorea/data/local/entity/SigunguCodeEntity.kt @@ -0,0 +1,13 @@ +package kr.ksw.visitkorea.data.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity("sigungu_code") +data class SigunguCodeEntity( + @PrimaryKey(autoGenerate = true) + val id: Int? = null, + val areaCode: String, + val code: String, + val name: String, +) \ No newline at end of file diff --git a/app/src/main/java/kr/ksw/visitkorea/data/workmanager/AreaCodeWorker.kt b/app/src/main/java/kr/ksw/visitkorea/data/workmanager/AreaCodeWorker.kt new file mode 100644 index 0000000..24a5955 --- /dev/null +++ b/app/src/main/java/kr/ksw/visitkorea/data/workmanager/AreaCodeWorker.kt @@ -0,0 +1,47 @@ +package kr.ksw.visitkorea.data.workmanager + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kr.ksw.visitkorea.data.local.databases.AreaCodeDatabase +import kr.ksw.visitkorea.data.local.entity.AreaCodeEntity +import kr.ksw.visitkorea.data.local.entity.SigunguCodeEntity +import kr.ksw.visitkorea.data.repository.AreaCodeRepository + +@HiltWorker +class AreaCodeWorker @AssistedInject constructor( + @Assisted private val appContext: Context, + @Assisted private val params: WorkerParameters, + private val areaCodeRepository: AreaCodeRepository, + private val areaCodeDatabase: AreaCodeDatabase +): CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + val areaCodeItems = areaCodeRepository.getAreaCode().getOrNull() ?: return Result.failure() + areaCodeItems.forEach { areaCodeItem -> + withContext(Dispatchers.IO) { + areaCodeDatabase.areaCodeDao.upsertAreaCodeEntity( + AreaCodeEntity( + code = areaCodeItem.code, + name = areaCodeItem.name + ) + ) + val sigunguItems = areaCodeRepository.getSigunguCode(areaCodeItem.code).getOrNull() + sigunguItems?.forEach { sigunguItem -> + areaCodeDatabase.areaCodeDao.upsertSigunguCodeEntity(SigunguCodeEntity( + areaCode = areaCodeItem.code, + code = sigunguItem.code, + name = sigunguItem.name + )) + } + } + } + return Result.success() + } + +} \ No newline at end of file diff --git a/app/src/main/java/kr/ksw/visitkorea/presentation/splash/SplashActivity.kt b/app/src/main/java/kr/ksw/visitkorea/presentation/splash/SplashActivity.kt index 3b03d47..660eac6 100644 --- a/app/src/main/java/kr/ksw/visitkorea/presentation/splash/SplashActivity.kt +++ b/app/src/main/java/kr/ksw/visitkorea/presentation/splash/SplashActivity.kt @@ -47,6 +47,7 @@ class SplashActivity : ComponentActivity() { } observeSideEffect() viewModel.checkPermission(this) + viewModel.initAreaCode(applicationContext) } private fun observeSideEffect() { diff --git a/app/src/main/java/kr/ksw/visitkorea/presentation/splash/SplashViewModel.kt b/app/src/main/java/kr/ksw/visitkorea/presentation/splash/SplashViewModel.kt index cb2cb58..afd3e7e 100644 --- a/app/src/main/java/kr/ksw/visitkorea/presentation/splash/SplashViewModel.kt +++ b/app/src/main/java/kr/ksw/visitkorea/presentation/splash/SplashViewModel.kt @@ -1,6 +1,7 @@ package kr.ksw.visitkorea.presentation.splash import android.Manifest +import android.content.Context import android.content.pm.PackageManager import android.util.Log import androidx.activity.ComponentActivity @@ -8,15 +9,23 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch +import kr.ksw.visitkorea.data.local.databases.AreaCodeDatabase +import kr.ksw.visitkorea.data.workmanager.AreaCodeWorker import javax.inject.Inject @HiltViewModel -class SplashViewModel @Inject constructor(): ViewModel() { +class SplashViewModel @Inject constructor( + private val areaCodeDatabase: AreaCodeDatabase +): ViewModel() { private val _sideEffect = MutableSharedFlow(replay = 0) val sideEffect: SharedFlow = _sideEffect.asSharedFlow() @@ -45,6 +54,18 @@ class SplashViewModel @Inject constructor(): ViewModel() { )) } } + + fun initAreaCode(context: Context) { + viewModelScope.launch { + val areaCodeItems = areaCodeDatabase.areaCodeDao.getAllAreaCodeEntities() + if(areaCodeItems.isEmpty()) { + val workRequest = OneTimeWorkRequest.Companion.from(AreaCodeWorker::class.java) + WorkManager + .getInstance(context) + .enqueue(workRequest) + } + } + } } sealed interface SplashSideEffect { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 93bab27..1ba798d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ composeBom = "2024.09.02" coroutine-core = "1.8.1" coroutine-test = "1.5.1" room = "2.6.1" +work = "2.9.1" retrofit = "2.9.0" okhttp = "4.12.0" @@ -21,6 +22,8 @@ okhttp = "4.12.0" hilt = "2.51.1" hilt-compiler = "1.2.0" +core-testing = "2.2.0" + [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -42,16 +45,20 @@ androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", ve androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-work = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work"} + hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hilt-compiler" } +androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hilt-compiler"} androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-compiler" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutine-test" } +androidx-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "core-testing" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }