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\""