diff --git a/fastlane/metadata/android/en-US/changelogs/202609010.txt b/fastlane/metadata/android/en-US/changelogs/202609010.txt new file mode 100644 index 0000000000..e3380c06f5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202609010.txt @@ -0,0 +1,2 @@ +Main changes in this version: important bug fixes and improvements. +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/timeline/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt index 97608e15fa..66807b74f2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt @@ -52,9 +52,9 @@ sealed interface TimelineEvent { */ sealed interface TimelineItemPollEvent : TimelineItemEvent - data class SelectPollAnswer( + data class SendPollResponse( val pollStartId: EventId, - val answerId: String + val answerIds: List ) : TimelineItemPollEvent data class EndPoll( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 43913c43bb..2fc4351cea 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -201,13 +201,15 @@ class TimelinePresenter( newEventState.value = NewEventState.None } } - is TimelineEvent.SelectPollAnswer -> sessionCoroutineScope.launch { + is TimelineEvent.SendPollResponse -> sessionCoroutineScope.launch { timelineController.invokeOnCurrentTimeline { sendPollResponseAction.execute( timeline = this, pollStartId = event.pollStartId, - answerId = event.answerId - ) + answerIds = event.answerIds, + ).onFailure { error -> + Timber.tag(tag).e(error, "Failed to send poll response") + } } } is TimelineEvent.EndPoll -> sessionCoroutineScope.launch { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt index b6e4d12517..c51d449fbc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt @@ -26,8 +26,8 @@ fun TimelineItemPollView( eventSink: (TimelineEvent.TimelineItemPollEvent) -> Unit, modifier: Modifier = Modifier, ) { - fun onSelectAnswer(pollStartId: EventId, answerId: String) { - eventSink(TimelineEvent.SelectPollAnswer(pollStartId, answerId)) + fun onSendPollResponse(pollStartId: EventId, answerIds: List) { + eventSink(TimelineEvent.SendPollResponse(pollStartId, answerIds)) } fun onEndPoll(pollStartId: EventId) { @@ -43,10 +43,12 @@ fun TimelineItemPollView( question = content.question, answerItems = content.answerItems.toImmutableList(), pollKind = content.pollKind, + isMultipleSelection = content.maxSelections > 1u, + maxSelections = content.maxSelections, isPollEnded = content.isEnded, isPollEditable = content.isEditable, isMine = content.isMine, - onSelectAnswer = ::onSelectAnswer, + onSendPollResponse = ::onSendPollResponse, onEditPoll = ::onEditPoll, onEndPoll = ::onEndPoll, modifier = modifier, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt index 2ceb81c487..69c8f16f37 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt @@ -33,6 +33,7 @@ class TimelineItemContentPollFactory( question = pollContentState.question, answerItems = pollContentState.answerItems, pollKind = pollContentState.pollKind, + maxSelections = pollContentState.maxSelections, isEnded = pollContentState.isPollEnded, isEdited = content.isEdited ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt index 8ddeb3d041..2e176251ed 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt @@ -19,6 +19,7 @@ data class TimelineItemPollContent( val question: String, val answerItems: List, val pollKind: PollKind, + val maxSelections: ULong, val isEnded: Boolean, override val isEdited: Boolean, ) : TimelineItemEventContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentPreviewParam.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentPreviewParam.kt index 2cabada8b9..7c74f7f7f1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentPreviewParam.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentPreviewParam.kt @@ -32,12 +32,14 @@ fun aTimelineItemPollContent( isEditable: Boolean = false, isEnded: Boolean = false, isEdited: Boolean = false, + maxSelections: ULong = 1u, ): TimelineItemPollContent { return TimelineItemPollContent( eventId = EventId("\$anEventId"), pollKind = PollKind.Disclosed, question = question, answerItems = answerItems, + maxSelections = maxSelections, isMine = isMine, isEditable = isEditable, isEnded = isEnded, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 3b20614dac..290246ffb9 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -951,14 +951,14 @@ class TimelinePresenterTest { } @Test - fun `present - PollAnswerSelected event`() = runTest { + fun `present - SendPollResponse event`() = runTest { val sendPollResponseAction = FakeSendPollResponseAction() val presenter = createTimelinePresenter( sendPollResponseAction = sendPollResponseAction, ) presenter.test { val initialState = awaitFirstItem() - initialState.eventSink.invoke(TimelineEvent.SelectPollAnswer(AN_EVENT_ID, "anAnswerId")) + initialState.eventSink.invoke(TimelineEvent.SendPollResponse(AN_EVENT_ID, listOf("anAnswerId"))) } delay(1) sendPollResponseAction.verifyExecutionCount(1) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt index 1d3bb26a93..2da5691736 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt @@ -50,7 +50,7 @@ class TimelineItemPollViewTest : RobolectricTest() { matcher = hasText(answer.text), useUnmergedTree = true, ).performClick() - eventsRecorder.assertSingle(TimelineEvent.SelectPollAnswer(content.eventId!!, answer.id)) + eventsRecorder.assertSingle(TimelineEvent.SendPollResponse(content.eventId!!, listOf(answer.id))) } @Test diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt index 1c9de095d3..0c6c81a513 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -229,6 +229,25 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() { assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("body")) } + @Test + fun `test create TextMessageType with a formatted body containing only an image falls back to the body`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent( + type = TextMessageType( + body = "\uD83D\uDE1C", + formatted = FormattedBody(MessageFormat.HTML, "\"\uD83D\uDE1C\"") + ) + ), + senderId = A_USER_ID, + senderProfile = aProfileDetails(), + eventId = AN_EVENT_ID, + ) + assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("\uD83D\uDE1C")) + assertThat(result.htmlDocument).isNull() + assertThat(result.plainText).isEqualTo("\uD83D\uDE1C") + } + @Test fun `test create VideoMessageType`() = runTest { val sut = createTimelineItemContentMessageFactory() diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt index cbaace50fb..fe9f5b12a3 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt @@ -12,17 +12,17 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.Timeline /** - * Sends the current user's answer to a poll, wrapping the timeline call so the analytics are captured in one place. + * Sends the current user's answer(s) to a poll, wrapping the timeline call so the analytics are captured in one place. */ interface SendPollResponseAction { /** * @param timeline the timeline the poll lives in. * @param pollStartId the poll start event being answered. - * @param answerId the id of the chosen answer. + * @param answerIds the ids of the chosen answers. */ suspend fun execute( timeline: Timeline, pollStartId: EventId, - answerId: String + answerIds: List, ): Result } diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentState.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentState.kt index 1689408098..796ac179d9 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentState.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentState.kt @@ -18,6 +18,7 @@ import kotlinx.collections.immutable.ImmutableList * @property question the poll question. * @property answerItems the list of answers. * @property pollKind the kind of poll. + * @property maxSelections the maximum number of selections allowed. * @property isPollEditable whether the poll is editable. * @property isPollEnded whether the poll is ended. * @property isMine whether the poll has been created by me. @@ -27,7 +28,10 @@ data class PollContentState( val question: String, val answerItems: ImmutableList, val pollKind: PollKind, + val maxSelections: ULong, val isPollEditable: Boolean, val isPollEnded: Boolean, val isMine: Boolean, -) +) { + val isMultipleSelection = maxSelections > 1u +} diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFixtures.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFixtures.kt index 7f15c7d513..57db5672f9 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFixtures.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFixtures.kt @@ -84,6 +84,7 @@ fun aPollContentState( hasVotes: Boolean = true, question: String = aPollQuestion(), pollKind: PollKind = PollKind.Disclosed, + maxSelections: ULong = 1u, answerItems: ImmutableList = aPollAnswerItemList( isEnded = isEnded, showVotes = showVotes, @@ -94,6 +95,7 @@ fun aPollContentState( question = question, answerItems = answerItems, pollKind = pollKind, + maxSelections = maxSelections, isPollEditable = isMine && !isEnded && isPollEditable, isPollEnded = isEnded, isMine = isMine, diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt index e1df373218..fdcadd7240 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt @@ -40,7 +40,7 @@ import kotlinx.collections.immutable.ImmutableList @Composable fun PollContentView( state: PollContentState, - onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, + onSendPollResponse: (pollStartId: EventId, answerIds: List) -> Unit, onEditPoll: (pollStartId: EventId) -> Unit, onEndPoll: (pollStartId: EventId) -> Unit, modifier: Modifier = Modifier, @@ -50,11 +50,13 @@ fun PollContentView( question = state.question, answerItems = state.answerItems, pollKind = state.pollKind, + isMultipleSelection = state.isMultipleSelection, + maxSelections = state.maxSelections, isPollEditable = state.isPollEditable, isPollEnded = state.isPollEnded, isMine = state.isMine, onEditPoll = onEditPoll, - onSelectAnswer = onSelectAnswer, + onSendPollResponse = onSendPollResponse, onEndPoll = onEndPoll, modifier = modifier, ) @@ -66,10 +68,12 @@ fun PollContentView( question: String, answerItems: ImmutableList, pollKind: PollKind, + isMultipleSelection: Boolean, + maxSelections: ULong, isPollEditable: Boolean, isPollEnded: Boolean, isMine: Boolean, - onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, + onSendPollResponse: (pollStartId: EventId, answerIds: List) -> Unit, onEditPoll: (pollStartId: EventId) -> Unit, onEndPoll: (pollStartId: EventId) -> Unit, modifier: Modifier = Modifier, @@ -77,7 +81,23 @@ fun PollContentView( val votesCount = remember(answerItems) { answerItems.sumOf { it.votesCount } } fun onSelectAnswer(pollAnswer: PollAnswer) { - eventId?.let { onSelectAnswer(it, pollAnswer.id) } + eventId?.let { + if (isMultipleSelection) { + val newSelections = answerItems.map { item -> + if (item.answer.id == pollAnswer.id) { + item.copy(isSelected = !item.isSelected) + } else { + item + } + }.filter { it.isSelected }.map { it.answer.id } + // Only send the response if the selection count is within the limit + if (newSelections.size <= maxSelections.toInt()) { + onSendPollResponse(it, newSelections) + } + } else { + onSendPollResponse(it, listOf(pollAnswer.id)) + } + } } fun onEditPoll() { @@ -107,7 +127,11 @@ fun PollContentView( ) { PollTitleView(title = question, isPollEnded = isPollEnded) - PollAnswers(answerItems = answerItems, onSelectAnswer = ::onSelectAnswer) + PollAnswers( + answerItems = answerItems, + isMultipleSelection = isMultipleSelection, + onSelectAnswer = ::onSelectAnswer, + ) if (isPollEnded || pollKind == PollKind.Disclosed) { DisclosedPollBottomNotice(votesCount = votesCount) @@ -130,8 +154,10 @@ fun PollContentView( @Composable private fun PollAnswers( answerItems: ImmutableList, + isMultipleSelection: Boolean, onSelectAnswer: (PollAnswer) -> Unit, ) { + val role = if (isMultipleSelection) Role.Checkbox else Role.RadioButton Column( modifier = Modifier.selectableGroup(), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -144,7 +170,7 @@ private fun PollAnswers( selected = it.isSelected, enabled = it.isEnabled, onClick = { onSelectAnswer(it.answer) }, - role = Role.RadioButton, + role = role, ), ) } @@ -207,10 +233,12 @@ internal fun PollContentViewUndisclosedPreview() = ElementPreview { question = "What type of food should we have at the party?", answerItems = aPollAnswerItemList(showVotes = false), pollKind = PollKind.Undisclosed, + isMultipleSelection = false, + maxSelections = 1u, isPollEnded = false, isPollEditable = false, isMine = false, - onSelectAnswer = { _, _ -> }, + onSendPollResponse = { _, _ -> }, onEditPoll = {}, onEndPoll = {}, ) @@ -224,10 +252,31 @@ internal fun PollContentViewDisclosedPreview() = ElementPreview { question = "What type of food should we have at the party?", answerItems = aPollAnswerItemList(), pollKind = PollKind.Disclosed, + isMultipleSelection = false, + maxSelections = 1u, isPollEnded = false, isPollEditable = false, isMine = false, - onSelectAnswer = { _, _ -> }, + onSendPollResponse = { _, _ -> }, + onEditPoll = {}, + onEndPoll = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun PollContentViewMultipleSelectionPreview() = ElementPreview { + PollContentView( + eventId = EventId("\$anEventId"), + question = "What type of food should we have at the party?", + answerItems = aPollAnswerItemList(), + pollKind = PollKind.Disclosed, + isMultipleSelection = true, + maxSelections = 3u, + isPollEnded = false, + isPollEditable = false, + isMine = false, + onSendPollResponse = { _, _ -> }, onEditPoll = {}, onEndPoll = {}, ) @@ -241,10 +290,12 @@ internal fun PollContentViewEndedPreview() = ElementPreview { question = "What type of food should we have at the party?", answerItems = aPollAnswerItemList(isEnded = true), pollKind = PollKind.Disclosed, + isMultipleSelection = false, + maxSelections = 1u, isPollEnded = true, isPollEditable = false, isMine = false, - onSelectAnswer = { _, _ -> }, + onSendPollResponse = { _, _ -> }, onEditPoll = {}, onEndPoll = {}, ) @@ -258,10 +309,12 @@ internal fun PollContentViewCreatorEditablePreview() = ElementPreview { question = "What type of food should we have at the party?", answerItems = aPollAnswerItemList(hasVotes = false, isEnded = false), pollKind = PollKind.Disclosed, + isMultipleSelection = false, + maxSelections = 1u, isPollEnded = false, isPollEditable = true, isMine = true, - onSelectAnswer = { _, _ -> }, + onSendPollResponse = { _, _ -> }, onEditPoll = {}, onEndPoll = {}, ) @@ -275,10 +328,12 @@ internal fun PollContentViewCreatorPreview() = ElementPreview { question = "What type of food should we have at the party?", answerItems = aPollAnswerItemList(isEnded = false), pollKind = PollKind.Disclosed, + isMultipleSelection = false, + maxSelections = 1u, isPollEnded = false, isPollEditable = false, isMine = true, - onSelectAnswer = { _, _ -> }, + onSendPollResponse = { _, _ -> }, onEditPoll = {}, onEndPoll = {}, ) @@ -292,10 +347,12 @@ internal fun PollContentViewCreatorEndedPreview() = ElementPreview { question = "What type of food should we have at the party?", answerItems = aPollAnswerItemList(isEnded = true), pollKind = PollKind.Disclosed, + isMultipleSelection = false, + maxSelections = 1u, isPollEnded = true, isPollEditable = false, isMine = true, - onSelectAnswer = { _, _ -> }, + onSendPollResponse = { _, _ -> }, onEditPoll = {}, onEndPoll = {}, ) diff --git a/features/poll/api/src/main/res/values-zh/translations.xml b/features/poll/api/src/main/res/values-zh/translations.xml index 15f71240d4..4cde80b945 100644 --- a/features/poll/api/src/main/res/values-zh/translations.xml +++ b/features/poll/api/src/main/res/values-zh/translations.xml @@ -1,7 +1,7 @@ - "占总投票的 %1$d%" + "占总投票的 %1$d%%" "将移除之前的选择" "此为胜出的答案" diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/PollConstants.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/PollConstants.kt index c3579dc447..7c6eab370b 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/PollConstants.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/PollConstants.kt @@ -12,5 +12,5 @@ internal object PollConstants { const val MIN_ANSWERS = 2 const val MAX_ANSWERS = 20 const val MAX_ANSWER_LENGTH = 240 - const val MAX_SELECTIONS = 1 + const val DEFAULT_MAX_SELECTIONS = 1 } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultSendPollResponseAction.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultSendPollResponseAction.kt index f48604b21d..b872f9fb27 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultSendPollResponseAction.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultSendPollResponseAction.kt @@ -20,10 +20,10 @@ import io.element.android.services.analytics.api.AnalyticsService class DefaultSendPollResponseAction( private val analyticsService: AnalyticsService, ) : SendPollResponseAction { - override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerId: String): Result { + override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerIds: List): Result { return timeline.sendPollResponse( pollStartId = pollStartId, - answers = listOf(answerId), + answers = answerIds, ).onSuccess { analyticsService.capture(PollVote()) } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvent.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvent.kt index b98ab899b9..2d686d3d8d 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvent.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvent.kt @@ -18,6 +18,7 @@ sealed interface CreatePollEvent { data object AddAnswer : CreatePollEvent data class RemoveAnswer(val index: Int) : CreatePollEvent data class SetPollKind(val pollKind: PollKind) : CreatePollEvent + data class SetMaxSelections(val maxSelections: Int) : CreatePollEvent data object NavBack : CreatePollEvent data object ConfirmNavBack : CreatePollEvent data object HideConfirmation : CreatePollEvent diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt index 8e01f1a3e4..b2c5bc1276 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt @@ -24,7 +24,6 @@ import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.PollCreation import io.element.android.features.messages.api.MessageComposerContext import io.element.android.features.poll.api.create.CreatePollMode -import io.element.android.features.poll.impl.PollConstants.MAX_SELECTIONS import io.element.android.features.poll.impl.data.PollRepository import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.poll.PollAnswer @@ -81,6 +80,7 @@ class CreatePollPresenter( question = it.question, answers = it.answers.map(PollAnswer::text).toImmutableList(), isDisclosed = it.kind.isDisclosed, + maxSelections = it.maxSelections.toInt(), ) initialPoll = loadedPoll poll = loadedPoll @@ -109,7 +109,7 @@ class CreatePollPresenter( question = poll.question, answers = poll.answers, pollKind = poll.pollKind, - maxSelections = MAX_SELECTIONS, + maxSelections = poll.maxSelections, ).onSuccess { analyticsService.capturePollSaved( isUndisclosed = poll.pollKind == PollKind.Undisclosed, @@ -151,6 +151,9 @@ class CreatePollPresenter( is CreatePollEvent.SetPollKind -> { poll = poll.copy(isDisclosed = event.pollKind.isDisclosed) } + is CreatePollEvent.SetMaxSelections -> { + poll = poll.copy(maxSelections = event.maxSelections) + } is CreatePollEvent.SetQuestion -> { poll = poll.copy(question = event.question) } @@ -182,6 +185,8 @@ class CreatePollPresenter( question = poll.question, answers = immutableAnswers, pollKind = poll.pollKind, + maxSelections = poll.maxSelections, + maxAllowedSelections = poll.answers.size, showBackConfirmation = showBackConfirmation, showDeleteConfirmation = showDeleteConfirmation, eventSink = ::handleEvent, diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt index 80aa7dc4b0..20f4beaf3e 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt @@ -18,6 +18,8 @@ data class CreatePollState( val question: String, val answers: ImmutableList, val pollKind: PollKind, + val maxSelections: Int, + val maxAllowedSelections: Int, val showBackConfirmation: Boolean, val showDeleteConfirmation: Boolean, val eventSink: (CreatePollEvent) -> Unit, diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollStatePreviewParam.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollStatePreviewParam.kt index 9c7a505421..793f415828 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollStatePreviewParam.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollStatePreviewParam.kt @@ -150,6 +150,21 @@ class CreatePollStatePreviewParam : PreviewParameterProvider { showDeleteConfirmation = true, showBackConfirmation = false, ), + aCreatePollState( + mode = CreatePollState.Mode.New, + canCreate = true, + canAddAnswer = true, + question = "What type of food should we have?", + answers = listOf( + Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false), + Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false), + Answer("Brazilian \uD83C\uDDE7\uD83C\uDDF7", true), + ), + showBackConfirmation = false, + showDeleteConfirmation = false, + pollKind = PollKind.Disclosed, + maxSelections = 2, + ), ) } @@ -161,7 +176,8 @@ private fun aCreatePollState( answers: List, showBackConfirmation: Boolean, showDeleteConfirmation: Boolean, - pollKind: PollKind + pollKind: PollKind, + maxSelections: Int = 1, ): CreatePollState { return CreatePollState( mode = mode, @@ -172,6 +188,8 @@ private fun aCreatePollState( showBackConfirmation = showBackConfirmation, showDeleteConfirmation = showDeleteConfirmation, pollKind = pollKind, + maxSelections = maxSelections, + maxAllowedSelections = answers.size, eventSink = {} ) } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt index a20c1f5af1..602466b582 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt @@ -11,11 +11,13 @@ package io.element.android.features.poll.impl.create import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -25,6 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -32,6 +35,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.poll.impl.R import io.element.android.libraries.designsystem.components.button.BackButton @@ -173,6 +178,17 @@ fun CreatePollView( item { Column { HorizontalDivider() + ListItem( + content = { Text(text = stringResource(id = R.string.screen_create_poll_max_selections_headline)) }, + trailingContent = ListItemContent.Custom { + MaxSelectionsStepper( + current = state.maxSelections, + max = state.maxAllowedSelections, + onDecrement = { state.eventSink(CreatePollEvent.SetMaxSelections(state.maxSelections - 1)) }, + onIncrement = { state.eventSink(CreatePollEvent.SetMaxSelections(state.maxSelections + 1)) }, + ) + }, + ) ListItem( content = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_headline)) }, supportingContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_desc)) }, @@ -229,6 +245,52 @@ private fun CreatePollTopAppBar( ) } +@Composable +private fun MaxSelectionsStepper( + current: Int, + max: Int, + onDecrement: () -> Unit, + onIncrement: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = CompoundIcons.Minus(), + contentDescription = stringResource(id = CommonStrings.a11y_create_poll_votes_allowed_decrease), + modifier = Modifier + .clickable(enabled = current > 1) { onDecrement() } + .size(32.dp) + .padding(4.dp), + tint = if (current > 1) { + ElementTheme.colors.bgAccentRest + } else { + ElementTheme.colors.iconDisabled + }, + ) + Text( + text = current.toString(), + modifier = Modifier.padding(horizontal = 12.dp), + style = ElementTheme.typography.fontBodyLgRegular, + ) + Icon( + imageVector = CompoundIcons.Plus(), + contentDescription = stringResource(id = CommonStrings.a11y_create_poll_votes_allowed_increase), + modifier = Modifier + .clickable(enabled = current < max) { onIncrement() } + .size(32.dp) + .padding(4.dp), + tint = if (current < max) { + ElementTheme.colors.bgAccentRest + } else { + ElementTheme.colors.iconDisabled + }, + ) + } +} + @PreviewsDayNight @Composable internal fun CreatePollViewPreview( diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/PollFormState.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/PollFormState.kt index 46045d711d..ef70ac4ae6 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/PollFormState.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/PollFormState.kt @@ -25,12 +25,14 @@ data class PollFormState( val question: String, val answers: ImmutableList, val isDisclosed: Boolean, + val maxSelections: Int, ) { companion object { val Empty = PollFormState( question = "", answers = MutableList(MIN_ANSWERS) { "" }.toImmutableList(), isDisclosed = true, + maxSelections = 1, ) } @@ -67,7 +69,9 @@ data class PollFormState( return this } - return copy(answers = answers.filterIndexed { i, _ -> i != index }.toImmutableList()) + val newAnswers = answers.filterIndexed { i, _ -> i != index }.toImmutableList() + val clampedMaxSelections = maxSelections.coerceIn(1, newAnswers.size) + return copy(answers = newAnswers, maxSelections = clampedMaxSelections) } /** @@ -110,6 +114,7 @@ internal val pollFormStateSaver = mapSaver( "question" to it.question, "answers" to it.answers.toTypedArray(), "isDisclosed" to it.isDisclosed, + "maxSelections" to it.maxSelections, ) }, restore = { saved -> @@ -117,6 +122,7 @@ internal val pollFormStateSaver = mapSaver( question = saved["question"] as String, answers = (saved["answers"] as Array<*>).map { it as String }.toImmutableList(), isDisclosed = saved["isDisclosed"] as Boolean, + maxSelections = saved["maxSelections"] as Int, ) } ) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvent.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvent.kt index 9d0d699d89..8afd6721ec 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvent.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvent.kt @@ -13,7 +13,7 @@ import io.element.android.libraries.matrix.api.core.EventId sealed interface PollHistoryEvent { data object LoadMore : PollHistoryEvent - data class SelectPollAnswer(val pollStartId: EventId, val answerId: String) : PollHistoryEvent + data class SendPollResponse(val pollStartId: EventId, val answerIds: List) : PollHistoryEvent data class EndPoll(val pollStartId: EventId) : PollHistoryEvent data class SelectFilter(val filter: PollHistoryFilter) : PollHistoryEvent } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt index 74a511da45..44d27ae607 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt @@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import timber.log.Timber @Inject class PollHistoryPresenter( @@ -68,12 +69,14 @@ class PollHistoryPresenter( is PollHistoryEvent.LoadMore -> { coroutineScope.loadMore(timeline) } - is PollHistoryEvent.SelectPollAnswer -> sessionCoroutineScope.launch { + is PollHistoryEvent.SendPollResponse -> sessionCoroutineScope.launch { sendPollResponseAction.execute( timeline = timeline, pollStartId = event.pollStartId, - answerId = event.answerId - ) + answerIds = event.answerIds + ).onFailure { error -> + Timber.e(error, "Failed to send poll response") + } } is PollHistoryEvent.EndPoll -> sessionCoroutineScope.launch { endPollAction.execute(timeline = timeline, pollStartId = event.pollStartId) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt index 61c7809f49..40bc2f893a 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt @@ -65,8 +65,8 @@ fun PollHistoryView( state.eventSink(PollHistoryEvent.LoadMore) } - fun onSelectAnswer(pollStartId: EventId, answerId: String) { - state.eventSink(PollHistoryEvent.SelectPollAnswer(pollStartId, answerId)) + fun onSendPollResponse(pollStartId: EventId, answerIds: List) { + state.eventSink(PollHistoryEvent.SendPollResponse(pollStartId, answerIds)) } fun onEndPoll(pollStartId: EventId) { @@ -114,7 +114,7 @@ fun PollHistoryView( pollHistoryItems = pollHistoryItems, hasMoreToLoad = state.hasMoreToLoad, isLoading = state.isLoading, - onSelectAnswer = ::onSelectAnswer, + onSendPollResponse = ::onSendPollResponse, onEditPoll = onEditPoll, onEndPoll = ::onEndPoll, onLoadMore = ::onLoadMore, @@ -151,7 +151,7 @@ private fun PollHistoryList( pollHistoryItems: ImmutableList, hasMoreToLoad: Boolean, isLoading: Boolean, - onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, + onSendPollResponse: (pollStartId: EventId, answerIds: List) -> Unit, onEditPoll: (pollStartId: EventId) -> Unit, onEndPoll: (pollStartId: EventId) -> Unit, onLoadMore: () -> Unit, @@ -166,7 +166,7 @@ private fun PollHistoryList( items(pollHistoryItems) { pollHistoryItem -> PollHistoryItemRow( pollHistoryItem = pollHistoryItem, - onSelectAnswer = onSelectAnswer, + onSendPollResponse = onSendPollResponse, onEditPoll = onEditPoll, onEndPoll = onEndPoll, modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp) @@ -222,7 +222,7 @@ private fun LoadMoreButton(isLoading: Boolean, onClick: () -> Unit) { @Composable private fun PollHistoryItemRow( pollHistoryItem: PollHistoryItem, - onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, + onSendPollResponse: (pollStartId: EventId, answerIds: List) -> Unit, onEditPoll: (pollStartId: EventId) -> Unit, onEndPoll: (pollStartId: EventId) -> Unit, modifier: Modifier = Modifier, @@ -244,7 +244,7 @@ private fun PollHistoryItemRow( Spacer(modifier = Modifier.height(4.dp)) PollContentView( state = pollHistoryItem.state, - onSelectAnswer = onSelectAnswer, + onSendPollResponse = onSendPollResponse, onEditPoll = onEditPoll, onEndPoll = onEndPoll, ) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/model/DefaultPollContentStateFactory.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/model/DefaultPollContentStateFactory.kt index f781b9474d..16ac2415fa 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/model/DefaultPollContentStateFactory.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/model/DefaultPollContentStateFactory.kt @@ -43,15 +43,21 @@ class DefaultPollContentStateFactory( ?.value .orEmpty() } + val currentSelectionCount = myVotes.size + val maxSelections = content.maxSelections.toInt() + val isSelectionLimitReached = currentSelectionCount >= maxSelections + val answerItems = content.answers.map { answer -> val answerVoteCount = content.votes[answer.id]?.size ?: 0 val isSelected = answer.id in myVotes val isWinner = answer.id in winnerIds val percentage = if (totalVoteCount > 0) answerVoteCount.toFloat() / totalVoteCount.toFloat() else 0f + // An answer is enabled if the poll hasn't ended and either the user can still select more answers or this answer is already selected + val isEnabled = !isPollEnded && (!isSelectionLimitReached || isSelected) PollAnswerItem( answer = answer, isSelected = isSelected, - isEnabled = !isPollEnded, + isEnabled = isEnabled, isWinner = isWinner, showVotes = content.kind.isDisclosed || isPollEnded, votesCount = answerVoteCount, @@ -64,6 +70,7 @@ class DefaultPollContentStateFactory( question = content.question, answerItems = answerItems.toImmutableList(), pollKind = content.kind, + maxSelections = content.maxSelections, isPollEditable = isEditable, isPollEnded = isPollEnded, isMine = isOwn, diff --git a/features/poll/impl/src/main/res/values/temporary.xml b/features/poll/impl/src/main/res/values/temporary.xml new file mode 100644 index 0000000000..8292270d45 --- /dev/null +++ b/features/poll/impl/src/main/res/values/temporary.xml @@ -0,0 +1,4 @@ + + + "Votes allowed per person" + diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt index dee0268c1c..61190e20a7 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt @@ -525,6 +525,8 @@ class CreatePollPresenterTest { assertThat(question).isEmpty() assertThat(answers).isEqualTo(listOf(Answer("", false), Answer("", false))) assertThat(pollKind).isEqualTo(PollKind.Disclosed) + assertThat(maxSelections).isEqualTo(1) + assertThat(maxAllowedSelections).isEqualTo(2) assertThat(showBackConfirmation).isFalse() assertThat(showDeleteConfirmation).isFalse() } diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/PollFormStateSaverTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/PollFormStateSaverTest.kt index 86e4b28676..ba46d77a26 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/PollFormStateSaverTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/PollFormStateSaverTest.kt @@ -24,6 +24,7 @@ class PollFormStateSaverTest { question = "question", answers = persistentListOf("answer1", "answer2"), isDisclosed = true, + maxSelections = 2, ) val saved = with(CanSaveScope) { diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt index 1342f89c60..ca20cc2ee9 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt @@ -117,7 +117,7 @@ class PollHistoryPresenterTest { state.eventSink(PollHistoryEvent.EndPoll(AN_EVENT_ID)) runCurrent() endPollAction.verifyExecutionCount(1) - state.eventSink(PollHistoryEvent.SelectPollAnswer(AN_EVENT_ID, "answer")) + state.eventSink(PollHistoryEvent.SendPollResponse(AN_EVENT_ID, listOf("answer"))) runCurrent() sendPollResponseAction.verifyExecutionCount(1) cancelAndConsumeRemainingEvents() diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt index 1b74118734..e0a6eb98b1 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt @@ -133,7 +133,7 @@ class PollHistoryViewTest : RobolectricTest() { useUnmergedTree = true, ).performClick() eventsRecorder.assertSingle( - PollHistoryEvent.SelectPollAnswer(eventId, answer.id) + PollHistoryEvent.SendPollResponse(eventId, listOf(answer.id)) ) } diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt index 277508681e..5996b29835 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt @@ -59,10 +59,10 @@ class PollContentStateFactoryTest { ) val expectedState = aPollContentState( answerItems = listOf( - aPollAnswerItem(answer = A_POLL_ANSWER_1, votesCount = 3, percentage = 0.3f), + aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, votesCount = 3, percentage = 0.3f), aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, votesCount = 6, percentage = 0.6f), - aPollAnswerItem(answer = A_POLL_ANSWER_3), - aPollAnswerItem(answer = A_POLL_ANSWER_4, votesCount = 1, percentage = 0.1f), + aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false), + aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, votesCount = 1, percentage = 0.1f), ) ) assertThat(state).isEqualTo(expectedState) @@ -139,10 +139,10 @@ class PollContentStateFactoryTest { val expectedState = aPollContentState( pollKind = PollKind.Undisclosed, answerItems = listOf( - aPollAnswerItem(answer = A_POLL_ANSWER_1, showVotes = false, votesCount = 3, percentage = 0.3f), + aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, showVotes = false, votesCount = 3, percentage = 0.3f), aPollAnswerItem(answer = A_POLL_ANSWER_2, showVotes = false, isSelected = true, votesCount = 6, percentage = 0.6f), - aPollAnswerItem(answer = A_POLL_ANSWER_3, showVotes = false), - aPollAnswerItem(answer = A_POLL_ANSWER_4, showVotes = false, votesCount = 1, percentage = 0.1f), + aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false, showVotes = false), + aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, showVotes = false, votesCount = 1, percentage = 0.1f), ), ) assertThat(state).isEqualTo(expectedState) @@ -241,6 +241,7 @@ class PollContentStateFactoryTest { question = question, answerItems = answerItems.toImmutableList(), pollKind = pollKind, + maxSelections = 1u, isPollEditable = isEditable, isPollEnded = isEnded, isMine = isMine, diff --git a/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeSendPollResponseAction.kt b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeSendPollResponseAction.kt index 1a779d5cab..e188dc1e4f 100644 --- a/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeSendPollResponseAction.kt +++ b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeSendPollResponseAction.kt @@ -19,7 +19,7 @@ class FakeSendPollResponseAction : SendPollResponseAction { assert(executionCount == count) } - override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerId: String): Result { + override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerIds: List): Result { executionCount++ return Result.success(Unit) } diff --git a/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/pollcontent/FakePollContentStateFactory.kt b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/pollcontent/FakePollContentStateFactory.kt index 87755afaf0..4a3bfc2041 100644 --- a/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/pollcontent/FakePollContentStateFactory.kt +++ b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/pollcontent/FakePollContentStateFactory.kt @@ -22,6 +22,7 @@ class FakePollContentStateFactory : PollContentStateFactory { question = content.question, answerItems = emptyList().toImmutableList(), pollKind = content.kind, + maxSelections = content.maxSelections, isPollEditable = isEditable, isPollEnded = content.endTime != null, isMine = isOwn, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5be4eab26f..70bf6894bf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,7 +32,7 @@ accompanist = "0.37.3" # Test test_core = "1.7.0" -roborazzi = "1.72.0" +roborazzi = "1.73.0" # Jetbrain datetime = "0.8.0" diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt index d29a28cba9..2f52320b0d 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt @@ -19,7 +19,8 @@ import org.jsoup.safety.Safelist /** * Converts the HTML string [FormattedBody.body] to a [Document] by parsing it. - * If the message is not formatted or the format is not [MessageFormat.HTML] we return `null`. + * If the message is not formatted, the format is not [MessageFormat.HTML], or nothing renderable is left once the + * unsupported tags have been removed, we return `null` so that the plain text body is used instead. * * This will also make sure mentions are prefixed with `@`. * @@ -44,7 +45,7 @@ fun FormattedBody.toHtmlDocument( // Prepend `@` to mentions fixMentions(dom, permalinkParser) - dom + dom.takeIf { it.text().isNotBlank() } } } diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocumentTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocumentTest.kt index ae336cd0d0..dcf8935e57 100644 --- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocumentTest.kt +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocumentTest.kt @@ -103,4 +103,28 @@ class ToHtmlDocumentTest : RobolectricTest() { }) assertThat(document?.text()).isEqualTo("Hey Alice!") } + + @Test + fun `toHtmlDocument - returns null if the body only contains unsupported tags`() { + val body = FormattedBody( + format = MessageFormat.HTML, + body = "\"\uD83D\uDE1C\"" + ) + + val document = body.toHtmlDocument(permalinkParser = FakePermalinkParser()) + + assertThat(document).isNull() + } + + @Test + fun `toHtmlDocument - returns a Document if some text survives next to unsupported tags`() { + val body = FormattedBody( + format = MessageFormat.HTML, + body = "Look at this \"cat\"!" + ) + + val document = body.toHtmlDocument(permalinkParser = FakePermalinkParser()) + + assertThat(document?.text()).isEqualTo("Look at this !") + } } diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainTextTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainTextTest.kt index b1031176e7..aa5cf2e96e 100644 --- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainTextTest.kt +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainTextTest.kt @@ -149,4 +149,16 @@ class ToPlainTextTest : RobolectricTest() { ) assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo("This is the message content.") } + + @Test + fun `TextMessageType toPlainText - returns the body if the formatted one only contains an image`() { + val messageType = TextMessageType( + body = "\uD83D\uDE1C", + formatted = FormattedBody( + format = MessageFormat.HTML, + body = "\"\uD83D\uDE1C\"" + ) + ) + assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo("\uD83D\uDE1C") + } } diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index 28154af737..e33351f2ef 100644 --- a/plugins/src/main/kotlin/Versions.kt +++ b/plugins/src/main/kotlin/Versions.kt @@ -45,7 +45,7 @@ private const val versionMonth = 9 * Release number in the month. Value must be in [0,99]. * Do not update this value. it is updated by the release script. */ -private const val versionReleaseNumber = 0 +private const val versionReleaseNumber = 1 object Versions { /** diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt index ed901a99b4..339ba1023a 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt @@ -125,6 +125,7 @@ class KonsistPreviewTest { "PollContentViewCreatorPreview", "PollContentViewDisclosedPreview", "PollContentViewEndedPreview", + "PollContentViewMultipleSelectionPreview", "PollContentViewUndisclosedPreview", "ProgressDialogWithContentPreview", "ProgressDialogWithTextAndContentPreview", diff --git a/tests/uitests/src/test/snapshots/images/features.poll.api.pollcontent_PollContentViewMultipleSelection_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.poll.api.pollcontent_PollContentViewMultipleSelection_Day_0_en.png new file mode 100644 index 0000000000..276c82c9ec --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.poll.api.pollcontent_PollContentViewMultipleSelection_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:35741e180e6c07b452c485099c11473e80ac308ae81d3620fcd8e3bd1385f977 +size 46801 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.api.pollcontent_PollContentViewMultipleSelection_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.poll.api.pollcontent_PollContentViewMultipleSelection_Night_0_en.png new file mode 100644 index 0000000000..85edbd5662 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.poll.api.pollcontent_PollContentViewMultipleSelection_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9b64a00c01448b8935985343ae33912c80dd84ea2329717b8addfc07dd6ef012 +size 45403 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_0_en.png index 671ccdaf1d..eaa6aa90ad 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d71e5d6bdf78f145e573181ba8fe5faccf8fc8121d1b0318affc786222166bdd -size 31928 +oid sha256:af008ab901ea99aa9746625a30e1e6635aa471e12de5e80e6b1b0edd4bbd227f +size 36663 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_1_en.png index 8d7a518c24..d0af439fcc 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e4d08ca16ac4ebe85c49387264490c076aa95ed0aa15f467acd0f6d07fa6fc01 -size 36340 +oid sha256:f09703cfe6146050c3828022ad0038e9538d4d57f2de3fb4dbc25f9d1c16e2c9 +size 41135 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_2_en.png index 19847bbdc6..511b3e1e8c 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:30ff43a32b34192d49dbe9763fe7649c8fa5723c7f2b9ae6ed1531209d986378 -size 39885 +oid sha256:37758cba9b8f6e925d49f7800643f2a5be1ea3a812b5af49b88679675805af52 +size 42145 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_3_en.png index c9a0b43e73..dcae3c5d73 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9a5efead7d85b19215a64ccdc9c1f6ddf66558a27358eb197aeae1aebbac71a2 -size 44019 +oid sha256:44ba43c5a994442b16b185918d7132a4588fb16c374025574990ae1c9f5ea8de +size 48877 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_6_en.png index ea57b81992..189e2a834d 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3c2b6cae066cb50c3916a08009041d7b2a3335baa8e26e8a29d6bda75818000c -size 32650 +oid sha256:c7c9e5e1c89cc3f8be713c4e54f920465a7bde4eddf5516c8c61f3f1ea6da02f +size 37178 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_7_en.png index b025ff46e1..8e285a9f4e 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f3c20f5f523433f9d750736f006780d943beb47622190bd91980391f2a0ec55d -size 35996 +oid sha256:1d2051f52b7c19bc89fccf03f7df704977d4a57d3d675462f24a3c8a0355718b +size 38311 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_8_en.png new file mode 100644 index 0000000000..0d37c1465f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:935d1fcc6281c464da058256c2263e5524dd17d3cb8e6a54e3bed0ab545219bc +size 46248 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_0_en.png index 5833854bb3..3d1721d102 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cf48171815e7af7568af42d81c861cd6d66f413f16f76afbb5f472828ecf826b -size 31053 +oid sha256:c4bb28055f351d3086cb211e058d03067b60de339eec9fecce4c3e74e41ecba0 +size 35636 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_1_en.png index 1052f840ff..6746f98bd0 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8f38b81f5df1f85f449bc22bd52d19f8dfda7413fd50a880dcf87af6b668a00b -size 35195 +oid sha256:82291f1f901e2f4833d6653b2b706fea02b41cd337351ecf990f887d4c2c4dfe +size 39782 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_2_en.png index 0dd50a54c3..f256c57b2b 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e7f6f50bb7b1621f01f21cfde24961080127715be540435a594c356712f10501 -size 37658 +oid sha256:2cc022276e6cecce39bc96f57f03c97e29d42fb94a0645b6b75921d29e6dc5de +size 40067 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_3_en.png index d07a35f70b..da856dc91d 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9f3557e5598202ef6d965405a4711bd93f4b7fbee5585b5cd3d4ba8f4307b1a4 -size 42713 +oid sha256:35e6e1cfea95da68f9bd0b3f5616cea73ea8283a1749cab3c3353635ffce56b9 +size 47612 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_6_en.png index 69a97d0dbb..61ce31e648 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0fbf59e6846ba24941ae9278abf6fa80af208ca5eaea2bd2b65b3c9954f11992 -size 31662 +oid sha256:1f41fd5590ad4fe368091b0522a94b1029982e4d74a8596b3550b6569cc70790 +size 36124 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_7_en.png index 93a9905735..f1631ddf29 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9954304cc948652c3c63378ac77aeaba623398b72eab6bb90d23b15850fe278c -size 33522 +oid sha256:9432d0011ca454e716717a3c8a0daa72b2bddde3add15686e2470cb61081a3e2 +size 36008 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_8_en.png new file mode 100644 index 0000000000..17dd841646 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e65180faa17fa1914f77f4e6cb445e83c383c3de964e66963ebbdf8d116d6888 +size 44818 diff --git a/tools/localazy/checkForbiddenTerms.py b/tools/localazy/checkForbiddenTerms.py index 48bf7030b8..3bf893dd0b 100755 --- a/tools/localazy/checkForbiddenTerms.py +++ b/tools/localazy/checkForbiddenTerms.py @@ -42,6 +42,31 @@ forbiddenTerms = { ] } +# A complete, VALID placeholder, anchored to consume the whole token: +# %% -> literal percent (allowed) +# %s %d %f ... -> simple conversion +# %1$s %2$d ... -> positional +# %.2f %1$.2f -> optional precision +_VALID_PLACEHOLDER = re.compile( + r'%(?:\d+\$)?(?:\.\d+)?[a-zA-Z]' +) + +# Grab each candidate token: a '%' plus the run of chars that could belong to a +# specifier (digits, '$', '.', and letters). This makes '%sd' and '%1d' surface +# as single tokens instead of being partially swallowed. +_PLACEHOLDER_MATCHES = re.compile(r'%%|%[0-9$.A-Za-z]*') + +def find_invalid_placeholders(value): + """Return the list of malformed placeholder tokens found in `value`.""" + invalid = [] + for token in _PLACEHOLDER_MATCHES.findall(value): + if token == '%%': + continue # escaped percent is fine + if not _VALID_PLACEHOLDER.fullmatch(token): + invalid.append(token) + return invalid + + content = minidom.parse(file) errors = [] @@ -60,6 +85,9 @@ for elem in content.getElementsByTagName('string'): matches = re.search(term, value) if matches and name not in exceptions: errors.append('Forbidden term "' + term + '" in string: "' + name + '": ' + value) + invalid_placeholders = find_invalid_placeholders(value) + for placeholder in invalid_placeholders: + errors.append('Invalid placeholder "' + placeholder + '" in string: "' + name + '": ' + value) ### Plurals for elem in content.getElementsByTagName('plurals'): @@ -78,6 +106,9 @@ for elem in content.getElementsByTagName('plurals'): matches = re.search(term, value) if matches and name not in exceptions: errors.append('Forbidden term "' + term + '" in plural: "' + name + '": ' + value) + invalid_placeholders = find_invalid_placeholders(value) + for placeholder in invalid_placeholders: + errors.append('Invalid placeholder "' + placeholder + '" in plural: "' + name + '": ' + value) # If errors is not empty print the report if errors: diff --git a/tools/manifest/gplay/release/aaptDump.txt b/tools/manifest/gplay/release/aaptDump.txt index 2dcdb2245b..b67c1f527c 100644 --- a/tools/manifest/gplay/release/aaptDump.txt +++ b/tools/manifest/gplay/release/aaptDump.txt @@ -1,4 +1,4 @@ -package: name='io.element.android.x' versionCode='202609000' versionName='26.09.0' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17' +package: name='io.element.android.x' versionCode='202609010' versionName='26.09.1' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17' sdkVersion:'24' targetSdkVersion:'37' uses-permission: name='android.permission.POST_NOTIFICATIONS'