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
7 changes: 7 additions & 0 deletions androidApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />

<category android:name="android.intent.category.DEFAULT" />

<data android:mimeType="text/plain" />
</intent-filter>
</activity>

<service
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,100 @@
package com.nexters.hytime.gitit

import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue

/** 재생성 중 보존할 미처리 공유 저장소 URL의 상태 키다. */
private const val SHARED_REPOSITORY_URL_STATE = "sharedRepositoryUrl"

/** 공유 대상으로 허용하는 GitHub 저장소 루트 URL 형식이다. */
private val GITHUB_REPOSITORY_URL =
Regex(
pattern = "^https://github\\.com/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/([A-Za-z0-9._-]+)/?$",
option = RegexOption.IGNORE_CASE,
)

/**
* Android 공유 Intent에서 지원하는 GitHub 저장소 URL을 반환한다.
*
* @param action 수신한 Intent action
* @param mimeType 공유 데이터의 MIME type
* @param sharedText 공유된 텍스트
* @return 저장소 루트 URL이면 공백을 제거한 값, 지원하지 않는 공유이면 null
*/
internal fun resolveSharedRepositoryUrl(
action: String?,
mimeType: String?,
sharedText: CharSequence?,
): String? {
if (action != Intent.ACTION_SEND || mimeType != "text/plain") return null
return sharedText?.toString()?.trim()?.takeIf(GITHUB_REPOSITORY_URL::matches)
}

/**
* Android 앱의 시작 화면과 Compose 콘텐츠를 호스팅한다.
*/
class MainActivity : ComponentActivity() {
/** 공통 내비게이션이 아직 소비하지 않은 공유 저장소 URL이다. */
private var sharedRepositoryUrl by mutableStateOf<String?>(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 }
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProjectLoadViewModel>()
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) {
Expand Down
13 changes: 11 additions & 2 deletions shared/src/commonMain/kotlin/com/nexters/hytime/gitit/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

/**
* 화면의 역할에 맞는 전환 유형을 반환한다.
*
Expand All @@ -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
Expand Down Expand Up @@ -215,9 +240,17 @@ private fun rememberAppNavEntryDecorators(): List<NavEntryDecorator<NavKey>> =
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 =
Expand All @@ -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),
Expand Down Expand Up @@ -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) },
Expand Down Expand Up @@ -333,8 +385,9 @@ fun AppNavHost() {
onNavigateToQuiz = { projectId -> backStack.add(AppRoute.Quiz(projectId)) },
)
}
entry<AppRoute.ProjectLoad>(metadata = AppRoute.ProjectLoad.navigationMetadata()) {
entry<AppRoute.ProjectLoad>(metadata = { it.navigationMetadata() }) { route ->
ProjectLoadRoute(
repositoryUrl = route.repositoryUrl,
onBackClick = { backStack.removeLastOrNull() },
onRepositoryConfirmed = { repository ->
backStack.add(AppRoute.QuizCreate("https://github.com/${repository.ownerName}/${repository.name}"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,13 @@ sealed interface AppRoute : NavKey {

/**
* 질문 생성을 시작할 GitHub 저장소를 입력하고 확인하는 화면이다.
*
* @property repositoryUrl 외부 공유로 미리 채울 저장소 URL. 일반 진입이면 빈 문자열
*/
@Serializable
data object ProjectLoad : AppRoute
data class ProjectLoad(
val repositoryUrl: String = "",
) : AppRoute

/**
* 선택한 저장소의 문제 생성 조건을 설정하고 생성 진행 상태를 표시한다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, SharedRepositoryAction>(
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() })
}
}
Loading