diff --git a/CHANGES.md b/CHANGES.md index 59bda3e59d..c3a966e77f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,34 @@ +Changes in Element X v25.03.3 +============================= + + + +## What's Changed +### ✨ Features +* Add 'unencrypted room' badges and labels by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4445 +* Use embedded version of Element Call by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4470 +### 🐛 Bugfixes +* Fix 'unverified session' flow displayed when creating account by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4467 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4461 +### 🧱 Build +* Let element enterprise be able to configure id for mapTiler. by @bmarty in https://github.com/element-hq/element-x-android/pull/4446 +### Dependency upgrades +* chore(deps): update rnkdsh/action-upload-diawi action to v1.5.8 by @renovate in https://github.com/element-hq/element-x-android/pull/4457 +* chore(deps): update plugin licensee to v1.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4447 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.8.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4450 +* fix(deps): update dependency com.google.firebase:firebase-bom to v33.11.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4448 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.3.24 by @renovate in https://github.com/element-hq/element-x-android/pull/4394 +* fix(deps): update dependencyanalysis to v2.13.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4464 +* chore(deps): update plugin sonarqube to v6.1.0.5360 by @renovate in https://github.com/element-hq/element-x-android/pull/4468 +* fix(deps): update android.gradle.plugin to v8.9.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4465 +### Others +* Sync Strings - tweaks to identity change messages by @andybalaam in https://github.com/element-hq/element-x-android/pull/4454 +* Check link click by @bmarty in https://github.com/element-hq/element-x-android/pull/4463 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.03.2...v25.03.3 + Changes in Element X v25.03.2 ============================= diff --git a/fastlane/metadata/android/en-US/changelogs/202503040.txt b/fastlane/metadata/android/en-US/changelogs/202503040.txt new file mode 100644 index 0000000000..21f4248fbc --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202503040.txt @@ -0,0 +1,2 @@ +Main changes in this version: added a fix for Element Call not being able to report issues. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 679589f797..8489139bb1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.media3.common.MimeTypes import androidx.media3.common.util.UnstableApi import dagger.assisted.Assisted @@ -84,11 +85,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import timber.log.Timber import kotlin.time.Duration.Companion.seconds @@ -135,7 +138,6 @@ class MessageComposerPresenter @AssistedInject constructor( @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal var showTextFormatting: Boolean by mutableStateOf(false) - @OptIn(FlowPreview::class) @SuppressLint("UnsafeOptInUsageError") @Composable override fun present(): MessageComposerState { @@ -148,12 +150,6 @@ class MessageComposerPresenter @AssistedInject constructor( richTextEditorState.isReadyToProcessActions = true } val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false) - var isMentionsEnabled by remember { mutableStateOf(false) } - var isRoomAliasSuggestionsEnabled by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - isMentionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.Mentions) - isRoomAliasSuggestionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.RoomAliasSuggestions) - } val cameraPermissionState = cameraPermissionPresenter.present() @@ -187,8 +183,6 @@ class MessageComposerPresenter @AssistedInject constructor( val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true) - val roomAliasSuggestions by roomAliasSuggestionsDataSource.getAllRoomAliasSuggestions().collectAsState(initial = emptyList()) - LaunchedEffect(cameraPermissionState.permissionGranted) { if (cameraPermissionState.permissionGranted) { when (pendingEvent) { @@ -201,35 +195,7 @@ class MessageComposerPresenter @AssistedInject constructor( } val suggestions = remember { mutableStateListOf() } - LaunchedEffect(isMentionsEnabled) { - if (!isMentionsEnabled) return@LaunchedEffect - val currentUserId = room.sessionId - - suspend fun canSendRoomMention(): Boolean { - val userCanSendAtRoom = room.canUserTriggerRoomNotification(currentUserId).getOrDefault(false) - return !room.isDm() && userCanSendAtRoom - } - - // This will trigger a search immediately when `@` is typed - val mentionStartTrigger = suggestionSearchTrigger.filter { it?.text.isNullOrEmpty() } - // This will start a search when the user changes the text after the `@` with a debounce to prevent too much wasted work - val mentionCompletionTrigger = suggestionSearchTrigger.debounce(0.3.seconds).filter { !it?.text.isNullOrEmpty() } - merge(mentionStartTrigger, mentionCompletionTrigger) - .combine(room.membersStateFlow) { suggestion, roomMembersState -> - suggestions.clear() - val result = suggestionsProcessor.process( - suggestion = suggestion, - roomMembersState = roomMembersState, - roomAliasSuggestions = if (isRoomAliasSuggestionsEnabled) roomAliasSuggestions else emptyList(), - currentUserId = currentUserId, - canSendRoomMention = ::canSendRoomMention, - ) - if (result.isNotEmpty()) { - suggestions.addAll(result) - } - } - .collect() - } + ResolveSuggestionsEffect(suggestions) DisposableEffect(Unit) { // Declare that the user is not typing anymore when the composer is disposed @@ -409,6 +375,45 @@ class MessageComposerPresenter @AssistedInject constructor( ) } + @OptIn(FlowPreview::class) + @Composable + private fun ResolveSuggestionsEffect( + suggestions: SnapshotStateList, + ) { + LaunchedEffect(Unit) { + val currentUserId = room.sessionId + + suspend fun canSendRoomMention(): Boolean { + val userCanSendAtRoom = room.canUserTriggerRoomNotification(currentUserId).getOrDefault(false) + return !room.isDm() && userCanSendAtRoom + } + + // This will trigger a search immediately when `@` is typed + val mentionStartTrigger = suggestionSearchTrigger.filter { it?.text.isNullOrEmpty() } + // This will start a search when the user changes the text after the `@` with a debounce to prevent too much wasted work + val mentionCompletionTrigger = suggestionSearchTrigger.debounce(0.3.seconds).filter { !it?.text.isNullOrEmpty() } + + val mentionTriggerFlow = merge(mentionStartTrigger, mentionCompletionTrigger) + + val roomAliasSuggestionsFlow = roomAliasSuggestionsDataSource + .getAllRoomAliasSuggestions() + .stateIn(this, SharingStarted.Lazily, emptyList()) + + combine(mentionTriggerFlow, room.membersStateFlow, roomAliasSuggestionsFlow) { suggestion, roomMembersState, roomAliasSuggestions -> + val result = suggestionsProcessor.process( + suggestion = suggestion, + roomMembersState = roomMembersState, + roomAliasSuggestions = roomAliasSuggestions, + currentUserId = currentUserId, + canSendRoomMention = ::canSendRoomMention, + ) + suggestions.clear() + suggestions.addAll(result) + } + .collect() + } + } + private fun CoroutineScope.sendMessage( markdownTextEditorState: MarkdownTextEditorState, richTextEditorState: RichTextEditorState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt index 2729f5e776..3b0e9a236a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt @@ -53,7 +53,11 @@ class SuggestionsProcessor @Inject constructor() { } SuggestionType.Room -> { roomAliasSuggestions - .filter { it.roomAlias.value.contains(suggestion.text, ignoreCase = true) } + .filter { roomAliasSuggestion -> + // Filter by either room alias or room name (if available) + roomAliasSuggestion.roomAlias.value.contains(suggestion.text, ignoreCase = true) || + roomAliasSuggestion.roomName?.contains(suggestion.text, ignoreCase = true) == true + } .map { ResolvedSuggestion.Alias( roomAlias = it.roomAlias, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index 79f8221b46..8328624bf6 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -61,7 +61,6 @@ import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_3 import io.element.android.libraries.matrix.test.A_USER_ID_4 -import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.room.FakeMatrixRoom @@ -1011,16 +1010,10 @@ class MessageComposerPresenterTest { ) givenRoomInfo(aRoomInfo(isDirect = false)) } - val flagsService = FakeFeatureFlagService( - mapOf( - FeatureFlags.Mentions.key to true, - ) - ) - val presenter = createPresenter(this, room, featureFlagService = flagsService) + val presenter = createPresenter(this, room) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) val initialState = awaitItem() // A null suggestion (no suggestion was received) returns nothing @@ -1078,16 +1071,10 @@ class MessageComposerPresenterTest { ) ) } - val flagsService = FakeFeatureFlagService( - mapOf( - FeatureFlags.Mentions.key to true, - ) - ) - val presenter = createPresenter(this, room, featureFlagService = flagsService) + val presenter = createPresenter(this, room) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) val initialState = awaitItem() // An empty suggestion returns the joined members that are not the current user, but not the room @@ -1293,11 +1280,11 @@ class MessageComposerPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - awaitFirstItem().also { state -> + skipItems(2) + awaitItem().also { state -> assertThat(state.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE) assertThat(state.textEditorState.messageHtml()).isNull() } - assert(loadDraftLambda) .isCalledOnce() .with(value(A_ROOM_ID), value(false)) @@ -1327,7 +1314,8 @@ class MessageComposerPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - awaitFirstItem().also { state -> + skipItems(1) + awaitItem().also { state -> assertThat(state.showTextFormatting).isTrue() assertThat(state.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE) assertThat(state.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) @@ -1360,7 +1348,8 @@ class MessageComposerPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - awaitFirstItem().also { state -> + skipItems(2) + awaitItem().also { state -> assertThat(state.showTextFormatting).isFalse() assertThat(state.mode).isEqualTo(anEditMode()) assertThat(state.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE) @@ -1406,7 +1395,8 @@ class MessageComposerPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - awaitFirstItem().also { state -> + skipItems(2) + awaitItem().also { state -> assertThat(state.showTextFormatting).isFalse() assertThat(state.mode).isEqualTo(aReplyMode()) assertThat(state.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE) @@ -1580,8 +1570,7 @@ class MessageComposerPresenterTest { } private suspend fun ReceiveTurbine.awaitFirstItem(): T { - // Skip 2 item if Mentions feature is enabled, else 1 - skipItems(if (FeatureFlags.Mentions.defaultValue(aBuildMeta())) 2 else 1) + skipItems(1) return awaitItem() } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt index 20581ddab5..66b8aeb152 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt @@ -133,7 +133,7 @@ class SuggestionsProcessorTest { } @Test - fun `processing Room suggestion with aliases will return a suggestion`() = runTest { + fun `processing Room suggestion with aliases will return a suggestion when matching on alias`() = runTest { val aRoomSummary = aRoomSummary(canonicalAlias = A_ROOM_ALIAS) val result = suggestionsProcessor.process( suggestion = aRoomSuggestion("ali"), @@ -171,7 +171,56 @@ class SuggestionsProcessorTest { RoomAliasSuggestion( roomAlias = A_ROOM_ALIAS, roomId = aRoomSummary.roomId, - roomName = aRoomSummary.info.name, + roomName = "Element", + roomAvatarUrl = aRoomSummary.info.avatarUrl, + ) + ), + currentUserId = A_USER_ID, + canSendRoomMention = { true }, + ) + assertThat(result).isEmpty() + } + + @Test + fun `processing Room suggestion will return a suggestion when matching on room name`() = runTest { + val aRoomSummary = aRoomSummary(canonicalAlias = A_ROOM_ALIAS) + val result = suggestionsProcessor.process( + suggestion = aRoomSuggestion("lement"), + roomMembersState = MatrixRoomMembersState.Ready(persistentListOf()), + roomAliasSuggestions = listOf( + RoomAliasSuggestion( + roomAlias = A_ROOM_ALIAS, + roomId = aRoomSummary.roomId, + roomName = "Element", + roomAvatarUrl = aRoomSummary.info.avatarUrl, + ) + ), + currentUserId = A_USER_ID, + canSendRoomMention = { true }, + ) + assertThat(result).isEqualTo( + listOf( + ResolvedSuggestion.Alias( + roomAlias = A_ROOM_ALIAS, + roomId = aRoomSummary.roomId, + roomName = "Element", + roomAvatarUrl = aRoomSummary.info.avatarUrl, + ) + ) + ) + } + + @Test + fun `processing Room suggestion will not return a suggestion when room has no name`() = runTest { + val aRoomSummary = aRoomSummary(canonicalAlias = A_ROOM_ALIAS) + val result = suggestionsProcessor.process( + suggestion = aRoomSuggestion("lement"), + roomMembersState = MatrixRoomMembersState.Ready(persistentListOf()), + roomAliasSuggestions = listOf( + RoomAliasSuggestion( + roomAlias = A_ROOM_ALIAS, + roomId = aRoomSummary.roomId, + roomName = null, roomAvatarUrl = aRoomSummary.info.avatarUrl, ) ), diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt index a940e2a3a2..c2b4672878 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt @@ -80,7 +80,7 @@ class DeveloperSettingsPresenterTest { presenter.test { skipItems(2) awaitItem().also { state -> - val feature = state.features.first() + val feature = state.features.first { !it.isEnabled } state.eventSink(DeveloperSettingsEvents.UpdateEnabledFeature(feature, !feature.isEnabled)) } awaitItem().also { state -> diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt index c241cef105..66a2b2b2cd 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt @@ -30,7 +30,6 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationS import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationRequest import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -57,7 +56,7 @@ class IncomingVerificationPresenter @AssistedInject constructor( @Composable override fun present(): IncomingVerificationState { - val coroutineScope = rememberCoroutineScope { Dispatchers.IO } + val coroutineScope = rememberCoroutineScope() val stateAndDispatch = stateMachine.rememberStateAndDispatch() diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt index 73068bcaaf..8fdf62df4f 100644 --- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt @@ -131,6 +131,9 @@ class IncomingVerificationPresenterTest { isWaiting = false, ) ) + + advanceTimeBy(1.seconds) + resetLambda.assertions().isCalledOnce().with(value(false)) acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(anIncomingSessionVerificationRequest)) acceptVerificationRequestLambda.assertions().isNeverCalled() @@ -139,7 +142,9 @@ class IncomingVerificationPresenterTest { skipItems(1) val initialWaitingState = awaitItem() assertThat((initialWaitingState.step as IncomingVerificationState.Step.Initial).isWaiting).isTrue() - advanceUntilIdle() + + advanceTimeBy(1.seconds) + acceptVerificationRequestLambda.assertions().isCalledOnce() // Remote sent the data fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec5a52e42a..a293b34b65 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -50,10 +50,10 @@ wysiwyg = "2.38.3" telephoto = "0.15.1" # Dependency analysis -dependencyAnalysis = "2.13.1" +dependencyAnalysis = "2.13.2" # DI -dagger = "2.56" +dagger = "2.56.1" anvil = "0.4.1" # Auto service @@ -212,7 +212,7 @@ anvil_compiler_api = { module = "dev.zacsweers.anvil:compiler-api", version.ref anvil_compiler_utils = { module = "dev.zacsweers.anvil:compiler-utils", version.ref = "anvil" } # Element Call -element_call_embedded = "io.element.android:element-call-embedded:0.9.0-rc.2" +element_call_embedded = "io.element.android:element-call-embedded:0.9.0-rc.4" # Auto services google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" } diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index f8e0d3405a..6e8fba8bd1 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -54,20 +54,6 @@ enum class FeatureFlags( defaultValue = { true }, isFinished = true, ), - Mentions( - key = "feature.mentions", - title = "Mentions", - description = "Type `@` to get mention suggestions and insert them", - defaultValue = { true }, - isFinished = false, - ), - RoomAliasSuggestions( - key = "feature.roomAliasSuggestions", - title = "Room alias suggestions", - description = "Type `#` to get room alias suggestions and insert them", - defaultValue = { false }, - isFinished = false, - ), MarkAsUnread( key = "feature.markAsUnread", title = "Mark as unread", diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index 92bfe80ae7..0e732137cf 100644 --- a/plugins/src/main/kotlin/Versions.kt +++ b/plugins/src/main/kotlin/Versions.kt @@ -32,7 +32,7 @@ private const val versionYear = 25 private const val versionMonth = 3 // Note: must be in [0,99] -private const val versionReleaseNumber = 3 +private const val versionReleaseNumber = 4 object Versions { const val VERSION_CODE = (2000 + versionYear) * 10_000 + versionMonth * 100 + versionReleaseNumber