diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 75c29968..56d94c2c 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -25,6 +25,13 @@ + + + + + + + (null) + + /** + * Activity를 만들고 최초 실행 Intent 또는 저장된 미처리 URL을 Compose 콘텐츠에 전달한다. + * + * @param savedInstanceState 재생성 전 저장된 Activity 상태 + */ override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) + if (savedInstanceState == null) { + handleSharedIntent(intent) + } else { + sharedRepositoryUrl = savedInstanceState.getString(SHARED_REPOSITORY_URL_STATE) + } setContent { - App() + App( + sharedRepositoryUrl = sharedRepositoryUrl, + onSharedRepositoryUrlConsumed = { sharedRepositoryUrl = null }, + ) } } + + /** + * 재생성 전에 아직 처리하지 않은 공유 URL을 저장한다. + * + * @param outState 새 Activity에 전달할 상태 Bundle + */ + override fun onSaveInstanceState(outState: Bundle) { + outState.putString(SHARED_REPOSITORY_URL_STATE, sharedRepositoryUrl) + super.onSaveInstanceState(outState) + } + + /** + * 실행 중인 Activity에 새로 전달된 공유 Intent를 처리한다. + * + * @param intent 새로 수신한 공유 Intent + */ + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleSharedIntent(intent) + } + + /** + * 유효한 저장소 공유이면 내비게이션에서 소비할 URL로 보관한다. + * + * @param intent GitHub 앱 등 외부 앱에서 전달된 Intent + */ + private fun handleSharedIntent(intent: Intent) { + resolveSharedRepositoryUrl( + action = intent.action, + mimeType = intent.type, + sharedText = intent.getCharSequenceExtra(Intent.EXTRA_TEXT), + )?.let { sharedRepositoryUrl = it } + } } diff --git a/androidApp/src/test/kotlin/com/nexters/hytime/gitit/SharedRepositoryIntentTest.kt b/androidApp/src/test/kotlin/com/nexters/hytime/gitit/SharedRepositoryIntentTest.kt new file mode 100644 index 00000000..d00e8b01 --- /dev/null +++ b/androidApp/src/test/kotlin/com/nexters/hytime/gitit/SharedRepositoryIntentTest.kt @@ -0,0 +1,41 @@ +package com.nexters.hytime.gitit + +import android.content.Intent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** GitHub 저장소 공유 Intent의 허용 범위를 검증한다. */ +class SharedRepositoryIntentTest { + /** 저장소 루트 URL을 텍스트 공유하면 주변 공백을 제거해 반환하는지 검증한다. */ + @Test + fun resolveSharedRepositoryUrl_repositoryRoot_returnsUrl() { + val url = "https://github.com/Nexters/Git-It-KMP" + + val result = + resolveSharedRepositoryUrl( + action = Intent.ACTION_SEND, + mimeType = "text/plain", + sharedText = " $url/ ", + ) + + assertEquals("$url/", result) + } + + /** 지원하지 않는 Intent 정보나 저장소 하위 링크를 공유하면 무시하는지 검증한다. */ + @Test + fun resolveSharedRepositoryUrl_unsupportedShare_returnsNull() { + val invalidShares = + listOf( + Triple(Intent.ACTION_VIEW, "text/plain", "https://github.com/Nexters/Git-It-KMP"), + Triple(Intent.ACTION_SEND, "text/html", "https://github.com/Nexters/Git-It-KMP"), + Triple(Intent.ACTION_SEND, "text/plain", "https://example.com/Nexters/Git-It-KMP"), + Triple(Intent.ACTION_SEND, "text/plain", "https://github.com/Nexters/Git-It-KMP/issues"), + Triple(Intent.ACTION_SEND, "text/plain", "https://github.com/Nexters/Git-It-KMP/blob/main/README.md"), + ) + + invalidShares.forEach { (action, mimeType, text) -> + assertNull(resolveSharedRepositoryUrl(action, mimeType, text), text) + } + } +} diff --git a/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/load/ProjectLoadRoute.kt b/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/load/ProjectLoadRoute.kt index 2e2bd509..20dc4bf9 100644 --- a/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/load/ProjectLoadRoute.kt +++ b/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/load/ProjectLoadRoute.kt @@ -11,17 +11,26 @@ import org.koin.compose.viewmodel.koinViewModel /** * 프로젝트로 등록할 저장소 확인 화면의 상태와 이벤트를 연결한다. * + * @param repositoryUrl 외부 공유로 전달되어 즉시 검증할 저장소 URL * @param onBackClick 이전 화면으로 이동하는 콜백 * @param onRepositoryConfirmed 확인한 저장소로 학습 설정을 진행하는 콜백 */ @Composable fun ProjectLoadRoute( + repositoryUrl: String = "", onBackClick: () -> Unit, onRepositoryConfirmed: (GitHubRepository) -> Unit, ) { val viewModel = koinViewModel() val uiState by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(repositoryUrl) { + if (repositoryUrl.isNotBlank() && uiState.repositoryUrl != repositoryUrl) { + viewModel.onIntent(ProjectLoadIntent.RepositoryUrlChanged(repositoryUrl)) + viewModel.onIntent(ProjectLoadIntent.LoadRepository) + } + } + LaunchedEffect(Unit) { viewModel.events.collectLatest { event -> when (event) { diff --git a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/App.kt b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/App.kt index eb20f181..fe755b17 100644 --- a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/App.kt +++ b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/App.kt @@ -7,11 +7,20 @@ import com.nexters.hytime.gitit.navigation.AppNavHost /** * 앱 공통 테마와 최상위 내비게이션을 제공한다. + * + * @param sharedRepositoryUrl Android 공유로 전달되어 아직 처리하지 않은 저장소 URL + * @param onSharedRepositoryUrlConsumed 공유 URL을 이동하거나 폐기한 뒤 호출하는 콜백 */ @Composable -fun App() { +fun App( + sharedRepositoryUrl: String? = null, + onSharedRepositoryUrlConsumed: () -> Unit = {}, +) { GitItTheme { - AppNavHost() + AppNavHost( + sharedRepositoryUrl = sharedRepositoryUrl, + onSharedRepositoryUrlConsumed = onSharedRepositoryUrlConsumed, + ) } } diff --git a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppNavHost.kt b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppNavHost.kt index 9b22ad55..f2176e99 100644 --- a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppNavHost.kt +++ b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppNavHost.kt @@ -13,6 +13,7 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator @@ -95,6 +96,30 @@ internal enum class AppNavigationMotion { Vertical, } +/** 공유 저장소 URL을 현재 화면에서 처리할 방법이다. */ +internal enum class SharedRepositoryAction { + /** 로그인 확인이 끝날 때까지 공유 URL을 보관한다. */ + Wait, + + /** 로그인하지 않은 흐름에서는 공유 URL을 폐기한다. */ + Discard, + + /** 로그인된 화면 위에 저장소 입력 화면을 연다. */ + Open, +} + +/** + * 현재 화면의 인증 상태에 맞는 공유 저장소 처리 방법을 반환한다. + * + * @return 스플래시는 대기, 온보딩 흐름은 폐기, 나머지 화면은 열기 + */ +internal fun AppRoute.sharedRepositoryAction(): SharedRepositoryAction = + when (this) { + AppRoute.Splash -> SharedRepositoryAction.Wait + AppRoute.Onboarding, AppRoute.IntermediateSplash -> SharedRepositoryAction.Discard + else -> SharedRepositoryAction.Open + } + /** * 화면의 역할에 맞는 전환 유형을 반환한다. * @@ -118,7 +143,7 @@ internal fun AppRoute.navigationMotion(): AppNavigationMotion = AppRoute.Settings, -> AppNavigationMotion.Horizontal - AppRoute.ProjectLoad, + is AppRoute.ProjectLoad, is AppRoute.QuizCreate, is AppRoute.Quiz, -> AppNavigationMotion.Vertical @@ -215,9 +240,17 @@ private fun rememberAppNavEntryDecorators(): List> = rememberViewModelStoreNavEntryDecorator(), ) -/** 앱의 모든 화면 경로를 백스택에 따라 표시한다. */ +/** + * 앱의 모든 화면 경로를 백스택에 따라 표시한다. + * + * @param sharedRepositoryUrl 외부 공유로 전달되어 아직 처리하지 않은 저장소 URL + * @param onSharedRepositoryUrlConsumed 공유 URL을 이동하거나 폐기한 뒤 호출하는 콜백 + */ @Composable -fun AppNavHost() { +fun AppNavHost( + sharedRepositoryUrl: String? = null, + onSharedRepositoryUrlConsumed: () -> Unit = {}, +) { val uriHandler = LocalUriHandler.current val notificationPermissionState = rememberNotificationPermissionState() val backStack = @@ -235,6 +268,25 @@ fun AppNavHost() { } } + LaunchedEffect(sharedRepositoryUrl, backStack.lastOrNull()) { + val repositoryUrl = sharedRepositoryUrl ?: return@LaunchedEffect + val currentRoute = backStack.lastOrNull() as? AppRoute ?: return@LaunchedEffect + + when (currentRoute.sharedRepositoryAction()) { + SharedRepositoryAction.Wait -> Unit + SharedRepositoryAction.Discard -> onSharedRepositoryUrlConsumed() + SharedRepositoryAction.Open -> { + val projectLoad = AppRoute.ProjectLoad(repositoryUrl) + if (currentRoute is AppRoute.ProjectLoad) { + backStack[backStack.lastIndex] = projectLoad + } else { + backStack.add(projectLoad) + } + onSharedRepositoryUrlConsumed() + } + } + } + NavDisplay( backStack = backStack, modifier = Modifier.fillMaxSize().background(GitItTheme.colors.grey700), @@ -263,7 +315,7 @@ fun AppNavHost() { ) { isQuizCreating -> HomeRoute( isQuizCreating = isQuizCreating, - onNavigateToProjectLoad = { backStack.add(AppRoute.ProjectLoad) }, + onNavigateToProjectLoad = { backStack.add(AppRoute.ProjectLoad()) }, onNavigateToProjectList = { navigateToMainRoute(AppRoute.ProjectList) }, onNavigateToMy = { navigateToMainRoute(AppRoute.My) }, onNavigateToBookmark = { navigateToMainRoute(AppRoute.Bookmark) }, @@ -333,8 +385,9 @@ fun AppNavHost() { onNavigateToQuiz = { projectId -> backStack.add(AppRoute.Quiz(projectId)) }, ) } - entry(metadata = AppRoute.ProjectLoad.navigationMetadata()) { + entry(metadata = { it.navigationMetadata() }) { route -> ProjectLoadRoute( + repositoryUrl = route.repositoryUrl, onBackClick = { backStack.removeLastOrNull() }, onRepositoryConfirmed = { repository -> backStack.add(AppRoute.QuizCreate("https://github.com/${repository.ownerName}/${repository.name}")) diff --git a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppRoute.kt b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppRoute.kt index 2bd1b0e9..cd72ed85 100644 --- a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppRoute.kt +++ b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppRoute.kt @@ -64,9 +64,13 @@ sealed interface AppRoute : NavKey { /** * 질문 생성을 시작할 GitHub 저장소를 입력하고 확인하는 화면이다. + * + * @property repositoryUrl 외부 공유로 미리 채울 저장소 URL. 일반 진입이면 빈 문자열 */ @Serializable - data object ProjectLoad : AppRoute + data class ProjectLoad( + val repositoryUrl: String = "", + ) : AppRoute /** * 선택한 저장소의 문제 생성 조건을 설정하고 생성 진행 상태를 표시한다. diff --git a/shared/src/commonTest/kotlin/com/nexters/hytime/gitit/navigation/AppNavigationMotionTest.kt b/shared/src/commonTest/kotlin/com/nexters/hytime/gitit/navigation/AppNavigationMotionTest.kt index bb04cf01..43fe7732 100644 --- a/shared/src/commonTest/kotlin/com/nexters/hytime/gitit/navigation/AppNavigationMotionTest.kt +++ b/shared/src/commonTest/kotlin/com/nexters/hytime/gitit/navigation/AppNavigationMotionTest.kt @@ -22,11 +22,27 @@ class AppNavigationMotionTest { AppRoute.LiquidGlassExample to AppNavigationMotion.Horizontal, AppRoute.ProjectDetail(projectId = "project-1") to AppNavigationMotion.Horizontal, AppRoute.Settings to AppNavigationMotion.Horizontal, - AppRoute.ProjectLoad to AppNavigationMotion.Vertical, + AppRoute.ProjectLoad() to AppNavigationMotion.Vertical, AppRoute.QuizCreate(repositoryUrl = "https://github.com/Nexters/Git-It-KMP") to AppNavigationMotion.Vertical, AppRoute.Quiz(projectId = "project-1") to AppNavigationMotion.Vertical, ) assertEquals(expected, expected.keys.associateWith { it.navigationMotion() }) } + + /** 인증 흐름에 따라 공유 저장소 URL을 대기, 폐기, 열기로 분류하는지 검증한다. */ + @Test + fun sharedRepositoryAction_routeCategory_returnsExpectedAction() { + val expected = + mapOf( + AppRoute.Splash to SharedRepositoryAction.Wait, + AppRoute.Onboarding to SharedRepositoryAction.Discard, + AppRoute.IntermediateSplash to SharedRepositoryAction.Discard, + AppRoute.Home to SharedRepositoryAction.Open, + AppRoute.Settings to SharedRepositoryAction.Open, + AppRoute.ProjectLoad("https://github.com/Nexters/Git-It-KMP") to SharedRepositoryAction.Open, + ) + + assertEquals(expected, expected.keys.associateWith { it.sharedRepositoryAction() }) + } }