diff --git a/.github/scripts/run_android_tests.sh b/.github/scripts/run_android_tests.sh new file mode 100644 index 000000000..ff93eeb7f --- /dev/null +++ b/.github/scripts/run_android_tests.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +PKGS="${TEST_PACKAGES:-}" +if [ -z "$PKGS" ]; then + ./gradlew connectedDebugAndroidTest --stacktrace +else + # connectedDebugAndroidTest uses runner args for filtering, not --tests + PKG_FILTER=$(echo "$PKGS" | tr ' ' '\n' | grep -v '^$' | tr '\n' ',' | sed 's/,$//') + ./gradlew connectedDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.package="$PKG_FILTER" \ + --stacktrace +fi diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index d99b53971..ab8801676 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -104,8 +104,34 @@ jobs: - name: Change gradlew permissions run: chmod +x ./gradlew + - name: Detect changed test packages + id: packages + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + PKGS=$(gh api "repos/$REPO/pulls/$PR_NUMBER/files" --paginate \ + --jq '.[].filename' \ + | grep -E "^app/src/(main|test|androidTest)/java/.+\.kt$" \ + | sed 's|app/src/[^/]*/java/||;s|/[^/]*\.kt$||;s|/|.|g' \ + | sort -u \ + | tr '\n' ' ') + echo "packages=$PKGS" >> "$GITHUB_OUTPUT" + else + echo "packages=" >> "$GITHUB_OUTPUT" + fi + - name: Run unit tests - run: ./gradlew testDebugUnitTest --stacktrace + run: | + PKGS="${{ steps.packages.outputs.packages }}" + if [ -z "$PKGS" ]; then + ./gradlew testDebugUnitTest --stacktrace + else + ARGS=$(echo "$PKGS" | tr ' ' '\n' | grep -v '^$' | sed 's/$/.*/;s/^/--tests /' | tr '\n' ' ') + ./gradlew testDebugUnitTest $ARGS --stacktrace + fi - name: Comment unit test failures on PR if: always() && github.event_name == 'pull_request' @@ -217,6 +243,25 @@ jobs: - name: Change gradlew permissions run: chmod +x ./gradlew + - name: Detect changed test packages + id: packages + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + PKGS=$(gh api "repos/$REPO/pulls/$PR_NUMBER/files" --paginate \ + --jq '.[].filename' \ + | grep -E "^app/src/(main|test|androidTest)/java/.+\.kt$" \ + | sed 's|app/src/[^/]*/java/||;s|/[^/]*\.kt$||;s|/|.|g' \ + | sort -u \ + | tr '\n' ' ') + echo "packages=$PKGS" >> "$GITHUB_OUTPUT" + else + echo "packages=" >> "$GITHUB_OUTPUT" + fi + - name: Enable KVM group perms run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules @@ -225,12 +270,14 @@ jobs: - name: Run Compose UI tests on emulator uses: reactivecircus/android-emulator-runner@v2 + env: + TEST_PACKAGES: ${{ steps.packages.outputs.packages }} with: api-level: 34 target: google_apis arch: x86_64 profile: pixel_6 - script: ./gradlew connectedDebugAndroidTest --stacktrace + script: bash .github/scripts/run_android_tests.sh - name: Comment Compose UI test failures on PR if: always() && github.event_name == 'pull_request' diff --git a/app/build.gradle b/app/build.gradle index eaafa88ee..6d10a6235 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -137,6 +137,7 @@ dependencies { implementation libs.androidx.swiperefreshlayout implementation libs.androidx.preference.ktx implementation libs.androidx.security.crypto.ktx + implementation libs.androidx.splashscreen // Compose implementation platform(libs.compose.bom) diff --git a/app/src/androidTest/java/com/runnect/runnect/presentation/splash/SplashScreenTest.kt b/app/src/androidTest/java/com/runnect/runnect/presentation/splash/SplashScreenTest.kt new file mode 100644 index 000000000..0a70dd76b --- /dev/null +++ b/app/src/androidTest/java/com/runnect/runnect/presentation/splash/SplashScreenTest.kt @@ -0,0 +1,21 @@ +package com.runnect.runnect.presentation.splash + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import org.junit.Rule +import org.junit.Test + +class SplashScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `SplashScreen이_렌더링된다`() { + composeTestRule.setContent { + SplashScreen() + } + composeTestRule.onRoot().assertIsDisplayed() + } +} diff --git a/app/src/main/java/com/runnect/runnect/presentation/splash/SplashActivity.kt b/app/src/main/java/com/runnect/runnect/presentation/splash/SplashActivity.kt index c0192cdc2..3043f92fc 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/splash/SplashActivity.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/splash/SplashActivity.kt @@ -2,35 +2,41 @@ package com.runnect.runnect.presentation.splash import android.content.Intent import android.os.Bundle -import android.os.Handler -import android.os.Looper +import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import com.runnect.runnect.R +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.runnect.runnect.presentation.login.LoginActivity -import com.runnect.runnect.presentation.scheme.SchemeActivity - +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +@AndroidEntryPoint class SplashActivity : AppCompatActivity() { - private val handler = Handler(Looper.getMainLooper()) + private val viewModel: SplashViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() + val splashScreen = installSplashScreen() + splashScreen.setKeepOnScreenCondition { false } super.onCreate(savedInstanceState) - setContentView(R.layout.activity_splash) - navigateToLoginScreen() - } + enableEdgeToEdge() - private fun navigateToLoginScreen() { - handler.postDelayed({ - val intent = Intent(this, LoginActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) - startActivity(intent) - finish() - }, DELAY_TIME) - } + setContent { + SplashScreen() + } - companion object { - private const val DELAY_TIME = 1000L + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.navigateEvent.collect { + startActivity(Intent(this@SplashActivity, LoginActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) + }) + finish() + } + } + } } } diff --git a/app/src/main/java/com/runnect/runnect/presentation/splash/SplashScreen.kt b/app/src/main/java/com/runnect/runnect/presentation/splash/SplashScreen.kt new file mode 100644 index 000000000..b709bfd0a --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/presentation/splash/SplashScreen.kt @@ -0,0 +1,19 @@ +package com.runnect.runnect.presentation.splash + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import com.runnect.runnect.R + +@Composable +fun SplashScreen() { + Image( + painter = painterResource(id = R.drawable.splash), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.FillBounds, + ) +} diff --git a/app/src/main/java/com/runnect/runnect/presentation/splash/SplashViewModel.kt b/app/src/main/java/com/runnect/runnect/presentation/splash/SplashViewModel.kt new file mode 100644 index 000000000..eaf9ea499 --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/presentation/splash/SplashViewModel.kt @@ -0,0 +1,36 @@ +package com.runnect.runnect.presentation.splash + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SplashViewModel @Inject constructor() : ViewModel() { + + private val _isReady = MutableStateFlow(false) + val isReady: StateFlow = _isReady.asStateFlow() + + private val _navigateEvent = MutableSharedFlow(replay = 1) + val navigateEvent: SharedFlow = _navigateEvent.asSharedFlow() + + init { + viewModelScope.launch { + delay(SPLASH_DELAY) + _isReady.value = true + _navigateEvent.emit(Unit) + } + } + + companion object { + const val SPLASH_DELAY = 1000L + } +} diff --git a/app/src/main/res/layout/activity_splash.xml b/app/src/main/res/layout/activity_splash.xml deleted file mode 100644 index bcff2d717..000000000 --- a/app/src/main/res/layout/activity_splash.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index d7f9d7df3..a97d5ccb6 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -29,10 +29,9 @@ @style/Widget.Runnect.EditText - diff --git a/app/src/test/java/com/runnect/runnect/presentation/splash/SplashViewModelTest.kt b/app/src/test/java/com/runnect/runnect/presentation/splash/SplashViewModelTest.kt new file mode 100644 index 000000000..11a4dfa7a --- /dev/null +++ b/app/src/test/java/com/runnect/runnect/presentation/splash/SplashViewModelTest.kt @@ -0,0 +1,61 @@ +package com.runnect.runnect.presentation.splash + +import app.cash.turbine.test +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SplashViewModelTest { + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `초기 상태에서 isReady는 false다`() { + val viewModel = SplashViewModel() + assertFalse(viewModel.isReady.value) + } + + @Test + fun `1초 경과 전에는 isReady가 false다`() = runTest(testDispatcher) { + val viewModel = SplashViewModel() + advanceTimeBy(SplashViewModel.SPLASH_DELAY - 1) + assertFalse(viewModel.isReady.value) + } + + @Test + fun `1초 경과 후 isReady가 true가 된다`() = runTest(testDispatcher) { + val viewModel = SplashViewModel() + advanceUntilIdle() + assertTrue(viewModel.isReady.value) + } + + @Test + fun `1초 후 navigateEvent가 emit된다`() = runTest(testDispatcher) { + val viewModel = SplashViewModel() + viewModel.navigateEvent.test { + advanceTimeBy(SplashViewModel.SPLASH_DELAY) + awaitItem() + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 057211dc5..ea96cc8db 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ viewpager2 = "1.1.0" swiperefreshlayout = "1.2.0" preference-ktx = "1.2.1" security-crypto = "1.1.0" +splashscreen = "1.0.1" # Compose compose-bom = "2026.03.01" @@ -106,6 +107,7 @@ androidx-viewpager2 = { group = "androidx.viewpager2", name = "viewpager2", vers androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefreshlayout" } androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preference-ktx" } androidx-security-crypto-ktx = { group = "androidx.security", name = "security-crypto-ktx", version.ref = "security-crypto" } +androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" } # Compose compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }