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