mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-04-22 04:27:58 +00:00
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:
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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
@@ -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 />
|
||||
+27
@@ -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
|
||||
}
|
||||
}
|
||||
+143
@@ -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)
|
||||
}
|
||||
}
|
||||
+14
@@ -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
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package io.novafoundation.nova.feature_versions_impl.data
|
||||
|
||||
class VersionResponse(
|
||||
val version: String,
|
||||
val severity: String,
|
||||
val time: String
|
||||
)
|
||||
+39
@@ -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
|
||||
}
|
||||
+23
@@ -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
|
||||
}
|
||||
+26
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+35
@@ -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)
|
||||
}
|
||||
+36
@@ -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()
|
||||
}
|
||||
}
|
||||
+63
@@ -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()
|
||||
}
|
||||
}
|
||||
+23
@@ -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
|
||||
+134
@@ -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
|
||||
}
|
||||
}
|
||||
+108
@@ -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() }
|
||||
}
|
||||
}
|
||||
+55
@@ -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
|
||||
}
|
||||
}
|
||||
+58
@@ -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
|
||||
}
|
||||
}
|
||||
+50
@@ -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
|
||||
}
|
||||
}
|
||||
+45
@@ -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() }
|
||||
}
|
||||
}
|
||||
+26
@@ -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)
|
||||
}
|
||||
+40
@@ -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)
|
||||
}
|
||||
}
|
||||
+21
@@ -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" />
|
||||
+131
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user