First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.rageshake.api"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.uiStrings)
}
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api
import kotlinx.coroutines.flow.Flow
fun interface RageshakeFeatureAvailability {
fun isAvailable(): Flow<Boolean>
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api.bugreport
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
interface BugReportEntryPoint : FeatureEntryPoint {
fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: Callback,
): Node
interface Callback : Plugin {
fun onDone()
}
}
@@ -0,0 +1,14 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api.crash
sealed interface CrashDetectionEvents {
data object ResetAllCrashData : CrashDetectionEvents
data object ResetAppHasCrashed : CrashDetectionEvents
}
@@ -0,0 +1,13 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api.crash
import io.element.android.libraries.architecture.Presenter
interface CrashDetectionPresenter : Presenter<CrashDetectionState>
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api.crash
data class CrashDetectionState(
val appName: String,
val crashDetected: Boolean,
val eventSink: (CrashDetectionEvents) -> Unit
)
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api.crash
fun aCrashDetectionState() = CrashDetectionState(
appName = "Element",
crashDetected = false,
eventSink = {}
)
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api.crash
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.element.android.features.rageshake.api.R
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun CrashDetectionView(
state: CrashDetectionState,
onOpenBugReport: () -> Unit = { },
) {
fun onPopupDismissed() {
state.eventSink(CrashDetectionEvents.ResetAllCrashData)
}
if (state.crashDetected) {
CrashDetectionContent(
appName = state.appName,
onYesClick = onOpenBugReport,
onNoClick = ::onPopupDismissed,
onDismiss = ::onPopupDismissed,
)
}
}
@Composable
private fun CrashDetectionContent(
appName: String,
onNoClick: () -> Unit = { },
onYesClick: () -> Unit = { },
onDismiss: () -> Unit = { },
) {
ConfirmationDialog(
title = stringResource(id = CommonStrings.action_report_bug),
content = stringResource(id = R.string.crash_detection_dialog_content, appName),
submitText = stringResource(id = CommonStrings.action_yes),
cancelText = stringResource(id = CommonStrings.action_no),
onCancelClick = onNoClick,
onSubmitClick = onYesClick,
onDismiss = onDismiss,
)
}
@PreviewsDayNight
@Composable
internal fun CrashDetectionViewPreview() = ElementPreview {
CrashDetectionView(
state = aCrashDetectionState().copy(crashDetected = true)
)
}
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api.detection
import io.element.android.features.rageshake.api.screenshot.ImageResult
sealed interface RageshakeDetectionEvents {
data object Dismiss : RageshakeDetectionEvents
data object Disable : RageshakeDetectionEvents
data object StartDetection : RageshakeDetectionEvents
data object StopDetection : RageshakeDetectionEvents
data class ProcessScreenshot(val imageResult: ImageResult) : RageshakeDetectionEvents
}
@@ -0,0 +1,13 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api.detection
import io.element.android.libraries.architecture.Presenter
interface RageshakeDetectionPresenter : Presenter<RageshakeDetectionState>
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api.detection
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
data class RageshakeDetectionState(
val takeScreenshot: Boolean,
val showDialog: Boolean,
val isStarted: Boolean,
val preferenceState: RageshakePreferencesState,
val eventSink: (RageshakeDetectionEvents) -> Unit
)
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api.detection
import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState
fun aRageshakeDetectionState() = RageshakeDetectionState(
takeScreenshot = false,
showDialog = false,
isStarted = false,
preferenceState = aRageshakePreferencesState(),
eventSink = {}
)
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api.detection
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.Lifecycle
import io.element.android.features.rageshake.api.R
import io.element.android.features.rageshake.api.screenshot.ImageResult
import io.element.android.features.rageshake.api.screenshot.screenshot
import io.element.android.libraries.androidutils.hardware.vibrate
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun RageshakeDetectionView(
state: RageshakeDetectionState,
onOpenBugReport: () -> Unit = { },
) {
val eventSink = state.eventSink
val context = LocalContext.current
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> eventSink(RageshakeDetectionEvents.StartDetection)
Lifecycle.Event.ON_PAUSE -> eventSink(RageshakeDetectionEvents.StopDetection)
else -> Unit
}
}
when {
state.takeScreenshot -> TakeScreenshot(
onScreenshot = { eventSink(RageshakeDetectionEvents.ProcessScreenshot(it)) }
)
state.showDialog -> {
LaunchedEffect(Unit) {
context.vibrate()
}
RageshakeDialogContent(
onNoClick = { eventSink(RageshakeDetectionEvents.Dismiss) },
onDisableClick = { eventSink(RageshakeDetectionEvents.Disable) },
onYesClick = onOpenBugReport
)
}
}
}
@Composable
private fun TakeScreenshot(
onScreenshot: (ImageResult) -> Unit
) {
val view = LocalView.current
val latestOnScreenshot by rememberUpdatedState(onScreenshot)
LaunchedEffect(Unit) {
view.screenshot {
latestOnScreenshot(it)
}
}
}
@Composable
private fun RageshakeDialogContent(
onNoClick: () -> Unit = { },
onDisableClick: () -> Unit = { },
onYesClick: () -> Unit = { },
) {
ConfirmationDialog(
title = stringResource(id = CommonStrings.action_report_bug),
content = stringResource(id = R.string.rageshake_detection_dialog_content),
thirdButtonText = stringResource(id = CommonStrings.action_disable),
submitText = stringResource(id = CommonStrings.action_yes),
cancelText = stringResource(id = CommonStrings.action_no),
onCancelClick = onNoClick,
onThirdButtonClick = onDisableClick,
onSubmitClick = onYesClick,
onDismiss = onNoClick,
)
}
@PreviewsDayNight
@Composable
internal fun RageshakeDialogContentPreview() = ElementPreview {
RageshakeDialogContent()
}
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api.logs
import java.io.File
interface LogFilesRemover {
/**
* Perform the log files removal.
* @param predicate a predicate to filter the files to remove. By default, all files are removed.
*/
suspend fun perform(predicate: (File) -> Boolean = { true })
}
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api.logs
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration
fun BugReporter.createWriteToFilesConfiguration(): WriteToFilesConfiguration {
return WriteToFilesConfiguration.Enabled(
directory = logDirectory().absolutePath,
filenamePrefix = "logs",
// Keep a maximum of 1 week of log files.
numberOfFiles = 7 * 24,
)
}
@@ -0,0 +1,14 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api.preferences
sealed interface RageshakePreferencesEvents {
data class SetSensitivity(val sensitivity: Float) : RageshakePreferencesEvents
data class SetIsEnabled(val isEnabled: Boolean) : RageshakePreferencesEvents
}
@@ -0,0 +1,13 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api.preferences
import io.element.android.libraries.architecture.Presenter
interface RageshakePreferencesPresenter : Presenter<RageshakePreferencesState>
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api.preferences
data class RageshakePreferencesState(
val isFeatureEnabled: Boolean,
val isEnabled: Boolean,
val isSupported: Boolean,
val sensitivity: Float,
val eventSink: (RageshakePreferencesEvents) -> Unit,
)
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api.preferences
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class RageshakePreferencesStateProvider : PreviewParameterProvider<RageshakePreferencesState> {
override val values: Sequence<RageshakePreferencesState>
get() = sequenceOf(
aRageshakePreferencesState(isEnabled = true, isSupported = true, sensitivity = 0.5f),
aRageshakePreferencesState(isEnabled = true, isSupported = false, sensitivity = 0.5f),
)
}
fun aRageshakePreferencesState(
isFeatureEnabled: Boolean = true,
isEnabled: Boolean = false,
isSupported: Boolean = true,
sensitivity: Float = 0.3f,
eventSink: (RageshakePreferencesEvents) -> Unit = {}
) = RageshakePreferencesState(
isFeatureEnabled = isFeatureEnabled,
isEnabled = isEnabled,
isSupported = isSupported,
sensitivity = sensitivity,
eventSink = eventSink,
)
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api.preferences
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.rageshake.api.R
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceSlide
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun RageshakePreferencesView(
state: RageshakePreferencesState,
modifier: Modifier = Modifier,
) {
fun onSensitivityChanged(sensitivity: Float) {
state.eventSink(RageshakePreferencesEvents.SetSensitivity(sensitivity = sensitivity))
}
fun onEnabledChanged(isEnabled: Boolean) {
state.eventSink(RageshakePreferencesEvents.SetIsEnabled(isEnabled = isEnabled))
}
Column(modifier = modifier) {
if (state.isFeatureEnabled) {
PreferenceCategory(title = stringResource(id = R.string.settings_rageshake)) {
if (state.isSupported) {
PreferenceSwitch(
title = stringResource(id = CommonStrings.preference_rageshake),
isChecked = state.isEnabled,
onCheckedChange = ::onEnabledChanged
)
PreferenceSlide(
title = stringResource(id = R.string.settings_rageshake_detection_threshold),
// summary = stringResource(id = CommonStrings.settings_rageshake_detection_threshold_summary),
value = state.sensitivity,
enabled = state.isEnabled,
// 5 possible values - steps are in ]0, 1[
steps = 3,
onValueChange = ::onSensitivityChanged
)
} else {
ListItem(
headlineContent = {
Text("Rageshaking is not supported by your device")
},
)
}
}
}
}
}
@PreviewsDayNight
@Composable
internal fun RageshakePreferencesViewPreview(@PreviewParameter(RageshakePreferencesStateProvider::class) state: RageshakePreferencesState) = ElementPreview {
RageshakePreferencesView(state)
}
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api.reporter
import java.io.File
interface BugReporter {
/**
* Send a bug report.
*
* @param withDevicesLogs true to include the device log
* @param withCrashLogs true to include the crash logs
* @param withScreenshot true to include the screenshot
* @param problemDescription the bug description
* @param canContact true if the user opt in to be contacted directly
* @param sendPushRules true to include the push rules
* @param listener the listener
*/
suspend fun sendBugReport(
withDevicesLogs: Boolean,
withCrashLogs: Boolean,
withScreenshot: Boolean,
problemDescription: String,
canContact: Boolean = false,
sendPushRules: Boolean = false,
listener: BugReporterListener
)
/**
* Provide the log directory.
*/
fun logDirectory(): File
/**
* Set the current tracing log level.
*/
fun setCurrentTracingLogLevel(logLevel: String)
/**
* Save the logcat.
*/
fun saveLogCat(): File?
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api.reporter
/**
* Bug report upload listener.
*/
interface BugReporterListener {
/**
* The bug report has been cancelled.
*/
fun onUploadCancelled()
/**
* The bug report upload failed.
*
* @param reason the failure reason
*/
fun onUploadFailed(reason: String?)
/**
* The upload progress (in percent).
*
* @param progress the upload progress
*/
fun onProgress(progress: Int)
/**
* The bug report upload succeeded.
*/
fun onUploadSucceed()
}
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.api.screenshot
import android.app.Activity
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.PixelCopy
import android.view.View
fun View.screenshot(bitmapCallback: (ImageResult) -> Unit) {
try {
val handler = Handler(Looper.getMainLooper())
val bitmap = Bitmap.createBitmap(
width,
height,
Bitmap.Config.ARGB_8888,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
PixelCopy.request(
(this.context as Activity).window,
clipBounds,
bitmap,
{
when (it) {
PixelCopy.SUCCESS -> {
bitmapCallback.invoke(ImageResult.Success(bitmap))
}
else -> {
bitmapCallback.invoke(ImageResult.Error(Exception(it.toString())))
}
}
},
handler
)
} else {
handler.post {
val canvas = Canvas(bitmap)
.apply {
translate(-clipBounds.left.toFloat(), -clipBounds.top.toFloat())
}
this.draw(canvas)
canvas.setBitmap(null)
bitmapCallback.invoke(ImageResult.Success(bitmap))
}
}
} catch (e: Exception) {
bitmapCallback.invoke(ImageResult.Error(e))
}
}
sealed interface ImageResult {
data class Error(val exception: Exception) : ImageResult
data class Success(val data: Bitmap) : ImageResult
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"Пры апошнім выкарыстанні %1$s адбыўся збой. Хочаце падзяліцца справаздачай аб збоі?"</string>
<string name="rageshake_detection_dialog_content">"Падобна, што вы трасеце тэлефон. Хочаце адкрыць экран паведамлення пра памылку?"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Парог выяўлення"</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s се срина при последното използване. Искате ли да споделите доклад за срива с нас?"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s havaroval při posledním použití. Chcete se s námi podělit o zprávu o selhání?"</string>
<string name="rageshake_detection_dialog_content">"Zdá se, že frustrovaně třesete telefonem. Chcete nahlásit chybu?"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Práh detekce"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"Chwalodd %1$s y tro diwethaf iddo gael ei ddefnyddio. Hoffech chi rannu adroddiad gwall gyda ni?"</string>
<string name="rageshake_detection_dialog_content">"Mae\'n ymddangos eich bod yn ysgwyd y ffôn mewn rhwystredigaeth. Hoffech chi agor y sgrin adrodd gwallau?"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Trothwy canfod"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s crashede sidste gang den blev brugt. Vil du dele en ulykkesrapport med os?"</string>
<string name="rageshake_detection_dialog_content">"Det ser ud til, at du ryster telefonen i frustration. Vil du åbne fejlrapporteringsskærmen?"</string>
<string name="settings_rageshake">"Ryst enheden i frustration"</string>
<string name="settings_rageshake_detection_threshold">"Tærskel for registrering"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s ist bei der letzten Nutzung abgestürzt. Möchtest du einen Absturzbericht mit uns teilen?"</string>
<string name="rageshake_detection_dialog_content">"Du scheinst das Telefon aus Frustration zu schütteln. Möchtest du den Bildschirm für Fehlerberichte öffnen?"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Erkennungsschwelle"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"Το %1$s διακόπηκε την τελευταία φορά που χρησιμοποιήθηκε. Θα \'θελες να μοιραστείς μια αναφορά σφάλματος μαζί μας;"</string>
<string name="rageshake_detection_dialog_content">"Φαίνεται να κουνάς το τηλέφωνο με σύγχυση. Θες να ανοίξεις την οθόνη αναφοράς σφαλμάτων;"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Όριο ανίχνευσης"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s se cerró inesperadamente la última vez que se lo usaste. ¿Quieres compartir un informe de error con nosotros?"</string>
<string name="rageshake_detection_dialog_content">"Parece que sacudes el teléfono con frustración. ¿Quieres abrir la pantalla de informe de errores?"</string>
<string name="settings_rageshake">"Agitar con fuerza"</string>
<string name="settings_rageshake_detection_threshold">"Umbral de detección"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s jooksis kokku viimati, kui seda kasutasid. Kas tahaksid selle kohta meile veateate saata?"</string>
<string name="rageshake_detection_dialog_content">"Tundub, et sa raputad oma nutiseadet ägedalt. Kas sa soovid saata meile veateadet?"</string>
<string name="settings_rageshake">"Seadme äge raputamine"</string>
<string name="settings_rageshake_detection_threshold">"Tuvastamise lävi"</string>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s kraskatu zen azkenekoz erabili zenean. Gurekin partekatu nahi al duzu kraskatzearen txostena?"</string>
<string name="rageshake_detection_dialog_content">"Frustrazioaren eraginez mugikorra astintzen ari zarela dirudi. Erroreen berri emateko pantaila ireki nahi al duzu?"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$sآخرین باری که استفاده شد، از کار افتاد. آیا مایلید گزارش خرابی را با ما به اشتراک بگذارید؟"</string>
<string name="rageshake_detection_dialog_content">"به نظر می‌رسد دارید گوشی خود را به دلیل کار نکردن تکان می‌دهید! آیا می‌خواهید یک اشکال در برنامه گزارش نمایید؟"</string>
<string name="settings_rageshake">"تکان دادن"</string>
<string name="settings_rageshake_detection_threshold">"آستانهٔ تشخیص"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s kaatui edellisellä käyttökerralla. Haluatko jakaa virheraportin kanssamme?"</string>
<string name="rageshake_detection_dialog_content">"Näytät ravistelevan puhelinta turhautuneena. Haluatko avata vikailmoitusnäytön?"</string>
<string name="settings_rageshake">"Raivostunut ravistaminen"</string>
<string name="settings_rageshake_detection_threshold">"Havaitsemiskynnys"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s sest arrêté la dernière fois quil a été utilisé. Souhaitez-vous partager un rapport dincident avec nous ?"</string>
<string name="rageshake_detection_dialog_content">"Vous semblez secouez votre téléphone avec frustration. Souhaitez-vous ouvrir le formulaire pour reporter un problème ?"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Seuil de détection"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"Az %1$s összeomlott a legutóbbi használata óta. Megosztja velünk az összeomlás-jelentést?"</string>
<string name="rageshake_detection_dialog_content">"Úgy tűnik, mintha dühösen rázná a telefont. Megnyitja a hibajelentési képernyőt?"</string>
<string name="settings_rageshake">"Ideges rázás"</string>
<string name="settings_rageshake_detection_threshold">"Észlelési küszöb"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s mengalami kemogokan saat terakhir kali digunakan. Apakah Anda ingin berbagi laporan kerusakan dengan kami?"</string>
<string name="rageshake_detection_dialog_content">"Anda tampaknya mengguncang telepon karena frustrasi. Apakah Anda ingin membuka layar laporan kutu?"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Ambang batas deteksi"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s si è chiuso inaspettatamente l\'ultima volta che è stato usato. Vuoi condividere con noi un rapporto sull\'arresto anomalo?"</string>
<string name="rageshake_detection_dialog_content">"Sembra che tu stia scuotendo il telefono per la frustrazione. Vuoi aprire la schermata di segnalazione dei problemi?"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Soglia di rilevamento"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s ავარიულად გაითიშა ბოლოს გამოიყენებისას. გსურთ, გამოგვიგზავნოთ ავარიული გათიშვის ჟურნალი?"</string>
<string name="rageshake_detection_dialog_content">"როგორც ჩანს, იმედგაცრუებით ტელეფონს აჯანჯღალებთ. გსურთ, გახსნათ შეცდომის დარეპორტების ეკრანი?"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"გამოვლენის ზღვარი"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s이(가) 이전에 마지막으로 사용할 때 충돌했습니다. 충돌 보고서를 공유해주실 수 있나요?"</string>
<string name="rageshake_detection_dialog_content">"휴대폰을 강하게 흔드셨습니다. 버그 보고 화면을 여시겠어요?"</string>
<string name="settings_rageshake">"강하게 흔들기"</string>
<string name="settings_rageshake_detection_threshold">"감지 수준"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s nulūžo paskutinį kartą, kai buvo naudojama. Ar norėtumėte su mumis pasidalyti strigčių ataskaita?"</string>
<string name="rageshake_detection_dialog_content">"Atrodo, kad nusivylęs purtote telefoną. Ar norėtumėte atidaryti pranešimo apie klaidas ekraną?"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Aptikimo riba"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s krasjet sist gang den ble brukt. Vil du dele en krasjrapport med oss?"</string>
<string name="rageshake_detection_dialog_content">"Du ser ut til å riste på telefonen i frustrasjon. Vil du åpne feilrapportskjermen?"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Gjenkjenningsterskel"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s crashte de laatste keer dat het werd gebruikt. Wil je een crashrapport met ons delen?"</string>
<string name="rageshake_detection_dialog_content">"Het lijkt erop dat je gefrustreerd de telefoon hebt geschud. Wil je het scherm openen om een bug te rapporteren?"</string>
<string name="settings_rageshake">"Schudden uit woede"</string>
<string name="settings_rageshake_detection_threshold">"Drempel voor detectie"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s uległ awarii podczas ostatniego użycia. Czy chcesz przesłać nam raport o awarii?"</string>
<string name="rageshake_detection_dialog_content">"Wygląda na to, że potrząsasz telefonem z frustracji. Czy chcesz otworzyć ekran zgłaszania błędów?"</string>
<string name="settings_rageshake">"Gniewne wstrząsanie"</string>
<string name="settings_rageshake_detection_threshold">"Próg wykrywania"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s falhou inesperadamente na última vez que foi usado. Gostaria de compartilhar um relatório de falhas conosco?"</string>
<string name="rageshake_detection_dialog_content">"Você parece estar sacudindo o telefone com frustração. Você gostaria de abrir a tela de relatório de bugs?"</string>
<string name="settings_rageshake">"Agitar agressivamente"</string>
<string name="settings_rageshake_detection_threshold">"Fronteira de detecção"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"A %1$s teve uma falha da última vez que foi utilizada. Gostarias de partilhar um relatório de acidente connosco?"</string>
<string name="rageshake_detection_dialog_content">"Parece que estás a abanar o telefone em sinal de frustração. Gostarias de abrir o painel de relatório de erros?"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Limiar de deteção"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s s-a blocat ultima dată când a fost folosit. Doriți să ne trimiteți un raport?"</string>
<string name="rageshake_detection_dialog_content">"Se pare că scuturați telefonul de frustrare. Doriți să deschdeți ecranul de raportare a unei erori?"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Prag de detecție"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"При последнем использовании %1$s произошел сбой. Хотите поделиться отчетом?"</string>
<string name="rageshake_detection_dialog_content">"Похоже, что вы трясете телефон. Хотите открыть экран сообщения об ошибке?"</string>
<string name="settings_rageshake">"Встряхните"</string>
<string name="settings_rageshake_detection_threshold">"Порог обнаружения"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s zlyhal pri poslednom použití. Chcete zdieľať správu o páde s našim tímom?"</string>
<string name="rageshake_detection_dialog_content">"Zdá sa, že zúrivo trasiete telefónom. Chcete otvoriť obrazovku s hlásením chýb?"</string>
<string name="settings_rageshake">"Zúrivé potrasenie"</string>
<string name="settings_rageshake_detection_threshold">"Prahová hodnota detekcie"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s kraschade senast den användes. Vill du dela en kraschrapport med oss?"</string>
<string name="rageshake_detection_dialog_content">"Du verkar skaka telefonen i frustration. Vill du öppna felrapporteringsskärmen?"</string>
<string name="settings_rageshake">"Raseriskaka"</string>
<string name="settings_rageshake_detection_threshold">"Detektionströskel"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s son kullanıldığında çöktü. Bizimle bir çökme raporu paylaşmak ister misiniz?"</string>
<string name="rageshake_detection_dialog_content">"Sinirden telefonu sallıyor gibi görünüyorsunuz. Hata raporu ekranını açmak ister misiniz?"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Algılama eşiği"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"Стався збій %1$s під час останнього користування. Хочете поділитися з нами звітом про збій?"</string>
<string name="rageshake_detection_dialog_content">"Здається, ви роздратовано трясете телефоном. Бажаєте запустити вікно для звіту про помилку?"</string>
<string name="settings_rageshake">"Лютострус"</string>
<string name="settings_rageshake_detection_threshold">"Поріг виявлення"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$sآخری بار استعمال ہونے پر ٹکرا گیا۔ کیا آپ ہمارے ساتھ ٹکر کی گزارش (رپورٹ) کا اشتراک کرنا چاہیں گے؟"</string>
<string name="rageshake_detection_dialog_content">"لگتا ہے آپ مایوسی میں ہاتف ہلا رہے ہیں۔ کیا آپ گزارش خطاء نمائش کھولنا چاہیں گے؟"</string>
<string name="settings_rageshake">"غصے سے جھٹکانا"</string>
<string name="settings_rageshake_detection_threshold">"حد کھوج"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$soxirgi marta ishlatilganda qulab tushdi. Biz bilan nosozlik hisobotini baham ko\'rmoqchimisiz?"</string>
<string name="rageshake_detection_dialog_content">"Siz hafsalasi pir bo\'lib telefonni silkitayotganga o\'xshaysiz. Xatolar haqida hisobot ekranini ochmoqchimisiz?"</string>
<string name="settings_rageshake">"G\'azablanish"</string>
<string name="settings_rageshake_detection_threshold">"Aniqlash chegarasi"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s 上次使用時當機了。您想要與我們分享當機報告嗎?"</string>
<string name="rageshake_detection_dialog_content">"您似乎正在沮喪地搖晃手機。您要開啟臭蟲回報畫面嗎?"</string>
<string name="settings_rageshake">"憤怒搖晃"</string>
<string name="settings_rageshake_detection_threshold">"偵測閾值"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s 上次使用时崩溃了。想和我们分享崩溃报告吗?"</string>
<string name="rageshake_detection_dialog_content">"你似乎愤怒地摇晃了手机。想要打开 Bug 报告页面吗?"</string>
<string name="settings_rageshake">"摇一摇"</string>
<string name="settings_rageshake_detection_threshold">"检测阈值"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s crashed the last time it was used. Would you like to share a crash report with us?"</string>
<string name="rageshake_detection_dialog_content">"You seem to be shaking the phone in frustration. Would you like to open the bug report screen?"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Detection threshold"</string>
</resources>
+60
View File
@@ -0,0 +1,60 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.rageshake.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupDependencyInjection()
dependencies {
implementation(projects.appconfig)
implementation(projects.features.enterprise.api)
implementation(projects.features.viewfolder.api)
implementation(projects.services.toolbox.api)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.network)
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.matrix.api)
api(libs.squareup.seismic)
api(projects.features.rageshake.api)
implementation(libs.androidx.datastore.preferences)
implementation(platform(libs.network.okhttp.bom))
implementation(libs.network.okhttp.okhttp)
implementation(libs.coil)
implementation(libs.coil.compose)
testCommonDependencies(libs)
testImplementation(projects.features.enterprise.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.viewfolder.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.services.toolbox.test)
testImplementation(libs.network.mockwebserver)
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.features.rageshake.impl.reporter.BugReporterUrlProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@ContributesBinding(AppScope::class)
class DefaultRageshakeFeatureAvailability(
private val bugReporterUrlProvider: BugReporterUrlProvider,
) : RageshakeFeatureAvailability {
override fun isAvailable(): Flow<Boolean> {
return bugReporterUrlProvider.provide()
.map { it != null }
}
}
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.bugreport
sealed interface BugReportEvents {
data object SendBugReport : BugReportEvents
data object ResetAll : BugReportEvents
data object ClearError : BugReportEvents
data class SetDescription(val description: String) : BugReportEvents
data class SetSendLog(val sendLog: Boolean) : BugReportEvents
data class SetCanContact(val canContact: Boolean) : BugReportEvents
data class SetSendScreenshot(val sendScreenshot: Boolean) : BugReportEvents
data class SetSendPushRules(val sendPushRules: Boolean) : BugReportEvents
}
@@ -0,0 +1,95 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.bugreport
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.features.viewfolder.api.ViewFolderEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
@AssistedInject
class BugReportFlowNode(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val viewFolderEntryPoint: ViewFolderEntryPoint,
) : BaseFlowNode<BugReportFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
private val callback: BugReportEntryPoint.Callback = callback()
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data class ViewLogs(
val rootPath: String,
) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
val callback = object : BugReportNode.Callback {
override fun onDone() {
callback.onDone()
}
override fun navigateToViewLogs(basePath: String) {
backstack.push(NavTarget.ViewLogs(rootPath = basePath))
}
}
createNode<BugReportNode>(buildContext, listOf(callback))
}
is NavTarget.ViewLogs -> {
val callback = object : ViewFolderEntryPoint.Callback {
override fun onDone() {
backstack.pop()
}
}
val params = ViewFolderEntryPoint.Params(
rootPath = navTarget.rootPath,
)
viewFolderEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = params,
callback = callback,
)
}
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView()
}
}
@@ -0,0 +1,13 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.bugreport
sealed class BugReportFormError : Exception() {
data object DescriptionTooShort : BugReportFormError()
}
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.bugreport
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.libraries.androidutils.system.toast
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.ui.strings.CommonStrings
@ContributesNode(AppScope::class)
@AssistedInject
class BugReportNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: BugReportPresenter,
private val bugReporter: BugReporter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onDone()
fun navigateToViewLogs(basePath: String)
}
private val callback: Callback = callback()
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val activity = LocalActivity.current
BugReportView(
state = state,
modifier = modifier,
onBackClick = { navigateUp() },
onSuccess = {
activity?.toast(CommonStrings.common_report_submitted)
callback.onDone()
},
onViewLogs = {
// Force a logcat dump
bugReporter.saveLogCat()
callback.navigateToViewLogs(bugReporter.logDirectory().absolutePath)
}
)
}
}
@@ -0,0 +1,153 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.bugreport
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import dev.zacsweers.metro.Inject
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.reporter.BugReporterListener
import io.element.android.features.rageshake.impl.crash.CrashDataStore
import io.element.android.features.rageshake.impl.screenshot.ScreenshotHolder
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.annotations.AppCoroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Inject
class BugReportPresenter(
private val bugReporter: BugReporter,
private val crashDataStore: CrashDataStore,
private val screenshotHolder: ScreenshotHolder,
@AppCoroutineScope
private val appCoroutineScope: CoroutineScope,
) : Presenter<BugReportState> {
private class BugReporterUploadListener(
private val sendingProgress: MutableFloatState,
private val sendingAction: MutableState<AsyncAction<Unit>>
) : BugReporterListener {
override fun onUploadCancelled() {
sendingProgress.floatValue = 0f
sendingAction.value = AsyncAction.Uninitialized
}
override fun onUploadFailed(reason: String?) {
sendingProgress.floatValue = 0f
sendingAction.value = AsyncAction.Failure(Exception(reason))
}
override fun onProgress(progress: Int) {
sendingProgress.floatValue = progress.toFloat() / 100
sendingAction.value = AsyncAction.Loading
}
override fun onUploadSucceed() {
sendingProgress.floatValue = 0f
sendingAction.value = AsyncAction.Success(Unit)
}
}
@Composable
override fun present(): BugReportState {
val screenshotUri = rememberSaveable {
mutableStateOf(
screenshotHolder.getFileUri()
)
}
val crashInfo: String by remember {
crashDataStore.crashInfo()
}.collectAsState(initial = "")
val sendingProgress = remember {
mutableFloatStateOf(0f)
}
val sendingAction: MutableState<AsyncAction<Unit>> = remember {
mutableStateOf(AsyncAction.Uninitialized)
}
val formState: MutableState<BugReportFormState> = rememberSaveable {
mutableStateOf(BugReportFormState.Default)
}
val uploadListener = BugReporterUploadListener(sendingProgress, sendingAction)
fun handleEvent(event: BugReportEvents) {
when (event) {
BugReportEvents.SendBugReport -> {
if (formState.value.description.length < 10) {
sendingAction.value = AsyncAction.Failure(BugReportFormError.DescriptionTooShort)
} else {
sendingAction.value = AsyncAction.Loading
appCoroutineScope.sendBugReport(formState.value, crashInfo.isNotEmpty(), uploadListener)
}
}
BugReportEvents.ResetAll -> appCoroutineScope.resetAll()
is BugReportEvents.SetDescription -> updateFormState(formState) {
copy(description = event.description)
}
is BugReportEvents.SetCanContact -> updateFormState(formState) {
copy(canContact = event.canContact)
}
is BugReportEvents.SetSendLog -> updateFormState(formState) {
copy(sendLogs = event.sendLog)
}
is BugReportEvents.SetSendScreenshot -> updateFormState(formState) {
copy(sendScreenshot = event.sendScreenshot)
}
is BugReportEvents.SetSendPushRules -> updateFormState(formState) {
copy(sendPushRules = event.sendPushRules)
}
BugReportEvents.ClearError -> {
sendingProgress.floatValue = 0f
sendingAction.value = AsyncAction.Uninitialized
}
}
}
return BugReportState(
hasCrashLogs = crashInfo.isNotEmpty(),
sendingProgress = sendingProgress.floatValue,
sending = sendingAction.value,
formState = formState.value,
screenshotUri = screenshotUri.value,
eventSink = ::handleEvent,
)
}
private fun updateFormState(formState: MutableState<BugReportFormState>, operation: BugReportFormState.() -> BugReportFormState) {
formState.value = operation(formState.value)
}
private fun CoroutineScope.sendBugReport(
formState: BugReportFormState,
hasCrashLogs: Boolean,
listener: BugReporterListener,
) = launch {
bugReporter.sendBugReport(
withDevicesLogs = formState.sendLogs,
withCrashLogs = hasCrashLogs && formState.sendLogs,
withScreenshot = formState.sendScreenshot,
problemDescription = formState.description,
canContact = formState.canContact,
sendPushRules = formState.sendPushRules,
listener = listener
)
}
private fun CoroutineScope.resetAll() = launch {
screenshotHolder.reset()
crashDataStore.reset()
}
}
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.bugreport
import android.os.Parcelable
import io.element.android.libraries.architecture.AsyncAction
import kotlinx.parcelize.Parcelize
data class BugReportState(
val formState: BugReportFormState,
val hasCrashLogs: Boolean,
val screenshotUri: String?,
val sendingProgress: Float,
val sending: AsyncAction<Unit>,
val eventSink: (BugReportEvents) -> Unit
) {
val submitEnabled = sending !is AsyncAction.Loading
val isDescriptionInError = sending is AsyncAction.Failure &&
sending.error is BugReportFormError.DescriptionTooShort
}
@Parcelize
data class BugReportFormState(
val description: String,
val sendLogs: Boolean,
val canContact: Boolean,
val sendScreenshot: Boolean,
val sendPushRules: Boolean,
) : Parcelable {
companion object {
val Default = BugReportFormState(
description = "",
sendLogs = true,
canContact = false,
sendScreenshot = false,
sendPushRules = false,
)
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.bugreport
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
open class BugReportStateProvider : PreviewParameterProvider<BugReportState> {
override val values: Sequence<BugReportState>
get() = sequenceOf(
aBugReportState(),
aBugReportState().copy(
formState = BugReportFormState.Default.copy(
description = "A long enough description",
sendScreenshot = true,
),
hasCrashLogs = true,
screenshotUri = "aUri"
),
aBugReportState().copy(sending = AsyncAction.Loading),
aBugReportState().copy(sending = AsyncAction.Success(Unit)),
aBugReportState().copy(sending = AsyncAction.Failure(BugReportFormError.DescriptionTooShort)),
)
}
fun aBugReportState() = BugReportState(
formState = BugReportFormState.Default,
hasCrashLogs = false,
screenshotUri = null,
sendingProgress = 0F,
sending = AsyncAction.Uninitialized,
eventSink = {}
)
@@ -0,0 +1,206 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.bugreport
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import io.element.android.features.rageshake.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.components.preferences.PreferenceDivider
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.components.preferences.PreferenceRow
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TextFieldValidity
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun BugReportView(
state: BugReportState,
onViewLogs: () -> Unit,
onSuccess: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val eventSink = state.eventSink
Box(modifier = modifier) {
PreferencePage(
title = stringResource(id = CommonStrings.common_report_a_problem),
onBackClick = onBackClick
) {
val keyboardController = LocalSoftwareKeyboardController.current
val isFormEnabled = state.sending !is AsyncAction.Loading
var descriptionFieldState by textFieldState(
stateValue = state.formState.description
)
Spacer(modifier = Modifier.height(16.dp))
PreferenceRow {
TextField(
value = descriptionFieldState,
modifier = Modifier
.fillMaxWidth()
.onTabOrEnterKeyFocusNext(LocalFocusManager.current),
enabled = isFormEnabled,
placeholder = stringResource(id = R.string.screen_bug_report_editor_placeholder),
supportingText = stringResource(id = R.string.screen_bug_report_editor_description),
onValueChange = {
descriptionFieldState = it
eventSink(BugReportEvents.SetDescription(it))
},
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(onNext = {
keyboardController?.hide()
}),
minLines = 3,
validity = if (state.isDescriptionInError) TextFieldValidity.Invalid else TextFieldValidity.None,
)
}
Spacer(modifier = Modifier.height(16.dp))
PreferenceDivider()
ListItem(
headlineContent = {
Text(stringResource(id = R.string.screen_bug_report_view_logs))
},
enabled = isFormEnabled,
onClick = onViewLogs,
)
PreferenceDivider()
PreferenceSwitch(
isChecked = state.formState.sendLogs,
onCheckedChange = { eventSink(BugReportEvents.SetSendLog(it)) },
enabled = isFormEnabled,
title = stringResource(id = R.string.screen_bug_report_include_logs),
subtitle = stringResource(id = R.string.screen_bug_report_logs_description),
)
PreferenceSwitch(
isChecked = state.formState.canContact,
onCheckedChange = { eventSink(BugReportEvents.SetCanContact(it)) },
enabled = isFormEnabled,
title = stringResource(id = R.string.screen_bug_report_contact_me_title),
subtitle = stringResource(id = R.string.screen_bug_report_contact_me),
)
if (state.screenshotUri != null) {
PreferenceSwitch(
isChecked = state.formState.sendScreenshot,
onCheckedChange = { eventSink(BugReportEvents.SetSendScreenshot(it)) },
enabled = isFormEnabled,
title = stringResource(id = R.string.screen_bug_report_include_screenshot)
)
if (state.formState.sendScreenshot) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
val context = LocalContext.current
val model = ImageRequest.Builder(context)
.data(state.screenshotUri)
.build()
AsyncImage(
modifier = Modifier.fillMaxWidth(fraction = 0.5f),
model = model,
contentDescription = null,
)
}
}
}
PreferenceSwitch(
isChecked = state.formState.sendPushRules,
onCheckedChange = { eventSink(BugReportEvents.SetSendPushRules(it)) },
enabled = isFormEnabled,
title = stringResource(R.string.screen_bug_report_send_notification_settings_title),
subtitle = stringResource(R.string.screen_bug_report_send_notification_settings_description),
)
// Submit
PreferenceRow {
Button(
text = stringResource(id = CommonStrings.action_send),
onClick = { eventSink(BugReportEvents.SendBugReport) },
enabled = state.submitEnabled,
showProgress = state.sending.isLoading(),
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp, bottom = 16.dp)
)
}
}
AsyncActionView(
async = state.sending,
progressDialog = { },
onSuccess = {
eventSink(BugReportEvents.ResetAll)
onSuccess()
},
errorMessage = { error ->
when (error) {
BugReportFormError.DescriptionTooShort -> stringResource(id = R.string.screen_bug_report_error_description_too_short)
else -> error.message ?: error.toString()
}
},
onErrorDismiss = { eventSink(BugReportEvents.ClearError) },
)
}
}
@Preview(heightDp = 1000)
@Composable
internal fun BugReportViewDayPreview(@PreviewParameter(BugReportStateProvider::class) state: BugReportState) = ElementPreview {
BugReportView(
state = state,
onSuccess = {},
onBackClick = {},
onViewLogs = {},
)
}
@Preview(heightDp = 1000, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
internal fun BugReportViewNightPreview(@PreviewParameter(BugReportStateProvider::class) state: BugReportState) = ElementPreview {
BugReportView(
state = state,
onSuccess = {},
onBackClick = {},
onViewLogs = {},
)
}
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.bugreport
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class)
class DefaultBugReportEntryPoint : BugReportEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: BugReportEntryPoint.Callback,
): Node {
return parentNode.createNode<BugReportFlowNode>(buildContext, listOf(callback))
}
}
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.crash
import kotlinx.coroutines.flow.Flow
interface CrashDataStore {
fun setCrashData(crashData: String)
suspend fun resetAppHasCrashed()
fun appHasCrashed(): Flow<Boolean>
fun crashInfo(): Flow<String>
suspend fun reset()
}
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.rageshake.impl.crash
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.features.rageshake.api.crash.CrashDetectionEvents
import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter
import io.element.android.features.rageshake.api.crash.CrashDetectionState
import io.element.android.libraries.core.meta.BuildMeta
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
@ContributesBinding(AppScope::class)
class DefaultCrashDetectionPresenter(
private val buildMeta: BuildMeta,
private val crashDataStore: CrashDataStore,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
) : CrashDetectionPresenter {
@Composable
override fun present(): CrashDetectionState {
val localCoroutineScope = rememberCoroutineScope()
val crashDetected by remember {
rageshakeFeatureAvailability.isAvailable()
.flatMapLatest { isAvailable ->
if (isAvailable) {
crashDataStore.appHasCrashed()
} else {
flowOf(false)
}
}
}.collectAsState(false)
fun handleEvent(event: CrashDetectionEvents) {
when (event) {
CrashDetectionEvents.ResetAllCrashData -> localCoroutineScope.resetAll()
CrashDetectionEvents.ResetAppHasCrashed -> localCoroutineScope.resetAppHasCrashed()
}
}
return CrashDetectionState(
appName = buildMeta.applicationName,
crashDetected = crashDetected,
eventSink = ::handleEvent,
)
}
private fun CoroutineScope.resetAppHasCrashed() = launch {
crashDataStore.resetAppHasCrashed()
}
private fun CoroutineScope.resetAll() = launch {
crashDataStore.reset()
}
}
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.crash
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
private val appHasCrashedKey = booleanPreferencesKey("appHasCrashed")
private val crashDataKey = stringPreferencesKey("crashData")
@ContributesBinding(AppScope::class)
class PreferencesCrashDataStore(
preferenceDataStoreFactory: PreferenceDataStoreFactory,
) : CrashDataStore {
private val store = preferenceDataStoreFactory.create("elementx_crash")
override fun setCrashData(crashData: String) {
// Must block
runBlocking {
store.edit { prefs ->
prefs[appHasCrashedKey] = true
prefs[crashDataKey] = crashData
}
}
}
override suspend fun resetAppHasCrashed() {
store.edit { prefs ->
prefs[appHasCrashedKey] = false
}
}
override fun appHasCrashed(): Flow<Boolean> {
return store.data.map { prefs ->
prefs[appHasCrashedKey].orFalse()
}
}
override fun crashInfo(): Flow<String> {
return store.data.map { prefs ->
prefs[crashDataKey].orEmpty()
}
}
override suspend fun reset() {
store.edit { it.clear() }
}
}
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.crash
import android.os.Build
import io.element.android.libraries.core.data.tryOrNull
import timber.log.Timber
import java.io.PrintWriter
import java.io.StringWriter
class VectorUncaughtExceptionHandler(
private val preferencesCrashDataStore: PreferencesCrashDataStore,
) : Thread.UncaughtExceptionHandler {
private var previousHandler: Thread.UncaughtExceptionHandler? = null
/**
* Activate this handler.
*/
fun activate() {
previousHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler(this)
}
/**
* An uncaught exception has been triggered.
*
* @param thread the thread
* @param throwable the throwable
*/
@Suppress("PrintStackTrace")
override fun uncaughtException(thread: Thread, throwable: Throwable) {
Timber.v("Uncaught exception: $throwable")
val bugDescription = buildString {
val appName = "ElementX"
// append(appName + " Build : " + versionCodeProvider.getVersionCode() + "\n")
append("$appName Version : 1.0") // ${versionProvider.getVersion(longFormat = true)}\n")
// append("SDK Version : ${Matrix.getSdkVersion()}\n")
append("Phone : " + Build.MODEL.trim() + " (" + Build.VERSION.INCREMENTAL + " " + Build.VERSION.RELEASE + " " + Build.VERSION.CODENAME + ")\n")
append("Memory statuses \n")
var freeSize = 0L
var totalSize = 0L
var usedSize = -1L
tryOrNull {
val info = Runtime.getRuntime()
freeSize = info.freeMemory()
totalSize = info.totalMemory()
usedSize = totalSize - freeSize
}
append("usedSize " + usedSize / 1_048_576L + " MB\n")
append("freeSize " + freeSize / 1_048_576L + " MB\n")
append("totalSize " + totalSize / 1_048_576L + " MB\n")
append("Thread: ")
append(thread.name)
append(", Exception: ")
val sw = StringWriter()
val pw = PrintWriter(sw, true)
throwable.printStackTrace(pw)
append(sw.buffer.toString())
}
Timber.e("FATAL EXCEPTION $bugDescription")
preferencesCrashDataStore.setCrashData(bugDescription)
// Show the classical system popup
previousHandler?.uncaughtException(thread, throwable)
}
}
@@ -0,0 +1,116 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.detection
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.rageshake.api.detection.RageshakeDetectionEvents
import io.element.android.features.rageshake.api.detection.RageshakeDetectionPresenter
import io.element.android.features.rageshake.api.detection.RageshakeDetectionState
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesEvents
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesPresenter
import io.element.android.features.rageshake.api.screenshot.ImageResult
import io.element.android.features.rageshake.impl.rageshake.RageShake
import io.element.android.features.rageshake.impl.screenshot.ScreenshotHolder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
@ContributesBinding(AppScope::class)
class DefaultRageshakeDetectionPresenter(
private val screenshotHolder: ScreenshotHolder,
private val rageShake: RageShake,
private val preferencesPresenter: RageshakePreferencesPresenter,
) : RageshakeDetectionPresenter {
@Composable
override fun present(): RageshakeDetectionState {
val localCoroutineScope = rememberCoroutineScope()
val preferencesState = preferencesPresenter.present()
val isStarted = rememberSaveable {
mutableStateOf(false)
}
val takeScreenshot = rememberSaveable {
mutableStateOf(false)
}
val showDialog = rememberSaveable {
mutableStateOf(false)
}
fun handleEvent(event: RageshakeDetectionEvents) {
when (event) {
RageshakeDetectionEvents.Disable -> {
preferencesState.eventSink(RageshakePreferencesEvents.SetIsEnabled(false))
showDialog.value = false
}
RageshakeDetectionEvents.StartDetection -> isStarted.value = true
RageshakeDetectionEvents.StopDetection -> isStarted.value = false
is RageshakeDetectionEvents.ProcessScreenshot -> localCoroutineScope.processScreenshot(takeScreenshot, showDialog, event.imageResult)
RageshakeDetectionEvents.Dismiss -> showDialog.value = false
}
}
val state = remember(preferencesState, isStarted.value, takeScreenshot.value, showDialog.value) {
RageshakeDetectionState(
isStarted = isStarted.value,
takeScreenshot = takeScreenshot.value,
showDialog = showDialog.value,
preferenceState = preferencesState,
eventSink = ::handleEvent,
)
}
LaunchedEffect(preferencesState.sensitivity) {
rageShake.setSensitivity(preferencesState.sensitivity)
}
val shouldStart = preferencesState.isFeatureEnabled &&
preferencesState.isEnabled &&
preferencesState.isSupported &&
isStarted.value &&
!takeScreenshot.value &&
!showDialog.value
LaunchedEffect(shouldStart) {
handleRageShake(shouldStart, state, takeScreenshot)
}
return state
}
private fun handleRageShake(start: Boolean, state: RageshakeDetectionState, takeScreenshot: MutableState<Boolean>) {
if (start) {
rageShake.start(state.preferenceState.sensitivity)
rageShake.setInterceptor {
takeScreenshot.value = true
}
} else {
rageShake.stop()
rageShake.setInterceptor(null)
}
}
private fun CoroutineScope.processScreenshot(takeScreenshot: MutableState<Boolean>, showDialog: MutableState<Boolean>, imageResult: ImageResult) = launch {
screenshotHolder.reset()
when (imageResult) {
is ImageResult.Error -> {
Timber.e(imageResult.exception, "Unable to write screenshot")
}
is ImageResult.Success -> {
screenshotHolder.writeBitmap(imageResult.data)
}
}
takeScreenshot.value = false
showDialog.value = true
}
}
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.di
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo
import io.element.android.features.rageshake.impl.crash.PreferencesCrashDataStore
@ContributesTo(AppScope::class)
interface RageshakeBindings {
fun preferencesCrashDataStore(): PreferencesCrashDataStore
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.di
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.Binds
import dev.zacsweers.metro.ContributesTo
import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter
import io.element.android.features.rageshake.api.crash.CrashDetectionState
import io.element.android.features.rageshake.api.detection.RageshakeDetectionPresenter
import io.element.android.features.rageshake.api.detection.RageshakeDetectionState
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesPresenter
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
import io.element.android.libraries.architecture.Presenter
@ContributesTo(AppScope::class)
@BindingContainer
interface RageshakeModule {
@Binds
fun bindRageshakePreferencesPresenter(presenter: RageshakePreferencesPresenter): Presenter<RageshakePreferencesState>
@Binds
fun bindRageshakeDetectionPresenter(presenter: RageshakeDetectionPresenter): Presenter<RageshakeDetectionState>
@Binds
fun bindCrashDetectionPresenter(presenter: CrashDetectionPresenter): Presenter<CrashDetectionState>
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.logs
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.rageshake.api.logs.LogFilesRemover
import io.element.android.features.rageshake.impl.reporter.DefaultBugReporter
import java.io.File
@ContributesBinding(AppScope::class)
class DefaultLogFilesRemover(
private val bugReporter: DefaultBugReporter,
) : LogFilesRemover {
override suspend fun perform(predicate: (File) -> Boolean) {
bugReporter.deleteAllFiles(predicate)
}
}
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.preferences
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesEvents
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesPresenter
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
import io.element.android.features.rageshake.impl.rageshake.RageShake
import io.element.android.features.rageshake.impl.rageshake.RageshakeDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ContributesBinding(AppScope::class)
class DefaultRageshakePreferencesPresenter(
private val rageshake: RageShake,
private val rageshakeDataStore: RageshakeDataStore,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
) : RageshakePreferencesPresenter {
@Composable
override fun present(): RageshakePreferencesState {
val localCoroutineScope = rememberCoroutineScope()
val isSupported: MutableState<Boolean> = rememberSaveable {
mutableStateOf(rageshake.isAvailable())
}
val isFeatureAvailable by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
val isEnabled by remember {
rageshakeDataStore.isEnabled()
}.collectAsState(initial = false)
val sensitivity by remember {
rageshakeDataStore.sensitivity()
}.collectAsState(initial = 0f)
fun handleEvent(event: RageshakePreferencesEvents) {
when (event) {
is RageshakePreferencesEvents.SetIsEnabled -> localCoroutineScope.setIsEnabled(event.isEnabled)
is RageshakePreferencesEvents.SetSensitivity -> localCoroutineScope.setSensitivity(event.sensitivity)
}
}
return RageshakePreferencesState(
isFeatureEnabled = isFeatureAvailable,
isEnabled = isEnabled,
isSupported = isSupported.value,
sensitivity = sensitivity,
eventSink = ::handleEvent,
)
}
private fun CoroutineScope.setSensitivity(sensitivity: Float) = launch {
rageshakeDataStore.setSensitivity(sensitivity)
}
private fun CoroutineScope.setIsEnabled(enabled: Boolean) = launch {
rageshakeDataStore.setIsEnabled(enabled)
}
}
@@ -0,0 +1,68 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.rageshake
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorManager
import androidx.core.content.getSystemService
import com.squareup.seismic.ShakeDetector
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metro.binding
import io.element.android.libraries.di.annotations.ApplicationContext
@SingleIn(AppScope::class)
@ContributesBinding(scope = AppScope::class, binding = binding<RageShake>())
class DefaultRageShake(
@ApplicationContext context: Context,
) : ShakeDetector.Listener, RageShake {
private var sensorManager = context.getSystemService<SensorManager>()
private var shakeDetector: ShakeDetector? = null
private var interceptor: (() -> Unit)? = null
override fun setInterceptor(interceptor: (() -> Unit)?) {
this.interceptor = interceptor
}
/**
* Check if the feature is available on this device.
*/
override fun isAvailable(): Boolean {
return sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null
}
override fun start(sensitivity: Float) {
sensorManager?.let {
shakeDetector = ShakeDetector(this).apply {
start(it, SensorManager.SENSOR_DELAY_GAME)
}
setSensitivity(sensitivity)
}
}
override fun stop() {
shakeDetector?.stop()
}
/**
* sensitivity will be {0, O.25, 0.5, 0.75, 1} and converted to
* [ShakeDetector.SENSITIVITY_LIGHT (=11), ShakeDetector.SENSITIVITY_HARD (=15)].
*/
override fun setSensitivity(sensitivity: Float) {
shakeDetector?.setSensitivity(
ShakeDetector.SENSITIVITY_LIGHT + (sensitivity * 4).toInt()
)
}
override fun hearShake() {
interceptor?.invoke()
}
}
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.rageshake
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val enabledKey = booleanPreferencesKey("enabled")
private val sensitivityKey = floatPreferencesKey("sensitivity")
@ContributesBinding(AppScope::class)
class PreferencesRageshakeDataStore(
preferenceDataStoreFactory: PreferenceDataStoreFactory,
) : RageshakeDataStore {
private val store = preferenceDataStoreFactory.create("elementx_rageshake")
override fun isEnabled(): Flow<Boolean> {
return store.data.map { prefs ->
prefs[enabledKey].orFalse()
}
}
override suspend fun setIsEnabled(isEnabled: Boolean) {
store.edit { prefs ->
prefs[enabledKey] = isEnabled
}
}
override fun sensitivity(): Flow<Float> {
return store.data.map { prefs ->
prefs[sensitivityKey] ?: 0.5f
}
}
override suspend fun setSensitivity(sensitivity: Float) {
store.edit { prefs ->
prefs[sensitivityKey] = sensitivity
}
}
override suspend fun reset() {
store.edit { it.clear() }
}
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.rageshake
interface RageShake {
/**
* Check if the feature is available on this device.
*/
fun isAvailable(): Boolean
fun start(sensitivity: Float)
fun stop()
/**
* sensitivity will be {0, O.25, 0.5, 0.75, 1} and converted to
* [ShakeDetector.SENSITIVITY_LIGHT (=11), ShakeDetector.SENSITIVITY_HARD (=15)].
*/
fun setSensitivity(sensitivity: Float)
fun setInterceptor(interceptor: (() -> Unit)?)
}
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.rageshake
import kotlinx.coroutines.flow.Flow
interface RageshakeDataStore {
fun isEnabled(): Flow<Boolean>
suspend fun setIsEnabled(isEnabled: Boolean)
fun sensitivity(): Flow<Float>
suspend fun setSensitivity(sensitivity: Float)
suspend fun reset()
}
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.reporter
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.appconfig.RageshakeConfig
fun interface BugReportAppNameProvider {
fun provide(): String
}
@ContributesBinding(AppScope::class)
class DefaultBugReportAppNameProvider : BugReportAppNameProvider {
override fun provide(): String = RageshakeConfig.BUG_REPORT_APP_NAME
}
@@ -0,0 +1,420 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright 2014 Square, Inc.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:Suppress(
"unused",
"KDocUnresolvedReference",
"SpellCheckingInspection",
)
package io.element.android.features.rageshake.impl.reporter
import kotlinx.collections.immutable.toImmutableList
import okhttp3.Headers
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody
import okio.Buffer
import okio.BufferedSink
import okio.ByteString
import okio.ByteString.Companion.encodeUtf8
import java.io.IOException
import java.util.UUID
/**
* Copy of [okhttp3.MultipartBody] with addition of a listener to track progress (Last imported from OkHttp 5.0.0).
* Patches are surrounded by ELEMENT-START and ELEMENT-END
*
* An [RFC 2387][rfc_2387]-compliant request body.
*
* [rfc_2387]: http://www.ietf.org/rfc/rfc2387.txt
*/
@Suppress("NAME_SHADOWING")
class BugReporterMultipartBody internal constructor(
private val boundaryByteString: ByteString,
@get:JvmName("type") val type: MediaType,
@get:JvmName("parts") val parts: List<Part>,
) : RequestBody() {
// ELEMENT-START
private var listener: BugReporterMultipartBodyListener? = null
private fun onWrite(totalWrittenBytes: Long) {
listener
?.takeIf { contentLength > 0 }
?.onWrite(totalWrittenBytes, contentLength)
}
private val contentLengthSize = mutableListOf<Long>()
fun setWriteListener(listener: BugReporterMultipartBodyListener?) {
this.listener = listener
}
// ELEMENT-END
private val contentType: MediaType = "$type; boundary=$boundary".toMediaType()
private var contentLength = -1L
@get:JvmName("boundary")
val boundary: String
get() = boundaryByteString.utf8()
/** The number of parts in this multipart body. */
@get:JvmName("size")
val size: Int
get() = parts.size
fun part(index: Int): Part = parts[index]
override fun isOneShot(): Boolean = parts.any { it.body.isOneShot() }
/** A combination of [type] and [boundaryByteString]. */
override fun contentType(): MediaType = contentType
@JvmName("-deprecated_type")
@Deprecated(
message = "moved to val",
replaceWith = ReplaceWith(expression = "type"),
level = DeprecationLevel.ERROR,
)
fun type(): MediaType = type
@JvmName("-deprecated_boundary")
@Deprecated(
message = "moved to val",
replaceWith = ReplaceWith(expression = "boundary"),
level = DeprecationLevel.ERROR,
)
fun boundary(): String = boundary
@JvmName("-deprecated_size")
@Deprecated(
message = "moved to val",
replaceWith = ReplaceWith(expression = "size"),
level = DeprecationLevel.ERROR,
)
fun size(): Int = size
@JvmName("-deprecated_parts")
@Deprecated(
message = "moved to val",
replaceWith = ReplaceWith(expression = "parts"),
level = DeprecationLevel.ERROR,
)
fun parts(): List<Part> = parts
@Throws(IOException::class)
override fun contentLength(): Long {
var result = contentLength
if (result == -1L) {
result = writeOrCountBytes(null, true)
contentLength = result
}
return result
}
@Throws(IOException::class)
override fun writeTo(sink: BufferedSink) {
writeOrCountBytes(sink, false)
}
/**
* Either writes this request to [sink] or measures its content length. We have one method do
* double-duty to make sure the counting and content are consistent, particularly when it comes
* to awkward operations like measuring the encoded length of header strings, or the
* length-in-digits of an encoded integer.
*/
@Throws(IOException::class)
private fun writeOrCountBytes(
sink: BufferedSink?,
countBytes: Boolean,
): Long {
var sink = sink
var byteCount = 0L
var byteCountBuffer: Buffer? = null
if (countBytes) {
byteCountBuffer = Buffer()
sink = byteCountBuffer
// ELEMENT-START
contentLengthSize.clear()
// ELEMENT-END
}
for (p in 0 until parts.size) {
val part = parts[p]
val headers = part.headers
val body = part.body
sink!!.write(DASHDASH)
sink.write(boundaryByteString)
sink.write(CRLF)
if (headers != null) {
for (h in 0 until headers.size) {
sink
.writeUtf8(headers.name(h))
.write(COLONSPACE)
.writeUtf8(headers.value(h))
.write(CRLF)
}
}
val contentType = body.contentType()
if (contentType != null) {
sink
.writeUtf8("Content-Type: ")
.writeUtf8(contentType.toString())
.write(CRLF)
}
// We can't measure the body's size without the sizes of its components.
val contentLength = body.contentLength()
if (contentLength == -1L && countBytes) {
byteCountBuffer!!.clear()
return -1L
}
sink.write(CRLF)
if (countBytes) {
byteCount += contentLength
// ELEMENT-START
contentLengthSize.add(byteCount)
// ELEMENT-END
} else {
body.writeTo(sink)
// ELEMENT-START
// warn the listener of upload progress
// sink.buffer().size() does not give the right value
// assume that some data are popped
contentLengthSize.getOrNull(p)?.let { writtenByte ->
onWrite(writtenByte)
}
// ELEMENT-END
}
sink.write(CRLF)
}
sink!!.write(DASHDASH)
sink.write(boundaryByteString)
sink.write(DASHDASH)
sink.write(CRLF)
if (countBytes) {
byteCount += byteCountBuffer!!.size
byteCountBuffer.clear()
}
return byteCount
}
class Part private constructor(
@get:JvmName("headers") val headers: Headers?,
@get:JvmName("body") val body: RequestBody,
) {
@JvmName("-deprecated_headers")
@Deprecated(
message = "moved to val",
replaceWith = ReplaceWith(expression = "headers"),
level = DeprecationLevel.ERROR,
)
fun headers(): Headers? = headers
@JvmName("-deprecated_body")
@Deprecated(
message = "moved to val",
replaceWith = ReplaceWith(expression = "body"),
level = DeprecationLevel.ERROR,
)
fun body(): RequestBody = body
companion object {
@JvmStatic
fun create(body: RequestBody): Part = create(null, body)
@JvmStatic
fun create(
headers: Headers?,
body: RequestBody,
): Part {
require(headers?.get("Content-Type") == null) { "Unexpected header: Content-Type" }
require(headers?.get("Content-Length") == null) { "Unexpected header: Content-Length" }
return Part(headers, body)
}
@JvmStatic
fun createFormData(
name: String,
value: String,
): Part = createFormData(name, null, value.toRequestBody())
@JvmStatic
fun createFormData(
name: String,
filename: String?,
body: RequestBody,
): Part {
val disposition =
buildString {
append("form-data; name=")
appendQuotedString(name)
if (filename != null) {
append("; filename=")
appendQuotedString(filename)
}
}
val headers =
Headers
.Builder()
.addUnsafeNonAscii("Content-Disposition", disposition)
.build()
return create(headers, body)
}
}
}
class Builder
@JvmOverloads
constructor(
boundary: String = UUID.randomUUID().toString(),
) {
private val boundary: ByteString = boundary.encodeUtf8()
// ELEMENT-START
// Element: use FORM as default type
private var type = FORM
// ELEMENT-END
private val parts = mutableListOf<Part>()
/**
* Set the MIME type. Expected values for `type` are [MIXED] (the default), [ALTERNATIVE],
* [DIGEST], [PARALLEL] and [FORM].
*/
fun setType(type: MediaType) =
apply {
require(type.type == "multipart") { "multipart != $type" }
this.type = type
}
/** Add a part to the body. */
fun addPart(body: RequestBody) =
apply {
addPart(Part.create(body))
}
/** Add a part to the body. */
fun addPart(
headers: Headers?,
body: RequestBody,
) = apply {
addPart(Part.create(headers, body))
}
/** Add a form data part to the body. */
fun addFormDataPart(
name: String,
value: String,
) = apply {
addPart(Part.createFormData(name, value))
}
/** Add a form data part to the body. */
fun addFormDataPart(
name: String,
filename: String?,
body: RequestBody,
) = apply {
addPart(Part.createFormData(name, filename, body))
}
/** Add a part to the body. */
fun addPart(part: Part) =
apply {
parts += part
}
/** Assemble the specified parts into a request body. */
fun build(): BugReporterMultipartBody {
check(parts.isNotEmpty()) { "Multipart body must have at least one part." }
return BugReporterMultipartBody(boundary, type, parts.toImmutableList())
}
}
companion object {
/**
* The "mixed" subtype of "multipart" is intended for use when the body parts are independent
* and need to be bundled in a particular order. Any "multipart" subtypes that an implementation
* does not recognize must be treated as being of subtype "mixed".
*/
@JvmField
val MIXED = "multipart/mixed".toMediaType()
/**
* The "multipart/alternative" type is syntactically identical to "multipart/mixed", but the
* semantics are different. In particular, each of the body parts is an "alternative" version of
* the same information.
*/
@JvmField
val ALTERNATIVE = "multipart/alternative".toMediaType()
/**
* This type is syntactically identical to "multipart/mixed", but the semantics are different.
* In particular, in a digest, the default `Content-Type` value for a body part is changed from
* "text/plain" to "message/rfc822".
*/
@JvmField
val DIGEST = "multipart/digest".toMediaType()
/**
* This type is syntactically identical to "multipart/mixed", but the semantics are different.
* In particular, in a parallel entity, the order of body parts is not significant.
*/
@JvmField
val PARALLEL = "multipart/parallel".toMediaType()
/**
* The media-type multipart/form-data follows the rules of all multipart MIME data streams as
* outlined in RFC 2046. In forms, there are a series of fields to be supplied by the user who
* fills out the form. Each field has a name. Within a given form, the names are unique.
*/
@JvmField
val FORM = "multipart/form-data".toMediaType()
private val COLONSPACE = byteArrayOf(':'.code.toByte(), ' '.code.toByte())
private val CRLF = byteArrayOf('\r'.code.toByte(), '\n'.code.toByte())
private val DASHDASH = byteArrayOf('-'.code.toByte(), '-'.code.toByte())
/**
* Appends a quoted-string to a StringBuilder.
*
* RFC 2388 is rather vague about how one should escape special characters in form-data
* parameters, and as it turns out Firefox and Chrome actually do rather different things, and
* both say in their comments that they're not really sure what the right approach is. We go
* with Chrome's behavior (which also experimentally seems to match what IE does), but if you
* actually want to have a good chance of things working, please avoid double-quotes, newlines,
* percent signs, and the like in your field names.
*/
internal fun StringBuilder.appendQuotedString(key: String) {
append('"')
for (i in 0 until key.length) {
when (val ch = key[i]) {
'\n' -> append("%0A")
'\r' -> append("%0D")
'"' -> append("%22")
else -> append(ch)
}
}
append('"')
}
}
}
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.reporter
fun interface BugReporterMultipartBodyListener {
/**
* Upload listener.
*
* @param totalWritten total written bytes
* @param contentLength content length
*/
fun onWrite(totalWritten: Long, contentLength: Long)
}
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.reporter
import kotlinx.coroutines.flow.Flow
import okhttp3.HttpUrl
fun interface BugReporterUrlProvider {
fun provide(): Flow<HttpUrl?>
}
@@ -0,0 +1,444 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.reporter
import android.content.Context
import android.os.Build
import androidx.core.net.toFile
import androidx.core.net.toUri
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Provider
import dev.zacsweers.metro.SingleIn
import io.element.android.appconfig.RageshakeConfig
import io.element.android.features.rageshake.api.logs.createWriteToFilesConfiguration
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.reporter.BugReporterListener
import io.element.android.features.rageshake.impl.crash.CrashDataStore
import io.element.android.features.rageshake.impl.screenshot.ScreenshotHolder
import io.element.android.libraries.androidutils.file.compressFile
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.SdkMetadata
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.tracing.TracingService
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.api.sessionIdFlow
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.json.JSONException
import org.json.JSONObject
import timber.log.Timber
import java.io.File
import java.io.IOException
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
/**
* BugReporter creates and sends the bug reports.
*/
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultBugReporter(
@ApplicationContext private val context: Context,
@AppCoroutineScope
private val appCoroutineScope: CoroutineScope,
private val screenshotHolder: ScreenshotHolder,
private val crashDataStore: CrashDataStore,
private val coroutineDispatchers: CoroutineDispatchers,
private val okHttpClient: Provider<OkHttpClient>,
private val userAgentProvider: UserAgentProvider,
private val sessionStore: SessionStore,
private val buildMeta: BuildMeta,
private val bugReporterUrlProvider: BugReporterUrlProvider,
private val sdkMetadata: SdkMetadata,
private val matrixClientProvider: MatrixClientProvider,
private val tracingService: TracingService,
) : BugReporter {
companion object {
// filenames
private const val LOG_CAT_FILENAME = "logcat.log"
private const val LOG_DIRECTORY_NAME = "logs"
}
private val logcatCommandDebug = arrayOf("logcat", "-d", "-v", "threadtime", "*:*")
private var currentTracingLogLevel: String? = null
private val baseLogDirectory = File(context.cacheDir, LOG_DIRECTORY_NAME)
private var currentLogDirectory: File = baseLogDirectory
init {
if (buildMeta.isEnterpriseBuild) {
val logSubfolder = runBlocking {
sessionStore.getLatestSession()
}?.userId?.let(::UserId)?.domainName
setCurrentLogDirectory(logSubfolder)
sessionStore.sessionIdFlow()
.map {
it?.let(::UserId)?.domainName
}
.distinctUntilChanged()
.onEach { logSubfolder ->
setCurrentLogDirectory(logSubfolder)
tracingService.updateWriteToFilesConfiguration(createWriteToFilesConfiguration())
}
.launchIn(appCoroutineScope)
}
}
override suspend fun sendBugReport(
withDevicesLogs: Boolean,
withCrashLogs: Boolean,
withScreenshot: Boolean,
problemDescription: String,
canContact: Boolean,
sendPushRules: Boolean,
listener: BugReporterListener,
) {
val url = bugReporterUrlProvider.provide().first()
if (url == null) {
// It should not happen, but if the URL is null, we cannot proceed
Timber.e("## sendBugReport() : bug report URL is null")
error("Bug report URL is null, cannot send bug report")
}
// enumerate files to delete
val bugReportFiles: MutableList<File> = ArrayList()
var response: Response? = null
try {
var serverError: String? = null
withContext(coroutineDispatchers.io) {
val crashCallStack = crashDataStore.crashInfo().first()
val bugDescription = buildString {
append(problemDescription)
if (crashCallStack.isNotEmpty() && withCrashLogs) {
append("\n\n\n\n--------------------------------- crash call stack ---------------------------------\n")
append(crashCallStack)
}
}
val gzippedFiles = mutableListOf<File>()
if (withDevicesLogs) {
val files = getLogFiles().sortedByDescending { it.lastModified() }
files.mapNotNullTo(gzippedFiles) { file ->
when {
file.extension == "gz" -> file
else -> compressFile(file)
}
}
}
if (withCrashLogs || withDevicesLogs) {
saveLogCat()
?.let { logCatFile ->
compressFile(logCatFile).also {
logCatFile.safeDelete()
}
}
?.let { gzippedLogcat ->
gzippedFiles.add(0, gzippedLogcat)
}
}
val sessionData = sessionStore.getLatestSession()
val numberOfAccounts = sessionStore.numberOfSessions()
val deviceId = sessionData?.deviceId ?: "undefined"
val userId = sessionData?.userId?.let { UserId(it) }
// build the multi part request
val builder = BugReporterMultipartBody.Builder()
.addFormDataPart("text", bugDescription)
.addFormDataPart("app", RageshakeConfig.BUG_REPORT_APP_NAME)
.addFormDataPart("user_agent", userAgentProvider.provide())
.addFormDataPart("user_id", userId?.toString() ?: "undefined")
.addFormDataPart("number_of_accounts", numberOfAccounts.toString())
.addFormDataPart("can_contact", canContact.toString())
.addFormDataPart("device_id", deviceId)
.addFormDataPart("device", Build.MODEL.trim())
.addFormDataPart("locale", Locale.getDefault().toString())
.addFormDataPart("sdk_sha", sdkMetadata.sdkGitSha)
.addFormDataPart("local_time", LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME))
.addFormDataPart("utc_time", LocalDateTime.ofInstant(Instant.now(), ZoneOffset.UTC).format(DateTimeFormatter.ISO_DATE_TIME))
.addFormDataPart("app_id", buildMeta.applicationId)
// Nightly versions have a custom version name suffix that we should remove for the bug report
.addFormDataPart("Version", buildMeta.versionName.replace("-nightly", ""))
.addFormDataPart("label", buildMeta.versionName)
.addFormDataPart("label", buildMeta.flavorDescription)
.addFormDataPart("branch_name", buildMeta.gitBranchName)
userId?.let {
matrixClientProvider.getOrNull(it)?.let { client ->
val curveKey = client.encryptionService.deviceCurve25519()
val edKey = client.encryptionService.deviceEd25519()
if (curveKey != null && edKey != null) {
builder.addFormDataPart("device_keys", "curve25519:$curveKey, ed25519:$edKey")
}
if (sendPushRules) {
client.notificationSettingsService.getRawPushRules().getOrNull()?.let { pushRules ->
builder.addFormDataPart(
name = "file",
filename = "push_rules.json",
body = pushRules.toByteArray().toRequestBody(MimeTypes.Json.toMediaTypeOrNull())
)
}
}
}
}
if (crashCallStack.isNotEmpty() && withCrashLogs) {
builder.addFormDataPart("label", "crash")
}
currentTracingLogLevel?.let {
builder.addFormDataPart("tracing_log_level", it)
}
if (buildMeta.isEnterpriseBuild) {
builder.addFormDataPart("label", "Enterprise")
}
// add the gzipped files, don't cancel the whole upload if only some file failed to upload
var totalUploadedSize = 0L
var uploadedSomeLogs = false
for (file in gzippedFiles) {
try {
val requestBody = file.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull())
totalUploadedSize += requestBody.contentLength()
// If we are about to upload more than the max request size, stop here
if (totalUploadedSize > RageshakeConfig.MAX_LOG_UPLOAD_SIZE) {
Timber.e("Could not upload file ${file.name} because it would exceed the max request size")
break
}
builder.addFormDataPart("compressed-log", file.name, requestBody)
uploadedSomeLogs = true
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.e(e, "## sendBugReport() : fail to attach file ${file.name}")
}
}
bugReportFiles.addAll(gzippedFiles)
if (gzippedFiles.isNotEmpty() && !uploadedSomeLogs) {
serverError = "Couldn't upload any logs, please retry."
return@withContext
}
if (withScreenshot) {
screenshotHolder.getFileUri()
?.toUri()
?.toFile()
?.let { screenshotFile ->
try {
builder.addFormDataPart(
"file",
screenshotFile.name,
screenshotFile.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull())
)
} catch (e: Exception) {
Timber.e(e, "## sendBugReport() : fail to write screenshot")
}
}
}
val requestBody = builder.build()
// add a progress listener
requestBody.setWriteListener { totalWritten, contentLength ->
val percentage = if (-1L != contentLength) {
if (totalWritten > contentLength) {
100
} else {
(totalWritten * 100 / contentLength).toInt()
}
} else {
0
}
Timber.v("## onWrite() : $percentage%")
listener.onProgress(percentage)
}
// build the request
val request = Request.Builder()
.url(url)
.post(requestBody)
.build()
var errorMessage: String? = null
// trigger the request
try {
response = okHttpClient()
.newCall(request)
.execute()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.e(e, "Error executing the request")
errorMessage = e.localizedMessage
}
val responseCode = response?.code
// if the upload failed, try to retrieve the reason
if (responseCode != HttpURLConnection.HTTP_OK) {
serverError = if (errorMessage != null) {
"Failed with error $errorMessage"
} else {
val responseBody = response?.body
if (responseBody == null) {
"Failed with error $responseCode"
} else {
try {
val inputStream = responseBody.byteStream()
val serverErrorJson = inputStream.use {
it.readBytes().toString(Charsets.UTF_8)
}
try {
val responseJSON = JSONObject(serverErrorJson)
responseJSON.getString("error")
} catch (e: CancellationException) {
throw e
} catch (e: JSONException) {
Timber.e(e, "Json conversion failed")
"Failed with error $responseCode"
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.e(e, "## sendBugReport() : failed to parse error")
"Failed with error $responseCode"
}
}
}
}
}
if (serverError == null) {
listener.onUploadSucceed()
} else {
listener.onUploadFailed(serverError)
}
} finally {
withContext(coroutineDispatchers.io) {
// delete the generated files when the bug report process has finished
for (file in bugReportFiles) {
file.safeDelete()
}
response?.close()
}
}
}
override fun logDirectory(): File {
return currentLogDirectory.apply {
mkdirs()
}
}
private fun setCurrentLogDirectory(subfolderName: String?) {
currentLogDirectory = if (subfolderName == null) {
baseLogDirectory
} else {
File(baseLogDirectory, subfolderName)
}
}
suspend fun deleteAllFiles(predicate: (File) -> Boolean) {
withContext(coroutineDispatchers.io) {
deleteAllFilesRecursive(baseLogDirectory, predicate)
}
}
private fun deleteAllFilesRecursive(
directory: File,
predicate: (File) -> Boolean,
) {
directory.listFiles()?.forEach { file ->
if (file.isDirectory) {
deleteAllFilesRecursive(file, predicate)
} else {
if (predicate(file)) {
file.safeDelete()
}
}
}
}
override fun setCurrentTracingLogLevel(logLevel: String) {
currentTracingLogLevel = logLevel
}
/**
* @return the files on the log directory.
*/
private fun getLogFiles(): List<File> {
return tryOrNull(
onException = { Timber.e(it, "## getLogFiles() failed") }
) {
val logDirectory = logDirectory()
logDirectory.listFiles()
?.filter { it.isFile && !it.name.endsWith(LOG_CAT_FILENAME) }
}.orEmpty()
}
// ==============================================================================================================
// Logcat management
// ==============================================================================================================
/**
* Save the logcat.
*
* @return the file if the operation succeeds
*/
override fun saveLogCat(): File? {
val file = File(baseLogDirectory, LOG_CAT_FILENAME)
if (file.exists()) {
file.safeDelete()
}
return try {
file.writer().use {
getLogCatContent(it)
}
file
} catch (e: Exception) {
Timber.e(e, "## saveLogCat() : fail to write logcat")
null
}
}
/**
* Retrieves the logs.
*
* @param streamWriter the stream writer
*/
private fun getLogCatContent(streamWriter: OutputStreamWriter) {
val logcatProcess = tryOrNull {
Runtime.getRuntime().exec(logcatCommandDebug)
} ?: return
try {
val separator = System.lineSeparator()
logcatProcess.inputStream
.reader()
.buffered(RageshakeConfig.MAX_LOG_UPLOAD_SIZE.toInt())
.forEachLine { line ->
streamWriter.append(line)
streamWriter.append(separator)
}
} catch (e: IOException) {
Timber.e(e, "getLogCatContent fails")
}
}
}
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.reporter
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.appconfig.RageshakeConfig
import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.api.sessionIdFlow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
@ContributesBinding(AppScope::class)
class DefaultBugReporterUrlProvider(
private val bugReportAppNameProvider: BugReportAppNameProvider,
private val enterpriseService: EnterpriseService,
private val sessionStore: SessionStore,
) : BugReporterUrlProvider {
@OptIn(ExperimentalCoroutinesApi::class)
override fun provide(): Flow<HttpUrl?> {
if (bugReportAppNameProvider.provide().isEmpty()) return flowOf(null)
return sessionStore.sessionIdFlow().flatMapLatest { sessionId ->
enterpriseService.bugReportUrlFlow(sessionId?.let(::SessionId))
.map { bugReportUrl ->
when (bugReportUrl) {
is BugReportUrl.Custom -> bugReportUrl.url
BugReportUrl.Disabled -> null
BugReportUrl.UseDefault -> RageshakeConfig.BUG_REPORT_URL.takeIf { it.isNotEmpty() }
}
}
.map { it?.toHttpUrl() }
}
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.screenshot
import android.content.Context
import android.graphics.Bitmap
import androidx.core.net.toUri
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.androidutils.bitmap.writeBitmap
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.di.annotations.ApplicationContext
import java.io.File
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultScreenshotHolder(
@ApplicationContext private val context: Context,
) : ScreenshotHolder {
private val file = File(context.filesDir, "screenshot.png")
override fun writeBitmap(data: Bitmap) {
file.writeBitmap(data, Bitmap.CompressFormat.PNG, 85)
}
override fun getFileUri(): String? {
return file
.takeIf { it.exists() && it.length() > 0 }
?.toUri()
?.toString()
}
override fun reset() {
file.safeDelete()
}
}
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.rageshake.impl.screenshot
import android.graphics.Bitmap
interface ScreenshotHolder {
fun writeBitmap(data: Bitmap)
fun getFileUri(): String?
fun reset()
}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bug_report_attach_screenshot">"Далучыць здымак экрана"</string>
<string name="screen_bug_report_contact_me">"Вы можаце звязацца са мной, калі ў Вас узнікнуць якія-небудзь дадатковыя пытанні."</string>
<string name="screen_bug_report_contact_me_title">"Звяжыцеся са мной"</string>
<string name="screen_bug_report_edit_screenshot">"Рэдагаваць здымак экрана"</string>
<string name="screen_bug_report_editor_description">"Калі ласка, апішыце памылку. Што вы зрабілі? Якія паводзіны вы чакалі? Што адбылося насамрэч. Калі ласка, апішыце ўсё як магчыма падрабязней."</string>
<string name="screen_bug_report_editor_placeholder">"Апішыце праблему…"</string>
<string name="screen_bug_report_editor_supporting">"Калі магчыма, калі ласка, напішыце апісанне на англійскай мове."</string>
<string name="screen_bug_report_error_description_too_short">"Апісанне занадта кароткае. Дайце больш падрабязную інфармацыю аб тым, што адбылося. Дзякуй!"</string>
<string name="screen_bug_report_include_crash_logs">"Адправіць журналы збояў"</string>
<string name="screen_bug_report_include_logs">"Дазволіць журналы"</string>
<string name="screen_bug_report_include_screenshot">"Адправіць здымак экрана"</string>
<string name="screen_bug_report_logs_description">"Каб пераканацца, што ўсё працуе правільна, у паведамленне будуць уключаны часопісы. Каб адправіць паведамленне без часопісаў, адключыце гэтую наладу."</string>
<string name="screen_bug_report_rash_logs_alert_title">"Пры апошнім выкарыстанні %1$s адбыўся збой. Хочаце падзяліцца справаздачай аб збоі?"</string>
<string name="screen_bug_report_view_logs">"Прагляд журналаў"</string>
</resources>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bug_report_attach_screenshot">"Прикачване на екранна снимка"</string>
<string name="screen_bug_report_contact_me">"Можеш да се свържеш с мен, ако има допълнителни въпроси."</string>
<string name="screen_bug_report_contact_me_title">"Свързване с мен"</string>
<string name="screen_bug_report_edit_screenshot">"Редактиране на екранната снимка"</string>
<string name="screen_bug_report_editor_description">"Моля, опишете проблема. Какво направихте? Какво очаквахте да се случи? Какво се случи в действителност. Моля, изложете колкото се може повече подробности."</string>
<string name="screen_bug_report_editor_placeholder">"Опишете проблема…"</string>
<string name="screen_bug_report_editor_supporting">"Ако е възможно, моля, напишете описанието на английски език."</string>
<string name="screen_bug_report_error_description_too_short">"Описанието е твърде кратко, моля, дайте повече подробности за случилото се. Благодаря!"</string>
<string name="screen_bug_report_include_crash_logs">"Изпращане на дневниците за сривове"</string>
<string name="screen_bug_report_include_logs">"Разрешаване на дневниците"</string>
<string name="screen_bug_report_include_screenshot">"Изпращане на екранна снимка"</string>
<string name="screen_bug_report_logs_description">"Дневниците ще бъдат включени към вашето съобщение, за да се уверим, че всичко работи правилно. За да изпратите съобщението си без дневници, изключете тази настройка."</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s се срина при последното използване. Искате ли да споделите доклад за срива с нас?"</string>
<string name="screen_bug_report_view_logs">"Преглед на дневниците"</string>
</resources>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bug_report_attach_screenshot">"Připojit snímek obrazovky"</string>
<string name="screen_bug_report_contact_me">"V případě dalších dotazů se na mě můžete obrátit."</string>
<string name="screen_bug_report_contact_me_title">"Kontaktujte mě"</string>
<string name="screen_bug_report_edit_screenshot">"Upravit snímek obrazovky"</string>
<string name="screen_bug_report_editor_description">"Popište prosím chybu. Co jste udělali? Co jste očekávali, že se stane? Co se ve skutečnosti stalo? Uveďte co nejvíce podrobností."</string>
<string name="screen_bug_report_editor_placeholder">"Popište chybu…"</string>
<string name="screen_bug_report_editor_supporting">"Pokud je to možné, prosím, napište popis anglicky."</string>
<string name="screen_bug_report_error_description_too_short">"Popis je příliš krátký, uveďte prosím více podrobností o tom, co se stalo. Děkujeme!"</string>
<string name="screen_bug_report_include_crash_logs">"Odeslat záznamy o selhání"</string>
<string name="screen_bug_report_include_logs">"Povolit protokoly"</string>
<string name="screen_bug_report_include_logs_error">"Vaše protokoly jsou příliš velké, proto je nelze zahrnout do této zprávy. Zašlete nám je prosím jiným způsobem."</string>
<string name="screen_bug_report_include_screenshot">"Odeslat snímek obrazovky"</string>
<string name="screen_bug_report_logs_description">"Protokoly budou součástí vaší zprávy, aby se zajistilo že vše funguje správně. Chcete-li odeslat zprávu bez protokolů, vypněte toto nastavení."</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s havaroval při posledním použití. Chcete se s námi podělit o zprávu o selhání?"</string>
<string name="screen_bug_report_send_notification_settings_description">"Pokud máte problémy s oznámeními, nahrání nastavení oznámení nám může pomoci určit jejich příčinu."</string>
<string name="screen_bug_report_send_notification_settings_title">"Nastavení odesílání oznámení"</string>
<string name="screen_bug_report_view_logs">"Zobrazit protokoly"</string>
</resources>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bug_report_attach_screenshot">"Atodwch lun sgrin"</string>
<string name="screen_bug_report_contact_me">"Gallwch gysylltu â mi os oes gennych unrhyw gwestiynau dilynol."</string>
<string name="screen_bug_report_contact_me_title">"Cysylltwch â mi"</string>
<string name="screen_bug_report_edit_screenshot">"Golygu\'r llun sgrin"</string>
<string name="screen_bug_report_editor_description">"Disgrifiwch y broblem. Beth wnaethoch chi? Beth oeddech chi\'n disgwyl i ddigwydd? Beth ddigwyddodd mewn gwirionedd. Ewch i gymaint o fanylion ag y gallwch."</string>
<string name="screen_bug_report_editor_placeholder">"Disgrifiwch y broblem…"</string>
<string name="screen_bug_report_editor_supporting">"Os yn bosibl, ysgrifennwch y disgrifiad yn Saesneg."</string>
<string name="screen_bug_report_error_description_too_short">"Mae\'r disgrifiad yn rhy fyr, rhowch fwy o fanylion am yr hyn ddigwyddodd. Diolch!"</string>
<string name="screen_bug_report_include_crash_logs">"Anfonwch logiau chwalu"</string>
<string name="screen_bug_report_include_logs">"Caniatáu logiau"</string>
<string name="screen_bug_report_include_logs_error">"Mae eich logiau\'n rhy fawr felly nid oes modd eu cynnwys yn yr adroddiad hwn, anfonwch nhw atom mewn ffordd arall."</string>
<string name="screen_bug_report_include_screenshot">"Anfon luniau sgrin"</string>
<string name="screen_bug_report_logs_description">"Bydd cofnodion yn cael eu cynnwys gyda\'ch neges i wneud yn siŵr bod popeth yn gweithio\'n iawn. I anfon eich neges heb logiau, diffoddwch y gosodiad hwn."</string>
<string name="screen_bug_report_rash_logs_alert_title">"Chwalodd %1$s y tro diwethaf iddo gael ei ddefnyddio. Hoffech chi rannu adroddiad gwall gyda ni?"</string>
<string name="screen_bug_report_send_notification_settings_description">"Os ydych chi\'n cael problemau gyda hysbysiadau, gall llwytho\'r gosodiadau hysbysiadau ein helpu i ddarganfod yr achos gwreiddiol."</string>
<string name="screen_bug_report_send_notification_settings_title">"Anfon gosodiadau hysbysiadau"</string>
<string name="screen_bug_report_view_logs">"Gweld logiau"</string>
</resources>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bug_report_attach_screenshot">"Vedhæft skærmbillede"</string>
<string name="screen_bug_report_contact_me">"Du kan kontakte mig, hvis du har opfølgende spørgsmål."</string>
<string name="screen_bug_report_contact_me_title">"Kontakt mig"</string>
<string name="screen_bug_report_edit_screenshot">"Rediger skærmbillede"</string>
<string name="screen_bug_report_editor_description">"Beskriv venligst problemet: Hvad gjorde du? Hvad forventede du, at der skulle ske? Hvad skete der faktisk? - Beskriv det med så mange detaljer som du kan."</string>
<string name="screen_bug_report_editor_placeholder">"Beskriv problemet…"</string>
<string name="screen_bug_report_editor_supporting">"Hvis det er muligt, må du meget gerne lave beskrivelsen på engelsk."</string>
<string name="screen_bug_report_error_description_too_short">"Beskrivelsen er for kort, giv venligst flere detaljer om, hvad der skete. Tak!"</string>
<string name="screen_bug_report_include_crash_logs">"Send nedbrudslogfiler"</string>
<string name="screen_bug_report_include_logs">"Tillad logfiler"</string>
<string name="screen_bug_report_include_logs_error">"Dine logfiler er for store, så de kan ikke medtages i denne rapport, send dem venligst til os på en anden måde."</string>
<string name="screen_bug_report_include_screenshot">"Send skærmbillede"</string>
<string name="screen_bug_report_logs_description">"Logfiler vil blive inkluderet i din besked for at sikre, at alt fungerer korrekt. Hvis du vil sende din besked uden logfiler, skal du deaktivere denne indstilling."</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s crashede sidste gang den blev brugt. Vil du dele en ulykkesrapport med os?"</string>
<string name="screen_bug_report_send_notification_settings_description">"Hvis du har problemer med notifikationer, kan upload af notifikationsindstillingerne hjælpe os med at identificere den grundlæggende årsag."</string>
<string name="screen_bug_report_send_notification_settings_title">"Send notifikationsindstillinger"</string>
<string name="screen_bug_report_view_logs">"Se logfiler"</string>
</resources>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bug_report_attach_screenshot">"Bildschirmfoto anhängen"</string>
<string name="screen_bug_report_contact_me">"Du kannst mich kontaktieren, solltest du weitere Fragen haben."</string>
<string name="screen_bug_report_contact_me_title">"Kontaktiere mich"</string>
<string name="screen_bug_report_edit_screenshot">"Bildschirmfoto bearbeiten"</string>
<string name="screen_bug_report_editor_description">"Bitte beschreibe das Problem. Was hast du getan? Was hast du erwartet, was passiert? Was ist tatsächlich passiert? Bitte gehe so detailliert wie möglich vor."</string>
<string name="screen_bug_report_editor_placeholder">"Beschreibe den Fehler…"</string>
<string name="screen_bug_report_editor_supporting">"Wenn möglich, verfasse die Beschreibung bitte auf Englisch."</string>
<string name="screen_bug_report_error_description_too_short">"Die Beschreibung ist zu kurz. Bitte gib weitere Informationen darüber an, was passiert ist."</string>
<string name="screen_bug_report_include_crash_logs">"Absturzprotokolle senden"</string>
<string name="screen_bug_report_include_logs">"Logdateien mitsenden"</string>
<string name="screen_bug_report_include_logs_error">"Deine Logs sind zu groß und können dem Bericht nicht beigefügt werden. Bitte sende sie uns auf einem anderen Weg."</string>
<string name="screen_bug_report_include_screenshot">"Bildschirmfoto senden"</string>
<string name="screen_bug_report_logs_description">"Die Protokolle werden deiner Nachricht beigefügt, um sicherzustellen, dass alles ordnungsgemäß funktioniert. Um deine Nachricht ohne Protokolle zu senden, deaktiviere diese Einstellung."</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s ist bei der letzten Nutzung abgestürzt. Möchtest du einen Absturzbericht mit uns teilen?"</string>
<string name="screen_bug_report_send_notification_settings_description">"Wenn du Probleme mit Benachrichtigungen hast, kann das Hochladen der Einstellungen für Benachrichtigungen uns helfen, die Ursache zu finden."</string>
<string name="screen_bug_report_send_notification_settings_title">"Einstellungen für Benachrichtigungen senden"</string>
<string name="screen_bug_report_view_logs">"Logs ansehen"</string>
</resources>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bug_report_attach_screenshot">"Επισύναψη στιγμιοτύπου οθόνης"</string>
<string name="screen_bug_report_contact_me">"Μπορείς να επικοινωνήσεις μαζί μου εάν έχεις οποιεσδήποτε επιπλέον ερωτήσεις."</string>
<string name="screen_bug_report_contact_me_title">"Επικοινώνησε μαζί μου"</string>
<string name="screen_bug_report_edit_screenshot">"Επεξεργασία στιγμιότυπου οθόνης"</string>
<string name="screen_bug_report_editor_description">"Παρακαλώ περιέγραψε το πρόβλημα. Τί έκανες; Τί περίμενες να συμβεί; Τι πραγματικά συνέβη. Παρακαλώ μπες σε όσο περισσότερες λεπτομέρειες μπορείς."</string>
<string name="screen_bug_report_editor_placeholder">"Περιέγραψε το πρόβλημα…"</string>
<string name="screen_bug_report_editor_supporting">"Εάν είναι δυνατόν, γράψε την περιγραφή στα αγγλικά."</string>
<string name="screen_bug_report_error_description_too_short">"Η περιγραφή είναι πολύ σύντομη, δώσε περισσότερες λεπτομέρειες σχετικά με το τί συνέβη. Ευχαριστώ!"</string>
<string name="screen_bug_report_include_crash_logs">"Αποστολή αρχείων καταγραφής σφαλμάτων"</string>
<string name="screen_bug_report_include_logs">"Να επιτρέπονται τα αρχεία καταγραφής"</string>
<string name="screen_bug_report_include_screenshot">"Αποστολή στιγμιοτύπου οθόνης"</string>
<string name="screen_bug_report_logs_description">"Τα αρχεία καταγραφής θα συμπεριληφθούν στο μήνυμά σου για να βεβαιωθούμε ότι όλα λειτουργούν σωστά. Για να στείλεις το μήνυμά σου χωρίς αρχεία καταγραφής, απενεργοποίησε αυτήν τη ρύθμιση."</string>
<string name="screen_bug_report_rash_logs_alert_title">"Το %1$s διακόπηκε την τελευταία φορά που χρησιμοποιήθηκε. Θα \'θελες να μοιραστείς μια αναφορά σφάλματος μαζί μας;"</string>
<string name="screen_bug_report_view_logs">"Προβολή αρχείων καταγραφής"</string>
</resources>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bug_report_attach_screenshot">"Adjuntar captura de pantalla"</string>
<string name="screen_bug_report_contact_me">"Pueden ponerse en contacto conmigo si tienen alguna pregunta relacionada."</string>
<string name="screen_bug_report_contact_me_title">"Contáctame"</string>
<string name="screen_bug_report_edit_screenshot">"Editar captura de pantalla"</string>
<string name="screen_bug_report_editor_description">"Describe el problema. ¿Qué has hecho? ¿Qué esperabas que ocurriera? ¿Qué ocurrió realmente? Por favor, detállalo todo lo que puedas."</string>
<string name="screen_bug_report_editor_placeholder">"Describe el problema…"</string>
<string name="screen_bug_report_editor_supporting">"Si es posible, escribe la descripción en inglés."</string>
<string name="screen_bug_report_error_description_too_short">"La descripción es demasiado corta. Proporciona más detalles sobre lo sucedido. ¡Gracias!"</string>
<string name="screen_bug_report_include_crash_logs">"Enviar registros de fallos"</string>
<string name="screen_bug_report_include_logs">"Permitir registros"</string>
<string name="screen_bug_report_include_screenshot">"Enviar captura de pantalla"</string>
<string name="screen_bug_report_logs_description">"Los registros se incluirán con tu mensaje para asegurarse de que todo funciona correctamente. Para enviar tu mensaje sin registros, desactiva esta opción."</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s se cerró inesperadamente la última vez que se lo usaste. ¿Quieres compartir un informe de error con nosotros?"</string>
<string name="screen_bug_report_view_logs">"Ver los registros"</string>
</resources>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bug_report_attach_screenshot">"Lisa ekraanitõmmis"</string>
<string name="screen_bug_report_contact_me">"Kui sul on täiendavaid küsimusi, siis võid minuga ühendust võtta."</string>
<string name="screen_bug_report_contact_me_title">"Võta minuga ühendust"</string>
<string name="screen_bug_report_edit_screenshot">"Muuda ekraanitõmmist"</string>
<string name="screen_bug_report_editor_description">"Palun kirjelda juhtunut. Mida sina tegid? Mis sinu arvates pidi juhtuma? Mis tegelikult juhtus? Palun kirjelda kõike seda võimalikult üksikasjalikult."</string>
<string name="screen_bug_report_editor_placeholder">"Palun kirjelda probleemi…"</string>
<string name="screen_bug_report_editor_supporting">"Kui vähegi võimalik, siis kirjuta inglise keeles."</string>
<string name="screen_bug_report_error_description_too_short">"Kirjeldus on liiga lühike. Palun jaga täpsemat teavet selle kohta, mis juhtus. Tänud juba ette!"</string>
<string name="screen_bug_report_include_crash_logs">"Saada krahhilogid"</string>
<string name="screen_bug_report_include_logs">"Luba logide saatmine"</string>
<string name="screen_bug_report_include_logs_error">"Sinu logid on väga mahukad ja neid ei saa siia lisada. Palun saada logid meile mõnel muul viisil."</string>
<string name="screen_bug_report_include_screenshot">"Saada ekraanitõmmis"</string>
<string name="screen_bug_report_logs_description">"Tõhusama veaotsingu nimel lisame sinu veateatele logid. Kui sa seda ei soovi, siis lülita antud valik välja."</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s jooksis kokku viimati, kui seda kasutasid. Kas tahaksid selle kohta meile veateate saata?"</string>
<string name="screen_bug_report_send_notification_settings_description">"Kui sul teavitused ei toimi päris korralikult, siis teavituste seadistuste üleslaadimine võib aidata meil põhjuse tuvastada."</string>
<string name="screen_bug_report_send_notification_settings_title">"Teavituste seadistuste saatmine"</string>
<string name="screen_bug_report_view_logs">"Vaata logisid"</string>
</resources>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bug_report_attach_screenshot">"Erantsi pantaila-argazkia"</string>
<string name="screen_bug_report_contact_me">"Galderarik baduzu, nirekin jar zaitezke harremanetan."</string>
<string name="screen_bug_report_contact_me_title">"Jarri nirekin harremanetan"</string>
<string name="screen_bug_report_edit_screenshot">"Editatu pantaila-argazkia"</string>
<string name="screen_bug_report_editor_description">"Deskribatu arazoa. Zer egin duzu? Zer espero zenuen gertatzea? Benetan gertatu dena. Eman ahalik eta xehetasun gehien."</string>
<string name="screen_bug_report_editor_placeholder">"Deskribatu arazoa…"</string>
<string name="screen_bug_report_editor_supporting">"Ahal izanez gero, idatzi deskribapena ingelesez."</string>
<string name="screen_bug_report_error_description_too_short">"Deskribapena laburregia da; eman gertatutakoari buruzko xehetasun gehiago. Eskerrik asko!"</string>
<string name="screen_bug_report_include_crash_logs">"Bidali kraskaduraren erregistroak"</string>
<string name="screen_bug_report_include_logs">"Baimendu erregistroak"</string>
<string name="screen_bug_report_include_screenshot">"Bidali pantaila-argazkia"</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s kraskatu zen azkenekoz erabili zenean. Gurekin partekatu nahi al duzu kraskatzearen txostena?"</string>
<string name="screen_bug_report_view_logs">"Ikusi erregistroak"</string>
</resources>

Some files were not shown because too many files have changed in this diff Show More