[Architecture] split all feature modules to api/impl

This commit is contained in:
ganfra 2023-03-08 16:13:45 +01:00
parent b2c68d555a
commit 7051bfc14e
214 changed files with 626 additions and 1090 deletions

View file

@ -0,0 +1,299 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.impl.reporter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import okhttp3.Headers;
import okhttp3.MediaType;
import okhttp3.RequestBody;
import okhttp3.internal.Util;
import okio.Buffer;
import okio.BufferedSink;
import okio.ByteString;
// simplified version of MultipartBody (OkHttp 3.6.0)
public class BugReporterMultipartBody extends RequestBody {
/**
* Listener
*/
public interface WriteListener {
/**
* Upload listener
*
* @param totalWritten total written bytes
* @param contentLength content length
*/
void onWrite(long totalWritten, long contentLength);
}
private static final MediaType FORM = MediaType.parse("multipart/form-data");
private static final byte[] COLONSPACE = {':', ' '};
private static final byte[] CRLF = {'\r', '\n'};
private static final byte[] DASHDASH = {'-', '-'};
private final ByteString mBoundary;
private final MediaType mContentType;
private final List<Part> mParts;
private long mContentLength = -1L;
// listener
private WriteListener mWriteListener;
//
private List<Long> mContentLengthSize = null;
private BugReporterMultipartBody(ByteString boundary, List<Part> parts) {
mBoundary = boundary;
mContentType = MediaType.parse(FORM + "; boundary=" + boundary.utf8());
mParts = Util.toImmutableList(parts);
}
@Override
public MediaType contentType() {
return mContentType;
}
@Override
public long contentLength() throws IOException {
long result = mContentLength;
if (result != -1L) return result;
return mContentLength = writeOrCountBytes(null, true);
}
@Override
public void writeTo(BufferedSink sink) throws IOException {
writeOrCountBytes(sink, false);
}
/**
* Set the listener
*
* @param listener the
*/
public void setWriteListener(WriteListener listener) {
mWriteListener = listener;
}
/**
* Warn the listener that some bytes have been written
*
* @param totalWrittenBytes the total written bytes
*/
private void onWrite(long totalWrittenBytes) {
if ((null != mWriteListener) && (mContentLength > 0)) {
mWriteListener.onWrite(totalWrittenBytes, mContentLength);
}
}
/**
* Either writes this request to {@code 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.
*/
private long writeOrCountBytes(BufferedSink sink, boolean countBytes) throws IOException {
long byteCount = 0L;
Buffer byteCountBuffer = null;
if (countBytes) {
sink = byteCountBuffer = new Buffer();
mContentLengthSize = new ArrayList<>();
}
for (int p = 0, partCount = mParts.size(); p < partCount; p++) {
Part part = mParts.get(p);
Headers headers = part.headers;
RequestBody body = part.body;
sink.write(DASHDASH);
sink.write(mBoundary);
sink.write(CRLF);
if (headers != null) {
for (int h = 0, headerCount = headers.size(); h < headerCount; h++) {
sink.writeUtf8(headers.name(h))
.write(COLONSPACE)
.writeUtf8(headers.value(h))
.write(CRLF);
}
}
MediaType contentType = body.contentType();
if (contentType != null) {
sink.writeUtf8("Content-Type: ")
.writeUtf8(contentType.toString())
.write(CRLF);
}
int contentLength = (int) body.contentLength();
if (contentLength != -1) {
sink.writeUtf8("Content-Length: ")
.writeUtf8(contentLength + "")
.write(CRLF);
} else if (countBytes) {
// We can't measure the body's size without the sizes of its components.
byteCountBuffer.clear();
return -1L;
}
sink.write(CRLF);
if (countBytes) {
byteCount += contentLength;
mContentLengthSize.add(byteCount);
} else {
body.writeTo(sink);
// warn the listener of upload progress
// sink.buffer().size() does not give the right value
// assume that some data are popped
if ((null != mContentLengthSize) && (p < mContentLengthSize.size())) {
onWrite(mContentLengthSize.get(p));
}
}
sink.write(CRLF);
}
sink.write(DASHDASH);
sink.write(mBoundary);
sink.write(DASHDASH);
sink.write(CRLF);
if (countBytes) {
byteCount += byteCountBuffer.size();
byteCountBuffer.clear();
}
return byteCount;
}
private static void appendQuotedString(StringBuilder target, String key) {
target.append('"');
for (int i = 0, len = key.length(); i < len; i++) {
char ch = key.charAt(i);
switch (ch) {
case '\n':
target.append("%0A");
break;
case '\r':
target.append("%0D");
break;
case '"':
target.append("%22");
break;
default:
target.append(ch);
break;
}
}
target.append('"');
}
public static final class Part {
public static Part create(Headers headers, RequestBody body) {
if (body == null) {
throw new NullPointerException("body == null");
}
if (headers != null && headers.get("Content-Type") != null) {
throw new IllegalArgumentException("Unexpected header: Content-Type");
}
if (headers != null && headers.get("Content-Length") != null) {
throw new IllegalArgumentException("Unexpected header: Content-Length");
}
return new Part(headers, body);
}
public static Part createFormData(String name, String value) {
return createFormData(name, null, RequestBody.create(value, null));
}
public static Part createFormData(String name, String filename, RequestBody body) {
if (name == null) {
throw new NullPointerException("name == null");
}
StringBuilder disposition = new StringBuilder("form-data; name=");
appendQuotedString(disposition, name);
if (filename != null) {
disposition.append("; filename=");
appendQuotedString(disposition, filename);
}
return create(Headers.of("Content-Disposition", disposition.toString()), body);
}
final Headers headers;
final RequestBody body;
private Part(Headers headers, RequestBody body) {
this.headers = headers;
this.body = body;
}
}
public static final class Builder {
private final ByteString boundary;
private final List<Part> parts = new ArrayList<>();
public Builder() {
this(UUID.randomUUID().toString());
}
public Builder(String boundary) {
this.boundary = ByteString.encodeUtf8(boundary);
}
/**
* Add a form data part to the body.
*/
public Builder addFormDataPart(String name, String value) {
return addPart(Part.createFormData(name, value));
}
/**
* Add a form data part to the body.
*/
public Builder addFormDataPart(String name, String filename, RequestBody body) {
return addPart(Part.createFormData(name, filename, body));
}
/**
* Add a part to the body.
*/
public Builder addPart(Part part) {
if (part == null) throw new NullPointerException("part == null");
parts.add(part);
return this;
}
/**
* Assemble the specified parts into a request body.
*/
public BugReporterMultipartBody build() {
if (parts.isEmpty()) {
throw new IllegalStateException("Multipart body must have at least one part.");
}
return new BugReporterMultipartBody(boundary, parts);
}
}
}

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.impl.bugreport
sealed interface BugReportEvents {
object SendBugReport : BugReportEvents
object ResetAll : BugReportEvents
data class SetDescription(val description: String) : BugReportEvents
data class SetSendLog(val sendLog: Boolean) : BugReportEvents
data class SetSendCrashLog(val sendCrashlog: Boolean) : BugReportEvents
data class SetCanContact(val canContact: Boolean) : BugReportEvents
data class SetSendScreenshot(val sendScreenshot: Boolean) : BugReportEvents
}

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.impl.bugreport
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.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.features.rageshake.impl.bugreport.BugReportPresenter
import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class)
class BugReportNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: BugReportPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
BugReportView(
state = state,
modifier = modifier,
onDone = this::onDone
)
}
private fun onDone() {
plugins<BugReportEntryPoint.Callback>().forEach { it.onBugReportSent() }
}
}

View file

@ -0,0 +1,154 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.impl.bugreport
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.saveable.rememberSaveable
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.reporter.BugReporterListener
import io.element.android.features.rageshake.api.reporter.ReportType
import io.element.android.features.rageshake.api.crash.CrashDataStore
import io.element.android.features.rageshake.impl.logs.VectorFileLogger
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class BugReportPresenter @Inject constructor(
private val bugReporter: BugReporter,
private val crashDataStore: CrashDataStore,
private val screenshotHolder: ScreenshotHolder,
private val appCoroutineScope: CoroutineScope,
) : Presenter<BugReportState> {
private class BugReporterUploadListener(
private val sendingProgress: MutableState<Float>,
private val sendingAction: MutableState<Async<Unit>>
) : BugReporterListener {
override fun onUploadCancelled() {
sendingProgress.value = 0f
sendingAction.value = Async.Uninitialized
}
override fun onUploadFailed(reason: String?) {
sendingProgress.value = 0f
sendingAction.value = Async.Failure(Exception(reason))
}
override fun onProgress(progress: Int) {
sendingProgress.value = progress.toFloat() / 100
sendingAction.value = Async.Loading()
}
override fun onUploadSucceed(reportUrl: String?) {
sendingProgress.value = 0f
sendingAction.value = Async.Success(Unit)
}
}
@Composable
override fun present(): BugReportState {
val screenshotUri = rememberSaveable {
mutableStateOf(
screenshotHolder.getFileUri()
)
}
val crashInfo: String by crashDataStore
.crashInfo()
.collectAsState(initial = "")
val sendingProgress = remember {
mutableStateOf(0f)
}
val sendingAction: MutableState<Async<Unit>> = remember {
mutableStateOf(Async.Uninitialized)
}
val formState: MutableState<BugReportFormState> = remember {
mutableStateOf(BugReportFormState.Default)
}
val uploadListener = BugReporterUploadListener(sendingProgress, sendingAction)
fun handleEvents(event: BugReportEvents) {
when (event) {
BugReportEvents.SendBugReport -> 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.SetSendCrashLog -> updateFormState(formState) {
copy(sendCrashLogs = event.sendCrashlog)
}
is BugReportEvents.SetSendLog -> updateFormState(formState) {
copy(sendLogs = event.sendLog)
}
is BugReportEvents.SetSendScreenshot -> updateFormState(formState) {
copy(sendScreenshot = event.sendScreenshot)
}
}
}
return BugReportState(
hasCrashLogs = crashInfo.isNotEmpty(),
sendingProgress = sendingProgress.value,
sending = sendingAction.value,
formState = formState.value,
screenshotUri = screenshotUri.value,
eventSink = ::handleEvents
)
}
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(
coroutineScope = this,
reportType = ReportType.BUG_REPORT,
withDevicesLogs = formState.sendLogs,
withCrashLogs = hasCrashLogs && formState.sendCrashLogs,
withKeyRequestHistory = false,
withScreenshot = formState.sendScreenshot,
theBugDescription = formState.description,
serverVersion = "",
canContact = formState.canContact,
customFields = emptyMap(),
listener = listener
)
}
private fun CoroutineScope.resetAll() = launch {
screenshotHolder.reset()
crashDataStore.reset()
VectorFileLogger.getFromTimber()?.reset()
}
}

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.impl.bugreport
import android.os.Parcelable
import io.element.android.libraries.architecture.Async
import kotlinx.parcelize.Parcelize
data class BugReportState(
val formState: BugReportFormState,
val hasCrashLogs: Boolean,
val screenshotUri: String?,
val sendingProgress: Float,
val sending: Async<Unit>,
val eventSink: (BugReportEvents) -> Unit
) {
val submitEnabled =
formState.description.length > 10 && sending !is Async.Loading
}
@Parcelize
data class BugReportFormState(
val description: String,
val sendLogs: Boolean,
val sendCrashLogs: Boolean,
val canContact: Boolean,
val sendScreenshot: Boolean
) : Parcelable {
companion object {
val Default = BugReportFormState(
description = "",
sendLogs = true,
sendCrashLogs = true,
canContact = false,
sendScreenshot = false
)
}
}

View file

@ -0,0 +1,46 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.impl.bugreport
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
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 = Async.Loading()),
aBugReportState().copy(sending = Async.Success(Unit)),
)
}
fun aBugReportState() = BugReportState(
formState = BugReportFormState.Default,
hasCrashLogs = false,
screenshotUri = null,
sendingProgress = 0F,
sending = Async.Uninitialized,
eventSink = {}
)

View file

@ -0,0 +1,224 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.impl.bugreport
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.LabelledCheckbox
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.debugPlaceholderBackground
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.ui.strings.R as StringR
@Composable
fun BugReportView(
state: BugReportState,
modifier: Modifier = Modifier,
onDone: () -> Unit = { },
) {
LogCompositions(tag = "Rageshake", msg = "Root")
val eventSink = state.eventSink
if (state.sending is Async.Success) {
LaunchedEffect(state.sending) {
eventSink(BugReportEvents.ResetAll)
onDone()
}
return
}
Box(
modifier = modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding()
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.verticalScroll(
state = scrollState,
)
.padding(horizontal = 16.dp),
) {
val isError = state.sending is Async.Failure
val isFormEnabled = state.sending !is Async.Loading
// Title
Text(
text = stringResource(id = StringR.string.send_bug_report),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
color = MaterialTheme.colorScheme.primary,
)
// Form
Text(
text = stringResource(id = StringR.string.send_bug_report_description),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
fontSize = 16.sp,
color = MaterialTheme.colorScheme.primary,
)
var descriptionFieldState by textFieldState(
stateValue = state.formState.description
)
Column(
// modifier = Modifier.weight(1f),
) {
OutlinedTextField(
value = descriptionFieldState,
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
enabled = isFormEnabled,
label = {
Text(text = stringResource(id = StringR.string.send_bug_report_placeholder))
},
supportingText = {
Text(text = stringResource(id = StringR.string.send_bug_report_description_in_english))
},
onValueChange = {
descriptionFieldState = it
eventSink(BugReportEvents.SetDescription(it))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
// TODO Error text too short
)
}
LabelledCheckbox(
checked = state.formState.sendLogs,
onCheckedChange = { eventSink(BugReportEvents.SetSendLog(it)) },
enabled = isFormEnabled,
text = stringResource(id = StringR.string.send_bug_report_include_logs)
)
if (state.hasCrashLogs) {
LabelledCheckbox(
checked = state.formState.sendCrashLogs,
onCheckedChange = { eventSink(BugReportEvents.SetSendCrashLog(it)) },
enabled = isFormEnabled,
text = stringResource(id = StringR.string.send_bug_report_include_crash_logs)
)
}
LabelledCheckbox(
checked = state.formState.canContact,
onCheckedChange = { eventSink(BugReportEvents.SetCanContact(it)) },
enabled = isFormEnabled,
text = stringResource(id = StringR.string.you_may_contact_me)
)
if (state.screenshotUri != null) {
LabelledCheckbox(
checked = state.formState.sendScreenshot,
onCheckedChange = { eventSink(BugReportEvents.SetSendScreenshot(it)) },
enabled = isFormEnabled,
text = stringResource(id = StringR.string.send_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,
placeholder = debugPlaceholderBackground(),
)
}
}
}
// Submit
Button(
onClick = { eventSink(BugReportEvents.SendBugReport) },
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp)
) {
Text(text = stringResource(id = StringR.string.action_send))
}
}
when (state.sending) {
is Async.Loading -> {
CircularProgressIndicator(
progress = state.sendingProgress,
modifier = Modifier.align(Alignment.Center)
)
}
is Async.Failure -> ErrorDialog(
content = state.sending.error.toString(),
)
else -> Unit
}
}
}
@Preview
@Composable
fun BugReportViewLightPreview(@PreviewParameter(BugReportStateProvider::class) state: BugReportState) = ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun BugReportViewDarkPreview(@PreviewParameter(BugReportStateProvider::class) state: BugReportState) = ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: BugReportState) {
BugReportView(state = state)
}

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.impl.bugreport
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultBugReportEntryPoint @Inject constructor() : BugReportEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): BugReportEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : BugReportEntryPoint.NodeBuilder {
override fun callback(callback: BugReportEntryPoint.Callback): BugReportEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<BugReportNode>(buildContext)
}
}
}
}

View file

@ -0,0 +1,61 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.impl.crash
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import com.squareup.anvil.annotations.ContributesBinding
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.features.rageshake.api.crash.CrashDataStore
import io.element.android.libraries.di.AppScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultCrashDetectionPresenter @Inject constructor(private val crashDataStore: CrashDataStore) :
CrashDetectionPresenter {
@Composable
override fun present(): CrashDetectionState {
val localCoroutineScope = rememberCoroutineScope()
val crashDetected = crashDataStore.appHasCrashed().collectAsState(initial = false)
fun handleEvents(event: CrashDetectionEvents) {
when (event) {
CrashDetectionEvents.ResetAllCrashData -> localCoroutineScope.resetAll()
CrashDetectionEvents.ResetAppHasCrashed -> localCoroutineScope.resetAppHasCrashed()
}
}
return CrashDetectionState(
crashDetected = crashDetected.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.resetAppHasCrashed() = launch {
crashDataStore.resetAppHasCrashed()
}
private fun CoroutineScope.resetAll() = launch {
crashDataStore.reset()
}
}

View file

@ -0,0 +1,78 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.impl.crash
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.rageshake.api.crash.CrashDataStore
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_crash")
private val appHasCrashedKey = booleanPreferencesKey("appHasCrashed")
private val crashDataKey = stringPreferencesKey("crashData")
@ContributesBinding(AppScope::class)
class PreferencesCrashDataStore @Inject constructor(
@ApplicationContext context: Context
) : CrashDataStore {
private val store = context.dataStore
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() }
}
}

View file

@ -0,0 +1,81 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.impl.crash
import android.content.Context
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(
context: Context
) : Thread.UncaughtExceptionHandler {
private val crashDataStore = PreferencesCrashDataStore(context)
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 / 1048576L + " MB\n")
append("freeSize " + freeSize / 1048576L + " MB\n")
append("totalSize " + totalSize / 1048576L + " 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")
crashDataStore.setCrashData(bugDescription)
// Show the classical system popup
previousHandler?.uncaughtException(thread, throwable)
}
}

View file

@ -0,0 +1,125 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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 com.squareup.anvil.annotations.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.rageshake.RageShake
import io.element.android.features.rageshake.api.screenshot.ImageResult
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
import io.element.android.libraries.di.AppScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultRageshakeDetectionPresenter @Inject constructor(
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 handleEvents(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 = ::handleEvents
)
}
LaunchedEffect(preferencesState.sensitivity) {
rageShake.setSensitivity(preferencesState.sensitivity)
}
val shouldStart = 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
}
}

View file

@ -0,0 +1,64 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.impl.logs
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.logging.Formatter
import java.util.logging.LogRecord
internal class LogFormatter : Formatter() {
override fun format(r: LogRecord): String {
if (!mIsTimeZoneSet) {
DATE_FORMAT.timeZone = TimeZone.getTimeZone("UTC")
mIsTimeZoneSet = true
}
val thrown = r.thrown
if (thrown != null) {
val sw = StringWriter()
val pw = PrintWriter(sw)
sw.write(r.message)
sw.write(LINE_SEPARATOR)
thrown.printStackTrace(pw)
pw.flush()
return sw.toString()
} else {
val b = StringBuilder()
val date = DATE_FORMAT.format(Date(r.millis))
b.append(date)
b.append("Z ")
b.append(r.message)
b.append(LINE_SEPARATOR)
return b.toString()
}
}
companion object {
private val LINE_SEPARATOR = System.getProperty("line.separator") ?: "\n"
// private val DATE_FORMAT = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US)
private val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss*SSSZZZZ", Locale.US)
private var mIsTimeZoneSet = false
}
}

View file

@ -0,0 +1,181 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.impl.logs
import android.content.Context
import android.util.Log
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.data.tryOrNull
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.util.logging.FileHandler
import java.util.logging.Level
import java.util.logging.Logger
/**
* Will be planted in Timber.
*/
class VectorFileLogger(
context: Context,
// private val vectorPreferences: VectorPreferences
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) : Timber.Tree() {
companion object {
fun getFromTimber(): VectorFileLogger? {
return Timber.forest().filterIsInstance<VectorFileLogger>().firstOrNull()
}
private const val SIZE_20MB = 20 * 1024 * 1024
private const val SIZE_50MB = 50 * 1024 * 1024
}
/*
private val maxLogSizeByte = if (vectorPreferences.labAllowedExtendedLogging()) SIZE_50MB else SIZE_20MB
private val logRotationCount = if (vectorPreferences.labAllowedExtendedLogging()) 15 else 7
*/
private val maxLogSizeByte = SIZE_20MB
private val logRotationCount = 7
private val logger = Logger.getLogger(context.packageName).apply {
tryOrNull {
useParentHandlers = false
level = Level.ALL
}
}
private val fileHandler: FileHandler?
private val cacheDirectory = File(context.cacheDir, "logs")
private var fileNamePrefix = "logs"
private val prioPrefixes = mapOf(
Log.VERBOSE to "V/ ",
Log.DEBUG to "D/ ",
Log.INFO to "I/ ",
Log.WARN to "W/ ",
Log.ERROR to "E/ ",
Log.ASSERT to "WTF/ "
)
init {
if (!cacheDirectory.exists()) {
cacheDirectory.mkdirs()
}
for (i in 0..15) {
val file = File(cacheDirectory, "elementLogs.${i}.txt")
file.safeDelete()
}
fileHandler = tryOrNull(
onError = { Timber.e(it, "Failed to initialize FileLogger") }
) {
FileHandler(
cacheDirectory.absolutePath + "/" + fileNamePrefix + ".%g.txt",
maxLogSizeByte,
logRotationCount
)
.also { it.formatter = LogFormatter() }
.also { logger.addHandler(it) }
}
}
fun reset() {
// Delete all files
getLogFiles().map {
it.safeDelete()
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
fileHandler ?: return
GlobalScope.launch(dispatcher) {
if (skipLog(priority)) return@launch
if (t != null) {
logToFile(t)
}
logToFile(prioPrefixes[priority] ?: "$priority ", tag ?: "Tag", message)
}
}
private fun skipLog(priority: Int): Boolean {
/*
return if (vectorPreferences.labAllowedExtendedLogging()) {
false
} else {
// Exclude verbose logs
priority < Log.DEBUG
}
*/
// Exclude verbose logs
return priority < Log.DEBUG
}
/**
* Adds our own log files to the provided list of files.
*
* @return The list of files with logs.
*/
fun getLogFiles(): List<File> {
return tryOrNull(
onError = { Timber.e(it, "## getLogFiles() failed") }
) {
fileHandler
?.flush()
?.let { 0 until logRotationCount }
?.mapNotNull { index ->
File(cacheDirectory, "$fileNamePrefix.${index}.txt")
.takeIf { it.exists() }
}
}
.orEmpty()
}
/**
* Log an Throwable.
*
* @param throwable the throwable to log
*/
private fun logToFile(throwable: Throwable?) {
throwable ?: return
val errors = StringWriter()
throwable.printStackTrace(PrintWriter(errors))
logger.info(errors.toString())
}
private fun logToFile(level: String, tag: String, content: String) {
val b = StringBuilder()
b.append(Thread.currentThread().id)
b.append(" ")
b.append(level)
b.append("/")
b.append(tag)
b.append(": ")
b.append(content)
logger.info(b.toString())
}
}

View file

@ -0,0 +1,78 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import com.squareup.anvil.annotations.ContributesBinding
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.api.rageshake.RageShake
import io.element.android.features.rageshake.api.rageshake.RageshakeDataStore
import io.element.android.libraries.di.AppScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultRageshakePreferencesPresenter @Inject constructor(
private val rageshake: RageShake,
private val rageshakeDataStore: RageshakeDataStore,
) : RageshakePreferencesPresenter {
@Composable
override fun present(): RageshakePreferencesState {
val localCoroutineScope = rememberCoroutineScope()
val isSupported: MutableState<Boolean> = rememberSaveable {
mutableStateOf(rageshake.isAvailable())
}
val isEnabled = rageshakeDataStore
.isEnabled()
.collectAsState(initial = false)
val sensitivity = rageshakeDataStore
.sensitivity()
.collectAsState(initial = 0f)
fun handleEvents(event: RageshakePreferencesEvents) {
when (event) {
is RageshakePreferencesEvents.SetIsEnabled -> localCoroutineScope.setIsEnabled(event.isEnabled)
is RageshakePreferencesEvents.SetSensitivity -> localCoroutineScope.setSensitivity(event.sensitivity)
}
}
return RageshakePreferencesState(
isEnabled = isEnabled.value,
isSupported = isSupported.value,
sensitivity = sensitivity.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.setSensitivity(sensitivity: Float) = launch {
rageshakeDataStore.setSensitivity(sensitivity)
}
private fun CoroutineScope.setIsEnabled(enabled: Boolean) = launch {
rageshakeDataStore.setIsEnabled(enabled)
}
}

View file

@ -0,0 +1,78 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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.anvil.annotations.ContributesBinding
import com.squareup.seismic.ShakeDetector
import io.element.android.features.rageshake.api.rageshake.RageShake
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import javax.inject.Inject
@SingleIn(AppScope::class)
@ContributesBinding(scope = AppScope::class, boundType = RageShake::class)
class DefaultRageShake @Inject constructor(
@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()
}
}

View file

@ -0,0 +1,73 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.impl.rageshake
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.rageshake.api.rageshake.RageshakeDataStore
import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_rageshake")
private val enabledKey = booleanPreferencesKey("enabled")
private val sensitivityKey = floatPreferencesKey("sensitivity")
@ContributesBinding(AppScope::class)
class PreferencesRageshakeDataStore @Inject constructor(
@ApplicationContext context: Context
) : RageshakeDataStore {
private val store = context.dataStore
override fun isEnabled(): Flow<Boolean> {
return store.data.map { prefs ->
prefs[enabledKey].orTrue()
}
}
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() }
}
}

View file

@ -0,0 +1,531 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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 com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.reporter.BugReporterListener
import io.element.android.features.rageshake.api.reporter.ReportType
import io.element.android.features.rageshake.impl.R
import io.element.android.features.rageshake.api.crash.CrashDataStore
import io.element.android.features.rageshake.impl.logs.VectorFileLogger
import io.element.android.features.rageshake.api.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.extensions.toOnOff
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
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.util.Locale
import javax.inject.Inject
/**
* BugReporter creates and sends the bug reports.
*/
@ContributesBinding(AppScope::class)
class DefaultBugReporter @Inject constructor(
@ApplicationContext private val context: Context,
private val screenshotHolder: ScreenshotHolder,
private val crashDataStore: CrashDataStore,
private val coroutineDispatchers: CoroutineDispatchers,
/*
private val activeSessionHolder: ActiveSessionHolder,
private val versionProvider: VersionProvider,
private val vectorPreferences: VectorPreferences,
private val vectorFileLogger: VectorFileLogger,
private val systemLocaleProvider: SystemLocaleProvider,
private val matrix: Matrix,
private val buildMeta: BuildMeta,
private val processInfo: ProcessInfo,
private val sdkIntProvider: BuildVersionSdkIntProvider,
private val vectorLocale: VectorLocaleProvider,
*/
) : BugReporter {
var inMultiWindowMode = false
companion object {
// filenames
private const val LOG_CAT_ERROR_FILENAME = "logcatError.log"
private const val LOG_CAT_FILENAME = "logcat.log"
private const val KEY_REQUESTS_FILENAME = "keyRequests.log"
private const val BUFFER_SIZE = 1024 * 1024 * 50
}
// the http client
private val mOkHttpClient = OkHttpClient()
// the pending bug report call
private var mBugReportCall: Call? = null
// boolean to cancel the bug report
private val mIsCancelled = false
/*
val adapter = MatrixJsonParser.getMoshi()
.adapter<JsonDict>(Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java))
*/
private val LOGCAT_CMD_ERROR = arrayOf(
"logcat", // /< Run 'logcat' command
"-d", // /< Dump the log rather than continue outputting it
"-v", // formatting
"threadtime", // include timestamps
"AndroidRuntime:E " + // /< Pick all AndroidRuntime errors (such as uncaught exceptions)"communicatorjni:V " + ///< All communicatorjni logging
"libcommunicator:V " + // /< All libcommunicator logging
"DEBUG:V " + // /< All DEBUG logging - which includes native land crashes (seg faults, etc)
"*:S" // /< Everything else silent, so don't pick it..
)
private val LOGCAT_CMD_DEBUG = arrayOf("logcat", "-d", "-v", "threadtime", "*:*")
/**
* Send a bug report.
*
* @param coroutineScope The coroutine scope
* @param reportType The report type (bug, suggestion, feedback)
* @param withDevicesLogs true to include the device log
* @param withCrashLogs true to include the crash logs
* @param withKeyRequestHistory true to include the crash logs
* @param withScreenshot true to include the screenshot
* @param theBugDescription the bug description
* @param serverVersion version of the server
* @param canContact true if the user opt in to be contacted directly
* @param customFields fields which will be sent with the report
* @param listener the listener
*/
override fun sendBugReport(
coroutineScope: CoroutineScope,
reportType: ReportType,
withDevicesLogs: Boolean,
withCrashLogs: Boolean,
withKeyRequestHistory: Boolean,
withScreenshot: Boolean,
theBugDescription: String,
serverVersion: String,
canContact: Boolean,
customFields: Map<String, String>?,
listener: BugReporterListener?
) {
// enumerate files to delete
val mBugReportFiles: MutableList<File> = ArrayList()
coroutineScope.launch {
var serverError: String? = null
var reportURL: String? = null
withContext(coroutineDispatchers.io) {
var bugDescription = theBugDescription
val crashCallStack = crashDataStore.crashInfo().first()
if (crashCallStack.isNotEmpty() && withCrashLogs) {
bugDescription += "\n\n\n\n--------------------------------- crash call stack ---------------------------------\n"
bugDescription += crashCallStack
}
val gzippedFiles = ArrayList<File>()
val vectorFileLogger = VectorFileLogger.getFromTimber()
if (withDevicesLogs && vectorFileLogger != null) {
val files = vectorFileLogger.getLogFiles()
files.mapNotNullTo(gzippedFiles) { f ->
if (!mIsCancelled) {
compressFile(f)
} else {
null
}
}
}
if (!mIsCancelled && (withCrashLogs || withDevicesLogs)) {
val gzippedLogcat = saveLogCat(false)
if (null != gzippedLogcat) {
if (gzippedFiles.size == 0) {
gzippedFiles.add(gzippedLogcat)
} else {
gzippedFiles.add(0, gzippedLogcat)
}
}
}
/*
activeSessionHolder.getSafeActiveSession()
?.takeIf { !mIsCancelled && withKeyRequestHistory }
?.cryptoService()
?.getGossipingEvents()
?.let { GossipingEventsSerializer().serialize(it) }
?.toByteArray()
?.let { rawByteArray ->
File(context.cacheDir.absolutePath, KEY_REQUESTS_FILENAME)
.also {
it.outputStream()
.use { os -> os.write(rawByteArray) }
}
}
?.let { compressFile(it) }
?.let { gzippedFiles.add(it) }
*/
var deviceId = "undefined"
var userId = "undefined"
var olmVersion = "undefined"
/*
activeSessionHolder.getSafeActiveSession()?.let { session ->
userId = session.myUserId
deviceId = session.sessionParams.deviceId ?: "undefined"
olmVersion = session.cryptoService().getCryptoVersion(context, true)
}
*/
if (!mIsCancelled) {
val text = when (reportType) {
ReportType.BUG_REPORT -> bugDescription
ReportType.SUGGESTION -> "[Suggestion] $bugDescription"
ReportType.SPACE_BETA_FEEDBACK -> "[spaces-feedback] $bugDescription"
ReportType.THREADS_BETA_FEEDBACK -> "[threads-feedback] $bugDescription"
ReportType.AUTO_UISI_SENDER,
ReportType.AUTO_UISI -> bugDescription
}
// build the multi part request
val builder = BugReporterMultipartBody.Builder()
.addFormDataPart("text", text)
.addFormDataPart("app", rageShakeAppNameForReport(reportType))
// .addFormDataPart("user_agent", matrix.getUserAgent())
.addFormDataPart("user_id", userId)
.addFormDataPart("can_contact", canContact.toString())
.addFormDataPart("device_id", deviceId)
// .addFormDataPart("version", versionProvider.getVersion(longFormat = true))
// .addFormDataPart("branch_name", buildMeta.gitBranchName)
// .addFormDataPart("matrix_sdk_version", Matrix.getSdkVersion())
.addFormDataPart("olm_version", olmVersion)
.addFormDataPart("device", Build.MODEL.trim())
// .addFormDataPart("verbose_log", vectorPreferences.labAllowedExtendedLogging().toOnOff())
.addFormDataPart("multi_window", inMultiWindowMode.toOnOff())
// .addFormDataPart(
// "os", Build.VERSION.RELEASE + " (API " + sdkIntProvider.get() + ") " +
// Build.VERSION.INCREMENTAL + "-" + Build.VERSION.CODENAME
// )
.addFormDataPart("locale", Locale.getDefault().toString())
// .addFormDataPart("app_language", vectorLocale.applicationLocale.toString())
// .addFormDataPart("default_app_language", systemLocaleProvider.getSystemLocale().toString())
// .addFormDataPart("theme", ThemeUtils.getApplicationTheme(context))
.addFormDataPart("server_version", serverVersion)
.apply {
customFields?.forEach { (name, value) ->
addFormDataPart(name, value)
}
}
// add the gzipped files
for (file in gzippedFiles) {
builder.addFormDataPart("compressed-log", file.name, file.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull()))
}
mBugReportFiles.addAll(gzippedFiles)
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")
}
}
}
// add some github labels
// builder.addFormDataPart("label", buildMeta.versionName)
// builder.addFormDataPart("label", buildMeta.flavorDescription)
// builder.addFormDataPart("label", buildMeta.gitBranchName)
// Possible values for BuildConfig.BUILD_TYPE: "debug", "nightly", "release".
// builder.addFormDataPart("label", BuildConfig.BUILD_TYPE)
when (reportType) {
ReportType.BUG_REPORT -> {
/* nop */
}
ReportType.SUGGESTION -> builder.addFormDataPart("label", "[Suggestion]")
ReportType.SPACE_BETA_FEEDBACK -> builder.addFormDataPart("label", "spaces-feedback")
ReportType.THREADS_BETA_FEEDBACK -> builder.addFormDataPart("label", "threads-feedback")
ReportType.AUTO_UISI -> {
builder.addFormDataPart("label", "Z-UISI")
builder.addFormDataPart("label", "android")
builder.addFormDataPart("label", "uisi-recipient")
}
ReportType.AUTO_UISI_SENDER -> {
builder.addFormDataPart("label", "Z-UISI")
builder.addFormDataPart("label", "android")
builder.addFormDataPart("label", "uisi-sender")
}
}
if (crashCallStack.isNotEmpty() && withCrashLogs) {
builder.addFormDataPart("label", "crash")
}
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
}
if (mIsCancelled && null != mBugReportCall) {
mBugReportCall!!.cancel()
}
Timber.v("## onWrite() : $percentage%")
try {
listener?.onProgress(percentage)
} catch (e: Exception) {
Timber.e(e, "## onProgress() : failed")
}
}
// build the request
val request = Request.Builder()
.url(context.getString(R.string.bug_report_url))
.post(requestBody)
.build()
var responseCode = HttpURLConnection.HTTP_INTERNAL_ERROR
var response: Response? = null
var errorMessage: String? = null
// trigger the request
try {
mBugReportCall = mOkHttpClient.newCall(request)
response = mBugReportCall!!.execute()
responseCode = response.code
} catch (e: Exception) {
Timber.e(e, "response")
errorMessage = e.localizedMessage
}
// if the upload failed, try to retrieve the reason
if (responseCode != HttpURLConnection.HTTP_OK) {
if (null != errorMessage) {
serverError = "Failed with error $errorMessage"
} else if (response?.body == null) {
serverError = "Failed with error $responseCode"
} else {
try {
val inputStream = response.body!!.byteStream()
serverError = inputStream.use {
buildString {
var ch = it.read()
while (ch != -1) {
append(ch.toChar())
ch = it.read()
}
}
}
// check if the error message
serverError?.let {
try {
val responseJSON = JSONObject(it)
serverError = responseJSON.getString("error")
} catch (e: JSONException) {
Timber.e(e, "doInBackground ; Json conversion failed")
}
}
// should never happen
if (null == serverError) {
serverError = "Failed with error $responseCode"
}
} catch (e: Exception) {
Timber.e(e, "## sendBugReport() : failed to parse error")
}
}
} else {
/*
reportURL = response?.body?.string()?.let { stringBody ->
adapter.fromJson(stringBody)?.get("report_url")?.toString()
}
*/
}
}
}
withContext(coroutineDispatchers.main) {
mBugReportCall = null
// delete when the bug report has been successfully sent
for (file in mBugReportFiles) {
file.safeDelete()
}
if (null != listener) {
try {
if (mIsCancelled) {
listener.onUploadCancelled()
} else if (null == serverError) {
listener.onUploadSucceed(reportURL)
} else {
listener.onUploadFailed(serverError)
}
} catch (e: Exception) {
Timber.e(e, "## onPostExecute() : failed")
}
}
}
}
}
/**
* Send a bug report either with email or with Vector.
*/
/* TODO Remove
fun openBugReportScreen(activity: FragmentActivity, reportType: ReportType = ReportType.BUG_REPORT) {
screenshot = takeScreenshot(activity)
logDbInfo()
logProcessInfo()
logOtherInfo()
activity.startActivity(BugReportActivity.intent(activity, reportType))
}
*/
// private fun logOtherInfo() {
// Timber.i("SyncThread state: " + activeSessionHolder.getSafeActiveSession()?.syncService()?.getSyncState())
// }
// private fun logDbInfo() {
// val dbInfo = matrix.debugService().getDbUsageInfo()
// Timber.i(dbInfo)
// }
// private fun logProcessInfo() {
// val pInfo = processInfo.getInfo()
// Timber.i(pInfo)
// }
private fun rageShakeAppNameForReport(reportType: ReportType): String {
// As per https://github.com/matrix-org/rageshake
// app: Identifier for the application (eg 'riot-web').
// Should correspond to a mapping configured in the configuration file for github issue reporting to work.
// (see R.string.bug_report_url for configured RS server)
return context.getString(
when (reportType) {
ReportType.AUTO_UISI_SENDER,
ReportType.AUTO_UISI -> R.string.bug_report_auto_uisi_app_name
else -> R.string.bug_report_app_name
}
)
}
// ==============================================================================================================
// Logcat management
// ==============================================================================================================
/**
* Save the logcat.
*
* @param isErrorLogcat true to save the error logcat
* @return the file if the operation succeeds
*/
private fun saveLogCat(isErrorLogcat: Boolean): File? {
val logCatErrFile = File(context.cacheDir.absolutePath, if (isErrorLogcat) LOG_CAT_ERROR_FILENAME else LOG_CAT_FILENAME)
if (logCatErrFile.exists()) {
logCatErrFile.safeDelete()
}
try {
logCatErrFile.writer().use {
getLogCatError(it, isErrorLogcat)
}
return compressFile(logCatErrFile)
} catch (error: OutOfMemoryError) {
Timber.e(error, "## saveLogCat() : fail to write logcat$error")
} catch (e: Exception) {
Timber.e(e, "## saveLogCat() : fail to write logcat$e")
}
return null
}
/**
* Retrieves the logs.
*
* @param streamWriter the stream writer
* @param isErrorLogCat true to save the error logs
*/
private fun getLogCatError(streamWriter: OutputStreamWriter, isErrorLogCat: Boolean) {
val logcatProc: Process
try {
logcatProc = Runtime.getRuntime().exec(if (isErrorLogCat) LOGCAT_CMD_ERROR else LOGCAT_CMD_DEBUG)
} catch (e1: IOException) {
return
}
try {
val separator = System.getProperty("line.separator")
logcatProc.inputStream
.reader()
.buffered(BUFFER_SIZE)
.forEachLine { line ->
streamWriter.append(line)
streamWriter.append(separator)
}
} catch (e: IOException) {
Timber.e(e, "getLog fails")
}
}
}

View file

@ -0,0 +1,53 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.impl.screenshot
import android.content.Context
import android.graphics.Bitmap
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
import io.element.android.libraries.androidutils.bitmap.writeBitmap
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import java.io.File
import javax.inject.Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultScreenshotHolder @Inject constructor(
@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()
}
}

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2022 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<resources>
<!-- Rageshake configuration -->
<string name="bug_report_url" translatable="false">https://riot.im/bugreports/submit</string>
<string name="bug_report_app_name" translatable="false">element-x-android</string>
<string name="bug_report_auto_uisi_app_name" translatable="false">element-auto-uisi</string>
</resources>

View file

@ -0,0 +1,249 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.rageshake.impl.bugreport
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.impl.crash.ui.A_CRASH_DATA
import io.element.android.features.rageshake.impl.crash.ui.FakeCrashDataStore
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
const val A_SHORT_DESCRIPTION = "bug!"
const val A_LONG_DESCRIPTION = "I have seen a bug!"
class BugReportPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = BugReportPresenter(
FakeBugReporter(),
FakeCrashDataStore(),
FakeScreenshotHolder(),
this,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.hasCrashLogs).isFalse()
assertThat(initialState.formState).isEqualTo(BugReportFormState.Default)
assertThat(initialState.sending).isEqualTo(Async.Uninitialized)
assertThat(initialState.screenshotUri).isNull()
assertThat(initialState.sendingProgress).isEqualTo(0f)
assertThat(initialState.submitEnabled).isFalse()
}
}
@Test
fun `present - set description`() = runTest {
val presenter = BugReportPresenter(
FakeBugReporter(),
FakeCrashDataStore(),
FakeScreenshotHolder(),
this,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(BugReportEvents.SetDescription(A_SHORT_DESCRIPTION))
assertThat(awaitItem().submitEnabled).isFalse()
initialState.eventSink.invoke(BugReportEvents.SetDescription(A_LONG_DESCRIPTION))
assertThat(awaitItem().submitEnabled).isTrue()
}
}
@Test
fun `present - can contact`() = runTest {
val presenter = BugReportPresenter(
FakeBugReporter(),
FakeCrashDataStore(),
FakeScreenshotHolder(),
this,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(BugReportEvents.SetCanContact(true))
assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(canContact = true))
initialState.eventSink.invoke(BugReportEvents.SetCanContact(false))
assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(canContact = false))
}
}
@Test
fun `present - send crash logs`() = runTest {
val presenter = BugReportPresenter(
FakeBugReporter(),
FakeCrashDataStore(),
FakeScreenshotHolder(),
this,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Since this is true by default, start by disabling
initialState.eventSink.invoke(BugReportEvents.SetSendCrashLog(false))
assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendCrashLogs = false))
initialState.eventSink.invoke(BugReportEvents.SetSendCrashLog(true))
assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendCrashLogs = true))
}
}
@Test
fun `present - send logs`() = runTest {
val presenter = BugReportPresenter(
FakeBugReporter(),
FakeCrashDataStore(),
FakeScreenshotHolder(),
this,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Since this is true by default, start by disabling
initialState.eventSink.invoke(BugReportEvents.SetSendLog(false))
assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendLogs = false))
initialState.eventSink.invoke(BugReportEvents.SetSendLog(true))
assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendLogs = true))
}
}
@Test
fun `present - send screenshot`() = runTest {
val presenter = BugReportPresenter(
FakeBugReporter(),
FakeCrashDataStore(),
FakeScreenshotHolder(),
this,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(BugReportEvents.SetSendScreenshot(true))
assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendScreenshot = true))
initialState.eventSink.invoke(BugReportEvents.SetSendScreenshot(false))
assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendScreenshot = false))
}
}
@Test
fun `present - reset all`() = runTest {
val presenter = BugReportPresenter(
FakeBugReporter(),
FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true),
FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI),
this,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.hasCrashLogs).isTrue()
assertThat(initialState.screenshotUri).isEqualTo(A_SCREENSHOT_URI)
initialState.eventSink.invoke(BugReportEvents.ResetAll)
val resetState = awaitItem()
assertThat(resetState.hasCrashLogs).isFalse()
// TODO Make it live assertThat(resetState.screenshotUri).isNull()
}
}
@Test
fun `present - send success`() = runTest {
val presenter = BugReportPresenter(
FakeBugReporter(mode = FakeBugReporterMode.Success),
FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true),
FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI),
this,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(BugReportEvents.SendBugReport)
skipItems(1)
val progressState = awaitItem()
assertThat(progressState.sending).isEqualTo(Async.Loading(null))
assertThat(progressState.sendingProgress).isEqualTo(0f)
assertThat(progressState.submitEnabled).isFalse()
assertThat(awaitItem().sendingProgress).isEqualTo(0.5f)
assertThat(awaitItem().sendingProgress).isEqualTo(1f)
skipItems(1)
assertThat(awaitItem().sending).isEqualTo(Async.Success(Unit))
}
}
@Test
fun `present - send failure`() = runTest {
val presenter = BugReportPresenter(
FakeBugReporter(mode = FakeBugReporterMode.Failure),
FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true),
FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI),
this,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(BugReportEvents.SendBugReport)
skipItems(1)
val progressState = awaitItem()
assertThat(progressState.sending).isEqualTo(Async.Loading(null))
assertThat(progressState.sendingProgress).isEqualTo(0f)
assertThat(awaitItem().sendingProgress).isEqualTo(0.5f)
// Failure
assertThat(awaitItem().sendingProgress).isEqualTo(0f)
assertThat((awaitItem().sending as Async.Failure).error.message).isEqualTo(A_FAILURE_REASON)
}
}
@Test
fun `present - send cancel`() = runTest {
val presenter = BugReportPresenter(
FakeBugReporter(mode = FakeBugReporterMode.Cancel),
FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true),
FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI),
this,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(BugReportEvents.SendBugReport)
skipItems(1)
val progressState = awaitItem()
assertThat(progressState.sending).isEqualTo(Async.Loading(null))
assertThat(progressState.sendingProgress).isEqualTo(0f)
assertThat(awaitItem().sendingProgress).isEqualTo(0.5f)
// Cancelled
assertThat(awaitItem().sendingProgress).isEqualTo(0f)
assertThat(awaitItem().sending).isEqualTo(Async.Uninitialized)
}
}
}

View file

@ -0,0 +1,69 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.impl.bugreport
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.reporter.BugReporterListener
import io.element.android.features.rageshake.api.reporter.ReportType
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Success) : BugReporter {
override fun sendBugReport(
coroutineScope: CoroutineScope,
reportType: ReportType,
withDevicesLogs: Boolean,
withCrashLogs: Boolean,
withKeyRequestHistory: Boolean,
withScreenshot: Boolean,
theBugDescription: String,
serverVersion: String,
canContact: Boolean,
customFields: Map<String, String>?,
listener: BugReporterListener?,
) {
coroutineScope.launch {
delay(100)
listener?.onProgress(0)
delay(100)
listener?.onProgress(50)
delay(100)
when (mode) {
FakeBugReporterMode.Success -> Unit
FakeBugReporterMode.Failure -> {
listener?.onUploadFailed(A_FAILURE_REASON)
return@launch
}
FakeBugReporterMode.Cancel -> {
listener?.onUploadCancelled()
return@launch
}
}
listener?.onProgress(100)
delay(100)
listener?.onUploadSucceed(null)
}
}
}
enum class FakeBugReporterMode {
Success,
Failure,
Cancel
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.impl.bugreport
import android.graphics.Bitmap
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
const val A_SCREENSHOT_URI = "file://content/uri"
class FakeScreenshotHolder(private val screenshotUri: String? = null) : ScreenshotHolder {
override fun writeBitmap(data: Bitmap) = Unit
override fun getFileUri() = screenshotUri
override fun reset() = Unit
}

View file

@ -0,0 +1,93 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.rageshake.impl.crash.ui
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.api.crash.CrashDetectionEvents
import io.element.android.features.rageshake.impl.crash.DefaultCrashDetectionPresenter
import io.element.android.features.rageshake.test.crash.A_CRASH_DATA
import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
class CrashDetectionPresenterTest {
@Test
fun `present - initial state no crash`() = runTest {
val presenter = DefaultCrashDetectionPresenter(
FakeCrashDataStore()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.crashDetected).isFalse()
}
}
@Test
fun `present - initial state crash`() = runTest {
val presenter = DefaultCrashDetectionPresenter(
FakeCrashDataStore(appHasCrashed = true)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.crashDetected).isTrue()
}
}
@Test
fun `present - reset app has crashed`() = runTest {
val presenter = DefaultCrashDetectionPresenter(
FakeCrashDataStore(appHasCrashed = true)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.crashDetected).isTrue()
initialState.eventSink.invoke(CrashDetectionEvents.ResetAppHasCrashed)
assertThat(awaitItem().crashDetected).isFalse()
}
}
@Test
fun `present - reset all crash data`() = runTest {
val presenter = DefaultCrashDetectionPresenter(
FakeCrashDataStore(appHasCrashed = true, crashData = A_CRASH_DATA)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.crashDetected).isTrue()
initialState.eventSink.invoke(CrashDetectionEvents.ResetAllCrashData)
assertThat(awaitItem().crashDetected).isFalse()
}
}
}

View file

@ -0,0 +1,193 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.rageshake.impl.detection
import android.graphics.Bitmap
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.api.detection.RageshakeDetectionEvents
import io.element.android.features.rageshake.api.screenshot.ImageResult
import io.element.android.features.rageshake.impl.bugreport.FakeScreenshotHolder
import io.element.android.features.rageshake.impl.preferences.DefaultRageshakePreferencesPresenter
import io.element.android.features.rageshake.test.rageshake.FakeRageShake
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RageshakeDetectionPresenterTest {
@Test
fun `present - initial state`() = runTest {
val screenshotHolder = FakeScreenshotHolder(screenshotUri = null)
val rageshake = FakeRageShake(isAvailableValue = true)
val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true)
val presenter = DefaultRageshakeDetectionPresenter(
screenshotHolder = screenshotHolder,
rageShake = rageshake,
preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.takeScreenshot).isFalse()
assertThat(initialState.showDialog).isFalse()
assertThat(initialState.isStarted).isFalse()
}
}
@Test
fun `present - start and stop detection`() = runTest {
val screenshotHolder = FakeScreenshotHolder(screenshotUri = null)
val rageshake = FakeRageShake(isAvailableValue = true)
val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true)
val presenter = DefaultRageshakeDetectionPresenter(
screenshotHolder = screenshotHolder,
rageShake = rageshake,
preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(RageshakeDetectionEvents.StartDetection)
assertThat(awaitItem().isStarted).isTrue()
initialState.eventSink.invoke(RageshakeDetectionEvents.StopDetection)
assertThat(awaitItem().isStarted).isFalse()
}
}
@Test
fun `present - screenshot with success then dismiss`() = runTest {
val screenshotHolder = FakeScreenshotHolder(screenshotUri = null)
val rageshake = FakeRageShake(isAvailableValue = true)
val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true)
val presenter = DefaultRageshakeDetectionPresenter(
screenshotHolder = screenshotHolder,
rageShake = rageshake,
preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isStarted).isFalse()
initialState.eventSink.invoke(RageshakeDetectionEvents.StartDetection)
assertThat(awaitItem().isStarted).isTrue()
rageshake.triggerPhoneRageshake()
assertThat(awaitItem().takeScreenshot).isTrue()
initialState.eventSink.invoke(
RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Success(aBitmap()))
)
assertThat(awaitItem().showDialog).isTrue()
initialState.eventSink.invoke(RageshakeDetectionEvents.Dismiss)
val finalState = awaitItem()
assertThat(finalState.showDialog).isFalse()
assertThat(rageshakeDataStore.isEnabled().first()).isTrue()
}
}
@Test
fun `present - screenshot with error then dismiss`() = runTest {
val screenshotHolder = FakeScreenshotHolder(screenshotUri = null)
val rageshake = FakeRageShake(isAvailableValue = true)
val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true)
val presenter = DefaultRageshakeDetectionPresenter(
screenshotHolder = screenshotHolder,
rageShake = rageshake,
preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isStarted).isFalse()
initialState.eventSink.invoke(RageshakeDetectionEvents.StartDetection)
assertThat(awaitItem().isStarted).isTrue()
rageshake.triggerPhoneRageshake()
assertThat(awaitItem().takeScreenshot).isTrue()
initialState.eventSink.invoke(
RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Error(AN_EXCEPTION))
)
assertThat(awaitItem().showDialog).isTrue()
initialState.eventSink.invoke(RageshakeDetectionEvents.Dismiss)
val finalState = awaitItem()
assertThat(finalState.showDialog).isFalse()
assertThat(rageshakeDataStore.isEnabled().first()).isTrue()
}
}
@Test
fun `present - screenshot then disable`() = runTest {
val screenshotHolder = FakeScreenshotHolder(screenshotUri = null)
val rageshake = FakeRageShake(isAvailableValue = true)
val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true)
val presenter = DefaultRageshakeDetectionPresenter(
screenshotHolder = screenshotHolder,
rageShake = rageshake,
preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isStarted).isFalse()
initialState.eventSink.invoke(RageshakeDetectionEvents.StartDetection)
assertThat(awaitItem().isStarted).isTrue()
rageshake.triggerPhoneRageshake()
assertThat(awaitItem().takeScreenshot).isTrue()
initialState.eventSink.invoke(
RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Success(aBitmap()))
)
assertThat(awaitItem().showDialog).isTrue()
initialState.eventSink.invoke(RageshakeDetectionEvents.Disable)
skipItems(1)
assertThat(awaitItem().showDialog).isFalse()
assertThat(rageshakeDataStore.isEnabled().first()).isFalse()
}
}
}
private fun aBitmap(): Bitmap = mockk()

View file

@ -0,0 +1,102 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.rageshake.impl.preferences
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesEvents
import io.element.android.features.rageshake.test.rageshake.A_SENSITIVITY
import io.element.android.features.rageshake.test.rageshake.FakeRageShake
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RageshakePreferencesPresenterTest {
@Test
fun `present - initial state available`() = runTest {
val presenter = DefaultRageshakePreferencesPresenter(
FakeRageShake(isAvailableValue = true),
FakeRageshakeDataStore(isEnabled = true)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isSupported).isTrue()
assertThat(initialState.isEnabled).isTrue()
}
}
@Test
fun `present - initial state not available`() = runTest {
val presenter = DefaultRageshakePreferencesPresenter(
FakeRageShake(isAvailableValue = false),
FakeRageshakeDataStore(isEnabled = true)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isSupported).isFalse()
assertThat(initialState.isEnabled).isTrue()
}
}
@Test
fun `present - enable and disable`() = runTest {
val presenter = DefaultRageshakePreferencesPresenter(
FakeRageShake(isAvailableValue = true),
FakeRageshakeDataStore(isEnabled = true)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isEnabled).isTrue()
initialState.eventSink.invoke(RageshakePreferencesEvents.SetIsEnabled(false))
assertThat(awaitItem().isEnabled).isFalse()
initialState.eventSink.invoke(RageshakePreferencesEvents.SetIsEnabled(true))
assertThat(awaitItem().isEnabled).isTrue()
}
}
@Test
fun `present - set sensitivity`() = runTest {
val presenter = DefaultRageshakePreferencesPresenter(
FakeRageShake(isAvailableValue = true),
FakeRageshakeDataStore(isEnabled = true)
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.sensitivity).isEqualTo(A_SENSITIVITY)
initialState.eventSink.invoke(RageshakePreferencesEvents.SetSensitivity(A_SENSITIVITY + 1f))
assertThat(awaitItem().sensitivity).isEqualTo(A_SENSITIVITY + 1f)
}
}
}