Initial commit: Pezkuwi Wallet Android

Security hardened release:
- Code obfuscation enabled (minifyEnabled=true, shrinkResources=true)
- Sensitive files excluded (google-services.json, keystores)
- Branch.io key moved to BuildConfig placeholder
- Updated dependencies: OkHttp 4.12.0, Gson 2.10.1, BouncyCastle 1.77
- Comprehensive ProGuard rules for crypto wallet
- Navigation 2.7.7, Lifecycle 2.7.0, ConstraintLayout 2.1.4
This commit is contained in:
2026-02-12 05:19:41 +03:00
commit a294aa1a6b
7687 changed files with 441811 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+58
View File
@@ -0,0 +1,58 @@
apply plugin: 'kotlin-parcelize'
apply from: '../tests.gradle'
apply from: '../scripts/secrets.gradle'
android {
defaultConfig {
buildConfigField "String", "NOTIFICATIONS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-android-releases/master/updates/v1/entrypoint_dev.json\""
buildConfigField "String", "NOTIFICATION_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-android-releases/master/updates/changelogs/dev/\""
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
buildConfigField "String", "NOTIFICATIONS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-android-releases/master/updates/v1/entrypoint_release.json\""
buildConfigField "String", "NOTIFICATION_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-android-releases/master/updates/changelogs/release/\""
}
}
namespace 'io.novafoundation.nova.feature_versions_impl'
buildFeatures {
viewBinding true
}
}
dependencies {
implementation project(':common')
implementation project(':feature-versions-api')
implementation kotlinDep
implementation androidDep
implementation materialDep
implementation constraintDep
implementation coroutinesDep
implementation coroutinesAndroidDep
implementation viewModelKtxDep
implementation liveDataKtxDep
implementation lifeCycleKtxDep
implementation markwonDep
implementation daggerDep
ksp daggerCompiler
implementation retrofitDep
implementation lifecycleDep
ksp lifecycleCompiler
testImplementation project(":test-shared")
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
@@ -0,0 +1,27 @@
package io.novafoundation.nova.feature_versions_impl.data
import io.novafoundation.nova.common.utils.formatting.parseDateISO_8601_NoMs
import io.novafoundation.nova.feature_versions_api.domain.Severity
import io.novafoundation.nova.feature_versions_api.domain.UpdateNotification
import io.novafoundation.nova.feature_versions_api.domain.Version
const val REMOTE_SEVERITY_CRITICAL = "Critical"
const val REMOTE_SEVERITY_MAJOR = "Major"
const val REMOTE_SEVERITY_NORMAL = "Normal"
fun mapFromRemoteVersion(version: Version, versionResponse: VersionResponse, changelog: String?): UpdateNotification {
return UpdateNotification(
version,
changelog,
mapSeverity(versionResponse.severity),
parseDateISO_8601_NoMs(versionResponse.time)!!
)
}
fun mapSeverity(severity: String): Severity {
return when (severity) {
REMOTE_SEVERITY_CRITICAL -> Severity.CRITICAL
REMOTE_SEVERITY_MAJOR -> Severity.MAJOR
else -> Severity.NORMAL
}
}
@@ -0,0 +1,143 @@
package io.novafoundation.nova.feature_versions_impl.data
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.common.resources.AppVersionProvider
import io.novafoundation.nova.feature_versions_api.domain.UpdateNotification
import io.novafoundation.nova.feature_versions_api.domain.Version
import io.novafoundation.nova.feature_versions_api.domain.toUnderscoreString
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
interface VersionRepository {
suspend fun hasImportantUpdates(): Boolean
suspend fun getNewUpdateNotifications(): List<UpdateNotification>
suspend fun skipCurrentUpdates()
fun inAppUpdatesCheckAllowedFlow(): Flow<Boolean>
fun allowUpdate()
suspend fun loadVersions()
}
class RealVersionRepository(
private val appVersionProvider: AppVersionProvider,
private val preferences: Preferences,
private val versionsFetcher: VersionsFetcher
) : VersionRepository {
companion object {
private const val PREF_VERSION_CHECKPOINT = "PREF_VERSION_CHECKPOINT"
}
private val mutex = Mutex(false)
private val appVersion = getAppVersion()
private var versions = mapOf<Version, VersionResponse>()
private val _inAppUpdatesCheckAllowed = MutableStateFlow(false)
override fun allowUpdate() {
_inAppUpdatesCheckAllowed.value = true
}
override suspend fun loadVersions() {
syncAndGetVersions()
}
override suspend fun hasImportantUpdates(): Boolean {
val lastSkippedVersion = getRecentVersionCheckpoint()
return syncAndGetVersions().any { it.shouldPresentUpdate(appVersion, lastSkippedVersion) }
}
private fun Map.Entry<Version, VersionResponse>.shouldPresentUpdate(
appVersion: Version,
latestSkippedVersion: Version?,
): Boolean {
val (updateVersion, updateInfo) = this
val alreadyUpdated = appVersion >= updateVersion
if (alreadyUpdated) return false
val notImportantUpdate = updateInfo.severity == REMOTE_SEVERITY_NORMAL
if (notImportantUpdate) return false
val hasSkippedThisUpdate = latestSkippedVersion != null && latestSkippedVersion >= updateVersion
val canBypassSkip = updateInfo.severity == REMOTE_SEVERITY_CRITICAL
if (hasSkippedThisUpdate && !canBypassSkip) return false
return true
}
override suspend fun getNewUpdateNotifications(): List<UpdateNotification> {
return syncAndGetVersions()
.filter { appVersion < it.key }
.map { getChangelogAsync(it.key, it.value) }
.awaitAll()
.filterNotNull()
}
override suspend fun skipCurrentUpdates() {
val latestUpdateNotification = syncAndGetVersions()
.maxWith { first, second -> first.key.compareTo(second.key) }
preferences.putString(PREF_VERSION_CHECKPOINT, latestUpdateNotification.key.toString())
}
override fun inAppUpdatesCheckAllowedFlow(): Flow<Boolean> {
return _inAppUpdatesCheckAllowed
}
private fun getRecentVersionCheckpoint(): Version? {
val checkpointVersion = preferences.getString(PREF_VERSION_CHECKPOINT)
return checkpointVersion?.toVersion()
}
private suspend fun getChangelogAsync(version: Version, versionResponse: VersionResponse): Deferred<UpdateNotification?> {
return coroutineScope {
async(Dispatchers.Default) {
val versionFileName = version.toUnderscoreString()
val changelog = runCatching { versionsFetcher.getChangelog(versionFileName) }.getOrNull()
mapFromRemoteVersion(version, versionResponse, changelog)
}
}
}
private suspend fun syncAndGetVersions(): Map<Version, VersionResponse> {
return mutex.withLock {
if (versions.isEmpty()) {
versions = runCatching { fetchVersions() }
.getOrElse { emptyMap() }
}
versions
}
}
private suspend fun fetchVersions(): Map<Version, VersionResponse> {
return versionsFetcher.getVersions()
.associateBy { it.version.toVersion() }
}
@Suppress("DEPRECATION")
private fun getAppVersion(): Version {
return appVersionProvider.versionName.toVersion()
}
private fun String.toVersion(): Version {
val cleanedVersion = replace("[^\\d.]".toRegex(), "")
val (major, minor, patch) = cleanedVersion.split(".")
.map { it.toLong() }
return Version(major, minor, patch)
}
}
@@ -0,0 +1,14 @@
package io.novafoundation.nova.feature_versions_impl.data
import io.novafoundation.nova.feature_versions_impl.BuildConfig
import retrofit2.http.GET
import retrofit2.http.Path
interface VersionsFetcher {
@GET(BuildConfig.NOTIFICATIONS_URL)
suspend fun getVersions(): List<VersionResponse>
@GET(BuildConfig.NOTIFICATION_DETAILS_URL + "{version}.md")
suspend fun getChangelog(@Path("version") version: String): String
}
@@ -0,0 +1,7 @@
package io.novafoundation.nova.feature_versions_impl.data
class VersionResponse(
val version: String,
val severity: String,
val time: String
)
@@ -0,0 +1,39 @@
package io.novafoundation.nova.feature_versions_impl.di
import dagger.BindsInstance
import dagger.Component
import io.novafoundation.nova.common.di.CommonApi
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.feature_versions_api.di.VersionsFeatureApi
import io.novafoundation.nova.feature_versions_api.presentation.VersionsRouter
import io.novafoundation.nova.feature_versions_impl.presentation.update.di.UpdateNotificationsComponent
@Component(
dependencies = [
VersionsFeatureDependencies::class
],
modules = [
VersionsFeatureModule::class
]
)
@FeatureScope
interface VersionsFeatureComponent : VersionsFeatureApi {
fun updateNotificationsFragmentComponentFactory(): UpdateNotificationsComponent.Factory
@Component.Factory
interface Factory {
fun create(
@BindsInstance router: VersionsRouter,
deps: VersionsFeatureDependencies
): VersionsFeatureComponent
}
@Component(
dependencies = [
CommonApi::class
]
)
interface StakingFeatureDependenciesComponent : VersionsFeatureDependencies
}
@@ -0,0 +1,23 @@
package io.novafoundation.nova.feature_versions_impl.di
import android.content.Context
import coil.ImageLoader
import io.novafoundation.nova.common.data.network.NetworkApiCreator
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.common.resources.AppVersionProvider
import io.novafoundation.nova.common.resources.ResourceManager
interface VersionsFeatureDependencies {
fun resourceManager(): ResourceManager
fun networkApiCreator(): NetworkApiCreator
fun imageLoader(): ImageLoader
fun preferences(): Preferences
fun context(): Context
fun appVersionProvider(): AppVersionProvider
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_versions_impl.di
import io.novafoundation.nova.common.di.FeatureApiHolder
import io.novafoundation.nova.common.di.FeatureContainer
import io.novafoundation.nova.common.di.scope.ApplicationScope
import io.novafoundation.nova.feature_versions_api.presentation.VersionsRouter
import javax.inject.Inject
@ApplicationScope
class VersionsFeatureHolder @Inject constructor(
featureContainer: FeatureContainer,
private val router: VersionsRouter
) : FeatureApiHolder(featureContainer) {
override fun initializeDependencies(): Any {
val dependencies = DaggerVersionsFeatureComponent_StakingFeatureDependenciesComponent.builder()
.commonApi(commonApi())
.build()
return DaggerVersionsFeatureComponent.factory()
.create(
router = router,
deps = dependencies
)
}
}
@@ -0,0 +1,35 @@
package io.novafoundation.nova.feature_versions_impl.di
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.data.network.NetworkApiCreator
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.resources.AppVersionProvider
import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor
import io.novafoundation.nova.feature_versions_impl.data.RealVersionRepository
import io.novafoundation.nova.feature_versions_impl.data.VersionRepository
import io.novafoundation.nova.feature_versions_impl.data.VersionsFetcher
import io.novafoundation.nova.feature_versions_impl.domain.RealUpdateNotificationsInteractor
@Module
class VersionsFeatureModule {
@Provides
fun provideVersionsFetcher(
networkApiCreator: NetworkApiCreator,
) = networkApiCreator.create(VersionsFetcher::class.java)
@Provides
fun provideVersionService(
appVersionProvider: AppVersionProvider,
preferences: Preferences,
versionsFetcher: VersionsFetcher
): VersionRepository = RealVersionRepository(appVersionProvider, preferences, versionsFetcher)
@Provides
@FeatureScope
fun provideUpdateNotificationsInteractor(
versionRepository: VersionRepository
): UpdateNotificationsInteractor = RealUpdateNotificationsInteractor(versionRepository)
}
@@ -0,0 +1,36 @@
package io.novafoundation.nova.feature_versions_impl.domain
import io.novafoundation.nova.feature_versions_api.domain.UpdateNotification
import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor
import io.novafoundation.nova.feature_versions_impl.data.VersionRepository
import kotlinx.coroutines.flow.first
class RealUpdateNotificationsInteractor(
private val versionRepository: VersionRepository
) : UpdateNotificationsInteractor {
override suspend fun loadVersions() {
versionRepository.loadVersions()
}
override suspend fun waitPermissionToUpdate() {
versionRepository.inAppUpdatesCheckAllowedFlow().first { allowed -> allowed }
}
override fun allowInAppUpdateCheck() {
versionRepository.allowUpdate()
}
override suspend fun hasImportantUpdates(): Boolean {
return versionRepository.hasImportantUpdates()
}
override suspend fun getUpdateNotifications(): List<UpdateNotification> {
return versionRepository.getNewUpdateNotifications()
.sortedByDescending { it.version }
}
override suspend fun skipNewUpdates() {
versionRepository.skipCurrentUpdates()
}
}
@@ -0,0 +1,63 @@
package io.novafoundation.nova.feature_versions_impl.presentation.update
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.recyclerview.widget.ConcatAdapter
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.presentation.LoadingState
import io.novafoundation.nova.feature_versions_api.di.VersionsFeatureApi
import io.novafoundation.nova.feature_versions_impl.databinding.FragmentUpdateNotificationsBinding
import io.novafoundation.nova.feature_versions_impl.di.VersionsFeatureComponent
import io.novafoundation.nova.feature_versions_impl.presentation.update.adapters.UpdateNotificationsAdapter
import io.novafoundation.nova.feature_versions_impl.presentation.update.adapters.UpdateNotificationsBannerAdapter
import io.novafoundation.nova.feature_versions_impl.presentation.update.adapters.UpdateNotificationsSeeAllAdapter
class UpdateNotificationFragment :
BaseFragment<UpdateNotificationViewModel, FragmentUpdateNotificationsBinding>(),
UpdateNotificationsSeeAllAdapter.SeeAllClickedListener {
override fun createBinding() = FragmentUpdateNotificationsBinding.inflate(layoutInflater)
private val bannerAdapter = UpdateNotificationsBannerAdapter()
private val listAdapter = UpdateNotificationsAdapter()
private val seeAllAdapter = UpdateNotificationsSeeAllAdapter(this)
private val adapter = ConcatAdapter(bannerAdapter, listAdapter, seeAllAdapter)
override fun initViews() {
binder.updatesList.adapter = adapter
val decoration = UpdateNotificationsItemDecoration(requireContext())
binder.updatesList.addItemDecoration(decoration)
binder.updatesToolbar.setRightActionClickListener { viewModel.skipClicked() }
binder.updatesApply.setOnClickListener { viewModel.installUpdateClicked() }
}
override fun inject() {
FeatureUtils.getFeature<VersionsFeatureComponent>(this, VersionsFeatureApi::class.java)
.updateNotificationsFragmentComponentFactory()
.create(this)
.inject(this)
}
override fun subscribe(viewModel: UpdateNotificationViewModel) {
viewModel.bannerModel.observe {
bannerAdapter.setModel(it)
}
viewModel.notificationModels.observe {
binder.updateNotificationsProgress.isVisible = it is LoadingState.Loading
binder.updatesList.isGone = it is LoadingState.Loading
if (it is LoadingState.Loaded) {
listAdapter.submitList(it.data)
}
}
viewModel.seeAllButtonVisible.observe {
seeAllAdapter.showButton(it)
}
}
override fun onSeeAllClicked() {
viewModel.showAllNotifications()
}
}
@@ -0,0 +1,23 @@
package io.novafoundation.nova.feature_versions_impl.presentation.update
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
class UpdateNotificationBannerModel(
@DrawableRes val iconRes: Int,
@DrawableRes val backgroundRes: Int,
val title: String,
val message: String
)
class UpdateNotificationModel(
val version: String,
val changelog: String,
val isLatestUpdate: Boolean,
val severity: String?,
@ColorRes val severityColorRes: Int?,
@ColorRes val severityBackgroundRes: Int?,
val date: String
)
class SeeAllButtonModel
@@ -0,0 +1,134 @@
package io.novafoundation.nova.feature_versions_impl.presentation.update
import io.noties.markwon.Markwon
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.common.utils.formatting.formatDateSinceEpoch
import io.novafoundation.nova.common.utils.withLoading
import io.novafoundation.nova.feature_versions_api.domain.Severity
import io.novafoundation.nova.feature_versions_api.domain.UpdateNotification
import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor
import io.novafoundation.nova.feature_versions_api.presentation.VersionsRouter
import io.novafoundation.nova.feature_versions_impl.R
import io.novafoundation.nova.feature_versions_impl.presentation.update.models.UpdateNotificationBannerModel
import io.novafoundation.nova.feature_versions_impl.presentation.update.models.UpdateNotificationModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class UpdateNotificationViewModel(
private val router: VersionsRouter,
private val interactor: UpdateNotificationsInteractor,
private val resourceManager: ResourceManager,
private val markwon: Markwon,
) : BaseViewModel() {
private val showAllNotifications = MutableStateFlow(false)
private val notifications = flowOf { interactor.getUpdateNotifications() }
val bannerModel = notifications.map { getBannerOrNull(it) }
.shareInBackground()
val notificationModels = combine(showAllNotifications, notifications) { shouldShowAll, notifications ->
val result = if (shouldShowAll) {
notifications
} else {
notifications.take(1)
}
mapUpdateNotificationsToModels(result)
}
.withLoading()
.shareInBackground()
val seeAllButtonVisible = combine(showAllNotifications, notifications) { shouldShowAll, notifications ->
notifications.size > 1 && !shouldShowAll
}
.shareInBackground()
fun skipClicked() = launch {
interactor.skipNewUpdates()
router.closeUpdateNotifications()
}
fun installUpdateClicked() {
router.closeUpdateNotifications()
router.openAppUpdater()
}
fun showAllNotifications() {
showAllNotifications.value = true
}
private fun hasCriticalUpdates(list: List<UpdateNotification>): Boolean {
return list.any {
it.severity == Severity.CRITICAL
}
}
private fun hasMajorUpdates(list: List<UpdateNotification>): Boolean {
return list.any {
it.severity == Severity.MAJOR
}
}
private fun mapUpdateNotificationsToModels(list: List<UpdateNotification>): List<UpdateNotificationModel> {
return list.mapIndexed { index, version ->
UpdateNotificationModel(
version = version.version.toString(),
changelog = version.changelog?.let { markwon.toMarkdown(it) },
isLatestUpdate = index == 0,
severity = mapSeverity(version.severity),
severityColorRes = mapSeverityColor(version.severity),
severityBackgroundRes = mapSeverityBackground(version.severity),
date = version.time.formatDateSinceEpoch(resourceManager)
)
}
}
private fun mapSeverity(severity: Severity): String? {
return when (severity) {
Severity.CRITICAL -> resourceManager.getString(R.string.update_notifications_severity_critical)
Severity.MAJOR -> resourceManager.getString(R.string.update_notifications_severity_major)
Severity.NORMAL -> null
}
}
private fun mapSeverityColor(severity: Severity): Int? {
return when (severity) {
Severity.CRITICAL -> R.color.critical_update_chip_text
Severity.MAJOR -> R.color.major_update_chip_text
Severity.NORMAL -> null
}
}
private fun mapSeverityBackground(severity: Severity): Int? {
return when (severity) {
Severity.CRITICAL -> R.color.critical_update_chip_background
Severity.MAJOR -> R.color.major_update_chip_background
Severity.NORMAL -> null
}
}
private fun getBannerOrNull(notifications: List<UpdateNotification>): UpdateNotificationBannerModel? {
if (hasCriticalUpdates(notifications)) {
return UpdateNotificationBannerModel(
R.drawable.ic_critical_update,
R.drawable.ic_banner_yellow_gradient,
resourceManager.getString(R.string.update_notifications_critical_update_alert_titile),
resourceManager.getString(R.string.update_notifications_critical_update_alert_subtitile)
)
} else if (hasMajorUpdates(notifications)) {
return UpdateNotificationBannerModel(
R.drawable.ic_major_update,
R.drawable.ic_banner_turquoise_gradient,
resourceManager.getString(R.string.update_notifications_major_update_alert_titile),
resourceManager.getString(R.string.update_notifications_major_update_alert_subtitile)
)
}
return null
}
}
@@ -0,0 +1,108 @@
package io.novafoundation.nova.feature_versions_impl.presentation.update
import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import io.novafoundation.nova.common.list.GroupedListHolder
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.setTextColorRes
import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable
import io.novafoundation.nova.feature_versions_impl.R
import io.novafoundation.nova.feature_versions_impl.databinding.ItemUpdateNotificationBinding
import io.novafoundation.nova.feature_versions_impl.databinding.ItemUpdateNotificationHeaderBinding
import io.novafoundation.nova.feature_versions_impl.databinding.ItemUpdateNotificationSeeAllBinding
class UpdateNotificationsAdapter(private val seeAllClickedListener: SeeAllClickedListener) : ListAdapter<Any, ViewHolder>(
DiffCallback
) {
interface SeeAllClickedListener {
fun onSeeAllClicked()
}
companion object {
private const val TYPE_HEADER = 0
private const val TYPE_VERSION = 1
private const val TYPE_SEE_ALL = 2
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return when (viewType) {
TYPE_HEADER -> UpdateNotificationBannerHolder(ItemUpdateNotificationHeaderBinding.inflate(parent.inflater(), parent, false))
TYPE_VERSION -> UpdateNotificationHolder(ItemUpdateNotificationBinding.inflate(parent.inflater(), parent, false))
TYPE_SEE_ALL -> SeeAllButtonHolder(ItemUpdateNotificationSeeAllBinding.inflate(parent.inflater(), parent, false), seeAllClickedListener)
else -> throw IllegalStateException()
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = getItem(position)
when (holder) {
is UpdateNotificationBannerHolder -> holder.bind(item as UpdateNotificationBannerModel)
is UpdateNotificationHolder -> holder.bind(item as UpdateNotificationModel)
}
}
override fun getItemViewType(position: Int): Int {
val item = getItem(position)
return when (item) {
is UpdateNotificationBannerModel -> TYPE_HEADER
is UpdateNotificationModel -> TYPE_VERSION
is SeeAllButtonModel -> TYPE_SEE_ALL
else -> throw IllegalStateException()
}
}
}
private object DiffCallback : DiffUtil.ItemCallback<Any>() {
override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean {
return true
}
override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean {
return true
}
}
class UpdateNotificationBannerHolder(private val binder: ItemUpdateNotificationHeaderBinding) : GroupedListHolder(binder.root) {
fun bind(item: UpdateNotificationBannerModel) {
binder.itemUpdateNotificationBanner.setImage(item.iconRes)
binder.itemUpdateNotificationBanner.setBannerBackground(item.backgroundRes)
binder.itemUpdateNotificationAlertTitle.text = item.title
binder.itemUpdateNotificationAlertSubtitle.text = item.message
}
}
class UpdateNotificationHolder(private val binder: ItemUpdateNotificationBinding) : GroupedListHolder(binder.root) {
init {
binder.itemNotificationLatest.background = binder.root.context.getRoundedCornerDrawable(R.color.chips_background, cornerSizeInDp = 6)
}
fun bind(item: UpdateNotificationModel) {
binder.itemNotificationVersion.text = itemView.context.getString(R.string.update_notification_item_version, item.version)
binder.itemNotificationDescription.text = item.changelog
binder.itemNotificationSeverity.isGone = item.severity == null
binder.itemNotificationSeverity.text = item.severity
item.severityColorRes?.let { binder.itemNotificationSeverity.setTextColorRes(it) }
item.severityBackgroundRes?.let { binder.itemNotificationSeverity.background = itemView.context.getRoundedCornerDrawable(it, cornerSizeInDp = 6) }
binder.itemNotificationLatest.isVisible = item.isLatestUpdate
binder.itemNotificationDate.text = item.date
}
}
class SeeAllButtonHolder(
private val binder: ItemUpdateNotificationSeeAllBinding,
seeAllClickedListener: UpdateNotificationsAdapter.SeeAllClickedListener
) : GroupedListHolder(binder.root) {
init {
binder.root.setOnClickListener { seeAllClickedListener.onSeeAllClicked() }
}
}
@@ -0,0 +1,55 @@
package io.novafoundation.nova.feature_versions_impl.presentation.update
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Rect
import android.view.View
import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView
import io.novafoundation.nova.common.utils.dpF
import io.novafoundation.nova.feature_versions_impl.R
import io.novafoundation.nova.feature_versions_impl.presentation.update.adapters.UpdateNotificationHolder
import kotlin.math.roundToInt
class UpdateNotificationsItemDecoration(
val context: Context
) : RecyclerView.ItemDecoration() {
private val dividerColor: Int = context.getColor(R.color.divider)
private val dividerSize: Float = 1.dpF(context)
private val paddingHorizontal: Float = 16.dpF(context)
private val dividerOffset = dividerSize / 2
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
strokeWidth = dividerSize
color = dividerColor
style = Paint.Style.STROKE
}
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val index = parent.layoutManager!!.getPosition(view)
if (!parent.shouldApplyDecoration(parent, index, view)) return
outRect.set(0, 0, 0, dividerSize.roundToInt())
}
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
val children = filterItems(parent)
children.forEach { view ->
val dividerY = view.bottom + dividerOffset
canvas.drawLine(paddingHorizontal, dividerY, view.right - paddingHorizontal, dividerY, paint)
}
}
private fun filterItems(parent: RecyclerView): List<View> {
return parent.children
.toList()
.filterIndexed { index, view -> parent.shouldApplyDecoration(parent, index, view) }
}
private fun RecyclerView.shouldApplyDecoration(parent: RecyclerView, index: Int, view: View): Boolean {
val thisViewHolder = getChildViewHolder(view)
val nextViewHolder = getChildAt(index + 1)?.let { getChildViewHolder(it) }
return thisViewHolder is UpdateNotificationHolder && nextViewHolder is UpdateNotificationHolder
}
}
@@ -0,0 +1,58 @@
package io.novafoundation.nova.feature_versions_impl.presentation.update.adapters
import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import io.novafoundation.nova.common.list.GroupedListHolder
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.setTextColorRes
import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable
import io.novafoundation.nova.feature_versions_impl.R
import io.novafoundation.nova.feature_versions_impl.databinding.ItemUpdateNotificationBinding
import io.novafoundation.nova.feature_versions_impl.presentation.update.models.UpdateNotificationModel
class UpdateNotificationsAdapter : ListAdapter<UpdateNotificationModel, UpdateNotificationHolder>(
UpdateNotificationsDiffCallback
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UpdateNotificationHolder {
return UpdateNotificationHolder(ItemUpdateNotificationBinding.inflate(parent.inflater(), parent, false))
}
override fun onBindViewHolder(holder: UpdateNotificationHolder, position: Int) {
holder.bind(getItem(position))
}
}
private object UpdateNotificationsDiffCallback : DiffUtil.ItemCallback<UpdateNotificationModel>() {
override fun areItemsTheSame(oldItem: UpdateNotificationModel, newItem: UpdateNotificationModel): Boolean {
return oldItem.version == newItem.version
}
override fun areContentsTheSame(oldItem: UpdateNotificationModel, newItem: UpdateNotificationModel): Boolean {
return true
}
}
class UpdateNotificationHolder(private val binder: ItemUpdateNotificationBinding) : GroupedListHolder(binder.root) {
init {
binder.itemNotificationLatest.background = binder.root.context.getRoundedCornerDrawable(R.color.chips_background, cornerSizeInDp = 6)
}
fun bind(item: UpdateNotificationModel) {
binder.itemNotificationVersion.text = itemView.context.getString(R.string.update_notification_item_version, item.version)
binder.itemNotificationDescription.text = item.changelog
binder.itemNotificationDescription.isVisible = item.changelog != null
binder.itemNotificationSeverity.isGone = item.severity == null
binder.itemNotificationSeverity.text = item.severity
item.severityColorRes?.let { binder.itemNotificationSeverity.setTextColorRes(it) }
item.severityBackgroundRes?.let { binder.itemNotificationSeverity.background = itemView.context.getRoundedCornerDrawable(it, cornerSizeInDp = 6) }
binder.itemNotificationLatest.isVisible = item.isLatestUpdate
binder.itemNotificationDate.text = item.date
}
}
@@ -0,0 +1,50 @@
package io.novafoundation.nova.feature_versions_impl.presentation.update.adapters
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import io.novafoundation.nova.common.list.GroupedListHolder
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.feature_versions_impl.databinding.ItemUpdateNotificationHeaderBinding
import io.novafoundation.nova.feature_versions_impl.presentation.update.models.UpdateNotificationBannerModel
class UpdateNotificationsBannerAdapter : RecyclerView.Adapter<UpdateNotificationBannerHolder>() {
private var bannerModel: UpdateNotificationBannerModel? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UpdateNotificationBannerHolder {
return UpdateNotificationBannerHolder(ItemUpdateNotificationHeaderBinding.inflate(parent.inflater(), parent, false))
}
override fun onBindViewHolder(holder: UpdateNotificationBannerHolder, position: Int) {
holder.bind(bannerModel ?: return)
}
override fun getItemCount(): Int {
return if (bannerModel != null) 1 else 0
}
fun setModel(model: UpdateNotificationBannerModel?) {
val newNotNull = model != null
val oldNotNull = bannerModel != null
bannerModel = model
if (newNotNull != oldNotNull) {
if (newNotNull) {
notifyItemInserted(0)
} else {
notifyItemRemoved(0)
}
}
}
}
class UpdateNotificationBannerHolder(private val binder: ItemUpdateNotificationHeaderBinding) : GroupedListHolder(binder.root) {
fun bind(item: UpdateNotificationBannerModel) {
binder.itemUpdateNotificationBanner.setImage(item.iconRes)
binder.itemUpdateNotificationBanner.setBannerBackground(item.backgroundRes)
binder.itemUpdateNotificationAlertTitle.text = item.title
binder.itemUpdateNotificationAlertSubtitle.text = item.message
}
}
@@ -0,0 +1,45 @@
package io.novafoundation.nova.feature_versions_impl.presentation.update.adapters
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import io.novafoundation.nova.common.list.GroupedListHolder
import io.novafoundation.nova.common.utils.inflateChild
import io.novafoundation.nova.feature_versions_impl.R
class UpdateNotificationsSeeAllAdapter(private val seeAllClickedListener: SeeAllClickedListener) : RecyclerView.Adapter<SeeAllButtonHolder>() {
interface SeeAllClickedListener {
fun onSeeAllClicked()
}
private var showBanner: Boolean = false
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SeeAllButtonHolder {
return SeeAllButtonHolder(parent.inflateChild(R.layout.item_update_notification_see_all), seeAllClickedListener)
}
override fun getItemCount(): Int {
return if (showBanner) 1 else 0
}
override fun onBindViewHolder(holder: SeeAllButtonHolder, position: Int) {}
fun showButton(show: Boolean) {
if (showBanner != show) {
showBanner = show
if (showBanner) {
notifyItemInserted(0)
} else {
notifyItemRemoved(0)
}
}
}
}
class SeeAllButtonHolder(view: View, seeAllClickedListener: UpdateNotificationsSeeAllAdapter.SeeAllClickedListener) : GroupedListHolder(view) {
init {
view.setOnClickListener { seeAllClickedListener.onSeeAllClicked() }
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_versions_impl.presentation.update.di
import androidx.fragment.app.Fragment
import dagger.BindsInstance
import dagger.Subcomponent
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.feature_versions_impl.presentation.update.UpdateNotificationFragment
@Subcomponent(
modules = [
UpdateNotificationsModule::class
]
)
@ScreenScope
interface UpdateNotificationsComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment
): UpdateNotificationsComponent
}
fun inject(fragment: UpdateNotificationFragment)
}
@@ -0,0 +1,40 @@
package io.novafoundation.nova.feature_versions_impl.presentation.update.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.noties.markwon.Markwon
import io.novafoundation.nova.common.di.modules.shared.MarkdownFullModule
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor
import io.novafoundation.nova.feature_versions_api.presentation.VersionsRouter
import io.novafoundation.nova.feature_versions_impl.presentation.update.UpdateNotificationViewModel
@Module(includes = [ViewModelModule::class, MarkdownFullModule::class])
class UpdateNotificationsModule {
@Provides
@IntoMap
@ViewModelKey(UpdateNotificationViewModel::class)
fun provideViewModel(
router: VersionsRouter,
interactor: UpdateNotificationsInteractor,
resourceManager: ResourceManager,
markwon: Markwon,
): ViewModel {
return UpdateNotificationViewModel(router, interactor, resourceManager, markwon)
}
@Provides
fun provideViewModelCreator(
fragment: Fragment,
viewModelFactory: ViewModelProvider.Factory
): UpdateNotificationViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(UpdateNotificationViewModel::class.java)
}
}
@@ -0,0 +1,21 @@
package io.novafoundation.nova.feature_versions_impl.presentation.update.models
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
class UpdateNotificationBannerModel(
@DrawableRes val iconRes: Int,
@DrawableRes val backgroundRes: Int,
val title: String,
val message: String
)
class UpdateNotificationModel(
val version: String,
val changelog: CharSequence?,
val isLatestUpdate: Boolean,
val severity: String?,
@ColorRes val severityColorRes: Int?,
@ColorRes val severityBackgroundRes: Int?,
val date: String
)
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/secondary_screen_background">
<io.novafoundation.nova.common.view.Toolbar
android:id="@+id/updatesToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:dividerVisible="false"
app:homeButtonVisible="false"
app:layout_constraintTop_toTopOf="parent"
app:textRight="@string/common_skip"
app:titleText="@string/update_notification_title" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/updatesList"
android:layout_width="match_parent"
android:layout_height="0dp"
android:clipToPadding="false"
android:overScrollMode="never"
android:paddingBottom="72dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/updatesToolbar"
tools:itemCount="10" />
<ProgressBar
android:id="@+id/updateNotificationsProgress"
style="@style/Widget.Nova.ProgressBar.Indeterminate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.499" />
<io.novafoundation.nova.common.view.PrimaryButton
android:id="@+id/updatesApply"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="24dp"
android:text="@string/update_notification_install_btn"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="16dp"
android:paddingBottom="16dp">
<TextView
android:id="@+id/itemNotificationVersion"
style="@style/TextAppearance.NovaFoundation.Bold.Title2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:includeFontPadding="false"
android:textColor="#fff"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Version 5.0.3" />
<TextView
android:id="@+id/itemNotificationSeverity"
style="@style/TextAppearance.NovaFoundation.SemiBold.Caps2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:minHeight="16dp"
android:paddingHorizontal="6dp"
android:paddingVertical="1dp"
android:textColor="@color/chip_text"
app:layout_constraintBottom_toBottomOf="@id/itemNotificationVersion"
app:layout_constraintStart_toEndOf="@+id/itemNotificationVersion"
app:layout_constraintTop_toTopOf="@id/itemNotificationVersion"
tools:text="@string/update_notifications_severity_critical" />
<TextView
android:id="@+id/itemNotificationLatest"
style="@style/TextAppearance.NovaFoundation.SemiBold.Caps2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:minHeight="16dp"
android:paddingHorizontal="6dp"
android:paddingVertical="1dp"
android:text="@string/update_notifications_latest"
android:textColor="@color/chip_text"
app:layout_constraintBottom_toBottomOf="@id/itemNotificationVersion"
app:layout_constraintStart_toEndOf="@+id/itemNotificationSeverity"
app:layout_constraintTop_toTopOf="@id/itemNotificationVersion" />
<TextView
android:id="@+id/itemNotificationDate"
style="@style/TextAppearance.NovaFoundation.Regular.Caption1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="4dp"
android:textColor="@color/text_secondary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/itemNotificationVersion"
tools:text="Dec 27, 2022" />
<TextView
android:id="@+id/itemNotificationDescription"
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="12dp"
android:textColor="@color/text_secondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/itemNotificationDate"
tools:text="Tokens used in governance can now be used in staking Updated ERC-20 Transaction History screen to display actual transaction fee for transfers Fixed the fee calculation for tokens when a chain's main token is disabled" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<io.novafoundation.nova.common.view.BannerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/itemUpdateNotificationBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="12dp"
tools:bannerBackground="@drawable/ic_banner_gray_gradient">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="76dp"
android:layout_marginBottom="36dp"
android:orientation="vertical">
<TextView
android:id="@+id/itemUpdateNotificationAlertTitle"
style="@style/TextAppearance.NovaFoundation.Bold.Title3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="end"
android:includeFontPadding="false"
android:textColor="@color/text_primary"
tools:text="@string/update_notifications_critical_update_alert_titile" />
<TextView
android:id="@+id/itemUpdateNotificationAlertSubtitle"
style="@style/TextAppearance.NovaFoundation.Regular.Caption1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="@color/text_secondary"
tools:text="@string/update_notifications_critical_update_alert_subtitile" />
</LinearLayout>
</io.novafoundation.nova.common.view.BannerView>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:gravity="center"
android:paddingHorizontal="8dp"
android:paddingVertical="6dp"
android:text="@string/update_notifications_show_more"
android:textColor="@color/button_text_accent" />
@@ -0,0 +1,131 @@
package io.novafoundation.nova.feature_versions_impl.data
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.common.resources.AppVersionProvider
import io.novafoundation.nova.test_shared.any
import io.novafoundation.nova.test_shared.whenever
import junit.framework.Assert.assertEquals
import kotlinx.coroutines.runBlocking
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito
import org.mockito.junit.MockitoJUnitRunner
@RunWith(MockitoJUnitRunner::class)
internal class RealVersionRepositoryTest {
@Test
fun `should not show updates if app is updated`() = runBlocking {
runHasImportantUpdatesTest(
appVersion = "1.2.0",
updates = listOf(critical("1.2.0")),
latestSkippedVersion = null,
isImportant = false
)
runHasImportantUpdatesTest(
appVersion = "1.2.0",
updates = listOf(normal("1.2.0")),
latestSkippedVersion = null,
isImportant = false
)
runHasImportantUpdatesTest(
appVersion = "1.2.0",
updates = listOf(critical("1.1.0"), major("1.2.0")),
latestSkippedVersion = null,
isImportant = false
)
}
@Test
fun `should not show updates if there are no updates`() = runBlocking {
runHasImportantUpdatesTest(
appVersion = "1.2.0",
updates = emptyList(),
latestSkippedVersion = null,
isImportant = false
)
}
@Test
fun `should show updates if there is a critical update`() = runBlocking {
runHasImportantUpdatesTest(
appVersion = "1.0.0",
updates = listOf(critical("1.1.0")),
latestSkippedVersion = null,
isImportant = true
)
// even if it is skipped
runHasImportantUpdatesTest(
appVersion = "1.0.0",
updates = listOf(critical("1.1.0")),
latestSkippedVersion = "1.1.0",
isImportant = true
)
}
@Test
fun `should not show updates if there is only a normal update`() = runBlocking {
runHasImportantUpdatesTest(
appVersion = "1.0.0",
updates = listOf(normal("1.0.1")),
latestSkippedVersion = null,
isImportant = false
)
}
@Test
fun `should not show updates if major update was skipped`() = runBlocking {
runHasImportantUpdatesTest(
appVersion = "1.0.0",
updates = listOf(major("1.1.0")),
latestSkippedVersion = "1.1.0",
isImportant = false
)
}
@Test
fun `should not show updates if critical update is installed and others are skipped major`() = runBlocking {
runHasImportantUpdatesTest(
appVersion = "1.1.0",
updates = listOf(critical("1.1.0"), major("1.2.0")),
latestSkippedVersion = "1.2.0",
isImportant = false
)
}
private suspend fun runHasImportantUpdatesTest(
appVersion: String,
updates: List<Pair<String, String>>, // [(version, priority)]
latestSkippedVersion: String?,
isImportant: Boolean
) {
val preferences = Mockito.mock(Preferences::class.java).also {
whenever(it.getString(any())).thenReturn(latestSkippedVersion)
}
val versionResponses = updates.map { (version, severity) -> VersionResponse(version, severity, "2022-12-22T06:46:07Z") }
val fetcher = Mockito.mock(VersionsFetcher::class.java).also {
whenever(it.getVersions()).thenReturn(versionResponses)
}
val appVersionProvider = Mockito.mock(AppVersionProvider::class.java).also {
whenever(it.versionName).thenReturn(appVersion)
}
val repository = RealVersionRepository(appVersionProvider, preferences, fetcher)
val actualIsImportant = repository.hasImportantUpdates()
assertEquals(isImportant, actualIsImportant)
}
private fun critical(version: String) = version to REMOTE_SEVERITY_CRITICAL
private fun normal(version: String) = version to REMOTE_SEVERITY_NORMAL
private fun major(version: String) = version to REMOTE_SEVERITY_MAJOR
}