Merge branch 'develop' into renovate/actions-stale-10.x

This commit is contained in:
ganfra
2026-05-29 18:33:57 +02:00
committed by GitHub
40 changed files with 333 additions and 119 deletions
+1
View File
@@ -52,6 +52,7 @@ Uncomment this markdown table below and edit the last line `|||`:
<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. --> <!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->
- [ ] I am aware of the [etiquette](https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#etiquette).
- This PR was made with the help of AI: - This PR was made with the help of AI:
- [ ] Yes. In this case, please request a review by Copilot. - [ ] Yes. In this case, please request a review by Copilot.
- [ ] No. - [ ] No.
+8 -5
View File
@@ -44,11 +44,14 @@ PRs must meet these rules.
## Pull Request Guidelines ## Pull Request Guidelines
- Use sentence-style commit/PR messages (no conventional commits). - Sentence-style titles (no conventional commits).
- Apply exactly **one** `PR-` label for changelog categorization. - Exactly one `pr-` label (see `.github/release.yml`).
- PR title = changelog entry — make it descriptive; no "Fixes #…" prefixes. - Title = changelog entry — descriptive, no "Fixes #…".
- Include screenshots or screen recordings for any UI changes. - Leave description template for the developer. Redirect them to the [contributing etiquette](CONTRIBUTING.md#etiquette).
- Keep PRs focused; split changes over 1000 lines. - Screenshots/videos for visual changes.
- 500 additions max — split large changes.
- Commits need a title and description; no tiny or massive commits.
- No history rewrites.
--- ---
+23 -2
View File
@@ -6,7 +6,8 @@
* [I want to help translating Element](#i-want-to-help-translating-element) * [I want to help translating Element](#i-want-to-help-translating-element)
* [I want to fix a bug](#i-want-to-fix-a-bug) * [I want to fix a bug](#i-want-to-fix-a-bug)
* [I want to add a new feature or enhancement](#i-want-to-add-a-new-feature-or-enhancement) * [I want to add a new feature or enhancement](#i-want-to-add-a-new-feature-or-enhancement)
* [Developer onboarding](#developer-onboarding) * [Etiquette](#etiquette)
* [Developer onboarding](#developer-onboarding)
* [Submitting the PRs](#submitting-the-prs) * [Submitting the PRs](#submitting-the-prs)
* [Android Studio settings](#android-studio-settings) * [Android Studio settings](#android-studio-settings)
* [Compilation](#compilation) * [Compilation](#compilation)
@@ -63,7 +64,27 @@ Once we know that you want to contribute and have confirmed that the new feature
Only once all of the above is met should you open a PR with your proposed changes. Only once all of the above is met should you open a PR with your proposed changes.
## Developer onboarding ### Etiquette
* As stated above all significant changes should be communicated through an issue before opening a PR
* We are happy to receive contributions but features always require maintenance, so depending on the change we might not be willing to accept it
* We are also fine with AI led contributions within reasonable bounds
* You are completely responsible for the quality of the PR
* If the PR doesn't show minimal effort (code does not compile, code does not work as expected, etc.) on your part it will be closed
* Please write the description yourself, we don't have the bandwidth to read LLM essays. The code needs to speak for itself.
* We use git for version control and GitHub for reviews, so in order to make everybody's life easier please:
* Keep the existing pull request template
* Don't submit large PRs, especially if not previously talked about. Anything above 200 lines is large (excluding generated code e.g. tests, translations, mocks)
* Please don't open unfinished PRs and expect us to fill in the details
* If you would like our opinion/direction on unfinished code please link your branch or idea in the ticket
* Please limit the number of commits in a single PR. We are perfectly happy with splitting work across multiple sessions as long as they're logically independant and show promise of progress (ideally expressed through a ticket)
* Each and every commit should stand on its own, clearly explaining what it does and why
* Once a PR goes into review please don't rewrite the history unless agreed so with the reviewer.
* Tweaks and fixes following review can be directly committed (to be interactively rebased later) or as fixups
*The reviewer's response time will generally match yours. Switching contexts is very hard so please act accordingly. You are responsible for making the reviewers job enjoyable!*
### Developer onboarding
For a detailed overview of the project, see [Developer Onboarding](./docs/_developer_onboarding.md). For a detailed overview of the project, see [Developer Onboarding](./docs/_developer_onboarding.md).
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.common.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import org.maplibre.compose.location.Location
import org.maplibre.compose.location.UserLocationState
import kotlin.math.abs
/**
* Drop-in replacement for the library's LocationTrackingEffect.
* TODO remove once https://github.com/maplibre/maplibre-compose/issues/808 is fixed
*/
@Composable
internal fun SimpleLocationTrackingEffect(
locationState: UserLocationState,
enabled: Boolean = true,
precision: Double = 0.00001,
onLocationChange: suspend (Location?) -> Unit,
) {
val latestOnLocationChange by rememberUpdatedState(onLocationChange)
LaunchedEffect(locationState, enabled) {
if (!enabled) return@LaunchedEffect
val locationStateFlow = snapshotFlow { locationState.location }
locationStateFlow
.distinctUntilChanged { oldLocation, newLocation ->
if (oldLocation != null && newLocation != null) {
when {
abs(oldLocation.position.value.latitude - newLocation.position.value.latitude) >= precision -> false
abs(oldLocation.position.value.longitude - newLocation.position.value.longitude) >= precision -> false
else -> true
}
} else {
false
}
}
.collect { location ->
latestOnLocationChange(location)
}
}
}
@@ -18,11 +18,11 @@ import org.maplibre.compose.location.DesiredAccuracy
import org.maplibre.compose.location.LocationPuck import org.maplibre.compose.location.LocationPuck
import org.maplibre.compose.location.LocationPuckColors import org.maplibre.compose.location.LocationPuckColors
import org.maplibre.compose.location.LocationPuckSizes import org.maplibre.compose.location.LocationPuckSizes
import org.maplibre.compose.location.LocationTrackingEffect
import org.maplibre.compose.location.UserLocationState import org.maplibre.compose.location.UserLocationState
import org.maplibre.compose.location.rememberAndroidLocationProvider import org.maplibre.compose.location.rememberAndroidLocationProvider
import org.maplibre.compose.location.rememberNullLocationProvider import org.maplibre.compose.location.rememberNullLocationProvider
import org.maplibre.compose.location.rememberUserLocationState import org.maplibre.compose.location.rememberUserLocationState
import org.maplibre.spatialk.units.extensions.meters
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Composable @Composable
@@ -31,22 +31,24 @@ fun UserLocationPuck(
locationState: UserLocationState, locationState: UserLocationState,
trackUserLocation: Boolean, trackUserLocation: Boolean,
) { ) {
LocationTrackingEffect( SimpleLocationTrackingEffect(
locationState = locationState, locationState = locationState,
enabled = trackUserLocation, enabled = trackUserLocation,
) { ) { currentLocation ->
val finalPosition = cameraState.position.copy( val target = currentLocation?.position?.value ?: cameraState.position.target
target = currentLocation.position, val newPosition = cameraState.position.copy(
bearing = currentLocation.bearing ?: cameraState.position.bearing, target = target,
// Force pointing to NORTH
bearing = 0.0,
zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM) zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM)
) )
cameraState.animateTo(finalPosition) cameraState.animateTo(newPosition)
} }
val location = locationState.location val location = locationState.location
if (location != null) { if (location != null) {
LocationPuck( LocationPuck(
idPrefix = "user-location", idPrefix = "user-location",
locationState = locationState, location = location,
cameraState = cameraState, cameraState = cameraState,
accuracyThreshold = Float.POSITIVE_INFINITY, accuracyThreshold = Float.POSITIVE_INFINITY,
showBearingAccuracy = false, showBearingAccuracy = false,
@@ -74,7 +76,7 @@ fun rememberUserLocationState(hasLocationPermission: Boolean): UserLocationState
rememberAndroidLocationProvider( rememberAndroidLocationProvider(
updateInterval = 5.seconds, updateInterval = 5.seconds,
desiredAccuracy = DesiredAccuracy.High, desiredAccuracy = DesiredAccuracy.High,
minDistanceMeters = 5f, minDistance = 5.meters,
) )
} }
return rememberUserLocationState(locationProvider) return rememberUserLocationState(locationProvider)
@@ -36,6 +36,8 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import org.maplibre.compose.location.AndroidLocationProvider import org.maplibre.compose.location.AndroidLocationProvider
import org.maplibre.compose.location.DesiredAccuracy import org.maplibre.compose.location.DesiredAccuracy
import org.maplibre.spatialk.units.extensions.inMeters
import org.maplibre.spatialk.units.extensions.meters
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import io.element.android.features.location.api.Location as ApiLocation import io.element.android.features.location.api.Location as ApiLocation
@@ -91,7 +93,7 @@ class LiveLocationSharingService : Service() {
val locationProvider = AndroidLocationProvider( val locationProvider = AndroidLocationProvider(
context = applicationContext, context = applicationContext,
updateInterval = UPDATE_INTERVAL_IN_SECOND.seconds, updateInterval = UPDATE_INTERVAL_IN_SECOND.seconds,
minDistanceMeters = minDistanceMeters.toFloat(), minDistance = minDistanceMeters.meters,
desiredAccuracy = DesiredAccuracy.Balanced, desiredAccuracy = DesiredAccuracy.Balanced,
coroutineScope = coroutineScope coroutineScope = coroutineScope
) )
@@ -100,9 +102,9 @@ class LiveLocationSharingService : Service() {
.filterNotNull() .filterNotNull()
.map { location -> .map { location ->
ApiLocation( ApiLocation(
lat = location.position.latitude, lat = location.position.value.latitude,
lon = location.position.longitude, lon = location.position.value.longitude,
accuracy = location.accuracy.toFloat(), accuracy = location.position.accuracy?.inMeters?.toFloat(),
) )
} }
.onEach(coordinator::dispatch) .onEach(coordinator::dispatch)
@@ -100,8 +100,8 @@ class ShareLocationPresenter(
fun checkLocationConstraints() { fun checkLocationConstraints() {
// No need to check SendLiveLocationPermissions here // No need to check SendLiveLocationPermissions here
val locationConstraints = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED) val locationConstraints = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
dialogState = ShareLocationState.Dialog.Constraints(locationConstraints.toDialogState())
trackUserPosition = locationConstraints is LocationConstraintsCheck.Success trackUserPosition = locationConstraints is LocationConstraintsCheck.Success
dialogState = ShareLocationState.Dialog.Constraints(locationConstraints.toDialogState())
} }
suspend fun computeLiveLocationDialogState(): ShareLocationState.Dialog { suspend fun computeLiveLocationDialogState(): ShareLocationState.Dialog {
@@ -225,8 +225,8 @@ private fun BottomSheetContent(
state.eventSink( state.eventSink(
ShareLocationEvent.ShareStaticLocation( ShareLocationEvent.ShareStaticLocation(
location = Location( location = Location(
lat = userLocation.position.latitude, lat = userLocation.position.value.latitude,
lon = userLocation.position.longitude lon = userLocation.position.value.longitude
), ),
isPinned = false isPinned = false
) )
@@ -155,7 +155,9 @@ fun ShowLocationView(
val position = CameraPosition( val position = CameraPosition(
padding = sheetPaddings, padding = sheetPaddings,
target = Position(locationShare.location.lon, locationShare.location.lat), target = Position(locationShare.location.lon, locationShare.location.lat),
zoom = MapDefaults.DEFAULT_ZOOM // Force pointing to NORTH
bearing = 0.0,
zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM),
) )
coroutineScope.launch { coroutineScope.launch {
cameraState.animateTo(finalPosition = position) cameraState.animateTo(finalPosition = position)
@@ -550,7 +550,7 @@ class MessagesPresenter(
val replyToDetails = loadReplyDetails(targetEvent.eventId).map(permalinkParser) val replyToDetails = loadReplyDetails(targetEvent.eventId).map(permalinkParser)
val composerMode = MessageComposerMode.Reply( val composerMode = MessageComposerMode.Reply(
replyToDetails = replyToDetails, replyToDetails = replyToDetails,
hideImage = timelineProtectionState.hideMediaContent(targetEvent.eventId), hideImage = timelineProtectionState.hideMediaContent(targetEvent.eventId, targetEvent.isMine),
) )
composerState.eventSink( composerState.eventSink(
MessageComposerEvent.SetMode(composerMode) MessageComposerEvent.SetMode(composerMode)
@@ -284,7 +284,7 @@ private fun TimelineItemEventContentViewWrapper(
} else { } else {
TimelineItemEventContentView( TimelineItemEventContentView(
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId), hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
@@ -32,7 +32,11 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
@@ -48,46 +52,63 @@ internal fun TimelineItemCallNotifyView(
timelineRoomInfo: TimelineRoomInfo, timelineRoomInfo: TimelineRoomInfo,
event: TimelineItem.Event, event: TimelineItem.Event,
content: TimelineItemRtcNotificationContent, content: TimelineItemRtcNotificationContent,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
onReadReceiptsClick: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row( Column(modifier = modifier) {
modifier = modifier Row(
.fillMaxWidth() modifier = Modifier
.border(1.dp, ElementTheme.colors.borderInteractiveSecondary, RoundedCornerShape(8.dp)) .fillMaxWidth()
.combinedClickable( .padding(start = 16.dp, end = 16.dp, top = 16.dp)
enabled = true, .border(1.dp, ElementTheme.colors.borderInteractiveSecondary, RoundedCornerShape(8.dp))
onClick = {}, .combinedClickable(
onLongClick = { onLongClick(event) }, enabled = true,
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), onClick = {},
onLongClick = { onLongClick(event) },
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
)
.onKeyboardContextMenuAction { onLongClick(event) }
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.size(20.sp.toDp()),
imageVector = getIcon(timelineRoomInfo, content),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
) )
.onKeyboardContextMenuAction { onLongClick(event) }
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.size(20.sp.toDp()),
imageVector = getIcon(timelineRoomInfo, content),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
)
Text( Text(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
text = stringResource(getTextRes(timelineRoomInfo, content)), text = stringResource(getTextRes(timelineRoomInfo, content)),
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Text( Text(
text = event.sentTime, text = event.sentTime,
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
)
}
TimelineItemReadReceiptView(
state = ReadReceiptViewState(
sendState = event.localSendState,
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
renderReadReceipts = renderReadReceipts,
onReadReceiptsClick = { onReadReceiptsClick(event) },
modifier = Modifier.padding(top = 4.dp),
) )
} }
} }
@@ -125,7 +146,10 @@ private fun getIcon(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun TimelineItemCallNotifyViewPreview() = ElementPreview { internal fun TimelineItemCallNotifyViewPreview() = ElementPreview {
Column(modifier = Modifier.padding(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { val readReceiptState = aTimelineItemReadReceipts(
receipts = List(3) { aReadReceiptData(it) },
)
Column(modifier = Modifier.padding(bottom = 16.dp)) {
listOf(false, true).forEach { isDm -> listOf(false, true).forEach { isDm ->
listOf(CallIntent.AUDIO, CallIntent.VIDEO).forEach { callIntent -> listOf(CallIntent.AUDIO, CallIntent.VIDEO).forEach { callIntent ->
listOf( listOf(
@@ -136,9 +160,18 @@ internal fun TimelineItemCallNotifyViewPreview() = ElementPreview {
val content = TimelineItemRtcNotificationContent(callIntent, state) val content = TimelineItemRtcNotificationContent(callIntent, state)
TimelineItemCallNotifyView( TimelineItemCallNotifyView(
timelineRoomInfo = aTimelineRoomInfo(isDm = isDm), timelineRoomInfo = aTimelineRoomInfo(isDm = isDm),
event = aTimelineItemEvent(content = content), event = aTimelineItemEvent(
content = content,
readReceiptState = readReceiptState,
),
content = content, content = content,
// Render read receipts for the first item only
renderReadReceipts = !isDm &&
callIntent == CallIntent.AUDIO &&
state == RtcNotificationState.Started,
isLastOutgoingMessage = false,
onLongClick = {}, onLongClick = {},
onReadReceiptsClick = {},
) )
} }
} }
@@ -63,7 +63,7 @@ fun TimelineItemGroupedEventsRow(
{ event, contentModifier, onContentLayoutChange -> { event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView( TimelineItemEventContentView(
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId), hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
@@ -136,7 +136,7 @@ private fun TimelineItemGroupedEventsRowContent(
{ event, contentModifier, onContentLayoutChange -> { event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView( TimelineItemEventContentView(
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId), hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick, onLinkLongClick = onLinkLongClick,
@@ -78,7 +78,7 @@ internal fun TimelineItemRow(
{ event, contentModifier, onContentLayoutChange -> { event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentView( TimelineItemEventContentView(
content = event.content, content = event.content,
hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId), hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId, event.isMine),
onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) },
onContentClick = { onContentClick(event) }, onContentClick = { onContentClick(event) },
onLongClick = { onLongClick(event) }, onLongClick = { onLongClick(event) },
@@ -124,11 +124,13 @@ internal fun TimelineItemRow(
} }
is TimelineItemRtcNotificationContent -> { is TimelineItemRtcNotificationContent -> {
TimelineItemCallNotifyView( TimelineItemCallNotifyView(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
event = timelineItem, event = timelineItem,
content = timelineItem.content, content = timelineItem.content,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
onLongClick = onLongClick, onLongClick = onLongClick,
onReadReceiptsClick = onReadReceiptClick,
) )
} }
else -> { else -> {
@@ -34,26 +34,32 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
*/ */
fun TimelineItem.mustBeProtected(): Boolean { fun TimelineItem.mustBeProtected(): Boolean {
return when (this) { return when (this) {
is TimelineItem.Event -> when (content) { is TimelineItem.Event -> {
is TimelineItemImageContent, if (isMine) {
is TimelineItemVideoContent, false
is TimelineItemStickerContent -> true } else {
is TimelineItemAudioContent, when (content) {
is TimelineItemRtcNotificationContent, is TimelineItemImageContent,
is TimelineItemEncryptedContent, is TimelineItemVideoContent,
is TimelineItemFileContent, is TimelineItemStickerContent -> true
TimelineItemLegacyCallInviteContent, is TimelineItemAudioContent,
is TimelineItemLocationContent, is TimelineItemRtcNotificationContent,
is TimelineItemPollContent, is TimelineItemEncryptedContent,
TimelineItemRedactedContent, is TimelineItemFileContent,
is TimelineItemProfileChangeContent, TimelineItemLegacyCallInviteContent,
is TimelineItemRoomMembershipContent, is TimelineItemLocationContent,
is TimelineItemStateEventContent, is TimelineItemPollContent,
is TimelineItemEmoteContent, TimelineItemRedactedContent,
is TimelineItemNoticeContent, is TimelineItemProfileChangeContent,
is TimelineItemTextContent, is TimelineItemRoomMembershipContent,
TimelineItemUnknownContent, is TimelineItemStateEventContent,
is TimelineItemVoiceContent -> false is TimelineItemEmoteContent,
is TimelineItemNoticeContent,
is TimelineItemTextContent,
TimelineItemUnknownContent,
is TimelineItemVoiceContent -> false
}
}
} }
is TimelineItem.Virtual -> false is TimelineItem.Virtual -> false
is TimelineItem.GroupedEvents -> false is TimelineItem.GroupedEvents -> false
@@ -16,9 +16,13 @@ data class TimelineProtectionState(
val protectionState: ProtectionState, val protectionState: ProtectionState,
val eventSink: (TimelineProtectionEvent) -> Unit, val eventSink: (TimelineProtectionEvent) -> Unit,
) { ) {
fun hideMediaContent(eventId: EventId?) = when (protectionState) { fun hideMediaContent(eventId: EventId?, isMine: Boolean = false) = if (isMine) {
is ProtectionState.RenderAll -> false false
is ProtectionState.RenderOnly -> eventId !in protectionState.eventIds } else {
when (protectionState) {
is ProtectionState.RenderAll -> false
is ProtectionState.RenderOnly -> eventId !in protectionState.eventIds
}
} }
} }
@@ -42,4 +42,19 @@ class TimelineProtectionStateTest {
assertThat(sut.hideMediaContent(AN_EVENT_ID)).isFalse() assertThat(sut.hideMediaContent(AN_EVENT_ID)).isFalse()
assertThat(sut.hideMediaContent(AN_EVENT_ID_2)).isTrue() assertThat(sut.hideMediaContent(AN_EVENT_ID_2)).isTrue()
} }
@Test
fun `when isMine is true, hideMediaContent always returns false regardless of state`() {
val sutRenderAll = aTimelineProtectionState(
protectionState = ProtectionState.RenderAll
)
assertThat(sutRenderAll.hideMediaContent(null, isMine = true)).isFalse()
assertThat(sutRenderAll.hideMediaContent(AN_EVENT_ID, isMine = true)).isFalse()
val sutRenderOnly = aTimelineProtectionState(
protectionState = ProtectionState.RenderOnly(persistentSetOf())
)
assertThat(sutRenderOnly.hideMediaContent(null, isMine = true)).isFalse()
assertThat(sutRenderOnly.hideMediaContent(AN_EVENT_ID, isMine = true)).isFalse()
}
} }
@@ -286,7 +286,7 @@ private fun SoundsPreferenceCategory(state: NotificationSettingsState) {
type = RingtoneManager.TYPE_NOTIFICATION, type = RingtoneManager.TYPE_NOTIFICATION,
current = state.messageSound.sound, current = state.messageSound.sound,
defaultUri = Settings.System.DEFAULT_NOTIFICATION_URI, defaultUri = Settings.System.DEFAULT_NOTIFICATION_URI,
onSoundPicked = { sound -> state.eventSink(NotificationSettingsEvents.SetMessageSound(sound)) }, onSoundPick = { sound -> state.eventSink(NotificationSettingsEvents.SetMessageSound(sound)) },
) )
// Skip the initial 0 emission so the picker doesn't auto-open on screen entry; only // Skip the initial 0 emission so the picker doesn't auto-open on screen entry; only
// increments fired by LaunchMessageSoundPicker should launch it. // increments fired by LaunchMessageSoundPicker should launch it.
@@ -313,7 +313,7 @@ private fun SoundsPreferenceCategory(state: NotificationSettingsState) {
type = RingtoneManager.TYPE_RINGTONE, type = RingtoneManager.TYPE_RINGTONE,
current = state.callRingtone.sound, current = state.callRingtone.sound,
defaultUri = Settings.System.DEFAULT_RINGTONE_URI, defaultUri = Settings.System.DEFAULT_RINGTONE_URI,
onSoundPicked = { sound -> state.eventSink(NotificationSettingsEvents.SetCallRingtone(sound)) }, onSoundPick = { sound -> state.eventSink(NotificationSettingsEvents.SetCallRingtone(sound)) },
) )
// Skip the initial 0 emission so the picker doesn't auto-open on screen entry; only // Skip the initial 0 emission so the picker doesn't auto-open on screen entry; only
// increments fired by LaunchCallRingtonePicker should launch it. // increments fired by LaunchCallRingtonePicker should launch it.
@@ -441,7 +441,7 @@ private fun rememberSoundPickerOnClick(
type: Int, type: Int,
current: NotificationSound, current: NotificationSound,
defaultUri: Uri, defaultUri: Uri,
onSoundPicked: (NotificationSound) -> Unit, onSoundPick: (NotificationSound) -> Unit,
): () -> Unit { ): () -> Unit {
// Paparazzi previews don't provide a LocalActivityResultRegistryOwner, which // Paparazzi previews don't provide a LocalActivityResultRegistryOwner, which
// rememberLauncherForActivityResult requires. Skip the launcher in inspection mode and // rememberLauncherForActivityResult requires. Skip the launcher in inspection mode and
@@ -453,7 +453,7 @@ private fun rememberSoundPickerOnClick(
if (result.resultCode == Activity.RESULT_OK) { if (result.resultCode == Activity.RESULT_OK) {
val sound = result.data?.toPickedNotificationSound(defaultUri) val sound = result.data?.toPickedNotificationSound(defaultUri)
if (sound != null) { if (sound != null) {
onSoundPicked(sound) onSoundPick(sound)
} }
} }
} }
@@ -20,6 +20,7 @@ interface BugReporter {
* @param problemDescription the bug description * @param problemDescription the bug description
* @param canContact true if the user opt in to be contacted directly * @param canContact true if the user opt in to be contacted directly
* @param sendPushRules true to include the push rules * @param sendPushRules true to include the push rules
* @param ghIssueNumber it not null, the GitHub issue number to link the bug report to.
* @param listener the listener * @param listener the listener
*/ */
suspend fun sendBugReport( suspend fun sendBugReport(
@@ -29,6 +30,7 @@ interface BugReporter {
problemDescription: String, problemDescription: String,
canContact: Boolean = false, canContact: Boolean = false,
sendPushRules: Boolean = false, sendPushRules: Boolean = false,
ghIssueNumber: Int? = null,
listener: BugReporterListener listener: BugReporterListener
) )
@@ -18,4 +18,5 @@ sealed interface BugReportEvents {
data class SetCanContact(val canContact: Boolean) : BugReportEvents data class SetCanContact(val canContact: Boolean) : BugReportEvents
data class SetSendScreenshot(val sendScreenshot: Boolean) : BugReportEvents data class SetSendScreenshot(val sendScreenshot: Boolean) : BugReportEvents
data class SetSendPushRules(val sendPushRules: Boolean) : BugReportEvents data class SetSendPushRules(val sendPushRules: Boolean) : BugReportEvents
data class SetGhIssueNumber(val ghIssueNumber: Int?) : BugReportEvents
} }
@@ -113,6 +113,9 @@ class BugReportPresenter(
sendingProgress.floatValue = 0f sendingProgress.floatValue = 0f
sendingAction.value = AsyncAction.Uninitialized sendingAction.value = AsyncAction.Uninitialized
} }
is BugReportEvents.SetGhIssueNumber -> updateFormState(formState) {
copy(ghIssueNumber = event.ghIssueNumber)
}
} }
} }
@@ -142,7 +145,8 @@ class BugReportPresenter(
problemDescription = formState.description, problemDescription = formState.description,
canContact = formState.canContact, canContact = formState.canContact,
sendPushRules = formState.sendPushRules, sendPushRules = formState.sendPushRules,
listener = listener ghIssueNumber = formState.ghIssueNumber,
listener = listener,
) )
} }
@@ -32,6 +32,7 @@ data class BugReportFormState(
val canContact: Boolean, val canContact: Boolean,
val sendScreenshot: Boolean, val sendScreenshot: Boolean,
val sendPushRules: Boolean, val sendPushRules: Boolean,
val ghIssueNumber: Int?,
) : Parcelable { ) : Parcelable {
companion object { companion object {
val Default = BugReportFormState( val Default = BugReportFormState(
@@ -40,6 +41,7 @@ data class BugReportFormState(
canContact = false, canContact = false,
sendScreenshot = false, sendScreenshot = false,
sendPushRules = false, sendPushRules = false,
ghIssueNumber = null,
) )
} }
} }
@@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.request.CachePolicy import coil3.request.CachePolicy
import coil3.request.ImageRequest import coil3.request.ImageRequest
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.rageshake.impl.R import io.element.android.features.rageshake.impl.R
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView
@@ -155,6 +156,48 @@ fun BugReportView(
title = stringResource(R.string.screen_bug_report_send_notification_settings_title), title = stringResource(R.string.screen_bug_report_send_notification_settings_title),
subtitle = stringResource(R.string.screen_bug_report_send_notification_settings_description), subtitle = stringResource(R.string.screen_bug_report_send_notification_settings_description),
) )
PreferenceRow {
var ghIssueNumberState by textFieldState(
stateValue = state.formState.ghIssueNumber?.toString() ?: ""
)
TextField(
value = ghIssueNumberState,
modifier = Modifier
.fillMaxWidth()
.onTabOrEnterKeyFocusNext(LocalFocusManager.current),
enabled = isFormEnabled,
label = stringResource(id = R.string.screen_bug_report_github_issue_label),
placeholder = "1234",
supportingText = stringResource(id = R.string.screen_bug_report_github_issue_description),
leadingIcon = {
Text(
text = "#",
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textSecondary,
)
},
onValueChange = {
if (it.isEmpty()) {
ghIssueNumberState = ""
eventSink(BugReportEvents.SetGhIssueNumber(null))
} else {
val number = it.toIntOrNull()?.takeIf { ghInt -> ghInt in 1..99_999 }
number?.let { ghIssueNumber ->
ghIssueNumberState = ghIssueNumber.toString()
eventSink(BugReportEvents.SetGhIssueNumber(ghIssueNumber))
}
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(onNext = {
keyboardController?.hide()
}),
singleLine = true,
)
}
// Submit // Submit
PreferenceRow { PreferenceRow {
Button( Button(
@@ -123,6 +123,7 @@ class DefaultBugReporter(
problemDescription: String, problemDescription: String,
canContact: Boolean, canContact: Boolean,
sendPushRules: Boolean, sendPushRules: Boolean,
ghIssueNumber: Int?,
listener: BugReporterListener, listener: BugReporterListener,
) { ) {
val url = bugReporterUrlProvider.provide().first() val url = bugReporterUrlProvider.provide().first()
@@ -144,6 +145,9 @@ class DefaultBugReporter(
val crashCallStack = crashDataStore.crashInfo().first() val crashCallStack = crashDataStore.crashInfo().first()
val bugDescription = buildString { val bugDescription = buildString {
append(problemDescription) append(problemDescription)
ghIssueNumber?.let {
append("\n\nhttps://github.com/element-hq/element-x-android/issues/$it")
}
if (crashCallStack.isNotEmpty() && withCrashLogs) { if (crashCallStack.isNotEmpty() && withCrashLogs) {
append("\n\n\n\n--------------------------------- crash call stack ---------------------------------\n") append("\n\n\n\n--------------------------------- crash call stack ---------------------------------\n")
append(crashCallStack) append(crashCallStack)
@@ -8,6 +8,8 @@
<string name="screen_bug_report_editor_placeholder">"Describe the problem…"</string> <string name="screen_bug_report_editor_placeholder">"Describe the problem…"</string>
<string name="screen_bug_report_editor_supporting">"If possible, please write the description in English."</string> <string name="screen_bug_report_editor_supporting">"If possible, please write the description in English."</string>
<string name="screen_bug_report_error_description_too_short">"The description is too short, please provide more details about what happened. Thanks!"</string> <string name="screen_bug_report_error_description_too_short">"The description is too short, please provide more details about what happened. Thanks!"</string>
<string name="screen_bug_report_github_issue_description">"You can enter the number of an associated GitHub issue, if any."</string>
<string name="screen_bug_report_github_issue_label">"GitHub issue"</string>
<string name="screen_bug_report_include_crash_logs">"Send crash logs"</string> <string name="screen_bug_report_include_crash_logs">"Send crash logs"</string>
<string name="screen_bug_report_include_logs">"Allow logs"</string> <string name="screen_bug_report_include_logs">"Allow logs"</string>
<string name="screen_bug_report_include_logs_error">"Your logs are excessively large so cannot be included in this report, please send them to us another way."</string> <string name="screen_bug_report_include_logs_error">"Your logs are excessively large so cannot be included in this report, please send them to us another way."</string>
@@ -107,6 +107,18 @@ class BugReportPresenterTest {
} }
} }
@Test
fun `present - set GitHub issue number`() = runTest {
val presenter = createPresenter()
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(BugReportEvents.SetGhIssueNumber(1))
assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(ghIssueNumber = 1))
initialState.eventSink.invoke(BugReportEvents.SetGhIssueNumber(null))
assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(ghIssueNumber = null))
}
}
@Test @Test
fun `present - reset all`() = runTest { fun `present - reset all`() = runTest {
val presenter = createPresenter( val presenter = createPresenter(
@@ -28,6 +28,7 @@ class FakeBugReporter(val mode: Mode = Mode.Success) : BugReporter {
problemDescription: String, problemDescription: String,
canContact: Boolean, canContact: Boolean,
sendPushRules: Boolean, sendPushRules: Boolean,
ghIssueNumber: Int?,
listener: BugReporterListener, listener: BugReporterListener,
) { ) {
delay(100) delay(100)
+1 -1
View File
@@ -214,7 +214,7 @@ telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "
statemachine = "com.freeletics.flowredux:compose:1.2.2" statemachine = "com.freeletics.flowredux:compose:1.2.2"
maplibre = "org.maplibre.gl:android-sdk:13.1.0" maplibre = "org.maplibre.gl:android-sdk:13.1.0"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
maplibre_compose = "org.maplibre.compose:maplibre-compose:0.12.1" maplibre_compose = "org.maplibre.compose:maplibre-compose:0.13.0"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
opusencoder = "io.element.android:opusencoder:1.2.0" opusencoder = "io.element.android:opusencoder:1.2.0"
zxing_cpp = "io.github.zxing-cpp:android:3.0.2" zxing_cpp = "io.github.zxing-cpp:android:3.0.2"
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4a10513e98757e17cc3ffb8302807896839a78d036331e79bb207686f3f9db86 oid sha256:a4b243025f40d5d4a91e32d59a6cac9ec21a273aaddbfac020cb26b3dc86c906
size 47842 size 53339
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:05efb0fa45bb6440e75e9280b219cfdac83c372efe9e6d6254b4bbe516f8b998 oid sha256:e98722e0c21fe6b509b2bdf4a9ce7f78a1c83b606411329d2fd97a676fc4bed3
size 112842 size 113728
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:833d21633cb0f153b660112fcfd0923c588cd26b64005403a007d7782ecfe5d5 oid sha256:44eed3e8a093977f51d706acbec65cf29d592dcf0af3cdc9fce85d2b8e570eb9
size 45138 size 51038
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4a10513e98757e17cc3ffb8302807896839a78d036331e79bb207686f3f9db86 oid sha256:a4b243025f40d5d4a91e32d59a6cac9ec21a273aaddbfac020cb26b3dc86c906
size 47842 size 53339
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ebe22ec065cc6dff2133dcc573a82e003d9061ae0446c2d8c13f4e1fba1f3c19 oid sha256:781287977ff2637fe6479c3653270783c4374bc10fffb91da8b45d4e4f588a98
size 37056 size 41744
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f647e92bf813ead7affcdb6ad07076fe9d739f6a4c0016b9787912f032e27476 oid sha256:f690c34f97eec23f01bfdc3c2606a229bb12abb91fe86fb79c89da79b08480a0
size 46567 size 52049
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cdfbb9f70a97724a1b6687ad4f199efc2c9bcc5f29fe9475a17ee6e52e5ecf8e oid sha256:751b442e5aecd9d3938817f8a1af11b7cc1d250c1166c9e8bd3154a85268471d
size 110829 size 111765
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1cfab4886243e6c914f466f5f127ec0347b0ab30ee43fb95c7bc82d2b16325d5 oid sha256:0719e803112640729fe2b59c40373343db3e9f2e8dded9258d4b6c44547f8fd1
size 43727 size 49420
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f647e92bf813ead7affcdb6ad07076fe9d739f6a4c0016b9787912f032e27476 oid sha256:f690c34f97eec23f01bfdc3c2606a229bb12abb91fe86fb79c89da79b08480a0
size 46567 size 52049
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8c24515cc18b3c5cc52f2acfcee4faa65c02d8d5c8be847d4b1ec5671a858210 oid sha256:1cb10ddf4a9dee4a001276554cf32cf874062e1e5d7d0ebb673f6417641530bf
size 35201 size 39730