Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ae6f188
feat: StorageScrapFragment Compose 마이그레이션 및 ViewModel 테스트 추가
unam98 Jun 24, 2026
cab9c8a
ci: PR CI에 unit test 실행 단계 추가 및 트리거 확대
unam98 Jun 24, 2026
2321b18
fix: CI JDK 버전을 21로 맞춤 (gradle-daemon-jvm.properties와 일치)
unam98 Jun 24, 2026
a807d66
fix: CodeRabbit이 발견한 Compose 포팅 회귀 2건 수정
unam98 Jun 25, 2026
8d786bc
test: StorageScrapScreen Compose UI test 3건 추가
unam98 Jun 25, 2026
135edb2
ci: Compose UI test(androidTest)를 에뮬레이터로 자동 실행하는 잡 추가
unam98 Jun 25, 2026
aa42315
fix: 스크랩 해제(하트탭) 시 누락된 로딩 피드백 복구
unam98 Jun 25, 2026
bd95fd5
refactor: 로딩 상태 결합 로직을 순수 함수로 분리 + 회귀 방지 테스트 추가
unam98 Jun 25, 2026
d47f44c
fix: CI 에뮬레이터 잡을 macos-latest(Apple Silicon)에 맞춰 arm64-v8a로 수정
unam98 Jun 25, 2026
f41f46b
fix: 안드로이드 에뮬레이터 CI 잡을 ubuntu-latest+KVM으로 전환
unam98 Jun 25, 2026
fe30826
fix: ExampleInstrumentedTest의 하드코딩된 패키지명 오타 수정
unam98 Jun 25, 2026
f780884
ci: 테스트별 성공/실패를 PR 체크에서 바로 확인하도록 test-reporter 추가
unam98 Jun 25, 2026
a8c2938
fix: test-reporter 체크런 생성에 필요한 actions:read 권한 추가
unam98 Jun 25, 2026
484725a
fix: dorny/test-reporter를 v3로 올려 체크런 대신 잡 서머리에만 찍히던 문제 해결
unam98 Jun 25, 2026
5f5a0fd
ci: dorny/test-reporter 대신 자체 스크립트로 테스트 결과를 PR 코멘트에 게시
unam98 Jun 25, 2026
dfdfd45
fix: 테스트 결과 코멘트에서 testcase의 classname으로 그루핑하도록 수정
unam98 Jun 25, 2026
b9a66c4
feat: 테스트 결과 코멘트의 각 테스트에 고정 앵커 추가
unam98 Jun 25, 2026
15c93d5
ci: 테스트 실패 시에만 PR 코멘트 게시, 통과 시 코멘트 삭제
unam98 Jun 26, 2026
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
12 changes: 7 additions & 5 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ on:
branches: [ develop ]

pull_request:
branches: [ develop ]


defaults:
run:
shell: bash
Expand All @@ -20,10 +19,10 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

- name: set up JDK 17
- name: set up JDK 21
uses: actions/setup-java@v4

@coderabbitai coderabbitai Bot Jun 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

git ls-files .github/workflows/CI.yml app/build.gradle

Repository: Runnect/Runnect-Android

Length of output: 203


🏁 Script executed:

sed -n '1,140p' .github/workflows/CI.yml

Repository: Runnect/Runnect-Android

Length of output: 4364


🏁 Script executed:

sed -n '1,220p' app/build.gradle

Repository: Runnect/Runnect-Android

Length of output: 6577


Pin actions/setup-java to a commit SHA

actions/setup-java@v4 is still a mutable tag. Pin it to the exact commit SHA your workflow allows instead of using a floating version.

🧰 Tools
🪛 zizmor (1.26.1)

[error] 23-23: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/CI.yml at line 23, The workflow uses a floating tag for
actions/setup-java, which should be pinned to an immutable commit SHA. Update
the setup-java step in the CI workflow to use the exact approved SHA instead of
actions/setup-java@v4. Keep the change scoped to the actions/setup-java
reference so the workflow remains functionally the same while using a fixed
revision.

Source: Linters/SAST tools

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.

좋은 제안입니다. CI 워크플로 전체에 영향 주는 변경이라 이번 PR 범위 밖으로 보고 별도 이슈로 분리하겠습니다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@unam98 이해했습니다. 이번 PR 범위에서는 분리하는 판단이 합리적입니다.

원하시면 제가 후속 GitHub 이슈로 정리해 드릴게요. actions/setup-java를 고정 SHA로 pinning 하는 작업 범위와 수용 기준까지 포함해서 작성할 수 있습니다.

(•ᵕ•)🐇

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.

네, 부탁드립니다. 이슈로 열어주세요.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="Runnect/Runnect-Android"
requester="unam98"
pr_url="https://github.com/Runnect/Runnect-Android/pull/392"
comment_url="https://github.com/Runnect/Runnect-Android/pull/392#discussion_r3469158477"

title="Pin actions/setup-java to an immutable commit SHA in CI workflow"

body_file="$(mktemp)"
cat > "$body_file" <<EOF
## Summary
Update the CI workflow to pin \`actions/setup-java\` to an immutable commit SHA instead of using the floating tag \`actions/setup-java@v4\`.

## Why
The current workflow uses a mutable action reference in \`.github/workflows/CI.yml\`. Pinning the action to an exact commit SHA improves supply-chain security and aligns the workflow with stricter CI/SAST policies that require immutable action references.

## Affected area
- \`.github/workflows/CI.yml\`

## Required change
Replace the current \`uses: actions/setup-java@v4\` reference with the exact approved commit SHA for the same action revision, keeping the step behavior otherwise unchanged.

## Acceptance criteria
- \`.github/workflows/CI.yml\` no longer uses a floating tag for \`actions/setup-java\`
- The workflow references \`actions/setup-java\` using a full commit SHA
- The JDK configuration remains functionally the same (Java 21, Temurin, Gradle cache)
- CI continues to run successfully after the change

## References
- Follow-up from PR `#392`: $pr_url
- Review discussion: $comment_url

## Requested by
- @$requester
EOF

issue_url="$(
  gh issue create \
    --repo "$repo" \
    --title "$title" \
    --body-file "$body_file" \
    --assignee "$requester"
)"

echo "$issue_url"

Length of output: 1806



@unam98 후속 이슈 생성해두었습니다: #393

필요하시면 이어서 이 PR의 해당 리뷰 코멘트도 제가 정리해서 마무리 방향 제안드릴게요.

with:
java-version: 17
java-version: 21
distribution: temurin
cache: gradle

Expand Down Expand Up @@ -99,5 +98,8 @@ jobs:
- name: Change gradlew permissions
run: chmod +x ./gradlew

- name: Build
- name: Run unit tests
run: ./gradlew testDebugUnitTest --stacktrace

- name: Build
run: ./gradlew assembleDebug --stacktrace
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ dependencies {
testImplementation libs.mockk
testImplementation libs.turbine
testImplementation libs.kotlinx.coroutines.test
testImplementation libs.androidx.core.testing
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.espresso.core
}
Original file line number Diff line number Diff line change
@@ -1,227 +1,139 @@
package com.runnect.runnect.presentation.storage

import android.content.ContentValues
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import androidx.core.view.isVisible
import android.view.ViewGroup
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import com.runnect.runnect.R
import com.runnect.runnect.binding.BindingFragment
import com.runnect.runnect.domain.entity.MyScrapCourse
import com.runnect.runnect.databinding.FragmentStorageScrapBinding
import com.runnect.runnect.presentation.MainActivity
import com.runnect.runnect.presentation.detail.CourseDetailActivity
import com.runnect.runnect.presentation.detail.CourseDetailRootScreen
import com.runnect.runnect.presentation.event.ScreenRefreshEvent
import com.runnect.runnect.presentation.event.ScreenRefreshEventBus
import com.runnect.runnect.presentation.detail.CourseDetailRootScreen
import com.runnect.runnect.presentation.state.UiStateV2
import com.runnect.runnect.presentation.storage.adapter.StorageScrapAdapter
import com.runnect.runnect.presentation.ui.theme.RunnectTheme
import com.runnect.runnect.util.analytics.Analytics
import com.runnect.runnect.util.analytics.EventName
import com.runnect.runnect.util.analytics.EventName.Param
import com.runnect.runnect.util.custom.deco.GridSpacingItemDecoration
import com.runnect.runnect.util.callback.ItemCount
import com.runnect.runnect.util.callback.listener.OnHeartButtonClick
import com.runnect.runnect.util.callback.listener.OnScrapItemClick
import com.runnect.runnect.util.extension.showSnackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject

@AndroidEntryPoint
class StorageScrapFragment :
BindingFragment<FragmentStorageScrapBinding>(R.layout.fragment_storage_scrap),
OnHeartButtonClick,
OnScrapItemClick,
ItemCount {
class StorageScrapFragment : Fragment() {
@Inject
lateinit var screenRefreshEventBus: ScreenRefreshEventBus

val viewModel: StorageViewModel by viewModels()
private lateinit var storageScrapAdapter: StorageScrapAdapter
private val viewModel: StorageViewModel by viewModels()

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
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.

ComposeView + setContent — Fragment 안에 Compose 트리 심기

onCreateView에서 XML을 inflate하는 대신 ComposeView를 만들고, 그 안에 setContent { }로 Composable 트리를 올립니다. Fragment 자체는 그대로 두고(Hilt, 생명주기 등은 기존 View 시스템 그대로), 화면 내용만 Compose로 바꾸는 점진 마이그레이션의 핵심 진입점입니다.

return ComposeView(requireContext()).apply {
    setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
    setContent {
        RunnectTheme { /* Composable 트리 시작 */ }
    }
}

핵심 개념:

  • XML 대응: inflater.inflate(R.layout.xxx, container, false) 자리를 ComposeView가 대신함
  • setViewCompositionStrategy는 Compose 트리가 언제 폐기(dispose)될지 결정 — Fragment의 View 생명주기와 맞춰주지 않으면 메모리 누수/중복 구독 위험
  • 이 한 줄이 있어야 View 기반 화면 안에 Compose 화면을 부분적으로 섞어 쓸 수 있음 (interop)

공식 문서: Compose in Views

RunnectTheme {
val getState by viewModel.myScrapCourseGetState.observeAsState()
val scrapState by viewModel.courseScrapState.observeAsState()

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.

observeAsState() — LiveData를 Compose의 State로 변환

StorageViewModel은 LiveData를 그대로 쓰고 있어서(이번 PR은 ViewModel 로직을 안 건드림), Compose 쪽에서 구독하려면 LiveData를 Compose가 이해하는 State<T>로 바꿔주는 다리가 필요합니다. observeAsState()가 그 역할입니다.

val getState by viewModel.myScrapCourseGetState.observeAsState()

핵심 개념:

  • XML/Fragment 대응: liveData.observe(viewLifecycleOwner) { value -> ... } 콜백 등록과 동일한 효과
  • by 위임을 쓰면 getStateState<T>가 아니라 그 안의 값(T) 자체처럼 동작 — 매번 .value를 안 써도 됨
  • LiveData 값이 바뀌면 이 값을 읽고 있는 Composable이 자동으로 recomposition(재구성)됨 — 옵저버 콜백 안에서 직접 View를 갱신하던 방식과 다르게, "값이 바뀌면 화면이 따라간다"는 선언적 흐름
  • androidx.compose.runtime:runtime-livedata 의존성이 있어야 함(이번 PR에서 신규 추가)

공식 문서: Compose와 LiveData


var courses by remember { mutableStateOf(emptyList<MyScrapCourse>()) }
var errorMessage by remember { mutableStateOf<String?>(null) }

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.

remember { mutableStateOf(...) } — Composable이 자기 상태를 기억하는 법

Composable 함수는 recomposition(재구성)될 때마다 처음부터 다시 실행됩니다. 그냥 var courses = emptyList<MyScrapCourse>()라고 쓰면 재구성마다 빈 리스트로 초기화돼버려서, remember로 감싸 "다음 재구성에도 이 값을 유지하라"고 알려줘야 합니다.

var courses by remember { mutableStateOf(emptyList<MyScrapCourse>()) }

핵심 개념:

  • XML/View 대응: 없음에 가까움 — View는 객체이고 한 번 만들어지면 필드가 유지되지만, Composable은 "함수"라서 재호출 시 지역변수가 초기화됨. remember가 그 차이를 메워줌
  • mutableStateOf로 만든 값이 바뀌면 그 값을 읽는 Composable만 자동으로 재구성됨 (전체 화면을 다시 그리지 않음)
  • by 위임 문법(getValue/setValue import 필요)을 쓰면 State<T>를 매번 .value로 안 풀고 일반 변수처럼 읽고 쓸 수 있음
  • 이 화면에서는 ViewModel의 LiveData 값을 그대로 화면에 못 박지 않고, "스크랩 해제로 로컬에서 한 번 더 필터링한 리스트"를 따로 들고 있어야 해서 이 로컬 상태가 필요했음

공식 문서: State in Compose


Comment thread
coderabbitai[bot] marked this conversation as resolved.
LaunchedEffect(getState) {

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.

LaunchedEffect — Composable 안에서 안전하게 "한 번만" 실행하기

Composable 함수 본문에 직접 Analytics.logEvent(...)나 상태 변경 코드를 적으면, 재구성될 때마다(심지어 화면이 안 바뀌어도) 계속 실행될 위험이 있습니다. LaunchedEffect(key)는 key 값이 처음 나타나거나 바뀔 때만 코루틴을 실행하고, 그 외엔 건너뜁니다.

LaunchedEffect(getState) {
    when (val current = getState) {
        is UiStateV2.Success -> { courses = current.data; Analytics.logEvent(...) }
        is UiStateV2.Failure -> errorMessage = current.msg
        else -> Unit
    }
}

핵심 개념:

  • XML/Fragment 대응: LiveData observer 콜백 내부 로직과 비슷하지만, "콜백이 호출되는 시점"이 아니라 "key가 바뀌는 시점"에 반응한다는 차이가 있음
  • key(getState)가 동일한 값으로 다시 들어오면 재실행되지 않음 — 그래서 Analytics.logEvent가 화면 재구성마다 중복 호출되는 걸 막아줌
  • 코루틴이라서 delay, suspend fun 호출 등 비동기 작업도 안전하게 가능
  • 이 화면엔 같은 패턴이 scrapState에도 한 번 더 쓰였음(하트탭 결과 처리) — 원리는 동일

공식 문서: Side-effects - LaunchedEffect

when (val current = getState) {
is UiStateV2.Success -> {
courses = current.data
Analytics.logEvent(
EventName.VIEW_STORAGE_SCRAP,
Param.COURSE_COUNT to current.data.size
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

is UiStateV2.Failure -> errorMessage = current.msg
else -> Unit
}
}

LaunchedEffect(scrapState) {
when (val current = scrapState) {
is UiStateV2.Success -> {
courses = courses.filterNot {
it.publicCourseId.toLong() == current.data.publicCourseId
}
}

is UiStateV2.Failure -> errorMessage = current.msg
else -> Unit
}
}

StorageScrapScreen(
state = StorageScrapUiState(
courses = courses,
isLoading = getState is UiStateV2.Loading,
errorMessage = errorMessage
),
onRefresh = { viewModel.getMyScrapCourses() },
onScrapItemClick = { course -> navigateToCourseDetail(course) },
onHeartClick = { course ->
viewModel.postCourseScrap(id = course.publicCourseId, scrapTF = false)
},
onGoToScrapClick = { navigateToDiscover() },
onErrorShown = { errorMessage = null }
)
}
}
}
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner

Analytics.logEvent(EventName.VIEW_STORAGE_SCRAP)
getMyScrapCourses()
initLayout()
initAdapter()
addListener()
addObserver()
}

fun getMyScrapCourses() {
viewModel.getMyScrapCourses()
}

private fun initLayout() {
binding.rvStorageScrap.apply {
val context = context ?: return
layoutManager = GridLayoutManager(context, 2)
addItemDecoration(
GridSpacingItemDecoration(
context = context,
spanCount = 2,
horizontalSpaceSize = 6,
topSpaceSize = 20
)
)
}
}

private fun initAdapter() {
storageScrapAdapter = StorageScrapAdapter(
onScrapItemClick = this,
onHeartButtonClick = this,
itemCount = this
).apply {
binding.rvStorageScrap.adapter = this
}
}

private fun addListener() {
initGoToScrapButtonClickListener()
initRefreshLayoutListener()
}

private fun initGoToScrapButtonClickListener() {
binding.btnStorageNoScrap.setOnClickListener {
val intent = Intent(activity, MainActivity::class.java).apply {
putExtra(EXTRA_FRAGMENT_REPLACEMENT_DIRECTION, "fromMyScrap")
}
startActivity(intent)
requireActivity().overridePendingTransition(
R.anim.slide_in_right,
R.anim.slide_out_left
)
}
}

private fun initRefreshLayoutListener() {
binding.refreshLayout.setOnRefreshListener {
getMyScrapCourses()
binding.refreshLayout.isRefreshing = false
}
}

private fun addObserver() {
setupItemSizeObserver()
setupMyScrapCourseGetStateObserver()
setupCourseScrapStateObserver()
collectScreenRefreshEvents()
}

private fun collectScreenRefreshEvents() {
viewLifecycleOwner.lifecycleScope.launch {
screenRefreshEventBus.events.collect { event ->
if (event is ScreenRefreshEvent.RefreshStorageScrap) {
getMyScrapCourses()
viewModel.getMyScrapCourses()
}
}
}
}

private fun setupCourseScrapStateObserver() {
viewModel.courseScrapState.observe(viewLifecycleOwner) { state ->
when (state) {
is UiStateV2.Loading -> {
showLoadingProgressBar()
}

is UiStateV2.Success -> {
dismissLoadingProgressBar()
storageScrapAdapter.removeCourseItem()
}

is UiStateV2.Failure -> {
dismissLoadingProgressBar()
context?.showSnackbar(
anchorView = binding.root,
message = state.msg
)
}

else -> {}
}
}
}

private fun setupItemSizeObserver() {
viewModel.itemSize.observe(viewLifecycleOwner) { itemSize ->
val isEmpty = (itemSize == 0)
updateEmptyView(isEmpty, itemSize)
private fun navigateToCourseDetail(course: MyScrapCourse) {
val intent = Intent(activity, CourseDetailActivity::class.java).apply {
putExtra(EXTRA_PUBLIC_COURSE_ID, course.publicCourseId)
putExtra(EXTRA_ROOT_SCREEN, CourseDetailRootScreen.COURSE_STORAGE_SCRAP)
}
startActivity(intent)
requireActivity().overridePendingTransition(
R.anim.slide_in_right,
R.anim.slide_out_left
)
}

private fun updateEmptyView(isEmpty: Boolean, itemSize: Int) {
binding.apply {
clMyDrawNoScrap.isVisible = isEmpty
rvStorageScrap.isVisible = !isEmpty
tvStorageScrapCount.isVisible = !isEmpty
tvStorageScrapCount.text = if (!isEmpty) "총 코스 ${itemSize}개" else ""
private fun navigateToDiscover() {
val intent = Intent(activity, MainActivity::class.java).apply {
putExtra(EXTRA_FRAGMENT_REPLACEMENT_DIRECTION, "fromMyScrap")
}
}

private fun setupMyScrapCourseGetStateObserver() {
viewModel.myScrapCourseGetState.observe(viewLifecycleOwner) { state ->
when (state) {
is UiStateV2.Loading -> {
showLoadingProgressBar()
}

is UiStateV2.Success -> {
dismissLoadingProgressBar()

val scrapCourses = state.data
updateEmptyView(scrapCourses.isEmpty(), scrapCourses.size)
storageScrapAdapter.submitList(scrapCourses)
Analytics.logEvent(
EventName.VIEW_STORAGE_SCRAP,
Param.COURSE_COUNT to scrapCourses.size
)
}

is UiStateV2.Failure -> {
dismissLoadingProgressBar()
context?.showSnackbar(
anchorView = binding.root,
message = state.msg
)
}

else -> {}
}
}
}

private fun showLoadingProgressBar() {
binding.pbStorageScrapLoading.isVisible = true
}

private fun dismissLoadingProgressBar() {
binding.pbStorageScrapLoading.isVisible = false
}

override fun calcItemSize(itemCount: Int) {
viewModel.itemSize.value = itemCount
}

override fun scrapCourse(id: Int, scrapTF: Boolean) {
viewModel.postCourseScrap(id, scrapTF)
}

override fun selectItem(item: MyScrapCourse) {
Timber.tag(ContentValues.TAG).d("코스 아이디 : ${item.publicCourseId}")

val intent = Intent(activity, CourseDetailActivity::class.java)
intent.putExtra(EXTRA_PUBLIC_COURSE_ID, item.publicCourseId)
intent.putExtra(EXTRA_ROOT_SCREEN, CourseDetailRootScreen.COURSE_STORAGE_SCRAP)
startActivity(intent)
requireActivity().overridePendingTransition(
R.anim.slide_in_right,
Expand Down
Loading
Loading