mirror of
https://github.com/apache/cordova-android.git
synced 2026-05-11 00:00:05 +08:00
[CB-3384] Rewrite of DataResource into UriResolver + UriResolvers
Includes unit tests woot! Note that this remove CordovaPlugin.shouldInterceptRequest(). Should be fine since this method was introduced only a couple of releases ago, was never documented, and afaict was only used by the Chrome Cordova plugins.
This commit is contained in:
@@ -943,4 +943,38 @@ public class CordovaWebView extends WebView {
|
||||
public void storeResult(int requestCode, int resultCode, Intent intent) {
|
||||
mResult = new ActivityResult(requestCode, resultCode, intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the given URI, giving plugins a chance to re-route or customly handle the URI.
|
||||
* A white-list rejection will be returned if the URI does not pass the white-list.
|
||||
* @return Never returns null.
|
||||
* @throws Throws an InvalidArgumentException for relative URIs. Relative URIs should be
|
||||
* resolved before being passed into this function.
|
||||
*/
|
||||
public UriResolver resolveUri(Uri uri) {
|
||||
return resolveUri(uri, false);
|
||||
}
|
||||
|
||||
UriResolver resolveUri(Uri uri, boolean fromWebView) {
|
||||
if (!uri.isAbsolute()) {
|
||||
throw new IllegalArgumentException("Relative URIs are not yet supported by resolveUri.");
|
||||
}
|
||||
// Check the against the white-list before delegating to plugins.
|
||||
if (("http".equals(uri.getScheme()) || "https".equals(uri.getScheme())) && !Config.isUrlWhiteListed(uri.toString()))
|
||||
{
|
||||
LOG.w(TAG, "resolveUri - URL is not in whitelist: " + uri);
|
||||
return new UriResolvers.ErrorUriResolver(uri, "Whitelist rejection");
|
||||
}
|
||||
|
||||
// Give plugins a chance to handle the request.
|
||||
UriResolver resolver = pluginManager.resolveUri(uri);
|
||||
if (resolver == null && !fromWebView) {
|
||||
resolver = UriResolvers.forUri(uri, cordova.getActivity());
|
||||
if (resolver == null) {
|
||||
resolver = new UriResolvers.ErrorUriResolver(uri, "Unresolvable URI");
|
||||
}
|
||||
}
|
||||
|
||||
return resolver;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,12 @@ import org.apache.cordova.api.CordovaInterface;
|
||||
import org.apache.cordova.api.LOG;
|
||||
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URLConnection;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.Locale;
|
||||
|
||||
public class FileHelper {
|
||||
@@ -124,6 +127,20 @@ public class FileHelper {
|
||||
return uriString;
|
||||
}
|
||||
|
||||
public static String getMimeTypeForExtension(String path) {
|
||||
String extension = path;
|
||||
int lastDot = extension.lastIndexOf('.');
|
||||
if (lastDot != -1) {
|
||||
extension = extension.substring(lastDot + 1);
|
||||
}
|
||||
// Convert the URI string to lower case to ensure compatibility with MimeTypeMap (see CB-2185).
|
||||
extension = extension.toLowerCase(Locale.getDefault());
|
||||
if (extension.equals("3ga")) {
|
||||
return "audio/3gpp";
|
||||
}
|
||||
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the mime type of the data specified by the given URI string.
|
||||
*
|
||||
@@ -137,19 +154,7 @@ public class FileHelper {
|
||||
if (uriString.startsWith("content://")) {
|
||||
mimeType = cordova.getActivity().getContentResolver().getType(uri);
|
||||
} else {
|
||||
// MimeTypeMap.getFileExtensionFromUrl() fails when there are query parameters.
|
||||
String extension = uri.getPath();
|
||||
int lastDot = extension.lastIndexOf('.');
|
||||
if (lastDot != -1) {
|
||||
extension = extension.substring(lastDot + 1);
|
||||
}
|
||||
// Convert the URI string to lower case to ensure compatibility with MimeTypeMap (see CB-2185).
|
||||
extension = extension.toLowerCase();
|
||||
if (extension.equals("3ga")) {
|
||||
mimeType = "audio/3gpp";
|
||||
} else {
|
||||
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
|
||||
}
|
||||
mimeType = getMimeTypeForExtension(uri.getPath());
|
||||
}
|
||||
|
||||
return mimeType;
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
package org.apache.cordova;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
@@ -26,6 +25,7 @@ import org.apache.cordova.api.CordovaInterface;
|
||||
import org.apache.cordova.api.LOG;
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.webkit.WebResourceResponse;
|
||||
import android.webkit.WebView;
|
||||
@@ -44,45 +44,29 @@ public class IceCreamCordovaWebViewClient extends CordovaWebViewClient {
|
||||
|
||||
@Override
|
||||
public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
|
||||
//Check if plugins intercept the request
|
||||
WebResourceResponse ret = super.shouldInterceptRequest(view, url);
|
||||
UriResolver uriResolver = appView.resolveUri(Uri.parse(url), true);
|
||||
|
||||
if(!Config.isUrlWhiteListed(url) && (url.startsWith("http://") || url.startsWith("https://")))
|
||||
{
|
||||
ret = getWhitelistResponse();
|
||||
if (uriResolver == null && url.startsWith("file:///android_asset/")) {
|
||||
if (url.contains("?") || url.contains("#") || needsIceCreamSpecialsInAssetUrlFix(url)) {
|
||||
uriResolver = appView.resolveUri(Uri.parse(url), false);
|
||||
}
|
||||
}
|
||||
else if(ret == null && (url.contains("?") || url.contains("#") || needsIceCreamSpecialsInAssetUrlFix(url))){
|
||||
ret = generateWebResourceResponse(url);
|
||||
}
|
||||
else if (ret == null && this.appView.pluginManager != null) {
|
||||
ret = this.appView.pluginManager.shouldInterceptRequest(url);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
private WebResourceResponse getWhitelistResponse()
|
||||
{
|
||||
WebResourceResponse emptyResponse;
|
||||
String empty = "";
|
||||
ByteArrayInputStream data = new ByteArrayInputStream(empty.getBytes());
|
||||
return new WebResourceResponse("text/plain", "UTF-8", data);
|
||||
}
|
||||
|
||||
private WebResourceResponse generateWebResourceResponse(String url) {
|
||||
if (url.startsWith("file:///android_asset/")) {
|
||||
String mimetype = FileHelper.getMimeType(url, cordova);
|
||||
|
||||
|
||||
if (uriResolver != null) {
|
||||
try {
|
||||
InputStream stream = FileHelper.getInputStreamFromUriString(url, cordova);
|
||||
WebResourceResponse response = new WebResourceResponse(mimetype, "UTF-8", stream);
|
||||
return response;
|
||||
InputStream stream = uriResolver.getInputStream();
|
||||
String mimeType = uriResolver.getMimeType();
|
||||
// If we don't know how to open this file, let the browser continue loading
|
||||
return new WebResourceResponse(mimeType, "UTF-8", stream);
|
||||
} catch (IOException e) {
|
||||
LOG.e("generateWebResourceResponse", e.getMessage(), e);
|
||||
LOG.e("IceCreamCordovaWebViewClient", "Error occurred while loading a file.", e);
|
||||
// Results in a 404.
|
||||
return new WebResourceResponse("text/plain", "UTF-8", null);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
private static boolean needsIceCreamSpecialsInAssetUrlFix(String url) {
|
||||
if (!url.contains("%20")){
|
||||
return false;
|
||||
@@ -96,5 +80,4 @@ public class IceCreamCordovaWebViewClient extends CordovaWebViewClient {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
*/
|
||||
package org.apache.cordova;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
/*
|
||||
* Interface for a class that can resolve URIs.
|
||||
* See CordovaUriResolver for an example.
|
||||
*/
|
||||
public interface UriResolver {
|
||||
|
||||
/** Returns the URI that this instance will resolve. */
|
||||
Uri getUri();
|
||||
|
||||
/**
|
||||
* Returns the InputStream for the resource.
|
||||
* Throws an exception if it cannot be read.
|
||||
* Never returns null.
|
||||
*/
|
||||
InputStream getInputStream() throws IOException;
|
||||
|
||||
/**
|
||||
* Returns the OutputStream for the resource.
|
||||
* Throws an exception if it cannot be written to.
|
||||
* Never returns null.
|
||||
*/
|
||||
OutputStream getOutputStream() throws IOException;
|
||||
|
||||
/**
|
||||
* Returns the MIME type of the resource.
|
||||
* Returns null if the MIME type cannot be determined (e.g. content: that doesn't exist).
|
||||
*/
|
||||
String getMimeType();
|
||||
|
||||
/** Returns whether the resource is writable. */
|
||||
boolean isWritable();
|
||||
|
||||
/**
|
||||
* Returns a File that points to the resource, or null if the resource
|
||||
* is not on the local file system.
|
||||
*/
|
||||
File getLocalFile();
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
*/
|
||||
package org.apache.cordova;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
import org.apache.cordova.FileHelper;
|
||||
import org.apache.http.util.EncodingUtils;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.content.res.AssetManager;
|
||||
import android.net.Uri;
|
||||
|
||||
/*
|
||||
* UriResolver implementations.
|
||||
*/
|
||||
public final class UriResolvers {
|
||||
private UriResolvers() {}
|
||||
|
||||
private static final class FileUriResolver implements UriResolver {
|
||||
private final Uri uri;
|
||||
private String mimeType;
|
||||
private File localFile;
|
||||
|
||||
FileUriResolver(Uri uri) {
|
||||
this.uri = uri;
|
||||
}
|
||||
|
||||
public Uri getUri() {
|
||||
return uri;
|
||||
}
|
||||
|
||||
public InputStream getInputStream() throws IOException {
|
||||
return new FileInputStream(getLocalFile());
|
||||
}
|
||||
|
||||
public OutputStream getOutputStream() throws FileNotFoundException {
|
||||
return new FileOutputStream(getLocalFile());
|
||||
}
|
||||
|
||||
public String getMimeType() {
|
||||
if (mimeType == null) {
|
||||
mimeType = FileHelper.getMimeTypeForExtension(getLocalFile().getName());
|
||||
}
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
public boolean isWritable() {
|
||||
File f = getLocalFile();
|
||||
if (f.isDirectory()) {
|
||||
return false;
|
||||
}
|
||||
if (f.exists()) {
|
||||
return f.canWrite();
|
||||
}
|
||||
return f.getParentFile().canWrite();
|
||||
}
|
||||
|
||||
public File getLocalFile() {
|
||||
if (localFile == null) {
|
||||
localFile = new File(uri.getPath());
|
||||
}
|
||||
return localFile;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AssetUriResolver implements UriResolver {
|
||||
private final Uri uri;
|
||||
private final AssetManager assetManager;
|
||||
private final String assetPath;
|
||||
private String mimeType;
|
||||
|
||||
AssetUriResolver(Uri uri, AssetManager assetManager) {
|
||||
this.uri = uri;
|
||||
this.assetManager = assetManager;
|
||||
this.assetPath = uri.getPath().substring(15);
|
||||
}
|
||||
|
||||
public Uri getUri() {
|
||||
return uri;
|
||||
}
|
||||
|
||||
public InputStream getInputStream() throws IOException {
|
||||
return assetManager.open(assetPath);
|
||||
}
|
||||
|
||||
public OutputStream getOutputStream() throws FileNotFoundException {
|
||||
throw new FileNotFoundException("URI not writable.");
|
||||
}
|
||||
|
||||
public String getMimeType() {
|
||||
if (mimeType == null) {
|
||||
mimeType = FileHelper.getMimeTypeForExtension(assetPath);
|
||||
}
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
public boolean isWritable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public File getLocalFile() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ContentUriResolver implements UriResolver {
|
||||
private final Uri uri;
|
||||
private final ContentResolver contentResolver;
|
||||
private String mimeType;
|
||||
|
||||
ContentUriResolver(Uri uri, ContentResolver contentResolver) {
|
||||
this.uri = uri;
|
||||
this.contentResolver = contentResolver;
|
||||
}
|
||||
|
||||
public Uri getUri() {
|
||||
return uri;
|
||||
}
|
||||
|
||||
public InputStream getInputStream() throws IOException {
|
||||
return contentResolver.openInputStream(uri);
|
||||
}
|
||||
|
||||
public OutputStream getOutputStream() throws FileNotFoundException {
|
||||
return contentResolver.openOutputStream(uri);
|
||||
}
|
||||
|
||||
public String getMimeType() {
|
||||
if (mimeType == null) {
|
||||
mimeType = contentResolver.getType(uri);
|
||||
}
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
public boolean isWritable() {
|
||||
return uri.getScheme().equals(ContentResolver.SCHEME_CONTENT);
|
||||
}
|
||||
|
||||
public File getLocalFile() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static final class ErrorUriResolver implements UriResolver {
|
||||
final Uri uri;
|
||||
final String errorMsg;
|
||||
|
||||
ErrorUriResolver(Uri uri, String errorMsg) {
|
||||
this.uri = uri;
|
||||
this.errorMsg = errorMsg;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWritable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri getUri() {
|
||||
return uri;
|
||||
}
|
||||
|
||||
@Override
|
||||
public File getLocalFile() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutputStream getOutputStream() throws IOException {
|
||||
throw new FileNotFoundException(errorMsg);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMimeType() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() throws IOException {
|
||||
throw new FileNotFoundException(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ReadOnlyResolver implements UriResolver {
|
||||
private Uri uri;
|
||||
private InputStream inputStream;
|
||||
private String mimeType;
|
||||
|
||||
public ReadOnlyResolver(Uri uri, InputStream inputStream, String mimeType) {
|
||||
this.uri = uri;
|
||||
this.inputStream = inputStream;
|
||||
this.mimeType = mimeType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWritable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uri getUri() {
|
||||
return uri;
|
||||
}
|
||||
|
||||
@Override
|
||||
public File getLocalFile() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutputStream getOutputStream() throws IOException {
|
||||
throw new FileNotFoundException("URI is not writable");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMimeType() {
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() throws IOException {
|
||||
return inputStream;
|
||||
}
|
||||
}
|
||||
|
||||
public static UriResolver createInline(Uri uri, String response, String mimeType) {
|
||||
return createInline(uri, EncodingUtils.getBytes(response, "UTF-8"), mimeType);
|
||||
}
|
||||
|
||||
public static UriResolver createInline(Uri uri, byte[] response, String mimeType) {
|
||||
return new ReadOnlyResolver(uri, new ByteArrayInputStream(response), mimeType);
|
||||
}
|
||||
|
||||
public static UriResolver createReadOnly(Uri uri, InputStream inputStream, String mimeType) {
|
||||
return new ReadOnlyResolver(uri, inputStream, mimeType);
|
||||
}
|
||||
|
||||
/* Package-private to force clients to go through CordovaWebView.resolveUri(). */
|
||||
static UriResolver forUri(Uri uri, Context context) {
|
||||
String scheme = uri.getScheme();
|
||||
if (ContentResolver.SCHEME_CONTENT.equals(scheme) || ContentResolver.SCHEME_ANDROID_RESOURCE.equals(scheme)) {
|
||||
return new ContentUriResolver(uri, context.getContentResolver());
|
||||
}
|
||||
if (ContentResolver.SCHEME_FILE.equals(scheme)) {
|
||||
if (uri.getPath().startsWith("/android_asset/")) {
|
||||
return new AssetUriResolver(uri, context.getAssets());
|
||||
}
|
||||
return new FileUriResolver(uri);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -20,14 +20,12 @@ package org.apache.cordova.api;
|
||||
|
||||
import org.apache.cordova.CordovaArgs;
|
||||
import org.apache.cordova.CordovaWebView;
|
||||
import org.apache.cordova.UriResolver;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
import android.webkit.WebResourceResponse;
|
||||
import android.net.Uri;
|
||||
|
||||
/**
|
||||
* Plugins must extend this class and override one of the execute methods.
|
||||
@@ -165,13 +163,10 @@ public class CordovaPlugin {
|
||||
}
|
||||
|
||||
/**
|
||||
* By specifying a <url-filter> in config.xml you can map a URL prefix to this method. It applies to all resources loaded in the WebView, not just top-level navigation.
|
||||
*
|
||||
* @param url The URL of the resource to be loaded.
|
||||
* @return Return a WebResourceResponse for the resource, or null to let the WebView handle it normally.
|
||||
* Hook for overriding the default URI handling mechanism.
|
||||
* Applies to WebView requests as well as requests made by plugins.
|
||||
*/
|
||||
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
|
||||
public WebResourceResponse shouldInterceptRequest(String url) {
|
||||
public UriResolver resolveUri(Uri uri) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,12 +26,14 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.apache.cordova.CordovaArgs;
|
||||
import org.apache.cordova.CordovaWebView;
|
||||
import org.apache.cordova.UriResolver;
|
||||
import org.json.JSONException;
|
||||
import org.xmlpull.v1.XmlPullParserException;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.content.res.XmlResourceParser;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
import android.webkit.WebResourceResponse;
|
||||
|
||||
@@ -379,25 +381,6 @@ public class PluginManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the WebView is loading any resource, top-level or not.
|
||||
*
|
||||
* Uses the same url-filter tag as onOverrideUrlLoading.
|
||||
*
|
||||
* @param url The URL of the resource to be loaded.
|
||||
* @return Return a WebResourceResponse with the resource, or null if the WebView should handle it.
|
||||
*/
|
||||
public WebResourceResponse shouldInterceptRequest(String url) {
|
||||
Iterator<Entry<String, String>> it = this.urlMap.entrySet().iterator();
|
||||
while (it.hasNext()) {
|
||||
HashMap.Entry<String, String> pairs = it.next();
|
||||
if (url.startsWith(pairs.getKey())) {
|
||||
return this.getPlugin(pairs.getValue()).shouldInterceptRequest(url);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the app navigates or refreshes.
|
||||
*/
|
||||
@@ -419,6 +402,18 @@ public class PluginManager {
|
||||
LOG.e(TAG, "=====================================================================================");
|
||||
}
|
||||
|
||||
/* Should be package private */ public UriResolver resolveUri(Uri uri) {
|
||||
for (PluginEntry entry : this.entries.values()) {
|
||||
if (entry.plugin != null) {
|
||||
UriResolver ret = entry.plugin.resolveUri(uri);
|
||||
if (ret != null) {
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private class PluginManagerService extends CordovaPlugin {
|
||||
@Override
|
||||
public boolean execute(String action, CordovaArgs args, final CallbackContext callbackContext) throws JSONException {
|
||||
|
||||
Reference in New Issue
Block a user