Merge "Revert SQLiteQueryBuilder for now."

This commit is contained in:
Jeff Sharkey
2018-07-19 20:12:20 +00:00
committed by Android (Google) Code Review
3 changed files with 1104 additions and 463 deletions

View File

@@ -16,39 +16,17 @@
package android.database.sqlite;
import static android.content.ContentResolver.QUERY_ARG_SQL_GROUP_BY;
import static android.content.ContentResolver.QUERY_ARG_SQL_HAVING;
import static android.content.ContentResolver.QUERY_ARG_SQL_LIMIT;
import static android.content.ContentResolver.QUERY_ARG_SQL_SELECTION;
import static android.content.ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS;
import static android.content.ContentResolver.QUERY_ARG_SQL_SORT_ORDER;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.os.Build;
import android.os.Bundle;
import android.os.CancellationSignal;
import android.os.OperationCanceledException;
import android.provider.BaseColumns;
import android.text.TextUtils;
import android.util.ArrayMap;
import android.util.Log;
import com.android.internal.util.ArrayUtils;
import dalvik.system.VMRuntime;
import libcore.util.EmptyArray;
import java.util.Arrays;
import java.util.Iterator;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
@@ -56,7 +34,8 @@ import java.util.regex.Pattern;
* This is a convenience class that helps build SQL queries to be sent to
* {@link SQLiteDatabase} objects.
*/
public class SQLiteQueryBuilder {
public class SQLiteQueryBuilder
{
private static final String TAG = "SQLiteQueryBuilder";
private static final Pattern sLimitPattern =
Pattern.compile("\\s*\\d+\\s*(,\\s*\\d+\\s*)?");
@@ -64,7 +43,6 @@ public class SQLiteQueryBuilder {
private Map<String, String> mProjectionMap = null;
private String mTables = "";
private StringBuilder mWhereClause = null; // lazily created
private String[] mWhereArgs = EmptyArray.STRING;
private boolean mDistinct;
private SQLiteDatabase.CursorFactory mFactory;
private boolean mStrict;
@@ -104,131 +82,43 @@ public class SQLiteQueryBuilder {
mTables = inTables;
}
/** {@hide} */
public @Nullable String getWhere() {
return (mWhereClause != null) ? mWhereClause.toString() : null;
}
/** {@hide} */
public String[] getWhereArgs() {
return mWhereArgs;
}
/**
* Append a chunk to the {@code WHERE} clause of the query. All chunks
* appended are surrounded by parenthesis and {@code AND}ed with the
* selection passed to {@link #query}. The final {@code WHERE} clause looks
* like:
* Append a chunk to the WHERE clause of the query. All chunks appended are surrounded
* by parenthesis and ANDed with the selection passed to {@link #query}. The final
* WHERE clause looks like:
*
* <pre>
* WHERE (&lt;append chunk 1>&lt;append chunk2>) AND (&lt;query() selection parameter>)
* </pre>
*
* @param inWhere the chunk of text to append to the {@code WHERE} clause.
* @param inWhere the chunk of text to append to the WHERE clause.
*/
public void appendWhere(@NonNull CharSequence inWhere) {
appendWhere(inWhere, EmptyArray.STRING);
}
/**
* Append a chunk to the {@code WHERE} clause of the query. All chunks
* appended are surrounded by parenthesis and {@code AND}ed with the
* selection passed to {@link #query}. The final {@code WHERE} clause looks
* like:
*
* <pre>
* WHERE (&lt;append chunk 1>&lt;append chunk2>) AND (&lt;query() selection parameter>)
* </pre>
*
* @param inWhere the chunk of text to append to the {@code WHERE} clause.
* @param inWhereArgs list of arguments to be bound to any '?' occurrences
* in the where clause.
*/
public void appendWhere(@NonNull CharSequence inWhere, String... inWhereArgs) {
public void appendWhere(CharSequence inWhere) {
if (mWhereClause == null) {
mWhereClause = new StringBuilder(inWhere.length() + 16);
}
if (mWhereClause.length() == 0) {
mWhereClause.append('(');
}
mWhereClause.append(inWhere);
mWhereArgs = ArrayUtils.concat(String.class, mWhereArgs, inWhereArgs);
}
/**
* Append a standalone expression to the {@code WHERE} clause of this query.
* <p>
* This method differs from {@link #appendWhere(CharSequence)} in that it
* automatically appends {@code AND} to any existing {@code WHERE} clause
* already under construction before appending the given standalone
* expression.
* Append a chunk to the WHERE clause of the query. All chunks appended are surrounded
* by parenthesis and ANDed with the selection passed to {@link #query}. The final
* WHERE clause looks like:
*
* @param inWhere the standalone expression to append to the {@code WHERE}
* clause. It will be wrapped in parentheses when it's appended.
*/
public void appendWhereExpression(@NonNull CharSequence inWhere) {
appendWhereExpression(inWhere, EmptyArray.STRING);
}
/**
* Append a standalone expression to the {@code WHERE} clause of this query.
* <p>
* This method differs from {@link #appendWhere(CharSequence)} in that it
* automatically appends {@code AND} to any existing {@code WHERE} clause
* already under construction before appending the given standalone
* expression.
* WHERE (&lt;append chunk 1>&lt;append chunk2>) AND (&lt;query() selection parameter>)
*
* @param inWhere the standalone expression to append to the {@code WHERE}
* clause. It will be wrapped in parentheses when it's appended.
* @param inWhereArgs list of arguments to be bound to any '?' occurrences
* in the standalone expression.
* @param inWhere the chunk of text to append to the WHERE clause. it will be escaped
* to avoid SQL injection attacks
*/
public void appendWhereExpression(@NonNull CharSequence inWhere, String... inWhereArgs) {
public void appendWhereEscapeString(String inWhere) {
if (mWhereClause == null) {
mWhereClause = new StringBuilder(inWhere.length() + 16);
}
if (mWhereClause.length() > 0) {
mWhereClause.append(" AND ");
}
mWhereClause.append('(').append(inWhere).append(')');
mWhereArgs = ArrayUtils.concat(String.class, mWhereArgs, inWhereArgs);
}
/**
* Append a chunk to the {@code WHERE} clause of the query. All chunks
* appended are surrounded by parenthesis and {@code AND}ed with the
* selection passed to {@link #query}. The final {@code WHERE} clause looks
* like this:
*
* <pre>
* WHERE (&lt;append chunk 1>&lt;append chunk2>) AND (&lt;query() selection parameter>)
* </pre>
*
* @param inWhere the chunk of text to append to the {@code WHERE} clause.
* It will be escaped to avoid SQL injection attacks.
*/
public void appendWhereEscapeString(@NonNull String inWhere) {
appendWhereEscapeString(inWhere, EmptyArray.STRING);
}
/**
* Append a chunk to the {@code WHERE} clause of the query. All chunks
* appended are surrounded by parenthesis and {@code AND}ed with the
* selection passed to {@link #query}. The final {@code WHERE} clause looks
* like this:
*
* <pre>
* WHERE (&lt;append chunk 1>&lt;append chunk2>) AND (&lt;query() selection parameter>)
* </pre>
*
* @param inWhere the chunk of text to append to the {@code WHERE} clause.
* It will be escaped to avoid SQL injection attacks.
* @param inWhereArgs list of arguments to be bound to any '?' occurrences
* in the where clause.
*/
public void appendWhereEscapeString(@NonNull String inWhere, String... inWhereArgs) {
if (mWhereClause == null) {
mWhereClause = new StringBuilder(inWhere.length() + 16);
if (mWhereClause.length() == 0) {
mWhereClause.append('(');
}
DatabaseUtils.appendEscapedSQLString(mWhereClause, inWhere);
mWhereArgs = ArrayUtils.concat(String.class, mWhereArgs, inWhereArgs);
}
/**
@@ -278,8 +168,8 @@ public class SQLiteQueryBuilder {
* </ul>
* By default, this value is false.
*/
public void setStrict(boolean strict) {
mStrict = strict;
public void setStrict(boolean flag) {
mStrict = flag;
}
/**
@@ -373,7 +263,7 @@ public class SQLiteQueryBuilder {
* information passed into this method.
*
* @param db the database to query on
* @param projection A list of which columns to return. Passing
* @param projectionIn A list of which columns to return. Passing
* null will return all columns, which is discouraged to prevent
* reading data from storage that isn't going to be used.
* @param selection A filter declaring which rows to return,
@@ -398,14 +288,10 @@ public class SQLiteQueryBuilder {
* @see android.content.ContentResolver#query(android.net.Uri, String[],
* String, String[], String)
*/
public @Nullable Cursor query(@NonNull SQLiteDatabase db,
@Nullable String[] projection,
@Nullable String selection,
@Nullable String[] selectionArgs,
@Nullable String groupBy,
@Nullable String having,
@Nullable String sortOrder) {
return query(db, projection, selection, selectionArgs, groupBy, having, sortOrder,
public Cursor query(SQLiteDatabase db, String[] projectionIn,
String selection, String[] selectionArgs, String groupBy,
String having, String sortOrder) {
return query(db, projectionIn, selection, selectionArgs, groupBy, having, sortOrder,
null /* limit */, null /* cancellationSignal */);
}
@@ -414,7 +300,7 @@ public class SQLiteQueryBuilder {
* information passed into this method.
*
* @param db the database to query on
* @param projection A list of which columns to return. Passing
* @param projectionIn A list of which columns to return. Passing
* null will return all columns, which is discouraged to prevent
* reading data from storage that isn't going to be used.
* @param selection A filter declaring which rows to return,
@@ -441,15 +327,10 @@ public class SQLiteQueryBuilder {
* @see android.content.ContentResolver#query(android.net.Uri, String[],
* String, String[], String)
*/
public @Nullable Cursor query(@NonNull SQLiteDatabase db,
@Nullable String[] projection,
@Nullable String selection,
@Nullable String[] selectionArgs,
@Nullable String groupBy,
@Nullable String having,
@Nullable String sortOrder,
@Nullable String limit) {
return query(db, projection, selection, selectionArgs,
public Cursor query(SQLiteDatabase db, String[] projectionIn,
String selection, String[] selectionArgs, String groupBy,
String having, String sortOrder, String limit) {
return query(db, projectionIn, selection, selectionArgs,
groupBy, having, sortOrder, limit, null);
}
@@ -458,42 +339,7 @@ public class SQLiteQueryBuilder {
* information passed into this method.
*
* @param db the database to query on
* @param projection A list of which columns to return. Passing
* null will return all columns, which is discouraged to prevent
* reading data from storage that isn't going to be used.
* @param selection A filter declaring which rows to return,
* formatted as an SQL WHERE clause (excluding the WHERE
* itself). Passing null will return all rows for the given URL.
* @param selectionArgs You may include ?s in selection, which
* will be replaced by the values from selectionArgs, in order
* that they appear in the selection. The values will be bound
* as Strings.
* @param sortOrder How to order the rows, formatted as an SQL
* ORDER BY clause (excluding the ORDER BY itself). Passing null
* will use the default sort order, which may be unordered.
* @param cancellationSignal A signal to cancel the operation in progress, or null if none.
* If the operation is canceled, then {@link OperationCanceledException} will be thrown
* when the query is executed.
* @return a cursor over the result set
* @see android.content.ContentResolver#query(android.net.Uri, String[],
* String, String[], String)
*/
public @Nullable Cursor query(@NonNull SQLiteDatabase db,
@Nullable String[] projection,
@Nullable String selection,
@Nullable String[] selectionArgs,
@Nullable String sortOrder,
@Nullable CancellationSignal cancellationSignal) {
return query(db, projection, selection, selectionArgs, null, null, sortOrder, null,
cancellationSignal);
}
/**
* Perform a query by combining all current settings and the
* information passed into this method.
*
* @param db the database to query on
* @param projection A list of which columns to return. Passing
* @param projectionIn A list of which columns to return. Passing
* null will return all columns, which is discouraged to prevent
* reading data from storage that isn't going to be used.
* @param selection A filter declaring which rows to return,
@@ -523,69 +369,14 @@ public class SQLiteQueryBuilder {
* @see android.content.ContentResolver#query(android.net.Uri, String[],
* String, String[], String)
*/
public @Nullable Cursor query(@NonNull SQLiteDatabase db,
@Nullable String[] projection,
@Nullable String selection,
@Nullable String[] selectionArgs,
@Nullable String groupBy,
@Nullable String having,
@Nullable String sortOrder,
@Nullable String limit,
@Nullable CancellationSignal cancellationSignal) {
final Bundle queryArgs = new Bundle();
maybePutString(queryArgs, QUERY_ARG_SQL_SELECTION, selection);
maybePutStringArray(queryArgs, QUERY_ARG_SQL_SELECTION_ARGS, selectionArgs);
maybePutString(queryArgs, QUERY_ARG_SQL_GROUP_BY, groupBy);
maybePutString(queryArgs, QUERY_ARG_SQL_HAVING, having);
maybePutString(queryArgs, QUERY_ARG_SQL_SORT_ORDER, sortOrder);
maybePutString(queryArgs, QUERY_ARG_SQL_LIMIT, limit);
return query(db, projection, queryArgs, cancellationSignal);
}
/**
* Perform a query by combining all current settings and the information
* passed into this method.
*
* @param db the database to query on
* @param projection A list of which columns to return. Passing null will
* return all columns, which is discouraged to prevent reading
* data from storage that isn't going to be used.
* @param queryArgs A collection of arguments for the query, defined using
* keys such as {@link ContentResolver#QUERY_ARG_SQL_SELECTION}
* and {@link ContentResolver#QUERY_ARG_SQL_SELECTION_ARGS}.
* @param cancellationSignal A signal to cancel the operation in progress,
* or null if none. If the operation is canceled, then
* {@link OperationCanceledException} will be thrown when the
* query is executed.
* @return a cursor over the result set
*/
public Cursor query(@NonNull SQLiteDatabase db,
@Nullable String[] projection,
@Nullable Bundle queryArgs,
@Nullable CancellationSignal cancellationSignal) {
Objects.requireNonNull(db, "No database defined");
if (VMRuntime.getRuntime().getTargetSdkVersion() >= Build.VERSION_CODES.Q) {
Objects.requireNonNull(mTables, "No tables defined");
} else if (mTables == null) {
public Cursor query(SQLiteDatabase db, String[] projectionIn,
String selection, String[] selectionArgs, String groupBy,
String having, String sortOrder, String limit, CancellationSignal cancellationSignal) {
if (mTables == null) {
return null;
}
if (queryArgs == null) {
queryArgs = Bundle.EMPTY;
}
// Final SQL that we will execute
final String sql;
final String unwrappedSql = buildQuery(projection,
queryArgs.getString(QUERY_ARG_SQL_SELECTION),
queryArgs.getString(QUERY_ARG_SQL_GROUP_BY),
queryArgs.getString(QUERY_ARG_SQL_HAVING),
queryArgs.getString(QUERY_ARG_SQL_SORT_ORDER),
queryArgs.getString(QUERY_ARG_SQL_LIMIT));
if (mStrict) {
if (mStrict && selection != null && selection.length() > 0) {
// Validate the user-supplied selection to detect syntactic anomalies
// in the selection string that could indicate a SQL injection attempt.
// The idea is to ensure that the selection clause is a valid SQL expression
@@ -593,135 +384,24 @@ public class SQLiteQueryBuilder {
// originally specified. An attacker cannot create an expression that
// would escape the SQL expression while maintaining balanced parentheses
// in both the wrapped and original forms.
// NOTE: The ordering of the below operations is important; we must
// execute the wrapped query to ensure the untrusted clause has been
// fully isolated.
// TODO: decode SORT ORDER and LIMIT clauses, since they can contain
// "expr" inside that need to be validated
final String wrappedSql = buildQuery(projection,
wrap(queryArgs.getString(QUERY_ARG_SQL_SELECTION)),
queryArgs.getString(QUERY_ARG_SQL_GROUP_BY),
queryArgs.getString(QUERY_ARG_SQL_HAVING),
queryArgs.getString(QUERY_ARG_SQL_SORT_ORDER),
queryArgs.getString(QUERY_ARG_SQL_LIMIT));
// Validate the unwrapped query
db.validateSql(unwrappedSql, cancellationSignal);
// Execute wrapped query for extra protection
sql = wrappedSql;
} else {
// Execute unwrapped query
sql = unwrappedSql;
String sqlForValidation = buildQuery(projectionIn, "(" + selection + ")", groupBy,
having, sortOrder, limit);
db.validateSql(sqlForValidation, cancellationSignal); // will throw if query is invalid
}
final String[] sqlArgs = ArrayUtils.concat(String.class,
queryArgs.getStringArray(QUERY_ARG_SQL_SELECTION_ARGS), mWhereArgs);
String sql = buildQuery(
projectionIn, selection, groupBy, having,
sortOrder, limit);
if (Build.IS_DEBUGGABLE && Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, sql + " with args " + Arrays.toString(sqlArgs));
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "Performing query: " + sql);
}
return db.rawQueryWithFactory(
mFactory, sql, sqlArgs,
mFactory, sql, selectionArgs,
SQLiteDatabase.findEditTable(mTables),
cancellationSignal); // will throw if query is invalid
}
/**
* Perform an update by combining all current settings and the
* information passed into this method.
*
* @param db the database to update on
* @param selection A filter declaring which rows to return,
* formatted as an SQL WHERE clause (excluding the WHERE
* itself). Passing null will return all rows for the given URL.
* @param selectionArgs You may include ?s in selection, which
* will be replaced by the values from selectionArgs, in order
* that they appear in the selection. The values will be bound
* as Strings.
* @return the number of rows updated
*/
public int update(@NonNull SQLiteDatabase db, @NonNull ContentValues values,
@Nullable String selection, @Nullable String[] selectionArgs) {
Objects.requireNonNull(mTables, "No tables defined");
Objects.requireNonNull(db, "No database defined");
Objects.requireNonNull(values, "No values defined");
if (mStrict) {
// Validate the user-supplied selection to detect syntactic anomalies
// in the selection string that could indicate a SQL injection attempt.
// The idea is to ensure that the selection clause is a valid SQL expression
// by compiling it twice: once wrapped in parentheses and once as
// originally specified. An attacker cannot create an expression that
// would escape the SQL expression while maintaining balanced parentheses
// in both the wrapped and original forms.
final String sql = buildUpdate(values, wrap(selection));
db.validateSql(sql, null); // will throw if query is invalid
}
final ArrayMap<String, Object> rawValues = values.getValues();
final String[] updateArgs = new String[rawValues.size()];
for (int i = 0; i < updateArgs.length; i++) {
final Object arg = rawValues.valueAt(i);
updateArgs[i] = (arg != null) ? arg.toString() : null;
}
final String sql = buildUpdate(values, selection);
final String[] sqlArgs = ArrayUtils.concat(String.class, updateArgs,
ArrayUtils.concat(String.class, selectionArgs, mWhereArgs));
if (Build.IS_DEBUGGABLE && Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, sql + " with args " + Arrays.toString(sqlArgs));
}
return db.executeSql(sql, sqlArgs);
}
/**
* Perform a delete by combining all current settings and the
* information passed into this method.
*
* @param db the database to delete on
* @param selection A filter declaring which rows to return,
* formatted as an SQL WHERE clause (excluding the WHERE
* itself). Passing null will return all rows for the given URL.
* @param selectionArgs You may include ?s in selection, which
* will be replaced by the values from selectionArgs, in order
* that they appear in the selection. The values will be bound
* as Strings.
* @return the number of rows deleted
*/
public int delete(@NonNull SQLiteDatabase db, @Nullable String selection,
@Nullable String[] selectionArgs) {
Objects.requireNonNull(mTables, "No tables defined");
Objects.requireNonNull(db, "No database defined");
if (mStrict) {
// Validate the user-supplied selection to detect syntactic anomalies
// in the selection string that could indicate a SQL injection attempt.
// The idea is to ensure that the selection clause is a valid SQL expression
// by compiling it twice: once wrapped in parentheses and once as
// originally specified. An attacker cannot create an expression that
// would escape the SQL expression while maintaining balanced parentheses
// in both the wrapped and original forms.
final String sql = buildDelete(wrap(selection));
db.validateSql(sql, null); // will throw if query is invalid
}
final String sql = buildDelete(selection);
final String[] sqlArgs = ArrayUtils.concat(String.class, selectionArgs, mWhereArgs);
if (Build.IS_DEBUGGABLE && Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, sql + " with args " + Arrays.toString(sqlArgs));
}
return db.executeSql(sql, sqlArgs);
}
/**
* Construct a SELECT statement suitable for use in a group of
* SELECT statements that will be joined through UNION operators
@@ -754,10 +434,28 @@ public class SQLiteQueryBuilder {
String[] projectionIn, String selection, String groupBy,
String having, String sortOrder, String limit) {
String[] projection = computeProjection(projectionIn);
String where = computeWhere(selection);
StringBuilder where = new StringBuilder();
boolean hasBaseWhereClause = mWhereClause != null && mWhereClause.length() > 0;
if (hasBaseWhereClause) {
where.append(mWhereClause.toString());
where.append(')');
}
// Tack on the user's selection, if present.
if (selection != null && selection.length() > 0) {
if (hasBaseWhereClause) {
where.append(" AND ");
}
where.append('(');
where.append(selection);
where.append(')');
}
return buildQueryString(
mDistinct, mTables, projection, where,
mDistinct, mTables, projection, where.toString(),
groupBy, having, sortOrder, limit);
}
@@ -774,42 +472,6 @@ public class SQLiteQueryBuilder {
return buildQuery(projectionIn, selection, groupBy, having, sortOrder, limit);
}
/** {@hide} */
public String buildUpdate(ContentValues values, String selection) {
if (values == null || values.isEmpty()) {
throw new IllegalArgumentException("Empty values");
}
StringBuilder sql = new StringBuilder(120);
sql.append("UPDATE ");
sql.append(mTables);
sql.append(" SET ");
final ArrayMap<String, Object> rawValues = values.getValues();
for (int i = 0; i < rawValues.size(); i++) {
if (i > 0) {
sql.append(',');
}
sql.append(rawValues.keyAt(i));
sql.append("=?");
}
final String where = computeWhere(selection);
appendClause(sql, " WHERE ", where);
return sql.toString();
}
/** {@hide} */
public String buildDelete(String selection) {
StringBuilder sql = new StringBuilder(120);
sql.append("DELETE FROM ");
sql.append(mTables);
final String where = computeWhere(selection);
appendClause(sql, " WHERE ", where);
return sql.toString();
}
/**
* Construct a SELECT statement suitable for use in a group of
* SELECT statements that will be joined through UNION operators
@@ -934,7 +596,7 @@ public class SQLiteQueryBuilder {
return query.toString();
}
private @Nullable String[] computeProjection(@Nullable String[] projectionIn) {
private String[] computeProjection(String[] projectionIn) {
if (projectionIn != null && projectionIn.length > 0) {
if (mProjectionMap != null) {
String[] projection = new String[projectionIn.length];
@@ -957,7 +619,7 @@ public class SQLiteQueryBuilder {
}
throw new IllegalArgumentException("Invalid column "
+ projectionIn[i] + " from tables " + mTables);
+ projectionIn[i]);
}
return projection;
} else {
@@ -983,53 +645,4 @@ public class SQLiteQueryBuilder {
}
return null;
}
private @NonNull String computeWhere(@Nullable String selection) {
final boolean hasUser = selection != null && selection.length() > 0;
final boolean hasInternal = mWhereClause != null && mWhereClause.length() > 0;
if (hasUser || hasInternal) {
final StringBuilder where = new StringBuilder();
if (hasUser) {
where.append('(').append(selection).append(')');
}
if (hasUser && hasInternal) {
where.append(" AND ");
}
if (hasInternal) {
where.append('(').append(mWhereClause.toString()).append(')');
}
return where.toString();
} else {
return null;
}
}
/**
* Wrap given argument in parenthesis, unless it's {@code null} or
* {@code ()}, in which case return it verbatim.
*/
private @Nullable String wrap(@Nullable String arg) {
if (arg == null) {
return null;
} else if (arg.equals("")) {
return arg;
} else {
return "(" + arg + ")";
}
}
private static void maybePutString(@NonNull Bundle bundle, @NonNull String key,
@Nullable String value) {
if (value != null) {
bundle.putString(key, value);
}
}
private static void maybePutStringArray(@NonNull Bundle bundle, @NonNull String key,
@Nullable String[] value) {
if (value != null) {
bundle.putStringArray(key, value);
}
}
}

File diff suppressed because it is too large Load Diff