Merge branch 'develop' into fix-6232

This commit is contained in:
Timur Gilfanov
2026-03-05 13:34:09 +04:00
committed by GitHub
199 changed files with 2907 additions and 2420 deletions

File diff suppressed because one or more lines are too long

View File

@@ -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,

View File

@@ -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,
)
}

View File

@@ -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 */

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
}

View File

@@ -21,7 +21,6 @@ internal data class ComposerAlertMoleculeParams(
internal class ComposerAlertMoleculeParamsProvider : PreviewParameterProvider<ComposerAlertMoleculeParams> {
private val allLevels = sequenceOf(
ComposerAlertLevel.Default,
ComposerAlertLevel.Info,
ComposerAlertLevel.Critical
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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>

View File

@@ -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.
*/

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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.

View File

@@ -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")
}
}

View File

@@ -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) }
}
}

View File

@@ -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())
}

View File

@@ -91,7 +91,7 @@ class RustMediaLoader(
return if (json != null) {
RustMediaSource.fromJson(json)
} else {
RustMediaSource.fromUrl(url)
RustMediaSource.fromUrl(safeUrl)
}
}
}

View File

@@ -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 -> {

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)))
}
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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" }
}

View File

@@ -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()

View File

@@ -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,

View File

@@ -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,
)

View File

@@ -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>)
}

View File

@@ -97,6 +97,7 @@ sqldelight {
databases {
create("PushDatabase") {
schemaOutputDirectory = File("src/main/sqldelight/databases")
verifyMigrations = true
}
}
}

View File

@@ -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?,

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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")
}
}

View File

@@ -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)
}
}
}
}
}
}

View File

@@ -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,
)
}
}

View File

@@ -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),
}

View File

@@ -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)

View File

@@ -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>)
}

View File

@@ -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,

View File

@@ -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() }

View File

@@ -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() }

View File

@@ -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,
)
}

View File

@@ -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,
)
}

View File

@@ -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)))
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -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 ?);

View File

@@ -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 ?);

View 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);

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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,
)
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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(),
)

View File

@@ -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,
)
}
}

View File

@@ -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