summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDavid Göransson <david.goransson90@gmail.com>2023-09-21 14:30:47 +0200
committerDavid Göransson <david.goransson90@gmail.com>2023-10-04 09:19:41 +0200
commit3c54cb0a604226fae03ccb1880ed0ccda1d388ea (patch)
treeed0675c81484ca989c491589f51f96f687593de2
parent66059ddae495b1fb173adb33cc950f6840937bb3 (diff)
downloadmullvadvpn-3c54cb0a604226fae03ccb1880ed0ccda1d388ea.tar.xz
mullvadvpn-3c54cb0a604226fae03ccb1880ed0ccda1d388ea.zip
Create view in compose
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scrollbar.kt46
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemNoEmailDialog.kt86
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemStateDialog.kt221
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt18
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt183
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt115
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/TextFieldColors.kt24
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/MinLoadingConstant.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/dataproxy/MullvadProblemReport.kt151
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt7
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConfirmNoEmailDialogFragment.kt64
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ProblemReportFragment.kt291
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ViewLogsFragment.kt50
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt81
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ViewLogsViewModel.kt33
17 files changed, 886 insertions, 495 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt
index e0f84db5ea..0d032f962a 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt
@@ -34,6 +34,7 @@ fun ScaffoldWithTopBar(
topBarColor: Color,
statusBarColor: Color,
navigationBarColor: Color,
+ modifier: Modifier = Modifier,
iconTintColor: Color = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaTopBar),
onSettingsClicked: (() -> Unit)?,
onAccountClicked: (() -> Unit)?,
@@ -48,6 +49,7 @@ fun ScaffoldWithTopBar(
}
Scaffold(
+ modifier = modifier,
topBar = {
TopBar(
backgroundColor = topBarColor,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scrollbar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scrollbar.kt
index 35ddc58592..52f2f1d726 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scrollbar.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scrollbar.kt
@@ -72,19 +72,33 @@ import kotlinx.coroutines.flow.collectLatest
fun Modifier.drawHorizontalScrollbar(
state: ScrollState,
reverseScrolling: Boolean = false
-): Modifier = drawScrollbar(state, Orientation.Horizontal, reverseScrolling)
+): Modifier = composed { drawScrollbar(state, Orientation.Horizontal, BarColor, reverseScrolling) }
fun Modifier.drawVerticalScrollbar(
state: ScrollState,
reverseScrolling: Boolean = false
-): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling)
+): Modifier = composed { drawScrollbar(state, Orientation.Vertical, BarColor, reverseScrolling) }
+
+fun Modifier.drawHorizontalScrollbar(
+ state: ScrollState,
+ color: Color,
+ reverseScrolling: Boolean = false
+): Modifier = drawScrollbar(state, Orientation.Horizontal, color, reverseScrolling)
+
+fun Modifier.drawVerticalScrollbar(
+ state: ScrollState,
+ color: Color,
+ reverseScrolling: Boolean = false
+): Modifier = drawScrollbar(state, Orientation.Vertical, color, reverseScrolling)
private fun Modifier.drawScrollbar(
state: ScrollState,
orientation: Orientation,
+ color: Color,
reverseScrolling: Boolean
): Modifier =
- drawScrollbar(orientation, reverseScrolling) { reverseDirection, atEnd, color, alpha ->
+ drawScrollbar(orientation, color, reverseScrolling) { reverseDirection, atEnd, paintColor, alpha
+ ->
if (state.maxValue > 0) {
val canvasSize = if (orientation == Orientation.Horizontal) size.width else size.height
val totalSize = canvasSize + state.maxValue
@@ -94,7 +108,7 @@ private fun Modifier.drawScrollbar(
orientation,
reverseDirection,
atEnd,
- color,
+ paintColor,
alpha,
thumbSize,
startOffset
@@ -105,19 +119,21 @@ private fun Modifier.drawScrollbar(
fun Modifier.drawHorizontalScrollbar(
state: LazyListState,
reverseScrolling: Boolean = false
-): Modifier = drawScrollbar(state, Orientation.Horizontal, reverseScrolling)
+): Modifier = composed { drawScrollbar(state, Orientation.Horizontal, BarColor, reverseScrolling) }
fun Modifier.drawVerticalScrollbar(
state: LazyListState,
reverseScrolling: Boolean = false
-): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling)
+): Modifier = composed { drawScrollbar(state, Orientation.Vertical, BarColor, reverseScrolling) }
private fun Modifier.drawScrollbar(
state: LazyListState,
orientation: Orientation,
+ color: Color,
reverseScrolling: Boolean
): Modifier =
- drawScrollbar(orientation, reverseScrolling) { reverseDirection, atEnd, color, alpha ->
+ drawScrollbar(orientation, color, reverseScrolling) { reverseDirection, atEnd, paintColor, alpha
+ ->
val layoutInfo = state.layoutInfo
val viewportSize = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset
val items = layoutInfo.visibleItemsInfo
@@ -137,7 +153,7 @@ private fun Modifier.drawScrollbar(
orientation,
reverseDirection,
atEnd,
- color,
+ paintColor,
alpha,
thumbSize,
startOffset
@@ -148,9 +164,14 @@ private fun Modifier.drawScrollbar(
fun Modifier.drawVerticalScrollbar(
state: LazyGridState,
spanCount: Int,
- reverseScrolling: Boolean = false
+ color: Color,
+ reverseScrolling: Boolean = false,
): Modifier =
- drawScrollbar(Orientation.Vertical, reverseScrolling) { reverseDirection, atEnd, color, alpha ->
+ drawScrollbar(Orientation.Vertical, color, reverseScrolling) {
+ reverseDirection,
+ atEnd,
+ paintColor,
+ alpha ->
val layoutInfo = state.layoutInfo
val viewportSize = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset
val items = layoutInfo.visibleItemsInfo
@@ -176,7 +197,7 @@ fun Modifier.drawVerticalScrollbar(
Orientation.Vertical,
reverseDirection,
atEnd,
- color,
+ paintColor,
alpha,
thumbSize,
startOffset
@@ -225,6 +246,7 @@ private fun DrawScope.drawScrollbar(
private fun Modifier.drawScrollbar(
orientation: Orientation,
+ color: Color,
reverseScrolling: Boolean,
onDraw:
DrawScope.(
@@ -269,8 +291,6 @@ private fun Modifier.drawScrollbar(
} else reverseScrolling
val atEnd = if (orientation == Orientation.Vertical) isLtr else true
- val color = BarColor
-
Modifier.nestedScroll(nestedScrollConnection).drawWithContent {
drawContent()
onDraw(reverseDirection, atEnd, color, alpha::value)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemNoEmailDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ReportProblemNoEmailDialog.kt
new file mode 100644
index 0000000000..ff9b658211
--- /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 0000000000..6a63423399
--- /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 3c4d9e1202..c02982427d 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 0000000000..007b7bacf2
--- /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 0000000000..80bb49f1af
--- /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 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/MinLoadingConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/MinLoadingConstant.kt
new file mode 100644
index 0000000000..a93422d3f6
--- /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 69fd7275e7..f36c9a4975 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<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) {
+ 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<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) {
-
- 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<String> {
+ 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 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/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..aba7bd860a 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,46 @@
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)
+// TODO
+// [ ] - Showing enable email secure screen?
+// [ ] - Decide to save logs?
- parentActivity = context as MainActivity
-
- problemReport = parentActivity.problemReport
- problemReport.collect()
- }
+class ProblemReportFragment : BaseFragment() {
+ 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)
+ ): View? {
- sendStatusLabel = view.findViewById<TextView>(R.id.send_status)
- sendDetailsLabel = view.findViewById<TextView>(R.id.send_details)
- responseMessageLabel = view.findViewById<TextView>(R.id.response_message)
-
- 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 +56,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/viewmodel/ReportProblemViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt
new file mode 100644
index 0000000000..c007ca5c8f
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ReportProblemViewModel.kt
@@ -0,0 +1,81 @@
+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_SPINNER_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()
+ if (shouldShowConfirmNoEmail(userEmail)) {
+ _uiState.update { it.copy(showConfirmNoEmail = true) }
+ } else {
+ _uiState.update {
+ it.copy(sendingState = SendingReportUiState.Sending, showConfirmNoEmail = false)
+ }
+
+ // Ensure we show loading for at least 500 ms
+ val deferredResult = async { mullvadProblemReporter.sendReport(UserReport(userEmail, description)) }
+ delay(MINIMUM_LOADING_SPINNER_TIME_MILLIS)
+
+ _uiState.update { it.copy(sendingState = deferredResult.await().toUiResult(userEmail)) }
+ }
+ }
+ }
+
+ fun clearSendResult() {
+ _uiState.update { it.copy(sendingState = null) }
+ }
+
+ fun dismissConfirmNoEmail() {
+ _uiState.update { it.copy(showConfirmNoEmail = false) }
+ }
+
+ private fun shouldShowConfirmNoEmail(userEmail: String): Boolean =
+ userEmail.isEmpty() &&
+ !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)
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ // Delete any logs if user leaves the screen
+ mullvadProblemReporter.deleteReport()
+ }
+}
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..cff6bf6043
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ViewLogsViewModel.kt
@@ -0,0 +1,33 @@
+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.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.
+ delay(500)
+ _uiState.update {
+ it.copy(
+ allLines = mullvadProblemReporter.readLogs().joinToString("\n"),
+ isLoading = false
+ )
+ }
+ }
+ }
+}