Skip to content

Commit

Permalink
Merge branch 'develop' into eastshine2741/add-notification-type
Browse files Browse the repository at this point in the history
  • Loading branch information
eastshine2741 authored Dec 23, 2023
2 parents ecb1e72 + 58d65e1 commit fe2cb71
Show file tree
Hide file tree
Showing 18 changed files with 213 additions and 155 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 }
}
2 changes: 2 additions & 0 deletions app/src/main/java/com/wafflestudio/snutt2/SNUTTApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.facebook.react.ReactPackage
import com.facebook.react.bridge.JavaScriptExecutorFactory
import com.facebook.react.shell.MainReactPackage
import com.horcrux.svg.SvgPackage
import com.reactnativecommunity.asyncstorage.AsyncStoragePackage
import com.reactnativecommunity.picker.RNCPickerPackage
import com.swmansion.gesturehandler.RNGestureHandlerPackage
import com.swmansion.reanimated.ReanimatedPackage
Expand Down Expand Up @@ -48,6 +49,7 @@ class SNUTTApplication : Application(), ReactApplication {
SafeAreaContextPackage(),
ReanimatedPackage(),
SvgPackage(),
AsyncStoragePackage(),
)

override fun getJSMainModuleName(): String = "friends"
Expand Down
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 @@ -11,79 +11,93 @@ import com.facebook.react.ReactRootView
import com.facebook.react.common.LifecycleState
import com.facebook.react.shell.MainReactPackage
import com.horcrux.svg.SvgPackage
import com.reactnativecommunity.asyncstorage.AsyncStoragePackage
import com.reactnativecommunity.picker.RNCPickerPackage
import com.swmansion.gesturehandler.RNGestureHandlerPackage
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(), AsyncStoragePackage()),
)
.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)
putStringArrayList("feature", arrayListOf("ASYNC_STORAGE"))
},
)
}
}
}
}
}.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 +107,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 +153,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
Loading

0 comments on commit fe2cb71

Please sign in to comment.