termux-app

Форк
0
476 строк · 21.5 Кб
1
package com.termux.shared.activities;
2

3
import androidx.annotation.NonNull;
4
import androidx.appcompat.app.ActionBar;
5
import androidx.appcompat.app.AppCompatActivity;
6
import androidx.appcompat.widget.Toolbar;
7
import androidx.recyclerview.widget.LinearLayoutManager;
8
import androidx.recyclerview.widget.RecyclerView;
9

10
import android.content.BroadcastReceiver;
11
import android.content.Context;
12
import android.content.Intent;
13
import android.content.pm.PackageManager;
14
import android.os.Bundle;
15
import android.view.Menu;
16
import android.view.MenuInflater;
17
import android.view.MenuItem;
18

19
import com.termux.shared.R;
20
import com.termux.shared.activity.media.AppCompatActivityUtils;
21
import com.termux.shared.data.DataUtils;
22
import com.termux.shared.file.FileUtils;
23
import com.termux.shared.file.filesystem.FileType;
24
import com.termux.shared.logger.Logger;
25
import com.termux.shared.errors.Error;
26
import com.termux.shared.termux.TermuxConstants;
27
import com.termux.shared.markdown.MarkdownUtils;
28
import com.termux.shared.interact.ShareUtils;
29
import com.termux.shared.models.ReportInfo;
30
import com.termux.shared.theme.NightMode;
31

32
import org.commonmark.node.FencedCodeBlock;
33
import org.jetbrains.annotations.NotNull;
34

35
import io.noties.markwon.Markwon;
36
import io.noties.markwon.recycler.MarkwonAdapter;
37
import 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
 */
50
public class ReportActivity extends AppCompatActivity {
51

52
    private static final String CLASS_NAME = ReportActivity.class.getCanonicalName();
53
    private static final String ACTION_DELETE_REPORT_INFO_OBJECT_FILE = CLASS_NAME + ".ACTION_DELETE_REPORT_INFO_OBJECT_FILE";
54

55
    private static final String EXTRA_REPORT_INFO_OBJECT = CLASS_NAME + ".EXTRA_REPORT_INFO_OBJECT";
56
    private static final String EXTRA_REPORT_INFO_OBJECT_FILE_PATH = CLASS_NAME + ".EXTRA_REPORT_INFO_OBJECT_FILE_PATH";
57

58
    private static final String CACHE_DIR_BASENAME = "report_activity";
59
    private static final String CACHE_FILE_BASENAME_PREFIX = "report_info_";
60

61
    public static final int REQUEST_GRANT_STORAGE_PERMISSION_FOR_SAVE_FILE = 1000;
62

63
    public static final int ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES = 1000 * 1024; // 1MB
64

65
    private ReportInfo mReportInfo;
66
    private String mReportInfoFilePath;
67
    private String mReportActivityMarkdownString;
68
    private Bundle mBundle;
69

70
    private static final String LOG_TAG = "ReportActivity";
71

72
    @Override
73
    protected void onCreate(Bundle savedInstanceState) {
74
        super.onCreate(savedInstanceState);
75
        Logger.logVerbose(LOG_TAG, "onCreate");
76

77
        AppCompatActivityUtils.setNightMode(this, NightMode.getAppNightMode().getName(), true);
78

79
        setContentView(R.layout.activity_report);
80

81
        Toolbar toolbar = findViewById(R.id.toolbar);
82
        if (toolbar != null) {
83
            setSupportActionBar(toolbar);
84
        }
85

86
        mBundle = null;
87
        Intent intent = getIntent();
88
        if (intent != null)
89
            mBundle = intent.getExtras();
90
        else if (savedInstanceState != null)
91
            mBundle = savedInstanceState;
92

93
        updateUI();
94

95
    }
96

97
    @Override
98
    protected void onNewIntent(Intent intent) {
99
        super.onNewIntent(intent);
100
        Logger.logVerbose(LOG_TAG, "onNewIntent");
101

102
        setIntent(intent);
103

104
        if (intent != null) {
105
            deleteReportInfoFile(this, mReportInfoFilePath);
106
            mBundle = intent.getExtras();
107
            updateUI();
108
        }
109
    }
110

111
    private void updateUI() {
112

113
        if (mBundle == null) {
114
            finish(); return;
115
        }
116

117
        mReportInfo = null;
118
        mReportInfoFilePath = null;
119

120
        if (mBundle.containsKey(EXTRA_REPORT_INFO_OBJECT_FILE_PATH)) {
121
            mReportInfoFilePath = mBundle.getString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH);
122
            Logger.logVerbose(LOG_TAG, ReportInfo.class.getSimpleName() + " serialized object will be read from file at path \"" + mReportInfoFilePath + "\"");
123
            if (mReportInfoFilePath != null) {
124
                try {
125
                    FileUtils.ReadSerializableObjectResult result = FileUtils.readSerializableObjectFromFile(ReportInfo.class.getSimpleName(), mReportInfoFilePath, ReportInfo.class, false);
126
                    if (result.error != null) {
127
                        Logger.logErrorExtended(LOG_TAG, result.error.toString());
128
                        Logger.showToast(this, Error.getMinimalErrorString(result.error), true);
129
                        finish(); return;
130
                    } else {
131
                        if (result.serializableObject != null)
132
                            mReportInfo = (ReportInfo) result.serializableObject;
133
                    }
134
                } catch (Exception e) {
135
                    Logger.logErrorAndShowToast(this, LOG_TAG, e.getMessage());
136
                    Logger.logStackTraceWithMessage(LOG_TAG, "Failure while getting " + ReportInfo.class.getSimpleName() + " serialized object from file at path \"" + mReportInfoFilePath + "\"", e);
137
                }
138
            }
139
        } else {
140
            mReportInfo = (ReportInfo) mBundle.getSerializable(EXTRA_REPORT_INFO_OBJECT);
141
        }
142

143
        if (mReportInfo == null) {
144
            finish(); return;
145
        }
146

147

148
        final ActionBar actionBar = getSupportActionBar();
149
        if (actionBar != null) {
150
            if (mReportInfo.reportTitle != null)
151
                actionBar.setTitle(mReportInfo.reportTitle);
152
            else
153
                actionBar.setTitle(TermuxConstants.TERMUX_APP_NAME + " App Report");
154
        }
155

156

157
        RecyclerView recyclerView = findViewById(R.id.recycler_view);
158

159
        final Markwon markwon = MarkdownUtils.getRecyclerMarkwonBuilder(this);
160

161
        final 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

165
        recyclerView.setLayoutManager(new LinearLayoutManager(this));
166
        recyclerView.setAdapter(adapter);
167

168
        generateReportActivityMarkdownString();
169
        adapter.setMarkdown(markwon, mReportActivityMarkdownString);
170
        adapter.notifyDataSetChanged();
171
    }
172

173

174
    @Override
175
    public void onSaveInstanceState(@NonNull Bundle outState) {
176
        super.onSaveInstanceState(outState);
177
        if (mBundle.containsKey(EXTRA_REPORT_INFO_OBJECT_FILE_PATH)) {
178
            outState.putString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH, mReportInfoFilePath);
179
        } else {
180
            outState.putSerializable(EXTRA_REPORT_INFO_OBJECT, mReportInfo);
181
        }
182
    }
183

184
    @Override
185
    protected void onDestroy() {
186
        super.onDestroy();
187
        Logger.logVerbose(LOG_TAG, "onDestroy");
188

189
        deleteReportInfoFile(this, mReportInfoFilePath);
190
    }
191

192
    @Override
193
    public boolean onCreateOptionsMenu(final Menu menu) {
194
        final MenuInflater inflater = getMenuInflater();
195
        inflater.inflate(R.menu.menu_report, menu);
196

197
        if (mReportInfo.reportSaveFilePath == null) {
198
            MenuItem item = menu.findItem(R.id.menu_item_save_report_to_file);
199
            if (item != null)
200
                item.setEnabled(false);
201
        }
202

203
        return true;
204
    }
205

206
    @Override
207
    public void onBackPressed() {
208
        // Remove activity from recents menu on back button press
209
        finishAndRemoveTask();
210
    }
211

212
    @Override
213
    public boolean onOptionsItemSelected(final MenuItem item) {
214
        int id = item.getItemId();
215
        if (id == R.id.menu_item_share_report) {
216
            ShareUtils.shareText(this, getString(R.string.title_report_text), ReportInfo.getReportInfoMarkdownString(mReportInfo));
217
        } else if (id == R.id.menu_item_copy_report) {
218
            ShareUtils.copyTextToClipboard(this, ReportInfo.getReportInfoMarkdownString(mReportInfo), null);
219
        } else if (id == R.id.menu_item_save_report_to_file) {
220
            ShareUtils.saveTextToFile(this, mReportInfo.reportSaveFileLabel,
221
                mReportInfo.reportSaveFilePath, ReportInfo.getReportInfoMarkdownString(mReportInfo),
222
                true, REQUEST_GRANT_STORAGE_PERMISSION_FOR_SAVE_FILE);
223
        }
224

225
        return false;
226
    }
227

228
    @Override
229
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
230
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
231
        if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
232
            Logger.logInfo(LOG_TAG, "Storage permission granted by user on request.");
233
            if (requestCode == REQUEST_GRANT_STORAGE_PERMISSION_FOR_SAVE_FILE) {
234
                ShareUtils.saveTextToFile(this, mReportInfo.reportSaveFileLabel,
235
                    mReportInfo.reportSaveFilePath, ReportInfo.getReportInfoMarkdownString(mReportInfo),
236
                    true, -1);
237
            }
238
        } else {
239
            Logger.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
     */
246
    private void generateReportActivityMarkdownString() {
247
        // We need to reduce chances of OutOfMemoryError happening so reduce new allocations and
248
        // do not keep output of getReportInfoMarkdownString in memory
249
        StringBuilder reportString = new StringBuilder();
250

251
        if (mReportInfo.reportStringPrefix != null)
252
            reportString.append(mReportInfo.reportStringPrefix);
253

254
        String reportMarkdownString = ReportInfo.getReportInfoMarkdownString(mReportInfo);
255
        int reportMarkdownStringSize = reportMarkdownString.getBytes().length;
256
        boolean truncated = false;
257
        if (reportMarkdownStringSize > ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES) {
258
            Logger.logVerbose(LOG_TAG, mReportInfo.reportTitle + " report string size " + reportMarkdownStringSize + " is greater than " + ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES + " and will be truncated");
259
            reportString.append(DataUtils.getTruncatedCommandOutput(reportMarkdownString, ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES, true, false, true));
260
            truncated = true;
261
        } else {
262
            reportString.append(reportMarkdownString);
263
        }
264

265
        // Free reference
266
        reportMarkdownString = null;
267

268
        if (mReportInfo.reportStringSuffix != null)
269
            reportString.append(mReportInfo.reportStringSuffix);
270

271
        int reportStringSize = reportString.length();
272
        if (reportStringSize > ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES) {
273
            // This may break markdown formatting
274
            Logger.logVerbose(LOG_TAG, mReportInfo.reportTitle + " report string total size " + reportStringSize + " is greater than " + ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES + " and will be truncated");
275
            mReportActivityMarkdownString = this.getString(R.string.msg_report_truncated) +
276
                DataUtils.getTruncatedCommandOutput(reportString.toString(), ACTIVITY_TEXT_SIZE_LIMIT_IN_BYTES, true, false, false);
277
        } else if (truncated) {
278
            mReportActivityMarkdownString = this.getString(R.string.msg_report_truncated) + reportString.toString();
279
        } else {
280
            mReportActivityMarkdownString = reportString.toString();
281
        }
282

283
    }
284

285

286

287

288

289
    public static class NewInstanceResult {
290
        /** An intent that can be used to start the {@link ReportActivity}. */
291
        public 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}. */
295
        public Intent deleteIntent;
296

297
        NewInstanceResult(Intent contentIntent, Intent deleteIntent) {
298
            this.contentIntent = contentIntent;
299
            this.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
     */
309
    public static void startReportActivity(@NonNull final Context context, @NonNull ReportInfo reportInfo) {
310
        NewInstanceResult result = newInstance(context, reportInfo);
311
        if (result.contentIntent == null) return;
312
        context.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
    @NonNull
332
    public static NewInstanceResult newInstance(@NonNull final Context context, @NonNull final ReportInfo reportInfo) {
333

334
        long size = DataUtils.getSerializedSize(reportInfo);
335
        if (size > DataUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES) {
336
            String reportInfoDirectoryPath = getReportInfoDirectoryPath(context);
337
            String reportInfoFilePath = reportInfoDirectoryPath + "/" + CACHE_FILE_BASENAME_PREFIX + reportInfo.reportTimestamp;
338
            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 + "\"");
339
            Error error = FileUtils.writeSerializableObjectToFile(ReportInfo.class.getSimpleName(), reportInfoFilePath, reportInfo);
340
            if (error != null) {
341
                Logger.logErrorExtended(LOG_TAG, error.toString());
342
                Logger.showToast(context, Error.getMinimalErrorString(error), true);
343
                return new NewInstanceResult(null, null);
344
            }
345

346
            return new NewInstanceResult(createContentIntent(context, null, reportInfoFilePath),
347
                createDeleteIntent(context, reportInfoFilePath));
348
        } else {
349
            return new NewInstanceResult(createContentIntent(context, reportInfo, null),
350
                null);
351
        }
352
    }
353

354
    private static Intent createContentIntent(@NonNull final Context context, final ReportInfo reportInfo, final String reportInfoFilePath) {
355
        Intent intent = new Intent(context, ReportActivity.class);
356
        Bundle bundle = new Bundle();
357

358
        if (reportInfoFilePath != null) {
359
            bundle.putString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH, reportInfoFilePath);
360
        } else {
361
            bundle.putSerializable(EXTRA_REPORT_INFO_OBJECT, reportInfo);
362
        }
363

364
        intent.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.
369
        intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
370
        return intent;
371
    }
372

373

374
    private static Intent createDeleteIntent(@NonNull final Context context, final String reportInfoFilePath) {
375
        if (reportInfoFilePath == null) return null;
376

377
        Intent intent = new Intent(context, ReportActivityBroadcastReceiver.class);
378
        intent.setAction(ACTION_DELETE_REPORT_INFO_OBJECT_FILE);
379

380
        Bundle bundle = new Bundle();
381
        bundle.putString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH, reportInfoFilePath);
382
        intent.putExtras(bundle);
383

384
        return intent;
385
    }
386

387

388

389

390

391
    @NotNull
392
    private static String getReportInfoDirectoryPath(Context context) {
393
        // Canonicalize to solve /data/data and /data/user/0 issues when comparing with reportInfoFilePath
394
        return FileUtils.getCanonicalPath(context.getCacheDir().getAbsolutePath(), null) + "/" + CACHE_DIR_BASENAME;
395
    }
396

397
    private static void deleteReportInfoFile(Context context, String reportInfoFilePath) {
398
        if (context == null || reportInfoFilePath == null) return;
399

400
        // Extra protection for mainly if someone set `exported="true"` for ReportActivityBroadcastReceiver
401
        String reportInfoDirectoryPath = getReportInfoDirectoryPath(context);
402
        reportInfoFilePath = FileUtils.getCanonicalPath(reportInfoFilePath, null);
403
        if(!reportInfoFilePath.equals(reportInfoDirectoryPath) && reportInfoFilePath.startsWith(reportInfoDirectoryPath + "/")) {
404
            Logger.logVerbose(LOG_TAG, "Deleting " + ReportInfo.class.getSimpleName() + " serialized object file at path \"" + reportInfoFilePath + "\"");
405
            Error error = FileUtils.deleteRegularFile(ReportInfo.class.getSimpleName(), reportInfoFilePath, true);
406
            if (error != null) {
407
                Logger.logErrorExtended(LOG_TAG, error.toString());
408
            }
409
        } else {
410
            Logger.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
     */
430
    public static Error deleteReportInfoFilesOlderThanXDays(@NonNull final Context context, int days, final boolean isSynchronous) {
431
        if (isSynchronous) {
432
            return deleteReportInfoFilesOlderThanXDaysInner(context, days);
433
        } else {
434
            new Thread() { public void run() {
435
                Error error = deleteReportInfoFilesOlderThanXDaysInner(context, days);
436
                if (error != null) {
437
                    Logger.logErrorExtended(LOG_TAG, error.toString());
438
                }
439
            }}.start();
440
            return null;
441
        }
442
    }
443

444
    private static Error deleteReportInfoFilesOlderThanXDaysInner(@NonNull final Context context, int days) {
445
        // Only regular files are deleted and subdirectories are not checked
446
        String reportInfoDirectoryPath = getReportInfoDirectoryPath(context);
447
        Logger.logVerbose(LOG_TAG, "Deleting " + ReportInfo.class.getSimpleName() + " serialized object files under directory path \"" + reportInfoDirectoryPath + "\" older than " + days + " days");
448
        return 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
     */
456
    public static class ReportActivityBroadcastReceiver extends BroadcastReceiver {
457
        private static final String LOG_TAG = "ReportActivityBroadcastReceiver";
458

459
        @Override
460
        public void onReceive(Context context, Intent intent) {
461
            if (intent == null) return;
462

463
            String action = intent.getAction();
464
            Logger.logVerbose(LOG_TAG, "onReceive: \"" + action + "\" action");
465

466
            if (ACTION_DELETE_REPORT_INFO_OBJECT_FILE.equals(action)) {
467
                Bundle bundle = intent.getExtras();
468
                if (bundle == null) return;
469
                if (bundle.containsKey(EXTRA_REPORT_INFO_OBJECT_FILE_PATH)) {
470
                    deleteReportInfoFile(context, bundle.getString(EXTRA_REPORT_INFO_OBJECT_FILE_PATH));
471
                }
472
            }
473
        }
474
    }
475

476
}
477

Использование cookies

Мы используем файлы cookie в соответствии с Политикой конфиденциальности и Политикой использования cookies.

Нажимая кнопку «Принимаю», Вы даете АО «СберТех» согласие на обработку Ваших персональных данных в целях совершенствования нашего веб-сайта и Сервиса GitVerse, а также повышения удобства их использования.

Запретить использование cookies Вы можете самостоятельно в настройках Вашего браузера.