diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 4e7ecd7215..ac9a9146e4 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ - + diff --git a/android/src/main/java/com/rnmapbox/rnmbx/RNMBXPackage.kt b/android/src/main/java/com/rnmapbox/rnmbx/RNMBXPackage.kt index 0b9bf05813..be55ce31cb 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/RNMBXPackage.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/RNMBXPackage.kt @@ -54,6 +54,7 @@ import com.rnmapbox.rnmbx.components.styles.terrain.RNMBXTerrainManager import com.rnmapbox.rnmbx.events.RNMBXCameraGestureObserverManager import com.rnmapbox.rnmbx.modules.RNMBXLocationModule import com.rnmapbox.rnmbx.modules.RNMBXLogging +import com.rnmapbox.rnmbx.modules.RNMBXMBTilesModule import com.rnmapbox.rnmbx.modules.RNMBXModule import com.rnmapbox.rnmbx.modules.RNMBXOfflineModule import com.rnmapbox.rnmbx.modules.RNMBXOfflineModuleLegacy @@ -104,6 +105,7 @@ class RNMBXPackage : TurboReactPackage() { RNMBXOfflineModuleLegacy.REACT_CLASS -> return RNMBXOfflineModuleLegacy(reactApplicationContext) RNMBXSnapshotModule.REACT_CLASS -> return RNMBXSnapshotModule(reactApplicationContext) RNMBXLogging.REACT_CLASS -> return RNMBXLogging(reactApplicationContext) + "RNMBXMBTiles" -> return RNMBXMBTilesModule(reactApplicationContext) NativeMapViewModule.NAME -> return NativeMapViewModule(reactApplicationContext, getViewTagResolver(reactApplicationContext, s)) RNMBXCameraModule.NAME -> return RNMBXCameraModule(reactApplicationContext, getViewTagResolver(reactApplicationContext, s)) RNMBXViewportModule.NAME -> return RNMBXViewportModule(reactApplicationContext, getViewTagResolver(reactApplicationContext, s)) @@ -248,6 +250,15 @@ class RNMBXPackage : TurboReactPackage() { false, // isCxxModule false // isTurboModule ) + moduleInfos["RNMBXMBTiles"] = ReactModuleInfo( + "RNMBXMBTiles", + "RNMBXMBTiles", + false, // canOverrideExistingModule + false, // needsEagerInit + false, // hasConstants + false, // isCxxModule + false // isTurboModule + ) moduleInfos[NativeMapViewModule.NAME] = ReactModuleInfo( NativeMapViewModule.NAME, NativeMapViewModule.NAME, @@ -323,4 +334,4 @@ class RNMBXPackage : TurboReactPackage() { moduleInfos } } -} \ No newline at end of file +} diff --git a/android/src/main/java/com/rnmapbox/rnmbx/modules/RNMBXMBTilesModule.kt b/android/src/main/java/com/rnmapbox/rnmbx/modules/RNMBXMBTilesModule.kt new file mode 100644 index 0000000000..e93ea9b697 --- /dev/null +++ b/android/src/main/java/com/rnmapbox/rnmbx/modules/RNMBXMBTilesModule.kt @@ -0,0 +1,265 @@ +package com.rnmapbox.rnmbx.modules + +import android.util.Log +import android.net.Uri +import android.os.Build +import com.facebook.react.bridge.* +import com.rnmapbox.rnmbx.utils.MBTilesServer +import com.rnmapbox.rnmbx.utils.MBTilesSource +import com.rnmapbox.rnmbx.utils.MBTilesSourceException +import java.io.File + +class RNMBXMBTilesModule(private val reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + private val TAG = "RNMBXMBTilesModule" + private val activeSources = mutableMapOf() + + override fun getName(): String { + return "RNMBXMBTiles" + } + + /** + * Initialize and activate an MBTiles source from a file path + */ + @ReactMethod + fun initMBTilesSource(filePath: String, sourceId: String, promise: Promise) { + try { + // Note: In v11, we don't need to set Mapbox.isConnected anymore + // The HTTP requests to localhost will work without this setting + + // Handle Android file paths + val resolvedPath = if (filePath.startsWith("file://")) { + Uri.parse(filePath).path ?: filePath.substring(7) + } else { + filePath + } + + // Check if file exists + val file = File(resolvedPath) + if (!file.exists()) { + promise.reject("FILE_NOT_FOUND", "MBTiles file not found at path: $resolvedPath") + return + } + + // Create and activate the MBTiles source + val mbSource = MBTilesSource(resolvedPath, sourceId).apply { activate() } + activeSources[sourceId] = mbSource + + // Return source information + val resultMap = Arguments.createMap().apply { + putString("id", mbSource.id) + putString("url", mbSource.url) + putBoolean("isVector", mbSource.isVector) + putString("format", mbSource.format) + mbSource.minZoom?.let { putDouble("minZoom", it.toDouble()) } + mbSource.maxZoom?.let { putDouble("maxZoom", it.toDouble()) } + } + promise.resolve(resultMap) + + } catch (e: MBTilesSourceException.CouldNotReadFileException) { + promise.reject("ERROR_READING_FILE", "Could not read the MBTiles file") + } catch (e: MBTilesSourceException.UnsupportedFormatException) { + promise.reject("UNSUPPORTED_FORMAT", "MBTiles format is not supported") + } catch (e: Exception) { + promise.reject("UNKNOWN_ERROR", "Error initializing MBTiles source: ${e.localizedMessage}") + } + } + + /** + * Initialize an MBTiles source from an asset in the app bundle + */ + @ReactMethod + fun initMBTilesSourceFromAsset(assetName: String, sourceId: String, promise: Promise) { + try { + // Note: In v11, we don't need to set Mapbox.isConnected anymore + // The HTTP requests to localhost will work without this setting + + // Copy from asset to local file + val filePath = MBTilesSource.readAsset(reactContext, assetName) + + // Create and activate the MBTiles source + val mbSource = MBTilesSource(filePath, sourceId).apply { activate() } + activeSources[sourceId] = mbSource + + // Return source information + val resultMap = Arguments.createMap().apply { + putString("id", mbSource.id) + putString("url", mbSource.url) + putBoolean("isVector", mbSource.isVector) + putString("format", mbSource.format) + mbSource.minZoom?.let { putDouble("minZoom", it.toDouble()) } + mbSource.maxZoom?.let { putDouble("maxZoom", it.toDouble()) } + } + promise.resolve(resultMap) + + } catch (e: MBTilesSourceException.CouldNotReadFileException) { + promise.reject("ERROR_READING_FILE", "Could not read the MBTiles asset") + } catch (e: MBTilesSourceException.UnsupportedFormatException) { + promise.reject("UNSUPPORTED_FORMAT", "MBTiles format is not supported") + } catch (e: Exception) { + promise.reject("UNKNOWN_ERROR", "Error initializing MBTiles source from asset: ${e.localizedMessage}") + } + } + + /** + * Initialize an MBTiles source from a remote URL (downloads first) + */ + @ReactMethod + fun initMBTilesSourceFromURL(urlString: String, sourceId: String, promise: Promise) { + Thread { + try { + val url = java.net.URL(urlString) + + // Generate a filename from the URL or sourceId + val fileName = if (sourceId.isEmpty()) { + url.path.substringAfterLast("/") + } else { + "$sourceId.mbtiles" + } + + // Get the destination path + val destinationFile = File(reactContext.filesDir, fileName) + + // Download the file + url.openStream().use { input -> + java.io.FileOutputStream(destinationFile).use { output -> + input.copyTo(output) + } + } + + // Create and activate the MBTiles source + val effectiveSourceId = if (sourceId.isEmpty()) { + fileName.substringBefore(".") + } else { + sourceId + } + val mbSource = MBTilesSource(destinationFile.absolutePath, effectiveSourceId).apply { activate() } + activeSources[effectiveSourceId] = mbSource + + // Return source information + val resultMap = Arguments.createMap().apply { + putString("id", mbSource.id) + putString("url", mbSource.url) + putBoolean("isVector", mbSource.isVector) + putString("format", mbSource.format) + mbSource.minZoom?.let { putDouble("minZoom", it.toDouble()) } + mbSource.maxZoom?.let { putDouble("maxZoom", it.toDouble()) } + } + promise.resolve(resultMap) + + } catch (e: MBTilesSourceException.CouldNotReadFileException) { + promise.reject("ERROR_READING_FILE", "Could not read the downloaded MBTiles file") + } catch (e: MBTilesSourceException.UnsupportedFormatException) { + promise.reject("UNSUPPORTED_FORMAT", "MBTiles format is not supported") + } catch (e: java.net.MalformedURLException) { + promise.reject("INVALID_URL", "Invalid URL: $urlString") + } catch (e: java.io.IOException) { + promise.reject("DOWNLOAD_ERROR", "Failed to download MBTiles file: ${e.localizedMessage}") + } catch (e: Exception) { + promise.reject("UNKNOWN_ERROR", "Error initializing MBTiles source from URL: ${e.localizedMessage}") + } + }.start() + } + + /** + * Get the HTTP URL for an active MBTiles source to use in style json + */ + @ReactMethod + fun getMBTilesURL(sourceId: String, promise: Promise) { + val mbSource = activeSources[sourceId] + if (mbSource != null) { + promise.resolve(mbSource.url) + } else { + promise.reject("SOURCE_NOT_FOUND", "MBTiles source with ID '$sourceId' is not active") + } + } + + /** + * Stop and remove an MBTiles source + */ + @ReactMethod + fun removeMBTilesSource(sourceId: String, promise: Promise) { + val mbSource = activeSources[sourceId] + if (mbSource != null) { + mbSource.deactivate() + activeSources.remove(sourceId) + promise.resolve(true) + } else { + promise.resolve(false) + } + } + + /** + * Check if an MBTiles source is currently active + */ + @ReactMethod + fun isMBTilesSourceActive(sourceId: String, promise: Promise) { + promise.resolve(activeSources.containsKey(sourceId)) + } + + /** + * List all active MBTiles sources + */ + @ReactMethod + fun getActiveMBTilesSources(promise: Promise) { + val sources = Arguments.createArray() + activeSources.forEach { (id, _) -> + sources.pushString(id) + } + promise.resolve(sources) + } + + /** + * Manually start the MBTiles server + */ + @ReactMethod + fun startServer(promise: Promise) { + try { + MBTilesServer.start() + promise.resolve(MBTilesServer.isRunning) + } catch (e: Exception) { + promise.reject("SERVER_ERROR", "Error starting MBTiles server: ${e.localizedMessage}") + } + } + + /** + * Manually stop the MBTiles server + */ + @ReactMethod + fun stopServer(promise: Promise) { + try { + MBTilesServer.stop() + promise.resolve(true) + } catch (e: Exception) { + promise.reject("SERVER_ERROR", "Error stopping MBTiles server: ${e.localizedMessage}") + } + } + + /** + * Check if the MBTiles server is running + */ + @ReactMethod + fun isServerRunning(promise: Promise) { + promise.resolve(MBTilesServer.isRunning) + } + + @ReactMethod + fun addListener(eventName: String) { + // Required for RN built-in Event Emitter Calls + } + + @ReactMethod + fun removeListeners(count: Int) { + // Required for RN built-in Event Emitter Calls + } + + override fun onCatalystInstanceDestroy() { + super.onCatalystInstanceDestroy() + // Cleanup when React context is destroyed + activeSources.forEach { (_, source) -> + source.deactivate() + } + activeSources.clear() + } +} diff --git a/android/src/main/java/com/rnmapbox/rnmbx/utils/MBTilesServer.kt b/android/src/main/java/com/rnmapbox/rnmbx/utils/MBTilesServer.kt new file mode 100644 index 0000000000..2cd174e2cc --- /dev/null +++ b/android/src/main/java/com/rnmapbox/rnmbx/utils/MBTilesServer.kt @@ -0,0 +1,143 @@ +package com.rnmapbox.rnmbx.utils + +import android.util.Log +import java.io.BufferedReader +import java.io.FileNotFoundException +import java.io.PrintStream +import java.net.ServerSocket +import java.net.Socket +import kotlin.math.pow + +/* + * Localhost tile server with MBTilesSource + */ + +object MBTilesServer : Runnable { + + private const val TAG = "MBTilesServer" + const val port = 8888 + private var serverSocket: ServerSocket? = null // Created in start() + var isRunning = false + val sources: MutableMap = mutableMapOf() + + fun start() { + if (isRunning) return + + try { + serverSocket = ServerSocket(port) + isRunning = true + Thread(this).start() + } catch (e: Exception) { + Log.e(TAG, "Error starting server: ${e.localizedMessage}") + } + } + + fun stop() { + isRunning = false + try { + serverSocket?.close() + serverSocket = null + } catch (e: Exception) { + Log.e(TAG, "Error closing server socket: ${e.localizedMessage}") + } + } + + override fun run() { + try { + while (isRunning) { + serverSocket?.accept()?.use { socket -> + Log.d(TAG, "Handling request") + handle(socket) + Log.d(TAG, "Request handled") + } + } + } catch (e: Exception) { + Log.d( + TAG, + e.localizedMessage ?: "Exception while running MBTilesServer" + ) + } finally { + Log.d(TAG, "Server stopped") + } + } + + @Throws + private fun handle(socket: Socket) { + val reader: BufferedReader = socket.getInputStream().reader().buffered() + // Output stream that we send the response to + val output = PrintStream(socket.getOutputStream()) + + try { + var route: String? = null + + // Read HTTP headers and parse out the route. + do { + val line = reader.readLine() ?: "" + if (line.startsWith("GET")) { + // the format for route should be {source}/{z}/{x}/{y} + route = line.substringAfter("GET /").substringBefore(".") + break + } + } while (line.isNotEmpty()) + + // the source which this request target to + val source = sources[route?.substringBefore("/")] ?: return + + // Prepare the content to send. + if (null == route) { + writeServerError(output) + return + } + + val bytes = loadContent(source, route) + if (bytes == null) { + // Return a 404 Not Found status instead of 500 for missing tiles + writeNotFoundError(output) + return + } + + // Send out the content. + with(output) { + println("HTTP/1.0 200 OK") + println("Content-Type: " + detectMimeType(source.format)) + println("Content-Length: " + bytes.size) + if (source.isVector) println("Content-Encoding: gzip") + println() + write(bytes) + flush() + } + } finally { + reader.close() + output.close() + } + } + + @Throws + private fun loadContent(source: MBTilesSource, route: String): ByteArray? = try { + val (z, x, y) = route.split("/").subList(1, 4).map { it.toInt() } + source.getTile(z, x, (2.0.pow(z)).toInt() - 1 - y) + } catch (e: FileNotFoundException) { + e.printStackTrace() + null + } + + private fun writeServerError(output: PrintStream) { + output.println("HTTP/1.0 500 Internal Server Error") + output.flush() + } + + private fun writeNotFoundError(output: PrintStream) { + output.println("HTTP/1.0 404 Not Found") + output.println("Content-Length: 0") + output.println() + output.flush() + } + + private fun detectMimeType(format: String): String = when (format) { + "jpg" -> "image/jpeg" + "png" -> "image/png" + "mvt" -> "application/x-protobuf" + "pbf" -> "application/x-protobuf" + else -> "application/octet-stream" + } +} diff --git a/android/src/main/java/com/rnmapbox/rnmbx/utils/MBTilesSource.kt b/android/src/main/java/com/rnmapbox/rnmbx/utils/MBTilesSource.kt new file mode 100644 index 0000000000..e0642ac6b7 --- /dev/null +++ b/android/src/main/java/com/rnmapbox/rnmbx/utils/MBTilesSource.kt @@ -0,0 +1,208 @@ +package com.rnmapbox.rnmbx.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.util.Log +import com.mapbox.maps.extension.style.sources.Source +import com.mapbox.maps.extension.style.sources.TileSet +import com.mapbox.maps.extension.style.sources.generated.RasterSource +import com.mapbox.maps.extension.style.sources.generated.VectorSource +import java.io.File +import java.io.FileOutputStream +import kotlin.properties.Delegates + +/* + * Mapbox Source backend by localhost tile server + */ + +sealed class MBTilesSourceException : Exception() { + class CouldNotReadFileException : MBTilesSourceException() + class UnsupportedFormatException : MBTilesSourceException() +} + +class MBTilesSource(filePath: String, sourceId: String? = null) { + private val TAG = "MBTilesSource" + + val id = sourceId ?: filePath.substringAfterLast("/").substringBefore(".") + val url get() = "http://localhost:${MBTilesServer.port}/$id/{z}/{x}/{y}.$format" + private val db: SQLiteDatabase = try { + SQLiteDatabase.openDatabase(filePath, null, SQLiteDatabase.OPEN_READONLY) + } catch (e: RuntimeException) { + Log.e(TAG, "Failed to open MBTiles file: ${e.localizedMessage}") + throw MBTilesSourceException.CouldNotReadFileException() + } + + // Lazy initialization of source instance based on tile format + val instance: Source by lazy { + if (isVector) { + VectorSource.Builder(id) + .tiles(listOf(url)) + .build() + } else { + RasterSource.Builder(id) + .tiles(listOf(url)) + .tileSize(256) + .build() + } + } + + var isVector by Delegates.notNull() + lateinit var format: String + // Optional metadata properties + var minZoom: Float? = null + var maxZoom: Float? = null + + init { + try { + // Retrieve format from metadata table + format = db.query( + "metadata", null, "name = ?", + arrayOf("format"), null, null, null + ).use { cursor -> + if (cursor.count == 0) { + throw MBTilesSourceException.UnsupportedFormatException() + } + cursor.moveToFirst() + val index = cursor.getColumnIndex("value") + cursor.getString(index) + } + + // Determine if this is vector or raster based on format + isVector = when (format) { + in validVectorFormats -> true + in validRasterFormats -> false + else -> throw MBTilesSourceException.UnsupportedFormatException() + } + + // Try to read additional metadata + try { + db.query( + "metadata", null, "name = ?", + arrayOf("minzoom"), null, null, null + ).use { cursor -> + if (cursor.count > 0) { + cursor.moveToFirst() + val index = cursor.getColumnIndex("value") + minZoom = cursor.getString(index).toFloatOrNull() + } + } + + db.query( + "metadata", null, "name = ?", + arrayOf("maxzoom"), null, null, null + ).use { cursor -> + if (cursor.count > 0) { + cursor.moveToFirst() + val index = cursor.getColumnIndex("value") + maxZoom = cursor.getString(index).toFloatOrNull() + } + } + } catch (e: Exception) { + Log.w(TAG, "Error reading additional metadata: ${e.localizedMessage}") + // Continue without metadata + } + + } catch (error: MBTilesSourceException) { + Log.e(TAG, "Error initializing MBTilesSource: ${error.localizedMessage}") + throw error + } + } + + fun getTile(z: Int, x: Int, y: Int): ByteArray? { + return db.query( + "tiles", null, "zoom_level = ? AND tile_column = ? AND tile_row = ?", + arrayOf("$z", "$x", "$y"), null, null, null + ).use { cursor -> + if (cursor.count == 0) return null + + cursor.moveToFirst() + val index = cursor.getColumnIndex("tile_data") + cursor.getBlob(index) + } + } + + fun activate() = with(MBTilesServer) { + sources[id] = this@MBTilesSource + } + + fun deactivate() = with(MBTilesServer) { + sources.remove(id) + + if (isRunning && sources.isEmpty()) { + stop() + } + + // Close the database connection + try { + db.close() + } catch (e: Exception) { + Log.e(TAG, "Error closing database for source $id: ${e.localizedMessage}") + } + } + + companion object { + private val TAG = "MBTilesSource" + val validRasterFormats = listOf("jpg", "png") + val validVectorFormats = listOf("pbf", "mvt") + + @SuppressLint("DiscouragedApi") + fun readAsset(context: Context, asset: String): String { + // Generate a safe filename for the output + val outputFileName = asset.replace("/", "_").let { + if (!it.endsWith(".mbtiles")) "$it.mbtiles" else it + } + val outputFile = File(context.getDatabasePath(outputFileName).path) + + // Ensure parent directory exists + outputFile.parentFile?.mkdirs() + + // First, try to find it as a raw resource (for require() bundled assets in release mode) + val rawResourceId: Int = context.resources.getIdentifier( + asset.replace("-", "_").replace(".", "_"), + "raw", + context.packageName + ) + + if (rawResourceId != 0) { + // Found as a raw resource (bundled via require() in release mode) + Log.d(TAG, "Found asset as raw resource: $asset (id: $rawResourceId)") + context.resources.openRawResource(rawResourceId).use { inputStream -> + FileOutputStream(outputFile).use { outputStream -> + inputStream.copyTo(outputStream) + outputStream.flush() + } + } + return outputFile.path + } + + // Try drawable as fallback (some assets end up here) + val drawableResourceId: Int = context.resources.getIdentifier( + asset.replace("-", "_").replace(".", "_"), + "drawable", + context.packageName + ) + + if (drawableResourceId != 0) { + Log.d(TAG, "Found asset as drawable resource: $asset (id: $drawableResourceId)") + context.resources.openRawResource(drawableResourceId).use { inputStream -> + FileOutputStream(outputFile).use { outputStream -> + inputStream.copyTo(outputStream) + outputStream.flush() + } + } + return outputFile.path + } + + // Fall back to assets folder (for manually placed assets) + Log.d(TAG, "Trying to load asset from assets folder: $asset") + context.assets.open(asset).use { inputStream -> + FileOutputStream(outputFile).use { outputStream -> + inputStream.copyTo(outputStream) + outputStream.flush() + } + } + return outputFile.path + } + } +} diff --git a/android/src/main/res/xml/network_security_config.xml b/android/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000000..6489d85644 --- /dev/null +++ b/android/src/main/res/xml/network_security_config.xml @@ -0,0 +1,6 @@ + + + + localhost + + \ No newline at end of file diff --git a/docs/MBTilesGuide.md b/docs/MBTilesGuide.md new file mode 100644 index 0000000000..fe4289c940 --- /dev/null +++ b/docs/MBTilesGuide.md @@ -0,0 +1,196 @@ +# MBTiles Support Guide + +This guide explains how to use MBTiles files with the React Native Mapbox GL library. + +## Overview + +MBTiles is a specification for storing tiled map data in SQLite databases. This implementation adds support for using MBTiles files in your React Native Mapbox GL maps through a local HTTP server running on the device. + +## Setup + +### 1. Android Configuration + +Add the network security configuration to your app's AndroidManifest.xml: + +```xml + +``` + +### 2. iOS Support + +Currently, MBTiles support is only available on Android. iOS support may be added in the future. + +## Usage + +### Initialize an MBTiles Source + +```javascript +import { MBTiles } from '@rnmapbox/maps'; + +// Initialize from a file path +const initMBTilesFile = async () => { + try { + const source = await MBTiles.initFromFile( + '/path/to/your/file.mbtiles', + 'my-source-id', + ); + console.log('MBTiles source initialized:', source); + return source; + } catch (error) { + console.error('Failed to initialize MBTiles:', error); + } +}; + +// Initialize from an asset in your app bundle +const initMBTilesAsset = async () => { + try { + const source = await MBTiles.initFromAsset( + 'your-asset.mbtiles', + 'my-source-id', + ); + console.log('MBTiles asset initialized:', source); + return source; + } catch (error) { + console.error('Failed to initialize MBTiles asset:', error); + } +}; +``` + +### Use in a Map Style + +After initializing the MBTiles source, you can use it in your map style: + +```javascript +import React, { useState, useEffect } from 'react'; +import { View } from 'react-native'; +import MapboxGL, { MBTiles } from '@rnmapbox/maps'; + +const MBTilesMap = () => { + const [mapStyle, setMapStyle] = useState(null); + + useEffect(() => { + const initMapStyle = async () => { + // Initialize MBTiles source + const source = await MBTiles.initFromFile( + '/path/to/your/file.mbtiles', + 'my-mbtiles', + ); + + // Create a style that uses the MBTiles source + const style = { + version: 8, + name: 'MBTiles Map', + sources: { + [source.id]: { + type: source.isVector ? 'vector' : 'raster', + url: source.url, // This is important - use the URL from the source + }, + }, + layers: source.isVector + ? [ + // For vector tiles, you need to specify sourceLayer + { + id: 'my-vector-layer', + type: 'fill', + source: source.id, + 'source-layer': 'your-source-layer', // This depends on your MBTiles file + paint: { + 'fill-color': 'blue', + 'fill-opacity': 0.7, + }, + }, + ] + : [ + // For raster tiles, it's simpler + { + id: 'my-raster-layer', + type: 'raster', + source: source.id, + paint: { + 'raster-opacity': 1, + }, + }, + ], + }; + + setMapStyle(style); + }; + + initMapStyle(); + + // Clean up when component unmounts + return () => { + MBTiles.remove('my-mbtiles'); + }; + }, []); + + if (!mapStyle) { + return ; + } + + return ( + + + + + + ); +}; + +export default MBTilesMap; +``` + +### Managing MBTiles Sources + +```javascript +// Check if a source is active +const isActive = await MBTiles.isActive('my-source-id'); + +// Get URL for an active source +const url = await MBTiles.getURL('my-source-id'); + +// Remove a source when no longer needed +await MBTiles.remove('my-source-id'); + +// Get list of all active sources +const activeSources = await MBTiles.getActiveSources(); +``` + +## Troubleshooting + +1. **Network Error**: If you get network errors when trying to load tiles, make sure: + + - Your network security configuration is set up correctly + - The MBTiles file exists and is valid + - For vector tiles, check that your 'source-layer' property matches a layer in your MBTiles file + +2. **Performance Issues**: If you experience performance issues: + + - Consider using smaller MBTiles files + - Remember to remove MBTiles sources when they're no longer needed + +3. **File Access**: Make sure your app has the necessary permissions to access the file: + ```xml + + ``` + +## How It Works + +Behind the scenes, this implementation: + +1. Starts a local HTTP server on the device (on port 8888) +2. Reads tiles from the MBTiles file and serves them via the HTTP server +3. The map loads tiles from http://localhost:8888/[source-id]/{z}/{x}/{y}.[format] + +This approach allows us to use MBTiles without modifying the core Mapbox library, which doesn't directly support the mbtiles:// protocol. diff --git a/example/metro.config.js b/example/metro.config.js index 0181a7e270..3d6d3d6597 100644 --- a/example/metro.config.js +++ b/example/metro.config.js @@ -25,6 +25,6 @@ config.resolver.unstable_enablePackageExports = true; if (config.resolver.assetExts == null) { config.resolver.assetExts = []; } -config.resolver.assetExts.push('gltf', 'glb', 'png'); +config.resolver.assetExts.push('gltf', 'glb', 'png', 'mbtiles'); module.exports = config; diff --git a/example/src/assets/ubombo.mbtiles b/example/src/assets/ubombo.mbtiles new file mode 100644 index 0000000000..b55e6ebf01 Binary files /dev/null and b/example/src/assets/ubombo.mbtiles differ diff --git a/example/src/examples/Map/MBTilesExample.tsx b/example/src/examples/Map/MBTilesExample.tsx new file mode 100644 index 0000000000..f85b050e73 --- /dev/null +++ b/example/src/examples/Map/MBTilesExample.tsx @@ -0,0 +1,158 @@ +import { useState, useEffect } from 'react'; +import { View, StyleSheet, Text } from 'react-native'; +import Mapbox, { + MapView, + Camera, + VectorSource, + FillLayer, +} from '@rnmapbox/maps'; +import MBTiles, { MBTilesSource } from '../../../../src/modules/MBTiles'; +import type { ExampleWithMetadata } from '../common/ExampleMetadata'; + +// Use require() to load the MBTiles file - works in both debug and release mode +// In debug: Metro serves it over HTTP, which is downloaded automatically +// In release: The file is bundled with the app as an asset +const MBTILES_SOURCE = require('../../assets/ubombo.mbtiles'); + +/** + * Example demonstrating how to use MBTiles files with the map + */ +const MBTilesExample = () => { + const [source, setSource] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + // Clean up the MBTiles source when unmounting + return () => { + if (source) { + console.log('Removing MBTiles source:', source.id); + MBTiles.remove(source.id).catch(console.error); + } + }; + }, [source]); + + const handleStyleLoaded = async () => { + try { + console.log('Style loaded, initializing MBTiles'); + + // Initialize using the new unified init() method + // This handles require(), file paths, assets, and URLs automatically + const mbtilesSource = await MBTiles.init( + MBTILES_SOURCE, + 'mbtiles-source', + ); + + console.log('MBTiles source initialized:', mbtilesSource); + setSource(mbtilesSource); + } catch (err) { + console.error('Failed to initialize MBTiles:', err); + setError(err instanceof Error ? err.message : 'Unknown error'); + } + }; + + return ( + + + + {source && ( + + + + )} + + + + {error ? ( + Error: {error} + ) : source ? ( + <> + Source ID: {source.id} + Format: {source.format} + + Vector: {source.isVector ? 'Yes' : 'No'} + + + ) : ( + Loading MBTiles... + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + map: { + flex: 1, + width: '100%', + }, + errorText: { + fontSize: 14, + color: 'red', + textAlign: 'center', + }, + infoText: { + fontSize: 14, + textAlign: 'center', + paddingHorizontal: 20, + marginTop: 5, + }, + infoPanel: { + position: 'absolute', + bottom: 20, + backgroundColor: 'rgba(255, 255, 255, 0.8)', + borderRadius: 10, + padding: 10, + margin: 10, + width: '90%', + }, +}); + +export default MBTilesExample; + +const metadata: ExampleWithMetadata['metadata'] = { + title: 'MBTiles Example', + tags: ['MBTiles', 'Offline'], + docs: `This example demonstrates how to use local MBTiles files with the map. + +## Usage + +The simplest way to load an MBTiles file is using require(): + +\`\`\`typescript +import MBTiles from '@rnmapbox/maps/src/modules/MBTiles'; + +// Load from bundled asset (works in debug and release) +const source = await MBTiles.init(require('./assets/map.mbtiles')); + +// Or load from a file path +const source = await MBTiles.init({ filePath: 'file:///path/to/map.mbtiles' }); + +// Or load from app bundle asset +const source = await MBTiles.init({ asset: 'map.mbtiles' }); + +// Or download from a URL +const source = await MBTiles.init({ url: 'https://example.com/map.mbtiles' }); +\`\`\` + +The MBTiles file is served through a local HTTP server that runs on the device.`, +}; + +MBTilesExample.metadata = metadata; diff --git a/example/src/examples/Map/index.js b/example/src/examples/Map/index.js index 285ee58e00..8e20d7781f 100644 --- a/example/src/examples/Map/index.js +++ b/example/src/examples/Map/index.js @@ -1,5 +1,6 @@ export { default as CameraGestureObserver } from './CameraGestureObserver'; export { default as ChangeLayerColor } from './ChangeLayerColor'; +export { default as MBTilesExample } from './MBTilesExample'; export { default as Ornaments } from './Ornaments'; export { default as PointInMapView } from './PointInMapView'; export { default as ShowAndHideLayer } from './ShowAndHideLayer'; diff --git a/ios/RNMBX/MBTiles.swift b/ios/RNMBX/MBTiles.swift new file mode 100644 index 0000000000..41e50b004a --- /dev/null +++ b/ios/RNMBX/MBTiles.swift @@ -0,0 +1,489 @@ +import Foundation +import MapboxMaps +import Network +import SQLite3 + +// MARK: - Shared Protocol +public protocol MBTileProvider { + var isVector: Bool { get } + func getTile(z: Int, x: Int, y: Int) -> Data? +} + +// MARK: - Error Types +public enum MBTilesSourceError: Error { + case couldNotReadFile + case unsupportedFormat + case databaseError(message: String) +} + +// MARK: - MBTilesServer +public class MBTilesServer { + public static let shared = MBTilesServer() + + public let port: UInt16 = 8888 + private var listener: NWListener? + public var sources: [String: MBTileProvider] = [:] + private let serverQueue = DispatchQueue( + label: "com.rnmapbox.mbtiles.server", qos: .userInitiated, attributes: .concurrent) + + public var isRunning: Bool { + return listener != nil + } + + private init() { + // Initialize the server + } + + public func start() { + guard listener == nil else { + return + } + + do { + // Create a listener on the local loopback interface + let port = NWEndpoint.Port(integerLiteral: self.port) + listener = try NWListener(using: .tcp, on: port) + + listener?.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + RNMBXLogInfo("MBTiles server started on port \(self?.port ?? 0)") + case .failed(let error): + RNMBXLogError("MBTiles server failed: \(error.localizedDescription)") + default: + break + } + } + + listener?.newConnectionHandler = { [weak self] connection in + self?.handleConnection(connection) + } + + listener?.start(queue: serverQueue) + } catch { + RNMBXLogError("Error starting MBTiles server: \(error.localizedDescription)") + } + } + + public func stop() { + listener?.stateUpdateHandler = nil + listener?.newConnectionHandler = nil + listener?.cancel() + listener = nil + RNMBXLogInfo("MBTiles server stopped") + } + + private func handleConnection(_ connection: NWConnection) { + connection.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + self?.receiveRequest(on: connection) + case .failed(let error): + RNMBXLogError("Connection failed: \(error.localizedDescription)") + connection.stateUpdateHandler = nil + connection.cancel() + case .cancelled: + connection.stateUpdateHandler = nil + default: + break + } + } + + connection.start(queue: serverQueue) + } + + private func receiveRequest(on connection: NWConnection) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { + [weak self] data, _, isComplete, error in + guard let self = self, let data = data, !data.isEmpty, error == nil else { + connection.stateUpdateHandler = nil + connection.cancel() + return + } + + if let requestString = String(data: data, encoding: .utf8) { + // Parse the HTTP request to extract the path + // This is a simplified implementation - a real HTTP server would need more robust parsing + if let requestLine = requestString.components(separatedBy: "\r\n").first, + let getRequestPath = requestLine.components(separatedBy: " ").dropFirst().first + { + + let path = getRequestPath + + // Expecting path format: /{sourceId}/{z}/{x}/{y}.{format} + let pathComponents = path.split(separator: "/").map(String.init) + + // Path should be like /mbtiles-source/0/0/0.pbf + // First component is empty (leading slash), second is sourceId + if pathComponents.count >= 4 { + // Use the first non-empty component as the sourceId + let sourceId = + pathComponents[0].isEmpty ? pathComponents[1] : pathComponents[0] + + if let source = self.sources[sourceId] { + // Determine the indices for z, x, y based on path component count and empty first component + let zIndex = pathComponents[0].isEmpty ? 2 : 1 + let xIndex = pathComponents[0].isEmpty ? 3 : 2 + let yIndex = pathComponents[0].isEmpty ? 4 : 3 + + // Make sure we have enough components + guard pathComponents.count > yIndex else { + self.sendErrorResponse(connection, statusCode: 400) + return + } + + // Extract z, x, y from path + let zString = pathComponents[zIndex] + let xString = pathComponents[xIndex] + + // Extract y and format from the last component (e.g., "10.pbf") + let lastComponent = pathComponents[yIndex] + let parts = lastComponent.split(separator: ".").map(String.init) + + if parts.count == 2, + let z = Int(zString), + let x = Int(xString), + let y = Int(parts[0]) + { + + let format = parts[1] + + // Convert y coordinate (TMS to XYZ) + let flippedY = (1 << z) - 1 - y + + if let tileData = source.getTile(z: z, x: x, y: flippedY) { + // Send the tile data as response + let contentType = self.mimeTypeFor(format: format) + var headers = + "HTTP/1.1 200 OK\r\nContent-Type: \(contentType)\r\nContent-Length: \(tileData.count)\r\nConnection: close\r\n" + + if source.isVector { + headers += "Content-Encoding: gzip\r\n" + } + + headers += "\r\n" + + if let headerData = headers.data(using: .utf8) { + // Send headers then tile data, then clean up + connection.send( + content: headerData, + completion: .contentProcessed { _ in + connection.send( + content: tileData, + completion: .contentProcessed { _ in + connection.stateUpdateHandler = nil + connection.cancel() + }) + }) + } else { + self.sendErrorResponse(connection, statusCode: 500) + } + return + } else { + self.sendErrorResponse(connection, statusCode: 404) + return + } + } else { + self.sendErrorResponse(connection, statusCode: 400) + return + } + } else { + self.sendErrorResponse(connection, statusCode: 404) + return + } + } + + // If we reach here, request was invalid + self.sendErrorResponse(connection, statusCode: 404) + } else { + self.sendErrorResponse(connection, statusCode: 400) + } + } else { + self.sendErrorResponse(connection, statusCode: 400) + } + } + } + + private func sendErrorResponse(_ connection: NWConnection, statusCode: Int) { + var statusText = "Not Found" + switch statusCode { + case 400: statusText = "Bad Request" + case 404: statusText = "Not Found" + case 500: statusText = "Internal Server Error" + default: break + } + + let response = + "HTTP/1.1 \(statusCode) \(statusText)\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + if let data = response.data(using: .utf8) { + connection.send( + content: data, + completion: .contentProcessed { _ in + connection.stateUpdateHandler = nil + connection.cancel() + }) + } else { + connection.stateUpdateHandler = nil + connection.cancel() + } + } + + private func mimeTypeFor(format: String) -> String { + switch format.lowercased() { + case "jpg", "jpeg": + return "image/jpeg" + case "png": + return "image/png" + case "pbf", "mvt": + return "application/x-protobuf" + default: + return "application/octet-stream" + } + } +} + +// MARK: - MBTilesSource +public class MBTilesSource: MBTileProvider { + private let TAG = "MBTilesSource" + + public let id: String + public let url: String + private var db: OpaquePointer? + private var tileStatement: OpaquePointer? + private let dbQueue = DispatchQueue(label: "com.rnmapbox.mbtiles.db") + + public var isVector: Bool = false + public var format: String = "" + public var minZoom: Float? + public var maxZoom: Float? + + // Lazy initialization of the source instance based on format + public lazy var instance: Source = { + if isVector { + #if RNMBX_11 + var builder = VectorSource(id: id) + #else + var builder = VectorSource() + #endif + builder.tiles = [url] + return builder + } else { + #if RNMBX_11 + var builder = RasterSource(id: id) + #else + var builder = RasterSource() + #endif + builder.tiles = [url] + builder.tileSize = 256 + return builder + } + }() + + public init(filePath: String, sourceId: String? = nil) throws { + // Set the source ID (use filename if not provided) + self.id = + sourceId ?? URL(fileURLWithPath: filePath).deletingPathExtension().lastPathComponent + self.url = "http://localhost:\(MBTilesServer.shared.port)/\(id)/{z}/{x}/{y}.{format}" + + // Open the SQLite database + if sqlite3_open(filePath, &db) != SQLITE_OK { + RNMBXLogError("Failed to open MBTiles file: \(filePath)") + throw MBTilesSourceError.couldNotReadFile + } + + // Read the format from metadata + try readFormat() + + // Read additional metadata + readMetadata() + + // Prepare the tile query statement for reuse + let tileQuery = + "SELECT tile_data FROM tiles WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?" + if sqlite3_prepare_v2(db, tileQuery, -1, &tileStatement, nil) != SQLITE_OK { + RNMBXLogError("Failed to prepare tile query statement") + } + } + + private func readFormat() throws { + let query = "SELECT value FROM metadata WHERE name = 'format'" + var statement: OpaquePointer? + + guard sqlite3_prepare_v2(db, query, -1, &statement, nil) == SQLITE_OK else { + throw MBTilesSourceError.databaseError( + message: "Failed to prepare statement for format query") + } + + defer { sqlite3_finalize(statement) } + + guard sqlite3_step(statement) == SQLITE_ROW else { + throw MBTilesSourceError.unsupportedFormat + } + + if let formatCString = sqlite3_column_text(statement, 0) { + format = String(cString: formatCString) + + // Determine if vector or raster based on format + if MBTilesSource.validVectorFormats.contains(format) { + isVector = true + } else if MBTilesSource.validRasterFormats.contains(format) { + isVector = false + } else { + throw MBTilesSourceError.unsupportedFormat + } + } else { + throw MBTilesSourceError.unsupportedFormat + } + } + + private func readMetadata() { + // Read minzoom + if let value = getMetadataValue(name: "minzoom") { + minZoom = Float(value) + } + + // Read maxzoom + if let value = getMetadataValue(name: "maxzoom") { + maxZoom = Float(value) + } + } + + private func getMetadataValue(name: String) -> String? { + let query = "SELECT value FROM metadata WHERE name = ?" + var statement: OpaquePointer? + + guard sqlite3_prepare_v2(db, query, -1, &statement, nil) == SQLITE_OK else { + return nil + } + + defer { sqlite3_finalize(statement) } + + // Use cString to get proper C string and ensure it stays in memory + let cName = name.cString(using: .utf8) + sqlite3_bind_text(statement, 1, cName, -1, nil) + + if sqlite3_step(statement) == SQLITE_ROW, + let valueCString = sqlite3_column_text(statement, 0) + { + return String(cString: valueCString) + } + + return nil + } + + public func getTile(z: Int, x: Int, y: Int) -> Data? { + return dbQueue.sync { + guard let statement = tileStatement else { return nil } + + defer { sqlite3_reset(statement) } + + sqlite3_bind_int(statement, 1, Int32(z)) + sqlite3_bind_int(statement, 2, Int32(x)) + sqlite3_bind_int(statement, 3, Int32(y)) + + if sqlite3_step(statement) == SQLITE_ROW { + let dataSize = Int(sqlite3_column_bytes(statement, 0)) + let dataPointer = sqlite3_column_blob(statement, 0) + + if let dataPointer = dataPointer, dataSize > 0 { + return Data(bytes: dataPointer, count: dataSize) + } + } + + return nil + } + } + + public func activate() { + MBTilesServer.shared.sources[id] = self + } + + public func deactivate() { + MBTilesServer.shared.sources.removeValue(forKey: id) + if MBTilesServer.shared.sources.isEmpty { + MBTilesServer.shared.stop() + } + } + + deinit { + if let statement = tileStatement { + sqlite3_finalize(statement) + } + if let db = db { + sqlite3_close(db) + } + } + + // Read an asset from the bundle + public static func readAsset(name: String) throws -> String { + var bundlePath: String? = nil + + RNMBXLogInfo("MBTiles: Attempting to load asset: \(name)") + + // Try different strategies to find the asset + // 1. Try with the name as-is (might include extension) + bundlePath = Bundle.main.path(forResource: name, ofType: nil) + + // 2. Try with .mbtiles extension explicitly + if bundlePath == nil { + bundlePath = Bundle.main.path(forResource: name, ofType: "mbtiles") + } + + // 3. Try without extension and add .mbtiles + if bundlePath == nil { + let nameWithoutExt = (name as NSString).deletingPathExtension + bundlePath = Bundle.main.path(forResource: nameWithoutExt, ofType: "mbtiles") + } + + // 4. Try with underscores replaced (React Native asset naming convention) + if bundlePath == nil { + let underscoreName = name.replacingOccurrences(of: "-", with: "_") + bundlePath = Bundle.main.path(forResource: underscoreName, ofType: "mbtiles") + } + + // 5. For require() bundled assets, they might use assets_ prefix + // Try looking with the assets_ prefix stripped + if bundlePath == nil { + let assetName = name.replacingOccurrences(of: "assets_", with: "") + .replacingOccurrences(of: "_", with: "-") + bundlePath = Bundle.main.path(forResource: assetName, ofType: "mbtiles") + } + + // 6. Try the original name with underscores converted to find ubombo1 style names + if bundlePath == nil { + // assets_ubombo1 -> ubombo1 + let cleanName = name.replacingOccurrences(of: "assets_", with: "") + bundlePath = Bundle.main.path(forResource: cleanName, ofType: "mbtiles") + } + + guard let resolvedPath = bundlePath else { + RNMBXLogError("MBTiles: Could not find asset: \(name) in bundle") + throw MBTilesSourceError.couldNotReadFile + } + + RNMBXLogInfo("MBTiles: Found asset at: \(resolvedPath)") + + // Create directory if needed + let documentsDirectory = NSSearchPathForDirectoriesInDomains( + .documentDirectory, .userDomainMask, true)[0] + + // Generate a safe output filename + let outputFileName = name.replacingOccurrences(of: "/", with: "_") + .appending(name.hasSuffix(".mbtiles") ? "" : ".mbtiles") + let destinationPath = "\(documentsDirectory)/\(outputFileName)" + + // Remove existing file if it exists (to allow updates) + let fileManager = FileManager.default + if fileManager.fileExists(atPath: destinationPath) { + try? fileManager.removeItem(atPath: destinationPath) + } + + // Copy file from bundle to documents directory + try fileManager.copyItem(atPath: resolvedPath, toPath: destinationPath) + RNMBXLogInfo("MBTiles: Copied asset to: \(destinationPath)") + return destinationPath + } + + public static let validRasterFormats = ["jpg", "png"] + public static let validVectorFormats = ["pbf", "mvt"] +} diff --git a/ios/RNMBX/RNMBXMBTilesModule.m b/ios/RNMBX/RNMBXMBTilesModule.m new file mode 100644 index 0000000000..01b1582640 --- /dev/null +++ b/ios/RNMBX/RNMBXMBTilesModule.m @@ -0,0 +1,44 @@ +#import + +@interface RCT_EXTERN_MODULE(RNMBXMBTiles, NSObject) + +RCT_EXTERN_METHOD(initMBTilesSource:(NSString *)filePath + sourceId:(NSString *)sourceId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(initMBTilesSourceFromAsset:(NSString *)assetName + sourceId:(NSString *)sourceId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(initMBTilesSourceFromURL:(NSString *)urlString + sourceId:(NSString *)sourceId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(getMBTilesURL:(NSString *)sourceId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(removeMBTilesSource:(NSString *)sourceId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(isMBTilesSourceActive:(NSString *)sourceId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(getActiveMBTilesSources:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(startServer:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(stopServer:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(isServerRunning:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +@end diff --git a/ios/RNMBX/RNMBXMBTilesModule.swift b/ios/RNMBX/RNMBXMBTilesModule.swift new file mode 100644 index 0000000000..46e3480cf9 --- /dev/null +++ b/ios/RNMBX/RNMBXMBTilesModule.swift @@ -0,0 +1,267 @@ +import Foundation +import MapboxMaps + +@objc(RNMBXMBTiles) +class RNMBXMBTilesModule: NSObject { + + private var activeSources: [String: MBTilesSource] = [:] + + @objc + static func requiresMainQueueSetup() -> Bool { + return false + } + + /** + * Initialize and activate an MBTiles source from a file path + */ + @objc + func initMBTilesSource( + _ filePath: String, sourceId: String, resolver: RCTPromiseResolveBlock, + rejecter: RCTPromiseRejectBlock + ) { + // Handle file URL paths if provided + var resolvedPath = filePath + + // Remove file:// prefix + if resolvedPath.starts(with: "file://") { + resolvedPath = resolvedPath.replacingOccurrences(of: "file://", with: "") + } + + // Decode URL encoding (e.g., %20 -> space) + if let decodedPath = resolvedPath.removingPercentEncoding { + resolvedPath = decodedPath + } + + RNMBXLogInfo("MBTiles: Attempting to load from path: \(resolvedPath)") + + // Check if file exists + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: resolvedPath) else { + rejecter("FILE_NOT_FOUND", "MBTiles file not found at path: \(resolvedPath)", nil) + return + } + + do { + // Create and activate the MBTiles source + let mbSource = try MBTilesSource( + filePath: resolvedPath, sourceId: sourceId.isEmpty ? nil : sourceId) + mbSource.activate() + activeSources[mbSource.id] = mbSource + + // Return source information + let resultDict: [String: Any] = [ + "id": mbSource.id, + "url": mbSource.url, + "isVector": mbSource.isVector, + "format": mbSource.format, + "minZoom": mbSource.minZoom as Any, + "maxZoom": mbSource.maxZoom as Any, + ] + + resolver(resultDict) + } catch MBTilesSourceError.couldNotReadFile { + rejecter("ERROR_READING_FILE", "Could not read the MBTiles file", nil) + } catch MBTilesSourceError.unsupportedFormat { + rejecter("UNSUPPORTED_FORMAT", "MBTiles format is not supported", nil) + } catch { + rejecter( + "UNKNOWN_ERROR", "Error initializing MBTiles source: \(error.localizedDescription)", + nil) + } + } + + /** + * Initialize an MBTiles source from an asset in the app bundle + */ + @objc + func initMBTilesSourceFromAsset( + _ assetName: String, sourceId: String, resolver: RCTPromiseResolveBlock, + rejecter: RCTPromiseRejectBlock + ) { + do { + // Copy from asset to local file + let filePath = try MBTilesSource.readAsset(name: assetName) + + // Create and activate the MBTiles source + let mbSource = try MBTilesSource( + filePath: filePath, sourceId: sourceId.isEmpty ? nil : sourceId) + mbSource.activate() + activeSources[mbSource.id] = mbSource + + // Return source information + let resultDict: [String: Any] = [ + "id": mbSource.id, + "url": mbSource.url, + "isVector": mbSource.isVector, + "format": mbSource.format, + "minZoom": mbSource.minZoom as Any, + "maxZoom": mbSource.maxZoom as Any, + ] + + resolver(resultDict) + } catch MBTilesSourceError.couldNotReadFile { + rejecter("ERROR_READING_FILE", "Could not read the MBTiles asset", nil) + } catch MBTilesSourceError.unsupportedFormat { + rejecter("UNSUPPORTED_FORMAT", "MBTiles format is not supported", nil) + } catch { + rejecter( + "UNKNOWN_ERROR", + "Error initializing MBTiles source from asset: \(error.localizedDescription)", nil) + } + } + + /** + * Initialize an MBTiles source from a remote URL (downloads first) + */ + @objc + func initMBTilesSourceFromURL( + _ urlString: String, sourceId: String, resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + guard let url = URL(string: urlString) else { + rejecter("INVALID_URL", "Invalid URL: \(urlString)", nil) + return + } + + // Generate a filename from the URL or sourceId + let fileName = sourceId.isEmpty + ? url.lastPathComponent + : "\(sourceId).mbtiles" + + // Get the destination path in the documents directory + let documentsDirectory = NSSearchPathForDirectoriesInDomains( + .documentDirectory, .userDomainMask, true)[0] + let destinationPath = "\(documentsDirectory)/\(fileName)" + + // Download the file + let task = URLSession.shared.downloadTask(with: url) { [weak self] tempURL, response, error in + guard let self = self else { return } + + if let error = error { + rejecter("DOWNLOAD_ERROR", "Failed to download MBTiles file: \(error.localizedDescription)", nil) + return + } + + guard let tempURL = tempURL else { + rejecter("DOWNLOAD_ERROR", "No data received from URL", nil) + return + } + + do { + // Remove existing file if it exists + let fileManager = FileManager.default + if fileManager.fileExists(atPath: destinationPath) { + try fileManager.removeItem(atPath: destinationPath) + } + + // Move the downloaded file to the destination + try fileManager.moveItem(at: tempURL, to: URL(fileURLWithPath: destinationPath)) + + // Create and activate the MBTiles source + let mbSource = try MBTilesSource( + filePath: destinationPath, sourceId: sourceId.isEmpty ? nil : sourceId) + mbSource.activate() + self.activeSources[mbSource.id] = mbSource + + // Return source information + let resultDict: [String: Any] = [ + "id": mbSource.id, + "url": mbSource.url, + "isVector": mbSource.isVector, + "format": mbSource.format, + "minZoom": mbSource.minZoom as Any, + "maxZoom": mbSource.maxZoom as Any, + ] + + resolver(resultDict) + } catch MBTilesSourceError.couldNotReadFile { + rejecter("ERROR_READING_FILE", "Could not read the downloaded MBTiles file", nil) + } catch MBTilesSourceError.unsupportedFormat { + rejecter("UNSUPPORTED_FORMAT", "MBTiles format is not supported", nil) + } catch { + rejecter( + "UNKNOWN_ERROR", + "Error initializing MBTiles source from URL: \(error.localizedDescription)", nil) + } + } + + task.resume() + } + + /** + * Get the HTTP URL for an active MBTiles source to use in style json + */ + @objc + func getMBTilesURL( + _ sourceId: String, resolver: RCTPromiseResolveBlock, rejecter: RCTPromiseRejectBlock + ) { + if let mbSource = activeSources[sourceId] { + resolver(mbSource.url) + } else { + rejecter("SOURCE_NOT_FOUND", "MBTiles source with ID '\(sourceId)' is not active", nil) + } + } + + /** + * Stop and remove an MBTiles source + */ + @objc + func removeMBTilesSource( + _ sourceId: String, resolver: RCTPromiseResolveBlock, rejecter: RCTPromiseRejectBlock + ) { + if let mbSource = activeSources[sourceId] { + mbSource.deactivate() + activeSources.removeValue(forKey: sourceId) + resolver(true) + } else { + resolver(false) + } + } + + /** + * Check if an MBTiles source is currently active + */ + @objc + func isMBTilesSourceActive( + _ sourceId: String, resolver: RCTPromiseResolveBlock, rejecter: RCTPromiseRejectBlock + ) { + resolver(activeSources[sourceId] != nil) + } + + /** + * List all active MBTiles sources + */ + @objc + func getActiveMBTilesSources( + _ resolver: RCTPromiseResolveBlock, rejecter: RCTPromiseRejectBlock + ) { + let sourceIds = Array(activeSources.keys) + resolver(sourceIds) + } + + /** + * Manually start the MBTiles server + */ + @objc + func startServer(_ resolver: RCTPromiseResolveBlock, rejecter: RCTPromiseRejectBlock) { + MBTilesServer.shared.start() + resolver(MBTilesServer.shared.isRunning) + } + + /** + * Manually stop the MBTiles server + */ + @objc + func stopServer(_ resolver: RCTPromiseResolveBlock, rejecter: RCTPromiseRejectBlock) { + MBTilesServer.shared.stop() + resolver(true) + } + + /** + * Check if the MBTiles server is running + */ + @objc + func isServerRunning(_ resolver: RCTPromiseResolveBlock, rejecter: RCTPromiseRejectBlock) { + resolver(MBTilesServer.shared.isRunning) + } +} diff --git a/src/index.native.ts b/src/index.native.ts index 6c19a7c793..a0f9b4419d 100644 --- a/src/index.native.ts +++ b/src/index.native.ts @@ -1,4 +1,8 @@ export * from './Mapbox.native'; import * as Mapbox from './Mapbox.native'; +// Add export for MBTiles +export { default as MBTiles } from './modules/MBTiles'; +export type { MBTilesSource } from './modules/MBTiles'; + export default Mapbox; diff --git a/src/index.ts b/src/index.ts index 0893f37510..59beea27a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,8 @@ export * from './Mapbox'; import * as Mapbox from './Mapbox'; +// Add export for MBTiles +export { default as MBTiles } from './modules/MBTiles'; +export type { MBTilesSource } from './modules/MBTiles'; + export default Mapbox; diff --git a/src/modules/MBTiles.ts b/src/modules/MBTiles.ts new file mode 100644 index 0000000000..ed6227b27b --- /dev/null +++ b/src/modules/MBTiles.ts @@ -0,0 +1,204 @@ +import { Image as RNImage, NativeModules } from 'react-native'; + +const { RNMBXMBTiles } = NativeModules; + +export interface MBTilesSource { + id: string; + url: string; + isVector: boolean; + format: string; + minZoom?: number; + maxZoom?: number; +} + +/** + * Represents a require() call for a bundled asset, e.g. require('./map.mbtiles') + */ +export type RequireSource = number; + +/** + * All supported MBTiles source types: + * - `number` (require): A bundled asset loaded via require('./map.mbtiles') + * - `{ filePath: string }`: An absolute file path on the device + * - `{ asset: string }`: An asset name in the app bundle + * - `{ url: string }`: A remote URL to download the MBTiles file from + */ +export type MBTilesAsyncSource = + | RequireSource + | { filePath: string } + | { asset: string } + | { url: string }; + +/** + * MBTiles module for loading and using MBTiles files with Mapbox + */ +class MBTiles { + /** + * Initialize and activate an MBTiles source from various source types. + * + * Supports: + * - `require('./map.mbtiles')` - bundled assets + * - `{ filePath: 'file:///path/to/file.mbtiles' }` - absolute file paths + * - `{ asset: 'map.mbtiles' }` - assets in the app bundle + * - `{ url: 'https://example.com/map.mbtiles' }` - remote URLs (downloads first) + * + * @param source The MBTiles source (require, filePath, asset, or url) + * @param sourceId Optional ID for the source (defaults to filename without extension) + * @returns MBTilesSource object with source information + * + * @example + * // Using require (recommended for bundled assets) + * const source = await MBTiles.init(require('./assets/map.mbtiles')); + * + * // Using file path + * const source = await MBTiles.init({ filePath: 'file:///path/to/map.mbtiles' }); + * + * // Using app bundle asset + * const source = await MBTiles.init({ asset: 'map.mbtiles' }); + * + * // Using remote URL + * const source = await MBTiles.init({ url: 'https://example.com/map.mbtiles' }); + */ + async init( + source: MBTilesAsyncSource, + sourceId?: string, + ): Promise { + if (typeof source === 'number') { + // It's a require(...) - a `number` which we need to resolve first + const resolvedSource = RNImage.resolveAssetSource(source); + + if (!resolvedSource || !resolvedSource.uri) { + throw new Error( + `Could not resolve asset source for require(). Make sure the file exists and is properly bundled.`, + ); + } + + if (resolvedSource.uri.startsWith('http')) { + // In debug mode, assets are streamed over the network via Metro bundler + return this.init({ url: resolvedSource.uri }, sourceId); + } else if ( + resolvedSource.uri.startsWith('file://') || + resolvedSource.uri.startsWith('file:///') || + resolvedSource.uri.startsWith('/') + ) { + // In release mode, assets are embedded files + return this.init({ filePath: resolvedSource.uri }, sourceId); + } else { + // It's a resource name (e.g., on Android: "asset:/map.mbtiles") + return this.init({ asset: resolvedSource.uri }, sourceId); + } + } else if ('filePath' in source) { + return this.initFromFile(source.filePath, sourceId); + } else if ('asset' in source) { + return this.initFromAsset(source.asset, sourceId); + } else if ('url' in source) { + return this.initFromURL(source.url, sourceId); + } else { + throw new Error(`Unknown MBTiles source type: ${JSON.stringify(source)}`); + } + } + + /** + * Initialize and activate an MBTiles source from a file path + * @param filePath Path to the MBTiles file + * @param sourceId Optional ID for the source (defaults to filename without extension) + * @returns MBTilesSource object with source information + */ + async initFromFile( + filePath: string, + sourceId?: string, + ): Promise { + return await RNMBXMBTiles.initMBTilesSource(filePath, sourceId || ''); + } + + /** + * Initialize and activate an MBTiles source from an asset in the app bundle + * @param assetName Name of the asset in the app bundle + * @param sourceId Optional ID for the source (defaults to asset name without extension) + * @returns MBTilesSource object with source information + */ + async initFromAsset( + assetName: string, + sourceId?: string, + ): Promise { + return await RNMBXMBTiles.initMBTilesSourceFromAsset( + assetName, + sourceId || '', + ); + } + + /** + * Initialize and activate an MBTiles source from a remote URL. + * Downloads the file first, then initializes it. + * @param url URL to download the MBTiles file from + * @param sourceId Optional ID for the source (defaults to filename without extension) + * @returns MBTilesSource object with source information + */ + async initFromURL(url: string, sourceId?: string): Promise { + return await RNMBXMBTiles.initMBTilesSourceFromURL(url, sourceId || ''); + } + + /** + * Get the HTTP URL for an active MBTiles source + * @param sourceId ID of the MBTiles source + * @returns URL to use in style JSON + */ + async getURL(sourceId: string): Promise { + return await RNMBXMBTiles.getMBTilesURL(sourceId); + } + + /** + * Remove an MBTiles source and release its resources + * @param sourceId ID of the MBTiles source to remove + * @returns True if the source was removed, false if it didn't exist + */ + async remove(sourceId: string): Promise { + return await RNMBXMBTiles.removeMBTilesSource(sourceId); + } + + /** + * Check if an MBTiles source is active + * @param sourceId ID of the MBTiles source to check + * @returns True if the source is active, false otherwise + */ + async isActive(sourceId: string): Promise { + return await RNMBXMBTiles.isMBTilesSourceActive(sourceId); + } + + /** + * Get all active MBTiles sources + * @returns Array of source IDs + */ + async getActiveSources(): Promise { + return await RNMBXMBTiles.getActiveMBTilesSources(); + } + + /** + * Manually start the MBTiles server + * Call this at app startup to pre-start the server before loading any maps. + * The server will also auto-start when initializing an MBTiles source. + * @returns True if the server is running after the call + */ + async startServer(): Promise { + return await RNMBXMBTiles.startServer(); + } + + /** + * Manually stop the MBTiles server + * Note: The server will also auto-stop when all MBTiles sources are removed. + * @returns True if the server was stopped + */ + async stopServer(): Promise { + return await RNMBXMBTiles.stopServer(); + } + + /** + * Check if the MBTiles server is currently running + * @returns True if the server is running + */ + async isServerRunning(): Promise { + return await RNMBXMBTiles.isServerRunning(); + } +} + +export default new MBTiles();