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 35e60efae2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 894 additions and 590 deletions

View file

@ -18,9 +18,8 @@ jobs:
build-apk:
name: Build APK
runs-on: ubuntu-latest
# Allow one per PR.
concurrency:
group: ${{ format('maestro-{0}', github.ref) }}
group: ${{ format('maestro-build-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v6
@ -57,10 +56,10 @@ jobs:
name: Maestro test suite
runs-on: ubuntu-latest
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:
group: ${{ format('maestro-{0}', github.ref) }}
cancel-in-progress: true
group: maestro-test
steps:
- uses: actions/checkout@v6
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
overwrite: true
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
if: steps.maestro_test.outcome != 'success'
run: |

View file

@ -8,6 +8,13 @@
# 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
echo "Starting the screen recording..."
adb push .github/workflows/scripts/maestro/local-recording.sh /data/local/tmp/

View file

@ -8,27 +8,6 @@ appId: ${MAESTRO_APP_ID}
- tapOn:
id: "login-continue"
## 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:
maxRetries: 3
commands:

View file

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

View file

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

View file

@ -2,4 +2,4 @@ appId: ${MAESTRO_APP_ID}
---
- extendedWaitUntil:
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
=============================

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.matrix.api.MatrixClient
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.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
@ -211,8 +210,6 @@ class LoggedInFlowNode(
onCreate = {
analyticsRoomListStateWatcher.start()
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)
matrixClient.sessionVerificationService.setListener(verificationListener)
mediaPreviewConfigMigration()
@ -242,7 +239,6 @@ class LoggedInFlowNode(
}
},
onDestroy = {
appNavigationStateService.onLeavingSpace(id)
appNavigationStateService.onLeavingSession(id)
loggedInFlowProcessor.stopObserving()
matrixClient.sessionVerificationService.setListener(null)

View file

@ -24,6 +24,7 @@
* [Logging](#logging)
* [Translations](#translations)
* [Rageshake](#rageshake)
* [Developer options](#developer-options)
* [Tips](#tips)
* [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.
#### 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
- 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
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`;

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 {
const val EXTRA_NOTIFICATION_DATA = "EXTRA_NOTIFICATION_DATA"
}
@Inject
lateinit var activeCallManager: ActiveCallManager
@ -40,7 +41,13 @@ class DeclineCallBroadcastReceiver : BroadcastReceiver() {
?: return
context.bindings<CallBindings>().inject(this)
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 {
appCoroutineScope.launch { activeCallManager.hungUpCall(callType) }
appCoroutineScope.launch { activeCallManager.hangUpCall(callType) }
}
}

View file

@ -118,7 +118,7 @@ class IncomingCallActivity : AppCompatActivity() {
private fun onCancel() {
val activeCall = activeCallManager.activeCall.value ?: return
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.ui.strings.CommonStrings
/**
* Ref: https://www.figma.com/design/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=16501-5740
*/
@Composable
internal fun IncomingCallScreen(
notificationData: CallNotificationData,
@ -94,11 +97,8 @@ internal fun IncomingCallScreen(
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp, end = 24.dp, bottom = 64.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
modifier = Modifier.padding(bottom = 64.dp),
horizontalArrangement = Arrangement.spacedBy(48.dp),
) {
ActionButton(
size = 64.dp,
@ -108,7 +108,6 @@ internal fun IncomingCallScreen(
backgroundColor = ElementTheme.colors.iconSuccessPrimary,
borderColor = ElementTheme.colors.borderSuccessSubtle
)
ActionButton(
size = 64.dp,
onClick = onCancel,
@ -143,7 +142,7 @@ private fun ActionButton(
onClick = onClick,
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = backgroundColor,
contentColor = Color.White,
contentColor = ElementTheme.colors.iconOnSolidPrimary,
)
) {
Icon(

View file

@ -72,10 +72,14 @@ interface ActiveCallManager {
suspend fun registerIncomingCall(notificationData: CallNotificationData)
/**
* Called when the active call has been hung up. It will 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.
* 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 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].
@ -192,12 +196,28 @@ class DefaultActiveCallManager(
}
}
override suspend fun hungUpCall(callType: CallType) = mutex.withLock {
Timber.tag(tag).d("Hung up call: $callType")
override suspend fun hangUpCall(
callType: CallType,
notificationData: CallNotificationData?,
) = mutex.withLock {
Timber.tag(tag).d("Hang up call: $callType")
cancelIncomingCallNotification()
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")
return@withLock
}
if (currentActiveCall.callType != callType) {
Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring")
return@withLock
@ -208,9 +228,13 @@ class DefaultActiveCallManager(
matrixClientProvider.getOrRestore(notificationData.sessionId).getOrNull()
?.getRoom(notificationData.roomId)
?.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) {
Timber.tag(tag).d("Releasing partial wakelock after hang up")
activeWakeLock.release()
@ -221,7 +245,6 @@ class DefaultActiveCallManager(
override suspend fun joinedCall(callType: CallType) = mutex.withLock {
Timber.tag(tag).d("Joined call: $callType")
cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) {
Timber.tag(tag).d("Releasing partial wakelock after joining call")

View file

@ -82,6 +82,13 @@ class WebViewAudioManager(
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
/**
@ -134,7 +141,7 @@ class WebViewAudioManager(
if (validNewDevices.isEmpty()) return
// 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))
// This should automatically switch to a new device if it has a higher priority than the current one
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.
*/
@ -304,7 +311,7 @@ class WebViewAudioManager(
} else {
val rawAudioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
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.
*/
private fun selectDefaultAudioDevice(availableDevices: List<AudioDeviceInfo> = listAudioDevices()) {
val selectedDevice = availableDevices
.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
}
}
val selectedDevice = availableDevices.firstOrNull()
expectedNewCommunicationDeviceId = selectedDevice?.id
audioManager.selectAudioDevice(selectedDevice)

View file

@ -155,7 +155,7 @@ class DefaultActiveCallManagerTest {
}
@Test
fun `hungUpCall - removes existing call if the CallType matches`() = runTest {
fun `hangUpCall - removes existing call if the CallType matches`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
@ -165,7 +165,7 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isNotNull()
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.activeWakeLock?.isHeld).isFalse()
@ -192,13 +192,41 @@ class DefaultActiveCallManagerTest {
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
manager.registerIncomingCall(notificationData)
manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
manager.hangUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
coVerify {
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)
@Test
fun `Decline event - Declining from another session should stop ringing`() = runTest {
@ -269,7 +297,7 @@ class DefaultActiveCallManagerTest {
}
@Test
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
fun `hangUpCall - does nothing if the CallType doesn't match`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
@ -278,11 +306,12 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isNotNull()
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.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)

View file

@ -17,7 +17,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
class FakeActiveCallManager(
var registerIncomingCallResult: (CallNotificationData) -> Unit = {},
var hungUpCallResult: (CallType) -> Unit = {},
var hangUpCallResult: (CallType, CallNotificationData?) -> Unit = { _, _ -> },
var joinedCallResult: (CallType) -> Unit = {},
) : ActiveCallManager {
override val activeCall = MutableStateFlow<ActiveCall?>(null)
@ -26,8 +26,8 @@ class FakeActiveCallManager(
registerIncomingCallResult(notificationData)
}
override suspend fun hungUpCall(callType: CallType) = simulateLongTask {
hungUpCallResult(callType)
override suspend fun hangUpCall(callType: CallType, notificationData: CallNotificationData?) = simulateLongTask {
hangUpCallResult(callType, notificationData)
}
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 {
val addChildToSpaceResult = lambdaRecorder<RoomId, RoomId, Result<Unit>> { _, _ -> Result.success(Unit) }
val spaceService = FakeSpaceService(
editableSpacesResult = { Result.success(emptyList()) },
addChildToSpaceResult = addChildToSpaceResult,
)
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 {
val addChildToSpaceResult = lambdaRecorder<RoomId, RoomId, Result<Unit>> { _, _ -> Result.success(Unit) }
val spaceService = FakeSpaceService(
editableSpacesResult = { Result.success(emptyList()) },
addChildToSpaceResult = addChildToSpaceResult,
)
val roomInfoFlow = MutableStateFlow<Optional<RoomInfo>>(Optional.empty())
@ -522,7 +524,9 @@ class ConfigureRoomPresenterTest {
private fun createMatrixClient(
isAliasAvailable: Boolean = true,
spaceService: FakeSpaceService = FakeSpaceService(),
spaceService: FakeSpaceService = FakeSpaceService(
editableSpacesResult = { Result.success(emptyList()) }
),
) = FakeMatrixClient(
userIdServerNameLambda = { "matrix.org" },
resolveRoomAliasResult = {

View file

@ -263,7 +263,7 @@ private fun SpaceFilterButton(
onClick = ::onClick,
colors = if (isSelected) {
IconButtonDefaults.iconButtonColors(
containerColor = ElementTheme.colors.bgAccentRest,
containerColor = ElementTheme.colors.bgActionPrimaryRest,
contentColor = ElementTheme.colors.iconOnSolidPrimary,
)
} 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)
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(savedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.SpaceMember::class.java)
assertThat(isSpaceMemberSelectable).isFalse()
// showSpaceMemberOption should still be true because savedSettings has SpaceMember
assertThat(isSpaceMemberSelectable).isTrue()
assertThat(showSpaceMemberOption).isTrue()
}
cancelAndIgnoreRemainingEvents()

View file

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

View file

@ -16,13 +16,13 @@ datastore = "1.2.0"
constraintlayout = "2.2.1"
constraintlayout_compose = "1.1.1"
lifecycle = "2.10.0"
activity = "1.12.3"
activity = "1.12.4"
media3 = "1.9.2"
camera = "1.5.3"
work = "2.11.1"
# Compose
compose_bom = "2026.01.00"
compose_bom = "2026.02.00"
# Coroutines
coroutines = "1.10.2"
@ -32,7 +32,7 @@ accompanist = "0.37.3"
# Test
test_core = "1.7.0"
roborazzi = "1.58.0"
roborazzi = "1.59.0"
# Jetbrain
datetime = "0.7.1"
@ -46,7 +46,7 @@ appyx = "1.7.1"
sqldelight = "2.2.1"
wysiwyg = "2.41.1"
telephoto = "0.18.0"
haze = "1.7.1"
haze = "1.7.2"
# Dependency analysis
dependencyAnalysis = "3.5.1"
@ -62,7 +62,7 @@ detekt = "1.23.8"
# See https://github.com/pinterest/ktlint/releases/
ktlint = "1.8.0"
androidx-test-ext-junit = "1.3.0"
kover = "0.9.6"
kover = "0.9.7"
[libraries]
# 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_test_manifest = { module = "androidx.compose.ui:ui-test-manifest" }
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" }
# Coroutines

View file

@ -16,8 +16,6 @@ import androidx.core.text.toSpannable
import androidx.core.text.util.LinkifyCompat
import io.element.android.libraries.core.extensions.runCatchingExceptions
import timber.log.Timber
import kotlin.collections.component1
import kotlin.collections.component2
/**
* 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
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.setSpan(URLSpan(url), start, newEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
} else {
@ -87,12 +86,12 @@ object LinkifyHelper {
var end = end
// Trailing punctuation found, adjust the end index
while (spannable[end - 1] in sequenceOf('.', ',', ';', ':', '!', '?', '…') && end > start) {
while (end > start && spannable[end - 1] in sequenceOf('.', ',', ';', ':', '!', '?', '…')) {
end--
}
// 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 closingParenthesisCount = 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.text.style.URLSpan
import androidx.core.text.buildSpannedString
import androidx.core.text.getSpans
import androidx.core.text.inSpans
import androidx.core.text.toSpannable
import com.google.common.truth.Truth.assertThat
import io.element.android.tests.testutils.WarmUpRule
@ -140,4 +142,27 @@ class LinkifierHelperTest {
assertThat(urlSpans.size).isEqualTo(1)
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))
}
leadingIcon != null -> {
androidx.compose.material.Icon(
Icon(
painter = leadingIcon.getPainter(),
contentDescription = null,
tint = LocalContentColor.current,

View file

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

View file

@ -9,8 +9,3 @@
package io.element.android.libraries.matrix.api.core
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 getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification>
fun getSummaryNotification(sessionId: SessionId): StatusBarNotification?
fun getFallbackNotification(sessionId: SessionId): StatusBarNotification?
fun count(sessionId: SessionId): Int
}
@ -76,6 +77,11 @@ class DefaultActiveNotificationsProvider(
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 {
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.NotificationIdProvider
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.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.NavigationState
import io.element.android.services.appnavstate.api.currentRoomId
import io.element.android.services.appnavstate.api.currentSessionId
import io.element.android.services.appnavstate.api.currentThreadId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -46,46 +55,49 @@ class DefaultNotificationDrawerManager(
private val matrixClientProvider: MatrixClientProvider,
private val imageLoaderHolder: ImageLoaderHolder,
private val activeNotificationsProvider: ActiveNotificationsProvider,
sessionObserver: SessionObserver,
) : NotificationCleaner {
// TODO EAx add a setting per user for this
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 {
// Observe application state
coroutineScope.launch {
appNavigationStateService.appNavigationState
.collect { onAppNavigationStateChange(it.navigationState) }
}
sessionObserver.addListener(sessionListener)
}
private var currentAppNavigationState: NavigationState? = null
private fun onAppNavigationStateChange(navigationState: NavigationState) {
when (navigationState) {
NavigationState.Root -> {
currentAppNavigationState?.currentSessionId()?.let { sessionId ->
// User signed out, clear all notifications related to the session.
clearAllEvents(sessionId)
}
NavigationState.Root -> {}
is NavigationState.Session -> {
// Cleanup the fallback notification
clearFallbackForSession(navigationState.sessionId)
}
is NavigationState.Session -> {}
is NavigationState.Space -> {}
is NavigationState.Room -> {
// Cleanup notification for current room
clearMessagesForRoom(
sessionId = navigationState.parentSpace.parentSession.sessionId,
sessionId = navigationState.parentSession.sessionId,
roomId = navigationState.roomId,
)
}
is NavigationState.Thread -> {
clearMessagesForThread(
sessionId = navigationState.parentRoom.parentSpace.parentSession.sessionId,
sessionId = navigationState.parentRoom.parentSession.sessionId,
roomId = navigationState.parentRoom.roomId,
threadId = navigationState.threadId,
)
}
}
currentAppNavigationState = navigationState
}
/**
@ -93,14 +105,11 @@ class DefaultNotificationDrawerManager(
* Events might be grouped and there might not be one notification per event!
*/
suspend fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
if (notifiableEvent.shouldIgnoreEventInRoom(appNavigationStateService.appNavigationState.value)) {
return
}
renderEvents(listOf(notifiableEvent))
onNotifiableEventsReceived(listOf(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)
}
@ -120,6 +129,17 @@ class DefaultNotificationDrawerManager(
.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].
* 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.RoomId
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.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock
@Inject
class FallbackNotificationFactory(
private val clock: SystemClock,
private val stringProvider: StringProvider,
) {
fun create(
sessionId: SessionId,
@ -36,7 +33,7 @@ class FallbackNotificationFactory(
isRedacted = false,
isUpdated = false,
timestamp = clock.epochMillis(),
description = stringProvider.getString(R.string.notification_fallback_content),
description = "",
cause = cause,
)
}

View file

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

View file

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

View file

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

View file

@ -75,8 +75,9 @@ interface NotificationCreator {
): Notification
fun createFallbackNotification(
existingNotification: Notification?,
notificationAccountParams: NotificationAccountParams,
fallbackNotifiableEvent: FallbackNotifiableEvent,
fallbackNotifiableEvents: List<FallbackNotifiableEvent>,
): Notification
/**
@ -308,31 +309,38 @@ class DefaultNotificationCreator(
}
override fun createFallbackNotification(
existingNotification: Notification?,
notificationAccountParams: NotificationAccountParams,
fallbackNotifiableEvent: FallbackNotifiableEvent,
fallbackNotifiableEvents: List<FallbackNotifiableEvent>,
): Notification {
val channelId = notificationChannels.getChannelIdForMessage(
sessionId = fallbackNotifiableEvent.sessionId,
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)
.setOnlyAlertOnce(true)
.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)
.configureWith(notificationAccountParams)
.setAutoCancel(true)
.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))
.setDeleteIntent(
pendingIntentFactory.createDismissEventPendingIntent(
fallbackNotifiableEvent.sessionId,
fallbackNotifiableEvent.roomId,
fallbackNotifiableEvent.eventId
)
)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
@ -522,6 +530,7 @@ class DefaultNotificationCreator(
companion object {
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.UserId
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(
override val sessionId: SessionId,
@ -56,24 +52,3 @@ data class NotifiableMessageEvent(
val imageUri: Uri?
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>
<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>
<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_inline_reply_failed">"** Failed to send - please open room"</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()
}
@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> {
every { this@mockk.id } returns id
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.test.notifications.FakeCallNotificationEventResolver
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.FakeSystemClock
import kotlinx.coroutines.test.runTest
@ -663,7 +662,7 @@ class DefaultNotifiableEventResolverTest {
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
editedEventId = null,
description = "You have new messages.",
description = "",
canBeReplaced = true,
isRedacted = false,
isUpdated = false,
@ -895,7 +894,6 @@ class DefaultNotifiableEventResolverTest {
callNotificationEventResolver = callNotificationEventResolver,
fallbackNotificationFactory = FallbackNotificationFactory(
clock = FakeSystemClock(),
stringProvider = FakeStringProvider(defaultResult = "You have new messages.")
),
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.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_2
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_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
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.FakeRoomGroupMessageCreator
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.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.observer.SessionObserver
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.AppNavigationStateService
import io.element.android.services.appnavstate.api.NavigationState
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
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.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
@ -92,28 +98,25 @@ class DefaultNotificationDrawerManagerTest {
@Test
fun `react to applicationStateChange`() = runTest {
// For now just call all the API. Later, add more valuable tests.
val appNavigationStateFlow: MutableStateFlow<AppNavigationState> = MutableStateFlow(
AppNavigationState(
navigationState = NavigationState.Root,
isInForeground = true,
)
)
val appNavigationStateService = FakeAppNavigationStateService(appNavigationState = appNavigationStateFlow)
val appNavigationStateService = FakeAppNavigationStateService()
createDefaultNotificationDrawerManager(
appNavigationStateService = appNavigationStateService
)
appNavigationStateFlow.emit(AppNavigationState(aNavigationState(), isInForeground = true))
appNavigationStateService.emitNavigationState(AppNavigationState(aNavigationState(), isInForeground = true))
runCurrent()
appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID), isInForeground = true))
appNavigationStateService.emitNavigationState(AppNavigationState(aNavigationState(A_SESSION_ID), isInForeground = true))
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()
appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID), isInForeground = true))
runCurrent()
appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID, A_THREAD_ID), isInForeground = true))
appNavigationStateService.emitNavigationState(
AppNavigationState(
aNavigationState(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID),
isInForeground = true
)
)
runCurrent()
// Like a user sign out
appNavigationStateFlow.emit(AppNavigationState(aNavigationState(), isInForeground = true))
appNavigationStateService.emitNavigationState(AppNavigationState(aNavigationState(), isInForeground = true))
runCurrent()
}
@ -205,35 +208,325 @@ class DefaultNotificationDrawerManagerTest {
)
}
private fun TestScope.createDefaultNotificationDrawerManager(
notificationDisplayer: NotificationDisplayer = FakeNotificationDisplayer(),
appNavigationStateService: AppNavigationStateService = FakeAppNavigationStateService(),
roomGroupMessageCreator: RoomGroupMessageCreator = FakeRoomGroupMessageCreator(),
summaryGroupMessageCreator: SummaryGroupMessageCreator = FakeSummaryGroupMessageCreator(),
activeNotificationsProvider: FakeActiveNotificationsProvider = FakeActiveNotificationsProvider(),
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
sessionStore: SessionStore = InMemorySessionStore(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
): DefaultNotificationDrawerManager {
return DefaultNotificationDrawerManager(
@Test
fun `when a session is signed out, clearAllEvent is invoked`() = runTest {
val cancelNotificationResult = lambdaRecorder<String?, Int, Unit> { _, _ -> }
val notificationDisplayer = FakeNotificationDisplayer(
cancelNotificationResult = cancelNotificationResult,
)
val summaryId = NotificationIdProvider.getSummaryNotificationId(A_SESSION_ID)
val activeNotificationsProvider = FakeActiveNotificationsProvider(
getNotificationsForSessionResult = {
listOf(
mockk {
every { id } returns summaryId
every { tag } returns null
},
)
},
countResult = { 1 },
)
val sessionObserver = FakeSessionObserver()
createDefaultNotificationDrawerManager(
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,
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.notification.FakeNotificationService
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.FakeNotificationDisplayer
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
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 kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent
@ -47,16 +43,10 @@ class DefaultOnMissedCallNotificationHandlerTest {
})
val defaultOnMissedCallNotificationHandler = DefaultOnMissedCallNotificationHandler(
matrixClientProvider = matrixClientProvider,
defaultNotificationDrawerManager = DefaultNotificationDrawerManager(
notificationDisplayer = FakeNotificationDisplayer(),
defaultNotificationDrawerManager = createDefaultNotificationDrawerManager(
notificationRenderer = createNotificationRenderer(
notificationDataFactory = dataFactory,
),
appNavigationStateService = FakeAppNavigationStateService(),
coroutineScope = backgroundScope,
matrixClientProvider = FakeMatrixClientProvider(),
imageLoaderHolder = FakeImageLoaderHolder(),
activeNotificationsProvider = FakeActiveNotificationsProvider(),
),
callNotificationEventResolver = FakeCallNotificationEventResolver(resolveEventLambda = { _, _, _ ->
Result.success(aNotifiableMessageEvent())

View file

@ -39,7 +39,6 @@ class DefaultSummaryGroupMessageCreatorTest {
RoomNotification(
notification = Notification(),
roomId = A_ROOM_ID,
summaryLine = "",
messageCount = 1,
latestTimestamp = A_FAKE_TIMESTAMP + 10,
shouldBing = true,
@ -48,7 +47,6 @@ class DefaultSummaryGroupMessageCreatorTest {
),
invitationNotifications = emptyList(),
simpleNotifications = emptyList(),
fallbackNotifications = emptyList(),
)
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.FakeRoomGroupMessageCreator
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.aSimpleNotifiableEvent
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 org.junit.Test
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 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_FALLBACK_EVENT = aFallbackNotifiableEvent()
@RunWith(RobolectricTestRunner::class)
class NotificationDataFactoryTest {
@ -47,7 +48,6 @@ class NotificationDataFactoryTest {
roomGroupMessageCreator = fakeRoomGroupMessageCreator,
summaryGroupMessageCreator = fakeSummaryGroupMessageCreator,
activeNotificationsProvider = activeNotificationsProvider,
stringProvider = FakeStringProvider(),
)
@Test
@ -64,7 +64,6 @@ class NotificationDataFactoryTest {
OneShotNotification(
notification = expectedNotification,
tag = A_ROOM_ID.value,
summaryLine = AN_INVITATION_EVENT.description,
isNoisy = AN_INVITATION_EVENT.noisy,
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
fun `given a simple event when mapping to notification then it's added`() = testWith(notificationDataFactory) {
val expectedNotification = notificationCreator.createRoomInvitationNotificationResult(
@ -83,7 +101,6 @@ class NotificationDataFactoryTest {
OneShotNotification(
notification = expectedNotification,
tag = AN_EVENT_ID.value,
summaryLine = A_SIMPLE_EVENT.description,
isNoisy = A_SIMPLE_EVENT.noisy,
timestamp = AN_INVITATION_EVENT.timestamp
)
@ -105,7 +122,6 @@ class NotificationDataFactoryTest {
existingNotification = null,
),
roomId = A_ROOM_ID,
summaryLine = "A room name: Bob Hello world!",
messageCount = events.size,
latestTimestamp = events.maxOf { it.timestamp },
shouldBing = events.any { it.noisy },
@ -161,7 +177,6 @@ class NotificationDataFactoryTest {
existingNotification = null,
),
roomId = A_ROOM_ID,
summaryLine = "A room name: Bob Hello world!",
messageCount = withRedactedRemoved.size,
latestTimestamp = withRedactedRemoved.maxOf { it.timestamp },
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.sessionstorage.api.SessionStore
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.value
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 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)
class NotificationRendererTest {
@ -57,7 +56,6 @@ class NotificationRendererTest {
roomGroupMessageCreator = roomGroupMessageCreator,
summaryGroupMessageCreator = summaryGroupMessageCreator,
activeNotificationsProvider = FakeActiveNotificationsProvider(),
stringProvider = FakeStringProvider(),
)
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.QuickReplyActionFactory
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.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
@ -84,19 +84,11 @@ class DefaultNotificationCreatorTest {
fun `test createFallbackNotification`() {
val sut = createNotificationCreator()
val result = sut.createFallbackNotification(
existingNotification = null,
notificationAccountParams = aNotificationAccountParams(),
FallbackNotifiableEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
editedEventId = null,
description = "description",
canBeReplaced = false,
isRedacted = false,
isUpdated = false,
timestamp = A_FAKE_TIMESTAMP,
cause = null,
),
fallbackNotifiableEvents = listOf(
aFallbackNotifiableEvent(),
)
)
result.commonAssertions(
expectedCategory = null,

View file

@ -21,6 +21,7 @@ class FakeActiveNotificationsProvider(
private val getMembershipNotificationForSessionResult: (SessionId) -> List<StatusBarNotification> = { emptyList() },
private val getMembershipNotificationForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> emptyList() },
private val getSummaryNotificationResult: (SessionId) -> StatusBarNotification? = { null },
private val getFallbackNotificationResult: (SessionId) -> StatusBarNotification? = { null },
private val countResult: (SessionId) -> Int = { 0 },
) : ActiveNotificationsProvider {
override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): List<StatusBarNotification> {
@ -47,6 +48,10 @@ class FakeActiveNotificationsProvider(
return getSummaryNotificationResult(sessionId)
}
override fun getFallbackNotification(sessionId: SessionId): StatusBarNotification? {
return getFallbackNotificationResult(sessionId)
}
override fun count(sessionId: SessionId): Int {
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.LambdaListAnyParamsRecorder
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.lambdaAnyRecorder
import io.element.android.tests.testutils.lambda.lambdaRecorder
@ -34,8 +35,8 @@ class FakeNotificationCreator(
lambdaRecorder { _, _ -> A_NOTIFICATION },
var createSimpleNotificationResult: LambdaTwoParamsRecorder<NotificationAccountParams, SimpleNotifiableEvent, Notification> =
lambdaRecorder { _, _ -> A_NOTIFICATION },
var createFallbackNotificationResult: LambdaTwoParamsRecorder<NotificationAccountParams, FallbackNotifiableEvent, Notification> =
lambdaRecorder { _, _ -> A_NOTIFICATION },
var createFallbackNotificationResult: LambdaThreeParamsRecorder<Notification?, NotificationAccountParams, List<FallbackNotifiableEvent>, Notification> =
lambdaRecorder { _, _, _ -> A_NOTIFICATION },
var createSummaryListNotificationResult: LambdaFiveParamsRecorder<
NotificationAccountParams, String, Boolean, Long, NotificationAccountParams, Notification
> = lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION },
@ -75,10 +76,15 @@ class FakeNotificationCreator(
}
override fun createFallbackNotification(
existingNotification: Notification?,
notificationAccountParams: NotificationAccountParams,
fallbackNotifiableEvent: FallbackNotifiableEvent,
fallbackNotifiableEvents: List<FallbackNotifiableEvent>,
): Notification {
return createFallbackNotificationResult(notificationAccountParams, fallbackNotifiableEvent)
return createFallbackNotificationResult(
existingNotification,
notificationAccountParams,
fallbackNotifiableEvents,
)
}
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.NotifiableMessageEvent
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.LambdaThreeParamsRecorder
import io.element.android.tests.testutils.lambda.lambdaRecorder
@ -28,18 +28,17 @@ class FakeNotificationDataFactory(
var messageEventToNotificationsResult: LambdaThreeParamsRecorder<
List<NotifiableMessageEvent>, ImageLoader, NotificationAccountParams, List<RoomNotification>
> = lambdaRecorder { _, _, _ -> emptyList() },
var summaryToNotificationsResult: LambdaFiveParamsRecorder<
var summaryToNotificationsResult: LambdaFourParamsRecorder<
List<RoomNotification>,
List<OneShotNotification>,
List<OneShotNotification>,
List<OneShotNotification>,
NotificationAccountParams,
SummaryNotification
> = lambdaRecorder { _, _, _, _, _ -> SummaryNotification.Update(A_NOTIFICATION) },
> = lambdaRecorder { _, _, _, _ -> SummaryNotification.Update(A_NOTIFICATION) },
var inviteToNotificationsResult: LambdaOneParamRecorder<List<InviteNotifiableEvent>, List<OneShotNotification>> = lambdaRecorder { _ -> emptyList() },
var simpleEventToNotificationsResult: LambdaOneParamRecorder<List<SimpleNotifiableEvent>, List<OneShotNotification>> = lambdaRecorder { _ -> emptyList() },
var fallbackEventToNotificationsResult: LambdaOneParamRecorder<List<FallbackNotifiableEvent>, List<OneShotNotification>> =
lambdaRecorder { _ -> emptyList() },
var fallbackEventToNotificationsResult: LambdaOneParamRecorder<List<FallbackNotifiableEvent>, OneShotNotification?> =
lambdaRecorder { _ -> null },
) : NotificationDataFactory {
override suspend fun toNotifications(
messages: List<NotifiableMessageEvent>,
@ -69,10 +68,10 @@ class FakeNotificationDataFactory(
@JvmName("toNotificationFallbackEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications(
override fun toNotification(
fallback: List<FallbackNotifiableEvent>,
notificationAccountParams: NotificationAccountParams,
): List<OneShotNotification> {
): OneShotNotification? {
return fallbackEventToNotificationsResult(fallback)
}
@ -80,14 +79,12 @@ class FakeNotificationDataFactory(
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
notificationAccountParams: NotificationAccountParams,
): SummaryNotification {
return summaryToNotificationsResult(
roomNotifications,
invitationNotifications,
simpleNotifications,
fallbackNotifications,
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.factories.NotificationAccountParams
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
class FakeSummaryGroupMessageCreator(
var createSummaryNotificationResult: LambdaFiveParamsRecorder<
NotificationAccountParams, List<RoomNotification>, List<OneShotNotification>, List<OneShotNotification>, List<OneShotNotification>, Notification> =
lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION }
var createSummaryNotificationResult: LambdaFourParamsRecorder<
NotificationAccountParams, List<RoomNotification>, List<OneShotNotification>, List<OneShotNotification>, Notification> =
lambdaRecorder { _, _, _, _ -> A_NOTIFICATION }
) : SummaryGroupMessageCreator {
override fun createSummaryNotification(
notificationAccountParams: NotificationAccountParams,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
fallbackNotifications: List<OneShotNotification>,
): Notification {
return createSummaryNotificationResult(
notificationAccountParams,
roomNotifications,
invitationNotifications,
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_USER_ID_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.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.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
fun aSimpleNotifiableEvent(
sessionId: SessionId = A_SESSION_ID,
@ -141,3 +143,18 @@ fun aNotifiableCallEvent(
senderAvatarUrl = senderAvatarUrl,
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.FallbackNotificationFactory
import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels
import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
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.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.test.FakeWorkManagerScheduler
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.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaError
@ -627,18 +626,7 @@ class DefaultPushHandlerTest {
@Test
fun `when receiving a fallback event, we notify the push history service about it not being resolved`() = runTest {
val aNotifiableFallbackEvent = FallbackNotifiableEvent(
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 aNotifiableFallbackEvent = aFallbackNotifiableEvent()
val notifiableEventResult =
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
@ -724,7 +712,6 @@ class DefaultPushHandlerTest {
appCoroutineScope = backgroundScope,
fallbackNotificationFactory = FallbackNotificationFactory(
clock = FakeSystemClock(),
stringProvider = FakeStringProvider(),
),
syncOnNotifiableEvent = syncOnNotifiableEvent,
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].
* 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].
* Do not update this value. it is updated by the release script.
*/
private const val versionReleaseNumber = 2
private const val versionReleaseNumber = 0
object Versions {
/**

View file

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

View file

@ -43,9 +43,9 @@ class DefaultAnalyticsRoomListStateWatcherTest {
runCurrent()
// 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()
navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true))
navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true))
runCurrent()
// The transaction should be present now
@ -63,9 +63,9 @@ class DefaultAnalyticsRoomListStateWatcherTest {
@Test
fun `Opening the app in a cold state does nothing`() = runTest {
val navigationStateService = FakeAppNavigationStateService().apply {
appNavigationState.emit(AppNavigationState(NavigationState.Root, false))
}
val navigationStateService = FakeAppNavigationStateService(
initialAppNavigationState = AppNavigationState(NavigationState.Root, false)
)
val roomListService = FakeRoomListService().apply {
postState(RoomListService.State.Idle)
}
@ -110,9 +110,9 @@ class DefaultAnalyticsRoomListStateWatcherTest {
runCurrent()
// 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()
navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true))
navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true))
runCurrent()
// The transaction should be present now
@ -145,9 +145,9 @@ class DefaultAnalyticsRoomListStateWatcherTest {
runCurrent()
// 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()
navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true))
navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true))
runCurrent()
// The transaction was never added

View file

@ -31,7 +31,6 @@ import io.sentry.Sentry
import io.sentry.SentryTracer
import io.sentry.protocol.SentryId
import io.sentry.protocol.SentryTransaction
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Test
import org.junit.runner.RunWith
@ -149,7 +148,7 @@ class SentryAnalyticsProviderTest {
)
},
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 {
init()
@ -182,7 +181,7 @@ class SentryAnalyticsProviderTest {
)
},
appNavigationStateService = FakeAppNavigationStateService(
MutableStateFlow(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true))
initialAppNavigationState = AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)
)
).run {
init()
@ -203,7 +202,7 @@ class SentryAnalyticsProviderTest {
)
},
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 {
init()
@ -221,7 +220,7 @@ class SentryAnalyticsProviderTest {
buildMeta: BuildMeta = aBuildMeta(),
getDatabaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) },
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(
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.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId
import kotlinx.coroutines.flow.StateFlow
@ -23,9 +22,6 @@ interface AppNavigationStateService {
fun onNavigateToSession(owner: String, sessionId: SessionId)
fun onLeavingSession(owner: String)
fun onNavigateToSpace(owner: String, spaceId: SpaceId)
fun onLeavingSpace(owner: String)
fun onNavigateToRoom(owner: String, roomId: RoomId)
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.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId
/**
@ -29,17 +28,10 @@ sealed class NavigationState(open val owner: String) {
val sessionId: SessionId,
) : 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(
override val owner: String,
val roomId: RoomId,
val parentSpace: Space,
val parentSession: Session,
) : NavigationState(owner)
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.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId
fun NavigationState.currentSessionId(): SessionId? {
return when (this) {
NavigationState.Root -> null
is NavigationState.Session -> sessionId
is NavigationState.Space -> parentSession.sessionId
is NavigationState.Room -> parentSpace.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
is NavigationState.Room -> parentSession.sessionId
is NavigationState.Thread -> parentRoom.parentSession.sessionId
}
}
@ -37,7 +25,6 @@ fun NavigationState.currentRoomId(): RoomId? {
return when (this) {
NavigationState.Root -> null
is NavigationState.Session -> null
is NavigationState.Space -> null
is NavigationState.Room -> roomId
is NavigationState.Thread -> parentRoom.roomId
}
@ -47,7 +34,6 @@ fun NavigationState.currentThreadId(): ThreadId? {
return when (this) {
NavigationState.Root -> null
is NavigationState.Session -> null
is NavigationState.Space -> null
is NavigationState.Room -> null
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.matrix.api.core.RoomId
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.services.appnavstate.api.AppForegroundStateService
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")
val newValue: NavigationState.Session = when (currentValue) {
is NavigationState.Session,
is NavigationState.Space,
is NavigationState.Room,
is NavigationState.Thread,
is NavigationState.Root -> NavigationState.Session(owner, sessionId)
@ -70,28 +68,14 @@ class DefaultAppNavigationStateService(
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) {
val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Navigating to room $roomId. Current state: $currentValue")
val newValue: NavigationState.Room = when (currentValue) {
NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> return logError("onNavigateToSpace()")
is NavigationState.Space -> NavigationState.Room(owner, roomId, currentValue)
is NavigationState.Room -> NavigationState.Room(owner, roomId, currentValue.parentSpace)
is NavigationState.Thread -> NavigationState.Room(owner, roomId, currentValue.parentRoom.parentSpace)
is NavigationState.Session -> NavigationState.Room(owner, roomId, currentValue)
is NavigationState.Room -> NavigationState.Room(owner, roomId, currentValue.parentSession)
is NavigationState.Thread -> NavigationState.Room(owner, roomId, currentValue.parentRoom.parentSession)
}
state.getAndUpdate { it.copy(navigationState = newValue) }
}
@ -101,8 +85,7 @@ class DefaultAppNavigationStateService(
Timber.tag(loggerTag.value).d("Navigating to thread $threadId. Current state: $currentValue")
val newValue: NavigationState.Thread = when (currentValue) {
NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> return logError("onNavigateToSpace()")
is NavigationState.Space -> return logError("onNavigateToRoom()")
is NavigationState.Session -> return logError("onNavigateToRoom()")
is NavigationState.Room -> NavigationState.Thread(owner, threadId, currentValue)
is NavigationState.Thread -> NavigationState.Thread(owner, threadId, currentValue.parentRoom)
}
@ -115,8 +98,7 @@ class DefaultAppNavigationStateService(
if (!currentValue.assertOwner(owner)) return
val newValue: NavigationState.Room = when (currentValue) {
NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> return logError("onNavigateToSpace()")
is NavigationState.Space -> return logError("onNavigateToRoom()")
is NavigationState.Session -> return logError("onNavigateToRoom()")
is NavigationState.Room -> return logError("onNavigateToThread()")
is NavigationState.Thread -> currentValue.parentRoom
}
@ -127,26 +109,11 @@ class DefaultAppNavigationStateService(
val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Leaving room. Current state: $currentValue")
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) {
NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> return logError("onNavigateToSpace()")
is NavigationState.Space -> currentValue.parentSession
is NavigationState.Room -> currentValue.parentSpace.parentSession
is NavigationState.Thread -> currentValue.parentRoom.parentSpace.parentSession
is NavigationState.Session -> return logError("onNavigateToRoom()")
is NavigationState.Room -> currentValue.parentSession
is NavigationState.Thread -> currentValue.parentRoom.parentSession
}
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_SESSION_ID
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_2
import io.element.android.services.appnavstate.api.AppNavigationStateService
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_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.FakeAppForegroundStateService
import kotlinx.coroutines.flow.first
@ -33,22 +30,17 @@ class DefaultNavigationStateServiceTest {
private val navigationStateRoot = NavigationState.Root
private val navigationStateSession = NavigationState.Session(
owner = A_SESSION_OWNER,
sessionId = A_SESSION_ID
)
private val navigationStateSpace = NavigationState.Space(
owner = A_SPACE_OWNER,
spaceId = A_SPACE_ID,
parentSession = navigationStateSession
sessionId = A_SESSION_ID,
)
private val navigationStateRoom = NavigationState.Room(
owner = A_ROOM_OWNER,
roomId = A_ROOM_ID,
parentSpace = navigationStateSpace
parentSession = navigationStateSession,
)
private val navigationStateThread = NavigationState.Thread(
owner = A_THREAD_OWNER,
threadId = A_THREAD_ID,
parentRoom = navigationStateRoom
parentRoom = navigationStateRoom,
)
@Test
@ -57,8 +49,6 @@ class DefaultNavigationStateServiceTest {
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID)
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)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID)
@ -67,8 +57,6 @@ class DefaultNavigationStateServiceTest {
service.onLeavingThread(A_THREAD_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
service.onLeavingRoom(A_ROOM_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
service.onLeavingSpace(A_SPACE_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
service.onLeavingSession(A_SESSION_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
@ -77,7 +65,7 @@ class DefaultNavigationStateServiceTest {
@Test
fun testFailure() = runTest {
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)
}
@ -92,11 +80,6 @@ class DefaultNavigationStateServiceTest {
service.navigateToSession()
service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID)
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
service.reset()
service.navigateToRoom()
@ -116,15 +99,10 @@ class DefaultNavigationStateServiceTest {
// From root (no effect)
service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
// From session (no effect)
// From session
service.reset()
service.navigateToSession()
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)
// From room
service.reset()
@ -139,35 +117,6 @@ class DefaultNavigationStateServiceTest {
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
fun testOnNavigateToSession() = runTest {
val service = createStateService()
@ -180,11 +129,6 @@ class DefaultNavigationStateServiceTest {
// Navigate to another session
service.onNavigateToSession(A_SESSION_OWNER, 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
service.reset()
service.navigateToRoom()
@ -208,11 +152,6 @@ class DefaultNavigationStateServiceTest {
service.navigateToSession()
service.onLeavingThread(A_THREAD_OWNER)
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)
service.reset()
service.navigateToRoom()
@ -236,16 +175,11 @@ class DefaultNavigationStateServiceTest {
service.navigateToSession()
service.onLeavingRoom(A_ROOM_OWNER)
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
service.reset()
service.navigateToRoom()
service.onLeavingRoom(A_ROOM_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From thread (no effect)
service.reset()
service.navigateToThread()
@ -253,34 +187,6 @@ class DefaultNavigationStateServiceTest {
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
fun testOnLeavingSession() = runTest {
val service = createStateService()
@ -292,11 +198,6 @@ class DefaultNavigationStateServiceTest {
service.navigateToSession()
service.onLeavingSession(A_SESSION_OWNER)
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)
service.reset()
service.navigateToRoom()
@ -318,13 +219,8 @@ class DefaultNavigationStateServiceTest {
onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID)
}
private fun AppNavigationStateService.navigateToSpace() {
navigateToSession()
onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
}
private fun AppNavigationStateService.navigateToRoom() {
navigateToSpace()
navigateToSession()
onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID)
}

View file

@ -8,21 +8,18 @@
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.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
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
const val A_SESSION_OWNER = "aSessionOwner"
const val A_SPACE_OWNER = "aSpaceOwner"
const val A_ROOM_OWNER = "aRoomOwner"
const val A_THREAD_OWNER = "aThreadOwner"
fun aNavigationState(
sessionId: SessionId? = null,
spaceId: SpaceId? = MAIN_SPACE,
roomId: RoomId? = null,
threadId: ThreadId? = null,
): NavigationState {
@ -30,16 +27,20 @@ fun aNavigationState(
return NavigationState.Root
}
val session = NavigationState.Session(A_SESSION_OWNER, sessionId)
if (spaceId == null) {
if (roomId == null) {
return session
}
val space = NavigationState.Space(A_SPACE_OWNER, spaceId, session)
if (roomId == null) {
return space
}
val room = NavigationState.Room(A_ROOM_OWNER, roomId, space)
val room = NavigationState.Room(A_ROOM_OWNER, roomId, session)
if (threadId == null) {
return 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.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.NavigationState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class FakeAppNavigationStateService(
override val appNavigationState: MutableStateFlow<AppNavigationState> = MutableStateFlow(
AppNavigationState(
navigationState = NavigationState.Root,
isInForeground = true,
)
initialAppNavigationState: AppNavigationState = AppNavigationState(
navigationState = NavigationState.Root,
isInForeground = true,
),
) : 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 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 onLeavingRoom(owner: String) = Unit
override fun onNavigateToThread(owner: String, threadId: ThreadId) = Unit
override fun onLeavingThread(owner: String) = Unit
}

View file

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

View file

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

View file

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

View file

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