Skip to content
Draft
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
29 changes: 29 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import java.util.Properties

plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
Expand All @@ -9,6 +11,8 @@ plugins {
alias(libs.plugins.compose.compiler)
}

val localProps = getLocalProperties()

android {
namespace = "dev.arkbuilders.rate"
compileSdk = libs.versions.compileSdk.get().toInt()
Expand All @@ -25,6 +29,15 @@ android {
vectorDrawables {
useSupportLibrary = true
}
buildConfigField(
"String",
"REVENUECAT_API_KEY",
toBuildConfigString(
localProps.getProperty("revenuecat.apiKey")
?: System.getenv("REVENUECAT_API_KEY")
?: "",
),
)

ksp {
arg("room.schemaLocation", "$projectDir/schemas")
Expand Down Expand Up @@ -127,6 +140,7 @@ dependencies {
implementation(project(":feature:portfolio"))
implementation(project(":feature:search"))
implementation(project(":feature:settings"))
implementation(project(":feature:paywall"))
implementation(project(":fiaticons"))
implementation(project(":cryptoicons"))

Expand Down Expand Up @@ -200,3 +214,18 @@ fun collectCurrencyIcons(moduleDir: File): List<String> {
}
}
}

fun getLocalProperties(): Properties {
val props = Properties()
val localPropsFile = File(rootDir, "local.properties")

if (localPropsFile.exists()) {
localPropsFile.inputStream().use { stream ->
props.load(stream)
}
}

return props
}

fun toBuildConfigString(value: String): String = "\"$value\""
6 changes: 6 additions & 0 deletions app/src/main/java/dev/arkbuilders/rate/presentation/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class App : Application(), Configuration.Provider, CoreComponentProvider {
Timber.plant(Timber.DebugTree())
coreComponent = DaggerCoreComponent.factory().create(this, applicationContext)
initBuildConfigFields()
initPremiumManager()
instance = this

initCrashlytics()
Expand All @@ -48,11 +49,16 @@ class App : Application(), Configuration.Provider, CoreComponentProvider {
versionCode = BuildConfig.VERSION_CODE,
versionName = BuildConfig.VERSION_NAME,
isGooglePlayBuild = BuildConfig.GOOGLE_PLAY_BUILD,
revenueCatApiKey = BuildConfig.REVENUECAT_API_KEY,
availableIconCodes = BuildConfig.ICON_CODES.toSet(),
),
)
}

private fun initPremiumManager() {
coreComponent.premiumManager()
}

private fun initCrashlytics() =
CoroutineScope(Dispatchers.IO).launch {
// Google Play will collect crashes in any case, so we will also send them to Firebase
Expand Down
31 changes: 31 additions & 0 deletions app/src/main/java/dev/arkbuilders/rate/presentation/MainScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,17 @@ import com.ramcosta.composedestinations.generated.destinations.SplashScreenDesti
import com.ramcosta.composedestinations.generated.onboarding.destinations.OnboardingQuickCalculationScreenDestination
import com.ramcosta.composedestinations.generated.onboarding.destinations.OnboardingQuickScreenDestination
import com.ramcosta.composedestinations.generated.onboarding.destinations.OnboardingScreenDestination
import com.ramcosta.composedestinations.generated.paywall.destinations.PaywallScreenDestination
import com.ramcosta.composedestinations.generated.portfolio.destinations.PortfolioScreenDestination
import com.ramcosta.composedestinations.generated.quick.destinations.AddQuickScreenDestination
import com.ramcosta.composedestinations.generated.quick.destinations.QuickScreenDestination
import com.ramcosta.composedestinations.generated.search.destinations.SearchCurrencyScreenDestination
import com.ramcosta.composedestinations.generated.search.navtype.searchNavResultNavType
import com.ramcosta.composedestinations.generated.settings.destinations.SettingsScreenDestination
import com.ramcosta.composedestinations.manualcomposablecalls.composable
import com.ramcosta.composedestinations.navargs.primitives.longNavType
import com.ramcosta.composedestinations.rememberNavHostEngine
import com.ramcosta.composedestinations.scope.resultBackNavigator
import com.ramcosta.composedestinations.scope.resultRecipient
import com.ramcosta.composedestinations.utils.startDestination
import dev.arkbuilders.rate.core.domain.repo.AnalyticsManager
Expand All @@ -52,6 +56,8 @@ import dev.arkbuilders.rate.feature.quick.presentation.QuickExternalNavigator
import dev.arkbuilders.rate.feature.quick.presentation.main.QuickScreen
import dev.arkbuilders.rate.feature.quickwidget.presentation.action.AddNewCalculationAction.Companion.ADD_NEW_CALCULATION
import dev.arkbuilders.rate.feature.quickwidget.presentation.action.AddNewCalculationAction.Companion.ADD_NEW_CALCULATION_GROUP_KEY
import dev.arkbuilders.rate.feature.search.presentation.SearchCurrencyScreen
import dev.arkbuilders.rate.feature.search.presentation.SearchExternalNavigator
import dev.arkbuilders.rate.presentation.navigation.AnimatedRateBottomNavigation
import kotlinx.coroutines.flow.drop

Expand Down Expand Up @@ -186,6 +192,10 @@ fun MainScreen() {
OnboardingQuickCalculationScreenDestination,
)
}

override fun navigateToPaywall() {
destinationsNavigator.navigate(PaywallScreenDestination)
}
}
}
QuickScreen(
Expand All @@ -194,6 +204,27 @@ fun MainScreen() {
externalNavigator = externalNavigator,
)
}

composable(SearchCurrencyScreenDestination) {
val args = navArgs
val externalNavigator =
remember {
object : SearchExternalNavigator {
override fun navigateToPaywall() {
destinationsNavigator.navigate(PaywallScreenDestination)
}
}
}

SearchCurrencyScreen(
title = args.title,
navKey = args.navKey,
navPos = args.navPos,
prohibitedCodes = args.prohibitedCodes,
resultNavigator = resultBackNavigator(searchNavResultNavType),
externalNavigator = externalNavigator,
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.ramcosta.composedestinations.annotation.NavHostGraph
import com.ramcosta.composedestinations.generated.onboarding.destinations.OnboardingQuickCalculationScreenDestination
import com.ramcosta.composedestinations.generated.onboarding.destinations.OnboardingQuickScreenDestination
import com.ramcosta.composedestinations.generated.onboarding.destinations.OnboardingScreenDestination
import com.ramcosta.composedestinations.generated.paywall.destinations.PaywallScreenDestination
import com.ramcosta.composedestinations.generated.portfolio.destinations.AddAssetScreenDestination
import com.ramcosta.composedestinations.generated.portfolio.destinations.EditAssetScreenDestination
import com.ramcosta.composedestinations.generated.portfolio.destinations.PortfolioScreenDestination
Expand All @@ -30,5 +31,6 @@ annotation class MainGraph {
@ExternalDestination<SettingsScreenDestination>
@ExternalDestination<AboutScreenDestination>
@ExternalDestination<SearchCurrencyScreenDestination>
@ExternalDestination<PaywallScreenDestination>
companion object Includes
}
1 change: 1 addition & 0 deletions core/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ dependencies {

implementation(libs.play.review)
implementation(libs.play.review.ktx)
implementation(libs.revenuecat.purchases)

implementation(libs.arrow.core)
implementation(libs.arrow.fx.coroutines)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package dev.arkbuilders.rate.core.data.repo

import android.app.Activity
import android.content.Context
import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.LogLevel
import com.revenuecat.purchases.ProductType
import com.revenuecat.purchases.PurchaseParams
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesConfiguration
import com.revenuecat.purchases.awaitCustomerInfo
import com.revenuecat.purchases.awaitGetProducts
import com.revenuecat.purchases.awaitPurchase
import com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener
import dev.arkbuilders.rate.core.domain.BuildConfigFieldsProvider
import dev.arkbuilders.rate.core.domain.repo.PremiumManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import timber.log.Timber

class PremiumManagerRevenueCatImpl(
private val context: Context,
private val buildConfigFieldsProvider: BuildConfigFieldsProvider,
) : PremiumManager {
private val _premiumState = MutableStateFlow(false)

override val premiumState: StateFlow<Boolean> = _premiumState.asStateFlow()

init {
configureRevenueCatIfNeeded()
setCustomerInfoListener()
}

override suspend fun refresh() {
if (Purchases.isConfigured.not()) {
Timber.d("RevenueCat refresh skipped: SDK is not configured")
updatePremium(isPremium = false)
return
}

runCatching {
Purchases.sharedInstance.awaitCustomerInfo()
}.onSuccess { customerInfo ->
updateCustomerInfo(customerInfo)
}.onFailure { error ->
Timber.d("RevenueCat customer info refresh failed: ${error.message}")
}
}

override suspend fun purchase(
activity: Activity,
productId: String,
): Boolean {
if (Purchases.isConfigured.not()) {
Timber.d("RevenueCat purchase skipped: SDK is not configured")
return false
}

val product =
runCatching {
Purchases.sharedInstance
.awaitGetProducts(
productIds = listOf(productId),
type = ProductType.SUBS,
).firstOrNull()
}.onFailure { error ->
Timber.d("RevenueCat product fetch failed: ${error.message}")
}.getOrNull()
?: return false

return runCatching {
Purchases.sharedInstance.awaitPurchase(
PurchaseParams.Builder(activity, product).build(),
)
}.onSuccess { result ->
updateCustomerInfo(result.customerInfo)
}.onFailure { error ->
Timber.d("RevenueCat purchase failed: ${error.message}")
}.isSuccess
}

private fun configureRevenueCatIfNeeded() {
if (Purchases.isConfigured)
return

val buildConfigFields = buildConfigFieldsProvider.provide()
val apiKey = buildConfigFields.revenueCatApiKey
if (apiKey.isBlank()) {
Timber.d("RevenueCat configure skipped: API key is empty")
return
}

Purchases.logLevel =
if (buildConfigFields.buildType == BUILD_TYPE_DEBUG)
LogLevel.DEBUG
else
LogLevel.INFO

Purchases.configure(
PurchasesConfiguration.Builder(context, apiKey).build(),
)
}

private fun setCustomerInfoListener() {
if (Purchases.isConfigured.not())
return

Purchases.sharedInstance.updatedCustomerInfoListener =
UpdatedCustomerInfoListener { customerInfo ->
updateCustomerInfo(customerInfo)
}
}

private fun updateCustomerInfo(customerInfo: CustomerInfo) {
updatePremium(
isPremium =
customerInfo
.entitlements[PREMIUM_ENTITLEMENT_ID]
?.isActive == true,
)
}

private fun updatePremium(isPremium: Boolean) {
_premiumState.value = isPremium
}

private companion object {
const val PREMIUM_ENTITLEMENT_ID = "premium"
const val BUILD_TYPE_DEBUG = "debug"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import dev.arkbuilders.rate.core.domain.repo.GroupRepo
import dev.arkbuilders.rate.core.domain.repo.InAppReviewManager
import dev.arkbuilders.rate.core.domain.repo.NetworkStatus
import dev.arkbuilders.rate.core.domain.repo.Prefs
import dev.arkbuilders.rate.core.domain.repo.PremiumManager
import dev.arkbuilders.rate.core.domain.repo.TimestampRepo
import dev.arkbuilders.rate.core.domain.usecase.CalcFrequentCurrUseCase
import dev.arkbuilders.rate.core.domain.usecase.ConvertWithRateUseCase
Expand Down Expand Up @@ -72,6 +73,8 @@ interface CoreComponent {

fun inAppReviewManager(): InAppReviewManager

fun premiumManager(): PremiumManager

@Component.Factory
interface Factory {
fun create(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import dev.arkbuilders.rate.core.data.repo.BuildConfigFieldsProviderImpl
import dev.arkbuilders.rate.core.data.repo.CodeUseStatRepoImpl
import dev.arkbuilders.rate.core.data.repo.GooglePlayInAppReviewManagerImpl
import dev.arkbuilders.rate.core.data.repo.GroupRepoImpl
import dev.arkbuilders.rate.core.data.repo.PremiumManagerRevenueCatImpl
import dev.arkbuilders.rate.core.data.repo.TimestampRepoImpl
import dev.arkbuilders.rate.core.data.repo.currency.CryptoCurrencyDataSource
import dev.arkbuilders.rate.core.data.repo.currency.CurrencyInfoDataSource
Expand All @@ -29,6 +30,7 @@ import dev.arkbuilders.rate.core.domain.repo.GroupRepo
import dev.arkbuilders.rate.core.domain.repo.InAppReviewManager
import dev.arkbuilders.rate.core.domain.repo.NetworkStatus
import dev.arkbuilders.rate.core.domain.repo.Prefs
import dev.arkbuilders.rate.core.domain.repo.PremiumManager
import dev.arkbuilders.rate.core.domain.repo.TimestampRepo
import dev.arkbuilders.rate.core.domain.usecase.DefaultGroupNameProvider
import dev.arkbuilders.rate.core.presentation.utils.DefaultGroupNameProviderImpl
Expand Down Expand Up @@ -88,6 +90,17 @@ class RepoModule {
@Provides
fun buildConfigFieldsProvider(): BuildConfigFieldsProvider = BuildConfigFieldsProviderImpl()

@Singleton
@Provides
fun premiumManager(
context: Context,
buildConfigFieldsProvider: BuildConfigFieldsProvider,
): PremiumManager =
PremiumManagerRevenueCatImpl(
context,
buildConfigFieldsProvider,
)

@Singleton
@Provides
fun defaultGroupNameProvider(context: Context): DefaultGroupNameProvider =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ data class BuildConfigFields(
val versionCode: Int,
val versionName: String,
val isGooglePlayBuild: Boolean,
val revenueCatApiKey: String,
val availableIconCodes: Set<CurrencyCode>,
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package dev.arkbuilders.rate.core.domain.repo

import android.app.Activity
import kotlinx.coroutines.flow.StateFlow

interface PremiumManager {
val premiumState: StateFlow<Boolean>

suspend fun refresh()

suspend fun purchase(
activity: Activity,
productId: String,
): Boolean

fun isPremium(): Boolean = premiumState.value

companion object {
const val TEST_YEARLY_PRODUCT_ID = "test_product_yearly"
const val TEST_MONTHLY_PRODUCT_ID = "test_product_monthly"
}
}
5 changes: 5 additions & 0 deletions core/presentation/src/main/res/drawable/ic_premium.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:viewportHeight="28" android:viewportWidth="28" android:width="24dp">

<path android:fillColor="#212121" android:pathData="M21.25,3C21.493,3 21.718,3.117 21.857,3.31L21.912,3.397L25.919,10.91L25.962,11.012L25.973,11.05L25.99,11.127L25.999,11.215L25.997,11.315L26,11.25C26,11.355 25.979,11.455 25.939,11.546L25.911,11.606L25.87,11.672L25.817,11.741L14.6,24.705C14.5,24.839 14.363,24.928 14.214,24.97L14.156,24.984L14.059,24.998L14,25L13.899,24.993L13.814,24.977L13.711,24.943L13.685,24.931C13.595,24.891 13.512,24.832 13.443,24.753L2.171,11.726L2.109,11.639L2.061,11.546L2.026,11.446L2.003,11.315L2.003,11.188L2.018,11.089L2.027,11.05L2.061,10.954L2.088,10.897L6.088,3.397C6.202,3.183 6.411,3.04 6.648,3.007L6.75,3H21.25ZM17.906,12H10.091L14.001,22.16L17.906,12ZM8.485,12H4.389L11.762,20.518L8.485,12ZM23.608,12H19.515L16.242,20.511L23.608,12ZM10.024,4.499H7.199L3.998,10.5H8.423L10.024,4.499ZM16.423,4.499H11.576L9.975,10.5H18.023L16.423,4.499ZM20.8,4.499H17.975L19.576,10.5H23.999L20.8,4.499Z"/>

</vector>
1 change: 1 addition & 0 deletions feature/paywall/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
Loading
Loading