diff --git a/config.xml b/config.xml index fc3b343..b3d0d62 100644 --- a/config.xml +++ b/config.xml @@ -99,4 +99,5 @@ + diff --git a/package-lock.json b/package-lock.json index 8fda849..744cdf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2413,6 +2413,19 @@ "resolved": "https://registry.npmjs.org/cordova-plugin-whitelist/-/cordova-plugin-whitelist-1.3.3.tgz", "integrity": "sha1-tehezbv+Wu3tQKG/TuI3LmfZb7Q=" }, + "cordova-sqlite-storage": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/cordova-sqlite-storage/-/cordova-sqlite-storage-2.5.1.tgz", + "integrity": "sha512-RMZcheSs9ihxcXUEmcAg8inG0UHZ5rQKQZqLY40jFES8rpiH5/sYeqaTmnuATx5w2apGB7fFLQHWMG2qaxAVtw==", + "requires": { + "cordova-sqlite-storage-dependencies": "1.2.0" + } + }, + "cordova-sqlite-storage-dependencies": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cordova-sqlite-storage-dependencies/-/cordova-sqlite-storage-dependencies-1.2.0.tgz", + "integrity": "sha512-lJl5uJFrCWrCYYGhpSEXe6sepjgOOzbeh8fFur3LqaSRvx+xFNYtfMYumE0+xqZwSmPODzex+y6I7ixwcBn73Q==" + }, "core-js": { "version": "2.5.5", "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.5.tgz", @@ -4601,7 +4614,7 @@ }, "ionic": { "version": "3.20.0", - "resolved": "https://registry.npmjs.org/ionic/-/ionic-3.20.0.tgz", + "resolved": "http://registry.npmjs.org/ionic/-/ionic-3.20.0.tgz", "integrity": "sha512-yeLPusYOSyF+VmO+Hf2a5kf2Kx4ST1f3MILM8g+9ckF/MdaoD9UzXif2/sumGem6I6RTrqo9horBmC7QJYcClA==", "dev": true, "requires": { diff --git a/package.json b/package.json index 934b178..7003882 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "cordova-plugin-splashscreen": "^5.0.2", "cordova-plugin-telerik-imagepicker": "^2.1.8", "cordova-plugin-whitelist": "^1.3.3", + "cordova-sqlite-storage": "2.5.1", "ionic-angular": "3.9.2", "ionicons": "4.2.4", "moment": "^2.18.1", @@ -72,7 +73,8 @@ "cordova-android-support-gradle-release": { "ANDROID_SUPPORT_VERSION": "27.+" }, - "cordova-plugin-filepath": {} + "cordova-plugin-filepath": {}, + "cordova-sqlite-storage": {} }, "platforms": [ "android" diff --git a/platforms/android/android.json b/platforms/android/android.json index 9c6bd98..be09320 100644 --- a/platforms/android/android.json +++ b/platforms/android/android.json @@ -49,6 +49,10 @@ { "xml": "", "count": 1 + }, + { + "xml": "", + "count": 1 } ] } @@ -135,6 +139,9 @@ }, "cordova-plugin-filepath": { "PACKAGE_NAME": "com.monkeystew.goober_m" + }, + "cordova-sqlite-storage": { + "PACKAGE_NAME": "com.monkeystew.goober_m" } }, "dependent_plugins": {}, @@ -384,6 +391,14 @@ "clobbers": [ "window.FilePath" ] + }, + { + "id": "cordova-sqlite-storage.SQLitePlugin", + "file": "plugins/cordova-sqlite-storage/www/SQLitePlugin.js", + "pluginId": "cordova-sqlite-storage", + "clobbers": [ + "SQLitePlugin" + ] } ], "plugin_metadata": { @@ -397,6 +412,7 @@ "cordova-plugin-splashscreen": "5.0.2", "cordova-plugin-device": "2.0.2", "cordova-android-support-gradle-release": "1.4.4", - "cordova-plugin-filepath": "1.4.2" + "cordova-plugin-filepath": "1.4.2", + "cordova-sqlite-storage": "2.5.1" } } \ No newline at end of file diff --git a/platforms/android/app/libs/sqlite-connector.jar b/platforms/android/app/libs/sqlite-connector.jar new file mode 100644 index 0000000..73387a1 Binary files /dev/null and b/platforms/android/app/libs/sqlite-connector.jar differ diff --git a/platforms/android/app/libs/sqlite-native-driver.jar b/platforms/android/app/libs/sqlite-native-driver.jar new file mode 100644 index 0000000..b1850ad Binary files /dev/null and b/platforms/android/app/libs/sqlite-native-driver.jar differ diff --git a/platforms/android/app/src/main/java/io/sqlc/SQLiteAndroidDatabase.java b/platforms/android/app/src/main/java/io/sqlc/SQLiteAndroidDatabase.java new file mode 100644 index 0000000..6540f86 --- /dev/null +++ b/platforms/android/app/src/main/java/io/sqlc/SQLiteAndroidDatabase.java @@ -0,0 +1,577 @@ +/* + * Copyright (c) 2012-present Christopher J. Brody (aka Chris Brody) + * Copyright (c) 2005-2010, Nitobi Software Inc. + * Copyright (c) 2010, IBM Corporation + */ + +package io.sqlc; + +import android.annotation.SuppressLint; + +import android.database.Cursor; +import android.database.CursorWindow; + +import android.database.sqlite.SQLiteConstraintException; +import android.database.sqlite.SQLiteCursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.database.sqlite.SQLiteStatement; + +import android.util.Log; + +import java.io.File; + +import java.lang.IllegalArgumentException; +import java.lang.Number; + +import java.util.Locale; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.cordova.CallbackContext; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Android Database helper class + */ +class SQLiteAndroidDatabase +{ + private static final Pattern FIRST_WORD = Pattern.compile("^[\\s;]*([^\\s;]+)", + Pattern.CASE_INSENSITIVE); + + private static final Pattern WHERE_CLAUSE = Pattern.compile("\\s+WHERE\\s+(.+)$", + Pattern.CASE_INSENSITIVE); + + private static final Pattern UPDATE_TABLE_NAME = Pattern.compile("^\\s*UPDATE\\s+(\\S+)", + Pattern.CASE_INSENSITIVE); + + private static final Pattern DELETE_TABLE_NAME = Pattern.compile("^\\s*DELETE\\s+FROM\\s+(\\S+)", + Pattern.CASE_INSENSITIVE); + + private static final boolean isPostHoneycomb = android.os.Build.VERSION.SDK_INT >= 11; + + File dbFile; + + SQLiteDatabase mydb; + + boolean isTransactionActive = false; + + /** + * NOTE: Using default constructor, no explicit constructor. + */ + + /** + * Open a database. + * + * @param dbfile The database File specification + */ + void open(File dbfile) throws Exception { + dbFile = dbfile; // for possible bug workaround + mydb = SQLiteDatabase.openOrCreateDatabase(dbfile, null); + } + + /** + * Close a database (in the current thread). + */ + void closeDatabaseNow() { + if (mydb != null) { + if (isTransactionActive) { + mydb.endTransaction(); + isTransactionActive = false; + } + mydb.close(); + mydb = null; + } + } + + void bugWorkaround() throws Exception { + this.closeDatabaseNow(); + this.open(dbFile); + } + + /** + * Executes a batch request and sends the results via cbc. + * + * @param queryarr Array of query strings + * @param jsonparamsArr Array of JSON query parameters + * @param cbc Callback context from Cordova API + */ + void executeSqlBatch(String[] queryarr, JSONArray[] jsonparamsArr, CallbackContext cbc) { + + if (mydb == null) { + // not allowed - can only happen if someone has closed (and possibly deleted) a database and then re-used the database + // (internal plugin error) + cbc.error("INTERNAL PLUGIN ERROR: database not open"); + return; + } + + int len = queryarr.length; + JSONArray batchResults = new JSONArray(); + + for (int i = 0; i < len; i++) { + executeSqlBatchStatement(queryarr[i], jsonparamsArr[i], batchResults); + } + + cbc.success(batchResults); + } + + @SuppressLint("NewApi") + private void executeSqlBatchStatement(String query, JSONArray json_params, JSONArray batchResults) { + + if (mydb == null) { + // Should not happen here + return; + + } else { + + int rowsAffectedCompat = 0; + boolean needRowsAffectedCompat = false; + + JSONObject queryResult = null; + + String errorMessage = "unknown"; + int code = 0; // SQLException.UNKNOWN_ERR + + try { + boolean needRawQuery = true; + + //Log.v("executeSqlBatch", "get query type"); + QueryType queryType = getQueryType(query); + //Log.v("executeSqlBatch", "query type: " + queryType); + + if (queryType == QueryType.update || queryType == queryType.delete) { + if (isPostHoneycomb) { + SQLiteStatement myStatement = mydb.compileStatement(query); + + if (json_params != null) { + bindArgsToStatement(myStatement, json_params); + } + + int rowsAffected = -1; // (assuming invalid) + + // Use try & catch just in case android.os.Build.VERSION.SDK_INT >= 11 is lying: + // (Catch SQLiteException here to avoid extra retry) + try { + rowsAffected = myStatement.executeUpdateDelete(); + // Indicate valid results: + needRawQuery = false; + } catch (SQLiteConstraintException ex) { + // Indicate problem & stop this query: + ex.printStackTrace(); + errorMessage = "constraint failure: " + ex.getMessage(); + code = 6; // SQLException.CONSTRAINT_ERR + Log.v("executeSqlBatch", "SQLiteStatement.executeUpdateDelete(): Error=" + errorMessage); + needRawQuery = false; + } catch (SQLiteException ex) { + // Indicate problem & stop this query: + ex.printStackTrace(); + errorMessage = ex.getMessage(); + Log.v("executeSqlBatch", "SQLiteStatement.executeUpdateDelete(): Error=" + errorMessage); + needRawQuery = false; + } catch (Exception ex) { + // Assuming SDK_INT was lying & method not found: + // do nothing here & try again with raw query. + ex.printStackTrace(); + Log.v("executeSqlBatch", "SQLiteStatement.executeUpdateDelete(): runtime error (fallback to old API): " + errorMessage); + } + + // "finally" cleanup myStatement + myStatement.close(); + + if (rowsAffected != -1) { + queryResult = new JSONObject(); + queryResult.put("rowsAffected", rowsAffected); + } + } + + if (needRawQuery) { // for pre-honeycomb behavior + rowsAffectedCompat = countRowsAffectedCompat(queryType, query, json_params, mydb); + needRowsAffectedCompat = true; + } + } + + // INSERT: + if (queryType == QueryType.insert && json_params != null) { + needRawQuery = false; + + SQLiteStatement myStatement = mydb.compileStatement(query); + + bindArgsToStatement(myStatement, json_params); + + long insertId = -1; // (invalid) + + try { + insertId = myStatement.executeInsert(); + + // statement has finished with no constraint violation: + queryResult = new JSONObject(); + if (insertId != -1) { + queryResult.put("insertId", insertId); + queryResult.put("rowsAffected", 1); + } else { + queryResult.put("rowsAffected", 0); + } + } catch (SQLiteConstraintException ex) { + // report constraint violation error result with the error message + ex.printStackTrace(); + errorMessage = "constraint failure: " + ex.getMessage(); + code = 6; // SQLException.CONSTRAINT_ERR + Log.v("executeSqlBatch", "SQLiteDatabase.executeInsert(): Error=" + errorMessage); + } catch (SQLiteException ex) { + // report some other error result with the error message + ex.printStackTrace(); + errorMessage = ex.getMessage(); + Log.v("executeSqlBatch", "SQLiteDatabase.executeInsert(): Error=" + errorMessage); + } + + // "finally" cleanup myStatement + myStatement.close(); + } + + if (queryType == QueryType.begin) { + needRawQuery = false; + try { + mydb.beginTransaction(); + isTransactionActive = true; + + queryResult = new JSONObject(); + queryResult.put("rowsAffected", 0); + } catch (SQLiteException ex) { + ex.printStackTrace(); + errorMessage = ex.getMessage(); + Log.v("executeSqlBatch", "SQLiteDatabase.beginTransaction(): Error=" + errorMessage); + } + } + + if (queryType == QueryType.commit) { + needRawQuery = false; + try { + mydb.setTransactionSuccessful(); + mydb.endTransaction(); + isTransactionActive = false; + + queryResult = new JSONObject(); + queryResult.put("rowsAffected", 0); + } catch (SQLiteException ex) { + ex.printStackTrace(); + errorMessage = ex.getMessage(); + Log.v("executeSqlBatch", "SQLiteDatabase.setTransactionSuccessful/endTransaction(): Error=" + errorMessage); + } + } + + if (queryType == QueryType.rollback) { + needRawQuery = false; + try { + mydb.endTransaction(); + isTransactionActive = false; + + queryResult = new JSONObject(); + queryResult.put("rowsAffected", 0); + } catch (SQLiteException ex) { + ex.printStackTrace(); + errorMessage = ex.getMessage(); + Log.v("executeSqlBatch", "SQLiteDatabase.endTransaction(): Error=" + errorMessage); + } + } + + // raw query for other statements: + if (needRawQuery) { + try { + queryResult = this.executeSqlStatementQuery(mydb, query, json_params); + + } catch (SQLiteConstraintException ex) { + // report constraint violation error result with the error message + ex.printStackTrace(); + errorMessage = "constraint failure: " + ex.getMessage(); + code = 6; // SQLException.CONSTRAINT_ERR + Log.v("executeSqlBatch", "Raw query error=" + errorMessage); + } catch (SQLiteException ex) { + // report some other error result with the error message + ex.printStackTrace(); + errorMessage = ex.getMessage(); + Log.v("executeSqlBatch", "Raw query error=" + errorMessage); + } + + if (needRowsAffectedCompat) { + queryResult.put("rowsAffected", rowsAffectedCompat); + } + } + } catch (Exception ex) { + ex.printStackTrace(); + errorMessage = ex.getMessage(); + Log.v("executeSqlBatch", "SQLiteAndroidDatabase.executeSql[Batch](): Error=" + errorMessage); + } + + try { + if (queryResult != null) { + JSONObject r = new JSONObject(); + + r.put("type", "success"); + r.put("result", queryResult); + + batchResults.put(r); + } else { + JSONObject r = new JSONObject(); + r.put("type", "error"); + + JSONObject er = new JSONObject(); + er.put("message", errorMessage); + er.put("code", code); + r.put("result", er); + + batchResults.put(r); + } + } catch (JSONException ex) { + ex.printStackTrace(); + Log.v("executeSqlBatch", "SQLiteAndroidDatabase.executeSql[Batch](): Error=" + ex.getMessage()); + // TODO what to do? + } + } + } + + private final int countRowsAffectedCompat(QueryType queryType, String query, JSONArray json_params, + SQLiteDatabase mydb) throws JSONException { + // quick and dirty way to calculate the rowsAffected in pre-Honeycomb. just do a SELECT + // beforehand using the same WHERE clause. might not be perfect, but it's better than nothing + Matcher whereMatcher = WHERE_CLAUSE.matcher(query); + + String where = ""; + + int pos = 0; + while (whereMatcher.find(pos)) { + where = " WHERE " + whereMatcher.group(1); + pos = whereMatcher.start(1); + } + // WHERE clause may be omitted, and also be sure to find the last one, + // e.g. for cases where there's a subquery + + // bindings may be in the update clause, so only take the last n + int numQuestionMarks = 0; + for (int j = 0; j < where.length(); j++) { + if (where.charAt(j) == '?') { + numQuestionMarks++; + } + } + + JSONArray subParams = null; + + if (json_params != null) { + // only take the last n of every array of sqlArgs + JSONArray origArray = json_params; + subParams = new JSONArray(); + int startPos = origArray.length() - numQuestionMarks; + for (int j = startPos; j < origArray.length(); j++) { + subParams.put(j - startPos, origArray.get(j)); + } + } + + if (queryType == QueryType.update) { + Matcher tableMatcher = UPDATE_TABLE_NAME.matcher(query); + if (tableMatcher.find()) { + String table = tableMatcher.group(1); + try { + SQLiteStatement statement = mydb.compileStatement( + "SELECT count(*) FROM " + table + where); + + if (subParams != null) { + bindArgsToStatement(statement, subParams); + } + + return (int)statement.simpleQueryForLong(); + } catch (Exception e) { + // assume we couldn't count for whatever reason, keep going + Log.e(SQLiteAndroidDatabase.class.getSimpleName(), "uncaught", e); + } + } + } else { // delete + Matcher tableMatcher = DELETE_TABLE_NAME.matcher(query); + if (tableMatcher.find()) { + String table = tableMatcher.group(1); + try { + SQLiteStatement statement = mydb.compileStatement( + "SELECT count(*) FROM " + table + where); + bindArgsToStatement(statement, subParams); + + return (int)statement.simpleQueryForLong(); + } catch (Exception e) { + // assume we couldn't count for whatever reason, keep going + Log.e(SQLiteAndroidDatabase.class.getSimpleName(), "uncaught", e); + + } + } + } + + return 0; + } + + private void bindArgsToStatement(SQLiteStatement myStatement, JSONArray sqlArgs) throws JSONException { + for (int i = 0; i < sqlArgs.length(); i++) { + if (sqlArgs.get(i) instanceof Float || sqlArgs.get(i) instanceof Double) { + myStatement.bindDouble(i + 1, sqlArgs.getDouble(i)); + } else if (sqlArgs.get(i) instanceof Number) { + myStatement.bindLong(i + 1, sqlArgs.getLong(i)); + } else if (sqlArgs.isNull(i)) { + myStatement.bindNull(i + 1); + } else { + myStatement.bindString(i + 1, sqlArgs.getString(i)); + } + } + } + + /** + * Get rows results from query cursor. + * + * @param cur Cursor into query results + * @return results in string form + */ + private JSONObject executeSqlStatementQuery(SQLiteDatabase mydb, String query, + JSONArray paramsAsJson) throws Exception { + JSONObject rowsResult = new JSONObject(); + + Cursor cur = null; + try { + String[] params = null; + + params = new String[paramsAsJson.length()]; + + for (int j = 0; j < paramsAsJson.length(); j++) { + if (paramsAsJson.isNull(j)) + params[j] = ""; + else + params[j] = paramsAsJson.getString(j); + } + + cur = mydb.rawQuery(query, params); + } catch (Exception ex) { + ex.printStackTrace(); + String errorMessage = ex.getMessage(); + Log.v("executeSqlBatch", "SQLiteAndroidDatabase.executeSql[Batch](): Error=" + errorMessage); + throw ex; + } + + // If query result has rows + if (cur != null && cur.moveToFirst()) { + JSONArray rowsArrayResult = new JSONArray(); + String key = ""; + int colCount = cur.getColumnCount(); + + // Build up JSON result object for each row + do { + JSONObject row = new JSONObject(); + try { + for (int i = 0; i < colCount; ++i) { + key = cur.getColumnName(i); + + if (isPostHoneycomb) { + + // Use try & catch just in case android.os.Build.VERSION.SDK_INT >= 11 is lying: + try { + bindPostHoneycomb(row, key, cur, i); + } catch (Exception ex) { + bindPreHoneycomb(row, key, cur, i); + } + } else { + bindPreHoneycomb(row, key, cur, i); + } + } + + rowsArrayResult.put(row); + + } catch (JSONException e) { + e.printStackTrace(); + } + } while (cur.moveToNext()); + + try { + rowsResult.put("rows", rowsArrayResult); + } catch (JSONException e) { + e.printStackTrace(); + } + } + + if (cur != null) { + cur.close(); + } + + return rowsResult; + } + + @SuppressLint("NewApi") + private void bindPostHoneycomb(JSONObject row, String key, Cursor cur, int i) throws JSONException { + int curType = cur.getType(i); + + switch (curType) { + case Cursor.FIELD_TYPE_NULL: + row.put(key, JSONObject.NULL); + break; + case Cursor.FIELD_TYPE_INTEGER: + row.put(key, cur.getLong(i)); + break; + case Cursor.FIELD_TYPE_FLOAT: + row.put(key, cur.getDouble(i)); + break; + case Cursor.FIELD_TYPE_STRING: + default: /* (BLOB) */ + row.put(key, cur.getString(i)); + break; + } + } + + private void bindPreHoneycomb(JSONObject row, String key, Cursor cursor, int i) throws JSONException { + // Since cursor.getType() is not available pre-honeycomb, this is + // a workaround so we don't have to bind everything as a string + // Details here: http://stackoverflow.com/q/11658239 + SQLiteCursor sqLiteCursor = (SQLiteCursor) cursor; + CursorWindow cursorWindow = sqLiteCursor.getWindow(); + int pos = cursor.getPosition(); + if (cursorWindow.isNull(pos, i)) { + row.put(key, JSONObject.NULL); + } else if (cursorWindow.isLong(pos, i)) { + row.put(key, cursor.getLong(i)); + } else if (cursorWindow.isFloat(pos, i)) { + row.put(key, cursor.getDouble(i)); + } else { + // STRING or BLOB: + row.put(key, cursor.getString(i)); + } + } + + static QueryType getQueryType(String query) { + Matcher matcher = FIRST_WORD.matcher(query); + + // FIND & return query type, or throw: + if (matcher.find()) { + try { + String first = matcher.group(1); + + // explictly reject if blank + // (needed for SQLCipher version) + if (first.length() == 0) throw new RuntimeException("query not found"); + + return QueryType.valueOf(first.toLowerCase(Locale.ENGLISH)); + } catch (IllegalArgumentException ignore) { + // unknown verb (NOT blank) + return QueryType.other; + } + } else { + // explictly reject if blank + // (needed for SQLCipher version) + throw new RuntimeException("query not found"); + } + } + + static enum QueryType { + update, + insert, + delete, + select, + begin, + commit, + rollback, + other + } +} /* vim: set expandtab : */ diff --git a/platforms/android/app/src/main/java/io/sqlc/SQLiteConnectorDatabase.java b/platforms/android/app/src/main/java/io/sqlc/SQLiteConnectorDatabase.java new file mode 100644 index 0000000..e16e822 --- /dev/null +++ b/platforms/android/app/src/main/java/io/sqlc/SQLiteConnectorDatabase.java @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2012-present Christopher J. Brody (aka Chris Brody) + * Copyright (c) 2005-2010, Nitobi Software Inc. + * Copyright (c) 2010, IBM Corporation + */ + +package io.sqlc; + +import android.annotation.SuppressLint; + +import android.util.Log; + +import java.io.File; + +import java.lang.IllegalArgumentException; +import java.lang.Number; + +import java.sql.SQLException; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.cordova.CallbackContext; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import io.liteglue.SQLCode; +import io.liteglue.SQLColumnType; +import io.liteglue.SQLiteConnector; +import io.liteglue.SQLiteConnection; +import io.liteglue.SQLiteOpenFlags; +import io.liteglue.SQLiteStatement; + +/** + * Android SQLite-Connector Database helper class + */ +class SQLiteConnectorDatabase extends SQLiteAndroidDatabase +{ + static SQLiteConnector connector = new SQLiteConnector(); + + SQLiteConnection mydb; + + /** + * NOTE: Using default constructor, no explicit constructor. + */ + + + /** + * Open a database. + * + * @param dbFile The database File specification + */ + @Override + void open(File dbFile) throws Exception { + mydb = connector.newSQLiteConnection(dbFile.getAbsolutePath(), + SQLiteOpenFlags.READWRITE | SQLiteOpenFlags.CREATE); + } + + /** + * Close a database (in the current thread). + */ + @Override + void closeDatabaseNow() { + try { + if (mydb != null) + mydb.dispose(); + } catch (Exception e) { + Log.e(SQLitePlugin.class.getSimpleName(), "couldn't close database, ignoring", e); + } + } + + /** + * Ignore Android bug workaround for NDK version + */ + @Override + void bugWorkaround() { } + + /** + * Executes a batch request and sends the results via cbc. + * + * @param dbname The name of the database. + * @param queryarr Array of query strings + * @param jsonparams Array of JSON query parameters + * @param cbc Callback context from Cordova API + */ + @Override + void executeSqlBatch( String[] queryarr, JSONArray[] jsonparams, CallbackContext cbc) { + + if (mydb == null) { + // not allowed - can only happen if someone has closed (and possibly deleted) a database and then re-used the database + cbc.error("database has been closed"); + return; + } + + int len = queryarr.length; + JSONArray batchResults = new JSONArray(); + + for (int i = 0; i < len; i++) { + int rowsAffectedCompat = 0; + boolean needRowsAffectedCompat = false; + + JSONObject queryResult = null; + + String errorMessage = "unknown"; + int sqliteErrorCode = -1; + int code = 0; // SQLException.UNKNOWN_ERR + + try { + String query = queryarr[i]; + + long lastTotal = mydb.getTotalChanges(); + queryResult = this.executeSQLiteStatement(query, jsonparams[i], cbc); + long newTotal = mydb.getTotalChanges(); + long rowsAffected = newTotal - lastTotal; + + queryResult.put("rowsAffected", rowsAffected); + if (rowsAffected > 0) { + long insertId = mydb.getLastInsertRowid(); + if (insertId > 0) { + queryResult.put("insertId", insertId); + } + } + } catch (SQLException ex) { + ex.printStackTrace(); + sqliteErrorCode = ex.getErrorCode(); + errorMessage = ex.getMessage(); + Log.v("executeSqlBatch", "SQLitePlugin.executeSql[Batch](): SQL Error code = " + sqliteErrorCode + " message = " + errorMessage); + + switch(sqliteErrorCode) { + case SQLCode.ERROR: + code = 5; // SQLException.SYNTAX_ERR + break; + case 13: // SQLITE_FULL + code = 4; // SQLException.QUOTA_ERR + break; + case SQLCode.CONSTRAINT: + code = 6; // SQLException.CONSTRAINT_ERR + break; + default: + /* do nothing */ + } + } catch (JSONException ex) { + // NOT expected: + ex.printStackTrace(); + errorMessage = ex.getMessage(); + code = 0; // SQLException.UNKNOWN_ERR + Log.e("executeSqlBatch", "SQLitePlugin.executeSql[Batch](): UNEXPECTED JSON Error=" + errorMessage); + } + + try { + if (queryResult != null) { + JSONObject r = new JSONObject(); + + r.put("type", "success"); + r.put("result", queryResult); + + batchResults.put(r); + } else { + JSONObject r = new JSONObject(); + r.put("type", "error"); + + JSONObject er = new JSONObject(); + er.put("message", errorMessage); + er.put("code", code); + r.put("result", er); + + batchResults.put(r); + } + } catch (JSONException ex) { + ex.printStackTrace(); + Log.e("executeSqlBatch", "SQLitePlugin.executeSql[Batch](): Error=" + ex.getMessage()); + // TODO what to do? + } + } + + cbc.success(batchResults); + } + + /** + * Get rows results from query cursor. + * + * @param cur Cursor into query results + * @return results in string form + */ + private JSONObject executeSQLiteStatement(String query, JSONArray paramsAsJson, + CallbackContext cbc) throws JSONException, SQLException { + JSONObject rowsResult = new JSONObject(); + + boolean hasRows = false; + + SQLiteStatement myStatement = mydb.prepareStatement(query); + + try { + String[] params = null; + + params = new String[paramsAsJson.length()]; + + for (int i = 0; i < paramsAsJson.length(); ++i) { + if (paramsAsJson.isNull(i)) { + myStatement.bindNull(i + 1); + } else { + Object p = paramsAsJson.get(i); + if (p instanceof Float || p instanceof Double) + myStatement.bindDouble(i + 1, paramsAsJson.getDouble(i)); + else if (p instanceof Number) + myStatement.bindLong(i + 1, paramsAsJson.getLong(i)); + else + myStatement.bindTextNativeString(i + 1, paramsAsJson.getString(i)); + } + } + + hasRows = myStatement.step(); + } catch (SQLException ex) { + ex.printStackTrace(); + String errorMessage = ex.getMessage(); + Log.v("executeSqlBatch", "SQLitePlugin.executeSql[Batch](): Error=" + errorMessage); + + // cleanup statement and throw the exception: + myStatement.dispose(); + throw ex; + } catch (JSONException ex) { + ex.printStackTrace(); + String errorMessage = ex.getMessage(); + Log.v("executeSqlBatch", "SQLitePlugin.executeSql[Batch](): Error=" + errorMessage); + + // cleanup statement and throw the exception: + myStatement.dispose(); + throw ex; + } + + // If query result has rows + if (hasRows) { + JSONArray rowsArrayResult = new JSONArray(); + String key = ""; + int colCount = myStatement.getColumnCount(); + + // Build up JSON result object for each row + do { + JSONObject row = new JSONObject(); + try { + for (int i = 0; i < colCount; ++i) { + key = myStatement.getColumnName(i); + + switch (myStatement.getColumnType(i)) { + case SQLColumnType.NULL: + row.put(key, JSONObject.NULL); + break; + + case SQLColumnType.REAL: + row.put(key, myStatement.getColumnDouble(i)); + break; + + case SQLColumnType.INTEGER: + row.put(key, myStatement.getColumnLong(i)); + break; + + case SQLColumnType.BLOB: + case SQLColumnType.TEXT: + default: // (just in case) + row.put(key, myStatement.getColumnTextNativeString(i)); + } + + } + + rowsArrayResult.put(row); + + } catch (JSONException e) { + e.printStackTrace(); + } + } while (myStatement.step()); + + try { + rowsResult.put("rows", rowsArrayResult); + } catch (JSONException e) { + e.printStackTrace(); + } + } + + myStatement.dispose(); + + return rowsResult; + } + +} /* vim: set expandtab : */ diff --git a/platforms/android/app/src/main/java/io/sqlc/SQLitePlugin.java b/platforms/android/app/src/main/java/io/sqlc/SQLitePlugin.java new file mode 100755 index 0000000..fdf92d0 --- /dev/null +++ b/platforms/android/app/src/main/java/io/sqlc/SQLitePlugin.java @@ -0,0 +1,433 @@ +/* + * Copyright (c) 2012-present Christopher J. Brody (aka Chris Brody) + * Copyright (c) 2005-2010, Nitobi Software Inc. + * Copyright (c) 2010, IBM Corporation + */ + +package io.sqlc; + +import android.annotation.SuppressLint; + +import android.util.Log; + +import java.io.File; + +import java.lang.IllegalArgumentException; +import java.lang.Number; + +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LinkedBlockingQueue; + +import org.apache.cordova.CallbackContext; +import org.apache.cordova.CordovaPlugin; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +public class SQLitePlugin extends CordovaPlugin { + + /** + * Multiple database runner map (static). + * + * NOTE: no public static accessor to db (runner) map since it is not + * expected to work properly with db threading. + * + * FUTURE TBD put DBRunner into a public class that can provide external accessor. + * + * ADDITIONAL NOTE: Storing as Map to avoid portabiity issue + * between Java 6/7/8 as discussed in: + * https://gist.github.com/AlainODea/1375759b8720a3f9f094 + * + * THANKS to @NeoLSN (Jason Yang/楊朝傑) for giving the pointer in: + * https://github.com/litehelpers/Cordova-sqlite-storage/issues/727 + */ + static Map dbrmap = new ConcurrentHashMap(); + + /** + * NOTE: Using default constructor, no explicit constructor. + */ + + /** + * Executes the request and returns PluginResult. + * + * @param actionAsString The action to execute. + * @param args JSONArry of arguments for the plugin. + * @param cbc Callback context from Cordova API + * @return Whether the action was valid. + */ + @Override + public boolean execute(String actionAsString, JSONArray args, CallbackContext cbc) { + + Action action; + try { + action = Action.valueOf(actionAsString); + } catch (IllegalArgumentException e) { + // shouldn't ever happen + Log.e(SQLitePlugin.class.getSimpleName(), "unexpected error", e); + return false; + } + + try { + return executeAndPossiblyThrow(action, args, cbc); + } catch (JSONException e) { + // TODO: signal JSON problem to JS + Log.e(SQLitePlugin.class.getSimpleName(), "unexpected error", e); + return false; + } + } + + private boolean executeAndPossiblyThrow(Action action, JSONArray args, CallbackContext cbc) + throws JSONException { + + boolean status = true; + JSONObject o; + String echo_value; + String dbname; + + switch (action) { + case echoStringValue: + o = args.getJSONObject(0); + echo_value = o.getString("value"); + cbc.success(echo_value); + break; + + case open: + o = args.getJSONObject(0); + dbname = o.getString("name"); + // open database and start reading its queue + this.startDatabase(dbname, o, cbc); + break; + + case close: + o = args.getJSONObject(0); + dbname = o.getString("path"); + // put request in the q to close the db + this.closeDatabase(dbname, cbc); + break; + + case delete: + o = args.getJSONObject(0); + dbname = o.getString("path"); + + deleteDatabase(dbname, cbc); + + break; + + case executeSqlBatch: + case backgroundExecuteSqlBatch: + JSONObject allargs = args.getJSONObject(0); + JSONObject dbargs = allargs.getJSONObject("dbargs"); + dbname = dbargs.getString("dbname"); + JSONArray txargs = allargs.getJSONArray("executes"); + + if (txargs.isNull(0)) { + cbc.error("INTERNAL PLUGIN ERROR: missing executes list"); + } else { + int len = txargs.length(); + String[] queries = new String[len]; + JSONArray[] jsonparams = new JSONArray[len]; + + for (int i = 0; i < len; i++) { + JSONObject a = txargs.getJSONObject(i); + queries[i] = a.getString("sql"); + jsonparams[i] = a.getJSONArray("params"); + } + + // put db query in the queue to be executed in the db thread: + DBQuery q = new DBQuery(queries, jsonparams, cbc); + DBRunner r = dbrmap.get(dbname); + if (r != null) { + try { + r.q.put(q); + } catch(Exception e) { + Log.e(SQLitePlugin.class.getSimpleName(), "couldn't add to queue", e); + cbc.error("INTERNAL PLUGIN ERROR: couldn't add to queue"); + } + } else { + cbc.error("INTERNAL PLUGIN ERROR: database not open"); + } + } + break; + } + + return status; + } + + /** + * Clean up and close all open databases. + */ + @Override + public void onDestroy() { + while (!dbrmap.isEmpty()) { + String dbname = dbrmap.keySet().iterator().next(); + + this.closeDatabaseNow(dbname); + + DBRunner r = dbrmap.get(dbname); + try { + // stop the db runner thread: + r.q.put(new DBQuery()); + } catch(Exception e) { + Log.e(SQLitePlugin.class.getSimpleName(), "INTERNAL PLUGIN CLEANUP ERROR: could not stop db thread due to exception", e); + } + dbrmap.remove(dbname); + } + } + + // -------------------------------------------------------------------------- + // LOCAL METHODS + // -------------------------------------------------------------------------- + + private void startDatabase(String dbname, JSONObject options, CallbackContext cbc) { + DBRunner r = dbrmap.get(dbname); + + if (r != null) { + // NO LONGER EXPECTED due to BUG 666 workaround solution: + cbc.error("INTERNAL ERROR: database already open for db name: " + dbname); + } else { + r = new DBRunner(dbname, options, cbc); + dbrmap.put(dbname, r); + this.cordova.getThreadPool().execute(r); + } + } + /** + * Open a database. + * + * @param dbName The name of the database file + */ + private SQLiteAndroidDatabase openDatabase(String dbname, CallbackContext cbc, boolean old_impl) throws Exception { + try { + // ASSUMPTION: no db (connection/handle) is already stored in the map + // [should be true according to the code in DBRunner.run()] + + File dbfile = this.cordova.getActivity().getDatabasePath(dbname); + + if (!dbfile.exists()) { + dbfile.getParentFile().mkdirs(); + } + + Log.v("info", "Open sqlite db: " + dbfile.getAbsolutePath()); + + SQLiteAndroidDatabase mydb = old_impl ? new SQLiteAndroidDatabase() : new SQLiteConnectorDatabase(); + mydb.open(dbfile); + + if (cbc != null) // XXX Android locking/closing BUG workaround + cbc.success(); + + return mydb; + } catch (Exception e) { + if (cbc != null) // XXX Android locking/closing BUG workaround + cbc.error("can't open database " + e); + throw e; + } + } + + /** + * Close a database (in another thread). + * + * @param dbName The name of the database file + */ + private void closeDatabase(String dbname, CallbackContext cbc) { + DBRunner r = dbrmap.get(dbname); + if (r != null) { + try { + r.q.put(new DBQuery(false, cbc)); + } catch(Exception e) { + if (cbc != null) { + cbc.error("couldn't close database" + e); + } + Log.e(SQLitePlugin.class.getSimpleName(), "couldn't close database", e); + } + } else { + if (cbc != null) { + cbc.success(); + } + } + } + + /** + * Close a database (in the current thread). + * + * @param dbname The name of the database file + */ + private void closeDatabaseNow(String dbname) { + DBRunner r = dbrmap.get(dbname); + + if (r != null) { + SQLiteAndroidDatabase mydb = r.mydb; + + if (mydb != null) + mydb.closeDatabaseNow(); + } + } + + private void deleteDatabase(String dbname, CallbackContext cbc) { + DBRunner r = dbrmap.get(dbname); + if (r != null) { + try { + r.q.put(new DBQuery(true, cbc)); + } catch(Exception e) { + if (cbc != null) { + cbc.error("couldn't close database" + e); + } + Log.e(SQLitePlugin.class.getSimpleName(), "couldn't close database", e); + } + } else { + boolean deleteResult = this.deleteDatabaseNow(dbname); + if (deleteResult) { + cbc.success(); + } else { + cbc.error("couldn't delete database"); + } + } + } + + /** + * Delete a database. + * + * @param dbName The name of the database file + * + * @return true if successful or false if an exception was encountered + */ + private boolean deleteDatabaseNow(String dbname) { + File dbfile = this.cordova.getActivity().getDatabasePath(dbname); + + try { + return cordova.getActivity().deleteDatabase(dbfile.getAbsolutePath()); + } catch (Exception e) { + Log.e(SQLitePlugin.class.getSimpleName(), "couldn't delete database", e); + return false; + } + } + + private class DBRunner implements Runnable { + final String dbname; + private boolean oldImpl; + private boolean bugWorkaround; + + final BlockingQueue q; + final CallbackContext openCbc; + + SQLiteAndroidDatabase mydb; + + DBRunner(final String dbname, JSONObject options, CallbackContext cbc) { + this.dbname = dbname; + this.oldImpl = options.has("androidOldDatabaseImplementation"); + Log.v(SQLitePlugin.class.getSimpleName(), "Android db implementation: built-in android.database.sqlite package"); + this.bugWorkaround = this.oldImpl && options.has("androidBugWorkaround"); + if (this.bugWorkaround) + Log.v(SQLitePlugin.class.getSimpleName(), "Android db closing/locking workaround applied"); + + this.q = new LinkedBlockingQueue(); + this.openCbc = cbc; + } + + public void run() { + try { + this.mydb = openDatabase(dbname, this.openCbc, this.oldImpl); + } catch (Exception e) { + Log.e(SQLitePlugin.class.getSimpleName(), "unexpected error, stopping db thread", e); + dbrmap.remove(dbname); + return; + } + + DBQuery dbq = null; + + try { + dbq = q.take(); + + while (!dbq.stop) { + mydb.executeSqlBatch(dbq.queries, dbq.jsonparams, dbq.cbc); + + if (this.bugWorkaround && dbq.queries.length == 1 && dbq.queries[0] == "COMMIT") + mydb.bugWorkaround(); + + dbq = q.take(); + } + } catch (Exception e) { + Log.e(SQLitePlugin.class.getSimpleName(), "unexpected error", e); + } + + if (dbq != null && dbq.close) { + try { + closeDatabaseNow(dbname); + + dbrmap.remove(dbname); // (should) remove ourself + + if (!dbq.delete) { + dbq.cbc.success(); + } else { + try { + boolean deleteResult = deleteDatabaseNow(dbname); + if (deleteResult) { + dbq.cbc.success(); + } else { + dbq.cbc.error("couldn't delete database"); + } + } catch (Exception e) { + Log.e(SQLitePlugin.class.getSimpleName(), "couldn't delete database", e); + dbq.cbc.error("couldn't delete database: " + e); + } + } + } catch (Exception e) { + Log.e(SQLitePlugin.class.getSimpleName(), "couldn't close database", e); + if (dbq.cbc != null) { + dbq.cbc.error("couldn't close database: " + e); + } + } + } + } + } + + private final class DBQuery { + // XXX TODO replace with DBRunner action enum: + final boolean stop; + final boolean close; + final boolean delete; + final String[] queries; + final JSONArray[] jsonparams; + final CallbackContext cbc; + + DBQuery(String[] myqueries, JSONArray[] params, CallbackContext c) { + this.stop = false; + this.close = false; + this.delete = false; + this.queries = myqueries; + this.jsonparams = params; + this.cbc = c; + } + + DBQuery(boolean delete, CallbackContext cbc) { + this.stop = true; + this.close = true; + this.delete = delete; + this.queries = null; + this.jsonparams = null; + this.cbc = cbc; + } + + // signal the DBRunner thread to stop: + DBQuery() { + this.stop = true; + this.close = false; + this.delete = false; + this.queries = null; + this.jsonparams = null; + this.cbc = null; + } + } + + private static enum Action { + echoStringValue, + open, + close, + delete, + executeSqlBatch, + backgroundExecuteSqlBatch, + } +} + +/* vim: set expandtab : */ diff --git a/platforms/android/app/src/main/res/xml/config.xml b/platforms/android/app/src/main/res/xml/config.xml index 58c1605..89634ef 100644 --- a/platforms/android/app/src/main/res/xml/config.xml +++ b/platforms/android/app/src/main/res/xml/config.xml @@ -33,13 +33,16 @@ + + + Goober Goober, a mobile app for pnut.io Morgan McMillian - + @@ -49,6 +52,7 @@ + diff --git a/platforms/android/platform_www/cordova_plugins.js b/platforms/android/platform_www/cordova_plugins.js index a9e93ae..d6e5719 100644 --- a/platforms/android/platform_www/cordova_plugins.js +++ b/platforms/android/platform_www/cordova_plugins.js @@ -245,6 +245,14 @@ module.exports = [ "clobbers": [ "window.FilePath" ] + }, + { + "id": "cordova-sqlite-storage.SQLitePlugin", + "file": "plugins/cordova-sqlite-storage/www/SQLitePlugin.js", + "pluginId": "cordova-sqlite-storage", + "clobbers": [ + "SQLitePlugin" + ] } ]; module.exports.metadata = @@ -260,7 +268,8 @@ module.exports.metadata = "cordova-plugin-splashscreen": "5.0.2", "cordova-plugin-device": "2.0.2", "cordova-android-support-gradle-release": "1.4.4", - "cordova-plugin-filepath": "1.4.2" + "cordova-plugin-filepath": "1.4.2", + "cordova-sqlite-storage": "2.5.1" }; // BOTTOM OF METADATA }); \ No newline at end of file