diff --git a/app/build.gradle b/app/build.gradle
index 94dc064..922cc70 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -68,6 +68,7 @@ dependencies {
implementation project(Features.recipeDetail)
implementation project(Features.settings)
implementation project(Features.settingsTheme)
+ implementation project(Features.inAppUpdate)
testImplementation TestConfig.jUnit
testImplementation TestConfig.mockk
diff --git a/app/src/main/java/br/com/recipebook/di/KoinInitializer.kt b/app/src/main/java/br/com/recipebook/di/KoinInitializer.kt
index 7a8e929..9c005a9 100644
--- a/app/src/main/java/br/com/recipebook/di/KoinInitializer.kt
+++ b/app/src/main/java/br/com/recipebook/di/KoinInitializer.kt
@@ -4,6 +4,7 @@ import android.app.Application
import br.com.recipebook.analytics.amplitude.di.amplitudeAnalyticsModule
import br.com.recipebook.coreandroid.di.coreAndroidModule
import br.com.recipebook.image.di.imageModule
+import br.com.recipebook.inappupdate.di.inAppUpdateModules
import br.com.recipebook.monitoring.di.monitoringModule
import br.com.recipebook.navigation.di.navigationModule
import br.com.recipebook.recipecollection.di.recipeCollectionModules
@@ -31,6 +32,8 @@ object KoinInitializer {
settingsModules +
settingsThemeModules +
amplitudeAnalyticsModule +
+ configurationModules +
+ inAppUpdateModules +
monitoringModule
)
}
diff --git a/app/src/main/java/br/com/recipebook/environment/BuildConfigurationProvider.kt b/app/src/main/java/br/com/recipebook/environment/BuildConfigurationProvider.kt
index cecefb2..a9e24e9 100644
--- a/app/src/main/java/br/com/recipebook/environment/BuildConfigurationProvider.kt
+++ b/app/src/main/java/br/com/recipebook/environment/BuildConfigurationProvider.kt
@@ -14,6 +14,7 @@ object BuildConfigurationProvider {
appInfo = AppInfo(
name = BuildConfig.APPLICATION_ID,
version = BuildConfig.VERSION_NAME,
+ versionCode = BuildConfig.VERSION_CODE,
variant = BuildConfig.BUILD_TYPE,
buildVariant = if (BuildConfig.DEBUG) BuildVariant.DEBUG else BuildVariant.RELEASE
),
diff --git a/features/in-app-update/build.gradle b/features/in-app-update/build.gradle
new file mode 100644
index 0000000..2b69318
--- /dev/null
+++ b/features/in-app-update/build.gradle
@@ -0,0 +1,11 @@
+apply from: "$rootDir/project-config/feature-complete-build.gradle"
+apply plugin: 'kotlin-parcelize'
+
+dependencies {
+ implementation AndroidLibConfig.playCore
+ implementation AndroidLibConfig.playCoreExtensions
+
+ implementation project(ProjectConfig.configuration)
+ implementation project(ProjectConfig.monitoring)
+ implementation project(ProjectConfig.analytics)
+}
\ No newline at end of file
diff --git a/features/in-app-update/src/main/AndroidManifest.xml b/features/in-app-update/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..905ebe3
--- /dev/null
+++ b/features/in-app-update/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
\ No newline at end of file
diff --git a/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/InAppUpdateHeadlessFragment.kt b/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/InAppUpdateHeadlessFragment.kt
new file mode 100644
index 0000000..403b81f
--- /dev/null
+++ b/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/InAppUpdateHeadlessFragment.kt
@@ -0,0 +1,102 @@
+package br.com.recipebook.inappupdate
+
+import android.content.Intent
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.Fragment
+import br.com.recipebook.utilityandroid.view.putSafeArgs
+import br.com.recipebook.utilityandroid.view.safeArgs
+import com.google.android.play.core.appupdate.AppUpdateManagerFactory
+import com.google.android.play.core.install.model.UpdateAvailability
+import com.google.android.play.core.ktx.startUpdateFlowForResult
+import kotlinx.coroutines.CompletableDeferred
+
+internal class InAppUpdateHeadlessFragment : Fragment() {
+ private val safeArgs by safeArgs()
+
+ lateinit var completableDeferred: CompletableDeferred
+
+ override fun onResume() {
+ super.onResume()
+ checkInAppUpdate()
+ }
+
+ private fun checkInAppUpdate() {
+ // Creates instance of the manager.
+ val appUpdateManager = AppUpdateManagerFactory.create(requireContext())
+
+ // Returns an intent object that you use to check for an update.
+ val appUpdateInfoTask = appUpdateManager.appUpdateInfo
+
+ // Checks that the platform will allow the specified type of update.
+ appUpdateInfoTask.addOnSuccessListener { appUpdateInfo ->
+ if (isUpdateAvailable(appUpdateInfo.updateAvailability()) &&
+ appUpdateInfo.isUpdateTypeAllowed(safeArgs.appUpdateType)
+ ) {
+ // Request the update.
+ appUpdateManager.startUpdateFlowForResult(
+ appUpdateInfo,
+ safeArgs.appUpdateType,
+ this,
+ safeArgs.requestCode
+ )
+ } else {
+ completeAndRemoveFragment(InAppUpdateResult.UpdateNotAvailable)
+ }
+ }
+ }
+
+ private fun isUpdateAvailable(updateAvailability: Int): Boolean {
+ return updateAvailability == UpdateAvailability.UPDATE_AVAILABLE ||
+ updateAvailability == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS
+ }
+
+ override fun onActivityResult(
+ requestCode: Int,
+ resultCode: Int,
+ data: Intent?
+ ) {
+ super.onActivityResult(requestCode, resultCode, data)
+ if (requestCode == safeArgs.requestCode) {
+ if (resultCode != AppCompatActivity.RESULT_OK) {
+ // If the update is cancelled or fails,
+ // you can request to start the update again.
+ completeAndRemoveFragment(InAppUpdateResult.UpdateFailed)
+ } else {
+ completeAndRemoveFragment(InAppUpdateResult.UpdateCompleted)
+ }
+ }
+ }
+
+ private fun completeAndRemoveFragment(result: InAppUpdateResult) {
+ if (::completableDeferred.isInitialized) {
+ completableDeferred.complete(result)
+ }
+ removeThisFragment()
+ }
+
+ private fun removeThisFragment() {
+ parentFragmentManager.beginTransaction()
+ .remove(this)
+ .commitAllowingStateLoss()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ if (::completableDeferred.isInitialized && completableDeferred.isActive) {
+ completableDeferred.cancel()
+ }
+ }
+
+ companion object {
+ fun newInstance(
+ appUpdateType: Int,
+ requestCode: Int
+ ) = InAppUpdateHeadlessFragment()
+ .putSafeArgs(
+ InAppUpdateSafeArgs(
+ appUpdateType = appUpdateType,
+ requestCode = requestCode,
+ )
+ )
+ }
+}
diff --git a/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/InAppUpdateManager.kt b/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/InAppUpdateManager.kt
new file mode 100644
index 0000000..cf74303
--- /dev/null
+++ b/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/InAppUpdateManager.kt
@@ -0,0 +1,31 @@
+package br.com.recipebook.inappupdate
+
+import androidx.fragment.app.FragmentActivity
+import kotlinx.coroutines.CompletableDeferred
+
+private const val TAG = "InAppUpdateManager"
+
+suspend fun FragmentActivity.requestInAppUpdate(
+ appUpdateType: Int,
+ requestCode: Int
+): InAppUpdateResult {
+ val fragment = supportFragmentManager.findFragmentByTag(TAG) as? InAppUpdateHeadlessFragment
+ return if (fragment == null) {
+ val inAppUpdate = InAppUpdateHeadlessFragment.newInstance(
+ appUpdateType = appUpdateType,
+ requestCode = requestCode
+ ).apply {
+ completableDeferred = CompletableDeferred()
+ }
+ supportFragmentManager.beginTransaction().add(inAppUpdate, TAG).commitAllowingStateLoss()
+ inAppUpdate.completableDeferred.await()
+ } else {
+ fragment.completableDeferred.await()
+ }
+}
+
+sealed class InAppUpdateResult {
+ object UpdateCompleted : InAppUpdateResult()
+ object UpdateNotAvailable : InAppUpdateResult()
+ object UpdateFailed : InAppUpdateResult()
+}
diff --git a/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/InAppUpdateSafeArgs.kt b/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/InAppUpdateSafeArgs.kt
new file mode 100644
index 0000000..7f11900
--- /dev/null
+++ b/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/InAppUpdateSafeArgs.kt
@@ -0,0 +1,10 @@
+package br.com.recipebook.inappupdate
+
+import android.os.Parcelable
+import kotlinx.android.parcel.Parcelize
+
+@Parcelize
+data class InAppUpdateSafeArgs(
+ val appUpdateType: Int,
+ val requestCode: Int,
+) : Parcelable
diff --git a/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/analytics/InAppUpdateEvent.kt b/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/analytics/InAppUpdateEvent.kt
new file mode 100644
index 0000000..94906a6
--- /dev/null
+++ b/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/analytics/InAppUpdateEvent.kt
@@ -0,0 +1,17 @@
+package br.com.recipebook.inappupdate.analytics
+
+import br.com.recipebook.analytics.Event
+
+data class InAppUpdateEvent(
+ private val shouldUpdate: Boolean,
+ private val updateStatus: String?,
+ private val currentVersionCode: Int,
+) : Event {
+ override val id: String = "in_app_update"
+ override val properties: Map
+ get() = mapOf(
+ "should_update" to shouldUpdate,
+ "update_status" to updateStatus,
+ "current_version_code" to currentVersionCode,
+ )
+}
diff --git a/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/di/InAppUpdateModule.kt b/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/di/InAppUpdateModule.kt
new file mode 100644
index 0000000..a83f1cc
--- /dev/null
+++ b/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/di/InAppUpdateModule.kt
@@ -0,0 +1,31 @@
+package br.com.recipebook.inappupdate.di
+
+import br.com.recipebook.inappupdate.domain.CheckInAppUpdate
+import br.com.recipebook.inappupdate.domain.CheckInAppUpdateUseCase
+import br.com.recipebook.inappupdate.domain.InAppUpdater
+import br.com.recipebook.inappupdate.view.InAppUpdaterImpl
+import org.koin.dsl.module
+
+val inAppUpdateDomainModule = module {
+ factory {
+ CheckInAppUpdate(
+ buildConfiguration = get(),
+ configurationRepository = get(),
+ inAppUpdater = get(),
+ analytics = get(),
+ )
+ }
+}
+
+val inAppUpdateViewModule = module {
+ factory {
+ InAppUpdaterImpl(
+ activityProvider = get(),
+ )
+ }
+}
+
+val inAppUpdateModules = listOf(
+ inAppUpdateDomainModule,
+ inAppUpdateViewModule
+)
diff --git a/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/domain/CheckInAppUpdateUseCase.kt b/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/domain/CheckInAppUpdateUseCase.kt
new file mode 100644
index 0000000..25013ff
--- /dev/null
+++ b/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/domain/CheckInAppUpdateUseCase.kt
@@ -0,0 +1,65 @@
+package br.com.recipebook.inappupdate.domain
+
+import br.com.recipebook.analytics.Analytics
+import br.com.recipebook.di.BuildConfiguration
+import br.com.recipebook.domain.ConfigurationRepository
+import br.com.recipebook.domain.model.AppUpdateInfoModel
+import br.com.recipebook.inappupdate.InAppUpdateResult
+import br.com.recipebook.inappupdate.analytics.InAppUpdateEvent
+import br.com.recipebook.monitoring.crashreport.Breadcrumb
+import br.com.recipebook.monitoring.crashreport.Monitoring
+import br.com.recipebook.utilitykotlin.ResultWrapper
+
+interface CheckInAppUpdateUseCase {
+ suspend operator fun invoke(): Boolean
+}
+
+internal class CheckInAppUpdate(
+ private val buildConfiguration: BuildConfiguration,
+ private val configurationRepository: ConfigurationRepository,
+ private val inAppUpdater: InAppUpdater,
+ private val analytics: Analytics,
+) : CheckInAppUpdateUseCase {
+ override suspend fun invoke(): Boolean {
+ val shouldUpdate = when (val result = configurationRepository.getAppUpdateInfo()) {
+ is ResultWrapper.Success -> shouldAskUpdate(result.data)
+ is ResultWrapper.Failure -> true
+ }
+
+ return if (shouldUpdate) {
+ Monitoring.addBreadcrumb(Breadcrumb.StartingInAppUpdate)
+ val result = inAppUpdater()
+ sendEvent(shouldUpdate, result)
+ result !is InAppUpdateResult.UpdateFailed
+ } else {
+ sendEvent(shouldUpdate, null)
+ true
+ }
+ }
+
+ private fun shouldAskUpdate(info: AppUpdateInfoModel): Boolean {
+ return buildConfiguration.appInfo.versionCode.let { currentVersionCode ->
+ (currentVersionCode < info.minimumVersionCode ?: 0) ||
+ info.excludedVersionCodes.contains(currentVersionCode)
+ }
+ }
+
+ private fun sendEvent(
+ shouldUpdate: Boolean,
+ updateStatus: InAppUpdateResult?
+ ) {
+ val updateResult = when (updateStatus) {
+ null -> null
+ is InAppUpdateResult.UpdateNotAvailable -> "Not available"
+ is InAppUpdateResult.UpdateCompleted -> "Completed"
+ is InAppUpdateResult.UpdateFailed -> "Failed"
+ }
+ analytics.sendEvent(
+ InAppUpdateEvent(
+ shouldUpdate = shouldUpdate,
+ updateStatus = updateResult,
+ currentVersionCode = buildConfiguration.appInfo.versionCode
+ )
+ )
+ }
+}
diff --git a/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/domain/InAppUpdater.kt b/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/domain/InAppUpdater.kt
new file mode 100644
index 0000000..c140f02
--- /dev/null
+++ b/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/domain/InAppUpdater.kt
@@ -0,0 +1,7 @@
+package br.com.recipebook.inappupdate.domain
+
+import br.com.recipebook.inappupdate.InAppUpdateResult
+
+interface InAppUpdater {
+ suspend operator fun invoke(): InAppUpdateResult
+}
diff --git a/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/view/InAppUpdaterImpl.kt b/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/view/InAppUpdaterImpl.kt
new file mode 100644
index 0000000..5cba576
--- /dev/null
+++ b/features/in-app-update/src/main/java/br/com/recipebook/inappupdate/view/InAppUpdaterImpl.kt
@@ -0,0 +1,21 @@
+package br.com.recipebook.inappupdate.view
+
+import androidx.fragment.app.FragmentActivity
+import br.com.recipebook.inappupdate.InAppUpdateResult
+import br.com.recipebook.inappupdate.domain.InAppUpdater
+import br.com.recipebook.inappupdate.requestInAppUpdate
+import br.com.recipebook.view.ActivityProvider
+import com.google.android.play.core.install.model.AppUpdateType
+
+private const val REQUEST_CODE = 1292
+
+internal class InAppUpdaterImpl(
+ private val activityProvider: ActivityProvider,
+) : InAppUpdater {
+ override suspend fun invoke(): InAppUpdateResult {
+ return (activityProvider.activeActivity as? FragmentActivity)?.requestInAppUpdate(
+ appUpdateType = AppUpdateType.IMMEDIATE,
+ requestCode = REQUEST_CODE
+ ) ?: InAppUpdateResult.UpdateFailed
+ }
+}
diff --git a/features/recipe-collection/build.gradle b/features/recipe-collection/build.gradle
index 308ed9e..ede1d9e 100644
--- a/features/recipe-collection/build.gradle
+++ b/features/recipe-collection/build.gradle
@@ -4,4 +4,5 @@ dependencies {
implementation AndroidLibConfig.swipeRefresh
implementation project(ProjectConfig.analytics)
+ implementation project(Features.inAppUpdate)
}
\ No newline at end of file
diff --git a/features/recipe-collection/src/main/java/br/com/recipebook/recipecollection/RecipeCollectionActivity.kt b/features/recipe-collection/src/main/java/br/com/recipebook/recipecollection/RecipeCollectionActivity.kt
index 149b566..3d23c78 100644
--- a/features/recipe-collection/src/main/java/br/com/recipebook/recipecollection/RecipeCollectionActivity.kt
+++ b/features/recipe-collection/src/main/java/br/com/recipebook/recipecollection/RecipeCollectionActivity.kt
@@ -17,6 +17,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
+import kotlin.system.exitProcess
@ExperimentalCoroutinesApi
class RecipeCollectionActivity : AppCompatActivity() {
@@ -93,6 +94,9 @@ class RecipeCollectionActivity : AppCompatActivity() {
RecipeDetailIntent(recipeId = it.recipeId, title = it.title)
)
}
+ is RecipeCollectionCommand.FinishApp -> {
+ exitProcess(1)
+ }
}
}
}
diff --git a/features/recipe-collection/src/main/java/br/com/recipebook/recipecollection/di/RecipeCollectionModule.kt b/features/recipe-collection/src/main/java/br/com/recipebook/recipecollection/di/RecipeCollectionModule.kt
index 303082f..8db827c 100644
--- a/features/recipe-collection/src/main/java/br/com/recipebook/recipecollection/di/RecipeCollectionModule.kt
+++ b/features/recipe-collection/src/main/java/br/com/recipebook/recipecollection/di/RecipeCollectionModule.kt
@@ -20,7 +20,8 @@ val recipeCollectionPresentationModule = module {
RecipeCollectionViewModel(
viewState = RecipeCollectionViewState(),
getRecipeCollection = get(),
- analytics = get()
+ analytics = get(),
+ checkInAppUpdate = get(),
)
}
}
diff --git a/features/recipe-collection/src/main/java/br/com/recipebook/recipecollection/presentation/RecipeCollectionCommand.kt b/features/recipe-collection/src/main/java/br/com/recipebook/recipecollection/presentation/RecipeCollectionCommand.kt
index d23aa7d..97498b1 100644
--- a/features/recipe-collection/src/main/java/br/com/recipebook/recipecollection/presentation/RecipeCollectionCommand.kt
+++ b/features/recipe-collection/src/main/java/br/com/recipebook/recipecollection/presentation/RecipeCollectionCommand.kt
@@ -1,5 +1,6 @@
package br.com.recipebook.recipecollection.presentation
sealed class RecipeCollectionCommand {
+ object FinishApp : RecipeCollectionCommand()
data class OpenRecipeDetail(val recipeId: String, val title: String?) : RecipeCollectionCommand()
}
diff --git a/features/recipe-collection/src/main/java/br/com/recipebook/recipecollection/presentation/RecipeCollectionViewModel.kt b/features/recipe-collection/src/main/java/br/com/recipebook/recipecollection/presentation/RecipeCollectionViewModel.kt
index 97327c2..ceed22b 100644
--- a/features/recipe-collection/src/main/java/br/com/recipebook/recipecollection/presentation/RecipeCollectionViewModel.kt
+++ b/features/recipe-collection/src/main/java/br/com/recipebook/recipecollection/presentation/RecipeCollectionViewModel.kt
@@ -2,6 +2,7 @@ package br.com.recipebook.recipecollection.presentation
import androidx.lifecycle.viewModelScope
import br.com.recipebook.analytics.Analytics
+import br.com.recipebook.inappupdate.domain.CheckInAppUpdateUseCase
import br.com.recipebook.recipecollection.analytics.ViewRecipeCollectionEvent
import br.com.recipebook.recipecollection.domain.model.RecipeModel
import br.com.recipebook.recipecollection.domain.usecase.GetRecipeCollectionUseCase
@@ -16,12 +17,19 @@ import kotlinx.coroutines.launch
class RecipeCollectionViewModel(
override val viewState: RecipeCollectionViewState,
private val getRecipeCollection: GetRecipeCollectionUseCase,
- private val analytics: Analytics
+ private val analytics: Analytics,
+ private val checkInAppUpdate: CheckInAppUpdateUseCase,
) : BaseViewModel() {
init {
setInitialState()
- loadRecipeList()
+ viewModelScope.launch {
+ if (checkInAppUpdate()) {
+ loadRecipeList()
+ } else {
+ commandSender.send(RecipeCollectionCommand.FinishApp)
+ }
+ }
}
override fun dispatchAction(action: RecipeCollectionAction) {
diff --git a/infrastructure/configuration/build.gradle b/infrastructure/configuration/build.gradle
index f114ddd..9ecad7e 100644
--- a/infrastructure/configuration/build.gradle
+++ b/infrastructure/configuration/build.gradle
@@ -1,11 +1,40 @@
-apply plugin: 'kotlin'
+apply plugin: "com.android.library"
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlin-kapt'
-repositories {
- jcenter()
+android {
+ compileSdkVersion AndroidConfig.compileSdk
+ buildToolsVersion AndroidConfig.buildTools
+
+ defaultConfig {
+ minSdkVersion AndroidConfig.minSdk
+ targetSdkVersion AndroidConfig.targetSdk
+ versionCode AndroidConfig.versionCode
+ versionName AndroidConfig.versionName
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = jvmTargetVersion
+ }
}
dependencies {
implementation EnvironmentConfig.kotlinJdk
+ implementation AndroidLibConfig.coroutinesCore
+
+ implementation AndroidLibConfig.koinAndroid
+
+ implementation project(ProjectConfig.utilityKotlin)
+ implementation project(ProjectConfig.utilityAndroid)
+
+ implementation AndroidLibConfig.retrofit
+ implementation AndroidLibConfig.moshiConverter
+ implementation AndroidLibConfig.moshi
+ kapt AndroidLibConfig.moshiCodeGen
}
java {
diff --git a/infrastructure/configuration/src/main/AndroidManifest.xml b/infrastructure/configuration/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..c8e9c88
--- /dev/null
+++ b/infrastructure/configuration/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/infrastructure/configuration/src/main/java/br/com/recipebook/data/ConfigurationRepositoryImpl.kt b/infrastructure/configuration/src/main/java/br/com/recipebook/data/ConfigurationRepositoryImpl.kt
new file mode 100644
index 0000000..0644d99
--- /dev/null
+++ b/infrastructure/configuration/src/main/java/br/com/recipebook/data/ConfigurationRepositoryImpl.kt
@@ -0,0 +1,28 @@
+package br.com.recipebook.data
+
+import br.com.recipebook.data.local.ConfigurationDataSourceLocal
+import br.com.recipebook.data.remote.ConfigurationDataSourceRemote
+import br.com.recipebook.domain.ConfigurationRepository
+import br.com.recipebook.domain.model.AppUpdateInfoModel
+import br.com.recipebook.domain.model.AppUpdateInfoModelError
+import br.com.recipebook.utilitykotlin.ResultWrapper
+
+internal class ConfigurationRepositoryImpl(
+ private val dataSourceRemote: ConfigurationDataSourceRemote,
+ private val dataSourceLocal: ConfigurationDataSourceLocal,
+) : ConfigurationRepository {
+ override suspend fun getAppUpdateInfo(): ResultWrapper {
+ return when (val localResult = dataSourceLocal.getAppUpdateInfo()) {
+ is ResultWrapper.Success -> localResult
+ is ResultWrapper.Failure -> {
+ when (val remoteResult = dataSourceRemote.getAppUpdateInfo()) {
+ is ResultWrapper.Success -> {
+ dataSourceLocal.saveAppUpdateInfo(remoteResult.data)
+ ResultWrapper.Success(remoteResult.data)
+ }
+ is ResultWrapper.Failure -> remoteResult
+ }
+ }
+ }
+ }
+}
diff --git a/infrastructure/configuration/src/main/java/br/com/recipebook/data/local/ConfigurationDataSourceLocal.kt b/infrastructure/configuration/src/main/java/br/com/recipebook/data/local/ConfigurationDataSourceLocal.kt
new file mode 100644
index 0000000..dd7b544
--- /dev/null
+++ b/infrastructure/configuration/src/main/java/br/com/recipebook/data/local/ConfigurationDataSourceLocal.kt
@@ -0,0 +1,10 @@
+package br.com.recipebook.data.local
+
+import br.com.recipebook.domain.model.AppUpdateInfoModel
+import br.com.recipebook.domain.model.AppUpdateInfoModelError
+import br.com.recipebook.utilitykotlin.ResultWrapper
+
+interface ConfigurationDataSourceLocal {
+ fun getAppUpdateInfo(): ResultWrapper
+ fun saveAppUpdateInfo(info: AppUpdateInfoModel)
+}
diff --git a/infrastructure/configuration/src/main/java/br/com/recipebook/data/local/ConfigurationDataSourceLocalImpl.kt b/infrastructure/configuration/src/main/java/br/com/recipebook/data/local/ConfigurationDataSourceLocalImpl.kt
new file mode 100644
index 0000000..dcf5707
--- /dev/null
+++ b/infrastructure/configuration/src/main/java/br/com/recipebook/data/local/ConfigurationDataSourceLocalImpl.kt
@@ -0,0 +1,19 @@
+package br.com.recipebook.data.local
+
+import br.com.recipebook.domain.model.AppUpdateInfoModel
+import br.com.recipebook.domain.model.AppUpdateInfoModelError
+import br.com.recipebook.utilitykotlin.ResultWrapper
+
+internal class ConfigurationDataSourceLocalImpl : ConfigurationDataSourceLocal {
+ private var appUpdateInfoModel: AppUpdateInfoModel? = null
+
+ override fun getAppUpdateInfo(): ResultWrapper {
+ return appUpdateInfoModel?.let {
+ ResultWrapper.Success(it)
+ } ?: ResultWrapper.Failure(AppUpdateInfoModelError.NoInformation)
+ }
+
+ override fun saveAppUpdateInfo(info: AppUpdateInfoModel) {
+ appUpdateInfoModel = info
+ }
+}
diff --git a/infrastructure/configuration/src/main/java/br/com/recipebook/data/remote/ConfigurationDataSourceRemote.kt b/infrastructure/configuration/src/main/java/br/com/recipebook/data/remote/ConfigurationDataSourceRemote.kt
new file mode 100644
index 0000000..951728d
--- /dev/null
+++ b/infrastructure/configuration/src/main/java/br/com/recipebook/data/remote/ConfigurationDataSourceRemote.kt
@@ -0,0 +1,9 @@
+package br.com.recipebook.data.remote
+
+import br.com.recipebook.domain.model.AppUpdateInfoModel
+import br.com.recipebook.domain.model.AppUpdateInfoModelError
+import br.com.recipebook.utilitykotlin.ResultWrapper
+
+interface ConfigurationDataSourceRemote {
+ suspend fun getAppUpdateInfo(): ResultWrapper
+}
diff --git a/infrastructure/configuration/src/main/java/br/com/recipebook/data/remote/ConfigurationDataSourceRemoteImpl.kt b/infrastructure/configuration/src/main/java/br/com/recipebook/data/remote/ConfigurationDataSourceRemoteImpl.kt
new file mode 100644
index 0000000..d5678fe
--- /dev/null
+++ b/infrastructure/configuration/src/main/java/br/com/recipebook/data/remote/ConfigurationDataSourceRemoteImpl.kt
@@ -0,0 +1,38 @@
+package br.com.recipebook.data.remote
+
+import br.com.recipebook.data.remote.response.ConfigurationResponse
+import br.com.recipebook.domain.model.AppUpdateInfoModel
+import br.com.recipebook.domain.model.AppUpdateInfoModelError
+import br.com.recipebook.utilityandroid.network.safeApiCall
+import br.com.recipebook.utilitykotlin.ResultWrapper
+import kotlinx.coroutines.Dispatchers
+import retrofit2.http.GET
+
+internal class ConfigurationDataSourceRemoteImpl(
+ private val api: ConfigurationApi,
+) : ConfigurationDataSourceRemote {
+ override suspend fun getAppUpdateInfo(): ResultWrapper {
+ val result = safeApiCall(Dispatchers.IO) {
+ api.getData()
+ }
+ return when (result) {
+ is ResultWrapper.Success -> {
+ result.data.takeIf { it.minimumVersionCode != null }?.let {
+ ResultWrapper.Success(mapConfigurationResponseToModel(it))
+ } ?: ResultWrapper.Failure(AppUpdateInfoModelError.NoInformation)
+ }
+ is ResultWrapper.Failure -> ResultWrapper.Failure(AppUpdateInfoModelError.UnknownError)
+ }
+ }
+
+ private fun mapConfigurationResponseToModel(recipe: ConfigurationResponse) =
+ AppUpdateInfoModel(
+ minimumVersionCode = recipe.minimumVersionCode,
+ excludedVersionCodes = recipe.excludedVersionCodes ?: emptyList(),
+ )
+}
+
+internal interface ConfigurationApi {
+ @GET("/configuration.json")
+ suspend fun getData(): ConfigurationResponse
+}
diff --git a/infrastructure/configuration/src/main/java/br/com/recipebook/data/remote/response/ConfigurationResponse.kt b/infrastructure/configuration/src/main/java/br/com/recipebook/data/remote/response/ConfigurationResponse.kt
new file mode 100644
index 0000000..3a192ce
--- /dev/null
+++ b/infrastructure/configuration/src/main/java/br/com/recipebook/data/remote/response/ConfigurationResponse.kt
@@ -0,0 +1,9 @@
+package br.com.recipebook.data.remote.response
+
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class ConfigurationResponse(
+ val minimumVersionCode: Int?,
+ val excludedVersionCodes: List?,
+)
diff --git a/infrastructure/configuration/src/main/java/br/com/recipebook/di/BuildConfiguration.kt b/infrastructure/configuration/src/main/java/br/com/recipebook/di/BuildConfiguration.kt
index ad47a41..37d4a40 100644
--- a/infrastructure/configuration/src/main/java/br/com/recipebook/di/BuildConfiguration.kt
+++ b/infrastructure/configuration/src/main/java/br/com/recipebook/di/BuildConfiguration.kt
@@ -8,6 +8,7 @@ data class BuildConfiguration(
data class AppInfo(
val name: String,
val version: String,
+ val versionCode: Int,
val variant: String,
val buildVariant: BuildVariant
)
diff --git a/infrastructure/configuration/src/main/java/br/com/recipebook/di/ConfigurationModule.kt b/infrastructure/configuration/src/main/java/br/com/recipebook/di/ConfigurationModule.kt
new file mode 100644
index 0000000..82a9f0d
--- /dev/null
+++ b/infrastructure/configuration/src/main/java/br/com/recipebook/di/ConfigurationModule.kt
@@ -0,0 +1,40 @@
+package br.com.recipebook.di
+
+import br.com.recipebook.data.ConfigurationRepositoryImpl
+import br.com.recipebook.data.local.ConfigurationDataSourceLocal
+import br.com.recipebook.data.local.ConfigurationDataSourceLocalImpl
+import br.com.recipebook.data.remote.ConfigurationApi
+import br.com.recipebook.data.remote.ConfigurationDataSourceRemote
+import br.com.recipebook.data.remote.ConfigurationDataSourceRemoteImpl
+import br.com.recipebook.domain.ConfigurationRepository
+import br.com.recipebook.view.ActivityProvider
+import org.koin.dsl.module
+import retrofit2.Retrofit
+
+val configurationDataModule = module {
+ factory {
+ ConfigurationRepositoryImpl(
+ dataSourceLocal = get(),
+ dataSourceRemote = get()
+ )
+ }
+
+ single {
+ ConfigurationDataSourceLocalImpl()
+ }
+
+ factory {
+ ConfigurationDataSourceRemoteImpl(
+ api = (getKoin().get() as Retrofit).create(ConfigurationApi::class.java)
+ )
+ }
+}
+
+val configurationViewModule = module {
+ single { ActivityProvider(get()) }
+}
+
+val configurationModules = listOf(
+ configurationDataModule,
+ configurationViewModule
+)
diff --git a/infrastructure/configuration/src/main/java/br/com/recipebook/domain/ConfigurationRepository.kt b/infrastructure/configuration/src/main/java/br/com/recipebook/domain/ConfigurationRepository.kt
new file mode 100644
index 0000000..13cc69b
--- /dev/null
+++ b/infrastructure/configuration/src/main/java/br/com/recipebook/domain/ConfigurationRepository.kt
@@ -0,0 +1,9 @@
+package br.com.recipebook.domain
+
+import br.com.recipebook.domain.model.AppUpdateInfoModel
+import br.com.recipebook.domain.model.AppUpdateInfoModelError
+import br.com.recipebook.utilitykotlin.ResultWrapper
+
+interface ConfigurationRepository {
+ suspend fun getAppUpdateInfo(): ResultWrapper
+}
diff --git a/infrastructure/configuration/src/main/java/br/com/recipebook/domain/model/AppUpdateInfoModel.kt b/infrastructure/configuration/src/main/java/br/com/recipebook/domain/model/AppUpdateInfoModel.kt
new file mode 100644
index 0000000..2235cbb
--- /dev/null
+++ b/infrastructure/configuration/src/main/java/br/com/recipebook/domain/model/AppUpdateInfoModel.kt
@@ -0,0 +1,6 @@
+package br.com.recipebook.domain.model
+
+data class AppUpdateInfoModel(
+ val minimumVersionCode: Int?,
+ val excludedVersionCodes: List,
+)
diff --git a/infrastructure/configuration/src/main/java/br/com/recipebook/domain/model/AppUpdateInfoModelError.kt b/infrastructure/configuration/src/main/java/br/com/recipebook/domain/model/AppUpdateInfoModelError.kt
new file mode 100644
index 0000000..5a14c0e
--- /dev/null
+++ b/infrastructure/configuration/src/main/java/br/com/recipebook/domain/model/AppUpdateInfoModelError.kt
@@ -0,0 +1,6 @@
+package br.com.recipebook.domain.model
+
+sealed class AppUpdateInfoModelError {
+ object NoInformation : AppUpdateInfoModelError()
+ object UnknownError : AppUpdateInfoModelError()
+}
diff --git a/infrastructure/configuration/src/main/java/br/com/recipebook/view/ActivityProvider.kt b/infrastructure/configuration/src/main/java/br/com/recipebook/view/ActivityProvider.kt
new file mode 100644
index 0000000..3f9e334
--- /dev/null
+++ b/infrastructure/configuration/src/main/java/br/com/recipebook/view/ActivityProvider.kt
@@ -0,0 +1,43 @@
+package br.com.recipebook.view
+
+import android.app.Activity
+import android.app.Application
+import android.os.Bundle
+
+// FIXME this class shouldn't be inside this module
+class ActivityProvider(application: Application) {
+ var activeActivity: Activity? = null
+
+ init {
+ application.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
+ override fun onActivityCreated(
+ activity: Activity,
+ savedInstanceState: Bundle?
+ ) {
+ }
+
+ override fun onActivityStarted(activity: Activity) {
+ }
+
+ override fun onActivityResumed(activity: Activity) {
+ activeActivity = activity
+ }
+
+ override fun onActivityPaused(activity: Activity) {
+ activeActivity = null
+ }
+
+ override fun onActivityStopped(activity: Activity) {
+ }
+
+ override fun onActivitySaveInstanceState(
+ activity: Activity,
+ outState: Bundle
+ ) {
+ }
+
+ override fun onActivityDestroyed(activity: Activity) {
+ }
+ })
+ }
+}
diff --git a/infrastructure/monitoring/src/main/java/br/com/recipebook/monitoring/crashreport/Monitoring.kt b/infrastructure/monitoring/src/main/java/br/com/recipebook/monitoring/crashreport/Monitoring.kt
index 6a1eb28..809a3a0 100644
--- a/infrastructure/monitoring/src/main/java/br/com/recipebook/monitoring/crashreport/Monitoring.kt
+++ b/infrastructure/monitoring/src/main/java/br/com/recipebook/monitoring/crashreport/Monitoring.kt
@@ -3,8 +3,9 @@ package br.com.recipebook.monitoring.crashreport
import io.sentry.Sentry
sealed class Breadcrumb(val value: String) {
- object StartupJobFinished : Breadcrumb("STARTUP_JOBS_FINISHED")
+ object StartupJobFinished : Breadcrumb("Startup job finished")
class Navigation(message: String) : Breadcrumb(message)
+ object StartingInAppUpdate : Breadcrumb("Starting in-app update")
}
object Monitoring {
diff --git a/project-config/dependencies.gradle b/project-config/dependencies.gradle
index ab87d7b..3cb2c5c 100644
--- a/project-config/dependencies.gradle
+++ b/project-config/dependencies.gradle
@@ -11,7 +11,7 @@ ext {
AndroidConfig = [
applicationId: "br.com.recipebook",
- versionCode: 2,
+ versionCode: 1,
versionName: "0.1.0",
compileSdk: 30,
@@ -41,6 +41,10 @@ ext {
coordinatorLayout: "androidx.coordinatorlayout:coordinatorlayout:1.1.0",
material: "com.google.android.material:material:1.2.1",
+ // Android utilities
+ playCore: "com.google.android.play:core:1.9.1", // For in-App update
+ playCoreExtensions: "com.google.android.play:core-ktx:1.8.1",
+
// DI
koinAndroid: "org.koin:koin-android:2.2.2", // Koin for Android
koinLifecycle: "org.koin:koin-androidx-scope:2.2.2", // or Koin for Lifecycle scoping
@@ -78,7 +82,8 @@ ext {
recipeCollection: ":features:recipe-collection",
recipeDetail: ":features:recipe-detail",
settings: ":features:settings",
- settingsTheme: ":features:settings-theme"
+ settingsTheme: ":features:settings-theme",
+ inAppUpdate: ":features:in-app-update"
]
TestConfig = [
diff --git a/settings.gradle b/settings.gradle
index 59b36ff..828cadb 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -16,6 +16,7 @@ include ':app',
':features:recipe-collection',
':features:recipe-detail',
':features:settings',
- ':features:settings-theme'
+ ':features:settings-theme',
+ ':features:in-app-update'
rootProject.name = 'Recipe Book'
\ No newline at end of file
diff --git a/utility/utility-android/src/main/java/br/com/recipebook/utilityandroid/view/ViewExtensions.kt b/utility/utility-android/src/main/java/br/com/recipebook/utilityandroid/view/ViewExtensions.kt
index c3a57c5..d8eb0dd 100644
--- a/utility/utility-android/src/main/java/br/com/recipebook/utilityandroid/view/ViewExtensions.kt
+++ b/utility/utility-android/src/main/java/br/com/recipebook/utilityandroid/view/ViewExtensions.kt
@@ -2,17 +2,21 @@ package br.com.recipebook.utilityandroid.view
import android.app.Activity
import android.content.Intent
+import android.os.Bundle
import android.os.Parcelable
import androidx.fragment.app.Fragment
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
-private val SCREEN_ARGS = "SCREEN_ARGS"
+private const val SCREEN_ARGS = "SCREEN_ARGS"
fun safeArgs() = object : ReadOnlyProperty {
var value: V? = null
- override fun getValue(thisRef: Fragment, property: KProperty<*>): V {
+ override fun getValue(
+ thisRef: Fragment,
+ property: KProperty<*>
+ ): V {
if (value == null) {
val args = thisRef.arguments
?: throw IllegalArgumentException("There are no fragment arguments!")
@@ -26,10 +30,18 @@ fun safeArgs() = object : ReadOnlyProperty {
}
}
+fun T.putSafeArgs(parcelable: Parcelable): T =
+ apply {
+ arguments = Bundle().apply { putParcelable(SCREEN_ARGS, parcelable) }
+ }
+
fun activitySafeArgs() = object : ReadOnlyProperty {
var value: V? = null
- override fun getValue(thisRef: Activity, property: KProperty<*>): V {
+ override fun getValue(
+ thisRef: Activity,
+ property: KProperty<*>
+ ): V {
if (value == null) {
val args = thisRef.intent.extras
?: throw IllegalArgumentException("There are no activity arguments!")