diff --git a/app/build.gradle b/app/build.gradle
index c15f91c3740..3bbf403777d 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -129,6 +129,8 @@ dependencies {
// DB
implementation "androidx.room:room-runtime:${room_version}"
annotationProcessor "androidx.room:room-compiler:${room_version}"
+ implementation 'com.github.evrencoskun:TableView:v0.8.9.4'
+ implementation 'de.siegmar:fastcsv:2.2.1'
// FM
implementation "com.j256.simplemagic:simplemagic:${simplemagic_version}"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8cf1c2b26f4..a4c256c158b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -718,6 +718,10 @@
android:label="@string/files"
android:launchMode="singleTop"
android:taskAffinity="" />
+
> resultRows;
+ public final List resultColumns;
+ public final long totalRows;
+ public final int limit;
+ public final long startOffset;
+ @Nullable
+ public final String query;
+ @Nullable
+ public final String tableName;
+
+ public QueryResultData(@NonNull List> resultRows,
+ @NonNull List resultColumns,
+ long totalRows, int limit, long startOffset,
+ @Nullable String query, @Nullable String tableName) {
+ this.resultRows = resultRows;
+ this.resultColumns = resultColumns;
+ this.totalRows = totalRows;
+ this.limit = limit;
+ this.startOffset = startOffset;
+ this.query = query;
+ this.tableName = tableName;
+ }
+
+ @Nullable
+ public Long nextOffset() {
+ long nextOffset = startOffset + limit;
+ if (nextOffset < totalRows) {
+ return nextOffset;
+ }
+ // No next offset
+ return null;
+ }
+
+ @Nullable
+ public Long previousOffset() {
+ long previousOffset = startOffset - limit;
+ if (previousOffset < totalRows) {
+ if (previousOffset >= 0) {
+ return previousOffset;
+ }
+ // Candidate offset is less than 0.
+ // If start offset was not 0, start with 0
+ if (startOffset != 0) {
+ return 0L;
+ }
+ }
+ // Invalid offset
+ return null;
+ }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/sqlite/RecordItemHeader.java b/app/src/main/java/io/github/muntashirakon/AppManager/sqlite/RecordItemHeader.java
new file mode 100644
index 00000000000..5e0f85b1a9e
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/sqlite/RecordItemHeader.java
@@ -0,0 +1,14 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package io.github.muntashirakon.AppManager.sqlite;
+
+public class RecordItemHeader extends TableRecordItem {
+
+ public RecordItemHeader(String id) {
+ super(id, null);
+ }
+
+ public RecordItemHeader(String id, String data) {
+ super(id, data);
+ }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/sqlite/Sqlite3Database.java b/app/src/main/java/io/github/muntashirakon/AppManager/sqlite/Sqlite3Database.java
new file mode 100644
index 00000000000..7aac7c017be
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/sqlite/Sqlite3Database.java
@@ -0,0 +1,322 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package io.github.muntashirakon.AppManager.sqlite;
+
+import android.database.Cursor;
+import android.database.DatabaseUtils;
+import android.database.SQLException;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+
+import io.github.muntashirakon.AppManager.logs.Log;
+import io.github.muntashirakon.AppManager.self.filecache.FileCache;
+import io.github.muntashirakon.AppManager.utils.FileUtils;
+import io.github.muntashirakon.io.IoUtils;
+import io.github.muntashirakon.io.Path;
+
+public class Sqlite3Database implements Closeable {
+ public static final String TAG = Sqlite3Database.class.getSimpleName();
+
+ private final SQLiteDatabase db;
+ private final Path realPath;
+ @Nullable
+ private final File cachedPath;
+ private final FileCache fileCache = new FileCache();
+
+ public Sqlite3Database(@NonNull Path path, boolean create, boolean readOnly) throws IOException {
+ realPath = path;
+ File realFile = path.getFile();
+ cachedPath = getCachedPathIfRequired(create, readOnly);
+ // At this point, if cachedPath is null, realPath must be non-null
+ File neededFile = cachedPath != null ? cachedPath : Objects.requireNonNull(realFile);
+ int flags = 0;
+ if (create) flags |= SQLiteDatabase.CREATE_IF_NECESSARY;
+ if (readOnly) flags |= SQLiteDatabase.OPEN_READONLY;
+ db = SQLiteDatabase.openDatabase(neededFile.getAbsolutePath(), null, flags);
+ }
+
+ public SQLiteDatabase getDb() {
+ return db;
+ }
+
+ @NonNull
+ public ArrayList getTables() {
+ String sql = "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name";
+ Cursor res = db.rawQuery(sql, null);
+ ArrayList tables = new ArrayList<>();
+ while (res.moveToNext()) {
+ tables.add(res.getString(0));
+ }
+ res.close();
+ return tables;
+ }
+
+ public ArrayList getColumns(@NonNull String table) {
+ String sql = "PRAGMA table_info([" + table + "])";
+ Cursor cursor = db.rawQuery(sql, null);
+
+ ArrayList columns = new ArrayList<>();
+ while (cursor.moveToNext()) {
+ String name = cursor.getString(1);
+ String type = cursor.getString(2);
+ int nonNull = cursor.getInt(3);
+ String def = cursor.getString(4);
+ int pk = cursor.getInt(5);
+ TableColumn column = new TableColumn(name, type, nonNull, pk, def);
+ columns.add(column);
+ }
+ cursor.close();
+ return columns;
+ }
+
+ public int getColumnCount(@NonNull String table) {
+ String sql = "SELECT * FROM [" + table + "] LIMIT 1";
+ Cursor cursor = db.rawQuery(sql, null);
+ int cols = cursor.getColumnCount();
+ cursor.close();
+ return cols;
+ }
+
+ public String[] getColumnNames(String table) {
+ String sql = "PRAGMA table_info([" + table + "])";
+ Cursor cursor = db.rawQuery(sql, null);
+ String[] columns = new String[cursor.getCount()];
+ int i = 0;
+ while (cursor.moveToNext()) {
+ columns[i] = cursor.getString(1);
+ i++;
+ }
+ cursor.close();
+ return columns;
+ }
+
+ public long getRowCount(@NonNull String table) {
+ return DatabaseUtils.queryNumEntries(db, table);
+ }
+
+ public long getQueryRowCount(@NonNull String customQuery) {
+ Cursor cursor = db.rawQuery(customQuery, null);
+ int count = cursor.getCount();
+ cursor.close();
+ return count;
+ }
+
+ public QueryResultData getQueryData(@NonNull String query, int limit, long offsetFrom) {
+ return new QueryResultData(
+ runQuery(query, limit, offsetFrom),
+ getQueryColumns(query),
+ getQueryRowCount(query),
+ limit, offsetFrom, query, null);
+ }
+
+ public QueryResultData getTableData(@NonNull String tableName, int limit, long offsetFrom) {
+ return new QueryResultData(
+ getTableRows(tableName, limit, offsetFrom),
+ getColumns(tableName),
+ getRowCount(tableName),
+ limit, offsetFrom, null, tableName);
+ }
+
+ @NonNull
+ public List> getTableRows(@NonNull String tableName, int limit, long offsetFrom) {
+ String[] cols = getColumnNames(tableName);
+
+ StringBuilder sql = new StringBuilder().append("SELECT ");
+ for (int i = 0; i < cols.length; i++) {
+ sql.append(cols[i]);
+ if (i < cols.length - 1)
+ sql.append(", ");
+ }
+ sql.append(" FROM ").append(tableName)
+ .append(" LIMIT ").append(limit)
+ .append(" OFFSET ").append(offsetFrom);
+
+ Log.d(TAG, "Query: " + sql);
+
+ Cursor cursor = db.rawQuery(sql.toString(), null);
+ List> tableRows = new ArrayList<>();
+ while (cursor.moveToNext()) {
+ List colData = new ArrayList<>();
+ for (String col : cols) {
+ int colIndex = cursor.getColumnIndex(col);
+ String data = "";
+ try {
+ data = cursor.getString(cursor.getColumnIndex(col));
+ } catch (SQLException e) {
+ if (Objects.requireNonNull(e.getMessage()).contains("Unable to convert BLOB to string")) {
+ data = "(BLOB)";
+ }
+ }
+ colData.add(new TableRecordItem(String.valueOf(colIndex), data));
+ }
+ tableRows.add(colData);
+ }
+ cursor.close();
+ return tableRows;
+ }
+
+ private int getLimitPosition(@NonNull String query) {
+ return query.toLowerCase(Locale.ROOT).lastIndexOf(" limit ");
+ }
+
+ private String getLimitSubstr(@NonNull String query) {
+ int pos = getLimitPosition(query);
+ return pos >= 0 ?
+ query.substring(query.toLowerCase().lastIndexOf("limit", query.length())) : query;
+ }
+
+ public ArrayList getQueryColumns(String sql) {
+ String subSubstr = getLimitSubstr(sql);
+ int limitpos = getLimitPosition(sql);
+ StringBuilder columnSQL = new StringBuilder();
+ if (limitpos > 0 && !subSubstr.contains(")")) {
+ columnSQL.append(sql.substring(0, limitpos)).append(" LIMIT 1");
+ } else {
+ columnSQL.append(sql).append(" LIMIT 1");
+ }
+
+ ArrayList fields = new ArrayList<>();
+
+ Cursor res = db.rawQuery(columnSQL.toString(), null);
+ int colCount = res.getColumnCount();
+ while (res.moveToNext()) {
+ for (int i = 0; i < colCount; i++) {
+ TableColumn field = new TableColumn(res.getColumnName(i), getColumnDataType(res.getType(i)), 0, 0, null);
+ fields.add(field);
+ }
+ }
+
+ res.close();
+ return fields;
+ }
+
+ public List> runQuery(String query, int limit, long offsetFrom) throws SQLiteException {
+ String subSubstr = getLimitSubstr(query);
+ int limitpos = getLimitPosition(query);
+ StringBuilder customSQL = new StringBuilder();
+ int customQueryLimit = -1; //Moved from global var
+ if (limitpos > 0 && !subSubstr.contains(")")) {
+ customQueryLimit = Integer.parseInt(
+ subSubstr.toLowerCase().split("limit")[1].trim()
+ );
+ customSQL.append(query.substring(0, limitpos));
+ } else
+ customSQL.append(query);
+
+ customSQL.append(" LIMIT ");
+ if (customQueryLimit != -1) {
+ if (customQueryLimit < limit) {
+ customSQL.append(customQueryLimit);
+ } else if ((offsetFrom + offsetFrom) > customQueryLimit) {
+ customSQL.append(customQueryLimit - offsetFrom).append(" OFFSET ").append(offsetFrom);
+ } else {
+ customSQL.append(limit).append(" OFFSET ").append(offsetFrom);
+ }
+ } else {
+ customSQL.append(limit).append(" OFFSET ").append(offsetFrom);
+ }
+
+ Cursor cursor = db.rawQuery(customSQL.toString(), null);
+
+ List> Tabledata = new ArrayList<>();
+ while (cursor.moveToNext()) {
+ List colData = new ArrayList<>();
+ String data = "";
+ int colCount = cursor.getColumnCount();
+ for (int i = 0; i < colCount; i++) {
+ try {
+ data = cursor.getString(i);
+ } catch (SQLException e) {
+ if (Objects.requireNonNull(e.getMessage()).contains("Unable to convert BLOB to string"))
+ data = "(BLOB)";
+ }
+ colData.add(new TableRecordItem(String.valueOf(i), data));
+ }
+ Tabledata.add(colData);
+ }
+ cursor.close();
+ return Tabledata;
+ }
+
+ @Override
+ public void close() throws IOException {
+ boolean isRw = !db.isReadOnly();
+ db.close();
+ if (isRw && cachedPath != null) {
+ // Write the DB back to the real path
+ try (InputStream is = new FileInputStream(cachedPath);
+ OutputStream os = realPath.openOutputStream()) {
+ IoUtils.copy(is, os);
+ }
+ }
+ fileCache.close();
+ }
+
+ @Nullable
+ private File getCachedPathIfRequired(boolean create, boolean readOnly) throws IOException {
+ File file = realPath.getFile();
+ if (file != null) {
+ // Backed by a file
+ if (realPath.exists()) {
+ if ((readOnly && FileUtils.canRead(file)) || (!readOnly && file.canWrite())) {
+ Log.i(TAG, "Opening DB without caching.");
+ return null;
+ }
+ // Requires caching
+ return fileCache.getCachedFile(realPath);
+ }
+ // File does not exist
+ if (create && !file.createNewFile()) {
+ // Could not create new file, requires caching
+ return fileCache.createCachedFile("db");
+ }
+ if (file.exists()) {
+ // No caching required as the file was created
+ Log.i(TAG, "Opening DB without caching.");
+ return null;
+ }
+ // Caching required but the file didn't exist in first place
+ throw new FileNotFoundException(file + " does not exist");
+ }
+ // Not backed by a file, requires caching
+ if (realPath.exists()) {
+ return fileCache.getCachedFile(realPath);
+ }
+ // Path does not exist, create if necessary
+ if (create) {
+ return fileCache.createCachedFile("db");
+ }
+ // Caching required but the file didn't exist in first place
+ throw new FileNotFoundException(realPath + " does not exist");
+ }
+
+ public static String getColumnDataType(int type) {
+ switch (type) {
+ case 1:
+ return "INTEGER";
+ case 2:
+ return "FLOAT";
+ case 3:
+ return "STRING";
+ case 4:
+ return "BLOB";
+ default:
+ return "null";
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/sqlite/SqliteDbEditorActivity.java b/app/src/main/java/io/github/muntashirakon/AppManager/sqlite/SqliteDbEditorActivity.java
new file mode 100644
index 00000000000..4e27ee4d65b
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/sqlite/SqliteDbEditorActivity.java
@@ -0,0 +1,208 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package io.github.muntashirakon.AppManager.sqlite;
+
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.widget.AppCompatSpinner;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.evrencoskun.tableview.TableView;
+import com.evrencoskun.tableview.listener.ITableViewListener;
+import com.google.android.material.progressindicator.LinearProgressIndicator;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import io.github.muntashirakon.AppManager.BaseActivity;
+import io.github.muntashirakon.AppManager.R;
+import io.github.muntashirakon.AppManager.intercept.IntentCompat;
+import io.github.muntashirakon.AppManager.utils.UIUtils;
+import io.github.muntashirakon.util.UiUtils;
+
+public class SqliteDbEditorActivity extends BaseActivity implements ITableViewListener {
+ private SqliteDbTableViewAdapter mTableViewAdapter;
+ private AppCompatSpinner mTableSelectionSpinner;
+ private ArrayAdapter mTableSelectionSpinnerAdapter;
+ private LinearProgressIndicator mProgressIndicator;
+ private SqliteDbEditorViewModel mViewModel;
+ private final ActivityResultLauncher mCsvExporter = registerForActivityResult(
+ new ActivityResultContracts.CreateDocument("text/csv"),
+ uri -> {
+ if (uri == null) {
+ return;
+ }
+ mProgressIndicator.show();
+ mViewModel.exportTableAsCsv(uri);
+ }
+ );
+
+ private static List generateRowHeader(List> tableData, long offset) {
+ List rowHeader = new ArrayList<>();
+ long localOffset = offset;
+ for (long i = 0; i < tableData.size(); i++) {
+ rowHeader.add(new RecordItemHeader(String.valueOf(i), String.valueOf(localOffset += 1)));
+ }
+ return rowHeader;
+ }
+
+ @Override
+ protected void onAuthenticated(@Nullable Bundle savedInstanceState) {
+ setContentView(R.layout.activity_sqlite_db_editor);
+ setSupportActionBar(findViewById(R.id.toolbar));
+ mViewModel = new ViewModelProvider(this).get(SqliteDbEditorViewModel.class);
+ Uri dbUri = IntentCompat.getDataUri(getIntent());
+ if (dbUri == null) {
+ UIUtils.displayLongToast(R.string.failed_to_open_database);
+ finish();
+ return;
+ }
+
+ ActionBar actionBar = getSupportActionBar();
+ if (actionBar != null) {
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ actionBar.setTitle(dbUri.getLastPathSegment());
+ // TODO: 20/2/23 Add subtitle provided through extras
+ }
+
+ mProgressIndicator = findViewById(R.id.progress_linear);
+ mProgressIndicator.setVisibilityAfterHide(View.GONE);
+
+ mTableSelectionSpinner = findViewById(R.id.spinner);
+ mTableSelectionSpinnerAdapter = new ArrayAdapter<>(this, R.layout.item_checked_text_view);
+ mTableSelectionSpinner.setAdapter(mTableSelectionSpinnerAdapter);
+ mTableSelectionSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView> parent, View view, int position, long id) {
+ if (position == mTableSelectionSpinnerAdapter.getCount() - 1) {
+ // TODO: 21/2/23 Display custom query prompt
+ return;
+ }
+ String tableName = mTableSelectionSpinnerAdapter.getItem(position);
+ if (tableName != null) {
+ mViewModel.loadTable(tableName, 0);
+ }
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView> parent) {
+ }
+ });
+ TableView tableView = findViewById(R.id.content);
+ UiUtils.applyWindowInsetsAsPaddingNoTop(tableView);
+ mTableViewAdapter = new SqliteDbTableViewAdapter(this);
+ tableView.setAdapter(mTableViewAdapter);
+ tableView.setTableViewListener(this);
+
+ mViewModel.getTableNames().observe(this, tables -> {
+ mTableSelectionSpinnerAdapter.setNotifyOnChange(false);
+ mTableSelectionSpinnerAdapter.clear();
+ mTableSelectionSpinnerAdapter.addAll(tables);
+ // Last position = Custom query
+ mTableSelectionSpinnerAdapter.add("Custom query"); // TODO: 21/2/23 Add localization
+ mTableSelectionSpinnerAdapter.notifyDataSetChanged();
+ });
+
+ mViewModel.getQueryResult().observe(this, queryResultData -> {
+ mProgressIndicator.hide();
+ // Set spinner
+ int selectedPosition;
+ if (queryResultData.tableName != null) {
+ selectedPosition = mTableSelectionSpinnerAdapter.getPosition(queryResultData.tableName);
+ } else {
+ // Custom query
+ selectedPosition = mTableSelectionSpinnerAdapter.getCount() - 1;
+ }
+ mTableSelectionSpinner.setSelection(selectedPosition);
+ // Set rows
+ List columnHeaders = new ArrayList<>();
+ for (TableColumn field : queryResultData.resultColumns) {
+ columnHeaders.add(new ColumnHeader("1", field.getHeaderName()));
+ }
+ List RowHeader = generateRowHeader(queryResultData.resultRows, queryResultData.startOffset);
+ mTableViewAdapter.setAllItems(columnHeaders, RowHeader, queryResultData.resultRows);
+ });
+
+ mViewModel.openDb(dbUri);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(@NonNull Menu menu) {
+ getMenuInflater().inflate(R.menu.activity_sqlite_db_editor_actions, menu);
+ return super.onCreateOptionsMenu(menu);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(@NonNull MenuItem item) {
+ int itemId = item.getItemId();
+ if (itemId == android.R.id.home) {
+ finish();
+ } else if (itemId == R.id.action_export) {
+ QueryResultData data = mViewModel.getLastQueryResult();
+ String filename;
+ if (data != null && data.tableName != null) {
+ filename = data.tableName + ".csv";
+ } else filename = "custom_query.csv";
+ mCsvExporter.launch(filename);
+ } else return super.onOptionsItemSelected(item);
+ return true;
+ }
+
+ @Override
+ public void onCellClicked(@NonNull RecyclerView.ViewHolder cellView, int column, int row) {
+
+ }
+
+ @Override
+ public void onCellDoubleClicked(@NonNull RecyclerView.ViewHolder cellView, int column, int row) {
+
+ }
+
+ @Override
+ public void onCellLongPressed(@NonNull RecyclerView.ViewHolder cellView, int column, int row) {
+
+ }
+
+ @Override
+ public void onColumnHeaderClicked(@NonNull RecyclerView.ViewHolder columnHeaderView, int column) {
+
+ }
+
+ @Override
+ public void onColumnHeaderDoubleClicked(@NonNull RecyclerView.ViewHolder columnHeaderView, int column) {
+
+ }
+
+ @Override
+ public void onColumnHeaderLongPressed(@NonNull RecyclerView.ViewHolder columnHeaderView, int column) {
+
+ }
+
+ @Override
+ public void onRowHeaderClicked(@NonNull RecyclerView.ViewHolder rowHeaderView, int row) {
+
+ }
+
+ @Override
+ public void onRowHeaderDoubleClicked(@NonNull RecyclerView.ViewHolder rowHeaderView, int row) {
+
+ }
+
+ @Override
+ public void onRowHeaderLongPressed(@NonNull RecyclerView.ViewHolder rowHeaderView, int row) {
+
+ }
+}
+
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/sqlite/SqliteDbEditorViewModel.java b/app/src/main/java/io/github/muntashirakon/AppManager/sqlite/SqliteDbEditorViewModel.java
new file mode 100644
index 00000000000..915690e4036
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/sqlite/SqliteDbEditorViewModel.java
@@ -0,0 +1,141 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package io.github.muntashirakon.AppManager.sqlite;
+
+import android.app.Application;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.AndroidViewModel;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import de.siegmar.fastcsv.writer.CsvWriter;
+import io.github.muntashirakon.io.Paths;
+
+public class SqliteDbEditorViewModel extends AndroidViewModel {
+ private final ExecutorService mExecutor = Executors.newFixedThreadPool(2);
+ private final MutableLiveData mQueryResult = new MutableLiveData<>();
+ private final MutableLiveData> mTableNames = new MutableLiveData<>();
+
+ @Nullable
+ private Sqlite3Database db;
+ @Nullable
+ private ArrayList tables;
+ @Nullable
+ private QueryResultData lastQueryResult;
+ private final int maxRowsPerPage = 300; // TODO: 20/2/23 Load from settings
+
+ public SqliteDbEditorViewModel(@NonNull Application application) {
+ super(application);
+ }
+
+ @Override
+ protected void onCleared() {
+ mExecutor.shutdownNow();
+ super.onCleared();
+ }
+
+ public LiveData getQueryResult() {
+ return mQueryResult;
+ }
+
+ public LiveData> getTableNames() {
+ return mTableNames;
+ }
+
+ @Nullable
+ public QueryResultData getLastQueryResult() {
+ return lastQueryResult;
+ }
+
+ public void openDb(@NonNull Uri dbUri) {
+ mExecutor.submit(() -> {
+ try {
+ db = new Sqlite3Database(Paths.get(dbUri), false, true);
+ tables = db.getTables();
+ mTableNames.postValue(tables);
+ // Load the first table
+ String firstTable = !tables.isEmpty() ? tables.get(0) : null;
+ if (firstTable != null) {
+ lastQueryResult = db.getTableData(firstTable, maxRowsPerPage, 0);
+ mQueryResult.postValue(lastQueryResult);
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ });
+ }
+
+ public void loadTable(@NonNull String tableName, long startOffset) {
+ mExecutor.submit(() -> {
+ if (db != null) {
+ lastQueryResult = db.getTableData(tableName, maxRowsPerPage, startOffset);
+ mQueryResult.postValue(lastQueryResult);
+ }
+ });
+ }
+
+ public void loadCustomQuery(@NonNull String sql, long startOffset) {
+ mExecutor.submit(() -> {
+ if (db != null) {
+ lastQueryResult = db.getQueryData(sql, maxRowsPerPage, startOffset);
+ mQueryResult.postValue(lastQueryResult);
+ }
+ });
+ }
+
+ public void refreshLastQuery() {
+ mExecutor.submit(() -> {
+ if (db != null && lastQueryResult != null) {
+ if (lastQueryResult.tableName != null) {
+ lastQueryResult = db.getTableData(lastQueryResult.tableName, lastQueryResult.limit, lastQueryResult.startOffset);
+ } else if (lastQueryResult.query != null) {
+ lastQueryResult = db.getQueryData(lastQueryResult.query, lastQueryResult.limit, lastQueryResult.startOffset);
+ } else {
+ throw new IllegalStateException("Neither table nor custom query was defined in the last query.");
+ }
+ mQueryResult.postValue(lastQueryResult);
+ }
+ });
+ }
+
+ public void exportTableAsCsv(@NonNull Uri csvUri) {
+ mExecutor.submit(() -> {
+ if (lastQueryResult == null) {
+ return;
+ }
+ try (CsvWriter br = CsvWriter.builder().build(new PrintWriter(Paths.get(csvUri).openOutputStream(), true))) {
+ // Write header
+ List columns = new ArrayList<>(lastQueryResult.resultColumns.size());
+ for (TableColumn column : lastQueryResult.resultColumns) {
+ columns.add(column.name);
+ }
+ br.writeRow(columns);
+ // Write data
+ for (List tableRecordItems : lastQueryResult.resultRows) {
+ // Write per row
+ List row = new ArrayList<>(tableRecordItems.size());
+ for (TableRecordItem tableRecordItem : tableRecordItems) {
+ if (tableRecordItem.data == null) {
+ row.add(null);
+ } else {
+ row.add(tableRecordItem.data.toString());
+ }
+ }
+ br.writeRow(row);
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/sqlite/SqliteDbTableViewAdapter.java b/app/src/main/java/io/github/muntashirakon/AppManager/sqlite/SqliteDbTableViewAdapter.java
new file mode 100644
index 00000000000..78dd1c6c8d5
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/sqlite/SqliteDbTableViewAdapter.java
@@ -0,0 +1,141 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package io.github.muntashirakon.AppManager.sqlite;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.evrencoskun.tableview.adapter.AbstractTableAdapter;
+import com.evrencoskun.tableview.adapter.recyclerview.holder.AbstractViewHolder;
+
+import io.github.muntashirakon.AppManager.R;
+
+public class SqliteDbTableViewAdapter extends AbstractTableAdapter {
+ private final Context context;
+
+ public SqliteDbTableViewAdapter(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ @NonNull
+ public AbstractViewHolder onCreateCellViewHolder(@NonNull ViewGroup parent, int viewType) {
+ View layout = LayoutInflater.from(context).inflate(R.layout.table_view_cell, parent, false);
+ return new CellViewHolder(layout);
+ }
+
+ @Override
+ public void onBindCellViewHolder(@NonNull AbstractViewHolder holder, @Nullable TableRecordItem cellItemModel,
+ int columnPosition, int rowPosition) {
+ CellViewHolder viewHolder = (CellViewHolder) holder;
+ viewHolder.textView.setText((String) cellItemModel.data);
+ // Resize
+ viewHolder.ItemView.getLayoutParams().width = LinearLayout.LayoutParams.WRAP_CONTENT;
+ viewHolder.textView.requestLayout();
+ }
+
+ @Override
+ @NonNull
+ public AbstractViewHolder onCreateColumnHeaderViewHolder(@NonNull ViewGroup parent, int viewType) {
+ View layout = LayoutInflater.from(context).inflate(R.layout.table_view_column_header, parent, false);
+ return new ColumnHeaderViewHolder(layout);
+ }
+
+ @Override
+ public void onBindColumnHeaderViewHolder(@NonNull AbstractViewHolder holder,
+ @Nullable ColumnHeader columnHeaderItemModel, int columnPosition) {
+ ColumnHeaderViewHolder columnHeaderViewHolder = (ColumnHeaderViewHolder) holder;
+ columnHeaderViewHolder.textView.setText((String) columnHeaderItemModel.data);
+ // Resize
+ columnHeaderViewHolder.container.getLayoutParams().width = LinearLayout.LayoutParams.WRAP_CONTENT;
+ columnHeaderViewHolder.textView.requestLayout();
+ }
+
+ @Override
+ @NonNull
+ public AbstractViewHolder onCreateRowHeaderViewHolder(@NonNull ViewGroup parent, int viewType) {
+ View layout = LayoutInflater.from(context).inflate(R.layout.table_view_row_header, parent, false);
+ return new RowHeaderViewHolder(layout);
+ }
+
+ @Override
+ public void onBindRowHeaderViewHolder(@NonNull AbstractViewHolder holder, @Nullable RecordItemHeader rowHeaderItemModel,
+ int rowPosition) {
+ RowHeaderViewHolder rowHeaderViewHolder = (RowHeaderViewHolder) holder;
+ rowHeaderViewHolder.textView.setText((String) rowHeaderItemModel.data);
+ }
+
+ @NonNull
+ @Override
+ public View onCreateCornerView(@NonNull ViewGroup parent) {
+ return LayoutInflater.from(context).inflate(R.layout.table_view_corner, parent, false);
+ }
+
+ @Override
+ public int getColumnHeaderItemViewType(int columnPosition) {
+ // The unique ID for this type of column header item
+ // If you have different items for Cell View by X (Column) position,
+ // then you should fill this method to be able to create different
+ // type of CellViewHolder on "onCreateCellViewHolder"
+ return 0;
+ }
+
+ @Override
+ public int getRowHeaderItemViewType(int rowPosition) {
+ // The unique ID for this type of row header item
+ // If you have different items for Row Header View by Y (Row) position,
+ // then you should fill this method to be able create different
+ // type of RowHeaderViewHolder on "onCreateRowHeaderViewHolder"
+ return 0;
+ }
+
+ @Override
+ public int getCellItemViewType(int columnPosition) {
+ // The unique ID for this type of cell item
+ // If you have different items for Cell View by X (Column) position,
+ // then you should fill this method to be able create different
+ // type of CellViewHolder on "onCreateCellViewHolder"
+ return 0;
+ }
+
+ public static final class CellViewHolder extends AbstractViewHolder {
+ public TextView textView;
+ public LinearLayout ItemView;
+
+ public CellViewHolder(View itemView) {
+ super(itemView);
+ textView = itemView.findViewById(R.id.cell_data);
+ ItemView = itemView.findViewById(R.id.cell_container);
+ }
+ }
+
+ static class ColumnHeaderViewHolder extends AbstractViewHolder {
+
+ public final TextView textView;
+ public final LinearLayout container;
+
+ public ColumnHeaderViewHolder(View itemView) {
+ super(itemView);
+ textView = itemView.findViewById(R.id.column_header_textView);
+ container = itemView.findViewById(R.id.column_header_container);
+ }
+ }
+
+ static class RowHeaderViewHolder extends AbstractViewHolder {
+
+ public final TextView textView;
+
+ public RowHeaderViewHolder(View itemView) {
+ super(itemView);
+ textView = itemView.findViewById(R.id.row_header_textview);
+ }
+ }
+}
+
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/sqlite/TableColumn.java b/app/src/main/java/io/github/muntashirakon/AppManager/sqlite/TableColumn.java
new file mode 100644
index 00000000000..154d7cad96f
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/sqlite/TableColumn.java
@@ -0,0 +1,23 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package io.github.muntashirakon.AppManager.sqlite;
+
+public class TableColumn {
+ public final String name;
+ public final String type;
+ public final int nonNull;
+ public final int pk;
+ public final String def;
+
+ public TableColumn(String name, String type, int nonNull, int pk, String def) {
+ this.name = name;
+ this.type = type;
+ this.nonNull = nonNull;
+ this.pk = pk;
+ this.def = def;
+ }
+
+ public String getHeaderName() {
+ return name + " (" + type + ")";
+ }
+}
diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/sqlite/TableRecordItem.java b/app/src/main/java/io/github/muntashirakon/AppManager/sqlite/TableRecordItem.java
new file mode 100644
index 00000000000..6c99c41b8ea
--- /dev/null
+++ b/app/src/main/java/io/github/muntashirakon/AppManager/sqlite/TableRecordItem.java
@@ -0,0 +1,31 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package io.github.muntashirakon.AppManager.sqlite;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.evrencoskun.tableview.sort.ISortableModel;
+
+public class TableRecordItem implements ISortableModel {
+ @NonNull
+ public final String id;
+ public final Object data;
+
+ public TableRecordItem(@NonNull String id, Object data) {
+ this.id = id;
+ this.data = data;
+ }
+
+ @NonNull
+ @Override
+ public String getId() {
+ return id;
+ }
+
+ @Nullable
+ @Override
+ public Object getContent() {
+ return data;
+ }
+}
diff --git a/app/src/main/res/layout/activity_sqlite_db_editor.xml b/app/src/main/res/layout/activity_sqlite_db_editor.xml
new file mode 100644
index 00000000000..d484b997237
--- /dev/null
+++ b/app/src/main/res/layout/activity_sqlite_db_editor.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/table_view_cell.xml b/app/src/main/res/layout/table_view_cell.xml
new file mode 100644
index 00000000000..298845d6dde
--- /dev/null
+++ b/app/src/main/res/layout/table_view_cell.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/table_view_column_header.xml b/app/src/main/res/layout/table_view_column_header.xml
new file mode 100644
index 00000000000..13fb542799e
--- /dev/null
+++ b/app/src/main/res/layout/table_view_column_header.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/table_view_corner.xml b/app/src/main/res/layout/table_view_corner.xml
new file mode 100644
index 00000000000..4be8f43e318
--- /dev/null
+++ b/app/src/main/res/layout/table_view_corner.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/table_view_row_header.xml b/app/src/main/res/layout/table_view_row_header.xml
new file mode 100644
index 00000000000..08fa0b619ae
--- /dev/null
+++ b/app/src/main/res/layout/table_view_row_header.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/activity_sqlite_db_editor_actions.xml b/app/src/main/res/menu/activity_sqlite_db_editor_actions.xml
new file mode 100644
index 00000000000..a44d3f0b6fc
--- /dev/null
+++ b/app/src/main/res/menu/activity_sqlite_db_editor_actions.xml
@@ -0,0 +1,11 @@
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c286c29c199..87edbb33729 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -136,6 +136,7 @@
\n- Open Keychain
\n- SAI
\n- Shizuku
+ \n- SqliteViewer
\n- Watt
\nOriginal logo credit: Atrate.
@@ -1335,4 +1336,7 @@
- Could not optimize %1$d app
- Could not optimize %1$d apps
+ Could not open the database.
+ SQLite DB Editor
+ Export CSV