Select to view content in your preferred language

How to support custom tiled map format in Runtime v100 ?

1904
3
Jump to solution
10-16-2017 11:43 AM
PatrickBolduc
Emerging Contributor

Hi,

We are trying to migrate one of our applicatoin from v10.2.9 to v100.1. We need to support map formats that are currently unsupported in the new runtime (i.e. : MBTiles). In our case, the system is always offline and the map are stored locally on the Android device.

It was easy to do that in the previous version (Creating a custom TileServiceLayer in ArcGIS Android | ArcGIS Blog ), but I had no success so for on the new v100.1.

I tried to extend the ImageTiledLayer, but I get an exception when I try to create a TileInfo using the following code :

public static ImageTiledLayer createMBTilesLayer(final Uri databaseUri)
{
    final SQLiteDatabase database = SQLiteDatabase.openDatabase(databaseUri.getPath(), null, SQLiteDatabase.OPEN_READONLY, new ErrorHandler());
    final int maxLevel = retrieveMaxLevel(database);

    final List<LevelOfDetail> levelOfDetails = new ArrayList<>();
    for(int i = 0; i < maxLevel; i++)
        levelOfDetails.add(new LevelOfDetail(i, 156543.032 / Math.pow(2, i), 554678932 / Math.pow(2, i)));

    final TileInfo tileInfo = new TileInfo(96, TileInfo.ImageFormat.JPG, levelOfDetails, new Point(0, 0), SpatialReferences.getWebMercator(), 256, 256);

    return new MBTilesLayer(tileInfo, new Envelope(-180.0, -85.0, 180, 85, SpatialReferences.getWebMercator()), database);
}

java.lang.RuntimeException: Unable to start activity ComponentInfo{}: com.esri.arcgisruntime.ArcGISRuntimeException: Invalid argument
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2456)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2539)
at android.app.ActivityThread.access$900(ActivityThread.java:159)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1384)
at android.os.Handler.dispatchMessage(Handler.java:102)
at android.os.Looper.loop(Looper.java:148)
at android.app.ActivityThread.main(ActivityThread.java:5507)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:726)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:616)

Caused by: com.esri.arcgisruntime.ArcGISRuntimeException: Invalid argument
at com.esri.arcgisruntime.internal.jni.CoreTileInfo.nativeCreateWith(Native Method)
at com.esri.arcgisruntime.internal.jni.CoreTileInfo.<init>(SourceFile:72)
at com.esri.arcgisruntime.arcgisservices.TileInfo.<init>(SourceFile:121)

...

Is it the right approach to support other tiled format ? Is it some obvious error in the code snippet above that I do not see ?

Regards,

1 Solution

Accepted Solutions
AlexanderNohe1
Honored Contributor

I took some time today and converted the 10.2.x sample to runtime 100:

This is the MBTilesLayer.java class:

package com.arcgis.apps.mbtiles;

import android.database.Cursor;
import android.database.SQLException;
import android.database.sqlite.SQLiteDatabase;
import android.util.Log;

import com.esri.arcgisruntime.arcgisservices.LevelOfDetail;
import com.esri.arcgisruntime.arcgisservices.TileInfo;
import com.esri.arcgisruntime.data.TileKey;
import com.esri.arcgisruntime.geometry.Envelope;
import com.esri.arcgisruntime.geometry.GeometryEngine;
import com.esri.arcgisruntime.geometry.Point;
import com.esri.arcgisruntime.geometry.SpatialReferences;
import com.esri.arcgisruntime.layers.ImageTiledLayer;

import java.util.ArrayList;
import java.util.List;


/**
 * The MBTilesLayer class allows you to work with a MBTiles stored in a SQLite
 * database.
 * 
 */
public class MBTilesLayer extends ImageTiledLayer {

  private SQLiteDatabase mapDb;
  private int mLevels = 0;

  /**
   * The constructor to instantiate MBTiles from a path on device
   * 
   * @param path
   *          path is expected to be of the form /sdcard/path/package.mbtiles
   */
  public static MBTilesLayer init(String path) {
    SQLiteDatabase mapDb;
    int mLevels = 0;
    try {
      mapDb = SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY);
    } catch (SQLException ex) {
      Log.e("MBTiles", ex.getMessage());
      throw (ex);
    }

    // Default TMS bounds = bounds of Web Mercator projection
    Envelope envWGS = new Envelope(-180.0, -85.0511, 180.0, 85.0511, SpatialReferences.getWgs84());

    // See if the MBTiles DB defines their own Bounds in the metadata table
    Cursor bounds = mapDb.rawQuery("SELECT value FROM metadata WHERE name = 'bounds'", null);
    if (bounds.moveToFirst()) {
      String bs = bounds.getString(0);
      String[] ba = bs.split(",", 4);
      if (ba.length == 4) {
        double leftLon = Double.parseDouble(ba[0]);
        double topLat = Double.parseDouble(ba[3]);
        double rightLon = Double.parseDouble(ba[2]);
        double bottomLat = Double.parseDouble(ba[1]);

        envWGS = new Envelope(leftLon, bottomLat, rightLon, topLat, SpatialReferences.getWgs84());
      }
    }

    Envelope envWeb = (Envelope) GeometryEngine.project(envWGS,
        SpatialReferences.getWebMercator());

    Point origin = new Point(envWeb.getXMin(), envWeb.getYMax(), envWeb.getSpatialReference());

    Cursor maxLevelCur = mapDb.rawQuery("SELECT MAX(zoom_level) AS max_zoom FROM tiles", null);
    if (maxLevelCur.moveToFirst()) {
      mLevels = maxLevelCur.getInt(0);
    }

    Log.i("TAG", "Max levels = " + Integer.toString(mLevels));

    double[] resolution = new double[mLevels];
    double[] scale = new double[mLevels];
    List<LevelOfDetail> lod = new ArrayList<>(mLevels);
    for (int i = 0; i < mLevels; i++) {
      // see the TMS spec for derivation of the level 0 scale and resolution
      // For each level the resolution (in meters per pixel) doubles
      resolution[i] = 156543.032 / Math.pow(2, i);
      // Level 0 scale is 1:554,678,932. Each level doubles this.
      scale[i] = 554678932 / Math.pow(2, i);
      lod.add(new LevelOfDetail(i, resolution[i], scale[i]));
    }

    /*
     * Note, the constructor must set the following values or we won't send the
     * status change events to listeners and the tiles will not be fetched
     * 
     * Origin is Top Left (web Mercator) , the rest are defined by the TMS
     * Global-mercator spec (scales, resolution, 96dpi 256x256 pixel tiles) See:
     * http://wiki.osgeo.org/wiki/Tile_Map_Service_Specification#global-mercator
     */
    TileInfo ti = new TileInfo(96, TileInfo.ImageFormat.PNG, lod, origin, origin.getSpatialReference(), 256, 256);

    return new MBTilesLayer(ti, envWeb, mapDb, mLevels);

  }

  private MBTilesLayer(TileInfo tileInfo, Envelope fullExtent, SQLiteDatabase mapDb, int mLevels) {
    super(tileInfo, fullExtent);
    this.mapDb = mapDb;
    this.mLevels = mLevels;
  }


  @Override
  protected byte[] getTile(TileKey tileKey) {
    // need to flip origin
    int nRows = (1 << tileKey.getLevel()); // Num rows = 2^level
    int tmsRow = nRows - 1 - tileKey.getRow();

    Cursor imageCur = mapDb.rawQuery("SELECT tile_data FROM tiles WHERE zoom_level = " + Integer.toString(tileKey.getLevel())
            + " AND tile_column = " + Integer.toString(tileKey.getColumn()) + " AND tile_row = " + Integer.toString(tmsRow), null);
    if (imageCur.moveToFirst()) {
      return imageCur.getBlob(0);
    }
    return null; // Alternatively we might return a "no data" tile
  }
}‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍

And here is how I called it:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    mapView = (MapView) findViewById(R.id.mapView);

    MBTilesLayer mbTilesLayer = MBTilesLayer.init(Environment.getExternalStorageDirectory().getPath() 
         + "/ArcGIS/mmpks/world_countries.mbtiles");

    ArcGISMap arcGISMap = new ArcGISMap(new Basemap(mbTilesLayer));


    mapView.setMap(arcGISMap);
}

There are some things I am avoiding (like runtime permissions) but this should be a good start for you to implement this in your applications.

I hope this helps!

View solution in original post

3 Replies
AlexanderNohe1
Honored Contributor

I took some time today and converted the 10.2.x sample to runtime 100:

This is the MBTilesLayer.java class:

package com.arcgis.apps.mbtiles;

import android.database.Cursor;
import android.database.SQLException;
import android.database.sqlite.SQLiteDatabase;
import android.util.Log;

import com.esri.arcgisruntime.arcgisservices.LevelOfDetail;
import com.esri.arcgisruntime.arcgisservices.TileInfo;
import com.esri.arcgisruntime.data.TileKey;
import com.esri.arcgisruntime.geometry.Envelope;
import com.esri.arcgisruntime.geometry.GeometryEngine;
import com.esri.arcgisruntime.geometry.Point;
import com.esri.arcgisruntime.geometry.SpatialReferences;
import com.esri.arcgisruntime.layers.ImageTiledLayer;

import java.util.ArrayList;
import java.util.List;


/**
 * The MBTilesLayer class allows you to work with a MBTiles stored in a SQLite
 * database.
 * 
 */
public class MBTilesLayer extends ImageTiledLayer {

  private SQLiteDatabase mapDb;
  private int mLevels = 0;

  /**
   * The constructor to instantiate MBTiles from a path on device
   * 
   * @param path
   *          path is expected to be of the form /sdcard/path/package.mbtiles
   */
  public static MBTilesLayer init(String path) {
    SQLiteDatabase mapDb;
    int mLevels = 0;
    try {
      mapDb = SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY);
    } catch (SQLException ex) {
      Log.e("MBTiles", ex.getMessage());
      throw (ex);
    }

    // Default TMS bounds = bounds of Web Mercator projection
    Envelope envWGS = new Envelope(-180.0, -85.0511, 180.0, 85.0511, SpatialReferences.getWgs84());

    // See if the MBTiles DB defines their own Bounds in the metadata table
    Cursor bounds = mapDb.rawQuery("SELECT value FROM metadata WHERE name = 'bounds'", null);
    if (bounds.moveToFirst()) {
      String bs = bounds.getString(0);
      String[] ba = bs.split(",", 4);
      if (ba.length == 4) {
        double leftLon = Double.parseDouble(ba[0]);
        double topLat = Double.parseDouble(ba[3]);
        double rightLon = Double.parseDouble(ba[2]);
        double bottomLat = Double.parseDouble(ba[1]);

        envWGS = new Envelope(leftLon, bottomLat, rightLon, topLat, SpatialReferences.getWgs84());
      }
    }

    Envelope envWeb = (Envelope) GeometryEngine.project(envWGS,
        SpatialReferences.getWebMercator());

    Point origin = new Point(envWeb.getXMin(), envWeb.getYMax(), envWeb.getSpatialReference());

    Cursor maxLevelCur = mapDb.rawQuery("SELECT MAX(zoom_level) AS max_zoom FROM tiles", null);
    if (maxLevelCur.moveToFirst()) {
      mLevels = maxLevelCur.getInt(0);
    }

    Log.i("TAG", "Max levels = " + Integer.toString(mLevels));

    double[] resolution = new double[mLevels];
    double[] scale = new double[mLevels];
    List<LevelOfDetail> lod = new ArrayList<>(mLevels);
    for (int i = 0; i < mLevels; i++) {
      // see the TMS spec for derivation of the level 0 scale and resolution
      // For each level the resolution (in meters per pixel) doubles
      resolution[i] = 156543.032 / Math.pow(2, i);
      // Level 0 scale is 1:554,678,932. Each level doubles this.
      scale[i] = 554678932 / Math.pow(2, i);
      lod.add(new LevelOfDetail(i, resolution[i], scale[i]));
    }

    /*
     * Note, the constructor must set the following values or we won't send the
     * status change events to listeners and the tiles will not be fetched
     * 
     * Origin is Top Left (web Mercator) , the rest are defined by the TMS
     * Global-mercator spec (scales, resolution, 96dpi 256x256 pixel tiles) See:
     * http://wiki.osgeo.org/wiki/Tile_Map_Service_Specification#global-mercator
     */
    TileInfo ti = new TileInfo(96, TileInfo.ImageFormat.PNG, lod, origin, origin.getSpatialReference(), 256, 256);

    return new MBTilesLayer(ti, envWeb, mapDb, mLevels);

  }

  private MBTilesLayer(TileInfo tileInfo, Envelope fullExtent, SQLiteDatabase mapDb, int mLevels) {
    super(tileInfo, fullExtent);
    this.mapDb = mapDb;
    this.mLevels = mLevels;
  }


  @Override
  protected byte[] getTile(TileKey tileKey) {
    // need to flip origin
    int nRows = (1 << tileKey.getLevel()); // Num rows = 2^level
    int tmsRow = nRows - 1 - tileKey.getRow();

    Cursor imageCur = mapDb.rawQuery("SELECT tile_data FROM tiles WHERE zoom_level = " + Integer.toString(tileKey.getLevel())
            + " AND tile_column = " + Integer.toString(tileKey.getColumn()) + " AND tile_row = " + Integer.toString(tmsRow), null);
    if (imageCur.moveToFirst()) {
      return imageCur.getBlob(0);
    }
    return null; // Alternatively we might return a "no data" tile
  }
}‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍

And here is how I called it:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    mapView = (MapView) findViewById(R.id.mapView);

    MBTilesLayer mbTilesLayer = MBTilesLayer.init(Environment.getExternalStorageDirectory().getPath() 
         + "/ArcGIS/mmpks/world_countries.mbtiles");

    ArcGISMap arcGISMap = new ArcGISMap(new Basemap(mbTilesLayer));


    mapView.setMap(arcGISMap);
}

There are some things I am avoiding (like runtime permissions) but this should be a good start for you to implement this in your applications.

I hope this helps!

PatrickBolduc
Emerging Contributor

Hi Alexander,

I changed the envelope creation code with what you provided in your reply and it fixed my issue and it works fine.

Thanks for the quick reply !

AlexanderNohe1
Honored Contributor

No problem!

If it helped resolve your problem, could you kindly mark the answer as correct.

0 Kudos