[Architecture] split all feature modules to api/impl
This commit is contained in:
parent
b2c68d555a
commit
7051bfc14e
214 changed files with 626 additions and 1090 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {}
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
25
features/rageshake/impl/src/main/res/values/strings.xml
Normal file
25
features/rageshake/impl/src/main/res/values/strings.xml
Normal 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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue