From 4148a5a91aefef20f28e520d1c0d4b6485cf0234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Mon, 10 Sep 2018 20:59:49 +0200 Subject: [android] expose offline database merge API --- .../mapbox/mapboxsdk/offline/OfflineManager.java | 167 +++++++++++++++++++++ .../testapp/offline/OfflineManagerTest.kt | 119 +++++++++++++++ .../src/main/AndroidManifest.xml | 12 ++ .../src/main/assets/offline.db | Bin 0 -> 73728 bytes .../offline/MergeOfflineRegionsActivity.kt | 129 ++++++++++++++++ .../src/main/res/layout/activity_map_simple.xml | 7 +- .../res/layout/activity_merge_offline_regions.xml | 20 +++ .../src/main/res/values/descriptions.xml | 1 + .../src/main/res/values/titles.xml | 1 + platform/android/scripts/exclude-activity-gen.json | 3 +- platform/android/src/offline/offline_manager.cpp | 72 ++++++++- platform/android/src/offline/offline_manager.hpp | 22 ++- 12 files changed, 538 insertions(+), 15 deletions(-) create mode 100644 platform/android/MapboxGLAndroidSDKTestApp/src/androidTest/java/com/mapbox/mapboxsdk/testapp/offline/OfflineManagerTest.kt create mode 100644 platform/android/MapboxGLAndroidSDKTestApp/src/main/assets/offline.db create mode 100644 platform/android/MapboxGLAndroidSDKTestApp/src/main/java/com/mapbox/mapboxsdk/testapp/activity/offline/MergeOfflineRegionsActivity.kt create mode 100644 platform/android/MapboxGLAndroidSDKTestApp/src/main/res/layout/activity_merge_offline_regions.xml 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 08b58fa796..fbbdf087b0 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 @@ package com.mapbox.mapboxsdk.offline; 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; @@ -15,6 +17,11 @@ import com.mapbox.mapboxsdk.net.ConnectivityReceiver; import com.mapbox.mapboxsdk.storage.FileSource; 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 +99,27 @@ public class OfflineManager { 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 +211,143 @@ public class OfflineManager { }); } + /** + * 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); + if (!src.canRead()) { + // path not readable, abort + callback.onError("Secondary database needs to be located in a readable path."); + return; + } + + if (src.canWrite()) { + // path writable, merge and update schema in place if necessary + mergeOfflineDatabaseFiles(src, callback, false); + } else { + // 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(this, callback).execute(src, dst); + } + } + + 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 +437,6 @@ public class OfflineManager { 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/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 0000000000..dd22d28f84 --- /dev/null +++ b/platform/android/MapboxGLAndroidSDKTestApp/src/androidTest/java/com/mapbox/mapboxsdk/testapp/offline/OfflineManagerTest.kt @@ -0,0 +1,119 @@ +package com.mapbox.mapboxsdk.testapp.offline + +import android.R +import android.content.Context +import android.support.test.espresso.Espresso.onView +import android.support.test.espresso.IdlingRegistry +import android.support.test.espresso.UiController +import android.support.test.espresso.assertion.ViewAssertions.matches +import android.support.test.espresso.idling.CountingIdlingResource +import android.support.test.espresso.matcher.ViewMatchers.isDisplayed +import android.support.test.espresso.matcher.ViewMatchers.withId +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.testapp.action.MapboxMapAction.invoke +import com.mapbox.mapboxsdk.testapp.activity.BaseActivityTest +import com.mapbox.mapboxsdk.testapp.activity.espresso.EspressoTestActivity +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.io.FileOutputStream + +@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() + copyAsset(context) + OfflineManager.getInstance(context).mergeOfflineRegions( + context.filesDir.absolutePath + "/" + 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") + } + }) + } + + 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 + onView(withId(R.id.content)).check(matches(isDisplayed())) + } + + override fun afterTest() { + super.afterTest() + IdlingRegistry.getInstance().unregister(offlineIdlingResource) + } + + private fun copyAsset(context: Context) { + val bufferSize = 1024 + val assetManager = context.assets + val inputStream = assetManager.open(TEST_DB_FILE_NAME) + val outputStream = FileOutputStream(File(context.filesDir.absoluteFile, TEST_DB_FILE_NAME)) + + try { + inputStream.copyTo(outputStream, bufferSize) + } finally { + inputStream.close() + outputStream.flush() + outputStream.close() + } + } +} \ 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 a0594d8b83..5fcbcb9630 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"> + + + + + , grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == PERMISSIONS_REQUEST_CODE) { + for (result in grantResults) { + if (result != PackageManager.PERMISSION_GRANTED) { + finish() + } + } + mergeDb() + } + } + + private fun mergeDb() { + // copy db asset to internal memory + copyAsset() + + OfflineManager.getInstance(this).mergeOfflineRegions( + this.filesDir.absolutePath + "/" + TEST_DB_FILE_NAME, + object : OfflineManager.MergeOfflineRegionsCallback { + override fun onMerge(offlineRegions: Array) { + mapView.setStyleUrl(Style.SATELLITE) + Toast.makeText( + this@MergeOfflineRegionsActivity, + String.format("Merged %d regions.", offlineRegions.size), + Toast.LENGTH_LONG).show() + } + + override fun onError(error: String) { + Logger.e("MBGL_OFFLINE_DB_MERGE", error) + } + }) + } + + private fun copyAsset() { + val bufferSize = 1024 + val assetManager = this.assets + val inputStream = assetManager.open(TEST_DB_FILE_NAME) + val outputStream = FileOutputStream(File(this.filesDir, TEST_DB_FILE_NAME)) + + try { + inputStream.copyTo(outputStream, bufferSize) + } finally { + inputStream.close() + outputStream.flush() + outputStream.close() + } + } + + 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() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + mapView.onSaveInstanceState(outState) + } +} 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 96a3f5b046..e67740ad54 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 0000000000..5c610418a9 --- /dev/null +++ b/platform/android/MapboxGLAndroidSDKTestApp/src/main/res/layout/activity_merge_offline_regions.xml @@ -0,0 +1,20 @@ + + + + + +