diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-10-04 10:31:38 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-10-04 10:31:38 +0200 |
| commit | 53b7f1ceffecae15eb8261c86399867e51c3978f (patch) | |
| tree | f39a8046ad008f50538a4f3e421d47a32f44845b /android/app/src | |
| parent | 66059ddae495b1fb173adb33cc950f6840937bb3 (diff) | |
| parent | c83c55f3ae3fffab104cfc14592ab026f920b658 (diff) | |
| download | mullvadvpn-53b7f1ceffecae15eb8261c86399867e51c3978f.tar.xz mullvadvpn-53b7f1ceffecae15eb8261c86399867e51c3978f.zip | |
Merge branch 'migrate-problem-report-view-to-compose-droid-58'
Diffstat (limited to 'android/app/src')
23 files changed, 919 insertions, 981 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt index e0f84db5ea..0d032f962a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt @@ -34,6 +34,7 @@ fun ScaffoldWithTopBar( topBarColor: Color, statusBarColor: Color, navigationBarColor: Color, + modifier: Modifier = Modifier, iconTintColor: Color = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaTopBar), onSettingsClicked: (() -> Unit)?, onAccountClicked: (() -> Unit)?, @@ -48,6 +49,7 @@ fun ScaffoldWithTopBar( } Scaffold( + modifier = modifier, topBar = { TopBar( backgroundColor = topBarColor, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scrollbar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scrollbar.kt index 35ddc58592..52f2f1d726 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scrollbar.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scrollbar.kt @@ -72,19 +72,33 @@ import kotlinx.coroutines.flow.collectLatest fun Modifier.drawHorizontalScrollbar( state: ScrollState, reverseScrolling: Boolean = false -): Modifier = drawScrollbar(state, Orientation.Horizontal, reverseScrolling) +): Modifier = composed { drawScrollbar(state, Orientation.Horizontal, BarColor, reverseScrolling) } fun Modifier.drawVerticalScrollbar( state: ScrollState, reverseScrolling: Boolean = false -): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling) +): Modifier = composed { drawScrollbar(state, Orientation.Vertical, BarColor, reverseScrolling) } + +fun Modifier.drawHorizontalScrollbar( + state: ScrollState, + color: Color, + reverseScrolling: Boolean = false +): Modifier = drawScrollbar(state, Orientation.Horizontal, color, reverseScrolling) + +fun Modifier.drawVerticalScrollbar( + state: ScrollState, + color: Color, + reverseScrolling: Boolean = false +): Modifier = drawScrollbar(state, Orientation.Vertical, color, reverseScrolling) private fun Modifier.drawScrollbar( state: ScrollState, orientation: Orientation, + color: Color, reverseScrolling: Boolean ): Modifier = - drawScrollbar(orientation, reverseScrolling) { reverseDirection, atEnd, color, alpha -> + drawScrollbar(orientation, color, reverseScrolling) { reverseDirection, atEnd, paintColor, alpha + -> if (state.maxValue > 0) { val canvasSize = if (orientation == Orientation.Horizontal) size.width else size.height val totalSize = canvasSize + state.maxValue @@ -94,7 +108,7 @@ private fun Modifier.drawScrollbar( orientation, reverseDirection, atEnd, - color, + paintColor, alpha, thumbSize, startOffset @@ -105,19 +119,21 @@ private fun Modifier.drawScrollbar( fun Modifier.drawHorizontalScrollbar( state: LazyListState, reverseScrolling: Boolean = false -): Modifier = drawScrollbar(state, Orientation.Horizontal, reverseScrolling) +): Modifier = composed { drawScrollbar(state, Orientation.Horizontal, BarColor, reverseScrolling) } fun Modifier.drawVerticalScrollbar( state: LazyListState, reverseScrolling: Boolean = false -): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling) +): Modifier = composed { drawScrollbar(state, Orientation.Vertical, BarColor, reverseScrolling) } private fun Modifier.drawScrollbar( state: LazyListState, orientation: Orientation, + color: Color, reverseScrolling: Boolean ): Modifier = - drawScrollbar(orientation, reverseScrolling) { reverseDirection, atEnd, color, alpha -> + drawScrollbar(orientation, color, reverseScrolling) { reverseDirection, atEnd, paintColor, alpha + -> val layoutInfo = state.layoutInfo val viewportSize = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset val items = layoutInfo.visibleItemsInfo @@ -137,7 +153,7 @@ private fun Modifier.drawScrollbar( orientation, reverseDirection, atEnd, - color, + paintColor, alpha, thumbSize, startOffset @@ -148,9 +164,14 @@ private fun Modifier.drawScrollbar( fun Modifier.drawVerticalScrollbar( state: LazyGridState, spanCount: Int, - reverseScrolling: Boolean = false + color: Color, + reverseScrolling: Boolean = false, ): Modifier = - drawScrollbar(Orientation.Vertical, reverseScrolling) { reverseDirection, atEnd, color, alpha -> + drawScrollbar(Orientation.Vertical, color, reverseScrolling) { + reverseDirection, + atEnd, + paintColor, + alpha -> val layoutInfo = state.layoutInfo val viewportSize = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset val items = layoutInfo.visibleItemsInfo @@ -176,7 +197,7 @@ fun Modifier.drawVerticalScrollbar( Orientation.Vertical, reverseDirection, atEnd, - color, + paintColor, alpha, thumbSize, startOffset @@ -225,6 +246,7 @@ private fun DrawScope.drawScrollbar( private fun Modifier.drawScrollbar( orientation: Orientation, + color: Color, reverseScrolling: Boolean, onDraw: DrawScope.( @@ -269,8 +291,6 @@ private fun Modifier.drawScrollbar( } else reverseScrolling val atEnd = if (orientation == Orientation.Vertical) isLtr else true - val color = BarColor - Modifier.nestedScroll(nestedScrollConnection).drawWithContent { drawContent() onDraw(reverseDirection, atEnd, color, alpha::value) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemNoEmailDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemNoEmailDialog.kt new file mode 100644 index 0000000000..7417d6ae7c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemNoEmailDialog.kt @@ -0,0 +1,77 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.ActionButton +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@Preview +@Composable +private fun PreviewReportProblemNoEmailDialog() { + AppTheme { + ReportProblemNoEmailDialog( + onDismiss = {}, + onConfirm = {}, + ) + } +} + +@Composable +fun ReportProblemNoEmailDialog(onDismiss: () -> Unit, onConfirm: () -> Unit) { + AlertDialog( + onDismissRequest = { onDismiss() }, + icon = { + Icon( + painter = painterResource(id = R.drawable.icon_alert), + contentDescription = null, + modifier = Modifier.size(Dimens.dialogIconHeight), + tint = Color.Unspecified + ) + }, + text = { + Text( + text = stringResource(id = R.string.confirm_no_email), + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodySmall + ) + }, + dismissButton = { + ActionButton( + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + onClick = onConfirm, + text = stringResource(id = R.string.send_anyway) + ) + }, + confirmButton = { + ActionButton( + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + onClick = { onDismiss() }, + text = stringResource(id = R.string.back) + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt index 3c4d9e1202..388963866f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -28,7 +27,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -59,6 +57,7 @@ import net.mullvad.mullvadvpn.compose.state.LoginError import net.mullvad.mullvadvpn.compose.state.LoginState import net.mullvad.mullvadvpn.compose.state.LoginState.* import net.mullvad.mullvadvpn.compose.state.LoginUiState +import net.mullvad.mullvadvpn.compose.textfield.mullvadWhiteTextFieldColors import net.mullvad.mullvadvpn.compose.util.accountTokenVisualTransformation import net.mullvad.mullvadvpn.lib.theme.AlphaTopBar import net.mullvad.mullvadvpn.lib.theme.AppTheme @@ -195,21 +194,7 @@ private fun LoginContent( maxLines = 1, visualTransformation = accountTokenVisualTransformation(), enabled = uiState.loginState is Idle, - colors = - TextFieldDefaults.colors( - focusedTextColor = Color.Black, - unfocusedTextColor = Color.Gray, - disabledTextColor = Color.Gray, - errorTextColor = Color.Black, - cursorColor = MaterialTheme.colorScheme.background, - focusedPlaceholderColor = MaterialTheme.colorScheme.background, - unfocusedPlaceholderColor = MaterialTheme.colorScheme.primary, - focusedLabelColor = MaterialTheme.colorScheme.background, - disabledLabelColor = Color.Gray, - unfocusedLabelColor = MaterialTheme.colorScheme.background, - focusedLeadingIconColor = Color.Black, - unfocusedSupportingTextColor = Color.Black, - ), + colors = mullvadWhiteTextFieldColors(), isError = uiState.loginState.isError(), ) @@ -266,14 +251,13 @@ private fun LoginIcon(loginState: LoginState, modifier: Modifier = Modifier) { } is Loading -> CircularProgressIndicator( - modifier = Modifier.size(Dimens.progressIndicatorSize), + modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = Dimens.loadingSpinnerStrokeWidth, strokeCap = StrokeCap.Round ) Success -> Image( - modifier = Modifier.offset(-Dimens.smallPadding, -Dimens.smallPadding), painter = painterResource(id = R.drawable.icon_success), contentDescription = stringResource(id = R.string.logged_in_title), ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt new file mode 100644 index 0000000000..4d524c28dc --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt @@ -0,0 +1,320 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import me.onebone.toolbar.ScrollStrategy +import me.onebone.toolbar.rememberCollapsingToolbarScaffoldState +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.ActionButton +import net.mullvad.mullvadvpn.compose.component.CollapsingToolbarScaffold +import net.mullvad.mullvadvpn.compose.component.CollapsingTopBar +import net.mullvad.mullvadvpn.compose.dialog.ReportProblemNoEmailDialog +import net.mullvad.mullvadvpn.compose.textfield.mullvadWhiteTextFieldColors +import net.mullvad.mullvadvpn.dataproxy.SendProblemReportResult +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.viewmodel.ReportProblemUiState +import net.mullvad.mullvadvpn.viewmodel.SendingReportUiState + +@Preview +@Composable +private fun PreviewReportProblemScreen() { + AppTheme { ReportProblemScreen(uiState = ReportProblemUiState()) } +} + +@Preview +@Composable +private fun PreviewReportProblemSendingScreen() { + AppTheme { + ReportProblemScreen(uiState = ReportProblemUiState(false, SendingReportUiState.Sending)) + } +} + +@Preview +@Composable +private fun PreviewReportProblemConfirmNoEmailScreen() { + AppTheme { ReportProblemScreen(uiState = ReportProblemUiState(true)) } +} + +@Preview +@Composable +private fun PreviewReportProblemSuccessScreen() { + AppTheme { + ReportProblemScreen( + uiState = ReportProblemUiState(false, SendingReportUiState.Success("email@mail.com")) + ) + } +} + +@Preview +@Composable +private fun PreviewReportProblemErrorScreen() { + AppTheme { + ReportProblemScreen( + uiState = + ReportProblemUiState( + false, + SendingReportUiState.Error(SendProblemReportResult.Error.CollectLog) + ) + ) + } +} + +@Composable +fun ReportProblemScreen( + uiState: ReportProblemUiState, + onSendReport: (String, String) -> Unit = { _, _ -> }, + onDismissNoEmailDialog: () -> Unit = {}, + onClearSendResult: () -> Unit = {}, + onNavigateToViewLogs: () -> Unit = {}, + onBackClick: () -> Unit = {} +) { + + val scaffoldState = rememberCollapsingToolbarScaffoldState() + val progress = scaffoldState.toolbarState.progress + CollapsingToolbarScaffold( + backgroundColor = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxSize(), + state = scaffoldState, + scrollStrategy = ScrollStrategy.ExitUntilCollapsed, + isEnabledWhenCollapsable = false, + toolbar = { + val scaffoldModifier = + Modifier.road( + whenCollapsed = Alignment.TopCenter, + whenExpanded = Alignment.BottomStart + ) + CollapsingTopBar( + backgroundColor = MaterialTheme.colorScheme.background, + onBackClicked = onBackClick, + title = stringResource(id = R.string.report_a_problem), + progress = progress, + modifier = scaffoldModifier, + ) + }, + ) { + var email by rememberSaveable { mutableStateOf("") } + var description by rememberSaveable { mutableStateOf("") } + + // Show sending states + if (uiState.sendingState != null) { + Column( + modifier = + Modifier.fillMaxSize() + .padding(vertical = Dimens.mediumPadding, horizontal = Dimens.sideMargin) + ) { + when (uiState.sendingState) { + SendingReportUiState.Sending -> SendingContent() + is SendingReportUiState.Error -> + ErrorContent({ onSendReport(email, description) }, onClearSendResult) + is SendingReportUiState.Success -> SentContent(uiState.sendingState) + } + return@CollapsingToolbarScaffold + } + } + + // Dialog to show confirm if no email was added + if (uiState.showConfirmNoEmail) { + ReportProblemNoEmailDialog( + onDismiss = onDismissNoEmailDialog, + onConfirm = { onSendReport(email, description) } + ) + } + + Surface(color = MaterialTheme.colorScheme.background) { + Column( + modifier = + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.verticalSpace, + ), + verticalArrangement = Arrangement.spacedBy(Dimens.mediumPadding) + ) { + Text(text = stringResource(id = R.string.problem_report_description)) + + TextField( + modifier = Modifier.fillMaxWidth(), + value = email, + onValueChange = { email = it }, + maxLines = 1, + singleLine = true, + placeholder = { Text(text = stringResource(id = R.string.user_email_hint)) }, + colors = mullvadWhiteTextFieldColors() + ) + + TextField( + modifier = Modifier.fillMaxWidth().weight(1f), + value = description, + onValueChange = { description = it }, + placeholder = { Text(stringResource(R.string.user_message_hint)) }, + colors = mullvadWhiteTextFieldColors() + ) + + ActionButton( + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + onClick = onNavigateToViewLogs, + text = stringResource(id = R.string.view_logs) + ) + + ActionButton( + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface + ), + onClick = { onSendReport(email, description) }, + isEnabled = description.isNotEmpty(), + text = stringResource(id = R.string.send) + ) + } + } + } +} + +@Composable +private fun ColumnScope.SendingContent() { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.CenterHorizontally), + strokeCap = StrokeCap.Round, + strokeWidth = Dimens.loadingSpinnerStrokeWidth + ) + Spacer(modifier = Modifier.height(Dimens.problemReportIconToTitlePadding)) + Text( + text = stringResource(id = R.string.sending), + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onBackground + ) +} + +@Composable +private fun ColumnScope.SentContent(sendingState: SendingReportUiState.Success) { + Icon( + painter = painterResource(id = R.drawable.icon_success), + contentDescription = stringResource(id = R.string.sent), + modifier = Modifier.align(Alignment.CenterHorizontally).size(Dimens.dialogIconHeight), + tint = Color.Unspecified + ) + + Spacer(modifier = Modifier.height(Dimens.problemReportIconToTitlePadding)) + Text( + text = stringResource(id = R.string.sent), + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = + buildAnnotatedString { + withStyle(SpanStyle(color = MaterialTheme.colorScheme.surface)) { + append(stringResource(id = R.string.sent_thanks)) + } + append(" ") + withStyle(SpanStyle(color = MaterialTheme.colorScheme.onPrimary)) { + append(stringResource(id = R.string.we_will_look_into_this)) + } + }, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(Dimens.smallPadding)) + sendingState.email?.let { + val emailTemplate = stringResource(R.string.sent_contact) + val annotatedEmailString = + remember(it) { + val emailStart = emailTemplate.indexOf('%') + + buildAnnotatedString { + append(emailTemplate.substring(0, emailStart)) + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(sendingState.email) + } + } + } + + Text( + text = annotatedEmailString, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +private fun ColumnScope.ErrorContent(retry: () -> Unit, onDismiss: () -> Unit) { + Icon( + painter = painterResource(id = R.drawable.icon_fail), + contentDescription = stringResource(id = R.string.failed_to_send), + modifier = Modifier.size(Dimens.dialogIconHeight).align(Alignment.CenterHorizontally), + tint = Color.Unspecified + ) + Spacer(modifier = Modifier.height(Dimens.problemReportIconToTitlePadding)) + Text( + text = stringResource(id = R.string.failed_to_send), + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = stringResource(id = R.string.failed_to_send_details), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.weight(1f)) + ActionButton( + modifier = Modifier.fillMaxWidth().padding(vertical = Dimens.mediumPadding), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + onClick = onDismiss, + text = stringResource(id = R.string.edit_message) + ) + ActionButton( + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + onClick = retry, + text = stringResource(id = R.string.try_again) + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt new file mode 100644 index 0000000000..091d19f480 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt @@ -0,0 +1,116 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import me.onebone.toolbar.ScrollStrategy +import me.onebone.toolbar.rememberCollapsingToolbarScaffoldState +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.component.CollapsingToolbarScaffold +import net.mullvad.mullvadvpn.compose.component.CollapsingTopBar +import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.viewmodel.ViewLogsUiState + +@Preview +@Composable +private fun PreviewViewLogsScreen() { + AppTheme { ViewLogsScreen(uiState = ViewLogsUiState("Lorem ipsum")) } +} + +@Preview +@Composable +private fun PreviewViewLogsLoadingScreen() { + AppTheme { ViewLogsScreen(uiState = ViewLogsUiState()) } +} + +@Composable +fun ViewLogsScreen( + uiState: ViewLogsUiState, + onBackClick: () -> Unit = {}, +) { + + val scaffoldState = rememberCollapsingToolbarScaffoldState() + val progress = scaffoldState.toolbarState.progress + CollapsingToolbarScaffold( + backgroundColor = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxSize(), + state = scaffoldState, + scrollStrategy = ScrollStrategy.ExitUntilCollapsed, + isEnabledWhenCollapsable = false, + toolbar = { + val scaffoldModifier = + Modifier.road( + whenCollapsed = Alignment.TopCenter, + whenExpanded = Alignment.BottomStart + ) + CollapsingTopBar( + backgroundColor = MaterialTheme.colorScheme.secondary, + onBackClicked = onBackClick, + title = stringResource(id = R.string.view_logs), + progress = progress, + modifier = scaffoldModifier, + ) + }, + ) { + Card( + modifier = + Modifier.fillMaxSize() + .padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.screenVerticalMargin + ), + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = + Modifier.padding(Dimens.mediumPadding).align(Alignment.CenterHorizontally) + ) + } else { + SelectionContainer { + val scrollState = rememberScrollState() + Column( + modifier = + Modifier.drawVerticalScrollbar( + scrollState, + color = MaterialTheme.colorScheme.primary + ) + ) { + TextField( + modifier = + Modifier.verticalScroll(scrollState) + .padding(horizontal = Dimens.smallPadding), + value = uiState.allLines, + textStyle = MaterialTheme.typography.bodySmall, + onValueChange = {}, + readOnly = true, + colors = + TextFieldDefaults.colors( + focusedTextColor = Color.Black, + unfocusedTextColor = Color.Black, + disabledTextColor = Color.Black, + cursorColor = MaterialTheme.colorScheme.background, + ) + ) + } + } + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/TextFieldColors.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/TextFieldColors.kt new file mode 100644 index 0000000000..c3060a46d5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/TextFieldColors.kt @@ -0,0 +1,24 @@ +package net.mullvad.mullvadvpn.compose.textfield + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +@Composable +fun mullvadWhiteTextFieldColors(): TextFieldColors = + TextFieldDefaults.colors( + focusedTextColor = Color.Black, + unfocusedTextColor = Color.Gray, + disabledTextColor = Color.Gray, + errorTextColor = Color.Black, + cursorColor = MaterialTheme.colorScheme.background, + focusedPlaceholderColor = MaterialTheme.colorScheme.background, + unfocusedPlaceholderColor = MaterialTheme.colorScheme.primary, + focusedLabelColor = MaterialTheme.colorScheme.background, + disabledLabelColor = Color.Gray, + unfocusedLabelColor = MaterialTheme.colorScheme.background, + focusedLeadingIconColor = Color.Black, + unfocusedSupportingTextColor = Color.Black, + ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/TimingConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/TimingConstant.kt new file mode 100644 index 0000000000..cace9b2b79 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/TimingConstant.kt @@ -0,0 +1,4 @@ +package net.mullvad.mullvadvpn.constant + +const val MINIMUM_LOADING_TIME_MILLIS = 500L +const val NAVIGATION_DELAY_MILLIS = 500L diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/dataproxy/MullvadProblemReport.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/dataproxy/MullvadProblemReport.kt index 69fd7275e7..67eaeca48d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/dataproxy/MullvadProblemReport.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/dataproxy/MullvadProblemReport.kt @@ -1,135 +1,88 @@ package net.mullvad.mullvadvpn.dataproxy +import android.content.Context import java.io.File -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Deferred +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.async -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ClosedReceiveChannelException -import kotlinx.coroutines.channels.actor -import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.withContext -const val PROBLEM_REPORT_FILE = "problem_report.txt" +const val PROBLEM_REPORT_LOGS_FILE = "problem_report.txt" -class MullvadProblemReport { - private sealed class Command { - data object Collect : Command() +sealed interface SendProblemReportResult { + data object Success : SendProblemReportResult - class Load(val logs: CompletableDeferred<String>) : Command() + sealed interface Error : SendProblemReportResult { + data object CollectLog : Error - class Send(val result: CompletableDeferred<Boolean>) : Command() - - data object Delete : Command() + // This is usually due to network error or bad email address + data object SendReport : Error } +} - val logDirectory = CompletableDeferred<File>() - val cacheDirectory = CompletableDeferred<File>() - - private val commandChannel = spawnActor() - - private val problemReportPath = - GlobalScope.async(Dispatchers.Default) { File(logDirectory.await(), PROBLEM_REPORT_FILE) } - - private var isCollected = false +data class UserReport(val email: String?, val message: String) - var confirmNoEmail: CompletableDeferred<Boolean>? = null +class MullvadProblemReport(context: Context, val dispatcher: CoroutineDispatcher = Dispatchers.IO) { - var userEmail = "" - var userMessage = "" + private val cacheDirectory = File(context.cacheDir.toURI()) + private val logDirectory = File(context.filesDir.toURI()) + private val logsPath = File(logDirectory, PROBLEM_REPORT_LOGS_FILE) init { System.loadLibrary("mullvad_jni") } - fun collect() { - commandChannel.trySendBlocking(Command.Collect) - } - - suspend fun load(): String { - val logs = CompletableDeferred<String>() - - commandChannel.send(Command.Load(logs)) - - return logs.await() - } - - fun send(): Deferred<Boolean> { - val result = CompletableDeferred<Boolean>() - - commandChannel.trySendBlocking(Command.Send(result)) - - return result - } - - fun deleteReportFile() { - commandChannel.trySendBlocking(Command.Delete) - } - - private fun spawnActor() = - GlobalScope.actor<Command>(Dispatchers.Default, Channel.UNLIMITED) { - try { - while (true) { + suspend fun collectLogs(): Boolean = + withContext(dispatcher) { + // Delete any old report + deleteLogs() - when (val command = channel.receive()) { - is Command.Collect -> doCollect() - is Command.Load -> command.logs.complete(doLoad()) - is Command.Send -> command.result.complete(doSend()) - is Command.Delete -> doDelete() - } - } - } catch (exception: ClosedReceiveChannelException) {} + collectReport(logDirectory.absolutePath, logsPath.absolutePath) } - private suspend fun doCollect() { - val logDirectoryPath = logDirectory.await().absolutePath - val reportPath = problemReportPath.await().absolutePath - - doDelete() - - isCollected = collectReport(logDirectoryPath, reportPath) - } - - private suspend fun doLoad(): String { - if (!isCollected) { - doCollect() + suspend fun sendReport(userReport: UserReport): SendProblemReportResult { + // If report is not collected then, collect it, if it fails then return error + if (!logsExists() && !collectLogs()) { + return SendProblemReportResult.Error.CollectLog } - return if (isCollected) { - problemReportPath.await().readText() + val sentSuccessfully = + withContext(dispatcher) { + sendProblemReport( + userReport.email ?: "", + userReport.message, + logsPath.absolutePath, + cacheDirectory.absolutePath + ) + } + + return if (sentSuccessfully) { + deleteLogs() + SendProblemReportResult.Success } else { - "Failed to collect logs for problem report" + SendProblemReportResult.Error.SendReport } } - private suspend fun doSend(): Boolean { - if (!isCollected) { - doCollect() + suspend fun readLogs(): List<String> { + if (!logsExists()) { + collectLogs() } - val result = - isCollected && - sendProblemReport( - userEmail, - userMessage, - problemReportPath.await().absolutePath, - cacheDirectory.await().absolutePath - ) - - if (result) { - doDelete() + return if (logsExists()) { + logsPath.readLines() + } else { + listOf("Failed to collect logs for problem report") } - - return result } - private suspend fun doDelete() { - problemReportPath.await().delete() - isCollected = false + private fun logsExists() = logsPath.exists() + + fun deleteLogs() { + logsPath.delete() } - private external fun collectReport(logDirectory: String, reportPath: String): Boolean + // TODO We should remove the external functions from this class and migrate it to the service + private external fun collectReport(logDirectory: String, logsPath: String): Boolean private external fun sendProblemReport( userEmail: String, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index 987a55b45f..18e98964e8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.Dispatchers import net.mullvad.mullvadvpn.BuildConfig import net.mullvad.mullvadvpn.applist.ApplicationsIconManager import net.mullvad.mullvadvpn.applist.ApplicationsProvider +import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport import net.mullvad.mullvadvpn.lib.ipc.EventDispatcher import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.ChangelogRepository @@ -26,9 +27,11 @@ import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel import net.mullvad.mullvadvpn.viewmodel.LoginViewModel import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel +import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel +import net.mullvad.mullvadvpn.viewmodel.ViewLogsViewModel import net.mullvad.mullvadvpn.viewmodel.VpnSettingsViewModel import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel import org.apache.commons.validator.routines.InetAddressValidator @@ -70,6 +73,7 @@ val uiModule = module { ) } single { SettingsRepository(get()) } + single { MullvadProblemReport(get()) } single<IChangelogDataProvider> { ChangelogDataProvider(get()) } @@ -87,6 +91,8 @@ val uiModule = module { viewModel { SettingsViewModel(get(), get()) } viewModel { VpnSettingsViewModel(get(), get(), get(), get()) } viewModel { WelcomeViewModel(get(), get(), get()) } + viewModel { ReportProblemViewModel(get()) } + viewModel { ViewLogsViewModel(get()) } viewModel { OutOfTimeViewModel(get(), get()) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/CollapsibleTitleController.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/CollapsibleTitleController.kt deleted file mode 100644 index 1c37945602..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/CollapsibleTitleController.kt +++ /dev/null @@ -1,219 +0,0 @@ -package net.mullvad.mullvadvpn.ui - -import android.view.View -import android.view.View.OnLayoutChangeListener -import android.view.ViewGroup.MarginLayoutParams -import kotlin.properties.Delegates.observable -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.util.LinearInterpolation -import net.mullvad.mullvadvpn.util.ListenableScrollableView - -// In order to use this view controller, the parent view must contain four views with specific IDs: -// -// 1. A scroll area `View` with the `scrollAreaId` that implements `ListenableScrollableView`, which -// is used to animate the title based on the scroll offset. -// 2. A view inside the scroll area with the ID `expanded_title`. This view is made invisible so -// that it's not drawn, but it is used to measure the layout and the animation positions. -// 3. A view outside the scroll area with the ID `collapsed_title`. This view is also made -// invisible just like the `expanded_view`. -// 4. A view with the ID `title`. This is the view that's actually drawn, and it's position and size -// are interpolated from the expanded title to the collapsed title. This view should be placed -// somewhere where it is drawn over all other views. -// -// The animation interpolation is calculated based on the Y scroll offset of the scroll area. Once -// the offset reaches a value that completely hides the expanded title inside the scroll view, the -// animation finishes with the title being in the collapsed state. -class CollapsibleTitleController(val parentView: View, scrollAreaId: Int = R.id.scroll_area) { - private inner class LayoutListener(val listener: (View) -> Unit) : OnLayoutChangeListener { - override fun onLayoutChange( - view: View, - left: Int, - top: Int, - right: Int, - bottom: Int, - oldLeft: Int, - oldTop: Int, - oldRight: Int, - oldBottom: Int - ) { - listener.invoke(view) - update() - } - } - - private val scaleInterpolation = LinearInterpolation() - private val scrollInterpolation = LinearInterpolation() - private val xOffsetInterpolation = LinearInterpolation() - private val yOffsetInterpolation = LinearInterpolation() - - private val collapsedTitleLayoutListener: LayoutListener = LayoutListener { collapsedTitle -> - val (x, y) = calculateViewCoordinates(collapsedTitle) - - collapsedTitleHeight = collapsedTitle.height.toFloat() - - scaleInterpolation.end = collapsedTitleHeight / maxOf(1.0f, titleHeight) - xOffsetInterpolation.end = x - yOffsetInterpolation.end = y - } - - private val collapsedTitleView = - parentView.findViewById<View>(R.id.collapsed_title).apply { - addOnLayoutChangeListener(collapsedTitleLayoutListener) - visibility = View.INVISIBLE - } - - private val expandedTitleLayoutListener: LayoutListener = LayoutListener { expandedTitle -> - val (x, y) = calculateViewCoordinates(expandedTitle) - - val expandedTitleMarginTop = - when (val layoutParams = expandedTitle.layoutParams) { - is MarginLayoutParams -> layoutParams.topMargin - else -> 0 - } - - expandedTitleHeight = expandedTitle.height.toFloat() - - scaleInterpolation.start = expandedTitleHeight / maxOf(1.0f, titleHeight) - xOffsetInterpolation.start = x - yOffsetInterpolation.start = y - - scrollInterpolation.end = expandedTitleHeight + expandedTitleMarginTop - } - - private val titleLayoutListener: LayoutListener = LayoutListener { title -> - val (x, y) = calculateViewCoordinates(title) - - titleWidth = title.width.toFloat() - titleHeight = title.height.toFloat() - - scaleInterpolation.start = expandedTitleHeight / maxOf(1.0f, titleHeight) - scaleInterpolation.end = collapsedTitleHeight / maxOf(1.0f, titleHeight) - xOffsetInterpolation.reference = x - yOffsetInterpolation.reference = y - } - - private val titleView = - parentView.findViewById<View>(R.id.title).apply { - addOnLayoutChangeListener(titleLayoutListener) - - // Setting the scale pivot point to the left corner simplifies the calculations - pivotX = 0.0f - pivotY = 0.0f - } - - private val scrollAreaLayoutListener: LayoutListener = LayoutListener { - scrollOffset = scrollArea.verticalScrollOffset.toFloat() - } - - private val scrollArea = - parentView.findViewById<View>(scrollAreaId).let { view -> - val scrollableView = view as ListenableScrollableView - - view.addOnLayoutChangeListener(scrollAreaLayoutListener) - - scrollableView.onScrollListener = { _, top, _, _ -> - scrollOffset = top.toFloat() - update() - } - - scrollableView - } - - private var scrollOffsetUpdated = false - get() { - if (field == true) { - field = false - return true - } else { - return false - } - } - - private var collapsedTitleHeight = 0.0f - private var expandedTitleHeight = 0.0f - private var titleWidth = 0.0f - private var titleHeight = 0.0f - - private var scrollOffset: Float by - observable(0.0f) { _, old, new -> - if (scrollOffsetUpdated == false && old != new) { - scrollOffsetUpdated = true - } - } - - val fullCollapseScrollOffset: Float - get() = scrollInterpolation.end - - var expandedTitleView by - observable<View?>(null) { _, oldView, newView -> - oldView?.removeOnLayoutChangeListener(expandedTitleLayoutListener) - newView?.apply { - addOnLayoutChangeListener(expandedTitleLayoutListener) - expandedTitleLayoutListener.listener(this) - visibility = View.INVISIBLE - } - } - - init { - expandedTitleView = parentView.findViewById<View>(R.id.expanded_title) - update() - } - - fun onDestroy() { - scrollArea.onScrollListener = null - (scrollArea as View).removeOnLayoutChangeListener(scrollAreaLayoutListener) - - collapsedTitleView.removeOnLayoutChangeListener(collapsedTitleLayoutListener) - expandedTitleView?.removeOnLayoutChangeListener(expandedTitleLayoutListener) - titleView.removeOnLayoutChangeListener(titleLayoutListener) - } - - private fun update() { - val shouldUpdate = - scrollOffsetUpdated || - scaleInterpolation.updated || - xOffsetInterpolation.updated || - yOffsetInterpolation.updated - - if (shouldUpdate) { - val progress = - if (expandedTitleView != null) { - maxOf(0.0f, minOf(1.0f, scrollInterpolation.progress(scrollOffset))) - } else { - 1.0f - } - - val scale = scaleInterpolation.interpolate(progress) - val offsetX = xOffsetInterpolation.interpolate(progress) - val offsetY = yOffsetInterpolation.interpolate(progress) - - titleView.apply { - scaleX = scale - scaleY = scale - translationX = offsetX - translationY = offsetY - } - } - } - - private fun calculateViewCoordinates(view: View): Pair<Float, Float> { - var currentView = view - var x = 0.0f - var y = 0.0f - - while (currentView != parentView) { - val parent = currentView.parent - - x += currentView.x - currentView.translationX - y += currentView.y - currentView.translationY - - if (parent is View) { - currentView = parent - } else { - break - } - } - - return Pair(x, y) - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ListItemDividerDecoration.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ListItemDividerDecoration.kt deleted file mode 100644 index 4fcde0e314..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ListItemDividerDecoration.kt +++ /dev/null @@ -1,16 +0,0 @@ -package net.mullvad.mullvadvpn.ui - -import android.graphics.Rect -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.ItemDecoration -import androidx.recyclerview.widget.RecyclerView.State - -class ListItemDividerDecoration(private val bottomOffset: Int = 0, private val topOffset: Int = 0) : - ItemDecoration() { - - override fun getItemOffsets(offsets: Rect, view: View, parent: RecyclerView, state: State) { - offsets.bottom = bottomOffset - offsets.top = topOffset - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt index fa88696cd9..3d30d28845 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt @@ -30,7 +30,6 @@ import kotlinx.coroutines.withTimeoutOrNull import net.mullvad.mullvadvpn.BuildConfig import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.dialog.ChangelogDialog -import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport import net.mullvad.mullvadvpn.di.uiModule import net.mullvad.mullvadvpn.lib.common.util.SdkUtils.isNotificationPermissionGranted import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointConfiguration @@ -59,7 +58,6 @@ import org.koin.android.ext.android.getKoin import org.koin.core.context.loadKoinModules open class MainActivity : FragmentActivity() { - val problemReport = MullvadProblemReport() private var requestNotificationPermissionLauncher: ActivityResultLauncher<String> = registerForActivityResult(ActivityResultContracts.RequestPermission()) { // NotificationManager.areNotificationsEnabled is used to check the state rather than @@ -105,11 +103,6 @@ open class MainActivity : FragmentActivity() { super.onCreate(savedInstanceState) - problemReport.apply { - logDirectory.complete(filesDir) - cacheDirectory.complete(cacheDir) - } - setContentView(R.layout.main) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConfirmNoEmailDialogFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConfirmNoEmailDialogFragment.kt deleted file mode 100644 index d31c5551b5..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConfirmNoEmailDialogFragment.kt +++ /dev/null @@ -1,64 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.app.Dialog -import android.content.Context -import android.content.DialogInterface -import android.graphics.drawable.ColorDrawable -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams -import android.widget.Button -import androidx.fragment.app.DialogFragment -import kotlinx.coroutines.CompletableDeferred -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.ui.MainActivity - -class ConfirmNoEmailDialogFragment : DialogFragment() { - private var confirmNoEmail: CompletableDeferred<Boolean>? = null - - override fun onAttach(context: Context) { - super.onAttach(context) - - val parentActivity = context as MainActivity - - confirmNoEmail = parentActivity.problemReport.confirmNoEmail - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val view = inflater.inflate(R.layout.confirm_no_email, container, false) - - view.findViewById<Button>(R.id.back_button).setOnClickListener { activity?.onBackPressed() } - - view.findViewById<Button>(R.id.send_button).setOnClickListener { - confirmNoEmail?.complete(true) - confirmNoEmail = null - dismiss() - } - - return view - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val dialog = super.onCreateDialog(savedInstanceState) - - dialog.window?.setBackgroundDrawable(ColorDrawable(android.R.color.transparent)) - - return dialog - } - - override fun onStart() { - super.onStart() - - dialog?.window?.setLayout(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) - } - - override fun onDismiss(dialogInterface: DialogInterface) { - confirmNoEmail?.complete(false) - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ProblemReportFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ProblemReportFragment.kt index b30b5713fb..397039719c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ProblemReportFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ProblemReportFragment.kt @@ -1,136 +1,42 @@ package net.mullvad.mullvadvpn.ui.fragment -import android.content.Context -import android.graphics.Typeface import android.os.Bundle -import android.text.Editable -import android.text.Spannable -import android.text.SpannableStringBuilder -import android.text.TextWatcher -import android.text.style.ForegroundColorSpan -import android.text.style.StyleSpan import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Button -import android.widget.EditText -import android.widget.ScrollView -import android.widget.TextView -import android.widget.ViewSwitcher -import kotlin.properties.Delegates.observable -import kotlinx.coroutines.CompletableDeferred +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport -import net.mullvad.mullvadvpn.lib.common.util.JobTracker -import net.mullvad.mullvadvpn.ui.CollapsibleTitleController -import net.mullvad.mullvadvpn.ui.MainActivity +import net.mullvad.mullvadvpn.compose.screen.ReportProblemScreen +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel class ProblemReportFragment : BaseFragment() { - private val jobTracker = JobTracker() - - private var showingEmail by - observable(false) { _, oldValue, newValue -> - if (oldValue != newValue) { - if (newValue == true) { - parentActivity.enterSecureScreen(this) - } else { - parentActivity.leaveSecureScreen(this) - } - } - } - - private lateinit var parentActivity: MainActivity - private lateinit var problemReport: MullvadProblemReport - - private lateinit var bodyContainer: ViewSwitcher - private lateinit var userEmailInput: EditText - private lateinit var userMessageInput: EditText - private lateinit var sendButton: Button - - private lateinit var sendingSpinner: View - private lateinit var sentSuccessfullyIcon: View - private lateinit var failedToSendIcon: View - - private lateinit var sendStatusLabel: TextView - private lateinit var sendDetailsLabel: TextView - private lateinit var responseMessageLabel: TextView - - private lateinit var editMessageButton: Button - private lateinit var tryAgainButton: Button - - private lateinit var scrollArea: ScrollView - private lateinit var titleController: CollapsibleTitleController - - override fun onAttach(context: Context) { - super.onAttach(context) - - parentActivity = context as MainActivity - - problemReport = parentActivity.problemReport - problemReport.collect() - } + private val vm by viewModel<ReportProblemViewModel>() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - val view = inflater.inflate(R.layout.problem_report, container, false) - - view.findViewById<View>(R.id.back).setOnClickListener { activity?.onBackPressed() } - - bodyContainer = view.findViewById<ViewSwitcher>(R.id.body_container) - userEmailInput = view.findViewById<EditText>(R.id.user_email) - userMessageInput = view.findViewById<EditText>(R.id.user_message) - sendButton = view.findViewById<Button>(R.id.send_button) - - sendingSpinner = view.findViewById<View>(R.id.sending_spinner) - sentSuccessfullyIcon = view.findViewById<View>(R.id.sent_successfully_icon) - failedToSendIcon = view.findViewById<View>(R.id.failed_to_send_icon) - - sendStatusLabel = view.findViewById<TextView>(R.id.send_status) - sendDetailsLabel = view.findViewById<TextView>(R.id.send_details) - responseMessageLabel = view.findViewById<TextView>(R.id.response_message) + ): View? { - editMessageButton = view.findViewById<Button>(R.id.edit_message_button) - tryAgainButton = view.findViewById<Button>(R.id.try_again_button) - - view.findViewById<Button>(R.id.view_logs).setOnClickListener { showLogs() } - - sendButton.setOnClickListener { jobTracker.newUiJob("sendReport") { sendReport(true) } } - - editMessageButton.setOnClickListener { showForm() } - - tryAgainButton.setOnClickListener { - jobTracker.newUiJob("sendReport") { sendReport(false) } + return inflater.inflate(R.layout.fragment_compose, container, false).apply { + findViewById<ComposeView>(R.id.compose_view).setContent { + AppTheme { + val uiState = vm.uiState.collectAsState().value + ReportProblemScreen( + uiState, + onSendReport = { email, description -> vm.sendReport(email, description) }, + onDismissNoEmailDialog = vm::dismissConfirmNoEmail, + onClearSendResult = vm::clearSendResult, + onNavigateToViewLogs = { showLogs() } + ) { + activity?.onBackPressed() + } + } + } } - - userEmailInput.setText(problemReport.userEmail) - userMessageInput.setText(problemReport.userMessage) - - setSendButtonEnabled(!userMessageInput.text.isEmpty()) - userMessageInput.addTextChangedListener(InputWatcher()) - - scrollArea = view.findViewById(R.id.scroll_area) - titleController = CollapsibleTitleController(view) - - return view - } - - override fun onDestroyView() { - problemReport.userEmail = userEmailInput.text.toString() - problemReport.userMessage = userMessageInput.text.toString() - problemReport.deleteReportFile() - - titleController.onDestroy() - - super.onDestroyView() - } - - override fun onDetach() { - showingEmail = false - - super.onDetach() } private fun showLogs() { @@ -146,151 +52,4 @@ class ProblemReportFragment : BaseFragment() { commitAllowingStateLoss() } } - - private suspend fun sendReport(shouldConfirmNoEmail: Boolean) { - val userEmail = userEmailInput.text.trim().toString() - - problemReport.userEmail = userEmail - problemReport.userMessage = userMessageInput.text.toString() - - if (!userEmail.isEmpty() || !shouldConfirmNoEmail || confirmSendWithNoEmail()) { - showSendingScreen() - - if (problemReport.send().await()) { - clearForm() - showSuccessScreen(userEmail) - } else { - showErrorScreen() - } - } - } - - private suspend fun confirmSendWithNoEmail(): Boolean { - val confirmation = CompletableDeferred<Boolean>() - - problemReport.confirmNoEmail = confirmation - showConfirmNoEmailDialog() - - return confirmation.await() - } - - private fun clearForm() { - userEmailInput.setText("") - userMessageInput.setText("") - - problemReport.userEmail = "" - problemReport.userMessage = "" - } - - private fun showForm() { - bodyContainer.displayedChild = 0 - } - - private fun showConfirmNoEmailDialog() { - val transaction = parentFragmentManager.beginTransaction() - - transaction.addToBackStack(null) - - ConfirmNoEmailDialogFragment().show(transaction, null) - } - - private fun showSendingScreen() { - bodyContainer.displayedChild = 1 - - sendingSpinner.visibility = View.VISIBLE - sentSuccessfullyIcon.visibility = View.GONE - failedToSendIcon.visibility = View.GONE - - sendStatusLabel.visibility = View.VISIBLE - sendDetailsLabel.visibility = View.GONE - responseMessageLabel.visibility = View.GONE - - sendStatusLabel.setText(R.string.sending) - - editMessageButton.visibility = View.GONE - tryAgainButton.visibility = View.GONE - } - - private fun showSuccessScreen(userEmail: String) { - sendingSpinner.visibility = View.GONE - - sentSuccessfullyIcon.visibility = View.VISIBLE - sendStatusLabel.visibility = View.VISIBLE - - if (!userEmail.isEmpty()) { - showResponseMessage(userEmail) - } - - showThanksMessage() - sendStatusLabel.setText(R.string.sent) - - scrollArea.scrollTo(0, titleController.fullCollapseScrollOffset.toInt()) - } - - private fun showThanksMessage() { - val thanks = parentActivity.getString(R.string.sent_thanks) - val weWillLookIntoThis = parentActivity.getString(R.string.we_will_look_into_this) - - val colorStyle = ForegroundColorSpan(parentActivity.getColor(R.color.green)) - - sendDetailsLabel.text = - SpannableStringBuilder("$thanks $weWillLookIntoThis").apply { - setSpan(colorStyle, 0, thanks.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - } - - sendDetailsLabel.visibility = View.VISIBLE - } - - private fun showResponseMessage(userEmail: String) { - val responseMessageTemplate = parentActivity.getString(R.string.sent_contact) - val responseMessage = parentActivity.getString(R.string.sent_contact, userEmail) - - val emailStart = responseMessageTemplate.indexOf('%') - val emailEndFromStringEnd = responseMessageTemplate.length - (emailStart + 4) - val emailEnd = responseMessage.length - emailEndFromStringEnd - - val boldStyle = StyleSpan(Typeface.BOLD) - val colorStyle = ForegroundColorSpan(parentActivity.getColor(R.color.white)) - - responseMessageLabel.text = - SpannableStringBuilder(responseMessage).apply { - setSpan(boldStyle, emailStart, emailEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - setSpan(colorStyle, emailStart, emailEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - } - - responseMessageLabel.visibility = View.VISIBLE - - showingEmail = true - } - - private fun showErrorScreen() { - sendingSpinner.visibility = View.GONE - - failedToSendIcon.visibility = View.VISIBLE - sendStatusLabel.visibility = View.VISIBLE - sendDetailsLabel.visibility = View.VISIBLE - - sendStatusLabel.setText(R.string.failed_to_send) - sendDetailsLabel.setText(R.string.failed_to_send_details) - - editMessageButton.visibility = View.VISIBLE - tryAgainButton.visibility = View.VISIBLE - - scrollArea.scrollTo(0, titleController.fullCollapseScrollOffset.toInt()) - } - - private fun setSendButtonEnabled(enabled: Boolean) { - sendButton.isEnabled = enabled - sendButton.alpha = if (enabled) 1.0F else 0.5F - } - - inner class InputWatcher : TextWatcher { - override fun beforeTextChanged(text: CharSequence, start: Int, count: Int, after: Int) {} - - override fun onTextChanged(text: CharSequence, start: Int, before: Int, count: Int) {} - - override fun afterTextChanged(text: Editable) { - setSendButtonEnabled(!text.isEmpty()) - } - } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ViewLogsFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ViewLogsFragment.kt index e519526f52..21931ab876 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ViewLogsFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ViewLogsFragment.kt @@ -1,52 +1,36 @@ package net.mullvad.mullvadvpn.ui.fragment -import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.EditText +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport -import net.mullvad.mullvadvpn.lib.common.util.JobTracker -import net.mullvad.mullvadvpn.ui.MainActivity +import net.mullvad.mullvadvpn.compose.screen.ViewLogsScreen +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.viewmodel.ViewLogsViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel class ViewLogsFragment : BaseFragment() { - private val jobTracker = JobTracker() - - private lateinit var problemReport: MullvadProblemReport - - private lateinit var logArea: EditText - - override fun onAttach(context: Context) { - super.onAttach(context) - - val parentActivity = context as MainActivity - - problemReport = parentActivity.problemReport - } + private val vm by viewModel<ViewLogsViewModel>() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - val view = inflater.inflate(R.layout.view_logs, container, false) - - view.findViewById<View>(R.id.back).setOnClickListener { activity?.onBackPressed() } - - logArea = view.findViewById<EditText>(R.id.log_area) - - return view - } - - override fun onStart() { - super.onStart() - - jobTracker.newUiJob("showLogs") { - val logs = jobTracker.runOnBackground { problemReport.load() } - logArea.setText(logs) + return inflater.inflate(R.layout.fragment_compose, container, false).apply { + findViewById<ComposeView>(R.id.compose_view).setContent { + AppTheme { + val uiState = vm.uiState.collectAsState() + ViewLogsScreen( + uiState = uiState.value, + onBackClick = { activity?.onBackPressed() } + ) + } + } } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/EditTextExt.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/EditTextExt.kt deleted file mode 100644 index b90201edfe..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/EditTextExt.kt +++ /dev/null @@ -1,15 +0,0 @@ -package net.mullvad.mullvadvpn.util - -import android.view.KeyEvent -import android.view.inputmethod.EditorInfo -import android.widget.EditText - -fun EditText.setOnEnterOrDoneAction(callback: () -> Unit) { - setOnEditorActionListener { _, action, event -> - if (action == EditorInfo.IME_ACTION_DONE || event?.keyCode == KeyEvent.KEYCODE_ENTER) { - callback() - } - - false - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt new file mode 100644 index 0000000000..a7daf9e8d9 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt @@ -0,0 +1,90 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.constant.MINIMUM_LOADING_TIME_MILLIS +import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport +import net.mullvad.mullvadvpn.dataproxy.SendProblemReportResult +import net.mullvad.mullvadvpn.dataproxy.UserReport + +data class ReportProblemUiState( + val showConfirmNoEmail: Boolean = false, + val sendingState: SendingReportUiState? = null +) + +sealed interface SendingReportUiState { + data object Sending : SendingReportUiState + + data class Success(val email: String?) : SendingReportUiState + + data class Error(val error: SendProblemReportResult.Error) : SendingReportUiState +} + +class ReportProblemViewModel(private val mullvadProblemReporter: MullvadProblemReport) : + ViewModel() { + + private val _uiState = MutableStateFlow(ReportProblemUiState()) + val uiState = _uiState.asStateFlow() + + fun sendReport( + email: String, + description: String, + ) { + viewModelScope.launch { + val userEmail = email.trim() + val nullableEmail = if (email.isEmpty()) null else userEmail + if (shouldShowConfirmNoEmail(nullableEmail)) { + _uiState.update { it.copy(showConfirmNoEmail = true) } + } else { + _uiState.update { + it.copy(sendingState = SendingReportUiState.Sending, showConfirmNoEmail = false) + } + + // Ensure we show loading for at least MINIMUM_LOADING_TIME_MILLIS + val deferredResult = async { + mullvadProblemReporter.sendReport(UserReport(nullableEmail, description)) + } + delay(MINIMUM_LOADING_TIME_MILLIS) + + _uiState.update { + it.copy(sendingState = deferredResult.await().toUiResult(nullableEmail)) + } + } + } + } + + fun clearSendResult() { + _uiState.update { it.copy(sendingState = null) } + } + + fun dismissConfirmNoEmail() { + _uiState.update { it.copy(showConfirmNoEmail = false) } + } + + private fun shouldShowConfirmNoEmail(userEmail: String?): Boolean = + userEmail.isNullOrEmpty() && + !uiState.value.showConfirmNoEmail && + uiState.value.sendingState !is SendingReportUiState + + private fun SendProblemReportResult.toUiResult(email: String?): SendingReportUiState = + when (this) { + is SendProblemReportResult.Error -> SendingReportUiState.Error(this) + SendProblemReportResult.Success -> SendingReportUiState.Success(email) + } + + init { + viewModelScope.launch { mullvadProblemReporter.collectLogs() } + } + + override fun onCleared() { + super.onCleared() + // Delete any logs if user leaves the screen + mullvadProblemReporter.deleteLogs() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ViewLogsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ViewLogsViewModel.kt new file mode 100644 index 0000000000..7b07adc874 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ViewLogsViewModel.kt @@ -0,0 +1,35 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.constant.NAVIGATION_DELAY_MILLIS +import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport + +data class ViewLogsUiState(val allLines: String = "", val isLoading: Boolean = true) + +class ViewLogsViewModel(private val mullvadProblemReporter: MullvadProblemReport) : ViewModel() { + + private val _uiState = MutableStateFlow(ViewLogsUiState()) + val uiState = _uiState.asStateFlow() + + init { + viewModelScope.launch { + // Loading this much text takes a while, so we show a loading indicator until the + // fragment transitions is done. I'd very much prefer to use LazyColumn in the view + // which would make the loading way faster but then the SelectionContainer is broken and + // text would not be copyable. + delay(NAVIGATION_DELAY_MILLIS) + _uiState.update { + it.copy( + allLines = mullvadProblemReporter.readLogs().joinToString("\n"), + isLoading = false + ) + } + } + } +} diff --git a/android/app/src/main/res/layout/confirm_no_email.xml b/android/app/src/main/res/layout/confirm_no_email.xml deleted file mode 100644 index bd6be5f4a4..0000000000 --- a/android/app/src/main/res/layout/confirm_no_email.xml +++ /dev/null @@ -1,31 +0,0 @@ -<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:scrollbars="none"> - <LinearLayout android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:padding="30dp" - android:background="@drawable/dialog_background" - android:orientation="vertical" - android:gravity="start"> - <ImageView android:layout_width="44dp" - android:layout_height="44dp" - android:layout_marginTop="8dp" - android:layout_gravity="center" - android:src="@drawable/icon_alert" /> - <TextView android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_weight="0" - android:layout_marginTop="16dp" - android:textColor="@color/white80" - android:textSize="@dimen/text_small" - android:text="@string/confirm_no_email" /> - <Button android:id="@+id/send_button" - android:layout_marginVertical="@dimen/button_separation" - android:text="@string/send_anyway" - style="@style/RedButton" /> - <Button android:id="@+id/back_button" - android:text="@string/back" - style="@style/BlueButton" /> - </LinearLayout> -</ScrollView> diff --git a/android/app/src/main/res/layout/problem_report.xml b/android/app/src/main/res/layout/problem_report.xml deleted file mode 100644 index 5832b1a893..0000000000 --- a/android/app/src/main/res/layout/problem_report.xml +++ /dev/null @@ -1,163 +0,0 @@ -<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:mullvad="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:background="@color/darkBlue" - android:gravity="start"> - <TextView android:id="@+id/title" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/report_a_problem" - style="@style/SettingsCollapsedHeader" /> - <LinearLayout android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical"> - <FrameLayout android:layout_width="match_parent" - android:layout_height="wrap_content"> - <net.mullvad.mullvadvpn.ui.widget.BackButton android:id="@+id/back" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - mullvad:text="@string/settings" /> - <TextView android:id="@+id/collapsed_title" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginHorizontal="4dp" - android:layout_gravity="center" - android:text="@string/report_a_problem" - style="@style/SettingsCollapsedHeader" /> - </FrameLayout> - <net.mullvad.mullvadvpn.ui.widget.ListenableScrollView android:id="@+id/scroll_area" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:fillViewport="true"> - <LinearLayout android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical"> - <TextView android:id="@+id/expanded_title" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_weight="0" - android:layout_marginTop="2dp" - android:layout_marginBottom="8dp" - android:layout_marginHorizontal="@dimen/side_margin" - android:lines="1" - android:text="@string/report_a_problem" - style="@style/SettingsExpandedHeader" /> - <ViewSwitcher android:id="@+id/body_container" - android:layout_width="match_parent" - android:layout_height="match_parent"> - <LinearLayout android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical"> - <TextView android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_weight="0" - android:layout_marginBottom="@dimen/vertical_space" - android:layout_marginHorizontal="@dimen/side_margin" - android:textColor="@color/white80" - android:textSize="@dimen/text_small" - android:text="@string/problem_report_description" /> - <EditText android:id="@+id/user_email" - android:inputType="textEmailAddress" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_weight="0" - android:layout_marginBottom="12dp" - android:layout_marginHorizontal="22dp" - android:singleLine="true" - android:hint="@string/user_email_hint" - style="@style/InputText" /> - <EditText android:id="@+id/user_message" - android:layout_width="match_parent" - android:layout_height="0dp" - android:layout_weight="1" - android:layout_marginHorizontal="@dimen/side_margin" - android:singleLine="false" - android:hint="@string/user_message_hint" - android:gravity="top" - style="@style/InputText" /> - <Button android:id="@+id/view_logs" - android:layout_marginHorizontal="@dimen/side_margin" - android:layout_marginVertical="@dimen/button_separation" - android:enabled="true" - android:text="@string/view_logs" - style="@style/BlueButton" /> - <Button android:id="@+id/send_button" - android:layout_marginHorizontal="@dimen/side_margin" - android:layout_marginBottom="@dimen/screen_vertical_margin" - android:enabled="false" - android:text="@string/send" - style="@style/GreenButton" /> - </LinearLayout> - <LinearLayout android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_marginTop="16dp" - android:layout_marginBottom="@dimen/screen_vertical_margin" - android:layout_marginHorizontal="@dimen/side_margin" - android:orientation="vertical"> - <FrameLayout android:layout_width="60dp" - android:layout_height="60dp" - android:layout_gravity="center_horizontal" - android:layout_marginBottom="32dp"> - <ProgressBar android:id="@+id/sending_spinner" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_gravity="center" - android:indeterminate="true" - android:indeterminateOnly="true" - android:indeterminateDuration="600" - android:indeterminateDrawable="@drawable/icon_spinner" /> - <ImageView android:id="@+id/sent_successfully_icon" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_gravity="center" - android:src="@drawable/icon_success" - android:visibility="gone" /> - <ImageView android:id="@+id/failed_to_send_icon" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_gravity="center" - android:src="@drawable/icon_fail" - android:visibility="gone" /> - </FrameLayout> - <TextView android:id="@+id/send_status" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginBottom="4dp" - android:textColor="@color/white" - android:textSize="@dimen/text_huge" - android:textStyle="bold" - android:text="@string/sending" /> - <TextView android:id="@+id/send_details" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:textColor="@color/white60" - android:textSize="@dimen/text_small" - android:text="@string/sent_thanks" - android:visibility="gone" /> - <TextView android:id="@+id/response_message" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:textColor="@color/white60" - android:textSize="@dimen/text_small" - android:text="@string/sent_contact" - android:visibility="gone" /> - <Space android:layout_width="match_parent" - android:layout_height="0dp" - android:layout_weight="1" /> - <Button android:id="@+id/edit_message_button" - android:layout_marginTop="@dimen/button_separation" - android:text="@string/edit_message" - android:visibility="gone" - style="@style/BlueButton" /> - <Button android:id="@+id/try_again_button" - android:layout_marginTop="@dimen/button_separation" - android:text="@string/try_again" - android:visibility="gone" - style="@style/GreenButton" /> - </LinearLayout> - </ViewSwitcher> - </LinearLayout> - </net.mullvad.mullvadvpn.ui.widget.ListenableScrollView> - </LinearLayout> -</FrameLayout> diff --git a/android/app/src/main/res/layout/view_logs.xml b/android/app/src/main/res/layout/view_logs.xml deleted file mode 100644 index 2011c8b5c9..0000000000 --- a/android/app/src/main/res/layout/view_logs.xml +++ /dev/null @@ -1,38 +0,0 @@ -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:mullvad="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:background="@color/darkBlue" - android:gravity="start" - android:orientation="vertical"> - <net.mullvad.mullvadvpn.ui.widget.BackButton android:id="@+id/back" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - mullvad:text="@string/report_a_problem" /> - <TextView android:id="@+id/title" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_weight="0" - android:layout_marginTop="2dp" - android:layout_marginHorizontal="@dimen/side_margin" - android:text="@string/view_logs" - style="@style/SettingsExpandedHeader" /> - <ScrollView android:layout_width="match_parent" - android:layout_height="0dp" - android:layout_weight="1" - android:layout_marginTop="@dimen/vertical_space" - android:layout_marginHorizontal="@dimen/side_margin" - android:layout_marginBottom="@dimen/screen_vertical_margin" - android:scrollbarThumbVertical="@color/blue" - android:background="@drawable/input_text_background"> - <EditText android:id="@+id/log_area" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:editable="false" - android:textIsSelectable="true" - android:singleLine="false" - android:gravity="top" - android:background="@null" - style="@style/InputText" /> - </ScrollView> -</LinearLayout> diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemModelTest.kt new file mode 100644 index 0000000000..4f91189d0a --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemModelTest.kt @@ -0,0 +1,117 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.viewModelScope +import app.cash.turbine.test +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.impl.annotations.MockK +import kotlin.test.assertEquals +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport +import net.mullvad.mullvadvpn.dataproxy.SendProblemReportResult +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class ReportProblemModelTest { + @get:Rule val testCoroutineRule = TestCoroutineRule() + + @MockK private lateinit var mockMullvadProblemReport: MullvadProblemReport + + private lateinit var viewModel: ReportProblemViewModel + + @Before + fun setUp() { + MockKAnnotations.init(this) + coEvery { mockMullvadProblemReport.collectLogs() } returns true + viewModel = ReportProblemViewModel(mockMullvadProblemReport) + } + + @After + fun tearDown() { + viewModel.viewModelScope.coroutineContext.cancel() + } + + @Test + fun sendReportFailedToCollectLogs() = runTest { + // Arrange + coEvery { mockMullvadProblemReport.sendReport(any()) } returns + SendProblemReportResult.Error.CollectLog + val email = "my@email.com" + + // Act, Assert + viewModel.uiState.test { + assertEquals(null, awaitItem().sendingState) + viewModel.sendReport(email, "My description") + assertEquals(SendingReportUiState.Sending, awaitItem().sendingState) + assertEquals( + SendingReportUiState.Error(SendProblemReportResult.Error.CollectLog), + awaitItem().sendingState + ) + } + } + + @Test + fun sendReportFailedToSendReport() = runTest { + // Arrange + coEvery { mockMullvadProblemReport.sendReport(any()) } returns + SendProblemReportResult.Error.SendReport + val email = "my@email.com" + + // Act, Assert + viewModel.uiState.test { + assertEquals(null, awaitItem().sendingState) + viewModel.sendReport(email, "My description") + assertEquals(SendingReportUiState.Sending, awaitItem().sendingState) + assertEquals( + SendingReportUiState.Error(SendProblemReportResult.Error.SendReport), + awaitItem().sendingState + ) + } + } + + @Test + fun sendReportWithoutEmailSuccessfully() = runTest { + // Arrange + coEvery { mockMullvadProblemReport.sendReport(any()) } returns + SendProblemReportResult.Success + val email = "" + + // Act, Assert + viewModel.uiState.test { + assertEquals(ReportProblemUiState(false, null), awaitItem()) + viewModel.sendReport(email, "My description") + assertEquals(ReportProblemUiState(true, null), awaitItem()) + viewModel.sendReport(email, "My description") + assertEquals(ReportProblemUiState(false, SendingReportUiState.Sending), awaitItem()) + assertEquals( + ReportProblemUiState(false, SendingReportUiState.Success(null)), + awaitItem() + ) + } + } + + @Test + fun sendReportSuccessfully() = runTest { + // Arrange + coEvery { mockMullvadProblemReport.collectLogs() } returns true + coEvery { mockMullvadProblemReport.sendReport(any()) } returns + SendProblemReportResult.Success + val email = "my@email.com" + + // Act, Assert + viewModel.uiState.test { + assertEquals(awaitItem(), ReportProblemUiState(false, null)) + viewModel.sendReport(email, "My description") + + assertEquals(awaitItem(), ReportProblemUiState(false, SendingReportUiState.Sending)) + assertEquals( + awaitItem(), + ReportProblemUiState(false, SendingReportUiState.Success(email)) + ) + } + } +} |
