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 | |
| parent | 66059ddae495b1fb173adb33cc950f6840937bb3 (diff) | |
| parent | c83c55f3ae3fffab104cfc14592ab026f920b658 (diff) | |
| download | mullvadvpn-53b7f1ceffecae15eb8261c86399867e51c3978f.tar.xz mullvadvpn-53b7f1ceffecae15eb8261c86399867e51c3978f.zip | |
Merge branch 'migrate-problem-report-view-to-compose-droid-58'
27 files changed, 945 insertions, 1004 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index c84df59a90..aaa69f9184 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,8 @@ Line wrap the file at 100 chars. Th - Migrate out of time view to compose. - Migrate login view to compose. - Add Social media to content blockers. +- Migrate Report Problem view to compose. +- Migrate View Logs view to compose. #### Linux - Don't block forwarding of traffic when the split tunnel mark (ct mark) is set. 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)) + ) + } + } +} diff --git a/android/lib/resource/src/main/res/drawable/icon_fail.xml b/android/lib/resource/src/main/res/drawable/icon_fail.xml index b3bb63843b..1bb4906a33 100644 --- a/android/lib/resource/src/main/res/drawable/icon_fail.xml +++ b/android/lib/resource/src/main/res/drawable/icon_fail.xml @@ -1,13 +1,12 @@ -<?xml version="1.0" encoding="utf-8"?> <vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="60dp" - android:height="60dp" - android:viewportWidth="60.0" - android:viewportHeight="60.0"> - <group> - <path android:fillColor="#FFFFFF" - android:pathData="M8 30 a 22,22 0 1,0 44,0 a 22,22 0 1,0 -44,0 Z" /> - <path android:fillColor="#E34039" - android:pathData="M33.2371523,30 L41.337119,21.9033278 C42.2203329,21.020473 42.223948,19.5681264 41.3300331,18.6745751 C40.429886,17.774794 38.9899682,17.7778525 38.0999667,18.6674921 L30,26.7641643 L21.9000333,18.6674921 C21.0100318,17.7778525 19.570114,17.774794 18.6699669,18.6745751 C17.776052,19.5681264 17.7796671,21.020473 18.662881,21.9033278 L26.7628477,30 L18.662881,38.0966722 C17.7796671,38.979527 17.776052,40.4318736 18.6699669,41.3254249 C19.570114,42.225206 21.0100318,42.2221475 21.9000333,41.3325079 L30,33.2358357 L38.0999667,41.3325079 C38.9899682,42.2221475 40.429886,42.225206 41.3300331,41.3254249 C42.223948,40.4318736 42.2203329,38.979527 41.337119,38.0966722 L33.2371523,30 Z" /> - </group> + android:width="44dp" + android:height="44dp" + android:viewportWidth="44" + android:viewportHeight="44"> + <path + android:pathData="M22,22m-22,0a22,22 0,1 1,44 0a22,22 0,1 1,-44 0" + android:fillColor="#fff"/> + <path + android:pathData="m25.147,22 l7.875,-7.872A2.225,2.225 0,0 0,29.875 10.982l-7.875,7.872L14.125,10.982A2.225,2.225 0,0 0,10.977 14.128l7.875,7.872 -7.875,7.871a2.225,2.225 0,0 0,3.147 3.146l7.875,-7.872 7.875,7.872a2.225,2.225 0,0 0,3.147 -3.146l-7.875,-7.872z" + android:fillColor="#e34039"/> </vector> diff --git a/android/lib/resource/src/main/res/drawable/icon_success.xml b/android/lib/resource/src/main/res/drawable/icon_success.xml index 4f5fdaae34..fc8627e9b6 100644 --- a/android/lib/resource/src/main/res/drawable/icon_success.xml +++ b/android/lib/resource/src/main/res/drawable/icon_success.xml @@ -1,13 +1,13 @@ <?xml version="1.0" encoding="utf-8"?> <vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="60dp" - android:height="60dp" - android:viewportWidth="60.0" - android:viewportHeight="60.0"> - <group> - <path android:fillColor="#FFFFFF" - android:pathData="M8 30 a 22,22 0 1,0 44,0 a 22,22 0 1,0 -44,0 Z" /> - <path android:fillColor="#44AD4D" - android:pathData="M19.4142136,28.5857864 C18.633165,27.8047379 17.366835,27.8047379 16.5857864,28.5857864 C15.8047379,29.366835 15.8047379,30.633165 16.5857864,31.4142136 L24.5857864,39.4142136 C25.366835,40.1952621 26.633165,40.1952621 27.4142136,39.4142136 L43.4142136,23.4142136 C44.1952621,22.633165 44.1952621,21.366835 43.4142136,20.5857864 C42.633165,19.8047379 41.366835,19.8047379 40.5857864,20.5857864 L26,35.1715729 L19.4142136,28.5857864 Z" /> - </group> + android:width="44dp" + android:height="44dp" + android:viewportWidth="44.0" + android:viewportHeight="44.0"> + <path + android:fillColor="#fff" + android:pathData="M22,22m-22,0a22,22 0,1 1,44 0a22,22 0,1 1,-44 0" /> + <path + android:fillColor="#44AD4D" + android:pathData="M11.4142136,20.5857864 C10.633165,19.8047379 9.366835,19.8047379 8.5857864,20.5857864 C7.8047379,21.366835 7.8047379,22.633165 8.5857864,23.4142136 L16.5857864,31.4142136 C17.366835,32.1952621 18.633165,32.1952621 19.4142136,31.4142136 L35.4142136,15.4142136 C36.1952621,14.633165 36.1952621,13.366835 35.4142136,12.5857864 C34.633165,11.8047379 33.366835,11.8047379 32.5857864,12.5857864 L18,27.1715729 L11.4142136,20.5857864 Z" /> </vector> diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt index 99c2132719..c3fd722a12 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt @@ -20,6 +20,7 @@ data class Dimensions( val countryRowPadding: Dp = 18.dp, val customPortBoxMinWidth: Dp = 80.dp, val dialogIconHeight: Dp = 44.dp, + val dialogIconSize: Dp = 48.dp, val expandableCellChevronSize: Dp = 30.dp, val iconFailSuccessSize: Dp = 60.dp, val iconFailSuccessTopMargin: Dp = 30.dp, @@ -34,12 +35,13 @@ data class Dimensions( val loadingSpinnerSize: Dp = 24.dp, val loadingSpinnerSizeMedium: Dp = 28.dp, val loadingSpinnerStrokeWidth: Dp = 6.dp, - val loginIconContainerSize: Dp = 60.dp, + val loginIconContainerSize: Dp = 44.dp, val mediumPadding: Dp = 16.dp, val notificationBannerEndPadding: Dp = 12.dp, val notificationBannerStartPadding: Dp = 16.dp, val notificationEndIconPadding: Dp = 4.dp, val notificationStatusIconSize: Dp = 10.dp, + val problemReportIconToTitlePadding: Dp = 60.dp, val progressIndicatorSize: Dp = 48.dp, val relayCircleSize: Dp = 16.dp, val relayRowPadding: Dp = 50.dp, @@ -56,7 +58,7 @@ data class Dimensions( val titleIconSize: Dp = 24.dp, val topBarHeight: Dp = 64.dp, val verticalSpace: Dp = 20.dp, - val verticalSpacer: Dp = 1.dp + val verticalSpacer: Dp = 1.dp, ) val defaultDimensions = Dimensions() |
