diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d2357492..c6ce0ea6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -168,6 +168,8 @@ + + Report Issue Generating Report + Add termux debug info to report? The &TERMUX_STYLING_APP_NAME; Plugin App is not installed. Install diff --git a/termux-shared/src/main/java/com/termux/shared/activities/ReportActivity.java b/termux-shared/src/main/java/com/termux/shared/activities/ReportActivity.java index e960ff05..b42a5867 100644 --- a/termux-shared/src/main/java/com/termux/shared/activities/ReportActivity.java +++ b/termux-shared/src/main/java/com/termux/shared/activities/ReportActivity.java @@ -7,36 +7,71 @@ import androidx.appcompat.widget.Toolbar; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; +import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; +import android.content.pm.PackageManager; import android.os.Bundle; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import com.termux.shared.R; +import com.termux.shared.data.DataUtils; +import com.termux.shared.file.FileUtils; +import com.termux.shared.file.filesystem.FileType; +import com.termux.shared.logger.Logger; +import com.termux.shared.models.errors.Error; import com.termux.shared.termux.TermuxConstants; import com.termux.shared.markdown.MarkdownUtils; import com.termux.shared.interact.ShareUtils; import com.termux.shared.models.ReportInfo; import org.commonmark.node.FencedCodeBlock; +import org.jetbrains.annotations.NotNull; import io.noties.markwon.Markwon; import io.noties.markwon.recycler.MarkwonAdapter; import io.noties.markwon.recycler.SimpleEntry; +/** + * An activity to show reports in markdown format as per CommonMark spec. + * Add Following to `AndroidManifest.xml` to use in an app: + * {@code `` } + * and + * {@code `` } + * Receiver **must not** be `exported="true"`!!! + * + * Also make an incremental call to {@link #deleteReportInfoFilesOlderThanXDays(Context, int, boolean)} + * in the app to cleanup cached files. + */ public class ReportActivity extends AppCompatActivity { - private static final String EXTRA_REPORT_INFO = "report_info"; + private static final String CLASS_NAME = ReportActivity.class.getCanonicalName(); + private static final String ACTION_DELETE_REPORT_INFO_OBJECT_FILE = CLASS_NAME + ".ACTION_DELETE_REPORT_INFO_OBJECT_FILE"; + + private static final String EXTRA_REPORT_INFO_OBJECT = CLASS_NAME + ".EXTRA_REPORT_INFO_OBJECT"; + private static final String EXTRA_REPORT_INFO_OBJECT_FILE_PATH = CLASS_NAME + ".EXTRA_REPORT_INFO_OBJECT_FILE_PATH"; + + private static final String CACHE_DIR_BASENAME = "report_activity"; + private static final String CACHE_FILE_BASENAME_PREFIX = "report_info_"; + + public static final int REQUEST_GRANT_STORAGE_PERMISSION_FOR_SAVE_FILE = 1000; + + public static final int ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES = 1000 * 1024; // 1MB ReportInfo mReportInfo; - String mReportMarkdownString; + String mReportInfoFilePath; String mReportActivityMarkdownString; + Bundle mBundle; + + private static final String LOG_TAG = "ReportActivity"; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + Logger.logVerbose(LOG_TAG, "onCreate"); + setContentView(R.layout.activity_report); Toolbar toolbar = findViewById(R.id.toolbar); @@ -44,38 +79,65 @@ public class ReportActivity extends AppCompatActivity { setSupportActionBar(toolbar); } - Bundle bundle = null; + mBundle = null; Intent intent = getIntent(); if (intent != null) - bundle = intent.getExtras(); + mBundle = intent.getExtras(); else if (savedInstanceState != null) - bundle = savedInstanceState; + mBundle = savedInstanceState; - updateUI(bundle); + updateUI(); } @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); + Logger.logVerbose(LOG_TAG, "onNewIntent"); + setIntent(intent); - if (intent != null) - updateUI(intent.getExtras()); + if (intent != null) { + deleteReportInfoFile(this, mReportInfoFilePath); + mBundle = intent.getExtras(); + updateUI(); + } } - private void updateUI(Bundle bundle) { + private void updateUI() { - if (bundle == null) { - finish(); - return; + if (mBundle == null) { + finish(); return; } - mReportInfo = (ReportInfo) bundle.getSerializable(EXTRA_REPORT_INFO); + mReportInfo = null; + mReportInfoFilePath =null; + + if (mBundle.containsKey(EXTRA_REPORT_INFO_OBJECT_FILE_PATH)) { + mReportInfoFilePath = mBundle.getString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH); + Logger.logVerbose(LOG_TAG, ReportInfo.class.getSimpleName() + " serialized object will be read from file at path \"" + mReportInfoFilePath + "\""); + if (mReportInfoFilePath != null) { + try { + FileUtils.ReadSerializableObjectResult result = FileUtils.readSerializableObjectFromFile(ReportInfo.class.getSimpleName(), mReportInfoFilePath, ReportInfo.class, false); + if (result.error != null) { + Logger.logErrorExtended(LOG_TAG, result.error.toString()); + Logger.showToast(this, Error.getMinimalErrorString(result.error), true); + finish(); return; + } else { + if (result.serializableObject != null) + mReportInfo = (ReportInfo) result.serializableObject; + } + } catch (Exception e) { + Logger.logErrorAndShowToast(this, LOG_TAG, e.getMessage()); + Logger.logStackTraceWithMessage(LOG_TAG, "Failure while getting " + ReportInfo.class.getSimpleName() + " serialized object from file at path \"" + mReportInfoFilePath + "\"", e); + } + } + } else { + mReportInfo = (ReportInfo) mBundle.getSerializable(EXTRA_REPORT_INFO_OBJECT); + } if (mReportInfo == null) { - finish(); - return; + finish(); return; } @@ -99,25 +161,41 @@ public class ReportActivity extends AppCompatActivity { recyclerView.setLayoutManager(new LinearLayoutManager(this)); recyclerView.setAdapter(adapter); - generateReportActivityMarkdownString(); adapter.setMarkdown(markwon, mReportActivityMarkdownString); adapter.notifyDataSetChanged(); } - @Override public void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); + if (mBundle.containsKey(EXTRA_REPORT_INFO_OBJECT_FILE_PATH)) { + outState.putString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH, mReportInfoFilePath); + } else { + outState.putSerializable(EXTRA_REPORT_INFO_OBJECT, mReportInfo); + } + } - outState.putSerializable(EXTRA_REPORT_INFO, mReportInfo); + @Override + protected void onDestroy() { + super.onDestroy(); + Logger.logVerbose(LOG_TAG, "onDestroy"); + + deleteReportInfoFile(this, mReportInfoFilePath); } @Override public boolean onCreateOptionsMenu(final Menu menu) { final MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.menu_report, menu); + + if (mReportInfo.reportSaveFilePath == null) { + MenuItem item = menu.findItem(R.id.menu_item_save_report_to_file); + if (item != null) + item.setEnabled(false); + } + return true; } @@ -131,50 +209,264 @@ public class ReportActivity extends AppCompatActivity { public boolean onOptionsItemSelected(final MenuItem item) { int id = item.getItemId(); if (id == R.id.menu_item_share_report) { - if (mReportMarkdownString != null) - ShareUtils.shareText(this, getString(R.string.title_report_text), mReportMarkdownString); + ShareUtils.shareText(this, getString(R.string.title_report_text), ReportInfo.getReportInfoMarkdownString(mReportInfo)); } else if (id == R.id.menu_item_copy_report) { - if (mReportMarkdownString != null) - ShareUtils.copyTextToClipboard(this, mReportMarkdownString, null); + ShareUtils.copyTextToClipboard(this, ReportInfo.getReportInfoMarkdownString(mReportInfo), null); + } else if (id == R.id.menu_item_save_report_to_file) { + ShareUtils.saveTextToFile(this, mReportInfo.reportSaveFileLabel, + mReportInfo.reportSaveFilePath, ReportInfo.getReportInfoMarkdownString(mReportInfo), + true, REQUEST_GRANT_STORAGE_PERMISSION_FOR_SAVE_FILE); } return false; } + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + Logger.logInfo(LOG_TAG, "Storage permission granted by user on request."); + if (requestCode == REQUEST_GRANT_STORAGE_PERMISSION_FOR_SAVE_FILE) { + ShareUtils.saveTextToFile(this, mReportInfo.reportSaveFileLabel, + mReportInfo.reportSaveFilePath, ReportInfo.getReportInfoMarkdownString(mReportInfo), + true, -1); + } + } else { + Logger.logInfo(LOG_TAG, "Storage permission denied by user on request."); + } + } + /** * Generate the markdown {@link String} to be shown in {@link ReportActivity}. */ private void generateReportActivityMarkdownString() { - mReportMarkdownString = ReportInfo.getReportInfoMarkdownString(mReportInfo); + // We need to reduce chances of OutOfMemoryError happening so reduce new allocations and + // do not keep output of getReportInfoMarkdownString in memory + StringBuilder reportString = new StringBuilder(); - mReportActivityMarkdownString = ""; if (mReportInfo.reportStringPrefix != null) - mReportActivityMarkdownString += mReportInfo.reportStringPrefix; + reportString.append(mReportInfo.reportStringPrefix); - mReportActivityMarkdownString += mReportMarkdownString; + String reportMarkdownString = ReportInfo.getReportInfoMarkdownString(mReportInfo); + int reportMarkdownStringSize = reportMarkdownString.getBytes().length; + boolean truncated = false; + if (reportMarkdownStringSize > ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES) { + Logger.logVerbose(LOG_TAG, mReportInfo.reportTitle + " report string size " + reportMarkdownStringSize + " is greater than " + ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES + " and will be truncated"); + reportString.append(DataUtils.getTruncatedCommandOutput(reportMarkdownString, ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES, true, false, true)); + truncated = true; + } else { + reportString.append(reportMarkdownString); + } + + // Free reference + reportMarkdownString = null; if (mReportInfo.reportStringSuffix != null) - mReportActivityMarkdownString += mReportInfo.reportStringSuffix; + reportString.append(mReportInfo.reportStringSuffix); + + int reportStringSize = reportString.length(); + if (reportStringSize > ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES) { + // This may break markdown formatting + Logger.logVerbose(LOG_TAG, mReportInfo.reportTitle + " report string total size " + reportStringSize + " is greater than " + ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES + " and will be truncated"); + mReportActivityMarkdownString = this.getString(R.string.msg_report_truncated) + + DataUtils.getTruncatedCommandOutput(reportString.toString(), ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES, true, false, false); + } else if (truncated) { + mReportActivityMarkdownString = this.getString(R.string.msg_report_truncated) + reportString.toString(); + } else { + mReportActivityMarkdownString = reportString.toString(); + } + } - public static void startReportActivity(@NonNull final Context context, @NonNull final ReportInfo reportInfo) { - context.startActivity(newInstance(context, reportInfo)); + + + public static class NewInstanceResult { + /** An intent that can be used to start the {@link ReportActivity}. */ + public Intent contentIntent; + /** An intent that can should be adding as the {@link android.app.Notification#deleteIntent} + * by a call to {@link android.app.PendingIntent#getBroadcast(Context, int, Intent, int)} + * so that {@link ReportActivityBroadcastReceiver} can do cleanup of {@link #EXTRA_REPORT_INFO_OBJECT_FILE_PATH}. */ + public Intent deleteIntent; + + NewInstanceResult(Intent contentIntent, Intent deleteIntent) { + this.contentIntent = contentIntent; + this.deleteIntent = deleteIntent; + } } - public static Intent newInstance(@NonNull final Context context, @NonNull final ReportInfo reportInfo) { + /** + * Start the {@link ReportActivity}. + * + * @param context The {@link Context} for operations. + * @param reportInfo The {@link ReportInfo} contain info that needs to be displayed. + */ + public static void startReportActivity(@NonNull final Context context, @NonNull ReportInfo reportInfo) { + NewInstanceResult result = newInstance(context, reportInfo); + if (result.contentIntent == null) return; + context.startActivity(result.contentIntent); + } + + /** + * Get content and delete intents for the {@link ReportActivity} that can be used to start it + * and do cleanup. + * + * If {@link ReportInfo} size is too large, then a TransactionTooLargeException will be thrown + * so its object may be saved to a file in the {@link Context#getCacheDir()}. Then when activity + * starts, its read back and the file is deleted in {@link #onDestroy()}. + * Note that files may still be left if {@link #onDestroy()} is not called or doesn't finish. + * A separate cleanup routine is implemented from that case by + * {@link #deleteReportInfoFilesOlderThanXDays(Context, int, boolean)} which should be called + * incrementally or at app startup. + * + * @param context The {@link Context} for operations. + * @param reportInfo The {@link ReportInfo} contain info that needs to be displayed. + * @return Returns {@link NewInstanceResult}. + */ + @NonNull + public static NewInstanceResult newInstance(@NonNull final Context context, @NonNull final ReportInfo reportInfo) { + + long size = DataUtils.getSerializedSize(reportInfo); + if (size > DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES) { + String reportInfoDirectoryPath = getReportInfoDirectoryPath(context); + String reportInfoFilePath = reportInfoDirectoryPath + "/" + CACHE_FILE_BASENAME_PREFIX + reportInfo.reportTimestamp; + Logger.logVerbose(LOG_TAG, reportInfo.reportTitle + " " + ReportInfo.class.getSimpleName() + " serialized object size " + size + " is greater than " + DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES + " and it will be written to file at path \"" + reportInfoFilePath + "\""); + Error error = FileUtils.writeSerializableObjectToFile(ReportInfo.class.getSimpleName(), reportInfoFilePath, reportInfo); + if (error != null) { + Logger.logErrorExtended(LOG_TAG, error.toString()); + Logger.showToast(context, Error.getMinimalErrorString(error), true); + return new NewInstanceResult(null, null); + } + + return new NewInstanceResult(createContentIntent(context, null, reportInfoFilePath), + createDeleteIntent(context, reportInfoFilePath)); + } else { + return new NewInstanceResult(createContentIntent(context, reportInfo, null), + null); + } + } + + private static Intent createContentIntent(@NonNull final Context context, final ReportInfo reportInfo, final String reportInfoFilePath) { Intent intent = new Intent(context, ReportActivity.class); Bundle bundle = new Bundle(); - bundle.putSerializable(EXTRA_REPORT_INFO, reportInfo); + + if (reportInfoFilePath != null) { + bundle.putString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH, reportInfoFilePath); + } else { + bundle.putSerializable(EXTRA_REPORT_INFO_OBJECT, reportInfo); + } + intent.putExtras(bundle); - // Note that ReportActivity task has documentLaunchMode="intoExisting" set in AndroidManifest.xml - // which has equivalent behaviour to the following. The following dynamic way doesn't seem to - // work for notification pending intent, i.e separate task isn't created and activity is - // launched in the same task as TermuxActivity. - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NEW_DOCUMENT); + // Note that ReportActivity should have `documentLaunchMode="intoExisting"` set in `AndroidManifest.xml` + // which has equivalent behaviour to FLAG_ACTIVITY_NEW_DOCUMENT. + // FLAG_ACTIVITY_SINGLE_TOP must also be passed for onNewIntent to be called. + intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NEW_DOCUMENT); return intent; } + + private static Intent createDeleteIntent(@NonNull final Context context, final String reportInfoFilePath) { + if (reportInfoFilePath == null) return null; + + Intent intent = new Intent(context, ReportActivityBroadcastReceiver.class); + intent.setAction(ACTION_DELETE_REPORT_INFO_OBJECT_FILE); + + Bundle bundle = new Bundle(); + bundle.putString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH, reportInfoFilePath); + intent.putExtras(bundle); + + return intent; + } + + + + + + @NotNull + private static String getReportInfoDirectoryPath(Context context) { + // Canonicalize to solve /data/data and /data/user/0 issues when comparing with reportInfoFilePath + return FileUtils.getCanonicalPath(context.getCacheDir().getAbsolutePath(), null) + "/" + CACHE_DIR_BASENAME; + } + + private static void deleteReportInfoFile(Context context, String reportInfoFilePath) { + if (context == null || reportInfoFilePath == null) return; + + // Extra protection for mainly if someone set `exported="true"` for ReportActivityBroadcastReceiver + String reportInfoDirectoryPath = getReportInfoDirectoryPath(context); + reportInfoFilePath = FileUtils.getCanonicalPath(reportInfoFilePath, null); + if(!reportInfoFilePath.equals(reportInfoDirectoryPath) && reportInfoFilePath.startsWith(reportInfoDirectoryPath + "/")) { + Logger.logVerbose(LOG_TAG, "Deleting " + ReportInfo.class.getSimpleName() + " serialized object file at path \"" + reportInfoFilePath + "\""); + Error error = FileUtils.deleteRegularFile(ReportInfo.class.getSimpleName(), reportInfoFilePath, true); + if (error != null) { + Logger.logErrorExtended(LOG_TAG, error.toString()); + } + } else { + Logger.logError(LOG_TAG, "Not deleting " + ReportInfo.class.getSimpleName() + " serialized object file at path \"" + reportInfoFilePath + "\" since its not under \"" + reportInfoDirectoryPath + "\""); + } + } + + /** + * Delete {@link ReportInfo} serialized object files from cache older than x days. If a notification + * has still not been opened after x days that's using a PendingIntent to ReportActivity, then + * opening the notification will throw a file not found error, so choose days value appropriately + * or check if a notification is still active if tracking notification ids. + * The {@link Context} object passed must be of the same package with which {@link #newInstance(Context, ReportInfo)} + * was called since a call to {@link Context#getCacheDir()} is made. + * + * @param context The {@link Context} for operations. + * @param days The x amount of days before which files should be deleted. This must be `>=0`. + * @param isSynchronous If set to {@code true}, then the command will be executed in the + * caller thread and results returned synchronously. + * If set to {@code false}, then a new thread is started run the commands + * asynchronously in the background and control is returned to the caller thread. + * @return Returns the {@code error} if deleting was not successful, otherwise {@code null}. + */ + public static Error deleteReportInfoFilesOlderThanXDays(@NonNull final Context context, int days, final boolean isSynchronous) { + if (isSynchronous) { + return deleteReportInfoFilesOlderThanXDaysInner(context, days); + } else { + new Thread() { public void run() { + Error error = deleteReportInfoFilesOlderThanXDaysInner(context, days); + if (error != null) { + Logger.logErrorExtended(LOG_TAG, error.toString()); + } + }}.start(); + return null; + } + } + + private static Error deleteReportInfoFilesOlderThanXDaysInner(@NonNull final Context context, int days) { + // Only regular files are deleted and subdirectories are not checked + String reportInfoDirectoryPath = getReportInfoDirectoryPath(context); + Logger.logVerbose(LOG_TAG, "Deleting " + ReportInfo.class.getSimpleName() + " serialized object files under directory path \"" + reportInfoDirectoryPath + "\" older than " + days + " days"); + return FileUtils.deleteFilesOlderThanXDays(ReportInfo.class.getSimpleName(), reportInfoDirectoryPath, null, days, true, FileType.REGULAR.getValue()); + } + + + /** + * The {@link BroadcastReceiver} for {@link ReportActivity} that currently does cleanup when + * {@link android.app.Notification#deleteIntent} is called. It must be registered in `AndroidManifest.xml`. + */ + public static class ReportActivityBroadcastReceiver extends BroadcastReceiver { + private static final String LOG_TAG = "ReportActivityBroadcastReceiver"; + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null) return; + + String action = intent.getAction(); + Logger.logVerbose(LOG_TAG, "onReceive: \"" + action + "\" action"); + + if (ACTION_DELETE_REPORT_INFO_OBJECT_FILE.equals(action)) { + Bundle bundle = intent.getExtras(); + if (bundle == null) return; + if (bundle.containsKey(EXTRA_REPORT_INFO_OBJECT_FILE_PATH)) { + deleteReportInfoFile(context, bundle.getString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH)); + } + } + } + } + } diff --git a/termux-shared/src/main/java/com/termux/shared/interact/ShareUtils.java b/termux-shared/src/main/java/com/termux/shared/interact/ShareUtils.java index 2f38a3a9..c1614955 100644 --- a/termux-shared/src/main/java/com/termux/shared/interact/ShareUtils.java +++ b/termux-shared/src/main/java/com/termux/shared/interact/ShareUtils.java @@ -1,16 +1,25 @@ package com.termux.shared.interact; +import android.Manifest; +import android.app.Activity; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; import android.net.Uri; +import android.os.Environment; +import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; import com.termux.shared.R; import com.termux.shared.data.DataUtils; +import com.termux.shared.file.FileUtils; import com.termux.shared.logger.Logger; +import com.termux.shared.models.errors.Error; +import com.termux.shared.packages.PermissionUtils; + +import java.nio.charset.Charset; public class ShareUtils { @@ -41,12 +50,12 @@ public class ShareUtils { * @param text The text to share. */ public static void shareText(final Context context, final String subject, final String text) { - if (context == null) return; + if (context == null || text == null) return; final Intent shareTextIntent = new Intent(Intent.ACTION_SEND); shareTextIntent.setType("text/plain"); shareTextIntent.putExtra(Intent.EXTRA_SUBJECT, subject); - shareTextIntent.putExtra(Intent.EXTRA_TEXT, DataUtils.getTruncatedCommandOutput(text, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, false, false, false)); + shareTextIntent.putExtra(Intent.EXTRA_TEXT, DataUtils.getTruncatedCommandOutput(text, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, true, false, false)); openSystemAppChooser(context, shareTextIntent, context.getString(R.string.title_share_with)); } @@ -60,12 +69,12 @@ public class ShareUtils { * clipboard is successful. */ public static void copyTextToClipboard(final Context context, final String text, final String toastString) { - if (context == null) return; + if (context == null || text == null) return; final ClipboardManager clipboardManager = ContextCompat.getSystemService(context, ClipboardManager.class); if (clipboardManager != null) { - clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text)); + clipboardManager.setPrimaryClip(ClipData.newPlainText(null, DataUtils.getTruncatedCommandOutput(text, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, true, false, false))); if (toastString != null && !toastString.isEmpty()) Logger.showToast(context, toastString, true); } @@ -88,4 +97,50 @@ public class ShareUtils { } } + /** + * Save a file at the path. + * + * If if path is under {@link Environment#getExternalStorageDirectory()} + * or `/sdcard` and storage permission is missing, it will be requested if {@code context} is an + * instance of {@link Activity} or {@link AppCompatActivity} and {@code storagePermissionRequestCode} + * is `>=0` and the function will automatically return. The caller should call this function again + * if user granted the permission. + * + * @param context The context for operations. + * @param label The label for file. + * @param filePath The path to save the file. + * @param text The text to write to file. + * @param showToast If set to {@code true}, then a toast is shown if saving to file is successful. + * @param storagePermissionRequestCode The request code to use while asking for permission. + */ + public static void saveTextToFile(final Context context, final String label, final String filePath, final String text, final boolean showToast, final int storagePermissionRequestCode) { + if (context == null || filePath == null || filePath.isEmpty() || text == null) return; + + // If path is under primary external storage directory, then check for missing permissions. + if ((FileUtils.isPathInDirPath(filePath, Environment.getExternalStorageDirectory().getAbsolutePath(), true) || + FileUtils.isPathInDirPath(filePath, "/sdcard", true)) && + !PermissionUtils.checkPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + Logger.logErrorAndShowToast(context, LOG_TAG, context.getString(R.string.msg_storage_permission_not_granted)); + + if (storagePermissionRequestCode >= 0) { + if (context instanceof AppCompatActivity) + PermissionUtils.requestPermission(((AppCompatActivity) context), Manifest.permission.WRITE_EXTERNAL_STORAGE, storagePermissionRequestCode); + else if (context instanceof Activity) + PermissionUtils.requestPermission(((Activity) context), Manifest.permission.WRITE_EXTERNAL_STORAGE, storagePermissionRequestCode); + } + + return; + } + + Error error = FileUtils.writeStringToFile(label, filePath, + Charset.defaultCharset(), text, false); + if (error != null) { + Logger.logErrorExtended(LOG_TAG, error.toString()); + Logger.showToast(context, Error.getMinimalErrorString(error), true); + } else { + if (showToast) + Logger.showToast(context, context.getString(R.string.msg_file_saved_successfully, label, filePath), true); + } + } + } diff --git a/termux-shared/src/main/java/com/termux/shared/models/ReportInfo.java b/termux-shared/src/main/java/com/termux/shared/models/ReportInfo.java index ebecdd5a..93f10323 100644 --- a/termux-shared/src/main/java/com/termux/shared/models/ReportInfo.java +++ b/termux-shared/src/main/java/com/termux/shared/models/ReportInfo.java @@ -14,26 +14,35 @@ public class ReportInfo implements Serializable { /** The report title. */ public final String reportTitle; /** The markdown report text prefix. Will not be part of copy and share operations, etc. */ - public final String reportStringPrefix; + public String reportStringPrefix; /** The markdown report text. */ - public final String reportString; + public String reportString; /** The markdown report text suffix. Will not be part of copy and share operations, etc. */ - public final String reportStringSuffix; + public String reportStringSuffix; /** If set to {@code true}, then report, app and device info will be added to the report when * markdown is generated. */ - public final boolean addReportInfoToMarkdown; + public final boolean addReportInfoHeaderToMarkdown; /** The timestamp for the report. */ public final String reportTimestamp; - public ReportInfo(String userAction, String sender, String reportTitle, String reportStringPrefix, String reportString, String reportStringSuffix, boolean addReportInfoToMarkdown) { + /** The label for the report file to save if user selects menu_item_save_report_to_file. */ + public final String reportSaveFileLabel; + /** The path for the report file to save if user selects menu_item_save_report_to_file. */ + public final String reportSaveFilePath; + + public ReportInfo(String userAction, String sender, String reportTitle, String reportStringPrefix, + String reportString, String reportStringSuffix, boolean addReportInfoHeaderToMarkdown, + String reportSaveFileLabel, String reportSaveFilePath) { this.userAction = userAction; this.sender = sender; this.reportTitle = reportTitle; this.reportStringPrefix = reportStringPrefix; this.reportString = reportString; this.reportStringSuffix = reportStringSuffix; - this.addReportInfoToMarkdown = addReportInfoToMarkdown; + this.addReportInfoHeaderToMarkdown = addReportInfoHeaderToMarkdown; + this.reportSaveFileLabel = reportSaveFileLabel; + this.reportSaveFilePath = reportSaveFilePath; this.reportTimestamp = AndroidUtils.getCurrentMilliSecondUTCTimeStamp(); } @@ -48,7 +57,7 @@ public class ReportInfo implements Serializable { StringBuilder markdownString = new StringBuilder(); - if (reportInfo.addReportInfoToMarkdown) { + if (reportInfo.addReportInfoHeaderToMarkdown) { markdownString.append("## Report Info\n\n"); markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("User Action", reportInfo.userAction, "-")); markdownString.append("\n").append(MarkdownUtils.getSingleLineMarkdownStringEntry("Sender", reportInfo.sender, "-")); diff --git a/termux-shared/src/main/res/menu/menu_report.xml b/termux-shared/src/main/res/menu/menu_report.xml index cb851713..f1faa1df 100644 --- a/termux-shared/src/main/res/menu/menu_report.xml +++ b/termux-shared/src/main/res/menu/menu_report.xml @@ -12,4 +12,9 @@ android:icon="@drawable/ic_copy" android:title="@string/action_copy" app:showAsAction="never" /> + + diff --git a/termux-shared/src/main/res/values/strings.xml b/termux-shared/src/main/res/values/strings.xml index b6efd5c5..5a9e7ce0 100644 --- a/termux-shared/src/main/res/values/strings.xml +++ b/termux-shared/src/main/res/values/strings.xml @@ -31,12 +31,16 @@ Copy Share + Save To File Report Text + **Report Truncated**\n\nReport is too large to view here. Use `Save To File` option from options menu (3-dots on top right) and view it in an external text editor app.\n\n##\n\n Share With + The storage permission not granted." + The %1$s file saved successfully at \"%2$s\""