diff --git a/app/build.gradle b/app/build.gradle
index 3ac6bc98..a832c262 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -64,10 +64,14 @@ android {
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
buildConfigField "String", "SENTRY_DSN", "\"${property('revoltbuild.properties', 'sentry.dsn', 'RVX_SENTRY_DSN')}\""
+ buildConfigField "Boolean", "ANALYSIS_ENABLED", "${property('revoltbuild.properties', 'analysis.enabled', 'RVX_ANALYSIS_ENABLED')}"
+ buildConfigField "String", "ANALYSIS_BASEURL", "\"${property('revoltbuild.properties', 'analysis.base_url', 'RVX_ANALYSIS_BASEURL')}\""
}
debug {
buildConfigField "String", "SENTRY_DSN", "\"${property('revoltbuild.properties', 'sentry.dsn', 'RVX_SENTRY_DSN')}\""
+ buildConfigField "Boolean", "ANALYSIS_ENABLED", "${property('revoltbuild.properties', 'analysis.enabled', 'RVX_ANALYSIS_ENABLED')}"
+ buildConfigField "String", "ANALYSIS_BASEURL", "\"${property('revoltbuild.properties', 'analysis.base_url', 'RVX_ANALYSIS_BASEURL')}\""
}
}
compileOptions {
@@ -112,9 +116,9 @@ dependencies {
implementation 'androidx.compose.material:material'
implementation 'androidx.compose.material3:material3'
implementation "androidx.compose.ui:ui-tooling-preview"
- implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.0'
- implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0'
- implementation 'androidx.activity:activity-compose:1.6.1'
+ implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
+ implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1'
+ implementation 'androidx.activity:activity-compose:1.7.0'
// Accompanist - Jetpack Compose Extensions
implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version"
diff --git a/app/src/main/java/chat/revolt/MainActivity.kt b/app/src/main/java/chat/revolt/MainActivity.kt
index 79966863..93d04258 100644
--- a/app/src/main/java/chat/revolt/MainActivity.kt
+++ b/app/src/main/java/chat/revolt/MainActivity.kt
@@ -14,12 +14,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
+import androidx.navigation.compose.dialog
import chat.revolt.api.settings.GlobalState
import chat.revolt.screens.SplashScreen
import chat.revolt.screens.about.AboutScreen
import chat.revolt.screens.about.AttributionScreen
import chat.revolt.screens.about.PlaceholderScreen
import chat.revolt.screens.chat.ChatRouterScreen
+import chat.revolt.screens.chat.dialogs.FeedbackDialog
import chat.revolt.screens.login.GreeterScreen
import chat.revolt.screens.login.LoginScreen
import chat.revolt.screens.login.MfaScreen
@@ -111,6 +113,7 @@ fun AppEntrypoint() {
composable("settings") { SettingsScreen(navController) }
composable("settings/appearance") { AppearanceSettingsScreen(navController) }
+ dialog("settings/feedback") { FeedbackDialog(navController) }
composable("about") { AboutScreen(navController) }
composable("about/oss") { AttributionScreen(navController) }
diff --git a/app/src/main/java/chat/revolt/api/RevoltAPI.kt b/app/src/main/java/chat/revolt/api/RevoltAPI.kt
index dae85751..8dafbca4 100644
--- a/app/src/main/java/chat/revolt/api/RevoltAPI.kt
+++ b/app/src/main/java/chat/revolt/api/RevoltAPI.kt
@@ -4,18 +4,27 @@ import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.compose.runtime.mutableStateMapOf
+import chat.revolt.BuildConfig
import chat.revolt.api.realtime.DisconnectionState
import chat.revolt.api.realtime.RealtimeSocket
import chat.revolt.api.routes.user.fetchSelf
-import chat.revolt.api.schemas.*
+import chat.revolt.api.schemas.Channel
+import chat.revolt.api.schemas.Emoji
+import chat.revolt.api.schemas.Message
+import chat.revolt.api.schemas.Server
+import chat.revolt.api.schemas.User
import chat.revolt.api.unreads.Unreads
-import io.ktor.client.*
-import io.ktor.client.engine.okhttp.*
-import io.ktor.client.plugins.*
-import io.ktor.client.plugins.contentnegotiation.*
-import io.ktor.client.plugins.logging.*
-import io.ktor.client.plugins.websocket.*
-import io.ktor.serialization.kotlinx.json.*
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.okhttp.OkHttp
+import io.ktor.client.plugins.DefaultRequest
+import io.ktor.client.plugins.HttpRequestRetry
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.defaultRequest
+import io.ktor.client.plugins.logging.LogLevel
+import io.ktor.client.plugins.logging.Logging
+import io.ktor.client.plugins.websocket.WebSockets
+import io.ktor.client.request.header
+import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -61,6 +70,10 @@ val RevoltHttp = HttpClient(OkHttp) {
defaultRequest {
url(REVOLT_BASE)
+ header(
+ "User-Agent",
+ "Ktor RevoltAndroid/${BuildConfig.VERSION_NAME} (Android ${android.os.Build.VERSION.SDK_INT}; ${android.os.Build.MANUFACTURER} ${android.os.Build.DEVICE})"
+ )
}
}
diff --git a/app/src/main/java/chat/revolt/components/generic/FormTextField.kt b/app/src/main/java/chat/revolt/components/generic/FormTextField.kt
index 1aa610ad..8cd224fb 100644
--- a/app/src/main/java/chat/revolt/components/generic/FormTextField.kt
+++ b/app/src/main/java/chat/revolt/components/generic/FormTextField.kt
@@ -10,6 +10,9 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
+/**
+ * Convenience wrapper around [TextField] that sets the [KeyboardOptions] and [VisualTransformation]
+ */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FormTextField(
@@ -18,14 +21,19 @@ fun FormTextField(
onChange: (it: String) -> Unit,
modifier: Modifier = Modifier,
type: KeyboardType = KeyboardType.Text,
+ supportingText: @Composable (() -> Unit)? = null,
+ singleLine: Boolean = true,
+ enabled: Boolean = true,
) {
TextField(
value = value,
onValueChange = onChange,
- singleLine = true,
+ singleLine = singleLine,
keyboardOptions = KeyboardOptions(keyboardType = type),
visualTransformation = if (type == KeyboardType.Password) PasswordVisualTransformation() else VisualTransformation.None,
label = { Text(label) },
+ supportingText = supportingText,
+ enabled = enabled,
modifier = modifier
)
}
\ No newline at end of file
diff --git a/app/src/main/java/chat/revolt/screens/chat/dialogs/FeedbackDialog.kt b/app/src/main/java/chat/revolt/screens/chat/dialogs/FeedbackDialog.kt
new file mode 100644
index 00000000..496fa1b2
--- /dev/null
+++ b/app/src/main/java/chat/revolt/screens/chat/dialogs/FeedbackDialog.kt
@@ -0,0 +1,268 @@
+package chat.revolt.screens.chat.dialogs
+
+import android.util.Log
+import android.widget.Toast
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconToggleButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavController
+import chat.revolt.BuildConfig
+import chat.revolt.R
+import chat.revolt.api.REVOLT_BASE
+import chat.revolt.api.RevoltAPI
+import chat.revolt.api.RevoltHttp
+import chat.revolt.components.generic.FormTextField
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.client.statement.bodyAsText
+import io.ktor.http.ContentType
+import io.ktor.http.contentType
+import kotlinx.coroutines.launch
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+enum class FeedbackType(val value: String) {
+ Satisfaction("satisfaction"),
+ FeatureRequest("featurerequest"),
+ BugReport("bug"),
+ UxFeedback("ux"),
+ Performance("performance"),
+ Security("security"),
+ Other("other")
+}
+
+@Serializable
+data class FeedbackBody(
+ val type: String,
+ val message: String,
+ val api_host: String,
+ val app_id: String,
+ val app_version: String,
+ val app_build: String,
+ val android_api: String,
+ val android_device: String,
+ val android_manufacturer: String,
+ @SerialName("id_for_spam_protection_pls_dont_spam_but_if_you_do_i_will_know")
+ val author: String
+)
+
+suspend fun sendFeedback(type: FeedbackType, message: String): String {
+ val response = RevoltHttp.post("${BuildConfig.ANALYSIS_BASEURL}/api/feedback/android") {
+ setBody(
+ FeedbackBody(
+ type = type.value,
+ message = message,
+ api_host = REVOLT_BASE,
+ app_id = BuildConfig.APPLICATION_ID,
+ app_version = BuildConfig.VERSION_NAME,
+ app_build = BuildConfig.VERSION_CODE.toString(),
+ android_api = android.os.Build.VERSION.SDK_INT.toString(),
+ android_device = android.os.Build.DEVICE,
+ android_manufacturer = android.os.Build.MANUFACTURER,
+ author = RevoltAPI.selfId ?: "RevoltAPI.selfId is null"
+ )
+ )
+ contentType(ContentType.Application.Json)
+ }
+
+ Log.d("FeedbackDialog", "Feedback sent: ${response.bodyAsText()}")
+
+ return response.bodyAsText()
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
+@Composable
+fun FeedbackDialog(navController: NavController) {
+ if (!BuildConfig.ANALYSIS_ENABLED) {
+ AlertDialog(onDismissRequest = {
+ navController.popBackStack()
+ }, title = {
+ Text(
+ text = stringResource(id = R.string.settings_feedback_disabled_title),
+ modifier = Modifier.fillMaxWidth()
+ )
+ }, text = {
+ Text(text = stringResource(id = R.string.settings_feedback_disabled_message))
+ }, confirmButton = {
+ TextButton(onClick = {
+ navController.popBackStack()
+ }) {
+ Text(text = stringResource(id = R.string.ok))
+ }
+ })
+ return
+ }
+
+ val category = mapOf(
+ FeedbackType.Satisfaction to stringResource(R.string.settings_feedback_category_satisfaction),
+ FeedbackType.FeatureRequest to stringResource(R.string.settings_feedback_category_feature),
+ FeedbackType.BugReport to stringResource(R.string.settings_feedback_category_bug),
+ FeedbackType.UxFeedback to stringResource(R.string.settings_feedback_category_ux),
+ FeedbackType.Performance to stringResource(R.string.settings_feedback_category_performance),
+ FeedbackType.Security to stringResource(R.string.settings_feedback_category_security),
+ FeedbackType.Other to stringResource(R.string.settings_feedback_category_other)
+ )
+ val categoryDropdownExpanded = remember { mutableStateOf(false) }
+ val categoryDropdownSelected = remember { mutableStateOf(FeedbackType.Satisfaction) }
+ val message = remember { mutableStateOf("") }
+ val error = remember { mutableStateOf("") }
+ val sending = remember { mutableStateOf(false) }
+
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+
+ AlertDialog(
+ onDismissRequest = {
+ navController.popBackStack()
+ },
+ title = {
+ Text(
+ text = stringResource(id = R.string.settings_feedback),
+ modifier = Modifier.fillMaxWidth()
+ )
+ },
+ text = {
+ Column {
+ Text(
+ text = stringResource(id = R.string.settings_feedback_introduction)
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Box {
+ TextField(
+ value = category[categoryDropdownSelected.value]
+ ?: stringResource(id = R.string.unknown),
+ onValueChange = {},
+ label = {
+ Text(
+ text = stringResource(id = R.string.settings_feedback_category),
+ )
+ },
+ readOnly = true,
+ trailingIcon = {
+ IconToggleButton(
+ checked = categoryDropdownExpanded.value,
+ onCheckedChange = {
+ categoryDropdownExpanded.value = it
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Default.ArrowDropDown,
+ contentDescription = stringResource(id = R.string.settings_feedback_category)
+ )
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ DropdownMenu(
+ expanded = categoryDropdownExpanded.value,
+ onDismissRequest = {
+ categoryDropdownExpanded.value = false
+ },
+ ) {
+ category.forEach { (key, value) ->
+ DropdownMenuItem(
+ text = {
+ Text(text = value)
+ },
+ onClick = {
+ categoryDropdownSelected.value = key
+ categoryDropdownExpanded.value = false
+ }
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ FormTextField(
+ value = message.value,
+ label = stringResource(id = R.string.settings_feedback_message),
+ onChange = {
+ message.value = it
+ },
+ supportingText = {
+ Text(
+ text = "${message.value.length}/1250",
+ )
+ },
+ enabled = !sending.value,
+ singleLine = false,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = {
+ navController.popBackStack()
+ },
+ modifier = Modifier.testTag("feedback_cancel"),
+ enabled = !sending.value,
+ ) {
+ Text(text = stringResource(id = R.string.cancel))
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ if (message.value.length > 1250) {
+ error.value =
+ context.getString(R.string.settings_feedback_message_too_long, 1250)
+ } else {
+ error.value = ""
+ sending.value = true
+ scope.launch {
+ try {
+ val result =
+ sendFeedback(categoryDropdownSelected.value, message.value)
+ Log.d("FeedbackDialog", "Feedback sent with result: $result")
+
+ if (result.isBlank()) {
+ navController.popBackStack()
+ } else {
+ error.value = result
+ Toast.makeText(context, error.value, Toast.LENGTH_SHORT).show()
+ }
+ } catch (e: Exception) {
+ Log.e("FeedbackDialog", "Error sending feedback", e)
+ error.value = context.getString(R.string.settings_feedback_error)
+ } finally {
+ sending.value = false
+ }
+ }
+ }
+ },
+ enabled = !sending.value && message.value.isNotBlank(),
+ modifier = Modifier.testTag("feedback_submit")
+ ) {
+ Text(text = stringResource(id = R.string.report_submit))
+ }
+ }
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportMessageDialog.kt b/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportMessageDialog.kt
index 83bef37e..19835ca5 100644
--- a/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportMessageDialog.kt
+++ b/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportMessageDialog.kt
@@ -30,7 +30,7 @@ import chat.revolt.components.chat.Message
import chat.revolt.components.generic.FormTextField
import kotlinx.coroutines.launch
-enum class ReportingState {
+enum class ReportFlowState {
Reason,
Sending,
Done,
@@ -52,13 +52,13 @@ fun ReportMessageDialog(
val author = RevoltAPI.userCache[message.author]
val messageIsBridged = author?.let { author.bot != null && message.masquerade != null } ?: false
- val state = remember { mutableStateOf(ReportingState.Reason) }
+ val state = remember { mutableStateOf(ReportFlowState.Reason) }
val selectedReason = remember { mutableStateOf("Illegal") }
val userAddedContext = remember { mutableStateOf("") }
when (state.value) {
- ReportingState.Reason -> {
+ ReportFlowState.Reason -> {
val reasons = mapOf(
"Illegal" to stringResource(id = R.string.report_reason_content_illegal),
"PromotesHarm" to stringResource(id = R.string.report_reason_content_promotes_harm),
@@ -169,19 +169,18 @@ fun ReportMessageDialog(
Spacer(modifier = Modifier.height(16.dp))
- Text(
- text = stringResource(id = R.string.report_reason_additional_hint),
- fontSize = 12.sp,
- )
-
- Spacer(modifier = Modifier.height(4.dp))
-
FormTextField(
value = userAddedContext.value,
label = stringResource(id = R.string.report_reason_additional),
onChange = {
userAddedContext.value = it
- })
+ },
+ supportingText = {
+ Text(
+ text = stringResource(id = R.string.report_reason_additional_hint)
+ )
+ }
+ )
}
},
dismissButton = {
@@ -197,7 +196,7 @@ fun ReportMessageDialog(
confirmButton = {
TextButton(
onClick = {
- state.value = ReportingState.Sending
+ state.value = ReportFlowState.Sending
},
modifier = Modifier.testTag("report_send")
) {
@@ -207,7 +206,7 @@ fun ReportMessageDialog(
)
}
- ReportingState.Sending -> {
+ ReportFlowState.Sending -> {
AlertDialog(
onDismissRequest = {},
title = {
@@ -231,9 +230,9 @@ fun ReportMessageDialog(
ContentReportReason.valueOf(selectedReason.value),
userAddedContext.value
)
- state.value = ReportingState.Done
+ state.value = ReportFlowState.Done
} catch (e: Exception) {
- state.value = ReportingState.Error
+ state.value = ReportFlowState.Error
Log.e("ReportMessageDialog", "Failed to report message", e)
return@launch
}
@@ -246,7 +245,7 @@ fun ReportMessageDialog(
)
}
- ReportingState.Done -> {
+ ReportFlowState.Done -> {
val scope = rememberCoroutineScope()
AlertDialog(
@@ -315,7 +314,7 @@ fun ReportMessageDialog(
)
}
- ReportingState.Error -> {
+ ReportFlowState.Error -> {
AlertDialog(
onDismissRequest = {
navController.popBackStack()
diff --git a/app/src/main/java/chat/revolt/screens/settings/SettingsScreen.kt b/app/src/main/java/chat/revolt/screens/settings/SettingsScreen.kt
index 37cb3bbc..abb16071 100644
--- a/app/src/main/java/chat/revolt/screens/settings/SettingsScreen.kt
+++ b/app/src/main/java/chat/revolt/screens/settings/SettingsScreen.kt
@@ -1,10 +1,14 @@
package chat.revolt.screens.settings
+import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Build
+import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Info
+import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -73,6 +77,40 @@ fun SettingsScreen(
) {
navController.navigate("about")
}
+
+ Divider()
+
+ SheetClickable(
+ icon = { modifier ->
+ Icon(
+ imageVector = Icons.Default.Build,
+ contentDescription = stringResource(id = R.string.settings_feedback),
+ modifier = modifier
+ )
+ },
+ label = { textStyle ->
+ Text(text = stringResource(id = R.string.settings_feedback), style = textStyle)
+ },
+ modifier = Modifier.testTag("settings_view_feedback")
+ ) {
+ navController.navigate("settings/feedback")
+ }
+
+ SheetClickable(
+ icon = { modifier ->
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = stringResource(id = R.string.logout),
+ modifier = modifier
+ )
+ },
+ label = { textStyle ->
+ Text(text = stringResource(id = R.string.logout), style = textStyle)
+ },
+ modifier = Modifier.testTag("settings_view_logout")
+ ) {
+ Toast.makeText(navController.context, "Not implemented yet", Toast.LENGTH_SHORT).show()
+ }
}
}
}
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 38aa0f94..b1387370 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -211,4 +211,21 @@
Material You
Material You (unsupported)
Material You is not supported on this device.
+
+ Feedback
+ Any feedback you have for Revolt is greatly appreciated and all feedback is read by the development team of our Android app.
+ Category
+ General-purpose feedback
+ Feature request
+ Bug report
+ User experience (UI/UX)
+ Performance
+ Security
+ Other
+ Message
+ Message is too long. Please shorten it to %1$d characters or less.
+ An error occurred while sending your feedback. Please try again later.
+
+ Feedback unavailable
+ Feedback is not available on this build of Revolt. Support for self-built versions is limited.
diff --git a/revoltbuild.properties.example b/revoltbuild.properties.example
index 295859d3..01e53abc 100644
--- a/revoltbuild.properties.example
+++ b/revoltbuild.properties.example
@@ -1 +1,3 @@
-sentry.dsn=
\ No newline at end of file
+sentry.dsn=
+analysis.enabled=false
+analysis.base_url=
\ No newline at end of file