forked from dsutanto/bChot-android
First Commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
+15
@@ -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>
|
||||
}
|
||||
+26
@@ -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()
|
||||
}
|
||||
}
|
||||
+14
@@ -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
|
||||
}
|
||||
+13
@@ -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>
|
||||
+15
@@ -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
|
||||
)
|
||||
+15
@@ -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 = {}
|
||||
)
|
||||
+62
@@ -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)
|
||||
)
|
||||
}
|
||||
+19
@@ -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
|
||||
}
|
||||
+13
@@ -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>
|
||||
+19
@@ -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
|
||||
)
|
||||
+19
@@ -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 = {}
|
||||
)
|
||||
+96
@@ -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()
|
||||
}
|
||||
+19
@@ -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 })
|
||||
}
|
||||
+21
@@ -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,
|
||||
)
|
||||
}
|
||||
+14
@@ -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
|
||||
}
|
||||
+13
@@ -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>
|
||||
+17
@@ -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,
|
||||
)
|
||||
+33
@@ -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,
|
||||
)
|
||||
+73
@@ -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)
|
||||
}
|
||||
+49
@@ -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?
|
||||
}
|
||||
+38
@@ -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()
|
||||
}
|
||||
+65
@@ -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 s’est arrêté la dernière fois qu’il a été utilisé. Souhaitez-vous partager un rapport d’incident 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>
|
||||
@@ -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)
|
||||
}
|
||||
+26
@@ -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 }
|
||||
}
|
||||
}
|
||||
+21
@@ -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
|
||||
}
|
||||
+95
@@ -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()
|
||||
}
|
||||
}
|
||||
+13
@@ -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()
|
||||
}
|
||||
+60
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+153
@@ -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()
|
||||
}
|
||||
}
|
||||
+45
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+39
@@ -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 = {}
|
||||
)
|
||||
+206
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+27
@@ -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))
|
||||
}
|
||||
}
|
||||
+21
@@ -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()
|
||||
}
|
||||
+72
@@ -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()
|
||||
}
|
||||
}
|
||||
+62
@@ -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() }
|
||||
}
|
||||
}
|
||||
+71
@@ -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)
|
||||
}
|
||||
}
|
||||
+116
@@ -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
|
||||
}
|
||||
}
|
||||
+18
@@ -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
|
||||
}
|
||||
+34
@@ -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>
|
||||
}
|
||||
+24
@@ -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)
|
||||
}
|
||||
}
|
||||
+74
@@ -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)
|
||||
}
|
||||
}
|
||||
+68
@@ -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()
|
||||
}
|
||||
}
|
||||
+57
@@ -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() }
|
||||
}
|
||||
}
|
||||
+28
@@ -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)?)
|
||||
}
|
||||
+23
@@ -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()
|
||||
}
|
||||
+22
@@ -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
|
||||
}
|
||||
+420
@@ -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('"')
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -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)
|
||||
}
|
||||
+16
@@ -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?>
|
||||
}
|
||||
+444
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -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()
|
||||
}
|
||||
}
|
||||
+17
@@ -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
Reference in New Issue
Block a user