From 3c54cb0a604226fae03ccb1880ed0ccda1d388ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20G=C3=B6ransson?= Date: Thu, 21 Sep 2023 14:30:47 +0200 Subject: [PATCH 1/8] Create view in compose --- .../compose/component/Scaffolding.kt | 2 + .../mullvadvpn/compose/component/Scrollbar.kt | 46 ++- .../dialog/ReportProblemNoEmailDialog.kt | 86 +++++ .../dialog/ReportProblemStateDialog.kt | 221 +++++++++++++ .../mullvadvpn/compose/screen/LoginScreen.kt | 18 +- .../compose/screen/ReportProblemScreen.kt | 183 +++++++++++ .../compose/screen/ViewLogsScreen.kt | 115 +++++++ .../compose/textfield/TextFieldColors.kt | 24 ++ .../mullvadvpn/constant/MinLoadingConstant.kt | 3 + .../dataproxy/MullvadProblemReport.kt | 151 ++++----- .../net/mullvad/mullvadvpn/di/UiModule.kt | 6 + .../net/mullvad/mullvadvpn/ui/MainActivity.kt | 7 - .../fragment/ConfirmNoEmailDialogFragment.kt | 64 ---- .../ui/fragment/ProblemReportFragment.kt | 293 ++---------------- .../ui/fragment/ViewLogsFragment.kt | 50 +-- .../viewmodel/ReportProblemViewModel.kt | 81 +++++ .../mullvadvpn/viewmodel/ViewLogsViewModel.kt | 33 ++ 17 files changed, 887 insertions(+), 496 deletions(-) create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemNoEmailDialog.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemStateDialog.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/TextFieldColors.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/MinLoadingConstant.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConfirmNoEmailDialogFragment.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ViewLogsViewModel.kt 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 e0f84db5ea21..0d032f962ab2 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 35ddc585923f..52f2f1d7268e 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 000000000000..ff9b65821175 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemNoEmailDialog.kt @@ -0,0 +1,86 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +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.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.ActionButton + +@Preview +@Composable +private fun PreviewReportProblemNoEmailDialog() { + ReportProblemNoEmailDialog( + onDismiss = {}, + onConfirm = {}, + ) +} + +@Composable +fun ReportProblemNoEmailDialog(onDismiss: () -> Unit, onConfirm: () -> Unit) { + AlertDialog( + onDismissRequest = { onDismiss() }, + title = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.drawable.icon_alert), + contentDescription = "Remove", + modifier = Modifier.width(50.dp).height(50.dp) + ) + } + }, + text = { + Text( + text = stringResource(id = R.string.confirm_no_email), + color = colorResource(id = R.color.white), + fontSize = dimensionResource(id = R.dimen.text_small).value.sp, + modifier = Modifier.fillMaxWidth() + ) + }, + dismissButton = { + ActionButton( + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = colorResource(id = R.color.red), + contentColor = Color.White + ), + onClick = onConfirm, + ) { + Text(text = stringResource(id = R.string.send_anyway), fontSize = 18.sp) + } + }, + confirmButton = { + ActionButton( + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = colorResource(id = R.color.blue), + contentColor = Color.White + ), + onClick = { onDismiss() }, + ) { + Text(text = stringResource(id = R.string.back), fontSize = 18.sp) + } + }, + containerColor = colorResource(id = R.color.darkBlue) + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemStateDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemStateDialog.kt new file mode 100644 index 000000000000..6a63423399e7 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemStateDialog.kt @@ -0,0 +1,221 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +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.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +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.FontStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.DialogProperties +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.ActionButton +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.viewmodel.SendingReportUiState + +@Composable +fun ShowReportProblemStateDialog( + sendingState: SendingReportUiState, + onDismiss: () -> Unit, + onClearForm: () -> Unit, + retry: () -> Unit +) { + when (sendingState) { + SendingReportUiState.Sending -> ReportProblemSendingDialog() + is SendingReportUiState.Error -> + ReportProblemErrorDialog(onDismiss = onDismiss, retry = retry) + is SendingReportUiState.Success -> + ReportProblemSuccessDialog( + sendingState.email, + onConfirm = { + onClearForm() + onDismiss() + } + ) + } +} + +@Preview +@Composable +private fun PreviewReportProblemSendingDialog() { + ReportProblemSendingDialog() +} + +@Composable +private fun ReportProblemSendingDialog() { + AlertDialog( + onDismissRequest = {}, + title = { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(Dimens.progressIndicatorSize) + ) + } + }, + text = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(id = R.string.sending), + color = colorResource(id = R.color.white), + fontSize = dimensionResource(id = R.dimen.text_small).value.sp, + fontStyle = FontStyle.Normal, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = {}, + properties = + DialogProperties( + dismissOnClickOutside = false, + dismissOnBackPress = false, + ), + containerColor = colorResource(id = R.color.darkBlue) + ) +} + +@Preview +@Composable +private fun PreviewReportProblemSuccessDialog() { + ReportProblemSuccessDialog( + "Email@em.com", + onConfirm = {}, + ) +} + +@Composable +fun ReportProblemSuccessDialog(email: String?, onConfirm: () -> Unit) { + AlertDialog( + onDismissRequest = { onConfirm() }, + title = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.drawable.icon_success), + contentDescription = "Remove", + modifier = Modifier.width(50.dp).height(50.dp) + ) + } + }, + text = { + Text( + text = + buildAnnotatedString { + withStyle(SpanStyle(color = colorResource(id = R.color.green))) { + append(stringResource(id = R.string.sent_thanks)) + } + append(" ") + + withStyle(SpanStyle(color = colorResource(id = R.color.white))) { + append(stringResource(id = R.string.we_will_look_into_this)) + } + }, + fontSize = dimensionResource(id = R.dimen.text_small).value.sp, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + ActionButton( + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = colorResource(id = R.color.blue), + contentColor = Color.White + ), + onClick = { onConfirm() }, + ) { + Text(text = stringResource(id = R.string.dismiss), fontSize = 18.sp) + } + }, + containerColor = colorResource(id = R.color.darkBlue) + ) +} + +@Preview +@Composable +private fun PreviewReportProblemErrorDialog() { + ReportProblemErrorDialog( + onDismiss = {}, + retry = {}, + ) +} + +@Composable +fun ReportProblemErrorDialog(onDismiss: () -> Unit, retry: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.drawable.icon_fail), + contentDescription = null, + modifier = Modifier.width(50.dp).height(50.dp) + ) + } + }, + text = { + Text( + text = stringResource(id = R.string.failed_to_send_details), + color = colorResource(id = R.color.white), + fontSize = dimensionResource(id = R.dimen.text_small).value.sp, + modifier = Modifier.fillMaxWidth() + ) + }, + dismissButton = { + ActionButton( + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = colorResource(id = R.color.blue), + contentColor = Color.White + ), + onClick = onDismiss, + ) { + Text(text = stringResource(id = R.string.edit_message), fontSize = 18.sp) + } + }, + confirmButton = { + ActionButton( + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = colorResource(id = R.color.green), + contentColor = Color.White + ), + onClick = retry, + ) { + Text(text = stringResource(id = R.string.try_again), fontSize = 18.sp) + } + }, + containerColor = colorResource(id = R.color.darkBlue) + ) +} 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 3c4d9e12023e..c02982427d4f 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 @@ -28,7 +28,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 +58,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 +195,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(), ) 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 000000000000..007b7bacf2fc --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt @@ -0,0 +1,183 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ButtonDefaults +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.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.dialog.ShowReportProblemStateDialog +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 ReportProblemScreenPreview() { + AppTheme { ReportProblemScreen(uiState = ReportProblemUiState()) } +} + +@Preview +@Composable +private fun ReportProblemSendingScreenPreview() { + AppTheme { + ReportProblemScreen(uiState = ReportProblemUiState(false, SendingReportUiState.Sending)) + } +} + +@Preview +@Composable +private fun ReportProblemConfirmNoEmailScreenPreview() { + AppTheme { ReportProblemScreen(uiState = ReportProblemUiState(true)) } +} + +@Preview +@Composable +private fun ReportProblemSuccessScreenPreview() { + AppTheme { + ReportProblemScreen( + uiState = ReportProblemUiState(false, SendingReportUiState.Success(null)) + ) + } +} + +@Preview +@Composable +private fun ReportProblemErrorScreenPreview() { + 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("") } + Surface(color = MaterialTheme.colorScheme.background) { + if (uiState.sendingState != null) { + ShowReportProblemStateDialog( + uiState.sendingState, + onDismiss = onClearSendResult, + onClearForm = { + email = "" + description = "" + }, + retry = { onSendReport(email, description) } + ) + } + Column( + modifier = + Modifier.padding( + horizontal = Dimens.sideMargin, + vertical = Dimens.screenVerticalMargin + ), + 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) + ) + + if (uiState.showConfirmNoEmail) { + ReportProblemNoEmailDialog( + onDismiss = onDismissNoEmailDialog, + onConfirm = { onSendReport(email, description) } + ) + } + } + } + } +} 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 000000000000..80bb49f1afd6 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt @@ -0,0 +1,115 @@ +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 ViewLogsScreenPreview() { + AppTheme { ViewLogsScreen(uiState = ViewLogsUiState("Lorem ipsum")) } +} + +@Preview +@Composable +private fun ViewLogsLoadingScreenPreview() { + 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( + vertical = Dimens.sideMargin, + horizontal = 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 000000000000..c3060a46d596 --- /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/MinLoadingConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/MinLoadingConstant.kt new file mode 100644 index 000000000000..a93422d3f63b --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/MinLoadingConstant.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.constant + +const val MINIMUM_LOADING_SPINNER_TIME_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 69fd7275e70a..f36c9a49757b 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,134 +1,89 @@ package net.mullvad.mullvadvpn.dataproxy +import android.content.Context import java.io.File -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Deferred 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" -class MullvadProblemReport { - private sealed class Command { - data object Collect : Command() +sealed interface SendProblemReportResult { + data object Success : SendProblemReportResult - class Load(val logs: CompletableDeferred) : Command() + sealed interface Error : SendProblemReportResult { + data object CollectLog : Error - class Send(val result: CompletableDeferred) : Command() - - data object Delete : Command() + // This is usually due to network error or bad email address + data object SendReport : Error } +} - val logDirectory = CompletableDeferred() - val cacheDirectory = CompletableDeferred() - - 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? = null +class MullvadProblemReport(context: Context) { + private val logDirectory = File(context.filesDir.toURI()) + private val cacheDirectory = File(context.cacheDir.toURI()) + private val problemReportPath = File(logDirectory, PROBLEM_REPORT_FILE) - var userEmail = "" - var userMessage = "" + private var hasCollectedReport = false init { System.loadLibrary("mullvad_jni") } - fun collect() { - commandChannel.trySendBlocking(Command.Collect) - } - - suspend fun load(): String { - val logs = CompletableDeferred() - - commandChannel.send(Command.Load(logs)) - - return logs.await() - } - - fun send(): Deferred { - val result = CompletableDeferred() - - commandChannel.trySendBlocking(Command.Send(result)) - - return result - } - - fun deleteReportFile() { - commandChannel.trySendBlocking(Command.Delete) - } - - private fun spawnActor() = - GlobalScope.actor(Dispatchers.Default, Channel.UNLIMITED) { - try { - while (true) { - - 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) {} + private suspend fun collectReport() = + withContext(Dispatchers.IO) { + val logDirectoryPath = logDirectory.absolutePath + val reportPath = problemReportPath.absolutePath + // Delete any old report + deleteReport() + hasCollectedReport = collectReport(logDirectoryPath, reportPath) } - 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 (!hasCollectedReport) { + collectReport() + } + if (!hasCollectedReport) { + return SendProblemReportResult.Error.CollectLog } - return if (isCollected) { - problemReportPath.await().readText() + val sentSuccessfully = + withContext(Dispatchers.IO) { + sendProblemReport( + userReport.email, + userReport.message, + problemReportPath.absolutePath, + cacheDirectory.absolutePath + ) + } + + return if (sentSuccessfully) { + deleteReport() + 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 { + if (!hasCollectedReport) { + collectReport() } - val result = - isCollected && - sendProblemReport( - userEmail, - userMessage, - problemReportPath.await().absolutePath, - cacheDirectory.await().absolutePath - ) - - if (result) { - doDelete() + return if (hasCollectedReport) { + problemReportPath.readLines() + } else { + listOf("Failed to collect logs for problem report") } - - return result } - private suspend fun doDelete() { - problemReportPath.await().delete() - isCollected = false + fun deleteReport() { + problemReportPath.delete() + hasCollectedReport = false } + // TODO We should remove the external functions from this class and migrate it to the service private external fun collectReport(logDirectory: String, reportPath: String): Boolean private external fun sendProblemReport( 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 987a55b45f7b..18e98964e8d3 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 { 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/MainActivity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt index fa88696cd9d8..3d30d2884576 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 = 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 d31c5551b555..000000000000 --- 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? = 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