diff --git a/v4/app/src/main/AndroidManifest.xml b/v4/app/src/main/AndroidManifest.xml index 46b35b18..f937920e 100644 --- a/v4/app/src/main/AndroidManifest.xml +++ b/v4/app/src/main/AndroidManifest.xml @@ -8,6 +8,9 @@ + + + + + + + diff --git a/v4/app/src/main/assets/settings.json b/v4/app/src/main/assets/settings.json index 59a5590c..0cd65c6a 100644 --- a/v4/app/src/main/assets/settings.json +++ b/v4/app/src/main/assets/settings.json @@ -25,21 +25,21 @@ }, { "title" : { - "text" : "APP.V4.SYSTEM_STATUS" + "text" : "APP.V4.DIRECTION_COLOR_PREFERENCE" }, "link" : { - "text" : "settings/status" + "text" : "settings/direction_color_preference" + }, + "field":{ + "field":"direction_color_preference" } }, { "title" : { - "text" : "APP.V4.DIRECTION_COLOR_PREFERENCE" + "text" : "APP.V4.SYSTEM_STATUS" }, "link" : { - "text" : "settings/direction_color_preference" - }, - "field":{ - "field":"direction_color_preference" + "text" : "settings/status" } } ] diff --git a/v4/app/src/main/assets/settings_debug.json b/v4/app/src/main/assets/settings_debug.json index 5a224188..349a3e9e 100644 --- a/v4/app/src/main/assets/settings_debug.json +++ b/v4/app/src/main/assets/settings_debug.json @@ -23,6 +23,17 @@ "field":"v4_theme" } }, + { + "title" : { + "text" : "APP.V4.DIRECTION_COLOR_PREFERENCE" + }, + "link" : { + "text" : "settings/direction_color_preference" + }, + "field":{ + "field":"direction_color_preference" + } + }, { "title" : { "text" : "APP.V4.TRADING_NETWORK" @@ -44,29 +55,26 @@ }, { "title" : { - "text" : "Feature Flag Overrides" + "text" : "APP.ISSUE_REPORT.SETTINGS_TITLE" }, "link" : { - "text" : "features" + "text" : "settings/report_issue" } }, { "title" : { - "text" : "Debug Settings" + "text" : "Feature Flag Overrides" }, "link" : { - "text" : "settings/debug" + "text" : "features" } }, { "title" : { - "text" : "APP.V4.DIRECTION_COLOR_PREFERENCE" + "text" : "Debug Settings" }, "link" : { - "text" : "settings/direction_color_preference" - }, - "field":{ - "field":"direction_color_preference" + "text" : "settings/debug" } } ] diff --git a/v4/app/src/main/res/xml/file_paths.xml b/v4/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..06a785c4 --- /dev/null +++ b/v4/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/v4/common/src/main/java/exchange/dydx/trading/common/navigation/DydxRoutes.kt b/v4/common/src/main/java/exchange/dydx/trading/common/navigation/DydxRoutes.kt index b71076e3..d86e3d85 100644 --- a/v4/common/src/main/java/exchange/dydx/trading/common/navigation/DydxRoutes.kt +++ b/v4/common/src/main/java/exchange/dydx/trading/common/navigation/DydxRoutes.kt @@ -32,6 +32,7 @@ object ProfileRoutes { const val help = "help" const val rewards = "rewards" const val debug_enable = "action/debug/enable" + const val report_issue = "settings/report_issue" } object NewsAlertsRoutes { diff --git a/v4/feature/profile/src/main/java/exchange/dydx/trading/feature/profile/DydxProfileRouter.kt b/v4/feature/profile/src/main/java/exchange/dydx/trading/feature/profile/DydxProfileRouter.kt index e43f3ac2..5e2badf1 100644 --- a/v4/feature/profile/src/main/java/exchange/dydx/trading/feature/profile/DydxProfileRouter.kt +++ b/v4/feature/profile/src/main/java/exchange/dydx/trading/feature/profile/DydxProfileRouter.kt @@ -15,6 +15,7 @@ import exchange.dydx.trading.feature.profile.help.DydxHelpView import exchange.dydx.trading.feature.profile.history.DydxHistoryView import exchange.dydx.trading.feature.profile.keyexport.DydxKeyExportView import exchange.dydx.trading.feature.profile.language.DydxLanguageView +import exchange.dydx.trading.feature.profile.reportissue.DydxReportIssueView import exchange.dydx.trading.feature.profile.rewards.DydxRewardsView import exchange.dydx.trading.feature.profile.settings.DydxSettingsView import exchange.dydx.trading.feature.profile.systemstatus.DydxSystemStatusView @@ -161,4 +162,12 @@ fun NavGraphBuilder.profileGraph( ) { navBackStackEntry -> DydxDebugEnableView.Content(Modifier) } + + dydxComposable( + router = appRouter, + route = ProfileRoutes.report_issue, + deepLinks = appRouter.deeplinks(ProfileRoutes.report_issue), + ) { navBackStackEntry -> + DydxReportIssueView.Content(Modifier) + } } diff --git a/v4/feature/profile/src/main/java/exchange/dydx/trading/feature/profile/reportissue/DydxReportIssueView.kt b/v4/feature/profile/src/main/java/exchange/dydx/trading/feature/profile/reportissue/DydxReportIssueView.kt new file mode 100644 index 00000000..d4402c44 --- /dev/null +++ b/v4/feature/profile/src/main/java/exchange/dydx/trading/feature/profile/reportissue/DydxReportIssueView.kt @@ -0,0 +1,67 @@ +package exchange.dydx.trading.feature.profile.reportissue + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import exchange.dydx.abacus.protocols.LocalizerProtocol +import exchange.dydx.platformui.designSystem.theme.ThemeFont +import exchange.dydx.platformui.designSystem.theme.dydxDefault +import exchange.dydx.platformui.designSystem.theme.themeFont +import exchange.dydx.trading.common.component.DydxComponent +import exchange.dydx.trading.common.compose.collectAsStateWithLifecycle +import exchange.dydx.trading.common.theme.DydxThemedPreviewSurface +import exchange.dydx.trading.common.theme.MockLocalizer + +@Preview +@Composable +fun Preview_DydxReportIssueView() { + DydxThemedPreviewSurface { + DydxReportIssueView.Content(Modifier, DydxReportIssueView.ViewState.preview) + } +} + +object DydxReportIssueView : DydxComponent { + data class ViewState( + val localizer: LocalizerProtocol, + val text: String?, + ) { + companion object { + val preview = ViewState( + localizer = MockLocalizer(), + text = "1.0M", + ) + } + } + + @Composable + override fun Content(modifier: Modifier) { + val viewModel: DydxReportIssueViewModel = hiltViewModel() + + val state = viewModel.state.collectAsStateWithLifecycle(initialValue = null).value + Content(modifier, state) + } + + @Composable + fun Content(modifier: Modifier, state: ViewState?) { + if (state == null) { + return + } + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + style = TextStyle.dydxDefault.themeFont(fontSize = ThemeFont.FontSize.extra), + text = state?.text ?: "", + ) + } + } +} diff --git a/v4/feature/profile/src/main/java/exchange/dydx/trading/feature/profile/reportissue/DydxReportIssueViewModel.kt b/v4/feature/profile/src/main/java/exchange/dydx/trading/feature/profile/reportissue/DydxReportIssueViewModel.kt new file mode 100644 index 00000000..0aee74da --- /dev/null +++ b/v4/feature/profile/src/main/java/exchange/dydx/trading/feature/profile/reportissue/DydxReportIssueViewModel.kt @@ -0,0 +1,106 @@ +package exchange.dydx.trading.feature.profile.reportissue + +import android.content.Context +import android.net.Uri +import android.os.Environment +import androidx.core.content.FileProvider +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import exchange.dydx.abacus.protocols.LocalizerProtocol +import exchange.dydx.platformui.components.PlatformInfo +import exchange.dydx.trading.common.DydxViewModel +import exchange.dydx.trading.common.navigation.DydxRouter +import exchange.dydx.utilities.utils.EmailUtils +import exchange.dydx.utilities.utils.FileUtils +import exchange.dydx.utilities.utils.LogCatReader +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import kotlinx.coroutines.withContext +import java.io.File +import javax.inject.Inject + +@HiltViewModel +class DydxReportIssueViewModel @Inject constructor( + private val localizer: LocalizerProtocol, + private val router: DydxRouter, + @ApplicationContext private val context: Context, + val platformInfo: PlatformInfo, +) : ViewModel(), DydxViewModel { + + private val textFlow = MutableStateFlow("") + + val state: Flow = textFlow.map { text -> + createViewState(text) + } + + init { + textFlow.value = localizer.localize("APP.ISSUE_REPORT.LOADING_TITLE") + + viewModelScope.launch { + var logUri: Uri? = null + withContext(Dispatchers.IO) { + // add a delay to show the loading text + kotlinx.coroutines.delay(500) + logUri = createLog() + } + if (logUri != null) { + textFlow.value = localizer.localize("APP.ISSUE_REPORT.LOADING_COMPLETED_TITLE") + EmailUtils.sendEmailWithAttachment( + context = context, + fileUri = logUri, + email = "", + subject = localizer.localize("APP.ISSUE_REPORT.EMAIL_SUBJECT"), + body = localizer.localize("APP.ISSUE_REPORT.EMAIL_BODY"), + mimeType = "application/x-zip", + chooserTitle = localizer.localize("APP.ISSUE_REPORT.CHOOSER_TITLE"), + ) + } else { + val error = localizer.localize("APP.ISSUE_REPORT.LOADING_ERROR_TITLE") + textFlow.value = error + platformInfo.show(message = error) + } + + router.navigateBack() + } + } + + private fun createViewState(text: String): DydxReportIssueView.ViewState { + return DydxReportIssueView.ViewState( + localizer = localizer, + text = text, + ) + } + + private fun createLog(): Uri? { + val file = + File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "dydx_app.log") + if (!LogCatReader.saveLogCatToFile(file)) { + return null + } + val zipFile = createZipFile(context, file) + file.delete() + return if (zipFile != null) { + FileProvider.getUriForFile(context, context.packageName, zipFile) + } else { + null + } + } + + private fun createZipFile(context: Context, file: File): File? { + val fileName = file.name + val zipFileName = "$fileName.zip" + val zipFilePath = + File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), zipFileName) + return if (FileUtils.compressFile(context, file.absolutePath, zipFilePath.absolutePath)) { + zipFilePath + } else { + null + } + } +} diff --git a/v4/utilities/src/main/java/exchange/dydx/utilities/utils/EmailUtils.kt b/v4/utilities/src/main/java/exchange/dydx/utilities/utils/EmailUtils.kt new file mode 100644 index 00000000..700075e9 --- /dev/null +++ b/v4/utilities/src/main/java/exchange/dydx/utilities/utils/EmailUtils.kt @@ -0,0 +1,29 @@ +package exchange.dydx.utilities.utils + +import android.content.Context +import android.content.Intent +import android.net.Uri + +object EmailUtils { + fun sendEmailWithAttachment( + context: Context, + fileUri: Uri?, + email: String, + subject: String?, + body: String?, + mimeType: String?, + chooserTitle: String? + ) { + val emailIntent = Intent(Intent.ACTION_SEND) + emailIntent.setType(mimeType) + emailIntent.putExtra(Intent.EXTRA_EMAIL, arrayOf(email)) + emailIntent.putExtra(Intent.EXTRA_SUBJECT, subject) + emailIntent.putExtra(Intent.EXTRA_TEXT, body) + emailIntent.putExtra(Intent.EXTRA_STREAM, fileUri) + val chooser = Intent.createChooser(emailIntent, chooserTitle) + if (chooser.resolveActivity(context.packageManager) != null) { + chooser.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(chooser) + } + } +} diff --git a/v4/utilities/src/main/java/exchange/dydx/utilities/utils/FileUtils.kt b/v4/utilities/src/main/java/exchange/dydx/utilities/utils/FileUtils.kt index 3df25a3e..62cb9b13 100644 --- a/v4/utilities/src/main/java/exchange/dydx/utilities/utils/FileUtils.kt +++ b/v4/utilities/src/main/java/exchange/dydx/utilities/utils/FileUtils.kt @@ -2,8 +2,18 @@ package exchange.dydx.utilities.utils import android.content.Context import android.util.Log +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream object FileUtils { + + private const val TAG = "FileUtils" + fun loadFromAssets(context: Context, fileName: String): String? { return try { val manager = context.assets @@ -14,8 +24,37 @@ object FileUtils { inputStream.close() String(buffer) } catch (e: Exception) { - Log.e("FileUtils", "error: $e") + Log.e(TAG, "error: $e") + e.printStackTrace() null } } + + fun compressFile(context: Context, sourceFilePath: String, compressedFilePath: String): Boolean { + val bufferSize = 1024 + try { + val sourceFile = File(sourceFilePath) + val compressedFile = File(compressedFilePath) + + val fileInputStream = FileInputStream(sourceFile) + val zipOutputStream = ZipOutputStream(BufferedOutputStream(FileOutputStream(compressedFile))) + val zipEntry = ZipEntry(sourceFile.name) + zipOutputStream.putNextEntry(zipEntry) + + val buffer = ByteArray(bufferSize) + var length: Int + while (fileInputStream.read(buffer).also { length = it } > 0) { + zipOutputStream.write(buffer, 0, length) + } + + zipOutputStream.closeEntry() + zipOutputStream.close() + fileInputStream.close() + return true + } catch (e: IOException) { + Log.e(TAG, "Error compressing file: $e") + e.printStackTrace() + return false + } + } } diff --git a/v4/utilities/src/main/java/exchange/dydx/utilities/utils/LogCatReader.kt b/v4/utilities/src/main/java/exchange/dydx/utilities/utils/LogCatReader.kt new file mode 100644 index 00000000..f168497d --- /dev/null +++ b/v4/utilities/src/main/java/exchange/dydx/utilities/utils/LogCatReader.kt @@ -0,0 +1,38 @@ +package exchange.dydx.utilities.utils + +import android.util.Log +import java.io.BufferedWriter +import java.io.File +import java.io.FileWriter +import java.io.IOException + +object LogCatReader { + + private const val TAG = "LogCatReader" + + /** + * Reads the logcat and stores the log in a file. + */ + fun saveLogCatToFile(logFile: File): Boolean { + try { + val process = Runtime.getRuntime().exec("logcat -d") + val reader = process.inputStream.bufferedReader() + val writer = BufferedWriter(FileWriter(logFile)) + + var line: String? = reader.readLine() + while (line != null) { + writer.write(line) + writer.newLine() + line = reader.readLine() + } + + writer.close() + reader.close() + process.destroy() + return true + } catch (e: IOException) { + Log.e(TAG, "saveLogCatToFile: $e") + return false + } + } +}