terikon / cordova-plugin-photo-library

Maintainer needed. Please contact if you're using this library in your project
MIT License
149 stars 300 forks source link

android 11 support (SDK 30) - get error when saving an image #199

Open YisaHsu opened 3 years ago

YisaHsu commented 3 years ago

When this sentence is executed(if whereClause != ""), an error occurs:

 final Cursor cursor = context.getContentResolver().query(
   collection,
   columnValues.toArray(new String[columns.length()]),
   whereClause, null, sortOrder);

(com/terikon/cordova/photolibrary/PhotoLibraryService.java line : 283)

josuegr0487 commented 3 years ago

Same error, there is a try catch, it returns this message "java.lang.IllegalArgumentException: Invalid token /storage/emulated/0/Pictures/imageX.jpg", value of WhereClause

YisaHsu commented 3 years ago

Cursor cursor; if (whereClause == "") { cursor= context.getContentResolver().query( collection, columnValues.toArray(new String[columns.length()]), whereClause, null, sortOrder); } else { Uri uri = Uri.parse(whereClause); cursor= context.getContentResolver().query( uri, columnValues.toArray(new String[columns.length()]), null, null, null); }

`  public void saveImage(final Context context, final CordovaInterface cordova, final String url, String album, final JSONObjectRunnable completion)
throws IOException, URISyntaxException {

saveMedia(context, cordova, url, album, imageMimeToExtension, new FilePathRunnable() {
  @Override
  public void run(String filePath, Uri uri) {
    try {
      // Find the saved image in the library and return it as libraryItem
      String whereClause = "(" + MediaStore.MediaColumns.DATA + " = \"" + filePath + "\")";
      whereClause = uri.toString();
      queryLibrary(context, whereClause, new ChunkResultRunnable() {
        @Override
        public void run(ArrayList<JSONObject> chunk, int chunkNum, boolean isLastChunk) {
          completion.run(chunk.size() == 1 ? chunk.get(0) : null);
        }
      });
    } catch (Exception e) {
      completion.run(null);
    }
  }
});

} `

I modified these codes and it seems to work.

josuegr0487 commented 3 years ago

where to make the changes?

YisaHsu commented 3 years ago

https://bak.infolight.com/android/YUPIN/PhotoLibraryService.java.txt

I only modified one file, and the link to the content of the modified file is above.

eddsaura commented 3 years ago

https://bak.infolight.com/android/YUPIN/PhotoLibraryService.java.txt

I only modified one file, and the link to the content of the modified file is above.

You are the man, Yisa.

josegutierro commented 3 years ago

203

We did a PR with the @YisaHsu solution, we can confirm it's working on Android 11 and lower.

frankhasen commented 3 years ago

based on @YisaHsu s patch I added a few null checks for having no albums yet. In the manifest you will need android:requestLegacyExternalStorage="true" on the application. I am on ionic v6 and it is tested on Android 10, 11 and 9.

package com.terikon.cordova.photolibrary;

import android.content.Context;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Matrix;
import android.media.ExifInterface;
import android.media.MediaScannerConnection;
import android.media.ThumbnailUtils;
import android.net.Uri;
import android.os.Environment;
import android.os.SystemClock;
import android.provider.MediaStore;
import android.util.Base64;

import org.apache.cordova.CordovaInterface;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.URL;
import java.net.URISyntaxException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.TimeZone;

public class PhotoLibraryService {

  // TODO: implement cache
  //int cacheSize = 4 * 1024 * 1024; // 4MB
  //private LruCache<String, byte[]> imageCache = new LruCache<String, byte[]>(cacheSize);

  protected PhotoLibraryService() {
    dateFormatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
    dateFormatter.setTimeZone(TimeZone.getTimeZone("UTC"));
  }

  public static final String PERMISSION_ERROR = "Permission Denial: This application is not allowed to access Photo data.";

  public static PhotoLibraryService getInstance() {
    if (instance == null) {
      synchronized (PhotoLibraryService.class) {
        if (instance == null) {
          instance = new PhotoLibraryService();
        }
      }
    }
    return instance;
  }

  public void getLibrary(Context context, PhotoLibraryGetLibraryOptions options, ChunkResultRunnable completion) throws JSONException {

    String whereClause = "";
    queryLibrary(context, options.itemsInChunk, options.chunkTimeSec, options.includeAlbumData, whereClause, completion);

  }

  public ArrayList<JSONObject> getAlbums(Context context) throws JSONException {

    // All columns here: https://developer.android.com/reference/android/provider/MediaStore.Images.ImageColumns.html,
    // https://developer.android.com/reference/android/provider/MediaStore.MediaColumns.html
    JSONObject columns = new JSONObject() {{
      put("id", MediaStore.Images.ImageColumns.BUCKET_ID);
      put("title", MediaStore.Images.ImageColumns.BUCKET_DISPLAY_NAME);
    }};

    final ArrayList<JSONObject> queryResult = queryContentProvider(context, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, columns, "1) GROUP BY 1,(2");

    return queryResult;

  }

  public PictureData getThumbnail(Context context, String photoId, int thumbnailWidth, int thumbnailHeight, double quality) throws IOException {

    Bitmap bitmap = null;

    String imageURL = getImageURL(photoId);
    File imageFile = new File(imageURL);

    // TODO: maybe it never worth using MediaStore.Images.Thumbnails.getThumbnail, as it returns sizes less than 512x384?
    if (thumbnailWidth == 512 && thumbnailHeight == 384) { // In such case, thumbnail will be cached by MediaStore
      int imageId = getImageId(photoId);
      // For some reason and against documentation, MINI_KIND image can be returned in size different from 512x384, so the image will be scaled later if needed
      bitmap = MediaStore.Images.Thumbnails.getThumbnail(
        context.getContentResolver(),
        imageId ,
        MediaStore.Images.Thumbnails.MINI_KIND,
        (BitmapFactory.Options) null);
    }

    if (bitmap == null) { // No free caching here
      Uri imageUri = Uri.fromFile(imageFile);
      BitmapFactory.Options options = new BitmapFactory.Options();

      options.inJustDecodeBounds = true;
      InputStream is = context.getContentResolver().openInputStream(imageUri);
      BitmapFactory.decodeStream(is, null, options);

      // get bitmap with size of closest power of 2
      options.inSampleSize = calculateInSampleSize(options, thumbnailWidth, thumbnailHeight);
      options.inJustDecodeBounds = false;
      is = context.getContentResolver().openInputStream(imageUri);
      bitmap = BitmapFactory.decodeStream(is, null, options);
      is.close();
    }

    if (bitmap != null) {

      // correct image orientation
      int orientation = getImageOrientation(imageFile);
      Bitmap rotatedBitmap = rotateImage(bitmap, orientation);
      if (bitmap != rotatedBitmap) {
        bitmap.recycle();
      }

      Bitmap thumbnailBitmap = ThumbnailUtils.extractThumbnail(rotatedBitmap, thumbnailWidth, thumbnailHeight);
      if (rotatedBitmap != thumbnailBitmap) {
        rotatedBitmap.recycle();
      }

      // TODO: cache bytes for performance

      byte[] bytes = getJpegBytesFromBitmap(thumbnailBitmap, quality);
      String mimeType = "image/jpeg";

      thumbnailBitmap.recycle();

      return new PictureData(bytes, mimeType);

    }

    return null;

  }

  public PictureAsStream getPhotoAsStream(Context context, String photoId) throws IOException {

    int imageId = getImageId(photoId);
    String imageURL = getImageURL(photoId);
    File imageFile = new File(imageURL);
    Uri imageUri = Uri.fromFile(imageFile);

    String mimeType = queryMimeType(context, imageId);

    InputStream is = context.getContentResolver().openInputStream(imageUri);

    if (mimeType.equals("image/jpeg")) {
      int orientation = getImageOrientation(imageFile);
      if (orientation > 1) { // Image should be rotated

        Bitmap bitmap = BitmapFactory.decodeStream(is, null, null);
        is.close();

        Bitmap rotatedBitmap = rotateImage(bitmap, orientation);

        bitmap.recycle();

        // Here we perform conversion with data loss, but it seems better than handling orientation in JavaScript.
        // Converting to PNG can be an option to prevent data loss, but in price of very large files.
        byte[] bytes = getJpegBytesFromBitmap(rotatedBitmap, 1.0); // minimize data loss with 1.0 quality

        is = new ByteArrayInputStream(bytes);
      }
    }

    return new PictureAsStream(is, mimeType);

  }

  public PictureData getPhoto(Context context, String photoId) throws IOException {

    PictureAsStream pictureAsStream = getPhotoAsStream(context, photoId);

    byte[] bytes =  readBytes(pictureAsStream.getStream());
    pictureAsStream.getStream().close();

    return new PictureData(bytes, pictureAsStream.getMimeType());

  }

  public void saveImage(final Context context, final CordovaInterface cordova, final String url, String album, final JSONObjectRunnable completion)
    throws IOException, URISyntaxException {

    saveMedia(context, cordova, url, album, imageMimeToExtension, new FilePathRunnable() {
      @Override
      public void run(String filePath, Uri uri) {
        try {
          // Find the saved image in the library and return it as libraryItem
          String whereClause = "(" + MediaStore.MediaColumns.DATA + " = \"" + filePath + "\")";
          whereClause = uri.toString();
          queryLibrary(context, whereClause, new ChunkResultRunnable() {
            @Override
            public void run(ArrayList<JSONObject> chunk, int chunkNum, boolean isLastChunk) {
              completion.run(chunk.size() == 1 ? chunk.get(0) : null);
            }
          });
        } catch (Exception e) {
          completion.run(null);
        }
      }
    });

  }

  public void saveVideo(final Context context, final CordovaInterface cordova, String url, String album)
    throws IOException, URISyntaxException {

    saveMedia(context, cordova, url, album, videMimeToExtension, new FilePathRunnable() {
      @Override
      public void run(String filePath, Uri uri) {
        // TODO: call queryLibrary and return libraryItem of what was saved
      }
    });
  }

  public class PictureData {

    public final byte[] bytes;
    public final String mimeType;

    public PictureData(byte[] bytes, String mimeType) {
      this.bytes = bytes;
      this.mimeType = mimeType;
    }

  }

  public class PictureAsStream {

    public PictureAsStream(InputStream stream, String mimeType) {
      this.stream = stream;
      this.mimeType = mimeType;
    }

    public InputStream getStream() { return this.stream; }

    public String getMimeType() { return this.mimeType; }

    private InputStream stream;
    private String mimeType;

  }

  private static PhotoLibraryService instance = null;

  private SimpleDateFormat dateFormatter;

  private Pattern dataURLPattern = Pattern.compile("^data:(.+?)/(.+?);base64,");

  private ArrayList<JSONObject> queryContentProvider(Context context, Uri collection, JSONObject columns, String whereClause) throws JSONException {

    final ArrayList<String> columnNames = new ArrayList<String>();
    final ArrayList<String> columnValues = new ArrayList<String>();

    Iterator<String> iteratorFields = columns.keys();

    while (iteratorFields.hasNext()) {
      String column = iteratorFields.next();

      columnNames.add(column);
      columnValues.add("" + columns.getString(column));
    }

    final String sortOrder = MediaStore.Images.Media.DATE_TAKEN + " DESC";

    Cursor cursor;
    if (whereClause == "")
    {
        cursor= context.getContentResolver().query(
              collection,
              columnValues.toArray(new String[columns.length()]),
              whereClause, null, sortOrder);
    }
    else
    {
        Uri uri = Uri.parse(whereClause);
        cursor= context.getContentResolver().query(
              uri,
              columnValues.toArray(new String[columns.length()]),
              null, null, null);
    }

    final ArrayList<JSONObject> buffer = new ArrayList<JSONObject>();

    if (cursor != null && cursor.moveToFirst()) {
      do {
        JSONObject item = new JSONObject();

        for (String column : columnNames) {
          int columnIndex = cursor.getColumnIndex(columns.get(column).toString());

          if (column.startsWith("int.")) {
            item.put(column.substring(4), cursor.getInt(columnIndex));
            if (column.substring(4).equals("width") && item.getInt("width") == 0) {
              System.err.println("cursor: " + cursor.getInt(columnIndex));
            }
          } else if (column.startsWith("float.")) {
            item.put(column.substring(6), cursor.getFloat(columnIndex));
          } else if (column.startsWith("date.")) {
            long intDate = cursor.getLong(columnIndex);
            Date date = new Date(intDate);
            item.put(column.substring(5), dateFormatter.format(date));
          } else {
            item.put(column, cursor.getString(columnIndex));
          }
        }
        buffer.add(item);

        // TODO: return partial result

      }
      while (cursor.moveToNext());
    }
    if(cursor != null){
      cursor.close();
    }

    return buffer;

  }

  private void queryLibrary(Context context, String whereClause, ChunkResultRunnable completion) throws JSONException {
    queryLibrary(context, 0, 0, false, whereClause, completion);
  }

  private void queryLibrary(Context context, int itemsInChunk, double chunkTimeSec, boolean includeAlbumData, String whereClause, ChunkResultRunnable completion)
    throws JSONException {

    // All columns here: https://developer.android.com/reference/android/provider/MediaStore.Images.ImageColumns.html,
    // https://developer.android.com/reference/android/provider/MediaStore.MediaColumns.html
    JSONObject columns = new JSONObject() {{
      put("int.id", MediaStore.Images.Media._ID);
      put("fileName", MediaStore.Images.ImageColumns.DISPLAY_NAME);
      put("int.width", MediaStore.Images.ImageColumns.WIDTH);
      put("int.height", MediaStore.Images.ImageColumns.HEIGHT);
      put("albumId", MediaStore.Images.ImageColumns.BUCKET_ID);
      put("date.creationDate", MediaStore.Images.ImageColumns.DATE_TAKEN);
      put("float.latitude", MediaStore.Images.ImageColumns.LATITUDE);
      put("float.longitude", MediaStore.Images.ImageColumns.LONGITUDE);
      put("nativeURL", MediaStore.MediaColumns.DATA); // will not be returned to javascript
    }};

    final ArrayList<JSONObject> queryResults = queryContentProvider(context, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, columns, whereClause);

    ArrayList<JSONObject> chunk = new ArrayList<JSONObject>();

    long chunkStartTime = SystemClock.elapsedRealtime();
    int chunkNum = 0;

    for (int i=0; i<queryResults.size(); i++) {
      JSONObject queryResult = queryResults.get(i);

      // swap width and height if needed
      try {
        int orientation = getImageOrientation(new File(queryResult.getString("nativeURL")));
        if (isOrientationSwapsDimensions(orientation)) { // swap width and height
          int tempWidth = queryResult.getInt("width");
          queryResult.put("width", queryResult.getInt("height"));
          queryResult.put("height", tempWidth);
        }
      } catch (IOException e) {
        // Do nothing
      }

      // photoId is in format "imageid;imageurl"
      queryResult.put("id",
          queryResult.get("id") + ";" +
          queryResult.get("nativeURL"));

      queryResult.remove("nativeURL"); // Not needed

      String albumId = queryResult.getString("albumId");
      queryResult.remove("albumId");
      if (includeAlbumData) {
        JSONArray albumsArray = new JSONArray();
        albumsArray.put(albumId);
        queryResult.put("albumIds", albumsArray);
      }

      chunk.add(queryResult);

      if (i == queryResults.size() - 1) { // Last item
        completion.run(chunk, chunkNum, true);
      } else if ((itemsInChunk > 0 && chunk.size() == itemsInChunk) || (chunkTimeSec > 0 && (SystemClock.elapsedRealtime() - chunkStartTime) >= chunkTimeSec*1000)) {
        completion.run(chunk, chunkNum, false);
        chunkNum += 1;
        chunk = new ArrayList<JSONObject>();
        chunkStartTime = SystemClock.elapsedRealtime();
      }

    }

  }

  private String queryMimeType(Context context, int imageId) {

    Cursor cursor = context.getContentResolver().query(
      MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
      new String[] { MediaStore.Images.ImageColumns.MIME_TYPE },
      MediaStore.MediaColumns._ID + "=?",
      new String[] {Integer.toString(imageId)}, null);

    if (cursor != null && cursor.moveToFirst()) {
      String mimeType = cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE));
      cursor.close();

      return mimeType;

    }
    if (cursor != null){
      cursor.close();
    }

    return null;
  }

  // From https://developer.android.com/training/displaying-bitmaps/load-bitmap.html
  private static int calculateInSampleSize(

    BitmapFactory.Options options, int reqWidth, int reqHeight) {
    // Raw height and width of image
    final int height = options.outHeight;
    final int width = options.outWidth;
    int inSampleSize = 1;

    if (height > reqHeight || width > reqWidth) {

      final int halfHeight = height / 2;
      final int halfWidth = width / 2;

      // Calculate the largest inSampleSize value that is a power of 2 and keeps both
      // height and width larger than the requested height and width.
      while ((halfHeight / inSampleSize) >= reqHeight
        && (halfWidth / inSampleSize) >= reqWidth) {
        inSampleSize *= 2;
      }
    }

    return inSampleSize;

  }

  private static byte[] getJpegBytesFromBitmap(Bitmap bitmap, double quality) {

    ByteArrayOutputStream stream = new ByteArrayOutputStream();
    bitmap.compress(Bitmap.CompressFormat.JPEG, (int)(quality * 100), stream);

    return stream.toByteArray();

  }

  private static void copyStream(InputStream source, OutputStream target) throws IOException {

    int bufferSize = 1024;
    byte[] buffer = new byte[bufferSize];

    int len;
    while ((len = source.read(buffer)) != -1) {
      target.write(buffer, 0, len);
    }

  }

  private static byte[] readBytes(InputStream inputStream) throws IOException {

    ByteArrayOutputStream byteBuffer = new ByteArrayOutputStream();

    int bufferSize = 1024;
    byte[] buffer = new byte[bufferSize];

    int len;
    while ((len = inputStream.read(buffer)) != -1) {
      byteBuffer.write(buffer, 0, len);
    }

    return byteBuffer.toByteArray();

  }

  // photoId is in format "imageid;imageurl;[swap]"
  private static int getImageId(String photoId) {
    return Integer.parseInt(photoId.split(";")[0]);
  }

  // photoId is in format "imageid;imageurl;[swap]"
  private static String getImageURL(String photoId) {
    return photoId.split(";")[1];
  }

  private static int getImageOrientation(File imageFile) throws IOException {

    ExifInterface exif = new ExifInterface(imageFile.getAbsolutePath());
    int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);

    return orientation;

  }

  // see http://www.daveperrett.com/articles/2012/07/28/exif-orientation-handling-is-a-ghetto/
  private static Bitmap rotateImage(Bitmap source, int orientation) {

    Matrix matrix = new Matrix();

    switch (orientation) {
      case ExifInterface.ORIENTATION_NORMAL: // 1
          return source;
      case ExifInterface.ORIENTATION_FLIP_HORIZONTAL: // 2
        matrix.setScale(-1, 1);
        break;
      case ExifInterface.ORIENTATION_ROTATE_180: // 3
        matrix.setRotate(180);
        break;
      case ExifInterface.ORIENTATION_FLIP_VERTICAL: // 4
        matrix.setRotate(180);
        matrix.postScale(-1, 1);
        break;
      case ExifInterface.ORIENTATION_TRANSPOSE: // 5
        matrix.setRotate(90);
        matrix.postScale(-1, 1);
        break;
      case ExifInterface.ORIENTATION_ROTATE_90: // 6
        matrix.setRotate(90);
        break;
      case ExifInterface.ORIENTATION_TRANSVERSE: // 7
        matrix.setRotate(-90);
        matrix.postScale(-1, 1);
        break;
      case ExifInterface.ORIENTATION_ROTATE_270: // 8
        matrix.setRotate(-90);
        break;
      default:
        return source;
    }

    return Bitmap.createBitmap(source, 0, 0, source.getWidth(), source.getHeight(), matrix, false);

  }

  // Returns true if orientation rotates image by 90 or 270 degrees.
  private static boolean isOrientationSwapsDimensions(int orientation) {
    return orientation == ExifInterface.ORIENTATION_TRANSPOSE // 5
      || orientation == ExifInterface.ORIENTATION_ROTATE_90 // 6
      || orientation == ExifInterface.ORIENTATION_TRANSVERSE // 7
      || orientation == ExifInterface.ORIENTATION_ROTATE_270; // 8
  }

  private static File makeAlbumInPhotoLibrary(String album) {
    File albumDirectory = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), album);
    if (!albumDirectory.exists()) {
      albumDirectory.mkdirs();
    }
    return albumDirectory;
  }

  private File getImageFileName(File albumDirectory, String extension) {
    Calendar calendar = Calendar.getInstance();
    String dateStr = calendar.get(Calendar.YEAR) +
      "-" + calendar.get(Calendar.MONTH) +
      "-" + calendar.get(Calendar.DAY_OF_MONTH);
    int i = 1;
    File result;
    do {
      String fileName = dateStr + "-" + i + extension;
      i += 1;
      result = new File(albumDirectory, fileName);
    } while (result.exists());
    return result;
  }

  private void addFileToMediaLibrary(Context context, File file, final FilePathRunnable completion) {

    String filePath = file.getAbsolutePath();

    MediaScannerConnection.scanFile(context, new String[]{filePath}, null, new MediaScannerConnection.OnScanCompletedListener() {
      @Override
      public void onScanCompleted(String path, Uri uri) {
        completion.run(path, uri);
      }
    });

  }

  private Map<String, String> imageMimeToExtension = new HashMap<String, String>(){{
    put("jpeg", ".jpg");
  }};

  private Map<String, String> videMimeToExtension = new HashMap<String, String>(){{
    put("quicktime", ".mov");
    put("ogg", ".ogv");
  }};

  private void saveMedia(Context context, CordovaInterface cordova, String url, String album, Map<String, String> mimeToExtension, FilePathRunnable completion)
    throws IOException, URISyntaxException {

    File albumDirectory = makeAlbumInPhotoLibrary(album);
    File targetFile;

    if (url.startsWith("data:")) {

      Matcher matcher = dataURLPattern.matcher(url);
      if (!matcher.find()) {
        throw new IllegalArgumentException("The dataURL is in incorrect format");
      }
      String mime = matcher.group(2);
      int dataPos = matcher.end();

      String base64 = url.substring(dataPos); // Use substring and not replace to keep memory footprint small
      byte[] decoded = Base64.decode(base64, Base64.DEFAULT);

      if (decoded == null) {
        throw new IllegalArgumentException("The dataURL could not be decoded");
      }

      String extension = mimeToExtension.get(mime);
      if (extension == null) {
        extension = "." + mime;
      }

      targetFile = getImageFileName(albumDirectory, extension);

      FileOutputStream os = new FileOutputStream(targetFile);

      os.write(decoded);

      os.flush();
      os.close();

    } else {

      String extension = url.contains(".") ? url.substring(url.lastIndexOf(".")) : "";
      targetFile = getImageFileName(albumDirectory, extension);

      InputStream is;
      FileOutputStream os = new FileOutputStream(targetFile);

      if(url.startsWith("file:///android_asset/")) {
        String assetUrl = url.replace("file:///android_asset/", "");
        is = cordova.getActivity().getApplicationContext().getAssets().open(assetUrl);
      } else {
        is = new URL(url).openStream();
      }

      copyStream(is, os);

      os.flush();
      os.close();
      is.close();

    }

    addFileToMediaLibrary(context, targetFile, completion);

  }

  public interface ChunkResultRunnable {

    void run(ArrayList<JSONObject> chunk, int chunkNum, boolean isLastChunk);

  }

  public interface FilePathRunnable {

    void run(String filePath, Uri uri);

  }

  public interface JSONObjectRunnable {

    void run(JSONObject result);

  }

}