termux-app
476 строк · 21.5 Кб
1package com.termux.shared.activities;2
3import androidx.annotation.NonNull;4import androidx.appcompat.app.ActionBar;5import androidx.appcompat.app.AppCompatActivity;6import androidx.appcompat.widget.Toolbar;7import androidx.recyclerview.widget.LinearLayoutManager;8import androidx.recyclerview.widget.RecyclerView;9
10import android.content.BroadcastReceiver;11import android.content.Context;12import android.content.Intent;13import android.content.pm.PackageManager;14import android.os.Bundle;15import android.view.Menu;16import android.view.MenuInflater;17import android.view.MenuItem;18
19import com.termux.shared.R;20import com.termux.shared.activity.media.AppCompatActivityUtils;21import com.termux.shared.data.DataUtils;22import com.termux.shared.file.FileUtils;23import com.termux.shared.file.filesystem.FileType;24import com.termux.shared.logger.Logger;25import com.termux.shared.errors.Error;26import com.termux.shared.termux.TermuxConstants;27import com.termux.shared.markdown.MarkdownUtils;28import com.termux.shared.interact.ShareUtils;29import com.termux.shared.models.ReportInfo;30import com.termux.shared.theme.NightMode;31
32import org.commonmark.node.FencedCodeBlock;33import org.jetbrains.annotations.NotNull;34
35import io.noties.markwon.Markwon;36import io.noties.markwon.recycler.MarkwonAdapter;37import io.noties.markwon.recycler.SimpleEntry;38
39/**
40* An activity to show reports in markdown format as per CommonMark spec based on config passed as {@link ReportInfo}.
41* Add Following to `AndroidManifest.xml` to use in an app:
42* {@code `<activity android:name="com.termux.shared.activities.ReportActivity" android:theme="@style/Theme.AppCompat.TermuxReportActivity" android:documentLaunchMode="intoExisting" />` }
43* and
44* {@code `<receiver android:name="com.termux.shared.activities.ReportActivity$ReportActivityBroadcastReceiver" android:exported="false" />` }
45* Receiver **must not** be `exported="true"`!!!
46*
47* Also make an incremental call to {@link #deleteReportInfoFilesOlderThanXDays(Context, int, boolean)}
48* in the app to cleanup cached files.
49*/
50public class ReportActivity extends AppCompatActivity {51
52private static final String CLASS_NAME = ReportActivity.class.getCanonicalName();53private static final String ACTION_DELETE_REPORT_INFO_OBJECT_FILE = CLASS_NAME + ".ACTION_DELETE_REPORT_INFO_OBJECT_FILE";54
55private static final String EXTRA_REPORT_INFO_OBJECT = CLASS_NAME + ".EXTRA_REPORT_INFO_OBJECT";56private static final String EXTRA_REPORT_INFO_OBJECT_FILE_PATH = CLASS_NAME + ".EXTRA_REPORT_INFO_OBJECT_FILE_PATH";57
58private static final String CACHE_DIR_BASENAME = "report_activity";59private static final String CACHE_FILE_BASENAME_PREFIX = "report_info_";60
61public static final int REQUEST_GRANT_STORAGE_PERMISSION_FOR_SAVE_FILE = 1000;62
63public static final int ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES = 1000 * 1024; // 1MB64
65private ReportInfo mReportInfo;66private String mReportInfoFilePath;67private String mReportActivityMarkdownString;68private Bundle mBundle;69
70private static final String LOG_TAG = "ReportActivity";71
72@Override73protected void onCreate(Bundle savedInstanceState) {74super.onCreate(savedInstanceState);75Logger.logVerbose(LOG_TAG, "onCreate");76
77AppCompatActivityUtils.setNightMode(this, NightMode.getAppNightMode().getName(), true);78
79setContentView(R.layout.activity_report);80
81Toolbar toolbar = findViewById(R.id.toolbar);82if (toolbar != null) {83setSupportActionBar(toolbar);84}85
86mBundle = null;87Intent intent = getIntent();88if (intent != null)89mBundle = intent.getExtras();90else if (savedInstanceState != null)91mBundle = savedInstanceState;92
93updateUI();94
95}96
97@Override98protected void onNewIntent(Intent intent) {99super.onNewIntent(intent);100Logger.logVerbose(LOG_TAG, "onNewIntent");101
102setIntent(intent);103
104if (intent != null) {105deleteReportInfoFile(this, mReportInfoFilePath);106mBundle = intent.getExtras();107updateUI();108}109}110
111private void updateUI() {112
113if (mBundle == null) {114finish(); return;115}116
117mReportInfo = null;118mReportInfoFilePath = null;119
120if (mBundle.containsKey(EXTRA_REPORT_INFO_OBJECT_FILE_PATH)) {121mReportInfoFilePath = mBundle.getString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH);122Logger.logVerbose(LOG_TAG, ReportInfo.class.getSimpleName() + " serialized object will be read from file at path \"" + mReportInfoFilePath + "\"");123if (mReportInfoFilePath != null) {124try {125FileUtils.ReadSerializableObjectResult result = FileUtils.readSerializableObjectFromFile(ReportInfo.class.getSimpleName(), mReportInfoFilePath, ReportInfo.class, false);126if (result.error != null) {127Logger.logErrorExtended(LOG_TAG, result.error.toString());128Logger.showToast(this, Error.getMinimalErrorString(result.error), true);129finish(); return;130} else {131if (result.serializableObject != null)132mReportInfo = (ReportInfo) result.serializableObject;133}134} catch (Exception e) {135Logger.logErrorAndShowToast(this, LOG_TAG, e.getMessage());136Logger.logStackTraceWithMessage(LOG_TAG, "Failure while getting " + ReportInfo.class.getSimpleName() + " serialized object from file at path \"" + mReportInfoFilePath + "\"", e);137}138}139} else {140mReportInfo = (ReportInfo) mBundle.getSerializable(EXTRA_REPORT_INFO_OBJECT);141}142
143if (mReportInfo == null) {144finish(); return;145}146
147
148final ActionBar actionBar = getSupportActionBar();149if (actionBar != null) {150if (mReportInfo.reportTitle != null)151actionBar.setTitle(mReportInfo.reportTitle);152else153actionBar.setTitle(TermuxConstants.TERMUX_APP_NAME + " App Report");154}155
156
157RecyclerView recyclerView = findViewById(R.id.recycler_view);158
159final Markwon markwon = MarkdownUtils.getRecyclerMarkwonBuilder(this);160
161final MarkwonAdapter adapter = MarkwonAdapter.builderTextViewIsRoot(R.layout.markdown_adapter_node_default)162.include(FencedCodeBlock.class, SimpleEntry.create(R.layout.markdown_adapter_node_code_block, R.id.code_text_view))163.build();164
165recyclerView.setLayoutManager(new LinearLayoutManager(this));166recyclerView.setAdapter(adapter);167
168generateReportActivityMarkdownString();169adapter.setMarkdown(markwon, mReportActivityMarkdownString);170adapter.notifyDataSetChanged();171}172
173
174@Override175public void onSaveInstanceState(@NonNull Bundle outState) {176super.onSaveInstanceState(outState);177if (mBundle.containsKey(EXTRA_REPORT_INFO_OBJECT_FILE_PATH)) {178outState.putString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH, mReportInfoFilePath);179} else {180outState.putSerializable(EXTRA_REPORT_INFO_OBJECT, mReportInfo);181}182}183
184@Override185protected void onDestroy() {186super.onDestroy();187Logger.logVerbose(LOG_TAG, "onDestroy");188
189deleteReportInfoFile(this, mReportInfoFilePath);190}191
192@Override193public boolean onCreateOptionsMenu(final Menu menu) {194final MenuInflater inflater = getMenuInflater();195inflater.inflate(R.menu.menu_report, menu);196
197if (mReportInfo.reportSaveFilePath == null) {198MenuItem item = menu.findItem(R.id.menu_item_save_report_to_file);199if (item != null)200item.setEnabled(false);201}202
203return true;204}205
206@Override207public void onBackPressed() {208// Remove activity from recents menu on back button press209finishAndRemoveTask();210}211
212@Override213public boolean onOptionsItemSelected(final MenuItem item) {214int id = item.getItemId();215if (id == R.id.menu_item_share_report) {216ShareUtils.shareText(this, getString(R.string.title_report_text), ReportInfo.getReportInfoMarkdownString(mReportInfo));217} else if (id == R.id.menu_item_copy_report) {218ShareUtils.copyTextToClipboard(this, ReportInfo.getReportInfoMarkdownString(mReportInfo), null);219} else if (id == R.id.menu_item_save_report_to_file) {220ShareUtils.saveTextToFile(this, mReportInfo.reportSaveFileLabel,221mReportInfo.reportSaveFilePath, ReportInfo.getReportInfoMarkdownString(mReportInfo),222true, REQUEST_GRANT_STORAGE_PERMISSION_FOR_SAVE_FILE);223}224
225return false;226}227
228@Override229public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {230super.onRequestPermissionsResult(requestCode, permissions, grantResults);231if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {232Logger.logInfo(LOG_TAG, "Storage permission granted by user on request.");233if (requestCode == REQUEST_GRANT_STORAGE_PERMISSION_FOR_SAVE_FILE) {234ShareUtils.saveTextToFile(this, mReportInfo.reportSaveFileLabel,235mReportInfo.reportSaveFilePath, ReportInfo.getReportInfoMarkdownString(mReportInfo),236true, -1);237}238} else {239Logger.logInfo(LOG_TAG, "Storage permission denied by user on request.");240}241}242
243/**244* Generate the markdown {@link String} to be shown in {@link ReportActivity}.
245*/
246private void generateReportActivityMarkdownString() {247// We need to reduce chances of OutOfMemoryError happening so reduce new allocations and248// do not keep output of getReportInfoMarkdownString in memory249StringBuilder reportString = new StringBuilder();250
251if (mReportInfo.reportStringPrefix != null)252reportString.append(mReportInfo.reportStringPrefix);253
254String reportMarkdownString = ReportInfo.getReportInfoMarkdownString(mReportInfo);255int reportMarkdownStringSize = reportMarkdownString.getBytes().length;256boolean truncated = false;257if (reportMarkdownStringSize > ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES) {258Logger.logVerbose(LOG_TAG, mReportInfo.reportTitle + " report string size " + reportMarkdownStringSize + " is greater than " + ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES + " and will be truncated");259reportString.append(DataUtils.getTruncatedCommandOutput(reportMarkdownString, ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES, true, false, true));260truncated = true;261} else {262reportString.append(reportMarkdownString);263}264
265// Free reference266reportMarkdownString = null;267
268if (mReportInfo.reportStringSuffix != null)269reportString.append(mReportInfo.reportStringSuffix);270
271int reportStringSize = reportString.length();272if (reportStringSize > ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES) {273// This may break markdown formatting274Logger.logVerbose(LOG_TAG, mReportInfo.reportTitle + " report string total size " + reportStringSize + " is greater than " + ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES + " and will be truncated");275mReportActivityMarkdownString = this.getString(R.string.msg_report_truncated) +276DataUtils.getTruncatedCommandOutput(reportString.toString(), ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES, true, false, false);277} else if (truncated) {278mReportActivityMarkdownString = this.getString(R.string.msg_report_truncated) + reportString.toString();279} else {280mReportActivityMarkdownString = reportString.toString();281}282
283}284
285
286
287
288
289public static class NewInstanceResult {290/** An intent that can be used to start the {@link ReportActivity}. */291public Intent contentIntent;292/** An intent that can should be adding as the {@link android.app.Notification#deleteIntent}293* by a call to {@link android.app.PendingIntent#getBroadcast(Context, int, Intent, int)}
294* so that {@link ReportActivityBroadcastReceiver} can do cleanup of {@link #EXTRA_REPORT_INFO_OBJECT_FILE_PATH}. */
295public Intent deleteIntent;296
297NewInstanceResult(Intent contentIntent, Intent deleteIntent) {298this.contentIntent = contentIntent;299this.deleteIntent = deleteIntent;300}301}302
303/**304* Start the {@link ReportActivity}.
305*
306* @param context The {@link Context} for operations.
307* @param reportInfo The {@link ReportInfo} containing info that needs to be displayed.
308*/
309public static void startReportActivity(@NonNull final Context context, @NonNull ReportInfo reportInfo) {310NewInstanceResult result = newInstance(context, reportInfo);311if (result.contentIntent == null) return;312context.startActivity(result.contentIntent);313}314
315/**316* Get content and delete intents for the {@link ReportActivity} that can be used to start it
317* and do cleanup.
318*
319* If {@link ReportInfo} size is too large, then a TransactionTooLargeException will be thrown
320* so its object may be saved to a file in the {@link Context#getCacheDir()}. Then when activity
321* starts, its read back and the file is deleted in {@link #onDestroy()}.
322* Note that files may still be left if {@link #onDestroy()} is not called or doesn't finish.
323* A separate cleanup routine is implemented from that case by
324* {@link #deleteReportInfoFilesOlderThanXDays(Context, int, boolean)} which should be called
325* incrementally or at app startup.
326*
327* @param context The {@link Context} for operations.
328* @param reportInfo The {@link ReportInfo} containing info that needs to be displayed.
329* @return Returns {@link NewInstanceResult}.
330*/
331@NonNull332public static NewInstanceResult newInstance(@NonNull final Context context, @NonNull final ReportInfo reportInfo) {333
334long size = DataUtils.getSerializedSize(reportInfo);335if (size > DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES) {336String reportInfoDirectoryPath = getReportInfoDirectoryPath(context);337String reportInfoFilePath = reportInfoDirectoryPath + "/" + CACHE_FILE_BASENAME_PREFIX + reportInfo.reportTimestamp;338Logger.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 + "\"");339Error error = FileUtils.writeSerializableObjectToFile(ReportInfo.class.getSimpleName(), reportInfoFilePath, reportInfo);340if (error != null) {341Logger.logErrorExtended(LOG_TAG, error.toString());342Logger.showToast(context, Error.getMinimalErrorString(error), true);343return new NewInstanceResult(null, null);344}345
346return new NewInstanceResult(createContentIntent(context, null, reportInfoFilePath),347createDeleteIntent(context, reportInfoFilePath));348} else {349return new NewInstanceResult(createContentIntent(context, reportInfo, null),350null);351}352}353
354private static Intent createContentIntent(@NonNull final Context context, final ReportInfo reportInfo, final String reportInfoFilePath) {355Intent intent = new Intent(context, ReportActivity.class);356Bundle bundle = new Bundle();357
358if (reportInfoFilePath != null) {359bundle.putString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH, reportInfoFilePath);360} else {361bundle.putSerializable(EXTRA_REPORT_INFO_OBJECT, reportInfo);362}363
364intent.putExtras(bundle);365
366// Note that ReportActivity should have `documentLaunchMode="intoExisting"` set in `AndroidManifest.xml`367// which has equivalent behaviour to FLAG_ACTIVITY_NEW_DOCUMENT.368// FLAG_ACTIVITY_SINGLE_TOP must also be passed for onNewIntent to be called.369intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NEW_DOCUMENT);370return intent;371}372
373
374private static Intent createDeleteIntent(@NonNull final Context context, final String reportInfoFilePath) {375if (reportInfoFilePath == null) return null;376
377Intent intent = new Intent(context, ReportActivityBroadcastReceiver.class);378intent.setAction(ACTION_DELETE_REPORT_INFO_OBJECT_FILE);379
380Bundle bundle = new Bundle();381bundle.putString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH, reportInfoFilePath);382intent.putExtras(bundle);383
384return intent;385}386
387
388
389
390
391@NotNull392private static String getReportInfoDirectoryPath(Context context) {393// Canonicalize to solve /data/data and /data/user/0 issues when comparing with reportInfoFilePath394return FileUtils.getCanonicalPath(context.getCacheDir().getAbsolutePath(), null) + "/" + CACHE_DIR_BASENAME;395}396
397private static void deleteReportInfoFile(Context context, String reportInfoFilePath) {398if (context == null || reportInfoFilePath == null) return;399
400// Extra protection for mainly if someone set `exported="true"` for ReportActivityBroadcastReceiver401String reportInfoDirectoryPath = getReportInfoDirectoryPath(context);402reportInfoFilePath = FileUtils.getCanonicalPath(reportInfoFilePath, null);403if(!reportInfoFilePath.equals(reportInfoDirectoryPath) && reportInfoFilePath.startsWith(reportInfoDirectoryPath + "/")) {404Logger.logVerbose(LOG_TAG, "Deleting " + ReportInfo.class.getSimpleName() + " serialized object file at path \"" + reportInfoFilePath + "\"");405Error error = FileUtils.deleteRegularFile(ReportInfo.class.getSimpleName(), reportInfoFilePath, true);406if (error != null) {407Logger.logErrorExtended(LOG_TAG, error.toString());408}409} else {410Logger.logError(LOG_TAG, "Not deleting " + ReportInfo.class.getSimpleName() + " serialized object file at path \"" + reportInfoFilePath + "\" since its not under \"" + reportInfoDirectoryPath + "\"");411}412}413
414/**415* Delete {@link ReportInfo} serialized object files from cache older than x days. If a notification
416* has still not been opened after x days that's using a PendingIntent to ReportActivity, then
417* opening the notification will throw a file not found error, so choose days value appropriately
418* or check if a notification is still active if tracking notification ids.
419* The {@link Context} object passed must be of the same package with which {@link #newInstance(Context, ReportInfo)}
420* was called since a call to {@link Context#getCacheDir()} is made.
421*
422* @param context The {@link Context} for operations.
423* @param days The x amount of days before which files should be deleted. This must be `>=0`.
424* @param isSynchronous If set to {@code true}, then the command will be executed in the
425* caller thread and results returned synchronously.
426* If set to {@code false}, then a new thread is started run the commands
427* asynchronously in the background and control is returned to the caller thread.
428* @return Returns the {@code error} if deleting was not successful, otherwise {@code null}.
429*/
430public static Error deleteReportInfoFilesOlderThanXDays(@NonNull final Context context, int days, final boolean isSynchronous) {431if (isSynchronous) {432return deleteReportInfoFilesOlderThanXDaysInner(context, days);433} else {434new Thread() { public void run() {435Error error = deleteReportInfoFilesOlderThanXDaysInner(context, days);436if (error != null) {437Logger.logErrorExtended(LOG_TAG, error.toString());438}439}}.start();440return null;441}442}443
444private static Error deleteReportInfoFilesOlderThanXDaysInner(@NonNull final Context context, int days) {445// Only regular files are deleted and subdirectories are not checked446String reportInfoDirectoryPath = getReportInfoDirectoryPath(context);447Logger.logVerbose(LOG_TAG, "Deleting " + ReportInfo.class.getSimpleName() + " serialized object files under directory path \"" + reportInfoDirectoryPath + "\" older than " + days + " days");448return FileUtils.deleteFilesOlderThanXDays(ReportInfo.class.getSimpleName(), reportInfoDirectoryPath, null, days, true, FileType.REGULAR.getValue());449}450
451
452/**453* The {@link BroadcastReceiver} for {@link ReportActivity} that currently does cleanup when
454* {@link android.app.Notification#deleteIntent} is called. It must be registered in `AndroidManifest.xml`.
455*/
456public static class ReportActivityBroadcastReceiver extends BroadcastReceiver {457private static final String LOG_TAG = "ReportActivityBroadcastReceiver";458
459@Override460public void onReceive(Context context, Intent intent) {461if (intent == null) return;462
463String action = intent.getAction();464Logger.logVerbose(LOG_TAG, "onReceive: \"" + action + "\" action");465
466if (ACTION_DELETE_REPORT_INFO_OBJECT_FILE.equals(action)) {467Bundle bundle = intent.getExtras();468if (bundle == null) return;469if (bundle.containsKey(EXTRA_REPORT_INFO_OBJECT_FILE_PATH)) {470deleteReportInfoFile(context, bundle.getString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH));471}472}473}474}475
476}
477