Skip to content

Commit

Permalink
RemoteConfig / ReactBundleManager 리액티브하게 리팩토링 (#224)
Browse files Browse the repository at this point in the history
  • Loading branch information
JuTaK97 authored Oct 7, 2023
1 parent 8f5a088 commit 87fa190
Show file tree
Hide file tree
Showing 12 changed files with 184 additions and 99 deletions.
74 changes: 32 additions & 42 deletions app/src/main/java/com/wafflestudio/snutt2/RemoteConfig.kt
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
package com.wafflestudio.snutt2

import com.wafflestudio.snutt2.data.user.UserRepository
import com.wafflestudio.snutt2.lib.network.NetworkConnectivityManager
import com.wafflestudio.snutt2.lib.network.SNUTTRestApi
import com.wafflestudio.snutt2.lib.network.dto.core.RemoteConfigDto
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
Expand All @@ -22,42 +22,32 @@ import javax.inject.Singleton
class RemoteConfig @Inject constructor(
api: SNUTTRestApi,
userRepository: UserRepository,
networkConnectivityManager: NetworkConnectivityManager,
) {
private val fetchDone = MutableSharedFlow<Unit>(replay = 1)
private val config = callbackFlow {
userRepository.accessToken.filter { it.isNotEmpty() }.collect {
withContext(Dispatchers.IO) {
try {
send(api._getRemoteConfig())
} catch (e: Exception) {
this@callbackFlow.close()
private val config = MutableStateFlow(RemoteConfigDto())
init {
CoroutineScope(Dispatchers.Main).launch {
combine(
userRepository.accessToken.filter { it.isNotEmpty() },
networkConnectivityManager.networkConnectivity.filter { it },
) { _, _ ->
withContext(Dispatchers.IO) {
runCatching {
api._getRemoteConfig()
}.onSuccess {
config.emit(it)
}
}
}
}.collect()
}
awaitClose {}
}.onCompletion {
fetchDone.emit(Unit)
}.onEach {
fetchDone.emit(Unit)
}.stateIn(
CoroutineScope(Dispatchers.Main),
SharingStarted.Eagerly,
RemoteConfigDto(),
)

val friendBundleSrc: String
get() = config.value.reactNativeBundleSrc?.src?.get("android") ?: ""

val vacancyNotificationBannerEnabled: Boolean
get() = config.value.vacancyBannerConfig.visible

val sugangSNUUrl: String
get() = config.value.vacancyUrlConfig.url ?: ""

val settingPageNewBadgeTitles: List<String>
get() = config.value.settingsBadgeConfig.new

suspend fun waitForFetchConfig() {
fetchDone.first()
}

val friendsBundleSrc: Flow<String>
get() = config.map { it.reactNativeBundleSrc?.src?.get("android") }.filterNotNull()
val vacancyNotificationBannerEnabled: Flow<Boolean>
get() = config.map { it.vacancyBannerConfig.visible }
val sugangSNUUrl: Flow<String>
get() = config.map { it.vacancyUrlConfig.url }.filterNotNull()
val settingPageNewBadgeTitles: Flow<List<String>>
get() = config.map { it.settingsBadgeConfig.new }
}
8 changes: 8 additions & 0 deletions app/src/main/java/com/wafflestudio/snutt2/di/NetworkModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.wafflestudio.snutt2.di

import android.annotation.SuppressLint
import android.content.Context
import android.net.ConnectivityManager
import android.os.Build
import android.provider.Settings.Secure
import com.squareup.moshi.Moshi
Expand Down Expand Up @@ -132,4 +133,11 @@ object NetworkModule {
private const val SIZE_OF_CACHE = (
10 * 1024 * 1024 // 10 MB
).toLong()

@Provides
fun provideConnectivityManager(
@ApplicationContext context: Context,
): ConnectivityManager {
return (context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.wafflestudio.snutt2.lib.network

import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.shareIn
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class NetworkConnectivityManager @Inject constructor(
connectivityManager: ConnectivityManager,
) {
private val _networkConnectivity = MutableStateFlow<Boolean?>(null)
val networkConnectivity = _networkConnectivity.filterNotNull()
.shareIn(
CoroutineScope(Dispatchers.Main),
SharingStarted.Eagerly,
replay = 1,
)

init {
connectivityManager.registerNetworkCallback(
NetworkRequest.Builder().apply {
addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
}.build(),
object : ConnectivityManager.NetworkCallback() {
override fun onLost(network: Network) {
super.onLost(network)
_networkConnectivity.value = false
}

override fun onAvailable(network: Network) {
super.onAvailable(network)
_networkConnectivity.value = true
}
},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,73 +17,85 @@ import com.swmansion.reanimated.ReanimatedPackage
import com.th3rdwave.safeareacontext.SafeAreaContextPackage
import com.wafflestudio.snutt2.R
import com.wafflestudio.snutt2.RemoteConfig
import com.wafflestudio.snutt2.ui.ThemeMode
import com.wafflestudio.snutt2.ui.isSystemDarkMode
import com.wafflestudio.snutt2.data.user.UserRepository
import com.wafflestudio.snutt2.lib.network.NetworkConnectivityManager
import com.wafflestudio.snutt2.ui.isDarkMode
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import javax.inject.Inject

class ReactNativeBundleManager(
private val context: Context,
private val remoteConfig: RemoteConfig,
private val token: StateFlow<String>,
private val themeMode: StateFlow<ThemeMode>,
@ActivityScoped
class ReactNativeBundleManager @Inject constructor(
@ApplicationContext applicationContext: Context,
@ActivityContext activityContext: Context,
remoteConfig: RemoteConfig,
userRepository: UserRepository,
networkConnectivityManager: NetworkConnectivityManager,
) {
private val rnBundleFileSrc: String
get() = if (USE_LOCAL_BUNDLE) LOCAL_BUNDLE_URL else remoteConfig.friendBundleSrc
private var myReactInstanceManager: ReactInstanceManager? = null
var reactRootView = mutableStateOf<ReactRootView?>(null)
private val reloadSignal = MutableSharedFlow<Unit>()

init {
CoroutineScope(Dispatchers.IO).launch {
remoteConfig.waitForFetchConfig()
token.filter { it.isNotEmpty() }.collectLatest {
val jsBundleFile = getExistingFriendsBundleFileOrNull() ?: return@collectLatest
withContext(Dispatchers.Main) {
myReactInstanceManager = ReactInstanceManager.builder()
.setApplication(context.applicationContext as Application)
.setCurrentActivity(context as Activity)
.setJavaScriptExecutorFactory(HermesExecutorFactory())
.setJSBundleFile(jsBundleFile.absolutePath)
.addPackages(
listOf(MainReactPackage(), RNGestureHandlerPackage(), ReanimatedPackage(), SafeAreaContextPackage(), RNCPickerPackage(), SvgPackage()),
)
.setInitialLifecycleState(LifecycleState.RESUMED)
.build()
themeMode.collectLatest {
val isDarkMode = when (it) {
ThemeMode.AUTO -> isSystemDarkMode(context)
else -> (it == ThemeMode.DARK)
combine(
if (USE_LOCAL_BUNDLE) MutableStateFlow(LOCAL_BUNDLE_URL) else remoteConfig.friendsBundleSrc,
userRepository.accessToken.filter { it.isNotEmpty() },
userRepository.themeMode,
networkConnectivityManager.networkConnectivity.filter { it },
reloadSignal.onStart { emit(Unit) },
) { bundleSrc, token, theme, _, _ ->
getExistingBundleFileOrNull(applicationContext, bundleSrc)?.let { bundleFile ->
withContext(Dispatchers.Main) {
if (myReactInstanceManager == null) {
myReactInstanceManager = ReactInstanceManager.builder()
.setApplication(applicationContext as Application)
.setCurrentActivity(activityContext as Activity)
.setJavaScriptExecutorFactory(HermesExecutorFactory())
.setJSBundleFile(bundleFile.absolutePath)
.addPackages(
listOf(MainReactPackage(), RNGestureHandlerPackage(), ReanimatedPackage(), SafeAreaContextPackage(), RNCPickerPackage(), SvgPackage()),
)
.setInitialLifecycleState(LifecycleState.RESUMED)
.build()
}
reactRootView.value = ReactRootView(context).apply {

reactRootView.value = ReactRootView(activityContext).apply {
startReactApplication(
myReactInstanceManager ?: return@apply,
FRIENDS_MODULE_NAME,
Bundle().apply {
putString("x-access-token", token.value)
putString("x-access-token", token)
putString("x-access-apikey", context.getString(R.string.api_key))
putString("theme", if (isDarkMode) "dark" else "light")
putString("theme", if (isDarkMode(activityContext, theme)) "dark" else "light")
putBoolean("allowFontScaling", true)
},
)
}
}
}
}
}.collect()
}
}

// 번들 파일을 저장할 폴더 (없으면 만들기, 실패하면 null)
private fun bundlesBaseFolder(): File? {
val baseDir = File(context.applicationContext.dataDir.absolutePath, BUNDLE_BASE_FOLDER)
private fun bundlesBaseFolder(context: Context): File? {
val baseDir = File(context.dataDir.absolutePath, BUNDLE_BASE_FOLDER)
return if (baseDir.isDirectory && baseDir.exists()) {
baseDir
} else if (baseDir.mkdir()) {
Expand All @@ -93,9 +105,13 @@ class ReactNativeBundleManager(
}
}

private fun getExistingFriendsBundleFileOrNull(): File? {
val baseDir = bundlesBaseFolder() ?: return null
val friendsBaseDir = File(baseDir, FRIENDS_MODULE_NAME)
private fun getExistingBundleFileOrNull(
context: Context,
rnBundleFileSrc: String,
moduleName: String = FRIENDS_MODULE_NAME, // 나중에 다른 모듈 추가되면 이 파라미터 사용
): File? {
val baseDir = bundlesBaseFolder(context) ?: return null
val friendsBaseDir = File(baseDir, moduleName)
if (friendsBaseDir.exists().not() && friendsBaseDir.mkdir().not()) return null

// Config에서 가져온 bundle name대로 fileName을 만든다.
Expand Down Expand Up @@ -135,6 +151,11 @@ class ReactNativeBundleManager(
return targetFile
}

// 수동으로 번들 reload하고 싶을 때 사용
suspend fun reloadBundle() {
reloadSignal.emit(Unit)
}

// 번들 파일들은 $rootDir/data/ReactNativeBundles 폴더에 각 모듈별로 저장된다.
// friends 모듈의 번들 파일은 $rootDir/data/ReactNativeBundles/friends 폴더에 저장된다.
// 번들 파일의 이름은 src가 https://~~~.com/{version}/android.jsbundle 일 때 version-android.jsbundle 이다.
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/com/wafflestudio/snutt2/ui/Theme.kt
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ fun isDarkMode(): Boolean {
}
}

fun isDarkMode(
context: Context,
theme: ThemeMode,
): Boolean {
return when (theme) {
ThemeMode.AUTO -> isSystemDarkMode(context)
else -> (theme == ThemeMode.DARK)
}
}

fun isSystemDarkMode(context: Context): Boolean {
return when (context.resources?.configuration?.uiMode?.and(Configuration.UI_MODE_NIGHT_MASK)) {
Configuration.UI_MODE_NIGHT_YES -> true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,13 @@ class RootActivity : AppCompatActivity() {
@Inject
lateinit var remoteConfig: RemoteConfig

@Inject
lateinit var friendBundleManager: ReactNativeBundleManager

private var isInitialRefreshFinished = false

private val composeRoot by lazy { findViewById<ComposeView>(R.id.compose_root) }

private val friendBundleManager by lazy {
ReactNativeBundleManager(this, remoteConfig, userViewModel.accessToken, userViewModel.themeMode)
}

override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()

Expand Down Expand Up @@ -206,7 +205,7 @@ class RootActivity : AppCompatActivity() {
) {
onboardGraph()

composableRoot(NavigationDestination.Home) { HomePage(friendBundleManager) }
composableRoot(NavigationDestination.Home) { HomePage() }

composable2(NavigationDestination.Notification) { NotificationPage() }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import com.wafflestudio.snutt2.layouts.ModalDrawerWithBottomSheetLayout
import com.wafflestudio.snutt2.lib.android.webview.WebViewContainer
import com.wafflestudio.snutt2.lib.network.dto.core.TableDto
import com.wafflestudio.snutt2.provider.TimetableWidgetProvider
import com.wafflestudio.snutt2.react_native.ReactNativeBundleManager
import com.wafflestudio.snutt2.ui.SNUTTColors
import com.wafflestudio.snutt2.ui.isDarkMode
import com.wafflestudio.snutt2.views.*
Expand All @@ -39,7 +38,7 @@ import kotlinx.coroutines.launch

@OptIn(ExperimentalAnimationApi::class)
@Composable
fun HomePage(reactNativeBundleManager: ReactNativeBundleManager) {
fun HomePage() {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val pageController = LocalHomePageController.current
Expand Down Expand Up @@ -130,7 +129,7 @@ fun HomePage(reactNativeBundleManager: ReactNativeBundleManager) {
ReviewPage()
}
}
HomeItem.Friends -> FriendsPage(reactNativeBundleManager)
HomeItem.Friends -> FriendsPage()
HomeItem.Settings -> SettingsPage(uncheckedNotification)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ class HomeViewModel @Inject constructor(
} ?: tableRepository.fetchDefaultTable()
},
async { userRepository.fetchUserInfo() },
async { remoteConfig.waitForFetchConfig() },
)
}
} catch (e: Exception) {
Expand Down
Loading

0 comments on commit 87fa190

Please sign in to comment.