Merge branch 'develop' into feature/fga/update_rust_sdk

This commit is contained in:
ganfra 2023-02-14 21:15:28 +01:00
commit 8bca19a3fa
341 changed files with 9655 additions and 2495 deletions

View file

@ -42,6 +42,13 @@ dependencies {
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrixtest)
androidTestImplementation(libs.test.junitext)
}

View file

@ -14,8 +14,6 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.login.changeserver
import androidx.compose.foundation.background
@ -33,13 +31,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
@ -57,6 +49,12 @@ import io.element.android.features.login.error.changeServerError
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.VectorIcon
import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
@ -66,126 +64,129 @@ fun ChangeServerView(
modifier: Modifier = Modifier,
onChangeServerSuccess: () -> Unit = {},
) {
Surface(
modifier = modifier,
color = MaterialTheme.colorScheme.background,
val eventSink = state.eventSink
val scrollState = rememberScrollState()
Box(
modifier = modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding()
) {
val eventSink = state.eventSink
val scrollState = rememberScrollState()
Box(
Column(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding()
.verticalScroll(
state = scrollState,
)
.padding(horizontal = 16.dp)
) {
Column(
val isError = state.changeServerAction is Async.Failure
Box(
modifier = Modifier
.verticalScroll(
state = scrollState,
.padding(top = 99.dp)
.size(width = 81.dp, height = 73.dp)
.align(Alignment.CenterHorizontally)
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(32.dp)
)
.padding(horizontal = 16.dp)
) {
val isError = state.changeServerAction is Async.Failure
Box(
VectorIcon(
modifier = Modifier
.padding(top = 99.dp)
.size(width = 81.dp, height = 73.dp)
.align(Alignment.CenterHorizontally)
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(32.dp)
)
) {
VectorIcon(
modifier = Modifier
.align(Alignment.Center)
.size(width = 48.dp, height = 48.dp),
// TODO Update with design input
resourceId = R.drawable.ic_baseline_dataset_24,
)
}
Text(
text = "Your server",
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 56.dp)
.align(Alignment.CenterHorizontally)
.padding(top = 38.dp),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
.align(Alignment.Center)
.size(width = 48.dp, height = 48.dp),
// TODO Update with design input
resourceId = R.drawable.ic_baseline_dataset_24,
)
Text(
text = "A server is a home for all your data.\n" +
"You choose your server and its easy to make one.", // TODO "Learn more.",
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterHorizontally)
.padding(top = 16.dp),
textAlign = TextAlign.Center,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.secondary
}
Text(
text = "Your server",
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 56.dp)
.align(Alignment.CenterHorizontally)
.padding(top = 38.dp),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "A server is a home for all your data.\n" +
"You choose your server and its easy to make one.", // TODO "Learn more.",
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterHorizontally)
.padding(top = 16.dp),
textAlign = TextAlign.Center,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.secondary,
)
var homeserverFieldState by textFieldState(stateValue = state.homeserver)
OutlinedTextField(
value = homeserverFieldState,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.changeServerServer)
.padding(top = 200.dp),
onValueChange = {
homeserverFieldState = it
eventSink(ChangeServerEvents.SetServer(it))
},
label = {
Text(text = "Server")
},
isError = isError,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { eventSink(ChangeServerEvents.Submit) }
)
var homeserverFieldState by textFieldState(stateValue = state.homeserver)
OutlinedTextField(
value = homeserverFieldState,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.changeServerServer)
.padding(top = 200.dp),
onValueChange = {
homeserverFieldState = it
eventSink(ChangeServerEvents.SetServer(it))
},
label = {
Text(text = "Server")
},
isError = isError,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
)
if (state.changeServerAction is Async.Failure) {
Text(
text = changeServerError(
state.homeserver,
state.changeServerAction.error
),
keyboardActions = KeyboardActions(
onDone = { eventSink(ChangeServerEvents.Submit) }
)
)
if (state.changeServerAction is Async.Failure) {
Text(
text = changeServerError(
state.homeserver,
state.changeServerAction.error
),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp)
)
}
Button(
onClick = { eventSink(ChangeServerEvents.Submit) },
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.changeServerContinue)
.padding(top = 44.dp)
) {
Text(text = "Continue")
}
if (state.changeServerAction is Async.Success) {
onChangeServerSuccess()
}
}
if (state.changeServerAction is Async.Loading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp)
)
}
Button(
onClick = { eventSink(ChangeServerEvents.Submit) },
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.changeServerContinue)
.padding(top = 44.dp)
) {
Text(text = "Continue")
}
if (state.changeServerAction is Async.Success) {
onChangeServerSuccess()
}
}
if (state.changeServerAction is Async.Loading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
}
}
@Composable
@Preview
fun ChangeServerContentPreview() {
@Composable
fun ChangeServerViewLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun ChangeServerViewDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
ChangeServerView(
state = ChangeServerState(homeserver = "matrix.org"),
)

View file

@ -14,8 +14,6 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.login.root
import androidx.compose.foundation.layout.Box
@ -32,15 +30,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -60,12 +50,19 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.login.error.loginError
import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.core.SessionId
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginRootScreen(
state: LoginRootState,
@ -74,161 +71,164 @@ fun LoginRootScreen(
onLoginWithSuccess: (SessionId) -> Unit = {},
) {
val eventSink = state.eventSink
Surface(
modifier = modifier,
color = MaterialTheme.colorScheme.background,
Box(
modifier = modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding()
) {
Box(
val scrollState = rememberScrollState()
var loginFieldState by textFieldState(stateValue = state.formState.login)
var passwordFieldState by textFieldState(stateValue = state.formState.password)
Column(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding()
.verticalScroll(
state = scrollState,
)
.padding(horizontal = 16.dp),
) {
val scrollState = rememberScrollState()
var loginFieldState by textFieldState(stateValue = state.formState.login)
var passwordFieldState by textFieldState(stateValue = state.formState.password)
Column(
val isError = state.loggedInState is LoggedInState.ErrorLoggingIn
// Title
Text(
text = stringResource(id = StringR.string.ftue_auth_welcome_back_title),
modifier = Modifier
.verticalScroll(
state = scrollState,
)
.padding(horizontal = 16.dp),
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 48.dp),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
color = MaterialTheme.colorScheme.primary,
)
// Form
Column(
// modifier = Modifier.weight(1f),
) {
val isError = state.loggedInState is LoggedInState.ErrorLoggingIn
// Title
Text(
text = stringResource(id = StringR.string.ftue_auth_welcome_back_title),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 48.dp),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
)
// Form
Column(
// modifier = Modifier.weight(1f),
Box(
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = state.homeserver,
modifier = Modifier.fillMaxWidth(),
onValueChange = { /* no op */ },
enabled = false,
label = {
Text(text = "Server")
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
),
)
Button(
onClick = onChangeServer,
modifier = Modifier
.align(Alignment.CenterEnd)
.testTag(TestTags.loginChangeServer)
.padding(top = 8.dp, end = 8.dp),
content = {
Text(text = "Change")
}
)
}
OutlinedTextField(
value = loginFieldState,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginEmailUsername)
.padding(top = 60.dp),
value = state.homeserver,
modifier = Modifier.fillMaxWidth(),
onValueChange = { /* no op */ },
enabled = false,
label = {
Text(text = stringResource(id = StringR.string.login_signin_username_hint))
},
onValueChange = {
loginFieldState = it
eventSink(LoginRootEvents.SetLogin(it))
Text(text = "Server")
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
keyboardType = KeyboardType.Uri,
),
)
var passwordVisible by remember { mutableStateOf(false) }
if (state.loggedInState is LoggedInState.LoggingIn) {
// Ensure password is hidden when user submits the form
passwordVisible = false
}
OutlinedTextField(
value = passwordFieldState,
Button(
onClick = onChangeServer,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginPassword)
.padding(top = 24.dp),
onValueChange = {
passwordFieldState = it
eventSink(LoginRootEvents.SetPassword(it))
},
label = {
Text(text = "Password")
},
isError = isError,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
val image =
if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
val description =
if (passwordVisible) "Hide password" else "Show password"
.align(Alignment.CenterEnd)
.testTag(TestTags.loginChangeServer)
.padding(top = 8.dp, end = 8.dp),
content = {
Text(text = "Change")
}
)
}
OutlinedTextField(
value = loginFieldState,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginEmailUsername)
.padding(top = 60.dp),
label = {
Text(text = stringResource(id = StringR.string.login_signin_username_hint))
},
onValueChange = {
loginFieldState = it
eventSink(LoginRootEvents.SetLogin(it))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
)
var passwordVisible by remember { mutableStateOf(false) }
if (state.loggedInState is LoggedInState.LoggingIn) {
// Ensure password is hidden when user submits the form
passwordVisible = false
}
OutlinedTextField(
value = passwordFieldState,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginPassword)
.padding(top = 24.dp),
onValueChange = {
passwordFieldState = it
eventSink(LoginRootEvents.SetPassword(it))
},
label = {
Text(text = "Password")
},
isError = isError,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
val image =
if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
val description =
if (passwordVisible) "Hide password" else "Show password"
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(imageVector = image, description)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { eventSink(LoginRootEvents.Submit) }
),
)
if (state.loggedInState is LoggedInState.ErrorLoggingIn) {
Text(
text = loginError(state.formState, state.loggedInState.failure),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp)
)
}
}
// Submit
Button(
onClick = { eventSink(LoginRootEvents.Submit) },
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)
.padding(vertical = 32.dp)
) {
Text(text = "Continue")
}
when (val loggedInState = state.loggedInState) {
is LoggedInState.LoggedIn -> onLoginWithSuccess(loggedInState.sessionId)
else -> Unit
}
}
if (state.loggedInState is LoggedInState.LoggingIn) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(imageVector = image, description)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { eventSink(LoginRootEvents.Submit) }
),
)
if (state.loggedInState is LoggedInState.ErrorLoggingIn) {
Text(
text = loginError(state.formState, state.loggedInState.failure),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp)
)
}
}
// Submit
Button(
onClick = { eventSink(LoginRootEvents.Submit) },
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)
.padding(vertical = 32.dp)
) {
Text(text = "Continue")
}
when (val loggedInState = state.loggedInState) {
is LoggedInState.LoggedIn -> onLoginWithSuccess(loggedInState.sessionId)
else -> Unit
}
}
if (state.loggedInState is LoggedInState.LoggingIn) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
}
}
@Composable
@Preview
fun LoginContentPreview() {
@Composable
fun LoginRootScreenLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun LoginRootScreenDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
LoginRootScreen(
state = LoginRootState(
homeserver = "matrix.org",

View file

@ -0,0 +1,81 @@
/*
* Copyright (c) 2023 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.login.changeserver
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrixtest.A_HOMESERVER
import io.element.android.libraries.matrixtest.auth.FakeAuthenticationService
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ChangeServerPresenterTest {
@Test
fun `present - should start with default homeserver`() = runTest {
val presenter = ChangeServerPresenter(
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER)
assertThat(initialState.submitEnabled).isTrue()
}
}
@Test
fun `present - disable if empty or not correct`() = runTest {
val presenter = ChangeServerPresenter(
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(ChangeServerEvents.SetServer(""))
val emptyState = awaitItem()
assertThat(emptyState.homeserver).isEqualTo("")
assertThat(emptyState.submitEnabled).isFalse()
}
}
@Test
fun `present - submit`() = runTest {
val presenter = ChangeServerPresenter(
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(ChangeServerEvents.Submit)
val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isFalse()
assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java)
val successState = awaitItem()
assertThat(successState.submitEnabled).isTrue()
assertThat(successState.changeServerAction).isInstanceOf(Async.Success::class.java)
}
}
}

View file

@ -0,0 +1,135 @@
/*
* Copyright (c) 2023 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.login.root
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.core.SessionId
import io.element.android.libraries.matrixtest.A_HOMESERVER
import io.element.android.libraries.matrixtest.A_HOMESERVER_2
import io.element.android.libraries.matrixtest.A_PASSWORD
import io.element.android.libraries.matrixtest.A_SESSION_ID
import io.element.android.libraries.matrixtest.A_THROWABLE
import io.element.android.libraries.matrixtest.A_USER_NAME
import io.element.android.libraries.matrixtest.auth.FakeAuthenticationService
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LoginRootPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = LoginRootPresenter(
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER)
assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
assertThat(initialState.submitEnabled).isFalse()
}
}
@Test
fun `present - enter login and password`() = runTest {
val presenter = LoginRootPresenter(
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_USER_NAME))
val loginState = awaitItem()
assertThat(loginState.formState).isEqualTo(LoginFormState(login = A_USER_NAME, password = ""))
assertThat(loginState.submitEnabled).isFalse()
initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD))
val loginAndPasswordState = awaitItem()
assertThat(loginAndPasswordState.formState).isEqualTo(LoginFormState(login = A_USER_NAME, password = A_PASSWORD))
assertThat(loginAndPasswordState.submitEnabled).isTrue()
}
}
@Test
fun `present - submit`() = runTest {
val presenter = LoginRootPresenter(
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_USER_NAME))
initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD))
skipItems(1)
val loginAndPasswordState = awaitItem()
loginAndPasswordState.eventSink.invoke(LoginRootEvents.Submit)
val submitState = awaitItem()
assertThat(submitState.loggedInState).isEqualTo(LoggedInState.LoggingIn)
val loggedInState = awaitItem()
assertThat(loggedInState.loggedInState).isEqualTo(LoggedInState.LoggedIn(SessionId(A_SESSION_ID)))
}
}
@Test
fun `present - submit with error`() = runTest {
val authenticationService = FakeAuthenticationService()
val presenter = LoginRootPresenter(
authenticationService,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_USER_NAME))
initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD))
skipItems(1)
val loginAndPasswordState = awaitItem()
authenticationService.givenLoginError(A_THROWABLE)
loginAndPasswordState.eventSink.invoke(LoginRootEvents.Submit)
val submitState = awaitItem()
assertThat(submitState.loggedInState).isEqualTo(LoggedInState.LoggingIn)
val loggedInState = awaitItem()
assertThat(loggedInState.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE))
}
}
@Test
fun `present - refresh server`() = runTest {
val authenticationService = FakeAuthenticationService()
val presenter = LoginRootPresenter(
authenticationService,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER)
authenticationService.givenHomeserver(A_HOMESERVER_2)
initialState.eventSink.invoke(LoginRootEvents.RefreshHomeServer)
val refreshedState = awaitItem()
assertThat(refreshedState.homeserver).isEqualTo(A_HOMESERVER_2)
}
}
}

View file

@ -40,6 +40,13 @@ dependencies {
implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings)
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrixtest)
androidTestImplementation(libs.test.junitext)
}

View file

@ -29,6 +29,8 @@ import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.ui.strings.R as StringR
@Composable
@ -88,8 +90,15 @@ fun LogoutPreferenceContent(
}
}
@Composable
@Preview
fun LogoutContentPreview() {
@Composable
fun LogoutPreferenceViewLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun LogoutPreferenceViewDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
LogoutPreferenceView(LogoutPreferenceState())
}

View file

@ -0,0 +1,83 @@
/*
* Copyright (c) 2023 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.logout
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.core.SessionId
import io.element.android.libraries.matrixtest.A_THROWABLE
import io.element.android.libraries.matrixtest.FakeMatrixClient
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LogoutPreferencePresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = LogoutPreferencePresenter(
FakeMatrixClient(SessionId("sessionId")),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized)
}
}
@Test
fun `present - logout`() = runTest {
val presenter = LogoutPreferencePresenter(
FakeMatrixClient(SessionId("sessionId")),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(LogoutPreferenceEvents.Logout)
val loadingState = awaitItem()
assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java)
val successState = awaitItem()
assertThat(successState.logoutAction).isInstanceOf(Async.Success::class.java)
}
}
@Test
fun `present - logout with error`() = runTest {
val matrixClient = FakeMatrixClient(SessionId("sessionId"))
val presenter = LogoutPreferencePresenter(
matrixClient,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
matrixClient.givenLogoutError(A_THROWABLE)
initialState.eventSink.invoke(LogoutPreferenceEvents.Logout)
val loadingState = awaitItem()
assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java)
val successState = awaitItem()
assertThat(successState.logoutAction).isEqualTo(Async.Failure<LogoutPreferenceState>(A_THROWABLE))
}
}
}

View file

@ -44,7 +44,14 @@ dependencies {
implementation(libs.accompanist.flowlayout)
implementation(libs.androidx.recyclerview)
implementation(libs.jsoup)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrixtest)
androidTestImplementation(libs.test.junitext)
ksp(libs.showkase.processor)
}

View file

@ -39,13 +39,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -64,6 +59,11 @@ import io.element.android.features.messages.timeline.TimelineView
import io.element.android.features.messages.timeline.model.TimelineItem
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.LogCompositions
import kotlinx.coroutines.launch
import timber.log.Timber

View file

@ -29,11 +29,10 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ListItem
import androidx.compose.material.LocalContentColor
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ModalBottomSheetLayout
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.Text
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
@ -44,6 +43,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.features.messages.actionlist.model.TimelineItemAction
import io.element.android.features.messages.timeline.model.TimelineItem
import io.element.android.libraries.designsystem.components.VectorIcon
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheetLayout
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
@ -85,7 +85,7 @@ fun ActionListView(
.imePadding()
)
}
) {}
)
}
@Composable
@ -115,13 +115,13 @@ private fun SheetContent(
text = {
Text(
text = action.title,
color = if (action.destructive) MaterialTheme.colors.error else Color.Unspecified,
color = if (action.destructive) MaterialTheme.colorScheme.error else Color.Unspecified,
)
},
icon = {
VectorIcon(
resourceId = action.icon,
tint = if (action.destructive) MaterialTheme.colors.error else LocalContentColor.current,
tint = if (action.destructive) MaterialTheme.colorScheme.error else LocalContentColor.current,
)
}
)

View file

@ -18,6 +18,7 @@ package io.element.android.features.messages.actionlist.model
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import io.element.android.libraries.designsystem.VectorIcons
@Immutable
sealed class TimelineItemAction(
@ -25,9 +26,9 @@ sealed class TimelineItemAction(
@DrawableRes val icon: Int,
val destructive: Boolean = false
) {
object Forward : TimelineItemAction("Forward", io.element.android.libraries.designsystem.VectorIcons.ArrowForward)
object Copy : TimelineItemAction("Copy", io.element.android.libraries.designsystem.VectorIcons.Copy)
object Redact : TimelineItemAction("Redact", io.element.android.libraries.designsystem.VectorIcons.Delete, destructive = true)
object Reply : TimelineItemAction("Reply", io.element.android.libraries.designsystem.VectorIcons.Reply)
object Edit : TimelineItemAction("Edit", io.element.android.libraries.designsystem.VectorIcons.Edit)
object Forward : TimelineItemAction("Forward", VectorIcons.ArrowForward)
object Copy : TimelineItemAction("Copy", VectorIcons.Copy)
object Redact : TimelineItemAction("Redact", VectorIcons.Delete, destructive = true)
object Reply : TimelineItemAction("Reply", VectorIcons.Reply)
object Edit : TimelineItemAction("Edit", VectorIcons.Edit)
}

View file

@ -59,7 +59,10 @@ class MessageComposerPresenter @Inject constructor(
when (event) {
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
is MessageComposerEvents.UpdateText -> text.value = event.text.toStableCharSequence()
MessageComposerEvents.CloseSpecialMode -> composerMode.setToNormal()
MessageComposerEvents.CloseSpecialMode -> {
text.value = "".toStableCharSequence()
composerMode.setToNormal()
}
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode, text)
is MessageComposerEvents.SetMode -> composerMode.value = event.composerMode
}

View file

@ -18,7 +18,7 @@ package io.element.android.features.messages.textcomposer
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.designsystem.LocalIsDarkTheme
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.textcomposer.TextComposer
@Composable
@ -51,7 +51,7 @@ fun MessageComposerView(
onComposerTextChange = ::onComposerTextChange,
composerCanSendMessage = state.isSendButtonVisible,
composerText = state.text?.charSequence?.toString(),
isInDarkMode = LocalIsDarkTheme.current,
isInDarkMode = !ElementTheme.colors.isLight,
modifier = modifier
)
}

View file

@ -39,10 +39,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
@ -69,7 +66,6 @@ import io.element.android.features.messages.timeline.components.virtual.Timeline
import io.element.android.features.messages.timeline.model.AggregatedReaction
import io.element.android.features.messages.timeline.model.MessagesItemGroupPosition
import io.element.android.features.messages.timeline.model.TimelineItem
import io.element.android.features.messages.timeline.model.TimelineItemGroupPositionProvider
import io.element.android.features.messages.timeline.model.TimelineItemReactions
import io.element.android.features.messages.timeline.model.event.MessagesTimelineItemContentProvider
import io.element.android.features.messages.timeline.model.event.TimelineItemEncryptedContent
@ -82,12 +78,15 @@ import io.element.android.features.messages.timeline.model.virtual.TimelineItemD
import io.element.android.features.messages.timeline.model.virtual.TimelineItemLoadingModel
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.PairCombinedPreviewParameter
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import timber.log.Timber
@Composable
fun TimelineView(
@ -358,24 +357,26 @@ internal fun BoxScope.TimelineScrollHelper(
}
}
class MessagesItemGroupPositionToMessagesTimelineItemContentProvider :
PairCombinedPreviewParameter<MessagesItemGroupPosition, TimelineItemEventContent>(
TimelineItemGroupPositionProvider() to MessagesTimelineItemContentProvider()
)
@Suppress("PreviewPublic")
@Preview
@Composable
fun TimelineItemsPreview(
@PreviewParameter(MessagesTimelineItemContentProvider::class)
content: TimelineItemEventContent
) {
fun LoginRootScreenLightPreview(
@PreviewParameter(MessagesTimelineItemContentProvider::class) content: TimelineItemEventContent
) = ElementPreviewLight { ContentToPreview(content) }
@Preview
@Composable
fun LoginRootScreenDarkPreview(
@PreviewParameter(MessagesTimelineItemContentProvider::class) content: TimelineItemEventContent
) = ElementPreviewDark { ContentToPreview(content) }
@Composable
private fun ContentToPreview(content: TimelineItemEventContent) {
val timelineItems = persistentListOf(
// 3 items (First Middle Last) with isMine = false
createMessageEvent(
isMine = false,
content = content,
groupPosition = MessagesItemGroupPosition.First
groupPosition = MessagesItemGroupPosition.Last
),
createMessageEvent(
isMine = false,
@ -385,13 +386,13 @@ fun TimelineItemsPreview(
createMessageEvent(
isMine = false,
content = content,
groupPosition = MessagesItemGroupPosition.Last
groupPosition = MessagesItemGroupPosition.First
),
// 3 items (First Middle Last) with isMine = true
createMessageEvent(
isMine = true,
content = content,
groupPosition = MessagesItemGroupPosition.First
groupPosition = MessagesItemGroupPosition.Last
),
createMessageEvent(
isMine = true,
@ -401,7 +402,7 @@ fun TimelineItemsPreview(
createMessageEvent(
isMine = true,
content = content,
groupPosition = MessagesItemGroupPosition.Last
groupPosition = MessagesItemGroupPosition.First
),
)
TimelineView(

View file

@ -23,20 +23,14 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.timeline.model.MessagesItemGroupPosition
import io.element.android.libraries.designsystem.LocalIsDarkTheme
import io.element.android.libraries.designsystem.MessageHighlightDark
import io.element.android.libraries.designsystem.MessageHighlightLight
import io.element.android.libraries.designsystem.SystemGrey5Dark
import io.element.android.libraries.designsystem.SystemGrey5Light
import io.element.android.libraries.designsystem.SystemGrey6Dark
import io.element.android.libraries.designsystem.SystemGrey6Light
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Surface
private val BUBBLE_RADIUS = 16.dp
@ -88,24 +82,12 @@ fun MessageEventBubble(
}
val backgroundBubbleColor = if (isHighlighted) {
if (LocalIsDarkTheme.current) {
MessageHighlightDark
} else {
MessageHighlightLight
}
ElementTheme.colors.messageHighlightedBackground
} else {
if (isMine) {
if (LocalIsDarkTheme.current) {
SystemGrey5Dark
} else {
SystemGrey5Light
}
ElementTheme.colors.messageFromMeBackground
} else {
if (LocalIsDarkTheme.current) {
SystemGrey6Dark
} else {
SystemGrey6Light
}
ElementTheme.colors.messageFromOtherBackground
}
}
val bubbleShape = bubbleShape()

View file

@ -14,11 +14,8 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalFoundationApi::class)
package io.element.android.features.messages.timeline.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth

View file

@ -20,16 +20,21 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun TimelineItemInformativeView(
@ -57,3 +62,20 @@ fun TimelineItemInformativeView(
)
}
}
@Preview
@Composable
fun MatrixUserRowLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun MatrixUserRowDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
TimelineItemInformativeView(
text = "Info",
iconDescription = "",
icon = Icons.Default.Delete
)
}

View file

@ -24,8 +24,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -34,6 +32,8 @@ import androidx.compose.ui.unit.sp
import com.google.accompanist.flowlayout.FlowRow
import io.element.android.features.messages.timeline.model.AggregatedReaction
import io.element.android.features.messages.timeline.model.TimelineItemReactions
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun TimelineItemReactionsView(

View file

@ -28,8 +28,6 @@ import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
@ -49,6 +47,8 @@ import androidx.compose.ui.unit.sp
import com.google.accompanist.flowlayout.FlowRow
import io.element.android.libraries.designsystem.LinkColor
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.permalink.PermalinkData
import io.element.android.libraries.matrix.permalink.PermalinkParser
import kotlinx.collections.immutable.persistentMapOf

View file

@ -0,0 +1,178 @@
/*
* Copyright (c) 2022 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.messages
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.actionlist.ActionListPresenter
import io.element.android.features.messages.actionlist.model.TimelineItemAction
import io.element.android.features.messages.textcomposer.MessageComposerPresenter
import io.element.android.features.messages.timeline.TimelinePresenter
import io.element.android.features.messages.timeline.model.TimelineItem
import io.element.android.features.messages.timeline.model.TimelineItemReactions
import io.element.android.features.messages.timeline.model.content.TimelineItemContent
import io.element.android.features.messages.timeline.model.content.TimelineItemTextContent
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.room.MatrixRoom
import io.element.android.libraries.matrixtest.AN_EVENT_ID
import io.element.android.libraries.matrixtest.A_MESSAGE
import io.element.android.libraries.matrixtest.A_ROOM_ID
import io.element.android.libraries.matrixtest.A_USER_ID
import io.element.android.libraries.matrixtest.A_USER_NAME
import io.element.android.libraries.matrixtest.FakeMatrixClient
import io.element.android.libraries.matrixtest.room.FakeMatrixRoom
import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Test
class MessagesPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createMessagePresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.roomId).isEqualTo(A_ROOM_ID)
}
}
@Test
fun `present - handle action forward`() = runTest {
val presenter = createMessagePresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
// Still a TODO in the code
}
}
@Test
fun `present - handle action copy`() = runTest {
val presenter = createMessagePresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Copy, aMessageEvent()))
// Still a TODO in the code
}
}
@Test
fun `present - handle action reply`() = runTest {
val presenter = createMessagePresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent()))
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
}
}
@Test
fun `present - handle action edit`() = runTest {
val presenter = createMessagePresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent()))
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Edit::class.java)
}
}
@Test
fun `present - handle action redact`() = runTest {
val matrixRoom = FakeMatrixRoom()
val presenter = createMessagePresenter(matrixRoom)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent()))
assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID)
}
}
private fun TestScope.createMessagePresenter(
matrixRoom: MatrixRoom = FakeMatrixRoom()
): MessagesPresenter {
val matrixClient = FakeMatrixClient()
val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this,
room = matrixRoom
)
val timelinePresenter = TimelinePresenter(
coroutineDispatchers = testCoroutineDispatchers(),
client = matrixClient,
room = matrixRoom,
)
val actionListPresenter = ActionListPresenter()
return MessagesPresenter(
room = matrixRoom,
composerPresenter = messageComposerPresenter,
timelinePresenter = timelinePresenter,
actionListPresenter = actionListPresenter,
)
}
}
// TODO Move to common module to reuse
fun testCoroutineDispatchers() = CoroutineDispatchers(
io = UnconfinedTestDispatcher(),
computation = UnconfinedTestDispatcher(),
main = UnconfinedTestDispatcher(),
diffUpdateDispatcher = UnconfinedTestDispatcher(),
)
// TODO Move to common module to reuse and remove this duplication
private fun aMessageEvent(
isMine: Boolean = true,
content: TimelineItemContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null),
) = TimelineItem.MessageEvent(
id = AN_EVENT_ID,
senderId = A_USER_ID.value,
senderDisplayName = A_USER_NAME,
senderAvatar = AvatarData(),
content = content,
sentTime = "",
isMine = isMine,
reactionsState = TimelineItemReactions(persistentListOf())
)

View file

@ -0,0 +1,176 @@
/*
* Copyright (c) 2023 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.messages.actionlist
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.actionlist.model.TimelineItemAction
import io.element.android.features.messages.timeline.model.TimelineItem
import io.element.android.features.messages.timeline.model.TimelineItemReactions
import io.element.android.features.messages.timeline.model.content.TimelineItemContent
import io.element.android.features.messages.timeline.model.content.TimelineItemRedactedContent
import io.element.android.features.messages.timeline.model.content.TimelineItemTextContent
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrixtest.AN_EVENT_ID
import io.element.android.libraries.matrixtest.A_MESSAGE
import io.element.android.libraries.matrixtest.A_USER_ID
import io.element.android.libraries.matrixtest.A_USER_NAME
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ActionListPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = ActionListPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute for message from me redacted`() = runTest {
val presenter = ActionListPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(true, TimelineItemRedactedContent)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
messageEvent,
persistentListOf(
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute for message from others redacted`() = runTest {
val presenter = ActionListPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(false, TimelineItemRedactedContent)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
messageEvent,
persistentListOf(
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute for others message`() = runTest {
val presenter = ActionListPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null)
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
messageEvent,
persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute for my message`() = runTest {
val presenter = ActionListPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null)
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
messageEvent,
persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Copy,
TimelineItemAction.Edit,
TimelineItemAction.Redact,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
}
private fun aMessageEvent(
isMine: Boolean,
content: TimelineItemContent,
) = TimelineItem.MessageEvent(
id = AN_EVENT_ID,
senderId = A_USER_ID.value,
senderDisplayName = A_USER_NAME,
senderAvatar = AvatarData(),
content = content,
sentTime = "",
isMine = isMine,
reactionsState = TimelineItemReactions(persistentListOf())
)

View file

@ -0,0 +1,252 @@
/*
* Copyright (c) 2023 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.messages.textcomposer
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.matrixtest.ANOTHER_MESSAGE
import io.element.android.libraries.matrixtest.AN_EVENT_ID
import io.element.android.libraries.matrixtest.A_MESSAGE
import io.element.android.libraries.matrixtest.A_REPLY
import io.element.android.libraries.matrixtest.A_USER_NAME
import io.element.android.libraries.matrixtest.room.FakeMatrixRoom
import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
class MessageComposerPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = MessageComposerPresenter(
this,
FakeMatrixRoom()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.isFullScreen).isFalse()
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal(""))
assertThat(initialState.isSendButtonVisible).isFalse()
}
}
@Test
fun `present - toggle fullscreen`() = runTest {
val presenter = MessageComposerPresenter(
this,
FakeMatrixRoom()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(MessageComposerEvents.ToggleFullScreenState)
val fullscreenState = awaitItem()
assertThat(fullscreenState.isFullScreen).isTrue()
fullscreenState.eventSink.invoke(MessageComposerEvents.ToggleFullScreenState)
val notFullscreenState = awaitItem()
assertThat(notFullscreenState.isFullScreen).isFalse()
}
}
@Test
fun `present - change message`() = runTest {
val presenter = MessageComposerPresenter(
this,
FakeMatrixRoom()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
val withMessageState = awaitItem()
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
assertThat(withMessageState.isSendButtonVisible).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(""))
val withEmptyMessageState = awaitItem()
assertThat(withEmptyMessageState.text).isEqualTo(StableCharSequence(""))
assertThat(withEmptyMessageState.isSendButtonVisible).isFalse()
}
}
@Test
fun `present - change mode to edit`() = runTest {
val presenter = MessageComposerPresenter(
this,
FakeMatrixRoom()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
var state = awaitItem()
val mode = anEditMode()
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
state = awaitItem()
assertThat(state.text).isEqualTo(StableCharSequence(A_MESSAGE))
assertThat(state.isSendButtonVisible).isTrue()
backToNormalMode(state, skipCount = 1)
}
}
private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) {
state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
skipItems(skipCount)
val normalState = awaitItem()
assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
assertThat(normalState.text).isEqualTo(StableCharSequence(""))
assertThat(normalState.isSendButtonVisible).isFalse()
}
@Test
fun `present - change mode to reply`() = runTest {
val presenter = MessageComposerPresenter(
this,
FakeMatrixRoom()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
var state = awaitItem()
val mode = aReplyMode()
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
assertThat(state.text).isEqualTo(StableCharSequence(""))
assertThat(state.isSendButtonVisible).isFalse()
backToNormalMode(state)
}
}
@Test
fun `present - change mode to quote`() = runTest {
val presenter = MessageComposerPresenter(
this,
FakeMatrixRoom()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
var state = awaitItem()
val mode = aQuoteMode()
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
assertThat(state.text).isEqualTo(StableCharSequence(""))
assertThat(state.isSendButtonVisible).isFalse()
backToNormalMode(state)
}
}
@Test
fun `present - send message`() = runTest {
val presenter = MessageComposerPresenter(
this,
FakeMatrixRoom()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
val withMessageState = awaitItem()
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
assertThat(withMessageState.isSendButtonVisible).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE))
val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
assertThat(messageSentState.isSendButtonVisible).isFalse()
}
}
@Test
fun `present - edit message`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
val presenter = MessageComposerPresenter(
this,
fakeMatrixRoom
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
val mode = anEditMode()
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
skipItems(1)
val withMessageState = awaitItem()
assertThat(withMessageState.mode).isEqualTo(mode)
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
assertThat(withMessageState.isSendButtonVisible).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
val withEditedMessageState = awaitItem()
assertThat(withEditedMessageState.text).isEqualTo(StableCharSequence(ANOTHER_MESSAGE))
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
skipItems(1)
val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
assertThat(messageSentState.isSendButtonVisible).isFalse()
assertThat(fakeMatrixRoom.editMessageParameter).isEqualTo(ANOTHER_MESSAGE)
}
}
@Test
fun `present - reply message`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
val presenter = MessageComposerPresenter(
this,
fakeMatrixRoom
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
val mode = aReplyMode()
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
val state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
assertThat(state.text).isEqualTo(StableCharSequence(""))
assertThat(state.isSendButtonVisible).isFalse()
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_REPLY))
val withMessageState = awaitItem()
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_REPLY))
assertThat(withMessageState.isSendButtonVisible).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY))
skipItems(1)
val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
assertThat(messageSentState.isSendButtonVisible).isFalse()
assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY)
}
}
}
fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE)
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, AN_EVENT_ID, A_MESSAGE)
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.messages.timeline
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
// TODO Move to common module to reuse
fun testCoroutineDispatchers() = CoroutineDispatchers(
io = UnconfinedTestDispatcher(),
computation = UnconfinedTestDispatcher(),
main = UnconfinedTestDispatcher(),
diffUpdateDispatcher = UnconfinedTestDispatcher(),
)

View file

@ -0,0 +1,116 @@
/*
* Copyright (c) 2023 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.messages.timeline
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.timeline.model.TimelineItem
import io.element.android.libraries.matrix.timeline.MatrixTimelineItem
import io.element.android.libraries.matrixtest.AN_EVENT_ID
import io.element.android.libraries.matrixtest.FakeMatrixClient
import io.element.android.libraries.matrixtest.room.FakeMatrixRoom
import io.element.android.libraries.matrixtest.timeline.FakeMatrixTimeline
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
class TimelinePresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = TimelinePresenter(
testCoroutineDispatchers(),
FakeMatrixClient(),
FakeMatrixRoom()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.timelineItems).isEmpty()
}
}
@Test
fun `present - load more`() = runTest {
val matrixTimeline = FakeMatrixTimeline()
val matrixRoom = FakeMatrixRoom(matrixTimeline = matrixTimeline)
val presenter = TimelinePresenter(
testCoroutineDispatchers(),
FakeMatrixClient(),
matrixRoom
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.hasMoreToLoad).isTrue()
matrixTimeline.givenHasMoreToLoad(false)
initialState.eventSink.invoke(TimelineEvents.LoadMore)
val loadedState = awaitItem()
assertThat(loadedState.hasMoreToLoad).isFalse()
}
}
@Test
fun `present - set highlighted event`() = runTest {
val matrixTimeline = FakeMatrixTimeline()
val matrixRoom = FakeMatrixRoom(matrixTimeline = matrixTimeline)
val presenter = TimelinePresenter(
testCoroutineDispatchers(),
FakeMatrixClient(),
matrixRoom
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.highlightedEventId).isNull()
initialState.eventSink.invoke(TimelineEvents.SetHighlightedEvent(AN_EVENT_ID))
val withHighlightedState = awaitItem()
assertThat(withHighlightedState.highlightedEventId).isEqualTo(AN_EVENT_ID)
initialState.eventSink.invoke(TimelineEvents.SetHighlightedEvent(null))
val withoutHighlightedState = awaitItem()
assertThat(withoutHighlightedState.highlightedEventId).isNull()
}
}
@Test
fun `present - test callback`() = runTest {
val matrixTimeline = FakeMatrixTimeline()
val matrixRoom = FakeMatrixRoom(matrixTimeline = matrixTimeline)
val presenter = TimelinePresenter(
testCoroutineDispatchers(),
FakeMatrixClient(),
matrixRoom
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.timelineItems).isEmpty()
// Simulate callback from the SDK
matrixTimeline.callback?.onPushedTimelineItem(MatrixTimelineItem.Virtual)
val nonEmptyState = awaitItem()
assertThat(nonEmptyState.timelineItems).isNotEmpty()
assertThat(nonEmptyState.timelineItems[0]).isEqualTo(TimelineItem.Virtual("virtual_item_0"))
}
}
}

View file

@ -14,8 +14,6 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.onboarding
import androidx.compose.foundation.Image
@ -26,10 +24,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -49,7 +43,8 @@ import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.HorizontalPagerIndicator
import com.google.accompanist.pager.rememberPagerState
import io.element.android.libraries.designsystem.components.VectorButton
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import kotlinx.coroutines.delay
@ -64,74 +59,58 @@ fun OnBoardingScreen(
onSignUp: () -> Unit = {},
onSignIn: () -> Unit = {},
) {
val carrouselState = remember { SplashCarouselStateFactory().create() }
val nbOfPages = carrouselState.items.size
val carrouselData = remember { SplashCarouselDataFactory().create() }
val nbOfPages = carrouselData.items.size
var key by remember { mutableStateOf(false) }
Surface(
modifier = modifier,
color = MaterialTheme.colorScheme.background,
Box(
modifier = modifier
.fillMaxSize()
.systemBarsPadding()
.padding(vertical = 16.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.padding(vertical = 16.dp)
Column(
modifier = Modifier.fillMaxSize(),
) {
Column(
modifier = Modifier.fillMaxSize(),
val pagerState = rememberPagerState()
LaunchedEffect(key) {
launch {
delay(3_000)
pagerState.animateScrollToPage((pagerState.currentPage + 1) % nbOfPages)
// https://stackoverflow.com/questions/73714228/accompanist-pager-animatescrolltopage-doesnt-scroll-to-next-page-correctly
key = !key
}
}
LaunchedEffect(pagerState) {
// Collect from the pager state a snapshotFlow reading the currentPage
snapshotFlow { pagerState.currentPage }.collect { page ->
onPageChanged(page)
}
}
HorizontalPager(
modifier = Modifier.weight(1f),
count = nbOfPages,
state = pagerState,
) { page ->
// Our page content
OnBoardingPage(carrouselData.items[page])
}
HorizontalPagerIndicator(
pagerState = pagerState,
modifier = Modifier
.align(CenterHorizontally)
.padding(16.dp),
)
Button(
onClick = {
onSignIn()
},
enabled = true,
modifier = Modifier
.align(CenterHorizontally)
.testTag(TestTags.onBoardingSignIn)
.padding(top = 16.dp)
) {
val pagerState = rememberPagerState()
LaunchedEffect(key) {
launch {
delay(3_000)
pagerState.animateScrollToPage((pagerState.currentPage + 1) % nbOfPages)
// https://stackoverflow.com/questions/73714228/accompanist-pager-animatescrolltopage-doesnt-scroll-to-next-page-correctly
key = !key
}
}
LaunchedEffect(pagerState) {
// Collect from the pager state a snapshotFlow reading the currentPage
snapshotFlow { pagerState.currentPage }.collect { page ->
onPageChanged(page)
}
}
HorizontalPager(
modifier = Modifier.weight(1f),
count = nbOfPages,
state = pagerState,
) { page ->
// Our page content
OnBoardingPage(carrouselState.items[page])
}
HorizontalPagerIndicator(
pagerState = pagerState,
modifier = Modifier
.align(CenterHorizontally)
.padding(16.dp),
)
/*
VectorButton(
text = "CREATE ACCOUNT",
onClick = {
onSignUp()
},
enabled = true,
modifier = Modifier
.align(CenterHorizontally)
.padding(top = 16.dp)
)
*/
VectorButton(
text = stringResource(id = StringR.string.login_splash_submit),
onClick = {
onSignIn()
},
enabled = true,
modifier = Modifier
.align(CenterHorizontally)
.testTag(TestTags.onBoardingSignIn)
.padding(top = 16.dp)
)
Text(text = stringResource(id = StringR.string.login_splash_submit))
}
}
}
@ -139,7 +118,7 @@ fun OnBoardingScreen(
@Composable
fun OnBoardingPage(
item: SplashCarouselState.Item,
item: SplashCarouselData.Item,
modifier: Modifier = Modifier,
) {
Box(

View file

@ -19,7 +19,7 @@ package io.element.android.features.onboarding
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
data class SplashCarouselState(
data class SplashCarouselData(
val items: List<Item>
) {
data class Item(

View file

@ -19,8 +19,8 @@ package io.element.android.features.onboarding
import androidx.annotation.DrawableRes
import io.element.android.libraries.ui.strings.R as StringR
class SplashCarouselStateFactory {
fun create(): SplashCarouselState {
class SplashCarouselDataFactory {
fun create(): SplashCarouselData {
val lightTheme = true
fun background(@DrawableRes lightDrawable: Int) =
@ -29,9 +29,9 @@ class SplashCarouselStateFactory {
fun hero(@DrawableRes lightDrawable: Int, @DrawableRes darkDrawable: Int) =
if (lightTheme) lightDrawable else darkDrawable
return SplashCarouselState(
return SplashCarouselData(
listOf(
SplashCarouselState.Item(
SplashCarouselData.Item(
StringR.string.ftue_auth_carousel_secure_title,
StringR.string.ftue_auth_carousel_secure_body,
hero(
@ -40,19 +40,19 @@ class SplashCarouselStateFactory {
),
background(R.drawable.bg_carousel_page_1)
),
SplashCarouselState.Item(
SplashCarouselData.Item(
StringR.string.ftue_auth_carousel_control_title,
StringR.string.ftue_auth_carousel_control_body,
hero(R.drawable.ic_splash_control, R.drawable.ic_splash_control_dark),
background(R.drawable.bg_carousel_page_2)
),
SplashCarouselState.Item(
SplashCarouselData.Item(
StringR.string.ftue_auth_carousel_encrypted_title,
StringR.string.ftue_auth_carousel_encrypted_body,
hero(R.drawable.ic_splash_secure, R.drawable.ic_splash_secure_dark),
background(R.drawable.bg_carousel_page_3)
),
SplashCarouselState.Item(
SplashCarouselData.Item(
collaborationTitle(),
StringR.string.ftue_auth_carousel_workplace_body,
hero(

View file

@ -44,7 +44,14 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(libs.datetime)
implementation(libs.accompanist.placeholder)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrixtest)
androidTestImplementation(libs.test.junitext)
ksp(libs.showkase.processor)
}

View file

@ -27,6 +27,8 @@ import io.element.android.features.rageshake.preferences.RageshakePreferencesSta
import io.element.android.features.rageshake.preferences.RageshakePreferencesView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.preferences.PreferenceView
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.ui.strings.R as StringR
@Composable
@ -56,7 +58,14 @@ fun PreferencesRootView(
@Preview
@Composable
fun PreferencesContentPreview() {
fun PreferencesRootViewLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun PreferencesRootViewDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
val state = PreferencesRootState(
logoutState = LogoutPreferenceState(),
rageshakeState = RageshakePreferencesState(),

View file

@ -1,32 +0,0 @@
/*
* Copyright (c) 2022 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.features.preferences
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright (c) 2023 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.features.preferences.root
import io.element.android.features.rageshake.rageshake.RageShake
// TODO Remove this duplicated class when we will rework modules.
class FakeRageShake(
private var isAvailableValue: Boolean = true
) : RageShake {
private var interceptor: (() -> Unit)? = null
override fun isAvailable() = isAvailableValue
override fun start(sensitivity: Float) {
}
override fun stop() {
}
override fun setSensitivity(sensitivity: Float) {
}
override fun setInterceptor(interceptor: (() -> Unit)?) {
this.interceptor = interceptor
}
fun triggerPhoneRageshake() = interceptor?.invoke()
}

View file

@ -0,0 +1,46 @@
/*
* Copyright (c) 2023 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.features.preferences.root
import io.element.android.features.rageshake.rageshake.RageshakeDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
const val A_SENSITIVITY = 1f
// TODO Remove this duplicated class when we will rework modules.
class FakeRageshakeDataStore(
isEnabled: Boolean = true,
sensitivity: Float = A_SENSITIVITY,
) : RageshakeDataStore {
private val isEnabledFlow = MutableStateFlow(isEnabled)
override fun isEnabled(): Flow<Boolean> = isEnabledFlow
override suspend fun setIsEnabled(isEnabled: Boolean) {
isEnabledFlow.value = isEnabled
}
private val sensitivityFlow = MutableStateFlow(sensitivity)
override fun sensitivity(): Flow<Float> = sensitivityFlow
override suspend fun setSensitivity(sensitivity: Float) {
sensitivityFlow.value = sensitivity
}
override suspend fun reset() = Unit
}

View file

@ -0,0 +1,53 @@
/*
* Copyright (c) 2023 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.preferences.root
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.logout.LogoutPreferencePresenter
import io.element.android.features.rageshake.preferences.RageshakePreferencesPresenter
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrixtest.FakeMatrixClient
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
class PreferencesRootPresenterTest {
@Test
fun `present - initial state`() = runTest {
val logoutPresenter = LogoutPreferencePresenter(FakeMatrixClient())
val rageshakePresenter = RageshakePreferencesPresenter(FakeRageShake(), FakeRageshakeDataStore())
val presenter = PreferencesRootPresenter(
logoutPresenter, rageshakePresenter
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.logoutState.logoutAction).isEqualTo(Async.Uninitialized)
assertThat(initialState.rageshakeState.isEnabled).isTrue()
assertThat(initialState.rageshakeState.isSupported).isTrue()
assertThat(initialState.rageshakeState.sensitivity).isEqualTo(1.0f)
assertThat(initialState.myUser).isEqualTo(Async.Uninitialized)
}
}
}

View file

@ -40,11 +40,19 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings)
implementation(libs.squareup.seismic)
api(libs.squareup.seismic)
implementation(libs.androidx.datastore.preferences)
implementation(libs.coil)
implementation(libs.coil.compose)
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrixtest)
testImplementation(libs.test.mockk)
androidTestImplementation(libs.test.junitext)
}

View file

@ -23,10 +23,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.core.net.toUri
import io.element.android.features.rageshake.crash.CrashDataStore
import io.element.android.features.rageshake.logs.VectorFileLogger
import io.element.android.features.rageshake.reporter.BugReporter
import io.element.android.features.rageshake.reporter.BugReporterListener
import io.element.android.features.rageshake.reporter.ReportType
import io.element.android.features.rageshake.screenshot.ScreenshotHolder
import io.element.android.libraries.architecture.Async
@ -45,7 +45,7 @@ class BugReportPresenter @Inject constructor(
private class BugReporterUploadListener(
private val sendingProgress: MutableState<Float>,
private val sendingAction: MutableState<Async<Unit>>
) : BugReporter.IMXBugReportListener {
) : BugReporterListener {
override fun onUploadCancelled() {
sendingProgress.value = 0f
@ -72,7 +72,7 @@ class BugReportPresenter @Inject constructor(
override fun present(): BugReportState {
val screenshotUri = rememberSaveable {
mutableStateOf(
screenshotHolder.getFile()?.toUri()?.toString()
screenshotHolder.getFileUri()
)
}
val crashInfo: String by crashDataStore
@ -126,7 +126,11 @@ class BugReportPresenter @Inject constructor(
formState.value = operation(formState.value)
}
private fun CoroutineScope.sendBugReport(formState: BugReportFormState, hasCrashLogs: Boolean, listener: BugReporter.IMXBugReportListener) = launch {
private fun CoroutineScope.sendBugReport(
formState: BugReportFormState,
hasCrashLogs: Boolean,
listener: BugReporterListener,
) = launch {
bugReporter.sendBugReport(
coroutineScope = this,
reportType = ReportType.BUG_REPORT,
@ -145,6 +149,6 @@ class BugReportPresenter @Inject constructor(
private fun CoroutineScope.resetAll() = launch {
screenshotHolder.reset()
crashDataStore.reset()
VectorFileLogger.getFromTimber().reset()
VectorFileLogger.getFromTimber()?.reset()
}
}

View file

@ -26,13 +26,7 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -54,10 +48,15 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.LabelledCheckbox
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BugReportView(
state: BugReportState,
@ -73,147 +72,151 @@ fun BugReportView(
}
return
}
Surface(
modifier = modifier,
color = MaterialTheme.colorScheme.background,
Box(
modifier = modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding()
) {
Box(
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding()
.verticalScroll(
state = scrollState,
)
.padding(horizontal = 16.dp),
) {
val scrollState = rememberScrollState()
Column(
val isError = state.sending is Async.Failure
val isFormEnabled = state.sending !is Async.Loading
// Title
Text(
text = stringResource(id = StringR.string.send_bug_report),
modifier = Modifier
.verticalScroll(
state = scrollState,
)
.padding(horizontal = 16.dp),
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
color = MaterialTheme.colorScheme.primary,
)
// Form
Text(
text = stringResource(id = StringR.string.send_bug_report_description),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
fontSize = 16.sp,
color = MaterialTheme.colorScheme.primary,
)
var descriptionFieldState by textFieldState(
stateValue = state.formState.description
)
Column(
// modifier = Modifier.weight(1f),
) {
val isError = state.sending is Async.Failure
val isFormEnabled = state.sending !is Async.Loading
// Title
Text(
text = stringResource(id = StringR.string.send_bug_report),
OutlinedTextField(
value = descriptionFieldState,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
)
// Form
Text(
text = stringResource(id = StringR.string.send_bug_report_description),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
fontSize = 16.sp,
)
var descriptionFieldState by textFieldState(
stateValue = state.formState.description
)
Column(
// modifier = Modifier.weight(1f),
) {
OutlinedTextField(
value = descriptionFieldState,
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
enabled = isFormEnabled,
label = {
Text(text = stringResource(id = StringR.string.send_bug_report_placeholder))
},
supportingText = {
Text(text = stringResource(id = StringR.string.send_bug_report_description_in_english))
},
onValueChange = {
descriptionFieldState = it
eventSink(BugReportEvents.SetDescription(it))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
// TODO Error text too short
)
}
LabelledCheckbox(
checked = state.formState.sendLogs,
onCheckedChange = { eventSink(BugReportEvents.SetSendLog(it)) },
.padding(top = 16.dp),
enabled = isFormEnabled,
text = stringResource(id = StringR.string.send_bug_report_include_logs)
label = {
Text(text = stringResource(id = StringR.string.send_bug_report_placeholder))
},
supportingText = {
Text(text = stringResource(id = StringR.string.send_bug_report_description_in_english))
},
onValueChange = {
descriptionFieldState = it
eventSink(BugReportEvents.SetDescription(it))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
// TODO Error text too short
)
if (state.hasCrashLogs) {
LabelledCheckbox(
checked = state.formState.sendCrashLogs,
onCheckedChange = { eventSink(BugReportEvents.SetSendCrashLog(it)) },
enabled = isFormEnabled,
text = stringResource(id = StringR.string.send_bug_report_include_crash_logs)
)
}
}
LabelledCheckbox(
checked = state.formState.sendLogs,
onCheckedChange = { eventSink(BugReportEvents.SetSendLog(it)) },
enabled = isFormEnabled,
text = stringResource(id = StringR.string.send_bug_report_include_logs)
)
if (state.hasCrashLogs) {
LabelledCheckbox(
checked = state.formState.canContact,
onCheckedChange = { eventSink(BugReportEvents.SetCanContact(it)) },
checked = state.formState.sendCrashLogs,
onCheckedChange = { eventSink(BugReportEvents.SetSendCrashLog(it)) },
enabled = isFormEnabled,
text = stringResource(id = StringR.string.you_may_contact_me)
text = stringResource(id = StringR.string.send_bug_report_include_crash_logs)
)
if (state.screenshotUri != null) {
LabelledCheckbox(
checked = state.formState.sendScreenshot,
onCheckedChange = { eventSink(BugReportEvents.SetSendScreenshot(it)) },
enabled = isFormEnabled,
text = stringResource(id = StringR.string.send_bug_report_include_screenshot)
)
if (state.formState.sendScreenshot) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
val context = LocalContext.current
val model = ImageRequest.Builder(context)
.data(state.screenshotUri)
.build()
AsyncImage(
modifier = Modifier.fillMaxWidth(fraction = 0.5f),
model = model,
contentDescription = null
)
}
}
LabelledCheckbox(
checked = state.formState.canContact,
onCheckedChange = { eventSink(BugReportEvents.SetCanContact(it)) },
enabled = isFormEnabled,
text = stringResource(id = StringR.string.you_may_contact_me)
)
if (state.screenshotUri != null) {
LabelledCheckbox(
checked = state.formState.sendScreenshot,
onCheckedChange = { eventSink(BugReportEvents.SetSendScreenshot(it)) },
enabled = isFormEnabled,
text = stringResource(id = StringR.string.send_bug_report_include_screenshot)
)
if (state.formState.sendScreenshot) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
val context = LocalContext.current
val model = ImageRequest.Builder(context)
.data(state.screenshotUri)
.build()
AsyncImage(
modifier = Modifier.fillMaxWidth(fraction = 0.5f),
model = model,
contentDescription = null
)
}
}
// Submit
Button(
onClick = { eventSink(BugReportEvents.SendBugReport) },
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp)
) {
Text(text = stringResource(id = StringR.string.action_send))
}
}
when (state.sending) {
is Async.Loading -> {
CircularProgressIndicator(
progress = state.sendingProgress,
modifier = Modifier.align(Alignment.Center)
)
}
is Async.Failure -> ErrorDialog(
content = state.sending.error.toString(),
// Submit
Button(
onClick = { eventSink(BugReportEvents.SendBugReport) },
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp)
) {
Text(text = stringResource(id = StringR.string.action_send))
}
}
when (state.sending) {
is Async.Loading -> {
CircularProgressIndicator(
progress = state.sendingProgress,
modifier = Modifier.align(Alignment.Center)
)
else -> Unit
}
is Async.Failure -> ErrorDialog(
content = state.sending.error.toString(),
)
else -> Unit
}
}
}
@Composable
@Preview
fun BugReportContentPreview() {
@Composable
fun BugReportViewLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun BugReportViewDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
BugReportView(
state = BugReportState(),
)

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* Copyright (c) 2023 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.
@ -16,59 +16,14 @@
package io.element.android.features.rageshake.crash
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_crash")
interface CrashDataStore {
fun setCrashData(crashData: String)
private val appHasCrashedKey = booleanPreferencesKey("appHasCrashed")
private val crashDataKey = stringPreferencesKey("crashData")
suspend fun resetAppHasCrashed()
fun appHasCrashed(): Flow<Boolean>
fun crashInfo(): Flow<String>
class CrashDataStore @Inject constructor(
@ApplicationContext context: Context
) {
private val store = context.dataStore
fun setCrashData(crashData: String) {
// Must block
runBlocking {
store.edit { prefs ->
prefs[appHasCrashedKey] = true
prefs[crashDataKey] = crashData
}
}
}
suspend fun resetAppHasCrashed() {
store.edit { prefs ->
prefs[appHasCrashedKey] = false
}
}
fun appHasCrashed(): Flow<Boolean> {
return store.data.map { prefs ->
prefs[appHasCrashedKey].orFalse()
}
}
fun crashInfo(): Flow<String> {
return store.data.map { prefs ->
prefs[crashDataKey].orEmpty()
}
}
suspend fun reset() {
store.edit { it.clear() }
}
suspend fun reset()
}

View file

@ -0,0 +1,77 @@
/*
* Copyright (c) 2022 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.features.rageshake.crash
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_crash")
private val appHasCrashedKey = booleanPreferencesKey("appHasCrashed")
private val crashDataKey = stringPreferencesKey("crashData")
@ContributesBinding(AppScope::class)
class PreferencesCrashDataStore @Inject constructor(
@ApplicationContext context: Context
) : CrashDataStore {
private val store = context.dataStore
override fun setCrashData(crashData: String) {
// Must block
runBlocking {
store.edit { prefs ->
prefs[appHasCrashedKey] = true
prefs[crashDataKey] = crashData
}
}
}
override suspend fun resetAppHasCrashed() {
store.edit { prefs ->
prefs[appHasCrashedKey] = false
}
}
override fun appHasCrashed(): Flow<Boolean> {
return store.data.map { prefs ->
prefs[appHasCrashedKey].orFalse()
}
}
override fun crashInfo(): Flow<String> {
return store.data.map { prefs ->
prefs[crashDataKey].orEmpty()
}
}
override suspend fun reset() {
store.edit { it.clear() }
}
}

View file

@ -26,7 +26,7 @@ import java.io.StringWriter
class VectorUncaughtExceptionHandler(
context: Context
) : Thread.UncaughtExceptionHandler {
private val crashDataStore = CrashDataStore(context)
private val crashDataStore = PreferencesCrashDataStore(context)
private var previousHandler: Thread.UncaughtExceptionHandler? = null
/**

View file

@ -20,6 +20,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.ui.strings.R as StringR
@ -67,7 +69,14 @@ fun CrashDetectionContent(
@Preview
@Composable
fun CrashDetectionContentPreview() {
fun CrashDetectionContentLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun CrashDetectionContentDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
CrashDetectionContent(
state = CrashDetectionState()
)

View file

@ -95,12 +95,12 @@ class RageshakeDetectionPresenter @Inject constructor(
private fun handleRageShake(start: Boolean, state: RageshakeDetectionState, takeScreenshot: MutableState<Boolean>) {
if (start) {
rageShake.start(state.preferenceState.sensitivity)
rageShake.interceptor = {
rageShake.setInterceptor {
takeScreenshot.value = true
}
} else {
rageShake.stop()
rageShake.interceptor = null
rageShake.setInterceptor(null)
}
}

View file

@ -27,6 +27,8 @@ import io.element.android.features.rageshake.screenshot.ImageResult
import io.element.android.features.rageshake.screenshot.screenshot
import io.element.android.libraries.androidutils.hardware.vibrate
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.ui.strings.R as StringR
@ -99,6 +101,13 @@ fun RageshakeDialogContent(
@Preview
@Composable
fun RageshakeDialogContentPreview() {
fun RageshakeDialogContentLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun RageshakeDialogContentDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
RageshakeDialogContent()
}

View file

@ -43,8 +43,8 @@ class VectorFileLogger(
) : Timber.Tree() {
companion object {
fun getFromTimber(): VectorFileLogger {
return Timber.forest().filterIsInstance<VectorFileLogger>().first()
fun getFromTimber(): VectorFileLogger? {
return Timber.forest().filterIsInstance<VectorFileLogger>().firstOrNull()
}
private const val SIZE_20MB = 20 * 1024 * 1024

View file

@ -27,6 +27,8 @@ import io.element.android.libraries.designsystem.components.preferences.Preferen
import io.element.android.libraries.designsystem.components.preferences.PreferenceSlide
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.ui.strings.R as StringR
@Composable
@ -73,14 +75,28 @@ fun RageshakePreferencesView(
}
}
@Composable
@Preview
fun RageshakePreferencesViewPreview() {
@Composable
fun RageshakePreferencesViewLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun RageshakePreferencesViewDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
RageshakePreferencesView(RageshakePreferencesState(isEnabled = true, isSupported = true, sensitivity = 0.5f))
}
@Composable
@Preview
fun RageshakePreferenceNotSupportedPreview() {
@Composable
fun RageshakePreferencesViewNotSupportedLightPreview() = ElementPreviewLight { ContentNotSupportedToPreview() }
@Preview
@Composable
fun RageshakePreferencesViewNotSupportedDarkPreview() = ElementPreviewDark { ContentNotSupportedToPreview() }
@Composable
private fun ContentNotSupportedToPreview() {
RageshakePreferencesView(RageshakePreferencesState(isEnabled = true, isSupported = false, sensitivity = 0.5f))
}

View file

@ -0,0 +1,77 @@
/*
* Copyright (c) 2022 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.features.rageshake.rageshake
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorManager
import androidx.core.content.getSystemService
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.seismic.ShakeDetector
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import javax.inject.Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class, RageShake::class)
class DefaultRageShake @Inject constructor(
@ApplicationContext context: Context,
) : ShakeDetector.Listener, RageShake {
private var sensorManager = context.getSystemService<SensorManager>()
private var shakeDetector: ShakeDetector? = null
private var interceptor: (() -> Unit)? = null
override fun setInterceptor(interceptor: (() -> Unit)?) {
this.interceptor = interceptor
}
/**
* Check if the feature is available on this device.
*/
override fun isAvailable(): Boolean {
return sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null
}
override fun start(sensitivity: Float) {
sensorManager?.let {
shakeDetector = ShakeDetector(this).apply {
start(it, SensorManager.SENSOR_DELAY_GAME)
}
setSensitivity(sensitivity)
}
}
override fun stop() {
shakeDetector?.stop()
}
/**
* sensitivity will be {0, O.25, 0.5, 0.75, 1} and converted to
* [ShakeDetector.SENSITIVITY_LIGHT (=11), ShakeDetector.SENSITIVITY_HARD (=15)].
*/
override fun setSensitivity(sensitivity: Float) {
shakeDetector?.setSensitivity(
ShakeDetector.SENSITIVITY_LIGHT + (sensitivity * 4).toInt()
)
}
override fun hearShake() {
interceptor?.invoke()
}
}

View file

@ -0,0 +1,72 @@
/*
* Copyright (c) 2022 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.features.rageshake.rageshake
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_rageshake")
private val enabledKey = booleanPreferencesKey("enabled")
private val sensitivityKey = floatPreferencesKey("sensitivity")
@ContributesBinding(AppScope::class)
class PreferencesRageshakeDataStore @Inject constructor(
@ApplicationContext context: Context
) : RageshakeDataStore {
private val store = context.dataStore
override fun isEnabled(): Flow<Boolean> {
return store.data.map { prefs ->
prefs[enabledKey].orTrue()
}
}
override suspend fun setIsEnabled(isEnabled: Boolean) {
store.edit { prefs ->
prefs[enabledKey] = isEnabled
}
}
override fun sensitivity(): Flow<Float> {
return store.data.map { prefs ->
prefs[sensitivityKey] ?: 0.5f
}
}
override suspend fun setSensitivity(sensitivity: Float) {
store.edit { prefs ->
prefs[sensitivityKey] = sensitivity
}
}
override suspend fun reset() {
store.edit { it.clear() }
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* Copyright (c) 2023 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.
@ -16,57 +16,21 @@
package io.element.android.features.rageshake.rageshake
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorManager
import androidx.core.content.getSystemService
import com.squareup.seismic.ShakeDetector
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import javax.inject.Inject
@SingleIn(AppScope::class)
class RageShake @Inject constructor(
@ApplicationContext context: Context,
) : ShakeDetector.Listener {
private var sensorManager = context.getSystemService<SensorManager>()
private var shakeDetector: ShakeDetector? = null
var interceptor: (() -> Unit)? = null
interface RageShake {
/**
* Check if the feature is available on this device.
*/
fun isAvailable(): Boolean {
return sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null
}
fun isAvailable(): Boolean
fun start(sensitivity: Float) {
sensorManager?.let {
shakeDetector = ShakeDetector(this).apply {
start(it, SensorManager.SENSOR_DELAY_GAME)
}
setSensitivity(sensitivity)
}
}
fun start(sensitivity: Float)
fun stop() {
shakeDetector?.stop()
}
fun stop()
/**
* sensitivity will be {0, O.25, 0.5, 0.75, 1} and converted to
* [ShakeDetector.SENSITIVITY_LIGHT (=11), ShakeDetector.SENSITIVITY_HARD (=15)].
*/
fun setSensitivity(sensitivity: Float) {
shakeDetector?.setSensitivity(
ShakeDetector.SENSITIVITY_LIGHT + (sensitivity * 4).toInt()
)
}
fun setSensitivity(sensitivity: Float)
override fun hearShake() {
interceptor?.invoke()
}
fun setInterceptor(interceptor: (() -> Unit)?)
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* Copyright (c) 2023 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.
@ -16,54 +16,16 @@
package io.element.android.features.rageshake.rageshake
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.di.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_rageshake")
interface RageshakeDataStore {
fun isEnabled(): Flow<Boolean>
private val enabledKey = booleanPreferencesKey("enabled")
private val sensitivityKey = floatPreferencesKey("sensitivity")
suspend fun setIsEnabled(isEnabled: Boolean)
class RageshakeDataStore @Inject constructor(
@ApplicationContext context: Context
) {
private val store = context.dataStore
fun sensitivity(): Flow<Float>
fun isEnabled(): Flow<Boolean> {
return store.data.map { prefs ->
prefs[enabledKey].orTrue()
}
}
suspend fun setSensitivity(sensitivity: Float)
suspend fun setIsEnabled(isEnabled: Boolean) {
store.edit { prefs ->
prefs[enabledKey] = isEnabled
}
}
fun sensitivity(): Flow<Float> {
return store.data.map { prefs ->
prefs[sensitivityKey] ?: 0.5f
}
}
suspend fun setSensitivity(sensitivity: Float) {
store.edit { prefs ->
prefs[sensitivityKey] = sensitivity
}
}
suspend fun reset() {
store.edit { it.clear() }
}
suspend fun reset()
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* Copyright (c) 2023 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.
@ -16,126 +16,9 @@
package io.element.android.features.rageshake.reporter
import android.content.Context
import android.os.Build
import io.element.android.features.rageshake.R
import io.element.android.features.rageshake.crash.CrashDataStore
import io.element.android.features.rageshake.logs.VectorFileLogger
import io.element.android.features.rageshake.screenshot.ScreenshotHolder
import io.element.android.libraries.androidutils.file.compressFile
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.toOnOff
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.Response
import org.json.JSONException
import org.json.JSONObject
import timber.log.Timber
import java.io.File
import java.io.IOException
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.util.Locale
import javax.inject.Inject
/**
* BugReporter creates and sends the bug reports.
*/
class BugReporter @Inject constructor(
@ApplicationContext private val context: Context,
private val screenshotHolder: ScreenshotHolder,
private val crashDataStore: CrashDataStore,
private val coroutineDispatchers: CoroutineDispatchers,
/*
private val activeSessionHolder: ActiveSessionHolder,
private val versionProvider: VersionProvider,
private val vectorPreferences: VectorPreferences,
private val vectorFileLogger: VectorFileLogger,
private val systemLocaleProvider: SystemLocaleProvider,
private val matrix: Matrix,
private val buildMeta: BuildMeta,
private val processInfo: ProcessInfo,
private val sdkIntProvider: BuildVersionSdkIntProvider,
private val vectorLocale: VectorLocaleProvider,
*/
) {
var inMultiWindowMode = false
companion object {
// filenames
private const val LOG_CAT_ERROR_FILENAME = "logcatError.log"
private const val LOG_CAT_FILENAME = "logcat.log"
private const val KEY_REQUESTS_FILENAME = "keyRequests.log"
private const val BUFFER_SIZE = 1024 * 1024 * 50
}
// the http client
private val mOkHttpClient = OkHttpClient()
// the pending bug report call
private var mBugReportCall: Call? = null
// boolean to cancel the bug report
private val mIsCancelled = false
/*
val adapter = MatrixJsonParser.getMoshi()
.adapter<JsonDict>(Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java))
*/
private val LOGCAT_CMD_ERROR = arrayOf(
"logcat", // /< Run 'logcat' command
"-d", // /< Dump the log rather than continue outputting it
"-v", // formatting
"threadtime", // include timestamps
"AndroidRuntime:E " + // /< Pick all AndroidRuntime errors (such as uncaught exceptions)"communicatorjni:V " + ///< All communicatorjni logging
"libcommunicator:V " + // /< All libcommunicator logging
"DEBUG:V " + // /< All DEBUG logging - which includes native land crashes (seg faults, etc)
"*:S" // /< Everything else silent, so don't pick it..
)
private val LOGCAT_CMD_DEBUG = arrayOf("logcat", "-d", "-v", "threadtime", "*:*")
/**
* Bug report upload listener.
*/
interface IMXBugReportListener {
/**
* The bug report has been cancelled.
*/
fun onUploadCancelled()
/**
* The bug report upload failed.
*
* @param reason the failure reason
*/
fun onUploadFailed(reason: String?)
/**
* The upload progress (in percent).
*
* @param progress the upload progress
*/
fun onProgress(progress: Int)
/**
* The bug report upload succeeded.
*/
fun onUploadSucceed(reportUrl: String?)
}
interface BugReporter {
/**
* Send a bug report.
*
@ -162,388 +45,6 @@ class BugReporter @Inject constructor(
serverVersion: String,
canContact: Boolean = false,
customFields: Map<String, String>? = null,
listener: IMXBugReportListener?
) {
// enumerate files to delete
val mBugReportFiles: MutableList<File> = ArrayList()
coroutineScope.launch {
var serverError: String? = null
var reportURL: String? = null
withContext(coroutineDispatchers.io) {
var bugDescription = theBugDescription
val crashCallStack = crashDataStore.crashInfo().first()
if (crashCallStack.isNotEmpty() && withCrashLogs) {
bugDescription += "\n\n\n\n--------------------------------- crash call stack ---------------------------------\n"
bugDescription += crashCallStack
}
val gzippedFiles = ArrayList<File>()
val vectorFileLogger = VectorFileLogger.getFromTimber()
if (withDevicesLogs) {
val files = vectorFileLogger.getLogFiles()
files.mapNotNullTo(gzippedFiles) { f ->
if (!mIsCancelled) {
compressFile(f)
} else {
null
}
}
}
if (!mIsCancelled && (withCrashLogs || withDevicesLogs)) {
val gzippedLogcat = saveLogCat(false)
if (null != gzippedLogcat) {
if (gzippedFiles.size == 0) {
gzippedFiles.add(gzippedLogcat)
} else {
gzippedFiles.add(0, gzippedLogcat)
}
}
}
/*
activeSessionHolder.getSafeActiveSession()
?.takeIf { !mIsCancelled && withKeyRequestHistory }
?.cryptoService()
?.getGossipingEvents()
?.let { GossipingEventsSerializer().serialize(it) }
?.toByteArray()
?.let { rawByteArray ->
File(context.cacheDir.absolutePath, KEY_REQUESTS_FILENAME)
.also {
it.outputStream()
.use { os -> os.write(rawByteArray) }
}
}
?.let { compressFile(it) }
?.let { gzippedFiles.add(it) }
*/
var deviceId = "undefined"
var userId = "undefined"
var olmVersion = "undefined"
/*
activeSessionHolder.getSafeActiveSession()?.let { session ->
userId = session.myUserId
deviceId = session.sessionParams.deviceId ?: "undefined"
olmVersion = session.cryptoService().getCryptoVersion(context, true)
}
*/
if (!mIsCancelled) {
val text = when (reportType) {
ReportType.BUG_REPORT -> bugDescription
ReportType.SUGGESTION -> "[Suggestion] $bugDescription"
ReportType.SPACE_BETA_FEEDBACK -> "[spaces-feedback] $bugDescription"
ReportType.THREADS_BETA_FEEDBACK -> "[threads-feedback] $bugDescription"
ReportType.AUTO_UISI_SENDER,
ReportType.AUTO_UISI -> bugDescription
}
// build the multi part request
val builder = BugReporterMultipartBody.Builder()
.addFormDataPart("text", text)
.addFormDataPart("app", rageShakeAppNameForReport(reportType))
// .addFormDataPart("user_agent", matrix.getUserAgent())
.addFormDataPart("user_id", userId)
.addFormDataPart("can_contact", canContact.toString())
.addFormDataPart("device_id", deviceId)
// .addFormDataPart("version", versionProvider.getVersion(longFormat = true))
// .addFormDataPart("branch_name", buildMeta.gitBranchName)
// .addFormDataPart("matrix_sdk_version", Matrix.getSdkVersion())
.addFormDataPart("olm_version", olmVersion)
.addFormDataPart("device", Build.MODEL.trim())
// .addFormDataPart("verbose_log", vectorPreferences.labAllowedExtendedLogging().toOnOff())
.addFormDataPart("multi_window", inMultiWindowMode.toOnOff())
// .addFormDataPart(
// "os", Build.VERSION.RELEASE + " (API " + sdkIntProvider.get() + ") " +
// Build.VERSION.INCREMENTAL + "-" + Build.VERSION.CODENAME
// )
.addFormDataPart("locale", Locale.getDefault().toString())
// .addFormDataPart("app_language", vectorLocale.applicationLocale.toString())
// .addFormDataPart("default_app_language", systemLocaleProvider.getSystemLocale().toString())
// .addFormDataPart("theme", ThemeUtils.getApplicationTheme(context))
.addFormDataPart("server_version", serverVersion)
.apply {
customFields?.forEach { (name, value) ->
addFormDataPart(name, value)
}
}
// add the gzipped files
for (file in gzippedFiles) {
builder.addFormDataPart("compressed-log", file.name, file.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull()))
}
mBugReportFiles.addAll(gzippedFiles)
if (withScreenshot) {
screenshotHolder.getFile()?.let { screenshotFile ->
try {
builder.addFormDataPart(
"file",
screenshotFile.name, screenshotFile.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull())
)
} catch (e: Exception) {
Timber.e(e, "## sendBugReport() : fail to write screenshot")
}
}
}
// add some github labels
// builder.addFormDataPart("label", buildMeta.versionName)
// builder.addFormDataPart("label", buildMeta.flavorDescription)
// builder.addFormDataPart("label", buildMeta.gitBranchName)
// Possible values for BuildConfig.BUILD_TYPE: "debug", "nightly", "release".
// builder.addFormDataPart("label", BuildConfig.BUILD_TYPE)
when (reportType) {
ReportType.BUG_REPORT -> {
/* nop */
}
ReportType.SUGGESTION -> builder.addFormDataPart("label", "[Suggestion]")
ReportType.SPACE_BETA_FEEDBACK -> builder.addFormDataPart("label", "spaces-feedback")
ReportType.THREADS_BETA_FEEDBACK -> builder.addFormDataPart("label", "threads-feedback")
ReportType.AUTO_UISI -> {
builder.addFormDataPart("label", "Z-UISI")
builder.addFormDataPart("label", "android")
builder.addFormDataPart("label", "uisi-recipient")
}
ReportType.AUTO_UISI_SENDER -> {
builder.addFormDataPart("label", "Z-UISI")
builder.addFormDataPart("label", "android")
builder.addFormDataPart("label", "uisi-sender")
}
}
if (crashCallStack.isNotEmpty() && withCrashLogs) {
builder.addFormDataPart("label", "crash")
}
val requestBody = builder.build()
// add a progress listener
requestBody.setWriteListener { totalWritten, contentLength ->
val percentage = if (-1L != contentLength) {
if (totalWritten > contentLength) {
100
} else {
(totalWritten * 100 / contentLength).toInt()
}
} else {
0
}
if (mIsCancelled && null != mBugReportCall) {
mBugReportCall!!.cancel()
}
Timber.v("## onWrite() : $percentage%")
try {
listener?.onProgress(percentage)
} catch (e: Exception) {
Timber.e(e, "## onProgress() : failed")
}
}
// build the request
val request = Request.Builder()
.url(context.getString(R.string.bug_report_url))
.post(requestBody)
.build()
var responseCode = HttpURLConnection.HTTP_INTERNAL_ERROR
var response: Response? = null
var errorMessage: String? = null
// trigger the request
try {
mBugReportCall = mOkHttpClient.newCall(request)
response = mBugReportCall!!.execute()
responseCode = response.code
} catch (e: Exception) {
Timber.e(e, "response")
errorMessage = e.localizedMessage
}
// if the upload failed, try to retrieve the reason
if (responseCode != HttpURLConnection.HTTP_OK) {
if (null != errorMessage) {
serverError = "Failed with error $errorMessage"
} else if (response?.body == null) {
serverError = "Failed with error $responseCode"
} else {
try {
val inputStream = response.body!!.byteStream()
serverError = inputStream.use {
buildString {
var ch = it.read()
while (ch != -1) {
append(ch.toChar())
ch = it.read()
}
}
}
// check if the error message
serverError?.let {
try {
val responseJSON = JSONObject(it)
serverError = responseJSON.getString("error")
} catch (e: JSONException) {
Timber.e(e, "doInBackground ; Json conversion failed")
}
}
// should never happen
if (null == serverError) {
serverError = "Failed with error $responseCode"
}
} catch (e: Exception) {
Timber.e(e, "## sendBugReport() : failed to parse error")
}
}
} else {
/*
reportURL = response?.body?.string()?.let { stringBody ->
adapter.fromJson(stringBody)?.get("report_url")?.toString()
}
*/
}
}
}
withContext(coroutineDispatchers.main) {
mBugReportCall = null
// delete when the bug report has been successfully sent
for (file in mBugReportFiles) {
file.safeDelete()
}
if (null != listener) {
try {
if (mIsCancelled) {
listener.onUploadCancelled()
} else if (null == serverError) {
listener.onUploadSucceed(reportURL)
} else {
listener.onUploadFailed(serverError)
}
} catch (e: Exception) {
Timber.e(e, "## onPostExecute() : failed")
}
}
}
}
}
/**
* Send a bug report either with email or with Vector.
*/
/* TODO Remove
fun openBugReportScreen(activity: FragmentActivity, reportType: ReportType = ReportType.BUG_REPORT) {
screenshot = takeScreenshot(activity)
logDbInfo()
logProcessInfo()
logOtherInfo()
activity.startActivity(BugReportActivity.intent(activity, reportType))
}
*/
// private fun logOtherInfo() {
// Timber.i("SyncThread state: " + activeSessionHolder.getSafeActiveSession()?.syncService()?.getSyncState())
// }
// private fun logDbInfo() {
// val dbInfo = matrix.debugService().getDbUsageInfo()
// Timber.i(dbInfo)
// }
// private fun logProcessInfo() {
// val pInfo = processInfo.getInfo()
// Timber.i(pInfo)
// }
private fun rageShakeAppNameForReport(reportType: ReportType): String {
// As per https://github.com/matrix-org/rageshake
// app: Identifier for the application (eg 'riot-web').
// Should correspond to a mapping configured in the configuration file for github issue reporting to work.
// (see R.string.bug_report_url for configured RS server)
return context.getString(
when (reportType) {
ReportType.AUTO_UISI_SENDER,
ReportType.AUTO_UISI -> R.string.bug_report_auto_uisi_app_name
else -> R.string.bug_report_app_name
}
)
}
// ==============================================================================================================
// Logcat management
// ==============================================================================================================
/**
* Save the logcat.
*
* @param isErrorLogcat true to save the error logcat
* @return the file if the operation succeeds
*/
private fun saveLogCat(isErrorLogcat: Boolean): File? {
val logCatErrFile = File(context.cacheDir.absolutePath, if (isErrorLogcat) LOG_CAT_ERROR_FILENAME else LOG_CAT_FILENAME)
if (logCatErrFile.exists()) {
logCatErrFile.safeDelete()
}
try {
logCatErrFile.writer().use {
getLogCatError(it, isErrorLogcat)
}
return compressFile(logCatErrFile)
} catch (error: OutOfMemoryError) {
Timber.e(error, "## saveLogCat() : fail to write logcat$error")
} catch (e: Exception) {
Timber.e(e, "## saveLogCat() : fail to write logcat$e")
}
return null
}
/**
* Retrieves the logs.
*
* @param streamWriter the stream writer
* @param isErrorLogCat true to save the error logs
*/
private fun getLogCatError(streamWriter: OutputStreamWriter, isErrorLogCat: Boolean) {
val logcatProc: Process
try {
logcatProc = Runtime.getRuntime().exec(if (isErrorLogCat) LOGCAT_CMD_ERROR else LOGCAT_CMD_DEBUG)
} catch (e1: IOException) {
return
}
try {
val separator = System.getProperty("line.separator")
logcatProc.inputStream
.reader()
.buffered(BUFFER_SIZE)
.forEachLine { line ->
streamWriter.append(line)
streamWriter.append(separator)
}
} catch (e: IOException) {
Timber.e(e, "getLog fails")
}
}
listener: BugReporterListener?
)
}

View file

@ -0,0 +1,46 @@
/*
* Copyright (c) 2023 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.features.rageshake.reporter
/**
* Bug report upload listener.
*/
interface BugReporterListener {
/**
* The bug report has been cancelled.
*/
fun onUploadCancelled()
/**
* The bug report upload failed.
*
* @param reason the failure reason
*/
fun onUploadFailed(reason: String?)
/**
* The upload progress (in percent).
*
* @param progress the upload progress
*/
fun onProgress(progress: Int)
/**
* The bug report upload succeeded.
*/
fun onUploadSucceed(reportUrl: String?)
}

View file

@ -0,0 +1,525 @@
/*
* Copyright (c) 2022 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.features.rageshake.reporter
import android.content.Context
import android.os.Build
import androidx.core.net.toFile
import androidx.core.net.toUri
import io.element.android.features.rageshake.R
import io.element.android.features.rageshake.crash.CrashDataStore
import io.element.android.features.rageshake.logs.VectorFileLogger
import io.element.android.features.rageshake.screenshot.ScreenshotHolder
import io.element.android.libraries.androidutils.file.compressFile
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.toOnOff
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.Response
import org.json.JSONException
import org.json.JSONObject
import timber.log.Timber
import java.io.File
import java.io.IOException
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.util.Locale
import javax.inject.Inject
/**
* BugReporter creates and sends the bug reports.
*/
class DefaultBugReporter @Inject constructor(
@ApplicationContext private val context: Context,
private val screenshotHolder: ScreenshotHolder,
private val crashDataStore: CrashDataStore,
private val coroutineDispatchers: CoroutineDispatchers,
/*
private val activeSessionHolder: ActiveSessionHolder,
private val versionProvider: VersionProvider,
private val vectorPreferences: VectorPreferences,
private val vectorFileLogger: VectorFileLogger,
private val systemLocaleProvider: SystemLocaleProvider,
private val matrix: Matrix,
private val buildMeta: BuildMeta,
private val processInfo: ProcessInfo,
private val sdkIntProvider: BuildVersionSdkIntProvider,
private val vectorLocale: VectorLocaleProvider,
*/
) : BugReporter {
var inMultiWindowMode = false
companion object {
// filenames
private const val LOG_CAT_ERROR_FILENAME = "logcatError.log"
private const val LOG_CAT_FILENAME = "logcat.log"
private const val KEY_REQUESTS_FILENAME = "keyRequests.log"
private const val BUFFER_SIZE = 1024 * 1024 * 50
}
// the http client
private val mOkHttpClient = OkHttpClient()
// the pending bug report call
private var mBugReportCall: Call? = null
// boolean to cancel the bug report
private val mIsCancelled = false
/*
val adapter = MatrixJsonParser.getMoshi()
.adapter<JsonDict>(Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java))
*/
private val LOGCAT_CMD_ERROR = arrayOf(
"logcat", // /< Run 'logcat' command
"-d", // /< Dump the log rather than continue outputting it
"-v", // formatting
"threadtime", // include timestamps
"AndroidRuntime:E " + // /< Pick all AndroidRuntime errors (such as uncaught exceptions)"communicatorjni:V " + ///< All communicatorjni logging
"libcommunicator:V " + // /< All libcommunicator logging
"DEBUG:V " + // /< All DEBUG logging - which includes native land crashes (seg faults, etc)
"*:S" // /< Everything else silent, so don't pick it..
)
private val LOGCAT_CMD_DEBUG = arrayOf("logcat", "-d", "-v", "threadtime", "*:*")
/**
* Send a bug report.
*
* @param coroutineScope The coroutine scope
* @param reportType The report type (bug, suggestion, feedback)
* @param withDevicesLogs true to include the device log
* @param withCrashLogs true to include the crash logs
* @param withKeyRequestHistory true to include the crash logs
* @param withScreenshot true to include the screenshot
* @param theBugDescription the bug description
* @param serverVersion version of the server
* @param canContact true if the user opt in to be contacted directly
* @param customFields fields which will be sent with the report
* @param listener the listener
*/
override fun sendBugReport(
coroutineScope: CoroutineScope,
reportType: ReportType,
withDevicesLogs: Boolean,
withCrashLogs: Boolean,
withKeyRequestHistory: Boolean,
withScreenshot: Boolean,
theBugDescription: String,
serverVersion: String,
canContact: Boolean,
customFields: Map<String, String>?,
listener: BugReporterListener?
) {
// enumerate files to delete
val mBugReportFiles: MutableList<File> = ArrayList()
coroutineScope.launch {
var serverError: String? = null
var reportURL: String? = null
withContext(coroutineDispatchers.io) {
var bugDescription = theBugDescription
val crashCallStack = crashDataStore.crashInfo().first()
if (crashCallStack.isNotEmpty() && withCrashLogs) {
bugDescription += "\n\n\n\n--------------------------------- crash call stack ---------------------------------\n"
bugDescription += crashCallStack
}
val gzippedFiles = ArrayList<File>()
val vectorFileLogger = VectorFileLogger.getFromTimber()
if (withDevicesLogs && vectorFileLogger != null) {
val files = vectorFileLogger.getLogFiles()
files.mapNotNullTo(gzippedFiles) { f ->
if (!mIsCancelled) {
compressFile(f)
} else {
null
}
}
}
if (!mIsCancelled && (withCrashLogs || withDevicesLogs)) {
val gzippedLogcat = saveLogCat(false)
if (null != gzippedLogcat) {
if (gzippedFiles.size == 0) {
gzippedFiles.add(gzippedLogcat)
} else {
gzippedFiles.add(0, gzippedLogcat)
}
}
}
/*
activeSessionHolder.getSafeActiveSession()
?.takeIf { !mIsCancelled && withKeyRequestHistory }
?.cryptoService()
?.getGossipingEvents()
?.let { GossipingEventsSerializer().serialize(it) }
?.toByteArray()
?.let { rawByteArray ->
File(context.cacheDir.absolutePath, KEY_REQUESTS_FILENAME)
.also {
it.outputStream()
.use { os -> os.write(rawByteArray) }
}
}
?.let { compressFile(it) }
?.let { gzippedFiles.add(it) }
*/
var deviceId = "undefined"
var userId = "undefined"
var olmVersion = "undefined"
/*
activeSessionHolder.getSafeActiveSession()?.let { session ->
userId = session.myUserId
deviceId = session.sessionParams.deviceId ?: "undefined"
olmVersion = session.cryptoService().getCryptoVersion(context, true)
}
*/
if (!mIsCancelled) {
val text = when (reportType) {
ReportType.BUG_REPORT -> bugDescription
ReportType.SUGGESTION -> "[Suggestion] $bugDescription"
ReportType.SPACE_BETA_FEEDBACK -> "[spaces-feedback] $bugDescription"
ReportType.THREADS_BETA_FEEDBACK -> "[threads-feedback] $bugDescription"
ReportType.AUTO_UISI_SENDER,
ReportType.AUTO_UISI -> bugDescription
}
// build the multi part request
val builder = BugReporterMultipartBody.Builder()
.addFormDataPart("text", text)
.addFormDataPart("app", rageShakeAppNameForReport(reportType))
// .addFormDataPart("user_agent", matrix.getUserAgent())
.addFormDataPart("user_id", userId)
.addFormDataPart("can_contact", canContact.toString())
.addFormDataPart("device_id", deviceId)
// .addFormDataPart("version", versionProvider.getVersion(longFormat = true))
// .addFormDataPart("branch_name", buildMeta.gitBranchName)
// .addFormDataPart("matrix_sdk_version", Matrix.getSdkVersion())
.addFormDataPart("olm_version", olmVersion)
.addFormDataPart("device", Build.MODEL.trim())
// .addFormDataPart("verbose_log", vectorPreferences.labAllowedExtendedLogging().toOnOff())
.addFormDataPart("multi_window", inMultiWindowMode.toOnOff())
// .addFormDataPart(
// "os", Build.VERSION.RELEASE + " (API " + sdkIntProvider.get() + ") " +
// Build.VERSION.INCREMENTAL + "-" + Build.VERSION.CODENAME
// )
.addFormDataPart("locale", Locale.getDefault().toString())
// .addFormDataPart("app_language", vectorLocale.applicationLocale.toString())
// .addFormDataPart("default_app_language", systemLocaleProvider.getSystemLocale().toString())
// .addFormDataPart("theme", ThemeUtils.getApplicationTheme(context))
.addFormDataPart("server_version", serverVersion)
.apply {
customFields?.forEach { (name, value) ->
addFormDataPart(name, value)
}
}
// add the gzipped files
for (file in gzippedFiles) {
builder.addFormDataPart("compressed-log", file.name, file.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull()))
}
mBugReportFiles.addAll(gzippedFiles)
if (withScreenshot) {
screenshotHolder.getFileUri()
?.toUri()
?.toFile()
?.let { screenshotFile ->
try {
builder.addFormDataPart(
"file",
screenshotFile.name, screenshotFile.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull())
)
} catch (e: Exception) {
Timber.e(e, "## sendBugReport() : fail to write screenshot")
}
}
}
// add some github labels
// builder.addFormDataPart("label", buildMeta.versionName)
// builder.addFormDataPart("label", buildMeta.flavorDescription)
// builder.addFormDataPart("label", buildMeta.gitBranchName)
// Possible values for BuildConfig.BUILD_TYPE: "debug", "nightly", "release".
// builder.addFormDataPart("label", BuildConfig.BUILD_TYPE)
when (reportType) {
ReportType.BUG_REPORT -> {
/* nop */
}
ReportType.SUGGESTION -> builder.addFormDataPart("label", "[Suggestion]")
ReportType.SPACE_BETA_FEEDBACK -> builder.addFormDataPart("label", "spaces-feedback")
ReportType.THREADS_BETA_FEEDBACK -> builder.addFormDataPart("label", "threads-feedback")
ReportType.AUTO_UISI -> {
builder.addFormDataPart("label", "Z-UISI")
builder.addFormDataPart("label", "android")
builder.addFormDataPart("label", "uisi-recipient")
}
ReportType.AUTO_UISI_SENDER -> {
builder.addFormDataPart("label", "Z-UISI")
builder.addFormDataPart("label", "android")
builder.addFormDataPart("label", "uisi-sender")
}
}
if (crashCallStack.isNotEmpty() && withCrashLogs) {
builder.addFormDataPart("label", "crash")
}
val requestBody = builder.build()
// add a progress listener
requestBody.setWriteListener { totalWritten, contentLength ->
val percentage = if (-1L != contentLength) {
if (totalWritten > contentLength) {
100
} else {
(totalWritten * 100 / contentLength).toInt()
}
} else {
0
}
if (mIsCancelled && null != mBugReportCall) {
mBugReportCall!!.cancel()
}
Timber.v("## onWrite() : $percentage%")
try {
listener?.onProgress(percentage)
} catch (e: Exception) {
Timber.e(e, "## onProgress() : failed")
}
}
// build the request
val request = Request.Builder()
.url(context.getString(R.string.bug_report_url))
.post(requestBody)
.build()
var responseCode = HttpURLConnection.HTTP_INTERNAL_ERROR
var response: Response? = null
var errorMessage: String? = null
// trigger the request
try {
mBugReportCall = mOkHttpClient.newCall(request)
response = mBugReportCall!!.execute()
responseCode = response.code
} catch (e: Exception) {
Timber.e(e, "response")
errorMessage = e.localizedMessage
}
// if the upload failed, try to retrieve the reason
if (responseCode != HttpURLConnection.HTTP_OK) {
if (null != errorMessage) {
serverError = "Failed with error $errorMessage"
} else if (response?.body == null) {
serverError = "Failed with error $responseCode"
} else {
try {
val inputStream = response.body!!.byteStream()
serverError = inputStream.use {
buildString {
var ch = it.read()
while (ch != -1) {
append(ch.toChar())
ch = it.read()
}
}
}
// check if the error message
serverError?.let {
try {
val responseJSON = JSONObject(it)
serverError = responseJSON.getString("error")
} catch (e: JSONException) {
Timber.e(e, "doInBackground ; Json conversion failed")
}
}
// should never happen
if (null == serverError) {
serverError = "Failed with error $responseCode"
}
} catch (e: Exception) {
Timber.e(e, "## sendBugReport() : failed to parse error")
}
}
} else {
/*
reportURL = response?.body?.string()?.let { stringBody ->
adapter.fromJson(stringBody)?.get("report_url")?.toString()
}
*/
}
}
}
withContext(coroutineDispatchers.main) {
mBugReportCall = null
// delete when the bug report has been successfully sent
for (file in mBugReportFiles) {
file.safeDelete()
}
if (null != listener) {
try {
if (mIsCancelled) {
listener.onUploadCancelled()
} else if (null == serverError) {
listener.onUploadSucceed(reportURL)
} else {
listener.onUploadFailed(serverError)
}
} catch (e: Exception) {
Timber.e(e, "## onPostExecute() : failed")
}
}
}
}
}
/**
* Send a bug report either with email or with Vector.
*/
/* TODO Remove
fun openBugReportScreen(activity: FragmentActivity, reportType: ReportType = ReportType.BUG_REPORT) {
screenshot = takeScreenshot(activity)
logDbInfo()
logProcessInfo()
logOtherInfo()
activity.startActivity(BugReportActivity.intent(activity, reportType))
}
*/
// private fun logOtherInfo() {
// Timber.i("SyncThread state: " + activeSessionHolder.getSafeActiveSession()?.syncService()?.getSyncState())
// }
// private fun logDbInfo() {
// val dbInfo = matrix.debugService().getDbUsageInfo()
// Timber.i(dbInfo)
// }
// private fun logProcessInfo() {
// val pInfo = processInfo.getInfo()
// Timber.i(pInfo)
// }
private fun rageShakeAppNameForReport(reportType: ReportType): String {
// As per https://github.com/matrix-org/rageshake
// app: Identifier for the application (eg 'riot-web').
// Should correspond to a mapping configured in the configuration file for github issue reporting to work.
// (see R.string.bug_report_url for configured RS server)
return context.getString(
when (reportType) {
ReportType.AUTO_UISI_SENDER,
ReportType.AUTO_UISI -> R.string.bug_report_auto_uisi_app_name
else -> R.string.bug_report_app_name
}
)
}
// ==============================================================================================================
// Logcat management
// ==============================================================================================================
/**
* Save the logcat.
*
* @param isErrorLogcat true to save the error logcat
* @return the file if the operation succeeds
*/
private fun saveLogCat(isErrorLogcat: Boolean): File? {
val logCatErrFile = File(context.cacheDir.absolutePath, if (isErrorLogcat) LOG_CAT_ERROR_FILENAME else LOG_CAT_FILENAME)
if (logCatErrFile.exists()) {
logCatErrFile.safeDelete()
}
try {
logCatErrFile.writer().use {
getLogCatError(it, isErrorLogcat)
}
return compressFile(logCatErrFile)
} catch (error: OutOfMemoryError) {
Timber.e(error, "## saveLogCat() : fail to write logcat$error")
} catch (e: Exception) {
Timber.e(e, "## saveLogCat() : fail to write logcat$e")
}
return null
}
/**
* Retrieves the logs.
*
* @param streamWriter the stream writer
* @param isErrorLogCat true to save the error logs
*/
private fun getLogCatError(streamWriter: OutputStreamWriter, isErrorLogCat: Boolean) {
val logcatProc: Process
try {
logcatProc = Runtime.getRuntime().exec(if (isErrorLogCat) LOGCAT_CMD_ERROR else LOGCAT_CMD_DEBUG)
} catch (e1: IOException) {
return
}
try {
val separator = System.getProperty("line.separator")
logcatProc.inputStream
.reader()
.buffered(BUFFER_SIZE)
.forEachLine { line ->
streamWriter.append(line)
streamWriter.append(separator)
}
} catch (e: IOException) {
Timber.e(e, "getLog fails")
}
}
}

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2022 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.features.rageshake.screenshot
import android.content.Context
import android.graphics.Bitmap
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.androidutils.bitmap.writeBitmap
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import java.io.File
import javax.inject.Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultScreenshotHolder @Inject constructor(
@ApplicationContext private val context: Context,
) : ScreenshotHolder {
private val file = File(context.filesDir, "screenshot.png")
override fun writeBitmap(data: Bitmap) {
file.writeBitmap(data, Bitmap.CompressFormat.PNG, 85)
}
override fun getFileUri(): String? {
return file
.takeIf { it.exists() && it.length() > 0 }
?.toUri()
?.toString()
}
override fun reset() {
file.safeDelete()
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* Copyright (c) 2023 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.
@ -16,29 +16,10 @@
package io.element.android.features.rageshake.screenshot
import android.content.Context
import android.graphics.Bitmap
import io.element.android.libraries.androidutils.bitmap.writeBitmap
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import java.io.File
import javax.inject.Inject
@SingleIn(AppScope::class)
class ScreenshotHolder @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val file = File(context.filesDir, "screenshot.png")
fun writeBitmap(data: Bitmap) {
file.writeBitmap(data, Bitmap.CompressFormat.PNG, 85)
}
fun getFile() = file.takeIf { it.exists() && it.length() > 0 }
fun reset() {
file.safeDelete()
}
interface ScreenshotHolder {
fun writeBitmap(data: Bitmap)
fun getFileUri(): String?
fun reset()
}

View file

@ -0,0 +1,249 @@
/*
* Copyright (c) 2023 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.rageshake.bugreport
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.crash.ui.A_CRASH_DATA
import io.element.android.features.rageshake.crash.ui.FakeCrashDataStore
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrixtest.A_FAILURE_REASON
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
const val A_SHORT_DESCRIPTION = "bug!"
const val A_LONG_DESCRIPTION = "I have seen a bug!"
class BugReportPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = BugReportPresenter(
FakeBugReporter(),
FakeCrashDataStore(),
FakeScreenshotHolder(),
this,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.hasCrashLogs).isFalse()
assertThat(initialState.formState).isEqualTo(BugReportFormState.Default)
assertThat(initialState.sending).isEqualTo(Async.Uninitialized)
assertThat(initialState.screenshotUri).isNull()
assertThat(initialState.sendingProgress).isEqualTo(0f)
assertThat(initialState.submitEnabled).isFalse()
}
}
@Test
fun `present - set description`() = runTest {
val presenter = BugReportPresenter(
FakeBugReporter(),
FakeCrashDataStore(),
FakeScreenshotHolder(),
this,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(BugReportEvents.SetDescription(A_SHORT_DESCRIPTION))
assertThat(awaitItem().submitEnabled).isFalse()
initialState.eventSink.invoke(BugReportEvents.SetDescription(A_LONG_DESCRIPTION))
assertThat(awaitItem().submitEnabled).isTrue()
}
}
@Test
fun `present - can contact`() = runTest {
val presenter = BugReportPresenter(
FakeBugReporter(),
FakeCrashDataStore(),
FakeScreenshotHolder(),
this,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(BugReportEvents.SetCanContact(true))
assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(canContact = true))
initialState.eventSink.invoke(BugReportEvents.SetCanContact(false))
assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(canContact = false))
}
}
@Test
fun `present - send crash logs`() = runTest {
val presenter = BugReportPresenter(
FakeBugReporter(),
FakeCrashDataStore(),
FakeScreenshotHolder(),
this,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Since this is true by default, start by disabling
initialState.eventSink.invoke(BugReportEvents.SetSendCrashLog(false))
assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendCrashLogs = false))
initialState.eventSink.invoke(BugReportEvents.SetSendCrashLog(true))
assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendCrashLogs = true))
}
}
@Test
fun `present - send logs`() = runTest {
val presenter = BugReportPresenter(
FakeBugReporter(),
FakeCrashDataStore(),
FakeScreenshotHolder(),
this,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Since this is true by default, start by disabling
initialState.eventSink.invoke(BugReportEvents.SetSendLog(false))
assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendLogs = false))
initialState.eventSink.invoke(BugReportEvents.SetSendLog(true))
assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendLogs = true))
}
}
@Test
fun `present - send screenshot`() = runTest {
val presenter = BugReportPresenter(
FakeBugReporter(),
FakeCrashDataStore(),
FakeScreenshotHolder(),
this,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(BugReportEvents.SetSendScreenshot(true))
assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendScreenshot = true))
initialState.eventSink.invoke(BugReportEvents.SetSendScreenshot(false))
assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendScreenshot = false))
}
}
@Test
fun `present - reset all`() = runTest {
val presenter = BugReportPresenter(
FakeBugReporter(),
FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true),
FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI),
this,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.hasCrashLogs).isTrue()
assertThat(initialState.screenshotUri).isEqualTo(A_SCREENSHOT_URI)
initialState.eventSink.invoke(BugReportEvents.ResetAll)
val resetState = awaitItem()
assertThat(resetState.hasCrashLogs).isFalse()
// TODO Make it live assertThat(resetState.screenshotUri).isNull()
}
}
@Test
fun `present - send success`() = runTest {
val presenter = BugReportPresenter(
FakeBugReporter(mode = FakeBugReporterMode.Success),
FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true),
FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI),
this,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(BugReportEvents.SendBugReport)
skipItems(1)
val progressState = awaitItem()
assertThat(progressState.sending).isEqualTo(Async.Loading(null))
assertThat(progressState.sendingProgress).isEqualTo(0f)
assertThat(progressState.submitEnabled).isFalse()
assertThat(awaitItem().sendingProgress).isEqualTo(0.5f)
assertThat(awaitItem().sendingProgress).isEqualTo(1f)
skipItems(1)
assertThat(awaitItem().sending).isEqualTo(Async.Success(Unit))
}
}
@Test
fun `present - send failure`() = runTest {
val presenter = BugReportPresenter(
FakeBugReporter(mode = FakeBugReporterMode.Failure),
FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true),
FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI),
this,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(BugReportEvents.SendBugReport)
skipItems(1)
val progressState = awaitItem()
assertThat(progressState.sending).isEqualTo(Async.Loading(null))
assertThat(progressState.sendingProgress).isEqualTo(0f)
assertThat(awaitItem().sendingProgress).isEqualTo(0.5f)
// Failure
assertThat(awaitItem().sendingProgress).isEqualTo(0f)
assertThat((awaitItem().sending as Async.Failure).error.message).isEqualTo(A_FAILURE_REASON)
}
}
@Test
fun `present - send cancel`() = runTest {
val presenter = BugReportPresenter(
FakeBugReporter(mode = FakeBugReporterMode.Cancel),
FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true),
FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI),
this,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(BugReportEvents.SendBugReport)
skipItems(1)
val progressState = awaitItem()
assertThat(progressState.sending).isEqualTo(Async.Loading(null))
assertThat(progressState.sendingProgress).isEqualTo(0f)
assertThat(awaitItem().sendingProgress).isEqualTo(0.5f)
// Cancelled
assertThat(awaitItem().sendingProgress).isEqualTo(0f)
assertThat(awaitItem().sending).isEqualTo(Async.Uninitialized)
}
}
}

View file

@ -0,0 +1,69 @@
/*
* Copyright (c) 2023 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.features.rageshake.bugreport
import io.element.android.features.rageshake.reporter.BugReporter
import io.element.android.features.rageshake.reporter.BugReporterListener
import io.element.android.features.rageshake.reporter.ReportType
import io.element.android.libraries.matrixtest.A_FAILURE_REASON
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Success) : BugReporter {
override fun sendBugReport(
coroutineScope: CoroutineScope,
reportType: ReportType,
withDevicesLogs: Boolean,
withCrashLogs: Boolean,
withKeyRequestHistory: Boolean,
withScreenshot: Boolean,
theBugDescription: String,
serverVersion: String,
canContact: Boolean,
customFields: Map<String, String>?,
listener: BugReporterListener?,
) {
coroutineScope.launch {
delay(100)
listener?.onProgress(0)
delay(100)
listener?.onProgress(50)
delay(100)
when (mode) {
FakeBugReporterMode.Success -> Unit
FakeBugReporterMode.Failure -> {
listener?.onUploadFailed(A_FAILURE_REASON)
return@launch
}
FakeBugReporterMode.Cancel -> {
listener?.onUploadCancelled()
return@launch
}
}
listener?.onProgress(100)
delay(100)
listener?.onUploadSucceed(null)
}
}
}
enum class FakeBugReporterMode {
Success,
Failure,
Cancel
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* Copyright (c) 2023 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.
@ -14,19 +14,17 @@
* limitations under the License.
*/
package io.element.android.features.messages
package io.element.android.features.rageshake.bugreport
import org.junit.Assert.assertEquals
import org.junit.Test
import android.graphics.Bitmap
import io.element.android.features.rageshake.screenshot.ScreenshotHolder
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
const val A_SCREENSHOT_URI = "file://content/uri"
class FakeScreenshotHolder(private val screenshotUri: String? = null) : ScreenshotHolder {
override fun writeBitmap(data: Bitmap) = Unit
override fun getFileUri() = screenshotUri
override fun reset() = Unit
}

View file

@ -0,0 +1,89 @@
/*
* Copyright (c) 2023 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.rageshake.crash.ui
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
class CrashDetectionPresenterTest {
@Test
fun `present - initial state no crash`() = runTest {
val presenter = CrashDetectionPresenter(
FakeCrashDataStore()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.crashDetected).isFalse()
}
}
@Test
fun `present - initial state crash`() = runTest {
val presenter = CrashDetectionPresenter(
FakeCrashDataStore(appHasCrashed = true)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.crashDetected).isTrue()
}
}
@Test
fun `present - reset app has crashed`() = runTest {
val presenter = CrashDetectionPresenter(
FakeCrashDataStore(appHasCrashed = true)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.crashDetected).isTrue()
initialState.eventSink.invoke(CrashDetectionEvents.ResetAppHasCrashed)
assertThat(awaitItem().crashDetected).isFalse()
}
}
@Test
fun `present - reset all crash data`() = runTest {
val presenter = CrashDetectionPresenter(
FakeCrashDataStore(appHasCrashed = true, crashData = A_CRASH_DATA)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.crashDetected).isTrue()
initialState.eventSink.invoke(CrashDetectionEvents.ResetAllCrashData)
assertThat(awaitItem().crashDetected).isFalse()
}
}
}

View file

@ -0,0 +1,48 @@
/*
* Copyright (c) 2023 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.features.rageshake.crash.ui
import io.element.android.features.rageshake.crash.CrashDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
const val A_CRASH_DATA = "Some crash data"
class FakeCrashDataStore(
crashData: String = "",
appHasCrashed: Boolean = false,
) : CrashDataStore {
private val appHasCrashedFlow = MutableStateFlow(appHasCrashed)
private val crashDataFlow = MutableStateFlow(crashData)
override fun setCrashData(crashData: String) {
crashDataFlow.value = crashData
}
override suspend fun resetAppHasCrashed() {
appHasCrashedFlow.value = false
}
override fun appHasCrashed(): Flow<Boolean> = appHasCrashedFlow
override fun crashInfo(): Flow<String> = crashDataFlow
override suspend fun reset() {
appHasCrashedFlow.value = false
crashDataFlow.value = ""
}
}

View file

@ -0,0 +1,192 @@
/*
* Copyright (c) 2023 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.rageshake.detection
import android.graphics.Bitmap
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.bugreport.FakeScreenshotHolder
import io.element.android.features.rageshake.preferences.FakeRageShake
import io.element.android.features.rageshake.preferences.FakeRageshakeDataStore
import io.element.android.features.rageshake.preferences.RageshakePreferencesPresenter
import io.element.android.features.rageshake.screenshot.ImageResult
import io.element.android.libraries.matrixtest.AN_EXCEPTION
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RageshakeDetectionPresenterTest {
@Test
fun `present - initial state`() = runTest {
val screenshotHolder = FakeScreenshotHolder(screenshotUri = null)
val rageshake = FakeRageShake(isAvailableValue = true)
val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true)
val presenter = RageshakeDetectionPresenter(
screenshotHolder = screenshotHolder,
rageShake = rageshake,
preferencesPresenter = RageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.takeScreenshot).isFalse()
assertThat(initialState.showDialog).isFalse()
assertThat(initialState.isStarted).isFalse()
}
}
@Test
fun `present - start and stop detection`() = runTest {
val screenshotHolder = FakeScreenshotHolder(screenshotUri = null)
val rageshake = FakeRageShake(isAvailableValue = true)
val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true)
val presenter = RageshakeDetectionPresenter(
screenshotHolder = screenshotHolder,
rageShake = rageshake,
preferencesPresenter = RageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(RageshakeDetectionEvents.StartDetection)
assertThat(awaitItem().isStarted).isTrue()
initialState.eventSink.invoke(RageshakeDetectionEvents.StopDetection)
assertThat(awaitItem().isStarted).isFalse()
}
}
@Test
fun `present - screenshot with success then dismiss`() = runTest {
val screenshotHolder = FakeScreenshotHolder(screenshotUri = null)
val rageshake = FakeRageShake(isAvailableValue = true)
val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true)
val presenter = RageshakeDetectionPresenter(
screenshotHolder = screenshotHolder,
rageShake = rageshake,
preferencesPresenter = RageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isStarted).isFalse()
initialState.eventSink.invoke(RageshakeDetectionEvents.StartDetection)
assertThat(awaitItem().isStarted).isTrue()
rageshake.triggerPhoneRageshake()
assertThat(awaitItem().takeScreenshot).isTrue()
initialState.eventSink.invoke(
RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Success(aBitmap()))
)
assertThat(awaitItem().showDialog).isTrue()
initialState.eventSink.invoke(RageshakeDetectionEvents.Dismiss)
val finalState = awaitItem()
assertThat(finalState.showDialog).isFalse()
assertThat(rageshakeDataStore.isEnabled().first()).isTrue()
}
}
@Test
fun `present - screenshot with error then dismiss`() = runTest {
val screenshotHolder = FakeScreenshotHolder(screenshotUri = null)
val rageshake = FakeRageShake(isAvailableValue = true)
val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true)
val presenter = RageshakeDetectionPresenter(
screenshotHolder = screenshotHolder,
rageShake = rageshake,
preferencesPresenter = RageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isStarted).isFalse()
initialState.eventSink.invoke(RageshakeDetectionEvents.StartDetection)
assertThat(awaitItem().isStarted).isTrue()
rageshake.triggerPhoneRageshake()
assertThat(awaitItem().takeScreenshot).isTrue()
initialState.eventSink.invoke(
RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Error(AN_EXCEPTION))
)
assertThat(awaitItem().showDialog).isTrue()
initialState.eventSink.invoke(RageshakeDetectionEvents.Dismiss)
val finalState = awaitItem()
assertThat(finalState.showDialog).isFalse()
assertThat(rageshakeDataStore.isEnabled().first()).isTrue()
}
}
@Test
fun `present - screenshot then disable`() = runTest {
val screenshotHolder = FakeScreenshotHolder(screenshotUri = null)
val rageshake = FakeRageShake(isAvailableValue = true)
val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true)
val presenter = RageshakeDetectionPresenter(
screenshotHolder = screenshotHolder,
rageShake = rageshake,
preferencesPresenter = RageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isStarted).isFalse()
initialState.eventSink.invoke(RageshakeDetectionEvents.StartDetection)
assertThat(awaitItem().isStarted).isTrue()
rageshake.triggerPhoneRageshake()
assertThat(awaitItem().takeScreenshot).isTrue()
initialState.eventSink.invoke(
RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Success(aBitmap()))
)
assertThat(awaitItem().showDialog).isTrue()
initialState.eventSink.invoke(RageshakeDetectionEvents.Disable)
skipItems(1)
assertThat(awaitItem().showDialog).isFalse()
assertThat(rageshakeDataStore.isEnabled().first()).isFalse()
}
}
}
private fun aBitmap(): Bitmap = mockk()

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2023 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.features.rageshake.preferences
import io.element.android.features.rageshake.rageshake.RageShake
const val A_SENSITIVITY = 1f
class FakeRageShake(
private var isAvailableValue: Boolean = true
) : RageShake {
private var interceptor: (() -> Unit)? = null
override fun isAvailable() = isAvailableValue
override fun start(sensitivity: Float) {
}
override fun stop() {
}
override fun setSensitivity(sensitivity: Float) {
}
override fun setInterceptor(interceptor: (() -> Unit)?) {
this.interceptor = interceptor
}
fun triggerPhoneRageshake() = interceptor?.invoke()
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2023 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.features.rageshake.preferences
import io.element.android.features.rageshake.rageshake.RageshakeDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakeRageshakeDataStore(
isEnabled: Boolean = true,
sensitivity: Float = A_SENSITIVITY,
) : RageshakeDataStore {
private val isEnabledFlow = MutableStateFlow(isEnabled)
override fun isEnabled(): Flow<Boolean> = isEnabledFlow
override suspend fun setIsEnabled(isEnabled: Boolean) {
isEnabledFlow.value = isEnabled
}
private val sensitivityFlow = MutableStateFlow(sensitivity)
override fun sensitivity(): Flow<Float> = sensitivityFlow
override suspend fun setSensitivity(sensitivity: Float) {
sensitivityFlow.value = sensitivity
}
override suspend fun reset() = Unit
}

View file

@ -0,0 +1,98 @@
/*
* Copyright (c) 2023 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.rageshake.preferences
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RageshakePreferencesPresenterTest {
@Test
fun `present - initial state available`() = runTest {
val presenter = RageshakePreferencesPresenter(
FakeRageShake(isAvailableValue = true),
FakeRageshakeDataStore(isEnabled = true)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isSupported).isTrue()
assertThat(initialState.isEnabled).isTrue()
}
}
@Test
fun `present - initial state not available`() = runTest {
val presenter = RageshakePreferencesPresenter(
FakeRageShake(isAvailableValue = false),
FakeRageshakeDataStore(isEnabled = true)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isSupported).isFalse()
assertThat(initialState.isEnabled).isTrue()
}
}
@Test
fun `present - enable and disable`() = runTest {
val presenter = RageshakePreferencesPresenter(
FakeRageShake(isAvailableValue = true),
FakeRageshakeDataStore(isEnabled = true)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isEnabled).isTrue()
initialState.eventSink.invoke(RageshakePreferencesEvents.SetIsEnabled(false))
assertThat(awaitItem().isEnabled).isFalse()
initialState.eventSink.invoke(RageshakePreferencesEvents.SetIsEnabled(true))
assertThat(awaitItem().isEnabled).isTrue()
}
}
@Test
fun `present - set sensitivity`() = runTest {
val presenter = RageshakePreferencesPresenter(
FakeRageShake(isAvailableValue = true),
FakeRageshakeDataStore(isEnabled = true)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.sensitivity).isEqualTo(A_SENSITIVITY)
initialState.eventSink.invoke(RageshakePreferencesEvents.SetSensitivity(A_SENSITIVITY + 1f))
assertThat(awaitItem().sensitivity).isEqualTo(A_SENSITIVITY + 1f)
}
}
}

View file

@ -41,7 +41,7 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings)
implementation(libs.datetime)
implementation(projects.libraries.dateformatter)
implementation(libs.accompanist.placeholder)
testImplementation(libs.test.junit)
@ -51,6 +51,8 @@ dependencies {
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrixtest)
testImplementation(testFixtures(projects.libraries.matrix))
androidTestImplementation(libs.test.junitext)
ksp(libs.showkase.processor)

View file

@ -1,102 +0,0 @@
/*
* Copyright (c) 2022 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.features.roomlist
import android.text.format.DateFormat
import android.text.format.DateUtils
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime
import kotlinx.datetime.toLocalDateTime
import java.time.Period
import java.time.format.DateTimeFormatter
import java.util.Locale
import javax.inject.Inject
import kotlin.math.absoluteValue
class LastMessageFormatter @Inject constructor() {
private val clock: Clock = Clock.System
private val locale: Locale = Locale.getDefault()
private val onlyTimeFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "HH:mm")
DateTimeFormatter.ofPattern(pattern)
}
private val dateWithMonthFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "d MMM")
DateTimeFormatter.ofPattern(pattern)
}
private val dateWithYearFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "dd.MM.yyyy")
DateTimeFormatter.ofPattern(pattern)
}
fun format(timestamp: Long?): String {
if (timestamp == null) return ""
val now: Instant = clock.now()
val tsInstant = Instant.fromEpochMilliseconds(timestamp)
val nowDateTime = now.toLocalDateTime(TimeZone.currentSystemDefault())
val tsDateTime = tsInstant.toLocalDateTime(TimeZone.currentSystemDefault())
val isSameDay = nowDateTime.date == tsDateTime.date
return when {
isSameDay -> {
onlyTimeFormatter.format(tsDateTime.toJavaLocalDateTime())
}
else -> {
formatDate(tsDateTime, nowDateTime)
}
}
}
private fun formatDate(
date: LocalDateTime,
currentDate: LocalDateTime,
): String {
val period = Period.between(date.date.toJavaLocalDate(), currentDate.date.toJavaLocalDate())
return if (period.years.absoluteValue >= 1) {
formatDateWithYear(date)
} else if (period.days.absoluteValue < 2 && period.months.absoluteValue < 1) {
getRelativeDay(date.toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds())
} else {
formatDateWithMonth(date)
}
}
private fun formatDateWithMonth(localDateTime: LocalDateTime): String {
return dateWithMonthFormatter.format(localDateTime.toJavaLocalDateTime())
}
private fun formatDateWithYear(localDateTime: LocalDateTime): String {
return dateWithYearFormatter.format(localDateTime.toJavaLocalDateTime())
}
private fun getRelativeDay(ts: Long): String {
return DateUtils.getRelativeTimeSpanString(
ts,
clock.now().toEpochMilliseconds(),
DateUtils.DAY_IN_MILLIS,
DateUtils.FORMAT_SHOW_WEEKDAY
).toString()
}
}

View file

@ -31,6 +31,7 @@ import io.element.android.features.roomlist.model.RoomListRoomSummaryPlaceholder
import io.element.android.features.roomlist.model.RoomListState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.parallelMap
import io.element.android.libraries.dateformatter.LastMessageFormatter
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.MatrixClient
@ -57,7 +58,6 @@ class RoomListPresenter @Inject constructor(
mutableStateOf(null)
}
var filter by rememberSaveable { mutableStateOf("") }
val isLoginOut = rememberSaveable { mutableStateOf(false) }
val roomSummaries by client
.roomSummaryDataSource()
.roomSummaries()
@ -86,7 +86,6 @@ class RoomListPresenter @Inject constructor(
matrixUser = matrixUser.value,
roomList = filteredRoomSummaries.value,
filter = filter,
isLoginOut = isLoginOut.value,
eventSink = ::handleEvents
)
}

View file

@ -14,8 +14,6 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.roomlist
import androidx.compose.foundation.layout.Column
@ -24,7 +22,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
@ -43,6 +40,9 @@ import io.element.android.features.roomlist.model.RoomListRoomSummary
import io.element.android.features.roomlist.model.RoomListState
import io.element.android.features.roomlist.model.stubbedRoomSummaries
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.matrix.core.RoomId
import io.element.android.libraries.matrix.core.UserId
@ -76,6 +76,7 @@ fun RoomListView(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomListView(
roomSummaries: ImmutableList<RoomListRoomSummary>,
@ -125,11 +126,15 @@ fun RoomListView(
filter = filter,
onFilterChanged = onFilterChanged,
onOpenSettings = onOpenSettings,
scrollBehavior = scrollBehavior
scrollBehavior = scrollBehavior,
modifier = Modifier,
)
},
content = { padding ->
Column(modifier = Modifier.padding(padding)) {
Column(
modifier = Modifier
.padding(padding)
) {
LazyColumn(
modifier = Modifier
.weight(1f)
@ -152,7 +157,14 @@ private fun RoomListRoomSummary.contentType() = isPlaceholder
@Preview
@Composable
fun RoomListViewPreview() {
fun RoomListViewLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun RoomListViewDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
RoomListView(
roomSummaries = stubbedRoomSummaries(),
matrixUser = MatrixUser(id = UserId("@id"), username = "User#1", avatarData = AvatarData("U")),

View file

@ -27,14 +27,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -46,7 +39,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
@ -54,6 +46,12 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.MediumTopAppBar
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.matrix.ui.model.MatrixUser
import io.element.android.libraries.ui.strings.R as StringR
@ -64,7 +62,8 @@ fun RoomListTopBar(
filter: String,
onFilterChanged: (String) -> Unit,
onOpenSettings: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior
scrollBehavior: TopAppBarScrollBehavior,
modifier: Modifier = Modifier,
) {
LogCompositions(
tag = "RoomListScreen",
@ -87,6 +86,7 @@ fun RoomListTopBar(
onFilterChanged = onFilterChanged,
onCloseClicked = ::closeFilter,
scrollBehavior = scrollBehavior,
modifier = modifier,
)
} else {
DefaultRoomListTopBar(
@ -96,6 +96,7 @@ fun RoomListTopBar(
searchWidgetStateIsOpened = true
},
scrollBehavior = scrollBehavior,
modifier = modifier,
)
}
}
@ -148,14 +149,6 @@ fun SearchRoomListTopBar(
}
}
},
colors = TextFieldDefaults.textFieldColors(
textColor = MaterialTheme.colorScheme.onBackground,
containerColor = Color.Transparent,
cursorColor = MaterialTheme.colorScheme.onBackground.copy(alpha = ContentAlpha.medium),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent
)
)
},
navigationIcon = {
@ -182,10 +175,11 @@ private fun DefaultRoomListTopBar(
matrixUser: MatrixUser?,
onOpenSettings: () -> Unit,
onSearchClicked: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior
scrollBehavior: TopAppBarScrollBehavior,
modifier: Modifier = Modifier,
) {
MediumTopAppBar(
modifier = Modifier
modifier = modifier
.nestedScroll(scrollBehavior.nestedScrollConnection),
title = {
Text(

View file

@ -32,7 +32,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@ -54,6 +53,13 @@ import androidx.compose.ui.unit.sp
import com.google.accompanist.placeholder.material.placeholder
import io.element.android.features.roomlist.model.RoomListRoomSummary
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.roomListPlaceHolder
import io.element.android.libraries.designsystem.theme.roomListRoomMessage
import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate
import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator
private val minHeight = 72.dp
@ -95,7 +101,11 @@ internal fun DefaultRoomSummaryRow(
) {
Avatar(
room.avatarData,
modifier = Modifier.placeholder(room.isPlaceholder, shape = CircleShape)
modifier = Modifier.placeholder(
visible = room.isPlaceholder,
shape = CircleShape,
color = ElementTheme.colors.roomListPlaceHolder(),
)
)
Column(
modifier = Modifier
@ -105,19 +115,27 @@ internal fun DefaultRoomSummaryRow(
) {
// Name
Text(
modifier = Modifier
.placeholder(room.isPlaceholder, shape = TextPlaceholderShape),
modifier = Modifier.placeholder(
visible = room.isPlaceholder,
shape = TextPlaceholderShape,
color = ElementTheme.colors.roomListPlaceHolder(),
),
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
text = room.name,
color = MaterialTheme.roomListRoomName(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Last Message
Text(
modifier = Modifier.placeholder(room.isPlaceholder, shape = TextPlaceholderShape),
modifier = Modifier.placeholder(
visible = room.isPlaceholder,
shape = TextPlaceholderShape,
color = ElementTheme.colors.roomListPlaceHolder(),
),
text = room.lastMessage?.toString().orEmpty(),
color = MaterialTheme.colorScheme.secondary,
color = MaterialTheme.roomListRoomMessage(),
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@ -129,14 +147,18 @@ internal fun DefaultRoomSummaryRow(
.alignByBaseline(),
) {
Text(
modifier = Modifier.placeholder(room.isPlaceholder, shape = TextPlaceholderShape),
modifier = Modifier.placeholder(
visible = room.isPlaceholder,
shape = TextPlaceholderShape,
color = ElementTheme.colors.roomListPlaceHolder(),
),
fontSize = 12.sp,
text = room.timestamp ?: "",
color = MaterialTheme.colorScheme.secondary,
color = MaterialTheme.roomListRoomMessageDate(),
)
Spacer(Modifier.size(4.dp))
val unreadIndicatorColor =
if (room.hasUnread) MaterialTheme.colorScheme.primary else Color.Transparent
if (room.hasUnread) MaterialTheme.roomListUnreadIndicator() else Color.Transparent
Box(
modifier = Modifier
.size(12.dp)

View file

@ -33,8 +33,8 @@ object RoomListRoomSummaryPlaceholders {
fun createFakeList(size: Int): List<RoomListRoomSummary> {
return mutableListOf<RoomListRoomSummary>().apply {
for (i in 0..size) {
add(create("\$fakeRoom$i"))
repeat(size) {
add(create("\$fakeRoom$it"))
}
}
}

View file

@ -25,6 +25,5 @@ data class RoomListState(
val matrixUser: MatrixUser?,
val roomList: ImmutableList<RoomListRoomSummary>,
val filter: String,
val isLoginOut: Boolean,
val eventSink: (RoomListEvents) -> Unit
)

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* Copyright (c) 2023 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.
@ -14,19 +14,17 @@
* limitations under the License.
*/
package io.element.android.features.login
package io.element.android.features.roomlist
import org.junit.Assert.assertEquals
import org.junit.Test
import io.element.android.libraries.dateformatter.LastMessageFormatter
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
class FakeLastMessageFormatter : LastMessageFormatter {
private var format = ""
fun givenFormat(format: String) {
this.format = format
}
override fun format(timestamp: Long?): String {
return format
}
}

View file

@ -14,14 +14,30 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.roomlist
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomlist.model.RoomListEvents
import io.element.android.features.roomlist.model.RoomListRoomSummary
import io.element.android.libraries.dateformatter.LastMessageFormatter
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.core.SessionId
import io.element.android.libraries.matrixtest.AN_AVATAR_URL
import io.element.android.libraries.matrixtest.AN_EXCEPTION
import io.element.android.libraries.matrixtest.A_MESSAGE
import io.element.android.libraries.matrixtest.A_ROOM_ID
import io.element.android.libraries.matrixtest.A_ROOM_NAME
import io.element.android.libraries.matrixtest.A_USER_ID
import io.element.android.libraries.matrixtest.A_USER_NAME
import io.element.android.libraries.matrixtest.FakeMatrixClient
import io.element.android.libraries.matrixtest.room.FakeRoomSummaryDataSource
import io.element.android.libraries.matrixtest.room.aRoomSummaryFilled
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -29,11 +45,11 @@ class RoomListPresenterTests {
@Test
fun `present - should start with no user and then load user with success`() = runTest {
val presenter = RoomListPresenter(
FakeMatrixClient(
SessionId("sessionId")
), LastMessageFormatter()
),
createDateFormatter()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -41,7 +57,166 @@ class RoomListPresenterTests {
val initialState = awaitItem()
assertThat(initialState.matrixUser).isNull()
val withUserState = awaitItem()
assertThat(withUserState).isNotNull()
assertThat(withUserState.matrixUser).isNotNull()
assertThat(withUserState.matrixUser!!.id).isEqualTo(A_USER_ID)
assertThat(withUserState.matrixUser!!.username).isEqualTo(A_USER_NAME)
assertThat(withUserState.matrixUser!!.avatarData.name).isEqualTo(A_USER_NAME)
assertThat(withUserState.matrixUser!!.avatarData.url).isEqualTo(AN_AVATAR_URL)
}
}
@Test
fun `present - should start with no user and then load user with error`() = runTest {
val presenter = RoomListPresenter(
FakeMatrixClient(
SessionId("sessionId"),
userDisplayName = Result.failure(AN_EXCEPTION),
userAvatarURLString = Result.failure(AN_EXCEPTION),
),
createDateFormatter()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.matrixUser).isNull()
val withUserState = awaitItem()
assertThat(withUserState.matrixUser).isNotNull()
// username fallback to user id value
assertThat(withUserState.matrixUser!!.username).isEqualTo(A_USER_ID.value)
}
}
@Test
fun `present - should filter room with success`() = runTest {
val presenter = RoomListPresenter(
FakeMatrixClient(
SessionId("sessionId")
),
createDateFormatter()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val withUserState = awaitItem()
assertThat(withUserState.filter).isEqualTo("")
withUserState.eventSink.invoke(RoomListEvents.UpdateFilter("t"))
val withFilterState = awaitItem()
assertThat(withFilterState.filter).isEqualTo("t")
}
}
@Test
fun `present - load 1 room with success`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource()
val presenter = RoomListPresenter(
FakeMatrixClient(
sessionId = SessionId("sessionId"),
roomSummaryDataSource = roomSummaryDataSource
),
createDateFormatter()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val withUserState = awaitItem()
// Room list is loaded with 16 placeholders
assertThat(withUserState.roomList.size).isEqualTo(16)
assertThat(withUserState.roomList.all { it.isPlaceholder }).isTrue()
roomSummaryDataSource.postRoomSummary(listOf(aRoomSummaryFilled()))
skipItems(1)
val withRoomState = awaitItem()
assertThat(withRoomState.roomList.size).isEqualTo(1)
assertThat(withRoomState.roomList.first()).isEqualTo(aRoomListRoomSummary)
}
}
@Test
fun `present - load 1 room with success and filter rooms`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource()
val presenter = RoomListPresenter(
FakeMatrixClient(
sessionId = SessionId("sessionId"),
roomSummaryDataSource = roomSummaryDataSource
),
createDateFormatter()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
roomSummaryDataSource.postRoomSummary(listOf(aRoomSummaryFilled()))
skipItems(3)
val loadedState = awaitItem()
// Test filtering with result
loadedState.eventSink.invoke(RoomListEvents.UpdateFilter(A_ROOM_NAME.substring(0, 3)))
val withNotFilteredRoomState = awaitItem()
assertThat(withNotFilteredRoomState.filter).isEqualTo(A_ROOM_NAME.substring(0, 3))
assertThat(withNotFilteredRoomState.roomList.size).isEqualTo(1)
assertThat(withNotFilteredRoomState.roomList.first()).isEqualTo(aRoomListRoomSummary)
// Test filtering without result
withNotFilteredRoomState.eventSink.invoke(RoomListEvents.UpdateFilter("tada"))
skipItems(1) // Filter update
val withFilteredRoomState = awaitItem()
assertThat(withFilteredRoomState.filter).isEqualTo("tada")
assertThat(withFilteredRoomState.roomList).isEmpty()
}
}
@Test
fun `present - update visible range`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource()
val presenter = RoomListPresenter(
FakeMatrixClient(
sessionId = SessionId("sessionId"),
roomSummaryDataSource = roomSummaryDataSource
),
createDateFormatter()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
roomSummaryDataSource.postRoomSummary(listOf(aRoomSummaryFilled()))
skipItems(3)
val loadedState = awaitItem()
// check initial value
assertThat(roomSummaryDataSource.latestSlidingSyncRange).isNull()
// Test empty range
loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(1, 0)))
assertThat(roomSummaryDataSource.latestSlidingSyncRange).isNull()
// Update visible range and check that range is transmitted to the SDK after computation
loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(0, 0)))
assertThat(roomSummaryDataSource.latestSlidingSyncRange).isEqualTo(IntRange(0, 20))
loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(0, 1)))
assertThat(roomSummaryDataSource.latestSlidingSyncRange).isEqualTo(IntRange(0, 21))
loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(19, 29)))
assertThat(roomSummaryDataSource.latestSlidingSyncRange).isEqualTo(IntRange(0, 49))
loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(49, 59)))
assertThat(roomSummaryDataSource.latestSlidingSyncRange).isEqualTo(IntRange(29, 79))
loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(149, 159)))
assertThat(roomSummaryDataSource.latestSlidingSyncRange).isEqualTo(IntRange(129, 179))
loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(149, 259)))
assertThat(roomSummaryDataSource.latestSlidingSyncRange).isEqualTo(IntRange(129, 279))
}
}
private fun createDateFormatter(): LastMessageFormatter {
return FakeLastMessageFormatter().apply {
givenFormat(A_FORMATTED_DATE)
}
}
}
private const val A_FORMATTED_DATE = "formatted_date"
private val aRoomListRoomSummary = RoomListRoomSummary(
id = A_ROOM_ID.value,
roomId = A_ROOM_ID,
name = A_ROOM_NAME,
hasUnread = true,
timestamp = A_FORMATTED_DATE,
lastMessage = A_MESSAGE,
avatarData = AvatarData(name = A_ROOM_NAME),
isPlaceholder = false,
)

View file

@ -17,11 +17,14 @@
package io.element.android.features.template
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Text
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun TemplateView(
@ -29,13 +32,23 @@ fun TemplateView(
modifier: Modifier = Modifier,
) {
Box(modifier, contentAlignment = Alignment.Center) {
Text("Template feature view")
Text(
"Template feature view",
color = MaterialTheme.colorScheme.primary,
)
}
}
@Composable
@Preview
fun TemplateViewPreview() {
@Composable
fun TemplateViewLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun TemplateViewDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
TemplateView(
state = TemplateState(),
)

View file

@ -14,27 +14,39 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.template
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
class TemplatePresenterTests {
@Test
fun `present - `() = runTest {
fun `present - initial state`() = runTest {
val presenter = TemplatePresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState)
assertThat(initialState)
}
}
@Test
fun `present - send event`() = runTest {
val presenter = TemplatePresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(TemplateEvents.MyEvent)
}
}
}