diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt index 1fdc52cf99..8dbf067b95 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt @@ -21,21 +21,21 @@ import androidx.core.net.toUri import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.MediaInfo -import io.element.android.libraries.mediaviewer.api.local.aFileInfo -import io.element.android.libraries.mediaviewer.api.local.anImageInfo +import io.element.android.libraries.mediaviewer.api.local.anApkMediaInfo +import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo open class AttachmentsPreviewStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( anAttachmentsPreviewState(), - anAttachmentsPreviewState(mediaInfo = aFileInfo()), + anAttachmentsPreviewState(mediaInfo = anApkMediaInfo()), anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)), anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))), ) } fun anAttachmentsPreviewState( - mediaInfo: MediaInfo = anImageInfo(), + mediaInfo: MediaInfo = anImageMediaInfo(), sendActionState: SendActionState = SendActionState.Idle ) = AttachmentsPreviewState( attachment = Attachment.Media( diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt index e5f0434c50..f2c004d2ca 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalAutofill import androidx.compose.ui.platform.LocalAutofillTree +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation @@ -116,7 +117,7 @@ fun TextField( keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, singleLine: Boolean = false, - maxLines: Int = Int.MAX_VALUE, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, shape: Shape = TextFieldDefaults.shape, colors: TextFieldColors = TextFieldDefaults.colors() @@ -163,9 +164,44 @@ private fun ContentToPreview() { allBooleans.forEach { enabled -> allBooleans.forEach { readonly -> TextField( + value = "Hello er=${isError.asInt()}, en=${enabled.asInt()}, ro=${readonly.asInt()}", + onValueChange = {}, + label = { Text(text = "label") }, + isError = isError, + enabled = enabled, + readOnly = readonly, + ) + Spacer(modifier = Modifier.height(2.dp)) + } + } + } + } +} + +@Preview(group = PreviewGroup.TextFields) +@Composable +internal fun TextFieldValueLightPreview() = + ElementPreviewLight { TextFieldValueContentToPreview() } + +@Preview(group = PreviewGroup.TextFields) +@Composable +internal fun TextFieldValueTextFieldDarkPreview() = + ElementPreviewDark { TextFieldValueContentToPreview() } + +@ExcludeFromCoverage +@Composable +private fun TextFieldValueContentToPreview() { + Column(modifier = Modifier.padding(4.dp)) { + allBooleans.forEach { isError -> + allBooleans.forEach { enabled -> + allBooleans.forEach { readonly -> + TextField( + value = TextFieldValue( + text = "Hello er=${isError.asInt()}, en=${enabled.asInt()}, ro=${readonly.asInt()}", + selection = TextRange(0, "Hello".length), + ), onValueChange = {}, label = { Text(text = "label") }, - value = "Hello er=${isError.asInt()}, en=${enabled.asInt()}, ro=${readonly.asInt()}", isError = isError, enabled = enabled, readOnly = readonly, diff --git a/libraries/mediaviewer/api/build.gradle.kts b/libraries/mediaviewer/api/build.gradle.kts index 2745302623..28590bfcfc 100644 --- a/libraries/mediaviewer/api/build.gradle.kts +++ b/libraries/mediaviewer/api/build.gradle.kts @@ -22,6 +22,11 @@ plugins { android { namespace = "io.element.android.libraries.mediaviewer.api" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } anvil { @@ -60,6 +65,8 @@ dependencies { testImplementation(libs.test.turbine) testImplementation(libs.coroutines.core) testImplementation(libs.coroutines.test) + testImplementation(libs.androidx.compose.ui.test.junit) + testReleaseImplementation(libs.androidx.compose.ui.test.manifest) ksp(libs.showkase.processor) } diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/MediaInfo.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/MediaInfo.kt index 1def7b4522..71dccfe4d4 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/MediaInfo.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/MediaInfo.kt @@ -28,35 +28,35 @@ data class MediaInfo( val fileExtension: String, ) : Parcelable -fun anImageInfo(): MediaInfo = MediaInfo( +fun anImageMediaInfo(): MediaInfo = MediaInfo( "an image file.jpg", MimeTypes.Jpeg, "4MB", "jpg" ) -fun aVideoInfo(): MediaInfo = MediaInfo( +fun aVideoMediaInfo(): MediaInfo = MediaInfo( "a video file.mp4", MimeTypes.Mp4, "14MB", "mp4" ) -fun aPdfInfo(): MediaInfo = MediaInfo( +fun aPdfMediaInfo(): MediaInfo = MediaInfo( "a pdf file.pdf", MimeTypes.Pdf, "23MB", "pdf" ) -fun aFileInfo(): MediaInfo = MediaInfo( +fun anApkMediaInfo(): MediaInfo = MediaInfo( "an apk file.apk", MimeTypes.Apk, "50MB", "apk" ) -fun anAudioInfo(): MediaInfo = MediaInfo( +fun anAudioMediaInfo(): MediaInfo = MediaInfo( "an audio file.mp3", MimeTypes.Mp3, "7MB", diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerStateProvider.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerStateProvider.kt index a9d6e4fc98..4b0719ed3a 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerStateProvider.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerStateProvider.kt @@ -21,11 +21,11 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.MediaInfo -import io.element.android.libraries.mediaviewer.api.local.aFileInfo -import io.element.android.libraries.mediaviewer.api.local.aPdfInfo -import io.element.android.libraries.mediaviewer.api.local.aVideoInfo -import io.element.android.libraries.mediaviewer.api.local.anAudioInfo -import io.element.android.libraries.mediaviewer.api.local.anImageInfo +import io.element.android.libraries.mediaviewer.api.local.aPdfMediaInfo +import io.element.android.libraries.mediaviewer.api.local.aVideoMediaInfo +import io.element.android.libraries.mediaviewer.api.local.anApkMediaInfo +import io.element.android.libraries.mediaviewer.api.local.anAudioMediaInfo +import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo open class MediaViewerStateProvider : PreviewParameterProvider { override val values: Sequence @@ -35,47 +35,47 @@ open class MediaViewerStateProvider : PreviewParameterProvider aMediaViewerState(AsyncData.Failure(IllegalStateException("error"))), aMediaViewerState( AsyncData.Success( - LocalMedia(Uri.EMPTY, anImageInfo()) + LocalMedia(Uri.EMPTY, anImageMediaInfo()) ), - anImageInfo(), + anImageMediaInfo(), ), aMediaViewerState( AsyncData.Success( - LocalMedia(Uri.EMPTY, aVideoInfo()) + LocalMedia(Uri.EMPTY, aVideoMediaInfo()) ), - aVideoInfo(), + aVideoMediaInfo(), ), aMediaViewerState( AsyncData.Success( - LocalMedia(Uri.EMPTY, aPdfInfo()) + LocalMedia(Uri.EMPTY, aPdfMediaInfo()) ), - aPdfInfo(), + aPdfMediaInfo(), ), aMediaViewerState( AsyncData.Loading(), - aFileInfo(), + anApkMediaInfo(), ), aMediaViewerState( AsyncData.Success( - LocalMedia(Uri.EMPTY, aFileInfo()) + LocalMedia(Uri.EMPTY, anApkMediaInfo()) ), - aFileInfo(), + anApkMediaInfo(), ), aMediaViewerState( AsyncData.Loading(), - anAudioInfo(), + anAudioMediaInfo(), ), aMediaViewerState( AsyncData.Success( - LocalMedia(Uri.EMPTY, anAudioInfo()) + LocalMedia(Uri.EMPTY, anAudioMediaInfo()) ), - anAudioInfo(), + anAudioMediaInfo(), ), aMediaViewerState( AsyncData.Success( - LocalMedia(Uri.EMPTY, anImageInfo()) + LocalMedia(Uri.EMPTY, anImageMediaInfo()) ), - anImageInfo(), + anImageMediaInfo(), canDownload = false, canShare = false, ), @@ -84,9 +84,10 @@ open class MediaViewerStateProvider : PreviewParameterProvider fun aMediaViewerState( downloadedMedia: AsyncData = AsyncData.Uninitialized, - mediaInfo: MediaInfo = anImageInfo(), + mediaInfo: MediaInfo = anImageMediaInfo(), canDownload: Boolean = true, canShare: Boolean = true, + eventSink: (MediaViewerEvents) -> Unit = {}, ) = MediaViewerState( mediaInfo = mediaInfo, thumbnailSource = null, @@ -94,4 +95,5 @@ fun aMediaViewerState( snackbarMessage = null, canDownload = canDownload, canShare = canShare, -) {} + eventSink = eventSink, +) diff --git a/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/MediaViewerPresenterTest.kt index c5b761411a..42a0a93c64 100644 --- a/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/MediaViewerPresenterTest.kt +++ b/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/MediaViewerPresenterTest.kt @@ -27,7 +27,7 @@ import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.matrix.test.media.FakeMediaLoader import io.element.android.libraries.matrix.test.media.aMediaSource -import io.element.android.libraries.mediaviewer.api.local.aFileInfo +import io.element.android.libraries.mediaviewer.api.local.anApkMediaInfo import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerEvents import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerPresenter @@ -40,7 +40,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -private val TESTED_MEDIA_INFO = aFileInfo() +private val TESTED_MEDIA_INFO = anApkMediaInfo() class MediaViewerPresenterTest { @get:Rule diff --git a/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerViewTest.kt b/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerViewTest.kt new file mode 100644 index 0000000000..e6c210a19d --- /dev/null +++ b/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerViewTest.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.mediaviewer.api.viewer + +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MediaViewerViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invokes expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setMediaViewerView( + aMediaViewerState( + eventSink = eventsRecorder + ), + onBackPressed = callback, + ) + rule.pressBack() + } + } + + @Test + fun `clicking on open emit expected Event`() { + testMenuAction(CommonStrings.action_open_with, MediaViewerEvents.OpenWith) + } + + @Test + fun `clicking on save emit expected Event`() { + testMenuAction(CommonStrings.action_save, MediaViewerEvents.SaveOnDisk) + } + + @Test + fun `clicking on share emit expected Event`() { + testMenuAction(CommonStrings.action_share, MediaViewerEvents.Share) + } + + private fun testMenuAction(contentDescriptionRes: Int, expectedEvent: MediaViewerEvents) { + val eventsRecorder = EventsRecorder() + rule.setMediaViewerView( + aMediaViewerState( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, anImageMediaInfo()) + ), + mediaInfo = anImageMediaInfo(), + eventSink = eventsRecorder + ), + ) + val contentDescription = rule.activity.getString(contentDescriptionRes) + rule.onNodeWithContentDescription(contentDescription).performClick() + eventsRecorder.assertSingle(expectedEvent) + } + + @Ignore("This test is not passing yet, maybe due to interaction with ZoomableAsyncImage?") + @Test + fun `clicking on image hides the overlay`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setMediaViewerView( + aMediaViewerState( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, anImageMediaInfo()) + ), + mediaInfo = anImageMediaInfo(), + eventSink = eventsRecorder + ), + ) + // Ensure that the action are visible + val contentDescription = rule.activity.getString(CommonStrings.action_open_with) + rule.onNodeWithContentDescription(contentDescription).assertHasClickAction() + val imageContentDescription = rule.activity.getString(CommonStrings.common_image) + rule.onNodeWithContentDescription(imageContentDescription).performClick() + // assertHasNoClickAction does not work as expected (?) + // rule.onNodeWithContentDescription(contentDescription).assertHasNoClickAction() + rule.onNodeWithContentDescription(contentDescription).performClick() + // No emitted event + } + + @Ignore("This test is not passing yet, maybe due to interaction with ZoomableAsyncImage?") + @Test + fun `clicking swipe on the image invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setMediaViewerView( + aMediaViewerState( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, anImageMediaInfo()) + ), + mediaInfo = anImageMediaInfo(), + eventSink = eventsRecorder + ), + onBackPressed = callback, + ) + val imageContentDescription = rule.activity.getString(CommonStrings.common_image) + rule.onNodeWithContentDescription(imageContentDescription).performTouchInput { swipeDown() } + rule.mainClock.advanceTimeBy(1_000) + } + } + + @Test + fun `error case, click on retry emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setMediaViewerView( + aMediaViewerState( + downloadedMedia = AsyncData.Failure(IllegalStateException("error")), + mediaInfo = anImageMediaInfo(), + eventSink = eventsRecorder + ), + ) + rule.clickOn(CommonStrings.action_retry) + eventsRecorder.assertSingle(MediaViewerEvents.RetryLoading) + } + + @Test + fun `error case, click on cancel emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setMediaViewerView( + aMediaViewerState( + downloadedMedia = AsyncData.Failure(IllegalStateException("error")), + mediaInfo = anImageMediaInfo(), + eventSink = eventsRecorder + ), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(MediaViewerEvents.ClearLoadingError) + } +} + +private fun AndroidComposeTestRule.setMediaViewerView( + state: MediaViewerState, + onBackPressed: () -> Unit = EnsureNeverCalled(), +) { + setContent { + MediaViewerView( + state = state, + onBackPressed = onBackPressed, + ) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt index 2563511ee2..d8f09c618f 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt @@ -22,7 +22,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.test.media.FakeMediaFile import io.element.android.libraries.mediaviewer.api.local.MediaInfo -import io.element.android.libraries.mediaviewer.api.local.anImageInfo +import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation import org.junit.Test import org.junit.runner.RunWith @@ -34,7 +34,7 @@ class AndroidLocalMediaFactoryTest { @Test fun `test AndroidLocalMediaFactory`() { val sut = createAndroidLocalMediaFactory() - val result = sut.createFromMediaFile(aMediaFile(), anImageInfo()) + val result = sut.createFromMediaFile(aMediaFile(), anImageMediaInfo()) assertThat(result.uri.toString()).endsWith("aPath") assertThat(result.info).isEqualTo( MediaInfo( diff --git a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/viewer/LocalMedia.kt b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/viewer/LocalMedia.kt index 876e82000d..f9102c0ee2 100644 --- a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/viewer/LocalMedia.kt +++ b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/viewer/LocalMedia.kt @@ -19,11 +19,11 @@ package io.element.android.libraries.mediaviewer.test.viewer import android.net.Uri import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.MediaInfo -import io.element.android.libraries.mediaviewer.api.local.anImageInfo +import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo fun aLocalMedia( uri: Uri, - mediaInfo: MediaInfo = anImageInfo(), + mediaInfo: MediaInfo = anImageMediaInfo(), ) = LocalMedia( uri = uri, info = mediaInfo diff --git a/libraries/session-storage/impl/build.gradle.kts b/libraries/session-storage/impl/build.gradle.kts index cfbfa4c57d..a083d56480 100644 --- a/libraries/session-storage/impl/build.gradle.kts +++ b/libraries/session-storage/impl/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { testImplementation(libs.test.turbine) testImplementation(libs.coroutines.test) testImplementation(libs.sqldelight.driver.jvm) + testImplementation(projects.tests.testutils) } sqldelight { diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt index 5ccf62c61d..3d3b262121 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt @@ -103,7 +103,6 @@ class DatabaseSessionStore @Inject constructor( } override fun sessionsFlow(): Flow> { - Timber.w("Observing session list!") return database.sessionDataQueries.selectAll() .asFlow() .mapToList(dispatchers.io) diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt index 760eefd20c..46e90f6d52 100644 --- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt @@ -22,7 +22,6 @@ import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.session.SessionData import io.element.android.libraries.sessionstorage.api.LoggedInState -import io.element.android.libraries.sessionstorage.api.LoginType import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest @@ -33,19 +32,7 @@ class DatabaseSessionStoreTests { private lateinit var database: SessionDatabase private lateinit var databaseSessionStore: DatabaseSessionStore - private val aSessionData = SessionData( - userId = "userId", - deviceId = "deviceId", - accessToken = "accessToken", - refreshToken = "refreshToken", - homeserverUrl = "homeserverUrl", - slidingSyncProxy = null, - loginTimestamp = null, - oidcData = "aOidcData", - isTokenValid = 1, - loginType = LoginType.UNKNOWN.name, - passphrase = null, - ) + private val aSessionData = aSessionData() @OptIn(ExperimentalCoroutinesApi::class) @Before @@ -96,6 +83,24 @@ class DatabaseSessionStoreTests { assertThat(latestSession).isEqualTo(aSessionData) } + @Test + fun `getAllSessions should return all the sessions`() = runTest { + val noSessions = databaseSessionStore.getAllSessions() + assertThat(noSessions).isEmpty() + database.sessionDataQueries.insertSessionData(aSessionData) + val otherSessionData = aSessionData.copy(userId = "otherUserId") + database.sessionDataQueries.insertSessionData(otherSessionData) + val allSessions = databaseSessionStore.getAllSessions().map { + it.toDbModel() + } + assertThat(allSessions).isEqualTo( + listOf( + aSessionData, + otherSessionData, + ) + ) + } + @Test fun `getSession returns a matching session in DB if exists`() = runTest { database.sessionDataQueries.insertSessionData(aSessionData) @@ -173,4 +178,51 @@ class DatabaseSessionStoreTests { assertThat(alteredSession.oidcData).isEqualTo(secondSessionData.oidcData) assertThat(alteredSession.passphrase).isEqualTo(secondSessionData.passphrase) } + + @Test + fun `update data, session not found`() = runTest { + val firstSessionData = SessionData( + userId = "userId", + deviceId = "deviceId", + accessToken = "accessToken", + refreshToken = "refreshToken", + homeserverUrl = "homeserverUrl", + slidingSyncProxy = "slidingSyncProxy", + loginTimestamp = 1, + oidcData = "aOidcData", + isTokenValid = 1, + loginType = null, + passphrase = "aPassphrase", + ) + val secondSessionData = SessionData( + userId = "userIdUnknown", + deviceId = "deviceIdAltered", + accessToken = "accessTokenAltered", + refreshToken = "refreshTokenAltered", + homeserverUrl = "homeserverUrlAltered", + slidingSyncProxy = "slidingSyncProxyAltered", + loginTimestamp = 2, + oidcData = "aOidcDataAltered", + isTokenValid = 1, + loginType = null, + passphrase = "aPassphraseAltered", + ) + assertThat(firstSessionData.userId).isNotEqualTo(secondSessionData.userId) + + database.sessionDataQueries.insertSessionData(firstSessionData) + databaseSessionStore.updateData(secondSessionData.toApiModel()) + + // Get the session and check that it has not been altered + val notAlteredSession = databaseSessionStore.getSession(firstSessionData.userId)!!.toDbModel() + + assertThat(notAlteredSession.userId).isEqualTo(firstSessionData.userId) + assertThat(notAlteredSession.deviceId).isEqualTo(firstSessionData.deviceId) + assertThat(notAlteredSession.accessToken).isEqualTo(firstSessionData.accessToken) + assertThat(notAlteredSession.refreshToken).isEqualTo(firstSessionData.refreshToken) + assertThat(notAlteredSession.homeserverUrl).isEqualTo(firstSessionData.homeserverUrl) + assertThat(notAlteredSession.slidingSyncProxy).isEqualTo(firstSessionData.slidingSyncProxy) + assertThat(notAlteredSession.loginTimestamp).isEqualTo(firstSessionData.loginTimestamp) + assertThat(notAlteredSession.oidcData).isEqualTo(firstSessionData.oidcData) + assertThat(notAlteredSession.passphrase).isEqualTo(firstSessionData.passphrase) + } } diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt new file mode 100644 index 0000000000..341e5e0e92 --- /dev/null +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.sessionstorage.impl + +import io.element.android.libraries.matrix.session.SessionData +import io.element.android.libraries.sessionstorage.api.LoginType + +internal fun aSessionData() = SessionData( + userId = "userId", + deviceId = "deviceId", + accessToken = "accessToken", + refreshToken = "refreshToken", + homeserverUrl = "homeserverUrl", + slidingSyncProxy = null, + loginTimestamp = null, + oidcData = "aOidcData", + isTokenValid = 1, + loginType = LoginType.UNKNOWN.name, + passphrase = null, +) diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserverTest.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserverTest.kt new file mode 100644 index 0000000000..c0fc805fc6 --- /dev/null +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserverTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.sessionstorage.impl.observer + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.sessionstorage.impl.DatabaseSessionStore +import io.element.android.libraries.sessionstorage.impl.SessionDatabase +import io.element.android.libraries.sessionstorage.impl.aSessionData +import io.element.android.libraries.sessionstorage.impl.toApiModel +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) class DefaultSessionObserverTest { + private lateinit var database: SessionDatabase + private lateinit var databaseSessionStore: DatabaseSessionStore + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + // Initialise in memory SQLite driver + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + SessionDatabase.Schema.create(driver) + database = SessionDatabase(driver) + databaseSessionStore = DatabaseSessionStore( + database = database, + dispatchers = CoroutineDispatchers( + io = UnconfinedTestDispatcher(), + computation = UnconfinedTestDispatcher(), + main = UnconfinedTestDispatcher(), + ) + ) + } + + @Test + fun `adding data invokes onSessionCreated`() = runTest { + val sessionData = aSessionData() + val sut = createDefaultSessionObserver() + runCurrent() + val listener = TestSessionListener() + sut.addListener(listener) + databaseSessionStore.storeData(sessionData.toApiModel()) + listener.assertEvents(TestSessionListener.Event.Created(sessionData.userId)) + sut.removeListener(listener) + coroutineContext.cancelChildren() + } + + @Test + fun `adding and deleting data invokes onSessionCreated and onSessionDeleted`() = runTest { + val sessionData = aSessionData() + val sut = createDefaultSessionObserver() + runCurrent() + val listener = TestSessionListener() + sut.addListener(listener) + databaseSessionStore.storeData(sessionData.toApiModel()) + listener.assertEvents(TestSessionListener.Event.Created(sessionData.userId)) + databaseSessionStore.removeSession(sessionData.userId) + listener.assertEvents( + TestSessionListener.Event.Created(sessionData.userId), + TestSessionListener.Event.Deleted(sessionData.userId), + ) + coroutineContext.cancelChildren() + } + + private fun TestScope.createDefaultSessionObserver(): DefaultSessionObserver { + return DefaultSessionObserver( + sessionStore = databaseSessionStore, + coroutineScope = this, + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + ) + } +} diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/observer/TestSessionListener.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/observer/TestSessionListener.kt new file mode 100644 index 0000000000..5317f2df0b --- /dev/null +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/observer/TestSessionListener.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.sessionstorage.impl.observer + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.sessionstorage.api.observer.SessionListener + +class TestSessionListener : SessionListener { + sealed interface Event { + data class Created(val userId: String) : Event + data class Deleted(val userId: String) : Event + } + + private val trackRecord: MutableList = mutableListOf() + + override suspend fun onSessionCreated(userId: String) { + trackRecord.add(Event.Created(userId)) + } + + override suspend fun onSessionDeleted(userId: String) { + trackRecord.add(Event.Deleted(userId)) + } + + fun assertEvents(vararg events: Event) { + assertThat(trackRecord).containsExactly(*events) + } +} diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.theme.components_TextFieldValueLight_null_TextFields_TextFieldValueLight_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.theme.components_TextFieldValueLight_null_TextFields_TextFieldValueLight_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..43cbebf3ee --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.theme.components_TextFieldValueLight_null_TextFields_TextFieldValueLight_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98631172faca52549bdbc5890e06dcc9b25b70111c21e6a1d2aaf7412fbe52b4 +size 38596 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.theme.components_TextFieldValueTextFieldDark_null_TextFields_TextFieldValueTextFieldDark_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.theme.components_TextFieldValueTextFieldDark_null_TextFields_TextFieldValueTextFieldDark_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..80026ead6c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.theme.components_TextFieldValueTextFieldDark_null_TextFields_TextFieldValueTextFieldDark_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7407a2ad190a77b2fc30c1af55cdf33a0087f1104bd9a7fbfdd230b67ce0c535 +size 38147