Merge branch 'develop' into fix-6232
This commit is contained in:
commit
aeb42f72ca
199 changed files with 2907 additions and 2420 deletions
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fa16f659aa3e7d05fa03a51d52faddc0c40c3ab52231687f8c6c8a4ba81ff6f0
|
||||
size 219813
|
||||
oid sha256:460ddd253f4029b29edde9d858237204acb55aca7e13e92bc691ea71ca34c53e
|
||||
size 237462
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:72fb457dc50bf1a2261502fc1da15c01ab415344e9070354d38dc7b74234d790
|
||||
size 232095
|
||||
oid sha256:a0b76eee73be6a2ba58eb12883477ebce7daf039b6c60637e263a478a0bc68fe
|
||||
size 250668
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:24cfe760717881ee71f36fae1fb201e74b2c32a2f9a5aef71ef21dab69ea5366
|
||||
size 233212
|
||||
oid sha256:d12abdfa2a2d7d1943e0e377279134b44de0ad5d8fb12b09e23c2083b728989f
|
||||
size 251951
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c846cd10b83361c368bdbb31ed6220cc22693c3cbf52791fb369841af1e9ea48
|
||||
size 327701
|
||||
oid sha256:afb0295b04f302c25f40774562e7d5b2bb668c4cf1158b521ae9b50a35a58d2b
|
||||
size 322068
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:05b35fedbd53dec2cc5c4c211a8db1a56055963de69425ddae2cab5aff7e3e75
|
||||
size 325750
|
||||
oid sha256:3b94a6d004999869b8650559a70a1427882408b242c9b47788e56320aaeef34c
|
||||
size 320114
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8d98e64eda5d6333067ccc599e99636f618331397207bb7534595e2756edb75e
|
||||
size 309312
|
||||
oid sha256:06cebaaf9e0e4f2b69231ab2b866652419e70df50b0abb68288e08f748ed9b76
|
||||
size 301985
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5ccbf1234065b182939f001eb65eca0a62adae41a2d91ef0307d27b059407178
|
||||
size 309084
|
||||
oid sha256:baf841165dfd7c6315dc7bd82d1be8935976d0a9a70e83f4d70e23a2389dab95
|
||||
size 301760
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a699170cabca6fb912d034a588b45961485afe6ef6d2c24f0ab79f10ae00c168
|
||||
size 85629
|
||||
oid sha256:40f0940bd8a5ddee96ea2aac01d9672478fc15044621bfb10f5f0b20d61f035d
|
||||
size 93402
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dd4b2a40fcf02d6db29cb0bc371d93236b4a0be6d4446bab86358692cddb53f5
|
||||
size 91692
|
||||
oid sha256:48d8c1bef4a59554649fab33aa716ca2e9fe24f29a6b7e0dae9c404afedd6695
|
||||
size 99735
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -155,12 +155,10 @@ private fun getSemanticColors(): ImmutableMap<String, Color> {
|
|||
"gradientActionStop2" to gradientActionStop2,
|
||||
"gradientActionStop3" to gradientActionStop3,
|
||||
"gradientActionStop4" to gradientActionStop4,
|
||||
"gradientCriticalStop1" to gradientCriticalStop1,
|
||||
"gradientCriticalStop2" to gradientCriticalStop2,
|
||||
"gradientInfoStop1" to gradientInfoStop1,
|
||||
"gradientInfoStop2" to gradientInfoStop2,
|
||||
"gradientInfoStop3" to gradientInfoStop3,
|
||||
"gradientInfoStop4" to gradientInfoStop4,
|
||||
"gradientInfoStop5" to gradientInfoStop5,
|
||||
"gradientInfoStop6" to gradientInfoStop6,
|
||||
"gradientSubtleStop1" to gradientSubtleStop1,
|
||||
"gradientSubtleStop2" to gradientSubtleStop2,
|
||||
"gradientSubtleStop3" to gradientSubtleStop3,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
|
|
@ -25,6 +25,9 @@ object CompoundIcons {
|
|||
@Composable fun Admin(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_admin)
|
||||
}
|
||||
@Composable fun AdvancedSettings(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_advanced_settings)
|
||||
}
|
||||
@Composable fun ArrowDown(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_arrow_down)
|
||||
}
|
||||
|
|
@ -64,6 +67,9 @@ object CompoundIcons {
|
|||
@Composable fun Bold(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_bold)
|
||||
}
|
||||
@Composable fun Bug(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_bug)
|
||||
}
|
||||
@Composable fun Calendar(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_calendar)
|
||||
}
|
||||
|
|
@ -460,6 +466,9 @@ object CompoundIcons {
|
|||
@Composable fun RaisedHandSolid(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_raised_hand_solid)
|
||||
}
|
||||
@Composable fun ReOrder(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_re_order)
|
||||
}
|
||||
@Composable fun Reaction(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_reaction)
|
||||
}
|
||||
|
|
@ -478,9 +487,18 @@ object CompoundIcons {
|
|||
@Composable fun Room(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_room)
|
||||
}
|
||||
@Composable fun RotateLeft(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_rotate_left)
|
||||
}
|
||||
@Composable fun RotateRight(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_rotate_right)
|
||||
}
|
||||
@Composable fun Search(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_search)
|
||||
}
|
||||
@Composable fun Section(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_section)
|
||||
}
|
||||
@Composable fun Send(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_send)
|
||||
}
|
||||
|
|
@ -535,6 +553,12 @@ object CompoundIcons {
|
|||
@Composable fun Sticker(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_sticker)
|
||||
}
|
||||
@Composable fun Stop(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_stop)
|
||||
}
|
||||
@Composable fun StopSolid(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_stop_solid)
|
||||
}
|
||||
@Composable fun Strikethrough(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_strikethrough)
|
||||
}
|
||||
|
|
@ -550,6 +574,9 @@ object CompoundIcons {
|
|||
@Composable fun TextFormatting(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_text_formatting)
|
||||
}
|
||||
@Composable fun Theme(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_theme)
|
||||
}
|
||||
@Composable fun Threads(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_threads)
|
||||
}
|
||||
|
|
@ -559,6 +586,12 @@ object CompoundIcons {
|
|||
@Composable fun Time(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_time)
|
||||
}
|
||||
@Composable fun Translate(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_translate)
|
||||
}
|
||||
@Composable fun Tree(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_tree)
|
||||
}
|
||||
@Composable fun Underline(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_underline)
|
||||
}
|
||||
|
|
@ -607,6 +640,9 @@ object CompoundIcons {
|
|||
@Composable fun VideoCallOffSolid(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_video_call_off_solid)
|
||||
}
|
||||
@Composable fun VideoCallOutgoingSolid(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_video_call_outgoing_solid)
|
||||
}
|
||||
@Composable fun VideoCallSolid(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_video_call_solid)
|
||||
}
|
||||
|
|
@ -619,6 +655,15 @@ object CompoundIcons {
|
|||
@Composable fun VoiceCall(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_voice_call)
|
||||
}
|
||||
@Composable fun VoiceCallDeclinedSolid(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_declined_solid)
|
||||
}
|
||||
@Composable fun VoiceCallMissedSolid(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_missed_solid)
|
||||
}
|
||||
@Composable fun VoiceCallOutgoingSolid(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_outgoing_solid)
|
||||
}
|
||||
@Composable fun VoiceCallSolid(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_solid)
|
||||
}
|
||||
|
|
@ -643,9 +688,16 @@ object CompoundIcons {
|
|||
@Composable fun Windows(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_windows)
|
||||
}
|
||||
@Composable fun ZoomIn(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_zoom_in)
|
||||
}
|
||||
@Composable fun ZoomOut(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_zoom_out)
|
||||
}
|
||||
|
||||
val all @Composable get() = persistentListOf<ImageVector>(
|
||||
Admin(),
|
||||
AdvancedSettings(),
|
||||
ArrowDown(),
|
||||
ArrowLeft(),
|
||||
ArrowRight(),
|
||||
|
|
@ -659,6 +711,7 @@ object CompoundIcons {
|
|||
BackspaceSolid(),
|
||||
Block(),
|
||||
Bold(),
|
||||
Bug(),
|
||||
Calendar(),
|
||||
Chart(),
|
||||
Chat(),
|
||||
|
|
@ -791,13 +844,17 @@ object CompoundIcons {
|
|||
QrCode(),
|
||||
Quote(),
|
||||
RaisedHandSolid(),
|
||||
ReOrder(),
|
||||
Reaction(),
|
||||
ReactionAdd(),
|
||||
ReactionSolid(),
|
||||
Reply(),
|
||||
Restart(),
|
||||
Room(),
|
||||
RotateLeft(),
|
||||
RotateRight(),
|
||||
Search(),
|
||||
Section(),
|
||||
Send(),
|
||||
SendSolid(),
|
||||
Settings(),
|
||||
|
|
@ -816,14 +873,19 @@ object CompoundIcons {
|
|||
Spotlight(),
|
||||
SpotlightView(),
|
||||
Sticker(),
|
||||
Stop(),
|
||||
StopSolid(),
|
||||
Strikethrough(),
|
||||
SwitchCameraSolid(),
|
||||
TakePhoto(),
|
||||
TakePhotoSolid(),
|
||||
TextFormatting(),
|
||||
Theme(),
|
||||
Threads(),
|
||||
ThreadsSolid(),
|
||||
Time(),
|
||||
Translate(),
|
||||
Tree(),
|
||||
Underline(),
|
||||
Unknown(),
|
||||
UnknownSolid(),
|
||||
|
|
@ -840,10 +902,14 @@ object CompoundIcons {
|
|||
VideoCallMissedSolid(),
|
||||
VideoCallOff(),
|
||||
VideoCallOffSolid(),
|
||||
VideoCallOutgoingSolid(),
|
||||
VideoCallSolid(),
|
||||
VisibilityOff(),
|
||||
VisibilityOn(),
|
||||
VoiceCall(),
|
||||
VoiceCallDeclinedSolid(),
|
||||
VoiceCallMissedSolid(),
|
||||
VoiceCallOutgoingSolid(),
|
||||
VoiceCallSolid(),
|
||||
VolumeOff(),
|
||||
VolumeOffSolid(),
|
||||
|
|
@ -852,10 +918,13 @@ object CompoundIcons {
|
|||
Warning(),
|
||||
WebBrowser(),
|
||||
Windows(),
|
||||
ZoomIn(),
|
||||
ZoomOut(),
|
||||
)
|
||||
|
||||
val allResIds get() = persistentListOf(
|
||||
R.drawable.ic_compound_admin,
|
||||
R.drawable.ic_compound_advanced_settings,
|
||||
R.drawable.ic_compound_arrow_down,
|
||||
R.drawable.ic_compound_arrow_left,
|
||||
R.drawable.ic_compound_arrow_right,
|
||||
|
|
@ -869,6 +938,7 @@ object CompoundIcons {
|
|||
R.drawable.ic_compound_backspace_solid,
|
||||
R.drawable.ic_compound_block,
|
||||
R.drawable.ic_compound_bold,
|
||||
R.drawable.ic_compound_bug,
|
||||
R.drawable.ic_compound_calendar,
|
||||
R.drawable.ic_compound_chart,
|
||||
R.drawable.ic_compound_chat,
|
||||
|
|
@ -1001,13 +1071,17 @@ object CompoundIcons {
|
|||
R.drawable.ic_compound_qr_code,
|
||||
R.drawable.ic_compound_quote,
|
||||
R.drawable.ic_compound_raised_hand_solid,
|
||||
R.drawable.ic_compound_re_order,
|
||||
R.drawable.ic_compound_reaction,
|
||||
R.drawable.ic_compound_reaction_add,
|
||||
R.drawable.ic_compound_reaction_solid,
|
||||
R.drawable.ic_compound_reply,
|
||||
R.drawable.ic_compound_restart,
|
||||
R.drawable.ic_compound_room,
|
||||
R.drawable.ic_compound_rotate_left,
|
||||
R.drawable.ic_compound_rotate_right,
|
||||
R.drawable.ic_compound_search,
|
||||
R.drawable.ic_compound_section,
|
||||
R.drawable.ic_compound_send,
|
||||
R.drawable.ic_compound_send_solid,
|
||||
R.drawable.ic_compound_settings,
|
||||
|
|
@ -1026,14 +1100,19 @@ object CompoundIcons {
|
|||
R.drawable.ic_compound_spotlight,
|
||||
R.drawable.ic_compound_spotlight_view,
|
||||
R.drawable.ic_compound_sticker,
|
||||
R.drawable.ic_compound_stop,
|
||||
R.drawable.ic_compound_stop_solid,
|
||||
R.drawable.ic_compound_strikethrough,
|
||||
R.drawable.ic_compound_switch_camera_solid,
|
||||
R.drawable.ic_compound_take_photo,
|
||||
R.drawable.ic_compound_take_photo_solid,
|
||||
R.drawable.ic_compound_text_formatting,
|
||||
R.drawable.ic_compound_theme,
|
||||
R.drawable.ic_compound_threads,
|
||||
R.drawable.ic_compound_threads_solid,
|
||||
R.drawable.ic_compound_time,
|
||||
R.drawable.ic_compound_translate,
|
||||
R.drawable.ic_compound_tree,
|
||||
R.drawable.ic_compound_underline,
|
||||
R.drawable.ic_compound_unknown,
|
||||
R.drawable.ic_compound_unknown_solid,
|
||||
|
|
@ -1050,10 +1129,14 @@ object CompoundIcons {
|
|||
R.drawable.ic_compound_video_call_missed_solid,
|
||||
R.drawable.ic_compound_video_call_off,
|
||||
R.drawable.ic_compound_video_call_off_solid,
|
||||
R.drawable.ic_compound_video_call_outgoing_solid,
|
||||
R.drawable.ic_compound_video_call_solid,
|
||||
R.drawable.ic_compound_visibility_off,
|
||||
R.drawable.ic_compound_visibility_on,
|
||||
R.drawable.ic_compound_voice_call,
|
||||
R.drawable.ic_compound_voice_call_declined_solid,
|
||||
R.drawable.ic_compound_voice_call_missed_solid,
|
||||
R.drawable.ic_compound_voice_call_outgoing_solid,
|
||||
R.drawable.ic_compound_voice_call_solid,
|
||||
R.drawable.ic_compound_volume_off,
|
||||
R.drawable.ic_compound_volume_off_solid,
|
||||
|
|
@ -1062,5 +1145,7 @@ object CompoundIcons {
|
|||
R.drawable.ic_compound_warning,
|
||||
R.drawable.ic_compound_web_browser,
|
||||
R.drawable.ic_compound_windows,
|
||||
R.drawable.ic_compound_zoom_in,
|
||||
R.drawable.ic_compound_zoom_out,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
|
|
@ -121,18 +121,14 @@ data class SemanticColors(
|
|||
val gradientActionStop3: Color,
|
||||
/** Background gradient stop for super and send buttons */
|
||||
val gradientActionStop4: Color,
|
||||
/** Subtle background gradient stop for critical */
|
||||
val gradientCriticalStop1: Color,
|
||||
/** Subtle background gradient stop for critical */
|
||||
val gradientCriticalStop2: Color,
|
||||
/** Subtle background gradient stop for info */
|
||||
val gradientInfoStop1: Color,
|
||||
/** Subtle background gradient stop for info */
|
||||
val gradientInfoStop2: Color,
|
||||
/** Subtle background gradient stop for info */
|
||||
val gradientInfoStop3: Color,
|
||||
/** Subtle background gradient stop for info */
|
||||
val gradientInfoStop4: Color,
|
||||
/** Subtle background gradient stop for info */
|
||||
val gradientInfoStop5: Color,
|
||||
/** Subtle background gradient stop for info */
|
||||
val gradientInfoStop6: Color,
|
||||
/** Subtle background gradient stop for message highlight and bloom */
|
||||
val gradientSubtleStop1: Color,
|
||||
/** Subtle background gradient stop for message highlight and bloom */
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
|
|
@ -73,12 +73,10 @@ val compoundColorsDark = SemanticColors(
|
|||
gradientActionStop2 = DarkColorTokens.colorGreen900,
|
||||
gradientActionStop3 = DarkColorTokens.colorGreen700,
|
||||
gradientActionStop4 = DarkColorTokens.colorGreen500,
|
||||
gradientInfoStop1 = DarkColorTokens.colorAlphaBlue500,
|
||||
gradientInfoStop2 = DarkColorTokens.colorAlphaBlue400,
|
||||
gradientInfoStop3 = DarkColorTokens.colorAlphaBlue300,
|
||||
gradientInfoStop4 = DarkColorTokens.colorAlphaBlue200,
|
||||
gradientInfoStop5 = DarkColorTokens.colorAlphaBlue100,
|
||||
gradientInfoStop6 = DarkColorTokens.colorTransparent,
|
||||
gradientCriticalStop1 = DarkColorTokens.colorRed200,
|
||||
gradientCriticalStop2 = DarkColorTokens.colorThemeBg,
|
||||
gradientInfoStop1 = DarkColorTokens.colorBlue200,
|
||||
gradientInfoStop2 = DarkColorTokens.colorThemeBg,
|
||||
gradientSubtleStop1 = DarkColorTokens.colorAlphaGreen500,
|
||||
gradientSubtleStop2 = DarkColorTokens.colorAlphaGreen400,
|
||||
gradientSubtleStop3 = DarkColorTokens.colorAlphaGreen300,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
|
|
@ -73,12 +73,10 @@ val compoundColorsHcDark = SemanticColors(
|
|||
gradientActionStop2 = DarkHcColorTokens.colorGreen900,
|
||||
gradientActionStop3 = DarkHcColorTokens.colorGreen700,
|
||||
gradientActionStop4 = DarkHcColorTokens.colorGreen500,
|
||||
gradientInfoStop1 = DarkHcColorTokens.colorAlphaBlue500,
|
||||
gradientInfoStop2 = DarkHcColorTokens.colorAlphaBlue400,
|
||||
gradientInfoStop3 = DarkHcColorTokens.colorAlphaBlue300,
|
||||
gradientInfoStop4 = DarkHcColorTokens.colorAlphaBlue200,
|
||||
gradientInfoStop5 = DarkHcColorTokens.colorAlphaBlue100,
|
||||
gradientInfoStop6 = DarkHcColorTokens.colorTransparent,
|
||||
gradientCriticalStop1 = DarkHcColorTokens.colorRed200,
|
||||
gradientCriticalStop2 = DarkHcColorTokens.colorThemeBg,
|
||||
gradientInfoStop1 = DarkHcColorTokens.colorBlue200,
|
||||
gradientInfoStop2 = DarkHcColorTokens.colorThemeBg,
|
||||
gradientSubtleStop1 = DarkHcColorTokens.colorAlphaGreen500,
|
||||
gradientSubtleStop2 = DarkHcColorTokens.colorAlphaGreen400,
|
||||
gradientSubtleStop3 = DarkHcColorTokens.colorAlphaGreen300,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
|
|
@ -73,12 +73,10 @@ val compoundColorsLight = SemanticColors(
|
|||
gradientActionStop2 = LightColorTokens.colorGreen700,
|
||||
gradientActionStop3 = LightColorTokens.colorGreen900,
|
||||
gradientActionStop4 = LightColorTokens.colorGreen1100,
|
||||
gradientInfoStop1 = LightColorTokens.colorAlphaBlue500,
|
||||
gradientInfoStop2 = LightColorTokens.colorAlphaBlue400,
|
||||
gradientInfoStop3 = LightColorTokens.colorAlphaBlue300,
|
||||
gradientInfoStop4 = LightColorTokens.colorAlphaBlue200,
|
||||
gradientInfoStop5 = LightColorTokens.colorAlphaBlue100,
|
||||
gradientInfoStop6 = LightColorTokens.colorTransparent,
|
||||
gradientCriticalStop1 = LightColorTokens.colorRed200,
|
||||
gradientCriticalStop2 = LightColorTokens.colorThemeBg,
|
||||
gradientInfoStop1 = LightColorTokens.colorBlue200,
|
||||
gradientInfoStop2 = LightColorTokens.colorThemeBg,
|
||||
gradientSubtleStop1 = LightColorTokens.colorAlphaGreen500,
|
||||
gradientSubtleStop2 = LightColorTokens.colorAlphaGreen400,
|
||||
gradientSubtleStop3 = LightColorTokens.colorAlphaGreen300,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
|
|
@ -73,12 +73,10 @@ val compoundColorsHcLight = SemanticColors(
|
|||
gradientActionStop2 = LightHcColorTokens.colorGreen700,
|
||||
gradientActionStop3 = LightHcColorTokens.colorGreen900,
|
||||
gradientActionStop4 = LightHcColorTokens.colorGreen1100,
|
||||
gradientInfoStop1 = LightHcColorTokens.colorAlphaBlue500,
|
||||
gradientInfoStop2 = LightHcColorTokens.colorAlphaBlue400,
|
||||
gradientInfoStop3 = LightHcColorTokens.colorAlphaBlue300,
|
||||
gradientInfoStop4 = LightHcColorTokens.colorAlphaBlue200,
|
||||
gradientInfoStop5 = LightHcColorTokens.colorAlphaBlue100,
|
||||
gradientInfoStop6 = LightHcColorTokens.colorTransparent,
|
||||
gradientCriticalStop1 = LightHcColorTokens.colorRed200,
|
||||
gradientCriticalStop2 = LightHcColorTokens.colorThemeBg,
|
||||
gradientInfoStop1 = LightHcColorTokens.colorBlue200,
|
||||
gradientInfoStop2 = LightHcColorTokens.colorThemeBg,
|
||||
gradientSubtleStop1 = LightHcColorTokens.colorAlphaGreen500,
|
||||
gradientSubtleStop2 = LightHcColorTokens.colorAlphaGreen400,
|
||||
gradientSubtleStop3 = LightHcColorTokens.colorAlphaGreen300,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2m0,18c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8"
|
||||
android:fillColor="#FF000000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M13.49,11.38c0.43,-1.22 0.17,-2.64 -0.81,-3.62a3.47,3.47 0,0 0,-4.1 -0.59l2.35,2.35 -1.41,1.41 -2.35,-2.35c-0.71,1.32 -0.52,2.99 0.59,4.1 0.98,0.98 2.4,1.24 3.62,0.81l3.41,3.41c0.2,0.2 0.51,0.2 0.71,0l1.4,-1.4c0.2,-0.2 0.2,-0.51 0,-0.71z"
|
||||
android:fillColor="#FF000000"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
|
|
@ -4,11 +4,11 @@
|
|||
android:autoMirrored="true"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M0,0h24v24H0z"/>
|
||||
<path
|
||||
android:pathData="M21.167,3.75L7.417,3.75c-0.633,0 -1.128,0.32 -1.458,0.807L1,12l4.96,7.434c0.33,0.486 0.824,0.816 1.457,0.816h13.75A1.84,1.84 0,0 0,23 18.417L23,5.583a1.84,1.84 0,0 0,-1.833 -1.833m0,14.667L7.48,18.417L3.2,12l4.272,-6.417h13.695zM10.542,16.583 L13.833,13.293 17.124,16.583 18.417,15.291L15.126,12l3.29,-3.29 -1.292,-1.293 -3.29,3.29 -3.291,-3.29L9.25,8.709 12.54,12l-3.29,3.29z"
|
||||
android:fillColor="#FF000000"/>
|
||||
</group>
|
||||
<path
|
||||
android:pathData="M15.043,8.457a1,1 0,0 1,1.414 1.414l-2.043,2.043 2.129,2.129a1,1 0,1 1,-1.414 1.414l-2.13,-2.129 -2.127,2.129a1,1 0,0 1,-1.415 -1.414l2.129,-2.129 -2.043,-2.043a1,1 0,0 1,1.414 -1.414L13,10.5z"
|
||||
android:fillColor="#FF000000"/>
|
||||
<path
|
||||
android:pathData="M20,4a2,2 0,0 1,2 2v12a2,2 0,0 1,-2 2H7.28a2,2 0,0 1,-1.655 -0.877l-4.072,-6a2,2 0,0 1,0 -2.246l4.072,-6A2,2 0,0 1,7.28 4zM3.208,12l4.072,6H20V6H7.28z"
|
||||
android:fillColor="#FF000000"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M21.15,4H7.283c-0.638,0 -1.137,0.311 -1.47,0.782l-4.66,6.73a0.87,0.87 0,0 0,0 0.986l4.66,6.72c0.333,0.462 0.832,0.782 1.47,0.782h13.869C22.168,20 23,19.2 23,18.222V5.778C23,4.8 22.168,4 21.15,4m-3.42,11.822a0.947,0.947 0,0 1,-1.304 0l-2.672,-2.569 -2.672,2.57a0.947,0.947 0,0 1,-1.303 0,0.86 0.86,0 0,1 0,-1.254L12.45,12 9.779,9.431a0.86,0.86 0,0 1,0 -1.253,0.947 0.947,0 0,1 1.303,0l2.672,2.569 2.672,-2.57a0.947,0.947 0,0 1,1.304 0c0.36,0.347 0.36,0.907 0,1.254L15.058,12l2.672,2.569a0.877,0.877 0,0 1,0 1.253"
|
||||
android:fillColor="#FF000000"/>
|
||||
android:pathData="M20,4a2,2 0,0 1,2 2v12a2,2 0,0 1,-2 2L7.33,20a2,2 0,0 1,-1.673 -0.902l-3.937,-6a2,2 0,0 1,0 -2.196l3.937,-6A2,2 0,0 1,7.33 4zM16.457,8.457a1,1 0,0 0,-1.414 0L13,10.5l-2.043,-2.043a1,1 0,0 0,-1.414 1.414l2.043,2.043 -2.129,2.129a1,1 0,0 0,1.414 1.414l2.13,-2.129 2.128,2.129a1,1 0,0 0,1.414 -1.414l-2.129,-2.129 2.043,-2.043a1,1 0,0 0,0 -1.414"
|
||||
android:fillColor="#FF000000"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M19,8h-1.81a6,6 0,0 0,-1.82 -1.96l0.93,-0.93a0.996,0.996 0,1 0,-1.41 -1.41l-1.47,1.47C12.96,5.06 12.49,5 12,5s-0.96,0.06 -1.41,0.17L9.11,3.7A0.996,0.996 0,1 0,7.7 5.11l0.92,0.93C7.88,6.55 7.26,7.22 6.81,8H5c-0.55,0 -1,0.45 -1,1s0.45,1 1,1h1.09c-0.05,0.33 -0.09,0.66 -0.09,1v1H5c-0.55,0 -1,0.45 -1,1s0.45,1 1,1h1v1c0,0.34 0.04,0.67 0.09,1H5c-0.55,0 -1,0.45 -1,1s0.45,1 1,1h1.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3H19c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1h-1.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h1c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1h-1v-1c0,-0.34 -0.04,-0.67 -0.09,-1H19c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1m-6,8h-2c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h2c0.55,0 1,0.45 1,1s-0.45,1 -1,1m0,-4h-2c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h2c0.55,0 1,0.45 1,1s-0.45,1 -1,1"
|
||||
android:fillColor="#FF000000"/>
|
||||
</vector>
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M16.099,2.4a4.1,4.1 0,0 1,-1.057 3.074c-0.747,0.862 -1.878,1.358 -3.07,1.347 -0.075,-1.081 0.315,-2.146 1.085,-2.96 0.78,-0.825 1.866,-1.346 3.042,-1.461m3.767,6.54c-1.37,0.783 -2.213,2.163 -2.234,3.657 0.002,1.69 1.092,3.215 2.768,3.873a9.4,9.4 0,0 1,-1.44 2.723c-0.848,1.178 -1.737,2.329 -3.149,2.35 -0.67,0.015 -1.124,-0.165 -1.596,-0.351 -0.493,-0.195 -1.006,-0.398 -1.809,-0.398 -0.851,0 -1.388,0.21 -1.905,0.412 -0.447,0.174 -0.88,0.343 -1.49,0.367 -1.343,0.046 -2.37,-1.258 -3.25,-2.425 -1.756,-2.383 -3.124,-6.716 -1.29,-9.664 0.861,-1.437 2.471,-2.349 4.241,-2.402 0.763,-0.015 1.494,0.258 2.136,0.497 0.49,0.183 0.928,0.347 1.286,0.347 0.315,0 0.74,-0.157 1.237,-0.34 0.78,-0.288 1.737,-0.64 2.71,-0.545 1.514,0.044 2.917,0.748 3.785,1.9"
|
||||
android:pathData="M16.099,2.4a4.1,4.1 0,0 1,-1.057 3.073c-0.747,0.863 -1.878,1.36 -3.07,1.348 -0.075,-1.081 0.315,-2.146 1.085,-2.96 0.78,-0.825 1.866,-1.346 3.042,-1.461m3.767,6.54c-1.37,0.783 -2.214,2.163 -2.234,3.657 0.002,1.69 1.092,3.215 2.768,3.873a9.4,9.4 0,0 1,-1.44 2.723c-0.848,1.178 -1.737,2.329 -3.149,2.35 -0.671,0.015 -1.124,-0.165 -1.596,-0.351 -0.493,-0.195 -1.006,-0.398 -1.809,-0.398 -0.852,0 -1.388,0.21 -1.905,0.412 -0.447,0.174 -0.88,0.343 -1.49,0.367 -1.343,0.046 -2.37,-1.258 -3.25,-2.425 -1.756,-2.383 -3.124,-6.716 -1.29,-9.664 0.86,-1.437 2.471,-2.349 4.241,-2.402 0.763,-0.015 1.494,0.258 2.135,0.497 0.49,0.183 0.929,0.347 1.287,0.347 0.315,0 0.74,-0.157 1.237,-0.34 0.78,-0.288 1.737,-0.64 2.71,-0.545 1.514,0.044 2.917,0.748 3.785,1.9"
|
||||
android:fillColor="#FF000000"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M9,4c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2m0,6c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2m0,6c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2m6,0c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2m0,-6c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2m0,-6c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2"
|
||||
android:fillColor="#FF000000"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M6.56,7.98C6.1,7.52 5.31,7.6 5,8.17c-0.28,0.51 -0.5,1.03 -0.67,1.58 -0.19,0.63 0.31,1.25 0.96,1.25h0.01c0.43,0 0.82,-0.28 0.94,-0.7q0.18,-0.6 0.48,-1.17c0.22,-0.37 0.15,-0.84 -0.16,-1.15M5.31,13h-0.02c-0.65,0 -1.15,0.62 -0.96,1.25 0.16,0.54 0.38,1.07 0.66,1.58 0.31,0.57 1.11,0.66 1.57,0.2 0.3,-0.31 0.38,-0.77 0.17,-1.15 -0.2,-0.37 -0.36,-0.76 -0.48,-1.16a0.97,0.97 0,0 0,-0.94 -0.72m2.85,6.02q0.765,0.42 1.59,0.66c0.62,0.18 1.24,-0.32 1.24,-0.96v-0.03c0,-0.43 -0.28,-0.82 -0.7,-0.94 -0.4,-0.12 -0.78,-0.28 -1.15,-0.48a0.97,0.97 0,0 0,-1.16 0.17l-0.03,0.03c-0.45,0.45 -0.36,1.24 0.21,1.55M13,4.07v-0.66c0,-0.89 -1.08,-1.34 -1.71,-0.71L9.17,4.83c-0.4,0.4 -0.4,1.04 0,1.43l2.13,2.08c0.63,0.62 1.7,0.17 1.7,-0.72V6.09c2.84,0.48 5,2.94 5,5.91 0,2.73 -1.82,5.02 -4.32,5.75a0.97,0.97 0,0 0,-0.68 0.94v0.02c0,0.65 0.61,1.14 1.23,0.96A7.976,7.976 0,0 0,20 12c0,-4.08 -3.05,-7.44 -7,-7.93"
|
||||
android:fillColor="#FF000000"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M0,0h24v24H0z"/>
|
||||
<path
|
||||
android:pathData="M14.83,4.83 L12.7,2.7c-0.62,-0.62 -1.7,-0.18 -1.7,0.71v0.66C7.06,4.56 4,7.92 4,12c0,3.64 2.43,6.71 5.77,7.68 0.62,0.18 1.23,-0.32 1.23,-0.96v-0.03a0.97,0.97 0,0 0,-0.68 -0.94A5.98,5.98 0,0 1,6 12c0,-2.97 2.16,-5.43 5,-5.91v1.53c0,0.89 1.07,1.33 1.7,0.71l2.13,-2.08a0.99,0.99 0,0 0,0 -1.42m4.84,4.93q-0.24,-0.825 -0.66,-1.59c-0.31,-0.57 -1.1,-0.66 -1.56,-0.2l-0.01,0.01c-0.31,0.31 -0.38,0.78 -0.17,1.16 0.2,0.37 0.36,0.76 0.48,1.16 0.12,0.42 0.51,0.7 0.94,0.7h0.02c0.65,0 1.15,-0.62 0.96,-1.24M13,18.68v0.02c0,0.65 0.62,1.14 1.24,0.96q0.825,-0.24 1.59,-0.66c0.57,-0.31 0.66,-1.1 0.2,-1.56l-0.02,-0.02a0.97,0.97 0,0 0,-1.16 -0.17c-0.37,0.21 -0.76,0.37 -1.16,0.49 -0.41,0.12 -0.69,0.51 -0.69,0.94m4.44,-2.65c0.46,0.46 1.25,0.37 1.56,-0.2 0.28,-0.51 0.5,-1.04 0.67,-1.59 0.18,-0.62 -0.31,-1.24 -0.96,-1.24h-0.02c-0.44,0 -0.82,0.28 -0.94,0.7q-0.18,0.6 -0.48,1.17c-0.21,0.38 -0.13,0.86 0.17,1.16"
|
||||
android:fillColor="#FF000000"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M16,7a0.97,0.97 0,0 0,-0.713 0.287A0.97,0.97 0,0 0,15 8q0,0.424 0.287,0.713Q15.576,9 16,9t0.712,-0.287A0.97,0.97 0,0 0,17 8a0.97,0.97 0,0 0,-0.288 -0.713A0.97,0.97 0,0 0,16 7m0,4a0.97,0.97 0,0 0,-0.713 0.287A0.97,0.97 0,0 0,15 12q0,0.424 0.287,0.713 0.288,0.287 0.713,0.287 0.424,0 0.712,-0.287A0.97,0.97 0,0 0,17 12a0.97,0.97 0,0 0,-0.288 -0.713A0.97,0.97 0,0 0,16 11m0,4a0.97,0.97 0,0 0,-0.713 0.287A0.97,0.97 0,0 0,15 16q0,0.424 0.287,0.712 0.288,0.288 0.713,0.288 0.424,0 0.712,-0.288A0.97,0.97 0,0 0,17 16a0.97,0.97 0,0 0,-0.288 -0.713A0.97,0.97 0,0 0,16 15m-4,-8L8,7a0.97,0.97 0,0 0,-0.713 0.287A0.97,0.97 0,0 0,7 8q0,0.424 0.287,0.713Q7.576,9 8,9h4q0.424,0 0.713,-0.287A0.97,0.97 0,0 0,13 8a0.97,0.97 0,0 0,-0.287 -0.713A0.97,0.97 0,0 0,12 7m0,4L8,11a0.97,0.97 0,0 0,-0.713 0.287A0.97,0.97 0,0 0,7 12q0,0.424 0.287,0.713Q7.576,13 8,13h4q0.424,0 0.713,-0.287A0.97,0.97 0,0 0,13 12a0.97,0.97 0,0 0,-0.287 -0.713A0.97,0.97 0,0 0,12 11m0,4L8,15a0.97,0.97 0,0 0,-0.713 0.287A0.97,0.97 0,0 0,7 16q0,0.424 0.287,0.712Q7.576,17 8,17h4q0.424,0 0.713,-0.288A0.97,0.97 0,0 0,13 16a0.97,0.97 0,0 0,-0.287 -0.713A0.97,0.97 0,0 0,12 15m7,-12q0.824,0 1.413,0.587Q21,4.176 21,5v14q0,0.824 -0.587,1.413A1.93,1.93 0,0 1,19 21L5,21q-0.824,0 -1.412,-0.587A1.93,1.93 0,0 1,3 19L3,5q0,-0.824 0.587,-1.412A1.93,1.93 0,0 1,5 3zM19,5L5,5v14h14z"
|
||||
android:fillColor="#FF000000"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M16,18v2L8,20v-2zM18,16L18,8a2,2 0,0 0,-2 -2L8,6a2,2 0,0 0,-2 2v8a2,2 0,0 0,2 2v2a4,4 0,0 1,-4 -4L4,8a4,4 0,0 1,4 -4h8a4,4 0,0 1,4 4v8a4,4 0,0 1,-4 4v-2a2,2 0,0 0,2 -2"
|
||||
android:fillColor="#FF000000"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M4,8a4,4 0,0 1,4 -4h8a4,4 0,0 1,4 4v8a4,4 0,0 1,-4 4H8a4,4 0,0 1,-4 -4z"
|
||||
android:fillColor="#FF000000"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M12,22c5.52,0 10,-4.48 10,-10S17.52,2 12,2 2,6.48 2,12s4.48,10 10,10m1,-17.93c3.94,0.49 7,3.85 7,7.93s-3.05,7.44 -7,7.93z"
|
||||
android:fillColor="#FF000000"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M13,2a2,2 0,0 1,2 2v4h6a2,2 0,0 1,2 2v12.586c0,0.89 -1.077,1.337 -1.707,0.707L19,21h-8a2,2 0,0 1,-2 -2v-4L5,15l-2.293,2.293c-0.63,0.63 -1.707,0.184 -1.707,-0.707L1,4a2,2 0,0 1,2 -2zM15.5,12.125L12,12.125v1.25h4.37c-0.031,0.73 -0.325,1.457 -0.871,2.151a4.4,4.4 0,0 1,-0.613 -1.026h-1.33c0.202,0.69 0.57,1.335 1.067,1.932 -0.524,0.448 -1.162,0.873 -1.912,1.263l0.578,1.11a11.3,11.3 0,0 0,2.21 -1.483c0.633,0.553 1.382,1.05 2.212,1.483l0.578,-1.11c-0.75,-0.39 -1.388,-0.815 -1.912,-1.263 0.758,-0.912 1.213,-1.939 1.245,-3.057L20,13.375v-1.25h-3.25L16.75,10.25L15.5,10.25zM3,14.172l0.586,-0.586A2,2 0,0 1,5 13h4v-2.47L6.96,10.53L6.563,12L5,12l2.031,-7L8.97,5l0.96,3.312A2,2 0,0 1,11 8h2L13,4L3,4zM7.306,9.245h1.386l-0.67,-2.481h-0.047z"
|
||||
android:fillColor="#FF000000"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M9.01,5v1H11c0.333,0 1,0 1.5,0.5S13,7.667 13,8v7.01c0,0.54 0.45,0.99 0.99,0.99H15v-1a2,2 0,0 1,2 -2h3a2,2 0,0 1,2 2v4a2,2 0,0 1,-2 2h-3a2,2 0,0 1,-2 -2v-1h-1.01C12.34,18 11,16.66 11,15.01V9c0,-1 0,-1 -1,-1H9v1a2,2 0,0 1,-2 2H4a2,2 0,0 1,-2 -2V5c0,-1.1 0.9,-2 2,-2h3.01a2,2 0,0 1,2 2"
|
||||
android:fillColor="#FF000000"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:autoMirrored="true"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M16,4a2,2 0,0 1,2 2v4.286l3.35,-2.871a1,1 0,0 1,1.65 0.759v7.652a1,1 0,0 1,-1.65 0.759L18,13.714V18a2,2 0,0 1,-2 2H6a4,4 0,0 1,-4 -4V8a4,4 0,0 1,4 -4zM9.55,9l-0.103,0.005a1,1 0,0 0,0 1.99L9.55,11h0.571l-2.828,2.828a1,1 0,0 0,1.414 1.414L11.55,12.4v0.6l0.005,0.102a1,1 0,0 0,1.99 0L13.55,13v-3l-0.005,-0.103A1,1 0,0 0,12.55 9z"
|
||||
android:fillColor="#FF000000"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
|
|
@ -4,12 +4,8 @@
|
|||
android:autoMirrored="true"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M0,0h24v24H0z"/>
|
||||
<path
|
||||
android:pathData="M8.929,15.1a13.6,13.6 0,0 0,4.654 3.066q2.62,1.036 5.492,0.923h0.008l0.003,-0.004 0.003,-0.002 -0.034,-3.124 -3.52,-0.483 -1.791,1.792 -0.645,-0.322a13.5,13.5 0,0 1,-3.496 -2.52,13.4 13.4,0 0,1 -2.52,-3.496l-0.322,-0.644 1.792,-1.792 -0.483,-3.519 -3.123,-0.034 -0.003,0.002 -0.003,0.004v0.002a13.65,13.65 0,0 0,0.932 5.492A13.4,13.4 0,0 0,8.93 15.1m3.92,4.926a15.6,15.6 0,0 1,-5.334 -3.511,15.4 15.4,0 0,1 -3.505,-5.346 15.6,15.6 0,0 1,-1.069 -6.274,1.93 1.93,0 0,1 0.589,-1.366c0.366,-0.366 0.84,-0.589 1.386,-0.589h0.01l3.163,0.035a1.96,1.96 0,0 1,1.958 1.694v0.005l0.487,3.545v0.003c0.043,0.297 0.025,0.605 -0.076,0.907a2,2 0,0 1,-0.485 0.773l-0.762,0.762a11.4,11.4 0,0 0,3.206 3.54q0.457,0.33 0.948,0.614l0.762,-0.761a2,2 0,0 1,0.774 -0.486c0.302,-0.1 0.61,-0.118 0.907,-0.076l3.553,0.487a1.96,1.96 0,0 1,1.694 1.958l0.034,3.174c0,0.546 -0.223,1.02 -0.588,1.386 -0.361,0.36 -0.827,0.582 -1.363,0.588a15.3,15.3 0,0 1,-6.29 -1.062"
|
||||
android:fillColor="#FF000000"
|
||||
android:fillType="evenOdd"/>
|
||||
</group>
|
||||
<path
|
||||
android:pathData="M8.929,15.1a13.6,13.6 0,0 0,4.654 3.066q2.62,1.036 5.492,0.923h0.008l0.003,-0.004 0.003,-0.002 -0.034,-3.124 -3.52,-0.483 -1.791,1.792 -0.645,-0.322a13.5,13.5 0,0 1,-3.496 -2.52,13.4 13.4,0 0,1 -2.52,-3.496l-0.322,-0.645 1.792,-1.791 -0.483,-3.52 -3.123,-0.033 -0.003,0.002 -0.003,0.004v0.002a13.65,13.65 0,0 0,0.932 5.492A13.4,13.4 0,0 0,8.93 15.1m3.92,4.926a15.6,15.6 0,0 1,-5.334 -3.511,15.4 15.4,0 0,1 -3.505,-5.346 15.6,15.6 0,0 1,-1.069 -6.274,1.93 1.93,0 0,1 0.589,-1.366c0.366,-0.366 0.84,-0.589 1.386,-0.589h0.01l3.163,0.035a1.96,1.96 0,0 1,1.958 1.694v0.005l0.487,3.545v0.003c0.043,0.297 0.025,0.605 -0.076,0.907a2,2 0,0 1,-0.485 0.773l-0.762,0.762a11.3,11.3 0,0 0,1.806 2.348,11.4 11.4,0 0,0 2.348,1.806l0.762,-0.762a2,2 0,0 1,0.774 -0.485c0.302,-0.1 0.61,-0.118 0.907,-0.076l3.553,0.487a1.96,1.96 0,0 1,1.694 1.958l0.034,3.174c0,0.546 -0.223,1.02 -0.588,1.386 -0.36,0.36 -0.827,0.582 -1.363,0.588a15.3,15.3 0,0 1,-6.29 -1.062"
|
||||
android:fillColor="#FF000000"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:autoMirrored="true"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M7.623,3.04a1.07,1.07 0,0 1,1.086 0.929l0.542,3.954q0.039,0.27 -0.038,0.504a1.1,1.1 0,0 1,-0.272 0.427l-1.64,1.64Q7.806,11.5 8.456,12.4c0.433,0.601 1.444,1.697 1.444,1.697 0.013,0.012 1.098,1.014 1.696,1.444q0.9,0.65 1.909,1.153l1.64,-1.64q0.194,-0.194 0.426,-0.27a1.1,1.1 0,0 1,0.504 -0.04l3.953,0.543q0.407,0.058 0.67,0.358 0.26,0.301 0.26,0.728l0.04,3.527q0,0.427 -0.33,0.756 -0.33,0.33 -0.756,0.33a16,16 0,0 1,-6.57 -1.105,16.2 16.2,0 0,1 -5.563,-3.663 16.1,16.1 0,0 1,-3.653 -5.573,16.3 16.3,0 0,1 -1.116,-6.56q0,-0.426 0.329,-0.756Q3.67,3 4.095,3zM20.25,3q0.405,0 0.707,0.3 0.3,0.301 0.3,0.708t-0.3,0.707l-1.414,1.414 1.414,1.414q0.3,0.3 0.3,0.707t-0.3,0.707 -0.707,0.3 -0.707,-0.3l-1.414,-1.414 -1.414,1.414q-0.3,0.3 -0.707,0.3t-0.707,-0.3T15,8.25q0,-0.406 0.3,-0.707l1.415,-1.414L15.3,4.715q-0.3,-0.3 -0.301,-0.707 0,-0.407 0.3,-0.707t0.71,-0.301q0.405,0 0.707,0.3l1.414,1.415L19.543,3.3q0.3,-0.3 0.707,-0.301"
|
||||
android:fillColor="#FF000000"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:autoMirrored="true"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M7.623,3.04a1.07,1.07 0,0 1,1.086 0.929l0.542,3.954q0.039,0.27 -0.038,0.504a1.1,1.1 0,0 1,-0.272 0.427l-1.64,1.64Q7.806,11.5 8.456,12.4c0.433,0.601 1.444,1.697 1.444,1.697 0.013,0.012 1.098,1.014 1.696,1.444q0.9,0.65 1.909,1.153l1.64,-1.64q0.194,-0.194 0.426,-0.27a1.1,1.1 0,0 1,0.504 -0.04l3.953,0.543q0.407,0.058 0.67,0.358 0.26,0.301 0.26,0.728l0.04,3.527q0,0.427 -0.33,0.756 -0.33,0.33 -0.756,0.33a16,16 0,0 1,-6.57 -1.105,16.2 16.2,0 0,1 -5.563,-3.663 16.1,16.1 0,0 1,-3.653 -5.573,16.3 16.3,0 0,1 -1.116,-6.56q0,-0.426 0.329,-0.756Q3.67,3 4.095,3z"
|
||||
android:fillColor="#FF000000"/>
|
||||
<path
|
||||
android:pathData="M16,5q0.425,0 0.713,0.287Q17,5.575 17,6a0.97,0.97 0,0 1,-0.287 0.713A0.97,0.97 0,0 1,16 7h-0.5l2.2,2.15 2.4,-2.4a0.95,0.95 0,0 1,0.7 -0.275,0.95 0.95,0 0,1 0.7,0.275q0.3,0.3 0.3,0.7a0.92,0.92 0,0 1,-0.275 0.675l-3.125,3.15a0.8,0.8 0,0 1,-0.312 0.225,1.04 1.04,0 0,1 -0.776,0 0.9,0.9 0,0 1,-0.312 -0.2l-3,-3V9a0.97,0.97 0,0 1,-0.287 0.713A0.97,0.97 0,0 1,13 10a0.97,0.97 0,0 1,-0.713 -0.287A0.97,0.97 0,0 1,12 9V6q0,-0.425 0.287,-0.713A0.97,0.97 0,0 1,13 5z"
|
||||
android:fillColor="#FF000000"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:autoMirrored="true"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M7.623,3.04a1.07,1.07 0,0 1,1.086 0.929l0.542,3.954q0.039,0.27 -0.038,0.504a1.1,1.1 0,0 1,-0.272 0.427l-1.64,1.64Q7.806,11.5 8.456,12.4c0.433,0.601 1.444,1.697 1.444,1.697 0.013,0.012 1.098,1.014 1.696,1.444q0.9,0.65 1.909,1.153l1.64,-1.64q0.194,-0.194 0.426,-0.27a1.1,1.1 0,0 1,0.504 -0.04l3.953,0.543q0.407,0.058 0.67,0.358 0.26,0.301 0.26,0.728l0.04,3.527q0,0.427 -0.33,0.756 -0.33,0.33 -0.756,0.33a16,16 0,0 1,-6.57 -1.105,16.2 16.2,0 0,1 -5.563,-3.663 16.1,16.1 0,0 1,-3.653 -5.573,16.3 16.3,0 0,1 -1.116,-6.56q0,-0.426 0.329,-0.756Q3.67,3 4.095,3z"
|
||||
android:fillColor="#FF000000"/>
|
||||
<path
|
||||
android:pathData="M19.964,3a1,1 0,0 1,0.995 0.897l0.005,0.103v3l-0.005,0.103a1,1 0,0 1,-1.99 0L18.964,7v-0.605l-4.05,4.02A1,1 0,0 1,13.5 9l4.03,-4h-0.566l-0.103,-0.005a1,1 0,0 1,0 -1.99L16.964,3z"
|
||||
android:fillColor="#FF000000"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M0,0h24v24H0z"/>
|
||||
<path
|
||||
android:pathData="M10.5,6.5q0.425,0 0.713,0.287 0.288,0.288 0.287,0.713v2h2q0.425,0 0.713,0.287 0.288,0.288 0.287,0.713a0.97,0.97 0,0 1,-0.287 0.713,0.97 0.97,0 0,1 -0.713,0.287h-2v2a0.97,0.97 0,0 1,-0.287 0.713,0.97 0.97,0 0,1 -0.713,0.287 0.97,0.97 0,0 1,-0.713 -0.287,0.97 0.97,0 0,1 -0.287,-0.713v-2h-2a0.97,0.97 0,0 1,-0.713 -0.287,0.97 0.97,0 0,1 -0.287,-0.713q0,-0.425 0.287,-0.713A0.97,0.97 0,0 1,7.5 9.5h2v-2q0,-0.425 0.287,-0.713A0.97,0.97 0,0 1,10.5 6.5"
|
||||
android:fillColor="#FF000000"/>
|
||||
<path
|
||||
android:pathData="M10.5,3a7.5,7.5 0,0 1,5.963 12.049l3.244,3.244a1,1 0,1 1,-1.414 1.414l-3.244,-3.244A7.5,7.5 0,1 1,10.5 3m0,2a5.5,5.5 0,1 0,0 11,5.5 5.5,0 0,0 0,-11"
|
||||
android:fillColor="#FF000000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M15.05,16.463a7.5,7.5 0,1 1,1.414 -1.414l3.243,3.244a1,1 0,0 1,-1.414 1.414zM16,10.5a5.5,5.5 0,1 0,-11 0,5.5 5.5,0 0,0 11,0"
|
||||
android:fillColor="#FF000000"/>
|
||||
<path
|
||||
android:pathData="M7.875,11.375h1.75v1.75q0,0.372 0.252,0.623A0.85,0.85 0,0 0,10.5 14a0.85,0.85 0,0 0,0.623 -0.252,0.85 0.85,0 0,0 0.252,-0.623v-1.75h1.75a0.85,0.85 0,0 0,0.623 -0.252A0.85,0.85 0,0 0,14 10.5a0.85,0.85 0,0 0,-0.252 -0.623,0.85 0.85,0 0,0 -0.623,-0.252h-1.75v-1.75a0.85,0.85 0,0 0,-0.252 -0.623A0.85,0.85 0,0 0,10.5 7a0.85,0.85 0,0 0,-0.623 0.252,0.85 0.85,0 0,0 -0.252,0.623v1.75h-1.75a0.85,0.85 0,0 0,-0.623 0.252A0.85,0.85 0,0 0,7 10.5q0,0.372 0.252,0.623a0.85,0.85 0,0 0,0.623 0.252"
|
||||
android:fillColor="#FF000000"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M13.5,9.5q0.425,0 0.713,0.287 0.288,0.288 0.287,0.713a0.97,0.97 0,0 1,-0.287 0.713,0.97 0.97,0 0,1 -0.713,0.287h-6a0.97,0.97 0,0 1,-0.713 -0.287,0.97 0.97,0 0,1 -0.287,-0.713q0,-0.425 0.287,-0.713A0.97,0.97 0,0 1,7.5 9.5z"
|
||||
android:fillColor="#FF000000"/>
|
||||
<path
|
||||
android:pathData="M10.5,3a7.5,7.5 0,0 1,5.963 12.049l3.244,3.244a1,1 0,1 1,-1.414 1.414l-3.244,-3.244A7.5,7.5 0,1 1,10.5 3m0,2a5.5,5.5 0,1 0,0 11,5.5 5.5,0 0,0 0,-11"
|
||||
android:fillColor="#FF000000"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
|
|
@ -26,6 +26,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
|||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.colors.gradientCriticalColors
|
||||
import io.element.android.libraries.designsystem.colors.gradientInfoColors
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||
|
|
@ -38,13 +40,16 @@ import io.element.android.libraries.designsystem.theme.components.Icon
|
|||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
/**
|
||||
* Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2392-6721
|
||||
*/
|
||||
@Composable
|
||||
fun ComposerAlertMolecule(
|
||||
avatar: AvatarData?,
|
||||
content: AnnotatedString,
|
||||
onSubmitClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
level: ComposerAlertLevel = ComposerAlertLevel.Default,
|
||||
level: ComposerAlertLevel = ComposerAlertLevel.Info,
|
||||
showIcon: Boolean = false,
|
||||
submitText: String = stringResource(CommonStrings.action_ok),
|
||||
) {
|
||||
|
|
@ -52,20 +57,12 @@ fun ComposerAlertMolecule(
|
|||
modifier.fillMaxWidth()
|
||||
) {
|
||||
val lineColor = when (level) {
|
||||
ComposerAlertLevel.Default -> ElementTheme.colors.borderInfoSubtle
|
||||
ComposerAlertLevel.Info -> ElementTheme.colors.borderInfoSubtle
|
||||
ComposerAlertLevel.Critical -> ElementTheme.colors.borderCriticalSubtle
|
||||
}
|
||||
|
||||
val startColor = when (level) {
|
||||
ComposerAlertLevel.Default -> ElementTheme.colors.bgInfoSubtle
|
||||
ComposerAlertLevel.Info -> ElementTheme.colors.bgInfoSubtle
|
||||
ComposerAlertLevel.Critical -> ElementTheme.colors.bgCriticalSubtle
|
||||
}
|
||||
|
||||
val textColor = when (level) {
|
||||
ComposerAlertLevel.Default -> ElementTheme.colors.textPrimary
|
||||
ComposerAlertLevel.Info -> ElementTheme.colors.textInfoPrimary
|
||||
ComposerAlertLevel.Info -> ElementTheme.colors.textPrimary
|
||||
ComposerAlertLevel.Critical -> ElementTheme.colors.textCriticalPrimary
|
||||
}
|
||||
|
||||
|
|
@ -75,12 +72,13 @@ fun ComposerAlertMolecule(
|
|||
.height(1.dp)
|
||||
.background(lineColor)
|
||||
)
|
||||
val brush = Brush.verticalGradient(
|
||||
listOf(startColor, ElementTheme.colors.bgCanvasDefault),
|
||||
)
|
||||
val gradientColors = when (level) {
|
||||
ComposerAlertLevel.Info -> gradientInfoColors()
|
||||
ComposerAlertLevel.Critical -> gradientCriticalColors()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(brush)
|
||||
.background(Brush.verticalGradient(gradientColors))
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
|
||||
) {
|
||||
Column(
|
||||
|
|
@ -96,12 +94,10 @@ fun ComposerAlertMolecule(
|
|||
)
|
||||
} else if (showIcon) {
|
||||
val icon = when (level) {
|
||||
ComposerAlertLevel.Default -> CompoundIcons.Info()
|
||||
ComposerAlertLevel.Info -> CompoundIcons.Info()
|
||||
ComposerAlertLevel.Critical -> CompoundIcons.Error()
|
||||
}
|
||||
val iconTint = when (level) {
|
||||
ComposerAlertLevel.Default -> ElementTheme.colors.iconPrimary
|
||||
ComposerAlertLevel.Info -> ElementTheme.colors.iconInfoPrimary
|
||||
ComposerAlertLevel.Critical -> ElementTheme.colors.iconCriticalPrimary
|
||||
}
|
||||
|
|
@ -131,7 +127,6 @@ fun ComposerAlertMolecule(
|
|||
}
|
||||
|
||||
enum class ComposerAlertLevel {
|
||||
Default,
|
||||
Info,
|
||||
Critical
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ internal data class ComposerAlertMoleculeParams(
|
|||
|
||||
internal class ComposerAlertMoleculeParamsProvider : PreviewParameterProvider<ComposerAlertMoleculeParams> {
|
||||
private val allLevels = sequenceOf(
|
||||
ComposerAlertLevel.Default,
|
||||
ComposerAlertLevel.Info,
|
||||
ComposerAlertLevel.Critical
|
||||
)
|
||||
|
|
|
|||
|
|
@ -38,8 +38,11 @@ fun gradientSubtleColors(): List<Color> = listOf(
|
|||
fun gradientInfoColors(): List<Color> = listOf(
|
||||
ElementTheme.colors.gradientInfoStop1,
|
||||
ElementTheme.colors.gradientInfoStop2,
|
||||
ElementTheme.colors.gradientInfoStop3,
|
||||
ElementTheme.colors.gradientInfoStop4,
|
||||
ElementTheme.colors.gradientInfoStop5,
|
||||
ElementTheme.colors.gradientInfoStop6,
|
||||
)
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
fun gradientCriticalColors(): List<Color> = listOf(
|
||||
ElementTheme.colors.gradientCriticalStop1,
|
||||
ElementTheme.colors.gradientCriticalStop2,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import io.element.android.libraries.designsystem.R
|
|||
// All the icons should be defined in Compound.
|
||||
internal val iconsOther = listOf(
|
||||
R.drawable.ic_notification,
|
||||
R.drawable.ic_stop,
|
||||
R.drawable.pin,
|
||||
R.drawable.ic_winner,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M6,16V8C6,7.45 6.196,6.979 6.588,6.588C6.979,6.196 7.45,6 8,6H16C16.55,6 17.021,6.196 17.413,6.588C17.804,6.979 18,7.45 18,8V16C18,16.55 17.804,17.021 17.413,17.413C17.021,17.804 16.55,18 16,18H8C7.45,18 6.979,17.804 6.588,17.413C6.196,17.021 6,16.55 6,16Z"
|
||||
android:fillColor="#ffffff"/>
|
||||
</vector>
|
||||
|
|
@ -22,6 +22,11 @@ sealed class NotificationResolverException : Exception() {
|
|||
*/
|
||||
data object EventFilteredOut : NotificationResolverException()
|
||||
|
||||
/**
|
||||
* The event was found but it has been redacted.
|
||||
*/
|
||||
data object EventRedacted : NotificationResolverException()
|
||||
|
||||
/**
|
||||
* An unexpected error occurred while trying to resolve the event.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -33,13 +33,33 @@ sealed class ErrorType(message: String) : Exception(message) {
|
|||
*/
|
||||
class NotFound(message: String) : ErrorType(message)
|
||||
|
||||
/**
|
||||
* The device could not be created.
|
||||
*/
|
||||
class UnableToCreateDevice(message: String) : ErrorType(message)
|
||||
|
||||
/**
|
||||
* An unknown error has happened.
|
||||
*/
|
||||
class Unknown(message: String) : ErrorType(message)
|
||||
|
||||
/**
|
||||
* The requested device was not returned by the homeserver.
|
||||
*/
|
||||
class DeviceNotFound(message: String) : ErrorType(message)
|
||||
|
||||
/**
|
||||
* The other device is already signed in and so does not need to sign in.
|
||||
*/
|
||||
class OtherDeviceAlreadySignedIn(message: String) : ErrorType(message)
|
||||
|
||||
/**
|
||||
* The sign in was cancelled.
|
||||
*/
|
||||
class Cancelled(message: String) : ErrorType(message)
|
||||
|
||||
/**
|
||||
* The sign in was not completed in the required time.
|
||||
*/
|
||||
class Expired(message: String) : ErrorType(message)
|
||||
|
||||
/**
|
||||
* A secure connection could not have been established between the two devices.
|
||||
*/
|
||||
class ConnectionInsecure(message: String) : ErrorType(message)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
package io.element.android.libraries.matrix.api.media
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
|
|
@ -16,9 +17,20 @@ data class MediaSource(
|
|||
/**
|
||||
* Url of the media.
|
||||
*/
|
||||
val url: String,
|
||||
private val url: String,
|
||||
/**
|
||||
* This is used to hold data for encrypted media.
|
||||
*/
|
||||
val json: String? = null,
|
||||
) : Parcelable
|
||||
) : Parcelable {
|
||||
/**
|
||||
* A URL with invalid parts (like `#fragment`, if it's an MXC url) removed.
|
||||
*/
|
||||
@IgnoredOnParcel
|
||||
val safeUrl = if (url.startsWith("mxc")) {
|
||||
// We've seen some MXC urls in the wild having some `mxc://foo/bar#auto` fragment suffix, which is invalid
|
||||
url.substringBefore("#")
|
||||
} else {
|
||||
url
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@ interface RoomListService {
|
|||
data object Hide : SyncIndicator
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the initial sliding sync request is done or not.
|
||||
*/
|
||||
val isInitialSyncDone: Boolean
|
||||
|
||||
/**
|
||||
* Creates a room list that can be used to load more rooms and filter them dynamically.
|
||||
* @param pageSize the number of rooms to load at once.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.media
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.test.media.aMediaSource
|
||||
import org.junit.Test
|
||||
|
||||
class MediaSourceTest {
|
||||
@Test
|
||||
fun `safeUrl removes the fragment part in MXC urls`() {
|
||||
val mediaSource = aMediaSource(url = "mxc://matrix.org/url#fragment")
|
||||
assertThat(mediaSource.safeUrl).isEqualTo("mxc://matrix.org/url")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `safeUrl keeps the fragment part in a non-MXC url`() {
|
||||
val mediaSource = aMediaSource(url = "https://matrix.org/url#fragment")
|
||||
assertThat(mediaSource.safeUrl).isEqualTo("https://matrix.org/url#fragment")
|
||||
}
|
||||
}
|
||||
|
|
@ -83,7 +83,7 @@ import io.element.android.libraries.matrix.impl.util.SessionPathsProvider
|
|||
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
|
||||
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
||||
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
|
||||
import io.element.android.libraries.matrix.impl.workmanager.PerformDatabaseVacuumWorkManagerRequest
|
||||
import io.element.android.libraries.matrix.impl.workmanager.PerformDatabaseVacuumRequestBuilder
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestType
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerScheduler
|
||||
|
|
@ -832,8 +832,8 @@ class RustMatrixClient(
|
|||
if (workManagerScheduler.hasPendingWork(sessionId, WorkManagerRequestType.DB_VACUUM)) return
|
||||
|
||||
Timber.i("Scheduling periodic database vacuuming for session $sessionId")
|
||||
val request = PerformDatabaseVacuumWorkManagerRequest(sessionId)
|
||||
workManagerScheduler.submit(request)
|
||||
val request = PerformDatabaseVacuumRequestBuilder(sessionId)
|
||||
sessionCoroutineScope.launch { workManagerScheduler.submit(request) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ internal fun HumanQrGrantLoginException.map() = when (this) {
|
|||
is HumanQrGrantLoginException.InvalidCheckCode -> ErrorType.InvalidCheckCode(message.orEmpty())
|
||||
is HumanQrGrantLoginException.MissingSecretsBackup -> ErrorType.MissingSecretsBackup(message.orEmpty())
|
||||
is HumanQrGrantLoginException.NotFound -> ErrorType.NotFound(message.orEmpty())
|
||||
is HumanQrGrantLoginException.UnableToCreateDevice -> ErrorType.UnableToCreateDevice(message.orEmpty())
|
||||
is HumanQrGrantLoginException.Cancelled -> ErrorType.Cancelled(message.orEmpty())
|
||||
is HumanQrGrantLoginException.ConnectionInsecure -> ErrorType.ConnectionInsecure(message.orEmpty())
|
||||
is HumanQrGrantLoginException.DeviceNotFound -> ErrorType.DeviceNotFound(message.orEmpty())
|
||||
is HumanQrGrantLoginException.Expired -> ErrorType.Expired(message.orEmpty())
|
||||
is HumanQrGrantLoginException.OtherDeviceAlreadySignedIn -> ErrorType.OtherDeviceAlreadySignedIn(message.orEmpty())
|
||||
is HumanQrGrantLoginException.Unknown -> ErrorType.Unknown(message.orEmpty())
|
||||
is HumanQrGrantLoginException.UnsupportedProtocol -> ErrorType.UnsupportedProtocol(message.orEmpty())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ class RustMediaLoader(
|
|||
return if (json != null) {
|
||||
RustMediaSource.fromJson(json)
|
||||
} else {
|
||||
RustMediaSource.fromUrl(url)
|
||||
RustMediaSource.fromUrl(safeUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,10 @@ class RustNotificationService(
|
|||
Timber.d("Could not retrieve event for notification with $eventId - event filtered out")
|
||||
put(eventId, Result.failure(NotificationResolverException.EventFilteredOut))
|
||||
}
|
||||
NotificationStatus.EventRedacted -> {
|
||||
Timber.d("Could not retrieve event for notification with $eventId - event redacted")
|
||||
put(eventId, Result.failure(NotificationResolverException.EventRedacted))
|
||||
}
|
||||
}
|
||||
}
|
||||
is BatchNotificationResult.Error -> {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.stateIn
|
|||
import org.matrix.rustcomponents.sdk.RoomListServiceState
|
||||
import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicator
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import org.matrix.rustcomponents.sdk.RoomListService as InnerRustRoomListService
|
||||
|
||||
internal class RustRoomListService(
|
||||
|
|
@ -33,6 +34,9 @@ internal class RustRoomListService(
|
|||
private val roomSyncSubscriber: RoomSyncSubscriber,
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
) : RoomListService {
|
||||
private val _isInitialSyncDone = AtomicBoolean(false)
|
||||
override val isInitialSyncDone: Boolean get() = _isInitialSyncDone.get()
|
||||
|
||||
override fun createRoomList(
|
||||
pageSize: Int,
|
||||
source: RoomList.Source,
|
||||
|
|
@ -75,6 +79,9 @@ internal class RustRoomListService(
|
|||
.map { it.toRoomListState() }
|
||||
.onEach { state ->
|
||||
Timber.d("RoomList state=$state")
|
||||
if (state == RoomListService.State.Running) {
|
||||
_isInitialSyncDone.set(true)
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, RoomListService.State.Idle)
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ import org.matrix.rustcomponents.sdk.TimelineDiff
|
|||
import org.matrix.rustcomponents.sdk.TimelineInterface
|
||||
import org.matrix.rustcomponents.sdk.TimelineListener
|
||||
import timber.log.Timber
|
||||
import uniffi.matrix_sdk.RoomPaginationStatus
|
||||
import uniffi.matrix_sdk.PaginationStatus
|
||||
|
||||
internal fun TimelineInterface.liveBackPaginationStatus(): Flow<RoomPaginationStatus> = callbackFlow {
|
||||
internal fun TimelineInterface.liveBackPaginationStatus(): Flow<PaginationStatus> = callbackFlow {
|
||||
val listener = object : PaginationStatusListener {
|
||||
override fun onUpdate(status: RoomPaginationStatus) {
|
||||
override fun onUpdate(status: PaginationStatus) {
|
||||
trySend(status)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ import org.matrix.rustcomponents.sdk.UploadParameters
|
|||
import org.matrix.rustcomponents.sdk.UploadSource
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import timber.log.Timber
|
||||
import uniffi.matrix_sdk.RoomPaginationStatus
|
||||
import uniffi.matrix_sdk.PaginationStatus
|
||||
import java.io.File
|
||||
import org.matrix.rustcomponents.sdk.EventOrTransactionId as RustEventOrTransactionId
|
||||
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
|
||||
|
|
@ -147,8 +147,8 @@ class RustTimeline(
|
|||
.onEach { backPaginationStatus ->
|
||||
updatePaginationStatus(Timeline.PaginationDirection.BACKWARDS) {
|
||||
when (backPaginationStatus) {
|
||||
is RoomPaginationStatus.Idle -> it.copy(isPaginating = false, hasMoreToLoad = !backPaginationStatus.hitTimelineStart)
|
||||
is RoomPaginationStatus.Paginating -> it.copy(isPaginating = true, hasMoreToLoad = true)
|
||||
is PaginationStatus.Idle -> it.copy(isPaginating = false, hasMoreToLoad = !backPaginationStatus.hitTimelineStart)
|
||||
is PaginationStatus.Paginating -> it.copy(isPaginating = true, hasMoreToLoad = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,18 +10,18 @@ package io.element.android.libraries.matrix.impl.workmanager
|
|||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkRequest
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.impl.workmanager.VacuumDatabaseWorker.Companion.SESSION_ID_PARAM
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequest
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestType
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper
|
||||
import io.element.android.libraries.workmanager.api.workManagerTag
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class PerformDatabaseVacuumWorkManagerRequest(
|
||||
class PerformDatabaseVacuumRequestBuilder(
|
||||
private val sessionId: SessionId,
|
||||
) : WorkManagerRequest {
|
||||
override fun build(): Result<List<WorkRequest>> {
|
||||
) : WorkManagerRequestBuilder {
|
||||
override suspend fun build(): Result<List<WorkManagerRequestWrapper>> {
|
||||
val data = Data.Builder().putString(SESSION_ID_PARAM, sessionId.value).build()
|
||||
val workRequest = PeriodicWorkRequest.Builder(
|
||||
workerClass = VacuumDatabaseWorker::class,
|
||||
|
|
@ -41,6 +41,6 @@ class PerformDatabaseVacuumWorkManagerRequest(
|
|||
)
|
||||
.build()
|
||||
|
||||
return Result.success(listOf(workRequest))
|
||||
return Result.success(listOf(WorkManagerRequestWrapper(workRequest)))
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ import io.element.android.libraries.network.useragent.SimpleUserAgentProvider
|
|||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequest
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder
|
||||
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||
|
|
@ -33,7 +33,7 @@ import java.io.File
|
|||
class RustMatrixClientFactoryTest {
|
||||
@Test
|
||||
fun test() = runTest {
|
||||
val scheduleVacuumLambda = lambdaRecorder<WorkManagerRequest, Unit> {}
|
||||
val scheduleVacuumLambda = lambdaRecorder<WorkManagerRequestBuilder, Unit> {}
|
||||
val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = scheduleVacuumLambda)
|
||||
val sut = createRustMatrixClientFactory(workManagerScheduler = workManagerScheduler)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import org.matrix.rustcomponents.sdk.TaskHandle
|
|||
import org.matrix.rustcomponents.sdk.Timeline
|
||||
import org.matrix.rustcomponents.sdk.TimelineDiff
|
||||
import org.matrix.rustcomponents.sdk.TimelineListener
|
||||
import uniffi.matrix_sdk.RoomPaginationStatus
|
||||
import uniffi.matrix_sdk.PaginationStatus
|
||||
|
||||
class FakeFfiTimeline : Timeline(NoHandle) {
|
||||
private var listener: TimelineListener? = null
|
||||
|
|
@ -33,7 +33,7 @@ class FakeFfiTimeline : Timeline(NoHandle) {
|
|||
return FakeFfiTaskHandle()
|
||||
}
|
||||
|
||||
fun emitPaginationStatus(status: RoomPaginationStatus) {
|
||||
fun emitPaginationStatus(status: PaginationStatus) {
|
||||
paginationStatusListener!!.onUpdate(status)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import kotlinx.coroutines.test.runCurrent
|
|||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.matrix.rustcomponents.sdk.TimelineDiff
|
||||
import uniffi.matrix_sdk.RoomPaginationStatus
|
||||
import uniffi.matrix_sdk.PaginationStatus
|
||||
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
|
||||
|
||||
class RustTimelineTest {
|
||||
|
|
@ -68,10 +68,10 @@ class RustTimelineTest {
|
|||
// Start pagination
|
||||
sut.paginate(Timeline.PaginationDirection.BACKWARDS)
|
||||
// Simulate SDK starting pagination
|
||||
inner.emitPaginationStatus(RoomPaginationStatus.Paginating)
|
||||
inner.emitPaginationStatus(PaginationStatus.Paginating)
|
||||
// No new events received
|
||||
// Simulate SDK stopping pagination, more event to load
|
||||
inner.emitPaginationStatus(RoomPaginationStatus.Idle(hitTimelineStart = false))
|
||||
inner.emitPaginationStatus(PaginationStatus.Idle(hitTimelineStart = false))
|
||||
// expect an item to be emitted, with an updated timestamp
|
||||
with(awaitItem()) {
|
||||
assertThat(size).isEqualTo(2)
|
||||
|
|
|
|||
|
|
@ -20,10 +20,14 @@ class FakeRoomListService(
|
|||
private val subscribeToVisibleRoomsLambda: (List<RoomId>) -> Unit = {},
|
||||
private val createRoomListLambda: (pageSize: Int) -> DynamicRoomList = { pageSize -> FakeDynamicRoomList(pageSize = pageSize) },
|
||||
override val allRooms: RoomList = createRoomListLambda(Int.MAX_VALUE),
|
||||
private val isInitialSyncLambda: () -> Boolean = { true },
|
||||
) : RoomListService {
|
||||
private val roomListStateFlow = MutableStateFlow<RoomListService.State>(RoomListService.State.Idle)
|
||||
private val syncIndicatorStateFlow = MutableStateFlow<RoomListService.SyncIndicator>(RoomListService.SyncIndicator.Hide)
|
||||
|
||||
override val isInitialSyncDone: Boolean
|
||||
get() = isInitialSyncLambda()
|
||||
|
||||
suspend fun postState(state: RoomListService.State) {
|
||||
roomListStateFlow.emit(state)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,15 +27,15 @@ internal class CoilMediaFetcher(
|
|||
private val mediaData: MediaRequestData,
|
||||
) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult? {
|
||||
val source = mediaData.source
|
||||
if (source == null) {
|
||||
val mediaSource = mediaData.source
|
||||
if (mediaSource == null) {
|
||||
Timber.e("MediaData source is null")
|
||||
return null
|
||||
}
|
||||
return when (val kind = mediaData.kind) {
|
||||
is MediaRequestData.Kind.Content -> fetchContent(source)
|
||||
is MediaRequestData.Kind.Thumbnail -> fetchThumbnail(source, kind)
|
||||
is MediaRequestData.Kind.File -> fetchFile(source, kind)
|
||||
is MediaRequestData.Kind.Content -> fetchContent(mediaSource)
|
||||
is MediaRequestData.Kind.Thumbnail -> fetchThumbnail(mediaSource, kind)
|
||||
is MediaRequestData.Kind.File -> fetchFile(mediaSource, kind)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,5 +25,5 @@ internal class MediaRequestDataKeyer : Keyer<MediaRequestData> {
|
|||
}
|
||||
|
||||
private fun MediaRequestData.toKey(): String? {
|
||||
return source?.let { "${it.url}_$kind" }
|
||||
return source?.let { "${it.safeUrl}_$kind" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ class MediaViewerDataSource(
|
|||
when (mediaItem) {
|
||||
is MediaItem.DateSeparator -> Unit
|
||||
is MediaItem.Event -> {
|
||||
val sourceUrl = mediaItem.mediaSource().url
|
||||
val sourceUrl = mediaItem.mediaSource().safeUrl
|
||||
val localMedia = localMediaStates.getOrPut(sourceUrl) {
|
||||
mutableStateOf(AsyncData.Uninitialized)
|
||||
}
|
||||
|
|
@ -153,7 +153,7 @@ class MediaViewerDataSource(
|
|||
}.toImmutableList()
|
||||
|
||||
fun clearLoadingError(data: MediaViewerPageData.MediaViewerData) {
|
||||
localMediaStates[data.mediaSource.url]?.value = AsyncData.Uninitialized
|
||||
localMediaStates[data.mediaSource.safeUrl]?.value = AsyncData.Uninitialized
|
||||
}
|
||||
|
||||
suspend fun loadMore(direction: Timeline.PaginationDirection) {
|
||||
|
|
@ -162,7 +162,7 @@ class MediaViewerDataSource(
|
|||
|
||||
suspend fun loadMedia(data: MediaViewerPageData.MediaViewerData) {
|
||||
Timber.d("loadMedia for ${data.eventId}")
|
||||
val localMediaState = localMediaStates.getOrPut(data.mediaSource.url) {
|
||||
val localMediaState = localMediaStates.getOrPut(data.mediaSource.safeUrl) {
|
||||
mutableStateOf(AsyncData.Uninitialized)
|
||||
}
|
||||
localMediaState.value = AsyncData.Loading()
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ package io.element.android.libraries.permissions.impl.troubleshoot
|
|||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesIntoSet
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.permissions.api.PermissionStateProvider
|
||||
import io.element.android.libraries.permissions.impl.R
|
||||
import io.element.android.libraries.permissions.impl.action.PermissionActions
|
||||
|
|
@ -24,7 +24,7 @@ import io.element.android.services.toolbox.api.strings.StringProvider
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@ContributesIntoSet(AppScope::class)
|
||||
@ContributesIntoSet(SessionScope::class)
|
||||
class NotificationTroubleshootCheckPermissionTest(
|
||||
private val permissionStateProvider: PermissionStateProvider,
|
||||
private val sdkVersionProvider: BuildVersionSdkIntProvider,
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.push.api.push
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
data class NotificationEventRequest(
|
||||
val sessionId: SessionId,
|
||||
val roomId: RoomId,
|
||||
val eventId: EventId,
|
||||
val providerInfo: String,
|
||||
)
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.push.api.push
|
||||
|
||||
fun interface SyncOnNotifiableEvent {
|
||||
suspend operator fun invoke(requests: List<NotificationEventRequest>)
|
||||
}
|
||||
|
|
@ -97,6 +97,7 @@ sqldelight {
|
|||
databases {
|
||||
create("PushDatabase") {
|
||||
schemaOutputDirectory = File("src/main/sqldelight/databases")
|
||||
verifyMigrations = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,13 +14,16 @@ import android.os.PowerManager
|
|||
import androidx.core.content.getSystemService
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.push.impl.PushDatabase
|
||||
import io.element.android.libraries.push.impl.db.PushHistory
|
||||
import io.element.android.libraries.push.impl.db.PushRequest
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlin.time.Instant
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultPushHistoryService(
|
||||
|
|
@ -31,7 +34,37 @@ class DefaultPushHistoryService(
|
|||
private val powerManager = context.getSystemService<PowerManager>()
|
||||
private val packageName = context.packageName
|
||||
|
||||
override fun onPushReceived(
|
||||
override suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result<Unit> {
|
||||
return runCatchingExceptions { pushDatabase.pushRequestQueries.insertPushRequest(pushRequest).await() }
|
||||
}
|
||||
|
||||
override suspend fun insertOrUpdatePushRequests(pushRequests: List<PushRequest>): Result<Unit> {
|
||||
return runCatchingExceptions {
|
||||
pushDatabase.transaction {
|
||||
for (request in pushRequests) {
|
||||
pushDatabase.pushRequestQueries.insertPushRequest(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result<List<PushRequest>> {
|
||||
return runCatchingExceptions {
|
||||
pushDatabase.transactionWithResult {
|
||||
val sinceTimeMillis = since?.toEpochMilliseconds() ?: 0
|
||||
pushDatabase.pushRequestQueries.selectAllPendingForSession(sessionId.value, sinceTimeMillis).executeAsList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeOldPushRequests(sessionId: SessionId): Result<Unit> {
|
||||
return runCatchingExceptions {
|
||||
val keepAmount = 100L
|
||||
pushDatabase.pushRequestQueries.removeOldest(keepAmount)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPushResult(
|
||||
providerInfo: String,
|
||||
eventId: EventId?,
|
||||
roomId: RoomId?,
|
||||
|
|
|
|||
|
|
@ -11,13 +11,16 @@ package io.element.android.libraries.push.impl.history
|
|||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.push.impl.db.PushRequest
|
||||
import io.element.android.libraries.push.impl.push.PushRequestStatus
|
||||
import kotlin.time.Instant
|
||||
|
||||
interface PushHistoryService {
|
||||
/**
|
||||
* Create a new push history entry.
|
||||
* Do not use directly, prefer using the extension functions.
|
||||
*/
|
||||
fun onPushReceived(
|
||||
fun onPushResult(
|
||||
providerInfo: String,
|
||||
eventId: EventId?,
|
||||
roomId: RoomId?,
|
||||
|
|
@ -26,12 +29,33 @@ interface PushHistoryService {
|
|||
includeDeviceState: Boolean,
|
||||
comment: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Adds or replaces an existing [PushRequest] in the local database.
|
||||
*/
|
||||
suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result<Unit>
|
||||
|
||||
/**
|
||||
* Replace a list of [PushRequest] in the database.
|
||||
*/
|
||||
suspend fun insertOrUpdatePushRequests(pushRequests: List<PushRequest>): Result<Unit>
|
||||
|
||||
/**
|
||||
* Gets [PushRequestStatus.PENDING] push requests from the local database for a [SessionId].
|
||||
* A [since] param can optionally be provided to only return those received after that date.
|
||||
*/
|
||||
suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result<List<PushRequest>>
|
||||
|
||||
/**
|
||||
* Removes the oldest push requests for a [SessionId].
|
||||
*/
|
||||
suspend fun removeOldPushRequests(sessionId: SessionId): Result<Unit>
|
||||
}
|
||||
|
||||
fun PushHistoryService.onInvalidPushReceived(
|
||||
providerInfo: String,
|
||||
data: String,
|
||||
) = onPushReceived(
|
||||
) = onPushResult(
|
||||
providerInfo = providerInfo,
|
||||
eventId = null,
|
||||
roomId = null,
|
||||
|
|
@ -46,7 +70,7 @@ fun PushHistoryService.onUnableToRetrieveSession(
|
|||
eventId: EventId,
|
||||
roomId: RoomId,
|
||||
reason: String,
|
||||
) = onPushReceived(
|
||||
) = onPushResult(
|
||||
providerInfo = providerInfo,
|
||||
eventId = eventId,
|
||||
roomId = roomId,
|
||||
|
|
@ -62,7 +86,7 @@ fun PushHistoryService.onUnableToResolveEvent(
|
|||
roomId: RoomId,
|
||||
sessionId: SessionId,
|
||||
reason: String,
|
||||
) = onPushReceived(
|
||||
) = onPushResult(
|
||||
providerInfo = providerInfo,
|
||||
eventId = eventId,
|
||||
roomId = roomId,
|
||||
|
|
@ -78,7 +102,7 @@ fun PushHistoryService.onSuccess(
|
|||
roomId: RoomId,
|
||||
sessionId: SessionId,
|
||||
comment: String?,
|
||||
) = onPushReceived(
|
||||
) = onPushResult(
|
||||
providerInfo = providerInfo,
|
||||
eventId = eventId,
|
||||
roomId = roomId,
|
||||
|
|
@ -95,7 +119,7 @@ fun PushHistoryService.onSuccess(
|
|||
|
||||
fun PushHistoryService.onDiagnosticPush(
|
||||
providerInfo: String,
|
||||
) = onPushReceived(
|
||||
) = onPushResult(
|
||||
providerInfo = providerInfo,
|
||||
eventId = null,
|
||||
roomId = null,
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
|
|||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
||||
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.libraries.push.impl.db.PushRequest
|
||||
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.ResolvedPushEvent
|
||||
|
|
@ -64,10 +64,10 @@ private val loggerTag = LoggerTag("DefaultNotifiableEventResolver", LoggerTag.No
|
|||
/**
|
||||
* Result of resolving a batch of push events.
|
||||
* The outermost [Result] indicates whether the setup to resolve the events was successful.
|
||||
* The results for each push notification will be a map of [NotificationEventRequest] to [Result] of [ResolvedPushEvent].
|
||||
* The results for each push notification will be a map of [PushRequest] to [Result] of [ResolvedPushEvent].
|
||||
* If the resolution of a specific event fails, the innermost [Result] will contain an exception.
|
||||
*/
|
||||
typealias ResolvePushEventsResult = Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>
|
||||
typealias ResolvePushEventsResult = Result<Map<PushRequest, Result<ResolvedPushEvent>>>
|
||||
|
||||
/**
|
||||
* The notifiable event resolver is able to create a NotifiableEvent (view model for notifications) from an sdk Event.
|
||||
|
|
@ -78,7 +78,7 @@ typealias ResolvePushEventsResult = Result<Map<NotificationEventRequest, Result<
|
|||
interface NotifiableEventResolver {
|
||||
suspend fun resolveEvents(
|
||||
sessionId: SessionId,
|
||||
notificationEventRequests: List<NotificationEventRequest>
|
||||
notificationEventRequests: List<PushRequest>
|
||||
): ResolvePushEventsResult
|
||||
}
|
||||
|
||||
|
|
@ -96,15 +96,15 @@ class DefaultNotifiableEventResolver(
|
|||
) : NotifiableEventResolver {
|
||||
override suspend fun resolveEvents(
|
||||
sessionId: SessionId,
|
||||
notificationEventRequests: List<NotificationEventRequest>
|
||||
notificationEventRequests: List<PushRequest>
|
||||
): ResolvePushEventsResult {
|
||||
Timber.d("Queueing notifications: $notificationEventRequests")
|
||||
val client = matrixClientProvider.getOrRestore(sessionId).getOrElse {
|
||||
return Result.failure(it)
|
||||
}
|
||||
val ids = notificationEventRequests.groupBy { it.roomId }
|
||||
val ids = notificationEventRequests.groupBy { RoomId(it.roomId) }
|
||||
.mapValues { (_, requests) ->
|
||||
requests.map { it.eventId }
|
||||
requests.map { EventId(it.eventId) }
|
||||
}
|
||||
|
||||
// TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event
|
||||
|
|
@ -125,7 +125,7 @@ class DefaultNotifiableEventResolver(
|
|||
|
||||
return Result.success(
|
||||
notificationEventRequests.associate { request ->
|
||||
val notificationDataResult = notificationDataMap[request.eventId]
|
||||
val notificationDataResult = notificationDataMap[EventId(request.eventId)]
|
||||
if (notificationDataResult == null) {
|
||||
request to Result.failure(NotificationResolverException.UnknownError("No notification data for ${request.roomId} - ${request.eventId}"))
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ class DefaultNotificationMediaRepo(
|
|||
}
|
||||
}
|
||||
|
||||
private fun MediaSource.cachedFile(): File? = mxcTools.mxcUri2FilePath(url)?.let {
|
||||
private fun MediaSource.cachedFile(): File? = mxcTools.mxcUri2FilePath(safeUrl)?.let {
|
||||
File("${cacheDir.path}/$CACHE_NOTIFICATION_SUBDIR/$it")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,125 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.push.impl.notifications
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
||||
import io.element.android.libraries.push.impl.workmanager.SyncNotificationWorkManagerRequest
|
||||
import io.element.android.libraries.push.impl.workmanager.SyncNotificationsWorkerDataConverter
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerScheduler
|
||||
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
interface NotificationResolverQueue {
|
||||
val results: SharedFlow<Pair<List<NotificationEventRequest>, Map<NotificationEventRequest, Result<ResolvedPushEvent>>>>
|
||||
suspend fun enqueue(request: NotificationEventRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* This class is responsible for periodically batching notification requests and resolving them in a single call,
|
||||
* so that we can avoid having to resolve each notification individually in the SDK.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultNotificationResolverQueue(
|
||||
private val notifiableEventResolver: NotifiableEventResolver,
|
||||
@AppCoroutineScope
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
private val workManagerScheduler: WorkManagerScheduler,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val workerDataConverter: SyncNotificationsWorkerDataConverter,
|
||||
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
|
||||
) : NotificationResolverQueue {
|
||||
companion object {
|
||||
private const val BATCH_WINDOW_MS = 250L
|
||||
}
|
||||
|
||||
private val requestQueue = Channel<NotificationEventRequest>(capacity = 100)
|
||||
|
||||
private var currentProcessingJob: Job? = null
|
||||
|
||||
/**
|
||||
* A flow that emits pairs of a list of notification event requests and a map of the resolved events.
|
||||
* The map contains the original request as the key and the resolved event as the value.
|
||||
*/
|
||||
override val results = MutableSharedFlow<Pair<List<NotificationEventRequest>, Map<NotificationEventRequest, Result<ResolvedPushEvent>>>>()
|
||||
|
||||
/**
|
||||
* Enqueues a notification event request to be resolved.
|
||||
* The request will be processed in batches, so it may not be resolved immediately.
|
||||
*
|
||||
* @param request The notification event request to enqueue.
|
||||
*/
|
||||
override suspend fun enqueue(request: NotificationEventRequest) {
|
||||
// Cancel previous processing job if it exists, acting as a debounce operation
|
||||
Timber.d("Cancelling job: $currentProcessingJob")
|
||||
currentProcessingJob?.cancel()
|
||||
|
||||
// Enqueue the request and start a delayed processing job
|
||||
requestQueue.send(request)
|
||||
currentProcessingJob = processQueue()
|
||||
Timber.d("Starting processing job for request: $request")
|
||||
}
|
||||
|
||||
private fun processQueue() = appCoroutineScope.launch(SupervisorJob()) {
|
||||
delay(BATCH_WINDOW_MS.milliseconds)
|
||||
|
||||
// If this job is still active (so this is the latest job), we launch a separate one that won't be cancelled when enqueueing new items
|
||||
// to process the existing queued items.
|
||||
appCoroutineScope.launch {
|
||||
val groupedRequestsById = buildList {
|
||||
while (!requestQueue.isEmpty) {
|
||||
requestQueue.receiveCatching().getOrNull()?.let(::add)
|
||||
}
|
||||
}.groupBy { it.sessionId }
|
||||
|
||||
if (featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) {
|
||||
for ((sessionId, requests) in groupedRequestsById) {
|
||||
workManagerScheduler.submit(
|
||||
SyncNotificationWorkManagerRequest(
|
||||
sessionId = sessionId,
|
||||
notificationEventRequests = requests,
|
||||
workerDataConverter = workerDataConverter,
|
||||
buildVersionSdkIntProvider = buildVersionSdkIntProvider,
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val sessionIds = groupedRequestsById.keys
|
||||
for (sessionId in sessionIds) {
|
||||
val requests = groupedRequestsById[sessionId].orEmpty()
|
||||
Timber.d("Fetching notifications for $sessionId: $requests. Pending requests: ${!requestQueue.isEmpty}")
|
||||
// Resolving the events in parallel should improve performance since each session id will query a different Client
|
||||
launch {
|
||||
// No need for a Mutex since the SDK already has one internally
|
||||
val notifications = notifiableEventResolver.resolveEvents(sessionId, requests).getOrNull().orEmpty()
|
||||
results.emit(requests to notifications)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.push.impl.notifications
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.api.ElementCallEntryPoint
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
|
||||
import io.element.android.libraries.push.impl.db.PushRequest
|
||||
import io.element.android.libraries.push.impl.history.PushHistoryService
|
||||
import io.element.android.libraries.push.impl.history.onSuccess
|
||||
import io.element.android.libraries.push.impl.history.onUnableToResolveEvent
|
||||
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
|
||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
||||
import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore
|
||||
import io.element.android.libraries.push.impl.push.OnNotifiableEventReceived
|
||||
import io.element.android.libraries.push.impl.push.OnRedactedEventReceived
|
||||
import io.element.android.libraries.push.impl.push.SyncOnNotifiableEvent
|
||||
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
private const val TAG = "NotifResultProcessor"
|
||||
|
||||
interface NotificationResultProcessor {
|
||||
suspend fun emit(results: Map<PushRequest, Result<ResolvedPushEvent>>)
|
||||
fun start()
|
||||
fun stop()
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@SingleIn(AppScope::class)
|
||||
class DefaultNotificationResultProcessor(
|
||||
private val pushHistoryService: PushHistoryService,
|
||||
private val batteryOptimizationStore: MutableBatteryOptimizationStore,
|
||||
private val fallbackNotificationFactory: FallbackNotificationFactory,
|
||||
private val userPushStoreFactory: UserPushStoreFactory,
|
||||
private val onRedactedEventReceived: OnRedactedEventReceived,
|
||||
private val onNotifiableEventReceived: OnNotifiableEventReceived,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val syncOnNotifiableEvent: SyncOnNotifiableEvent,
|
||||
private val elementCallEntryPoint: ElementCallEntryPoint,
|
||||
private val notificationChannels: NotificationChannels,
|
||||
@AppCoroutineScope private val coroutineScope: CoroutineScope,
|
||||
) : NotificationResultProcessor {
|
||||
private val resultFlow = MutableSharedFlow<Map<PushRequest, Result<ResolvedPushEvent>>>(extraBufferCapacity = Int.MAX_VALUE)
|
||||
private var processJob: Job? = null
|
||||
|
||||
override suspend fun emit(results: Map<PushRequest, Result<ResolvedPushEvent>>) {
|
||||
resultFlow.emit(results)
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
if (processJob?.isActive == true) {
|
||||
Timber.tag(TAG).w("Is already processing, not starting again")
|
||||
return
|
||||
}
|
||||
processJob = resultFlow
|
||||
.onEach(::processResults)
|
||||
.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
if (processJob?.isActive != true) {
|
||||
Timber.tag(TAG).w("Is not processing, not stopping")
|
||||
return
|
||||
}
|
||||
|
||||
processJob?.cancel()
|
||||
processJob = null
|
||||
}
|
||||
|
||||
private suspend fun processResults(results: Map<PushRequest, Result<ResolvedPushEvent>>) {
|
||||
// TODO what happens with items that weren't reported back?
|
||||
for ((request, result) in results) {
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
if (it is ResolvedPushEvent.Event && it.notifiableEvent is FallbackNotifiableEvent) {
|
||||
pushHistoryService.onUnableToResolveEvent(
|
||||
providerInfo = request.providerInfo,
|
||||
eventId = EventId(request.eventId),
|
||||
roomId = RoomId(request.roomId),
|
||||
sessionId = SessionId(request.sessionId),
|
||||
reason = it.notifiableEvent.cause.orEmpty(),
|
||||
)
|
||||
} else {
|
||||
pushHistoryService.onSuccess(
|
||||
providerInfo = request.providerInfo,
|
||||
eventId = EventId(request.eventId),
|
||||
roomId = RoomId(request.roomId),
|
||||
sessionId = SessionId(request.sessionId),
|
||||
comment = "Push handled successfully",
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { exception ->
|
||||
if (exception is NotificationResolverException.EventFilteredOut) {
|
||||
pushHistoryService.onSuccess(
|
||||
providerInfo = request.providerInfo,
|
||||
eventId = EventId(request.eventId),
|
||||
roomId = RoomId(request.roomId),
|
||||
sessionId = SessionId(request.sessionId),
|
||||
comment = "Push handled successfully but notification was filtered out",
|
||||
)
|
||||
} else if (exception is NotificationResolverException.EventRedacted) {
|
||||
pushHistoryService.onSuccess(
|
||||
providerInfo = request.providerInfo,
|
||||
eventId = EventId(request.eventId),
|
||||
roomId = RoomId(request.roomId),
|
||||
sessionId = SessionId(request.sessionId),
|
||||
comment = "Push handled successfully but event has been redacted",
|
||||
)
|
||||
} else {
|
||||
val reason = when (exception) {
|
||||
is NotificationResolverException.EventNotFound -> "Event not found"
|
||||
else -> "Unknown error: ${exception.message}"
|
||||
}
|
||||
pushHistoryService.onUnableToResolveEvent(
|
||||
providerInfo = request.providerInfo,
|
||||
eventId = EventId(request.eventId),
|
||||
roomId = RoomId(request.roomId),
|
||||
sessionId = SessionId(request.sessionId),
|
||||
reason = "$reason - Showing fallback notification",
|
||||
)
|
||||
batteryOptimizationStore.showBatteryOptimizationBanner()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val events = mutableListOf<NotifiableEvent>()
|
||||
val redactions = mutableListOf<ResolvedPushEvent.Redaction>()
|
||||
|
||||
@Suppress("LoopWithTooManyJumpStatements")
|
||||
for ((request, result) in results) {
|
||||
val event = result.recover { exception ->
|
||||
// If the event could not be resolved, we create a fallback notification
|
||||
when (exception) {
|
||||
is NotificationResolverException.EventFilteredOut -> {
|
||||
// Do nothing, we don't want to show a notification for filtered out events
|
||||
null
|
||||
}
|
||||
is NotificationResolverException.EventRedacted -> {
|
||||
// Do nothing, we don't want to show a notification for redacted events
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
Timber.tag(TAG).e(exception, "Failed to resolve push event")
|
||||
ResolvedPushEvent.Event(
|
||||
fallbackNotificationFactory.create(
|
||||
sessionId = SessionId(request.sessionId),
|
||||
roomId = RoomId(request.roomId),
|
||||
eventId = EventId(request.eventId),
|
||||
cause = exception.message,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}.getOrNull() ?: continue
|
||||
|
||||
val userPushStore = userPushStoreFactory.getOrCreate(event.sessionId)
|
||||
val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first()
|
||||
// If notifications are disabled for this session and device, we don't want to show the notification
|
||||
// But if it's a ringing call, we want to show it anyway
|
||||
val isRingingCall = (event as? ResolvedPushEvent.Event)?.notifiableEvent is NotifiableRingingCallEvent
|
||||
if (!areNotificationsEnabled && !isRingingCall) continue
|
||||
|
||||
// We categorise each result into either a NotifiableEvent or a Redaction
|
||||
when (event) {
|
||||
is ResolvedPushEvent.Event -> {
|
||||
events.add(event.notifiableEvent)
|
||||
}
|
||||
is ResolvedPushEvent.Redaction -> {
|
||||
redactions.add(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process redactions of messages in background to not block operations with higher priority
|
||||
if (redactions.isNotEmpty()) {
|
||||
coroutineScope.launch { onRedactedEventReceived.onRedactedEventsReceived(redactions) }
|
||||
}
|
||||
|
||||
// Find and process ringing call notifications separately
|
||||
val (ringingCallEvents, nonRingingCallEvents) = events.partition { it is NotifiableRingingCallEvent }
|
||||
for (ringingCallEvent in ringingCallEvents) {
|
||||
Timber.tag(TAG).d("Ringing call event: $ringingCallEvent")
|
||||
handleRingingCallEvent(ringingCallEvent as NotifiableRingingCallEvent)
|
||||
}
|
||||
|
||||
// Finally, process other notifications (messages, invites, generic notifications, etc.)
|
||||
if (nonRingingCallEvents.isNotEmpty()) {
|
||||
onNotifiableEventReceived.onNotifiableEventsReceived(nonRingingCallEvents)
|
||||
}
|
||||
|
||||
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) {
|
||||
syncOnNotifiableEvent(results.keys.toList())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) {
|
||||
Timber.i("## handleInternal() : Incoming call.")
|
||||
elementCallEntryPoint.handleIncomingCall(
|
||||
callType = CallType.RoomCall(notifiableEvent.sessionId, notifiableEvent.roomId),
|
||||
eventId = notifiableEvent.eventId,
|
||||
senderId = notifiableEvent.senderId,
|
||||
roomName = notifiableEvent.roomName,
|
||||
senderName = notifiableEvent.senderDisambiguatedDisplayName,
|
||||
avatarUrl = notifiableEvent.roomAvatarUrl,
|
||||
timestamp = notifiableEvent.timestamp,
|
||||
expirationTimestamp = notifiableEvent.expirationTimestamp,
|
||||
notificationChannelId = notificationChannels.getChannelForIncomingCall(ring = true),
|
||||
textContent = notifiableEvent.description,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -11,42 +11,28 @@ package io.element.android.libraries.push.impl.push
|
|||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.api.ElementCallEntryPoint
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
|
||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||
import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.db.PushRequest
|
||||
import io.element.android.libraries.push.impl.history.PushHistoryService
|
||||
import io.element.android.libraries.push.impl.history.onDiagnosticPush
|
||||
import io.element.android.libraries.push.impl.history.onInvalidPushReceived
|
||||
import io.element.android.libraries.push.impl.history.onSuccess
|
||||
import io.element.android.libraries.push.impl.history.onUnableToResolveEvent
|
||||
import io.element.android.libraries.push.impl.history.onUnableToRetrieveSession
|
||||
import io.element.android.libraries.push.impl.notifications.FallbackNotificationFactory
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue
|
||||
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
|
||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationResultProcessor
|
||||
import io.element.android.libraries.push.impl.test.DefaultTestPush
|
||||
import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler
|
||||
import io.element.android.libraries.push.impl.workmanager.SyncPendingNotificationsRequestBuilder
|
||||
import io.element.android.libraries.pushproviders.api.PushData
|
||||
import io.element.android.libraries.pushproviders.api.PushHandler
|
||||
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
|
||||
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestType
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerScheduler
|
||||
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
private val loggerTag = LoggerTag("PushHandler", LoggerTag.PushLoggerTag)
|
||||
|
|
@ -54,161 +40,20 @@ private val loggerTag = LoggerTag("PushHandler", LoggerTag.PushLoggerTag)
|
|||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultPushHandler(
|
||||
private val onNotifiableEventReceived: OnNotifiableEventReceived,
|
||||
private val onRedactedEventReceived: OnRedactedEventReceived,
|
||||
private val incrementPushDataStore: IncrementPushDataStore,
|
||||
private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore,
|
||||
private val userPushStoreFactory: UserPushStoreFactory,
|
||||
private val pushClientSecret: PushClientSecret,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val diagnosticPushHandler: DiagnosticPushHandler,
|
||||
private val elementCallEntryPoint: ElementCallEntryPoint,
|
||||
private val notificationChannels: NotificationChannels,
|
||||
private val pushHistoryService: PushHistoryService,
|
||||
private val resolverQueue: NotificationResolverQueue,
|
||||
@AppCoroutineScope
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
private val fallbackNotificationFactory: FallbackNotificationFactory,
|
||||
private val syncOnNotifiableEvent: SyncOnNotifiableEvent,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val userPushStoreFactory: UserPushStoreFactory,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val systemClock: SystemClock,
|
||||
private val workManagerScheduler: WorkManagerScheduler,
|
||||
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
|
||||
resultProcessor: NotificationResultProcessor,
|
||||
) : PushHandler {
|
||||
init {
|
||||
processPushEventResults()
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the push notification event results emitted by the [resolverQueue].
|
||||
*/
|
||||
private fun processPushEventResults() {
|
||||
resolverQueue.results
|
||||
.map { (requests, resolvedEvents) ->
|
||||
for (request in requests) {
|
||||
// Log the result of the push notification event
|
||||
val result = resolvedEvents[request]
|
||||
if (result == null) {
|
||||
pushHistoryService.onUnableToResolveEvent(
|
||||
providerInfo = request.providerInfo,
|
||||
eventId = request.eventId,
|
||||
roomId = request.roomId,
|
||||
sessionId = request.sessionId,
|
||||
reason = "Push not handled: no result found for request",
|
||||
)
|
||||
} else {
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
if (it is ResolvedPushEvent.Event && it.notifiableEvent is FallbackNotifiableEvent) {
|
||||
pushHistoryService.onUnableToResolveEvent(
|
||||
providerInfo = request.providerInfo,
|
||||
eventId = request.eventId,
|
||||
roomId = request.roomId,
|
||||
sessionId = request.sessionId,
|
||||
reason = it.notifiableEvent.cause.orEmpty(),
|
||||
)
|
||||
} else {
|
||||
pushHistoryService.onSuccess(
|
||||
providerInfo = request.providerInfo,
|
||||
eventId = request.eventId,
|
||||
roomId = request.roomId,
|
||||
sessionId = request.sessionId,
|
||||
comment = "Push handled successfully",
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { exception ->
|
||||
if (exception is NotificationResolverException.EventFilteredOut) {
|
||||
pushHistoryService.onSuccess(
|
||||
providerInfo = request.providerInfo,
|
||||
eventId = request.eventId,
|
||||
roomId = request.roomId,
|
||||
sessionId = request.sessionId,
|
||||
comment = "Push handled successfully but notification was filtered out",
|
||||
)
|
||||
} else {
|
||||
val reason = when (exception) {
|
||||
is NotificationResolverException.EventNotFound -> "Event not found"
|
||||
else -> "Unknown error: ${exception.message}"
|
||||
}
|
||||
pushHistoryService.onUnableToResolveEvent(
|
||||
providerInfo = request.providerInfo,
|
||||
eventId = request.eventId,
|
||||
roomId = request.roomId,
|
||||
sessionId = request.sessionId,
|
||||
reason = "$reason - Showing fallback notification",
|
||||
)
|
||||
mutableBatteryOptimizationStore.showBatteryOptimizationBanner()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val events = mutableListOf<NotifiableEvent>()
|
||||
val redactions = mutableListOf<ResolvedPushEvent.Redaction>()
|
||||
|
||||
@Suppress("LoopWithTooManyJumpStatements")
|
||||
for ((request, result) in resolvedEvents) {
|
||||
val event = result.recover { exception ->
|
||||
// If the event could not be resolved, we create a fallback notification
|
||||
when (exception) {
|
||||
is NotificationResolverException.EventFilteredOut -> {
|
||||
// Do nothing, we don't want to show a notification for filtered out events
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
Timber.tag(loggerTag.value).e(exception, "Failed to resolve push event")
|
||||
ResolvedPushEvent.Event(
|
||||
fallbackNotificationFactory.create(
|
||||
sessionId = request.sessionId,
|
||||
roomId = request.roomId,
|
||||
eventId = request.eventId,
|
||||
cause = exception.message,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}.getOrNull() ?: continue
|
||||
|
||||
val userPushStore = userPushStoreFactory.getOrCreate(event.sessionId)
|
||||
val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first()
|
||||
// If notifications are disabled for this session and device, we don't want to show the notification
|
||||
// But if it's a ringing call, we want to show it anyway
|
||||
val isRingingCall = (event as? ResolvedPushEvent.Event)?.notifiableEvent is NotifiableRingingCallEvent
|
||||
if (!areNotificationsEnabled && !isRingingCall) continue
|
||||
|
||||
// We categorise each result into either a NotifiableEvent or a Redaction
|
||||
when (event) {
|
||||
is ResolvedPushEvent.Event -> {
|
||||
events.add(event.notifiableEvent)
|
||||
}
|
||||
is ResolvedPushEvent.Redaction -> {
|
||||
redactions.add(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process redactions of messages in background to not block operations with higher priority
|
||||
if (redactions.isNotEmpty()) {
|
||||
appCoroutineScope.launch { onRedactedEventReceived.onRedactedEventsReceived(redactions) }
|
||||
}
|
||||
|
||||
// Find and process ringing call notifications separately
|
||||
val (ringingCallEvents, nonRingingCallEvents) = events.partition { it is NotifiableRingingCallEvent }
|
||||
for (ringingCallEvent in ringingCallEvents) {
|
||||
Timber.tag(loggerTag.value).d("Ringing call event: $ringingCallEvent")
|
||||
handleRingingCallEvent(ringingCallEvent as NotifiableRingingCallEvent)
|
||||
}
|
||||
|
||||
// Finally, process other notifications (messages, invites, generic notifications, etc.)
|
||||
if (nonRingingCallEvents.isNotEmpty()) {
|
||||
onNotifiableEventReceived.onNotifiableEventsReceived(nonRingingCallEvents)
|
||||
}
|
||||
|
||||
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) {
|
||||
syncOnNotifiableEvent(requests)
|
||||
}
|
||||
}
|
||||
.launchIn(appCoroutineScope)
|
||||
resultProcessor.start()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -221,9 +66,7 @@ class DefaultPushHandler(
|
|||
// Start measuring how long it takes to display a notification from when the push is received
|
||||
Timber.d("Calculating push-to-notification for event ${pushData.eventId}")
|
||||
val parent = analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(pushData.eventId.value))
|
||||
if (featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) {
|
||||
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(pushData.eventId.value), parent)
|
||||
}
|
||||
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(pushData.eventId.value), parent)
|
||||
|
||||
Timber.tag(loggerTag.value).d("## handling pushData: ${pushData.roomId}/${pushData.eventId}")
|
||||
if (buildMeta.lowPrivacyLoggingEnabled) {
|
||||
|
|
@ -270,34 +113,56 @@ class DefaultPushHandler(
|
|||
return
|
||||
}
|
||||
|
||||
appCoroutineScope.launch {
|
||||
val notificationEventRequest = NotificationEventRequest(
|
||||
sessionId = userId,
|
||||
roomId = pushData.roomId,
|
||||
eventId = pushData.eventId,
|
||||
providerInfo = providerInfo,
|
||||
val areNotificationsEnabled = userPushStoreFactory.getOrCreate(userId).getNotificationEnabledForDevice().first()
|
||||
if (!areNotificationsEnabled) {
|
||||
Timber.w("Push notification received when push notifications are disabled.")
|
||||
return
|
||||
}
|
||||
|
||||
val pushRequest = PushRequest(
|
||||
pushDate = systemClock.epochMillis(),
|
||||
providerInfo = providerInfo,
|
||||
eventId = pushData.eventId.value,
|
||||
roomId = pushData.roomId.value,
|
||||
sessionId = userId.value,
|
||||
status = PushRequestStatus.PENDING.value,
|
||||
retries = 0L,
|
||||
)
|
||||
|
||||
Timber.d("Queueing notification: $pushRequest")
|
||||
pushHistoryService.insertOrUpdatePushRequest(pushRequest)
|
||||
|
||||
if (!workManagerScheduler.hasPendingWork(userId, WorkManagerRequestType.NOTIFICATION_SYNC)) {
|
||||
Timber.d("No pending worker for push notifications found")
|
||||
workManagerScheduler.submit(
|
||||
SyncPendingNotificationsRequestBuilder(
|
||||
sessionId = userId,
|
||||
buildVersionSdkIntProvider = buildVersionSdkIntProvider,
|
||||
)
|
||||
)
|
||||
Timber.d("Queueing notification: $notificationEventRequest")
|
||||
resolverQueue.enqueue(notificationEventRequest)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(loggerTag.value).e(e, "## handleInternal() failed")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) {
|
||||
Timber.i("## handleInternal() : Incoming call.")
|
||||
elementCallEntryPoint.handleIncomingCall(
|
||||
callType = CallType.RoomCall(notifiableEvent.sessionId, notifiableEvent.roomId),
|
||||
eventId = notifiableEvent.eventId,
|
||||
senderId = notifiableEvent.senderId,
|
||||
roomName = notifiableEvent.roomName,
|
||||
senderName = notifiableEvent.senderDisambiguatedDisplayName,
|
||||
avatarUrl = notifiableEvent.roomAvatarUrl,
|
||||
timestamp = notifiableEvent.timestamp,
|
||||
expirationTimestamp = notifiableEvent.expirationTimestamp,
|
||||
notificationChannelId = notificationChannels.getChannelForIncomingCall(ring = true),
|
||||
textContent = notifiableEvent.description,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the status of a [PushRequest].
|
||||
*/
|
||||
enum class PushRequestStatus(val value: Long) {
|
||||
/**
|
||||
* Either it was enqueued, and we never tried to fetch it, or it failed with a recoverable error.
|
||||
*/
|
||||
PENDING(0),
|
||||
|
||||
/**
|
||||
* The event for the [PushRequest] was fetched successfully.
|
||||
*/
|
||||
SUCCESS(1),
|
||||
|
||||
/**
|
||||
* Fetching the event for the [PushRequest] failed with an unrecoverable error, and it won't be retried.
|
||||
*/
|
||||
FAILED(2),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,9 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
|||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||
import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.push.impl.db.PushRequest
|
||||
import io.element.android.services.appnavstate.api.AppForegroundStateService
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -29,7 +30,7 @@ class DefaultSyncOnNotifiableEvent(
|
|||
private val appForegroundStateService: AppForegroundStateService,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : SyncOnNotifiableEvent {
|
||||
override suspend operator fun invoke(requests: List<NotificationEventRequest>) = withContext(dispatchers.io) {
|
||||
override suspend operator fun invoke(requests: List<PushRequest>) = withContext(dispatchers.io) {
|
||||
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncOnPush)) {
|
||||
return@withContext
|
||||
}
|
||||
|
|
@ -41,8 +42,8 @@ class DefaultSyncOnNotifiableEvent(
|
|||
Timber.d("Starting opportunistic room list sync | In foreground: ${appForegroundStateService.isInForeground.value}")
|
||||
|
||||
for ((sessionId, events) in eventsBySession) {
|
||||
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: continue
|
||||
val roomIds = events.map { it.roomId }.distinct()
|
||||
val client = matrixClientProvider.getOrRestore(SessionId(sessionId)).getOrNull() ?: continue
|
||||
val roomIds = events.map { RoomId(it.roomId) }.distinct()
|
||||
|
||||
client.roomListService.subscribeToVisibleRooms(roomIds)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.push.impl.push
|
||||
|
||||
import io.element.android.libraries.push.impl.db.PushRequest
|
||||
|
||||
fun interface SyncOnNotifiableEvent {
|
||||
suspend operator fun invoke(requests: List<PushRequest>)
|
||||
}
|
||||
|
|
@ -8,8 +8,8 @@
|
|||
|
||||
package io.element.android.libraries.push.impl.troubleshoot
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesIntoSet
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.libraries.pushproviders.api.PushProvider
|
||||
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest
|
||||
|
|
@ -19,7 +19,7 @@ import io.element.android.services.toolbox.api.strings.StringProvider
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@ContributesIntoSet(AppScope::class)
|
||||
@ContributesIntoSet(SessionScope::class)
|
||||
class PushProvidersTest(
|
||||
pushProviders: Set<@JvmSuppressWildcards PushProvider>,
|
||||
private val stringProvider: StringProvider,
|
||||
|
|
|
|||
|
|
@ -1,191 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.push.impl.workmanager
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import dev.zacsweers.metro.ContributesIntoMap
|
||||
import dev.zacsweers.metro.binding
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.auth.SessionRestorationException
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||
import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerScheduler
|
||||
import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory
|
||||
import io.element.android.libraries.workmanager.api.di.WorkerKey
|
||||
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analytics.api.finishLongRunningTransaction
|
||||
import io.element.android.services.analytics.api.recordTransaction
|
||||
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
|
||||
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@AssistedInject
|
||||
class FetchNotificationsWorker(
|
||||
@Assisted params: WorkerParameters,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val eventResolver: NotifiableEventResolver,
|
||||
private val queue: NotificationResolverQueue,
|
||||
private val workManagerScheduler: WorkManagerScheduler,
|
||||
private val syncOnNotifiableEvent: SyncOnNotifiableEvent,
|
||||
private val workerDataConverter: SyncNotificationsWorkerDataConverter,
|
||||
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
|
||||
private val analyticsService: AnalyticsService,
|
||||
) : CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
Timber.d("FetchNotificationsWorker started")
|
||||
val requests = workerDataConverter.deserialize(inputData) ?: return Result.failure()
|
||||
|
||||
val networkTimeoutSpans = requests.mapNotNull { request ->
|
||||
val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(request.eventId.value))
|
||||
parent?.startChild("Waiting for network connectivity", "await_network")
|
||||
}
|
||||
|
||||
// Wait for network to be available, but not more than 10 seconds
|
||||
val hasNetwork = withTimeoutOrNull(10.seconds) {
|
||||
networkMonitor.connectivity.first { it == NetworkStatus.Connected }
|
||||
} != null
|
||||
|
||||
networkTimeoutSpans.finish()
|
||||
|
||||
// If there is a problem with the updated network values, report it and retry if needed
|
||||
if (reportConnectivityError(requests = requests, hasNetwork = hasNetwork, isNetworkBlocked = networkMonitor.isNetworkBlocked())) {
|
||||
return Result.retry()
|
||||
}
|
||||
|
||||
val pendingAnalyticTransactions = requests.mapNotNull { request ->
|
||||
analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(request.eventId.value))
|
||||
val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(request.eventId.value))
|
||||
val transactionName = "WorkManager to event fetched"
|
||||
parent?.startChild(transactionName)?.let { request.eventId to it }
|
||||
}.toMap()
|
||||
|
||||
val failedSyncForSessions = mutableMapOf<SessionId, Throwable>()
|
||||
|
||||
val groupedRequests = requests.groupBy { it.sessionId }.toMutableMap()
|
||||
for ((sessionId, notificationRequests) in groupedRequests) {
|
||||
Timber.d("Processing notification requests for session $sessionId")
|
||||
eventResolver.resolveEvents(sessionId, notificationRequests)
|
||||
.fold(
|
||||
onSuccess = { result ->
|
||||
for ((_, transaction) in pendingAnalyticTransactions) {
|
||||
transaction.finish()
|
||||
}
|
||||
// Update the resolved results in the queue
|
||||
(queue.results as MutableSharedFlow).emit(requests to result)
|
||||
},
|
||||
onFailure = {
|
||||
for ((_, transaction) in pendingAnalyticTransactions) {
|
||||
transaction.attachError(it)
|
||||
transaction.finish()
|
||||
}
|
||||
failedSyncForSessions[sessionId] = it
|
||||
Timber.e(it, "Failed to resolve notification events for session $sessionId")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// If there were failures for whole sessions, we retry all their requests
|
||||
if (failedSyncForSessions.isNotEmpty()) {
|
||||
@Suppress("LoopWithTooManyJumpStatements")
|
||||
for ((failedSessionId, exception) in failedSyncForSessions) {
|
||||
if (exception.cause is SessionRestorationException) {
|
||||
Timber.e(exception, "Session $failedSessionId could not be restored, not retrying notification fetching")
|
||||
groupedRequests.remove(failedSessionId)
|
||||
continue
|
||||
}
|
||||
val requestsToRetry = groupedRequests[failedSessionId] ?: continue
|
||||
|
||||
for (request in requestsToRetry) {
|
||||
val failedTransaction = pendingAnalyticTransactions[request.eventId]
|
||||
failedTransaction?.attachError(exception)
|
||||
failedTransaction?.finish()
|
||||
|
||||
val eventId = request.eventId.value
|
||||
val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(eventId))
|
||||
// Since we're retrying, start a new transaction
|
||||
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId), parent)
|
||||
}
|
||||
|
||||
Timber.d("Re-scheduling ${requestsToRetry.size} failed notification requests for session $failedSessionId")
|
||||
workManagerScheduler.submit(
|
||||
SyncNotificationWorkManagerRequest(
|
||||
sessionId = failedSessionId,
|
||||
notificationEventRequests = requestsToRetry,
|
||||
workerDataConverter = workerDataConverter,
|
||||
buildVersionSdkIntProvider = buildVersionSdkIntProvider,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Timber.d("Notifications processed successfully")
|
||||
|
||||
analyticsService.recordTransaction("Opportunistic sync", "opportunistic_sync") {
|
||||
performOpportunisticSyncIfNeeded(groupedRequests)
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun reportConnectivityError(requests: List<NotificationEventRequest>, hasNetwork: Boolean, isNetworkBlocked: Boolean): Boolean {
|
||||
return if (!hasNetwork || isNetworkBlocked) {
|
||||
for (request in requests) {
|
||||
val eventId = request.eventId.value
|
||||
analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId)) {
|
||||
it.putExtraData("has_network_connection", hasNetwork.toString())
|
||||
it.putExtraData("is_network_blocked", isNetworkBlocked.toString())
|
||||
}
|
||||
val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(eventId))
|
||||
// Since we're retrying, start a new transaction
|
||||
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId), parent)
|
||||
}
|
||||
Timber.w("FetchNotificationsWorker will retry. Has network connectivity: $hasNetwork. Is network blocked: $isNetworkBlocked")
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun performOpportunisticSyncIfNeeded(
|
||||
groupedRequests: Map<SessionId, List<NotificationEventRequest>>,
|
||||
) {
|
||||
for ((sessionId, notificationRequests) in groupedRequests) {
|
||||
runCatchingExceptions {
|
||||
syncOnNotifiableEvent(notificationRequests)
|
||||
}.onFailure {
|
||||
Timber.e(it, "Failed to sync on notifiable events for session $sessionId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ContributesIntoMap(AppScope::class, binding = binding<MetroWorkerFactory.WorkerInstanceFactory<*>>())
|
||||
@WorkerKey(FetchNotificationsWorker::class)
|
||||
@AssistedFactory
|
||||
interface Factory : MetroWorkerFactory.WorkerInstanceFactory<FetchNotificationsWorker>
|
||||
}
|
||||
|
||||
private fun <T : AnalyticsTransaction> Collection<T>.finish() = forEach { it.finish() }
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.push.impl.workmanager
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import dev.zacsweers.metro.ContributesIntoMap
|
||||
import dev.zacsweers.metro.binding
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.auth.SessionRestorationException
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.exception.ClientException
|
||||
import io.element.android.libraries.matrix.api.exception.isNetworkError
|
||||
import io.element.android.libraries.push.impl.db.PushRequest
|
||||
import io.element.android.libraries.push.impl.history.PushHistoryService
|
||||
import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationResultProcessor
|
||||
import io.element.android.libraries.push.impl.push.PushRequestStatus
|
||||
import io.element.android.libraries.push.impl.push.SyncOnNotifiableEvent
|
||||
import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory
|
||||
import io.element.android.libraries.workmanager.api.di.WorkerKey
|
||||
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analytics.api.finishLongRunningTransaction
|
||||
import io.element.android.services.analytics.api.recordTransaction
|
||||
import io.element.android.services.analyticsproviders.api.AnalyticsTransaction
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
@AssistedInject
|
||||
class FetchPendingNotificationsWorker(
|
||||
@Assisted private val params: WorkerParameters,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val pushHistoryService: PushHistoryService,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val eventResolver: NotifiableEventResolver,
|
||||
private val syncOnNotifiableEvent: SyncOnNotifiableEvent,
|
||||
private val resultProcessor: NotificationResultProcessor,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val systemClock: SystemClock,
|
||||
) : CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
Timber.d("FetchNotificationsWorker started")
|
||||
// RunCatching for test in debug mode
|
||||
val sessionId = runCatchingExceptions {
|
||||
inputData.getString(SyncPendingNotificationsRequestBuilder.SESSION_ID)?.let(::SessionId)
|
||||
}.getOrNull() ?: return Result.failure()
|
||||
|
||||
// Fetch pending requests in the last 24 hours
|
||||
val fetchSince = Instant.fromEpochMilliseconds(systemClock.epochMillis()).minus(1.days)
|
||||
val requests = pushHistoryService.getPendingPushRequests(sessionId, fetchSince).getOrNull() ?: return Result.failure()
|
||||
|
||||
pushHistoryService.removeOldPushRequests(sessionId).onFailure {
|
||||
Timber.e(it, "Could not remove outdated push requests")
|
||||
}
|
||||
|
||||
if (requests.isEmpty()) {
|
||||
Timber.d("No pending notifications to fetch, returning early")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
checkNetworkConnection(requests)?.let { failure -> return failure }
|
||||
|
||||
Timber.d("Fetching ${requests.size} push requests")
|
||||
|
||||
val pendingAnalyticTransactions = requests.mapNotNull { request ->
|
||||
analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(request.eventId))
|
||||
val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(request.eventId))
|
||||
val transactionName = "WorkManager to event fetched"
|
||||
parent?.startChild(transactionName)?.let { request.eventId to it }
|
||||
}.toMap()
|
||||
|
||||
Timber.d("Processing notification requests for session $sessionId")
|
||||
val results = eventResolver.resolveEvents(sessionId, requests)
|
||||
.fold(
|
||||
onSuccess = { results ->
|
||||
for ((_, transaction) in pendingAnalyticTransactions) {
|
||||
transaction.finish()
|
||||
}
|
||||
// Update the resolved results in the queue
|
||||
resultProcessor.emit(results)
|
||||
|
||||
results
|
||||
},
|
||||
onFailure = {
|
||||
// This is a failure at the fetch notification setup, not a failure for a single fetch notification operation
|
||||
return handleSetupError(sessionId, requests, pendingAnalyticTransactions, it)
|
||||
}
|
||||
)
|
||||
|
||||
val updatedRequests = mutableListOf<PushRequest>()
|
||||
for (request in requests) {
|
||||
val result = results[request] ?: continue
|
||||
result.fold(
|
||||
onSuccess = { updatedRequests.add(request.copy(status = PushRequestStatus.SUCCESS.value)) },
|
||||
onFailure = { exception ->
|
||||
if (exception is ClientException && exception.isNetworkError()) {
|
||||
// Reset to pending so we can retry it later
|
||||
updatedRequests.add(request.copy(status = PushRequestStatus.PENDING.value))
|
||||
} else {
|
||||
updatedRequests.add(request.copy(status = PushRequestStatus.FAILED.value))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Timber.d("Notifications processed successfully")
|
||||
|
||||
pushHistoryService.insertOrUpdatePushRequests(updatedRequests)
|
||||
|
||||
analyticsService.recordTransaction("Opportunistic sync", "opportunistic_sync") {
|
||||
performOpportunisticSyncIfNeeded(mapOf(sessionId to requests))
|
||||
}
|
||||
|
||||
return if (updatedRequests.any { it.status == PushRequestStatus.PENDING.value }) Result.retry() else Result.success()
|
||||
}
|
||||
|
||||
private suspend fun performOpportunisticSyncIfNeeded(
|
||||
groupedRequests: Map<SessionId, List<PushRequest>>,
|
||||
) {
|
||||
for ((sessionId, notificationRequests) in groupedRequests) {
|
||||
runCatchingExceptions {
|
||||
syncOnNotifiableEvent(notificationRequests)
|
||||
}.onFailure {
|
||||
Timber.e(it, "Failed to sync on notifiable events for session $sessionId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkNetworkConnection(requests: List<PushRequest>): Result? {
|
||||
val networkTimeoutSpans = requests.mapNotNull { request ->
|
||||
val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(request.eventId))
|
||||
parent?.startChild("Waiting for network connectivity", "await_network")
|
||||
}
|
||||
|
||||
// Wait for network to be available, but not more than 10 seconds
|
||||
val hasNetwork = withTimeoutOrNull(10.seconds) {
|
||||
networkMonitor.connectivity.first { it == NetworkStatus.Connected }
|
||||
} != null
|
||||
|
||||
networkTimeoutSpans.finish()
|
||||
|
||||
// If there is a problem with the updated network values, report it and retry if needed
|
||||
if (reportConnectivityError(requests = requests, hasNetwork = hasNetwork, isNetworkBlocked = networkMonitor.isNetworkBlocked())) {
|
||||
pushHistoryService.insertOrUpdatePushRequests(requests.map { request ->
|
||||
request.copy(retries = request.retries + 1)
|
||||
})
|
||||
return Result.retry()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun reportConnectivityError(requests: List<PushRequest>, hasNetwork: Boolean, isNetworkBlocked: Boolean): Boolean {
|
||||
return if (!hasNetwork || isNetworkBlocked) {
|
||||
for (request in requests) {
|
||||
val eventId = request.eventId
|
||||
analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId)) {
|
||||
it.putExtraData("has_network_connection", hasNetwork.toString())
|
||||
it.putExtraData("is_network_blocked", isNetworkBlocked.toString())
|
||||
}
|
||||
val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(eventId))
|
||||
// Since we're retrying, start a new transaction
|
||||
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId), parent)
|
||||
}
|
||||
Timber.w("FetchNotificationsWorker will retry. Has network connectivity: $hasNetwork. Is network blocked: $isNetworkBlocked")
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleSetupError(
|
||||
sessionId: SessionId,
|
||||
requests: List<PushRequest>,
|
||||
pendingAnalyticTransactions: Map<String, AnalyticsTransaction>,
|
||||
throwable: Throwable,
|
||||
): Result {
|
||||
for ((_, transaction) in pendingAnalyticTransactions) {
|
||||
transaction.attachError(throwable)
|
||||
transaction.finish()
|
||||
}
|
||||
|
||||
// If there were failures on the setup step and they weren't recoverable, update the requests and fail
|
||||
if (throwable.cause is SessionRestorationException) {
|
||||
Timber.e(throwable, "Session $sessionId could not be restored, not retrying notification fetching")
|
||||
pushHistoryService.insertOrUpdatePushRequests(requests.map { request ->
|
||||
request.copy(status = PushRequestStatus.FAILED.value)
|
||||
})
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
// If the failure is recoverable, retry
|
||||
for (request in requests) {
|
||||
val failedTransaction = pendingAnalyticTransactions[request.eventId]
|
||||
failedTransaction?.attachError(throwable)
|
||||
failedTransaction?.finish()
|
||||
|
||||
val eventId = request.eventId
|
||||
val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(eventId))
|
||||
// Since we're retrying, start a new transaction
|
||||
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId), parent)
|
||||
}
|
||||
|
||||
Timber.d("Re-scheduling ${requests.size} failed notification requests for session $sessionId")
|
||||
|
||||
pushHistoryService.insertOrUpdatePushRequests(requests.map { request ->
|
||||
request.copy(retries = request.retries + 1)
|
||||
})
|
||||
|
||||
return Result.retry()
|
||||
}
|
||||
|
||||
@ContributesIntoMap(AppScope::class, binding = binding<MetroWorkerFactory.WorkerInstanceFactory<*>>())
|
||||
@WorkerKey(FetchPendingNotificationsWorker::class)
|
||||
@AssistedFactory
|
||||
interface Factory : MetroWorkerFactory.WorkerInstanceFactory<FetchPendingNotificationsWorker>
|
||||
}
|
||||
|
||||
private fun <T : AnalyticsTransaction> Collection<T>.finish() = forEach { it.finish() }
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.push.impl.workmanager
|
||||
|
||||
import android.os.Build
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.WorkRequest
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequest
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestType
|
||||
import io.element.android.libraries.workmanager.api.workManagerTag
|
||||
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import timber.log.Timber
|
||||
import java.security.InvalidParameterException
|
||||
|
||||
class SyncNotificationWorkManagerRequest(
|
||||
private val sessionId: SessionId,
|
||||
private val notificationEventRequests: List<NotificationEventRequest>,
|
||||
private val workerDataConverter: SyncNotificationsWorkerDataConverter,
|
||||
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
|
||||
) : WorkManagerRequest {
|
||||
override fun build(): Result<List<WorkRequest>> {
|
||||
if (notificationEventRequests.isEmpty()) {
|
||||
return Result.failure(InvalidParameterException("notificationEventRequests cannot be empty"))
|
||||
}
|
||||
Timber.d("Scheduling ${notificationEventRequests.size} notification requests with WorkManager for $sessionId")
|
||||
return workerDataConverter.serialize(notificationEventRequests).map { dataList ->
|
||||
dataList.map { data ->
|
||||
OneTimeWorkRequestBuilder<FetchNotificationsWorker>()
|
||||
.setInputData(data)
|
||||
.apply {
|
||||
// Expedited workers aren't needed on Android 12 or lower:
|
||||
// They force displaying a foreground sync notification for no good reason, since they sync almost immediately anyway
|
||||
// See https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#backwards-compat
|
||||
if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
|
||||
setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
}
|
||||
}
|
||||
.setTraceTag(workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC))
|
||||
// TODO investigate using this instead of the resolver queue
|
||||
// .setInputMerger()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Data(
|
||||
@SerialName("session_id")
|
||||
val sessionId: String,
|
||||
@SerialName("room_id")
|
||||
val roomId: String,
|
||||
@SerialName("event_id")
|
||||
val eventId: String,
|
||||
@SerialName("provider_info")
|
||||
val providerInfo: String,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.push.impl.workmanager
|
||||
|
||||
import androidx.work.Data
|
||||
import androidx.work.workDataOf
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.core.extensions.mapCatchingExceptions
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||
import timber.log.Timber
|
||||
|
||||
@Inject
|
||||
class SyncNotificationsWorkerDataConverter(
|
||||
private val json: JsonProvider,
|
||||
) {
|
||||
fun serialize(notificationEventRequests: List<NotificationEventRequest>): Result<List<Data>> {
|
||||
// First try to serialize all requests at once. In the vast majority of cases this will work.
|
||||
return serializeRequests(notificationEventRequests)
|
||||
.map { listOf(it) }
|
||||
.recoverCatching { t ->
|
||||
if (t is DataForWorkManagerIsTooBig) {
|
||||
// Perform serialization on sublists, workDataOf have failed because of size limit
|
||||
Timber.w(t, "Failed to serialize ${notificationEventRequests.size} notification requests, split the requests per room.")
|
||||
// Group the requests per rooms
|
||||
val requestsSortedPerRoom = notificationEventRequests.groupBy { it.roomId }.values
|
||||
// Build a list of sublist with size at most CHUNK_SIZE, and with all rooms kept together
|
||||
buildList {
|
||||
val currentChunk = mutableListOf<NotificationEventRequest>()
|
||||
for (requests in requestsSortedPerRoom) {
|
||||
if (currentChunk.size + requests.size <= CHUNK_SIZE) {
|
||||
// Can add the whole room requests to the current chunk
|
||||
currentChunk.addAll(requests)
|
||||
} else {
|
||||
// Add the current chunk
|
||||
add(currentChunk.toList())
|
||||
// Start a new chunk with the current room requests
|
||||
currentChunk.clear()
|
||||
// If a room has more requests than CHUNK_SIZE, we need to split them
|
||||
requests.chunked(CHUNK_SIZE) { chunk ->
|
||||
if (chunk.size == CHUNK_SIZE) {
|
||||
add(chunk.toList())
|
||||
} else {
|
||||
currentChunk.addAll(chunk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add any remaining requests
|
||||
add(currentChunk.toList())
|
||||
}
|
||||
.filter { it.isNotEmpty() }
|
||||
.also {
|
||||
Timber.d("Split notification requests into ${it.size} chunks for WorkManager serialization")
|
||||
it.forEach { requests ->
|
||||
Timber.d(" - Chunk with ${requests.size} requests")
|
||||
}
|
||||
}
|
||||
.mapNotNull { serializeRequests(it).getOrNull() }
|
||||
} else {
|
||||
throw t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun serializeRequests(notificationEventRequests: List<NotificationEventRequest>): Result<Data> {
|
||||
return runCatchingExceptions { json().encodeToString(notificationEventRequests.map { it.toData() }) }
|
||||
.onFailure {
|
||||
Timber.e(it, "Failed to serialize notification requests")
|
||||
}
|
||||
.mapCatchingExceptions { str ->
|
||||
// Note: workDataOf can fail if the data is too large
|
||||
try {
|
||||
workDataOf(REQUESTS_KEY to str)
|
||||
} catch (_: IllegalStateException) {
|
||||
throw DataForWorkManagerIsTooBig()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deserialize(data: Data): List<NotificationEventRequest>? {
|
||||
val rawRequestsJson = data.getString(REQUESTS_KEY) ?: return null
|
||||
return runCatchingExceptions {
|
||||
json().decodeFromString<List<SyncNotificationWorkManagerRequest.Data>>(rawRequestsJson).map { it.toRequest() }
|
||||
}.fold(
|
||||
onSuccess = {
|
||||
Timber.d("Deserialized ${it.size} requests")
|
||||
it
|
||||
},
|
||||
onFailure = {
|
||||
Timber.e(it, "Failed to deserialize notification requests")
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val REQUESTS_KEY = "requests"
|
||||
internal const val CHUNK_SIZE = 20
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotificationEventRequest.toData(): SyncNotificationWorkManagerRequest.Data {
|
||||
return SyncNotificationWorkManagerRequest.Data(
|
||||
sessionId = sessionId.value,
|
||||
roomId = roomId.value,
|
||||
eventId = eventId.value,
|
||||
providerInfo = providerInfo,
|
||||
)
|
||||
}
|
||||
|
||||
private fun SyncNotificationWorkManagerRequest.Data.toRequest(): NotificationEventRequest {
|
||||
return NotificationEventRequest(
|
||||
sessionId = SessionId(sessionId),
|
||||
roomId = RoomId(roomId),
|
||||
eventId = EventId(eventId),
|
||||
providerInfo = providerInfo,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.push.impl.workmanager
|
||||
|
||||
import android.os.Build
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.workDataOf
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestType
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerWorkerType
|
||||
import io.element.android.libraries.workmanager.api.workManagerTag
|
||||
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||
|
||||
class SyncPendingNotificationsRequestBuilder(
|
||||
private val sessionId: SessionId,
|
||||
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
|
||||
) : WorkManagerRequestBuilder {
|
||||
companion object {
|
||||
const val SESSION_ID = "session_id"
|
||||
}
|
||||
|
||||
override suspend fun build(): Result<List<WorkManagerRequestWrapper>> {
|
||||
val type = WorkManagerWorkerType.Unique(
|
||||
name = workManagerTag(sessionId = sessionId, requestType = WorkManagerRequestType.NOTIFICATION_SYNC),
|
||||
policy = ExistingWorkPolicy.APPEND_OR_REPLACE,
|
||||
)
|
||||
val request = OneTimeWorkRequestBuilder<FetchPendingNotificationsWorker>()
|
||||
.setInputData(workDataOf(SESSION_ID to sessionId.value))
|
||||
.apply {
|
||||
// Expedited workers aren't needed on Android 12 or lower:
|
||||
// They force displaying a foreground sync notification for no good reason, since they sync almost immediately anyway
|
||||
// See https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#backwards-compat
|
||||
if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
|
||||
setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
}
|
||||
}
|
||||
.setTraceTag(workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC))
|
||||
.build()
|
||||
return Result.success(listOf(WorkManagerRequestWrapper(request, type)))
|
||||
}
|
||||
}
|
||||
BIN
libraries/push/impl/src/main/sqldelight/databases/1.db
Normal file
BIN
libraries/push/impl/src/main/sqldelight/databases/1.db
Normal file
Binary file not shown.
BIN
libraries/push/impl/src/main/sqldelight/databases/2.db
Normal file
BIN
libraries/push/impl/src/main/sqldelight/databases/2.db
Normal file
Binary file not shown.
|
|
@ -17,6 +17,5 @@ INSERT INTO PushHistory VALUES ?;
|
|||
removeAll:
|
||||
DELETE FROM PushHistory;
|
||||
|
||||
-- add query to keep only the last x entries
|
||||
removeOldest:
|
||||
DELETE FROM PushHistory WHERE rowid NOT IN (SELECT rowid FROM PushHistory ORDER BY pushDate DESC LIMIT ?);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
CREATE TABLE PushRequest (
|
||||
pushDate INTEGER NOT NULL,
|
||||
providerInfo TEXT NOT NULL,
|
||||
eventId TEXT NOT NULL,
|
||||
roomId TEXT NOT NULL,
|
||||
sessionId TEXT NOT NULL,
|
||||
status INTEGER NOT NULL DEFAULT 0,
|
||||
retries INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(sessionId, eventId)
|
||||
);
|
||||
|
||||
CREATE INDEX PushRequestSessionAndStatus ON PushRequest (sessionId, status);
|
||||
|
||||
selectAllPendingForSession:
|
||||
SELECT * FROM PushRequest WHERE status = 0 AND sessionId = ? AND pushDate > ? ORDER BY pushDate ASC;
|
||||
|
||||
insertPushRequest:
|
||||
INSERT OR REPLACE INTO PushRequest VALUES ?;
|
||||
|
||||
removeAll:
|
||||
DELETE FROM PushRequest;
|
||||
|
||||
removeOldest:
|
||||
DELETE FROM PushRequest WHERE rowid NOT IN (SELECT rowid FROM PushRequest ORDER BY pushDate DESC LIMIT ?);
|
||||
14
libraries/push/impl/src/main/sqldelight/migrations/1.sqm
Normal file
14
libraries/push/impl/src/main/sqldelight/migrations/1.sqm
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
-- Migrate DB from version 1
|
||||
|
||||
CREATE TABLE PushRequest (
|
||||
pushDate INTEGER NOT NULL,
|
||||
providerInfo TEXT NOT NULL,
|
||||
eventId TEXT NOT NULL,
|
||||
roomId TEXT NOT NULL,
|
||||
sessionId TEXT NOT NULL,
|
||||
status INTEGER NOT NULL DEFAULT 0,
|
||||
retries INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(sessionId, eventId)
|
||||
);
|
||||
|
||||
CREATE INDEX PushRequestSessionAndStatus ON PushRequest (sessionId, status);
|
||||
|
|
@ -11,7 +11,9 @@ package io.element.android.libraries.push.impl.history
|
|||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.push.impl.db.PushRequest
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import kotlin.time.Instant
|
||||
|
||||
class FakePushHistoryService(
|
||||
private val onPushReceivedResult: (
|
||||
|
|
@ -22,9 +24,13 @@ class FakePushHistoryService(
|
|||
Boolean,
|
||||
Boolean,
|
||||
String?
|
||||
) -> Unit = { _, _, _, _, _, _, _ -> lambdaError() }
|
||||
) -> Unit = { _, _, _, _, _, _, _ -> lambdaError() },
|
||||
private val enqueuePushRequest: (PushRequest) -> Result<Unit> = { lambdaError() },
|
||||
private val replacePushRequests: (List<PushRequest>) -> Result<Unit> = { lambdaError() },
|
||||
private val getPendingPushRequests: (SessionId, Instant?) -> Result<List<PushRequest>> = { _, _ -> lambdaError() },
|
||||
private val removeOldPushRequests: (SessionId) -> Result<Unit> = { lambdaError() },
|
||||
) : PushHistoryService {
|
||||
override fun onPushReceived(
|
||||
override fun onPushResult(
|
||||
providerInfo: String,
|
||||
eventId: EventId?,
|
||||
roomId: RoomId?,
|
||||
|
|
@ -43,4 +49,20 @@ class FakePushHistoryService(
|
|||
comment
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result<Unit> {
|
||||
return enqueuePushRequest.invoke(pushRequest)
|
||||
}
|
||||
|
||||
override suspend fun insertOrUpdatePushRequests(pushRequests: List<PushRequest>): Result<Unit> {
|
||||
return replacePushRequests.invoke(pushRequests)
|
||||
}
|
||||
|
||||
override suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result<List<PushRequest>> {
|
||||
return getPendingPushRequests.invoke(sessionId, since)
|
||||
}
|
||||
|
||||
override suspend fun removeOldPushRequests(sessionId: SessionId): Result<Unit> {
|
||||
return removeOldPushRequests.invoke(sessionId)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,9 +47,10 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
|||
import io.element.android.libraries.matrix.test.notification.FakeNotificationService
|
||||
import io.element.android.libraries.matrix.test.notification.aNotificationData
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||
import io.element.android.libraries.push.impl.db.PushRequest
|
||||
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationMediaRepo
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.aPushRequest
|
||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||
|
|
@ -71,7 +72,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
@Test
|
||||
fun `resolve event no session`() = runTest {
|
||||
val sut = createDefaultNotifiableEventResolver(notificationService = null)
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")))
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")))
|
||||
assertThat(result.isFailure).isTrue()
|
||||
}
|
||||
|
||||
|
|
@ -80,7 +81,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.failure(AN_EXCEPTION)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
assertThat(result.isFailure).isTrue()
|
||||
}
|
||||
|
|
@ -90,7 +91,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(mapOf(AN_EVENT_ID to Result.failure(AN_EXCEPTION)))
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
assertThat(result.getEvent(request)?.isFailure).isTrue()
|
||||
}
|
||||
|
|
@ -109,7 +110,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
aNotifiableMessageEvent(body = "Hello world")
|
||||
|
|
@ -133,7 +134,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
aNotifiableMessageEvent(body = "Hello world", hasMentionOrReply = true)
|
||||
|
|
@ -161,7 +162,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
aNotifiableMessageEvent(body = "Hello world")
|
||||
|
|
@ -189,7 +190,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
aNotifiableMessageEvent(body = "Hello world")
|
||||
|
|
@ -211,7 +212,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
aNotifiableMessageEvent(body = "Audio")
|
||||
|
|
@ -233,7 +234,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
aNotifiableMessageEvent(body = "Video")
|
||||
|
|
@ -255,7 +256,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
aNotifiableMessageEvent(body = "Voice message")
|
||||
|
|
@ -277,7 +278,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
aNotifiableMessageEvent(body = "Image")
|
||||
|
|
@ -299,7 +300,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
aNotifiableMessageEvent(body = "Sticker")
|
||||
|
|
@ -321,7 +322,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
aNotifiableMessageEvent(body = "File")
|
||||
|
|
@ -343,7 +344,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
aNotifiableMessageEvent(body = "Location")
|
||||
|
|
@ -365,7 +366,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
aNotifiableMessageEvent(body = "Notice")
|
||||
|
|
@ -387,7 +388,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
aNotifiableMessageEvent(body = "* Bob is happy")
|
||||
|
|
@ -409,7 +410,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
aNotifiableMessageEvent(body = "Poll: A question")
|
||||
|
|
@ -432,7 +433,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
assertThat(result.getEvent(request)?.getOrNull()).isNull()
|
||||
}
|
||||
|
|
@ -451,7 +452,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
InviteNotifiableEvent(
|
||||
|
|
@ -490,7 +491,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
InviteNotifiableEvent(
|
||||
|
|
@ -527,7 +528,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
InviteNotifiableEvent(
|
||||
|
|
@ -565,7 +566,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
InviteNotifiableEvent(
|
||||
|
|
@ -605,7 +606,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
InviteNotifiableEvent(
|
||||
|
|
@ -642,7 +643,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
assertThat(result.getEvent(request)?.getOrNull()).isNull()
|
||||
}
|
||||
|
|
@ -654,7 +655,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
mapOf(AN_EVENT_ID to Result.success(aNotificationData(content = NotificationContent.MessageLike.RoomEncrypted)))
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
FallbackNotifiableEvent(
|
||||
|
|
@ -680,7 +681,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
mapOf(AN_EVENT_ID to Result.failure(NotificationResolverException.EventNotFound))
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
assertThat(result.getEvent(request)).isEqualTo(Result.failure<ResolvedPushEvent?>(NotificationResolverException.EventNotFound))
|
||||
}
|
||||
|
|
@ -698,7 +699,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
NotifiableMessageEvent(
|
||||
|
|
@ -766,7 +767,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
callNotificationEventResolver.resolveEventLambda = { _, _, _ -> Result.success(expectedResult.notifiableEvent) }
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
|
||||
}
|
||||
|
|
@ -791,7 +792,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
redactedEventId = AN_EVENT_ID_2,
|
||||
reason = A_REDACTION_REASON,
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
|
||||
}
|
||||
|
|
@ -810,7 +811,7 @@ class DefaultNotifiableEventResolverTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
assertThat(result.getEvent(request)?.getOrNull()).isNull()
|
||||
}
|
||||
|
|
@ -857,13 +858,13 @@ class DefaultNotifiableEventResolverTest {
|
|||
mapOf(AN_EVENT_ID to Result.success(aNotificationData(content = content)))
|
||||
)
|
||||
)
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
assertThat(result.getEvent(request)?.getOrNull()).isNull()
|
||||
}
|
||||
|
||||
private fun Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>.getEvent(
|
||||
request: NotificationEventRequest
|
||||
private fun Result<Map<PushRequest, Result<ResolvedPushEvent>>>.getEvent(
|
||||
request: PushRequest
|
||||
): Result<ResolvedPushEvent>? {
|
||||
return getOrNull()?.get(request)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,310 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.push.impl.notifications
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.test.FakeElementCallEntryPoint
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.push.impl.db.PushRequest
|
||||
import io.element.android.libraries.push.impl.history.FakePushHistoryService
|
||||
import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.aPushRequest
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
||||
import io.element.android.libraries.push.impl.push.FakeMutableBatteryOptimizationStore
|
||||
import io.element.android.libraries.push.impl.push.FakeOnNotifiableEventReceived
|
||||
import io.element.android.libraries.push.impl.push.FakeOnRedactedEventReceived
|
||||
import io.element.android.libraries.push.impl.push.SyncOnNotifiableEvent
|
||||
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory
|
||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||
import io.element.android.tests.testutils.lambda.any
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DefaultNotificationResultProcessorTest {
|
||||
@Test
|
||||
fun `when not able to resolve the event, the banner to disable battery optimization will be displayed`() {
|
||||
`test notification resolver failure`(
|
||||
notificationResolveResult = { requests: List<PushRequest> ->
|
||||
Result.success(
|
||||
requests.associateWith { Result.failure(NotificationResolverException.UnknownError("Unable to resolve event")) }
|
||||
)
|
||||
},
|
||||
shouldSetOptimizationBatteryBanner = true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun `test notification resolver failure`(
|
||||
notificationResolveResult: (List<PushRequest>) -> Result<Map<PushRequest, Result<ResolvedPushEvent>>>,
|
||||
shouldSetOptimizationBatteryBanner: Boolean,
|
||||
) {
|
||||
runTest {
|
||||
val notifiableEventResult =
|
||||
lambdaRecorder<SessionId, List<PushRequest>, Result<Map<PushRequest, Result<ResolvedPushEvent>>>> { _, requests ->
|
||||
notificationResolveResult(requests)
|
||||
}
|
||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||
val pushHistoryService = FakePushHistoryService(
|
||||
onPushReceivedResult = onPushReceivedResult,
|
||||
)
|
||||
val showBatteryOptimizationBannerResult = lambdaRecorder<Unit> {}
|
||||
val processor = createDefaultNotificationResultProcessor(
|
||||
mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(
|
||||
showBatteryOptimizationBannerResult = showBatteryOptimizationBannerResult,
|
||||
),
|
||||
pushHistoryService = pushHistoryService,
|
||||
)
|
||||
|
||||
runningProcessor(processor) {
|
||||
emit(mapOf(aPushRequest() to Result.failure(IllegalStateException("boom"))))
|
||||
}
|
||||
|
||||
notifiableEventResult.assertions()
|
||||
.isNeverCalled()
|
||||
onPushReceivedResult.assertions()
|
||||
.isCalledOnce()
|
||||
.with(any(), value(AN_EVENT_ID), value(A_ROOM_ID), value(A_USER_ID), value(false), value(true), any())
|
||||
showBatteryOptimizationBannerResult.assertions().let {
|
||||
if (shouldSetOptimizationBatteryBanner) {
|
||||
it.isCalledOnce()
|
||||
} else {
|
||||
it.isNeverCalled()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when ringing call PushData is received, the incoming call will be handled`() = runTest {
|
||||
val handleIncomingCallLambda = lambdaRecorder<
|
||||
CallType.RoomCall,
|
||||
EventId,
|
||||
UserId,
|
||||
String?,
|
||||
String?,
|
||||
String?,
|
||||
String,
|
||||
String?,
|
||||
Unit,
|
||||
> { _, _, _, _, _, _, _, _ -> }
|
||||
val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
|
||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||
val pushHistoryService = FakePushHistoryService(
|
||||
onPushReceivedResult = onPushReceivedResult,
|
||||
)
|
||||
val processor = createDefaultNotificationResultProcessor(
|
||||
elementCallEntryPoint = elementCallEntryPoint,
|
||||
onNotifiableEventsReceived = onNotifiableEventsReceived,
|
||||
pushHistoryService = pushHistoryService,
|
||||
)
|
||||
runningProcessor(processor) {
|
||||
emit(mapOf(aPushRequest() to Result.success(ResolvedPushEvent.Event(aNotifiableCallEvent()))))
|
||||
}
|
||||
|
||||
advanceTimeBy(300.milliseconds)
|
||||
|
||||
handleIncomingCallLambda.assertions().isCalledOnce()
|
||||
onNotifiableEventsReceived.assertions().isNeverCalled()
|
||||
onPushReceivedResult.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when notify call PushData is received, the incoming call will be treated as a normal notification`() = runTest {
|
||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
||||
val handleIncomingCallLambda = lambdaRecorder<
|
||||
CallType.RoomCall,
|
||||
EventId,
|
||||
UserId,
|
||||
String?,
|
||||
String?,
|
||||
String?,
|
||||
String,
|
||||
String?,
|
||||
Unit,
|
||||
> { _, _, _, _, _, _, _, _ -> }
|
||||
val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
|
||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||
val pushHistoryService = FakePushHistoryService(
|
||||
onPushReceivedResult = onPushReceivedResult,
|
||||
)
|
||||
val processor = createDefaultNotificationResultProcessor(
|
||||
elementCallEntryPoint = elementCallEntryPoint,
|
||||
onNotifiableEventsReceived = onNotifiableEventsReceived,
|
||||
pushHistoryService = pushHistoryService,
|
||||
)
|
||||
|
||||
runningProcessor(processor) {
|
||||
processor.emit(mapOf(aPushRequest() to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent(type = EventType.RTC_NOTIFICATION)))))
|
||||
}
|
||||
|
||||
advanceTimeBy(300.milliseconds)
|
||||
|
||||
handleIncomingCallLambda.assertions().isNeverCalled()
|
||||
onNotifiableEventsReceived.assertions().isCalledOnce()
|
||||
onPushReceivedResult.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when notify call PushData is received, the incoming call will be treated as a normal notification even if notification are disabled`() = runTest {
|
||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
||||
val handleIncomingCallLambda = lambdaRecorder<
|
||||
CallType.RoomCall,
|
||||
EventId,
|
||||
UserId,
|
||||
String?,
|
||||
String?,
|
||||
String?,
|
||||
String,
|
||||
String?,
|
||||
Unit,
|
||||
> { _, _, _, _, _, _, _, _ -> }
|
||||
val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
|
||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||
val pushHistoryService = FakePushHistoryService(
|
||||
onPushReceivedResult = onPushReceivedResult,
|
||||
)
|
||||
val processor = createDefaultNotificationResultProcessor(
|
||||
elementCallEntryPoint = elementCallEntryPoint,
|
||||
onNotifiableEventsReceived = onNotifiableEventsReceived,
|
||||
pushHistoryService = pushHistoryService,
|
||||
)
|
||||
|
||||
runningProcessor(processor) {
|
||||
processor.emit(mapOf(aPushRequest() to Result.success(ResolvedPushEvent.Event(aNotifiableCallEvent()))))
|
||||
}
|
||||
|
||||
advanceTimeBy(300.milliseconds)
|
||||
|
||||
handleIncomingCallLambda.assertions().isCalledOnce()
|
||||
onNotifiableEventsReceived.assertions().isNeverCalled()
|
||||
onPushReceivedResult.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when a redaction is received, the onRedactedEventReceived is informed`() = runTest {
|
||||
val aRedaction = ResolvedPushEvent.Redaction(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
redactedEventId = AN_EVENT_ID_2,
|
||||
reason = null
|
||||
)
|
||||
val onRedactedEventReceived = lambdaRecorder<List<ResolvedPushEvent.Redaction>, Unit> { }
|
||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||
val pushHistoryService = FakePushHistoryService(
|
||||
onPushReceivedResult = onPushReceivedResult,
|
||||
)
|
||||
val processor = createDefaultNotificationResultProcessor(
|
||||
onRedactedEventReceived = onRedactedEventReceived,
|
||||
pushHistoryService = pushHistoryService,
|
||||
)
|
||||
|
||||
runningProcessor(processor) {
|
||||
emit(mapOf(aPushRequest() to Result.success(aRedaction)))
|
||||
}
|
||||
|
||||
advanceTimeBy(300.milliseconds)
|
||||
|
||||
onRedactedEventReceived.assertions().isCalledOnce()
|
||||
.with(value(listOf(aRedaction)))
|
||||
onPushReceivedResult.assertions()
|
||||
.isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when receiving a fallback event, we notify the push history service about it not being resolved`() = runTest {
|
||||
val aNotifiableFallbackEvent = aFallbackNotifiableEvent()
|
||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
||||
var receivedFallbackEvent = false
|
||||
val onPushReceivedResult =
|
||||
lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, isResolved, _, comment ->
|
||||
receivedFallbackEvent = !isResolved && comment == "Unable to resolve event: ${aNotifiableFallbackEvent.cause}"
|
||||
}
|
||||
val pushHistoryService = FakePushHistoryService(
|
||||
onPushReceivedResult = onPushReceivedResult,
|
||||
)
|
||||
|
||||
val processor = createDefaultNotificationResultProcessor(
|
||||
onNotifiableEventsReceived = onNotifiableEventsReceived,
|
||||
pushHistoryService = pushHistoryService,
|
||||
)
|
||||
|
||||
runningProcessor(processor) {
|
||||
emit(mapOf(aPushRequest() to Result.success(ResolvedPushEvent.Event(aNotifiableFallbackEvent))))
|
||||
}
|
||||
|
||||
advanceTimeBy(300.milliseconds)
|
||||
|
||||
onNotifiableEventsReceived.assertions().isCalledOnce()
|
||||
|
||||
assertThat(receivedFallbackEvent).isTrue()
|
||||
}
|
||||
|
||||
private suspend fun TestScope.runningProcessor(processor: NotificationResultProcessor, block: suspend NotificationResultProcessor.() -> Unit) {
|
||||
processor.start()
|
||||
|
||||
runCurrent()
|
||||
|
||||
block(processor)
|
||||
|
||||
runCurrent()
|
||||
|
||||
processor.stop()
|
||||
}
|
||||
|
||||
private fun TestScope.createDefaultNotificationResultProcessor(
|
||||
systemClock: FakeSystemClock = FakeSystemClock(),
|
||||
pushHistoryService: FakePushHistoryService = FakePushHistoryService(),
|
||||
mutableBatteryOptimizationStore: FakeMutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(),
|
||||
fallbackNotificationFactory: FallbackNotificationFactory = FallbackNotificationFactory(systemClock),
|
||||
userPushStoreFactory: FakeUserPushStoreFactory = FakeUserPushStoreFactory(),
|
||||
onRedactedEventReceived: (List<ResolvedPushEvent.Redaction>) -> Unit = {},
|
||||
onNotifiableEventsReceived: (List<NotifiableEvent>) -> Unit = {},
|
||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
||||
syncOnNotifiableEvent: SyncOnNotifiableEvent = {},
|
||||
elementCallEntryPoint: FakeElementCallEntryPoint = FakeElementCallEntryPoint(),
|
||||
notificationChannels: FakeNotificationChannels = FakeNotificationChannels(),
|
||||
coroutineScope: CoroutineScope = backgroundScope,
|
||||
) = DefaultNotificationResultProcessor(
|
||||
pushHistoryService = pushHistoryService,
|
||||
batteryOptimizationStore = mutableBatteryOptimizationStore,
|
||||
fallbackNotificationFactory = fallbackNotificationFactory,
|
||||
userPushStoreFactory = userPushStoreFactory,
|
||||
onRedactedEventReceived = FakeOnRedactedEventReceived(onRedactedEventReceived),
|
||||
onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventsReceived),
|
||||
featureFlagService = featureFlagService,
|
||||
syncOnNotifiableEvent = syncOnNotifiableEvent,
|
||||
elementCallEntryPoint = elementCallEntryPoint,
|
||||
notificationChannels = notificationChannels,
|
||||
coroutineScope = coroutineScope,
|
||||
)
|
||||
}
|
||||
|
|
@ -9,18 +9,18 @@
|
|||
package io.element.android.libraries.push.impl.notifications
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||
import io.element.android.libraries.push.impl.db.PushRequest
|
||||
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeNotifiableEventResolver(
|
||||
private val resolveEventsResult: (SessionId, List<NotificationEventRequest>) -> Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>> =
|
||||
private val resolveEventsResult: (SessionId, List<PushRequest>) -> Result<Map<PushRequest, Result<ResolvedPushEvent>>> =
|
||||
{ _, _ -> lambdaError() }
|
||||
) : NotifiableEventResolver {
|
||||
override suspend fun resolveEvents(
|
||||
sessionId: SessionId,
|
||||
notificationEventRequests: List<NotificationEventRequest>
|
||||
): Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>> {
|
||||
notificationEventRequests: List<PushRequest>
|
||||
): Result<Map<PushRequest, Result<ResolvedPushEvent>>> {
|
||||
return resolveEventsResult(sessionId, notificationEventRequests)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.push.impl.notifications
|
||||
|
||||
import io.element.android.libraries.push.impl.db.PushRequest
|
||||
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeNotificationResultProcessor(
|
||||
private val emit: (Map<PushRequest, Result<ResolvedPushEvent>>) -> Unit = { lambdaError() },
|
||||
private val start: () -> Unit = { lambdaError() },
|
||||
private val stop: () -> Unit = { lambdaError() },
|
||||
) : NotificationResultProcessor {
|
||||
override suspend fun emit(results: Map<PushRequest, Result<ResolvedPushEvent>>) {
|
||||
return emit.invoke(results)
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
start.invoke()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
stop.invoke()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
|
|
@ -14,16 +13,22 @@ import io.element.android.libraries.matrix.api.core.SessionId
|
|||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||
import io.element.android.libraries.push.impl.db.PushRequest
|
||||
import io.element.android.libraries.push.impl.push.PushRequestStatus
|
||||
|
||||
fun aNotificationEventRequest(
|
||||
fun aPushRequest(
|
||||
sessionId: SessionId = A_SESSION_ID,
|
||||
roomId: RoomId = A_ROOM_ID,
|
||||
eventId: EventId = AN_EVENT_ID,
|
||||
providerInfo: String = "providerInfo",
|
||||
) = NotificationEventRequest(
|
||||
sessionId = sessionId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
providerInfo: String = "firebase",
|
||||
status: PushRequestStatus = PushRequestStatus.PENDING,
|
||||
retries: Int = 0,
|
||||
) = PushRequest(
|
||||
pushDate = System.currentTimeMillis(),
|
||||
providerInfo = providerInfo,
|
||||
eventId = eventId.value,
|
||||
roomId = roomId.value,
|
||||
sessionId = sessionId.value,
|
||||
status = status.value,
|
||||
retries = retries.toLong(),
|
||||
)
|
||||
|
|
@ -11,65 +11,38 @@
|
|||
package io.element.android.libraries.push.impl.push
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.test.FakeElementCallEntryPoint
|
||||
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
|
||||
import io.element.android.libraries.matrix.api.notification.RtcNotificationType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_SECRET
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||
import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.db.PushRequest
|
||||
import io.element.android.libraries.push.impl.history.FakePushHistoryService
|
||||
import io.element.android.libraries.push.impl.history.PushHistoryService
|
||||
import io.element.android.libraries.push.impl.notifications.DefaultNotificationResolverQueue
|
||||
import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver
|
||||
import io.element.android.libraries.push.impl.notifications.FallbackNotificationFactory
|
||||
import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
||||
import io.element.android.libraries.push.impl.notifications.FakeNotificationResultProcessor
|
||||
import io.element.android.libraries.push.impl.test.DefaultTestPush
|
||||
import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler
|
||||
import io.element.android.libraries.push.impl.workmanager.SyncNotificationsWorkerDataConverter
|
||||
import io.element.android.libraries.pushproviders.api.PushData
|
||||
import io.element.android.libraries.pushstore.api.UserPushStore
|
||||
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
|
||||
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore
|
||||
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory
|
||||
import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequest
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder
|
||||
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
|
||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||
import io.element.android.tests.testutils.lambda.any
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.matching
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
private const val A_PUSHER_INFO = "info"
|
||||
|
|
@ -96,84 +69,36 @@ class DefaultPushHandlerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `when classical PushData is received, the notification drawer is informed`() = runTest {
|
||||
val aNotifiableMessageEvent = aNotifiableMessageEvent()
|
||||
val notifiableEventResult =
|
||||
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
|
||||
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
|
||||
}
|
||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
||||
fun `when classical PushData is received, the work is scheduled`() = runTest {
|
||||
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||
val pushHistoryService = FakePushHistoryService(
|
||||
onPushReceivedResult = onPushReceivedResult,
|
||||
)
|
||||
val aPushData = PushData(
|
||||
eventId = AN_EVENT_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
unread = 0,
|
||||
clientSecret = A_SECRET,
|
||||
)
|
||||
|
||||
val enqueuePushRequestResult = lambdaRecorder<PushRequest, Result<Unit>> { Result.success(Unit) }
|
||||
val pushHistoryService = FakePushHistoryService(
|
||||
enqueuePushRequest = enqueuePushRequestResult,
|
||||
)
|
||||
val submitWorkLambda = lambdaRecorder<WorkManagerRequestBuilder, Unit> {}
|
||||
val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = submitWorkLambda)
|
||||
|
||||
val defaultPushHandler = createDefaultPushHandler(
|
||||
onNotifiableEventsReceived = onNotifiableEventsReceived,
|
||||
notifiableEventsResult = notifiableEventResult,
|
||||
pushClientSecret = FakePushClientSecret(
|
||||
getUserIdFromSecretResult = { A_USER_ID }
|
||||
),
|
||||
incrementPushCounterResult = incrementPushCounterResult,
|
||||
workManagerScheduler = workManagerScheduler,
|
||||
pushHistoryService = pushHistoryService,
|
||||
)
|
||||
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
||||
|
||||
advanceTimeBy(300.milliseconds)
|
||||
|
||||
incrementPushCounterResult.assertions()
|
||||
submitWorkLambda.assertions()
|
||||
.isCalledOnce()
|
||||
notifiableEventResult.assertions()
|
||||
.isCalledOnce()
|
||||
.with(value(A_USER_ID), any())
|
||||
onNotifiableEventsReceived.assertions()
|
||||
.isCalledOnce()
|
||||
.with(value(listOf(aNotifiableMessageEvent)))
|
||||
onPushReceivedResult.assertions()
|
||||
.isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when classical PushData is received and the workmanager flag is enabled, the work is scheduled`() = runTest {
|
||||
val aNotifiableMessageEvent = aNotifiableMessageEvent()
|
||||
val notifiableEventResult =
|
||||
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
|
||||
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
|
||||
}
|
||||
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
||||
val aPushData = PushData(
|
||||
eventId = AN_EVENT_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
unread = 0,
|
||||
clientSecret = A_SECRET,
|
||||
)
|
||||
|
||||
val featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SyncNotificationsWithWorkManager.key to true))
|
||||
val submitWorkLambda = lambdaRecorder<WorkManagerRequest, Unit> {}
|
||||
val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = submitWorkLambda)
|
||||
|
||||
val defaultPushHandler = createDefaultPushHandler(
|
||||
notifiableEventsResult = notifiableEventResult,
|
||||
pushClientSecret = FakePushClientSecret(
|
||||
getUserIdFromSecretResult = { A_USER_ID }
|
||||
),
|
||||
incrementPushCounterResult = incrementPushCounterResult,
|
||||
featureFlagService = featureFlagService,
|
||||
workManagerScheduler = workManagerScheduler,
|
||||
)
|
||||
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
||||
|
||||
advanceTimeBy(300.milliseconds)
|
||||
|
||||
submitWorkLambda.assertions().isCalledOnce()
|
||||
|
||||
incrementPushCounterResult.assertions()
|
||||
.isCalledOnce()
|
||||
|
|
@ -182,13 +107,6 @@ class DefaultPushHandlerTest {
|
|||
@Test
|
||||
fun `when classical PushData is received, but notifications are disabled, nothing happen`() =
|
||||
runTest {
|
||||
val aNotifiableMessageEvent = aNotifiableMessageEvent()
|
||||
val notifiableEventResult =
|
||||
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
|
||||
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
|
||||
}
|
||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
||||
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
||||
val aPushData = PushData(
|
||||
eventId = AN_EVENT_ID,
|
||||
|
|
@ -197,12 +115,15 @@ class DefaultPushHandlerTest {
|
|||
clientSecret = A_SECRET,
|
||||
)
|
||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||
val enqueuePushRequestResult = lambdaRecorder<PushRequest, Result<Unit>> { Result.success(Unit) }
|
||||
val pushHistoryService = FakePushHistoryService(
|
||||
onPushReceivedResult = onPushReceivedResult,
|
||||
enqueuePushRequest = enqueuePushRequestResult,
|
||||
)
|
||||
val submitWorkLambda = lambdaRecorder<WorkManagerRequestBuilder, Unit> {}
|
||||
val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = submitWorkLambda)
|
||||
|
||||
val defaultPushHandler = createDefaultPushHandler(
|
||||
onNotifiableEventsReceived = onNotifiableEventsReceived,
|
||||
notifiableEventsResult = notifiableEventResult,
|
||||
pushClientSecret = FakePushClientSecret(
|
||||
getUserIdFromSecretResult = { A_USER_ID }
|
||||
),
|
||||
|
|
@ -211,31 +132,24 @@ class DefaultPushHandlerTest {
|
|||
},
|
||||
incrementPushCounterResult = incrementPushCounterResult,
|
||||
pushHistoryService = pushHistoryService,
|
||||
workManagerScheduler = workManagerScheduler,
|
||||
)
|
||||
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
||||
|
||||
advanceTimeBy(300.milliseconds)
|
||||
|
||||
submitWorkLambda.assertions()
|
||||
.isNeverCalled()
|
||||
enqueuePushRequestResult.assertions()
|
||||
.isNeverCalled()
|
||||
incrementPushCounterResult.assertions()
|
||||
.isCalledOnce()
|
||||
notifiableEventResult.assertions()
|
||||
.isCalledOnce()
|
||||
onNotifiableEventsReceived.assertions()
|
||||
.isNeverCalled()
|
||||
onPushReceivedResult.assertions()
|
||||
.isCalledOnce()
|
||||
.isNeverCalled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when PushData is received, but client secret is not known, nothing happen`() =
|
||||
runTest {
|
||||
val aNotifiableMessageEvent = aNotifiableMessageEvent()
|
||||
val notifiableEventResult =
|
||||
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
|
||||
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
|
||||
}
|
||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
||||
fun `when PushData is received, but client secret is not known, nothing happen`() = runTest {
|
||||
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
||||
val aPushData = PushData(
|
||||
eventId = AN_EVENT_ID,
|
||||
|
|
@ -247,477 +161,85 @@ class DefaultPushHandlerTest {
|
|||
val pushHistoryService = FakePushHistoryService(
|
||||
onPushReceivedResult = onPushReceivedResult,
|
||||
)
|
||||
val submitWorkLambda = lambdaRecorder<WorkManagerRequestBuilder, Unit> {}
|
||||
val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = submitWorkLambda)
|
||||
val defaultPushHandler = createDefaultPushHandler(
|
||||
onNotifiableEventsReceived = onNotifiableEventsReceived,
|
||||
notifiableEventsResult = notifiableEventResult,
|
||||
pushClientSecret = FakePushClientSecret(
|
||||
getUserIdFromSecretResult = { null }
|
||||
),
|
||||
incrementPushCounterResult = incrementPushCounterResult,
|
||||
pushHistoryService = pushHistoryService,
|
||||
workManagerScheduler = workManagerScheduler,
|
||||
)
|
||||
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
||||
submitWorkLambda.assertions()
|
||||
.isNeverCalled()
|
||||
incrementPushCounterResult.assertions()
|
||||
.isCalledOnce()
|
||||
notifiableEventResult.assertions()
|
||||
.isNeverCalled()
|
||||
onNotifiableEventsReceived.assertions()
|
||||
.isNeverCalled()
|
||||
onPushReceivedResult.assertions()
|
||||
.isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when classical PushData is received, but a failure occurs (session not found), nothing happen`() {
|
||||
`test notification resolver failure`(
|
||||
notificationResolveResult = { _ ->
|
||||
Result.failure(NotificationResolverException.UnknownError("Unable to restore session"))
|
||||
},
|
||||
shouldSetOptimizationBatteryBanner = false,
|
||||
fun `when diagnostic PushData is received, the diagnostic push handler is informed`() = runTest {
|
||||
val aPushData = PushData(
|
||||
eventId = DefaultTestPush.TEST_EVENT_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
unread = 0,
|
||||
clientSecret = A_SECRET,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when classical PushData is received, but not able to resolve the event, the banner to disable battery optimization will be displayed`() {
|
||||
`test notification resolver failure`(
|
||||
notificationResolveResult = { requests: List<NotificationEventRequest> ->
|
||||
Result.success(
|
||||
requests.associateWith { Result.failure(NotificationResolverException.UnknownError("Unable to resolve event")) }
|
||||
)
|
||||
},
|
||||
shouldSetOptimizationBatteryBanner = true,
|
||||
val diagnosticPushHandler = DiagnosticPushHandler()
|
||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||
val pushHistoryService = FakePushHistoryService(
|
||||
onPushReceivedResult = onPushReceivedResult,
|
||||
)
|
||||
}
|
||||
|
||||
private fun `test notification resolver failure`(
|
||||
notificationResolveResult: (List<NotificationEventRequest>) -> Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>,
|
||||
shouldSetOptimizationBatteryBanner: Boolean,
|
||||
) {
|
||||
runTest {
|
||||
val notifiableEventResult =
|
||||
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, requests ->
|
||||
notificationResolveResult(requests)
|
||||
}
|
||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
||||
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
||||
val aPushData = PushData(
|
||||
eventId = AN_EVENT_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
unread = 0,
|
||||
clientSecret = A_SECRET,
|
||||
)
|
||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||
val pushHistoryService = FakePushHistoryService(
|
||||
onPushReceivedResult = onPushReceivedResult,
|
||||
)
|
||||
val showBatteryOptimizationBannerResult = lambdaRecorder<Unit> {}
|
||||
val defaultPushHandler = createDefaultPushHandler(
|
||||
onNotifiableEventsReceived = onNotifiableEventsReceived,
|
||||
notifiableEventsResult = notifiableEventResult,
|
||||
buildMeta = aBuildMeta(
|
||||
// Also test `lowPrivacyLoggingEnabled = false` here
|
||||
lowPrivacyLoggingEnabled = false
|
||||
),
|
||||
pushClientSecret = FakePushClientSecret(
|
||||
getUserIdFromSecretResult = { A_USER_ID }
|
||||
),
|
||||
incrementPushCounterResult = incrementPushCounterResult,
|
||||
mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(
|
||||
showBatteryOptimizationBannerResult = showBatteryOptimizationBannerResult,
|
||||
),
|
||||
pushHistoryService = pushHistoryService,
|
||||
)
|
||||
val defaultPushHandler = createDefaultPushHandler(
|
||||
diagnosticPushHandler = diagnosticPushHandler,
|
||||
incrementPushCounterResult = { },
|
||||
pushHistoryService = pushHistoryService,
|
||||
)
|
||||
diagnosticPushHandler.state.test {
|
||||
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
||||
|
||||
advanceTimeBy(300.milliseconds)
|
||||
|
||||
incrementPushCounterResult.assertions()
|
||||
.isCalledOnce()
|
||||
notifiableEventResult.assertions()
|
||||
.isCalledOnce()
|
||||
.with(value(A_USER_ID), any())
|
||||
onPushReceivedResult.assertions()
|
||||
.isCalledOnce()
|
||||
.with(any(), value(AN_EVENT_ID), value(A_ROOM_ID), value(A_USER_ID), value(false), value(true), any())
|
||||
showBatteryOptimizationBannerResult.assertions().let {
|
||||
if (shouldSetOptimizationBatteryBanner) {
|
||||
it.isCalledOnce()
|
||||
} else {
|
||||
it.isNeverCalled()
|
||||
}
|
||||
}
|
||||
awaitItem()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when ringing call PushData is received, the incoming call will be handled`() = runTest {
|
||||
val aPushData = PushData(
|
||||
eventId = AN_EVENT_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
unread = 0,
|
||||
clientSecret = A_SECRET,
|
||||
)
|
||||
val handleIncomingCallLambda = lambdaRecorder<
|
||||
CallType.RoomCall,
|
||||
EventId,
|
||||
UserId,
|
||||
String?,
|
||||
String?,
|
||||
String?,
|
||||
String,
|
||||
String?,
|
||||
Unit,
|
||||
> { _, _, _, _, _, _, _, _ -> }
|
||||
val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
|
||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||
val pushHistoryService = FakePushHistoryService(
|
||||
onPushReceivedResult = onPushReceivedResult,
|
||||
)
|
||||
val defaultPushHandler = createDefaultPushHandler(
|
||||
elementCallEntryPoint = elementCallEntryPoint,
|
||||
notifiableEventsResult = { _, _ ->
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
|
||||
Result.success(
|
||||
mapOf(
|
||||
request to Result.success(
|
||||
ResolvedPushEvent.Event(
|
||||
aNotifiableCallEvent(rtcNotificationType = RtcNotificationType.RING, timestamp = Instant.now().toEpochMilli())
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
incrementPushCounterResult = {},
|
||||
pushClientSecret = FakePushClientSecret(
|
||||
getUserIdFromSecretResult = { A_USER_ID }
|
||||
),
|
||||
onNotifiableEventsReceived = onNotifiableEventsReceived,
|
||||
pushHistoryService = pushHistoryService,
|
||||
)
|
||||
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
||||
|
||||
advanceTimeBy(300.milliseconds)
|
||||
|
||||
handleIncomingCallLambda.assertions().isCalledOnce()
|
||||
onNotifiableEventsReceived.assertions().isNeverCalled()
|
||||
onPushReceivedResult.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when notify call PushData is received, the incoming call will be treated as a normal notification`() = runTest {
|
||||
val aPushData = PushData(
|
||||
eventId = AN_EVENT_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
unread = 0,
|
||||
clientSecret = A_SECRET,
|
||||
)
|
||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
||||
val handleIncomingCallLambda = lambdaRecorder<
|
||||
CallType.RoomCall,
|
||||
EventId,
|
||||
UserId,
|
||||
String?,
|
||||
String?,
|
||||
String?,
|
||||
String,
|
||||
String?,
|
||||
Unit,
|
||||
> { _, _, _, _, _, _, _, _ -> }
|
||||
val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
|
||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||
val pushHistoryService = FakePushHistoryService(
|
||||
onPushReceivedResult = onPushReceivedResult,
|
||||
)
|
||||
val defaultPushHandler = createDefaultPushHandler(
|
||||
elementCallEntryPoint = elementCallEntryPoint,
|
||||
onNotifiableEventsReceived = onNotifiableEventsReceived,
|
||||
notifiableEventsResult = { _, _ ->
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
|
||||
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent(type = EventType.RTC_NOTIFICATION)))))
|
||||
},
|
||||
incrementPushCounterResult = {},
|
||||
pushClientSecret = FakePushClientSecret(
|
||||
getUserIdFromSecretResult = { A_USER_ID }
|
||||
),
|
||||
pushHistoryService = pushHistoryService,
|
||||
)
|
||||
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
||||
|
||||
advanceTimeBy(300.milliseconds)
|
||||
|
||||
handleIncomingCallLambda.assertions().isNeverCalled()
|
||||
onNotifiableEventsReceived.assertions().isCalledOnce()
|
||||
onPushReceivedResult.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when notify call PushData is received, the incoming call will be treated as a normal notification even if notification are disabled`() = runTest {
|
||||
val aPushData = PushData(
|
||||
eventId = AN_EVENT_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
unread = 0,
|
||||
clientSecret = A_SECRET,
|
||||
)
|
||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
||||
val handleIncomingCallLambda = lambdaRecorder<
|
||||
CallType.RoomCall,
|
||||
EventId,
|
||||
UserId,
|
||||
String?,
|
||||
String?,
|
||||
String?,
|
||||
String,
|
||||
String?,
|
||||
Unit,
|
||||
> { _, _, _, _, _, _, _, _ -> }
|
||||
val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
|
||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||
val pushHistoryService = FakePushHistoryService(
|
||||
onPushReceivedResult = onPushReceivedResult,
|
||||
)
|
||||
val defaultPushHandler = createDefaultPushHandler(
|
||||
elementCallEntryPoint = elementCallEntryPoint,
|
||||
onNotifiableEventsReceived = onNotifiableEventsReceived,
|
||||
notifiableEventsResult = { _, _ ->
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
|
||||
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableCallEvent()))))
|
||||
},
|
||||
incrementPushCounterResult = {},
|
||||
userPushStore = FakeUserPushStore().apply {
|
||||
setNotificationEnabledForDevice(false)
|
||||
},
|
||||
pushClientSecret = FakePushClientSecret(
|
||||
getUserIdFromSecretResult = { A_USER_ID }
|
||||
),
|
||||
pushHistoryService = pushHistoryService,
|
||||
)
|
||||
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
||||
|
||||
advanceTimeBy(300.milliseconds)
|
||||
|
||||
handleIncomingCallLambda.assertions().isCalledOnce()
|
||||
onNotifiableEventsReceived.assertions().isNeverCalled()
|
||||
onPushReceivedResult.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when a redaction is received, the onRedactedEventReceived is informed`() = runTest {
|
||||
val aPushData = PushData(
|
||||
eventId = AN_EVENT_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
unread = 0,
|
||||
clientSecret = A_SECRET,
|
||||
)
|
||||
val aRedaction = ResolvedPushEvent.Redaction(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
redactedEventId = AN_EVENT_ID_2,
|
||||
reason = null
|
||||
)
|
||||
val onRedactedEventReceived = lambdaRecorder<List<ResolvedPushEvent.Redaction>, Unit> { }
|
||||
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||
val pushHistoryService = FakePushHistoryService(
|
||||
onPushReceivedResult = onPushReceivedResult,
|
||||
)
|
||||
val defaultPushHandler = createDefaultPushHandler(
|
||||
onRedactedEventsReceived = onRedactedEventReceived,
|
||||
incrementPushCounterResult = incrementPushCounterResult,
|
||||
notifiableEventsResult = { _, _ ->
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
|
||||
Result.success(mapOf(request to Result.success(aRedaction)))
|
||||
},
|
||||
pushClientSecret = FakePushClientSecret(
|
||||
getUserIdFromSecretResult = { A_USER_ID }
|
||||
),
|
||||
pushHistoryService = pushHistoryService,
|
||||
)
|
||||
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
||||
|
||||
advanceTimeBy(300.milliseconds)
|
||||
|
||||
incrementPushCounterResult.assertions()
|
||||
.isCalledOnce()
|
||||
onRedactedEventReceived.assertions().isCalledOnce()
|
||||
.with(value(listOf(aRedaction)))
|
||||
onPushReceivedResult.assertions()
|
||||
.isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when diagnostic PushData is received, the diagnostic push handler is informed`() =
|
||||
runTest {
|
||||
val aPushData = PushData(
|
||||
eventId = DefaultTestPush.TEST_EVENT_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
unread = 0,
|
||||
clientSecret = A_SECRET,
|
||||
)
|
||||
val diagnosticPushHandler = DiagnosticPushHandler()
|
||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||
val pushHistoryService = FakePushHistoryService(
|
||||
onPushReceivedResult = onPushReceivedResult,
|
||||
)
|
||||
val defaultPushHandler = createDefaultPushHandler(
|
||||
diagnosticPushHandler = diagnosticPushHandler,
|
||||
incrementPushCounterResult = { },
|
||||
pushHistoryService = pushHistoryService,
|
||||
)
|
||||
diagnosticPushHandler.state.test {
|
||||
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
||||
awaitItem()
|
||||
}
|
||||
onPushReceivedResult.assertions()
|
||||
.isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when receiving several push notifications at the same time, those are batched before being processed`() = runTest {
|
||||
val aNotifiableMessageEvent = aNotifiableMessageEvent()
|
||||
val notifiableEventResult =
|
||||
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
|
||||
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
|
||||
}
|
||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
||||
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||
val pushHistoryService = FakePushHistoryService(
|
||||
onPushReceivedResult = onPushReceivedResult,
|
||||
)
|
||||
val aPushData = PushData(
|
||||
eventId = AN_EVENT_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
unread = 0,
|
||||
clientSecret = A_SECRET,
|
||||
)
|
||||
val anotherPushData = PushData(
|
||||
eventId = AN_EVENT_ID_2,
|
||||
roomId = A_ROOM_ID,
|
||||
unread = 0,
|
||||
clientSecret = A_SECRET,
|
||||
)
|
||||
val defaultPushHandler = createDefaultPushHandler(
|
||||
onNotifiableEventsReceived = onNotifiableEventsReceived,
|
||||
notifiableEventsResult = notifiableEventResult,
|
||||
pushClientSecret = FakePushClientSecret(
|
||||
getUserIdFromSecretResult = { A_USER_ID }
|
||||
),
|
||||
incrementPushCounterResult = incrementPushCounterResult,
|
||||
pushHistoryService = pushHistoryService,
|
||||
)
|
||||
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
||||
defaultPushHandler.handle(anotherPushData, A_PUSHER_INFO)
|
||||
|
||||
advanceTimeBy(300.milliseconds)
|
||||
|
||||
incrementPushCounterResult.assertions()
|
||||
.isCalledExactly(2)
|
||||
notifiableEventResult.assertions()
|
||||
.isCalledOnce()
|
||||
.with(value(A_USER_ID), matching<List<NotificationEventRequest>> { requests ->
|
||||
requests.size == 2 && requests.first().eventId == AN_EVENT_ID && requests.last().eventId == AN_EVENT_ID_2
|
||||
})
|
||||
onNotifiableEventsReceived.assertions()
|
||||
.isCalledOnce()
|
||||
onPushReceivedResult.assertions()
|
||||
.isCalledExactly(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when receiving a fallback event, we notify the push history service about it not being resolved`() = runTest {
|
||||
val aNotifiableFallbackEvent = aFallbackNotifiableEvent()
|
||||
val notifiableEventResult =
|
||||
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
|
||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
|
||||
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableFallbackEvent))))
|
||||
}
|
||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
||||
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
||||
var receivedFallbackEvent = false
|
||||
val onPushReceivedResult =
|
||||
lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, isResolved, _, comment ->
|
||||
receivedFallbackEvent = !isResolved && comment == "Unable to resolve event: ${aNotifiableFallbackEvent.cause}"
|
||||
}
|
||||
val pushHistoryService = FakePushHistoryService(
|
||||
onPushReceivedResult = onPushReceivedResult,
|
||||
)
|
||||
val aPushData = PushData(
|
||||
eventId = AN_EVENT_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
unread = 0,
|
||||
clientSecret = A_SECRET,
|
||||
)
|
||||
val defaultPushHandler = createDefaultPushHandler(
|
||||
onNotifiableEventsReceived = onNotifiableEventsReceived,
|
||||
notifiableEventsResult = notifiableEventResult,
|
||||
pushClientSecret = FakePushClientSecret(
|
||||
getUserIdFromSecretResult = { A_USER_ID }
|
||||
),
|
||||
incrementPushCounterResult = incrementPushCounterResult,
|
||||
pushHistoryService = pushHistoryService,
|
||||
)
|
||||
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
||||
|
||||
advanceTimeBy(300.milliseconds)
|
||||
|
||||
onNotifiableEventsReceived.assertions().isCalledOnce()
|
||||
|
||||
assertThat(receivedFallbackEvent).isTrue()
|
||||
}
|
||||
|
||||
private fun TestScope.createDefaultPushHandler(
|
||||
onNotifiableEventsReceived: (List<NotifiableEvent>) -> Unit = { lambdaError() },
|
||||
onRedactedEventsReceived: (List<ResolvedPushEvent.Redaction>) -> Unit = { lambdaError() },
|
||||
notifiableEventsResult: (SessionId, List<NotificationEventRequest>) -> Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>> =
|
||||
{ _, _ -> lambdaError() },
|
||||
private fun createDefaultPushHandler(
|
||||
incrementPushCounterResult: () -> Unit = { lambdaError() },
|
||||
mutableBatteryOptimizationStore: MutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(),
|
||||
userPushStore: UserPushStore = FakeUserPushStore(),
|
||||
userPushStore: FakeUserPushStore = FakeUserPushStore(),
|
||||
pushClientSecret: PushClientSecret = FakePushClientSecret(),
|
||||
buildMeta: BuildMeta = aBuildMeta(),
|
||||
diagnosticPushHandler: DiagnosticPushHandler = DiagnosticPushHandler(),
|
||||
elementCallEntryPoint: FakeElementCallEntryPoint = FakeElementCallEntryPoint(),
|
||||
notificationChannels: FakeNotificationChannels = FakeNotificationChannels(),
|
||||
pushHistoryService: PushHistoryService = FakePushHistoryService(),
|
||||
syncOnNotifiableEvent: SyncOnNotifiableEvent = SyncOnNotifiableEvent {},
|
||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.SyncNotificationsWithWorkManager.key to false)),
|
||||
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
|
||||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||
systemClock: FakeSystemClock = FakeSystemClock(),
|
||||
buildVersionSdkIntProvider: FakeBuildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(33),
|
||||
resultProcessor: FakeNotificationResultProcessor = FakeNotificationResultProcessor(
|
||||
emit = { Result.success(Unit) },
|
||||
start = {},
|
||||
stop = {},
|
||||
),
|
||||
): DefaultPushHandler {
|
||||
return DefaultPushHandler(
|
||||
onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventsReceived),
|
||||
onRedactedEventReceived = FakeOnRedactedEventReceived(onRedactedEventsReceived),
|
||||
incrementPushDataStore = object : IncrementPushDataStore {
|
||||
override suspend fun incrementPushCounter() {
|
||||
incrementPushCounterResult()
|
||||
}
|
||||
},
|
||||
mutableBatteryOptimizationStore = mutableBatteryOptimizationStore,
|
||||
userPushStoreFactory = FakeUserPushStoreFactory { userPushStore },
|
||||
pushClientSecret = pushClientSecret,
|
||||
buildMeta = buildMeta,
|
||||
diagnosticPushHandler = diagnosticPushHandler,
|
||||
elementCallEntryPoint = elementCallEntryPoint,
|
||||
notificationChannels = notificationChannels,
|
||||
pushHistoryService = pushHistoryService,
|
||||
// We don't use a fake here so we can perform tests that are a bit more end to end
|
||||
resolverQueue = DefaultNotificationResolverQueue(
|
||||
notifiableEventResolver = FakeNotifiableEventResolver(notifiableEventsResult),
|
||||
appCoroutineScope = backgroundScope,
|
||||
workManagerScheduler = workManagerScheduler,
|
||||
featureFlagService = featureFlagService,
|
||||
workerDataConverter = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()),
|
||||
buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(33),
|
||||
),
|
||||
appCoroutineScope = backgroundScope,
|
||||
fallbackNotificationFactory = FallbackNotificationFactory(
|
||||
clock = FakeSystemClock(),
|
||||
),
|
||||
syncOnNotifiableEvent = syncOnNotifiableEvent,
|
||||
featureFlagService = featureFlagService,
|
||||
analyticsService = analyticsService,
|
||||
systemClock = systemClock,
|
||||
workManagerScheduler = workManagerScheduler,
|
||||
buildVersionSdkIntProvider = buildVersionSdkIntProvider,
|
||||
resultProcessor = resultProcessor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
|||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||
import io.element.android.libraries.matrix.test.sync.FakeSyncService
|
||||
import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.aNotificationEventRequest
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.aPushRequest
|
||||
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
|
|
@ -53,7 +52,7 @@ class SyncOnNotifiableEventTest {
|
|||
givenGetRoomResult(A_ROOM_ID, room)
|
||||
}
|
||||
|
||||
private val notificationRequest = aNotificationEventRequest()
|
||||
private val notificationRequest = aPushRequest()
|
||||
|
||||
@Test
|
||||
fun `when feature flag is disabled, nothing happens`() = runTest {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue