Initial commit: Pezkuwi Wallet Android

Complete rebrand of Nova Wallet for Pezkuwichain ecosystem.

## Features
- Full Pezkuwichain support (HEZ & PEZ tokens)
- Polkadot ecosystem compatibility
- Staking, Governance, DeFi, NFTs
- XCM cross-chain transfers
- Hardware wallet support (Ledger, Polkadot Vault)
- WalletConnect v2
- Push notifications

## Languages
- English, Turkish, Kurmanci (Kurdish), Spanish, French, German, Russian, Japanese, Chinese, Korean, Portuguese, Vietnamese

Based on Nova Wallet by Novasama Technologies GmbH
© Dijital Kurdistan Tech Institute 2026
This commit is contained in:
2026-01-23 01:31:12 +03:00
commit 31c8c5995f
7621 changed files with 425838 additions and 0 deletions
@@ -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
}