Repository: MuntashirAkon/AppManager (language: java)

Issue:
Logcat viewer
A properly formatted logcat viewer is very helpful in debugging applications and a must have feature of an app manager.


Discussion / hints:
To integrate it more tightly with the idea of AppManager, it could maybe be possible to press "logcat" in an app's detaiped view and have the logcat filtered by packagename of the app
> To integrate it more tightly with the idea of AppManager, it could maybe be possible to press "logcat" in an app's detaiped view and have the logcat filtered by packagename of the app

Great thinking. I will keep that in mind when I'll implement this.
Features:
1. Dedicated entrance to the log viewer page via the three-dots menu
2. Package-specific (actually PID specific) viewer via the app details page (for running packages only)
3. PID specific viewer from the running apps page

Capabilities:
We'll be reusing code from [Matlog Libre](https://github.com/pluscubed/matlog) (which is based on CatLog), therefore it will have all the features that this software offers, except the colour schemes. AM will allow users to edit/import all the colours used by the app (except the primary, secondary and accent colours) in the future.

Fix patch:
diff --git a/app/src/debug/res/xml/shortcuts.xml b/app/src/debug/res/xml/shortcuts.xml
new file mode 100644
index 00000000000..2fe3b6d88cb
--- /dev/null
+++ b/app/src/debug/res/xml/shortcuts.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shortcuts xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    tools:targetApi="n_mr1">
+
+    <shortcut
+        android:enabled="true"
+        android:icon="@drawable/ic_shortcut_record"
+        android:shortcutId="record"
+        android:shortcutLongLabel="@string/start_recording_log"
+        android:shortcutShortLabel="@string/start_recording_log">
+        <intent
+            android:action="android.intent.action.MAIN"
+            android:targetClass="io.github.muntashirakon.AppManager.logcat.LogViewerActivity"
+            android:targetPackage="io.github.muntashirakon.AppManager.debug">
+
+            <extra
+                android:name="shortcut_action"
+                android:value="record" />
+
+        </intent>
+    </shortcut>
+
+</shortcuts>
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ac37d3285e2..03f675c85c3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -48,6 +48,8 @@
     <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
         tools:ignore="QueryAllPackagesPermission" />
     <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
+    <uses-permission android:name="android.permission.READ_LOGS"
+        tools:ignore="ProtectedPermissions" />
     <uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
     <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
     <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
@@ -497,6 +499,26 @@
             android:name=".crypto.AuthenticationActivity"
             android:exported="false"
             android:theme="@style/AppTheme.TransparentBackground" />
+        <activity
+            android:name=".logcat.LogViewerActivity"
+            android:configChanges="screenSize|orientation"
+            android:enabled="true"
+            android:exported="true"
+            android:label="@string/log_viewer"
+            android:launchMode="singleTop"
+            android:taskAffinity=".logcat.LogcatActivity">
+            <intent-filter>
+                <action android:name="${applicationId}.action.LAUNCH" />
+                <category android:name="android.intent.category.DEFAULT" />
+            </intent-filter>
+
+            <meta-data
+                android:name="android.app.shortcuts"
+                android:resource="@xml/shortcuts" />
+        </activity>
+        <activity
+            android:name=".logcat.RecordLogDialogActivity"
+            android:theme="@style/AppTheme.TransparentBackground" />
 
         <service android:name=".apk.installer.PackageInstallerService" />
         <service
@@ -505,6 +527,32 @@
         <service
             android:name=".profiles.ProfileApplierService"
             android:exported="false" />
+        <service
+            android:name=".logcat.LogcatRecordingService"
+            android:exported="false" />
+        <service
+            android:name=".logcat.CrazyLoggerService"
+            android:exported="false" />
+
+        <receiver android:name=".logcat.RecordingWidgetProvider">
+            <intent-filter>
+                <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
+                <data android:scheme="log_viewer_widget" />
+            </intent-filter>
+
+            <intent-filter>
+                <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
+            </intent-filter>
+
+            <intent-filter>
+                <action android:name="${applicationId}.action.RECORD_OR_STOP" />
+                <data android:scheme="log_viewer_widget" />
+            </intent-filter>
+
+            <meta-data
+                android:name="android.appwidget.provider"
+                android:resource="@xml/recording_widget_info" />
+        </receiver>
 
         <provider
             android:name="androidx.core.content.FileProvider"
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/AppManager.java b/app/src/main/java/io/github/muntashirakon/AppManager/AppManager.java
index 9653abf8cd7..65926cf7d3a 100644
--- a/app/src/main/java/io/github/muntashirakon/AppManager/AppManager.java
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/AppManager.java
@@ -64,7 +64,7 @@ public static IPackageManager getIPackageManager() {
     public static synchronized AMDatabase getDb() {
         if (db == null) {
             db = Room.databaseBuilder(getContext(), AMDatabase.class, "am")
-                    .addMigrations(AMDatabase.MIGRATION_1_2)
+                    .addMigrations(AMDatabase.MIGRATION_1_2, AMDatabase.MIGRATION_2_3)
                     .build();
         }
         return db;
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/BaseActivity.java b/app/src/main/java/io/github/muntashirakon/AppManager/BaseActivity.java
index 82544b9c57d..38a870d24da 100644
--- a/app/src/main/java/io/github/muntashirakon/AppManager/BaseActivity.java
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/BaseActivity.java
@@ -22,6 +22,7 @@
 import android.os.Bundle;
 import android.view.Menu;
 import androidx.activity.result.ActivityResult;
+import androidx.annotation.CallSuper;
 import androidx.annotation.Nullable;
 import androidx.appcompat.app.AppCompatActivity;
 import androidx.appcompat.app.AppCompatDelegate;
@@ -35,6 +36,7 @@
 public abstract class BaseActivity extends AppCompatActivity {
     private final BetterActivityResult<Intent, ActivityResult> authActivity = BetterActivityResult.registerActivityForResult(this);
 
+    @CallSuper
     @Override
     protected final void onCreate(@Nullable Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
@@ -58,6 +60,7 @@ protected final void onCreate(@Nullable Bundle savedInstanceState) {
 
     protected abstract void onAuthenticated(@Nullable Bundle savedInstanceState);
 
+    @CallSuper
     @SuppressLint("RestrictedApi")
     @Override
     public boolean onCreateOptionsMenu(Menu menu) {
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/backup/BackupFiles.java b/app/src/main/java/io/github/muntashirakon/AppManager/backup/BackupFiles.java
index cfac0a66e61..ac0e3778aeb 100644
--- a/app/src/main/java/io/github/muntashirakon/AppManager/backup/BackupFiles.java
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/backup/BackupFiles.java
@@ -20,6 +20,8 @@
 import android.os.RemoteException;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+
+import io.github.muntashirakon.AppManager.db.entity.App;
 import io.github.muntashirakon.AppManager.utils.AppPref;
 import io.github.muntashirakon.io.ProxyFile;
 import io.github.muntashirakon.io.ProxyFileReader;
@@ -42,7 +44,7 @@ public class BackupFiles {
 
     @NonNull
     public static ProxyFile getBackupDirectory() {
-        return new ProxyFile((String) AppPref.get(AppPref.PrefKey.PREF_BACKUP_VOLUME_STR), "AppManager");
+        return AppPref.getAppManagerDirectory();
     }
 
     @NonNull
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/db/AMDatabase.java b/app/src/main/java/io/github/muntashirakon/AppManager/db/AMDatabase.java
index d9f1f2659e5..ea01339aa00 100644
--- a/app/src/main/java/io/github/muntashirakon/AppManager/db/AMDatabase.java
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/db/AMDatabase.java
@@ -23,9 +23,11 @@
 import androidx.room.migration.Migration;
 import androidx.sqlite.db.SupportSQLiteDatabase;
 import io.github.muntashirakon.AppManager.db.dao.AppDao;
+import io.github.muntashirakon.AppManager.db.dao.LogFilterDao;
 import io.github.muntashirakon.AppManager.db.entity.App;
+import io.github.muntashirakon.AppManager.db.entity.LogFilter;
 
-@Database(entities = {App.class}, version = 2)
+@Database(entities = {App.class, LogFilter.class}, version = 3)
 public abstract class AMDatabase extends RoomDatabase {
     public static final Migration MIGRATION_1_2 = new Migration(1, 2) {
         @Override
@@ -34,5 +36,15 @@ public void migrate(@NonNull SupportSQLiteDatabase database) {
         }
     };
 
+    public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
+        @Override
+        public void migrate(@NonNull SupportSQLiteDatabase database) {
+            database.execSQL("CREATE TABLE IF NOT EXISTS log_filter (id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, name TEXT)");
+            database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_name ON log_filter (name)");
+        }
+    };
+
     public abstract AppDao appDao();
+
+    public abstract LogFilterDao logFilterDao();
 }
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/db/dao/LogFilterDao.java b/app/src/main/java/io/github/muntashirakon/AppManager/db/dao/LogFilterDao.java
new file mode 100644
index 00000000000..3a3091210fd
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/db/dao/LogFilterDao.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.db.dao;
+
+import androidx.room.Dao;
+import androidx.room.Delete;
+import androidx.room.Insert;
+import androidx.room.OnConflictStrategy;
+import androidx.room.Query;
+
+import java.util.List;
+
+import io.github.muntashirakon.AppManager.db.entity.LogFilter;
+
+@Dao
+public interface LogFilterDao {
+    @Query("SELECT * FROM log_filter")
+    List<LogFilter> getAll();
+
+    @Query("SELECT * FROM log_filter WHERE id = :id LIMIT 1")
+    LogFilter get(long id);
+
+    @Query("INSERT INTO log_filter (name) VALUES(:filterName)")
+    long insert(String filterName);
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    void insert(LogFilter logFilter);
+
+    @Delete
+    void delete(LogFilter logFilter);
+
+    @Query("DELETE FROM log_filter WHERE id = :id")
+    void delete(int id);
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/db/entity/LogFilter.java b/app/src/main/java/io/github/muntashirakon/AppManager/db/entity/LogFilter.java
new file mode 100644
index 00000000000..72631f8b409
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/db/entity/LogFilter.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.db.entity;
+
+import androidx.annotation.NonNull;
+import androidx.room.ColumnInfo;
+import androidx.room.Entity;
+import androidx.room.Index;
+import androidx.room.PrimaryKey;
+
+import java.util.Comparator;
+
+@SuppressWarnings("NullableProblems")
+@Entity(tableName = "log_filter", indices = {@Index(name = "index_name", value = {"name"}, unique = true)})
+public class LogFilter implements Comparable<LogFilter> {
+    public static final Comparator<LogFilter> COMPARATOR = (o1, o2) -> {
+        String n1 = o1.name != null ? o1.name : "";
+        String n2 = o2.name != null ? o2.name : "";
+        return n1.compareToIgnoreCase(n2);
+    };
+
+    @ColumnInfo(name = "id")
+    @PrimaryKey(autoGenerate = true)
+    @NonNull
+    public long id;
+
+    @ColumnInfo(name = "name")
+    public String name;
+
+    @Override
+    public int compareTo(@NonNull LogFilter o) {
+        return COMPARATOR.compare(this, o);
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/details/info/AppInfoFragment.java b/app/src/main/java/io/github/muntashirakon/AppManager/details/info/AppInfoFragment.java
index f4db2f6fc16..151c928334a 100644
--- a/app/src/main/java/io/github/muntashirakon/AppManager/details/info/AppInfoFragment.java
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/details/info/AppInfoFragment.java
@@ -74,6 +74,8 @@
 import io.github.muntashirakon.AppManager.details.AppDetailsViewModel;
 import io.github.muntashirakon.AppManager.details.ManifestViewerActivity;
 import io.github.muntashirakon.AppManager.details.struct.AppDetailsItem;
+import io.github.muntashirakon.AppManager.logcat.LogViewerActivity;
+import io.github.muntashirakon.AppManager.logcat.struct.SearchCriteria;
 import io.github.muntashirakon.AppManager.logs.Log;
 import io.github.muntashirakon.AppManager.profiles.ProfileManager;
 import io.github.muntashirakon.AppManager.profiles.ProfileMetaManager;
@@ -300,7 +302,8 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) {
                     runOnUiThread(() -> {
                         Intent intent = new Intent(Intent.ACTION_SEND)
                                 .setType("application/*")
-                                .putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(mActivity, BuildConfig.APPLICATION_ID + ".provider", tmpApkSource))
+                                .putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(mActivity,
+                                        BuildConfig.APPLICATION_ID + ".provider", tmpApkSource))
                                 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
                         startActivity(Intent.createChooser(intent, getString(R.string.share_apk)));
                     });
@@ -310,6 +313,7 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) {
                 }
             });
         } else if (itemId == R.id.action_backup) {
+            if (mainModel == null) return true;
             BackupDialogFragment backupDialogFragment = new BackupDialogFragment();
             Bundle args = new Bundle();
             args.putParcelableArrayList(BackupDialogFragment.ARG_PACKAGE_PAIRS, new ArrayList<>(
@@ -326,7 +330,7 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) {
         } else if (itemId == R.id.action_export_blocking_rules) {
             final String fileName = "app_manager_rules_export-" + DateUtils.formatDateTime(System.currentTimeMillis()) + ".am.tsv";
             export.launch(fileName, uri -> {
-                if (uri == null) {
+                if (uri == null || mainModel == null) {
                     // Back button pressed.
                     return;
                 }
@@ -354,6 +358,7 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) {
                 if (granted) runInTermux();
             });
         } else if (itemId == R.id.action_enable_magisk_hide) {
+            if (mainModel == null) return true;
             if (MagiskUtils.hide(mPackageName)) {
                 try (ComponentsBlocker cb = ComponentsBlocker.getMutableInstance(mPackageName, mainModel.getUserHandle())) {
                     cb.setMagiskHide(true);
@@ -520,6 +525,7 @@ private void runWithTermux(String[] command) {
     }
 
     private void install() {
+        if (mainModel == null) return;
         Intent intent = new Intent(this.getContext(), PackageInstallerActivity.class);
         intent.putExtra(PackageInstallerActivity.EXTRA_APK_FILE_KEY, mainModel.getApkFileKey());
         try {
@@ -529,6 +535,7 @@ private void install() {
     }
 
     private void refreshDetails() {
+        if (mainModel == null) return;
         showProgressIndicator(true);
         mainModel.setIsPackageChanged();
     }
@@ -537,6 +544,7 @@ private void refreshDetails() {
     private void setupTagCloud() {
         model.getTagCloud().observe(getViewLifecycleOwner(), tagCloud -> {
             mTagCloud.removeAllViews();
+            if (mainModel == null) return;
             // Add tracker chip
             if (!tagCloud.trackerComponents.isEmpty()) {
                 CharSequence[] trackerComponentNames = new CharSequence[tagCloud.trackerComponents.size()];
@@ -619,9 +627,30 @@ private void setupTagCloud() {
             if (tagCloud.hasRequestedLargeHeap) {
                 addChip(R.string.requested_large_heap, R.color.tracker);
             }
-            if (tagCloud.isRunning) {
-                addChip(R.string.running, R.color.running).setOnClickListener(v ->
-                        mActivity.viewPager.setCurrentItem(AppDetailsFragment.SERVICES));
+            if (tagCloud.runningServices.size() > 0) {
+                addChip(R.string.running, R.color.running).setOnClickListener(v -> {
+                    mProgressIndicator.show();
+                    new Thread(() -> {
+                        int pid = PackageUtils.getPidForPackage(mPackageName, mApplicationInfo.uid);
+                        runOnUiThread(() -> {
+                            mProgressIndicator.hide();
+                            MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(mActivity)
+                                    .setTitle(R.string.running_services)
+                                    .setItems(tagCloud.runningServices.toArray(new String[0]), null)
+                                    .setNegativeButton(R.string.close, null);
+                            if (pid != 0) {
+                                builder.setNeutralButton(R.string.view_logs, (dialog, which) -> {
+                                    Intent logViewerIntent = new Intent(mActivity.getApplicationContext(), LogViewerActivity.class)
+                                            .setAction(LogViewerActivity.ACTION_LAUNCH)
+                                            .putExtra(LogViewerActivity.EXTRA_FILTER, SearchCriteria.PID_KEYWORD + pid)
+                                            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+                                    mActivity.startActivity(logViewerIntent);
+                                });
+                            }
+                            builder.show();
+                        });
+                    }).start();
+                });
             }
             if (tagCloud.isForceStopped) {
                 addChip(R.string.stopped, R.color.stopped);
@@ -686,7 +715,7 @@ private void setupTagCloud() {
                         .setNegativeButton(R.string.ok, null)
                         .show());
             }
-            if (tagCloud.ssaid != null) {
+            if (tagCloud.ssaid != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                 addChip(R.string.ssaid, R.color.red_orange).setOnClickListener(v -> {
                     View view = getLayoutInflater().inflate(R.layout.dialog_ssaid_info, null);
                     AlertDialog alertDialog = new MaterialAlertDialogBuilder(mActivity)
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/details/info/AppInfoViewModel.java b/app/src/main/java/io/github/muntashirakon/AppManager/details/info/AppInfoViewModel.java
index 55a3499473b..09a537fee33 100644
--- a/app/src/main/java/io/github/muntashirakon/AppManager/details/info/AppInfoViewModel.java
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/details/info/AppInfoViewModel.java
@@ -127,7 +127,7 @@ public void loadTagCloud() {
         tagCloud.isTestOnly = (applicationInfo.flags & ApplicationInfo.FLAG_TEST_ONLY) != 0;
         tagCloud.hasCode = (applicationInfo.flags & ApplicationInfo.FLAG_HAS_CODE) != 0;
         tagCloud.hasRequestedLargeHeap = (applicationInfo.flags & ApplicationInfo.FLAG_LARGE_HEAP) != 0;
-        tagCloud.isRunning = !mainModel.getIsExternalApk() && PackageUtils.hasRunningServices(packageName);
+        tagCloud.runningServices = PackageUtils.getRunningServicesForPackage(packageName);
         tagCloud.isForceStopped = (applicationInfo.flags & ApplicationInfo.FLAG_STOPPED) != 0;
         tagCloud.isAppEnabled = applicationInfo.enabled;
         tagCloud.isMagiskHideEnabled = !mainModel.getIsExternalApk() && AppPref.isRootEnabled() && MagiskUtils.isHidden(packageName);
@@ -291,7 +291,7 @@ public static class TagCloud {
         public boolean isTestOnly;
         public boolean hasCode;
         public boolean hasRequestedLargeHeap;
-        public boolean isRunning;
+        public List<String> runningServices;
         public boolean isForceStopped;
         public boolean isAppEnabled;
         public boolean isMagiskHideEnabled;
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/CrazyLoggerService.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/CrazyLoggerService.java
new file mode 100644
index 00000000000..1f20915c5a2
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/CrazyLoggerService.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat;
+
+import android.content.Intent;
+import android.util.Log;
+
+import java.util.Random;
+
+import io.github.muntashirakon.AppManager.types.ForegroundService;
+
+public class CrazyLoggerService extends ForegroundService {
+    public static final String TAG = CrazyLoggerService.class.getSimpleName();
+
+    public static final int[] LOG_LEVELS = new int[]{Log.VERBOSE, Log.DEBUG, Log.INFO, Log.WARN, Log.ERROR, Log.ASSERT};
+    public static final String[] LOG_MESSAGES = new String[]{
+            "Email: email@me.com",
+            "FTP: ftp://website.com:21",
+            "HTTP: https://website.com",
+            "A simple log",
+            "Another log"
+    };
+
+    private static final long INTERVAL = 300;
+    private boolean kill = false;
+
+    public CrazyLoggerService() {
+        super(TAG);
+    }
+
+    protected void onHandleIntent(Intent intent) {
+        while (!kill) {
+            try {
+                Thread.sleep(INTERVAL);
+            } catch (InterruptedException e) {
+                Log.e(TAG, "Interrupted", e);
+            }
+            if (new Random().nextInt(100) % 5 == 0) {
+                Log.println(LOG_LEVELS[new Random().nextInt(6)], TAG, LOG_MESSAGES[new Random().nextInt(5)]);
+            }
+        }
+    }
+
+    @Override
+    public void onDestroy() {
+        super.onDestroy();
+        kill = true;
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/LogFilterAdapter.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/LogFilterAdapter.java
new file mode 100644
index 00000000000..23162e63d53
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/LogFilterAdapter.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.fragment.app.FragmentActivity;
+
+import com.google.android.material.button.MaterialButton;
+
+import java.util.List;
+
+import io.github.muntashirakon.AppManager.AppManager;
+import io.github.muntashirakon.AppManager.R;
+import io.github.muntashirakon.AppManager.db.entity.LogFilter;
+
+public class LogFilterAdapter extends ArrayAdapter<LogFilter> {
+    LayoutInflater layoutInflater;
+
+    public interface OnClickListener {
+        void onClick(ViewGroup parent, View view, int position, LogFilter logFilter);
+    }
+
+    private OnClickListener listener;
+
+    public void setOnItemClickListener(OnClickListener listener) {
+        this.listener = listener;
+    }
+
+    public LogFilterAdapter(FragmentActivity activity, List<LogFilter> items) {
+        super(activity, R.layout.item_title_action, items);
+        layoutInflater = activity.getLayoutInflater();
+    }
+
+    @NonNull
+    @Override
+    public View getView(int position, View convertView, @NonNull ViewGroup parent) {
+        ViewHolder holder;
+        if (convertView == null) {
+            convertView = layoutInflater.inflate(R.layout.item_title_action, parent, false);
+            holder = new ViewHolder();
+            holder.textView = convertView.findViewById(R.id.item_title);
+            holder.actionButton = convertView.findViewById(R.id.item_action);
+            convertView.setTag(holder);
+        } else {
+            holder = (ViewHolder) convertView.getTag();
+        }
+        final LogFilter logFilter = getItem(position);
+        holder.textView.setText(logFilter.name);
+        View finalConvertView = convertView;
+        holder.textView.setOnClickListener(v -> {
+            if (listener != null) {
+                listener.onClick(parent, finalConvertView, position, logFilter);
+            }
+        });
+        holder.actionButton.setOnClickListener(v -> {
+            new Thread(() -> AppManager.getDb().logFilterDao().delete(logFilter)).start();
+            remove(logFilter);
+            notifyDataSetChanged();
+        });
+        return convertView;
+    }
+
+    private static class ViewHolder {
+        TextView textView;
+        MaterialButton actionButton;
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/LogViewerActivity.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/LogViewerActivity.java
new file mode 100644
index 00000000000..eff12f611ea
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/LogViewerActivity.java
@@ -0,0 +1,1379 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat;
+
+import android.Manifest;
+import android.annotation.SuppressLint;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.Intent;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.RemoteException;
+import android.provider.BaseColumns;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.CheckBox;
+import android.widget.Filter;
+import android.widget.Filter.FilterListener;
+import android.widget.ListView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResult;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.annotation.UiThread;
+import androidx.annotation.WorkerThread;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.widget.LinearLayoutCompat;
+import androidx.appcompat.widget.SearchView;
+import androidx.core.content.ContextCompat;
+import androidx.core.content.FileProvider;
+import androidx.cursoradapter.widget.CursorAdapter;
+import androidx.cursoradapter.widget.SimpleCursorAdapter;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+import com.google.android.material.progressindicator.LinearProgressIndicator;
+import com.google.android.material.snackbar.Snackbar;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Set;
+
+import io.github.muntashirakon.AppManager.AppManager;
+import io.github.muntashirakon.AppManager.BaseActivity;
+import io.github.muntashirakon.AppManager.BuildConfig;
+import io.github.muntashirakon.AppManager.R;
+import io.github.muntashirakon.AppManager.db.dao.LogFilterDao;
+import io.github.muntashirakon.AppManager.db.entity.LogFilter;
+import io.github.muntashirakon.AppManager.logcat.helper.BuildHelper;
+import io.github.muntashirakon.AppManager.logcat.helper.PreferenceHelper;
+import io.github.muntashirakon.AppManager.logcat.helper.SaveLogHelper;
+import io.github.muntashirakon.AppManager.logcat.helper.ServiceHelper;
+import io.github.muntashirakon.AppManager.logcat.reader.LogcatReader;
+import io.github.muntashirakon.AppManager.logcat.reader.LogcatReaderLoader;
+import io.github.muntashirakon.AppManager.logcat.struct.LogLine;
+import io.github.muntashirakon.AppManager.logcat.struct.SavedLog;
+import io.github.muntashirakon.AppManager.logcat.struct.SearchCriteria;
+import io.github.muntashirakon.AppManager.logcat.struct.SendLogDetails;
+import io.github.muntashirakon.AppManager.logs.Log;
+import io.github.muntashirakon.AppManager.runner.Runner;
+import io.github.muntashirakon.AppManager.servermanager.LocalServer;
+import io.github.muntashirakon.AppManager.servermanager.PackageManagerCompat;
+import io.github.muntashirakon.AppManager.settings.LogViewerPreferences;
+import io.github.muntashirakon.AppManager.settings.SettingsActivity;
+import io.github.muntashirakon.AppManager.types.SearchableMultiChoiceDialogBuilder;
+import io.github.muntashirakon.AppManager.types.TextInputDialogBuilder;
+import io.github.muntashirakon.AppManager.types.TextInputDropdownDialogBuilder;
+import io.github.muntashirakon.AppManager.users.Users;
+import io.github.muntashirakon.AppManager.utils.AppPref;
+import io.github.muntashirakon.AppManager.utils.BetterActivityResult;
+import io.github.muntashirakon.AppManager.utils.PermissionUtils;
+import io.github.muntashirakon.AppManager.utils.StoragePermission;
+import io.github.muntashirakon.AppManager.utils.UIUtils;
+import me.zhanghai.android.fastscroll.FastScrollerBuilder;
+
+import static io.github.muntashirakon.AppManager.logcat.LogViewerRecyclerAdapter.ViewHolder.CONTEXT_MENU_COPY_ID;
+import static io.github.muntashirakon.AppManager.logcat.LogViewerRecyclerAdapter.ViewHolder.CONTEXT_MENU_FILTER_ID;
+
+public class LogViewerActivity extends BaseActivity implements FilterListener,
+        LogViewerRecyclerAdapter.ViewHolder.OnClickListener {
+    public static final String TAG = LogViewerActivity.class.getSimpleName();
+
+    public static final String ACTION_LAUNCH = BuildConfig.APPLICATION_ID + ".action.LAUNCH";
+    public static final String EXTRA_FILTER = "filter";
+    public static final String EXTRA_LEVEL = "level";
+
+    // how often to check to see if we've gone over the max size
+    private static final int UPDATE_CHECK_INTERVAL = 200;
+
+    // how many suggestions to keep in the autosuggestions text
+    private static final int MAX_NUM_SUGGESTIONS = 1000;
+
+    private static final String INTENT_FILENAME = "filename";
+
+    private RecyclerView recyclerView;
+    private LinearProgressIndicator progressIndicator;
+    private FloatingActionButton fab;
+    private LogViewerRecyclerAdapter mLogListAdapter;
+    private LogReaderAsyncTask mTask;
+
+    private String mSearchingString;
+
+    private boolean mAutoscrollToBottom = true;
+    private boolean mCollapsedMode;
+
+    private String mFilterPattern = null;
+
+    private boolean mDynamicallyEnteringSearchText;
+    private boolean partialSelectMode;
+    private final List<LogLine> partiallySelectedLogLines = new ArrayList<>(2);
+
+    private final Set<String> mSearchSuggestionsSet = new HashSet<>();
+    private CursorAdapter mSearchSuggestionsAdapter;
+
+    private String mCurrentlyOpenLog = null;
+
+    private Handler mHandler;
+    private SearchView searchView;
+
+    private final BetterActivityResult<Intent, ActivityResult> activityLauncher =
+            BetterActivityResult.registerActivityForResult(this);
+    private final StoragePermission storagePermission = StoragePermission.init(this);
+    private final BetterActivityResult<String, Uri> saveLauncher = BetterActivityResult
+            .registerForActivityResult(this, new ActivityResultContracts.CreateDocument());
+
+    public static void startChooser(Context context, String subject, String body, SendLogDetails.AttachmentType attachmentType, File attachment) {
+        Intent actionSendIntent = new Intent(Intent.ACTION_SEND);
+
+        actionSendIntent.setType(attachmentType.getMimeType());
+        actionSendIntent.putExtra(Intent.EXTRA_SUBJECT, subject);
+        if (!body.isEmpty()) {
+            actionSendIntent.putExtra(Intent.EXTRA_TEXT, body);
+        }
+        if (attachment != null) {
+            Uri uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", attachment);
+            actionSendIntent.putExtra(Intent.EXTRA_STREAM, uri);
+        }
+
+        try {
+            context.startActivity(Intent.createChooser(actionSendIntent, context.getResources().getText(R.string.send_log_title)));
+        } catch (Exception e) {
+            Toast.makeText(context, e.getMessage(), Toast.LENGTH_LONG).show();
+        }
+    }
+
+    @Override
+    public void onAuthenticated(Bundle savedInstanceState) {
+        setContentView(R.layout.activity_logcat);
+        setSupportActionBar(findViewById(R.id.toolbar));
+        progressIndicator = findViewById(R.id.progress_linear);
+        progressIndicator.setVisibilityAfterHide(View.GONE);
+        fab = findViewById(R.id.fab);
+
+        ActionBar actionBar = getSupportActionBar();
+        if (actionBar != null) {
+            actionBar.setDisplayShowCustomEnabled(true);
+            searchView = UIUtils.setupSearchView(this, actionBar, new SearchView.OnQueryTextListener() {
+                @Override
+                public boolean onQueryTextSubmit(String query) {
+                    return false;
+                }
+
+                @Override
+                public boolean onQueryTextChange(String newText) {
+                    if (!mDynamicallyEnteringSearchText) {
+                        search(newText);
+                        populateSuggestionsAdapter(newText);
+                    }
+                    mDynamicallyEnteringSearchText = false;
+                    return false;
+                }
+            });
+            searchView.setOnSuggestionListener(new SearchView.OnSuggestionListener() {
+                @Override
+                public boolean onSuggestionSelect(int position) {
+                    return false;
+                }
+
+                @Override
+                public boolean onSuggestionClick(int position) {
+                    List<String> suggestions = getSuggestionsForQuery(mSearchingString);
+                    if (!suggestions.isEmpty()) {
+                        searchView.setQuery(suggestions.get(position), true);
+                    }
+                    return false;
+                }
+            });
+        }
+
+        mSearchSuggestionsAdapter = new SimpleCursorAdapter(this, R.layout.item_checked_text_view, null,
+                new String[]{"suggestion"}, new int[]{android.R.id.text1},
+                CursorAdapter.FLAG_REGISTER_CONTENT_OBSERVER);
+        searchView.setSuggestionsAdapter(mSearchSuggestionsAdapter);
+
+        // Set removal of sensitive info
+        LogLine.omitSensitiveInfo = AppPref.getBoolean(AppPref.PrefKey.PREF_LOG_VIEWER_OMIT_SENSITIVE_INFO_BOOL);
+
+        storagePermission.request(granted -> {
+            if (granted) handleShortcuts(getIntent().getStringExtra("shortcut_action"));
+        });
+
+        mHandler = new Handler(Looper.getMainLooper());
+
+        fab.setOnClickListener(v -> {
+            // Stop recording
+            ServiceHelper.stopBackgroundServiceIfRunning(LogViewerActivity.this);
+        });
+
+        recyclerView = findViewById(R.id.list);
+        recyclerView.setLayoutManager(new LinearLayoutManager(this));
+        recyclerView.setItemAnimator(null);
+        new FastScrollerBuilder(recyclerView).useMd2Style().build();
+
+        mCollapsedMode = !AppPref.getBoolean(AppPref.PrefKey.PREF_LOG_VIEWER_EXPAND_BY_DEFAULT_BOOL);
+        mFilterPattern = AppPref.getString(AppPref.PrefKey.PREF_LOG_VIEWER_FILTER_PATTERN_STR);
+
+        Log.d(TAG, "Initial collapsed mode is " + mCollapsedMode);
+
+        setUpLogViewerAdapter();
+        // Grant read logs permission if not already
+        if (!PermissionUtils.hasPermission(this, Manifest.permission.READ_LOGS) && LocalServer.isAMServiceAlive()) {
+            new Thread(() -> {
+                try {
+                    PackageManagerCompat.grantPermission(getPackageName(), Manifest.permission.READ_LOGS, Users.getCurrentUserHandle());
+                } catch (RemoteException e) {
+                    Log.d(TAG, e.toString());
+                }
+            }).start();
+        }
+        // It doesn't matter whether the permission has been granted or not
+        startLog();
+    }
+
+    private void handleShortcuts(String action) {
+        if (action == null) return;
+        if ("record".equals(action)) {
+            String logFilename = SaveLogHelper.createLogFilename();
+            new Thread(() -> {
+                // Start recording logs
+                ServiceHelper.startBackgroundServiceIfNotAlreadyRunning(this, logFilename, "",
+                        AppPref.getInt(AppPref.PrefKey.PREF_LOG_VIEWER_DEFAULT_LOG_LEVEL_INT));
+                runOnUiThread(this::finish);
+            }).start();
+        }
+    }
+
+    @WorkerThread
+    private void addFiltersToSuggestions() {
+        for (LogFilter logFilter : AppManager.getDb().logFilterDao().getAll()) {
+            addToAutocompleteSuggestions(logFilter.name);
+        }
+    }
+
+    private void startLog() {
+        Intent intent = getIntent();
+        if (intent == null || !intent.hasExtra(INTENT_FILENAME)) {
+            startMainLog();
+        } else {
+            String filename = intent.getStringExtra(INTENT_FILENAME);
+            openLogFile(filename);
+        }
+        doAfterInitialMessage(getIntent());
+    }
+
+    private void doAfterInitialMessage(Intent intent) {
+        // Handle an intent that was sent from an external application
+        if (intent != null && ACTION_LAUNCH.equals(intent.getAction())) {
+            String filter = intent.getStringExtra(EXTRA_FILTER);
+            String level = intent.getStringExtra(EXTRA_LEVEL);
+            if (!TextUtils.isEmpty(filter)) {
+                setSearchText(filter);
+            }
+            if (!TextUtils.isEmpty(level)) {
+                int logLevelLimit = LogLine.convertCharToLogLevel(level.charAt(0));
+                if (logLevelLimit == -1) {
+                    String invalidLevel = getString(R.string.toast_invalid_level, level);
+                    Toast.makeText(this, invalidLevel, Toast.LENGTH_LONG).show();
+                } else {
+                    mLogListAdapter.setLogLevelLimit(logLevelLimit);
+                    logLevelChanged();
+                }
+            }
+        }
+    }
+
+    @Override
+    public void onResume() {
+        super.onResume();
+        if (mLogListAdapter != null && mLogListAdapter.getItemCount() > 0) {
+            // Scroll to bottom, since for some reason it always scrolls to the top, which is annoying
+            scrollToBottom();
+        }
+        boolean recordingInProgress = ServiceHelper.checkIfServiceIsRunning(getApplicationContext(), LogcatRecordingService.class);
+        if (fab != null) {
+            fab.setVisibility(recordingInProgress ? View.VISIBLE : View.GONE);
+        }
+    }
+
+    private void restartMainLog() {
+        mLogListAdapter.clear();
+        startMainLog();
+    }
+
+    @Override
+    protected void onNewIntent(Intent intent) {
+        super.onNewIntent(intent);
+        doAfterInitialMessage(intent);
+        // Launched from the widget or notification
+        if (intent != null && !ACTION_LAUNCH.equals(intent.getAction()) && intent.hasExtra(INTENT_FILENAME)) {
+            String filename = intent.getStringExtra(INTENT_FILENAME);
+            openLogFile(filename);
+        }
+    }
+
+    private void startMainLog() {
+        Runnable mainLogRunnable = () -> {
+            if (mLogListAdapter != null) {
+                mLogListAdapter.clear();
+            }
+            mTask = new LogReaderAsyncTask();
+            mTask.execute((Void) null);
+        };
+
+        if (mTask != null) {
+            // Do only after current log is depleted, to avoid splicing the streams together
+            // (Don't cross the streams!)
+            mTask.unpause();
+            mTask.setOnFinished(mainLogRunnable);
+            mTask.killReader();
+            mTask = null;
+        } else {
+            // No main log currently running; just start up the main log now
+            mainLogRunnable.run();
+        }
+    }
+
+    @Override
+    public void onPause() {
+        super.onPause();
+        cancelPartialSelect();
+    }
+
+    @Override
+    public void onDestroy() {
+        super.onDestroy();
+        if (mTask != null) {
+            mTask.killReader();
+            mTask.cancel(true);
+            mTask = null;
+        }
+    }
+
+    private void populateSuggestionsAdapter(String query) {
+        final MatrixCursor c = new MatrixCursor(new String[]{BaseColumns._ID, "suggestion"});
+        List<String> suggestionsForQuery = getSuggestionsForQuery(query);
+        for (int i = 0, suggestionsForQuerySize = suggestionsForQuery.size(); i < suggestionsForQuerySize; i++) {
+            String suggestion = suggestionsForQuery.get(i);
+            c.addRow(new Object[]{i, suggestion});
+        }
+        mSearchSuggestionsAdapter.changeCursor(c);
+    }
+
+    private List<String> getSuggestionsForQuery(String query) {
+        List<String> suggestions = new ArrayList<>(mSearchSuggestionsSet);
+        Collections.sort(suggestions, String.CASE_INSENSITIVE_ORDER);
+        List<String> actualSuggestions = new ArrayList<>();
+        if (query != null) {
+            for (String suggestion : suggestions) {
+                if (suggestion.toLowerCase().startsWith(query.toLowerCase())) {
+                    actualSuggestions.add(suggestion);
+                }
+            }
+        }
+        return actualSuggestions;
+    }
+
+    @Override
+    public void onBackPressed() {
+        if (!searchView.isIconified()) {
+            searchView.setIconified(true);
+        } else if (mCurrentlyOpenLog != null) {
+            startMainLog();
+        } else {
+            super.onBackPressed();
+        }
+    }
+
+    @Override
+    public boolean onPrepareOptionsMenu(Menu menu) {
+        boolean showingMainLog = (mTask != null);
+        MenuItem item = menu.findItem(R.id.menu_expand_all);
+        if (mCollapsedMode) {
+            item.setIcon(R.drawable.ic_expand_more_white_24dp);
+            item.setTitle(R.string.expand_all);
+        } else {
+            item.setIcon(R.drawable.ic_expand_less_white_24dp);
+            item.setTitle(R.string.collapse_all);
+        }
+
+        MenuItem clear = menu.findItem(R.id.menu_clear);
+        MenuItem pause = menu.findItem(R.id.menu_play_pause);
+        clear.setVisible(mCurrentlyOpenLog == null);
+        pause.setVisible(mCurrentlyOpenLog == null);
+
+        MenuItem saveLogMenuItem = menu.findItem(R.id.menu_save_log);
+        MenuItem saveAsLogMenuItem = menu.findItem(R.id.menu_save_as_log);
+
+        saveLogMenuItem.setEnabled(showingMainLog);
+        saveLogMenuItem.setVisible(showingMainLog);
+
+        saveAsLogMenuItem.setEnabled(!showingMainLog);
+        saveAsLogMenuItem.setVisible(!showingMainLog);
+
+        boolean recordingInProgress = ServiceHelper.checkIfServiceIsRunning(getApplicationContext(), LogcatRecordingService.class);
+
+        MenuItem recordMenuItem = menu.findItem(R.id.menu_record_log);
+
+        recordMenuItem.setEnabled(!recordingInProgress);
+        recordMenuItem.setVisible(!recordingInProgress);
+
+        MenuItem crazyLoggerMenuItem = menu.findItem(R.id.menu_crazy_logger_service);
+        crazyLoggerMenuItem.setEnabled(BuildConfig.DEBUG);
+        crazyLoggerMenuItem.setVisible(BuildConfig.DEBUG);
+
+        MenuItem partialSelectMenuItem = menu.findItem(R.id.menu_partial_select);
+        partialSelectMenuItem.setEnabled(!partialSelectMode);
+        partialSelectMenuItem.setVisible(!partialSelectMode);
+
+        return super.onPrepareOptionsMenu(menu);
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        getMenuInflater().inflate(R.menu.activity_logcat_actions, menu);
+        return super.onCreateOptionsMenu(menu);
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(MenuItem item) {
+        int itemId = item.getItemId();
+        if (itemId == R.id.menu_play_pause) {
+            pauseOrUnpause(item);
+            return true;
+        } else if (itemId == R.id.menu_expand_all) {
+            expandOrCollapseAll(true);
+            return true;
+        } else if (itemId == R.id.menu_clear) {
+            if (mLogListAdapter != null) {
+                mLogListAdapter.clear();
+            }
+            Snackbar.make(findViewById(android.R.id.content), R.string.log_cleared, Snackbar.LENGTH_LONG)
+                    .setAction(getString(R.string.undo), v -> startMainLog())
+                    .setActionTextColor(ContextCompat.getColor(this, R.color.colorAccent))
+                    .show();
+            return true;
+        } else if (itemId == R.id.menu_log_level) {
+            CharSequence[] logLevelsLocalised = getResources().getStringArray(R.array.log_levels);
+            int currentLogLevelIdx = LogViewerPreferences.LOG_LEVEL_VALUES.indexOf(mLogListAdapter.getLogLevelLimit());
+            new MaterialAlertDialogBuilder(this)
+                    .setTitle(R.string.log_level)
+                    .setSingleChoiceItems(logLevelsLocalised, currentLogLevelIdx, (dialog, which) ->
+                            mLogListAdapter.setLogLevelLimit(LogViewerPreferences.LOG_LEVEL_VALUES.get(which)))
+                    .setNegativeButton(R.string.close, null)
+                    .show();
+            return true;
+        } else if (itemId == R.id.menu_open_log) {
+            storagePermission.request(granted -> {
+                if (granted) displayOpenLogFileDialog();
+            });
+            return true;
+        } else if (itemId == R.id.menu_save_log || itemId == R.id.menu_save_as_log) {
+            storagePermission.request(granted -> {
+                if (granted) displaySaveLogDialog();
+            });
+            return true;
+        } else if (itemId == R.id.menu_record_log) {
+            storagePermission.request(granted -> {
+                if (granted) showRecordLogDialog();
+            });
+            return true;
+        } else if (itemId == R.id.menu_send_log_zip) {
+            storagePermission.request(granted -> {
+                if (granted) displaySaveDebugLogsDialog(true);
+            });
+            return true;
+        } else if (itemId == R.id.menu_save_log_zip) {
+            storagePermission.request(granted -> {
+                if (granted) displaySaveDebugLogsDialog(false);
+            });
+            return true;
+        } else if (itemId == android.R.id.home) {
+            onBackPressed();
+            return true;
+        } else if (itemId == R.id.menu_delete_saved_log) {
+            storagePermission.request(granted -> {
+                if (granted) displayDeleteSavedLogsDialog();
+            });
+            return true;
+        } else if (itemId == R.id.menu_settings) {
+            // TODO: 16/4/21 Navigate to the relevant preferences, set result if changes are made
+            Intent intent = new Intent(this, SettingsActivity.class);
+            activityLauncher.launch(intent, result -> {
+                // Preferences may have changed
+                mCollapsedMode = !AppPref.getBoolean(AppPref.PrefKey.PREF_LOG_VIEWER_EXPAND_BY_DEFAULT_BOOL);
+                if (result.getResultCode() == RESULT_OK) {
+                    Intent data = result.getData();
+                    if (data != null && data.getBooleanExtra("bufferChanged", false)
+                            && mCurrentlyOpenLog == null) {
+                        // Log buffer changed, so update list
+                        restartMainLog();
+                    }
+                }
+                mLogListAdapter.notifyDataSetChanged();
+                updateUiForFilename();
+            });
+            return true;
+        } else if (itemId == R.id.menu_crazy_logger_service) {
+            ServiceHelper.startOrStopCrazyLogger(this);
+            return true;
+        } else if (itemId == R.id.menu_partial_select) {
+            startPartialSelectMode();
+            return true;
+        } else if (itemId == R.id.menu_filters) {
+            showFiltersDialog();
+            return true;
+        }
+        return false;
+    }
+
+    @Override
+    public boolean onMenuItemClick(MenuItem item, LogLine logLine) {
+        if (logLine != null) {
+            switch (item.getItemId()) {
+                case CONTEXT_MENU_COPY_ID:
+                    ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
+                    clipboard.setPrimaryClip(ClipData.newPlainText(null, logLine.getOriginalLine()));
+                    Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show();
+                    return true;
+                case CONTEXT_MENU_FILTER_ID:
+                    if (logLine.getProcessId() == -1) {
+                        // invalid line
+                        return false;
+                    }
+                    showSearchByDialog(logLine);
+                    return true;
+            }
+        }
+        return false;
+    }
+
+    @Override
+    public void onClick(final View itemView, final LogLine logLine) {
+        if (partialSelectMode) {
+            logLine.setHighlighted(true);
+            partiallySelectedLogLines.add(logLine);
+
+            mHandler.post(() -> mLogListAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(itemView)));
+
+            if (partiallySelectedLogLines.size() == 2) {
+                storagePermission.request(granted -> {
+                    if (granted) completePartialSelect();
+                });
+            }
+        } else {
+            logLine.setExpanded(!logLine.isExpanded());
+            mLogListAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(itemView));
+        }
+    }
+
+    private void showSearchByDialog(final LogLine logLine) {
+        View view = getLayoutInflater().inflate(R.layout.dialog_searchby, null);
+        AlertDialog dialog = new MaterialAlertDialogBuilder(this)
+                .setTitle(R.string.filter_choice)
+                .setIcon(R.drawable.ic_search)
+                .setView(view)
+                .show();
+
+        LinearLayoutCompat tag = view.findViewById(R.id.dialog_searchby_tag_linear);
+        LinearLayoutCompat pid = view.findViewById(R.id.dialog_searchby_pid_linear);
+
+        TextView tagText = view.findViewById(R.id.dialog_searchby_tag_text);
+        TextView pidText = view.findViewById(R.id.dialog_searchby_pid_text);
+
+        tagText.setText(logLine.getTag());
+        pidText.setText(String.valueOf(logLine.getProcessId()));
+
+        tag.setOnClickListener(v -> {
+            String tagQuery = (logLine.getTag().contains(" ")) ? ('"' + logLine.getTag() + '"') : logLine.getTag();
+            setSearchText(SearchCriteria.TAG_KEYWORD + tagQuery);
+            dialog.dismiss();
+            //TODO: put the cursor at the end
+            /*searchEditText.setSelection(searchEditText.length());*/
+        });
+
+        pid.setOnClickListener(v -> {
+            setSearchText(SearchCriteria.PID_KEYWORD + logLine.getProcessId());
+            dialog.dismiss();
+            //TODO: put the cursor at the end
+            /*searchEditText.setSelection(searchEditText.length());*/
+        });
+    }
+
+    private void showRecordLogDialog() {
+        // start up the dialog-like activity
+        String[] suggestions = mSearchSuggestionsSet.toArray(new String[0]);
+
+        Intent intent = new Intent(LogViewerActivity.this, RecordLogDialogActivity.class);
+        intent.putExtra(RecordLogDialogActivity.EXTRA_QUERY_SUGGESTIONS, suggestions);
+
+        startActivity(intent);
+    }
+
+    private void showFiltersDialog() {
+        new Thread(() -> {
+            final List<LogFilter> filters = AppManager.getDb().logFilterDao().getAll();
+            Collections.sort(filters);
+            mHandler.post(() -> {
+                final LogFilterAdapter logFilterAdapter = new LogFilterAdapter(LogViewerActivity.this, filters);
+                ListView view = new ListView(LogViewerActivity.this);
+                view.setAdapter(logFilterAdapter);
+                view.setDivider(null);
+                view.setDividerHeight(0);
+                View footer = getLayoutInflater().inflate(R.layout.header_logcat_add_filter, view, false);
+                view.addFooterView(footer);
+
+                footer.setOnClickListener(v -> new TextInputDropdownDialogBuilder(this, R.string.text_filter_text)
+                        .setTitle(R.string.add_filter)
+                        .setDropdownItems(new ArrayList<>(mSearchSuggestionsSet), true)
+                        .setPositiveButton(android.R.string.ok, (dialog1, which, inputText, isChecked) ->
+                                handleNewFilterText(inputText == null ? "" : inputText.toString(), logFilterAdapter))
+                        .setNegativeButton(R.string.cancel, null)
+                        .show());
+
+                logFilterAdapter.setOnItemClickListener((parent, view1, position, logFilter) -> {
+                    setSearchText(logFilter.name);
+                });
+
+                new MaterialAlertDialogBuilder(LogViewerActivity.this)
+                        .setTitle(R.string.title_filters)
+                        .setView(view)
+                        .setNegativeButton(R.string.ok, null)
+                        .show();
+            });
+        }).start();
+    }
+
+    protected void handleNewFilterText(String text, final LogFilterAdapter logFilterAdapter) {
+        final String trimmed = text.trim();
+        if (!TextUtils.isEmpty(trimmed)) {
+            new Thread(() -> {
+                LogFilterDao dao = AppManager.getDb().logFilterDao();
+                long id = dao.insert(trimmed);
+                LogFilter logFilter = dao.get(id);
+                mHandler.post(() -> {
+                    if (logFilter != null) {
+                        logFilterAdapter.add(logFilter);
+                        logFilterAdapter.sort(LogFilter.COMPARATOR);
+                        logFilterAdapter.notifyDataSetChanged();
+
+                        addToAutocompleteSuggestions(trimmed);
+                    }
+                });
+            }).start();
+        }
+    }
+
+    private void startPartialSelectMode() {
+        boolean hideHelp = PreferenceHelper.getHidePartialSelectHelpPreference(this);
+
+        if (hideHelp) {
+            partialSelectMode = true;
+            partiallySelectedLogLines.clear();
+            Toast.makeText(this, R.string.toast_started_select_partial, Toast.LENGTH_SHORT).show();
+        } else {
+            LayoutInflater inflater = (LayoutInflater) getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+            @SuppressLint("InflateParams") View helpView = inflater.inflate(R.layout.dialog_partial_save_help, null);
+            // don't show the scroll bar
+            helpView.setVerticalScrollBarEnabled(false);
+            helpView.setHorizontalScrollBarEnabled(false);
+            final CheckBox checkBox = helpView.findViewById(android.R.id.checkbox);
+
+            new MaterialAlertDialogBuilder(this)
+                    .setTitle(R.string.menu_title_partial_select)
+                    .setView(helpView)
+                    .setPositiveButton(R.string.ok, (dialog, which) -> {
+                        partialSelectMode = true;
+                        partiallySelectedLogLines.clear();
+                        Toast.makeText(LogViewerActivity.this, R.string.toast_started_select_partial, Toast.LENGTH_SHORT).show();
+                        if (checkBox.isChecked()) {
+                            // hide this help dialog in the future
+                            PreferenceHelper.setHidePartialSelectHelpPreference(LogViewerActivity.this, true);
+                        }
+                    })
+                    .setNegativeButton(R.string.cancel, null)
+                    .show();
+        }
+    }
+
+    private void expandOrCollapseAll(boolean change) {
+        mCollapsedMode = change != mCollapsedMode;
+        int oldFirstVisibleItem = ((LinearLayoutManager) recyclerView.getLayoutManager()).findFirstVisibleItemPosition();
+        for (LogLine logLine : mLogListAdapter.getTrueValues()) {
+            if (logLine != null) {
+                logLine.setExpanded(!mCollapsedMode);
+            }
+        }
+        mLogListAdapter.notifyDataSetChanged();
+        // Ensure that we either stay autoscrolling at the bottom of the list...
+        if (mAutoscrollToBottom) {
+            scrollToBottom();
+            // ... or that whatever was the previous first visible item is still the current first
+            // visible item after expanding/collapsing
+        } else if (oldFirstVisibleItem != -1) {
+            recyclerView.scrollToPosition(oldFirstVisibleItem);
+        }
+        supportInvalidateOptionsMenu();
+    }
+
+    private void displayDeleteSavedLogsDialog() {
+        List<File> logFiles = SaveLogHelper.getLogFiles();
+        if (logFiles.isEmpty()) {
+            Toast.makeText(this, R.string.no_saved_logs, Toast.LENGTH_SHORT).show();
+            return;
+        }
+        CharSequence[] filenameArray = SaveLogHelper.getFormattedFilenames(this, logFiles);
+        new SearchableMultiChoiceDialogBuilder<>(this, logFiles, filenameArray)
+                .setTitle(R.string.manage_saved_logs)
+                .setPositiveButton(R.string.delete, (dialog, which, selectedFiles) -> confirmDelete(selectedFiles))
+                .setNegativeButton(R.string.cancel, null)
+                .setNeutralButton(R.string.delete_all, (dialog, which, selectedFiles) -> confirmDelete(logFiles))
+                .show();
+    }
+
+    private void confirmDelete(@NonNull final List<File> selectedFiles) {
+        final int deleteCount = selectedFiles.size();
+        if (deleteCount > 0) {
+            new MaterialAlertDialogBuilder(this)
+                    .setTitle(R.string.delete_saved_log)
+                    .setCancelable(true)
+                    .setMessage(getResources().getQuantityString(R.plurals.are_you_sure, deleteCount, deleteCount))
+                    .setPositiveButton(android.R.string.ok, (dialog, which) -> {
+                        for (File selectedFile : selectedFiles) {
+                            SaveLogHelper.deleteLogIfExists(selectedFile.getName());
+                        }
+                        UIUtils.displayShortToast(R.string.deleted_successfully);
+                    })
+                    .setNegativeButton(android.R.string.cancel, null)
+                    .show();
+        }
+    }
+
+    private void displaySaveDebugLogsDialog(boolean share) {
+        View includeDeviceInfoView = getLayoutInflater().inflate(R.layout.dialog_send_log, null, false);
+        final CheckBox includeDeviceInfoCheckBox = includeDeviceInfoView.findViewById(android.R.id.checkbox);
+
+        // allow user to choose whether or not to include device info in report, use preferences for persistence
+        includeDeviceInfoCheckBox.setChecked(PreferenceHelper.getIncludeDeviceInfoPreference(this));
+        includeDeviceInfoCheckBox.setOnCheckedChangeListener((buttonView, isChecked) -> PreferenceHelper.setIncludeDeviceInfoPreference(LogViewerActivity.this, isChecked));
+
+        final CheckBox includeDmesgCheckBox = includeDeviceInfoView.findViewById(R.id.checkbox_dmesg);
+
+        // allow user to choose whether or not to include device info in report, use preferences for persistence
+        includeDmesgCheckBox.setChecked(PreferenceHelper.getIncludeDmesgPreference(this));
+        includeDmesgCheckBox.setOnCheckedChangeListener((buttonView, isChecked) -> PreferenceHelper.setIncludeDmesgPreference(LogViewerActivity.this, isChecked));
+
+        new MaterialAlertDialogBuilder(LogViewerActivity.this)
+                .setTitle(share ? R.string.share : R.string.save_log_zip)
+                .setView(includeDeviceInfoView)
+                .setPositiveButton(R.string.ok, (dialog, which) -> {
+                    boolean includeDeviceInfo = includeDeviceInfoCheckBox.isChecked();
+                    boolean includeDmesg = includeDmesgCheckBox.isChecked();
+                    if (share) {
+                        sendLogToTargetApp(includeDeviceInfo, includeDmesg);
+                    } else {
+                        saveLogToTargetApp(includeDeviceInfo, includeDmesg);
+                    }
+                })
+                .setNegativeButton(R.string.cancel, null)
+                .show();
+    }
+
+    protected void sendLogToTargetApp(final boolean includeDeviceInfo, final boolean includeDmesg) {
+        AlertDialog dialog;
+        if (mCurrentlyOpenLog == null || includeDeviceInfo || includeDmesg) {
+            dialog = UIUtils.getProgressDialog(LogViewerActivity.this, R.string.dialog_compiling_log);
+            dialog.setCanceledOnTouchOutside(false);
+            dialog.setCancelable(false);
+        } else dialog = null;
+        new Thread(() -> {
+            SendLogDetails sendLogDetails = getSendLogDetails(includeDeviceInfo, includeDmesg);
+            runOnUiThread(() -> {
+                startChooser(LogViewerActivity.this, sendLogDetails.getSubject(), sendLogDetails.getBody(),
+                        sendLogDetails.getAttachmentType(), sendLogDetails.getAttachment());
+                if (dialog != null && dialog.isShowing()) {
+                    dialog.dismiss();
+                }
+            });
+        }).start();
+
+    }
+
+    @UiThread
+    private void saveLogToTargetApp(final boolean includeDeviceInfo, final boolean includeDmesg) {
+        AlertDialog dialog;
+        if (mCurrentlyOpenLog == null || includeDeviceInfo || includeDmesg) {
+            dialog = UIUtils.getProgressDialog(LogViewerActivity.this, R.string.dialog_compiling_log);
+            dialog.setCanceledOnTouchOutside(false);
+            dialog.setCancelable(false);
+        } else dialog = null;
+        new Thread(() -> {
+            List<File> files = saveLogDetails(includeDeviceInfo, includeDmesg);
+            if (isDestroyed()) return;
+            runOnUiThread(() -> {
+                if (dialog != null && dialog.isShowing()) {
+                    dialog.dismiss();
+                }
+                saveLauncher.launch(SaveLogHelper.createZipFilename(true), uri -> {
+                    if (uri == null) return;
+                    new Thread(() -> {
+                        try {
+                            SaveLogHelper.saveZipFileAndThrow(this, uri, files);
+                            if (isDestroyed()) return;
+                            runOnUiThread(() -> UIUtils.displayShortToast(R.string.saved_successfully));
+                        } catch (IOException | RemoteException e) {
+                            if (isDestroyed()) return;
+                            runOnUiThread(() -> UIUtils.displayShortToast(R.string.saving_failed));
+                        }
+                    }).start();
+                });
+            });
+        }).start();
+
+    }
+
+    @NonNull
+    @WorkerThread
+    private SendLogDetails getSendLogDetails(boolean includeDeviceInfo, boolean includeDmesg) {
+        SendLogDetails sendLogDetails = new SendLogDetails();
+        List<File> files = saveLogDetails(includeDeviceInfo, includeDmesg);
+        sendLogDetails.setBody("");
+        sendLogDetails.setSubject(getString(R.string.subject_log_report));
+        // either zip up multiple files or just attach the one file
+        switch (files.size()) {
+            case 0: // no attachments
+                sendLogDetails.setAttachmentType(SendLogDetails.AttachmentType.None);
+                break;
+            case 1: // one plaintext file attachment
+                sendLogDetails.setAttachmentType(SendLogDetails.AttachmentType.Text);
+                sendLogDetails.setAttachment(files.get(0));
+                break;
+            default: // 2 files - need to zip them up
+                File zipFile = SaveLogHelper.saveTemporaryZipFile(SaveLogHelper.createZipFilename(true), files);
+                sendLogDetails.setSubject(zipFile.getName());
+                sendLogDetails.setAttachmentType(SendLogDetails.AttachmentType.Zip);
+                sendLogDetails.setAttachment(zipFile);
+                break;
+        }
+        return sendLogDetails;
+    }
+
+    @NonNull
+    @WorkerThread
+    private List<File> saveLogDetails(boolean includeDeviceInfo, boolean includeDmesg) {
+        List<File> files = new ArrayList<>();
+        SaveLogHelper.cleanTemp();
+
+        if (mCurrentlyOpenLog != null) { // Use saved log file
+            files.add(SaveLogHelper.getFile(mCurrentlyOpenLog));
+        } else { // Create a temp file to hold the current, unsaved log
+            File tempLogFile = SaveLogHelper.saveTemporaryFile(SaveLogHelper.TEMP_LOG_FILENAME, null,
+                    getCurrentLogAsListOfStrings());
+            files.add(tempLogFile);
+        }
+
+        if (includeDeviceInfo) {
+            String deviceInfo = BuildHelper.getBuildInformationAsString();
+            File tempFile = SaveLogHelper.saveTemporaryFile(SaveLogHelper.TEMP_DEVICE_INFO_FILENAME, deviceInfo, null);
+            files.add(tempFile);
+        }
+
+        if (includeDmesg) {
+            File tempDmsgFile = SaveLogHelper.saveTemporaryFile(SaveLogHelper.TEMP_DMESG_FILENAME, null,
+                    Runner.runCommand(Runner.getRootInstance(), "dmesg").getOutputAsList());
+            files.add(tempDmsgFile);
+        }
+        return files;
+    }
+
+    @NonNull
+    private List<String> getCurrentLogAsListOfStrings() {
+        List<String> result = new ArrayList<>(mLogListAdapter.getItemCount());
+        for (int i = 0; i < mLogListAdapter.getItemCount(); i++) {
+            result.add(mLogListAdapter.getItem(i).getOriginalLine());
+        }
+        return result;
+    }
+
+    private void displaySaveLogDialog() {
+        new TextInputDialogBuilder(this, R.string.filename)
+                .setTitle(R.string.save_log)
+                .setInputText(SaveLogHelper.createLogFilename())
+                .setPositiveButton(R.string.ok, (dialog, which, inputText, isChecked) -> {
+                    if (SaveLogHelper.isInvalidFilename(inputText)) {
+                        Toast.makeText(LogViewerActivity.this, R.string.enter_good_filename, Toast.LENGTH_SHORT).show();
+                    } else {
+                        @SuppressWarnings("ConstantConditions")
+                        String filename = inputText.toString();
+                        saveLog(filename);
+                    }
+                })
+                .setNegativeButton(R.string.cancel, null)
+                .show();
+    }
+
+    private void savePartialLog(final String filename, LogLine first, LogLine last) {
+        final List<String> logLines = new ArrayList<>(mLogListAdapter.getItemCount());
+
+        // Filter based on first and last
+        boolean started = false;
+        boolean foundLast = false;
+        for (int i = 0; i < mLogListAdapter.getItemCount(); i++) {
+            LogLine logLine = mLogListAdapter.getItem(i);
+            if (logLine == first) {
+                started = true;
+            }
+            if (started) {
+                logLines.add(logLine.getOriginalLine());
+            }
+            if (logLine == last) {
+                foundLast = true;
+                break;
+            }
+        }
+
+        if (!foundLast || logLines.isEmpty()) {
+            Toast.makeText(this, R.string.toast_invalid_selection, Toast.LENGTH_LONG).show();
+            cancelPartialSelect();
+            return;
+        }
+
+        new Thread(() -> {
+            SaveLogHelper.deleteLogIfExists(filename);
+            final boolean saved = SaveLogHelper.saveLog(logLines, filename);
+            mHandler.post(() -> {
+                if (saved) {
+                    UIUtils.displayShortToast(R.string.saved_successfully);
+                    openLogFile(filename);
+                } else {
+                    Toast.makeText(getApplicationContext(), R.string.unable_to_save_log, Toast.LENGTH_LONG).show();
+                }
+                cancelPartialSelect();
+            });
+        }).start();
+    }
+
+    private void saveLog(final String filename) {
+        final List<String> logLines = getCurrentLogAsListOfStrings();
+
+        new Thread(() -> {
+            SaveLogHelper.deleteLogIfExists(filename);
+            final boolean saved = SaveLogHelper.saveLog(logLines, filename);
+            mHandler.post(() -> {
+                if (saved) {
+                    Toast.makeText(getApplicationContext(), R.string.log_saved, Toast.LENGTH_SHORT).show();
+                    openLogFile(filename);
+                } else {
+                    Toast.makeText(getApplicationContext(), R.string.unable_to_save_log, Toast.LENGTH_LONG).show();
+                }
+            });
+        }).start();
+
+    }
+
+    private void displayOpenLogFileDialog() {
+        List<File> logFiles = SaveLogHelper.getLogFiles();
+        if (logFiles.isEmpty()) {
+            Toast.makeText(this, R.string.no_saved_logs, Toast.LENGTH_SHORT).show();
+            return;
+        }
+        new MaterialAlertDialogBuilder(this)
+                .setTitle(R.string.open)
+                .setItems(SaveLogHelper.getFormattedFilenames(this, logFiles), (dialog, which) ->
+                        openLogFile(logFiles.get(which).getName()))
+                .setNegativeButton(R.string.close, null)
+                .show();
+    }
+
+    private void openLogFile(final String filename) {
+        @SuppressLint("StaticFieldLeak") final AsyncTask<Void, Void, List<LogLine>> openFileTask = new AsyncTask<Void, Void, List<LogLine>>() {
+
+            @Override
+            protected void onPreExecute() {
+                super.onPreExecute();
+                resetDisplayedLog(filename);
+
+                progressIndicator.hide();
+                progressIndicator.setIndeterminate(false);
+                progressIndicator.show();
+            }
+
+            @Override
+            protected List<LogLine> doInBackground(Void... params) {
+
+                // remove any lines at the beginning if necessary
+                final int maxLines = AppPref.getInt(AppPref.PrefKey.PREF_LOG_VIEWER_DISPLAY_LIMIT_INT);
+                SavedLog savedLog = SaveLogHelper.openLog(filename, maxLines);
+                List<String> lines = savedLog.getLogLines();
+                List<LogLine> logLines = new ArrayList<>();
+                for (int lineNumber = 0, linesSize = lines.size(); lineNumber < linesSize; lineNumber++) {
+                    String line = lines.get(lineNumber);
+                    logLines.add(LogLine.newLogLine(line, !mCollapsedMode, mFilterPattern));
+                    final int finalLineNumber = lineNumber;
+                    runOnUiThread(() -> progressIndicator.setProgressCompat(finalLineNumber * 100 / linesSize, true));
+                }
+
+                // notify the user if the saved file was truncated
+                if (savedLog.isTruncated()) {
+                    mHandler.post(() -> {
+                        String toastText = getResources().getQuantityString(R.plurals.toast_log_truncated, maxLines, maxLines);
+                        Toast.makeText(LogViewerActivity.this, toastText, Toast.LENGTH_LONG).show();
+                    });
+                }
+
+                return logLines;
+            }
+
+            @Override
+            protected void onPostExecute(List<LogLine> logLines) {
+                super.onPostExecute(logLines);
+                progressIndicator.hide();
+
+                for (LogLine logLine : logLines) {
+                    mLogListAdapter.addWithFilter(logLine, "", false);
+                    addToAutocompleteSuggestions(logLine);
+
+                }
+                mLogListAdapter.notifyDataSetChanged();
+
+                // scroll to bottom
+                scrollToBottom();
+            }
+        };
+
+        // if the main log task is running, we can only run AFTER it's been canceled
+
+        if (mTask != null) {
+            mTask.setOnFinished(() -> openFileTask.execute((Void) null));
+            mTask.unpause();
+            mTask.killReader();
+            mTask = null;
+        } else {
+            // main log not running; just open in this thread
+            openFileTask.execute((Void) null);
+        }
+    }
+
+    public void resetDisplayedLog(String filename) {
+        mLogListAdapter.clear();
+        mCurrentlyOpenLog = filename;
+        mCollapsedMode = !AppPref.getBoolean(AppPref.PrefKey.PREF_LOG_VIEWER_EXPAND_BY_DEFAULT_BOOL);
+        // Populate suggestions with existing filters (if any)
+        new Thread(this::addFiltersToSuggestions).start();
+        updateUiForFilename();
+        resetFilter();
+    }
+
+    private void updateUiForFilename() {
+        boolean logFileMode = mCurrentlyOpenLog != null;
+
+        //noinspection ConstantConditions
+        getSupportActionBar().setSubtitle(logFileMode ? mCurrentlyOpenLog : "");
+        getSupportActionBar().setDisplayHomeAsUpEnabled(logFileMode);
+        supportInvalidateOptionsMenu();
+    }
+
+    private void resetFilter() {
+        mLogListAdapter.setLogLevelLimit(AppPref.getInt(AppPref.PrefKey.PREF_LOG_VIEWER_DEFAULT_LOG_LEVEL_INT));
+        logLevelChanged();
+    }
+
+    private void setUpLogViewerAdapter() {
+        mLogListAdapter = new LogViewerRecyclerAdapter(this);
+        mLogListAdapter.setClickListener(this);
+        recyclerView.setAdapter(mLogListAdapter);
+        recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
+            @Override
+            public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
+                super.onScrollStateChanged(recyclerView, newState);
+            }
+
+            @Override
+            public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
+                super.onScrolled(recyclerView, dx, dy);
+                // Update what the first viewable item is
+                final LinearLayoutManager layoutManager = (LinearLayoutManager) recyclerView.getLayoutManager();
+                if (layoutManager != null) {
+                    // Stop autoscroll if the bottom of the list isn't visible anymore
+                    mAutoscrollToBottom = layoutManager.findLastCompletelyVisibleItemPosition() ==
+                            (mLogListAdapter.getItemCount() - 1);
+                }
+            }
+        });
+        recyclerView.setHasFixedSize(true);
+    }
+
+    private void completePartialSelect() {
+        new TextInputDialogBuilder(this, R.string.filename)
+                .setTitle(R.string.save_log)
+                .setInputText(SaveLogHelper.createLogFilename())
+                .setPositiveButton(R.string.ok, (dialog, which, inputText, isChecked) -> {
+                    if (SaveLogHelper.isInvalidFilename(inputText)) {
+                        cancelPartialSelect();
+                        Toast.makeText(LogViewerActivity.this, R.string.enter_good_filename, Toast.LENGTH_SHORT).show();
+                    } else {
+                        @SuppressWarnings("ConstantConditions")
+                        String filename = inputText.toString();
+                        if (partiallySelectedLogLines.size() == 2)
+                            savePartialLog(filename, partiallySelectedLogLines.get(0), partiallySelectedLogLines.get(1));
+                    }
+                })
+                .setNegativeButton(R.string.cancel, (dialog, which, inputText, isChecked) -> cancelPartialSelect())
+                .show();
+    }
+
+    private void cancelPartialSelect() {
+        partialSelectMode = false;
+        boolean changed = false;
+        for (LogLine logLine : partiallySelectedLogLines) {
+            if (logLine.isHighlighted()) {
+                logLine.setHighlighted(false);
+                changed = true;
+            }
+        }
+        partiallySelectedLogLines.clear();
+        if (changed) {
+            mHandler.post(mLogListAdapter::notifyDataSetChanged);
+        }
+    }
+
+    private void setSearchText(String text) {
+        // Sets the search text without invoking autosuggestions, which are really only useful when typing
+        mDynamicallyEnteringSearchText = true;
+        search(text);
+    }
+
+    private void search(String filterText) {
+        Filter filter = mLogListAdapter.getFilter();
+        filter.filter(filterText, this);
+        mSearchingString = filterText;
+        if (!TextUtils.isEmpty(mSearchingString)) {
+            mDynamicallyEnteringSearchText = true;
+            searchView.setIconified(false);
+            searchView.setQuery(mSearchingString, true);
+            searchView.clearFocus();
+        }
+    }
+
+    private void pauseOrUnpause(MenuItem item) {
+        LogReaderAsyncTask currentTask = mTask;
+        if (currentTask != null) {
+            if (currentTask.isPaused()) {
+                currentTask.unpause();
+                item.setIcon(R.drawable.ic_pause_white_24dp);
+            } else {
+                currentTask.pause();
+                item.setIcon(R.drawable.ic_play_arrow_white_24dp);
+            }
+        }
+    }
+
+    @Override
+    public void onFilterComplete(int count) {
+        // always scroll to the bottom when searching
+        recyclerView.scrollToPosition(count - 1);
+    }
+
+    private void logLevelChanged() {
+        search(mSearchingString);
+    }
+
+    private void addToAutocompleteSuggestions(@NonNull LogLine logLine) {
+        if (logLine.getTag() == null) return;
+        String tag = logLine.getTag().trim();
+        if (!TextUtils.isEmpty(tag)) {
+            addToAutocompleteSuggestions(tag);
+        }
+    }
+
+    private void addToAutocompleteSuggestions(String trimmed) {
+        if (mSearchSuggestionsSet.size() < MAX_NUM_SUGGESTIONS
+                && !mSearchSuggestionsSet.contains(trimmed)) {
+            mSearchSuggestionsSet.add(trimmed);
+            populateSuggestionsAdapter(mSearchingString);
+            //searchSuggestionsAdapter.add(trimmed);
+        }
+    }
+
+    private void scrollToBottom() {
+        recyclerView.scrollToPosition(mLogListAdapter.getItemCount() - 1);
+    }
+
+    @SuppressLint("StaticFieldLeak")
+    private class LogReaderAsyncTask extends AsyncTask<Void, LogLine, Void> {
+
+        private final Object mLock = new Object();
+        private int counter = 0;
+        private volatile boolean mPaused;
+        private boolean mFirstLineReceived;
+        private boolean mKilled;
+        private LogcatReader mReader;
+        private Runnable mOnFinishedRunnable;
+
+        @Override
+        protected void onPreExecute() {
+            super.onPreExecute();
+            resetDisplayedLog(null);
+
+            progressIndicator.hide();
+            progressIndicator.setIndeterminate(true);
+            progressIndicator.show();
+        }
+
+        @Override
+        protected Void doInBackground(Void... params) {
+            try {
+                // use "recordingMode" because we want to load all the existing lines at once
+                // for a performance boost
+                LogcatReaderLoader loader = LogcatReaderLoader.create(true);
+                mReader = loader.loadReader();
+
+                int maxLines = AppPref.getInt(AppPref.PrefKey.PREF_LOG_VIEWER_DISPLAY_LIMIT_INT);
+
+                String line;
+                LinkedList<LogLine> initialLines = new LinkedList<>();
+                while ((line = mReader.readLine()) != null && !isCancelled()) {
+                    if (mPaused) {
+                        synchronized (mLock) {
+                            if (mPaused) {
+                                mLock.wait();
+                            }
+                        }
+                    }
+                    LogLine logLine = LogLine.newLogLine(line, !mCollapsedMode, mFilterPattern);
+                    if (!mReader.readyToRecord()) {
+                        // "ready to record" in this case means all the initial lines have been flushed from the reader
+                        initialLines.add(logLine);
+                        if (initialLines.size() > maxLines) {
+                            initialLines.removeFirst();
+                        }
+                    } else if (!initialLines.isEmpty()) {
+                        // flush all the initial lines we've loaded
+                        initialLines.add(logLine);
+                        publishProgress(initialLines.toArray(new LogLine[0]));
+                        initialLines.clear();
+                    } else {
+                        // just proceed as normal
+                        publishProgress(logLine);
+                    }
+                }
+            } catch (Exception e) {
+                Log.e(TAG, e);
+            } finally {
+                killReader();
+            }
+            return null;
+        }
+
+        void killReader() {
+            if (!mKilled) {
+                synchronized (mLock) {
+                    if (!mKilled && mReader != null) {
+                        mReader.killQuietly();
+                        mKilled = true;
+                    }
+                }
+            }
+        }
+
+        @Override
+        protected void onPostExecute(Void result) {
+            super.onPostExecute(result);
+            doWhenFinished();
+        }
+
+        @Override
+        protected void onProgressUpdate(LogLine... values) {
+            super.onProgressUpdate(values);
+
+            if (!mFirstLineReceived) {
+                mFirstLineReceived = true;
+                progressIndicator.hide();
+            }
+            for (LogLine logLine : values) {
+                mLogListAdapter.addWithFilter(logLine, mSearchingString, false);
+
+                addToAutocompleteSuggestions(logLine);
+            }
+            mLogListAdapter.notifyDataSetChanged();
+
+            // How many logs to keep in memory, to avoid OutOfMemoryError
+            int maxNumLogLines = AppPref.getInt(AppPref.PrefKey.PREF_LOG_VIEWER_DISPLAY_LIMIT_INT);
+
+            // check to see if the list needs to be truncated to avoid out of memory errors
+            if (++counter % UPDATE_CHECK_INTERVAL == 0
+                    && mLogListAdapter.getTrueValues().size() > maxNumLogLines) {
+                int numItemsToRemove = mLogListAdapter.getTrueValues().size() - maxNumLogLines;
+                mLogListAdapter.removeFirst(numItemsToRemove);
+                Log.d(TAG, "Truncating " + numItemsToRemove + " lines from log list to avoid out of memory errors");
+            }
+
+            if (mAutoscrollToBottom) {
+                scrollToBottom();
+            }
+        }
+
+        private void doWhenFinished() {
+            if (mPaused) {
+                unpause();
+            }
+            if (mOnFinishedRunnable != null) {
+                mOnFinishedRunnable.run();
+            }
+        }
+
+        private void pause() {
+            synchronized (mLock) {
+                mPaused = true;
+            }
+        }
+
+        private void unpause() {
+            synchronized (mLock) {
+                mPaused = false;
+                mLock.notify();
+            }
+        }
+
+        private boolean isPaused() {
+            return mPaused;
+        }
+
+        private void setOnFinished(Runnable onFinished) {
+            this.mOnFinishedRunnable = onFinished;
+        }
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/LogViewerRecyclerAdapter.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/LogViewerRecyclerAdapter.java
new file mode 100644
index 00000000000..194e652c049
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/LogViewerRecyclerAdapter.java
@@ -0,0 +1,504 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ * Copyright (C) 2006 The Android Open Source Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat;
+
+import android.content.Context;
+import android.graphics.Color;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Filter;
+import android.widget.Filterable;
+import android.widget.PopupMenu;
+import android.widget.TextView;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.collection.SparseArrayCompat;
+import androidx.core.content.ContextCompat;
+import androidx.recyclerview.widget.RecyclerView;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import io.github.muntashirakon.AppManager.BuildConfig;
+import io.github.muntashirakon.AppManager.R;
+import io.github.muntashirakon.AppManager.logcat.struct.LogLine;
+import io.github.muntashirakon.AppManager.logcat.struct.SearchCriteria;
+import io.github.muntashirakon.AppManager.logs.Log;
+import io.github.muntashirakon.AppManager.utils.AppPref;
+import io.github.muntashirakon.AppManager.utils.UIUtils;
+
+public class LogViewerRecyclerAdapter extends RecyclerView.Adapter<LogViewerRecyclerAdapter.ViewHolder>
+        implements Filterable {
+    public static final String TAG = LogViewerRecyclerAdapter.class.getSimpleName();
+
+    private static final SparseArrayCompat<Integer> BACKGROUND_COLORS = new SparseArrayCompat<Integer>(7) {
+        {
+            put(android.util.Log.VERBOSE, R.color.the_brown_shirts);
+            put(android.util.Log.DEBUG, R.color.night_blue_shadow);
+            put(android.util.Log.INFO, R.color.blue_popsicle);
+            put(android.util.Log.WARN, R.color.red_orange);
+            put(android.util.Log.ERROR, R.color.pure_red);
+            put(android.util.Log.ASSERT, R.color.pure_red);
+            put(LogLine.LOG_FATAL, R.color.electric_red);
+        }
+    };
+
+    private static final SparseArrayCompat<Integer> FOREGROUND_COLORS = new SparseArrayCompat<Integer>(7) {
+        {
+            put(android.util.Log.VERBOSE, R.color.brian_wrinkle_white);
+            put(android.util.Log.DEBUG, R.color.brian_wrinkle_white);
+            put(android.util.Log.INFO, R.color.brian_wrinkle_white);
+            put(android.util.Log.WARN, R.color.brian_wrinkle_white);
+            put(android.util.Log.ERROR, R.color.brian_wrinkle_white);
+            put(android.util.Log.ASSERT, R.color.brian_wrinkle_white);
+            put(LogLine.LOG_FATAL, R.color.brian_wrinkle_white);
+        }
+    };
+
+    private static int[] tagColors;
+
+    private static int getBackgroundColorForLogLevel(Context context, int logLevel) {
+        Integer result = BACKGROUND_COLORS.get(logLevel);
+        if (result == null) result = UIUtils.getPrimaryColor(context);
+        return ContextCompat.getColor(context, result);
+    }
+
+    private static int getForegroundColorForLogLevel(Context context, int logLevel) {
+        Integer result = FOREGROUND_COLORS.get(logLevel);
+        if (result == null) result = UIUtils.getTextColorPrimary(context);
+        return ContextCompat.getColor(context, result);
+    }
+
+    private static synchronized int getOrCreateTagColor(Context context, String tag) {
+        if (tagColors == null) {
+            tagColors = context.getResources().getIntArray(R.array.random_colors);
+        }
+        // Ensure consistency
+        int hashCode = (tag == null) ? 0 : tag.hashCode();
+        int smear = Math.abs(hashCode) % tagColors.length;
+        return tagColors[smear];
+    }
+
+    /**
+     * Lock used to modify the content of {@link #mObjects}. Any write operation
+     * performed on the array should be synchronized on this lock. This lock is also
+     * used by the filter (see {@link #getFilter()} to make a synchronized copy of
+     * the original array of data.
+     */
+    private final Object mLock = new Object();
+    /**
+     * Contains the list of objects that represent the data of this ArrayAdapter.
+     * The content of this list is referred to as "the array" in the documentation.
+     */
+    @GuardedBy("mLock")
+    private List<LogLine> mObjects;
+
+    private ViewHolder.OnClickListener mClickListener;
+
+    private ArrayList<LogLine> mOriginalValues;
+    private ArrayFilter mFilter;
+
+    private int logLevelLimit = AppPref.getInt(AppPref.PrefKey.PREF_LOG_VIEWER_DEFAULT_LOG_LEVEL_INT);
+
+    private static int mColorTransparent;
+    private static int mColorSemiTransparent;
+    private static int mColorHighlight;
+
+    public LogViewerRecyclerAdapter(LogViewerActivity activity) {
+        mObjects = new ArrayList<>();
+
+        setHasStableIds(true);
+
+        mColorTransparent = Color.TRANSPARENT;
+        mColorSemiTransparent = ContextCompat.getColor(activity, R.color.semi_transparent);
+        mColorHighlight = ContextCompat.getColor(activity, R.color.highlight);
+    }
+
+
+    /**
+     * Adds the specified object at the end of the array.
+     *
+     * @param object The object to add at the end of the array.
+     */
+    @GuardedBy("mLock")
+    public void add(LogLine object, boolean notify) {
+        synchronized (mLock) {
+            if (mOriginalValues != null) {
+                mOriginalValues.add(object);
+            }
+            mObjects.add(object);
+            if (notify) {
+                notifyItemInserted(mObjects.size());
+            }
+        }
+    }
+
+
+    public void addWithFilter(LogLine object, CharSequence text, boolean notify) {
+        if (mOriginalValues != null) {
+            List<LogLine> inputList = Collections.singletonList(object);
+            if (mFilter == null) {
+                mFilter = new ArrayFilter();
+            }
+            List<LogLine> filteredObjects = mFilter.performFilteringOnList(inputList, text);
+            synchronized (mLock) {
+                mOriginalValues.add(object);
+                mObjects.addAll(filteredObjects);
+                if (notify) {
+                    notifyItemRangeInserted(mObjects.size() - filteredObjects.size(), filteredObjects.size());
+                }
+            }
+        } else {
+            synchronized (mLock) {
+                mObjects.add(object);
+                if (notify) {
+                    notifyItemInserted(mObjects.size());
+                }
+            }
+        }
+    }
+
+    /**
+     * Inserts the specified object at the specified index in the array.
+     *
+     * @param object The object to insert into the array.
+     * @param index  The index at which the object must be inserted.
+     */
+    @GuardedBy("mLock")
+    public void insert(LogLine object, int index) {
+        synchronized (mLock) {
+            if (mOriginalValues != null) {
+                mOriginalValues.add(index, object);
+            } else {
+                mObjects.add(index, object);
+            }
+            notifyDataSetChanged();
+        }
+    }
+
+    /**
+     * Removes the specified object from the array.
+     *
+     * @param object The object to remove.
+     */
+    @GuardedBy("mLock")
+    public void remove(LogLine object) {
+        synchronized (mLock) {
+            if (mOriginalValues != null) {
+                mOriginalValues.remove(object);
+            } else {
+                mObjects.remove(object);
+            }
+            notifyDataSetChanged();
+        }
+    }
+
+    public void removeFirst(int n) {
+        StopWatch stopWatch = new StopWatch("removeFirst()");
+        if (mOriginalValues != null) {
+            synchronized (mLock) {
+                List<LogLine> subList = mOriginalValues.subList(n, mOriginalValues.size());
+                for (int i = 0; i < n; i++) {
+                    mObjects.remove(mOriginalValues.get(i));
+                }
+                mOriginalValues = new ArrayList<>(subList);
+            }
+        } else {
+            synchronized (mLock) {
+                mObjects = new ArrayList<>(mObjects.subList(n, mObjects.size()));
+            }
+        }
+        notifyDataSetChanged();
+        stopWatch.log();
+    }
+
+    /**
+     * Remove all elements from the list.
+     */
+    @GuardedBy("mLock")
+    public void clear() {
+        synchronized (mLock) {
+            if (mOriginalValues != null) {
+                mOriginalValues.clear();
+            }
+            mObjects.clear();
+            notifyDataSetChanged();
+        }
+    }
+
+    @GuardedBy("mLock")
+    public LogLine getItem(int position) {
+        synchronized (mLock) {
+            return mObjects.get(position);
+        }
+    }
+
+    public List<LogLine> getTrueValues() {
+        return mOriginalValues != null ? mOriginalValues : mObjects;
+    }
+
+    @NonNull
+    @Override
+    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_logcat, parent, false);
+        return new ViewHolder(v, mClickListener);
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+        Context context = holder.itemView.getContext();
+        LogLine logLine;
+        try {
+            logLine = getItem(position);
+        } catch (IndexOutOfBoundsException ignore) {
+            // XXX hack - I sometimes get array index out of bounds exceptions here
+            // no idea how to solve it, so this is the best I can do
+            // FIXME
+            logLine = LogLine.newLogLine("",
+                    AppPref.getBoolean(AppPref.PrefKey.PREF_LOG_VIEWER_EXPAND_BY_DEFAULT_BOOL),
+                    AppPref.getString(AppPref.PrefKey.PREF_LOG_VIEWER_FILTER_PATTERN_STR));
+        }
+
+        holder.logLine = logLine;
+
+        int levelColor = getBackgroundColorForLogLevel(context, logLine.getLogLevel());
+        TextView t = holder.logLevel;
+        t.setText(logLine.getProcessIdText());
+        t.setBackgroundColor(levelColor);
+        t.setTextColor(getForegroundColorForLogLevel(context, logLine.getLogLevel()));
+        t.setVisibility(logLine.getLogLevel() == -1 ? View.GONE : View.VISIBLE);
+
+        View contentView = holder.itemView.findViewById(R.id.log_content);
+        contentView.setBackgroundColor(logLine.isHighlighted() ? mColorHighlight : (position % 2 == 0 ?
+                mColorSemiTransparent : mColorTransparent));
+
+        //OUTPUT TEXT VIEW
+        TextView output = holder.output;
+        output.setSingleLine(!logLine.isExpanded());
+        output.setText(logLine.getLogOutput());
+
+        //TAG TEXT VIEW
+        TextView tag = holder.tag;
+        tag.setSingleLine(!logLine.isExpanded());
+        tag.setText(logLine.getTag());
+        tag.setVisibility(logLine.getLogLevel() == -1 ? View.GONE : View.VISIBLE);
+
+        //EXPANDED INFO
+        boolean extraInfoIsVisible = logLine.isExpanded() && logLine.getProcessId() != -1 // -1 marks lines like 'beginning of /dev/log...'
+                && AppPref.getBoolean(AppPref.PrefKey.PREF_LOG_VIEWER_SHOW_PID_TID_TIMESTAMP_BOOL);
+
+        TextView pidText = holder.pid;
+        pidText.setVisibility(extraInfoIsVisible ? View.VISIBLE : View.GONE);
+        TextView timestampText = holder.itemView.findViewById(R.id.timestamp_text);
+        timestampText.setVisibility(extraInfoIsVisible ? View.VISIBLE : View.GONE);
+
+        if (extraInfoIsVisible) {
+            pidText.setText(logLine.getProcessId() != -1 ? Integer.toString(logLine.getProcessId()) : null);
+            timestampText.setText(logLine.getTimestamp());
+        }
+
+        tag.setTextColor(getOrCreateTagColor(context, logLine.getTag()));
+    }
+
+    @GuardedBy("mLock")
+    @Override
+    public long getItemId(int position) {
+        synchronized (mLock) {
+            return mObjects.get(position).getOriginalLine().hashCode();
+        }
+    }
+
+    @GuardedBy("mLock")
+    @Override
+    public int getItemCount() {
+        synchronized (mLock) {
+            return mObjects.size();
+        }
+    }
+
+    public int getLogLevelLimit() {
+        return logLevelLimit;
+    }
+
+
+    public void setLogLevelLimit(int logLevelLimit) {
+        this.logLevelLimit = logLevelLimit;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public Filter getFilter() {
+        if (mFilter == null) {
+            mFilter = new ArrayFilter();
+        }
+        return mFilter;
+    }
+
+    public List<LogLine> getObjects() {
+        return mObjects;
+    }
+
+    public void setClickListener(ViewHolder.OnClickListener clickListener) {
+        mClickListener = clickListener;
+    }
+
+    /**
+     * <p>An array filter constrains the content of the array adapter with
+     * a prefix. Each item that does not start with the supplied prefix
+     * is removed from the list.</p>
+     */
+    private class ArrayFilter extends Filter {
+        @NonNull
+        @Override
+        protected FilterResults performFiltering(CharSequence prefix) {
+            FilterResults results = new FilterResults();
+
+            if (mOriginalValues == null) {
+                synchronized (mLock) {
+                    mOriginalValues = new ArrayList<>(mObjects);
+                }
+            }
+
+            ArrayList<LogLine> allValues = performFilteringOnList(mOriginalValues, prefix);
+
+            results.values = allValues;
+            results.count = allValues.size();
+
+            return results;
+        }
+
+        public ArrayList<LogLine> performFilteringOnList(List<LogLine> inputList, CharSequence query) {
+            SearchCriteria searchCriteria = new SearchCriteria(query);
+
+            // search by log level
+            ArrayList<LogLine> allValues = new ArrayList<>();
+
+            ArrayList<LogLine> logLines;
+            synchronized (mLock) {
+                logLines = new ArrayList<>(inputList);
+            }
+
+            for (LogLine logLine : logLines) {
+                if (logLine != null && logLine.getLogLevel() >= logLevelLimit) {
+                    allValues.add(logLine);
+                }
+            }
+            ArrayList<LogLine> finalValues = allValues;
+
+            // search by criteria
+            if (!searchCriteria.isEmpty()) {
+                final int count = allValues.size();
+                final ArrayList<LogLine> newValues = new ArrayList<>(count);
+                for (final LogLine value : allValues) {
+                    // search the logline based on the criteria
+                    if (searchCriteria.matches(value)) {
+                        newValues.add(value);
+                    }
+                }
+                finalValues = newValues;
+            }
+            return finalValues;
+        }
+
+        @SuppressWarnings("unchecked")
+        @Override
+        protected void publishResults(CharSequence constraint, FilterResults results) {
+            synchronized (mLock) {
+                mObjects = (List<LogLine>) results.values;
+            }
+            notifyDataSetChanged();
+        }
+    }
+
+    private static class StopWatch {
+        private long startTime;
+        private String name;
+
+        public StopWatch(String name) {
+            if (BuildConfig.DEBUG) {
+                this.name = name;
+                this.startTime = System.currentTimeMillis();
+            }
+        }
+
+        public void log() {
+            Log.d(TAG, name + " took " + (System.currentTimeMillis() - startTime) + " ms");
+        }
+    }
+
+    public static class ViewHolder extends RecyclerView.ViewHolder implements PopupMenu.OnMenuItemClickListener,
+            View.OnClickListener, View.OnLongClickListener {
+        // id for context menu entry
+        public static final int CONTEXT_MENU_FILTER_ID = 0;
+        public static final int CONTEXT_MENU_COPY_ID = 1;
+
+        LogLine logLine;
+        TextView logLevel;
+        TextView tag;
+        TextView output;
+        TextView pid;
+
+        private final ViewHolder.OnClickListener clickListener;
+
+        public ViewHolder(View itemView, final ViewHolder.OnClickListener clickListener) {
+            super(itemView);
+            logLevel = itemView.findViewById(R.id.log_level_text);
+            tag = itemView.findViewById(R.id.tag_text);
+            output = itemView.findViewById(R.id.log_output_text);
+            pid = itemView.findViewById(R.id.pid_text);
+
+            this.clickListener = clickListener;
+
+            itemView.setOnClickListener(this);
+            itemView.setOnLongClickListener(this);
+        }
+
+        @Override
+        public void onClick(View v) {
+            clickListener.onClick(v, logLine);
+        }
+
+        @Override
+        public boolean onLongClick(View v) {
+            PopupMenu menu = new PopupMenu(v.getContext(), v);
+            menu.getMenu().add(0, CONTEXT_MENU_FILTER_ID, 0, R.string.filter_choice);
+            menu.getMenu().add(0, CONTEXT_MENU_COPY_ID, 0, R.string.copy_to_clipboard);
+            menu.setOnMenuItemClickListener(this);
+            menu.show();
+            return true;
+        }
+
+        @Override
+        public boolean onMenuItemClick(MenuItem item) {
+            return clickListener.onMenuItemClick(item, logLine);
+        }
+
+        public interface OnClickListener {
+            void onClick(View itemView, LogLine logLine);
+
+            boolean onMenuItemClick(MenuItem item, LogLine logLine);
+        }
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/LogcatRecordingService.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/LogcatRecordingService.java
new file mode 100644
index 00000000000..b2b934959f1
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/LogcatRecordingService.java
@@ -0,0 +1,236 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat;
+
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.widget.Toast;
+
+import androidx.core.app.NotificationCompat;
+
+import io.github.muntashirakon.AppManager.logcat.helper.SaveLogHelper;
+import io.github.muntashirakon.AppManager.logcat.helper.ServiceHelper;
+import io.github.muntashirakon.AppManager.logcat.helper.WidgetHelper;
+
+import java.io.IOException;
+import java.util.Random;
+
+import io.github.muntashirakon.AppManager.BuildConfig;
+import io.github.muntashirakon.AppManager.R;
+import io.github.muntashirakon.AppManager.logcat.reader.LogcatReader;
+import io.github.muntashirakon.AppManager.logcat.reader.LogcatReaderLoader;
+import io.github.muntashirakon.AppManager.logcat.struct.LogLine;
+import io.github.muntashirakon.AppManager.logcat.struct.SearchCriteria;
+import io.github.muntashirakon.AppManager.logs.Log;
+import io.github.muntashirakon.AppManager.types.ForegroundService;
+import io.github.muntashirakon.AppManager.utils.AppPref;
+
+/**
+ * Reads logs.
+ *
+ * @author nolan
+ */
+public class LogcatRecordingService extends ForegroundService {
+    public static final String TAG = LogcatRecordingService.class.getSimpleName();
+
+    public static final String URI_SCHEME = "catlog_recording_service";
+    public static final String EXTRA_FILENAME = "filename";
+    public static final String EXTRA_LOADER = "loader";
+    public static final String EXTRA_QUERY_FILTER = "filter";
+    public static final String EXTRA_LEVEL = "level";
+    private static final String ACTION_STOP_RECORDING = BuildConfig.APPLICATION_ID + ".action.STOP_RECORDING";
+    private final Object lock = new Object();
+    private LogcatReader mReader;
+    private boolean mKilled;
+    private Handler handler;
+    private final BroadcastReceiver receiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            // Received broadcast to kill service
+            killProcess();
+            ServiceHelper.stopBackgroundServiceIfRunning(context);
+        }
+    };
+
+    public LogcatRecordingService() {
+        super("AppTrackerService");
+    }
+
+    @Override
+    public void onCreate() {
+        super.onCreate();
+        IntentFilter intentFilter = new IntentFilter(ACTION_STOP_RECORDING);
+        intentFilter.addDataScheme(URI_SCHEME);
+        registerReceiver(receiver, intentFilter);
+        handler = new Handler(Looper.getMainLooper());
+    }
+
+
+    private void initializeReader(Intent intent) {
+        try {
+            // use the "time" log so we can see what time the logs were logged at
+            LogcatReaderLoader loader = intent.getParcelableExtra(EXTRA_LOADER);
+            mReader = loader.loadReader();
+            while (mReader != null && !mReader.readyToRecord() && !mKilled) {
+                mReader.readLine();
+                // Keep skipping lines until we find one that is past the last log line, i.e.
+                // it's ready to record
+            }
+            if (!mKilled) {
+                makeToast(R.string.log_recording_started, Toast.LENGTH_SHORT);
+            }
+        } catch (IOException e) {
+            Log.e(TAG, e);
+        }
+    }
+
+    @Override
+    public void onDestroy() {
+        super.onDestroy();
+        killProcess();
+        unregisterReceiver(receiver);
+        stopForeground(true);
+        WidgetHelper.updateWidgets(getApplicationContext(), false);
+    }
+
+    @Override
+    public int onStartCommand(Intent intent, int flags, int startId) {
+        // Update widget
+        WidgetHelper.updateWidgets(getApplicationContext());
+        CharSequence tickerText = getText(R.string.notification_ticker);
+        Intent stopRecordingIntent = new Intent();
+        stopRecordingIntent.setAction(ACTION_STOP_RECORDING);
+        // Have to make this unique for God knows what reason
+        stopRecordingIntent.setData(Uri.withAppendedPath(Uri.parse(URI_SCHEME + "://stop/"),
+                Long.toHexString(new Random().nextLong())));
+        PendingIntent pendingIntent = PendingIntent.getBroadcast(this, 0 /* no requestCode */,
+                stopRecordingIntent, PendingIntent.FLAG_ONE_SHOT);
+
+        final String CHANNEL_ID = "matlog_logging_channel";
+        // Set the icon, scrolling text and timestamp
+        NotificationCompat.Builder notification = new NotificationCompat.Builder(getApplicationContext(), CHANNEL_ID);
+        notification.setSmallIcon(R.drawable.ic_launcher_foreground);
+        notification.setTicker(tickerText);
+        notification.setWhen(System.currentTimeMillis());
+        notification.setContentTitle(getString(R.string.notification_title));
+        notification.setContentText(getString(R.string.notification_subtext));
+        notification.setContentIntent(pendingIntent);
+
+        NotificationManager manager = (NotificationManager) getApplicationContext().getSystemService(Context.NOTIFICATION_SERVICE);
+        // Fix Oreo notifications showing
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            final CharSequence name = getString(R.string.app_name);
+            final int importance = NotificationManager.IMPORTANCE_DEFAULT;
+            NotificationChannel channel = new NotificationChannel(CHANNEL_ID, name, importance);
+            manager.createNotificationChannel(channel);
+        }
+        startForeground(R.string.notification_title, notification.build());
+        return super.onStartCommand(intent, flags, startId);
+    }
+
+    protected void onHandleIntent(Intent intent) {
+        handleIntent(intent);
+    }
+
+    private void handleIntent(Intent intent) {
+        Log.d(TAG, "Starting with intent: " + intent);
+        String filename = intent.getStringExtra(EXTRA_FILENAME);
+        String queryText = intent.getStringExtra(EXTRA_QUERY_FILTER);
+        SearchCriteria searchCriteria = new SearchCriteria(queryText);
+        int logLevel = intent.getIntExtra(EXTRA_LEVEL, AppPref.getInt(AppPref.PrefKey.PREF_LOG_VIEWER_DEFAULT_LOG_LEVEL_INT));
+        boolean searchCriteriaWillAlwaysMatch = searchCriteria.isEmpty();
+        boolean logLevelAcceptsEverything = logLevel == android.util.Log.VERBOSE;
+        StringBuilder stringBuilder = new StringBuilder();
+
+        SaveLogHelper.deleteLogIfExists(filename);
+        initializeReader(intent);
+        try {
+            String line;
+            int lineCount = 0;
+            int logLinePeriod = AppPref.getInt(AppPref.PrefKey.PREF_LOG_VIEWER_WRITE_PERIOD_INT);
+            String filterPattern = AppPref.getString(AppPref.PrefKey.PREF_LOG_VIEWER_FILTER_PATTERN_STR);
+            while (mReader != null && (line = mReader.readLine()) != null && !mKilled) {
+                // filter
+                if (!searchCriteriaWillAlwaysMatch || !logLevelAcceptsEverything) {
+                    if (!checkLogLine(line, searchCriteria, logLevel, filterPattern)) {
+                        continue;
+                    }
+                }
+                stringBuilder.append(line).append("\n");
+                if (++lineCount % logLinePeriod == 0) {
+                    // avoid OutOfMemoryErrors; flush now
+                    SaveLogHelper.saveLog(stringBuilder, filename);
+                    stringBuilder.delete(0, stringBuilder.length()); // clear
+                }
+            }
+        } catch (IOException e) {
+            Log.e(TAG, e);
+        } finally {
+            killProcess();
+            Log.d(TAG, "Service ended");
+            boolean logSaved = SaveLogHelper.saveLog(stringBuilder, filename);
+            if (logSaved) {
+                makeToast(R.string.log_saved, Toast.LENGTH_SHORT);
+                startLogcatActivityToViewSavedFile(filename);
+            } else {
+                makeToast(R.string.unable_to_save_log, Toast.LENGTH_LONG);
+            }
+        }
+    }
+
+    private boolean checkLogLine(String line, SearchCriteria searchCriteria, int logLevel, String filterPattern) {
+        LogLine logLine = LogLine.newLogLine(line, false, filterPattern);
+        return logLine.getLogLevel() >= logLevel && searchCriteria.matches(logLine);
+    }
+
+
+    private void startLogcatActivityToViewSavedFile(String filename) {
+        // Start up the logcat activity if necessary and show the saved file
+        Intent targetIntent = new Intent(getApplicationContext(), LogViewerActivity.class);
+        targetIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
+        targetIntent.setAction(Intent.ACTION_MAIN);
+        targetIntent.putExtra("filename", filename);
+        startActivity(targetIntent);
+    }
+
+
+    private void makeToast(final int stringResId, final int toastLength) {
+        handler.post(() -> Toast.makeText(LogcatRecordingService.this, stringResId, toastLength).show());
+    }
+
+    private void killProcess() {
+        if (!mKilled) {
+            synchronized (lock) {
+                if (!mKilled && mReader != null) {
+                    // kill the logcat process
+                    mReader.killQuietly();
+                    mKilled = true;
+                }
+            }
+        }
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/RecordLogDialogActivity.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/RecordLogDialogActivity.java
new file mode 100644
index 00000000000..d970d528449
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/RecordLogDialogActivity.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat;
+
+import android.os.Bundle;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+public class RecordLogDialogActivity extends AppCompatActivity {
+    public static final String EXTRA_QUERY_SUGGESTIONS = "suggestions";
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+
+        final String[] suggestions = (getIntent() != null && getIntent().hasExtra(EXTRA_QUERY_SUGGESTIONS))
+                ? getIntent().getStringArrayExtra(EXTRA_QUERY_SUGGESTIONS) : new String[]{};
+
+        RecordLogDialogFragment dialogFragment = new RecordLogDialogFragment();
+        Bundle args = new Bundle();
+        args.putStringArray(RecordLogDialogFragment.QUERY_SUGGESTIONS, suggestions);
+        dialogFragment.setArguments(args);
+        dialogFragment.show(getSupportFragmentManager(), RecordLogDialogFragment.TAG);
+    }
+
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/RecordLogDialogFragment.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/RecordLogDialogFragment.java
new file mode 100644
index 00000000000..550ee005822
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/RecordLogDialogFragment.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat;
+
+import android.app.Dialog;
+import android.os.Bundle;
+import android.widget.Button;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.FragmentActivity;
+
+import io.github.muntashirakon.AppManager.logcat.helper.SaveLogHelper;
+import io.github.muntashirakon.AppManager.logcat.helper.ServiceHelper;
+import io.github.muntashirakon.AppManager.logcat.helper.WidgetHelper;
+
+import java.util.Arrays;
+import java.util.List;
+
+import io.github.muntashirakon.AppManager.R;
+import io.github.muntashirakon.AppManager.settings.LogViewerPreferences;
+import io.github.muntashirakon.AppManager.types.TextInputDialogBuilder;
+import io.github.muntashirakon.AppManager.types.TextInputDropdownDialogBuilder;
+import io.github.muntashirakon.AppManager.utils.AppPref;
+
+public class RecordLogDialogFragment extends DialogFragment {
+    public static final String TAG = "RecordLogDialogFragment";
+
+    public static final String QUERY_SUGGESTIONS = "suggestions";
+
+    private FragmentActivity activity;
+    private String filterQuery;
+    private int logLevel;
+
+    @Override
+    public void onResume() {
+        super.onResume();
+        requireDialog().setCancelable(false);
+        requireDialog().setCanceledOnTouchOutside(false);
+    }
+
+    @NonNull
+    @Override
+    public Dialog onCreateDialog(Bundle savedInstanceState) {
+        activity = requireActivity();
+        final List<String> suggestions = Arrays.asList(requireArguments().getStringArray(QUERY_SUGGESTIONS));
+        String logFilename = SaveLogHelper.createLogFilename();
+        logLevel = AppPref.getInt(AppPref.PrefKey.PREF_LOG_VIEWER_DEFAULT_LOG_LEVEL_INT);
+        filterQuery = "";
+        AlertDialog alertDialog = new TextInputDialogBuilder(activity, R.string.enter_filename)
+                .setTitle(R.string.record_log)
+                .setInputText(logFilename)
+                .setPositiveButton(R.string.ok, (dialog, which, inputText, isChecked) -> {
+                    if (SaveLogHelper.isInvalidFilename(inputText)) {
+                        Toast.makeText(activity, R.string.enter_good_filename, Toast.LENGTH_SHORT).show();
+                    } else {
+                        //noinspection ConstantConditions
+                        String filename = inputText.toString();
+                        new Thread(() -> ServiceHelper.startBackgroundServiceIfNotAlreadyRunning(activity, filename,
+                                filterQuery, logLevel)).start();
+                        dialog.dismiss();
+                        activity.finish();
+                    }
+                })
+                .setNegativeButton(R.string.cancel, (dialog, which, inputText, isChecked) -> {
+                    WidgetHelper.updateWidgets(activity);
+                    dialog.dismiss();
+                    activity.finish();
+                })
+                .setNeutralButton(R.string.text_filter_ellipsis, null)
+                .create();
+        alertDialog.setOnShowListener(dialog -> {
+            AlertDialog dialog1 = (AlertDialog) dialog;
+            Button filterButton = dialog1.getButton(AlertDialog.BUTTON_NEUTRAL);
+            filterButton.setOnClickListener(v -> {
+                WidgetHelper.updateWidgets(activity);
+                showFilterDialogForRecording(suggestions);
+            });
+        });
+        return alertDialog;
+    }
+
+    public void showFilterDialogForRecording(List<String> filterQuerySuggestions) {
+        List<CharSequence> logLevelsLocalised = Arrays.asList(getResources().getStringArray(R.array.log_levels));
+        int idx = LogViewerPreferences.LOG_LEVEL_VALUES.indexOf(logLevel);
+        TextInputDropdownDialogBuilder builder = new TextInputDropdownDialogBuilder(activity, R.string.text_filter_text)
+                .setTitle(R.string.filter)
+                .setInputText(filterQuery)
+                .setDropdownItems(filterQuerySuggestions, true)
+                .setAuxiliaryInput(getText(R.string.log_level), null, logLevelsLocalised.get(idx),
+                        logLevelsLocalised, true)
+                .setNegativeButton(R.string.cancel, null);
+        builder.setPositiveButton(R.string.ok, (dialog, which, inputText, isChecked) -> {
+            if (inputText == null || builder.getAuxiliaryInput() == null) return;
+            int logLevelIdx = logLevelsLocalised.indexOf(builder.getAuxiliaryInput().toString().trim());
+            if (logLevelIdx == -1) return;
+            logLevel = LogViewerPreferences.LOG_LEVEL_VALUES.get(logLevelIdx);
+            filterQuery = inputText.toString();
+        }).show();
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/RecordingWidgetProvider.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/RecordingWidgetProvider.java
new file mode 100644
index 00000000000..68f1cc249b0
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/RecordingWidgetProvider.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat;
+
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.Context;
+import android.content.Intent;
+
+import androidx.annotation.NonNull;
+
+import io.github.muntashirakon.AppManager.logcat.helper.PreferenceHelper;
+import io.github.muntashirakon.AppManager.logcat.helper.ServiceHelper;
+import io.github.muntashirakon.AppManager.logcat.helper.WidgetHelper;
+
+import java.util.Arrays;
+
+import io.github.muntashirakon.AppManager.BuildConfig;
+import io.github.muntashirakon.AppManager.logs.Log;
+
+public class RecordingWidgetProvider extends AppWidgetProvider {
+    public static final String TAG = RecordingWidgetProvider.class.getSimpleName();
+
+    public static final String ACTION_RECORD_OR_STOP = BuildConfig.APPLICATION_ID + ".action.RECORD_OR_STOP";
+
+    public static final String URI_SCHEME = "log_viewer_widget";
+
+    @Override
+    public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
+        super.onUpdate(context, appWidgetManager, appWidgetIds);
+        Log.d(TAG, "onUpdate() for appWidgetIds " + Arrays.toString(appWidgetIds));
+
+        // track which widgets were created, since there's a bug in the android system that lets
+        // stale app widget ids stick around
+        PreferenceHelper.setWidgetExistsPreference(context, appWidgetIds);
+
+        WidgetHelper.updateWidgets(context, appWidgetIds);
+    }
+
+    @Override
+    public void onReceive(@NonNull final Context context, @NonNull Intent intent) {
+        super.onReceive(context, intent);
+        Log.d(TAG, "onReceive called with intent " + intent);
+        if (ACTION_RECORD_OR_STOP.equals(intent.getAction())) {
+            // Start or stop recording as necessary
+            synchronized (RecordingWidgetProvider.class) {
+                boolean alreadyRunning = ServiceHelper.checkIfServiceIsRunning(context, LogcatRecordingService.class);
+                if (alreadyRunning) {
+                    // stop the current recording process
+                    ServiceHelper.stopBackgroundServiceIfRunning(context);
+                } else {
+                    // start a new recording process
+                    Intent targetIntent = new Intent();
+                    targetIntent.setClass(context, RecordLogDialogActivity.class);
+                    targetIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK
+                            | Intent.FLAG_ACTIVITY_MULTIPLE_TASK
+                            | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
+
+                    context.startActivity(targetIntent);
+                }
+            }
+        }
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/helper/BuildHelper.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/helper/BuildHelper.java
new file mode 100644
index 00000000000..87683f04f7d
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/helper/BuildHelper.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat.helper;
+
+import android.os.Build;
+
+import androidx.annotation.NonNull;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+public class BuildHelper {
+    // From android.os.Build
+    private static final List<String> BUILD_FIELDS = Arrays.asList(
+            "BOARD", "BOOTLOADER", "BRAND", "CPU_ABI", "CPU_ABI2",
+            "DEVICE", "DISPLAY", "FINGERPRINT", "HARDWARE", "HOST",
+            "ID", "MANUFACTURER", "MODEL", "PRODUCT", "RADIO",
+            "SERIAL", "TAGS", "TIME", "TYPE", "USER");
+
+    // From android.os.Build.Version
+    private static final List<String> BUILD_VERSION_FIELDS = Arrays.asList(
+            "CODENAME", "INCREMENTAL", "RELEASE", "SDK_INT");
+
+    @NonNull
+    public static String getBuildInformationAsString() {
+        SortedMap<String, String> keysToValues = new TreeMap<>();
+        for (String buildField : BUILD_FIELDS) {
+            putKeyValue(Build.class, buildField, keysToValues);
+        }
+        for (String buildVersionField : BUILD_VERSION_FIELDS) {
+            putKeyValue(Build.VERSION.class, buildVersionField, keysToValues);
+        }
+        StringBuilder stringBuilder = new StringBuilder();
+        for (Entry<String, String> entry : keysToValues.entrySet()) {
+            stringBuilder.append(entry.getKey()).append(": ").append(entry.getValue()).append('\n');
+        }
+        return stringBuilder.toString();
+    }
+
+    private static void putKeyValue(@NonNull Class<?> clazz, String buildField,
+                                    @NonNull SortedMap<String, String> keysToValues) {
+        try {
+            Field field = clazz.getField(buildField);
+            Object value = field.get(null);
+            String key = clazz.getSimpleName().toLowerCase() + "." + buildField.toLowerCase();
+            keysToValues.put(key, String.valueOf(value));
+        } catch (SecurityException | NoSuchFieldException | IllegalAccessException ignore) {
+        }
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/helper/LogcatHelper.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/helper/LogcatHelper.java
new file mode 100644
index 00000000000..a312b915861
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/helper/LogcatHelper.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat.helper;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import io.github.muntashirakon.AppManager.logs.Log;
+import io.github.muntashirakon.AppManager.servermanager.ProcessCompat;
+
+public class LogcatHelper {
+    public static final String TAG = LogcatHelper.class.getSimpleName();
+
+    @IntDef(value = {LOG_ID_MAIN, LOG_ID_RADIO, LOG_ID_EVENTS, LOG_ID_SYSTEM, LOG_ID_CRASH}, flag = true)
+    public @interface LogBufferId {
+    }
+
+    public static final int LOG_ID_MAIN = 1;
+    public static final int LOG_ID_RADIO = 1 << 1;
+    public static final int LOG_ID_EVENTS = 1 << 2;
+    public static final int LOG_ID_SYSTEM = 1 << 3;
+    public static final int LOG_ID_CRASH = 1 << 4;
+
+    public static final String BUFFER_MAIN = "main";
+    public static final String BUFFER_RADIO = "radio";
+    public static final String BUFFER_EVENTS = "events";
+    public static final String BUFFER_SYSTEM = "system";
+    public static final String BUFFER_CRASH = "crash";
+
+    public static Process getLogcatProcess(@LogBufferId int buffers) throws IOException {
+        return ProcessCompat.exec(getLogcatArgs(buffers, false));
+    }
+
+    @Nullable
+    public static String getLastLogLine(@LogBufferId int buffers) {
+        Process dumpLogcatProcess = null;
+        BufferedReader reader;
+        String result = null;
+        try {
+            dumpLogcatProcess = ProcessCompat.exec(getLogcatArgs(buffers, true));
+            reader = new BufferedReader(new InputStreamReader(dumpLogcatProcess
+                    .getInputStream()), 8192);
+
+            String line;
+            while ((line = reader.readLine()) != null) {
+                result = line;
+            }
+        } catch (IOException e) {
+            Log.e(TAG, e);
+        } finally {
+            if (dumpLogcatProcess != null) {
+                dumpLogcatProcess.destroy();
+                Log.d(TAG, "destroyed 1 dump logcat process");
+            }
+        }
+        return result;
+    }
+
+    @NonNull
+    private static String[] getLogcatArgs(@LogBufferId int buffers, boolean dumpAndExit) {
+        List<String> args = new ArrayList<>(Arrays.asList("logcat", "-v", "time"));
+        // For some reason, adding -b main excludes log output from AndroidRuntime runtime exceptions,
+        // whereas just leaving it blank keeps them in.  So do not specify the buffer if it is "main"
+        if ((buffers & LOG_ID_MAIN) != 0) {
+            args.add("-b");
+            args.add(BUFFER_MAIN);
+        }
+        if ((buffers & LOG_ID_RADIO) != 0) {
+            args.add("-b");
+            args.add(BUFFER_RADIO);
+        }
+        if ((buffers & LOG_ID_EVENTS) != 0) {
+            args.add("-b");
+            args.add(BUFFER_EVENTS);
+        }
+        if ((buffers & LOG_ID_SYSTEM) != 0) {
+            args.add("-b");
+            args.add(BUFFER_SYSTEM);
+        }
+        if ((buffers & LOG_ID_CRASH) != 0) {
+            args.add("-b");
+            args.add(BUFFER_CRASH);
+        }
+        if (dumpAndExit) args.add("-d");
+        return args.toArray(new String[0]);
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/helper/PreferenceHelper.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/helper/PreferenceHelper.java
new file mode 100644
index 00000000000..7b581c4216e
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/helper/PreferenceHelper.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat.helper;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
+import android.preference.PreferenceManager;
+
+import androidx.annotation.NonNull;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import io.github.muntashirakon.AppManager.R;
+import io.github.muntashirakon.AppManager.utils.AppPref;
+
+public class PreferenceHelper {
+    private static final String WIDGET_EXISTS_PREFIX = "widget_";
+
+    public static boolean getWidgetExistsPreference(Context context, int appWidgetId) {
+        SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context);
+        String widgetExists = WIDGET_EXISTS_PREFIX.concat(Integer.toString(appWidgetId));
+        return sharedPrefs.getBoolean(widgetExists, false);
+    }
+
+    public static void setWidgetExistsPreference(Context context, @NonNull int[] appWidgetIds) {
+        SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context);
+        Editor editor = sharedPrefs.edit();
+        for (int appWidgetId : appWidgetIds) {
+            String widgetExists = WIDGET_EXISTS_PREFIX.concat(Integer.toString(appWidgetId));
+            editor.putBoolean(widgetExists, true);
+        }
+        editor.apply();
+    }
+
+    public static boolean getHidePartialSelectHelpPreference(Context context) {
+        SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context);
+        return sharedPrefs.getBoolean(context.getText(R.string.pref_hide_partial_select_help).toString(), false);
+    }
+
+    public static void setHidePartialSelectHelpPreference(Context context, boolean bool) {
+        SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context);
+        Editor editor = sharedPrefs.edit();
+        editor.putBoolean(context.getString(R.string.pref_hide_partial_select_help), bool);
+        editor.apply();
+    }
+
+    @NonNull
+    public static List<Integer> getBuffers() {
+        @LogcatHelper.LogBufferId int buffers = AppPref.getInt(AppPref.PrefKey.PREF_LOG_VIEWER_BUFFER_INT);
+        return getBuffers(buffers);
+    }
+
+    @NonNull
+    public static List<Integer> getBuffers(@LogcatHelper.LogBufferId int buffers) {
+        List<Integer> separatedBuffers = new ArrayList<>();
+        if ((buffers & LogcatHelper.LOG_ID_MAIN) != 0) {
+            separatedBuffers.add(LogcatHelper.LOG_ID_MAIN);
+        }
+        if ((buffers & LogcatHelper.LOG_ID_RADIO) != 0) {
+            separatedBuffers.add(LogcatHelper.LOG_ID_RADIO);
+        }
+        if ((buffers & LogcatHelper.LOG_ID_EVENTS) != 0) {
+            separatedBuffers.add(LogcatHelper.LOG_ID_EVENTS);
+        }
+        if ((buffers & LogcatHelper.LOG_ID_SYSTEM) != 0) {
+            separatedBuffers.add(LogcatHelper.LOG_ID_SYSTEM);
+        }
+        if ((buffers & LogcatHelper.LOG_ID_CRASH) != 0) {
+            separatedBuffers.add(LogcatHelper.LOG_ID_CRASH);
+        }
+        return separatedBuffers;
+    }
+
+    public static boolean getIncludeDeviceInfoPreference(Context context) {
+        SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context);
+        return sharedPrefs.getBoolean(context.getString(R.string.pref_include_device_info), true);
+    }
+
+    public static void setIncludeDeviceInfoPreference(Context context, boolean value) {
+        SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context);
+        Editor editor = sharedPrefs.edit();
+        editor.putBoolean(context.getString(R.string.pref_include_device_info), value);
+        editor.apply();
+    }
+
+    public static boolean getIncludeDmesgPreference(Context context) {
+        SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context);
+        return sharedPrefs.getBoolean(context.getString(R.string.pref_include_dmesg), true);
+    }
+
+    public static void setIncludeDmesgPreference(Context context, boolean value) {
+        SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context);
+        Editor editor = sharedPrefs.edit();
+        editor.putBoolean(context.getString(R.string.pref_include_dmesg), value);
+        editor.apply();
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/helper/SaveLogHelper.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/helper/SaveLogHelper.java
new file mode 100644
index 00000000000..21cbd1c54a0
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/helper/SaveLogHelper.java
@@ -0,0 +1,297 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat.helper;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.RemoteException;
+import android.text.SpannableStringBuilder;
+import android.text.TextUtils;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.PrintStream;
+import java.text.DateFormat;
+import java.text.DecimalFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import io.github.muntashirakon.AppManager.backup.BackupFiles;
+import io.github.muntashirakon.AppManager.logcat.struct.SavedLog;
+import io.github.muntashirakon.AppManager.logs.Log;
+import io.github.muntashirakon.AppManager.utils.AppPref;
+import io.github.muntashirakon.AppManager.utils.IOUtils;
+import io.github.muntashirakon.AppManager.utils.UIUtils;
+import io.github.muntashirakon.io.ProxyFile;
+import io.github.muntashirakon.io.ProxyInputStream;
+import io.github.muntashirakon.io.ProxyOutputStream;
+
+public class SaveLogHelper {
+    public static final String TAG = SaveLogHelper.class.getSimpleName();
+
+    public static final String TEMP_DEVICE_INFO_FILENAME = "device_info.txt";
+    public static final String TEMP_LOG_FILENAME = "logcat.log";
+    public static final String TEMP_DMESG_FILENAME = "dmesg.txt";
+    private static final String TEMP_ZIP_FILENAME = "logs";
+    private static final int BUFFER = 0x1000; // 4K
+    private static final String SAVED_LOGS_DIR = "saved_logs";
+
+    @Nullable
+    public static File saveTemporaryFile(String filename, CharSequence text, List<String> lines) {
+        File tempFile = new ProxyFile(getTempDirectory(), filename);
+        try (PrintStream out = new PrintStream(new BufferedOutputStream(new ProxyOutputStream(tempFile), BUFFER))) {
+            if (text != null) { // one big string
+                out.print(text);
+            } else { // multiple lines separated by newline
+                for (CharSequence line : lines) {
+                    out.println(line);
+                }
+            }
+            Log.d(TAG, "Saved temp file: " + tempFile);
+            return tempFile;
+        } catch (FileNotFoundException | RemoteException e) {
+            Log.e(TAG, e);
+            return null;
+        }
+    }
+
+    @NonNull
+    public static File getFile(@NonNull String filename) {
+        return new ProxyFile(getSavedLogsDirectory(), filename);
+    }
+
+    public static void deleteLogIfExists(String filename) {
+        File file = new ProxyFile(getSavedLogsDirectory(), filename);
+        if (file.exists()) {
+            file.delete();
+        }
+    }
+
+    @NonNull
+    public static CharSequence[] getFormattedFilenames(@NonNull Context context, @NonNull List<File> files) {
+        CharSequence[] fileNames = new CharSequence[files.size()];
+        DateFormat dateFormat = DateFormat.getDateTimeInstance();
+        for (int i =0; i < files.size(); ++i) {
+            fileNames[i] = new SpannableStringBuilder(UIUtils.getPrimaryText(context, files.get(i).getName()))
+                    .append("\n").append(UIUtils.getSmallerText(UIUtils.getSecondaryText(context,
+                            dateFormat.format(new Date(files.get(i).lastModified())))));
+        }
+        return fileNames;
+    }
+
+    @NonNull
+    public static List<File> getLogFiles() {
+        File logsDirectory = getSavedLogsDirectory();
+        File[] filesArray = logsDirectory.listFiles();
+        if (filesArray == null) {
+            return Collections.emptyList();
+        }
+        List<File> files = new ArrayList<>(Arrays.asList(filesArray));
+        Collections.sort(files, (o1, o2) -> Long.compare(o2.lastModified(), o1.lastModified()));
+        return files;
+    }
+
+    @NonNull
+    public static SavedLog openLog(@NonNull String filename, int maxLines) {
+        File logFile = new ProxyFile(getSavedLogsDirectory(), filename);
+        LinkedList<String> logLines = new LinkedList<>();
+        boolean truncated = false;
+        try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(new ProxyInputStream(logFile)), BUFFER)) {
+            while (bufferedReader.ready()) {
+                logLines.add(bufferedReader.readLine());
+                if (logLines.size() > maxLines) {
+                    logLines.removeFirst();
+                    truncated = true;
+                }
+            }
+        } catch (IOException | RemoteException e) {
+            Log.e(TAG, e);
+        }
+        return new SavedLog(logLines, truncated);
+    }
+
+    public static synchronized boolean saveLog(CharSequence logString, String filename) {
+        return saveLog(null, logString, filename);
+    }
+
+    public static synchronized boolean saveLog(List<String> logLines, String filename) {
+        return saveLog(logLines, null, filename);
+    }
+
+    private static boolean saveLog(List<String> logLines, CharSequence logString, String filename) {
+        File newFile = new ProxyFile(getSavedLogsDirectory(), filename);
+        try {
+            if (!newFile.exists()) {
+                newFile.createNewFile();
+            }
+        } catch (IOException e) {
+            Log.e(TAG, e);
+            return false;
+        }
+        try (PrintStream out = new PrintStream(new BufferedOutputStream(new ProxyOutputStream(newFile), BUFFER))) {
+            // Save a log as either a list of strings
+            if (logLines != null) {
+                for (CharSequence line : logLines) {
+                    out.println(line);
+                }
+            } else if (logString != null) {
+                out.print(logString);
+            }
+        } catch (FileNotFoundException | RemoteException e) {
+            Log.e(TAG, e);
+            return false;
+        }
+        return true;
+    }
+
+    @NonNull
+    public static File getTempDirectory() {
+        File tmpDir = BackupFiles.getTemporaryDirectory();
+        if (!tmpDir.exists()) {
+            tmpDir.mkdir();
+        }
+        return tmpDir;
+    }
+
+    @NonNull
+    private static File getSavedLogsDirectory() {
+        File savedLogsDir = new ProxyFile(getAMDirectory(), SAVED_LOGS_DIR);
+        if (!savedLogsDir.exists()) {
+            savedLogsDir.mkdir();
+        }
+        return savedLogsDir;
+    }
+
+    @NonNull
+    private static File getAMDirectory() {
+        File amDir = AppPref.getAppManagerDirectory();
+        if (!amDir.exists()) {
+            amDir.mkdir();
+        }
+        return amDir;
+    }
+
+    @Nullable
+    public static File saveTemporaryZipFile(String filename, List<File> files) {
+        try {
+            return saveZipFileAndThrow(getTempDirectory(), filename, files);
+        } catch (IOException | RemoteException e) {
+            Log.e(TAG, e);
+        }
+        return null;
+    }
+
+    @NonNull
+    private static File saveZipFileAndThrow(@NonNull File dir, @NonNull String filename, @NonNull List<File> files)
+            throws IOException, RemoteException {
+        File zipFile = new ProxyFile(dir, filename);
+        try (ZipOutputStream output = new ZipOutputStream(new BufferedOutputStream(new ProxyOutputStream(zipFile), BUFFER))) {
+            for (File file : files) {
+                ProxyInputStream fi = new ProxyInputStream(file);
+                try (BufferedInputStream input = new BufferedInputStream(fi, BUFFER)) {
+                    ZipEntry entry = new ZipEntry(file.getName());
+                    output.putNextEntry(entry);
+                    IOUtils.copy(input, output);
+                }
+            }
+        }
+        return zipFile;
+    }
+
+    public static void saveZipFileAndThrow(@NonNull Context context, @NonNull Uri uri, @NonNull List<File> files)
+            throws IOException, RemoteException {
+        OutputStream os = context.getContentResolver().openOutputStream(uri);
+        if (os == null) throw new IOException("Could not open uri.");
+        try (ZipOutputStream output = new ZipOutputStream(new BufferedOutputStream(os, BUFFER))) {
+            for (File file : files) {
+                ProxyInputStream fi = new ProxyInputStream(file);
+                try (BufferedInputStream input = new BufferedInputStream(fi, BUFFER)) {
+                    ZipEntry entry = new ZipEntry(file.getName());
+                    output.putNextEntry(entry);
+                    IOUtils.copy(input, output);
+                }
+            }
+        }
+    }
+
+    @NonNull
+    public static String createZipFilename(boolean withDate) {
+        return createLogFilename(TEMP_ZIP_FILENAME, ".zip", withDate);
+    }
+
+    @NonNull
+    public static String createLogFilename() {
+        return createLogFilename(null, ".log", true);
+    }
+
+    @NonNull
+    private static String createLogFilename(@Nullable String prefix, @NonNull String extension, boolean withDate) {
+        if (withDate) {
+            Date date = new Date();
+            GregorianCalendar calendar = new GregorianCalendar();
+            calendar.setTime(date);
+
+            DecimalFormat twoDigitDecimalFormat = new DecimalFormat("00");
+            DecimalFormat fourDigitDecimalFormat = new DecimalFormat("0000");
+
+            String year = fourDigitDecimalFormat.format(calendar.get(Calendar.YEAR));
+            String month = twoDigitDecimalFormat.format(calendar.get(Calendar.MONTH) + 1);
+            String day = twoDigitDecimalFormat.format(calendar.get(Calendar.DAY_OF_MONTH));
+            String hour = twoDigitDecimalFormat.format(calendar.get(Calendar.HOUR_OF_DAY));
+            String minute = twoDigitDecimalFormat.format(calendar.get(Calendar.MINUTE));
+            String second = twoDigitDecimalFormat.format(calendar.get(Calendar.SECOND));
+
+            return (prefix == null ? "" : prefix + "-") + year + "-" + month + "-" + day + "-" + hour + "-" + minute
+                    + "-" + second + extension;
+        } else return prefix + extension;
+    }
+
+    public static boolean isInvalidFilename(CharSequence filename) {
+        String filenameAsString;
+        return TextUtils.isEmpty(filename)
+                || (filenameAsString = filename.toString()).contains("/")
+                || filenameAsString.contains(":")
+                || filenameAsString.contains(" ")
+                || !filenameAsString.endsWith(".log");
+    }
+
+    public static void cleanTemp() {
+        File[] files = getTempDirectory().listFiles();
+        if (files == null) return;
+        for (File file : files) {
+            file.delete();
+        }
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/helper/ServiceHelper.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/helper/ServiceHelper.java
new file mode 100644
index 00000000000..9082ceebec8
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/helper/ServiceHelper.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat.helper;
+
+import android.app.ActivityManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+
+import java.util.List;
+
+import io.github.muntashirakon.AppManager.logcat.CrazyLoggerService;
+import io.github.muntashirakon.AppManager.logcat.LogcatRecordingService;
+import io.github.muntashirakon.AppManager.logcat.reader.LogcatReaderLoader;
+import io.github.muntashirakon.AppManager.logs.Log;
+
+public class ServiceHelper {
+    public static final String TAG = ServiceHelper.class.getSimpleName();
+
+    public static void startOrStopCrazyLogger(Context context) {
+        boolean alreadyRunning = checkIfServiceIsRunning(context, CrazyLoggerService.class);
+        Intent intent = new Intent(context, CrazyLoggerService.class);
+        if (!alreadyRunning) {
+            context.startService(intent);
+        } else {
+            context.stopService(intent);
+        }
+    }
+
+    public static synchronized void stopBackgroundServiceIfRunning(Context context) {
+        boolean alreadyRunning = ServiceHelper.checkIfServiceIsRunning(context, LogcatRecordingService.class);
+        Log.d(TAG, "Is LogcatRecordingService running: " + alreadyRunning);
+        if (alreadyRunning) {
+            Intent intent = new Intent(context, LogcatRecordingService.class);
+            context.stopService(intent);
+        }
+    }
+
+    public static synchronized void startBackgroundServiceIfNotAlreadyRunning(Context context, String filename,
+                                                                              String queryFilter, int logLevel) {
+        boolean alreadyRunning = ServiceHelper.checkIfServiceIsRunning(context, LogcatRecordingService.class);
+        Log.d(TAG, "Is LogcatRecordingService running: " + alreadyRunning);
+        if (!alreadyRunning) {
+            Intent intent = new Intent(context, LogcatRecordingService.class);
+            intent.putExtra(LogcatRecordingService.EXTRA_FILENAME, filename);
+            // Load "lastLine" in the background
+            LogcatReaderLoader loader = LogcatReaderLoader.create(true);
+            intent.putExtra(LogcatRecordingService.EXTRA_LOADER, loader);
+            // Add query text and log level
+            intent.putExtra(LogcatRecordingService.EXTRA_QUERY_FILTER, queryFilter);
+            intent.putExtra(LogcatRecordingService.EXTRA_LEVEL, logLevel);
+            context.startService(intent);
+        }
+    }
+
+    public static boolean checkIfServiceIsRunning(Context context, Class<?> service) {
+        String serviceName = service.getName();
+        ComponentName componentName = new ComponentName(context.getPackageName(), serviceName);
+        ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
+        List<ActivityManager.RunningServiceInfo> procList = activityManager.getRunningServices(Integer.MAX_VALUE);
+        if (procList != null) {
+            for (ActivityManager.RunningServiceInfo appProcInfo : procList) {
+                if (appProcInfo != null && componentName.equals(appProcInfo.service)) {
+                    Log.d(TAG, serviceName + " is already running.");
+                    return true;
+                }
+            }
+        }
+        Log.d(TAG, serviceName + " is not running.");
+        return false;
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/helper/WidgetHelper.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/helper/WidgetHelper.java
new file mode 100644
index 00000000000..5a4b5c7c392
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/helper/WidgetHelper.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat.helper;
+
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.view.View;
+import android.widget.RemoteViews;
+
+import androidx.annotation.NonNull;
+
+import io.github.muntashirakon.AppManager.logcat.LogcatRecordingService;
+import io.github.muntashirakon.AppManager.logcat.RecordingWidgetProvider;
+
+import io.github.muntashirakon.AppManager.R;
+import io.github.muntashirakon.AppManager.logs.Log;
+
+public class WidgetHelper {
+    public static void updateWidgets(Context context) {
+        int[] appWidgetIds = findAppWidgetIds(context);
+        updateWidgets(context, appWidgetIds);
+    }
+
+    /**
+     * manually tell us if the service is running or not
+     */
+    public static void updateWidgets(Context context, boolean serviceRunning) {
+        int[] appWidgetIds = findAppWidgetIds(context);
+        updateWidgets(context, appWidgetIds, serviceRunning);
+    }
+
+    public static void updateWidgets(Context context, int[] appWidgetIds) {
+        boolean serviceRunning = ServiceHelper.checkIfServiceIsRunning(context, LogcatRecordingService.class);
+        updateWidgets(context, appWidgetIds, serviceRunning);
+    }
+
+    public static void updateWidgets(Context context, @NonNull int[] appWidgetIds, boolean serviceRunning) {
+        AppWidgetManager manager = AppWidgetManager.getInstance(context);
+        for (int appWidgetId : appWidgetIds) {
+            if (!PreferenceHelper.getWidgetExistsPreference(context, appWidgetId)) {
+                // android has a bug that sometimes keeps stale app widget ids around
+                Log.d("WidgetHelper", "Found stale app widget id " + appWidgetId + "; skipping...");
+                continue;
+            }
+            updateWidget(context, manager, appWidgetId, serviceRunning);
+        }
+    }
+
+    private static void updateWidget(Context context, AppWidgetManager manager, int appWidgetId, boolean serviceRunning) {
+        RemoteViews updateViews = new RemoteViews(context.getPackageName(), R.layout.widget_recording);
+        // change the subtext depending on whether the service is running or not
+        CharSequence subtext = context.getText(serviceRunning ? R.string.widget_recording_in_progress :
+                R.string.widget_start_recording);
+        updateViews.setTextViewText(R.id.widget_subtext, subtext);
+        // if service not running, don't show the "recording" icon
+        updateViews.setViewVisibility(R.id.record_badge_image_view, serviceRunning ? View.VISIBLE : View.INVISIBLE);
+        PendingIntent pendingIntent = getPendingIntent(context, appWidgetId);
+        updateViews.setOnClickPendingIntent(R.id.clickable_linear_layout, pendingIntent);
+        manager.updateAppWidget(appWidgetId, updateViews);
+    }
+
+    private static PendingIntent getPendingIntent(Context context, int appWidgetId) {
+        Intent intent = new Intent(context, RecordingWidgetProvider.class);
+        intent.setAction(RecordingWidgetProvider.ACTION_RECORD_OR_STOP);
+        intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
+        // gotta make this unique for this appwidgetid - otherwise, the PendingIntents conflict
+        // it seems to be a quasi-bug in Android
+        Uri data = Uri.withAppendedPath(Uri.parse(RecordingWidgetProvider.URI_SCHEME + "://widget/id/#"), String.valueOf(appWidgetId));
+        intent.setData(data);
+        return PendingIntent.getBroadcast(context,
+                0 /* no requestCode */, intent, PendingIntent.FLAG_UPDATE_CURRENT);
+    }
+
+    private static int[] findAppWidgetIds(Context context) {
+        AppWidgetManager manager = AppWidgetManager.getInstance(context);
+        ComponentName widget = new ComponentName(context, RecordingWidgetProvider.class);
+        return manager.getAppWidgetIds(widget);
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/reader/AbsLogcatReader.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/reader/AbsLogcatReader.java
new file mode 100644
index 00000000000..d6619781e07
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/reader/AbsLogcatReader.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat.reader;
+
+
+public abstract class AbsLogcatReader implements LogcatReader {
+    protected boolean recordingMode;
+
+    public AbsLogcatReader(boolean recordingMode) {
+        this.recordingMode = recordingMode;
+    }
+
+    public boolean isRecordingMode() {
+        return recordingMode;
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/reader/LogcatReader.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/reader/LogcatReader.java
new file mode 100644
index 00000000000..ce032f96ba2
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/reader/LogcatReader.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat.reader;
+
+import java.io.IOException;
+import java.util.List;
+
+public interface LogcatReader {
+    /**
+     * Read a single log line, ala {@link java.io.BufferedReader#readLine()}.
+     *
+     * @return A single log line
+     */
+    String readLine() throws IOException;
+
+    /**
+     * Kill the reader and close all resources without throwing any exceptions.
+     */
+    void killQuietly();
+
+    @SuppressWarnings("BooleanMethodIsAlwaysInverted")
+    boolean readyToRecord();
+
+    List<Process> getProcesses();
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/reader/LogcatReaderLoader.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/reader/LogcatReaderLoader.java
new file mode 100644
index 00000000000..4c98c9f28f6
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/reader/LogcatReaderLoader.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat.reader;
+
+import android.content.Context;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import androidx.annotation.NonNull;
+
+import io.github.muntashirakon.AppManager.logcat.helper.LogcatHelper;
+import io.github.muntashirakon.AppManager.logcat.helper.PreferenceHelper;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class LogcatReaderLoader implements Parcelable {
+    private final Map<Integer, String> lastLines;
+    private final boolean recordingMode;
+    private final boolean multiple;
+
+    private LogcatReaderLoader(@LogcatHelper.LogBufferId @NonNull List<Integer> buffers, boolean recordingMode) {
+        this.recordingMode = recordingMode;
+        this.multiple = buffers.size() > 1;
+        this.lastLines = new HashMap<>();
+        for (Integer buffer : buffers) {
+            // No need to grab the last line if this isn't recording mode
+            String lastLine = recordingMode ? LogcatHelper.getLastLogLine(buffer) : null;
+            lastLines.put(buffer, lastLine);
+        }
+    }
+
+    @NonNull
+    public static LogcatReaderLoader create(boolean recordingMode) {
+        List<Integer> buffers = PreferenceHelper.getBuffers();
+        return new LogcatReaderLoader(buffers, recordingMode);
+    }
+
+    public LogcatReader loadReader() throws IOException {
+        LogcatReader reader;
+        if (!multiple) {
+            // single reader
+            Integer buffers = lastLines.keySet().iterator().next();
+            String lastLine = lastLines.values().iterator().next();
+            reader = new SingleLogcatReader(recordingMode, buffers, lastLine);
+        } else {
+            // multiple reader
+            reader = new MultipleLogcatReader(recordingMode, lastLines);
+        }
+
+        return reader;
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    public static final Parcelable.Creator<LogcatReaderLoader> CREATOR = new Parcelable.Creator<LogcatReaderLoader>() {
+        public LogcatReaderLoader createFromParcel(Parcel in) {
+            return new LogcatReaderLoader(in);
+        }
+
+        public LogcatReaderLoader[] newArray(int size) {
+            return new LogcatReaderLoader[size];
+        }
+    };
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeInt(recordingMode ? 1 : 0);
+        dest.writeInt(multiple ? 1 : 0);
+        writeParcelableMap(dest, lastLines);
+    }
+
+    private LogcatReaderLoader(@NonNull Parcel in) {
+        this.recordingMode = in.readInt() == 1;
+        this.multiple = in.readInt() == 1;
+        this.lastLines = readParcelableMap(in);
+    }
+
+    public static void writeParcelableMap(@NonNull Parcel parcel, @NonNull Map<Integer, String> map) {
+        parcel.writeInt(map.size());
+        for (Map.Entry<Integer, String> e : map.entrySet()) {
+            parcel.writeInt(e.getKey());
+            parcel.writeString(e.getValue());
+        }
+    }
+
+    @NonNull
+    public static Map<Integer, String> readParcelableMap(@NonNull Parcel parcel) {
+        int size = parcel.readInt();
+        Map<Integer, String> map = new HashMap<>(size);
+        for (int i = 0; i < size; i++) {
+            map.put(parcel.readInt(), parcel.readString());
+        }
+        return map;
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/reader/MultipleLogcatReader.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/reader/MultipleLogcatReader.java
new file mode 100644
index 00000000000..62f31462297
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/reader/MultipleLogcatReader.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat.reader;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
+
+import io.github.muntashirakon.AppManager.logcat.helper.LogcatHelper;
+import io.github.muntashirakon.AppManager.logs.Log;
+
+/**
+ * Combines multiple buffered readers into a single reader that merges all input synchronously.
+ *
+ * @author nolan
+ */
+public class MultipleLogcatReader extends AbsLogcatReader {
+    public static final String TAG = MultipleLogcatReader.class.getSimpleName();
+
+    private static final String DUMMY_NULL = "";  // Stop marker
+    private final List<ReaderThread> readerThreads = new LinkedList<>();
+    private final BlockingQueue<String> queue = new ArrayBlockingQueue<>(1);
+
+    public MultipleLogcatReader(boolean recordingMode,
+                                Map<Integer, String> lastLines) throws IOException {
+        super(recordingMode);
+        // Read from all three buffers all at once
+        for (Entry<Integer, String> entry : lastLines.entrySet()) {
+            Integer buffers = entry.getKey();
+            String lastLine = entry.getValue();
+            ReaderThread readerThread = new ReaderThread(buffers, lastLine);
+            readerThread.start();
+            readerThreads.add(readerThread);
+        }
+    }
+
+    public String readLine() throws IOException {
+        try {
+            String value = queue.take();
+            if (!value.equals(DUMMY_NULL)) {
+                return value;
+            }
+        } catch (InterruptedException e) {
+            Log.e(TAG, e);
+        }
+        return null;
+    }
+
+
+    @Override
+    public boolean readyToRecord() {
+        for (ReaderThread thread : readerThreads) {
+            if (!thread.reader.readyToRecord()) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    @Override
+    public void killQuietly() {
+        for (ReaderThread thread : readerThreads) {
+            thread.killed = true;
+        }
+        // Kill all threads in the background
+        new Thread(() -> {
+            for (ReaderThread thread : readerThreads) {
+                thread.reader.killQuietly();
+            }
+            queue.offer(DUMMY_NULL);
+        }).start();
+    }
+
+
+    @Override
+    public List<Process> getProcesses() {
+        List<Process> result = new ArrayList<>();
+        for (ReaderThread thread : readerThreads) {
+            result.addAll(thread.reader.getProcesses());
+        }
+        return result;
+    }
+
+    private class ReaderThread extends Thread {
+        private final SingleLogcatReader reader;
+        private boolean killed;
+
+        public ReaderThread(@LogcatHelper.LogBufferId int logBuffer, String lastLine) throws IOException {
+            this.reader = new SingleLogcatReader(recordingMode, logBuffer, lastLine);
+        }
+
+        @Override
+        public void run() {
+            String line;
+            try {
+                while (!killed && (line = reader.readLine()) != null && !killed) {
+                    queue.put(line);
+                }
+            } catch (IOException | InterruptedException e) {
+                Log.e(TAG, e);
+            }
+            Log.w(TAG, "Thread died");
+        }
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/reader/ScrubberUtils.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/reader/ScrubberUtils.java
new file mode 100644
index 00000000000..dba16d848f8
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/reader/ScrubberUtils.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ * Copyright (C) 2014 The CyanogenMod Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat.reader;
+
+import java.util.regex.Pattern;
+
+public class ScrubberUtils {
+    private static final Pattern EMAIL_PATTERN = Pattern.compile("[a-zA-Z0-9_]+(?:\\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*(@|%40)(?!([a-zA-Z0-9]*\\.[a-zA-Z0-9]*\\.[a-zA-Z0-9]*\\.))(?:[A-Za-z0-9](?:[a-zA-Z0-9-]*[A-Za-z0-9])?\\.)+[a-zA-Z](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?");
+    private static final Pattern PHONE_NUMBER_PATTERN = Pattern.compile("^(?:(?:\\+?1\\s*(?:[.-]\\s*)?)?(?:\\(\\s*([2-9]1[02-9]|[2-9][02-8]1|[2-9][02-8][02-9])\\s*\\)|([2-9]1[02-9]|[2-9][02-8]1|[2-9][02-8][02-9]))\\s*(?:[.-]\\s*)?)?([2-9]1[02-9]|[2-9][02-9]1|[2-9][02-9]{2})\\s*(?:[.-]\\s*)?([0-9]{4})(?:\\s*(?:#|x\\.?|ext\\.?|extension)\\s*(\\d+))?$");
+    private static final Pattern WEB_URL_PATTERN = Pattern.compile("\\b(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]");
+    private static final Pattern IP_ADDRESS_PATTERN = Pattern.compile("^([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.([01]?\\d\\d?|2[0-4]\\d|25[0-5])$");
+    private static final Pattern PHONE_INFO_PATTERN = Pattern.compile("(msisdn=|mMsisdn=|iccid=|iccid: |mImsi=)[a-zA-Z0-9]*", Pattern.CASE_INSENSITIVE);
+    private static final Pattern USER_INFO_PATTERN = Pattern.compile("(UserInfo\\{\\d:)[a-zA-Z0-9\\s]*", Pattern.CASE_INSENSITIVE);
+    private static final Pattern ACCOUNT_INFO_PATTERN = Pattern.compile("(Account \\{name=)[a-zA-Z0-9]*", Pattern.CASE_INSENSITIVE);
+
+    private static final String IGNORE_DATA_RESOURCE_CACHE = "/data/resource-cache";
+    private static final String IGNORE_DATA_DALVIK_CACHE = "/data/dalvik-cache";
+    private static final String IGNORE_CACHE_DALVIK_CACHE = "/cache/dalvik-cache";
+
+    public static String scrubLine(String line) {
+        if (line.contains(IGNORE_DATA_RESOURCE_CACHE)
+                || line.contains(IGNORE_DATA_DALVIK_CACHE)
+                || line.contains(IGNORE_CACHE_DALVIK_CACHE)) {
+            // Ugly work around :/
+            return line;
+        }
+        line = IP_ADDRESS_PATTERN.matcher(line).replaceAll("<IP address omitted>");
+        line = EMAIL_PATTERN.matcher(line).replaceAll("<email omitted>");
+        line = PHONE_NUMBER_PATTERN.matcher(line).replaceAll("<phone number omitted>");
+        line = WEB_URL_PATTERN.matcher(line).replaceAll("<web url omitted>");
+        line = PHONE_INFO_PATTERN.matcher(line).replaceAll("<omitted>");
+        line = USER_INFO_PATTERN.matcher(line).replaceAll("<omitted>");
+        line = ACCOUNT_INFO_PATTERN.matcher(line).replaceAll("<omitted>");
+
+        return line;
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/reader/SingleLogcatReader.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/reader/SingleLogcatReader.java
new file mode 100644
index 00000000000..e8d8bd4077a
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/reader/SingleLogcatReader.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat.reader;
+
+import android.text.TextUtils;
+
+import io.github.muntashirakon.AppManager.logcat.helper.LogcatHelper;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.Collections;
+import java.util.List;
+
+import io.github.muntashirakon.AppManager.logs.Log;
+
+public class SingleLogcatReader extends AbsLogcatReader {
+    private final Process logcatProcess;
+    private final BufferedReader bufferedReader;
+    private String lastLine;
+
+    public SingleLogcatReader(boolean recordingMode, @LogcatHelper.LogBufferId int buffers, String lastLine)
+            throws IOException {
+        super(recordingMode);
+        this.lastLine = lastLine;
+
+        // Use the "time" log so we can see what time the logs were logged at
+        logcatProcess = LogcatHelper.getLogcatProcess(buffers);
+        bufferedReader = new BufferedReader(new InputStreamReader(logcatProcess.getInputStream()), 8192);
+    }
+
+    @Override
+    public void killQuietly() {
+        if (logcatProcess != null) {
+            logcatProcess.destroy();
+            Log.d("SLR", "killed 1 logcat process");
+        }
+    }
+
+    @Override
+    public String readLine() throws IOException {
+        String line = bufferedReader.readLine();
+        if (recordingMode && lastLine != null) { // Still skipping past the 'last line'
+            if (lastLine.equals(line) /*|| isAfterLastTime(line)*/) {
+                lastLine = null; // Indicates we've passed the last line
+            }
+        }
+        return line;
+    }
+
+    private boolean isAfterLastTime(String line) {
+        // Doing a string comparison is sufficient to determine whether this line is chronologically
+        // after the last line, because the format they use is exactly the same and
+        // lists larger time period before smaller ones
+        return isDatedLogLine(lastLine) && isDatedLogLine(line) && line.compareTo(lastLine) > 0;
+    }
+
+    private boolean isDatedLogLine(String line) {
+        // 18 is the size of the logcat timestamp
+        return (!TextUtils.isEmpty(line) && line.length() >= 18 && Character.isDigit(line.charAt(0)));
+    }
+
+    @Override
+    public boolean readyToRecord() {
+        return recordingMode && lastLine == null;
+    }
+
+    @Override
+    public List<Process> getProcesses() {
+        return Collections.singletonList(logcatProcess);
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/struct/LogLine.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/struct/LogLine.java
new file mode 100644
index 00000000000..e59b5dc6ed1
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/struct/LogLine.java
@@ -0,0 +1,231 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat.struct;
+
+import android.text.TextUtils;
+import android.util.Log;
+
+import io.github.muntashirakon.AppManager.logcat.reader.ScrubberUtils;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+
+public class LogLine {
+    public static final int LOG_FATAL = 15;
+
+    private static final int TIMESTAMP_LENGTH = 19;
+
+    private static final Pattern logPattern = Pattern.compile(
+            // log level
+            "(\\w)" + "/" +
+                    // tag
+                    "([^(].+)" +
+                    "\\(\\s*" +
+                    // pid
+                    "(\\d+)" +
+                    // optional weird number that only occurs on ZTE blade
+                    "(?:\\*\\s*\\d+)?" +
+                    "\\): ");
+
+    private int logLevel;
+    private String tag;
+    private String logOutput;
+    private int processId = -1;
+    private String timestamp;
+    private boolean expanded = false;
+    private boolean highlighted = false;
+
+    public static boolean omitSensitiveInfo = false;
+
+    public static LogLine newLogLine(String originalLine, boolean expanded, String filterPattern) {
+
+        LogLine logLine = new LogLine();
+        logLine.setExpanded(expanded);
+
+        int startIdx = 0;
+
+        // if the first char is a digit, then this starts out with a timestamp
+        // otherwise, it's a legacy log or the beginning of the log output or something
+        if (!TextUtils.isEmpty(originalLine)
+                && Character.isDigit(originalLine.charAt(0))
+                && originalLine.length() >= TIMESTAMP_LENGTH) {
+            String timestamp = originalLine.substring(0, TIMESTAMP_LENGTH - 1);
+            logLine.setTimestamp(timestamp);
+            startIdx = TIMESTAMP_LENGTH; // cut off timestamp
+        }
+
+        Matcher matcher = logPattern.matcher(originalLine);
+
+        if (matcher.find(startIdx)) {
+            char logLevelChar = matcher.group(1).charAt(0);
+
+            String logText = originalLine.substring(matcher.end());
+            if (logText.matches("^maxLineHeight.*|Failed to read.*")) {
+                logLine.setLogLevel(convertCharToLogLevel('V'));
+            } else {
+                logLine.setLogLevel(convertCharToLogLevel(logLevelChar));
+            }
+
+            String tagText = matcher.group(2);
+            if (tagText.matches(filterPattern)) {
+                logLine.setLogLevel(convertCharToLogLevel('V'));
+            }
+
+            logLine.setTag(tagText);
+            logLine.setProcessId(Integer.parseInt(matcher.group(3)));
+
+            logLine.setLogOutput(logText);
+
+        } else {
+            Log.d("LogLine", "Line doesn't match pattern: " + originalLine);
+            logLine.setLogOutput(originalLine);
+            logLine.setLogLevel(-1);
+        }
+
+        return logLine;
+
+    }
+
+    public static int convertCharToLogLevel(char logLevelChar) {
+        switch (logLevelChar) {
+            case 'A':
+                return Log.ASSERT;
+            case 'D':
+                return Log.DEBUG;
+            case 'E':
+                return Log.ERROR;
+            case 'I':
+                return Log.INFO;
+            case 'V':
+                return Log.VERBOSE;
+            case 'W':
+                return Log.WARN;
+            case 'F':
+                return LOG_FATAL;
+        }
+        return -1;
+    }
+
+    public static char convertLogLevelToChar(int logLevel) {
+        switch (logLevel) {
+            case Log.ASSERT:
+                return 'A';
+            case Log.DEBUG:
+                return 'D';
+            case Log.ERROR:
+                return 'E';
+            case Log.INFO:
+                return 'I';
+            case Log.VERBOSE:
+                return 'V';
+            case Log.WARN:
+                return 'W';
+            case LOG_FATAL:
+                return 'F';
+        }
+        return ' ';
+    }
+
+    public String getOriginalLine() {
+
+        if (logLevel == -1) { // starter line like "begin of log etc. etc."
+            return logOutput;
+        }
+
+        StringBuilder stringBuilder = new StringBuilder();
+
+        if (timestamp != null) {
+            stringBuilder.append(timestamp).append(' ');
+        }
+
+        stringBuilder.append(convertLogLevelToChar(logLevel))
+                .append('/')
+                .append(tag)
+                .append('(')
+                .append(processId)
+                .append("): ")
+                .append(logOutput);
+
+        return stringBuilder.toString();
+    }
+
+    public String getProcessIdText() {
+        return Character.toString(convertLogLevelToChar(logLevel));
+    }
+
+    public int getLogLevel() {
+        return logLevel;
+    }
+
+    public void setLogLevel(int logLevel) {
+        this.logLevel = logLevel;
+    }
+
+    public String getTag() {
+        return tag;
+    }
+
+    public void setTag(String tag) {
+        this.tag = tag;
+    }
+
+    public String getLogOutput() {
+        return logOutput;
+    }
+
+    public void setLogOutput(String logOutput) {
+        if (omitSensitiveInfo) {
+            this.logOutput = ScrubberUtils.scrubLine(logOutput);
+        } else {
+            this.logOutput = logOutput;
+        }
+    }
+
+    public int getProcessId() {
+        return processId;
+    }
+
+    public void setProcessId(int processId) {
+        this.processId = processId;
+    }
+
+    public String getTimestamp() {
+        return timestamp;
+    }
+
+    public void setTimestamp(String timestamp) {
+        this.timestamp = timestamp;
+    }
+
+    public boolean isExpanded() {
+        return expanded;
+    }
+
+    public void setExpanded(boolean expanded) {
+        this.expanded = expanded;
+    }
+
+    public boolean isHighlighted() {
+        return highlighted;
+    }
+
+    public void setHighlighted(boolean highlighted) {
+        this.highlighted = highlighted;
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/struct/SavedLog.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/struct/SavedLog.java
new file mode 100644
index 00000000000..4e0c4fc36a9
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/struct/SavedLog.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat.struct;
+
+import java.util.List;
+
+public class SavedLog {
+    private final List<String> logLines;
+    private final boolean truncated;
+
+    public SavedLog(List<String> logLines, boolean truncated) {
+        this.logLines = logLines;
+        this.truncated = truncated;
+    }
+
+    public List<String> getLogLines() {
+        return logLines;
+    }
+
+    public boolean isTruncated() {
+        return truncated;
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/struct/SearchCriteria.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/struct/SearchCriteria.java
new file mode 100644
index 00000000000..d2c3e856ec9
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/struct/SearchCriteria.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat.struct;
+
+import android.text.TextUtils;
+
+import androidx.annotation.Nullable;
+
+import java.util.Locale;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class SearchCriteria {
+    public static final String PID_KEYWORD = "pid:";
+    public static final String TAG_KEYWORD = "tag:";
+
+    private static final Pattern PID_PATTERN = Pattern.compile("pid:(\\d+)", Pattern.CASE_INSENSITIVE);
+    private static final Pattern TAG_PATTERN = Pattern.compile("tag:(\"[^\"]+\"|\\S+)", Pattern.CASE_INSENSITIVE);
+
+    private int pid = -1;
+    private String tag;
+    private final String searchText;
+    private int searchTextAsInt = -1;
+
+    public SearchCriteria(@Nullable CharSequence inputQuery) {
+        // Check for the "pid" keyword
+        StringBuilder query = new StringBuilder(inputQuery == null ? "" : inputQuery);
+        Matcher pidMatcher = PID_PATTERN.matcher(query);
+        if (pidMatcher.find()) {
+            try {
+                pid = Integer.parseInt(Objects.requireNonNull(pidMatcher.group(1)));
+                query.replace(pidMatcher.start(), pidMatcher.end(), ""); // remove
+                // from
+                // search
+                // string
+            } catch (NumberFormatException ignore) {
+            }
+        }
+        // Check for the "tag" keyword
+        Matcher tagMatcher = TAG_PATTERN.matcher(query);
+        if (tagMatcher.find()) {
+            tag = Objects.requireNonNull(tagMatcher.group(1));
+            if (tag.startsWith("\"") && tag.endsWith("\"")) {
+                tag = tag.substring(1, tag.length() - 1); // remove quotes
+            }
+            query.replace(tagMatcher.start(), tagMatcher.end(), ""); // remove
+            // from
+            // search
+            // string
+        }
+        // Everything else becomes a search term
+        searchText = query.toString().trim();
+        try {
+            searchTextAsInt = Integer.parseInt(searchText);
+        } catch (NumberFormatException ignore) {
+        }
+    }
+
+    public boolean isEmpty() {
+        return pid == -1 && TextUtils.isEmpty(tag) && TextUtils.isEmpty(searchText);
+    }
+
+    public boolean matches(LogLine logLine) {
+        // Consider the criteria to be ANDed
+        if (!checkFoundPid(logLine)) {
+            return false;
+        }
+        if (!checkFoundTag(logLine)) {
+            return false;
+        }
+        return checkFoundText(logLine);
+    }
+
+    private boolean checkFoundText(LogLine logLine) {
+        return TextUtils.isEmpty(searchText)
+                || (searchTextAsInt != -1 && searchTextAsInt == logLine.getProcessId())
+                || (logLine.getTag() != null && logLine.getTag().toLowerCase(Locale.ROOT).contains(searchText.toLowerCase(Locale.ROOT)))
+                || (logLine.getLogOutput() != null && logLine.getLogOutput().toLowerCase(Locale.ROOT).contains(searchText.toLowerCase(Locale.ROOT)));
+    }
+
+    private boolean checkFoundTag(LogLine logLine) {
+        return TextUtils.isEmpty(tag)
+                || (logLine.getTag() != null && logLine.getTag().toLowerCase(Locale.ROOT).contains(tag.toLowerCase(Locale.ROOT)));
+    }
+
+    private boolean checkFoundPid(LogLine logLine) {
+        return pid == -1 || logLine.getProcessId() == pid;
+    }
+
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logcat/struct/SendLogDetails.java b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/struct/SendLogDetails.java
new file mode 100644
index 00000000000..039e9b0fb01
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logcat/struct/SendLogDetails.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.logcat.struct;
+
+import java.io.File;
+
+public class SendLogDetails {
+    private String subject;
+    private String body;
+    private File attachment;
+    private SendLogDetails.AttachmentType attachmentType;
+
+    public String getSubject() {
+        return subject;
+    }
+
+    public void setSubject(String subject) {
+        this.subject = subject;
+    }
+
+    public String getBody() {
+        return body;
+    }
+
+    public void setBody(String body) {
+        this.body = body;
+    }
+
+    public File getAttachment() {
+        return attachment;
+    }
+
+    public void setAttachment(File attachment) {
+        this.attachment = attachment;
+    }
+
+    public SendLogDetails.AttachmentType getAttachmentType() {
+        return attachmentType;
+    }
+
+    public void setAttachmentType(SendLogDetails.AttachmentType attachmentType) {
+        this.attachmentType = attachmentType;
+    }
+
+    public enum AttachmentType {
+        None("text/plain"),
+        Zip("application/zip"),
+        Text("application/*");
+
+        private final String mimeType;
+
+        AttachmentType(String mimeType) {
+            this.mimeType = mimeType;
+        }
+
+        public String getMimeType() {
+            return this.mimeType;
+        }
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/logs/Log.java b/app/src/main/java/io/github/muntashirakon/AppManager/logs/Log.java
index a989ee085cd..8b3ebff42de 100644
--- a/app/src/main/java/io/github/muntashirakon/AppManager/logs/Log.java
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/logs/Log.java
@@ -169,7 +169,7 @@ private static void println(@Level int level, @Nullable String tag, @Nullable St
         if (msg != null) sb.append(": ").append(msg);
         new Thread(() -> {
             synchronized (INSTANCE) {
-                INSTANCE.writer.println(sb.toString());
+                INSTANCE.writer.println(sb);
                 if (tr != null) {
                     tr.printStackTrace(INSTANCE.writer);
                 }
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/main/MainActivity.java b/app/src/main/java/io/github/muntashirakon/AppManager/main/MainActivity.java
index c18b6dd3b9c..2913c7c4dbc 100644
--- a/app/src/main/java/io/github/muntashirakon/AppManager/main/MainActivity.java
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/main/MainActivity.java
@@ -60,6 +60,7 @@
 import io.github.muntashirakon.AppManager.backup.BackupDialogFragment;
 import io.github.muntashirakon.AppManager.batchops.BatchOpsManager;
 import io.github.muntashirakon.AppManager.batchops.BatchOpsService;
+import io.github.muntashirakon.AppManager.logcat.LogViewerActivity;
 import io.github.muntashirakon.AppManager.misc.HelpActivity;
 import io.github.muntashirakon.AppManager.users.Users;
 import io.github.muntashirakon.AppManager.oneclickops.OneClickOpsActivity;
@@ -300,6 +301,10 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) {
         } else if (id == R.id.action_profiles) {
             Intent profilesIntent = new Intent(this, ProfilesActivity.class);
             startActivity(profilesIntent);
+        } else if (id == R.id.action_log_viewer) {
+            Intent intent = new Intent(getApplicationContext(), LogViewerActivity.class);
+            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+            startActivity(intent);
         } else return super.onOptionsItemSelected(item);
         return true;
     }
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/runningapps/RunningAppsAdapter.java b/app/src/main/java/io/github/muntashirakon/AppManager/runningapps/RunningAppsAdapter.java
index 37ec2551f66..2efdeb07d88 100644
--- a/app/src/main/java/io/github/muntashirakon/AppManager/runningapps/RunningAppsAdapter.java
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/runningapps/RunningAppsAdapter.java
@@ -17,6 +17,7 @@
 
 package io.github.muntashirakon.AppManager.runningapps;
 
+import android.content.Intent;
 import android.content.pm.ApplicationInfo;
 import android.graphics.Color;
 import android.os.RemoteException;
@@ -41,6 +42,8 @@
 import io.github.muntashirakon.AppManager.R;
 import io.github.muntashirakon.AppManager.appops.AppOpsManager;
 import io.github.muntashirakon.AppManager.appops.AppOpsService;
+import io.github.muntashirakon.AppManager.logcat.LogViewerActivity;
+import io.github.muntashirakon.AppManager.logcat.struct.SearchCriteria;
 import io.github.muntashirakon.AppManager.users.Users;
 import io.github.muntashirakon.AppManager.rules.compontents.ComponentsBlocker;
 import io.github.muntashirakon.AppManager.runner.Runner;
@@ -137,6 +140,16 @@ public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
                     return true;
                 });
             } else killItem.setVisible(false);
+            // Set view logs
+            MenuItem viewLogsItem = menu.findItem(R.id.action_view_logs);
+            viewLogsItem.setOnMenuItemClickListener(item -> {
+                Intent logViewerIntent = new Intent(mActivity.getApplicationContext(), LogViewerActivity.class)
+                        .setAction(LogViewerActivity.ACTION_LAUNCH)
+                        .putExtra(LogViewerActivity.EXTRA_FILTER, SearchCriteria.PID_KEYWORD + processItem.pid)
+                        .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+                mActivity.startActivity(logViewerIntent);
+                return true;
+            });
             // Set others
             MenuItem forceStopItem = menu.findItem(R.id.action_force_stop);
             MenuItem bgItem = menu.findItem(R.id.action_disable_background);
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/servermanager/ProcessCompat.java b/app/src/main/java/io/github/muntashirakon/AppManager/servermanager/ProcessCompat.java
new file mode 100644
index 00000000000..d1b540e5883
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/servermanager/ProcessCompat.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.servermanager;
+
+import android.os.RemoteException;
+
+import androidx.annotation.Nullable;
+
+import java.io.File;
+import java.io.IOException;
+
+import io.github.muntashirakon.AppManager.ipc.IPCUtils;
+import io.github.muntashirakon.AppManager.ipc.RemoteProcess;
+
+public class ProcessCompat {
+    public static Process exec(@Nullable String[] cmd, @Nullable String[] env, @Nullable File dir) throws IOException {
+        if (LocalServer.isAMServiceAlive()) {
+            try {
+                return new RemoteProcess(IPCUtils.getAmService().newProcess(cmd, env, dir == null ? null :
+                        dir.getAbsolutePath()));
+            } catch (RemoteException e) {
+                throw new IOException(e);
+            }
+        }
+        return Runtime.getRuntime().exec(cmd, env, dir);
+    }
+
+    public static Process exec(String[] cmd, String[] env) throws IOException {
+        return exec(cmd, env, null);
+    }
+
+    public static Process exec(String[] cmd) throws IOException {
+        return exec(cmd, null, null);
+    }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/settings/LogViewerPreferences.java b/app/src/main/java/io/github/muntashirakon/AppManager/settings/LogViewerPreferences.java
new file mode 100644
index 00000000000..fc6a16e380e
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/settings/LogViewerPreferences.java
@@ -0,0 +1,195 @@
+/*
+ * Copyright (c) 2021 Muntashir Al-Islam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package io.github.muntashirakon.AppManager.settings;
+
+import android.os.Bundle;
+import android.util.Log;
+
+import androidx.fragment.app.FragmentActivity;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceFragmentCompat;
+import androidx.preference.SwitchPreference;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import io.github.muntashirakon.AppManager.R;
+import io.github.muntashirakon.AppManager.logcat.helper.LogcatHelper;
+import io.github.muntashirakon.AppManager.logcat.helper.PreferenceHelper;
+import io.github.muntashirakon.AppManager.logcat.struct.LogLine;
+import io.github.muntashirakon.AppManager.types.SearchableMultiChoiceDialogBuilder;
+import io.github.muntashirakon.AppManager.types.TextInputDialogBuilder;
+import io.github.muntashirakon.AppManager.utils.AppPref;
+import io.github.muntashirakon.AppManager.utils.UIUtils;
+
+public class LogViewerPreferences extends PreferenceFragmentCompat {
+    public static final List<Integer> LOG_LEVEL_VALUES = Arrays.asList(Log.VERBOSE, Log.DEBUG, Log.INFO, Log.WARN,
+            Log.ERROR, LogLine.LOG_FATAL);
+    public static final List<CharSequence> LOG_BUFFER_NAMES = Arrays.asList(LogcatHelper.BUFFER_MAIN, LogcatHelper.BUFFER_RADIO,
+            LogcatHelper.BUFFER_EVENTS, LogcatHelper.BUFFER_SYSTEM, LogcatHelper.BUFFER_CRASH);
+    public static final List<Integer> LOG_BUFFERS = Arrays.asList(LogcatHelper.LOG_ID_MAIN, LogcatHelper.LOG_ID_RADIO,
+            LogcatHelper.LOG_ID_EVENTS, LogcatHelper.LOG_ID_SYSTEM, LogcatHelper.LOG_ID_CRASH);
+
+    private static final int MAX_LOG_WRITE_PERIOD = 1000;
+    private static final int MIN_LOG_WRITE_PERIOD = 1;
+    private static final int MAX_DISPLAY_LIMIT = 100000;
+    private static final int MIN_DISPLAY_LIMIT = 1000;
+
+    @Override
+    public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
+        addPreferencesFromResource(R.xml.preferences_log_viewer);
+        getPreferenceManager().setPreferenceDataStore(new SettingsDataStore());
+
+        FragmentActivity activity = requireActivity();
+
+        SwitchPreference expandByDefault = Objects.requireNonNull(findPreference("log_viewer_expand_by_default"));
+        expandByDefault.setChecked(AppPref.getBoolean(AppPref.PrefKey.PREF_LOG_VIEWER_EXPAND_BY_DEFAULT_BOOL));
+
+        SwitchPreference showPidTidTimestamp = Objects.requireNonNull(findPreference("log_viewer_show_pid_tid_timestamp"));
+        showPidTidTimestamp.setChecked(AppPref.getBoolean(AppPref.PrefKey.PREF_LOG_VIEWER_SHOW_PID_TID_TIMESTAMP_BOOL));
+
+        SwitchPreference omitSensitiveInfo = Objects.requireNonNull(findPreference("log_viewer_omit_sensitive_info"));
+        omitSensitiveInfo.setChecked(AppPref.getBoolean(AppPref.PrefKey.PREF_LOG_VIEWER_OMIT_SENSITIVE_INFO_BOOL));
+        omitSensitiveInfo.setOnPreferenceChangeListener((preference, newValue) -> {
+            LogLine.omitSensitiveInfo = (boolean) newValue;
+            return true;
+        });
+
+        Preference filterPattern = Objects.requireNonNull(findPreference("log_viewer_filter_pattern"));
+        filterPattern.setOnPreferenceClickListener(preference -> {
+            new TextInputDialogBuilder(activity, R.string.pref_filter_pattern_title)
+                    .setTitle(R.string.pref_filter_pattern_title)
+                    .setInputText(AppPref.getString(AppPref.PrefKey.PREF_LOG_VIEWER_FILTER_PATTERN_STR))
+                    .setPositiveButton(R.string.save, (dialog, which, inputText, isChecked) -> {
+                        if (inputText == null) return;
+                        AppPref.set(AppPref.PrefKey.PREF_LOG_VIEWER_FILTER_PATTERN_STR, inputText.toString().trim());
+                        UIUtils.displayLongToast(R.string.restart_log_viewer_to_see_changes);
+                    })
+                    .setNegativeButton(R.string.cancel, null)
+                    .setNeutralButton(R.string.reset_to_default, (dialog, which, inputText, isChecked) -> {
+                        AppPref.setDefault(AppPref.PrefKey.PREF_LOG_VIEWER_FILTER_PATTERN_STR);
+                        UIUtils.displayLongToast(R.string.restart_log_viewer_to_see_changes);
+                    })
+                    .show();
+            return true;
+        });
+
+        Preference displayLimit = Objects.requireNonNull(findPreference("log_viewer_display_limit"));
+        displayLimit.setSummary(getString(R.string.pref_display_limit_summary, AppPref.getInt(AppPref.PrefKey
+                .PREF_LOG_VIEWER_DISPLAY_LIMIT_INT)));
+        displayLimit.setOnPreferenceClickListener(preference -> {
+            new TextInputDialogBuilder(activity, R.string.pref_display_limit_title)
+                    .setTitle(R.string.pref_display_limit_title)
+                    .setHelperText(getString(R.string.pref_display_limit_hint, MIN_DISPLAY_LIMIT, MAX_DISPLAY_LIMIT))
+                    .setInputText(String.valueOf(AppPref.getInt(AppPref.PrefKey.PREF_LOG_VIEWER_DISPLAY_LIMIT_INT)))
+                    .setPositiveButton(R.string.save, (dialog, which, inputText, isChecked) -> {
+                        if (inputText == null) return;
+                        try {
+                            int displayLimitInt = Integer.parseInt(inputText.toString().trim());
+                            if (displayLimitInt >= MIN_DISPLAY_LIMIT && displayLimitInt <= MAX_DISPLAY_LIMIT) {
+                                AppPref.set(AppPref.PrefKey.PREF_LOG_VIEWER_DISPLAY_LIMIT_INT, displayLimitInt);
+                                UIUtils.displayLongToast(R.string.restart_log_viewer_to_see_changes);
+                                displayLimit.setSummary(getString(R.string.pref_display_limit_summary, displayLimitInt));
+                            }
+                        } catch (NumberFormatException ignore) {
+                        }
+                    })
+                    .setNegativeButton(R.string.cancel, null)
+                    .setNeutralButton(R.string.reset_to_default, (dialog, which, inputText, isChecked) -> {
+                        AppPref.setDefault(AppPref.PrefKey.PREF_LOG_VIEWER_DISPLAY_LIMIT_INT);
+                        UIUtils.displayLongToast(R.string.restart_log_viewer_to_see_changes);
+                        displayLimit.setSummary(getString(R.string.pref_display_limit_summary, AppPref
+                                .getInt(AppPref.PrefKey.PREF_LOG_VIEWER_DISPLAY_LIMIT_INT)));
+                    })
+                    .show();
+            return true;
+        });
+
+        Preference writePeriod = Objects.requireNonNull(findPreference("log_viewer_write_period"));
+        writePeriod.setSummary(getString(R.string.pref_log_write_period_summary, AppPref.getInt(AppPref.PrefKey
+                .PREF_LOG_VIEWER_WRITE_PERIOD_INT)));
+        writePeriod.setOnPreferenceClickListener(preference -> {
+            new TextInputDialogBuilder(activity, R.string.pref_log_write_period_title)
+                    .setTitle(R.string.pref_log_write_period_title)
+                    .setHelperText(getString(R.string.pref_log_line_period_error))
+                    .setInputText(String.valueOf((int) AppPref.get(AppPref.PrefKey.PREF_LOG_VIEWER_WRITE_PERIOD_INT)))
+                    .setPositiveButton(R.string.save, (dialog, which, inputText, isChecked) -> {
+                        if (inputText == null) return;
+                        try {
+                            int writePeriodInt = Integer.parseInt(inputText.toString().trim());
+                            if (writePeriodInt >= MIN_LOG_WRITE_PERIOD && writePeriodInt <= MAX_LOG_WRITE_PERIOD) {
+                                AppPref.set(AppPref.PrefKey.PREF_LOG_VIEWER_WRITE_PERIOD_INT, writePeriodInt);
+                                UIUtils.displayLongToast(R.string.restart_log_viewer_to_see_changes);
+                                writePeriod.setSummary(getString(R.string.pref_log_write_period_summary, writePeriodInt));
+                            }
+                        } catch (NumberFormatException ignore) {
+                        }
+                    })
+                    .setNegativeButton(R.string.cancel, null)
+                    .setNeutralButton(R.string.reset_to_default, (dialog, which, inputText, isChecked) -> {
+                        AppPref.setDefault(AppPref.PrefKey.PREF_LOG_VIEWER_WRITE_PERIOD_INT);
+                        UIUtils.displayLongToast(R.string.restart_log_viewer_to_see_changes);
+                        writePeriod.setSummary(getString(R.string.pref_log_write_period_summary, AppPref
+                                .getInt(AppPref.PrefKey.PREF_LOG_VIEWER_WRITE_PERIOD_INT)));
+                    })
+                    .show();
+            return true;
+        });
+
+        Preference logLevel = Objects.requireNonNull(findPreference("log_viewer_default_log_level"));
+        logLevel.setOnPreferenceClickListener(preference -> {
+            CharSequence[] logLevelsLocalised = getResources().getStringArray(R.array.log_levels);
+            int idx = LOG_LEVEL_VALUES.indexOf(AppPref.getInt(AppPref.PrefKey.PREF_LOG_VIEWER_DEFAULT_LOG_LEVEL_INT));
+            AtomicInteger newIdx = new AtomicInteger(idx);
+            new MaterialAlertDialogBuilder(activity)
+                    .setTitle(R.string.pref_default_log_level_title)
+                    .setSingleChoiceItems(logLevelsLocalised, idx, (dialog, which) -> newIdx.set(which))
+                    .setPositiveButton(R.string.save, (dialog, which) -> AppPref.set(AppPref.PrefKey
+                            .PREF_LOG_VIEWER_DEFAULT_LOG_LEVEL_INT, LOG_LEVEL_VALUES.get(newIdx.get())))
+                    .setNegativeButton(R.string.cancel, null)
+                    .setNeutralButton(R.string.reset_to_default, (dialog, which) -> AppPref.setDefault(
+                            AppPref.PrefKey.PREF_LOG_VIEWER_DEFAULT_LOG_LEVEL_INT))
+                    .show();
+            return true;
+        });
+
+        Preference logBuffers = Objects.requireNonNull(findPreference("log_viewer_buffer"));
+        logBuffers.setOnPreferenceClickListener(preference -> {
+            new SearchableMultiChoiceDialogBuilder<>(activity, LOG_BUFFERS, LOG_BUFFER_NAMES)
+                    .setTitle(R.string.pref_buffer_title)
+                    .setSelections(PreferenceHelper.getBuffers())
+                    .setPositiveButton(R.string.save, (dialog, which, selectedItems) -> {
+                        if (selectedItems.size() == 0) return;
+                        int bufferFlags = 0;
+                        for (int flag : selectedItems) {
+                            bufferFlags |= flag;
+                        }
+                        AppPref.set(AppPref.PrefKey.PREF_LOG_VIEWER_BUFFER_INT, bufferFlags);
+                    })
+                    .setNegativeButton(R.string.cancel, null)
+                    .setNeutralButton(R.string.reset_to_default, (dialog, which, selectedItems) ->
+                            AppPref.setDefault(AppPref.PrefKey.PREF_LOG_VIEWER_BUFFER_INT))
+                    .show();
+            return true;
+        });
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/types/ForegroundService.java b/app/src/main/java/io/github/muntashirakon/AppManager/types/ForegroundService.java
index e1c6ee75bfc..75400124bf4 100644
--- a/app/src/main/java/io/github/muntashirakon/AppManager/types/ForegroundService.java
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/types/ForegroundService.java
@@ -10,6 +10,7 @@
 import android.os.Message;
 import android.os.Process;
 
+import androidx.annotation.CallSuper;
 import androidx.annotation.Nullable;
 
 public abstract class ForegroundService extends Service {
@@ -42,6 +43,7 @@ public void onCreate() {
         serviceHandler = new ServiceHandler(serviceLooper);
     }
 
+    @CallSuper
     @Override
     public int onStartCommand(Intent intent, int flags, int startId) {
         Message msg = serviceHandler.obtainMessage();
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/utils/AppPref.java b/app/src/main/java/io/github/muntashirakon/AppManager/utils/AppPref.java
index 90c0f5ee6ab..e246ece6534 100644
--- a/app/src/main/java/io/github/muntashirakon/AppManager/utils/AppPref.java
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/utils/AppPref.java
@@ -22,18 +22,23 @@
 import android.content.SharedPreferences;
 import android.content.pm.PackageInfo;
 import android.os.Environment;
+import android.util.Log;
+
 import androidx.annotation.IntDef;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.appcompat.app.AppCompatDelegate;
 import io.github.muntashirakon.AppManager.AppManager;
+import io.github.muntashirakon.AppManager.R;
 import io.github.muntashirakon.AppManager.apk.signing.SigSchemes;
 import io.github.muntashirakon.AppManager.backup.BackupFlags;
 import io.github.muntashirakon.AppManager.backup.CryptoUtils;
 import io.github.muntashirakon.AppManager.details.AppDetailsFragment;
+import io.github.muntashirakon.AppManager.logcat.helper.LogcatHelper;
 import io.github.muntashirakon.AppManager.main.ListOptions;
 import io.github.muntashirakon.AppManager.runner.Runner;
 import io.github.muntashirakon.AppManager.runningapps.RunningAppsActivity;
+import io.github.muntashirakon.io.ProxyFile;
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
@@ -58,10 +63,12 @@ public enum PrefKey {
         PREF_APP_OP_SHOW_DEFAULT_BOOL,
         PREF_APP_OP_SORT_ORDER_INT,
         PREF_APP_THEME_INT,
+
         PREF_BACKUP_COMPRESSION_METHOD_STR,
         PREF_BACKUP_VOLUME_STR,
         PREF_BACKUP_FLAGS_INT,
         PREF_BACKUP_ANDROID_KEYSTORE_BOOL,
+
         PREF_ENABLED_FEATURES_INT,
         PREF_COMPONENTS_SORT_ORDER_INT,
         PREF_CUSTOM_LOCALE_STR,
@@ -69,22 +76,37 @@ public enum PrefKey {
         PREF_ENABLE_SCREEN_LOCK_BOOL,
         PREF_ENCRYPTION_STR,
         PREF_GLOBAL_BLOCKING_ENABLED_BOOL,
+
         PREF_INSTALLER_DISPLAY_USERS_BOOL,
         PREF_INSTALLER_INSTALL_LOCATION_INT,
         PREF_INSTALLER_INSTALLER_APP_STR,
         PREF_INSTALLER_SIGN_APK_BOOL,
+
         PREF_LAST_VERSION_CODE_LONG,
+
+        PREF_LOG_VIEWER_BUFFER_INT,
+        PREF_LOG_VIEWER_DEFAULT_LOG_LEVEL_INT,
+        PREF_LOG_VIEWER_DISPLAY_LIMIT_INT,
+        PREF_LOG_VIEWER_EXPAND_BY_DEFAULT_BOOL,
+        PREF_LOG_VIEWER_FILTER_PATTERN_STR,
+        PREF_LOG_VIEWER_OMIT_SENSITIVE_INFO_BOOL,
+        PREF_LOG_VIEWER_SHOW_PID_TID_TIMESTAMP_BOOL,
+        PREF_LOG_VIEWER_WRITE_PERIOD_INT,
+
         PREF_MAIN_WINDOW_FILTER_FLAGS_INT,
         PREF_MAIN_WINDOW_FILTER_PROFILE_STR,
         PREF_MAIN_WINDOW_SORT_ORDER_INT,
         PREF_MAIN_WINDOW_SORT_REVERSE_BOOL,
+
         PREF_MODE_OF_OPS_STR,
         PREF_OPEN_PGP_PACKAGE_STR,
         PREF_OPEN_PGP_USER_ID_STR,
         PREF_PERMISSIONS_SORT_ORDER_INT,
         PREF_ROOT_MODE_ENABLED_BOOL,
+
         PREF_RUNNING_APPS_FILTER_FLAGS_INT,
         PREF_RUNNING_APPS_SORT_ORDER_INT,
+
         PREF_SIGNATURE_SCHEMES_INT,
         PREF_SHOW_DISCLAIMER_BOOL,
         PREF_USAGE_ACCESS_ENABLED_BOOL;
@@ -150,6 +172,7 @@ private static int inferType(@NonNull String typeName) {
     public static final int TYPE_LONG = 3;
     public static final int TYPE_STRING = 4;
 
+    @SuppressLint("StaticFieldLeak")
     private static AppPref appPref;
 
     @NonNull
@@ -171,67 +194,85 @@ public static Object get(PrefKey key) {
         AppPref appPref = getInstance();
         switch (PrefKey.types[index]) {
             case TYPE_BOOLEAN:
-                return appPref.preferences.getBoolean(PrefKey.keys[index], (boolean) appPref.getDefaultValue(PrefKey.prefKeyList.get(index)));
+                return appPref.preferences.getBoolean(PrefKey.keys[index], (boolean) appPref.getDefaultValue(key));
             case TYPE_FLOAT:
-                return appPref.preferences.getFloat(PrefKey.keys[index], (float) appPref.getDefaultValue(PrefKey.prefKeyList.get(index)));
+                return appPref.preferences.getFloat(PrefKey.keys[index], (float) appPref.getDefaultValue(key));
             case TYPE_INTEGER:
-                return appPref.preferences.getInt(PrefKey.keys[index], (int) appPref.getDefaultValue(PrefKey.prefKeyList.get(index)));
+                return appPref.preferences.getInt(PrefKey.keys[index], (int) appPref.getDefaultValue(key));
             case TYPE_LONG:
-                return appPref.preferences.getLong(PrefKey.keys[index], (long) appPref.getDefaultValue(PrefKey.prefKeyList.get(index)));
+                return appPref.preferences.getLong(PrefKey.keys[index], (long) appPref.getDefaultValue(key));
             case TYPE_STRING:
-                return Objects.requireNonNull(appPref.preferences.getString(PrefKey.keys[index], (String) appPref.getDefaultValue(PrefKey.prefKeyList.get(index))));
+                return Objects.requireNonNull(appPref.preferences.getString(PrefKey.keys[index],
+                        (String) appPref.getDefaultValue(key)));
         }
         throw new IllegalArgumentException("Unknown key or type.");
     }
 
+    public static boolean getBoolean(PrefKey key) {
+        return (boolean) get(key);
+    }
+
+    public static int getInt(PrefKey key) {
+        return (int) get(key);
+    }
+
+    @NonNull
+    public static String getString(PrefKey key) {
+        return (String) get(key);
+    }
+
     public static boolean isAdbEnabled() {
-        return getInstance().getBoolean(PrefKey.PREF_ADB_MODE_ENABLED_BOOL);
+        return getBoolean(PrefKey.PREF_ADB_MODE_ENABLED_BOOL);
     }
 
     public static boolean isGlobalBlockingEnabled() {
-        return getInstance().getBoolean(PrefKey.PREF_GLOBAL_BLOCKING_ENABLED_BOOL);
+        return getBoolean(PrefKey.PREF_GLOBAL_BLOCKING_ENABLED_BOOL);
     }
 
     public static boolean isRootEnabled() {
-        return getInstance().getBoolean(PrefKey.PREF_ROOT_MODE_ENABLED_BOOL);
+        return getBoolean(PrefKey.PREF_ROOT_MODE_ENABLED_BOOL);
     }
 
     public static boolean isRootOrAdbEnabled() {
         return isRootEnabled() || isAdbEnabled();
     }
 
+    @NonNull
+    public static ProxyFile getAppManagerDirectory() {
+        return new ProxyFile((String) get(AppPref.PrefKey.PREF_BACKUP_VOLUME_STR), "AppManager");
+    }
+
+    public static String getLanguage(Context context) {
+        AppPref appPref = getNewInstance(context);
+        PrefKey key = PrefKey.PREF_CUSTOM_LOCALE_STR;
+        return Objects.requireNonNull(appPref.preferences.getString(PrefKey.keys[PrefKey.indexOf(key)],
+                (String) appPref.getDefaultValue(key)));
+    }
+
     public static void set(PrefKey key, Object value) {
         getInstance().setPref(key, value);
     }
 
+    public static void setDefault(PrefKey key) {
+        AppPref appPref = getInstance();
+        appPref.setPref(key, appPref.getDefaultValue(key));
+    }
+
     @NonNull
     private final SharedPreferences preferences;
     @NonNull
     private final SharedPreferences.Editor editor;
 
+    private final Context context;
+
     @SuppressLint("CommitPrefEdits")
     private AppPref(@NonNull Context context) {
+        this.context = context;
         this.preferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
         editor = preferences.edit();
         init();
     }
 
-    public boolean getBoolean(PrefKey key) {
-        int index = PrefKey.indexOf(key);
-        return preferences.getBoolean(PrefKey.keys[index], (boolean) getDefaultValue(PrefKey.prefKeyList.get(index)));
-    }
-
-    public int getInt(PrefKey key) {
-        int index = PrefKey.indexOf(key);
-        return preferences.getInt(PrefKey.keys[index], (int) getDefaultValue(PrefKey.prefKeyList.get(index)));
-    }
-
-    @NonNull
-    public String getString(PrefKey key) {
-        int index = PrefKey.indexOf(key);
-        return Objects.requireNonNull(preferences.getString(PrefKey.keys[index], (String) getDefaultValue(PrefKey.prefKeyList.get(index))));
-    }
-
     public void setPref(PrefKey key, Object value) {
         int index = PrefKey.indexOf(key);
         if (value instanceof Boolean) editor.putBoolean(PrefKey.keys[index], (Boolean) value);
@@ -319,10 +360,13 @@ public Object getDefaultValue(@NonNull PrefKey key) {
             case PREF_BACKUP_ANDROID_KEYSTORE_BOOL:
             case PREF_ENABLE_SCREEN_LOCK_BOOL:
             case PREF_MAIN_WINDOW_SORT_REVERSE_BOOL:
+            case PREF_LOG_VIEWER_EXPAND_BY_DEFAULT_BOOL:
+            case PREF_LOG_VIEWER_OMIT_SENSITIVE_INFO_BOOL:
                 return false;
             case PREF_APP_OP_SHOW_DEFAULT_BOOL:
             case PREF_USAGE_ACCESS_ENABLED_BOOL:
             case PREF_SHOW_DISCLAIMER_BOOL:
+            case PREF_LOG_VIEWER_SHOW_PID_TID_TIMESTAMP_BOOL:
                 return true;
             case PREF_LAST_VERSION_CODE_LONG:
                 return 0L;
@@ -360,6 +404,16 @@ public Object getDefaultValue(@NonNull PrefKey key) {
                 return SigSchemes.SIG_SCHEME_V1 | SigSchemes.SIG_SCHEME_V2;
             case PREF_BACKUP_VOLUME_STR:
                 return Environment.getExternalStorageDirectory().getAbsolutePath();
+            case PREF_LOG_VIEWER_FILTER_PATTERN_STR:
+                return context.getString(R.string.pref_filter_pattern_default);
+            case PREF_LOG_VIEWER_DISPLAY_LIMIT_INT:
+                return 10_000;
+            case PREF_LOG_VIEWER_WRITE_PERIOD_INT:
+                return 200;
+            case PREF_LOG_VIEWER_DEFAULT_LOG_LEVEL_INT:
+                return Log.VERBOSE;
+            case PREF_LOG_VIEWER_BUFFER_INT:
+                return LogcatHelper.LOG_ID_MAIN | LogcatHelper.LOG_ID_SYSTEM | LogcatHelper.LOG_ID_CRASH;
         }
         throw new IllegalArgumentException("Pref key not found.");
     }
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/utils/LangUtils.java b/app/src/main/java/io/github/muntashirakon/AppManager/utils/LangUtils.java
index 638028e8f1e..dee8930f490 100644
--- a/app/src/main/java/io/github/muntashirakon/AppManager/utils/LangUtils.java
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/utils/LangUtils.java
@@ -65,7 +65,7 @@ public static ArrayMap<String, Locale> getAppLanguages(@NonNull Context context)
 
     @NonNull
     public static Locale getLocaleByLanguage(@NonNull Context context) {
-        String language = AppPref.getNewInstance(context).getString(AppPref.PrefKey.PREF_CUSTOM_LOCALE_STR);
+        String language = AppPref.getLanguage(context);
         getAppLanguages(context);
         Locale locale = sLocaleMap.get(language);
         return locale != null ? locale : sDefaultLocale;
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/utils/PackageUtils.java b/app/src/main/java/io/github/muntashirakon/AppManager/utils/PackageUtils.java
index 0cae7c31271..ec8b2fd3853 100644
--- a/app/src/main/java/io/github/muntashirakon/AppManager/utils/PackageUtils.java
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/utils/PackageUtils.java
@@ -77,7 +77,9 @@
 import io.github.muntashirakon.AppManager.appops.AppOpsService;
 import io.github.muntashirakon.AppManager.backup.MetadataManager;
 import io.github.muntashirakon.AppManager.db.entity.App;
+import io.github.muntashirakon.AppManager.ipc.IPCUtils;
 import io.github.muntashirakon.AppManager.ipc.ProxyBinder;
+import io.github.muntashirakon.AppManager.ipc.ps.ProcessEntry;
 import io.github.muntashirakon.AppManager.logs.Log;
 import io.github.muntashirakon.AppManager.main.ApplicationItem;
 import io.github.muntashirakon.AppManager.misc.OsEnvironment;
@@ -555,6 +557,18 @@ public static boolean hasRunningServices(String packageName) {
         return false;
     }
 
+    public static int getPidForPackage(String packageName, int uid) {
+        try {
+            @SuppressWarnings("unchecked")
+            List<ProcessEntry> processItems = (List<ProcessEntry>) IPCUtils.getAmService().getRunningProcesses();
+            for (ProcessEntry entry : processItems) {
+                if (entry.name.equals(packageName) && entry.users.fsUid == uid) return entry.pid;
+            }
+        } catch (Throwable ignore) {
+        }
+        return 0;
+    }
+
     @NonNull
     public static String getPackageLabel(@NonNull PackageManager pm, String packageName) {
         try {
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/utils/UIUtils.java b/app/src/main/java/io/github/muntashirakon/AppManager/utils/UIUtils.java
index d24aa442f37..fb091379d35 100644
--- a/app/src/main/java/io/github/muntashirakon/AppManager/utils/UIUtils.java
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/utils/UIUtils.java
@@ -215,6 +215,17 @@ public static AlertDialog getProgressDialog(@NonNull FragmentActivity activity,
                 .create();
     }
 
+    @NonNull
+    public static AlertDialog getProgressDialog(@NonNull FragmentActivity activity, @StringRes int text) {
+        View view = activity.getLayoutInflater().inflate(R.layout.dialog_progress, null);
+        TextView tv = view.findViewById(android.R.id.text1);
+        tv.setText(text);
+        return new MaterialAlertDialogBuilder(activity)
+                .setCancelable(false)
+                .setView(view)
+                .create();
+    }
+
     @NonNull
     public static SearchView setupSearchView(@NonNull Context context, @NonNull ActionBar actionBar,
                                              @Nullable SearchView.OnQueryTextListener queryTextListener) {
diff --git a/app/src/main/java/io/github/muntashirakon/io/ProxyFile.java b/app/src/main/java/io/github/muntashirakon/io/ProxyFile.java
index 1cd65a7d0eb..032be8fe023 100644
--- a/app/src/main/java/io/github/muntashirakon/io/ProxyFile.java
+++ b/app/src/main/java/io/github/muntashirakon/io/ProxyFile.java
@@ -142,6 +142,18 @@ public boolean isFile() {
         return super.isFile();
     }
 
+    @Override
+    public long lastModified() {
+        if (isRemoteAlive()) {
+            try {
+                //noinspection ConstantConditions
+                return file.lastModified();
+            } catch (RemoteException ignore) {
+            }
+        }
+        return super.lastModified();
+    }
+
     @Override
     public boolean mkdir() {
         if (isRemoteAlive()) {
diff --git a/app/src/main/res/drawable/ic_expand_less_white_24dp.xml b/app/src/main/res/drawable/ic_expand_less_white_24dp.xml
new file mode 100644
index 00000000000..b4f6fa12d31
--- /dev/null
+++ b/app/src/main/res/drawable/ic_expand_less_white_24dp.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportHeight="24.0"
+    android:viewportWidth="24.0">
+    <path
+        android:fillColor="?android:attr/colorAccent"
+        android:pathData="M12,8l-6,6 1.41,1.41L12,10.83l4.59,4.58L18,14z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_expand_more_white_24dp.xml b/app/src/main/res/drawable/ic_expand_more_white_24dp.xml
new file mode 100644
index 00000000000..513c8444ac7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_expand_more_white_24dp.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportHeight="24.0"
+    android:viewportWidth="24.0">
+    <path
+        android:fillColor="?android:attr/colorAccent"
+        android:pathData="M16.59,8.59L12,13.17 7.41,8.59 6,10l6,6 6,-6z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_filter_list_white_24dp.xml b/app/src/main/res/drawable/ic_filter_list_white_24dp.xml
new file mode 100644
index 00000000000..a1f0eb601bc
--- /dev/null
+++ b/app/src/main/res/drawable/ic_filter_list_white_24dp.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportHeight="24.0"
+    android:viewportWidth="24.0">
+    <path
+        android:fillColor="?android:attr/colorAccent"
+        android:pathData="M10,18h4v-2h-4v2zM3,6v2h18L21,6L3,6zM6,13h12v-2L6,11v2z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_folder_white_24dp.xml b/app/src/main/res/drawable/ic_folder_white_24dp.xml
new file mode 100644
index 00000000000..e4a5ee4be5e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_folder_white_24dp.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportHeight="24.0"
+    android:viewportWidth="24.0">
+    <path
+        android:fillColor="?android:attr/colorAccent"
+        android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_insert_drive_file_white_24dp.xml b/app/src/main/res/drawable/ic_insert_drive_file_white_24dp.xml
new file mode 100644
index 00000000000..65ec3e54825
--- /dev/null
+++ b/app/src/main/res/drawable/ic_insert_drive_file_white_24dp.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportHeight="24.0"
+    android:viewportWidth="24.0">
+    <path
+        android:fillColor="?android:attr/colorAccent"
+        android:pathData="M6,2c-1.1,0 -1.99,0.9 -1.99,2L4,20c0,1.1 0.89,2 1.99,2L18,22c1.1,0 2,-0.9 2,-2L20,8l-6,-6L6,2zM13,9L13,3.5L18.5,9L13,9z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_pause_white_24dp.xml b/app/src/main/res/drawable/ic_pause_white_24dp.xml
new file mode 100644
index 00000000000..43b0ace1c14
--- /dev/null
+++ b/app/src/main/res/drawable/ic_pause_white_24dp.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportHeight="24.0"
+    android:viewportWidth="24.0">
+    <path
+        android:fillColor="?android:attr/colorAccent"
+        android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_play_arrow_white_24dp.xml b/app/src/main/res/drawable/ic_play_arrow_white_24dp.xml
new file mode 100644
index 00000000000..a8cf65c05e2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_play_arrow_white_24dp.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportHeight="24.0"
+    android:viewportWidth="24.0">
+    <path
+        android:fillColor="?android:attr/colorAccent"
+        android:pathData="M8,5v14l11,-7z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_record_white_24dp.xml b/app/src/main/res/drawable/ic_record_white_24dp.xml
new file mode 100644
index 00000000000..752b3d855e8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_record_white_24dp.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:height="24dp"
+    android:viewportHeight="24.0"
+    android:viewportWidth="24.0"
+    android:width="24dp">
+    <path
+        android:fillColor="?android:attr/colorAccent"
+        android:pathData="M12,12m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml
new file mode 100644
index 00000000000..dafd9138571
--- /dev/null
+++ b/app/src/main/res/drawable/ic_search.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:height="24dp"
+    android:viewportHeight="24.0"
+    android:viewportWidth="24.0"
+    android:width="24dp">
+    <path
+        android:fillColor="?android:attr/colorAccent"
+        android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_shortcut_record.xml b/app/src/main/res/drawable/ic_shortcut_record.xml
new file mode 100644
index 00000000000..3bcbce309c1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_shortcut_record.xml
@@ -0,0 +1,12 @@
+<vector android:height="24dp"
+    android:viewportHeight="192.0"
+    android:viewportWidth="192.0"
+    android:width="24dp"
+    xmlns:android="http://schemas.android.com/apk/res/android">
+    <path
+        android:fillColor="#f5f5f5"
+        android:pathData="M84.58,8.82C104.35,6.14 124.96,10.56 141.94,21.02C161.23,32.78 175.65,52.22 181.18,74.13C186.3,93.99 184.24,115.64 175.23,134.09C165.68,154.19 148.15,170.29 127.33,178.15C107.39,185.87 84.59,185.85 64.65,178.15C43.16,170.05 25.16,153.11 15.84,132.11C7.61,114.09 5.86,93.24 10.84,74.08C16.31,52.4 30.49,33.15 49.48,21.37C60.1,14.76 72.16,10.39 84.58,8.82M89.44,64.58C80.72,66.28 73.17,72.14 68.48,79.58C64.15,86.48 62.98,95.1 64.68,103.01C66.42,111.3 71.95,118.48 78.94,123.11C86.22,127.94 95.59,129.19 104.01,127.08C111.9,125.14 118.67,119.75 123.12,113.04C127.97,105.73 129.19,96.33 127.06,87.89C125.02,79.82 119.43,72.92 112.46,68.51C105.69,64.23 97.24,63.04 89.44,64.58Z" />
+    <path
+        android:fillColor="#f44336"
+        android:pathData="M89.44,64.58C97.24,63.04 105.69,64.23 112.46,68.51C119.43,72.92 125.02,79.82 127.06,87.89C129.19,96.33 127.97,105.73 123.12,113.04C118.67,119.75 111.9,125.14 104.01,127.08C95.59,129.19 86.22,127.94 78.94,123.11C71.95,118.48 66.42,111.3 64.68,103.01C62.98,95.1 64.15,86.48 68.48,79.58C73.17,72.14 80.72,66.28 89.44,64.58Z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_sort_white_24dp.xml b/app/src/main/res/drawable/ic_sort_white_24dp.xml
new file mode 100644
index 00000000000..31b2689d537
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sort_white_24dp.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportHeight="24.0"
+    android:viewportWidth="24.0">
+    <path
+        android:fillColor="?android:attr/colorAccent"
+        android:pathData="M3,18h6v-2L3,16v2zM3,6v2h18L21,6L3,6zM3,13h12v-2L3,11v2z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_stop_white_24dp.xml b/app/src/main/res/drawable/ic_stop_white_24dp.xml
new file mode 100644
index 00000000000..5a311f1be55
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stop_white_24dp.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportHeight="24.0"
+    android:viewportWidth="24.0">
+    <path
+        android:fillColor="#757575"
+        android:pathData="M6,6h12v12H6z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_view_agenda_white_24dp.xml b/app/src/main/res/drawable/ic_view_agenda_white_24dp.xml
new file mode 100644
index 00000000000..feb7020cb74
--- /dev/null
+++ b/app/src/main/res/drawable/ic_view_agenda_white_24dp.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportHeight="24.0"
+    android:viewportWidth="24.0">
+    <path
+        android:fillColor="?android:attr/colorAccent"
+        android:pathData="M20,13L3,13c-0.55,0 -1,0.45 -1,1v6c0,0.55 0.45,1 1,1h17c0.55,0 1,-0.45 1,-1v-6c0,-0.55 -0.45,-1 -1,-1zM20,3L3,3c-0.55,0 -1,0.45 -1,1v6c0,0.55 0.45,1 1,1h17c0.55,0 1,-0.45 1,-1L21,4c0,-0.55 -0.45,-1 -1,-1z" />
+</vector>
diff --git a/app/src/main/res/layout/activity_logcat.xml b/app/src/main/res/layout/activity_logcat.xml
new file mode 100644
index 00000000000..eca7fcfaeb6
--- /dev/null
+++ b/app/src/main/res/layout/activity_logcat.xml
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2021 Muntashir Al-Islam
+  ~
+  ~ This program is free software: you can redistribute it and/or modify
+  ~ it under the terms of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your option) any later version.
+  ~
+  ~ This program is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  ~ GNU General Public License for more details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with this program.  If not, see <https://www.gnu.org/licenses/>.
+  -->
+<androidx.coordinatorlayout.widget.CoordinatorLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".logcat.LogViewerActivity">
+
+    <include layout="@layout/appbar" />
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/list"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:scrollbars="none"
+        tools:listitem="@layout/item_logcat"
+        app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" />
+
+    <com.google.android.material.floatingactionbutton.FloatingActionButton
+        android:id="@+id/fab"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="bottom|end"
+        android:layout_margin="@dimen/padding_medium"
+        android:visibility="gone"
+        app:borderWidth="0dp"
+        app:elevation="6dp"
+        app:pressedTranslationZ="12dp"
+        app:srcCompat="@drawable/ic_stop_white_24dp"
+        tools:visibility="visible" />
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_partial_save_help.xml b/app/src/main/res/layout/dialog_partial_save_help.xml
new file mode 100644
index 00000000000..c9781dddc70
--- /dev/null
+++ b/app/src/main/res/layout/dialog_partial_save_help.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="vertical"
+    android:paddingStart="@dimen/padding_medium"
+    android:paddingEnd="@dimen/padding_medium">
+
+    <TextView
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="@string/dialog_partial_select_explanation"
+        android:textSize="16sp" />
+
+    <CheckBox
+        android:id="@android:id/checkbox"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="@string/checkbox_never_show_partial_select_help" />
+
+
+</LinearLayout>
diff --git a/app/src/main/res/layout/dialog_searchby.xml b/app/src/main/res/layout/dialog_searchby.xml
new file mode 100644
index 00000000000..54144dddb0a
--- /dev/null
+++ b/app/src/main/res/layout/dialog_searchby.xml
@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:baselineAligned="false"
+    android:orientation="horizontal"
+    android:paddingStart="@dimen/padding_medium"
+    android:paddingEnd="@dimen/padding_medium">
+
+    <androidx.appcompat.widget.LinearLayoutCompat
+        android:id="@+id/dialog_searchby_tag_linear"
+        android:layout_width="0dp"
+        android:layout_height="match_parent"
+        android:layout_weight="1"
+        android:background="?selectableItemBackground"
+        android:orientation="vertical"
+        android:padding="@dimen/padding_small">
+
+        <TextView
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:ellipsize="marquee"
+            android:paddingBottom="8dp"
+            android:text="@string/filter_choice_tag"
+            android:textSize="16sp"
+            android:singleLine="true" />
+
+        <TextView
+            android:id="@+id/dialog_searchby_tag_text"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:ellipsize="marquee"
+            android:padding="8dp"
+            android:textSize="12sp"
+            android:typeface="monospace"
+            tools:text="text"
+            android:singleLine="true" />
+
+    </androidx.appcompat.widget.LinearLayoutCompat>
+
+    <androidx.appcompat.widget.LinearLayoutCompat
+        android:id="@+id/dialog_searchby_pid_linear"
+        android:layout_width="0dp"
+        android:layout_height="match_parent"
+        android:layout_weight="1"
+        android:background="?selectableItemBackground"
+        android:orientation="vertical"
+        android:padding="8dp">
+
+        <TextView
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:ellipsize="marquee"
+            android:paddingBottom="8dp"
+            android:text="@string/filter_choice_pid"
+            android:textSize="16sp"
+            android:singleLine="true" />
+
+        <TextView
+            android:id="@+id/dialog_searchby_pid_text"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:ellipsize="marquee"
+            android:padding="8dp"
+            android:textSize="12sp"
+            android:typeface="monospace"
+            tools:text="text"
+            android:singleLine="true" />
+
+    </androidx.appcompat.widget.LinearLayoutCompat>
+
+</androidx.appcompat.widget.LinearLayoutCompat>
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_send_log.xml b/app/src/main/res/layout/dialog_send_log.xml
new file mode 100644
index 00000000000..eb8d9b9e19b
--- /dev/null
+++ b/app/src/main/res/layout/dialog_send_log.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="vertical"
+    android:paddingEnd="@dimen/padding_medium"
+    android:paddingStart="@dimen/padding_medium">
+
+    <androidx.appcompat.widget.AppCompatCheckBox
+        android:id="@android:id/checkbox"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:checked="true"
+        android:text="@string/text_include_device_info" />
+
+    <androidx.appcompat.widget.AppCompatCheckBox
+        android:id="@+id/checkbox_dmesg"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:checked="true"
+        android:text="@string/text_include_dmesg" />
+
+</androidx.appcompat.widget.LinearLayoutCompat>
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_text_input.xml b/app/src/main/res/layout/dialog_text_input.xml
index 002f051913a..326c9675ecc 100644
--- a/app/src/main/res/layout/dialog_text_input.xml
+++ b/app/src/main/res/layout/dialog_text_input.xml
@@ -45,8 +45,7 @@
                 style="@style/AppTheme.TextAppearance.Input"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
-                android:singleLine="true"
-                tools:text="@tools:sample/full_names" />
+                tools:text="@tools:sample/lorem/random" />
 
         </com.google.android.material.textfield.TextInputLayout>
 
diff --git a/app/src/main/res/layout/header_logcat_add_filter.xml b/app/src/main/res/layout/header_logcat_add_filter.xml
new file mode 100644
index 00000000000..3f2a03bf1a9
--- /dev/null
+++ b/app/src/main/res/layout/header_logcat_add_filter.xml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2021 Muntashir Al-Islam
+  ~
+  ~ This program is free software: you can redistribute it and/or modify
+  ~ it under the terms of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your option) any later version.
+  ~
+  ~ This program is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  ~ GNU General Public License for more details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with this program.  If not, see <https://www.gnu.org/licenses/>.
+  -->
+<androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:paddingTop="@dimen/padding_small"
+    android:paddingBottom="@dimen/padding_small"
+    android:orientation="horizontal">
+
+    <ImageView
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_weight="0"
+        android:layout_gravity="center_vertical"
+        android:layout_marginStart="@dimen/padding_medium"
+        android:contentDescription="@string/add_filter"
+        app:srcCompat="@drawable/ic_baseline_add_24" />
+
+    <TextView
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_weight="1"
+        android:layout_gravity="center_vertical"
+        android:layout_marginEnd="@dimen/padding_medium"
+        android:layout_marginStart="@dimen/padding_medium"
+        android:ellipsize="marquee"
+        android:text="@string/add_filter_ellipsis"
+        android:textSize="16sp"
+        android:singleLine="true" />
+
+</androidx.appcompat.widget.LinearLayoutCompat>
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_checked_text_view.xml b/app/src/main/res/layout/item_checked_text_view.xml
index 6a4d7661be1..d762a097a48 100644
--- a/app/src/main/res/layout/item_checked_text_view.xml
+++ b/app/src/main/res/layout/item_checked_text_view.xml
@@ -17,6 +17,7 @@
   -->
 
 <CheckedTextView xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
     android:id="@android:id/text1"
     android:layout_width="match_parent"
     android:layout_height="wrap_content"
@@ -26,4 +27,5 @@
     android:paddingStart="@dimen/padding_small"
     android:paddingEnd="@dimen/padding_small"
     android:singleLine="true"
-    android:textSize="14sp" />
+    android:textSize="14sp"
+    tools:text="@tools:sample/lorem[5]" />
diff --git a/app/src/main/res/layout/item_logcat.xml b/app/src/main/res/layout/item_logcat.xml
new file mode 100644
index 00000000000..4a0ff197d88
--- /dev/null
+++ b/app/src/main/res/layout/item_logcat.xml
@@ -0,0 +1,87 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:foreground="?selectableItemBackground"
+    android:orientation="horizontal">
+
+    <TextView
+        android:id="@+id/log_level_text"
+        android:layout_width="wrap_content"
+        android:layout_height="match_parent"
+        android:layout_weight="0"
+        android:paddingStart="3dp"
+        android:paddingEnd="3dp"
+        android:paddingTop="4dp"
+        android:paddingBottom="4dp"
+        tools:background="#FFFFCC99"
+        android:gravity="center_vertical"
+        android:maxLines="1"
+        android:textSize="12sp"
+        android:typeface="monospace"
+        tools:text="D" />
+
+    <androidx.appcompat.widget.LinearLayoutCompat
+        android:id="@+id/log_content"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_weight="1"
+        android:paddingStart="3dp"
+        android:paddingEnd="3dp"
+        android:paddingTop="4dp"
+        android:paddingBottom="4dp"
+        android:orientation="vertical">
+
+        <androidx.appcompat.widget.LinearLayoutCompat
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content">
+
+            <TextView
+                android:id="@+id/timestamp_text"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:maxLines="1"
+                android:paddingEnd="5dp"
+                android:textSize="10sp"
+                android:typeface="monospace"
+                tools:text="@tools:sample/date/ddmmyy"
+                tools:ignore="RtlSymmetry,SmallSp" />
+
+            <TextView
+                android:id="@+id/pid_text"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:maxLines="1"
+                android:paddingEnd="5dp"
+                android:textSize="10sp"
+                android:typeface="monospace"
+                tools:ignore="RtlSymmetry,SmallSp"
+                tools:text="6177" />
+
+        </androidx.appcompat.widget.LinearLayoutCompat>
+
+        <TextView
+            android:id="@+id/tag_text"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:ellipsize="end"
+            android:maxLines="1"
+            android:textSize="14sp"
+            android:typeface="monospace"
+            tools:text="SomeActivity" />
+
+        <TextView
+            android:id="@+id/log_output_text"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:ellipsize="end"
+            android:singleLine="true"
+            android:textSize="12sp"
+            android:typeface="monospace"
+            android:textColor="?android:attr/textColorPrimary"
+            tools:text="@tools:sample/lorem/random" />
+
+    </androidx.appcompat.widget.LinearLayoutCompat>
+
+</androidx.appcompat.widget.LinearLayoutCompat>
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_title_action.xml b/app/src/main/res/layout/item_title_action.xml
index 013b8318c0a..1b8e2493db3 100644
--- a/app/src/main/res/layout/item_title_action.xml
+++ b/app/src/main/res/layout/item_title_action.xml
@@ -24,8 +24,6 @@
     android:layout_height="wrap_content"
     android:clickable="true"
     android:focusable="true"
-    android:paddingStart="@dimen/padding_medium"
-    android:paddingEnd="@dimen/padding_small"
     android:orientation="horizontal">
 
     <TextView
@@ -33,24 +31,26 @@
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
         android:layout_gravity="center_vertical"
-        android:layout_marginEnd="@dimen/padding_small"
+        android:layout_marginStart="@dimen/padding_medium"
+        android:layout_marginEnd="@dimen/padding_very_small"
         android:layout_weight="1"
         android:paddingTop="@dimen/padding_small"
         android:paddingBottom="@dimen/padding_small"
         android:textColor="?android:attr/textColorPrimary"
         android:textSize="@dimen/title_font"
-        tools:text="@tools:sample/lorem" />
+        tools:text="@tools:sample/lorem[5]" />
 
     <com.google.android.material.button.MaterialButton
         android:id="@+id/item_action"
         style="@android:style/Widget.Material.ImageButton"
-        android:layout_width="56dp"
-        android:layout_height="match_parent"
+        android:layout_width="48dp"
+        android:layout_height="48dp"
         android:layout_gravity="center"
         android:layout_weight="0"
         android:paddingTop="@dimen/padding_small"
         android:paddingBottom="@dimen/padding_small"
-        android:background="?attr/selectableItemBackground"
+        android:layout_marginEnd="@dimen/padding_small"
+        android:background="?attr/selectableItemBackgroundBorderless"
         app:icon="@drawable/ic_baseline_clear_24"
         app:iconGravity="textTop"
         app:iconPadding="0dp"
diff --git a/app/src/main/res/layout/widget_recording.xml b/app/src/main/res/layout/widget_recording.xml
new file mode 100644
index 00000000000..7e47d2b9c0a
--- /dev/null
+++ b/app/src/main/res/layout/widget_recording.xml
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/clickable_linear_layout"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <ImageView
+        android:layout_width="64dp"
+        android:layout_height="64dp"
+        android:layout_centerInParent="true"
+        android:contentDescription="@string/app_name"
+        android:src="@mipmap/ic_launcher"
+        app:tint="#8f312f"
+        android:tintMode="multiply"/>
+
+    <LinearLayout
+        android:layout_width="64dp"
+        android:layout_height="64dp"
+        android:layout_centerInParent="true"
+        android:gravity="end|bottom">
+
+        <ImageView
+            android:id="@+id/record_badge_image_view"
+            android:layout_width="16dp"
+            android:layout_height="16dp"
+            android:contentDescription="@string/record_log"
+            app:tint="#EF5350"
+            android:visibility="invisible"
+            app:srcCompat="@drawable/ic_record_white_24dp"
+            tools:visibility="visible" />
+
+    </LinearLayout>
+
+    <TextView
+        android:id="@+id/widget_subtext"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_centerInParent="true"
+        android:ellipsize="marquee"
+        android:fontFamily="sans-serif-condensed"
+        android:gravity="center"
+        android:text="@string/widget_start_recording"
+        android:textColor="@color/primary_text_default_material_dark"
+        android:textSize="14sp" />
+
+</RelativeLayout>
\ No newline at end of file
diff --git a/app/src/main/res/menu/activity_logcat_actions.xml b/app/src/main/res/menu/activity_logcat_actions.xml
new file mode 100644
index 00000000000..8f9328194a1
--- /dev/null
+++ b/app/src/main/res/menu/activity_logcat_actions.xml
@@ -0,0 +1,118 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2021 Muntashir Al-Islam
+  ~
+  ~ This program is free software: you can redistribute it and/or modify
+  ~ it under the terms of the GNU General Public License as published by
+  ~ the Free Software Foundation, either version 3 of the License, or
+  ~ (at your option) any later version.
+  ~
+  ~ This program is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  ~ GNU General Public License for more details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with this program.  If not, see <https://www.gnu.org/licenses/>.
+  -->
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <item
+        android:id="@+id/menu_play_pause"
+        android:icon="@drawable/ic_pause_white_24dp"
+        android:title="@string/pause_unpause"
+        app:showAsAction="always" />
+
+    <item
+        android:id="@+id/menu_expand_all"
+        android:icon="@drawable/ic_expand_more_white_24dp"
+        android:title="@string/expand_all"
+        app:showAsAction="always" />
+
+    <item
+        android:id="@+id/menu_clear"
+        android:icon="@drawable/ic_baseline_clear_24"
+        android:title="@string/clear"
+        app:showAsAction="ifRoom" />
+
+    <item
+        android:id="@+id/menu_manage_saving"
+        android:icon="@drawable/ic_insert_drive_file_white_24dp"
+        android:title="@string/file">
+
+        <menu>
+            <item
+                android:id="@+id/menu_open_log"
+                android:alphabeticShortcut="@string/open"
+                android:icon="@drawable/ic_folder_white_24dp"
+                android:title="@string/open" />
+
+            <item
+                android:id="@+id/menu_save_log"
+                android:alphabeticShortcut="@string/save"
+                android:icon="@drawable/ic_baseline_save_24"
+                android:title="@string/save" />
+
+            <item
+                android:id="@+id/menu_partial_select"
+                android:alphabeticShortcut="@string/menu_title_partial_select"
+                android:icon="@drawable/ic_view_agenda_white_24dp"
+                android:title="@string/menu_title_partial_select" />
+
+            <item
+                android:id="@+id/menu_record_log"
+                android:alphabeticShortcut="@string/start_recording_log"
+                android:icon="@drawable/ic_record_white_24dp"
+                android:title="@string/start_recording_log" />
+
+            <item
+                android:id="@+id/menu_save_as_log"
+                android:alphabeticShortcut="@string/save_as"
+                android:icon="@drawable/ic_baseline_save_24"
+                android:title="@string/save_as" />
+
+            <item
+                android:id="@+id/menu_delete_saved_log"
+                android:alphabeticShortcut="@string/manage_saved_logs"
+                android:icon="@drawable/ic_delete_black_24dp"
+                android:title="@string/manage_saved_logs" />
+        </menu>
+    </item>
+
+    <item
+        android:id="@+id/menu_log_level"
+        android:alphabeticShortcut="@string/log_level"
+        android:icon="@drawable/ic_sort_black_24dp"
+        android:title="@string/log_level" />
+
+    <item
+        android:id="@+id/menu_filters"
+        android:alphabeticShortcut="@string/title_filters"
+        android:icon="@drawable/ic_filter_list_white_24dp"
+        android:title="@string/title_filters" />
+
+    <item
+        android:id="@+id/menu_send_log_zip"
+        android:alphabeticShortcut="@string/share_log"
+        android:icon="@drawable/ic_share_black_24dp"
+        android:title="@string/share_log" />
+
+    <item
+        android:id="@+id/menu_save_log_zip"
+        android:alphabeticShortcut="@string/save_log_zip"
+        android:icon="@drawable/ic_baseline_save_24"
+        android:title="@string/save_log_zip" />
+
+    <item
+        android:id="@+id/menu_settings"
+        android:alphabeticShortcut="@string/settings"
+        android:icon="@drawable/ic_baseline_settings_24"
+        android:title="@string/settings" />
+
+    <item
+        android:id="@+id/menu_crazy_logger_service"
+        android:alphabeticShortcut="Crazy Logger"
+        android:title="Crazy Logger" />
+
+</menu>
\ No newline at end of file
diff --git a/app/src/main/res/menu/activity_main_actions.xml b/app/src/main/res/menu/activity_main_actions.xml
index 35e621ee8b3..23c56eeea5e 100644
--- a/app/src/main/res/menu/activity_main_actions.xml
+++ b/app/src/main/res/menu/activity_main_actions.xml
@@ -60,6 +60,11 @@
         android:icon="@drawable/ic_view_list_black_24dp"
         android:title="@string/profiles" />
 
+    <item
+        android:id="@+id/action_log_viewer"
+        android:icon="@drawable/ic_view_list_black_24dp"
+        android:title="@string/log_viewer" />
+
     <item
         android:id="@+id/action_apk_updater"
         android:icon="@drawable/ic_baseline_sync_24"
diff --git a/app/src/main/res/menu/activity_running_apps_popup_actions.xml b/app/src/main/res/menu/activity_running_apps_popup_actions.xml
index ae66d61f53d..5e6a6f800c5 100644
--- a/app/src/main/res/menu/activity_running_apps_popup_actions.xml
+++ b/app/src/main/res/menu/activity_running_apps_popup_actions.xml
@@ -33,4 +33,9 @@
         android:icon="@drawable/ic_block_black_24dp"
         android:title="@string/disable_background_run" />
 
+    <item
+        android:id="@+id/action_view_logs"
+        android:icon="@drawable/ic_block_black_24dp"
+        android:title="@string/view_logs" />
+
 </menu>
\ No newline at end of file
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
index 5e856a4777b..4cacca48cb9 100644
--- a/app/src/main/res/values/arrays.xml
+++ b/app/src/main/res/values/arrays.xml
@@ -161,4 +161,74 @@
         <item>@string/pkcs12_keystore</item>
         <item>@string/pem_and_pk8</item>
     </string-array>
+    <integer-array name="random_colors">
+        <item>@color/original_orange</item>
+        <item>@color/dark_orange</item>
+        <item>@color/pink</item>
+        <item>@color/ocean_blue</item>
+        <item>@color/pure_red</item>
+        <item>@color/bright_green</item>
+        <item>@color/electric_red</item>
+        <item>@color/deep_green</item>
+        <item>@color/power_blue</item>
+        <item>@color/purple_y</item>
+        <item>@color/yellow_gloves</item>
+        <item>@color/readhead</item>
+        <item>@color/sand_tan</item>
+        <item>@color/sand_tan_shadow</item>
+        <item>@color/night_blue</item>
+        <item>@color/night_blue_shadow</item>
+        <item>@color/coral_pink</item>
+        <item>@color/sleuthe_yellow</item>
+        <item>@color/pink_leaf</item>
+        <item>@color/grassy_green</item>
+        <item>@color/purple_mountains_majesty</item>
+        <item>@color/misty_mountain_pink</item>
+        <item>@color/factory_stone_purple</item>
+        <item>@color/green_treeline</item>
+        <item>@color/pink_highlight</item>
+        <item>@color/bluewater_lowlight</item>
+        <item>@color/yellow_background</item>
+        <item>@color/pink_red_circle</item>
+        <item>@color/orange_circle</item>
+        <item>@color/old_makeup_pink</item>
+        <item>@color/goldenrod_yellow</item>
+        <item>@color/bluebell_light_blue</item>
+        <item>@color/bold_2019_green</item>
+        <item>@color/lightning_blue</item>
+        <item>@color/lightning_purple</item>
+        <item>@color/apricot</item>
+        <item>@color/citrus</item>
+        <item>@color/left_blue</item>
+        <item>@color/right_blue_muted</item>
+        <item>@color/blue_green</item>
+        <item>@color/red_orange</item>
+        <item>@color/redder_than_you</item>
+        <item>@color/goldi_lots</item>
+        <item>@color/darker_gold</item>
+        <item>@color/silver_tongue</item>
+        <item>@color/the_brown_shirts</item>
+        <item>@color/blondey</item>
+        <item>@color/green_mountain</item>
+        <item>@color/blue_mountain</item>
+        <item>@color/light_blue_backdrop</item>
+        <item>@color/orange</item>
+        <item>@color/yellowbrite</item>
+        <item>@color/painful_red</item>
+        <item>@color/_35_years_old_purple</item>
+        <item>@color/lighter_purple_on_the_gradient</item>
+        <item>@color/shadow_purple_red</item>
+        <item>@color/green</item>
+        <item>@color/ironic_blues</item>
+        <item>@color/pinky_ring</item>
+        <item>@color/egg_yellows</item>
+    </integer-array>
+    <string-array name="log_levels">
+        <item>@string/log_level_verbose</item>
+        <item>@string/log_level_debug</item>
+        <item>@string/log_level_info</item>
+        <item>@string/log_level_warn</item>
+        <item>@string/log_level_error</item>
+        <item>@string/log_level_fatal</item>
+    </string-array>
 </resources>
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index cab2b2eac63..e89d85673e7 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -113,4 +113,22 @@
     <color name="blue_underling">#94f0f1</color>
     <color name="pinky_ring">#f2b1d8</color>
     <color name="egg_yellows">#ffdc6a</color>
+
+    <color name="android_theme_tag_color_01">#FF222222</color>
+    <color name="android_theme_tag_color_02">#FF996666</color>
+    <color name="android_theme_tag_color_03">#FFFFCC99</color>
+    <color name="android_theme_tag_color_04">#FFCC0000</color>
+    <color name="android_theme_tag_color_05">#FF666633</color>
+    <color name="android_theme_tag_color_06">#FF990099</color>
+    <color name="android_theme_tag_color_07">#FF006600</color>
+    <color name="android_theme_tag_color_08">#FF660066</color>
+    <color name="android_theme_tag_color_09">#FF003399</color>
+    <color name="android_theme_tag_color_10">#FFFF6633</color>
+    <color name="android_theme_tag_color_11">#FFFFFFFF</color>
+    <color name="android_theme_tag_color_12">#FFCC3333</color>
+    <color name="android_theme_tag_color_13">#FFFFFF00</color>
+    <color name="android_theme_tag_color_14">#FF0099CC</color>
+    <color name="android_theme_tag_color_15">#FF009933</color>
+    <color name="android_theme_tag_color_16">#FFCC3399</color>
+    <color name="android_theme_tag_color_17">#FFFFCC00</color>
 </resources>
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8fab53ea8b0..56bda95cd43 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -953,4 +953,97 @@
         <item quantity="one"><xliff:g id="selection_no" example="20">%1$d</xliff:g> selected</item>
         <item quantity="other"><xliff:g id="selection_no" example="20">%1$d</xliff:g> selected</item>
     </plurals>
+    <string name="log_viewer">Log Viewer</string>
+    <string name="pref_log_viewer_msg">Configure how logs are displayed.</string>
+    <string name="restart_log_viewer_to_see_changes">Restart log viewer window to see changes.</string>
+    <string name="filename">Filename</string>
+    <string name="log_level_debug">Debug</string>
+    <string name="log_level_error">Error</string>
+    <string name="log_level_info">Info</string>
+    <string name="log_level_verbose">Verbose</string>
+    <string name="log_level_warn">Warn</string>
+    <string name="log_level_fatal">Fatal</string>
+    <string name="pref_filter_pattern_default" translatable="false">ResourceType|memtrack|android.os.Debug|BufferItemConsumer|DPM.*|MDM.*|ChimeraUtils|BatteryExternalStats.*|chatty.*|DisplayPowerController|WidgetHelper|WearableService|DigitalWidget.*|^ANDR-PERF-.*</string>
+    <string name="pref_hide_partial_select_help" translatable="false">pref_hide_partial_select_help</string>
+    <string name="pref_include_device_info" translatable="false">device_info</string>
+    <string name="add_filter">Add Filter</string>
+    <string name="add_filter_ellipsis">Add Filter…</string>
+    <plurals name="are_you_sure">
+        <item quantity="one">%d file will be deleted</item>
+        <item quantity="other">%d files will be deleted</item>
+    </plurals>
+    <string name="checkbox_never_show_partial_select_help">Don\'t show again</string>
+    <string name="copy_to_clipboard">Copy to clipboard</string>
+    <string name="delete_all">Delete All</string>
+    <string name="delete_saved_log">Delete Saved Logs</string>
+    <string name="dialog_compiling_log">Compiling log…</string>
+    <string name="dialog_partial_select_explanation">1. Select the <b>first</b> line.\n2. Then select the <b>last</b> line.</string>
+    <string name="enter_filename">Enter filename</string>
+    <string name="enter_good_filename">Please enter a valid filename.</string>
+    <string name="filter_choice">Search by</string>
+    <string name="filter_choice_pid">PID</string>
+    <string name="filter_choice_tag">Tag</string>
+    <string name="log_cleared">Logs cleared</string>
+    <string name="log_level">Log Level</string>
+    <string name="log_recording_started">Log recording started.</string>
+    <string name="log_saved">Log saved.</string>
+    <string name="manage_saved_logs">Manage Saved Logs</string>
+    <string name="menu_title_partial_select">Save Partial</string>
+    <string name="no_saved_logs">No saved logs found.</string>
+    <string name="notification_subtext">Touch to stop recording</string>
+    <string name="notification_ticker">Log recording started</string>
+    <string name="notification_title">Log recording in progress</string>
+    <string name="open">Open</string>
+    <string name="pref_buffer_title">Log Buffer(s)</string>
+    <string name="pref_cat_advanced">Advanced</string>
+    <string name="pref_cat_appearance">Appearance</string>
+    <string name="pref_cat_configuration">Configuration</string>
+    <string name="pref_default_log_level_summary">Log level at startup, when opening files, and when recording.</string>
+    <string name="pref_default_log_level_title">Default Log Level</string>
+    <string name="pref_display_limit_summary">Show only the last %1$d logs in order to avoid out of memory errors.</string>
+    <string name="pref_display_limit_title">Log Display Limit</string>
+    <string name="pref_filter_pattern_summary">Filter out the selected tags from logs.</string>
+    <string name="pref_filter_pattern_title">Filter Out Tags</string>
+    <string name="pref_expanded_by_default_summary">Show full log text by default.</string>
+    <string name="pref_expanded_by_default_title">Expand by Default</string>
+    <string name="pref_log_line_period_error">Please enter an integer between 1 and 1000.</string>
+    <string name="pref_log_write_period_summary">When recording, write to SD card every %1$d lines.</string>
+    <string name="pref_log_write_period_title">Write Period</string>
+    <string name="pref_show_timestamp_summary">Show process id and timestamp when expanded.</string>
+    <string name="pref_show_timestamp_title">Show Pid &amp; Timestamp</string>
+    <string name="record_log">Record Log</string>
+    <string name="save_as">Save As…</string>
+    <string name="save_log">Save Log</string>
+    <string name="send_log_title">Send Log</string>
+    <string name="settings">Settings</string>
+    <string name="start_recording_log">Record</string>
+    <string name="subject_log_report">Log report</string>
+    <string name="text_filter_ellipsis">Filter…</string>
+    <string name="text_filter_text">Filter text</string>
+    <string name="text_include_device_info">Include device information</string>
+    <string name="title_filters">Filters</string>
+    <string name="pref_display_limit_hint">Please enter a valid number between %1$d and %2$d.</string>
+    <string name="toast_invalid_level">Invalid level name: %s</string>
+    <string name="toast_invalid_selection">Invalid selection. Please try again.</string>
+    <plurals name="toast_log_truncated">
+        <item quantity="one">Log too large, showing last %d line.</item>
+        <item quantity="other">Log too large, showing last %d lines.</item>
+    </plurals>
+    <string name="toast_started_select_partial">Select Partial mode enabled</string>
+    <string name="unable_to_save_log">Unable to save log. Did you enter a valid filename?</string>
+    <string name="widget_recording_in_progress">Recording…</string>
+    <string name="widget_start_recording">Record\nLog</string>
+    <string name="file">File</string>
+    <string name="expand_all">Expand All</string>
+    <string name="collapse_all">Collapse All</string>
+    <string name="pause_unpause">Play/Pause</string>
+    <string name="undo">Undo</string>
+    <string name="omit_sensitive_info">Omit sensitive info</string>
+    <string name="omit_sensitive_info_summary">Omit sensitive info like web urls, phone numbers or emails.</string>
+    <string name="text_include_dmesg">Include kernel log</string>
+    <string name="pref_include_dmesg" translatable="false">dmesg</string>
+    <string name="save_log_zip">Save zip</string>
+    <string name="share_log">Share</string>
+    <string name="view_logs">View logs</string>
+    <string name="running_services">Running Services</string>
 </resources>
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index d0a63835aab..e95358bae50 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -80,6 +80,7 @@
     <style name="AppTheme.AlertDialog" parent="ThemeOverlay.MaterialComponents.MaterialAlertDialog">
         <item name="colorPrimary">@color/colorAccent</item>
         <item name="colorAccent">@color/colorAccent</item>
+        <item name="colorOnPrimary">@color/colorPrimary</item>
         <item name="colorOnSurface">@color/colorPrimaryDark</item>
         <item name="shapeAppearanceOverlay">@style/AppTheme.AlertDialog.Rounded</item>
         <item name="android:checkedTextViewStyle">@style/AppTheme.AlertDialog.CheckedTextView</item>
diff --git a/app/src/main/res/xml/preferences_log_viewer.xml b/app/src/main/res/xml/preferences_log_viewer.xml
new file mode 100644
index 00000000000..2b64ebfea29
--- /dev/null
+++ b/app/src/main/res/xml/preferences_log_viewer.xml
@@ -0,0 +1,71 @@
+<?xml version="1.0" encoding="utf-8"?>
+<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools">
+
+    <PreferenceCategory
+        app:title="@string/pref_cat_appearance"
+        app:iconSpaceReserved="false">
+
+        <SwitchPreference
+            app:key="log_viewer_expand_by_default"
+            app:summary="@string/pref_expanded_by_default_summary"
+            app:title="@string/pref_expanded_by_default_title"
+            app:iconSpaceReserved="false" />
+
+        <SwitchPreference
+            app:key="log_viewer_show_pid_tid_timestamp"
+            app:summary="@string/pref_show_timestamp_summary"
+            app:title="@string/pref_show_timestamp_title"
+            app:iconSpaceReserved="false" />
+
+    </PreferenceCategory>
+
+    <PreferenceCategory
+        app:title="@string/pref_cat_configuration"
+        app:iconSpaceReserved="false" >
+
+        <Preference
+            app:key="log_viewer_buffer"
+            app:title="@string/pref_buffer_title"
+            tools:summary="main, system, crash"
+            app:iconSpaceReserved="false" />
+
+        <Preference
+            app:key="log_viewer_default_log_level"
+            app:title="@string/pref_default_log_level_title"
+            app:summary="@string/pref_default_log_level_summary"
+            app:iconSpaceReserved="false" />
+
+    </PreferenceCategory>
+
+    <PreferenceCategory
+        app:title="@string/pref_cat_advanced"
+        app:iconSpaceReserved="false" >
+
+        <Preference
+            app:key="log_viewer_write_period"
+            app:title="@string/pref_log_write_period_title"
+            tools:summary="@string/pref_log_write_period_summary"
+            app:iconSpaceReserved="false" />
+
+        <Preference
+            app:key="log_viewer_display_limit"
+            app:title="@string/pref_display_limit_title"
+            tools:summary="@string/pref_display_limit_summary"
+            app:iconSpaceReserved="false" />
+
+        <Preference
+            app:key="log_viewer_filter_pattern"
+            app:title="@string/pref_filter_pattern_title"
+            app:summary="@string/pref_filter_pattern_summary"
+            app:iconSpaceReserved="false" />
+
+        <SwitchPreference
+            app:key="log_viewer_omit_sensitive_info"
+            app:title="@string/omit_sensitive_info"
+            app:summary="@string/omit_sensitive_info_summary"
+            app:iconSpaceReserved="false" />
+
+    </PreferenceCategory>
+
+</PreferenceScreen>
diff --git a/app/src/main/res/xml/preferences_main.xml b/app/src/main/res/xml/preferences_main.xml
index 68759e94f18..88f8fd1e7dd 100644
--- a/app/src/main/res/xml/preferences_main.xml
+++ b/app/src/main/res/xml/preferences_main.xml
@@ -76,6 +76,13 @@
         android:fragment="io.github.muntashirakon.AppManager.settings.BackupRestorePreferences"
         app:iconSpaceReserved="false" />
 
+    <Preference
+        app:key="log_viewer_prefs"
+        app:title="@string/log_viewer"
+        app:summary="@string/pref_log_viewer_msg"
+        android:fragment="io.github.muntashirakon.AppManager.settings.LogViewerPreferences"
+        app:iconSpaceReserved="false" />
+
     <PreferenceCategory
         app:key="rules"
         app:title="@string/rules"
diff --git a/app/src/main/res/xml/recording_widget_info.xml b/app/src/main/res/xml/recording_widget_info.xml
new file mode 100644
index 00000000000..bc88eec7015
--- /dev/null
+++ b/app/src/main/res/xml/recording_widget_info.xml
@@ -0,0 +1,7 @@
+<!-- 21600000 = 6 hours in milliseconds -->
+<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
+    android:initialLayout="@layout/widget_recording"
+    android:minHeight="40dp"
+    android:minWidth="40dp"
+    android:resizeMode="none"
+    android:updatePeriodMillis="21600000" />
\ No newline at end of file
diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml
new file mode 100644
index 00000000000..9003d8c3f23
--- /dev/null
+++ b/app/src/main/res/xml/shortcuts.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shortcuts xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    tools:targetApi="n_mr1">
+
+    <shortcut
+        android:enabled="true"
+        android:icon="@drawable/ic_shortcut_record"
+        android:shortcutId="record"
+        android:shortcutLongLabel="@string/start_recording_log"
+        android:shortcutShortLabel="@string/start_recording_log">
+        <intent
+            android:action="android.intent.action.MAIN"
+            android:targetClass="io.github.muntashirakon.AppManager.logcat.LogViewerActivity"
+            android:targetPackage="io.github.muntashirakon.AppManager">
+
+            <extra
+                android:name="shortcut_action"
+                android:value="record" />
+
+        </intent>
+    </shortcut>
+
+</shortcuts>
\ No newline at end of file


Test patch: