Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/scripts/run_android_tests.sh
Original file line number Diff line number Diff line change
@@ -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
51 changes: 49 additions & 2 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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'
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setContent { } — Compose 진입점

Activity에서 XML setContentView(R.layout.xxx)를 대체하는 Compose 진입점.
이 블록 안에 넘긴 @Composable 함수가 화면 전체를 그린다.

// 기존
setContentView(R.layout.activity_splash)

// Compose
setContent {
    SplashScreen()
}

핵심 개념:

  • setContent 내부에서 Compose 런타임이 초기화되고, @Composable 함수 호출 트리가 UI 트리로 변환됨
  • ComponentActivity.setContentandroidx.activity.compose 라이브러리가 제공 — AppCompatActivity를 상속해도 사용 가능

공식 문서 — 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()
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Composable — Compose 함수 선언

이 어노테이션이 붙은 함수는 UI를 **기술(describe)**하는 단위.
기존 XML inflate + View 참조 방식 대신 함수 호출 트리가 UI 구조가 된다.

// 기존: XML을 inflate하고 View에 데이터 바인딩
val binding = ActivitySplashBinding.inflate(layoutInflater)

// Compose: 함수 = UI
@Composable
fun SplashScreen() { ... }

핵심 개념:

  • @Composable 함수는 일반 함수처럼 호출하지만, Compose 런타임이 호출 트리를 추적해 상태 변화 시 변경된 부분만 다시 그림(recomposition)
  • 반환값이 없음 — 함수가 UI를 직접 '그리는' 것이 아니라 런타임에 UI 트리를 '기술'하는 것
  • @Composable 함수는 반드시 다른 @Composable 스코프에서 호출해야 함

공식 문서 — Composable functions

fun SplashScreen() {
Image(
painter = painterResource(id = R.drawable.splash),
contentDescription = null,
modifier = Modifier.fillMaxSize(),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modifier — Compose의 레이아웃/스타일 체이닝

기존 View XML의 layout_width, layout_height, padding, background 등 속성을 코드로 체이닝하는 방식.

<!-- 기존 XML -->
<ImageView
    android:layout_width="match_parent"
    android:layout_height="match_parent" />
// Compose
Image(
    modifier = Modifier.fillMaxSize()  // match_parent × match_parent
)

핵심 개념:

  • Modifier순서가 중요함fillMaxSize().padding(16.dp)padding(16.dp).fillMaxSize()는 다른 결과
  • 각 Composable의 modifier 파라미터로 전달 — 컴포넌트 자체 크기·위치·동작을 외부에서 제어하는 패턴
  • fillMaxSize() = match_parent / wrapContentSize() = wrap_content / size(100.dp) = 고정 크기

공식 문서 — Modifiers

contentScale = ContentScale.FillBounds,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ContentScale — Compose Image의 스케일링 방식

기존 ImageViewandroid:scaleType 속성에 대응.

ContentScale ImageView scaleType 동작
FillBounds fitXY 비율 무시하고 컨테이너 꽉 채움
Crop centerCrop 비율 유지, 짧은 축 기준으로 크롭
Fit fitCenter 비율 유지, 긴 축 기준으로 맞춤
FillWidth fitStart(유사) 너비 기준으로 채움

여기서 FillBounds를 선택한 이유: splash.webp가 정확히 기기 화면 비율(1440×3200)로 제작된 전체화면 이미지라 비율 왜곡이 발생해도 원본 windowBackground 렌더링 방식과 동일한 결과를 냄.

공식 문서 — ContentScale

)
}
Original file line number Diff line number Diff line change
@@ -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<Boolean> = _isReady.asStateFlow()

private val _navigateEvent = MutableSharedFlow<Unit>(replay = 1)
val navigateEvent: SharedFlow<Unit> = _navigateEvent.asSharedFlow()

init {
viewModelScope.launch {
delay(SPLASH_DELAY)
_isReady.value = true
_navigateEvent.emit(Unit)
}
}

companion object {
const val SPLASH_DELAY = 1000L
}
}
9 changes: 0 additions & 9 deletions app/src/main/res/layout/activity_splash.xml

This file was deleted.

7 changes: 3 additions & 4 deletions app/src/main/res/values/themes.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,9 @@
<item name="editTextStyle">@style/Widget.Runnect.EditText</item>
</style>

<style name="SplashTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="windowNoTitle">true</item>
<item name="windowActionBar">false</item>
<item name="android:windowBackground">@drawable/splash</item>
<style name="SplashTheme" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/M1</item>
<item name="postSplashScreenTheme">@style/Theme.Runnect</item>
</style>

<!-- 신고하기 BottomSheet 테마 -->
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" }
Expand Down
Loading