Added|Fixed!: Added support to save reports to files and fixed large reports generating TransactionTooLargeException

If `ReportActivity` was started with a large report, i.e a few hundred `KB`, like for terminal transcript or other command output, the activity start would fail. To solve the issue, if the serialized size of the ReportInfo info object is above `DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES` (`100KB`), it will be saved to a file in a cache directory `/data/data/com.termux/cache/report_activity` as a serialized object and loaded when activity is started. The file will be automatically deleted when activity is destroyed (`Activity.onDetroy()`) or when notification that would have started the activity is deleted (`Notification.deleteIntent`). In case, these two didn't happen, then on `TermuxActivity` startup, a thread will be started to delete files older than `14` days so that unneeded left over files are deleted. If user tries to open plugin error or crash report notifications after 14 days, they will get `ReportInfo` file not found errors, assuming `TermuxActivity` was started to run the cleanup routine.

Now these large reports can't be copied or shared with other apps since that would again result in `TransactionTooLargeException` exceptions and `ShareUtils` automatically truncates the data (now from end) to `100KB` length so that the exception doesn't occur. So now a `Save To File` option has been added in context menu (3 dots on top right) of `ReportActivity` so that large or small reports can be saved to a file if needed. They will be save in root of `/storage/emulated/0` or whatever is the default public external storage directory. The filename would depend on type of report. The storage permissions will be asked if missing. On android `11`, if you get permission denied errors even after granting permission, disable permission and grant it again. To solve privacy issues of report being saved to public storage since it may contain private info, an option for custom path will be added in future. The default directory is public storage instead of termux home since its easily accessible via all file managers or from pc. Instructing amateur users to get files via `SAF` from termux home is not something I wanna take on.

Another issue is that `ReportActivity` itself may not be able to show the entire report since Android may throw `OutOfMemoryError` exceptions if device memory is low. To solve this issue, `ReportActivity` will truncate the report to `1MB` from end that's shown to the user. It will add a header showing that report was truncated. To view the full report, the user will have to use the `Save To File` option and view the file in an external app or on pc that supports opening large files. The `QuickEdit` app on Android has been a reliable one in my experience that supports large files, although it has max row/column limits too at a few hundred thousand, depending on android version.

Despite all this, `OutOfMemoryError` exceptions could still be thrown if you try to view too large a report, like a few MB, since original report + the truncated report is still held in memory by the app and will consume `2-3` times memory when saving. It's fun coding for android, right?

The terminal transcript will not be truncated anymore that's generated via `Report Issue` option in terminal.

The `ShareUtils.copyTextToClipboard()` will truncate data now automatically, apparently all phones don't do it automatically and exception is raised.

The `ShareUtils.saveTextToFile()` has been added that will automatically ask for storage permissions if missing.

The `ReportInfo` now expects a `reportSaveFileLabel` and `reportSaveFilePath` arguments so that `ReportActivity` can use them to know where to save the file if users selects `Save To File` option.

The `ReportActivityBroadcastReceiver` must now be registered in `AndroidManifest.xml` if you are using `ReportActivity` in your app. Check `ReportActivity` javadoc for details. Moreover, an incremental call to `ReportActivity.deleteReportInfoFilesOlderThanXDays()` must also be made.
This commit is contained in:
agnostic-apollo 2021-08-20 23:31:01 +05:00
parent e7fc60af72
commit 351934a619
12 changed files with 470 additions and 59 deletions

View File

@ -168,6 +168,8 @@
<receiver android:name=".app.TermuxOpenReceiver" />
<receiver android:name=".shared.activities.ReportActivity$ReportActivityBroadcastReceiver" android:exported="false" />
<provider
android:name=".app.TermuxOpenReceiver$ContentProvider"
android:authorities="${TERMUX_PACKAGE_NAME}.files"

View File

@ -33,6 +33,7 @@ import android.widget.Toast;
import com.termux.R;
import com.termux.app.terminal.TermuxActivityRootView;
import com.termux.shared.activities.ReportActivity;
import com.termux.shared.packages.PermissionUtils;
import com.termux.shared.termux.TermuxConstants;
import com.termux.shared.termux.TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY;
@ -182,6 +183,9 @@ public final class TermuxActivity extends Activity implements ServiceConnection
// notification with the crash details if it did
CrashUtils.notifyAppCrashOnLastRun(this, LOG_TAG);
// Delete ReportInfo serialized object files from cache older than 14 days
ReportActivity.deleteReportInfoFilesOlderThanXDays(this, 14, false);
// Load termux shared properties
mProperties = new TermuxAppSharedProperties(this);

View File

@ -2,6 +2,7 @@ package com.termux.app.activities;
import android.content.Context;
import android.os.Bundle;
import android.os.Environment;
import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar;
@ -11,6 +12,7 @@ import androidx.preference.PreferenceFragmentCompat;
import com.termux.R;
import com.termux.shared.activities.ReportActivity;
import com.termux.shared.file.FileUtils;
import com.termux.shared.models.ReportInfo;
import com.termux.app.models.UserAction;
import com.termux.shared.interact.ShareUtils;
@ -86,7 +88,13 @@ public class SettingsActivity extends AppCompatActivity {
aboutString.append("\n\n").append(AndroidUtils.getDeviceInfoMarkdownString(context));
aboutString.append("\n\n").append(TermuxUtils.getImportantLinksMarkdownString(context));
ReportActivity.startReportActivity(context, new ReportInfo(UserAction.ABOUT.getName(), TermuxConstants.TERMUX_APP.TERMUX_SETTINGS_ACTIVITY_NAME, title, null, aboutString.toString(), null, false));
String userActionName = UserAction.ABOUT.getName();
ReportActivity.startReportActivity(context, new ReportInfo(userActionName,
TermuxConstants.TERMUX_APP.TERMUX_SETTINGS_ACTIVITY_NAME, title, null,
aboutString.toString(), null, false,
userActionName,
Environment.getExternalStorageDirectory() + "/" +
FileUtils.sanitizeFileName(TermuxConstants.TERMUX_APP_NAME + "-" + userActionName + ".log", true, true)));
}
}.start();

View File

@ -9,6 +9,7 @@ import android.content.Context;
import android.content.Intent;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Environment;
import android.text.TextUtils;
import android.view.Gravity;
import android.view.InputDevice;
@ -22,6 +23,7 @@ import android.widget.Toast;
import com.termux.R;
import com.termux.app.TermuxActivity;
import com.termux.shared.data.UrlUtils;
import com.termux.shared.file.FileUtils;
import com.termux.shared.shell.ShellUtils;
import com.termux.shared.terminal.TermuxTerminalViewClientBase;
import com.termux.shared.termux.AndroidUtils;
@ -668,15 +670,13 @@ public class TermuxTerminalViewClient extends TermuxTerminalViewClientBase {
new Thread() {
@Override
public void run() {
String transcriptTextTruncated = DataUtils.getTruncatedCommandOutput(transcriptText, DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES, false, true, false).trim();
StringBuilder reportString = new StringBuilder();
String title = TermuxConstants.TERMUX_APP_NAME + " Report Issue";
reportString.append("## Transcript\n");
reportString.append("\n").append(MarkdownUtils.getMarkdownCodeForString(transcriptTextTruncated, true));
reportString.append("\n").append(MarkdownUtils.getMarkdownCodeForString(transcriptText, true));
reportString.append("\n##\n");
reportString.append("\n\n").append(TermuxUtils.getAppInfoMarkdownString(mActivity, true));
reportString.append("\n\n").append(AndroidUtils.getDeviceInfoMarkdownString(mActivity));
@ -685,7 +685,15 @@ public class TermuxTerminalViewClient extends TermuxTerminalViewClientBase {
if (termuxAptInfo != null)
reportString.append("\n\n").append(termuxAptInfo);
ReportActivity.startReportActivity(mActivity, new ReportInfo(UserAction.REPORT_ISSUE_FROM_TRANSCRIPT.getName(), TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY_NAME, title, null, reportString.toString(), "\n\n" + TermuxUtils.getReportIssueMarkdownString(mActivity), false));
String userActionName = UserAction.REPORT_ISSUE_FROM_TRANSCRIPT.getName();
ReportActivity.startReportActivity(mActivity,
new ReportInfo(userActionName,
TermuxConstants.TERMUX_APP.TERMUX_ACTIVITY_NAME, title, null,
reportString.toString(), "\n\n" + TermuxUtils.getReportIssueMarkdownString(mActivity),
false,
userActionName,
Environment.getExternalStorageDirectory() + "/" +
FileUtils.sanitizeFileName(TermuxConstants.TERMUX_APP_NAME + "-" + userActionName + ".log", true, true)));
}
}.start();
}

View File

@ -4,7 +4,7 @@ import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Environment;
import androidx.annotation.Nullable;
@ -131,12 +131,23 @@ public class CrashUtils {
reportString.append("\n\n").append(AndroidUtils.getDeviceInfoMarkdownString(context));
}
Intent notificationIntent = ReportActivity.newInstance(context, new ReportInfo(UserAction.CRASH_REPORT.getName(), logTag, title, null, reportString.toString(), "\n\n" + TermuxUtils.getReportIssueMarkdownString(context), true));
String userActionName = UserAction.CRASH_REPORT.getName();
ReportActivity.NewInstanceResult result = ReportActivity.newInstance(context, new ReportInfo(userActionName,
logTag, title, null, reportString.toString(),
"\n\n" + TermuxUtils.getReportIssueMarkdownString(context), true,
userActionName,
Environment.getExternalStorageDirectory() + "/" +
FileUtils.sanitizeFileName(TermuxConstants.TERMUX_APP_NAME + "-" + userActionName + ".log", true, true)));
if (result.contentIntent == null) return;
// Must ensure result code for PendingIntents and id for notification are unique otherwise will override previous
int nextNotificationId = TermuxNotificationUtils.getNextNotificationId(context);
PendingIntent contentIntent = PendingIntent.getActivity(context, nextNotificationId, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT);
PendingIntent contentIntent = PendingIntent.getActivity(context, nextNotificationId, result.contentIntent, PendingIntent.FLAG_UPDATE_CURRENT);
PendingIntent deleteIntent = null;
if (result.deleteIntent != null)
deleteIntent = PendingIntent.getBroadcast(context, nextNotificationId, result.deleteIntent, PendingIntent.FLAG_UPDATE_CURRENT);
// Setup the notification channel if not already set up
setupCrashReportsNotificationChannel(context);

View File

@ -4,12 +4,13 @@ import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Environment;
import androidx.annotation.Nullable;
import com.termux.R;
import com.termux.shared.activities.ReportActivity;
import com.termux.shared.file.FileUtils;
import com.termux.shared.file.TermuxFileUtils;
import com.termux.shared.models.ResultConfig;
import com.termux.shared.models.ResultData;
@ -219,12 +220,23 @@ public class PluginUtils {
reportString.append("\n\n").append(TermuxUtils.getAppInfoMarkdownString(context, true));
reportString.append("\n\n").append(AndroidUtils.getDeviceInfoMarkdownString(context));
Intent notificationIntent = ReportActivity.newInstance(context, new ReportInfo(UserAction.PLUGIN_EXECUTION_COMMAND.getName(), logTag, title, null, reportString.toString(), null,true));
String userActionName = UserAction.PLUGIN_EXECUTION_COMMAND.getName();
ReportActivity.NewInstanceResult result = ReportActivity.newInstance(context,
new ReportInfo(userActionName, logTag, title, null,
reportString.toString(), null,true,
userActionName,
Environment.getExternalStorageDirectory() + "/" +
FileUtils.sanitizeFileName(TermuxConstants.TERMUX_APP_NAME + "-" + userActionName + ".log", true, true)));
if (result.contentIntent == null) return;
// Must ensure result code for PendingIntents and id for notification are unique otherwise will override previous
int nextNotificationId = TermuxNotificationUtils.getNextNotificationId(context);
PendingIntent contentIntent = PendingIntent.getActivity(context, nextNotificationId, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT);
PendingIntent contentIntent = PendingIntent.getActivity(context, nextNotificationId, result.contentIntent, PendingIntent.FLAG_UPDATE_CURRENT);
PendingIntent deleteIntent = null;
if (result.deleteIntent != null)
deleteIntent = PendingIntent.getBroadcast(context, nextNotificationId, result.deleteIntent, PendingIntent.FLAG_UPDATE_CURRENT);
// Setup the notification channel if not already set up
setupPluginCommandErrorsNotificationChannel(context);

View File

@ -78,6 +78,7 @@
<string name="action_report_issue">Report Issue</string>
<string name="msg_generating_report">Generating Report</string>
<string name="msg_add_termux_debug_info">Add termux debug info to report?</string>
<string name="error_styling_not_installed">The &TERMUX_STYLING_APP_NAME; Plugin App is not installed.</string>
<string name="action_styling_install">Install</string>

View File

@ -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 `<activity android:name="com.termux.shared.activities.ReportActivity" android:theme="@style/Theme.AppCompat.TermuxReportActivity" android:documentLaunchMode="intoExisting" />` }
* and
* {@code `<receiver android:name="com.termux.shared.activities.ReportActivity$ReportActivityBroadcastReceiver" android:exported="false" />` }
* 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));
}
}
}
}
}

View File

@ -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);
}
}
}

View File

@ -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, "-"));

View File

@ -12,4 +12,9 @@
android:icon="@drawable/ic_copy"
android:title="@string/action_copy"
app:showAsAction="never" />
<item
android:id="@+id/menu_item_save_report_to_file"
android:title="@string/action_save_to_file"
app:showAsAction="never" />
</menu>

View File

@ -31,12 +31,16 @@
<!-- ReportActivity -->
<string name="action_copy">Copy</string>
<string name="action_share">Share</string>
<string name="action_save_to_file">Save To File</string>
<string name="title_report_text">Report Text</string>
<string name="msg_report_truncated">**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</string>
<!-- ShareUtils -->
<string name="title_share_with">Share With</string>
<string name="msg_storage_permission_not_granted">The storage permission not granted."</string>
<string name="msg_file_saved_successfully">The %1$s file saved successfully at \"%2$s\""</string>