Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,16 @@ import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.getDirectRoomMember
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.allFiles
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
import kotlinx.collections.immutable.ImmutableList
Expand Down Expand Up @@ -75,6 +78,8 @@ class AttachmentsPreviewPresenter(
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val room: JoinedRoom,
private val notificationConversationService: NotificationConversationService,
) : Presenter<AttachmentsPreviewState> {
@AssistedFactory
interface Factory {
Expand Down Expand Up @@ -547,6 +552,7 @@ class AttachmentsPreviewPresenter(
}
}.fold(
onSuccess = {
notifyMessageSent()
mediaUploadInfos.forEach { cleanUp(it) }
sendActionState.value = SendActionState.Done
onDoneListener()
Expand All @@ -560,4 +566,20 @@ class AttachmentsPreviewPresenter(
}
}
)

private suspend fun notifyMessageSent() {
runCatchingExceptions {
val roomInfo = room.info()
val roomMembers = room.membersStateFlow.value
notificationConversationService.onMessageSent(
sessionId = room.sessionId,
roomId = roomInfo.id,
roomName = roomInfo.name,
roomIsDirect = roomInfo.isDm,
roomAvatarUrl = roomInfo.avatarUrl ?: roomMembers.getDirectRoomMember(roomInfo = roomInfo, sessionId = room.sessionId)?.avatarUrl,
)
}.onFailure {
Timber.e(it, "Failed to update notification conversation after sending attachment")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,7 @@ class MessageComposerPresenter(
val roomInfo = room.info()
val roomMembers = room.membersStateFlow.value

notificationConversationService.onSendMessage(
notificationConversationService.onMessageSent(
sessionId = room.sessionId,
roomId = roomInfo.id,
roomName = roomInfo.name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.GalleryItemInfo
Expand All @@ -57,6 +59,7 @@ import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
Expand Down Expand Up @@ -112,11 +115,15 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
},
)
val onDoneListener = lambdaRecorder<Unit> { }
val onMessageSent = lambdaRecorder<SessionId, RoomId, String?, Boolean, String?, Unit> { _, _, _, _, _ ->
}
val notificationConversationService = FakeNotificationConversationService(onMessageSentLambda = onMessageSent)
val mediaPreProcessor = FakeMediaPreProcessor()
val presenter = createAttachmentsPreviewPresenter(
room = room,
mediaPreProcessor = mediaPreProcessor,
onDoneListener = { onDoneListener() },
notificationConversationService = notificationConversationService,
)
presenter.test {
skipItems(1)
Expand All @@ -130,6 +137,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
onMessageSent.assertions().isCalledOnce()
assertThat(mediaPreProcessor.cleanUpCallCount).isEqualTo(1)
}
}
Expand Down Expand Up @@ -977,6 +985,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
}
},
videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(),
notificationConversationService: FakeNotificationConversationService = FakeNotificationConversationService(),
): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter(
attachments = attachments.toImmutableList(),
Expand All @@ -1001,6 +1010,8 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() {
timelineMode = timelineMode,
inReplyToEventId = null,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
room = room,
notificationConversationService = notificationConversationService,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@

package io.element.android.features.roomdetails.impl.notificationsettings

import android.content.ActivityNotFoundException
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
Expand All @@ -18,18 +21,26 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.androidutils.system.toast
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.push.api.notifications.RoomNotificationSettingsIntentProvider
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.launch

@ContributesNode(RoomScope::class)
@AssistedInject
class RoomNotificationSettingsNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: RoomNotificationSettingsPresenter.Factory,
@ApplicationContext private val context: Context,
private val room: JoinedRoom,
private val roomNotificationSettingsIntentProvider: RoomNotificationSettingsIntentProvider,
private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
data class RoomNotificationSettingInput(
Expand Down Expand Up @@ -60,7 +71,27 @@ class RoomNotificationSettingsNode(
state = state,
modifier = modifier,
onShowGlobalNotifications = callback::navigateToGlobalNotificationSettings,
onShowAndroidRoomNotificationSettings = ::openAndroidRoomNotificationSettings,
onBackClick = ::navigateUp,
)
}

private fun openAndroidRoomNotificationSettings() {
lifecycleScope.launch {
val roomInfo = room.info()
val roomDisplayName = roomInfo.name?.takeIf { it.isNotBlank() } ?: room.roomId.value
val intent = roomNotificationSettingsIntentProvider.getIntent(
sessionId = room.sessionId,
roomId = room.roomId,
roomDisplayName = roomDisplayName,
isDm = room.isDm(),
roomAvatarUrl = roomInfo.avatarUrl,
)
try {
context.startActivity(intent)
} catch (_: ActivityNotFoundException) {
context.toast(io.element.android.libraries.androidutils.R.string.error_no_compatible_app_found)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ import io.element.android.libraries.designsystem.components.preferences.Preferen
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.ui.strings.CommonStrings
Expand All @@ -40,20 +42,23 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun RoomNotificationSettingsView(
state: RoomNotificationSettingsState,
onShowGlobalNotifications: () -> Unit,
onShowAndroidRoomNotificationSettings: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (state.showUserDefinedSettingStyle) {
UserDefinedRoomNotificationSettingsView(
state = state,
modifier = modifier,
onShowAndroidRoomNotificationSettings = onShowAndroidRoomNotificationSettings,
onBackClick = onBackClick,
)
} else {
RoomSpecificNotificationSettingsView(
state = state,
modifier = modifier,
onShowGlobalNotifications = onShowGlobalNotifications,
onShowAndroidRoomNotificationSettings = onShowAndroidRoomNotificationSettings,
onBackClick = onBackClick,
)
}
Expand All @@ -63,6 +68,7 @@ fun RoomNotificationSettingsView(
private fun RoomSpecificNotificationSettingsView(
state: RoomNotificationSettingsState,
onShowGlobalNotifications: () -> Unit,
onShowAndroidRoomNotificationSettings: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Expand Down Expand Up @@ -144,6 +150,10 @@ private fun RoomSpecificNotificationSettingsView(
}
}

AndroidRoomNotificationSettingsItem(
onClick = onShowAndroidRoomNotificationSettings,
)

AsyncActionView(
async = state.setNotificationSettingAction,
onSuccess = {},
Expand Down Expand Up @@ -180,6 +190,18 @@ internal fun RoomNotificationSettingsViewPreview(
RoomNotificationSettingsView(
state = state,
onShowGlobalNotifications = {},
onShowAndroidRoomNotificationSettings = {},
onBackClick = {},
)
}

@Composable
internal fun AndroidRoomNotificationSettingsItem(
onClick: () -> Unit,
) {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_notification_settings_android_settings_title)) },
supportingContent = { Text(stringResource(R.string.screen_room_notification_settings_android_settings_subtitle)) },
onClick = onClick,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
@Composable
fun UserDefinedRoomNotificationSettingsView(
state: RoomNotificationSettingsState,
onShowAndroidRoomNotificationSettings: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Expand Down Expand Up @@ -65,6 +66,10 @@ fun UserDefinedRoomNotificationSettingsView(
)
}

AndroidRoomNotificationSettingsItem(
onClick = onShowAndroidRoomNotificationSettings,
)

ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_notification_settings_edit_remove_setting)) },
style = ListItemStyle.Destructive,
Expand Down Expand Up @@ -109,6 +114,7 @@ internal fun UserDefinedRoomNotificationSettingsViewPreview(
) = ElementPreview {
UserDefinedRoomNotificationSettingsView(
state = state,
onShowAndroidRoomNotificationSettings = {},
onBackClick = {},
)
}
5 changes: 5 additions & 0 deletions features/roomdetails/impl/src/main/res/values/temporary.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="screen_room_notification_settings_android_settings_subtitle">Change sound, vibration, priority and lock screen behaviour for this room in Android settings.</string>
<string name="screen_room_notification_settings_android_settings_title">Android notification settings</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

package io.element.android.libraries.preferences.api.store

import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.Flow

interface SessionPreferencesStore {
Expand Down Expand Up @@ -35,5 +36,24 @@ interface SessionPreferencesStore {
suspend fun setVideoCompressionPreset(preset: VideoCompressionPreset)
fun getVideoCompressionPreset(): Flow<VideoCompressionPreset>

/**
* Records "now" as the last time [roomId]'s auto-created per-room notification channel
* handled a notification. Used to retire long-inactive channels.
*/
suspend fun recordRoomChannelNotified(roomId: RoomId)

/** Clears [roomId]'s recorded channel last-notified timestamp, if any. */
suspend fun clearRoomChannelLastNotified(roomId: RoomId)

/**
* Every recorded channel last-notified timestamp, keyed by the same room-id hash embedded in
* that room's channel id. Channel ids only carry a one-way hash of the room id, not the id
* itself, so a caller enumerating system channels can only look this up by that hash.
*/
suspend fun getRoomChannelLastNotifiedByHash(): Map<String, Long>

/** Clears a channel last-notified timestamp by the room-id hash embedded in its channel id. */
suspend fun clearRoomChannelLastNotifiedByHash(roomHash: String)

suspend fun clear()
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,20 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStoreFile
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.androidutils.hash.hash
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.io.File

Expand All @@ -48,6 +51,16 @@ class DefaultSessionPreferencesStore(
private val compressImages = booleanPreferencesKey("compressMedia")
private val compressMediaPreset = stringPreferencesKey("compressMediaPreset")

// Keyed by a hash of the room id rather than declared statically: DataStore doesn't require
// enumerable keys, and one room can't collide with another's hashed prefix. Also looked up by
// raw hash, since a channel id enumerated from the system only carries that hash, not the
// original RoomId.
private val roomChannelLastNotifiedKeySuffix = "notifChannelLastNotified"
private fun roomChannelLastNotifiedKey(roomId: RoomId) =
longPreferencesKey("room_${roomId.value.hash().take(16)}_$roomChannelLastNotifiedKeySuffix")
private fun roomChannelLastNotifiedKeyFromHash(roomHash: String) =
longPreferencesKey("room_${roomHash}_$roomChannelLastNotifiedKeySuffix")

private val dataStoreFile = storeFile(context, sessionId)
private val store = PreferenceDataStoreFactory.create(
scope = sessionCoroutineScope,
Expand Down Expand Up @@ -94,6 +107,31 @@ class DefaultSessionPreferencesStore(
override fun getVideoCompressionPreset(): Flow<VideoCompressionPreset> = get(compressMediaPreset) { VideoCompressionPreset.STANDARD.name }
.map { tryOrNull { VideoCompressionPreset.valueOf(it) } ?: VideoCompressionPreset.STANDARD }

override suspend fun recordRoomChannelNotified(roomId: RoomId) {
update(roomChannelLastNotifiedKey(roomId), System.currentTimeMillis())
}

override suspend fun clearRoomChannelLastNotified(roomId: RoomId) {
store.edit { prefs -> prefs.remove(roomChannelLastNotifiedKey(roomId)) }
}

override suspend fun getRoomChannelLastNotifiedByHash(): Map<String, Long> {
val suffix = "_$roomChannelLastNotifiedKeySuffix"
return store.data.first().asMap().entries
.mapNotNull { (key, value) ->
if (key.name.startsWith("room_") && key.name.endsWith(suffix) && value is Long) {
key.name.removePrefix("room_").removeSuffix(suffix) to value
} else {
null
}
}
.toMap()
}

override suspend fun clearRoomChannelLastNotifiedByHash(roomHash: String) {
store.edit { prefs -> prefs.remove(roomChannelLastNotifiedKeyFromHash(roomHash)) }
}

override suspend fun clear() {
dataStoreFile.safeDelete()
}
Expand Down
Loading