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!")