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