feat: An option to show a changelog between current and previously installed version

This commit is contained in:
Artem Chepurnoy 2024-01-29 23:37:18 +02:00
parent a76c0f929c
commit d39be3d197
No known key found for this signature in database
GPG Key ID: FAC37D0CF674043E
16 changed files with 344 additions and 2 deletions

View File

@ -1,18 +1,21 @@
package com.artemchep.keyguard.common
import com.artemchep.keyguard.common.io.attempt
import com.artemchep.keyguard.common.io.launchIn
import com.artemchep.keyguard.common.model.MasterSession
import com.artemchep.keyguard.common.usecase.GetVaultSession
import com.artemchep.keyguard.common.usecase.UpdateVersionLog
import com.artemchep.keyguard.platform.lifecycle.LeLifecycleState
import com.artemchep.keyguard.platform.lifecycle.onState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
import org.kodein.di.DirectDI
import org.kodein.di.direct
@ -20,9 +23,11 @@ import org.kodein.di.instance
class AppWorkerIm(
private val getVaultSession: GetVaultSession,
private val updateVersionLog: UpdateVersionLog,
) : AppWorker {
constructor(directDI: DirectDI) : this(
getVaultSession = directDI.instance(),
updateVersionLog = directDI.instance(),
)
override fun launch(
@ -35,7 +40,17 @@ class AppWorkerIm(
.onState(LeLifecycleState.STARTED) {
launchSyncManagerWhenAvailable(this)
}
.collect()
.launchIn(this)
// The app should keep a log of last installed versions,
// so we can show a nice changelog to a user.
flow
.onState(LeLifecycleState.STARTED) {
updateVersionLog()
.attempt()
.launchIn(this)
}
.take(1) // no need to restart, the version won't change
.launchIn(this)
}
private fun launchSyncManagerWhenAvailable(scope: CoroutineScope) = getVaultSession()

View File

@ -0,0 +1,9 @@
package com.artemchep.keyguard.common.model
import kotlinx.datetime.Instant
data class AppVersionLog(
val version: String,
val ref: String,
val timestamp: Instant,
)

View File

@ -8,6 +8,9 @@ import kotlinx.coroutines.InternalCoroutinesApi
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
interface KeyValueStore {
companion object {
@ -59,6 +62,30 @@ fun <T> KeyValueStore.getObject(
}
}
inline fun <reified T> KeyValueStore.getSerializable(
json: Json,
key: String,
defaultValue: T,
): KeyValuePreference<T> = getObject<T>(
key = key,
defaultValue = defaultValue,
serialize = { entity ->
if (entity == null) {
return@getObject ""
}
json.encodeToString(entity)
},
deserialize = {
runCatching {
json.decodeFromString<T>(it)
}.getOrElse {
// Fallback to the default value
defaultValue
}
},
)
inline fun <reified T : Enum<*>> KeyValueStore.getEnumNullable(
key: String,
crossinline lens: (T) -> String,

View File

@ -3,6 +3,7 @@ package com.artemchep.keyguard.common.service.settings
import com.artemchep.keyguard.common.model.AppColors
import com.artemchep.keyguard.common.model.AppFont
import com.artemchep.keyguard.common.model.AppTheme
import com.artemchep.keyguard.common.model.AppVersionLog
import com.artemchep.keyguard.common.model.NavAnimation
import kotlinx.coroutines.flow.Flow
import kotlinx.datetime.Instant
@ -64,6 +65,8 @@ interface SettingsReadRepository {
fun getMarkdown(): Flow<Boolean>
fun getAppVersionLog(): Flow<List<AppVersionLog>>
fun getNavAnimation(): Flow<NavAnimation?>
fun getNavLabel(): Flow<Boolean>

View File

@ -4,6 +4,7 @@ import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.model.AppColors
import com.artemchep.keyguard.common.model.AppFont
import com.artemchep.keyguard.common.model.AppTheme
import com.artemchep.keyguard.common.model.AppVersionLog
import com.artemchep.keyguard.common.model.NavAnimation
import kotlinx.datetime.Instant
import kotlin.time.Duration
@ -112,6 +113,10 @@ interface SettingsReadWriteRepository : SettingsReadRepository {
markdown: Boolean,
): IO<Unit>
fun setAppVersionLog(
log: List<AppVersionLog>,
): IO<Unit>
fun setOnboardingLastVisitInstant(
instant: Instant,
): IO<Unit>

View File

@ -0,0 +1,47 @@
package com.artemchep.keyguard.common.service.settings.entity
import com.artemchep.keyguard.common.model.AppVersionLog
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
@Serializable
data class VersionLogEntity(
val items: List<Item> = emptyList(),
) {
companion object;
@Serializable
data class Item(
val version: String,
val ref: String,
val timestamp: Instant,
)
}
fun VersionLogEntity.Companion.of(
log: List<AppVersionLog>,
) = kotlin.run {
val items = log
.map { item ->
VersionLogEntity.Item(
version = item.version,
ref = item.ref,
timestamp = item.timestamp,
)
}
VersionLogEntity(
items = items,
)
}
fun VersionLogEntity.toDomain(): List<AppVersionLog> = run {
val items = items
.map { item ->
AppVersionLog(
version = item.version,
ref = item.ref,
timestamp = item.timestamp,
)
}
items
}

View File

@ -1,20 +1,29 @@
package com.artemchep.keyguard.common.service.settings.impl
import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.io.flatMap
import com.artemchep.keyguard.common.io.ioEffect
import com.artemchep.keyguard.common.model.AppColors
import com.artemchep.keyguard.common.model.AppFont
import com.artemchep.keyguard.common.model.AppTheme
import com.artemchep.keyguard.common.model.AppVersionLog
import com.artemchep.keyguard.common.model.NavAnimation
import com.artemchep.keyguard.common.service.Files
import com.artemchep.keyguard.common.service.keyvalue.KeyValueStore
import com.artemchep.keyguard.common.service.keyvalue.asDuration
import com.artemchep.keyguard.common.service.keyvalue.getEnumNullable
import com.artemchep.keyguard.common.service.keyvalue.getObject
import com.artemchep.keyguard.common.service.keyvalue.getSerializable
import com.artemchep.keyguard.common.service.keyvalue.setAndCommit
import com.artemchep.keyguard.common.service.settings.SettingsReadWriteRepository
import com.artemchep.keyguard.common.service.settings.entity.VersionLogEntity
import com.artemchep.keyguard.common.service.settings.entity.of
import com.artemchep.keyguard.common.service.settings.entity.toDomain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.kodein.di.DirectDI
import org.kodein.di.instance
import kotlin.time.Duration
@ -24,6 +33,7 @@ import kotlin.time.Duration
*/
class SettingsRepositoryImpl(
private val store: KeyValueStore,
private val json: Json,
) : SettingsReadWriteRepository {
companion object {
private const val KEY_AUTOFILL_INLINE_SUGGESTIONS = "autofill.inline_suggestions"
@ -51,6 +61,7 @@ class SettingsRepositoryImpl(
private const val KEY_APP_ICONS = "app_icons"
private const val KEY_WEBSITE_ICONS = "website_icons"
private const val KEY_MARKDOWN = "markdown"
private const val KEY_VERSION_LOG = "version_log"
private const val KEY_NAV_ANIMATION = "nav_animation"
private const val KEY_NAV_LABEL = "nav_label"
private const val KEY_TWO_PANEL_LAYOUT_LANDSCAPE = "two_panel_layout_landscape"
@ -185,8 +196,16 @@ class SettingsRepositoryImpl(
},
)
private val versionLogPref =
store.getSerializable<VersionLogEntity?>(
json,
KEY_VERSION_LOG,
defaultValue = null,
)
constructor(directDI: DirectDI) : this(
store = directDI.instance<Files, KeyValueStore>(arg = Files.SETTINGS),
json = directDI.instance(),
)
override fun setAutofillInlineSuggestions(inlineSuggestions: Boolean) =
@ -326,6 +345,26 @@ class SettingsRepositoryImpl(
override fun getMarkdown() = markdownPref
override fun setAppVersionLog(log: List<AppVersionLog>) =
ioEffect {
val entity = log
.takeIf { it.isNotEmpty() }
// convert to an entity
?.let {
VersionLogEntity.of(it)
}
entity
}.flatMap { entity ->
versionLogPref
.setAndCommit(entity)
}
override fun getAppVersionLog() = versionLogPref
.map { entity ->
entity?.toDomain()
.orEmpty()
}
override fun setNavAnimation(navAnimation: NavAnimation?) = navAnimationPref
.setAndCommit(navAnimation)

View File

@ -0,0 +1,6 @@
package com.artemchep.keyguard.common.usecase
import com.artemchep.keyguard.common.model.AppVersionLog
import kotlinx.coroutines.flow.Flow
interface GetVersionLog : () -> Flow<List<AppVersionLog>>

View File

@ -0,0 +1,5 @@
package com.artemchep.keyguard.common.usecase
import com.artemchep.keyguard.common.io.IO
interface UpdateVersionLog : () -> IO<Unit>

View File

@ -0,0 +1,20 @@
package com.artemchep.keyguard.common.usecase.impl
import com.artemchep.keyguard.common.service.settings.SettingsReadRepository
import com.artemchep.keyguard.common.usecase.GetVersionLog
import kotlinx.coroutines.flow.distinctUntilChanged
import org.kodein.di.DirectDI
import org.kodein.di.instance
class GetVersionLogImpl(
settingsReadRepository: SettingsReadRepository,
) : GetVersionLog {
private val sharedFlow = settingsReadRepository.getAppVersionLog()
.distinctUntilChanged()
constructor(directDI: DirectDI) : this(
settingsReadRepository = directDI.instance(),
)
override fun invoke() = sharedFlow
}

View File

@ -0,0 +1,62 @@
package com.artemchep.keyguard.common.usecase.impl
import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.io.bind
import com.artemchep.keyguard.common.io.ioEffect
import com.artemchep.keyguard.common.model.AppVersionLog
import com.artemchep.keyguard.common.service.settings.SettingsReadWriteRepository
import com.artemchep.keyguard.common.usecase.GetAppBuildRef
import com.artemchep.keyguard.common.usecase.GetAppVersionName
import com.artemchep.keyguard.common.usecase.UpdateVersionLog
import kotlinx.coroutines.flow.first
import kotlinx.datetime.Clock
import org.kodein.di.DirectDI
import org.kodein.di.instance
class UpdateVersionLogImpl(
private val settingsReadWriteRepository: SettingsReadWriteRepository,
private val getAppBuildRef: GetAppBuildRef,
private val getAppVersionName: GetAppVersionName,
) : UpdateVersionLog {
constructor(directDI: DirectDI) : this(
settingsReadWriteRepository = directDI.instance(),
getAppBuildRef = directDI.instance(),
getAppVersionName = directDI.instance(),
)
override fun invoke(): IO<Unit> = ioEffect {
val log = settingsReadWriteRepository
.getAppVersionLog()
.first()
.toMutableList()
val buildRef = getAppBuildRef().first()
if (buildRef.isBlank()) {
return@ioEffect
}
// Check if the first entry in the log
// has the same build reference as the
// current one. If so, we do not need to
// update it.
val last = log.firstOrNull()
if (last?.ref == buildRef) {
return@ioEffect
}
val version = getAppVersionName().first()
val newEntry = AppVersionLog(
version = version,
ref = buildRef,
timestamp = Clock.System.now(),
)
// Append the new entry to the start of the
// log. Cap the size of the log to a small amount.
log.add(0, newEntry)
val newLog = log.take(3)
// Save the updated log.
settingsReadWriteRepository
.setAppVersionLog(newLog)
.bind()
}
}

View File

@ -20,6 +20,7 @@ import com.artemchep.keyguard.feature.EmptyView
import com.artemchep.keyguard.feature.home.settings.component.SettingComponent
import com.artemchep.keyguard.feature.home.settings.component.settingAboutAppBuildDateProvider
import com.artemchep.keyguard.feature.home.settings.component.settingAboutAppBuildRefProvider
import com.artemchep.keyguard.feature.home.settings.component.settingAboutAppChangelogProvider
import com.artemchep.keyguard.feature.home.settings.component.settingAboutAppProvider
import com.artemchep.keyguard.feature.home.settings.component.settingAboutTeamProvider
import com.artemchep.keyguard.feature.home.settings.component.settingAboutTelegramProvider
@ -144,6 +145,7 @@ object Setting {
const val ABOUT_APP = "about_app"
const val ABOUT_APP_BUILD_DATE = "about_app_build_date"
const val ABOUT_APP_BUILD_REF = "about_app_build_ref"
const val ABOUT_APP_CHANGELOG = "about_app_changelog"
const val ABOUT_TEAM = "about_team"
const val EXPERIMENTAL = "experimental"
const val LAUNCH_APP_PICKER = "launch_app_picker"
@ -215,6 +217,7 @@ val hub = mapOf<String, (DirectDI) -> SettingComponent>(
Setting.ABOUT_APP to ::settingAboutAppProvider,
Setting.ABOUT_APP_BUILD_DATE to ::settingAboutAppBuildDateProvider,
Setting.ABOUT_APP_BUILD_REF to ::settingAboutAppBuildRefProvider,
Setting.ABOUT_APP_CHANGELOG to ::settingAboutAppChangelogProvider,
Setting.ABOUT_TEAM to ::settingAboutTeamProvider,
Setting.REDDIT to ::settingAboutTelegramProvider,
Setting.CROWDIN to ::settingLocalizationProvider,

View File

@ -0,0 +1,85 @@
package com.artemchep.keyguard.feature.home.settings.component
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import com.artemchep.keyguard.common.usecase.GetVersionLog
import com.artemchep.keyguard.feature.navigation.LocalNavigationController
import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.FlatItem
import com.artemchep.keyguard.ui.icons.ChevronIcon
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.flow.map
import org.kodein.di.DirectDI
import org.kodein.di.instance
fun settingAboutAppChangelogProvider(
directDI: DirectDI,
) = settingAboutAppChangelogProvider(
getVersionLog = directDI.instance(),
)
fun settingAboutAppChangelogProvider(
getVersionLog: GetVersionLog,
): SettingComponent = getVersionLog()
.map { log ->
if (log.size < 2) {
return@map null
}
val newRef = log[0].ref
val oldRef = log[1].ref
// composable
SettingIi(
search = SettingIi.Search(
group = "about",
tokens = listOf(
"about",
"app",
"changelog",
),
),
) {
SettingAboutAppChangelog(
newRef = newRef,
oldRef = oldRef,
)
}
}
@Composable
private fun SettingAboutAppChangelog(
newRef: String,
oldRef: String,
) {
val controller by rememberUpdatedState(LocalNavigationController.current)
FlatItem(
title = {
Text(
text = stringResource(Res.strings.pref_item_app_changelog_title),
)
},
text = {
Row {
Text(newRef)
Text("...")
Text(oldRef)
}
},
trailing = {
ChevronIcon()
},
onClick = {
val intent = run {
val url =
"https://github.com/AChep/keyguard-app/compare/$oldRef...$newRef"
NavigationIntent.NavigateToBrowser(url)
}
controller.queue(intent)
},
)
}

View File

@ -54,6 +54,7 @@ fun OtherSettingsScreen() {
SettingPaneItem.Item(Setting.ABOUT_APP),
SettingPaneItem.Item(Setting.ABOUT_APP_BUILD_DATE),
SettingPaneItem.Item(Setting.ABOUT_APP_BUILD_REF),
SettingPaneItem.Item(Setting.ABOUT_APP_CHANGELOG),
),
)
}

View File

@ -851,6 +851,7 @@
This value matches the branch or tag name shown on GitHub.
-->
<string name="pref_item_app_build_ref_title">App build ref</string>
<string name="pref_item_app_changelog_title">App changelog</string>
<string name="pref_item_app_team_title">Team behind the app</string>
<string name="pref_item_reddit_community_title">Reddit community</string>
<string name="pref_item_github_title">GitHub project</string>

View File

@ -141,6 +141,7 @@ import com.artemchep.keyguard.common.usecase.GetVaultLockAfterTimeout
import com.artemchep.keyguard.common.usecase.GetVaultLockAfterTimeoutVariants
import com.artemchep.keyguard.common.usecase.GetVaultPersist
import com.artemchep.keyguard.common.usecase.GetVaultSession
import com.artemchep.keyguard.common.usecase.GetVersionLog
import com.artemchep.keyguard.common.usecase.GetWebsiteIcons
import com.artemchep.keyguard.common.usecase.GetWriteAccess
import com.artemchep.keyguard.common.usecase.MessageHub
@ -189,6 +190,7 @@ import com.artemchep.keyguard.common.usecase.RemoveAttachment
import com.artemchep.keyguard.common.usecase.RequestAppReview
import com.artemchep.keyguard.common.usecase.ShowMessage
import com.artemchep.keyguard.common.usecase.UnlockUseCase
import com.artemchep.keyguard.common.usecase.UpdateVersionLog
import com.artemchep.keyguard.common.usecase.WindowCoroutineScope
import com.artemchep.keyguard.common.usecase.impl.AuthConfirmMasterKeyUseCaseImpl
import com.artemchep.keyguard.common.usecase.impl.AuthGenerateMasterKeyUseCaseImpl
@ -255,6 +257,7 @@ import com.artemchep.keyguard.common.usecase.impl.GetVaultLockAfterTimeoutImpl
import com.artemchep.keyguard.common.usecase.impl.GetVaultLockAfterTimeoutVariantsImpl
import com.artemchep.keyguard.common.usecase.impl.GetVaultPersistImpl
import com.artemchep.keyguard.common.usecase.impl.GetVaultSessionImpl
import com.artemchep.keyguard.common.usecase.impl.GetVersionLogImpl
import com.artemchep.keyguard.common.usecase.impl.GetWebsiteIconsImpl
import com.artemchep.keyguard.common.usecase.impl.GetWriteAccessImpl
import com.artemchep.keyguard.common.usecase.impl.MessageHubImpl
@ -301,6 +304,7 @@ import com.artemchep.keyguard.common.usecase.impl.ReadWordlistFromFileImpl
import com.artemchep.keyguard.common.usecase.impl.RemoveAttachmentImpl
import com.artemchep.keyguard.common.usecase.impl.RequestAppReviewImpl
import com.artemchep.keyguard.common.usecase.impl.UnlockUseCaseImpl
import com.artemchep.keyguard.common.usecase.impl.UpdateVersionLogImpl
import com.artemchep.keyguard.common.usecase.impl.WindowCoroutineScopeImpl
import com.artemchep.keyguard.copy.Base32ServiceJvm
import com.artemchep.keyguard.copy.Base64ServiceJvm
@ -865,6 +869,11 @@ fun globalModuleJvm() = DI.Module(
directDI = this,
)
}
bindSingleton<UpdateVersionLog> {
UpdateVersionLogImpl(
directDI = this,
)
}
bindSingleton<PutOnboardingLastVisitInstant> {
PutOnboardingLastVisitInstantImpl(
directDI = this,
@ -910,6 +919,11 @@ fun globalModuleJvm() = DI.Module(
directDI = this,
)
}
bindSingleton<GetVersionLog> {
GetVersionLogImpl(
directDI = this,
)
}
bindProvider<MessageHub> {
instance<MessageHubImpl>()
}