Merge branch 'develop' into feature/fga/improve_timeline_file_rendering

This commit is contained in:
ganfra 2023-06-02 17:00:13 +02:00
commit c5ac14014c
139 changed files with 1693 additions and 990 deletions

24
.github/renovate.json vendored
View file

@ -1,18 +1,28 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema" : "https://docs.renovatebot.com/renovate-schema.json",
"extends": [ "extends" : [
"config:base" "config:base"
], ],
"labels": ["dependencies"], "labels" : [
"ignoreDeps": ["string:app_name"], "dependencies"
"packageRules": [ ],
"ignoreDeps" : [
"string:app_name"
],
"packageRules" : [
{ {
"matchPackagePatterns": [ "matchPackagePatterns" : [
"^org.jetbrains.kotlin", "^org.jetbrains.kotlin",
"^com.google.devtools.ksp", "^com.google.devtools.ksp",
"^androidx.compose.compiler" "^androidx.compose.compiler"
], ],
"groupName": "kotlin" "groupName" : "kotlin"
},
{
"matchPackageNames" : [
"org.jetbrains.kotlinx.kover"
],
"enabled" : false
} }
] ]
} }

29
.github/workflows/recordScreenshots.yml vendored Normal file
View file

@ -0,0 +1,29 @@
name: Record screenshots
on:
workflow_dispatch:
# Enrich gradle.properties for CI/CD
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false
jobs:
record:
name: Record screenshots on branch ${{ inputs.param_branch }}
runs-on: ubuntu-latest
steps:
- name: ⏬ Checkout with LFS
uses: actions/checkout@v3
with:
lfs: 'true'
- name: ☕️ Use JDK 17
uses: actions/setup-java@v3
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Record screenshots
run: "./.github/workflows/scripts/recordScreenshots.sh"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ secrets.GITHUB_REPOSITORY }}

View file

@ -0,0 +1,55 @@
#!/bin/bash
#
# Copyright (c) 2023 New Vector Ltd
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
if [[ -z ${GITHUB_TOKEN} ]]; then
echo "Missing GITHUB_TOKEN variable"
exit 1
fi
if [[ -z ${GITHUB_REPOSITORY} ]]; then
echo "Missing GITHUB_REPOSITORY variable"
exit 1
fi
if [[ -z ${GITHUB_REF_NAME} ]]; then
echo "Missing GITHUB_REF_NAME variable"
exit 1
fi
git config user.name "ElementBot"
git config user.email "benoitm+elementbot@element.io"
echo "Git status"
git status
echo "Fetching..."
git fetch --all
echo "Checkout origin/$GITHUB_REF_NAME"
git checkout "origin/$GITHUB_REF_NAME"
echo "Record screenshots"
./gradlew recordPaparazziDebug --stacktrace -PpreDexEnable=false --max-workers 4 --warn
echo "Committing changes"
git add -A
git commit -m "Update screenshots"
echo "Pushing changes"
git push "https://$GITHUB_TOKEN@github.com/$GITHUB_REPOSITORY.git"
echo "Done!"

View file

@ -2,6 +2,8 @@
<dictionary name="shared"> <dictionary name="shared">
<words> <words>
<w>backstack</w> <w>backstack</w>
<w>kover</w>
<w>onboarding</w>
<w>textfields</w> <w>textfields</w>
</words> </words>
</dictionary> </dictionary>

View file

@ -1,6 +1,6 @@
appId: ${APP_ID} appId: ${APP_ID}
--- ---
- tapOn: "Get started" - tapOn: "Sign in manually"
- runFlow: ../assertions/assertLoginDisplayed.yaml - runFlow: ../assertions/assertLoginDisplayed.yaml
- takeScreenshot: build/maestro/100-SignIn - takeScreenshot: build/maestro/100-SignIn
- runFlow: changeServer.yaml - runFlow: changeServer.yaml

View file

@ -1,5 +1,5 @@
appId: ${APP_ID} appId: ${APP_ID}
--- ---
- extendedWaitUntil: - extendedWaitUntil:
visible: "Own your conversations." visible: "Communicate and collaborate securely"
timeout: 10_000 timeout: 10_000

View file

@ -142,15 +142,6 @@ android {
jvmTarget = "17" jvmTarget = "17"
} }
// Waiting for https://github.com/google/ksp/issues/37
applicationVariants.all {
kotlin.sourceSets {
getByName(name) {
kotlin.srcDir("build/generated/ksp/$name/kotlin")
}
}
}
buildFeatures { buildFeatures {
buildConfig = true buildConfig = true
} }

View file

@ -93,10 +93,10 @@ class RootFlowNode @AssistedInject constructor(
if (isLoggedIn) { if (isLoggedIn) {
tryToRestoreLatestSession( tryToRestoreLatestSession(
onSuccess = { switchToLoggedInFlow(it) }, onSuccess = { switchToLoggedInFlow(it) },
onFailure = { switchToLogoutFlow() } onFailure = { switchToNotLoggedInFlow() }
) )
} else { } else {
switchToLogoutFlow() switchToNotLoggedInFlow()
} }
} }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
@ -106,7 +106,7 @@ class RootFlowNode @AssistedInject constructor(
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId)) backstack.safeRoot(NavTarget.LoggedInFlow(sessionId))
} }
private fun switchToLogoutFlow() { private fun switchToNotLoggedInFlow() {
matrixClientsHolder.removeAll() matrixClientsHolder.removeAll()
backstack.safeRoot(NavTarget.NotLoggedInFlow) backstack.safeRoot(NavTarget.NotLoggedInFlow)
} }

View file

@ -203,6 +203,8 @@ koverMerged {
includes += "*Presenter" includes += "*Presenter"
excludes += "*Fake*Presenter" excludes += "*Fake*Presenter"
excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*" excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*"
// Too small presenter, cannot reach the threshold.
excludes += "io.element.android.features.onboarding.impl.OnBoardingPresenter"
} }
bound { bound {
minValue = 90 minValue = 90

1
changelog.d/483.feature Normal file
View file

@ -0,0 +1 @@
Redesign the timeline item context menu using M3 bottom sheet

View file

@ -118,6 +118,8 @@ class MessagesPresenter @Inject constructor(
TimelineItemAction.Redact -> handleActionRedact(targetEvent) TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState) TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState)
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState) TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState)
TimelineItemAction.Developer -> notImplementedYet()
TimelineItemAction.ReportContent -> notImplementedYet()
} }
} }

View file

@ -16,7 +16,6 @@
package io.element.android.features.messages.impl package io.element.android.features.messages.impl
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
@ -32,27 +31,22 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ListItem
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Collections
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -66,9 +60,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineView import io.element.android.features.messages.impl.timeline.TimelineView
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.networkmonitor.api.ui.ConnectivityIndicatorView import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
@ -80,7 +72,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheetLayout
import io.element.android.libraries.designsystem.theme.components.Scaffold 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.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
@ -91,7 +82,7 @@ import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import io.element.android.libraries.ui.strings.R as StringsR import io.element.android.libraries.ui.strings.R as StringsR
@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun MessagesView( fun MessagesView(
state: MessagesState, state: MessagesState,
@ -103,26 +94,11 @@ fun MessagesView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LogCompositions(tag = "MessagesScreen", msg = "Root") LogCompositions(tag = "MessagesScreen", msg = "Root")
val itemActionsBottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
)
val composerState = state.composerState
val initialBottomSheetState = if (LocalInspectionMode.current && composerState.showAttachmentSourcePicker) {
ModalBottomSheetValue.Expanded
} else {
ModalBottomSheetValue.Hidden
}
val bottomSheetState = rememberModalBottomSheetState(initialValue = initialBottomSheetState)
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var isMessageActionsBottomSheetVisible by rememberSaveable { mutableStateOf(false) }
AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments) AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments)
BackHandler(enabled = bottomSheetState.isVisible) {
coroutineScope.launch {
bottomSheetState.hide()
}
}
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val snackbarMessageText = state.snackbarMessage?.let { stringResource(it.messageResId) } val snackbarMessageText = state.snackbarMessage?.let { stringResource(it.messageResId) }
if (snackbarMessageText != null) { if (snackbarMessageText != null) {
@ -150,78 +126,57 @@ fun MessagesView(
Timber.v("OnMessageLongClicked= ${event.id}") Timber.v("OnMessageLongClicked= ${event.id}")
localView.hideKeyboard() localView.hideKeyboard()
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event)) state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event))
coroutineScope.launch { isMessageActionsBottomSheetVisible = true
itemActionsBottomSheetState.show()
}
} }
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) { fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
isMessageActionsBottomSheetVisible = false
state.eventSink(MessagesEvents.HandleAction(action, event)) state.eventSink(MessagesEvents.HandleAction(action, event))
} }
LaunchedEffect(composerState.showAttachmentSourcePicker) { fun onDismissActionListBottomSheet() {
if (composerState.showAttachmentSourcePicker) { isMessageActionsBottomSheetVisible = false
// We need to use this instead of `LocalFocusManager.clearFocus()` to hide the keyboard when focus is on an Android View
localView.hideKeyboard()
bottomSheetState.show()
} else {
bottomSheetState.hide()
}
} }
// Send 'DismissAttachmentMenu' event when the bottomsheet was just hidden
LaunchedEffect(bottomSheetState.isVisible) {
if (!bottomSheetState.isVisible) {
composerState.eventSink(MessageComposerEvents.DismissAttachmentMenu)
}
}
ModalBottomSheetLayout(
sheetState = bottomSheetState,
displayHandle = true,
sheetContent = {
AttachmentSourcePickerMenu(
eventSink = composerState.eventSink
)
}
) {
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets.statusBars,
topBar = {
Column {
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
MessagesViewTopBar(
roomTitle = state.roomName,
roomAvatar = state.roomAvatar,
onBackPressed = onBackPressed,
onRoomDetailsClicked = onRoomDetailsClicked,
)
}
},
content = { padding ->
MessagesViewContent(
state = state,
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding),
onMessageClicked = ::onMessageClicked,
onMessageLongClicked = ::onMessageLongClicked,
onUserDataClicked = onUserDataClicked,
)
},
snackbarHost = {
SnackbarHost(
snackbarHostState,
modifier = Modifier.navigationBarsPadding()
)
},
)
ActionListView( Scaffold(
state = state.actionListState, modifier = modifier,
modalBottomSheetState = itemActionsBottomSheetState, contentWindowInsets = WindowInsets.statusBars,
onActionSelected = ::onActionSelected topBar = {
) Column {
} ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
MessagesViewTopBar(
roomTitle = state.roomName,
roomAvatar = state.roomAvatar,
onBackPressed = onBackPressed,
onRoomDetailsClicked = onRoomDetailsClicked,
)
}
},
content = { padding ->
MessagesViewContent(
state = state,
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding),
onMessageClicked = ::onMessageClicked,
onMessageLongClicked = ::onMessageLongClicked,
onUserDataClicked = onUserDataClicked,
)
},
snackbarHost = {
SnackbarHost(
snackbarHostState,
modifier = Modifier.navigationBarsPadding()
)
},
)
ActionListView(
state = state.actionListState,
isVisible = isMessageActionsBottomSheetVisible,
onDismiss = ::onDismissActionListBottomSheet,
onActionSelected = ::onActionSelected
)
} }
@Composable @Composable
@ -312,36 +267,6 @@ fun MessagesViewTopBar(
) )
} }
@OptIn(ExperimentalMaterialApi::class)
@Composable
internal fun AttachmentSourcePickerMenu(
eventSink: (MessageComposerEvents) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier) {
ListItem(
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) },
icon = { Icon(Icons.Default.Collections, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
)
ListItem(
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) },
icon = { Icon(Icons.Default.AttachFile, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
)
ListItem(
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) },
icon = { Icon(Icons.Default.PhotoCamera, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
)
ListItem(
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) },
icon = { Icon(Icons.Default.Videocam, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
)
}
}
@Preview @Preview
@Composable @Composable
internal fun MessagesViewLightPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) = internal fun MessagesViewLightPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) =

View file

@ -26,12 +26,15 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class ActionListPresenter @Inject constructor() : Presenter<ActionListState> { class ActionListPresenter @Inject constructor(
private val buildMeta: BuildMeta,
) : Presenter<ActionListState> {
@Composable @Composable
override fun present(): ActionListState { override fun present(): ActionListState {
@ -60,21 +63,30 @@ class ActionListPresenter @Inject constructor() : Presenter<ActionListState> {
when (timelineItem.content) { when (timelineItem.content) {
is TimelineItemRedactedContent, is TimelineItemRedactedContent,
is TimelineItemStateContent -> { is TimelineItemStateContent -> {
// TODO Add Share action (also) here, and developer options buildList {
emptyList() add(TimelineItemAction.Copy)
} if (buildMeta.isDebuggable) {
else -> { add(TimelineItemAction.Developer)
mutableListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
).also {
if (timelineItem.isMine) {
it.add(TimelineItemAction.Edit)
it.add(TimelineItemAction.Redact)
} }
} }
} }
else -> buildList<TimelineItemAction> {
add(TimelineItemAction.Reply)
add(TimelineItemAction.Forward)
if (timelineItem.isMine) {
add(TimelineItemAction.Edit)
}
add(TimelineItemAction.Copy)
if (buildMeta.isDebuggable) {
add(TimelineItemAction.Developer)
}
if (!timelineItem.isMine) {
add(TimelineItemAction.ReportContent)
}
if (timelineItem.isMine) {
add(TimelineItemAction.Redact)
}
}
} }
target.value = ActionListState.Target.Success(timelineItem, actions.toImmutableList()) target.value = ActionListState.Target.Success(timelineItem, actions.toImmutableList())
} }

View file

@ -16,52 +16,81 @@
package io.element.android.features.messages.impl.actionlist package io.element.android.features.messages.impl.actionlist
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ListItem import androidx.compose.material.ListItem
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddReaction
import androidx.compose.material.icons.outlined.Attachment
import androidx.compose.material.icons.outlined.VideoCameraBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.components.blurhash.BlurHashAsyncImage
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.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheetLayout import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import kotlinx.coroutines.flow.filter import io.element.android.libraries.matrix.ui.media.MediaRequestData
import kotlinx.coroutines.launch import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ActionListView( fun ActionListView(
state: ActionListState, state: ActionListState,
modalBottomSheetState: ModalBottomSheetState, isVisible: Boolean,
onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit, onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val coroutineScope = rememberCoroutineScope() LaunchedEffect(isVisible) {
LaunchedEffect(modalBottomSheetState) { if (!isVisible) {
snapshotFlow { modalBottomSheetState.currentValue } state.eventSink(ActionListEvents.Clear)
.filter { it == ModalBottomSheetValue.Hidden } }
.collect {
state.eventSink(ActionListEvents.Clear)
}
} }
fun onItemActionClicked( fun onItemActionClicked(
@ -69,24 +98,22 @@ fun ActionListView(
targetItem: TimelineItem.Event targetItem: TimelineItem.Event
) { ) {
onActionSelected(itemAction, targetItem) onActionSelected(itemAction, targetItem)
coroutineScope.launch {
modalBottomSheetState.hide()
}
} }
ModalBottomSheetLayout( if (isVisible) {
modifier = modifier, ModalBottomSheet(
sheetState = modalBottomSheetState, onDismissRequest = onDismiss
sheetContent = { ) {
SheetContent( SheetContent(
state = state, state = state,
onActionClicked = ::onItemActionClicked, onActionClicked = ::onItemActionClicked,
modifier = Modifier modifier = modifier
.navigationBarsPadding() .padding(bottom = 32.dp)
.imePadding() // .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
// .imePadding()
) )
} }
) }
} }
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@ -108,6 +135,19 @@ private fun SheetContent(
LazyColumn( LazyColumn(
modifier = modifier.fillMaxWidth() modifier = modifier.fillMaxWidth()
) { ) {
item {
Column {
MessageSummary(event = target.event, modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp))
Spacer(modifier = Modifier.height(14.dp))
Divider()
}
}
item {
EmojiReactionsRow(Modifier.fillMaxWidth())
Divider()
}
items( items(
items = actions, items = actions,
) { action -> ) { action ->
@ -135,6 +175,141 @@ private fun SheetContent(
} }
} }
@Composable
private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modifier) {
val content: @Composable () -> Unit
var icon: @Composable () -> Unit = { Avatar(avatarData = event.senderAvatar.copy(size = AvatarSize.SMALL)) }
val contentStyle = ElementTextStyles.Regular.bodyMD.copy(color = MaterialTheme.colorScheme.secondary)
val imageModifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(9.dp))
@Composable
fun ContentForBody(body: String) {
Text(body, style = contentStyle, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
when (event.content) {
is TimelineItemTextBasedContent -> content = { ContentForBody(event.content.body) }
is TimelineItemStateContent -> content = { ContentForBody(event.content.body) }
is TimelineItemProfileChangeContent -> content = { ContentForBody(event.content.body) }
is TimelineItemEncryptedContent -> content = { ContentForBody(stringResource(StringR.string.common_unable_to_decrypt)) }
is TimelineItemRedactedContent -> content = { ContentForBody(stringResource(StringR.string.common_message_removed)) }
is TimelineItemUnknownContent -> content = { ContentForBody(stringResource(StringR.string.common_unsupported_event)) }
is TimelineItemImageContent -> {
icon = {
val mediaRequestData = MediaRequestData(
source = event.content.mediaSource,
kind = MediaRequestData.Kind.Thumbnail(32),
)
BlurHashAsyncImage(
model = mediaRequestData,
blurHash = event.content.blurhash,
contentDescription = stringResource(StringR.string.common_image),
contentScale = ContentScale.Crop,
modifier = imageModifier,
)
}
content = { ContentForBody(event.content.body) }
}
is TimelineItemVideoContent -> {
icon = {
val thumbnailSource = event.content.thumbnailSource
if (thumbnailSource != null) {
val mediaRequestData = MediaRequestData(
source = event.content.thumbnailSource,
kind = MediaRequestData.Kind.Thumbnail(32),
)
BlurHashAsyncImage(
model = mediaRequestData,
blurHash = event.content.blurHash,
contentDescription = stringResource(StringR.string.common_video),
contentScale = ContentScale.Crop,
modifier = imageModifier,
)
} else {
Box(
modifier = imageModifier.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.VideoCameraBack,
contentDescription = stringResource(StringR.string.common_video),
)
}
}
}
content = { ContentForBody(event.content.body) }
}
is TimelineItemFileContent -> {
icon = {
Box(
modifier = imageModifier.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.Attachment,
contentDescription = stringResource(StringR.string.common_file),
modifier = Modifier.rotate(-45f)
)
}
}
content = { ContentForBody(event.content.body) }
}
}
Row(modifier = modifier) {
icon()
Spacer(modifier = Modifier.width(8.dp))
Column {
Row {
if (event.senderDisplayName != null) {
Text(
text = event.senderDisplayName,
style = ElementTextStyles.Bold.caption1,
color = MaterialTheme.colorScheme.primary
)
}
Text(
event.sentTime,
style = ElementTextStyles.Regular.caption2,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.End,
modifier = Modifier.weight(1f)
)
}
content()
}
}
}
@Composable
internal fun EmojiReactionsRow(modifier: Modifier = Modifier) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = modifier.padding(horizontal = 28.dp, vertical = 16.dp)
) {
// TODO use real emojis, have real interaction
Text("\uD83D\uDC4D", fontSize = 28.dpToSp())
Text("\uD83D\uDC4E", fontSize = 28.dpToSp())
Text("\uD83D\uDD25", fontSize = 28.dpToSp())
Text("\uFE0F", fontSize = 28.dpToSp())
Text("\uD83D\uDC4F", fontSize = 28.dpToSp())
Icon(
imageVector = Icons.Outlined.AddReaction,
contentDescription = "Emojis",
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier
.size(24.dp)
.align(Alignment.CenterVertically)
)
}
}
@Composable
private fun Int.dpToSp(): TextUnit = with(LocalDensity.current) {
return dp.toSp()
}
@Preview @Preview
@Composable @Composable
fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) = fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) =
@ -145,14 +320,7 @@ fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) s
fun SheetContentDarkPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) = fun SheetContentDarkPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) =
ElementPreviewDark { ContentToPreview(state) } ElementPreviewDark { ContentToPreview(state) }
@OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
private fun ContentToPreview(state: ActionListState) { private fun ContentToPreview(state: ActionListState) {
ActionListView( SheetContent(state = state)
state = state,
modalBottomSheetState = ModalBottomSheetState(
initialValue = ModalBottomSheetValue.Expanded
),
onActionSelected = { _, _ -> }
)
} }

View file

@ -26,9 +26,11 @@ sealed class TimelineItemAction(
@DrawableRes val icon: Int, @DrawableRes val icon: Int,
val destructive: Boolean = false val destructive: Boolean = false
) { ) {
object Forward : TimelineItemAction("Forward", VectorIcons.ArrowForward) object Forward : TimelineItemAction("Forward", VectorIcons.Forward)
object Copy : TimelineItemAction("Copy", VectorIcons.Copy) object Copy : TimelineItemAction("Copy", VectorIcons.Copy)
object Redact : TimelineItemAction("Redact", VectorIcons.Delete, destructive = true) object Redact : TimelineItemAction("Redact", VectorIcons.Delete, destructive = true)
object Reply : TimelineItemAction("Reply", VectorIcons.Reply) object Reply : TimelineItemAction("Reply", VectorIcons.Reply)
object Edit : TimelineItemAction("Edit", VectorIcons.Edit) object Edit : TimelineItemAction("Edit", VectorIcons.Edit)
object Developer : TimelineItemAction("Developer", VectorIcons.DeveloperMode)
object ReportContent : TimelineItemAction("Report content", VectorIcons.ReportContent, destructive = true)
} }

View file

@ -0,0 +1,117 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.messagecomposer
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ListItem
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Collections
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R
import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun AttachmentsBottomSheet(
state: MessageComposerState,
modifier: Modifier = Modifier,
) {
val localView = LocalView.current
var isVisible by rememberSaveable { mutableStateOf(state.showAttachmentSourcePicker) }
BackHandler(enabled = isVisible) {
isVisible = false
}
LaunchedEffect(state.showAttachmentSourcePicker) {
if (state.showAttachmentSourcePicker) {
// We need to use this instead of `LocalFocusManager.clearFocus()` to hide the keyboard when focus is on an Android View
localView.hideKeyboard()
isVisible = true
} else {
isVisible = false
}
}
// Send 'DismissAttachmentMenu' event when the bottomsheet was just hidden
LaunchedEffect(isVisible) {
if (!isVisible) {
state.eventSink(MessageComposerEvents.DismissAttachmentMenu)
}
}
if (isVisible) {
ModalBottomSheet(
modifier = modifier,
onDismissRequest = { isVisible = false }
) {
AttachmentSourcePickerMenu(eventSink = state.eventSink)
}
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
internal fun AttachmentSourcePickerMenu(
eventSink: (MessageComposerEvents) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier.padding(bottom = 32.dp)
// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
) {
ListItem(
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) },
icon = { Icon(Icons.Default.Collections, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
)
ListItem(
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) },
icon = { Icon(Icons.Default.AttachFile, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
)
ListItem(
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) },
icon = { Icon(Icons.Default.PhotoCamera, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
)
ListItem(
modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) },
icon = { Icon(Icons.Default.Videocam, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
)
}
}

View file

@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.messagecomposer package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@ -46,21 +47,25 @@ fun MessageComposerView(
state.eventSink(MessageComposerEvents.UpdateText(text)) state.eventSink(MessageComposerEvents.UpdateText(text))
} }
TextComposer( Box {
onSendMessage = ::sendMessage, AttachmentsBottomSheet(state = state)
fullscreen = state.isFullScreen,
onFullscreenToggle = ::onFullscreenToggle, TextComposer(
composerMode = state.mode, onSendMessage = ::sendMessage,
onCloseSpecialMode = ::onCloseSpecialMode, fullscreen = state.isFullScreen,
onComposerTextChange = ::onComposerTextChange, onFullscreenToggle = ::onFullscreenToggle,
onAddAttachment = { composerMode = state.mode,
state.eventSink(MessageComposerEvents.AddAttachment) onCloseSpecialMode = ::onCloseSpecialMode,
}, onComposerTextChange = ::onComposerTextChange,
composerCanSendMessage = state.isSendButtonVisible, onAddAttachment = {
composerText = state.text?.charSequence?.toString(), state.eventSink(MessageComposerEvents.AddAttachment)
isInDarkMode = !ElementTheme.colors.isLight, },
modifier = modifier composerCanSendMessage = state.isSendButtonVisible,
) composerText = state.text?.charSequence?.toString(),
isInDarkMode = !ElementTheme.colors.isLight,
modifier = modifier
)
}
} }
@Preview @Preview

View file

@ -24,12 +24,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.outlined.Attachment
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
@ -58,14 +59,14 @@ fun TimelineItemFileView(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
modifier = Modifier.size(20.dp), imageVector = Icons.Outlined.Attachment,
imageVector = Icons.Filled.AttachFile, contentDescription = "OpenFile",
contentDescription = "OpenFile" modifier = Modifier.size(16.dp).rotate(-45f),
) )
} }
Column(modifier = Modifier.padding(horizontal = 8.dp),) { Column(modifier = Modifier.padding(horizontal = 8.dp),) {
Text( Text(
text = content.name, text = content.body,
maxLines = 2, maxLines = 2,
fontSize = 16.sp, fontSize = 16.sp,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis

View file

@ -73,7 +73,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
) )
} }
is FileMessageType -> TimelineItemFileContent( is FileMessageType -> TimelineItemFileContent(
name = messageType.body, body = messageType.body,
thumbnailSource = messageType.info?.thumbnailSource, thumbnailSource = messageType.info?.thumbnailSource,
fileSource = messageType.source, fileSource = messageType.source,
mimeType = messageType.info?.mimetype, mimeType = messageType.info?.mimetype,

View file

@ -19,7 +19,7 @@ package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
data class TimelineItemFileContent( data class TimelineItemFileContent(
val name: String, val body: String,
val fileSource: MediaSource, val fileSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val formattedFileSize: String?, val formattedFileSize: String?,
@ -27,7 +27,7 @@ data class TimelineItemFileContent(
) : TimelineItemEventContent { ) : TimelineItemEventContent {
override val type: String = "TimelineItemFileContent" override val type: String = "TimelineItemFileContent"
private val fileExtension = name.substringAfterLast('.', "").uppercase() private val fileExtension = body.substringAfterLast('.', "").uppercase()
val fileExtensionAndSize = buildString { val fileExtensionAndSize = buildString {
append(fileExtension) append(fileExtension)
if (formattedFileSize != null) { if (formattedFileSize != null) {

View file

@ -30,7 +30,7 @@ open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineIt
} }
fun aTimelineItemFileContent(fileName: String) = TimelineItemFileContent( fun aTimelineItemFileContent(fileName: String) = TimelineItemFileContent(
name = fileName, body = fileName,
thumbnailSource = MediaSource(url = ""), thumbnailSource = MediaSource(url = ""),
fileSource = MediaSource(url = ""), fileSource = MediaSource(url = ""),
mimeType = MimeTypes.OctetStream, mimeType = MimeTypes.OctetStream,

View file

@ -31,6 +31,8 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.media.FakeLocalMediaFactory import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
@ -132,6 +134,32 @@ class MessagesPresenterTest {
} }
} }
@Test
fun `present - handle action report content`() = runTest {
val presenter = createMessagePresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent()))
// Still a TODO in the code
}
}
@Test
fun `present - handle action show developer info`() = runTest {
val presenter = createMessagePresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent()))
// Still a TODO in the code
}
}
private fun TestScope.createMessagePresenter( private fun TestScope.createMessagePresenter(
matrixRoom: MatrixRoom = FakeMatrixRoom() matrixRoom: MatrixRoom = FakeMatrixRoom()
): MessagesPresenter { ): MessagesPresenter {
@ -148,7 +176,20 @@ class MessagesPresenterTest {
timelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory = aTimelineItemsFactory(),
room = matrixRoom, room = matrixRoom,
) )
val actionListPresenter = ActionListPresenter() val buildMeta = BuildMeta(
buildType = BuildType.DEBUG,
isDebuggable = true,
applicationId = "",
applicationName = "",
lowPrivacyLoggingEnabled = true,
versionName = "",
gitRevision = "",
gitBranchName = "",
gitRevisionDate = "",
flavorDescription = "",
flavorShortDescription = "",
)
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)
return MessagesPresenter( return MessagesPresenter(
room = matrixRoom, room = matrixRoom,
composerPresenter = messageComposerPresenter, composerPresenter = messageComposerPresenter,

View file

@ -29,6 +29,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemReac
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
@ -42,7 +44,7 @@ import org.junit.Test
class ActionListPresenterTest { class ActionListPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = ActionListPresenter() val presenter = anActionListPresenter(isBuildDebuggable = true)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -53,7 +55,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for message from me redacted`() = runTest { fun `present - compute for message from me redacted`() = runTest {
val presenter = ActionListPresenter() val presenter = anActionListPresenter(isBuildDebuggable = true)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -67,6 +69,8 @@ class ActionListPresenterTest {
ActionListState.Target.Success( ActionListState.Target.Success(
messageEvent, messageEvent,
persistentListOf( persistentListOf(
TimelineItemAction.Copy,
TimelineItemAction.Developer,
) )
) )
) )
@ -75,9 +79,10 @@ class ActionListPresenterTest {
} }
} }
@Test @Test
fun `present - compute for message from others redacted`() = runTest { fun `present - compute for message from others redacted`() = runTest {
val presenter = ActionListPresenter() val presenter = anActionListPresenter(isBuildDebuggable = true)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -91,6 +96,8 @@ class ActionListPresenterTest {
ActionListState.Target.Success( ActionListState.Target.Success(
messageEvent, messageEvent,
persistentListOf( persistentListOf(
TimelineItemAction.Copy,
TimelineItemAction.Developer,
) )
) )
) )
@ -101,7 +108,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for others message`() = runTest { fun `present - compute for others message`() = runTest {
val presenter = ActionListPresenter() val presenter = anActionListPresenter(isBuildDebuggable = true)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -121,6 +128,8 @@ class ActionListPresenterTest {
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.Developer,
TimelineItemAction.ReportContent,
) )
) )
) )
@ -131,7 +140,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for my message`() = runTest { fun `present - compute for my message`() = runTest {
val presenter = ActionListPresenter() val presenter = anActionListPresenter(isBuildDebuggable = true)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -150,8 +159,41 @@ class ActionListPresenterTest {
persistentListOf( persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Copy,
TimelineItemAction.Edit, TimelineItemAction.Edit,
TimelineItemAction.Copy,
TimelineItemAction.Developer,
TimelineItemAction.Redact,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute message in non-debuggable build`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = false)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
messageEvent,
persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Edit,
TimelineItemAction.Copy,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) )
) )
@ -162,6 +204,34 @@ class ActionListPresenterTest {
} }
} }
private fun aBuildMeta(
buildType: BuildType = BuildType.DEBUG,
isDebuggable: Boolean = true,
applicationName: String = "",
applicationId: String = "",
lowPrivacyLoggingEnabled: Boolean = true,
versionName: String = "",
gitRevision: String = "",
gitRevisionDate: String = "",
gitBranchName: String = "",
flavorDescription: String = "",
flavorShortDescription: String = "",
) = BuildMeta(
buildType,
isDebuggable,
applicationName,
applicationId,
lowPrivacyLoggingEnabled,
versionName,
gitRevision,
gitRevisionDate,
gitBranchName,
flavorDescription,
flavorShortDescription
)
private fun anActionListPresenter(isBuildDebuggable: Boolean) = ActionListPresenter(buildMeta = aBuildMeta(isDebuggable = isBuildDebuggable))
private fun aMessageEvent( private fun aMessageEvent(
isMine: Boolean, isMine: Boolean,
content: TimelineItemEventContent, content: TimelineItemEventContent,

View file

@ -40,8 +40,6 @@ dependencies {
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(libs.accompanist.pager)
implementation(libs.accompanist.pagerindicator)
api(projects.features.onboarding.api) api(projects.features.onboarding.api)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)

View file

@ -16,16 +16,7 @@
package io.element.android.features.onboarding.impl package io.element.android.features.onboarding.impl
import androidx.annotation.DrawableRes object OnBoardingConfig {
import androidx.annotation.StringRes const val canLoginWithQrCode = false
const val canCreateAccount = false
data class SplashCarouselData(
val items: List<Item>
) {
data class Item(
@StringRes val title: Int,
@StringRes val body: Int,
@DrawableRes val image: Int,
@DrawableRes val pageBackground: Int
)
} }

View file

@ -32,6 +32,7 @@ import io.element.android.libraries.di.AppScope
class OnBoardingNode @AssistedInject constructor( class OnBoardingNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: OnBoardingPresenter,
) : Node( ) : Node(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins plugins = plugins
@ -47,10 +48,11 @@ class OnBoardingNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
OnBoardingScreen( val state = presenter.present()
OnBoardingView(
state = state,
modifier = modifier, modifier = modifier,
onSignIn = this::onSignIn, onSignIn = ::onSignIn,
onSignUp = this::onSignUp
) )
} }
} }

View file

@ -0,0 +1,36 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.onboarding.impl
import androidx.compose.runtime.Composable
import io.element.android.libraries.architecture.Presenter
import javax.inject.Inject
/**
* Note: this Presenter is ignored regarding code coverage because it cannot reach the coverage threshold.
* When this presenter get more code in it, please remove the ignore rule in the kover configuration.
*/
class OnBoardingPresenter @Inject constructor(
) : Presenter<OnBoardingState> {
@Composable
override fun present(): OnBoardingState {
return OnBoardingState(
canLoginWithQrCode = OnBoardingConfig.canLoginWithQrCode,
canCreateAccount = OnBoardingConfig.canCreateAccount,
)
}
}

View file

@ -1,182 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.onboarding.impl
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.HorizontalPagerIndicator
import com.google.accompanist.pager.rememberPagerState
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun OnBoardingScreen(
modifier: Modifier = Modifier,
onPageChanged: (Int) -> Unit = {},
onSignUp: () -> Unit = {},
onSignIn: () -> Unit = {},
) {
val carrouselData = remember { SplashCarouselDataFactory().create() }
val nbOfPages = carrouselData.items.size
var key by remember { mutableStateOf(false) }
Box(
modifier = modifier
.fillMaxSize()
.systemBarsPadding()
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier.fillMaxSize(),
) {
val pagerState = rememberPagerState()
LaunchedEffect(key) {
launch {
delay(3_000)
pagerState.animateScrollToPage((pagerState.currentPage + 1) % nbOfPages)
// https://stackoverflow.com/questions/73714228/accompanist-pager-animatescrolltopage-doesnt-scroll-to-next-page-correctly
key = !key
}
}
LaunchedEffect(pagerState) {
// Collect from the pager state a snapshotFlow reading the currentPage
snapshotFlow { pagerState.currentPage }.collect { page ->
onPageChanged(page)
}
}
HorizontalPager(
modifier = Modifier.weight(1f),
count = nbOfPages,
state = pagerState,
) { page ->
// Our page content
OnBoardingPage(carrouselData.items[page])
}
HorizontalPagerIndicator(
pagerState = pagerState,
modifier = Modifier
.align(CenterHorizontally)
.padding(16.dp),
)
Button(
onClick = {
onSignIn()
},
enabled = true,
modifier = Modifier
.align(CenterHorizontally)
.testTag(TestTags.onBoardingSignIn)
.padding(top = 16.dp)
) {
Text(text = stringResource(id = R.string.login_splash_submit))
}
}
}
}
@Composable
fun OnBoardingPage(
item: SplashCarouselData.Item,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier,
) {
/*
Image(
painterResource(id = item.pageBackground),
contentDescription = null,
modifier = Modifier.fillMaxSize()
)
*/
Column(
modifier = Modifier.padding(vertical = 16.dp, horizontal = 32.dp)
) {
Image(
painterResource(id = item.image),
contentDescription = null,
modifier = Modifier
.align(CenterHorizontally)
.size(192.dp)
.padding(16.dp)
)
Text(
text = stringResource(id = item.title),
modifier = Modifier
.fillMaxWidth()
.align(CenterHorizontally)
.padding(8.dp),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
fontSize = 24.sp,
)
Text(
text = stringResource(id = item.body),
modifier = Modifier
.fillMaxWidth()
.align(CenterHorizontally),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
@Preview
@Composable
internal fun OnBoardingScreenLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun OnBoardingScreenDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
OnBoardingScreen()
}

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.onboarding.impl
data class OnBoardingState(
val canLoginWithQrCode: Boolean,
val canCreateAccount: Boolean,
)

View file

@ -0,0 +1,37 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.onboarding.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
override val values: Sequence<OnBoardingState>
get() = sequenceOf(
anOnBoardingState(),
anOnBoardingState(canLoginWithQrCode = true),
anOnBoardingState(canCreateAccount = true),
anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true),
)
}
fun anOnBoardingState(
canLoginWithQrCode: Boolean = false,
canCreateAccount: Boolean = false
) = OnBoardingState(
canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = canCreateAccount
)

View file

@ -0,0 +1,182 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.onboarding.impl
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
// Ref: https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=133-5427&t=5SHVppfYzjvkEywR-0
@Composable
fun OnBoardingView(
state: OnBoardingState,
modifier: Modifier = Modifier,
onSignInWithQrCode: () -> Unit = {},
onSignIn: () -> Unit = {},
onCreateAccount: () -> Unit = {},
) {
OnBoardingPage(
modifier = modifier,
footer = {
OnBoardingButtons(
state = state,
onSignInWithQrCode = onSignInWithQrCode,
onSignIn = onSignIn,
onCreateAccount = onCreateAccount,
)
}
) {
OnBoardingContent()
}
}
@Composable
private fun OnBoardingContent(modifier: Modifier = Modifier) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = BiasAlignment(
horizontalBias = 0f,
verticalBias = -0.2f
)
) {
Column(
modifier = Modifier
.fillMaxWidth(),
horizontalAlignment = CenterHorizontally,
) {
Image(
painter = painterResource(id = R.drawable.element_logo),
contentDescription = null,
)
Image(
modifier = Modifier.padding(top = 14.dp),
painter = painterResource(id = R.drawable.element),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
contentDescription = null,
)
Text(
modifier = Modifier.padding(top = 24.dp),
text = stringResource(id = R.string.screen_onboarding_subtitle),
color = MaterialTheme.colorScheme.secondary,
fontSize = 20.sp,
textAlign = TextAlign.Center
)
}
}
}
@Composable
private fun OnBoardingButtons(
state: OnBoardingState,
onSignInWithQrCode: () -> Unit,
onSignIn: () -> Unit,
onCreateAccount: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth(),
horizontalAlignment = CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (state.canLoginWithQrCode) {
Button(
onClick = {
onSignInWithQrCode()
},
enabled = true,
modifier = Modifier
.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.QrCode, contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary
)
Spacer(Modifier.width(14.dp))
Text(text = stringResource(id = R.string.screen_onboarding_sign_in_with_qr_code))
}
}
Button(
onClick = {
onSignIn()
},
enabled = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.onBoardingSignIn)
) {
Text(text = stringResource(id = R.string.screen_onboarding_sign_in_manually))
}
if (state.canCreateAccount) {
OutlinedButton(
onClick = {
onCreateAccount()
},
enabled = true,
modifier = Modifier
.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.screen_onboarding_sign_up))
}
}
}
}
@Preview
@Composable
internal fun OnBoardingScreenLightPreview(@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
internal fun OnBoardingScreenDarkPreview(@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: OnBoardingState) {
OnBoardingView(state)
}

View file

@ -1,73 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.onboarding.impl
import androidx.annotation.DrawableRes
class SplashCarouselDataFactory {
fun create(): SplashCarouselData {
val lightTheme = true
fun background(@DrawableRes lightDrawable: Int) =
if (lightTheme) lightDrawable else R.drawable.bg_color_background
fun hero(@DrawableRes lightDrawable: Int, @DrawableRes darkDrawable: Int) =
if (lightTheme) lightDrawable else darkDrawable
return SplashCarouselData(
listOf(
SplashCarouselData.Item(
R.string.ftue_auth_carousel_secure_title,
R.string.ftue_auth_carousel_secure_body,
hero(
R.drawable.ic_splash_conversations,
R.drawable.ic_splash_conversations_dark
),
background(R.drawable.bg_carousel_page_1)
),
SplashCarouselData.Item(
R.string.ftue_auth_carousel_control_title,
R.string.ftue_auth_carousel_control_body,
hero(R.drawable.ic_splash_control, R.drawable.ic_splash_control_dark),
background(R.drawable.bg_carousel_page_2)
),
SplashCarouselData.Item(
R.string.ftue_auth_carousel_encrypted_title,
R.string.ftue_auth_carousel_encrypted_body,
hero(R.drawable.ic_splash_secure, R.drawable.ic_splash_secure_dark),
background(R.drawable.bg_carousel_page_3)
),
SplashCarouselData.Item(
collaborationTitle(),
R.string.ftue_auth_carousel_workplace_body,
hero(
R.drawable.ic_splash_collaboration,
R.drawable.ic_splash_collaboration_dark
),
background(R.drawable.bg_carousel_page_4)
)
)
)
}
private fun collaborationTitle(): Int {
return when {
true -> R.string.cut_the_slack_from_teams
else -> R.string.ftue_auth_carousel_workplace_title
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View file

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2022 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="@integer/rtl_mirror_flip"
android:endColor="#3372C7DA"
android:startColor="#33BBE7CF" />
</shape>

View file

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2022 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="@integer/rtl_mirror_flip"
android:endColor="#33B972DA"
android:startColor="#3372C7DA" />
</shape>

View file

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2022 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="@integer/rtl_mirror_flip"
android:endColor="#330DBD8B"
android:startColor="#33B972DA" />
</shape>

View file

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2022 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="@integer/rtl_mirror_flip"
android:endColor="#33BBE7CF"
android:startColor="#330DBD8B" />
</shape>

View file

@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2022 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?android:colorBackground" />
</shape>

View file

@ -0,0 +1,27 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="160dp"
android:height="34dp"
android:viewportWidth="160"
android:viewportHeight="34">
<path
android:pathData="M22.18,23.71H5.07C5.27,25.51 5.92,26.94 7.02,28.01C8.12,29.05 9.56,29.57 11.35,29.57C12.53,29.57 13.6,29.28 14.56,28.71C15.51,28.13 16.19,27.35 16.59,26.36H21.79C21.1,28.65 19.8,30.5 17.89,31.92C16.01,33.31 13.79,34 11.22,34C7.87,34 5.16,32.89 3.08,30.66C1.03,28.43 0,25.61 0,22.2C0,18.87 1.04,16.08 3.12,13.82C5.2,11.56 7.88,10.43 11.18,10.43C14.47,10.43 17.13,11.55 19.15,13.78C21.2,15.97 22.22,18.75 22.22,22.11L22.18,23.71ZM11.18,14.64C9.56,14.64 8.22,15.12 7.15,16.08C6.08,17.03 5.42,18.3 5.16,19.9H17.11C16.88,18.3 16.25,17.03 15.21,16.08C14.17,15.12 12.82,14.64 11.18,14.64Z"
android:fillColor="#1B1D22"/>
<path
android:pathData="M25.77,26.75V0.93H30.93V26.84C30.93,28 31.56,28.58 32.83,28.58L33.74,28.53V33.44C33.25,33.52 32.73,33.57 32.18,33.57C29.96,33.57 28.33,33 27.29,31.87C26.28,30.75 25.77,29.04 25.77,26.75Z"
android:fillColor="#1B1D22"/>
<path
android:pathData="M57.75,23.71H40.64C40.84,25.51 41.49,26.94 42.59,28.01C43.69,29.05 45.13,29.57 46.92,29.57C48.11,29.57 49.18,29.28 50.13,28.71C51.08,28.13 51.76,27.35 52.16,26.36H57.36C56.67,28.65 55.37,30.5 53.46,31.92C51.59,33.31 49.36,34 46.79,34C43.44,34 40.73,32.89 38.65,30.66C36.6,28.43 35.57,25.61 35.57,22.2C35.57,18.87 36.61,16.08 38.69,13.82C40.77,11.56 43.46,10.43 46.75,10.43C50.04,10.43 52.7,11.55 54.72,13.78C56.77,15.97 57.8,18.75 57.8,22.11L57.75,23.71ZM46.75,14.64C45.13,14.64 43.79,15.12 42.72,16.08C41.65,17.03 40.99,18.3 40.73,19.9H52.68C52.45,18.3 51.82,17.03 50.78,16.08C49.74,15.12 48.4,14.64 46.75,14.64Z"
android:fillColor="#1B1D22"/>
<path
android:pathData="M81.01,20.55V33.48H75.86V19.98C75.86,16.57 74.44,14.86 71.61,14.86C70.08,14.86 68.85,15.35 67.93,16.34C67.03,17.32 66.59,18.67 66.59,20.37V33.48H61.43V10.95H66.2V13.95C66.75,12.94 67.58,12.1 68.71,11.43C69.84,10.77 71.24,10.43 72.91,10.43C76.03,10.43 78.28,11.62 79.67,13.99C81.58,11.62 84.12,10.43 87.29,10.43C89.92,10.43 91.94,11.26 93.36,12.91C94.77,14.53 95.48,16.67 95.48,19.33V33.48H90.33V19.98C90.33,16.57 88.91,14.86 86.08,14.86C84.52,14.86 83.28,15.37 82.36,16.38C81.46,17.36 81.01,18.75 81.01,20.55Z"
android:fillColor="#1B1D22"/>
<path
android:pathData="M121.12,23.71H104.01C104.21,25.51 104.86,26.94 105.96,28.01C107.06,29.05 108.5,29.57 110.29,29.57C111.47,29.57 112.54,29.28 113.5,28.71C114.45,28.13 115.13,27.35 115.53,26.36H120.73C120.04,28.65 118.74,30.5 116.83,31.92C114.96,33.31 112.73,34 110.16,34C106.81,34 104.1,32.89 102.02,30.66C99.97,28.43 98.94,25.61 98.94,22.2C98.94,18.87 99.98,16.08 102.06,13.82C104.14,11.56 106.82,10.43 110.12,10.43C113.41,10.43 116.07,11.55 118.09,13.78C120.14,15.97 121.16,18.75 121.16,22.11L121.12,23.71ZM110.12,14.64C108.5,14.64 107.16,15.12 106.09,16.08C105.02,17.03 104.36,18.3 104.1,19.9H116.05C115.82,18.3 115.19,17.03 114.15,16.08C113.11,15.12 111.76,14.64 110.12,14.64Z"
android:fillColor="#1B1D22"/>
<path
android:pathData="M129.56,10.95V13.95C130.08,12.97 130.94,12.14 132.12,11.48C133.33,10.78 134.79,10.43 136.5,10.43C139.15,10.43 141.2,11.24 142.65,12.86C144.12,14.48 144.86,16.64 144.86,19.33V33.48H139.7V19.98C139.7,18.39 139.33,17.15 138.57,16.25C137.85,15.32 136.74,14.86 135.24,14.86C133.59,14.86 132.29,15.35 131.34,16.34C130.42,17.32 129.95,18.68 129.95,20.42V33.48H124.8V10.95H129.56Z"
android:fillColor="#1B1D22"/>
<path
android:pathData="M159.91,28.88V33.35C159.28,33.52 158.38,33.61 157.23,33.61C152.84,33.61 150.64,31.4 150.64,26.97V15.08H147.22V10.95H150.64V5.1H155.8V10.95H160V15.08H155.8V26.45C155.8,28.21 156.63,29.1 158.31,29.1L159.91,28.88Z"
android:fillColor="#1B1D22"/>
</vector>

View file

@ -0,0 +1,26 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="50dp"
android:height="49dp"
android:viewportWidth="50"
android:viewportHeight="49">
<path
android:pathData="M24.8,48.608C38.199,48.608 49.061,37.726 49.061,24.304C49.061,10.881 38.199,0 24.8,0C11.401,0 0.54,10.881 0.54,24.304C0.54,37.726 11.401,48.608 24.8,48.608Z"
android:fillColor="#0DBD8B"
android:fillType="evenOdd"/>
<path
android:pathData="M20.365,11.324C20.365,10.343 21.16,9.548 22.142,9.548C28.793,9.548 34.185,14.938 34.185,21.587C34.185,22.568 33.389,23.363 32.408,23.363C31.426,23.363 30.631,22.568 30.631,21.587C30.631,16.9 26.83,13.1 22.142,13.1C21.16,13.1 20.365,12.305 20.365,11.324Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M37.753,19.81C38.735,19.81 39.53,20.606 39.53,21.587C39.53,28.236 34.138,33.626 27.487,33.626C26.506,33.626 25.71,32.831 25.71,31.85C25.71,30.869 26.506,30.073 27.487,30.073C32.176,30.073 35.976,26.274 35.976,21.587C35.976,20.606 36.772,19.81 37.753,19.81Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M29.264,37.283C29.264,38.264 28.468,39.06 27.487,39.06C20.836,39.06 15.444,33.669 15.444,27.02C15.444,26.039 16.24,25.244 17.221,25.244C18.202,25.244 18.998,26.039 18.998,27.02C18.998,31.708 22.799,35.507 27.487,35.507C28.468,35.507 29.264,36.302 29.264,37.283Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M11.847,28.797C10.866,28.797 10.071,28.002 10.071,27.021C10.071,20.372 15.462,14.981 22.113,14.981C23.095,14.981 23.89,15.777 23.89,16.758C23.89,17.739 23.095,18.534 22.113,18.534C17.425,18.534 13.624,22.333 13.624,27.021C13.624,28.002 12.829,28.797 11.847,28.797Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
</vector>

View file

@ -1,37 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2022 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<resources>
<!-- File to remove once the screen will be updated -->
<string name="cut_the_slack_from_teams" translatable="false">Cut the slack from teams.</string>
<string name="login_splash_submit">Get started</string>
<string name="ftue_auth_carousel_secure_title">Own your conversations.</string>
<string name="ftue_auth_carousel_control_title">You\'re in control.</string>
<string name="ftue_auth_carousel_encrypted_title">Secure messaging.</string>
<string name="ftue_auth_carousel_workplace_title">Messaging for your team.</string>
<string name="ftue_auth_carousel_secure_body">Secure and independent communication that gives you the same level of privacy as a face-to-face conversation in your own home.</string>
<string name="ftue_auth_carousel_control_body">Choose where your conversations are kept, giving you control and independence. Connected via Matrix.</string>
<string name="ftue_auth_carousel_encrypted_body">End-to-end encrypted and no phone number required. No ads or datamining.</string>
<string name="ftue_auth_carousel_workplace_body">Element is also great for the workplace. Its trusted by the worlds most secure organisations.</string>
</resources>

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.onboarding.impl
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test
class OnBoardingPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = OnBoardingPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.canLoginWithQrCode).isFalse()
assertThat(initialState.canCreateAccount).isFalse()
}
}
}

View file

@ -96,8 +96,6 @@ accompanist_permission = { module = "com.google.accompanist:accompanist-permissi
accompanist_material = { module = "com.google.accompanist:accompanist-navigation-material", version.ref = "accompanist" } accompanist_material = { module = "com.google.accompanist:accompanist-navigation-material", version.ref = "accompanist" }
accompanist_systemui = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" } accompanist_systemui = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" }
accompanist_placeholder = { module = "com.google.accompanist:accompanist-placeholder-material", version.ref = "accompanist" } accompanist_placeholder = { module = "com.google.accompanist:accompanist-placeholder-material", version.ref = "accompanist" }
accompanist_pager = { module = "com.google.accompanist:accompanist-pager", version.ref = "accompanist" }
accompanist_pagerindicator = { module = "com.google.accompanist:accompanist-pager-indicators", version.ref = "accompanist" }
accompanist_flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref = "accompanist" } accompanist_flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref = "accompanist" }
# Libraries # Libraries

View file

@ -34,6 +34,8 @@ inline fun <reified NODE : Node> Context.createNode(context: BuildContext, plugi
inline fun <reified NODE : Node> NodeFactoriesBindings.createNode(context: BuildContext, plugins: List<Plugin> = emptyList()): NODE { inline fun <reified NODE : Node> NodeFactoriesBindings.createNode(context: BuildContext, plugins: List<Plugin> = emptyList()): NODE {
val nodeClass = NODE::class.java val nodeClass = NODE::class.java
val nodeFactoryMap = nodeFactories() val nodeFactoryMap = nodeFactories()
// Note to developers: If you got the error below, make sure to build again after
// clearing the cache (sometimes several times) to let Dagger generate the NodeFactory.
val nodeFactory = nodeFactoryMap[nodeClass] ?: error("Cannot find NodeFactory for ${nodeClass.name}.") val nodeFactory = nodeFactoryMap[nodeClass] ?: error("Cannot find NodeFactory for ${nodeClass.name}.")
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")

View file

@ -18,9 +18,11 @@ package io.element.android.libraries.designsystem
object VectorIcons { object VectorIcons {
val Copy = R.drawable.ic_content_copy val Copy = R.drawable.ic_content_copy
val ArrowForward = R.drawable.ic_content_arrow_forward val Forward = R.drawable.ic_forward
val Delete = R.drawable.ic_baseline_delete_outline_24 val Delete = R.drawable.ic_delete
val Reply = R.drawable.ic_baseline_reply_24 val Reply = R.drawable.ic_reply
val Edit = R.drawable.ic_baseline_edit_24 val Edit = R.drawable.ic_edit
val DoorOpen = R.drawable.ic_door_open_24 val DoorOpen = R.drawable.ic_door_open_24
val DeveloperMode = R.drawable.ic_developer_mode
val ReportContent = R.drawable.ic_report_content
} }

View file

@ -0,0 +1,126 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.atomic.pages
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.R
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text
/**
* Page for onboarding screens, with content and optional footer.
*
* Ref: https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=133-5427&t=5SHVppfYzjvkEywR-0
* @param modifier Classical modifier.
* @param footer optional footer.
* @param content main content.
*/
@Composable
fun OnBoardingPage(
modifier: Modifier = Modifier,
footer: @Composable () -> Unit = {},
content: @Composable () -> Unit = {},
) {
Box(
modifier = modifier
.fillMaxSize()
) {
// BG
Image(
modifier = Modifier
.fillMaxSize(),
painter = painterResource(id = R.drawable.onboarding_bg),
contentScale = ContentScale.Crop,
contentDescription = null,
)
Column(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.padding(vertical = 16.dp),
) {
// Content
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 24.dp)
.fillMaxWidth(),
) {
content()
}
// Footer
Box(modifier = Modifier.padding(horizontal = 16.dp)) {
footer()
}
}
}
}
@Preview
@Composable
internal fun OnBoardingPageLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun OnBoardingPageDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
OnBoardingPage(
content = {
Box(
Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Content",
fontSize = 40.sp
)
}
},
footer = {
Box(
Modifier
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
text = "Footer",
fontSize = 40.sp
)
}
}
)
}

View file

@ -1,26 +0,0 @@
<!--
~ Copyright (c) 2022 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector android:height="24dp"
android:tint="#000000"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2L18,7L6,7v12zM8,9h8v10L8,19L8,9zM15.5,4l-1,-1h-5l-1,1L5,4v2h14L19,4z" />
</vector>

View file

@ -1,26 +0,0 @@
<!--
~ Copyright (c) 2022 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector android:height="24dp"
android:tint="#000000"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/white"
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M344,664L160,480L344,296L400,354L274,480L400,606L344,664ZM200,680L280,680L280,720L680,720L680,680L760,680L760,840Q760,873 736.5,896.5Q713,920 680,920L280,920Q247,920 223.5,896.5Q200,873 200,840L200,680ZM280,280L200,280L200,120Q200,87 223.5,63.5Q247,40 280,40L680,40Q713,40 736.5,63.5Q760,87 760,120L760,280L680,280L680,240L280,240L280,280ZM280,800L280,840Q280,840 280,840Q280,840 280,840L680,840Q680,840 680,840Q680,840 680,840L680,800L280,800ZM280,160L680,160L680,120Q680,120 680,120Q680,120 680,120L280,120Q280,120 280,120Q280,120 280,120L280,160ZM616,664L560,606L686,480L560,354L616,296L800,480L616,664ZM280,160L280,120Q280,120 280,120Q280,120 280,120L280,120Q280,120 280,120Q280,120 280,120L280,160L280,160ZM280,800L280,800L280,840Q280,840 280,840Q280,840 280,840L280,840Q280,840 280,840Q280,840 280,840L280,800Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M200,760L256,760L601,415L545,359L200,704L200,760ZM772,357L602,189L658,133Q681,110 714.5,110Q748,110 771,133L827,189Q850,212 851,244.5Q852,277 829,300L772,357ZM714,416L290,840L120,840L120,670L544,246L714,416ZM573,387L545,359L545,359L601,415L601,415L573,387Z"/>
</vector>

View file

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M120,760L120,600Q120,517 178.5,458.5Q237,400 320,400L688,400L544,256L600,200L840,440L600,680L544,624L688,480L320,480Q270,480 235,515Q200,550 200,600L200,760L120,760Z"/>
</vector>

View file

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M760,760L760,600Q760,550 725,515Q690,480 640,480L272,480L416,624L360,680L120,440L360,200L416,256L272,400L640,400Q723,400 781.5,458.5Q840,517 840,600L840,760L760,760Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,600Q497,600 508.5,588.5Q520,577 520,560Q520,543 508.5,531.5Q497,520 480,520Q463,520 451.5,531.5Q440,543 440,560Q440,577 451.5,588.5Q463,600 480,600ZM440,440L520,440L520,200L440,200L440,440ZM80,880L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L240,720L80,880ZM206,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,685L206,640ZM160,640L160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640L160,640Z"/>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

View file

@ -20,15 +20,23 @@ 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.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
//TODO add content
data class NotificationData( data class NotificationData(
val senderId: UserId, val senderId: UserId,
val eventId: EventId, val eventId: EventId,
val roomId: RoomId, val roomId: RoomId,
val senderAvatarUrl: String? = null, val senderAvatarUrl: String?,
val senderDisplayName: String? = null, val senderDisplayName: String?,
val roomAvatarUrl: String? = null, val roomAvatarUrl: String?,
val roomDisplayName: String?,
val isDirect: Boolean, val isDirect: Boolean,
val isEncrypted: Boolean, val isEncrypted: Boolean,
val isNoisy: Boolean, val isNoisy: Boolean,
val event: NotificationEvent,
)
data class NotificationEvent(
val timestamp: Long,
val content: String,
// For images for instance
val contentUrl: String?
) )

View file

@ -23,9 +23,9 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.notification.NotificationData
import org.matrix.rustcomponents.sdk.NotificationItem import org.matrix.rustcomponents.sdk.NotificationItem
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import javax.inject.Inject
class NotificationMapper @Inject constructor() { class NotificationMapper {
private val timelineEventMapper = TimelineEventMapper()
fun map(notificationItem: NotificationItem): NotificationData { fun map(notificationItem: NotificationItem): NotificationData {
return notificationItem.use { return notificationItem.use {
@ -36,9 +36,11 @@ class NotificationMapper @Inject constructor() {
senderAvatarUrl = it.senderAvatarUrl, senderAvatarUrl = it.senderAvatarUrl,
senderDisplayName = it.senderDisplayName, senderDisplayName = it.senderDisplayName,
roomAvatarUrl = it.roomAvatarUrl, roomAvatarUrl = it.roomAvatarUrl,
roomDisplayName = it.roomDisplayName,
isDirect = it.isDirect, isDirect = it.isDirect,
isEncrypted = it.isEncrypted.orFalse(), isEncrypted = it.isEncrypted.orFalse(),
isNoisy = it.isNoisy isNoisy = it.isNoisy,
event = it.event.use { event -> timelineEventMapper.map(event) }
) )
} }
} }

View file

@ -16,18 +16,13 @@
package io.element.android.libraries.matrix.impl.notification package io.element.android.libraries.matrix.impl.notification
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId 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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.notification.NotificationService
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
import java.io.File
class RustNotificationService( class RustNotificationService(
private val client: Client, private val client: Client,

View file

@ -0,0 +1,109 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.notification
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.NotificationEvent
import org.matrix.rustcomponents.sdk.MessageLikeEventContent
import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.StateEventContent
import org.matrix.rustcomponents.sdk.TimelineEvent
import org.matrix.rustcomponents.sdk.TimelineEventType
import org.matrix.rustcomponents.sdk.use
import javax.inject.Inject
class TimelineEventMapper @Inject constructor() {
fun map(timelineEvent: TimelineEvent): NotificationEvent {
return timelineEvent.use {
NotificationEvent(
timestamp = it.timestamp().toLong(),
content = it.eventType().toContent(),
contentUrl = null // TODO it.eventType().toContentUrl(),
)
}
}
}
private fun TimelineEventType.toContent(): String {
return when (this) {
is TimelineEventType.MessageLike -> content.toContent()
is TimelineEventType.State -> content.toContent()
}
}
private fun StateEventContent.toContent(): String {
return when (this) {
StateEventContent.PolicyRuleRoom -> "PolicyRuleRoom"
StateEventContent.PolicyRuleServer -> "PolicyRuleServer"
StateEventContent.PolicyRuleUser -> "PolicyRuleUser"
StateEventContent.RoomAliases -> "RoomAliases"
StateEventContent.RoomAvatar -> "RoomAvatar"
StateEventContent.RoomCanonicalAlias -> "RoomCanonicalAlias"
StateEventContent.RoomCreate -> "RoomCreate"
StateEventContent.RoomEncryption -> "RoomEncryption"
StateEventContent.RoomGuestAccess -> "RoomGuestAccess"
StateEventContent.RoomHistoryVisibility -> "RoomHistoryVisibility"
StateEventContent.RoomJoinRules -> "RoomJoinRules"
is StateEventContent.RoomMemberContent -> "$userId is now $membershipState"
StateEventContent.RoomName -> "RoomName"
StateEventContent.RoomPinnedEvents -> "RoomPinnedEvents"
StateEventContent.RoomPowerLevels -> "RoomPowerLevels"
StateEventContent.RoomServerAcl -> "RoomServerAcl"
StateEventContent.RoomThirdPartyInvite -> "RoomThirdPartyInvite"
StateEventContent.RoomTombstone -> "RoomTombstone"
StateEventContent.RoomTopic -> "RoomTopic"
StateEventContent.SpaceChild -> "SpaceChild"
StateEventContent.SpaceParent -> "SpaceParent"
}
}
private fun MessageLikeEventContent.toContent(): String {
return use {
when (it) {
MessageLikeEventContent.CallAnswer -> "CallAnswer"
MessageLikeEventContent.CallCandidates -> "CallCandidates"
MessageLikeEventContent.CallHangup -> "CallHangup"
MessageLikeEventContent.CallInvite -> "CallInvite"
MessageLikeEventContent.KeyVerificationAccept -> "KeyVerificationAccept"
MessageLikeEventContent.KeyVerificationCancel -> "KeyVerificationCancel"
MessageLikeEventContent.KeyVerificationDone -> "KeyVerificationDone"
MessageLikeEventContent.KeyVerificationKey -> "KeyVerificationKey"
MessageLikeEventContent.KeyVerificationMac -> "KeyVerificationMac"
MessageLikeEventContent.KeyVerificationReady -> "KeyVerificationReady"
MessageLikeEventContent.KeyVerificationStart -> "KeyVerificationStart"
is MessageLikeEventContent.ReactionContent -> "Reacted to ${it.relatedEventId.take(8)}"
MessageLikeEventContent.RoomEncrypted -> "RoomEncrypted"
is MessageLikeEventContent.RoomMessage -> it.messageType.toContent()
MessageLikeEventContent.RoomRedaction -> "RoomRedaction"
MessageLikeEventContent.Sticker -> "Sticker"
}
}
}
private fun MessageType.toContent(): String {
return when (this) {
is MessageType.Audio -> content.use { it.body }
is MessageType.Emote -> content.body
is MessageType.File -> content.use { it.body }
is MessageType.Image -> content.use { it.body }
is MessageType.Notice -> content.body
is MessageType.Text -> content.body
is MessageType.Video -> content.use { it.body }
}
}

View file

@ -35,6 +35,7 @@ dependencies {
implementation(libs.androidx.security.crypto) implementation(libs.androidx.security.crypto)
implementation(libs.network.retrofit) implementation(libs.network.retrofit)
implementation(libs.serialization.json) implementation(libs.serialization.json)
implementation(libs.coil)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.core) implementation(projects.libraries.core)
@ -42,6 +43,7 @@ dependencies {
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.network) implementation(projects.libraries.network)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
api(projects.libraries.pushproviders.api) api(projects.libraries.pushproviders.api)
api(projects.libraries.pushstore.api) api(projects.libraries.pushstore.api)
api(projects.libraries.push.api) api(projects.libraries.push.api)

View file

@ -24,6 +24,7 @@ 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.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.notification.NotificationEvent
import io.element.android.libraries.push.impl.log.pushLoggerTag import io.element.android.libraries.push.impl.log.pushLoggerTag
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
@ -44,9 +45,9 @@ class NotifiableEventResolver @Inject constructor(
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
// private val noticeEventFormatter: NoticeEventFormatter, // private val noticeEventFormatter: NoticeEventFormatter,
// private val displayableEventFormatter: DisplayableEventFormatter, // private val displayableEventFormatter: DisplayableEventFormatter,
private val clock: SystemClock,
private val matrixAuthenticationService: MatrixAuthenticationService, private val matrixAuthenticationService: MatrixAuthenticationService,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val clock: SystemClock,
) { ) {
suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? { suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? {
@ -80,14 +81,14 @@ class NotifiableEventResolver @Inject constructor(
editedEventId = null, editedEventId = null,
canBeReplaced = true, canBeReplaced = true,
noisy = isNoisy, noisy = isNoisy,
timestamp = clock.epochMillis(), timestamp = event.timestamp,
senderName = senderDisplayName, senderName = senderDisplayName,
senderId = senderId.value, senderId = senderId.value,
body = "Message ${eventId.value.take(8)}… in room ${roomId.value.take(8)}", body = event.content,
imageUriString = null, imageUriString = event.contentUrl,
threadId = null, threadId = null,
roomName = null, roomName = roomDisplayName,
roomIsDirect = false, roomIsDirect = isDirect,
roomAvatarPath = roomAvatarUrl, roomAvatarPath = roomAvatarUrl,
senderAvatarPath = senderAvatarUrl, senderAvatarPath = senderAvatarUrl,
soundName = null, soundName = null,
@ -97,18 +98,27 @@ class NotifiableEventResolver @Inject constructor(
isUpdated = false isUpdated = false
) )
} }
}
/** /**
* TODO This is a temporary method for EAx. * TODO This is a temporary method for EAx.
*/ */
private fun NotificationData?.orDefault(roomId: RoomId, eventId: EventId): NotificationData { private fun NotificationData?.orDefault(roomId: RoomId, eventId: EventId): NotificationData {
return this ?: NotificationData( return this ?: NotificationData(
eventId = eventId, eventId = eventId,
senderId = UserId("@user:domain"), senderId = UserId("@user:domain"),
roomId = roomId, roomId = roomId,
isNoisy = false, senderAvatarUrl = null,
isEncrypted = false, senderDisplayName = null,
isDirect = false roomAvatarUrl = null,
) roomDisplayName = null,
isNoisy = false,
isEncrypted = false,
isDirect = false,
event = NotificationEvent(
timestamp = clock.epochMillis(),
content = "Message ${eventId.value.take(8)}… in room ${roomId.value.take(8)}",
contentUrl = null
)
)
}
} }

View file

@ -19,9 +19,14 @@ package io.element.android.libraries.push.impl.notifications
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Build import android.os.Build
import androidx.annotation.WorkerThread
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.drawable.toBitmap
import coil.imageLoader
import coil.request.ImageRequest
import coil.transform.CircleCropTransformation
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -31,30 +36,24 @@ class NotificationBitmapLoader @Inject constructor(
/** /**
* Get icon of a room. * Get icon of a room.
* @param path mxc url
*/ */
@WorkerThread suspend fun getRoomBitmap(path: String?): Bitmap? {
fun getRoomBitmap(path: String?): Bitmap? {
if (path == null) { if (path == null) {
return null return null
} }
return loadRoomBitmap(path) return loadRoomBitmap(path)
} }
@WorkerThread private suspend fun loadRoomBitmap(path: String): Bitmap? {
private fun loadRoomBitmap(path: String): Bitmap? {
return try { return try {
null val imageRequest = ImageRequest.Builder(context)
/* TODO Notification .data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(1024)))
Glide.with(context) .build()
.asBitmap() val result = context.imageLoader.execute(imageRequest)
.load(path) result.drawable?.toBitmap()
.format(DecodeFormat.PREFER_ARGB_8888) } catch (e: Throwable) {
.signature(ObjectKey("room-icon-notification")) Timber.e(e, "Unable to load room bitmap")
.submit()
.get()
*/
} catch (e: Exception) {
Timber.e(e, "decodeFile failed")
null null
} }
} }
@ -62,9 +61,9 @@ class NotificationBitmapLoader @Inject constructor(
/** /**
* Get icon of a user. * Get icon of a user.
* Before Android P, this does nothing because the icon won't be used * Before Android P, this does nothing because the icon won't be used
* @param path mxc url
*/ */
@WorkerThread suspend fun getUserIcon(path: String?): IconCompat? {
fun getUserIcon(path: String?): IconCompat? {
if (path == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { if (path == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
return null return null
} }
@ -72,23 +71,17 @@ class NotificationBitmapLoader @Inject constructor(
return loadUserIcon(path) return loadUserIcon(path)
} }
@WorkerThread private suspend fun loadUserIcon(path: String): IconCompat? {
private fun loadUserIcon(path: String): IconCompat? {
return try { return try {
null val imageRequest = ImageRequest.Builder(context)
/* TODO Notification .data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(1024)))
val bitmap = Glide.with(context) .transformations(CircleCropTransformation())
.asBitmap() .build()
.load(path) val result = context.imageLoader.execute(imageRequest)
.transform(CircleCrop()) val bitmap = result.drawable?.toBitmap()
.format(DecodeFormat.PREFER_ARGB_8888) return bitmap?.let { IconCompat.createWithBitmap(it) }
.signature(ObjectKey("user-icon-notification")) } catch (e: Throwable) {
.submit() Timber.e(e, "Unable to load user bitmap")
.get()
IconCompat.createWithBitmap(bitmap)
*/
} catch (e: Exception) {
Timber.e(e, "decodeFile failed")
null null
} }
} }

View file

@ -16,28 +16,28 @@
package io.element.android.libraries.push.impl.notifications package io.element.android.libraries.push.impl.notifications
import android.content.Context
import android.os.Handler
import android.os.HandlerThread
import androidx.annotation.WorkerThread
import io.element.android.libraries.androidutils.throttler.FirstThrottler import io.element.android.libraries.androidutils.throttler.FirstThrottler
import io.element.android.libraries.core.cache.CircularCache import io.element.android.libraries.core.cache.CircularCache
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.RoomId 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.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.api.store.PushDataStore import io.element.android.libraries.push.api.store.PushDataStore
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom
import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -48,7 +48,6 @@ import javax.inject.Inject
*/ */
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
class NotificationDrawerManager @Inject constructor( class NotificationDrawerManager @Inject constructor(
@ApplicationContext context: Context,
private val pushDataStore: PushDataStore, private val pushDataStore: PushDataStore,
private val notifiableEventProcessor: NotifiableEventProcessor, private val notifiableEventProcessor: NotifiableEventProcessor,
private val notificationRenderer: NotificationRenderer, private val notificationRenderer: NotificationRenderer,
@ -56,17 +55,14 @@ class NotificationDrawerManager @Inject constructor(
private val filteredEventDetector: FilteredEventDetector, private val filteredEventDetector: FilteredEventDetector,
private val appNavigationStateService: AppNavigationStateService, private val appNavigationStateService: AppNavigationStateService,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val matrixAuthenticationService: MatrixAuthenticationService,
) { ) {
private val handlerThread: HandlerThread = HandlerThread("NotificationDrawerManager", Thread.MIN_PRIORITY)
private var backgroundHandler: Handler
/** /**
* Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events. * Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events.
*/ */
private val notificationState by lazy { createInitialNotificationState() } private val notificationState by lazy { createInitialNotificationState() }
private val avatarSize = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size)
private var currentAppNavigationState: AppNavigationState? = null private var currentAppNavigationState: AppNavigationState? = null
private val firstThrottler = FirstThrottler(200) private val firstThrottler = FirstThrottler(200)
@ -74,8 +70,6 @@ class NotificationDrawerManager @Inject constructor(
private var useCompleteNotificationFormat = true private var useCompleteNotificationFormat = true
init { init {
handlerThread.start()
backgroundHandler = Handler(handlerThread.looper)
// Observe application state // Observe application state
coroutineScope.launch { coroutineScope.launch {
appNavigationStateService.appNavigationStateFlow appNavigationStateService.appNavigationStateFlow
@ -193,30 +187,25 @@ class NotificationDrawerManager @Inject constructor(
notificationState.updateQueuedEvents(this) { queuedEvents, _ -> notificationState.updateQueuedEvents(this) { queuedEvents, _ ->
action(queuedEvents) action(queuedEvents)
} }
refreshNotificationDrawer() coroutineScope.refreshNotificationDrawer()
} }
private fun refreshNotificationDrawer() { private fun CoroutineScope.refreshNotificationDrawer() = launch {
// Implement last throttler // Implement last throttler
val canHandle = firstThrottler.canHandle() val canHandle = firstThrottler.canHandle()
Timber.v("refreshNotificationDrawer(), delay: ${canHandle.waitMillis()} ms") Timber.v("refreshNotificationDrawer(), delay: ${canHandle.waitMillis()} ms")
backgroundHandler.removeCallbacksAndMessages(null) withContext(dispatchers.io) {
delay(canHandle.waitMillis())
backgroundHandler.postDelayed( try {
{ refreshNotificationDrawerBg()
try { } catch (throwable: Throwable) {
refreshNotificationDrawerBg() // It can happen if for instance session has been destroyed. It's a bit ugly to try catch like this, but it's safer
} catch (throwable: Throwable) { Timber.w(throwable, "refreshNotificationDrawerBg failure")
// It can happen if for instance session has been destroyed. It's a bit ugly to try catch like this, but it's safer }
Timber.w(throwable, "refreshNotificationDrawerBg failure") }
}
},
canHandle.waitMillis()
)
} }
@WorkerThread private suspend fun refreshNotificationDrawerBg() {
private fun refreshNotificationDrawerBg() {
Timber.v("refreshNotificationDrawerBg()") Timber.v("refreshNotificationDrawerBg()")
val eventsToRender = notificationState.updateQueuedEvents(this) { queuedEvents, renderedEvents -> val eventsToRender = notificationState.updateQueuedEvents(this) { queuedEvents, renderedEvents ->
notifiableEventProcessor.process(queuedEvents.rawEvents(), currentAppNavigationState, renderedEvents).also { notifiableEventProcessor.process(queuedEvents.rawEvents(), currentAppNavigationState, renderedEvents).also {
@ -239,24 +228,34 @@ class NotificationDrawerManager @Inject constructor(
} }
} }
private fun renderEvents(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) { private suspend fun renderEvents(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) {
// Group by sessionId // Group by sessionId
val eventsForSessions = eventsToRender.groupBy { val eventsForSessions = eventsToRender.groupBy {
it.event.sessionId it.event.sessionId
} }
eventsForSessions.forEach { (sessionId, notifiableEvents) -> eventsForSessions.forEach { (sessionId, notifiableEvents) ->
// TODO EAx val user = session.getUserOrDefault(session.myUserId) val currentUser = tryOrNull(
// myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash onError = { Timber.e(it, "Unable to retrieve info for user ${sessionId.value}") },
val myUserDisplayName = "Todo display name" // user.toMatrixItem().getBestName() operation = {
// TODO EAx avatar URL val client = matrixAuthenticationService.restoreSession(sessionId).getOrNull()
val myUserAvatarUrl = null // session.contentUrlResolver().resolveThumbnail(
// contentUrl = user.avatarUrl, // myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash
// width = avatarSize, val myUserDisplayName = client?.loadUserDisplayName()?.getOrNull() ?: sessionId.value
// height = avatarSize, val userAvatarUrl = client?.loadUserAvatarURLString()?.getOrNull()
// method = ContentUrlResolver.ThumbnailMethod.SCALE MatrixUser(
//) userId = sessionId,
notificationRenderer.render(sessionId, myUserDisplayName, myUserAvatarUrl, useCompleteNotificationFormat, notifiableEvents) displayName = myUserDisplayName,
avatarUrl = userAvatarUrl
)
}
) ?: MatrixUser(
userId = sessionId,
displayName = sessionId.value,
avatarUrl = null
)
notificationRenderer.render(currentUser, useCompleteNotificationFormat, notifiableEvents)
} }
} }

View file

@ -18,7 +18,7 @@ package io.element.android.libraries.push.impl.notifications
import android.app.Notification import android.app.Notification
import io.element.android.libraries.matrix.api.core.RoomId 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.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
@ -34,10 +34,8 @@ class NotificationFactory @Inject constructor(
private val summaryGroupMessageCreator: SummaryGroupMessageCreator private val summaryGroupMessageCreator: SummaryGroupMessageCreator
) { ) {
fun Map<RoomId, ProcessedMessageEvents>.toNotifications( suspend fun Map<RoomId, ProcessedMessageEvents>.toNotifications(
sessionId: SessionId, currentUser: MatrixUser,
myUserDisplayName: String,
myUserAvatarUrl: String?
): List<RoomNotification> { ): List<RoomNotification> {
return map { (roomId, events) -> return map { (roomId, events) ->
when { when {
@ -45,11 +43,9 @@ class NotificationFactory @Inject constructor(
else -> { else -> {
val messageEvents = events.onlyKeptEvents().filterNot { it.isRedacted } val messageEvents = events.onlyKeptEvents().filterNot { it.isRedacted }
roomGroupMessageCreator.createRoomMessage( roomGroupMessageCreator.createRoomMessage(
sessionId = sessionId, currentUser = currentUser,
events = messageEvents, events = messageEvents,
roomId = roomId, roomId = roomId,
userDisplayName = myUserDisplayName,
userAvatarUrl = myUserAvatarUrl
) )
} }
} }
@ -99,7 +95,7 @@ class NotificationFactory @Inject constructor(
} }
fun createSummaryNotification( fun createSummaryNotification(
sessionId: SessionId, currentUser: MatrixUser,
roomNotifications: List<RoomNotification>, roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>, invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>, simpleNotifications: List<OneShotNotification>,
@ -112,7 +108,7 @@ class NotificationFactory @Inject constructor(
roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed
else -> SummaryNotification.Update( else -> SummaryNotification.Update(
summaryGroupMessageCreator.createSummaryNotification( summaryGroupMessageCreator.createSummaryNotification(
sessionId = sessionId, currentUser = currentUser,
roomNotifications = roomMeta, roomNotifications = roomMeta,
invitationNotifications = invitationMeta, invitationNotifications = invitationMeta,
simpleNotifications = simpleMeta, simpleNotifications = simpleMeta,

View file

@ -16,9 +16,8 @@
package io.element.android.libraries.push.impl.notifications package io.element.android.libraries.push.impl.notifications
import androidx.annotation.WorkerThread
import io.element.android.libraries.matrix.api.core.RoomId 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.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
@ -32,21 +31,18 @@ class NotificationRenderer @Inject constructor(
private val notificationFactory: NotificationFactory, private val notificationFactory: NotificationFactory,
) { ) {
@WorkerThread suspend fun render(
fun render( currentUser: MatrixUser,
sessionId: SessionId,
myUserDisplayName: String,
myUserAvatarUrl: String?,
useCompleteNotificationFormat: Boolean, useCompleteNotificationFormat: Boolean,
eventsToProcess: List<ProcessedEvent<NotifiableEvent>> eventsToProcess: List<ProcessedEvent<NotifiableEvent>>
) { ) {
val (roomEvents, simpleEvents, invitationEvents) = eventsToProcess.groupByType() val (roomEvents, simpleEvents, invitationEvents) = eventsToProcess.groupByType()
with(notificationFactory) { with(notificationFactory) {
val roomNotifications = roomEvents.toNotifications(sessionId, myUserDisplayName, myUserAvatarUrl) val roomNotifications = roomEvents.toNotifications(currentUser)
val invitationNotifications = invitationEvents.toNotifications() val invitationNotifications = invitationEvents.toNotifications()
val simpleNotifications = simpleEvents.toNotifications() val simpleNotifications = simpleEvents.toNotifications()
val summaryNotification = createSummaryNotification( val summaryNotification = createSummaryNotification(
sessionId = sessionId, currentUser = currentUser,
roomNotifications = roomNotifications, roomNotifications = roomNotifications,
invitationNotifications = invitationNotifications, invitationNotifications = invitationNotifications,
simpleNotifications = simpleNotifications, simpleNotifications = simpleNotifications,
@ -56,21 +52,27 @@ class NotificationRenderer @Inject constructor(
// Remove summary first to avoid briefly displaying it after dismissing the last notification // Remove summary first to avoid briefly displaying it after dismissing the last notification
if (summaryNotification == SummaryNotification.Removed) { if (summaryNotification == SummaryNotification.Removed) {
Timber.d("Removing summary notification") Timber.d("Removing summary notification")
notificationDisplayer.cancelNotificationMessage(null, notificationIdProvider.getSummaryNotificationId(sessionId)) notificationDisplayer.cancelNotificationMessage(
tag = null,
id = notificationIdProvider.getSummaryNotificationId(currentUser.userId)
)
} }
roomNotifications.forEach { wrapper -> roomNotifications.forEach { wrapper ->
when (wrapper) { when (wrapper) {
is RoomNotification.Removed -> { is RoomNotification.Removed -> {
Timber.d("Removing room messages notification ${wrapper.roomId}") Timber.d("Removing room messages notification ${wrapper.roomId}")
notificationDisplayer.cancelNotificationMessage(wrapper.roomId.value, notificationIdProvider.getRoomMessagesNotificationId(sessionId)) notificationDisplayer.cancelNotificationMessage(
tag = wrapper.roomId.value,
id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId)
)
} }
is RoomNotification.Message -> if (useCompleteNotificationFormat) { is RoomNotification.Message -> if (useCompleteNotificationFormat) {
Timber.d("Updating room messages notification ${wrapper.meta.roomId}") Timber.d("Updating room messages notification ${wrapper.meta.roomId}")
notificationDisplayer.showNotificationMessage( notificationDisplayer.showNotificationMessage(
wrapper.meta.roomId.value, tag = wrapper.meta.roomId.value,
notificationIdProvider.getRoomMessagesNotificationId(sessionId), id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId),
wrapper.notification notification = wrapper.notification
) )
} }
} }
@ -80,14 +82,17 @@ class NotificationRenderer @Inject constructor(
when (wrapper) { when (wrapper) {
is OneShotNotification.Removed -> { is OneShotNotification.Removed -> {
Timber.d("Removing invitation notification ${wrapper.key}") Timber.d("Removing invitation notification ${wrapper.key}")
notificationDisplayer.cancelNotificationMessage(wrapper.key, notificationIdProvider.getRoomInvitationNotificationId(sessionId)) notificationDisplayer.cancelNotificationMessage(
tag = wrapper.key,
id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId)
)
} }
is OneShotNotification.Append -> if (useCompleteNotificationFormat) { is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
Timber.d("Updating invitation notification ${wrapper.meta.key}") Timber.d("Updating invitation notification ${wrapper.meta.key}")
notificationDisplayer.showNotificationMessage( notificationDisplayer.showNotificationMessage(
wrapper.meta.key, tag = wrapper.meta.key,
notificationIdProvider.getRoomInvitationNotificationId(sessionId), id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId),
wrapper.notification notification = wrapper.notification
) )
} }
} }
@ -97,14 +102,17 @@ class NotificationRenderer @Inject constructor(
when (wrapper) { when (wrapper) {
is OneShotNotification.Removed -> { is OneShotNotification.Removed -> {
Timber.d("Removing simple notification ${wrapper.key}") Timber.d("Removing simple notification ${wrapper.key}")
notificationDisplayer.cancelNotificationMessage(wrapper.key, notificationIdProvider.getRoomEventNotificationId(sessionId)) notificationDisplayer.cancelNotificationMessage(
tag = wrapper.key,
id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId)
)
} }
is OneShotNotification.Append -> if (useCompleteNotificationFormat) { is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
Timber.d("Updating simple notification ${wrapper.meta.key}") Timber.d("Updating simple notification ${wrapper.meta.key}")
notificationDisplayer.showNotificationMessage( notificationDisplayer.showNotificationMessage(
wrapper.meta.key, tag = wrapper.meta.key,
notificationIdProvider.getRoomEventNotificationId(sessionId), id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId),
wrapper.notification notification = wrapper.notification
) )
} }
} }
@ -114,9 +122,9 @@ class NotificationRenderer @Inject constructor(
if (summaryNotification is SummaryNotification.Update) { if (summaryNotification is SummaryNotification.Update) {
Timber.d("Updating summary notification") Timber.d("Updating summary notification")
notificationDisplayer.showNotificationMessage( notificationDisplayer.showNotificationMessage(
null, tag = null,
notificationIdProvider.getSummaryNotificationId(sessionId), id = notificationIdProvider.getSummaryNotificationId(currentUser.userId),
summaryNotification.notification notification = summaryNotification.notification
) )
} }
} }

View file

@ -20,8 +20,9 @@ import android.graphics.Bitmap
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.Person import androidx.core.app.Person
import io.element.android.libraries.matrix.api.core.RoomId 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.user.MatrixUser
import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.strings.StringProvider
@ -36,24 +37,22 @@ class RoomGroupMessageCreator @Inject constructor(
private val notificationFactory: NotificationFactory private val notificationFactory: NotificationFactory
) { ) {
fun createRoomMessage( suspend fun createRoomMessage(
sessionId: SessionId, currentUser: MatrixUser,
events: List<NotifiableMessageEvent>, events: List<NotifiableMessageEvent>,
roomId: RoomId, roomId: RoomId,
userDisplayName: String,
userAvatarUrl: String?
): RoomNotification.Message { ): RoomNotification.Message {
val lastKnownRoomEvent = events.last() val lastKnownRoomEvent = events.last()
val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderName ?: "Room name (${roomId.value.take(8)}…)" val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderName ?: "Room name (${roomId.value.take(8)}…)"
val roomIsGroup = !lastKnownRoomEvent.roomIsDirect val roomIsGroup = !lastKnownRoomEvent.roomIsDirect
val style = NotificationCompat.MessagingStyle( val style = NotificationCompat.MessagingStyle(
Person.Builder() Person.Builder()
.setName(userDisplayName) .setName(currentUser.displayName?.annotateForDebug(50))
.setIcon(bitmapLoader.getUserIcon(userAvatarUrl)) .setIcon(bitmapLoader.getUserIcon(currentUser.avatarUrl))
.setKey(lastKnownRoomEvent.sessionId.value) .setKey(lastKnownRoomEvent.sessionId.value)
.build() .build()
).also { ).also {
it.conversationTitle = roomName.takeIf { roomIsGroup } it.conversationTitle = roomName.takeIf { roomIsGroup }?.annotateForDebug(51)
it.isGroupConversation = roomIsGroup it.isGroupConversation = roomIsGroup
it.addMessagesFromEvents(events) it.addMessagesFromEvents(events)
} }
@ -80,7 +79,7 @@ class RoomGroupMessageCreator @Inject constructor(
notificationFactory.createMessagesListNotification( notificationFactory.createMessagesListNotification(
style, style,
RoomEventGroupInfo( RoomEventGroupInfo(
sessionId = sessionId, sessionId = currentUser.userId,
roomId = roomId, roomId = roomId,
roomDisplayName = roomName, roomDisplayName = roomName,
isDirect = !roomIsGroup, isDirect = !roomIsGroup,
@ -99,13 +98,13 @@ class RoomGroupMessageCreator @Inject constructor(
) )
} }
private fun NotificationCompat.MessagingStyle.addMessagesFromEvents(events: List<NotifiableMessageEvent>) { private suspend fun NotificationCompat.MessagingStyle.addMessagesFromEvents(events: List<NotifiableMessageEvent>) {
events.forEach { event -> events.forEach { event ->
val senderPerson = if (event.outGoingMessage) { val senderPerson = if (event.outGoingMessage) {
null null
} else { } else {
Person.Builder() Person.Builder()
.setName(event.senderName) .setName(event.senderName?.annotateForDebug(70))
.setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath)) .setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath))
.setKey(event.senderId) .setKey(event.senderId)
.build() .build()
@ -117,7 +116,11 @@ class RoomGroupMessageCreator @Inject constructor(
senderPerson senderPerson
) )
else -> { else -> {
val message = NotificationCompat.MessagingStyle.Message(event.body, event.timestamp, senderPerson).also { message -> val message = NotificationCompat.MessagingStyle.Message(
event.body?.annotateForDebug(71),
event.timestamp,
senderPerson
).also { message ->
event.imageUri?.let { event.imageUri?.let {
message.setData("image/", it) message.setData("image/", it)
} }
@ -168,7 +171,7 @@ class RoomGroupMessageCreator @Inject constructor(
} }
} }
private fun getRoomBitmap(events: List<NotifiableMessageEvent>): Bitmap? { private suspend fun getRoomBitmap(events: List<NotifiableMessageEvent>): Bitmap? {
// Use the last event (most recent?) // Use the last event (most recent?)
return events.lastOrNull() return events.lastOrNull()
?.roomAvatarPath ?.roomAvatarPath

View file

@ -18,8 +18,9 @@ package io.element.android.libraries.push.impl.notifications
import android.app.Notification import android.app.Notification
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject import javax.inject.Inject
@ -40,20 +41,20 @@ import javax.inject.Inject
*/ */
class SummaryGroupMessageCreator @Inject constructor( class SummaryGroupMessageCreator @Inject constructor(
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val notificationFactory: NotificationFactory private val notificationFactory: NotificationFactory,
) { ) {
fun createSummaryNotification( fun createSummaryNotification(
sessionId: SessionId, currentUser: MatrixUser,
roomNotifications: List<RoomNotification.Message.Meta>, roomNotifications: List<RoomNotification.Message.Meta>,
invitationNotifications: List<OneShotNotification.Append.Meta>, invitationNotifications: List<OneShotNotification.Append.Meta>,
simpleNotifications: List<OneShotNotification.Append.Meta>, simpleNotifications: List<OneShotNotification.Append.Meta>,
useCompleteNotificationFormat: Boolean useCompleteNotificationFormat: Boolean
): Notification { ): Notification {
val summaryInboxStyle = NotificationCompat.InboxStyle().also { style -> val summaryInboxStyle = NotificationCompat.InboxStyle().also { style ->
roomNotifications.forEach { style.addLine(it.summaryLine) } roomNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(40)) }
invitationNotifications.forEach { style.addLine(it.summaryLine) } invitationNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(41)) }
simpleNotifications.forEach { style.addLine(it.summaryLine) } simpleNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(42)) }
} }
val summaryIsNoisy = roomNotifications.any { it.shouldBing } || val summaryIsNoisy = roomNotifications.any { it.shouldBing } ||
@ -69,12 +70,13 @@ class SummaryGroupMessageCreator @Inject constructor(
// FIXME roomIdToEventMap.size is not correct, this is the number of rooms // FIXME roomIdToEventMap.size is not correct, this is the number of rooms
val nbEvents = roomNotifications.size + simpleNotifications.size val nbEvents = roomNotifications.size + simpleNotifications.size
val sumTitle = stringProvider.getQuantityString(R.plurals.notification_compat_summary_title, nbEvents, nbEvents) val sumTitle = stringProvider.getQuantityString(R.plurals.notification_compat_summary_title, nbEvents, nbEvents)
summaryInboxStyle.setBigContentTitle(sumTitle) summaryInboxStyle.setBigContentTitle(sumTitle.annotateForDebug(43))
// TODO get latest event? //.setSummaryText(stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages, nbEvents, nbEvents).annotateForDebug(44))
.setSummaryText(stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages, nbEvents, nbEvents)) // Use account name now, for multi-session
.setSummaryText(currentUser.userId.value.annotateForDebug(44))
return if (useCompleteNotificationFormat) { return if (useCompleteNotificationFormat) {
notificationFactory.createSummaryListNotification( notificationFactory.createSummaryListNotification(
sessionId, currentUser,
summaryInboxStyle, summaryInboxStyle,
sumTitle, sumTitle,
noisy = summaryIsNoisy, noisy = summaryIsNoisy,
@ -82,7 +84,7 @@ class SummaryGroupMessageCreator @Inject constructor(
) )
} else { } else {
processSimpleGroupSummary( processSimpleGroupSummary(
sessionId, currentUser,
summaryIsNoisy, summaryIsNoisy,
messageCount, messageCount,
simpleNotifications.size, simpleNotifications.size,
@ -94,7 +96,7 @@ class SummaryGroupMessageCreator @Inject constructor(
} }
private fun processSimpleGroupSummary( private fun processSimpleGroupSummary(
sessionId: SessionId, currentUser: MatrixUser,
summaryIsNoisy: Boolean, summaryIsNoisy: Boolean,
messageEventsCount: Int, messageEventsCount: Int,
simpleEventsCount: Int, simpleEventsCount: Int,
@ -167,7 +169,7 @@ class SummaryGroupMessageCreator @Inject constructor(
} }
} }
return notificationFactory.createSummaryListNotification( return notificationFactory.createSummaryListNotification(
sessionId = sessionId, currentUser = currentUser,
style = null, style = null,
compatSummary = privacyTitle, compatSummary = privacyTitle,
noisy = summaryIsNoisy, noisy = summaryIsNoisy,

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications.debug
fun CharSequence.annotateForDebug(@Suppress("UNUSED_PARAMETER") prefix: Int): CharSequence {
return this // "$prefix-$this"
}

View file

@ -26,11 +26,12 @@ import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug
import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory
@ -84,16 +85,16 @@ class NotificationFactory @Inject constructor(
// ID of the corresponding shortcut, for conversation features under API 30+ // ID of the corresponding shortcut, for conversation features under API 30+
.setShortcutId(roomInfo.roomId.value) .setShortcutId(roomInfo.roomId.value)
// Title for API < 16 devices. // Title for API < 16 devices.
.setContentTitle(roomInfo.roomDisplayName) .setContentTitle(roomInfo.roomDisplayName.annotateForDebug(1))
// Content for API < 16 devices. // Content for API < 16 devices.
.setContentText(stringProvider.getString(R.string.notification_new_messages)) .setContentText(stringProvider.getString(R.string.notification_new_messages).annotateForDebug(2))
// Number of new notifications for API <24 (M and below) devices. // Number of new notifications for API <24 (M and below) devices.
.setSubText( .setSubText(
stringProvider.getQuantityString( stringProvider.getQuantityString(
R.plurals.notification_new_messages_for_room, R.plurals.notification_new_messages_for_room,
messageStyle.messages.size, messageStyle.messages.size,
messageStyle.messages.size messageStyle.messages.size
) ).annotateForDebug(3)
) )
// Auto-bundling is enabled for 4 or more notifications on API 24+ (N+) // Auto-bundling is enabled for 4 or more notifications on API 24+ (N+)
// devices and all Wear devices. But we want a custom grouping, so we specify the groupID // devices and all Wear devices. But we want a custom grouping, so we specify the groupID
@ -135,7 +136,7 @@ class NotificationFactory @Inject constructor(
} }
setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId)) setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId))
} }
.setTicker(tickerText) .setTicker(tickerText.annotateForDebug(4))
.build() .build()
} }
@ -147,8 +148,8 @@ class NotificationFactory @Inject constructor(
val channelId = notificationChannels.getChannelIdForMessage(inviteNotifiableEvent.noisy) val channelId = notificationChannels.getChannelIdForMessage(inviteNotifiableEvent.noisy)
return NotificationCompat.Builder(context, channelId) return NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setContentTitle(inviteNotifiableEvent.roomName ?: buildMeta.applicationName) .setContentTitle((inviteNotifiableEvent.roomName ?: buildMeta.applicationName).annotateForDebug(5))
.setContentText(inviteNotifiableEvent.description) .setContentText(inviteNotifiableEvent.description.annotateForDebug(6))
.setGroup(inviteNotifiableEvent.sessionId.value) .setGroup(inviteNotifiableEvent.sessionId.value)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon) .setSmallIcon(smallIcon)
@ -196,8 +197,8 @@ class NotificationFactory @Inject constructor(
val channelId = notificationChannels.getChannelIdForMessage(simpleNotifiableEvent.noisy) val channelId = notificationChannels.getChannelIdForMessage(simpleNotifiableEvent.noisy)
return NotificationCompat.Builder(context, channelId) return NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setContentTitle(buildMeta.applicationName) .setContentTitle(buildMeta.applicationName.annotateForDebug(7))
.setContentText(simpleNotifiableEvent.description) .setContentText(simpleNotifiableEvent.description.annotateForDebug(8))
.setGroup(simpleNotifiableEvent.sessionId.value) .setGroup(simpleNotifiableEvent.sessionId.value)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon) .setSmallIcon(smallIcon)
@ -226,7 +227,7 @@ class NotificationFactory @Inject constructor(
* Create the summary notification. * Create the summary notification.
*/ */
fun createSummaryListNotification( fun createSummaryListNotification(
sessionId: SessionId, currentUser: MatrixUser,
style: NotificationCompat.InboxStyle?, style: NotificationCompat.InboxStyle?,
compatSummary: String, compatSummary: String,
noisy: Boolean, noisy: Boolean,
@ -240,12 +241,12 @@ class NotificationFactory @Inject constructor(
// used in compat < N, after summary is built based on child notifications // used in compat < N, after summary is built based on child notifications
.setWhen(lastMessageTimestamp) .setWhen(lastMessageTimestamp)
.setStyle(style) .setStyle(style)
.setContentTitle(sessionId.value) .setContentTitle(currentUser.userId.value.annotateForDebug(9))
.setCategory(NotificationCompat.CATEGORY_MESSAGE) .setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setSmallIcon(smallIcon) .setSmallIcon(smallIcon)
// set content text to support devices running API level < 24 // set content text to support devices running API level < 24
.setContentText(compatSummary) .setContentText(compatSummary.annotateForDebug(10))
.setGroup(sessionId.value) .setGroup(currentUser.userId.value)
// set this notification as the summary for the group // set this notification as the summary for the group
.setGroupSummary(true) .setGroupSummary(true)
.setColor(accentColor) .setColor(accentColor)
@ -264,8 +265,8 @@ class NotificationFactory @Inject constructor(
priority = NotificationCompat.PRIORITY_LOW priority = NotificationCompat.PRIORITY_LOW
} }
} }
.setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(sessionId)) .setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(currentUser.userId))
.setDeleteIntent(pendingIntentFactory.createDismissSummaryPendingIntent(sessionId)) .setDeleteIntent(pendingIntentFactory.createDismissSummaryPendingIntent(currentUser.userId))
.build() .build()
} }

Some files were not shown because too many files have changed in this diff Show more