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() })
+ }
}