Merge branch 'develop' into fix-6232
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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
Reference in New Issue
Block a user