Skip to content

Commit

Permalink
refactor(app): enhance auto-app updater
Browse files Browse the repository at this point in the history
Removed dependency off of flixclusiveorg@flixclusive-config repository to get stable app updates.
  • Loading branch information
rhenwinch committed Sep 30, 2024
1 parent f6ad0fe commit 288adea
Show file tree
Hide file tree
Showing 17 changed files with 306 additions and 137 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ interface GithubApiService {
): GithubBranchInfo

/**
* Retrieves the release notes for the given tag.
* Retrieves the release for the given tag.
*
* @param tag The tag name.
* @return A [GithubReleaseInfo] object.
Expand All @@ -34,10 +34,17 @@ interface GithubApiService {
@Path("tag") tag: String
): GithubReleaseInfo

/**
* Retrieves the latest stable release for the given tag.
*
* @return A [GithubReleaseInfo] object.
*/
@GET("repos/$GITHUB_USERNAME/$GITHUB_REPOSITORY/releases/latest")
suspend fun getStableReleaseInfo(): GithubReleaseInfo

/**
* Retrieves the release notes for the given tag.
*
* @param tag The tag name.
* @return A [GithubReleaseInfo] object.
*/
@GET("repos/$GITHUB_USERNAME/$GITHUB_REPOSITORY/tags")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package com.flixclusive.core.network.retrofit

import com.flixclusive.core.util.common.GithubConstant.GITHUB_CONFIG_REPOSITORY
import com.flixclusive.core.util.common.GithubConstant.GITHUB_USERNAME
import com.flixclusive.model.configuration.AppConfig
import com.flixclusive.model.configuration.catalog.HomeCatalogsData
import com.flixclusive.model.configuration.catalog.SearchCatalogsData
import retrofit2.http.GET
Expand All @@ -20,6 +19,4 @@ interface GithubRawApiService {
@GET("$GITHUB_USERNAME/$GITHUB_CONFIG_REPOSITORY/main/search_items_config.json")
suspend fun getSearchCatalogsConfig(): SearchCatalogsData

@GET("$GITHUB_USERNAME/$GITHUB_CONFIG_REPOSITORY/main/app.json")
suspend fun getAppConfig(): AppConfig
}
5 changes: 4 additions & 1 deletion data/configuration/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ android {
}

dependencies {
api(projects.core.datastore)
api(libs.stubs.util)
api(projects.core.datastore)
api(projects.model.configuration)

implementation(libs.mockk)
implementation(projects.core.locale)
implementation(projects.core.network)


testImplementation(libs.retrofit.gson)
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
package com.flixclusive.data.configuration

import com.flixclusive.core.datastore.AppSettingsManager
import com.flixclusive.core.locale.UiText
import com.flixclusive.core.network.retrofit.GithubApiService
import com.flixclusive.core.network.retrofit.GithubRawApiService
import com.flixclusive.core.network.util.Resource
import com.flixclusive.core.network.util.Resource.Failure.Companion.toNetworkException
import com.flixclusive.core.util.common.GithubConstant.GITHUB_REPOSITORY
import com.flixclusive.core.util.common.GithubConstant.GITHUB_USERNAME
import com.flixclusive.core.util.coroutines.AppDispatchers
import com.flixclusive.core.util.coroutines.AppDispatchers.Companion.launchOnIO
import com.flixclusive.core.util.log.errorLog
import com.flixclusive.core.util.network.okhttp.UserAgentManager
import com.flixclusive.model.configuration.AppConfig
import com.flixclusive.model.configuration.catalog.HomeCatalogsData
import com.flixclusive.model.configuration.catalog.SearchCatalogsData
import kotlinx.coroutines.Job
Expand All @@ -27,16 +23,6 @@ import javax.inject.Inject
import javax.inject.Singleton
import com.flixclusive.core.locale.R as LocaleR

sealed class UpdateStatus(
val errorMessage: UiText? = null
) {
data object Fetching: UpdateStatus()
data object Maintenance : UpdateStatus()
data object Outdated : UpdateStatus()
data object UpToDate : UpdateStatus()
class Error(errorMessage: UiText?) : UpdateStatus(errorMessage)
}

/**
*
* Substitute model for BuildConfig
Expand All @@ -55,7 +41,7 @@ private const val MAX_RETRIES = 5
@Singleton
class AppConfigurationManager @Inject constructor(
private val githubRawApiService: GithubRawApiService,
private val githubApiService: GithubApiService,
private val appUpdateChecker: AppUpdateChecker,
private val appSettingsManager: AppSettingsManager,
client: OkHttpClient,
) {
Expand All @@ -72,16 +58,17 @@ class AppConfigurationManager @Inject constructor(
var currentAppBuild: AppBuild? = null
private set

var appConfig: AppConfig? = null
var appUpdateInfo: AppUpdateInfo? = null
var homeCatalogsData: HomeCatalogsData? = null
var searchCatalogsData: SearchCatalogsData? = null

private val Resource<Unit>.needsToInitialize: Boolean
get() = (this is Resource.Success
&& (appConfig == null || homeCatalogsData == null || searchCatalogsData == null))
&& (appUpdateInfo == null || homeCatalogsData == null || searchCatalogsData == null))
|| this is Resource.Failure

init {
AppDispatchers.Default.scope.launch {
launchOnIO {
_configurationStatus.collectLatest {
if(it.needsToInitialize)
initialize(currentAppBuild)
Expand All @@ -93,10 +80,10 @@ class AppConfigurationManager @Inject constructor(
if(fetchJob?.isActive == true)
return

if(this.currentAppBuild == null)
this.currentAppBuild = appBuild
if(currentAppBuild == null)
currentAppBuild = appBuild

fetchJob = AppDispatchers.Default.scope.launch {
fetchJob = AppDispatchers.IO.scope.launch {
val retryDelay = 3000L
for (i in 0..MAX_RETRIES) {
_configurationStatus.update { Resource.Loading }
Expand Down Expand Up @@ -135,58 +122,26 @@ class AppConfigurationManager @Inject constructor(
val appSettings = appSettingsManager.appSettings.data.first()
val isUsingPrereleaseUpdates = appSettings.isUsingPrereleaseUpdates

appConfig = githubRawApiService.getAppConfig()

if(appConfig!!.isMaintenance)
return _updateStatus.update { UpdateStatus.Maintenance }

if (isUsingPrereleaseUpdates && currentAppBuild?.debug == false) {
val lastCommitObject = githubApiService.getLastCommitObject()
val appCommitVersion = currentAppBuild?.commitVersion
?: throw NullPointerException("appCommitVersion should not be null!")

val preReleaseTag = "pre-release"
val preReleaseTagInfo = githubApiService.getTagsInfo().find { it.name == preReleaseTag }

val shortenedSha = lastCommitObject.lastCommit.sha.shortenSha()
val isNeedingAnUpdate = appCommitVersion != shortenedSha
&& lastCommitObject.lastCommit.sha == preReleaseTagInfo?.lastCommit?.sha

if (isNeedingAnUpdate) {
val preReleaseReleaseInfo = githubApiService.getReleaseInfo(tag = preReleaseTag)

appConfig = appConfig!!.copy(
versionName = "PR-$shortenedSha \uD83D\uDDFF",
updateInfo = preReleaseReleaseInfo.releaseNotes,
updateUrl = "https://github.com/$GITHUB_USERNAME/$GITHUB_REPOSITORY/releases/download/pre-release/flixclusive-release.apk"
)

_updateStatus.update { UpdateStatus.Outdated }
return
}

_updateStatus.update { UpdateStatus.UpToDate }
return
val status = if (isUsingPrereleaseUpdates && currentAppBuild?.debug == false) {
appUpdateChecker.checkForPrereleaseUpdates(
currentAppBuild = currentAppBuild!!
)
} else {
val isNeedingAnUpdate = appConfig!!.build != -1L && appConfig!!.build > currentAppBuild!!.build

if(isNeedingAnUpdate) {
val releaseInfo = githubApiService.getReleaseInfo(tag = appConfig!!.versionName)

appConfig = appConfig!!.copy(updateInfo = releaseInfo.releaseNotes)
return _updateStatus.update { UpdateStatus.Outdated }
}
appUpdateChecker.checkForStableUpdates(
currentAppBuild = currentAppBuild!!
)
}

return _updateStatus.update { UpdateStatus.UpToDate }
if (status is UpdateStatus.Outdated) {
appUpdateInfo = status.updateInfo
}

_updateStatus.update { status }
} catch (e: Exception) {
errorLog(e)
val errorMessageId = e.toNetworkException().error!!

_updateStatus.update { UpdateStatus.Error(errorMessageId) }
}
}

private fun String.shortenSha()
= substring(0, 7)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package com.flixclusive.data.configuration

import com.flixclusive.core.network.retrofit.GithubApiService
import com.flixclusive.core.util.common.GithubConstant
import retrofit2.HttpException
import java.time.Instant
import javax.inject.Inject

internal const val PRE_RELEASE_TAG = "pre-release"

class AppUpdateChecker @Inject constructor(
private val githubApiService: GithubApiService,
) {
suspend fun checkForPrereleaseUpdates(currentAppBuild: AppBuild): UpdateStatus {
return safeNetworkCall(currentAppBuild) { currentAppUpdateInfo ->
val lastCommitObject = githubApiService.getLastCommitObject()
val appCommitVersion = currentAppBuild.commitVersion

val preReleaseTagInfo =
githubApiService.getTagsInfo().find { it.name == PRE_RELEASE_TAG }

val shortenedSha = lastCommitObject.lastCommit.shortSha
val isNeedingAnUpdate = appCommitVersion != shortenedSha
&& lastCommitObject.lastCommit.sha == preReleaseTagInfo?.lastCommit?.sha

if (isNeedingAnUpdate) {
val preReleaseReleaseInfo = githubApiService.getReleaseInfo(tag = PRE_RELEASE_TAG)

val newAppConfig = AppUpdateInfo(
versionName = "PR-$shortenedSha \uD83D\uDDFF",
updateInfo = preReleaseReleaseInfo.releaseNotes,
updateUrl = "https://github.com/${GithubConstant.GITHUB_USERNAME}/${GithubConstant.GITHUB_REPOSITORY}/releases/download/$PRE_RELEASE_TAG/flixclusive-release.apk"
)

return UpdateStatus.Outdated(updateInfo = newAppConfig)
}

return UpdateStatus.UpToDate(updateInfo = currentAppUpdateInfo)
}
}

suspend fun checkForStableUpdates(currentAppBuild: AppBuild): UpdateStatus {
return safeNetworkCall(currentAppBuild) { currentAppUpdateInfo ->
val latestStableRelease = githubApiService.getStableReleaseInfo()
val currentReleaseInfo =
githubApiService.getReleaseInfo(tag = currentAppBuild.versionName)

val latestReleaseCreationDate =
Instant.parse(latestStableRelease.createdAt).toEpochMilli()
val currentReleaseCreationDate =
Instant.parse(currentReleaseInfo.createdAt).toEpochMilli()

val latestSemVer = parseSemVer(latestStableRelease.name)
val currentSemVer = parseSemVer(currentAppBuild.versionName)

val isNeedingAnUpdate = latestReleaseCreationDate > currentReleaseCreationDate
&& latestSemVer > currentSemVer

if (isNeedingAnUpdate) {
val newAppUpdateInfo = AppUpdateInfo(
versionName = latestStableRelease.name,
updateInfo = latestStableRelease.releaseNotes,
updateUrl = "https://github.com/${GithubConstant.GITHUB_USERNAME}/${GithubConstant.GITHUB_REPOSITORY}/releases/download/${latestStableRelease.name}/flixclusive-release.apk"
)

return UpdateStatus.Outdated(updateInfo = newAppUpdateInfo)
}

return UpdateStatus.UpToDate(updateInfo = currentAppUpdateInfo)
}
}

private inline fun safeNetworkCall(
currentAppBuild: AppBuild,
block: (AppUpdateInfo) -> UpdateStatus
): UpdateStatus {
val currentAppUpdateInfo = AppUpdateInfo(
versionName = currentAppBuild.versionName,
updateUrl = "https://github.com/${GithubConstant.GITHUB_USERNAME}/${GithubConstant.GITHUB_REPOSITORY}/releases/download/${currentAppBuild.versionName}/flixclusive-release.apk"
)

try {
return block.invoke(currentAppUpdateInfo)
} catch (e: HttpException) {
val body = e.response()?.errorBody()?.string()
if (e.code() == 404 || body?.contains("Not Found") == true) {
return UpdateStatus.UpToDate(updateInfo = currentAppUpdateInfo)
}

throw e
}
}

private fun parseSemVer(version: String): SemanticVersion {
val regex = Regex("""(\d+)\.(\d+)\.(\d+)""")
val match = regex.find(version)

return match?.let {
val (major, minor, patch) = it.destructured
SemanticVersion(major.toInt(), minor.toInt(), patch.toInt())
} ?: SemanticVersion(
major = -1,
minor = -1,
patch = -1
)
}

private data class SemanticVersion(
val major: Int,
val minor: Int,
val patch: Int
) : Comparable<SemanticVersion> {
override fun compareTo(other: SemanticVersion): Int {
// Compare major versions
if (this.major != other.major) {
return this.major.compareTo(other.major)
}

// Compare minor versions
if (this.minor != other.minor) {
return this.minor.compareTo(other.minor)
}

// Compare patch versions
return this.patch.compareTo(other.patch)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.flixclusive.data.configuration

data class AppUpdateInfo(
val versionName: String,
val updateUrl: String,
val updateInfo: String? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.flixclusive.data.configuration

import com.flixclusive.core.locale.UiText

sealed class UpdateStatus(
val errorMessage: UiText? = null
) {
data object Fetching : UpdateStatus()
data class Outdated(val updateInfo: AppUpdateInfo) : UpdateStatus()
data class UpToDate(val updateInfo: AppUpdateInfo) : UpdateStatus()
class Error(errorMessage: UiText?) : UpdateStatus(errorMessage)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ package com.flixclusive.data.configuration.di.test

import com.flixclusive.core.util.network.json.fromJson
import com.flixclusive.data.configuration.AppConfigurationManager
import com.flixclusive.data.configuration.AppUpdateInfo
import com.flixclusive.data.configuration.di.test.constant.APP_CONFIG
import com.flixclusive.data.configuration.di.test.constant.HOME_CATEGORIES
import com.flixclusive.data.configuration.di.test.constant.SEARCH_CATEGORIES
import com.flixclusive.model.configuration.AppConfig
import com.flixclusive.model.configuration.catalog.HomeCatalogsData
import com.flixclusive.model.configuration.catalog.SearchCatalogsData
import io.mockk.every
Expand All @@ -19,12 +19,12 @@ object TestAppConfigurationModule {
fun getMockAppConfigurationManager(): AppConfigurationManager {
val homeCatalogsDataMock = fromJson<HomeCatalogsData>(HOME_CATEGORIES)
val searchCatalogsDataMock = fromJson<SearchCatalogsData>(SEARCH_CATEGORIES)
val appConfigMock = fromJson<AppConfig>(APP_CONFIG)
val appUpdateInfoMock = fromJson<AppUpdateInfo>(APP_CONFIG)

val mock = mockk<AppConfigurationManager> {
every { homeCatalogsData } returns homeCatalogsDataMock
every { searchCatalogsData } returns searchCatalogsDataMock
every { appConfig } returns appConfigMock
every { appUpdateInfo } returns appUpdateInfoMock
}

return mock
Expand Down
Loading

0 comments on commit 288adea

Please sign in to comment.