diff --git a/platform/android/MapboxGLAndroidSDK/src/main/java/com/mapbox/mapboxsdk/offline/OfflineManager.java b/platform/android/MapboxGLAndroidSDK/src/main/java/com/mapbox/mapboxsdk/offline/OfflineManager.java index 08b58fa7963..cb9260b3cd2 100644 --- a/platform/android/MapboxGLAndroidSDK/src/main/java/com/mapbox/mapboxsdk/offline/OfflineManager.java +++ b/platform/android/MapboxGLAndroidSDK/src/main/java/com/mapbox/mapboxsdk/offline/OfflineManager.java @@ -2,10 +2,12 @@ import android.annotation.SuppressLint; import android.content.Context; +import android.os.AsyncTask; import android.os.Handler; import android.os.Looper; import android.support.annotation.Keep; import android.support.annotation.NonNull; + import com.mapbox.mapboxsdk.LibraryLoader; import com.mapbox.mapboxsdk.MapStrictMode; import com.mapbox.mapboxsdk.R; @@ -13,8 +15,14 @@ import com.mapbox.mapboxsdk.log.Logger; import com.mapbox.mapboxsdk.net.ConnectivityReceiver; import com.mapbox.mapboxsdk.storage.FileSource; +import com.mapbox.mapboxsdk.utils.FileUtils; import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.nio.channels.FileChannel; /** * The offline manager is the main entry point for offline-related functionality. @@ -92,6 +100,27 @@ public interface CreateOfflineRegionCallback { void onError(String error); } + /** + * This callback receives an asynchronous response containing a list of all + * OfflineRegion added to the database during the merge. + */ + @Keep + public interface MergeOfflineRegionsCallback { + /** + * Receives the list of merged offline regions. + * + * @param offlineRegions the offline region array + */ + void onMerge(OfflineRegion[] offlineRegions); + + /** + * Receives the error message. + * + * @param error the error message + */ + void onError(String error); + } + /* * Constructor */ @@ -183,6 +212,155 @@ public void run() { }); } + /** + * Merge offline regions from a secondary database into the main offline database. + *

+ * When the merge is completed, or fails, the {@link MergeOfflineRegionsCallback} will be invoked on the main thread. + *

+ * The secondary database may need to be upgraded to the latest schema. + * This is done in-place and requires write-access to the provided path. + * If the app's process doesn't have write-access to the provided path, + * the file will be copied to the temporary, internal directory for the duration of the merge. + *

+ * Only resources and tiles that belong to a region will be copied over. Identical + * regions will be flattened into a single new region in the main database. + *

+ * The operation will be aborted and {@link MergeOfflineRegionsCallback#onError(String)} with an appropriate message + * will be invoked if the merge would result in the offline tile count limit being exceeded. + *

+ * Merged regions may not be in a completed status if the secondary database + * does not contain all the tiles or resources required by the region definition. + * + * @param path secondary database writable path + * @param callback completion/error callback + */ + public void mergeOfflineRegions(@NonNull String path, @NonNull final MergeOfflineRegionsCallback callback) { + File src = new File(path); + new FileUtils.CheckFileReadPermissionTask(new FileUtils.OnCheckFileReadPermissionListener() { + @Override + public void onReadPermissionGranted() { + new FileUtils.CheckFileWritePermissionTask(new FileUtils.OnCheckFileWritePermissionListener() { + @Override + public void onWritePermissionGranted() { + // path writable, merge and update schema in place if necessary + mergeOfflineDatabaseFiles(src, callback, false); + } + + @Override + public void onError() { + // path not writable, copy the the file to temp directory, then merge and update schema on a copy if + // necessary + File dst = new File(FileSource.getInternalCachePath(context), src.getName()); + new CopyTempDatabaseFileTask(OfflineManager.this, callback).execute(src, dst); + } + }).execute(src); + } + + @Override + public void onError() { + // path not readable, abort + callback.onError("Secondary database needs to be located in a readable path."); + } + }).execute(src); + } + + private static final class CopyTempDatabaseFileTask extends AsyncTask { + private final WeakReference offlineManagerWeakReference; + private final WeakReference callbackWeakReference; + + CopyTempDatabaseFileTask(OfflineManager offlineManager, MergeOfflineRegionsCallback callback) { + this.offlineManagerWeakReference = new WeakReference<>(offlineManager); + this.callbackWeakReference = new WeakReference<>(callback); + } + + @Override + protected Object doInBackground(Object... objects) { + File src = (File) objects[0]; + File dst = (File) objects[1]; + + try { + copyTempDatabaseFile(src, dst); + return dst; + } catch (IOException ex) { + return ex.getMessage(); + } + } + + @Override + protected void onPostExecute(Object object) { + MergeOfflineRegionsCallback callback = callbackWeakReference.get(); + if (callback != null) { + OfflineManager offlineManager = offlineManagerWeakReference.get(); + if (object instanceof File && offlineManager != null) { + // successfully copied the file, perform merge + File dst = (File) object; + offlineManager.mergeOfflineDatabaseFiles(dst, callback, true); + } else if (object instanceof String) { + // error occurred + callback.onError((String) object); + } + } + } + } + + private static void copyTempDatabaseFile(File sourceFile, File destFile) throws IOException { + if (!destFile.exists() && !destFile.createNewFile()) { + throw new IOException("Unable to copy database file for merge."); + } + + FileChannel source = null; + FileChannel destination = null; + + try { + source = new FileInputStream(sourceFile).getChannel(); + destination = new FileOutputStream(destFile).getChannel(); + destination.transferFrom(source, 0, source.size()); + } catch (IOException ex) { + throw new IOException(String.format("Unable to copy database file for merge. %s", ex.getMessage())); + } finally { + if (source != null) { + source.close(); + } + if (destination != null) { + destination.close(); + } + } + } + + private void mergeOfflineDatabaseFiles(@NonNull File file, @NonNull final MergeOfflineRegionsCallback callback, + boolean isTemporaryFile) { + fileSource.activate(); + mergeOfflineRegions(fileSource, file.getAbsolutePath(), new MergeOfflineRegionsCallback() { + @Override + public void onMerge(OfflineRegion[] offlineRegions) { + getHandler().post(new Runnable() { + @Override + public void run() { + fileSource.deactivate(); + if (isTemporaryFile) { + file.delete(); + } + callback.onMerge(offlineRegions); + } + }); + } + + @Override + public void onError(String error) { + getHandler().post(new Runnable() { + @Override + public void run() { + fileSource.deactivate(); + if (isTemporaryFile) { + file.delete(); + } + callback.onError(error); + } + }); + } + }); + } + /** * Create an offline region in the database. *

@@ -272,4 +450,6 @@ private boolean isValidOfflineRegionDefinition(OfflineRegionDefinition definitio private native void createOfflineRegion(FileSource fileSource, OfflineRegionDefinition definition, byte[] metadata, CreateOfflineRegionCallback callback); + @Keep + private native void mergeOfflineRegions(FileSource fileSource, String path, MergeOfflineRegionsCallback callback); } diff --git a/platform/android/MapboxGLAndroidSDK/src/main/java/com/mapbox/mapboxsdk/utils/FileUtils.java b/platform/android/MapboxGLAndroidSDK/src/main/java/com/mapbox/mapboxsdk/utils/FileUtils.java new file mode 100644 index 00000000000..b7d09cda2a3 --- /dev/null +++ b/platform/android/MapboxGLAndroidSDK/src/main/java/com/mapbox/mapboxsdk/utils/FileUtils.java @@ -0,0 +1,121 @@ +package com.mapbox.mapboxsdk.utils; + +import android.os.AsyncTask; + +import java.io.File; +import java.lang.ref.WeakReference; + +public class FileUtils { + + /** + * Task checking whether app's process can read a file. + */ + public static class CheckFileReadPermissionTask extends AsyncTask { + private final WeakReference listenerWeakReference; + + public CheckFileReadPermissionTask(OnCheckFileReadPermissionListener listener) { + this.listenerWeakReference = new WeakReference<>(listener); + } + + @Override + protected Boolean doInBackground(File... files) { + try { + return files[0].canRead(); + } catch (Exception ex) { + return false; + } + } + + @Override + protected void onCancelled() { + OnCheckFileReadPermissionListener listener = listenerWeakReference.get(); + if (listener != null) { + listener.onError(); + } + } + + @Override + protected void onPostExecute(Boolean result) { + OnCheckFileReadPermissionListener listener = listenerWeakReference.get(); + if (listener != null) { + if (result) { + listener.onReadPermissionGranted(); + } else { + listener.onError(); + } + } + } + } + + /** + * Interface definition for a callback invoked when checking file's read permissions. + */ + public interface OnCheckFileReadPermissionListener { + + /** + * Invoked when app's process has a permission to read a file. + */ + void onReadPermissionGranted(); + + /** + * Invoked when app's process doesn't have a permission to read a file or an error occurs. + */ + void onError(); + } + + /** + * Task checking whether app's process can write to a file. + */ + public static class CheckFileWritePermissionTask extends AsyncTask { + private final WeakReference listenerWeakReference; + + public CheckFileWritePermissionTask(OnCheckFileWritePermissionListener listener) { + this.listenerWeakReference = new WeakReference<>(listener); + } + + @Override + protected Boolean doInBackground(File... files) { + try { + return files[0].canWrite(); + } catch (Exception ex) { + return false; + } + } + + @Override + protected void onCancelled() { + OnCheckFileWritePermissionListener listener = listenerWeakReference.get(); + if (listener != null) { + listener.onError(); + } + } + + @Override + protected void onPostExecute(Boolean result) { + OnCheckFileWritePermissionListener listener = listenerWeakReference.get(); + if (listener != null) { + if (result) { + listener.onWritePermissionGranted(); + } else { + listener.onError(); + } + } + } + } + + /** + * Interface definition for a callback invoked when checking file's write permissions. + */ + public interface OnCheckFileWritePermissionListener { + + /** + * Invoked when app's process has a permission to write to a file. + */ + void onWritePermissionGranted(); + + /** + * Invoked when app's process doesn't have a permission to write to a file or an error occurs. + */ + void onError(); + } +} diff --git a/platform/android/MapboxGLAndroidSDKTestApp/src/androidTest/java/com/mapbox/mapboxsdk/testapp/offline/OfflineManagerTest.kt b/platform/android/MapboxGLAndroidSDKTestApp/src/androidTest/java/com/mapbox/mapboxsdk/testapp/offline/OfflineManagerTest.kt new file mode 100644 index 00000000000..f702f8dafa5 --- /dev/null +++ b/platform/android/MapboxGLAndroidSDKTestApp/src/androidTest/java/com/mapbox/mapboxsdk/testapp/offline/OfflineManagerTest.kt @@ -0,0 +1,108 @@ +package com.mapbox.mapboxsdk.testapp.offline + +import android.content.Context +import android.support.test.espresso.Espresso +import android.support.test.espresso.IdlingRegistry +import android.support.test.espresso.UiController +import android.support.test.espresso.idling.CountingIdlingResource +import android.support.test.runner.AndroidJUnit4 +import com.mapbox.mapboxsdk.maps.MapboxMap +import com.mapbox.mapboxsdk.offline.OfflineManager +import com.mapbox.mapboxsdk.offline.OfflineRegion +import com.mapbox.mapboxsdk.storage.FileSource +import com.mapbox.mapboxsdk.testapp.action.MapboxMapAction.invoke +import com.mapbox.mapboxsdk.testapp.activity.BaseActivityTest +import com.mapbox.mapboxsdk.testapp.activity.espresso.EspressoTestActivity +import com.mapbox.mapboxsdk.testapp.utils.FileUtils +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class OfflineManagerTest : BaseActivityTest() { + + companion object { + private const val TEST_DB_FILE_NAME = "offline.db" + } + + private val context: Context by lazy { rule.activity } + + private lateinit var offlineIdlingResource: CountingIdlingResource + + override fun getActivityClass(): Class<*> { + return EspressoTestActivity::class.java + } + + override fun beforeTest() { + super.beforeTest() + offlineIdlingResource = CountingIdlingResource("idling_resource") + IdlingRegistry.getInstance().register(offlineIdlingResource) + } + + @Test + fun offlineMergeListDeleteTest() { + validateTestSetup() + + invoke(mapboxMap) { _: UiController, _: MapboxMap -> + offlineIdlingResource.increment() + FileUtils.CopyFileFromAssetsTask(rule.activity, object : FileUtils.OnFileCopiedFromAssetsListener { + override fun onFileCopiedFromAssets() { + OfflineManager.getInstance(context).mergeOfflineRegions( + FileSource.getResourcesCachePath(rule.activity) + "/" + TEST_DB_FILE_NAME, + object : OfflineManager.MergeOfflineRegionsCallback { + override fun onMerge(offlineRegions: Array?) { + assert(offlineRegions?.size == 1) + offlineIdlingResource.decrement() + } + + override fun onError(error: String?) { + throw RuntimeException("Unable to merge external offline database. $error") + } + }) + } + + override fun onError() { + throw IOException("Unable to copy DB file.") + } + }).execute(TEST_DB_FILE_NAME, FileSource.getResourcesCachePath(rule.activity)) + } + + invoke(mapboxMap) { _: UiController, _: MapboxMap -> + offlineIdlingResource.increment() + OfflineManager.getInstance(context).listOfflineRegions(object : OfflineManager.ListOfflineRegionsCallback { + override fun onList(offlineRegions: Array?) { + assert(offlineRegions?.size == 1) + if (offlineRegions != null) { + for (region in offlineRegions) { + offlineIdlingResource.increment() + region.delete(object : OfflineRegion.OfflineRegionDeleteCallback { + override fun onDelete() { + offlineIdlingResource.decrement() + } + + override fun onError(error: String?) { + throw RuntimeException("Unable to delete region with ID: ${region.id}. $error") + } + }) + } + } else { + throw RuntimeException("Unable to find merged region.") + } + offlineIdlingResource.decrement() + } + + override fun onError(error: String?) { + throw RuntimeException("Unable to obtain offline regions list. $error") + } + }) + } + + // waiting for offline idling resource + Espresso.onIdle() + } + + override fun afterTest() { + super.afterTest() + IdlingRegistry.getInstance().unregister(offlineIdlingResource) + } +} \ No newline at end of file diff --git a/platform/android/MapboxGLAndroidSDKTestApp/src/main/AndroidManifest.xml b/platform/android/MapboxGLAndroidSDKTestApp/src/main/AndroidManifest.xml index a0594d8b83d..5fcbcb96300 100644 --- a/platform/android/MapboxGLAndroidSDKTestApp/src/main/AndroidManifest.xml +++ b/platform/android/MapboxGLAndroidSDKTestApp/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ package="com.mapbox.mapboxsdk.testapp"> + + + + + ) { + mapView.setStyleUrl(TEST_STYLE) + Toast.makeText( + this@MergeOfflineRegionsActivity, + String.format("Merged %d regions.", offlineRegions.size), + Toast.LENGTH_LONG).show() + } + + override fun onError(error: String) { + Toast.makeText( + this@MergeOfflineRegionsActivity, + String.format("Offline DB merge error."), + Toast.LENGTH_LONG).show() + Logger.e(LOG_TAG, error) + } + }) + } + + override fun onStart() { + super.onStart() + mapView.onStart() + } + + override fun onResume() { + super.onResume() + mapView.onResume() + } + + override fun onPause() { + super.onPause() + mapView.onPause() + } + + override fun onStop() { + super.onStop() + mapView.onStop() + } + + override fun onLowMemory() { + super.onLowMemory() + mapView.onLowMemory() + } + + override fun onDestroy() { + super.onDestroy() + mapView.onDestroy() + + // restoring connectivity state + Mapbox.setConnected(null) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + mapView.onSaveInstanceState(outState) + } +} diff --git a/platform/android/MapboxGLAndroidSDKTestApp/src/main/java/com/mapbox/mapboxsdk/testapp/activity/offline/UpdateMetadataActivity.java b/platform/android/MapboxGLAndroidSDKTestApp/src/main/java/com/mapbox/mapboxsdk/testapp/activity/offline/UpdateMetadataActivity.java index e1a524790d1..58482032bee 100644 --- a/platform/android/MapboxGLAndroidSDKTestApp/src/main/java/com/mapbox/mapboxsdk/testapp/activity/offline/UpdateMetadataActivity.java +++ b/platform/android/MapboxGLAndroidSDKTestApp/src/main/java/com/mapbox/mapboxsdk/testapp/activity/offline/UpdateMetadataActivity.java @@ -14,6 +14,7 @@ import android.widget.ListView; import android.widget.TextView; import android.widget.Toast; + import com.mapbox.mapboxsdk.maps.MapView; import com.mapbox.mapboxsdk.offline.OfflineManager; import com.mapbox.mapboxsdk.offline.OfflineRegion; @@ -57,7 +58,9 @@ public void onItemClick(AdapterView parent, View view, int position, long id) final EditText input = new EditText(this); input.setText(metadata); input.setInputType(InputType.TYPE_CLASS_TEXT); - input.setSelection(metadata.length()); + if (metadata != null) { + input.setSelection(metadata.length()); + } builder.setView(input); builder.setPositiveButton("OK", (dialog, which) -> diff --git a/platform/android/MapboxGLAndroidSDKTestApp/src/main/java/com/mapbox/mapboxsdk/testapp/utils/FileUtils.kt b/platform/android/MapboxGLAndroidSDKTestApp/src/main/java/com/mapbox/mapboxsdk/testapp/utils/FileUtils.kt new file mode 100644 index 00000000000..26f2a2c56be --- /dev/null +++ b/platform/android/MapboxGLAndroidSDKTestApp/src/main/java/com/mapbox/mapboxsdk/testapp/utils/FileUtils.kt @@ -0,0 +1,65 @@ +package com.mapbox.mapboxsdk.testapp.utils + +import android.content.Context +import android.os.AsyncTask +import java.io.File +import java.io.FileOutputStream +import java.lang.ref.WeakReference + +class FileUtils { + + /** + * Task that copies a file from the assets directory to a provided directory. + * The asset's name is going to be kept in the new directory. + */ + class CopyFileFromAssetsTask(context: Context, listener: OnFileCopiedFromAssetsListener) : AsyncTask() { + private val contextWeakReference: WeakReference = WeakReference(context) + private val listenerWeakReference: WeakReference = WeakReference(listener) + + override fun doInBackground(vararg strings: String): Boolean? { + val assetName = strings[0] + val destinationPath = strings[1] + + contextWeakReference.get()?.let { + try { + copyAsset(it, assetName, destinationPath) + } catch (ex: Exception) { + return false + } + } + + return true + } + + override fun onCancelled() { + listenerWeakReference.get()?.onError() + } + + override fun onPostExecute(result: Boolean) { + if (result) { + listenerWeakReference.get()?.onFileCopiedFromAssets() + } else { + listenerWeakReference.get()?.onError() + } + } + + private fun copyAsset(context: Context, assetName: String, destinationPath: String) { + val bufferSize = 1024 + val assetManager = context.assets + val inputStream = assetManager.open(assetName) + val outputStream = FileOutputStream(File(destinationPath, assetName)) + try { + inputStream.copyTo(outputStream, bufferSize) + } finally { + inputStream.close() + outputStream.flush() + outputStream.close() + } + } + } + + interface OnFileCopiedFromAssetsListener { + fun onFileCopiedFromAssets() + fun onError() + } +} \ No newline at end of file diff --git a/platform/android/MapboxGLAndroidSDKTestApp/src/main/res/layout/activity_map_simple.xml b/platform/android/MapboxGLAndroidSDKTestApp/src/main/res/layout/activity_map_simple.xml index 96a3f5b046a..e67740ad543 100644 --- a/platform/android/MapboxGLAndroidSDKTestApp/src/main/res/layout/activity_map_simple.xml +++ b/platform/android/MapboxGLAndroidSDKTestApp/src/main/res/layout/activity_map_simple.xml @@ -1,6 +1,5 @@ - + android:layout_height="match_parent" /> - + diff --git a/platform/android/MapboxGLAndroidSDKTestApp/src/main/res/layout/activity_merge_offline_regions.xml b/platform/android/MapboxGLAndroidSDKTestApp/src/main/res/layout/activity_merge_offline_regions.xml new file mode 100644 index 00000000000..5c610418a9f --- /dev/null +++ b/platform/android/MapboxGLAndroidSDKTestApp/src/main/res/layout/activity_merge_offline_regions.xml @@ -0,0 +1,20 @@ + + + + + +