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