Merge branch 'develop' into feature/bma/notificationCustomSound

This commit is contained in:
Benoit Marty 2026-02-13 15:48:19 +01:00 committed by GitHub
commit c1f3233dce
66 changed files with 894 additions and 590 deletions

View file

@ -18,9 +18,8 @@ jobs:
build-apk: build-apk:
name: Build APK name: Build APK
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Allow one per PR.
concurrency: concurrency:
group: ${{ format('maestro-{0}', github.ref) }} group: ${{ format('maestro-build-{0}', github.ref) }}
cancel-in-progress: true cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@ -57,10 +56,10 @@ jobs:
name: Maestro test suite name: Maestro test suite
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [ build-apk ] needs: [ build-apk ]
# Allow one per PR. # Allow only one to run at a time, since they use the same environment.
# Otherwise, tests running in parallel can break each other.
concurrency: concurrency:
group: ${{ format('maestro-{0}', github.ref) }} group: maestro-test
cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch' if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
@ -110,6 +109,21 @@ jobs:
retention-days: 5 retention-days: 5
overwrite: true overwrite: true
if-no-files-found: error if-no-files-found: error
- name: Update summary (success)
if: steps.maestro_test.outcome == 'success'
run: |
echo "### Maestro tests worked :rocket:!"
- name: Update summary (failure)
if: steps.maestro_test.outcome != 'success'
run: |
LOG_FILE=$(find ~/.maestro/tests/ -name maestro.log)
echo "Log file: $LOG_FILE"
LOG_LINES="$(tail -n 30 $LOG_FILE)"
echo "### :x: Maestro tests failed...
\`\`\`
$LOG_LINES
\`\`\`" >> $GITHUB_STEP_SUMMARY
- name: Fail the workflow in case of error in test - name: Fail the workflow in case of error in test
if: steps.maestro_test.outcome != 'success' if: steps.maestro_test.outcome != 'success'
run: | run: |

View file

@ -8,6 +8,13 @@
# Please see LICENSE in the repository root for full details. # Please see LICENSE in the repository root for full details.
# #
# First we disable the onboarding flow on Chrome, which is a source of issues
# (see https://stackoverflow.com/a/64629745)
echo "Disabling Chrome onboarding flow"
adb shell am set-debug-app --persistent com.android.chrome
adb shell 'echo "chrome --disable-fre --no-default-browser-check --no-first-run" > /data/local/tmp/chrome-command-line'
adb shell am start -n com.android.chrome/com.google.android.apps.chrome.Main
adb install -r $1 adb install -r $1
echo "Starting the screen recording..." echo "Starting the screen recording..."
adb push .github/workflows/scripts/maestro/local-recording.sh /data/local/tmp/ adb push .github/workflows/scripts/maestro/local-recording.sh /data/local/tmp/

View file

@ -8,27 +8,6 @@ appId: ${MAESTRO_APP_ID}
- tapOn: - tapOn:
id: "login-continue" id: "login-continue"
## MAS page ## MAS page
## Conditional workflow to pass the Chrome first launch welcome page.
- retry:
maxRetries: 3
commands:
- runFlow:
when:
visible: 'Use without an account'
commands:
- tapOn: "Use without an account"
## For older chrome versions
- runFlow:
when:
visible: 'Accept & continue'
commands:
- tapOn: "Accept & continue"
- runFlow:
when:
visible: 'No thanks'
commands:
- tapOn: "No thanks"
## Working when running Maestro locally, but not on the CI yet.
- retry: - retry:
maxRetries: 3 maxRetries: 3
commands: commands:

View file

@ -1,5 +1,8 @@
appId: ${MAESTRO_APP_ID} appId: ${MAESTRO_APP_ID}
--- ---
- extendedWaitUntil:
visible: "Enter recovery key"
timeout: 30000
- takeScreenshot: build/maestro/150-Verify - takeScreenshot: build/maestro/150-Verify
- tapOn: "Enter recovery key" - tapOn: "Enter recovery key"
- tapOn: - tapOn:
@ -7,7 +10,10 @@ appId: ${MAESTRO_APP_ID}
- inputText: ${MAESTRO_RECOVERY_KEY} - inputText: ${MAESTRO_RECOVERY_KEY}
- hideKeyboard - hideKeyboard
- tapOn: "Continue" - tapOn: "Continue"
- extendedWaitUntil: - retry:
visible: "Device verified" maxRetries: 3
timeout: 30000 commands:
- extendedWaitUntil:
visible: "Device verified"
timeout: 30000
- tapOn: "Continue" - tapOn: "Continue"

View file

@ -2,4 +2,4 @@ appId: ${MAESTRO_APP_ID}
--- ---
- extendedWaitUntil: - extendedWaitUntil:
visible: "Be in your element" visible: "Be in your element"
timeout: 10000 timeout: 30000

View file

@ -2,4 +2,4 @@ appId: ${MAESTRO_APP_ID}
--- ---
- extendedWaitUntil: - extendedWaitUntil:
visible: "Confirm your identity" visible: "Confirm your identity"
timeout: 20000 timeout: 60000

View file

@ -1,3 +1,77 @@
Changes in Element X v26.02.0
=============================
<!-- Release notes generated using configuration in .github/release.yml at v26.02.0 -->
## What's Changed
### ✨ Features
* When a background SDK task fails, react in the client by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6166
* Enable space feature flags by default by @ganfra in https://github.com/element-hq/element-x-android/pull/6171
### 🙌 Improvements
* Improve space management with pagination and partial failure handling by @ganfra in https://github.com/element-hq/element-x-android/pull/6099
* Iterate on QrCode login error buttons by @bmarty in https://github.com/element-hq/element-x-android/pull/6101
* Update icon shown for world_readable rooms by @richvdh in https://github.com/element-hq/element-x-android/pull/6111
* QRCode login: treat not found error as expired error. by @bmarty in https://github.com/element-hq/element-x-android/pull/6161
* Iterate on Space related UI by @ganfra in https://github.com/element-hq/element-x-android/pull/6150
### 🔒 Security
* Ensure aspect ratio of images in the timeline is restricted by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6168
### 🐛 Bugfixes
* Ensure that Element Call activity is not closed when using an external link by @bmarty in https://github.com/element-hq/element-x-android/pull/6114
* Refresh a Space's room list after creating a room in it by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6135
* When creating a DM, set room history visibility to `invited` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6138
* Fix back navigation after creating a room in a space by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6134
* Fix `LinkifyHelper` index out of bounds with parenthesis by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6140
* Change role screen won't be dismissed until changes take effect by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6141
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6122
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6155
### 🧱 Build
* Try fixing Maestro tests (again) by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6149
* Add a stale bot for X-Needs-Info issues. by @bmarty in https://github.com/element-hq/element-x-android/pull/6153
* [Release script] Ensure that the release version will match the next Monday date by @bmarty in https://github.com/element-hq/element-x-android/pull/6152
### 🚧 In development 🚧
* Add Space Filters feature for Room List by @ganfra in https://github.com/element-hq/element-x-android/pull/6136
* Add history sharing badges to room details by @kaylendog in https://github.com/element-hq/element-x-android/pull/6132
### Dependency upgrades
* Update dependency androidx.work:work-runtime-ktx to v2.11.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6105
* Update metro to v0.10.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6106
* Update camera to v1.5.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6103
* Update activity to v1.12.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6104
* Update dependency com.posthog:posthog-android to v3.30.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6120
* Update kotlin by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6102
* Update roborazzi to v1.58.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6124
* Update kover by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6139
* Update dependency com.posthog:posthog-android to v3.31.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6145
* Update kotlin by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6142
* Update media3 to v1.9.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6151
* Update dependency org.matrix.rustcomponents:sdk-android to v26.02.6 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6144
* Update firebaseAppDistribution to v5.2.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6146
* Update dependency com.google.firebase:firebase-bom to v34.9.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6148
* Update dependency io.sentry:sentry-android to v8.32.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6157
* Update metro to v0.10.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6164
* Update dependency org.matrix.rustcomponents:sdk-android to v26.2.10 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6169
* chore(deps): update plugin paparazzi to v2.0.0-alpha04 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6048
* fix(deps): update dependency org.jetbrains.kotlinx:kover-gradle-plugin to v0.9.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6173
* fix(deps): update haze to v1.7.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6175
### Others
* Improve favorite wording and icon of room by @bmarty in https://github.com/element-hq/element-x-android/pull/6097
* Add special flow for leaving a space as the last owner by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6112
* Remove `runBlocking` in `ThreadedMessagesNode` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6108
* Revert "Add "call.pro.element.io" in the list of known hosts for Element Call." by @bmarty in https://github.com/element-hq/element-x-android/pull/6118
* Refactor room list filtering to use Rust SDK by @ganfra in https://github.com/element-hq/element-x-android/pull/6117
* Ensure http 429 are retried 3 times before failing. by @bmarty in https://github.com/element-hq/element-x-android/pull/6119
* Remove `JoinRule.Private` from the codebase by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6129
* Fix voice message recording not starting after permission is granted by @kknappe in https://github.com/element-hq/element-x-android/pull/6109
* Use correct bg color. by @bmarty in https://github.com/element-hq/element-x-android/pull/6165
* Document "Developer options" and remove outdated instructions by @MadLittleMods in https://github.com/element-hq/element-x-android/pull/6162
* Update SpaceFilterButton selected state color by @ganfra in https://github.com/element-hq/element-x-android/pull/6178
## New Contributors
* @kknappe made their first contribution in https://github.com/element-hq/element-x-android/pull/6109
* @MadLittleMods made their first contribution in https://github.com/element-hq/element-x-android/pull/6162
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.01.2...v26.02.0
Changes in Element X v26.01.2 Changes in Element X v26.01.2
============================= =============================

View file

@ -79,7 +79,6 @@ import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -211,8 +210,6 @@ class LoggedInFlowNode(
onCreate = { onCreate = {
analyticsRoomListStateWatcher.start() analyticsRoomListStateWatcher.start()
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId) appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
// TODO We do not support Space yet, so directly navigate to main space
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
loggedInFlowProcessor.observeEvents(sessionCoroutineScope) loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
matrixClient.sessionVerificationService.setListener(verificationListener) matrixClient.sessionVerificationService.setListener(verificationListener)
mediaPreviewConfigMigration() mediaPreviewConfigMigration()
@ -242,7 +239,6 @@ class LoggedInFlowNode(
} }
}, },
onDestroy = { onDestroy = {
appNavigationStateService.onLeavingSpace(id)
appNavigationStateService.onLeavingSession(id) appNavigationStateService.onLeavingSession(id)
loggedInFlowProcessor.stopObserving() loggedInFlowProcessor.stopObserving()
matrixClient.sessionVerificationService.setListener(null) matrixClient.sessionVerificationService.setListener(null)

View file

@ -24,6 +24,7 @@
* [Logging](#logging) * [Logging](#logging)
* [Translations](#translations) * [Translations](#translations)
* [Rageshake](#rageshake) * [Rageshake](#rageshake)
* [Developer options](#developer-options)
* [Tips](#tips) * [Tips](#tips)
* [Happy coding!](#happy-coding) * [Happy coding!](#happy-coding)
@ -409,14 +410,31 @@ The data will be sent to an internal server, which is not publicly accessible. A
Rageshake can be very useful to get logs from a release version of the application. Rageshake can be very useful to get logs from a release version of the application.
#### Developer options
> [!WARNING]
> Developer options can result in unexpected application behavior or destructive
> actions. Use with caution and only if you are instructed by someone at Element or are
> already familiar.
These options provide advanced controls for testing and debugging. They are visible by
default in debug and nightly builds but are hidden in release versions.
**Enabling in release builds:** Navigate to application settings and tap the version
number at the bottom 7 times. After tapping, a new "Developer options" entry will appear
at the bottom of the list.
The developer options include feature flags, notification/push history, Element call
customization, Rust SDK log levels, per-feature tracing toggles, Showkase to debug UI
components, rageshake controls, app crash controls, cache details/controls, persistent
storage maintenance tasks.
Keywords: Developer settings, developer mode
### Tips ### Tips
- Element Android has a `developer mode` in the `Settings/Advanced settings`. Other useful options are available here; (TODO Not supported yet!)
- Show hidden Events can also help to debug feature. When developer mode is enabled, it is possible to view the source (= the Json content) of any Events; (TODO
Not supported yet!)
- Type `/devtools` in a Room composer to access a developer menu. There are some other entry points. Developer mode has to be enabled; (TODO Not supported yet!)
- Hidden debug menu: when developer mode is enabled and on debug build, there are some extra screens that can be accessible using the green wheel. In those
screens, it will be possible to toggle some feature flags; (TODO Not supported yet!)
- Using logcat, filtering with `Compositions` can help you to understand what screen are currently displayed on your device. Searching for string displayed on - Using logcat, filtering with `Compositions` can help you to understand what screen are currently displayed on your device. Searching for string displayed on
the screen can also help to find the running code in the codebase. the screen can also help to find the running code in the codebase.
- When this is possible, prefer using `sealed interface` instead of `sealed class`; - When this is possible, prefer using `sealed interface` instead of `sealed class`;

View file

@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -29,6 +29,7 @@ class DeclineCallBroadcastReceiver : BroadcastReceiver() {
companion object { companion object {
const val EXTRA_NOTIFICATION_DATA = "EXTRA_NOTIFICATION_DATA" const val EXTRA_NOTIFICATION_DATA = "EXTRA_NOTIFICATION_DATA"
} }
@Inject @Inject
lateinit var activeCallManager: ActiveCallManager lateinit var activeCallManager: ActiveCallManager
@ -40,7 +41,13 @@ class DeclineCallBroadcastReceiver : BroadcastReceiver() {
?: return ?: return
context.bindings<CallBindings>().inject(this) context.bindings<CallBindings>().inject(this)
appCoroutineScope.launch { appCoroutineScope.launch {
activeCallManager.hungUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) activeCallManager.hangUpCall(
callType = CallType.RoomCall(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
),
notificationData = notificationData,
)
} }
} }
} }

View file

@ -100,7 +100,7 @@ class CallScreenPresenter(
) )
} }
onDispose { onDispose {
appCoroutineScope.launch { activeCallManager.hungUpCall(callType) } appCoroutineScope.launch { activeCallManager.hangUpCall(callType) }
} }
} }

View file

@ -118,7 +118,7 @@ class IncomingCallActivity : AppCompatActivity() {
private fun onCancel() { private fun onCancel() {
val activeCall = activeCallManager.activeCall.value ?: return val activeCall = activeCallManager.activeCall.value ?: return
appCoroutineScope.launch { appCoroutineScope.launch {
activeCallManager.hungUpCall(callType = activeCall.callType) activeCallManager.hangUpCall(callType = activeCall.callType)
} }
} }
} }

View file

@ -51,6 +51,9 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
/**
* Ref: https://www.figma.com/design/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=16501-5740
*/
@Composable @Composable
internal fun IncomingCallScreen( internal fun IncomingCallScreen(
notificationData: CallNotificationData, notificationData: CallNotificationData,
@ -94,11 +97,8 @@ internal fun IncomingCallScreen(
) )
} }
Row( Row(
modifier = Modifier modifier = Modifier.padding(bottom = 64.dp),
.fillMaxWidth() horizontalArrangement = Arrangement.spacedBy(48.dp),
.padding(start = 24.dp, end = 24.dp, bottom = 64.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
ActionButton( ActionButton(
size = 64.dp, size = 64.dp,
@ -108,7 +108,6 @@ internal fun IncomingCallScreen(
backgroundColor = ElementTheme.colors.iconSuccessPrimary, backgroundColor = ElementTheme.colors.iconSuccessPrimary,
borderColor = ElementTheme.colors.borderSuccessSubtle borderColor = ElementTheme.colors.borderSuccessSubtle
) )
ActionButton( ActionButton(
size = 64.dp, size = 64.dp,
onClick = onCancel, onClick = onCancel,
@ -143,7 +142,7 @@ private fun ActionButton(
onClick = onClick, onClick = onClick,
colors = IconButtonDefaults.filledIconButtonColors( colors = IconButtonDefaults.filledIconButtonColors(
containerColor = backgroundColor, containerColor = backgroundColor,
contentColor = Color.White, contentColor = ElementTheme.colors.iconOnSolidPrimary,
) )
) { ) {
Icon( Icon(

View file

@ -72,10 +72,14 @@ interface ActiveCallManager {
suspend fun registerIncomingCall(notificationData: CallNotificationData) suspend fun registerIncomingCall(notificationData: CallNotificationData)
/** /**
* Called when the active call has been hung up. It will remove any existing UI and the active call. * Called to hang up the active call. It will hang up the call and remove any existing UI and the active call.
* @param callType The type of call that the user hung up, either an external url one or a room one. * @param callType The type of call that the user hangs up, either an external url one or a room one.
* @param notificationData The data for the incoming call notification.
*/ */
suspend fun hungUpCall(callType: CallType) suspend fun hangUpCall(
callType: CallType,
notificationData: CallNotificationData? = null,
)
/** /**
* Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall]. * Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall].
@ -192,12 +196,28 @@ class DefaultActiveCallManager(
} }
} }
override suspend fun hungUpCall(callType: CallType) = mutex.withLock { override suspend fun hangUpCall(
Timber.tag(tag).d("Hung up call: $callType") callType: CallType,
notificationData: CallNotificationData?,
) = mutex.withLock {
Timber.tag(tag).d("Hang up call: $callType")
cancelIncomingCallNotification()
val currentActiveCall = activeCall.value ?: run { val currentActiveCall = activeCall.value ?: run {
// activeCall.value can be null if the application has been killed while the call was ringing
// Build a currentActiveCall with the provided parameters.
notificationData?.let {
ActiveCall(
callType = callType,
callState = CallState.Ringing(
notificationData = notificationData,
)
)
}
} ?: run {
Timber.tag(tag).w("No active call, ignoring hang up") Timber.tag(tag).w("No active call, ignoring hang up")
return@withLock return@withLock
} }
if (currentActiveCall.callType != callType) { if (currentActiveCall.callType != callType) {
Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring") Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring")
return@withLock return@withLock
@ -208,9 +228,13 @@ class DefaultActiveCallManager(
matrixClientProvider.getOrRestore(notificationData.sessionId).getOrNull() matrixClientProvider.getOrRestore(notificationData.sessionId).getOrNull()
?.getRoom(notificationData.roomId) ?.getRoom(notificationData.roomId)
?.declineCall(notificationData.eventId) ?.declineCall(notificationData.eventId)
?.onFailure {
Timber.e(it, "Failed to decline incoming call")
}
?: run {
Timber.tag(tag).d("Couldn't find session or room to decline call for incoming call")
}
} }
cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) { if (activeWakeLock?.isHeld == true) {
Timber.tag(tag).d("Releasing partial wakelock after hang up") Timber.tag(tag).d("Releasing partial wakelock after hang up")
activeWakeLock.release() activeWakeLock.release()
@ -221,7 +245,6 @@ class DefaultActiveCallManager(
override suspend fun joinedCall(callType: CallType) = mutex.withLock { override suspend fun joinedCall(callType: CallType) = mutex.withLock {
Timber.tag(tag).d("Joined call: $callType") Timber.tag(tag).d("Joined call: $callType")
cancelIncomingCallNotification() cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) { if (activeWakeLock?.isHeld == true) {
Timber.tag(tag).d("Releasing partial wakelock after joining call") Timber.tag(tag).d("Releasing partial wakelock after joining call")

View file

@ -82,6 +82,13 @@ class WebViewAudioManager(
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, AudioDeviceInfo.TYPE_BUILTIN_EARPIECE,
) )
private val audioDeviceComparator = Comparator<AudioDeviceInfo> { a, b ->
// If the device type is not in the wantedDeviceTypes list, we give it a high index, (i.e. low priority)
val indexOfA = wantedDeviceTypes.indexOf(a.type).let { if (it == -1) Int.MAX_VALUE else it }
val indexOfB = wantedDeviceTypes.indexOf(b.type).let { if (it == -1) Int.MAX_VALUE else it }
indexOfA.compareTo(indexOfB)
}
private val audioManager = webView.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager private val audioManager = webView.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
/** /**
@ -134,7 +141,7 @@ class WebViewAudioManager(
if (validNewDevices.isEmpty()) return if (validNewDevices.isEmpty()) return
// We need to calculate the available devices ourselves, since calling `listAudioDevices` will return an outdated list // We need to calculate the available devices ourselves, since calling `listAudioDevices` will return an outdated list
val audioDevices = (listAudioDevices() + validNewDevices).distinctBy { it.id } val audioDevices = (listAudioDevices() + validNewDevices).distinctBy { it.id }.sortedWith(audioDeviceComparator)
setAvailableAudioDevices(audioDevices.map(SerializableAudioDevice::fromAudioDeviceInfo)) setAvailableAudioDevices(audioDevices.map(SerializableAudioDevice::fromAudioDeviceInfo))
// This should automatically switch to a new device if it has a higher priority than the current one // This should automatically switch to a new device if it has a higher priority than the current one
selectDefaultAudioDevice(audioDevices) selectDefaultAudioDevice(audioDevices)
@ -294,7 +301,7 @@ class WebViewAudioManager(
} }
/** /**
* Returns the list of available audio devices. * Returns the list of available audio devices, sorted by likelihood of it being used for communication.
* *
* On Android 11 ([Build.VERSION_CODES.R]) and lower, it returns the list of output devices as a fallback. * On Android 11 ([Build.VERSION_CODES.R]) and lower, it returns the list of output devices as a fallback.
*/ */
@ -304,7 +311,7 @@ class WebViewAudioManager(
} else { } else {
val rawAudioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) val rawAudioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
rawAudioDevices.filter { it.type in wantedDeviceTypes && it.isSink } rawAudioDevices.filter { it.type in wantedDeviceTypes && it.isSink }
} }.sortedWith(audioDeviceComparator)
} }
/** /**
@ -323,19 +330,12 @@ class WebViewAudioManager(
} }
/** /**
* Selects the default audio device based on the available devices. * Selects the default audio device based on the sorted available devices.
* *
* @param availableDevices The list of available audio devices to select from. If not provided, it will use the current list of audio devices. * @param availableDevices The list of available audio devices to select from. If not provided, it will use the current list of audio devices.
*/ */
private fun selectDefaultAudioDevice(availableDevices: List<AudioDeviceInfo> = listAudioDevices()) { private fun selectDefaultAudioDevice(availableDevices: List<AudioDeviceInfo> = listAudioDevices()) {
val selectedDevice = availableDevices val selectedDevice = availableDevices.firstOrNull()
.minByOrNull {
wantedDeviceTypes.indexOf(it.type).let { index ->
// If the device type is not in the wantedDeviceTypes list, we give it a low priority
if (index == -1) Int.MAX_VALUE else index
}
}
expectedNewCommunicationDeviceId = selectedDevice?.id expectedNewCommunicationDeviceId = selectedDevice?.id
audioManager.selectAudioDevice(selectedDevice) audioManager.selectAudioDevice(selectedDevice)

View file

@ -155,7 +155,7 @@ class DefaultActiveCallManagerTest {
} }
@Test @Test
fun `hungUpCall - removes existing call if the CallType matches`() = runTest { fun `hangUpCall - removes existing call if the CallType matches`() = runTest {
setupShadowPowerManager() setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat) val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
@ -165,7 +165,7 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue() assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) manager.hangUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
assertThat(manager.activeCall.value).isNull() assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse() assertThat(manager.activeWakeLock?.isHeld).isFalse()
@ -192,13 +192,41 @@ class DefaultActiveCallManagerTest {
val notificationData = aCallNotificationData(roomId = A_ROOM_ID) val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
manager.registerIncomingCall(notificationData) manager.registerIncomingCall(notificationData)
manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) manager.hangUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
coVerify { coVerify {
room.declineCall(notificationEventId = notificationData.eventId) room.declineCall(notificationEventId = notificationData.eventId)
} }
} }
@Test
fun `Decline event - Hangup on a unknown call should send a decline event`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val room = mockk<JoinedRoom>(relaxed = true)
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
val manager = createActiveCallManager(
matrixClientProvider = clientProvider,
notificationManagerCompat = notificationManagerCompat
)
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
// Do not register the incoming call, so the manager doesn't know about it
manager.hangUpCall(
callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId),
notificationData = notificationData,
)
coVerify {
room.declineCall(notificationEventId = notificationData.eventId)
}
}
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `Decline event - Declining from another session should stop ringing`() = runTest { fun `Decline event - Declining from another session should stop ringing`() = runTest {
@ -269,7 +297,7 @@ class DefaultActiveCallManagerTest {
} }
@Test @Test
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest { fun `hangUpCall - does nothing if the CallType doesn't match`() = runTest {
setupShadowPowerManager() setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat) val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
@ -278,11 +306,12 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue() assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hungUpCall(CallType.ExternalUrl("https://example.com")) manager.hangUpCall(CallType.ExternalUrl("https://example.com"))
assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue() assertThat(manager.activeWakeLock?.isHeld).isTrue()
verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) } // The notification is always cancelled do not block the user
verify(exactly = 1) { notificationManagerCompat.cancel(notificationId) }
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)

View file

@ -17,7 +17,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
class FakeActiveCallManager( class FakeActiveCallManager(
var registerIncomingCallResult: (CallNotificationData) -> Unit = {}, var registerIncomingCallResult: (CallNotificationData) -> Unit = {},
var hungUpCallResult: (CallType) -> Unit = {}, var hangUpCallResult: (CallType, CallNotificationData?) -> Unit = { _, _ -> },
var joinedCallResult: (CallType) -> Unit = {}, var joinedCallResult: (CallType) -> Unit = {},
) : ActiveCallManager { ) : ActiveCallManager {
override val activeCall = MutableStateFlow<ActiveCall?>(null) override val activeCall = MutableStateFlow<ActiveCall?>(null)
@ -26,8 +26,8 @@ class FakeActiveCallManager(
registerIncomingCallResult(notificationData) registerIncomingCallResult(notificationData)
} }
override suspend fun hungUpCall(callType: CallType) = simulateLongTask { override suspend fun hangUpCall(callType: CallType, notificationData: CallNotificationData?) = simulateLongTask {
hungUpCallResult(callType) hangUpCallResult(callType, notificationData)
} }
override suspend fun joinedCall(callType: CallType) = simulateLongTask { override suspend fun joinedCall(callType: CallType) = simulateLongTask {

View file

@ -219,6 +219,7 @@ class ConfigureRoomPresenterTest {
fun `present - when creating a room in a space if the room doesn't receive the power levels value it can't be added to the space`() = runTest { fun `present - when creating a room in a space if the room doesn't receive the power levels value it can't be added to the space`() = runTest {
val addChildToSpaceResult = lambdaRecorder<RoomId, RoomId, Result<Unit>> { _, _ -> Result.success(Unit) } val addChildToSpaceResult = lambdaRecorder<RoomId, RoomId, Result<Unit>> { _, _ -> Result.success(Unit) }
val spaceService = FakeSpaceService( val spaceService = FakeSpaceService(
editableSpacesResult = { Result.success(emptyList()) },
addChildToSpaceResult = addChildToSpaceResult, addChildToSpaceResult = addChildToSpaceResult,
) )
val roomInfoFlow = MutableStateFlow<Optional<RoomInfo>>(Optional.empty()) val roomInfoFlow = MutableStateFlow<Optional<RoomInfo>>(Optional.empty())
@ -261,6 +262,7 @@ class ConfigureRoomPresenterTest {
fun `present - creating a room and adding it into a parent space works when all the data is available`() = runTest { fun `present - creating a room and adding it into a parent space works when all the data is available`() = runTest {
val addChildToSpaceResult = lambdaRecorder<RoomId, RoomId, Result<Unit>> { _, _ -> Result.success(Unit) } val addChildToSpaceResult = lambdaRecorder<RoomId, RoomId, Result<Unit>> { _, _ -> Result.success(Unit) }
val spaceService = FakeSpaceService( val spaceService = FakeSpaceService(
editableSpacesResult = { Result.success(emptyList()) },
addChildToSpaceResult = addChildToSpaceResult, addChildToSpaceResult = addChildToSpaceResult,
) )
val roomInfoFlow = MutableStateFlow<Optional<RoomInfo>>(Optional.empty()) val roomInfoFlow = MutableStateFlow<Optional<RoomInfo>>(Optional.empty())
@ -522,7 +524,9 @@ class ConfigureRoomPresenterTest {
private fun createMatrixClient( private fun createMatrixClient(
isAliasAvailable: Boolean = true, isAliasAvailable: Boolean = true,
spaceService: FakeSpaceService = FakeSpaceService(), spaceService: FakeSpaceService = FakeSpaceService(
editableSpacesResult = { Result.success(emptyList()) }
),
) = FakeMatrixClient( ) = FakeMatrixClient(
userIdServerNameLambda = { "matrix.org" }, userIdServerNameLambda = { "matrix.org" },
resolveRoomAliasResult = { resolveRoomAliasResult = {

View file

@ -263,7 +263,7 @@ private fun SpaceFilterButton(
onClick = ::onClick, onClick = ::onClick,
colors = if (isSelected) { colors = if (isSelected) {
IconButtonDefaults.iconButtonColors( IconButtonDefaults.iconButtonColors(
containerColor = ElementTheme.colors.bgAccentRest, containerColor = ElementTheme.colors.bgActionPrimaryRest,
contentColor = ElementTheme.colors.iconOnSolidPrimary, contentColor = ElementTheme.colors.iconOnSolidPrimary,
) )
} else { } else {

View file

@ -809,14 +809,13 @@ class SecurityAndPrivacyPresenterTest {
) )
) )
) )
// No spaces available, so isSpaceMemberSelectable should be false // Room has SpaceMember access with existing space ID, so isSpaceMemberSelectable is true
val presenter = createSecurityAndPrivacyPresenter(room = room) val presenter = createSecurityAndPrivacyPresenter(room = room)
presenter.test { presenter.test {
skipItems(1) skipItems(1)
with(awaitItem()) { with(awaitItem()) {
assertThat(savedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.SpaceMember::class.java) assertThat(savedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.SpaceMember::class.java)
assertThat(isSpaceMemberSelectable).isFalse() assertThat(isSpaceMemberSelectable).isTrue()
// showSpaceMemberOption should still be true because savedSettings has SpaceMember
assertThat(showSpaceMemberOption).isTrue() assertThat(showSpaceMemberOption).isTrue()
} }
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()

View file

@ -109,10 +109,12 @@ fun SpaceView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
acceptDeclineInviteView: @Composable () -> Unit, acceptDeclineInviteView: @Composable () -> Unit,
) { ) {
BackHandler { var handledBack by remember { mutableStateOf(false) }
BackHandler(enabled = !handledBack) {
if (state.isManageMode) { if (state.isManageMode) {
state.eventSink(SpaceEvents.ExitManageMode) state.eventSink(SpaceEvents.ExitManageMode)
} else { } else {
handledBack = true
onBackClick() onBackClick()
} }
} }

View file

@ -16,13 +16,13 @@ datastore = "1.2.0"
constraintlayout = "2.2.1" constraintlayout = "2.2.1"
constraintlayout_compose = "1.1.1" constraintlayout_compose = "1.1.1"
lifecycle = "2.10.0" lifecycle = "2.10.0"
activity = "1.12.3" activity = "1.12.4"
media3 = "1.9.2" media3 = "1.9.2"
camera = "1.5.3" camera = "1.5.3"
work = "2.11.1" work = "2.11.1"
# Compose # Compose
compose_bom = "2026.01.00" compose_bom = "2026.02.00"
# Coroutines # Coroutines
coroutines = "1.10.2" coroutines = "1.10.2"
@ -32,7 +32,7 @@ accompanist = "0.37.3"
# Test # Test
test_core = "1.7.0" test_core = "1.7.0"
roborazzi = "1.58.0" roborazzi = "1.59.0"
# Jetbrain # Jetbrain
datetime = "0.7.1" datetime = "0.7.1"
@ -46,7 +46,7 @@ appyx = "1.7.1"
sqldelight = "2.2.1" sqldelight = "2.2.1"
wysiwyg = "2.41.1" wysiwyg = "2.41.1"
telephoto = "0.18.0" telephoto = "0.18.0"
haze = "1.7.1" haze = "1.7.2"
# Dependency analysis # Dependency analysis
dependencyAnalysis = "3.5.1" dependencyAnalysis = "3.5.1"
@ -62,7 +62,7 @@ detekt = "1.23.8"
# See https://github.com/pinterest/ktlint/releases/ # See https://github.com/pinterest/ktlint/releases/
ktlint = "1.8.0" ktlint = "1.8.0"
androidx-test-ext-junit = "1.3.0" androidx-test-ext-junit = "1.3.0"
kover = "0.9.6" kover = "0.9.7"
[libraries] [libraries]
# Project # Project
@ -126,7 +126,6 @@ androidx_compose_ui_tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx_compose_ui_tooling_preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx_compose_ui_tooling_preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx_compose_ui_test_manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx_compose_ui_test_manifest = { module = "androidx.compose.ui:ui-test-manifest" }
androidx_compose_ui_test_junit = { module = "androidx.compose.ui:ui-test-junit4-android" } androidx_compose_ui_test_junit = { module = "androidx.compose.ui:ui-test-junit4-android" }
androidx_compose_material = { module = "androidx.compose.material:material" }
androidx_compose_material_icons = { module = "androidx.compose.material:material-icons-extended" } androidx_compose_material_icons = { module = "androidx.compose.material:material-icons-extended" }
# Coroutines # Coroutines

View file

@ -16,8 +16,6 @@ import androidx.core.text.toSpannable
import androidx.core.text.util.LinkifyCompat import androidx.core.text.util.LinkifyCompat
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
import timber.log.Timber import timber.log.Timber
import kotlin.collections.component1
import kotlin.collections.component2
/** /**
* Helper class to linkify text while preserving existing URL spans. * Helper class to linkify text while preserving existing URL spans.
@ -59,7 +57,8 @@ object LinkifyHelper {
// Adapt the url in the URL span to the new end index too if needed // Adapt the url in the URL span to the new end index too if needed
if (end != newEnd) { if (end != newEnd) {
val url = spannable.subSequence(start, newEnd).toString() val diff = end - newEnd
val url = urlSpan.url.substring(0, urlSpan.url.length - diff)
spannable.removeSpan(urlSpan) spannable.removeSpan(urlSpan)
spannable.setSpan(URLSpan(url), start, newEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) spannable.setSpan(URLSpan(url), start, newEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
} else { } else {
@ -87,12 +86,12 @@ object LinkifyHelper {
var end = end var end = end
// Trailing punctuation found, adjust the end index // Trailing punctuation found, adjust the end index
while (spannable[end - 1] in sequenceOf('.', ',', ';', ':', '!', '?', '…') && end > start) { while (end > start && spannable[end - 1] in sequenceOf('.', ',', ';', ':', '!', '?', '…')) {
end-- end--
} }
// If the last character is a closing parenthesis, check if it's part of a pair // If the last character is a closing parenthesis, check if it's part of a pair
if (spannable[end - 1] == ')' && end > start) { if (end > start && spannable[end - 1] == ')') {
val linkifiedTextLastPath = spannable.substring(start, end).substringAfterLast('/') val linkifiedTextLastPath = spannable.substring(start, end).substringAfterLast('/')
val closingParenthesisCount = linkifiedTextLastPath.count { it == ')' } val closingParenthesisCount = linkifiedTextLastPath.count { it == ')' }
val openingParenthesisCount = linkifiedTextLastPath.count { it == '(' } val openingParenthesisCount = linkifiedTextLastPath.count { it == '(' }

View file

@ -10,7 +10,9 @@ package io.element.android.libraries.androidutils.text
import android.telephony.TelephonyManager import android.telephony.TelephonyManager
import android.text.style.URLSpan import android.text.style.URLSpan
import androidx.core.text.buildSpannedString
import androidx.core.text.getSpans import androidx.core.text.getSpans
import androidx.core.text.inSpans
import androidx.core.text.toSpannable import androidx.core.text.toSpannable
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
@ -140,4 +142,27 @@ class LinkifierHelperTest {
assertThat(urlSpans.size).isEqualTo(1) assertThat(urlSpans.size).isEqualTo(1)
assertThat(urlSpans.first().url).isEqualTo("https://github.com/element-hq/element-android/READ(ME)") assertThat(urlSpans.first().url).isEqualTo("https://github.com/element-hq/element-android/READ(ME)")
} }
@Test
fun `linkification handles trailing question marks`() {
val text = "A url: https://github.com/element-hq/element-android?"
val result = LinkifyHelper.linkify(text)
val urlSpans = result.toSpannable().getSpans<URLSpan>()
assertThat(urlSpans.size).isEqualTo(1)
assertThat(urlSpans.first().url).isEqualTo("https://github.com/element-hq/element-android")
}
@Test
fun `linkification doesn't modify existing URLSpan`() {
val text = buildSpannedString {
append("A url: ")
inSpans(URLSpan("https://github.com/element-hq/element-android?")) {
append("here")
}
}
val result = LinkifyHelper.linkify(text)
val urlSpans = result.toSpannable().getSpans<URLSpan>()
assertThat(urlSpans.size).isEqualTo(1)
assertThat(urlSpans.first().url).isEqualTo("https://github.com/element-hq/element-android?")
}
} }

View file

@ -229,7 +229,7 @@ private fun ButtonInternal(
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
} }
leadingIcon != null -> { leadingIcon != null -> {
androidx.compose.material.Icon( Icon(
painter = leadingIcon.getPainter(), painter = leadingIcon.getPainter(),
contentDescription = null, contentDescription = null,
tint = LocalContentColor.current, tint = LocalContentColor.current,

View file

@ -74,21 +74,21 @@ enum class FeatureFlags(
key = "feature.createSpaces", key = "feature.createSpaces",
title = "Create spaces", title = "Create spaces",
description = "Allow creating spaces.", description = "Allow creating spaces.",
defaultValue = { false }, defaultValue = { true },
isFinished = false, isFinished = false,
), ),
SpaceSettings( SpaceSettings(
key = "feature.spaceSettings", key = "feature.spaceSettings",
title = "Space settings", title = "Space settings",
description = "Allow managing space settings such as details, permissions and privacy.", description = "Allow managing space settings such as details, permissions and privacy.",
defaultValue = { false }, defaultValue = { true },
isFinished = false, isFinished = false,
), ),
RoomListSpaceFilters( RoomListSpaceFilters(
key = "feature.roomListSpaceFilters", key = "feature.roomListSpaceFilters",
title = "Room list space filters", title = "Room list space filters",
description = "Allow filtering the room list by space.", description = "Allow filtering the room list by space.",
defaultValue = { false }, defaultValue = { true },
isFinished = false, isFinished = false,
), ),
PrintLogsToLogcat( PrintLogsToLogcat(

View file

@ -9,8 +9,3 @@
package io.element.android.libraries.matrix.api.core package io.element.android.libraries.matrix.api.core
typealias SpaceId = RoomId typealias SpaceId = RoomId
/**
* Value to use when no space is selected by the user.
*/
val MAIN_SPACE = SpaceId("!mainSpace:local")

View file

@ -34,6 +34,7 @@ interface ActiveNotificationsProvider {
fun getMembershipNotificationForSession(sessionId: SessionId): List<StatusBarNotification> fun getMembershipNotificationForSession(sessionId: SessionId): List<StatusBarNotification>
fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification>
fun getSummaryNotification(sessionId: SessionId): StatusBarNotification? fun getSummaryNotification(sessionId: SessionId): StatusBarNotification?
fun getFallbackNotification(sessionId: SessionId): StatusBarNotification?
fun count(sessionId: SessionId): Int fun count(sessionId: SessionId): Int
} }
@ -76,6 +77,11 @@ class DefaultActiveNotificationsProvider(
return getNotificationsForSession(sessionId).find { it.id == summaryId } return getNotificationsForSession(sessionId).find { it.id == summaryId }
} }
override fun getFallbackNotification(sessionId: SessionId): StatusBarNotification? {
val fallbackId = NotificationIdProvider.getFallbackNotificationId(sessionId)
return getNotificationsForSession(sessionId).find { it.id == fallbackId }
}
override fun count(sessionId: SessionId): Int { override fun count(sessionId: SessionId): Int {
return getNotificationsForSession(sessionId).size return getNotificationsForSession(sessionId).size
} }

View file

@ -22,11 +22,20 @@ import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
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.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.NavigationState import io.element.android.services.appnavstate.api.NavigationState
import io.element.android.services.appnavstate.api.currentRoomId
import io.element.android.services.appnavstate.api.currentSessionId import io.element.android.services.appnavstate.api.currentSessionId
import io.element.android.services.appnavstate.api.currentThreadId
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -46,46 +55,49 @@ class DefaultNotificationDrawerManager(
private val matrixClientProvider: MatrixClientProvider, private val matrixClientProvider: MatrixClientProvider,
private val imageLoaderHolder: ImageLoaderHolder, private val imageLoaderHolder: ImageLoaderHolder,
private val activeNotificationsProvider: ActiveNotificationsProvider, private val activeNotificationsProvider: ActiveNotificationsProvider,
sessionObserver: SessionObserver,
) : NotificationCleaner { ) : NotificationCleaner {
// TODO EAx add a setting per user for this // TODO EAx add a setting per user for this
private var useCompleteNotificationFormat = true private var useCompleteNotificationFormat = true
private val sessionListener = object : SessionListener {
override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) {
// User signed out, clear all notifications related to the session.
clearAllEvents(SessionId(userId))
}
}
init { init {
// Observe application state // Observe application state
coroutineScope.launch { coroutineScope.launch {
appNavigationStateService.appNavigationState appNavigationStateService.appNavigationState
.collect { onAppNavigationStateChange(it.navigationState) } .collect { onAppNavigationStateChange(it.navigationState) }
} }
sessionObserver.addListener(sessionListener)
} }
private var currentAppNavigationState: NavigationState? = null
private fun onAppNavigationStateChange(navigationState: NavigationState) { private fun onAppNavigationStateChange(navigationState: NavigationState) {
when (navigationState) { when (navigationState) {
NavigationState.Root -> { NavigationState.Root -> {}
currentAppNavigationState?.currentSessionId()?.let { sessionId -> is NavigationState.Session -> {
// User signed out, clear all notifications related to the session. // Cleanup the fallback notification
clearAllEvents(sessionId) clearFallbackForSession(navigationState.sessionId)
}
} }
is NavigationState.Session -> {}
is NavigationState.Space -> {}
is NavigationState.Room -> { is NavigationState.Room -> {
// Cleanup notification for current room // Cleanup notification for current room
clearMessagesForRoom( clearMessagesForRoom(
sessionId = navigationState.parentSpace.parentSession.sessionId, sessionId = navigationState.parentSession.sessionId,
roomId = navigationState.roomId, roomId = navigationState.roomId,
) )
} }
is NavigationState.Thread -> { is NavigationState.Thread -> {
clearMessagesForThread( clearMessagesForThread(
sessionId = navigationState.parentRoom.parentSpace.parentSession.sessionId, sessionId = navigationState.parentRoom.parentSession.sessionId,
roomId = navigationState.parentRoom.roomId, roomId = navigationState.parentRoom.roomId,
threadId = navigationState.threadId, threadId = navigationState.threadId,
) )
} }
} }
currentAppNavigationState = navigationState
} }
/** /**
@ -93,14 +105,11 @@ class DefaultNotificationDrawerManager(
* Events might be grouped and there might not be one notification per event! * Events might be grouped and there might not be one notification per event!
*/ */
suspend fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { suspend fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
if (notifiableEvent.shouldIgnoreEventInRoom(appNavigationStateService.appNavigationState.value)) { onNotifiableEventsReceived(listOf(notifiableEvent))
return
}
renderEvents(listOf(notifiableEvent))
} }
suspend fun onNotifiableEventsReceived(notifiableEvents: List<NotifiableEvent>) { suspend fun onNotifiableEventsReceived(notifiableEvents: List<NotifiableEvent>) {
val eventsToNotify = notifiableEvents.filter { !it.shouldIgnoreEventInRoom(appNavigationStateService.appNavigationState.value) } val eventsToNotify = notifiableEvents.filter { !appNavigationStateService.appNavigationState.value.shouldIgnoreEvent(it) }
renderEvents(eventsToNotify) renderEvents(eventsToNotify)
} }
@ -120,6 +129,17 @@ class DefaultNotificationDrawerManager(
.forEach { notificationDisplayer.cancelNotification(it.tag, it.id) } .forEach { notificationDisplayer.cancelNotification(it.tag, it.id) }
} }
/**
* Remove the fallback notification for the session.
*/
fun clearFallbackForSession(sessionId: SessionId) {
notificationDisplayer.cancelNotification(
DefaultNotificationDataFactory.FALLBACK_NOTIFICATION_TAG,
NotificationIdProvider.getFallbackNotificationId(sessionId),
)
clearSummaryNotificationIfNeeded(sessionId)
}
/** /**
* Should be called when the application is currently opened and showing timeline for the given [roomId]. * Should be called when the application is currently opened and showing timeline for the given [roomId].
* Used to ignore events related to that room (no need to display notification) and clean any existing notification on this room. * Used to ignore events related to that room (no need to display notification) and clean any existing notification on this room.
@ -191,3 +211,30 @@ class DefaultNotificationDrawerManager(
} }
} }
} }
/**
* Used to check if a notifiableEvent should be ignored based on the current application navigation state.
*/
private fun AppNavigationState.shouldIgnoreEvent(event: NotifiableEvent): Boolean {
if (!isInForeground) return false
return navigationState.currentSessionId() == event.sessionId &&
when (event) {
is NotifiableRingingCallEvent -> {
// Never ignore ringing call notifications
// Note that NotifiableRingingCallEvent are not handled by DefaultNotificationDrawerManager
false
}
is FallbackNotifiableEvent -> {
// Ignore if the room list is currently displayed
navigationState is NavigationState.Session
}
is InviteNotifiableEvent,
is SimpleNotifiableEvent -> {
event.roomId == navigationState.currentRoomId()
}
is NotifiableMessageEvent -> {
event.roomId == navigationState.currentRoomId() &&
event.threadId == navigationState.currentThreadId()
}
}
}

View file

@ -12,15 +12,12 @@ import dev.zacsweers.metro.Inject
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
@Inject @Inject
class FallbackNotificationFactory( class FallbackNotificationFactory(
private val clock: SystemClock, private val clock: SystemClock,
private val stringProvider: StringProvider,
) { ) {
fun create( fun create(
sessionId: SessionId, sessionId: SessionId,
@ -36,7 +33,7 @@ class FallbackNotificationFactory(
isRedacted = false, isRedacted = false,
isUpdated = false, isUpdated = false,
timestamp = clock.epochMillis(), timestamp = clock.epochMillis(),
description = stringProvider.getString(R.string.notification_fallback_content), description = "",
cause = cause, cause = cause,
) )
} }

View file

@ -9,24 +9,18 @@
package io.element.android.libraries.push.impl.notifications package io.element.android.libraries.push.impl.notifications
import android.app.Notification import android.app.Notification
import android.graphics.Typeface
import android.text.style.StyleSpan
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import coil3.ImageLoader import coil3.ImageLoader
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent 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.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.services.toolbox.api.strings.StringProvider
interface NotificationDataFactory { interface NotificationDataFactory {
suspend fun toNotifications( suspend fun toNotifications(
@ -51,16 +45,15 @@ interface NotificationDataFactory {
@JvmName("toNotificationFallbackEvents") @JvmName("toNotificationFallbackEvents")
@Suppress("INAPPLICABLE_JVM_NAME") @Suppress("INAPPLICABLE_JVM_NAME")
fun toNotifications( fun toNotification(
fallback: List<FallbackNotifiableEvent>, fallback: List<FallbackNotifiableEvent>,
notificationAccountParams: NotificationAccountParams, notificationAccountParams: NotificationAccountParams,
): List<OneShotNotification> ): OneShotNotification?
fun createSummaryNotification( fun createSummaryNotification(
roomNotifications: List<RoomNotification>, roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>, invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>, simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
notificationAccountParams: NotificationAccountParams, notificationAccountParams: NotificationAccountParams,
): SummaryNotification ): SummaryNotification
} }
@ -71,7 +64,6 @@ class DefaultNotificationDataFactory(
private val roomGroupMessageCreator: RoomGroupMessageCreator, private val roomGroupMessageCreator: RoomGroupMessageCreator,
private val summaryGroupMessageCreator: SummaryGroupMessageCreator, private val summaryGroupMessageCreator: SummaryGroupMessageCreator,
private val activeNotificationsProvider: ActiveNotificationsProvider, private val activeNotificationsProvider: ActiveNotificationsProvider,
private val stringProvider: StringProvider,
) : NotificationDataFactory { ) : NotificationDataFactory {
override suspend fun toNotifications( override suspend fun toNotifications(
messages: List<NotifiableMessageEvent>, messages: List<NotifiableMessageEvent>,
@ -81,10 +73,7 @@ class DefaultNotificationDataFactory(
val messagesToDisplay = messages.filterNot { it.canNotBeDisplayed() } val messagesToDisplay = messages.filterNot { it.canNotBeDisplayed() }
.groupBy { it.roomId } .groupBy { it.roomId }
return messagesToDisplay.flatMap { (roomId, events) -> return messagesToDisplay.flatMap { (roomId, events) ->
val roomName = events.lastOrNull()?.roomName ?: roomId.value
val isDm = events.lastOrNull()?.roomIsDm ?: false
val eventsByThreadId = events.groupBy { it.threadId } val eventsByThreadId = events.groupBy { it.threadId }
eventsByThreadId.map { (threadId, events) -> eventsByThreadId.map { (threadId, events) ->
val notification = roomGroupMessageCreator.createRoomMessage( val notification = roomGroupMessageCreator.createRoomMessage(
events = events, events = events,
@ -98,7 +87,6 @@ class DefaultNotificationDataFactory(
notification = notification, notification = notification,
roomId = roomId, roomId = roomId,
threadId = threadId, threadId = threadId,
summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, isDm),
messageCount = events.size, messageCount = events.size,
latestTimestamp = events.maxOf { it.timestamp }, latestTimestamp = events.maxOf { it.timestamp },
shouldBing = events.any { it.noisy } shouldBing = events.any { it.noisy }
@ -123,7 +111,6 @@ class DefaultNotificationDataFactory(
OneShotNotification( OneShotNotification(
tag = event.roomId.value, tag = event.roomId.value,
notification = notificationCreator.createRoomInvitationNotification(notificationAccountParams, event), notification = notificationCreator.createRoomInvitationNotification(notificationAccountParams, event),
summaryLine = event.description,
isNoisy = event.noisy, isNoisy = event.noisy,
timestamp = event.timestamp timestamp = event.timestamp
) )
@ -140,7 +127,6 @@ class DefaultNotificationDataFactory(
OneShotNotification( OneShotNotification(
tag = event.eventId.value, tag = event.eventId.value,
notification = notificationCreator.createSimpleEventNotification(notificationAccountParams, event), notification = notificationCreator.createSimpleEventNotification(notificationAccountParams, event),
summaryLine = event.description,
isNoisy = event.noisy, isNoisy = event.noisy,
timestamp = event.timestamp timestamp = event.timestamp
) )
@ -149,26 +135,31 @@ class DefaultNotificationDataFactory(
@JvmName("toNotificationFallbackEvents") @JvmName("toNotificationFallbackEvents")
@Suppress("INAPPLICABLE_JVM_NAME") @Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications( override fun toNotification(
fallback: List<FallbackNotifiableEvent>, fallback: List<FallbackNotifiableEvent>,
notificationAccountParams: NotificationAccountParams, notificationAccountParams: NotificationAccountParams,
): List<OneShotNotification> { ): OneShotNotification? {
return fallback.map { event -> if (fallback.isEmpty()) return null
OneShotNotification( val existingNotification = activeNotificationsProvider
tag = event.eventId.value, .getFallbackNotification(notificationAccountParams.user.userId)
notification = notificationCreator.createFallbackNotification(notificationAccountParams, event), ?.notification
summaryLine = event.description.orEmpty(), val notification = notificationCreator.createFallbackNotification(
isNoisy = false, existingNotification = existingNotification,
timestamp = event.timestamp notificationAccountParams = notificationAccountParams,
) fallbackNotifiableEvents = fallback,
} )
return OneShotNotification(
tag = FALLBACK_NOTIFICATION_TAG,
notification = notification,
isNoisy = false,
timestamp = fallback.first().timestamp
)
} }
override fun createSummaryNotification( override fun createSummaryNotification(
roomNotifications: List<RoomNotification>, roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>, invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>, simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
notificationAccountParams: NotificationAccountParams, notificationAccountParams: NotificationAccountParams,
): SummaryNotification { ): SummaryNotification {
return when { return when {
@ -178,51 +169,14 @@ class DefaultNotificationDataFactory(
roomNotifications = roomNotifications, roomNotifications = roomNotifications,
invitationNotifications = invitationNotifications, invitationNotifications = invitationNotifications,
simpleNotifications = simpleNotifications, simpleNotifications = simpleNotifications,
fallbackNotifications = fallbackNotifications,
notificationAccountParams = notificationAccountParams, notificationAccountParams = notificationAccountParams,
) )
) )
} }
} }
private fun createRoomMessagesGroupSummaryLine(events: List<NotifiableMessageEvent>, roomName: String, roomIsDm: Boolean): CharSequence { companion object {
return when (events.size) { const val FALLBACK_NOTIFICATION_TAG = "FALLBACK"
1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDm)
else -> {
stringProvider.getQuantityString(
R.plurals.notification_compat_summary_line_for_room,
events.size,
roomName,
events.size
)
}
}
}
private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDm: Boolean): CharSequence {
return if (roomIsDm) {
buildSpannedString {
event.senderDisambiguatedDisplayName?.let {
inSpans(StyleSpan(Typeface.BOLD)) {
append(it)
append(": ")
}
}
append(event.description)
}
} else {
buildSpannedString {
inSpans(StyleSpan(Typeface.BOLD)) {
append(roomName)
append(": ")
event.senderDisambiguatedDisplayName?.let {
append(it)
append(" ")
}
}
append(event.description)
}
}
} }
} }
@ -230,7 +184,6 @@ data class RoomNotification(
val notification: Notification, val notification: Notification,
val roomId: RoomId, val roomId: RoomId,
val threadId: ThreadId?, val threadId: ThreadId?,
val summaryLine: CharSequence,
val messageCount: Int, val messageCount: Int,
val latestTimestamp: Long, val latestTimestamp: Long,
val shouldBing: Boolean, val shouldBing: Boolean,
@ -239,7 +192,6 @@ data class RoomNotification(
return notification == other.notification && return notification == other.notification &&
roomId == other.roomId && roomId == other.roomId &&
threadId == other.threadId && threadId == other.threadId &&
summaryLine.toString() == other.summaryLine.toString() &&
messageCount == other.messageCount && messageCount == other.messageCount &&
latestTimestamp == other.latestTimestamp && latestTimestamp == other.latestTimestamp &&
shouldBing == other.shouldBing shouldBing == other.shouldBing
@ -249,7 +201,6 @@ data class RoomNotification(
data class OneShotNotification( data class OneShotNotification(
val notification: Notification, val notification: Notification,
val tag: String, val tag: String,
val summaryLine: CharSequence,
val isNoisy: Boolean, val isNoisy: Boolean,
val timestamp: Long, val timestamp: Long,
) )

View file

@ -55,12 +55,11 @@ class NotificationRenderer(
val roomNotifications = notificationDataFactory.toNotifications(groupedEvents.roomEvents, imageLoader, notificationAccountParams) val roomNotifications = notificationDataFactory.toNotifications(groupedEvents.roomEvents, imageLoader, notificationAccountParams)
val invitationNotifications = notificationDataFactory.toNotifications(groupedEvents.invitationEvents, notificationAccountParams) val invitationNotifications = notificationDataFactory.toNotifications(groupedEvents.invitationEvents, notificationAccountParams)
val simpleNotifications = notificationDataFactory.toNotifications(groupedEvents.simpleEvents, notificationAccountParams) val simpleNotifications = notificationDataFactory.toNotifications(groupedEvents.simpleEvents, notificationAccountParams)
val fallbackNotifications = notificationDataFactory.toNotifications(groupedEvents.fallbackEvents, notificationAccountParams) val fallbackNotification = notificationDataFactory.toNotification(groupedEvents.fallbackEvents, notificationAccountParams)
val summaryNotification = notificationDataFactory.createSummaryNotification( val summaryNotification = notificationDataFactory.createSummaryNotification(
roomNotifications = roomNotifications, roomNotifications = roomNotifications,
invitationNotifications = invitationNotifications, invitationNotifications = invitationNotifications,
simpleNotifications = simpleNotifications, simpleNotifications = simpleNotifications,
fallbackNotifications = fallbackNotifications,
notificationAccountParams = notificationAccountParams, notificationAccountParams = notificationAccountParams,
) )
@ -107,13 +106,12 @@ class NotificationRenderer(
} }
} }
// Show only the first fallback notification if (fallbackNotification != null) {
if (fallbackNotifications.isNotEmpty()) { Timber.tag(loggerTag.value).d("Showing or updating fallback notification")
Timber.tag(loggerTag.value).d("Showing fallback notification")
notificationDisplayer.showNotification( notificationDisplayer.showNotification(
tag = "FALLBACK", tag = fallbackNotification.tag,
id = NotificationIdProvider.getFallbackNotificationId(currentUser.userId), id = NotificationIdProvider.getFallbackNotificationId(currentUser.userId),
notification = fallbackNotifications.first().notification notification = fallbackNotification.notification,
) )
} }

View file

@ -22,7 +22,6 @@ interface SummaryGroupMessageCreator {
roomNotifications: List<RoomNotification>, roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>, invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>, simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
): Notification ): Notification
} }
@ -45,7 +44,6 @@ class DefaultSummaryGroupMessageCreator(
roomNotifications: List<RoomNotification>, roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>, invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>, simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
): Notification { ): Notification {
val summaryIsNoisy = roomNotifications.any { it.shouldBing } || val summaryIsNoisy = roomNotifications.any { it.shouldBing } ||
invitationNotifications.any { it.isNoisy } || invitationNotifications.any { it.isNoisy } ||

View file

@ -75,8 +75,9 @@ interface NotificationCreator {
): Notification ): Notification
fun createFallbackNotification( fun createFallbackNotification(
existingNotification: Notification?,
notificationAccountParams: NotificationAccountParams, notificationAccountParams: NotificationAccountParams,
fallbackNotifiableEvent: FallbackNotifiableEvent, fallbackNotifiableEvents: List<FallbackNotifiableEvent>,
): Notification ): Notification
/** /**
@ -308,31 +309,38 @@ class DefaultNotificationCreator(
} }
override fun createFallbackNotification( override fun createFallbackNotification(
existingNotification: Notification?,
notificationAccountParams: NotificationAccountParams, notificationAccountParams: NotificationAccountParams,
fallbackNotifiableEvent: FallbackNotifiableEvent, fallbackNotifiableEvents: List<FallbackNotifiableEvent>,
): Notification { ): Notification {
val channelId = notificationChannels.getChannelIdForMessage( val channelId = notificationChannels.getChannelIdForMessage(
sessionId = fallbackNotifiableEvent.sessionId, sessionId = fallbackNotifiableEvent.sessionId,
noisy = false, noisy = false,
) )
val existingCounter = existingNotification
?.extras
?.getInt(FALLBACK_COUNTER_EXTRA)
?: 0
val counter = existingCounter + fallbackNotifiableEvents.size
val fallbackNotifiableEvent = fallbackNotifiableEvents.first()
return NotificationCompat.Builder(context, channelId) return NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setContentTitle(buildMeta.applicationName.annotateForDebug(7)) .setContentTitle(buildMeta.applicationName.annotateForDebug(7))
.setContentText(fallbackNotifiableEvent.description.orEmpty().annotateForDebug(8)) .setContentText(
stringProvider.getQuantityString(R.plurals.notification_fallback_n_content, counter, counter)
.annotateForDebug(8)
)
.setExtras(
bundleOf(
FALLBACK_COUNTER_EXTRA to counter
)
)
.setNumber(counter)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.configureWith(notificationAccountParams) .configureWith(notificationAccountParams)
.setAutoCancel(true) .setAutoCancel(true)
.setWhen(fallbackNotifiableEvent.timestamp) .setWhen(fallbackNotifiableEvent.timestamp)
// Ideally we'd use `createOpenRoomPendingIntent` here, but the broken notification might apply to an invite
// and the user won't have access to the room yet, resulting in an error screen.
.setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(fallbackNotifiableEvent.sessionId)) .setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(fallbackNotifiableEvent.sessionId))
.setDeleteIntent(
pendingIntentFactory.createDismissEventPendingIntent(
fallbackNotifiableEvent.sessionId,
fallbackNotifiableEvent.roomId,
fallbackNotifiableEvent.eventId
)
)
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_LOW)
.build() .build()
} }
@ -522,6 +530,7 @@ class DefaultNotificationCreator(
companion object { companion object {
const val MESSAGE_EVENT_ID = "message_event_id" const val MESSAGE_EVENT_ID = "message_event_id"
private const val FALLBACK_COUNTER_EXTRA = "COUNTER"
} }
} }

View file

@ -15,10 +15,6 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventType import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.currentRoomId
import io.element.android.services.appnavstate.api.currentSessionId
import io.element.android.services.appnavstate.api.currentThreadId
data class NotifiableMessageEvent( data class NotifiableMessageEvent(
override val sessionId: SessionId, override val sessionId: SessionId,
@ -56,24 +52,3 @@ data class NotifiableMessageEvent(
val imageUri: Uri? val imageUri: Uri?
get() = imageUriString?.toUri() get() = imageUriString?.toUri()
} }
/**
* Used to check if a notification should be ignored based on the current app and navigation state.
*/
fun NotifiableEvent.shouldIgnoreEventInRoom(appNavigationState: AppNavigationState): Boolean {
val currentSessionId = appNavigationState.navigationState.currentSessionId() ?: return false
return when (val currentRoomId = appNavigationState.navigationState.currentRoomId()) {
null -> false
else -> {
// Never ignore ringing call notifications
if (this is NotifiableRingingCallEvent) {
false
} else {
appNavigationState.isInForeground &&
sessionId == currentSessionId &&
roomId == currentRoomId &&
(this as? NotifiableMessageEvent)?.threadId == appNavigationState.navigationState.currentThreadId()
}
}
}
}

View file

@ -15,6 +15,10 @@
</plurals> </plurals>
<string name="notification_error_unified_push_unregistered_android">"The UnifiedPush notification distributor couldn\'t be registered, so you will not receive notifications anymore. Please check the notifications settings of the app and the status of the push distributor."</string> <string name="notification_error_unified_push_unregistered_android">"The UnifiedPush notification distributor couldn\'t be registered, so you will not receive notifications anymore. Please check the notifications settings of the app and the status of the push distributor."</string>
<string name="notification_fallback_content">"You have new messages."</string> <string name="notification_fallback_content">"You have new messages."</string>
<plurals name="notification_fallback_n_content">
<item quantity="one">"You have %d new message."</item>
<item quantity="other">"You have %d new messages."</item>
</plurals>
<string name="notification_incoming_call">"📹 Incoming call"</string> <string name="notification_incoming_call">"📹 Incoming call"</string>
<string name="notification_inline_reply_failed">"** Failed to send - please open room"</string> <string name="notification_inline_reply_failed">"** Failed to send - please open room"</string>
<string name="notification_invitation_action_join">"Join"</string> <string name="notification_invitation_action_join">"Join"</string>

View file

@ -153,6 +153,19 @@ class DefaultActiveNotificationsProviderTest {
assertThat(activeNotificationsProvider.getSummaryNotification(A_SESSION_ID_2)).isNull() assertThat(activeNotificationsProvider.getSummaryNotification(A_SESSION_ID_2)).isNull()
} }
@Test
fun `getFallbackNotification returns only the fallback notification for that session id if it exists`() {
val activeNotifications = listOf(
aStatusBarNotification(id = notificationIdProvider.getFallbackNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value),
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value),
aStatusBarNotification(id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value),
)
val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications)
assertThat(activeNotificationsProvider.getFallbackNotification(A_SESSION_ID)).isNotNull()
assertThat(activeNotificationsProvider.getFallbackNotification(A_SESSION_ID_2)).isNull()
}
private fun aStatusBarNotification(id: Int, groupId: String, tag: String? = null) = mockk<StatusBarNotification> { private fun aStatusBarNotification(id: Int, groupId: String, tag: String? = null) = mockk<StatusBarNotification> {
every { this@mockk.id } returns id every { this@mockk.id } returns id
every { this@mockk.tag } returns tag every { this@mockk.tag } returns tag

View file

@ -56,7 +56,6 @@ import io.element.android.libraries.push.impl.notifications.model.NotifiableMess
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
import io.element.android.libraries.push.test.notifications.FakeCallNotificationEventResolver import io.element.android.libraries.push.test.notifications.FakeCallNotificationEventResolver
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -663,7 +662,7 @@ class DefaultNotifiableEventResolverTest {
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
editedEventId = null, editedEventId = null,
description = "You have new messages.", description = "",
canBeReplaced = true, canBeReplaced = true,
isRedacted = false, isRedacted = false,
isUpdated = false, isUpdated = false,
@ -895,7 +894,6 @@ class DefaultNotifiableEventResolverTest {
callNotificationEventResolver = callNotificationEventResolver, callNotificationEventResolver = callNotificationEventResolver,
fallbackNotificationFactory = FallbackNotificationFactory( fallbackNotificationFactory = FallbackNotificationFactory(
clock = FakeSystemClock(), clock = FakeSystemClock(),
stringProvider = FakeStringProvider(defaultResult = "You have new messages.")
), ),
featureFlagService = FakeFeatureFlagService(), featureFlagService = FakeFeatureFlagService(),
) )

View file

@ -15,9 +15,11 @@ import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.libraries.matrix.test.AN_EVENT_ID 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_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SPACE_ID import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
@ -29,22 +31,26 @@ import io.element.android.libraries.push.impl.notifications.fake.FakeNotificatio
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer
import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent
import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver
import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.NavigationState
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import io.element.android.services.appnavstate.test.aNavigationState import io.element.android.services.appnavstate.test.aNavigationState
import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.services.appnavstate.test.anAppNavigationState
import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -92,28 +98,25 @@ class DefaultNotificationDrawerManagerTest {
@Test @Test
fun `react to applicationStateChange`() = runTest { fun `react to applicationStateChange`() = runTest {
// For now just call all the API. Later, add more valuable tests. // For now just call all the API. Later, add more valuable tests.
val appNavigationStateFlow: MutableStateFlow<AppNavigationState> = MutableStateFlow( val appNavigationStateService = FakeAppNavigationStateService()
AppNavigationState(
navigationState = NavigationState.Root,
isInForeground = true,
)
)
val appNavigationStateService = FakeAppNavigationStateService(appNavigationState = appNavigationStateFlow)
createDefaultNotificationDrawerManager( createDefaultNotificationDrawerManager(
appNavigationStateService = appNavigationStateService appNavigationStateService = appNavigationStateService
) )
appNavigationStateFlow.emit(AppNavigationState(aNavigationState(), isInForeground = true)) appNavigationStateService.emitNavigationState(AppNavigationState(aNavigationState(), isInForeground = true))
runCurrent() runCurrent()
appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID), isInForeground = true)) appNavigationStateService.emitNavigationState(AppNavigationState(aNavigationState(A_SESSION_ID), isInForeground = true))
runCurrent() runCurrent()
appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID, A_SPACE_ID), isInForeground = true)) appNavigationStateService.emitNavigationState(AppNavigationState(aNavigationState(A_SESSION_ID, A_ROOM_ID), isInForeground = true))
runCurrent() runCurrent()
appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID), isInForeground = true)) appNavigationStateService.emitNavigationState(
runCurrent() AppNavigationState(
appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID, A_THREAD_ID), isInForeground = true)) aNavigationState(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID),
isInForeground = true
)
)
runCurrent() runCurrent()
// Like a user sign out // Like a user sign out
appNavigationStateFlow.emit(AppNavigationState(aNavigationState(), isInForeground = true)) appNavigationStateService.emitNavigationState(AppNavigationState(aNavigationState(), isInForeground = true))
runCurrent() runCurrent()
} }
@ -205,35 +208,325 @@ class DefaultNotificationDrawerManagerTest {
) )
} }
private fun TestScope.createDefaultNotificationDrawerManager( @Test
notificationDisplayer: NotificationDisplayer = FakeNotificationDisplayer(), fun `when a session is signed out, clearAllEvent is invoked`() = runTest {
appNavigationStateService: AppNavigationStateService = FakeAppNavigationStateService(), val cancelNotificationResult = lambdaRecorder<String?, Int, Unit> { _, _ -> }
roomGroupMessageCreator: RoomGroupMessageCreator = FakeRoomGroupMessageCreator(), val notificationDisplayer = FakeNotificationDisplayer(
summaryGroupMessageCreator: SummaryGroupMessageCreator = FakeSummaryGroupMessageCreator(), cancelNotificationResult = cancelNotificationResult,
activeNotificationsProvider: FakeActiveNotificationsProvider = FakeActiveNotificationsProvider(), )
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), val summaryId = NotificationIdProvider.getSummaryNotificationId(A_SESSION_ID)
sessionStore: SessionStore = InMemorySessionStore(), val activeNotificationsProvider = FakeActiveNotificationsProvider(
enterpriseService: EnterpriseService = FakeEnterpriseService(), getNotificationsForSessionResult = {
): DefaultNotificationDrawerManager { listOf(
return DefaultNotificationDrawerManager( mockk {
every { id } returns summaryId
every { tag } returns null
},
)
},
countResult = { 1 },
)
val sessionObserver = FakeSessionObserver()
createDefaultNotificationDrawerManager(
notificationDisplayer = notificationDisplayer, notificationDisplayer = notificationDisplayer,
notificationRenderer = NotificationRenderer(
notificationDisplayer = FakeNotificationDisplayer(),
notificationDataFactory = DefaultNotificationDataFactory(
notificationCreator = FakeNotificationCreator(),
roomGroupMessageCreator = roomGroupMessageCreator,
summaryGroupMessageCreator = summaryGroupMessageCreator,
activeNotificationsProvider = activeNotificationsProvider,
stringProvider = FakeStringProvider(),
),
enterpriseService = enterpriseService,
sessionStore = sessionStore,
),
appNavigationStateService = appNavigationStateService,
coroutineScope = backgroundScope,
matrixClientProvider = matrixClientProvider,
imageLoaderHolder = FakeImageLoaderHolder(),
activeNotificationsProvider = activeNotificationsProvider, activeNotificationsProvider = activeNotificationsProvider,
sessionObserver = sessionObserver,
)
// Simulate a session sign out
sessionObserver.onSessionDeleted(A_SESSION_ID.value)
// Verify we asked to cancel the notification with summaryId
cancelNotificationResult.assertions().isCalledExactly(1).withSequence(
listOf(value(null), value(summaryId)),
)
}
@Test
fun `when the application is in background, all events trigger a notification`() = testOnNotifiableEventReceived(
appNavigationState = anAppNavigationState(
navigationState = aNavigationState(sessionId = A_SESSION_ID),
isInForeground = false,
),
notifiableEvents = listOf(
aFallbackNotifiableEvent(sessionId = A_SESSION_ID),
aFallbackNotifiableEvent(sessionId = A_SESSION_ID_2),
anInviteNotifiableEvent(sessionId = A_SESSION_ID),
anInviteNotifiableEvent(sessionId = A_SESSION_ID_2),
aSimpleNotifiableEvent(sessionId = A_SESSION_ID),
aSimpleNotifiableEvent(sessionId = A_SESSION_ID_2),
aNotifiableMessageEvent(sessionId = A_SESSION_ID),
aNotifiableMessageEvent(sessionId = A_SESSION_ID_2),
aNotifiableMessageEvent(sessionId = A_SESSION_ID, threadId = A_THREAD_ID),
aNotifiableMessageEvent(sessionId = A_SESSION_ID_2, threadId = A_THREAD_ID_2),
),
shouldEmitNotification = true,
extraInvocationsForNotificationSummary = 2,
)
@Test
fun `fallback event is ignored when the room list is displayed`() = testOnNotifiableEventReceived(
appNavigationState = anAppNavigationState(
navigationState = aNavigationState(sessionId = A_SESSION_ID),
),
notifiableEvents = listOf(aFallbackNotifiableEvent(sessionId = A_SESSION_ID)),
shouldEmitNotification = false,
)
@Test
fun `fallback event is not ignored when a room is displayed`() = testOnNotifiableEventReceived(
appNavigationState = anAppNavigationState(
navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID),
),
notifiableEvents = listOf(aFallbackNotifiableEvent(sessionId = A_SESSION_ID)),
shouldEmitNotification = true,
)
@Test
fun `fallback event for other session is not ignored when the room list is displayed`() = testOnNotifiableEventReceived(
appNavigationState = anAppNavigationState(
navigationState = aNavigationState(sessionId = A_SESSION_ID_2),
),
notifiableEvents = listOf(aFallbackNotifiableEvent(sessionId = A_SESSION_ID)),
shouldEmitNotification = true,
)
@Test
fun `invite notifiable event is emits a notification when the room list is displayed`() = testOnNotifiableEventReceived(
appNavigationState = anAppNavigationState(
navigationState = aNavigationState(sessionId = A_SESSION_ID),
),
notifiableEvents = listOf(anInviteNotifiableEvent(sessionId = A_SESSION_ID)),
shouldEmitNotification = true,
extraInvocationsForNotificationSummary = 1,
)
@Test
fun `invite notifiable event does not emit a notification when the same room is displayed`() = testOnNotifiableEventReceived(
appNavigationState = anAppNavigationState(
navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID),
),
notifiableEvents = listOf(
anInviteNotifiableEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
)
),
shouldEmitNotification = false,
)
@Test
fun `invite notifiable event emits a notification when another room is displayed`() = testOnNotifiableEventReceived(
appNavigationState = anAppNavigationState(
navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID_2),
),
notifiableEvents = listOf(
anInviteNotifiableEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
)
),
shouldEmitNotification = true,
extraInvocationsForNotificationSummary = 1,
)
@Test
fun `simple notifiable event is emits a notification when the room list is displayed`() = testOnNotifiableEventReceived(
appNavigationState = anAppNavigationState(
navigationState = aNavigationState(sessionId = A_SESSION_ID),
),
notifiableEvents = listOf(aSimpleNotifiableEvent(sessionId = A_SESSION_ID)),
shouldEmitNotification = true,
extraInvocationsForNotificationSummary = 1,
)
@Test
fun `simple notifiable event does not emit a notification when the same room is displayed`() = testOnNotifiableEventReceived(
appNavigationState = anAppNavigationState(
navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID),
),
notifiableEvents = listOf(
aSimpleNotifiableEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
)
),
shouldEmitNotification = false,
)
@Test
fun `simple notifiable event emits a notification when another room is displayed`() = testOnNotifiableEventReceived(
appNavigationState = anAppNavigationState(
navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID_2),
),
notifiableEvents = listOf(
aSimpleNotifiableEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
)
),
shouldEmitNotification = true,
extraInvocationsForNotificationSummary = 1,
)
@Test
fun `notifiable event is emits a notification when the room list is displayed`() = testOnNotifiableEventReceived(
appNavigationState = anAppNavigationState(
navigationState = aNavigationState(sessionId = A_SESSION_ID),
),
notifiableEvents = listOf(aNotifiableMessageEvent(sessionId = A_SESSION_ID)),
shouldEmitNotification = true,
extraInvocationsForNotificationSummary = 1,
)
@Test
fun `notifiable event does not emit a notification when the same room is displayed`() = testOnNotifiableEventReceived(
appNavigationState = anAppNavigationState(
navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID),
),
notifiableEvents = listOf(
aNotifiableMessageEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
)
),
shouldEmitNotification = false,
)
@Test
fun `notifiable event for a thread emits a notification when the same room is displayed`() = testOnNotifiableEventReceived(
appNavigationState = anAppNavigationState(
navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID),
),
notifiableEvents = listOf(
aNotifiableMessageEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = A_THREAD_ID,
)
),
shouldEmitNotification = true,
extraInvocationsForNotificationSummary = 1,
)
@Test
fun `notifiable event for a thread does not emit a notification when the same thread is displayed`() = testOnNotifiableEventReceived(
appNavigationState = anAppNavigationState(
navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID),
),
notifiableEvents = listOf(
aNotifiableMessageEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = A_THREAD_ID,
)
),
shouldEmitNotification = false,
)
@Test
fun `notifiable event for a thread emits a notification when another thread is displayed`() = testOnNotifiableEventReceived(
appNavigationState = anAppNavigationState(
navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID_2),
),
notifiableEvents = listOf(
aNotifiableMessageEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = A_THREAD_ID,
)
),
shouldEmitNotification = true,
extraInvocationsForNotificationSummary = 1,
)
@Test
fun `notifiable event for a thread emits a notification when a thread of another room is displayed`() = testOnNotifiableEventReceived(
appNavigationState = anAppNavigationState(
navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID_2, threadId = A_THREAD_ID_2),
),
notifiableEvents = listOf(
aNotifiableMessageEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = A_THREAD_ID,
)
),
shouldEmitNotification = true,
extraInvocationsForNotificationSummary = 1,
)
@Test
fun `notifiable event emits a notification when another room is displayed`() = testOnNotifiableEventReceived(
appNavigationState = anAppNavigationState(
navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID_2),
),
notifiableEvents = listOf(
aNotifiableMessageEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
)
),
shouldEmitNotification = true,
extraInvocationsForNotificationSummary = 1,
)
private fun testOnNotifiableEventReceived(
appNavigationState: AppNavigationState,
notifiableEvents: List<NotifiableEvent>,
shouldEmitNotification: Boolean,
extraInvocationsForNotificationSummary: Int = 0,
) = runTest {
val showNotificationResult = lambdaRecorder<String?, Int, Notification, Boolean> { _, _, _ ->
true
}
val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager(
appNavigationStateService = FakeAppNavigationStateService(
initialAppNavigationState = appNavigationState,
),
notificationDisplayer = FakeNotificationDisplayer(
showNotificationResult = showNotificationResult,
)
)
defaultNotificationDrawerManager.onNotifiableEventsReceived(notifiableEvents)
showNotificationResult.assertions().isCalledExactly(
if (shouldEmitNotification) {
notifiableEvents.size + extraInvocationsForNotificationSummary
} else {
0
}
) )
} }
} }
fun TestScope.createDefaultNotificationDrawerManager(
notificationDisplayer: NotificationDisplayer = FakeNotificationDisplayer(),
notificationRenderer: NotificationRenderer? = null,
appNavigationStateService: AppNavigationStateService = FakeAppNavigationStateService(),
roomGroupMessageCreator: RoomGroupMessageCreator = FakeRoomGroupMessageCreator(),
summaryGroupMessageCreator: SummaryGroupMessageCreator = FakeSummaryGroupMessageCreator(),
activeNotificationsProvider: FakeActiveNotificationsProvider = FakeActiveNotificationsProvider(),
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
sessionStore: SessionStore = InMemorySessionStore(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
sessionObserver: SessionObserver = FakeSessionObserver(),
): DefaultNotificationDrawerManager {
return DefaultNotificationDrawerManager(
notificationDisplayer = notificationDisplayer,
notificationRenderer = notificationRenderer ?: NotificationRenderer(
notificationDisplayer = notificationDisplayer,
notificationDataFactory = DefaultNotificationDataFactory(
notificationCreator = FakeNotificationCreator(),
roomGroupMessageCreator = roomGroupMessageCreator,
summaryGroupMessageCreator = summaryGroupMessageCreator,
activeNotificationsProvider = activeNotificationsProvider,
),
enterpriseService = enterpriseService,
sessionStore = sessionStore,
),
appNavigationStateService = appNavigationStateService,
coroutineScope = backgroundScope,
matrixClientProvider = matrixClientProvider,
imageLoaderHolder = FakeImageLoaderHolder(),
activeNotificationsProvider = activeNotificationsProvider,
sessionObserver = sessionObserver,
)
}

View file

@ -16,13 +16,9 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider 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.FakeNotificationService
import io.element.android.libraries.matrix.test.notification.aNotificationData import io.element.android.libraries.matrix.test.notification.aNotificationData
import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder
import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDataFactory import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDataFactory
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.test.notifications.FakeCallNotificationEventResolver import io.element.android.libraries.push.test.notifications.FakeCallNotificationEventResolver
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
@ -47,16 +43,10 @@ class DefaultOnMissedCallNotificationHandlerTest {
}) })
val defaultOnMissedCallNotificationHandler = DefaultOnMissedCallNotificationHandler( val defaultOnMissedCallNotificationHandler = DefaultOnMissedCallNotificationHandler(
matrixClientProvider = matrixClientProvider, matrixClientProvider = matrixClientProvider,
defaultNotificationDrawerManager = DefaultNotificationDrawerManager( defaultNotificationDrawerManager = createDefaultNotificationDrawerManager(
notificationDisplayer = FakeNotificationDisplayer(),
notificationRenderer = createNotificationRenderer( notificationRenderer = createNotificationRenderer(
notificationDataFactory = dataFactory, notificationDataFactory = dataFactory,
), ),
appNavigationStateService = FakeAppNavigationStateService(),
coroutineScope = backgroundScope,
matrixClientProvider = FakeMatrixClientProvider(),
imageLoaderHolder = FakeImageLoaderHolder(),
activeNotificationsProvider = FakeActiveNotificationsProvider(),
), ),
callNotificationEventResolver = FakeCallNotificationEventResolver(resolveEventLambda = { _, _, _ -> callNotificationEventResolver = FakeCallNotificationEventResolver(resolveEventLambda = { _, _, _ ->
Result.success(aNotifiableMessageEvent()) Result.success(aNotifiableMessageEvent())

View file

@ -39,7 +39,6 @@ class DefaultSummaryGroupMessageCreatorTest {
RoomNotification( RoomNotification(
notification = Notification(), notification = Notification(),
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
summaryLine = "",
messageCount = 1, messageCount = 1,
latestTimestamp = A_FAKE_TIMESTAMP + 10, latestTimestamp = A_FAKE_TIMESTAMP + 10,
shouldBing = true, shouldBing = true,
@ -48,7 +47,6 @@ class DefaultSummaryGroupMessageCreatorTest {
), ),
invitationNotifications = emptyList(), invitationNotifications = emptyList(),
simpleNotifications = emptyList(), simpleNotifications = emptyList(),
fallbackNotifications = emptyList(),
) )
notificationCreator.createSummaryListNotificationResult.assertions() notificationCreator.createSummaryListNotificationResult.assertions()

View file

@ -20,10 +20,10 @@ import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotif
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent
import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -34,6 +34,7 @@ private val MY_AVATAR_URL: String? = null
private val AN_INVITATION_EVENT = anInviteNotifiableEvent(roomId = A_ROOM_ID) private val AN_INVITATION_EVENT = anInviteNotifiableEvent(roomId = A_ROOM_ID)
private val A_SIMPLE_EVENT = aSimpleNotifiableEvent(eventId = AN_EVENT_ID) private val A_SIMPLE_EVENT = aSimpleNotifiableEvent(eventId = AN_EVENT_ID)
private val A_MESSAGE_EVENT = aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID) private val A_MESSAGE_EVENT = aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID)
private val A_FALLBACK_EVENT = aFallbackNotifiableEvent()
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class NotificationDataFactoryTest { class NotificationDataFactoryTest {
@ -47,7 +48,6 @@ class NotificationDataFactoryTest {
roomGroupMessageCreator = fakeRoomGroupMessageCreator, roomGroupMessageCreator = fakeRoomGroupMessageCreator,
summaryGroupMessageCreator = fakeSummaryGroupMessageCreator, summaryGroupMessageCreator = fakeSummaryGroupMessageCreator,
activeNotificationsProvider = activeNotificationsProvider, activeNotificationsProvider = activeNotificationsProvider,
stringProvider = FakeStringProvider(),
) )
@Test @Test
@ -64,7 +64,6 @@ class NotificationDataFactoryTest {
OneShotNotification( OneShotNotification(
notification = expectedNotification, notification = expectedNotification,
tag = A_ROOM_ID.value, tag = A_ROOM_ID.value,
summaryLine = AN_INVITATION_EVENT.description,
isNoisy = AN_INVITATION_EVENT.noisy, isNoisy = AN_INVITATION_EVENT.noisy,
timestamp = AN_INVITATION_EVENT.timestamp timestamp = AN_INVITATION_EVENT.timestamp
) )
@ -72,6 +71,25 @@ class NotificationDataFactoryTest {
) )
} }
@Test
fun `given a fallback invitation when mapping to notification then it's added`() = testWith(notificationDataFactory) {
val fallbackEvents = listOf(A_FALLBACK_EVENT)
val expectedNotification = notificationCreator.createFallbackNotificationResult(
null,
aNotificationAccountParams(),
fallbackEvents,
)
val result = toNotification(fallbackEvents, aNotificationAccountParams())
assertThat(result).isEqualTo(
OneShotNotification(
notification = expectedNotification,
tag = "FALLBACK",
isNoisy = false,
timestamp = A_FALLBACK_EVENT.timestamp
)
)
}
@Test @Test
fun `given a simple event when mapping to notification then it's added`() = testWith(notificationDataFactory) { fun `given a simple event when mapping to notification then it's added`() = testWith(notificationDataFactory) {
val expectedNotification = notificationCreator.createRoomInvitationNotificationResult( val expectedNotification = notificationCreator.createRoomInvitationNotificationResult(
@ -83,7 +101,6 @@ class NotificationDataFactoryTest {
OneShotNotification( OneShotNotification(
notification = expectedNotification, notification = expectedNotification,
tag = AN_EVENT_ID.value, tag = AN_EVENT_ID.value,
summaryLine = A_SIMPLE_EVENT.description,
isNoisy = A_SIMPLE_EVENT.noisy, isNoisy = A_SIMPLE_EVENT.noisy,
timestamp = AN_INVITATION_EVENT.timestamp timestamp = AN_INVITATION_EVENT.timestamp
) )
@ -105,7 +122,6 @@ class NotificationDataFactoryTest {
existingNotification = null, existingNotification = null,
), ),
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
summaryLine = "A room name: Bob Hello world!",
messageCount = events.size, messageCount = events.size,
latestTimestamp = events.maxOf { it.timestamp }, latestTimestamp = events.maxOf { it.timestamp },
shouldBing = events.any { it.noisy }, shouldBing = events.any { it.noisy },
@ -161,7 +177,6 @@ class NotificationDataFactoryTest {
existingNotification = null, existingNotification = null,
), ),
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
summaryLine = "A room name: Bob Hello world!",
messageCount = withRedactedRemoved.size, messageCount = withRedactedRemoved.size,
latestTimestamp = withRedactedRemoved.maxOf { it.timestamp }, latestTimestamp = withRedactedRemoved.maxOf { it.timestamp },
shouldBing = withRedactedRemoved.any { it.noisy }, shouldBing = withRedactedRemoved.any { it.noisy },

View file

@ -29,7 +29,6 @@ import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNot
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -43,7 +42,7 @@ private const val USE_COMPLETE_NOTIFICATION_FORMAT = true
private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(A_NOTIFICATION) private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(A_NOTIFICATION)
private val ONE_SHOT_NOTIFICATION = private val ONE_SHOT_NOTIFICATION =
OneShotNotification(notification = A_NOTIFICATION, tag = "ignored", summaryLine = "ignored", isNoisy = false, timestamp = -1) OneShotNotification(notification = A_NOTIFICATION, tag = "ignored", isNoisy = false, timestamp = -1)
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class NotificationRendererTest { class NotificationRendererTest {
@ -57,7 +56,6 @@ class NotificationRendererTest {
roomGroupMessageCreator = roomGroupMessageCreator, roomGroupMessageCreator = roomGroupMessageCreator,
summaryGroupMessageCreator = summaryGroupMessageCreator, summaryGroupMessageCreator = summaryGroupMessageCreator,
activeNotificationsProvider = FakeActiveNotificationsProvider(), activeNotificationsProvider = FakeActiveNotificationsProvider(),
stringProvider = FakeStringProvider(),
) )
private val notificationIdProvider = NotificationIdProvider private val notificationIdProvider = NotificationIdProvider

View file

@ -37,8 +37,8 @@ import io.element.android.libraries.push.impl.notifications.factories.action.Acc
import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.RejectInvitationActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.RejectInvitationActionFactory
import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
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.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
@ -84,19 +84,11 @@ class DefaultNotificationCreatorTest {
fun `test createFallbackNotification`() { fun `test createFallbackNotification`() {
val sut = createNotificationCreator() val sut = createNotificationCreator()
val result = sut.createFallbackNotification( val result = sut.createFallbackNotification(
existingNotification = null,
notificationAccountParams = aNotificationAccountParams(), notificationAccountParams = aNotificationAccountParams(),
FallbackNotifiableEvent( fallbackNotifiableEvents = listOf(
sessionId = A_SESSION_ID, aFallbackNotifiableEvent(),
roomId = A_ROOM_ID, )
eventId = AN_EVENT_ID,
editedEventId = null,
description = "description",
canBeReplaced = false,
isRedacted = false,
isUpdated = false,
timestamp = A_FAKE_TIMESTAMP,
cause = null,
),
) )
result.commonAssertions( result.commonAssertions(
expectedCategory = null, expectedCategory = null,

View file

@ -21,6 +21,7 @@ class FakeActiveNotificationsProvider(
private val getMembershipNotificationForSessionResult: (SessionId) -> List<StatusBarNotification> = { emptyList() }, private val getMembershipNotificationForSessionResult: (SessionId) -> List<StatusBarNotification> = { emptyList() },
private val getMembershipNotificationForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> emptyList() }, private val getMembershipNotificationForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> emptyList() },
private val getSummaryNotificationResult: (SessionId) -> StatusBarNotification? = { null }, private val getSummaryNotificationResult: (SessionId) -> StatusBarNotification? = { null },
private val getFallbackNotificationResult: (SessionId) -> StatusBarNotification? = { null },
private val countResult: (SessionId) -> Int = { 0 }, private val countResult: (SessionId) -> Int = { 0 },
) : ActiveNotificationsProvider { ) : ActiveNotificationsProvider {
override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): List<StatusBarNotification> { override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): List<StatusBarNotification> {
@ -47,6 +48,10 @@ class FakeActiveNotificationsProvider(
return getSummaryNotificationResult(sessionId) return getSummaryNotificationResult(sessionId)
} }
override fun getFallbackNotification(sessionId: SessionId): StatusBarNotification? {
return getFallbackNotificationResult(sessionId)
}
override fun count(sessionId: SessionId): Int { override fun count(sessionId: SessionId): Int {
return countResult(sessionId) return countResult(sessionId)
} }

View file

@ -24,6 +24,7 @@ import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiab
import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder
import io.element.android.tests.testutils.lambda.LambdaListAnyParamsRecorder import io.element.android.tests.testutils.lambda.LambdaListAnyParamsRecorder
import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder
import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder
import io.element.android.tests.testutils.lambda.LambdaTwoParamsRecorder import io.element.android.tests.testutils.lambda.LambdaTwoParamsRecorder
import io.element.android.tests.testutils.lambda.lambdaAnyRecorder import io.element.android.tests.testutils.lambda.lambdaAnyRecorder
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
@ -34,8 +35,8 @@ class FakeNotificationCreator(
lambdaRecorder { _, _ -> A_NOTIFICATION }, lambdaRecorder { _, _ -> A_NOTIFICATION },
var createSimpleNotificationResult: LambdaTwoParamsRecorder<NotificationAccountParams, SimpleNotifiableEvent, Notification> = var createSimpleNotificationResult: LambdaTwoParamsRecorder<NotificationAccountParams, SimpleNotifiableEvent, Notification> =
lambdaRecorder { _, _ -> A_NOTIFICATION }, lambdaRecorder { _, _ -> A_NOTIFICATION },
var createFallbackNotificationResult: LambdaTwoParamsRecorder<NotificationAccountParams, FallbackNotifiableEvent, Notification> = var createFallbackNotificationResult: LambdaThreeParamsRecorder<Notification?, NotificationAccountParams, List<FallbackNotifiableEvent>, Notification> =
lambdaRecorder { _, _ -> A_NOTIFICATION }, lambdaRecorder { _, _, _ -> A_NOTIFICATION },
var createSummaryListNotificationResult: LambdaFiveParamsRecorder< var createSummaryListNotificationResult: LambdaFiveParamsRecorder<
NotificationAccountParams, String, Boolean, Long, NotificationAccountParams, Notification NotificationAccountParams, String, Boolean, Long, NotificationAccountParams, Notification
> = lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION }, > = lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION },
@ -75,10 +76,15 @@ class FakeNotificationCreator(
} }
override fun createFallbackNotification( override fun createFallbackNotification(
existingNotification: Notification?,
notificationAccountParams: NotificationAccountParams, notificationAccountParams: NotificationAccountParams,
fallbackNotifiableEvent: FallbackNotifiableEvent, fallbackNotifiableEvents: List<FallbackNotifiableEvent>,
): Notification { ): Notification {
return createFallbackNotificationResult(notificationAccountParams, fallbackNotifiableEvent) return createFallbackNotificationResult(
existingNotification,
notificationAccountParams,
fallbackNotifiableEvents,
)
} }
override fun createSummaryListNotification( override fun createSummaryListNotification(

View file

@ -19,7 +19,7 @@ import io.element.android.libraries.push.impl.notifications.model.FallbackNotifi
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder import io.element.android.tests.testutils.lambda.LambdaFourParamsRecorder
import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder
import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
@ -28,18 +28,17 @@ class FakeNotificationDataFactory(
var messageEventToNotificationsResult: LambdaThreeParamsRecorder< var messageEventToNotificationsResult: LambdaThreeParamsRecorder<
List<NotifiableMessageEvent>, ImageLoader, NotificationAccountParams, List<RoomNotification> List<NotifiableMessageEvent>, ImageLoader, NotificationAccountParams, List<RoomNotification>
> = lambdaRecorder { _, _, _ -> emptyList() }, > = lambdaRecorder { _, _, _ -> emptyList() },
var summaryToNotificationsResult: LambdaFiveParamsRecorder< var summaryToNotificationsResult: LambdaFourParamsRecorder<
List<RoomNotification>, List<RoomNotification>,
List<OneShotNotification>, List<OneShotNotification>,
List<OneShotNotification>, List<OneShotNotification>,
List<OneShotNotification>,
NotificationAccountParams, NotificationAccountParams,
SummaryNotification SummaryNotification
> = lambdaRecorder { _, _, _, _, _ -> SummaryNotification.Update(A_NOTIFICATION) }, > = lambdaRecorder { _, _, _, _ -> SummaryNotification.Update(A_NOTIFICATION) },
var inviteToNotificationsResult: LambdaOneParamRecorder<List<InviteNotifiableEvent>, List<OneShotNotification>> = lambdaRecorder { _ -> emptyList() }, var inviteToNotificationsResult: LambdaOneParamRecorder<List<InviteNotifiableEvent>, List<OneShotNotification>> = lambdaRecorder { _ -> emptyList() },
var simpleEventToNotificationsResult: LambdaOneParamRecorder<List<SimpleNotifiableEvent>, List<OneShotNotification>> = lambdaRecorder { _ -> emptyList() }, var simpleEventToNotificationsResult: LambdaOneParamRecorder<List<SimpleNotifiableEvent>, List<OneShotNotification>> = lambdaRecorder { _ -> emptyList() },
var fallbackEventToNotificationsResult: LambdaOneParamRecorder<List<FallbackNotifiableEvent>, List<OneShotNotification>> = var fallbackEventToNotificationsResult: LambdaOneParamRecorder<List<FallbackNotifiableEvent>, OneShotNotification?> =
lambdaRecorder { _ -> emptyList() }, lambdaRecorder { _ -> null },
) : NotificationDataFactory { ) : NotificationDataFactory {
override suspend fun toNotifications( override suspend fun toNotifications(
messages: List<NotifiableMessageEvent>, messages: List<NotifiableMessageEvent>,
@ -69,10 +68,10 @@ class FakeNotificationDataFactory(
@JvmName("toNotificationFallbackEvents") @JvmName("toNotificationFallbackEvents")
@Suppress("INAPPLICABLE_JVM_NAME") @Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications( override fun toNotification(
fallback: List<FallbackNotifiableEvent>, fallback: List<FallbackNotifiableEvent>,
notificationAccountParams: NotificationAccountParams, notificationAccountParams: NotificationAccountParams,
): List<OneShotNotification> { ): OneShotNotification? {
return fallbackEventToNotificationsResult(fallback) return fallbackEventToNotificationsResult(fallback)
} }
@ -80,14 +79,12 @@ class FakeNotificationDataFactory(
roomNotifications: List<RoomNotification>, roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>, invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>, simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
notificationAccountParams: NotificationAccountParams, notificationAccountParams: NotificationAccountParams,
): SummaryNotification { ): SummaryNotification {
return summaryToNotificationsResult( return summaryToNotificationsResult(
roomNotifications, roomNotifications,
invitationNotifications, invitationNotifications,
simpleNotifications, simpleNotifications,
fallbackNotifications,
notificationAccountParams, notificationAccountParams,
) )
} }

View file

@ -14,27 +14,25 @@ import io.element.android.libraries.push.impl.notifications.RoomNotification
import io.element.android.libraries.push.impl.notifications.SummaryGroupMessageCreator import io.element.android.libraries.push.impl.notifications.SummaryGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams
import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION
import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder import io.element.android.tests.testutils.lambda.LambdaFourParamsRecorder
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
class FakeSummaryGroupMessageCreator( class FakeSummaryGroupMessageCreator(
var createSummaryNotificationResult: LambdaFiveParamsRecorder< var createSummaryNotificationResult: LambdaFourParamsRecorder<
NotificationAccountParams, List<RoomNotification>, List<OneShotNotification>, List<OneShotNotification>, List<OneShotNotification>, Notification> = NotificationAccountParams, List<RoomNotification>, List<OneShotNotification>, List<OneShotNotification>, Notification> =
lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION } lambdaRecorder { _, _, _, _ -> A_NOTIFICATION }
) : SummaryGroupMessageCreator { ) : SummaryGroupMessageCreator {
override fun createSummaryNotification( override fun createSummaryNotification(
notificationAccountParams: NotificationAccountParams, notificationAccountParams: NotificationAccountParams,
roomNotifications: List<RoomNotification>, roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>, invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>, simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
): Notification { ): Notification {
return createSummaryNotificationResult( return createSummaryNotificationResult(
notificationAccountParams, notificationAccountParams,
roomNotifications, roomNotifications,
invitationNotifications, invitationNotifications,
simpleNotifications, simpleNotifications,
fallbackNotifications,
) )
} }
} }

View file

@ -24,10 +24,12 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_TIMESTAMP import io.element.android.libraries.matrix.test.A_TIMESTAMP
import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_NAME_2 import io.element.android.libraries.matrix.test.A_USER_NAME_2
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.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
fun aSimpleNotifiableEvent( fun aSimpleNotifiableEvent(
sessionId: SessionId = A_SESSION_ID, sessionId: SessionId = A_SESSION_ID,
@ -141,3 +143,18 @@ fun aNotifiableCallEvent(
senderAvatarUrl = senderAvatarUrl, senderAvatarUrl = senderAvatarUrl,
rtcNotificationType = rtcNotificationType, rtcNotificationType = rtcNotificationType,
) )
fun aFallbackNotifiableEvent(
sessionId: SessionId = A_SESSION_ID,
) = FallbackNotifiableEvent(
sessionId = sessionId,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
editedEventId = null,
description = "A fallback notification",
canBeReplaced = false,
isRedacted = false,
isUpdated = false,
timestamp = A_FAKE_TIMESTAMP,
cause = "Unable to decrypt event",
)

View file

@ -40,9 +40,9 @@ import io.element.android.libraries.push.impl.notifications.DefaultNotificationR
import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver 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.FallbackNotificationFactory
import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels 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.aNotifiableCallEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
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.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
import io.element.android.libraries.push.impl.test.DefaultTestPush import io.element.android.libraries.push.impl.test.DefaultTestPush
@ -57,7 +57,6 @@ import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.Fa
import io.element.android.libraries.workmanager.api.WorkManagerRequest import io.element.android.libraries.workmanager.api.WorkManagerRequest
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
@ -627,18 +626,7 @@ class DefaultPushHandlerTest {
@Test @Test
fun `when receiving a fallback event, we notify the push history service about it not being resolved`() = runTest { fun `when receiving a fallback event, we notify the push history service about it not being resolved`() = runTest {
val aNotifiableFallbackEvent = FallbackNotifiableEvent( val aNotifiableFallbackEvent = aFallbackNotifiableEvent()
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
editedEventId = null,
description = "A fallback notification",
canBeReplaced = false,
isRedacted = false,
isUpdated = false,
timestamp = 0L,
cause = "Unable to decrypt event",
)
val notifiableEventResult = val notifiableEventResult =
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ -> lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
@ -724,7 +712,6 @@ class DefaultPushHandlerTest {
appCoroutineScope = backgroundScope, appCoroutineScope = backgroundScope,
fallbackNotificationFactory = FallbackNotificationFactory( fallbackNotificationFactory = FallbackNotificationFactory(
clock = FakeSystemClock(), clock = FakeSystemClock(),
stringProvider = FakeStringProvider(),
), ),
syncOnNotifiableEvent = syncOnNotifiableEvent, syncOnNotifiableEvent = syncOnNotifiableEvent,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,

View file

@ -39,13 +39,13 @@ private const val versionYear = 26
* Month of the version on 2 digits. Value must be in [1,12]. * Month of the version on 2 digits. Value must be in [1,12].
* Do not update this value. it is updated by the release script. * Do not update this value. it is updated by the release script.
*/ */
private const val versionMonth = 1 private const val versionMonth = 2
/** /**
* Release number in the month. Value must be in [0,99]. * Release number in the month. Value must be in [0,99].
* Do not update this value. it is updated by the release script. * Do not update this value. it is updated by the release script.
*/ */
private const val versionReleaseNumber = 2 private const val versionReleaseNumber = 0
object Versions { object Versions {
/** /**

View file

@ -74,7 +74,6 @@ fun DependencyHandlerScope.composeDependencies(libs: LibrariesForLibs) {
implementation(composeBom) implementation(composeBom)
androidTestImplementation(composeBom) androidTestImplementation(composeBom)
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons) implementation(libs.androidx.compose.material.icons)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)

View file

@ -43,9 +43,9 @@ class DefaultAnalyticsRoomListStateWatcherTest {
runCurrent() runCurrent()
// Make sure it's warm by changing its internal state // Make sure it's warm by changing its internal state
navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false))
runCurrent() runCurrent()
navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true))
runCurrent() runCurrent()
// The transaction should be present now // The transaction should be present now
@ -63,9 +63,9 @@ class DefaultAnalyticsRoomListStateWatcherTest {
@Test @Test
fun `Opening the app in a cold state does nothing`() = runTest { fun `Opening the app in a cold state does nothing`() = runTest {
val navigationStateService = FakeAppNavigationStateService().apply { val navigationStateService = FakeAppNavigationStateService(
appNavigationState.emit(AppNavigationState(NavigationState.Root, false)) initialAppNavigationState = AppNavigationState(NavigationState.Root, false)
} )
val roomListService = FakeRoomListService().apply { val roomListService = FakeRoomListService().apply {
postState(RoomListService.State.Idle) postState(RoomListService.State.Idle)
} }
@ -110,9 +110,9 @@ class DefaultAnalyticsRoomListStateWatcherTest {
runCurrent() runCurrent()
// Make sure it's warm by changing its internal state // Make sure it's warm by changing its internal state
navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false))
runCurrent() runCurrent()
navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true))
runCurrent() runCurrent()
// The transaction should be present now // The transaction should be present now
@ -145,9 +145,9 @@ class DefaultAnalyticsRoomListStateWatcherTest {
runCurrent() runCurrent()
// Make sure it's warm by changing its internal state // Make sure it's warm by changing its internal state
navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false))
runCurrent() runCurrent()
navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true))
runCurrent() runCurrent()
// The transaction was never added // The transaction was never added

View file

@ -31,7 +31,6 @@ import io.sentry.Sentry
import io.sentry.SentryTracer import io.sentry.SentryTracer
import io.sentry.protocol.SentryId import io.sentry.protocol.SentryId
import io.sentry.protocol.SentryTransaction import io.sentry.protocol.SentryTransaction
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -149,7 +148,7 @@ class SentryAnalyticsProviderTest {
) )
}, },
appNavigationStateService = FakeAppNavigationStateService( appNavigationStateService = FakeAppNavigationStateService(
MutableStateFlow(AppNavigationState(navigationState = NavigationState.Session("owner", A_SESSION_ID), isInForeground = true)) initialAppNavigationState = AppNavigationState(navigationState = NavigationState.Session("owner", A_SESSION_ID), isInForeground = true)
) )
).run { ).run {
init() init()
@ -182,7 +181,7 @@ class SentryAnalyticsProviderTest {
) )
}, },
appNavigationStateService = FakeAppNavigationStateService( appNavigationStateService = FakeAppNavigationStateService(
MutableStateFlow(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) initialAppNavigationState = AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)
) )
).run { ).run {
init() init()
@ -203,7 +202,7 @@ class SentryAnalyticsProviderTest {
) )
}, },
appNavigationStateService = FakeAppNavigationStateService( appNavigationStateService = FakeAppNavigationStateService(
MutableStateFlow(AppNavigationState(navigationState = NavigationState.Session("owner", A_SESSION_ID), isInForeground = true)) initialAppNavigationState = AppNavigationState(navigationState = NavigationState.Session("owner", A_SESSION_ID), isInForeground = true)
) )
).run { ).run {
init() init()
@ -221,7 +220,7 @@ class SentryAnalyticsProviderTest {
buildMeta: BuildMeta = aBuildMeta(), buildMeta: BuildMeta = aBuildMeta(),
getDatabaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) }, getDatabaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) },
appNavigationStateService: FakeAppNavigationStateService = FakeAppNavigationStateService( appNavigationStateService: FakeAppNavigationStateService = FakeAppNavigationStateService(
MutableStateFlow(AppNavigationState(navigationState = NavigationState.Session("owner", A_SESSION_ID), isInForeground = true)) initialAppNavigationState = AppNavigationState(NavigationState.Session("owner", A_SESSION_ID), isInForeground = true)
) )
) = SentryAnalyticsProvider( ) = SentryAnalyticsProvider(
context = InstrumentationRegistry.getInstrumentation().targetContext, context = InstrumentationRegistry.getInstrumentation().targetContext,

View file

@ -10,7 +10,6 @@ package io.element.android.services.appnavstate.api
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -23,9 +22,6 @@ interface AppNavigationStateService {
fun onNavigateToSession(owner: String, sessionId: SessionId) fun onNavigateToSession(owner: String, sessionId: SessionId)
fun onLeavingSession(owner: String) fun onLeavingSession(owner: String)
fun onNavigateToSpace(owner: String, spaceId: SpaceId)
fun onLeavingSpace(owner: String)
fun onNavigateToRoom(owner: String, roomId: RoomId) fun onNavigateToRoom(owner: String, roomId: RoomId)
fun onLeavingRoom(owner: String) fun onLeavingRoom(owner: String)

View file

@ -10,7 +10,6 @@ package io.element.android.services.appnavstate.api
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
/** /**
@ -29,17 +28,10 @@ sealed class NavigationState(open val owner: String) {
val sessionId: SessionId, val sessionId: SessionId,
) : NavigationState(owner) ) : NavigationState(owner)
data class Space(
override val owner: String,
// Can be fake value, if no space is selected
val spaceId: SpaceId,
val parentSession: Session,
) : NavigationState(owner)
data class Room( data class Room(
override val owner: String, override val owner: String,
val roomId: RoomId, val roomId: RoomId,
val parentSpace: Space, val parentSession: Session,
) : NavigationState(owner) ) : NavigationState(owner)
data class Thread( data class Thread(

View file

@ -10,26 +10,14 @@ package io.element.android.services.appnavstate.api
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
fun NavigationState.currentSessionId(): SessionId? { fun NavigationState.currentSessionId(): SessionId? {
return when (this) { return when (this) {
NavigationState.Root -> null NavigationState.Root -> null
is NavigationState.Session -> sessionId is NavigationState.Session -> sessionId
is NavigationState.Space -> parentSession.sessionId is NavigationState.Room -> parentSession.sessionId
is NavigationState.Room -> parentSpace.parentSession.sessionId is NavigationState.Thread -> parentRoom.parentSession.sessionId
is NavigationState.Thread -> parentRoom.parentSpace.parentSession.sessionId
}
}
fun NavigationState.currentSpaceId(): SpaceId? {
return when (this) {
NavigationState.Root -> null
is NavigationState.Session -> null
is NavigationState.Space -> spaceId
is NavigationState.Room -> parentSpace.spaceId
is NavigationState.Thread -> parentRoom.parentSpace.spaceId
} }
} }
@ -37,7 +25,6 @@ fun NavigationState.currentRoomId(): RoomId? {
return when (this) { return when (this) {
NavigationState.Root -> null NavigationState.Root -> null
is NavigationState.Session -> null is NavigationState.Session -> null
is NavigationState.Space -> null
is NavigationState.Room -> roomId is NavigationState.Room -> roomId
is NavigationState.Thread -> parentRoom.roomId is NavigationState.Thread -> parentRoom.roomId
} }
@ -47,7 +34,6 @@ fun NavigationState.currentThreadId(): ThreadId? {
return when (this) { return when (this) {
NavigationState.Root -> null NavigationState.Root -> null
is NavigationState.Session -> null is NavigationState.Session -> null
is NavigationState.Space -> null
is NavigationState.Room -> null is NavigationState.Room -> null
is NavigationState.Thread -> threadId is NavigationState.Thread -> threadId
} }

View file

@ -15,7 +15,6 @@ import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.services.appnavstate.api.AppForegroundStateService import io.element.android.services.appnavstate.api.AppForegroundStateService
import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationState
@ -62,7 +61,6 @@ class DefaultAppNavigationStateService(
Timber.tag(loggerTag.value).d("Navigating to session $sessionId. Current state: $currentValue") Timber.tag(loggerTag.value).d("Navigating to session $sessionId. Current state: $currentValue")
val newValue: NavigationState.Session = when (currentValue) { val newValue: NavigationState.Session = when (currentValue) {
is NavigationState.Session, is NavigationState.Session,
is NavigationState.Space,
is NavigationState.Room, is NavigationState.Room,
is NavigationState.Thread, is NavigationState.Thread,
is NavigationState.Root -> NavigationState.Session(owner, sessionId) is NavigationState.Root -> NavigationState.Session(owner, sessionId)
@ -70,28 +68,14 @@ class DefaultAppNavigationStateService(
state.getAndUpdate { it.copy(navigationState = newValue) } state.getAndUpdate { it.copy(navigationState = newValue) }
} }
override fun onNavigateToSpace(owner: String, spaceId: SpaceId) {
val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Navigating to space $spaceId. Current state: $currentValue")
val newValue: NavigationState.Space = when (currentValue) {
NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> NavigationState.Space(owner, spaceId, currentValue)
is NavigationState.Space -> NavigationState.Space(owner, spaceId, currentValue.parentSession)
is NavigationState.Room -> NavigationState.Space(owner, spaceId, currentValue.parentSpace.parentSession)
is NavigationState.Thread -> NavigationState.Space(owner, spaceId, currentValue.parentRoom.parentSpace.parentSession)
}
state.getAndUpdate { it.copy(navigationState = newValue) }
}
override fun onNavigateToRoom(owner: String, roomId: RoomId) { override fun onNavigateToRoom(owner: String, roomId: RoomId) {
val currentValue = state.value.navigationState val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Navigating to room $roomId. Current state: $currentValue") Timber.tag(loggerTag.value).d("Navigating to room $roomId. Current state: $currentValue")
val newValue: NavigationState.Room = when (currentValue) { val newValue: NavigationState.Room = when (currentValue) {
NavigationState.Root -> return logError("onNavigateToSession()") NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> return logError("onNavigateToSpace()") is NavigationState.Session -> NavigationState.Room(owner, roomId, currentValue)
is NavigationState.Space -> NavigationState.Room(owner, roomId, currentValue) is NavigationState.Room -> NavigationState.Room(owner, roomId, currentValue.parentSession)
is NavigationState.Room -> NavigationState.Room(owner, roomId, currentValue.parentSpace) is NavigationState.Thread -> NavigationState.Room(owner, roomId, currentValue.parentRoom.parentSession)
is NavigationState.Thread -> NavigationState.Room(owner, roomId, currentValue.parentRoom.parentSpace)
} }
state.getAndUpdate { it.copy(navigationState = newValue) } state.getAndUpdate { it.copy(navigationState = newValue) }
} }
@ -101,8 +85,7 @@ class DefaultAppNavigationStateService(
Timber.tag(loggerTag.value).d("Navigating to thread $threadId. Current state: $currentValue") Timber.tag(loggerTag.value).d("Navigating to thread $threadId. Current state: $currentValue")
val newValue: NavigationState.Thread = when (currentValue) { val newValue: NavigationState.Thread = when (currentValue) {
NavigationState.Root -> return logError("onNavigateToSession()") NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> return logError("onNavigateToSpace()") is NavigationState.Session -> return logError("onNavigateToRoom()")
is NavigationState.Space -> return logError("onNavigateToRoom()")
is NavigationState.Room -> NavigationState.Thread(owner, threadId, currentValue) is NavigationState.Room -> NavigationState.Thread(owner, threadId, currentValue)
is NavigationState.Thread -> NavigationState.Thread(owner, threadId, currentValue.parentRoom) is NavigationState.Thread -> NavigationState.Thread(owner, threadId, currentValue.parentRoom)
} }
@ -115,8 +98,7 @@ class DefaultAppNavigationStateService(
if (!currentValue.assertOwner(owner)) return if (!currentValue.assertOwner(owner)) return
val newValue: NavigationState.Room = when (currentValue) { val newValue: NavigationState.Room = when (currentValue) {
NavigationState.Root -> return logError("onNavigateToSession()") NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> return logError("onNavigateToSpace()") is NavigationState.Session -> return logError("onNavigateToRoom()")
is NavigationState.Space -> return logError("onNavigateToRoom()")
is NavigationState.Room -> return logError("onNavigateToThread()") is NavigationState.Room -> return logError("onNavigateToThread()")
is NavigationState.Thread -> currentValue.parentRoom is NavigationState.Thread -> currentValue.parentRoom
} }
@ -127,26 +109,11 @@ class DefaultAppNavigationStateService(
val currentValue = state.value.navigationState val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Leaving room. Current state: $currentValue") Timber.tag(loggerTag.value).d("Leaving room. Current state: $currentValue")
if (!currentValue.assertOwner(owner)) return if (!currentValue.assertOwner(owner)) return
val newValue: NavigationState.Space = when (currentValue) {
NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> return logError("onNavigateToSpace()")
is NavigationState.Space -> return logError("onNavigateToRoom()")
is NavigationState.Room -> currentValue.parentSpace
is NavigationState.Thread -> currentValue.parentRoom.parentSpace
}
state.getAndUpdate { it.copy(navigationState = newValue) }
}
override fun onLeavingSpace(owner: String) {
val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Leaving space. Current state: $currentValue")
if (!currentValue.assertOwner(owner)) return
val newValue: NavigationState.Session = when (currentValue) { val newValue: NavigationState.Session = when (currentValue) {
NavigationState.Root -> return logError("onNavigateToSession()") NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> return logError("onNavigateToSpace()") is NavigationState.Session -> return logError("onNavigateToRoom()")
is NavigationState.Space -> currentValue.parentSession is NavigationState.Room -> currentValue.parentSession
is NavigationState.Room -> currentValue.parentSpace.parentSession is NavigationState.Thread -> currentValue.parentRoom.parentSession
is NavigationState.Thread -> currentValue.parentRoom.parentSpace.parentSession
} }
state.getAndUpdate { it.copy(navigationState = newValue) } state.getAndUpdate { it.copy(navigationState = newValue) }
} }

View file

@ -13,15 +13,12 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_SPACE_ID
import io.element.android.libraries.matrix.test.A_SPACE_ID_2
import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID_2 import io.element.android.libraries.matrix.test.A_THREAD_ID_2
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.NavigationState import io.element.android.services.appnavstate.api.NavigationState
import io.element.android.services.appnavstate.test.A_ROOM_OWNER import io.element.android.services.appnavstate.test.A_ROOM_OWNER
import io.element.android.services.appnavstate.test.A_SESSION_OWNER import io.element.android.services.appnavstate.test.A_SESSION_OWNER
import io.element.android.services.appnavstate.test.A_SPACE_OWNER
import io.element.android.services.appnavstate.test.A_THREAD_OWNER import io.element.android.services.appnavstate.test.A_THREAD_OWNER
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -33,22 +30,17 @@ class DefaultNavigationStateServiceTest {
private val navigationStateRoot = NavigationState.Root private val navigationStateRoot = NavigationState.Root
private val navigationStateSession = NavigationState.Session( private val navigationStateSession = NavigationState.Session(
owner = A_SESSION_OWNER, owner = A_SESSION_OWNER,
sessionId = A_SESSION_ID sessionId = A_SESSION_ID,
)
private val navigationStateSpace = NavigationState.Space(
owner = A_SPACE_OWNER,
spaceId = A_SPACE_ID,
parentSession = navigationStateSession
) )
private val navigationStateRoom = NavigationState.Room( private val navigationStateRoom = NavigationState.Room(
owner = A_ROOM_OWNER, owner = A_ROOM_OWNER,
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
parentSpace = navigationStateSpace parentSession = navigationStateSession,
) )
private val navigationStateThread = NavigationState.Thread( private val navigationStateThread = NavigationState.Thread(
owner = A_THREAD_OWNER, owner = A_THREAD_OWNER,
threadId = A_THREAD_ID, threadId = A_THREAD_ID,
parentRoom = navigationStateRoom parentRoom = navigationStateRoom,
) )
@Test @Test
@ -57,8 +49,6 @@ class DefaultNavigationStateServiceTest {
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID) service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID) service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID) service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID)
@ -67,8 +57,6 @@ class DefaultNavigationStateServiceTest {
service.onLeavingThread(A_THREAD_OWNER) service.onLeavingThread(A_THREAD_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
service.onLeavingRoom(A_ROOM_OWNER) service.onLeavingRoom(A_ROOM_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
service.onLeavingSpace(A_SPACE_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
service.onLeavingSession(A_SESSION_OWNER) service.onLeavingSession(A_SESSION_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
@ -77,7 +65,7 @@ class DefaultNavigationStateServiceTest {
@Test @Test
fun testFailure() = runTest { fun testFailure() = runTest {
val service = createStateService() val service = createStateService()
service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID) service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID)
assertThat(service.appNavigationState.value.navigationState).isEqualTo(NavigationState.Root) assertThat(service.appNavigationState.value.navigationState).isEqualTo(NavigationState.Root)
} }
@ -92,11 +80,6 @@ class DefaultNavigationStateServiceTest {
service.navigateToSession() service.navigateToSession()
service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID) service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From space (no effect)
service.reset()
service.navigateToSpace()
service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
// From room // From room
service.reset() service.reset()
service.navigateToRoom() service.navigateToRoom()
@ -116,15 +99,10 @@ class DefaultNavigationStateServiceTest {
// From root (no effect) // From root (no effect)
service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID) service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
// From session (no effect) // From session
service.reset() service.reset()
service.navigateToSession() service.navigateToSession()
service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID) service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From space
service.reset()
service.navigateToSpace()
service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
// From room // From room
service.reset() service.reset()
@ -139,35 +117,6 @@ class DefaultNavigationStateServiceTest {
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
} }
@Test
fun testOnNavigateToSpace() = runTest {
val service = createStateService()
// From root (no effect)
service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
// From session
service.reset()
service.navigateToSession()
service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
// From space
service.reset()
service.navigateToSpace()
// Navigate to another space
service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID_2)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace.copy(spaceId = A_SPACE_ID_2))
// From room (no effect)
service.reset()
service.navigateToRoom()
service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
// From thread (no effect)
service.reset()
service.navigateToThread()
service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
}
@Test @Test
fun testOnNavigateToSession() = runTest { fun testOnNavigateToSession() = runTest {
val service = createStateService() val service = createStateService()
@ -180,11 +129,6 @@ class DefaultNavigationStateServiceTest {
// Navigate to another session // Navigate to another session
service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID_2) service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID_2)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession.copy(sessionId = A_SESSION_ID_2)) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession.copy(sessionId = A_SESSION_ID_2))
// From space
service.reset()
service.navigateToSpace()
service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From room // From room
service.reset() service.reset()
service.navigateToRoom() service.navigateToRoom()
@ -208,11 +152,6 @@ class DefaultNavigationStateServiceTest {
service.navigateToSession() service.navigateToSession()
service.onLeavingThread(A_THREAD_OWNER) service.onLeavingThread(A_THREAD_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From space (no effect)
service.reset()
service.navigateToSpace()
service.onLeavingThread(A_THREAD_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
// From room (no effect) // From room (no effect)
service.reset() service.reset()
service.navigateToRoom() service.navigateToRoom()
@ -236,16 +175,11 @@ class DefaultNavigationStateServiceTest {
service.navigateToSession() service.navigateToSession()
service.onLeavingRoom(A_ROOM_OWNER) service.onLeavingRoom(A_ROOM_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From space (no effect)
service.reset()
service.navigateToSpace()
service.onLeavingRoom(A_ROOM_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
// From room // From room
service.reset() service.reset()
service.navigateToRoom() service.navigateToRoom()
service.onLeavingRoom(A_ROOM_OWNER) service.onLeavingRoom(A_ROOM_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From thread (no effect) // From thread (no effect)
service.reset() service.reset()
service.navigateToThread() service.navigateToThread()
@ -253,34 +187,6 @@ class DefaultNavigationStateServiceTest {
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateThread) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateThread)
} }
@Test
fun testOnLeavingSpace() = runTest {
val service = createStateService()
// From root (no effect)
service.onLeavingSpace(A_SPACE_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
// From session (no effect)
service.reset()
service.navigateToSession()
service.onLeavingSpace(A_SPACE_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From space
service.reset()
service.navigateToSpace()
service.onLeavingSpace(A_SPACE_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From room (no effect)
service.reset()
service.navigateToRoom()
service.onLeavingSpace(A_SPACE_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
// From thread (no effect)
service.reset()
service.navigateToThread()
service.onLeavingSpace(A_SPACE_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateThread)
}
@Test @Test
fun testOnLeavingSession() = runTest { fun testOnLeavingSession() = runTest {
val service = createStateService() val service = createStateService()
@ -292,11 +198,6 @@ class DefaultNavigationStateServiceTest {
service.navigateToSession() service.navigateToSession()
service.onLeavingSession(A_SESSION_OWNER) service.onLeavingSession(A_SESSION_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
// From space (no effect)
service.reset()
service.navigateToSpace()
service.onLeavingSession(A_SESSION_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
// From room (no effect) // From room (no effect)
service.reset() service.reset()
service.navigateToRoom() service.navigateToRoom()
@ -318,13 +219,8 @@ class DefaultNavigationStateServiceTest {
onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID) onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID)
} }
private fun AppNavigationStateService.navigateToSpace() {
navigateToSession()
onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
}
private fun AppNavigationStateService.navigateToRoom() { private fun AppNavigationStateService.navigateToRoom() {
navigateToSpace() navigateToSession()
onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID) onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID)
} }

View file

@ -8,21 +8,18 @@
package io.element.android.services.appnavstate.test package io.element.android.services.appnavstate.test
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.NavigationState import io.element.android.services.appnavstate.api.NavigationState
const val A_SESSION_OWNER = "aSessionOwner" const val A_SESSION_OWNER = "aSessionOwner"
const val A_SPACE_OWNER = "aSpaceOwner"
const val A_ROOM_OWNER = "aRoomOwner" const val A_ROOM_OWNER = "aRoomOwner"
const val A_THREAD_OWNER = "aThreadOwner" const val A_THREAD_OWNER = "aThreadOwner"
fun aNavigationState( fun aNavigationState(
sessionId: SessionId? = null, sessionId: SessionId? = null,
spaceId: SpaceId? = MAIN_SPACE,
roomId: RoomId? = null, roomId: RoomId? = null,
threadId: ThreadId? = null, threadId: ThreadId? = null,
): NavigationState { ): NavigationState {
@ -30,16 +27,20 @@ fun aNavigationState(
return NavigationState.Root return NavigationState.Root
} }
val session = NavigationState.Session(A_SESSION_OWNER, sessionId) val session = NavigationState.Session(A_SESSION_OWNER, sessionId)
if (spaceId == null) { if (roomId == null) {
return session return session
} }
val space = NavigationState.Space(A_SPACE_OWNER, spaceId, session) val room = NavigationState.Room(A_ROOM_OWNER, roomId, session)
if (roomId == null) {
return space
}
val room = NavigationState.Room(A_ROOM_OWNER, roomId, space)
if (threadId == null) { if (threadId == null) {
return room return room
} }
return NavigationState.Thread(A_THREAD_OWNER, threadId, room) return NavigationState.Thread(A_THREAD_OWNER, threadId, room)
} }
fun anAppNavigationState(
navigationState: NavigationState = aNavigationState(),
isInForeground: Boolean = true,
) = AppNavigationState(
navigationState = navigationState,
isInForeground = isInForeground,
)

View file

@ -10,33 +10,32 @@ package io.element.android.services.appnavstate.test
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.NavigationState import io.element.android.services.appnavstate.api.NavigationState
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class FakeAppNavigationStateService( class FakeAppNavigationStateService(
override val appNavigationState: MutableStateFlow<AppNavigationState> = MutableStateFlow( initialAppNavigationState: AppNavigationState = AppNavigationState(
AppNavigationState( navigationState = NavigationState.Root,
navigationState = NavigationState.Root, isInForeground = true,
isInForeground = true,
)
), ),
) : AppNavigationStateService { ) : AppNavigationStateService {
private val _appNavigationState: MutableStateFlow<AppNavigationState> = MutableStateFlow(initialAppNavigationState)
override val appNavigationState = _appNavigationState.asStateFlow()
fun emitNavigationState(state: AppNavigationState) {
_appNavigationState.value = state
}
override fun onNavigateToSession(owner: String, sessionId: SessionId) = Unit override fun onNavigateToSession(owner: String, sessionId: SessionId) = Unit
override fun onLeavingSession(owner: String) = Unit override fun onLeavingSession(owner: String) = Unit
override fun onNavigateToSpace(owner: String, spaceId: SpaceId) = Unit
override fun onLeavingSpace(owner: String) = Unit
override fun onNavigateToRoom(owner: String, roomId: RoomId) = Unit override fun onNavigateToRoom(owner: String, roomId: RoomId) = Unit
override fun onLeavingRoom(owner: String) = Unit override fun onLeavingRoom(owner: String) = Unit
override fun onNavigateToThread(owner: String, threadId: ThreadId) = Unit override fun onNavigateToThread(owner: String, threadId: ThreadId) = Unit
override fun onLeavingThread(owner: String) = Unit override fun onLeavingThread(owner: String) = Unit
} }

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:913d6230ab2b470dd5393344395bb9c25973318f09ecdc52499a17e9c9e8faba oid sha256:4dac0f93eb31b26fa32173fbd834c7f661e4f47c79db66fa4d1536d938a4585d
size 66219 size 66108

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f59ff395027433af611ef1aec1a1d3e5a7d670df3c77d1c5d01154199c123a71 oid sha256:4c3e5ef9368d68f661350a7a31b98b3ae3fbf975bc11d6f1b9e5ac908e6699dc
size 58586 size 58355

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6356cf3f4d2ec169852024b6a939a3b53bc117aa6d27179783252bc19038b0b4 oid sha256:f0caea3f8eb17dbebdc738a60f2f734ec26c1f94f511eab50669a1c2ec0370a1
size 23101 size 23094

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:86e1bd1197fb4affc1b14b7a2f7f24a1c89ffaaf8bdb17312c0e4059175bb878 oid sha256:0e41fe1823898014736c2b68a3d60f055d483626deae2407ad0145703a6fc1f6
size 21224 size 21317