diff --git a/data/src/main/java/com/nexters/boolti/data/datasource/AuthTokenDataSource.kt b/data/src/main/java/com/nexters/boolti/data/datasource/AuthTokenDataSource.kt new file mode 100644 index 00000000..172a8a36 --- /dev/null +++ b/data/src/main/java/com/nexters/boolti/data/datasource/AuthTokenDataSource.kt @@ -0,0 +1,23 @@ +package com.nexters.boolti.data.datasource + +import javax.inject.Inject + +internal class AuthTokenDataSource @Inject constructor( + private val authDataSource: AuthDataSource, + private val tokenDataSource: TokenDataSource, +) { + suspend fun getNewAccessToken(): String? { + val response = authDataSource.refresh() + val newToken = response.getOrNull() + return newToken?.let { + tokenDataSource.saveTokens( + accessToken = it.accessToken, + refreshToken = it.refreshToken, + ) + it.accessToken + } ?: run { + authDataSource.logout() + null + } + } +} diff --git a/data/src/main/java/com/nexters/boolti/data/di/DataSourceModule.kt b/data/src/main/java/com/nexters/boolti/data/di/DataSourceModule.kt index fe59d845..ff04d2e7 100644 --- a/data/src/main/java/com/nexters/boolti/data/di/DataSourceModule.kt +++ b/data/src/main/java/com/nexters/boolti/data/di/DataSourceModule.kt @@ -3,6 +3,7 @@ package com.nexters.boolti.data.di import android.content.Context import com.google.firebase.remoteconfig.FirebaseRemoteConfig import com.nexters.boolti.data.datasource.AuthDataSource +import com.nexters.boolti.data.datasource.AuthTokenDataSource import com.nexters.boolti.data.datasource.PolicyDataSource import com.nexters.boolti.data.datasource.RemoteConfigDataSource import com.nexters.boolti.data.datasource.TokenDataSource @@ -33,6 +34,16 @@ internal object DataSourceModule { @Provides fun provideTokenDataSource(@ApplicationContext context: Context): TokenDataSource = TokenDataSource(context) + @Singleton + @Provides + fun provideAuthTokenDataSource( + authDataSource: AuthDataSource, + tokenDataSource: TokenDataSource, + ): AuthTokenDataSource = AuthTokenDataSource( + authDataSource = authDataSource, + tokenDataSource = tokenDataSource, + ) + @Singleton @Provides fun providePolicyDataSource(@ApplicationContext context: Context): PolicyDataSource = PolicyDataSource(context) diff --git a/data/src/main/java/com/nexters/boolti/data/di/NetworkModule.kt b/data/src/main/java/com/nexters/boolti/data/di/NetworkModule.kt index 16c70e6b..b6241c0b 100644 --- a/data/src/main/java/com/nexters/boolti/data/di/NetworkModule.kt +++ b/data/src/main/java/com/nexters/boolti/data/di/NetworkModule.kt @@ -2,7 +2,7 @@ package com.nexters.boolti.data.di import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.nexters.boolti.data.BuildConfig -import com.nexters.boolti.data.datasource.AuthDataSource +import com.nexters.boolti.data.datasource.AuthTokenDataSource import com.nexters.boolti.data.datasource.TokenDataSource import com.nexters.boolti.data.network.AuthAuthenticator import com.nexters.boolti.data.network.AuthInterceptor @@ -114,7 +114,8 @@ internal object NetworkModule { @Singleton @Provides - fun provideTicketingService(@Named("auth") retrofit: Retrofit): TicketingService = retrofit.create() + fun provideTicketingService(@Named("auth") retrofit: Retrofit): TicketingService = + retrofit.create() @Singleton @Provides @@ -126,7 +127,8 @@ internal object NetworkModule { @Singleton @Provides - fun provideReservationService(@Named("auth") retrofit: Retrofit): ReservationService = retrofit.create() + fun provideReservationService(@Named("auth") retrofit: Retrofit): ReservationService = + retrofit.create() @Singleton @Provides @@ -134,7 +136,8 @@ internal object NetworkModule { @Singleton @Provides - fun provideAuthFileService(@Named("auth") retrofit: Retrofit): AuthFileService = retrofit.create() + fun provideAuthFileService(@Named("auth") retrofit: Retrofit): AuthFileService = + retrofit.create() @Singleton @Provides @@ -142,12 +145,16 @@ internal object NetworkModule { @Singleton @Provides - fun provideMemberService(@Named("non-auth") retrofit: Retrofit): MemberService = retrofit.create() + fun provideMemberService(@Named("non-auth") retrofit: Retrofit): MemberService = + retrofit.create() @Singleton @Provides @Named("auth") - fun provideAuthOkHttpClient(interceptor: AuthInterceptor, authenticator: AuthAuthenticator): OkHttpClient { + fun provideAuthOkHttpClient( + interceptor: AuthInterceptor, + authenticator: AuthAuthenticator + ): OkHttpClient { val loggingInterceptor = HttpLoggingInterceptor().apply { level = if (BuildConfig.DEBUG) { HttpLoggingInterceptor.Level.BODY @@ -202,12 +209,12 @@ internal object NetworkModule { @Singleton @Provides - fun provideAuthInterceptor(tokenDataSource: TokenDataSource): AuthInterceptor = AuthInterceptor(tokenDataSource) + fun provideAuthInterceptor(tokenDataSource: TokenDataSource): AuthInterceptor = + AuthInterceptor(tokenDataSource) @Singleton @Provides fun provideAuthenticator( - tokenDataSource: TokenDataSource, - authDataSource: AuthDataSource, - ): AuthAuthenticator = AuthAuthenticator(tokenDataSource, authDataSource) + authTokenDataSource: AuthTokenDataSource + ): AuthAuthenticator = AuthAuthenticator(authTokenDataSource) } diff --git a/data/src/main/java/com/nexters/boolti/data/network/AuthAuthenticator.kt b/data/src/main/java/com/nexters/boolti/data/network/AuthAuthenticator.kt index 8ae8e232..55ac1fd5 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/AuthAuthenticator.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/AuthAuthenticator.kt @@ -1,7 +1,6 @@ package com.nexters.boolti.data.network -import com.nexters.boolti.data.datasource.AuthDataSource -import com.nexters.boolti.data.datasource.TokenDataSource +import com.nexters.boolti.data.datasource.AuthTokenDataSource import kotlinx.coroutines.runBlocking import okhttp3.Authenticator import okhttp3.Request @@ -10,27 +9,11 @@ import okhttp3.Route import javax.inject.Inject internal class AuthAuthenticator @Inject constructor( - private val tokenDataSource: TokenDataSource, - private val authDataSource: AuthDataSource, + private val authTokenDataSource: AuthTokenDataSource, ) : Authenticator { override fun authenticate(route: Route?, response: Response): Request? { - val accessToken = runBlocking { getNewAccessToken() } ?: return null + val accessToken = runBlocking { authTokenDataSource.getNewAccessToken() } ?: return null return response.request.newBuilder().header("Authorization", "Bearer $accessToken").build() } - - private suspend fun getNewAccessToken(): String? { - val response = authDataSource.refresh() - val newToken = response.getOrNull() - return newToken?.let { - tokenDataSource.saveTokens( - accessToken = it.accessToken, - refreshToken = it.refreshToken, - ) - it.accessToken - } ?: run { - authDataSource.logout() - null - } - } } diff --git a/data/src/main/java/com/nexters/boolti/data/repository/AuthRepositoryImpl.kt b/data/src/main/java/com/nexters/boolti/data/repository/AuthRepositoryImpl.kt index 9835a6d9..1ae60fd7 100644 --- a/data/src/main/java/com/nexters/boolti/data/repository/AuthRepositoryImpl.kt +++ b/data/src/main/java/com/nexters/boolti/data/repository/AuthRepositoryImpl.kt @@ -1,13 +1,15 @@ package com.nexters.boolti.data.repository import com.nexters.boolti.data.datasource.AuthDataSource +import com.nexters.boolti.data.datasource.AuthTokenDataSource import com.nexters.boolti.data.datasource.DeviceTokenDataSource import com.nexters.boolti.data.datasource.SignUpDataSource import com.nexters.boolti.data.datasource.TokenDataSource import com.nexters.boolti.data.datasource.UserDataSource import com.nexters.boolti.data.network.response.LoginResponse +import com.nexters.boolti.domain.model.AccessToken import com.nexters.boolti.domain.model.LoginUserState -import com.nexters.boolti.domain.model.TokenPair +import com.nexters.boolti.domain.model.TokenPairs import com.nexters.boolti.domain.model.User import com.nexters.boolti.domain.repository.AuthRepository import com.nexters.boolti.domain.request.EditProfileRequest @@ -23,6 +25,7 @@ import javax.inject.Inject internal class AuthRepositoryImpl @Inject constructor( private val authDataSource: AuthDataSource, private val tokenDataSource: TokenDataSource, + private val authTokenDataSource: AuthTokenDataSource, private val signUpDataSource: SignUpDataSource, private val userDateSource: UserDataSource, private val deviceTokenDataSource: DeviceTokenDataSource, @@ -72,7 +75,13 @@ internal class AuthRepositoryImpl @Inject constructor( .onSuccess { authDataSource.updateUser(it) } .mapCatching {} - override fun getTokens(): Flow = authDataSource.getTokens().map { - TokenPair(it.first, it.second) + override fun getTokens(): Flow = authDataSource.getTokens().map { + TokenPairs(it.first, it.second) + } + + override fun refreshToken(): Flow = flow { + authTokenDataSource.getNewAccessToken()?.let { + emit(AccessToken(it)) + } } } diff --git a/domain/src/main/java/com/nexters/boolti/domain/model/TokenPair.kt b/domain/src/main/java/com/nexters/boolti/domain/model/TokenPairs.kt similarity index 58% rename from domain/src/main/java/com/nexters/boolti/domain/model/TokenPair.kt rename to domain/src/main/java/com/nexters/boolti/domain/model/TokenPairs.kt index ab04154a..e79184ff 100644 --- a/domain/src/main/java/com/nexters/boolti/domain/model/TokenPair.kt +++ b/domain/src/main/java/com/nexters/boolti/domain/model/TokenPairs.kt @@ -1,8 +1,14 @@ package com.nexters.boolti.domain.model -data class TokenPair( +data class TokenPairs( val accessToken: String, val refreshToken: String, ) { val isLoggedIn: Boolean = accessToken.isNotBlank() && refreshToken.isNotBlank() } + +@JvmInline +value class AccessToken(val token: String) + +@JvmInline +value class RefreshToken(val token: String) diff --git a/domain/src/main/java/com/nexters/boolti/domain/repository/AuthRepository.kt b/domain/src/main/java/com/nexters/boolti/domain/repository/AuthRepository.kt index 9ac3a335..a2b6e65f 100644 --- a/domain/src/main/java/com/nexters/boolti/domain/repository/AuthRepository.kt +++ b/domain/src/main/java/com/nexters/boolti/domain/repository/AuthRepository.kt @@ -1,7 +1,8 @@ package com.nexters.boolti.domain.repository +import com.nexters.boolti.domain.model.AccessToken import com.nexters.boolti.domain.model.LoginUserState -import com.nexters.boolti.domain.model.TokenPair +import com.nexters.boolti.domain.model.TokenPairs import com.nexters.boolti.domain.model.User import com.nexters.boolti.domain.request.EditProfileRequest import com.nexters.boolti.domain.request.LoginRequest @@ -30,5 +31,7 @@ interface AuthRepository { suspend fun editProfile(editProfileRequest: EditProfileRequest): Result - fun getTokens(): Flow + fun getTokens(): Flow + fun refreshToken(): Flow } + diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index d00668f5..3598d9e7 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) alias(libs.plugins.hilt) + alias(libs.plugins.kotlin.serialization) id("kotlin-kapt") id("kotlin-parcelize") } @@ -82,6 +83,8 @@ dependencies { implementation(libs.zoomable) kapt(libs.hilt.compiler) + implementation(libs.kotlinx.serialization.json) + implementation(libs.lottie) implementation(libs.bundles.coil) api(libs.kakao.login) diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/component/BtWebView.kt b/presentation/src/main/java/com/nexters/boolti/presentation/component/BtWebView.kt index 41326bfa..96df6f50 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/component/BtWebView.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/component/BtWebView.kt @@ -4,12 +4,19 @@ import android.annotation.SuppressLint import android.content.Context import android.net.Uri import android.util.AttributeSet +import android.webkit.ConsoleMessage +import android.webkit.JavascriptInterface import android.webkit.ValueCallback import android.webkit.WebChromeClient import android.webkit.WebView import android.webkit.WebViewClient +import com.nexters.boolti.presentation.util.bridge.BridgeDto +import com.nexters.boolti.presentation.util.bridge.BridgeManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import timber.log.Timber class BtWebView @JvmOverloads constructor( context: Context, @@ -53,6 +60,38 @@ class BtWebView @JvmOverloads constructor( onProgressChanged = { _progress.value = it }, ) } + + suspend fun setBridgeManager(bridgeManager: BridgeManager) { + addJavascriptInterface( + object { + @JavascriptInterface + fun postMessage(message: String) { + Timber.tag("webview_bridge").d("(WEB -> APP) $message 수신") + try { + // JSON 메시지 파싱 + val dto = Json.decodeFromString(BridgeDto.serializer(), message) + bridgeManager.handleIncomingData(dto) + } catch (e: SerializationException) { + Timber.tag("webview_bridge") + .e(e, "(WEB -> APP) 유효하지 않은 JSON 포맷: $message") + } catch (e: IllegalArgumentException) { + Timber.tag("webview_bridge") + .e(e, "(WEB -> APP) BridgeDto 타입으로 파싱 실패: $message") + } catch (e: Exception) { + Timber.tag("webview_bridge") + .e(e, "(WEB -> APP) 알 수 없는 에러") + e.printStackTrace() // 에러 처리 + } + } + }, + bridgeManager.bridgeName, + ) + bridgeManager.dataToSendWeb.collect { + evaluateJavascript(it) { result -> + Timber.tag("webview_bridge").d("(APP -> WEB)\n\t$it\n전송 결과:\n\t$result") + } + } + } } class BtWebViewClient : WebViewClient() @@ -79,4 +118,10 @@ class BtWebChromeClient( return true } + + override fun onConsoleMessage(message: ConsoleMessage?): Boolean { + Timber.tag("webview_console Message bridge") + .d("${message?.message()} -- From line ${message?.lineNumber()} of ${message?.sourceId()}") + return true + } } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/Main.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/Main.kt index 32c7e706..6d4e1319 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/Main.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/Main.kt @@ -298,6 +298,8 @@ fun MainNavigation(modifier: Modifier, onClickQrScan: (showId: String, showName: addShowRegistration( modifier = modifier, popBackStack = navController::popBackStack, + navigateTo = navController::navigateTo, + navigateToHome = navController::navigateToHome, ) } } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/my/MyScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/my/MyScreen.kt index c3ba4ba9..5678c1d0 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/my/MyScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/my/MyScreen.kt @@ -1,6 +1,5 @@ package com.nexters.boolti.presentation.screen.my -import android.widget.Toast import androidx.annotation.DrawableRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -78,10 +77,7 @@ fun MyScreen( onClickHeaderButton = if (user != null) navigateToProfile else requireLogin, onClickAccountSetting = if (user != null) onClickAccountSetting else requireLogin, onClickReservations = if (user != null) navigateToReservations else requireLogin, - onClickRegisterShow = { - uriHandler.openUri(url) - Toast.makeText(context, "공연 등록을 위해 웹으로 이동합니다", Toast.LENGTH_LONG).show() - },// navigateToShowRegistration, // TODO 추후 인앱 공연 등록 반영 시 주석 해제 + onClickRegisterShow = if (user != null) navigateToShowRegistration else requireLogin, onClickQrScan = if (user != null) onClickQrScan else requireLogin, ) } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/showregistration/ShowRegistrationNavigation.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/showregistration/ShowRegistrationNavigation.kt index 991e4659..44cc9577 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/showregistration/ShowRegistrationNavigation.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/showregistration/ShowRegistrationNavigation.kt @@ -8,6 +8,8 @@ import com.nexters.boolti.presentation.screen.MainDestination fun NavGraphBuilder.addShowRegistration( modifier: Modifier = Modifier, popBackStack: () -> Unit, + navigateTo: (route: String) -> Unit, + navigateToHome: () -> Unit, ) { composable( route = MainDestination.ShowRegistration.route, @@ -15,6 +17,8 @@ fun NavGraphBuilder.addShowRegistration( ShowRegistrationScreen( modifier = modifier, onClickBack = popBackStack, + navigateTo = navigateTo, + navigateToHome = navigateToHome, ) } } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/showregistration/ShowRegistrationScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/showregistration/ShowRegistrationScreen.kt index aab032df..4d021a2b 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/showregistration/ShowRegistrationScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/showregistration/ShowRegistrationScreen.kt @@ -4,15 +4,12 @@ import android.annotation.SuppressLint import android.net.Uri import android.webkit.CookieManager import android.webkit.ValueCallback -import android.webkit.WebStorage -import android.webkit.WebView import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding @@ -39,17 +36,20 @@ import com.nexters.boolti.presentation.component.BTDialog import com.nexters.boolti.presentation.component.BtBackAppBar import com.nexters.boolti.presentation.component.BtCircularProgressIndicator import com.nexters.boolti.presentation.component.BtWebView -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterNotNull +import com.nexters.boolti.presentation.util.bridge.BridgeCallbackHandler +import com.nexters.boolti.presentation.util.bridge.BridgeManager +import com.nexters.boolti.presentation.util.bridge.NavigateOption +import com.nexters.boolti.presentation.util.bridge.TokenDto import kotlinx.coroutines.launch import timber.log.Timber -@OptIn(ExperimentalLayoutApi::class) @SuppressLint("SetJavaScriptEnabled") @Composable fun ShowRegistrationScreen( modifier: Modifier = Modifier, onClickBack: () -> Unit, + navigateTo: (route: String) -> Unit, + navigateToHome: () -> Unit, viewModel: ShowRegistrationViewModel = hiltViewModel(), ) { var filePathCallback: ValueCallback>? by remember { mutableStateOf(null) } @@ -64,24 +64,33 @@ fun ShowRegistrationScreen( var showExitDialog by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - var webView: WebView? by remember { mutableStateOf(null) } + var webView: BtWebView? by remember { mutableStateOf(null) } var webviewProgress by remember { mutableIntStateOf(0) } val loading by remember { derivedStateOf { webviewProgress < 100 } } - LaunchedEffect(Unit) { - CookieManager.getInstance().removeAllCookies(null) - WebStorage.getInstance().deleteAllData() + LaunchedEffect(webView != null) { + webView?.setBridgeManager( + BridgeManager( + callbackHandler = object : BridgeCallbackHandler { + override suspend fun fetchToken(): TokenDto { + val accessToken = viewModel.refreshAndGetToken() + return TokenDto(token = accessToken.token) + } - viewModel.tokens - .filterNotNull() - .filter { it.isLoggedIn } - .collect { tokens -> - with(CookieManager.getInstance()) { - setCookie(url, "x-access-token=${tokens.accessToken}") - setCookie(url, "x-refresh-token=${tokens.refreshToken}") - flush() - } - } + override fun navigateTo(route: String, navigateOption: NavigateOption) { + when (navigateOption) { + NavigateOption.PUSH -> navigateTo(route) + NavigateOption.HOME -> navigateToHome() + NavigateOption.CLOSE_AND_OPEN -> { + onClickBack() + navigateTo(route) + } + } + } + }, + scope = scope, + ) + ) } BackHandler { diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/showregistration/ShowRegistrationViewModel.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/showregistration/ShowRegistrationViewModel.kt index d3999d3d..040530e5 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/showregistration/ShowRegistrationViewModel.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/showregistration/ShowRegistrationViewModel.kt @@ -1,16 +1,14 @@ package com.nexters.boolti.presentation.screen.showregistration import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.nexters.boolti.domain.repository.AuthRepository -import com.nexters.boolti.presentation.extension.stateInUi import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.first import javax.inject.Inject @HiltViewModel class ShowRegistrationViewModel @Inject constructor( val authRepository: AuthRepository, ) : ViewModel() { - val tokens = authRepository.getTokens() - .stateInUi(viewModelScope, null) -} \ No newline at end of file + suspend fun refreshAndGetToken() = authRepository.refreshToken().first() +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/util/bridge/BridgeCallbackHandler.kt b/presentation/src/main/java/com/nexters/boolti/presentation/util/bridge/BridgeCallbackHandler.kt new file mode 100644 index 00000000..69cc25f1 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/util/bridge/BridgeCallbackHandler.kt @@ -0,0 +1,22 @@ +package com.nexters.boolti.presentation.util.bridge + +import kotlinx.serialization.Serializable + +/** + * 브릿지를 통해 받은 데이터로부터 처리해야 하는 액션을 정의하는 핸들러 + * + * 새로운 액션이 정의되는 경우 [BridgeManager.handleIncomingData] 도 함께 수정되어야 한다. + */ +interface BridgeCallbackHandler { + suspend fun fetchToken(): TokenDto + fun navigateTo(route: String, navigateOption: NavigateOption = NavigateOption.PUSH) +} + +enum class NavigateOption { + PUSH, HOME, CLOSE_AND_OPEN, +} + +@Serializable +data class TokenDto( + val token: String, +) diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/util/bridge/BridgeDto.kt b/presentation/src/main/java/com/nexters/boolti/presentation/util/bridge/BridgeDto.kt new file mode 100644 index 00000000..51eae6cd --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/util/bridge/BridgeDto.kt @@ -0,0 +1,12 @@ +package com.nexters.boolti.presentation.util.bridge + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class BridgeDto( + val id: String, + val command: CommandType, + val timestamp: Long = System.currentTimeMillis(), + val data: JsonElement? = null, +) diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/util/bridge/BridgeManager.kt b/presentation/src/main/java/com/nexters/boolti/presentation/util/bridge/BridgeManager.kt new file mode 100644 index 00000000..7e67b9d8 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/util/bridge/BridgeManager.kt @@ -0,0 +1,109 @@ +package com.nexters.boolti.presentation.util.bridge + +import com.nexters.boolti.presentation.screen.MainDestination +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject +import timber.log.Timber + +class BridgeManager( + private val callbackHandler: BridgeCallbackHandler, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob()), + val bridgeName: String = DEFAULT_BRIDGE_NAME, + private val webCallbackName: String = DEFAULT_WEB_CALLBACK_NAME, +) { + private val originDataMap = mutableMapOf() + private val _dataToSendToWeb = Channel() + val dataToSendWeb = _dataToSendToWeb.receiveAsFlow() + + /** + * 웹뷰에서 호출하는 메서드 + * + * 웹 브릿지를 통해 받은 데이터의 타입에 따라 [BridgeCallbackHandler]의 메서드를 호출한다. + * 이후 함수 내부에서 [BridgeCallbackHandler]의 메서드로부터 받은 결과를 웹으로 콜백한다. + * + * @param data + */ + fun handleIncomingData(data: BridgeDto) { + originDataMap[data.id] = data // 디버깅을 위한 캐싱 + + when (data.command) { + CommandType.REQUEST_TOKEN -> { + scope.launch { + val token = callbackHandler.fetchToken() + callbackToWeb(data, json.encodeToJsonElement(token)) + } + } + + CommandType.NAVIGATE_TO_SHOW_DETAIL -> { + data.data?.jsonObject?.get("showId")?.toString()?.let { showId -> + scope.launch { + withContext(Dispatchers.Main) { + Timber.tag("bridge").d("공연 상세 화면으로 이동 $showId") + callbackHandler.navigateTo( + route = MainDestination.ShowDetail.createRoute(showId), + navigateOption = NavigateOption.CLOSE_AND_OPEN, + ) + } + } + } ?: Timber.tag("bridge").d("공연 상세 화면으로 이동 실패: showId 없음") + callbackToWeb(data, null) + } + + else -> callbackToWeb(data, null) + } + } + + /** + * WebView 에서 웹에 evaluation 할 자바스크립트 코드 전달 + * + * @param data + */ + fun sendDataToWeb(data: String) { + scope.launch { + _dataToSendToWeb.send("javascript:$webCallbackName($data)") + } + } + + /** + * 웹에서 브릿지를 통해 데이터를 받은 경우, 웹으로 콜백 해줘야 한다. (콜백 해주지 않으면 웹의 비동기 코드가 종료되지 않음) + * + * 예를 들어 화면 이동의 경우 웹으로 전달할 데이터가 없으니 [BridgeDto.timestamp]를 제외한 나머지 정보는 에코 방식으로 그대로 콜백한다. + * + * 토큰 요청과 같이 웹으로 전달할 데이터가 있는 경우 [BridgeDto.data]에 웹과 맞춘 형태로 데이터를 포함시키고, 나머지 [BridgeDto.id]와 [BridgeDto.command]는 그대로 콜백한다. + * + * @param originData 웹에서 브릿지를 통해 받은 원본 데이터 + * @param responseData [BridgeDto.data]에 포함될 데이터. `null` 이라면 원본 [BridgeDto.data] 를 사용한다. + */ + private fun callbackToWeb( + originData: BridgeDto, + responseData: JsonElement? + ) { + val responseDto = originData.copy( + data = responseData, + timestamp = System.currentTimeMillis(), + ) + val json = json.encodeToString(responseDto) + sendDataToWeb(json) + } + + companion object { + private val json = Json { + isLenient = true + prettyPrint = true + ignoreUnknownKeys = true + } + + const val DEFAULT_BRIDGE_NAME = "boolti" + const val DEFAULT_WEB_CALLBACK_NAME = "__boolti__webview__bridge__.postMessage" + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/util/bridge/CommandType.kt b/presentation/src/main/java/com/nexters/boolti/presentation/util/bridge/CommandType.kt new file mode 100644 index 00000000..ed728aeb --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/util/bridge/CommandType.kt @@ -0,0 +1,16 @@ +package com.nexters.boolti.presentation.util.bridge + +import kotlinx.serialization.Serializable + +@Serializable +enum class CommandType { + NAVIGATE_TO_SHOW_DETAIL, + NAVIGATE_BACK, + REQUEST_TOKEN, + UNKNOWN; + + companion object { + fun fromString(value: String): CommandType = + CommandType.entries.find { it.name == value.trim().uppercase() } ?: UNKNOWN + } +}