Skip to content

Commit

Permalink
Add feature flag support using launchdarkly
Browse files Browse the repository at this point in the history
  • Loading branch information
newmskywalker committed Oct 20, 2024
1 parent 3a0416d commit c4f023f
Show file tree
Hide file tree
Showing 14 changed files with 132 additions and 11 deletions.
1 change: 1 addition & 0 deletions .github/workflows/android-preview-branch-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ jobs:
echo "RECAPTCHA_SITE_KEY=${{ secrets.RECAPTCHA_SITE_KEY }}" >> ./local.properties
echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> ./local.properties
echo "LAUNCHDARKLY_MOBILE_KEY=${{ secrets.LAUNCHDARKLY_MOBILE_KEY }}" >> ./local.properties
echo "ANDROID_SENTRY_DSN=${{ secrets.ANDROID_SENTRY_DSN }}" >> ./local.properties
echo "auth.token=${{ secrets.ANDROID_SENTRY_AUTH_TOKEN}}" >> ./sentry.properties
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/android-release-bundle-ontag.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
echo "RECAPTCHA_SITE_KEY=${{ secrets.RECAPTCHA_SITE_KEY }}" >> ./local.properties
echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> ./local.properties
echo "ANDROID_SENTRY_DSN=${{ secrets.ANDROID_SENTRY_DSN }}" >> ./local.properties
echo "LAUNCHDARKLY_MOBILE_KEY=${{ secrets.LAUNCHDARKLY_MOBILE_KEY }}" >> ./local.properties
echo "auth.token=${{ secrets.ANDROID_SENTRY_AUTH_TOKEN}}" >> ./sentry.properties
- name: create google-services.json
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/android-ui-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ jobs:
echo "RECAPTCHA_SITE_KEY=${{ secrets.RECAPTCHA_SITE_KEY }}" >> ./local.properties
echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> ./local.properties
echo "ANDROID_SENTRY_DSN=${{ secrets.ANDROID_SENTRY_DSN }}" >> ./local.properties
echo "LAUNCHDARKLY_MOBILE_KEY=${{ secrets.LAUNCHDARKLY_MOBILE_KEY }}" >> ./local.properties
echo "auth.token=${{ secrets.ANDROID_SENTRY_AUTH_TOKEN}}" >> ./sentry.properties
- name: create google-services.json
Expand Down
1 change: 1 addition & 0 deletions android/app-newm/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.material)
implementation(libs.androidx.navigation.ui.ktx)
implementation(libs.launchdarkly.client)
implementation(libs.play.services.auth)
implementation(libs.recaptcha)
implementation(libs.androidx.core.splashscreen)
Expand Down
31 changes: 28 additions & 3 deletions android/app-newm/src/main/java/io/newm/AppLaunchGhostActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,22 @@ import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
import io.newm.shared.NewmAppLogger
import io.newm.shared.public.featureflags.FeatureFlagManager
import io.newm.shared.public.usecases.UserDetailsUseCase
import io.newm.shared.public.usecases.UserSessionUseCase
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject

class AppLaunchGhostActivity : ComponentActivity() {

private val tag = "AppLaunchGhostActivity"
private val userSession: UserSessionUseCase by inject()
private val userDetailsUseCase: UserDetailsUseCase by inject()
private val featureFlagMager: FeatureFlagManager by inject()
private val logger: NewmAppLogger by inject()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Expand All @@ -18,18 +28,33 @@ class AppLaunchGhostActivity : ComponentActivity() {
}

if (userSession.isLoggedIn()) {
launchHomeActivity()
lifecycleScope.launch {
try {
val user =
userDetailsUseCase.fetchLoggedInUserDetailsFlow().filterNotNull().first()
featureFlagMager.setUserId(user.id)
} catch (e: Exception) {
logger.error(
tag,
"Failed to identify feature flag because fetching user details failed.",
e
)
} finally {
launchHomeActivity()
}
}
} else {
launchLoginActivity()
}
finish()
}

private fun launchHomeActivity() {
startActivity(Intent(this@AppLaunchGhostActivity, HomeActivity::class.java))
finish()
}

private fun launchLoginActivity() {
startActivity(Intent(this@AppLaunchGhostActivity, LoginActivity::class.java))
finish()
}
}
13 changes: 10 additions & 3 deletions android/app-newm/src/main/java/io/newm/HomeActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ import com.slack.circuit.runtime.ui.Ui
import io.newm.core.theme.NewmTheme
import io.newm.screens.Screen
import io.newm.screens.Screen.NFTLibrary
import io.newm.screens.profile.view.ProfilePresenter
import io.newm.screens.profile.view.ProfileUiState
import io.newm.screens.profile.view.ProfileUi
import io.newm.screens.forceupdate.ForceAppUpdatePresenter
import io.newm.screens.forceupdate.ForceAppUpdateState
import io.newm.screens.forceupdate.ForceAppUpdateUi
Expand All @@ -30,9 +27,14 @@ import io.newm.screens.library.NFTLibraryState
import io.newm.screens.profile.edit.ProfileEditPresenter
import io.newm.screens.profile.edit.ProfileEditUi
import io.newm.screens.profile.edit.ProfileEditUiState
import io.newm.screens.profile.view.ProfilePresenter
import io.newm.screens.profile.view.ProfileUi
import io.newm.screens.profile.view.ProfileUiState
import io.newm.shared.NewmAppLogger
import io.newm.shared.public.analytics.NewmAppEventLogger
import io.newm.shared.public.analytics.events.AppScreens
import io.newm.shared.public.featureflags.FeatureFlagManager
import io.newm.shared.public.featureflags.FeatureFlags
import io.newm.utils.ForceAppUpdateViewModel
import io.newm.utils.ui
import org.koin.android.ext.android.inject
Expand All @@ -43,10 +45,15 @@ class HomeActivity : ComponentActivity() {
private val logger: NewmAppLogger by inject()
private val forceAppUpdateViewModel: ForceAppUpdateViewModel by inject()
private val eventLogger: NewmAppEventLogger by inject()
private val featureFlagManager : FeatureFlagManager by inject()

override fun onCreate(savedInstanceState: Bundle?) {
WindowCompat.setDecorFitsSystemWindows(window, false)

if(featureFlagManager.isEnabled(FeatureFlags.MarketPlace)) {
// TODO show/hide marketplace
logger.info("HomeActivity", "MarketPlace feature is enabled")
}
super.onCreate(savedInstanceState)
setContent {
NewmTheme(darkTheme = true) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,14 @@ import io.newm.screens.library.NFTLibraryPresenter
import io.newm.screens.profile.edit.ProfileEditPresenter
import io.newm.screens.profile.view.ProfilePresenter
import io.newm.shared.config.NewmSharedBuildConfig
import io.newm.shared.public.featureflags.FeatureFlagManager
import io.newm.utils.AndroidFeatureFlagManager
import io.newm.utils.ForceAppUpdateViewModel
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module

val viewModule = module {
single<FeatureFlagManager> { AndroidFeatureFlagManager(get(), get()) }
single { ForceAppUpdateViewModel(get(), get()) }
single { RecaptchaClientProvider() }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package io.newm.utils

import android.app.Application
import com.launchdarkly.sdk.ContextKind
import com.launchdarkly.sdk.LDContext
import com.launchdarkly.sdk.android.LDClient
import com.launchdarkly.sdk.android.LDConfig
import com.launchdarkly.sdk.android.LDConfig.Builder.AutoEnvAttributes
import io.newm.shared.config.NewmSharedBuildConfig
import io.newm.shared.public.featureflags.FeatureFlag
import io.newm.shared.public.featureflags.FeatureFlagManager
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.Future

class AndroidFeatureFlagManager(
private val application: Application,
private val sharedBuildConfig: NewmSharedBuildConfig,
) : FeatureFlagManager {

private val client: LDClient = buildClient()

private fun buildClient(): LDClient {
val context = LDContext.builder(ContextKind.DEFAULT, "anonymous")
.anonymous(true)
.build()

val ldConfig: LDConfig = LDConfig.Builder(AutoEnvAttributes.Enabled)
.mobileKey(sharedBuildConfig.launchDarklyKey)
.build()

return LDClient.init(application, ldConfig, context, 0)
}

override fun isEnabled(flag: FeatureFlag, default: Boolean): Boolean {
return client.boolVariation(flag.key, default)
}

override suspend fun setUserId(id: String) {
val ldContext = LDContext.builder(ContextKind.DEFAULT, id).build()

client.identify(ldContext)
.asDeferred()
.await()
}
}

private suspend fun <V> Future<V>.asDeferred(): Deferred<V> {
val deferred = CompletableDeferred<V>()

withContext(Dispatchers.IO) {
deferred.complete(get())
}

return deferred
}
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ koin = "3.5.0"
kotlin = "2.0.0"
kotlinxCoroutines = "1.8.1"
ktor = "2.3.12"
launchdarklyAndroidClientSdk = "5.0.0"
lifecycleRuntimeKtx = "2.8.3"
material = "1.6.8"
materialIconsExtended = "1.6.8"
Expand Down Expand Up @@ -112,6 +113,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
launchdarkly-client = { module = "com.launchdarkly:launchdarkly-android-client-sdk", version.ref = "launchdarklyAndroidClientSdk" }
material = { module = "androidx.compose.material:material", version.ref = "material" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
Expand Down
1 change: 1 addition & 0 deletions shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ buildConfig {
buildConfigField<String>("RECAPTCHA_SITE_KEY", properties.getProperty("RECAPTCHA_SITE_KEY").replace("\"", ""))
buildConfigField<String>("SENTRY_AUTH_TOKEN", properties.getProperty("SENTRY_AUTH_TOKEN").replace("\"", ""))
buildConfigField<String>("ANDROID_SENTRY_DSN", properties.getProperty("ANDROID_SENTRY_DSN").replace("\"", ""))
buildConfigField<String>("LAUNCHDARKLY_MOBILE_KEY", properties.getProperty("LAUNCHDARKLY_MOBILE_KEY").replace("\"", ""))
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ class NewmSharedBuildConfigImpl: NewmSharedBuildConfig, KoinComponent {
mode = defaultMode
}

override val launchDarklyKey: String
get() = BuildConfig.LAUNCHDARKLY_MOBILE_KEY

override val baseUrl: String
get() = when (mode) {
Mode.STAGING -> BuildConfig.STAGING_URL
Expand All @@ -55,6 +58,7 @@ class NewmSharedBuildConfigImpl: NewmSharedBuildConfig, KoinComponent {
}

interface NewmSharedBuildConfig {
val launchDarklyKey: String
val baseUrl: String
val sentryAuthToken: String
val androidSentryDSN: String
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.newm.shared.public.featureflags

interface FeatureFlagManager {
fun isEnabled(flag: FeatureFlag, default: Boolean = false): Boolean
suspend fun setUserId(id: String)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package io.newm.shared.public.featureflags

interface FeatureFlag {
val key: String
}

object FeatureFlags {
object MarketPlace : FeatureFlag {
override val key = "streams-marketplace"
}
}
10 changes: 5 additions & 5 deletions shared/src/commonTest/kotlin/ForceAppUpdateUseCaseTest.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@

import io.newm.shared.NewmAppLogger
import io.newm.shared.config.NewmSharedBuildConfig
import io.newm.shared.internal.implementations.ForceAppUpdateUseCaseImpl
import io.newm.shared.internal.repositories.RemoteConfigRepository
import io.newm.shared.internal.api.models.MobileClientConfig
import io.newm.shared.internal.api.models.MobileConfig
import io.newm.shared.internal.implementations.ForceAppUpdateUseCaseImpl
import io.newm.shared.internal.repositories.RemoteConfigRepository
import io.newm.shared.public.usecases.ForceAppUpdateUseCase
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertTrue
import kotlin.test.assertFalse
import kotlin.test.assertTrue


class ForceAppUpdateUseCaseTest {
Expand All @@ -26,7 +26,7 @@ class ForceAppUpdateUseCaseTest {
)
val remoteConfigRepository = FakeRemoteConfigRepository(fakeConfig)
logger = NewmAppLogger()
useCase = ForceAppUpdateUseCaseImpl(remoteConfigRepository, logger)
useCase = ForceAppUpdateUseCaseImpl(remoteConfigRepository)
}

@Test
Expand Down

0 comments on commit c4f023f

Please sign in to comment.