From 01ea0ceb5227a586e126fa5cace8868fc8f4e0a3 Mon Sep 17 00:00:00 2001 From: eotin Date: Wed, 27 Feb 2019 13:55:31 +0100 Subject: [PATCH 01/39] Fix issue in the WMTS layout --- sample/src/main/res/layout/wmts_map_view.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample/src/main/res/layout/wmts_map_view.xml b/sample/src/main/res/layout/wmts_map_view.xml index 42bd65738..c5442f225 100644 --- a/sample/src/main/res/layout/wmts_map_view.xml +++ b/sample/src/main/res/layout/wmts_map_view.xml @@ -20,7 +20,7 @@ + tools:context=".activities.WmtsActivity"> Date: Mon, 4 Mar 2019 14:36:06 +0100 Subject: [PATCH 02/39] Fix spelling Wmts activity --- .../activities/BaseNavigationDrawerActivity.java | 10 +++++++++- .../io/ona/kujaku/sample/activities/WmtsActivity.java | 2 +- .../res/menu/activity_navigation_drawer_drawer.xml | 4 ++-- sample/src/main/res/values/strings.xml | 3 ++- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/sample/src/main/java/io/ona/kujaku/sample/activities/BaseNavigationDrawerActivity.java b/sample/src/main/java/io/ona/kujaku/sample/activities/BaseNavigationDrawerActivity.java index 38171d51b..34c03da48 100644 --- a/sample/src/main/java/io/ona/kujaku/sample/activities/BaseNavigationDrawerActivity.java +++ b/sample/src/main/java/io/ona/kujaku/sample/activities/BaseNavigationDrawerActivity.java @@ -120,14 +120,17 @@ public boolean onNavigationItemSelected(MenuItem item) { drawerLayout.closeDrawer(GravityCompat.START); finish(); return true; + case R.id.nav_offline_regions: startActivity(new Intent(this, OfflineRegionsActivity.class)); finish(); return true; + case R.id.nav_task_queue: startActivity(new Intent(this, TaskQueueActivity.class)); finish(); return true; + case R.id.nav_main_activity: startActivity(new Intent(this, MainActivity.class)); finish(); @@ -137,6 +140,7 @@ public boolean onNavigationItemSelected(MenuItem item) { startActivity(new Intent(this, HighLevelLocationAddPointMapView.class)); finish(); return true; + case R.id.nav_card_activity: startActivity(new Intent(this, CardActivity.class)); finish(); @@ -147,7 +151,7 @@ public boolean onNavigationItemSelected(MenuItem item) { finish(); return true; - case R.id.nav_wmts: + case R.id.nav_wmts_activity: startActivity(new Intent(this, WmtsActivity.class)); finish(); return true; @@ -166,10 +170,12 @@ public boolean onNavigationItemSelected(MenuItem item) { startActivity(new Intent(this, BoundsChangeListenerActivity.class)); finish(); return true; + case R.id.nav_bounds_aware_activity: startActivity(new Intent(this, BoundsAwareActivity.class)); finish(); return true; + case R.id.nav_feature_click_listener: startActivity(new Intent(this, FeatureClickListenerActivity.class)); finish(); @@ -179,10 +185,12 @@ public boolean onNavigationItemSelected(MenuItem item) { startActivity(new Intent(this, PaddedBboxCalculatorActivity.class)); finish(); return true; + case R.id.nav_configurable_circle: startActivity(new Intent(this, ConfigurableLocationCircleActivity.class)); finish(); return true; + case R.id.nav_case_relationship_activity: startActivity(new Intent(this, CaseRelationshipActivity.class)); finish(); diff --git a/sample/src/main/java/io/ona/kujaku/sample/activities/WmtsActivity.java b/sample/src/main/java/io/ona/kujaku/sample/activities/WmtsActivity.java index b5db6c21c..34de3352d 100644 --- a/sample/src/main/java/io/ona/kujaku/sample/activities/WmtsActivity.java +++ b/sample/src/main/java/io/ona/kujaku/sample/activities/WmtsActivity.java @@ -60,7 +60,7 @@ protected int getContentView() { @Override protected int getSelectedNavigationItem() { - return R.id.nav_wmts; + return R.id.nav_wmts_activity; } @Override diff --git a/sample/src/main/res/menu/activity_navigation_drawer_drawer.xml b/sample/src/main/res/menu/activity_navigation_drawer_drawer.xml index ce054b3a3..caa69f89f 100644 --- a/sample/src/main/res/menu/activity_navigation_drawer_drawer.xml +++ b/sample/src/main/res/menu/activity_navigation_drawer_drawer.xml @@ -40,8 +40,8 @@ android:title="@string/low_level_add_point_custom_marker"/> + android:id="@+id/nav_wmts_activity" + android:title="@string/wmts_activity"/> High-level Add Point High-level Add Point - Location services Low-level Custom Marker - Wmts + Wmts Add or Update GO TO MY LOCATION DONE @@ -64,6 +64,7 @@ https://tpwd.texas.gov/arcgis/rest/services/Vegetation_Mapping/Texas_Ecological_Mapping_Systems_Data/mapserver/WMTS/1.0.0/WMTSCapabilities.xml + Case Relationship Activity DRAW ARROWS SHOWING RELATIONSHIP DISABLE ARROWS SHOWING RELATIONSHIP From 67492171a3d0d29946640bdd3396650c0de6cad0 Mon Sep 17 00:00:00 2001 From: eotin Date: Fri, 15 Mar 2019 18:10:15 +0100 Subject: [PATCH 03/39] Add TrackingService functionality Add PassiveRecordObjectActivity Refactor Storage helpers First commit -> Need refactoring now + tests + testing mService bounded to avoid null reference exception --- build.gradle | 6 +- gradle.properties | 2 + gradle/wrapper/gradle-wrapper.properties | 4 +- library/src/main/AndroidManifest.xml | 5 + .../io/ona/kujaku/activities/MapActivity.java | 4 +- .../kujaku/helpers/MapBoxStyleStorage.java | 254 ----- .../kujaku/helpers/MapBoxWebServiceApi.java | 1 + .../kujaku/helpers/storage/BaseStorage.java | 265 +++++ .../helpers/storage/MapBoxStyleStorage.java | 135 +++ .../helpers/storage/TrackingStorage.java | 107 ++ .../listeners/TrackingServiceListener.java | 46 + .../ona/kujaku/services/TrackingService.java | 921 ++++++++++++++++++ .../TrackingServiceHighAccuracyOptions.java | 38 + .../options/TrackingServiceOptions.java | 116 +++ .../TrackingServiceSaveBatteryOptions.java | 38 + .../io/ona/kujaku/views/KujakuMapView.java | 100 +- .../res/drawable-hdpi/ic_recording_gray.png | Bin 0 -> 2341 bytes .../res/drawable-hdpi/ic_recording_red.png | Bin 0 -> 2465 bytes .../res/layout/mapbox_mapview_internal.xml | 15 +- library/src/main/res/values/strings.xml | 3 + .../helpers/MapBoxStyleStorageTest.java | 1 + sample/build.gradle | 3 +- sample/src/main/AndroidManifest.xml | 4 + .../BaseNavigationDrawerActivity.java | 6 + .../sample/activities/MainActivity.java | 2 +- .../PassiveRecordObjectActivity.java | 211 ++++ ...ctivity_passive_record_object_map_view.xml | 76 ++ .../activity_navigation_drawer_drawer.xml | 4 + sample/src/main/res/values/strings.xml | 7 +- 29 files changed, 2092 insertions(+), 282 deletions(-) delete mode 100644 library/src/main/java/io/ona/kujaku/helpers/MapBoxStyleStorage.java create mode 100644 library/src/main/java/io/ona/kujaku/helpers/storage/BaseStorage.java create mode 100644 library/src/main/java/io/ona/kujaku/helpers/storage/MapBoxStyleStorage.java create mode 100644 library/src/main/java/io/ona/kujaku/helpers/storage/TrackingStorage.java create mode 100644 library/src/main/java/io/ona/kujaku/listeners/TrackingServiceListener.java create mode 100644 library/src/main/java/io/ona/kujaku/services/TrackingService.java create mode 100644 library/src/main/java/io/ona/kujaku/services/options/TrackingServiceHighAccuracyOptions.java create mode 100644 library/src/main/java/io/ona/kujaku/services/options/TrackingServiceOptions.java create mode 100644 library/src/main/java/io/ona/kujaku/services/options/TrackingServiceSaveBatteryOptions.java create mode 100644 library/src/main/res/drawable-hdpi/ic_recording_gray.png create mode 100644 library/src/main/res/drawable-hdpi/ic_recording_red.png create mode 100644 sample/src/main/java/io/ona/kujaku/sample/activities/PassiveRecordObjectActivity.java create mode 100644 sample/src/main/res/layout/activity_passive_record_object_map_view.xml diff --git a/build.gradle b/build.gradle index 55014a829..5dc4b4914 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,3 @@ -import groovy.transform.Field - // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { @@ -10,7 +8,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.2.0' + classpath 'com.android.tools.build:gradle:3.3.1' classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.8.0' classpath 'com.github.dcendents:android-maven-gradle-plugin:2.0' classpath 'io.fabric.tools:gradle:1.25.4' @@ -37,7 +35,7 @@ task clean(type: Delete) { ext { supportVersion = '27.1.1' - buildToolsVersion = "28.0.2" + buildToolsVersion = "28.0.3" compileSdkVersion = 27 volleyVersion = "1.1.0" targetSdkVersion = 27 diff --git a/gradle.properties b/gradle.properties index 1e44d1fd4..ce38ef8fd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -30,3 +30,5 @@ POM_SETTING_LICENCE_DIST=repo POM_SETTING_DEVELOPER_ID=opensrp POM_SETTING_DEVELOPER_NAME=OpenSRP Onadev +# android.debug.obsoleteApi=true + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0aa87ab19..61cd8a994 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Wed Sep 26 19:47:37 EAT 2018 +#Thu Feb 28 14:26:07 CET 2019 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.1-all.zip diff --git a/library/src/main/AndroidManifest.xml b/library/src/main/AndroidManifest.xml index fa373bd45..ccf481f5f 100644 --- a/library/src/main/AndroidManifest.xml +++ b/library/src/main/AndroidManifest.xml @@ -8,6 +8,8 @@ + + + + diff --git a/library/src/main/java/io/ona/kujaku/activities/MapActivity.java b/library/src/main/java/io/ona/kujaku/activities/MapActivity.java index f60727280..8f5dbbfaf 100644 --- a/library/src/main/java/io/ona/kujaku/activities/MapActivity.java +++ b/library/src/main/java/io/ona/kujaku/activities/MapActivity.java @@ -43,7 +43,7 @@ import io.ona.kujaku.adapters.InfoWindowObject; import io.ona.kujaku.adapters.holders.InfoWindowViewHolder; import io.ona.kujaku.domain.Point; -import io.ona.kujaku.helpers.MapBoxStyleStorage; +import io.ona.kujaku.helpers.storage.MapBoxStyleStorage; import io.ona.kujaku.sorting.Sorter; import io.ona.kujaku.utils.Constants; import io.ona.kujaku.utils.Permissions; @@ -454,7 +454,7 @@ protected void onPause() { protected void onDestroy() { super.onDestroy(); - if (currentStylePath != null && currentStylePath.startsWith("file://") && currentStylePath.contains(MapBoxStyleStorage.DIRECTORY)) { + if (currentStylePath != null && currentStylePath.startsWith("file://") && currentStylePath.contains(MapBoxStyleStorage.BASE_DIRECTORY)) { new MapBoxStyleStorage() .deleteFile(currentStylePath.replace("file://", ""), true); } diff --git a/library/src/main/java/io/ona/kujaku/helpers/MapBoxStyleStorage.java b/library/src/main/java/io/ona/kujaku/helpers/MapBoxStyleStorage.java deleted file mode 100644 index 30d4a36e2..000000000 --- a/library/src/main/java/io/ona/kujaku/helpers/MapBoxStyleStorage.java +++ /dev/null @@ -1,254 +0,0 @@ -package io.ona.kujaku.helpers; - -import android.os.Environment; -import android.support.annotation.NonNull; -import android.text.TextUtils; -import android.util.Log; - -import java.io.BufferedReader; -import java.io.File; -import java.io.FileReader; -import java.io.FileWriter; -import java.io.IOException; -import java.util.UUID; - -import io.ona.kujaku.activities.MapActivity; - -import io.ona.kujaku.utils.Constants; - -/** - * Helps add the MapBox Style to the MapBox MapView when provided as string since the MapBox API only allows - * urls i.e. assets, network & storage. Mapbox style should follow the Mapbox Style Spec

- *

- * Basically stores the style on shared External Storage

- *

- * HOW TO USE
- * ----------
- *

- * {@code MapBoxStyleStorage mapboxStyleStorage = new MapBoxStyleStorage()} - * {@code mapboxStyleStorage.getStyleURL("file:///storage/Downloads/style.json")} - * {@code mapboxStyleStorage.getStyleURL("asset://town_style.json")} - * {@code mapboxStyleStorage.getStyleURL("https://companysite.com/style/style.json")} - * {@code mapboxStyleStorage.getStyleURL("{ 'version': 8, 'name': 'kujaku-map', 'metadata': {}, }")}

- * {@code mapboxStyleStorage.deleteFile("json.style", false)} - * {@code mapboxStyleStorage.deleteFile("/sdcard/Downloads/json.style", true)} - * {@code mapboxStyleStorage.deleteFile("/sdcard/Downloads/json.style")} - *

- * Created by Ephraim Kigamba - ekigamba@ona.io on 09/11/2017. - */ - -public class MapBoxStyleStorage { - public static final String DIRECTORY = ".KujakuStyles"; - private static final String TAG = MapBoxStyleStorage.class.getSimpleName(); - - /** - * Converts the Mapbox style supplied to a usable resource by the Mapbox API by ensuring that the - * Mapbox style passed is either a file, network url or android asset file. It then provides an - * appropriate resource link where one is not provided - * - * @param stylePathOrJSON Mapbox Style Path or Mapbox Style JSON String - * @return Path to the MapBox Style in the format {@code file://[Path_to_file] } - */ - public String getStyleURL(@NonNull String stylePathOrJSON) { - if (stylePathOrJSON.startsWith("file:") - || stylePathOrJSON.startsWith("asset:") - || stylePathOrJSON.startsWith("http:") - || stylePathOrJSON.startsWith("https:") - || stylePathOrJSON.matches(Constants.MAP_BOX_URL_FORMAT)) { - return stylePathOrJSON; - } - - String fileName = ""; - boolean fileCreated = false; - - while (!fileCreated) { - fileName = UUID.randomUUID().toString() + ".json"; - fileName = writeToFile(DIRECTORY, fileName, stylePathOrJSON); - fileCreated = !TextUtils.isEmpty(fileName); - } - - return "file://" + fileName; - } - - /** - * Writes content to a file on local storage - * - * @param folderName Folder name(s) eg. AppFiles where to create file - * @param fileName Filename with extension - * @param content String content to be written to the file - * @return Absolute Path to the Stored file if SUCCESSFUL eg /emulated/storage/... or NULL is the operation FAILS - * This operation fails if the file exists, permissions denied, invalid - */ - public String writeToFile(String folderName, String fileName, String content) { - File file = new File(Environment.getExternalStorageDirectory(), folderName + File.separator + fileName); - - new File(Environment.getExternalStorageDirectory(), folderName) - .mkdirs(); - - if (!file.exists()) { - try { - FileWriter fileWriter = new FileWriter(file.getAbsoluteFile()); - fileWriter.write(content); - fileWriter.flush(); - fileWriter.close(); - - return file.getAbsolutePath(); - } catch (IOException e) { - Log.e(TAG, Log.getStackTraceString(e)); - } - } - - return null; - } - - /** - * Deletes a file on external/shared storage given the path or file name - * This should be called on {@link MapActivity#onDestroy()} so as to clean up the resources created - * - * @param filePath Path to the file eg. /emulated/storage/style.json, style.json - * @param isCompletePath Flag indicating whether the Path is complete - * eg. /emulated/storage/style.json is a complete path - * style.json is not a complete path & will be resolved to /sdcard/{@link MapBoxStyleStorage#DIRECTORY}/styles.json - * @return {@code TRUE} if the operation was SUCCESSFUL, {@code FALSE} if it failed - */ - public boolean deleteFile(String filePath, boolean isCompletePath) { - if (!isCompletePath) { - filePath = Environment.getExternalStorageDirectory() + DIRECTORY + File.separator + filePath; - } - return deleteFile(filePath); - } - - /** - * Deletes a file given the complete path - * This should be called on {@link MapActivity#onDestroy()} so as to clean up the resources created - * - * @param filePath Path to the file eg. /emulated/storage/style.json - * @return {@code TRUE} if the operation was SUCCESSFUL, {@code FALSE} if it failed - */ - public boolean deleteFile(String filePath) { - File file = new File(filePath); - - if (!file.exists()) { - return false; - } - return file.delete(); - } - - /** - * Caches Mapbox styles in Folder: {@value DIRECTORY} as user/stylename eg. mapbox://style/edwin/9iowdcjsd - * will be saved in {@value DIRECTORY}/edwin/9iowdcjsd - * - * @param mapBoxUrl - * @param mapBoxStyleJSON - * @return

- {@code TRUE} if the style is cached successfully
- * - {@code FALSE} if the style is not cached successfully

- */ - public boolean cacheStyle(@NonNull String mapBoxUrl, @NonNull String mapBoxStyleJSON) { - if (mapBoxUrl.matches(Constants.MAP_BOX_URL_FORMAT)) { - String[] mapBoxPaths = mapBoxUrl.replace("mapbox://styles/", "").split("/"); - String folder = mapBoxPaths[0]; - String filename = mapBoxPaths[1]; - - String fileAbsolutePath = writeToFile(DIRECTORY + File.separator + folder, filename, mapBoxStyleJSON); - return !TextUtils.isEmpty(fileAbsolutePath); - } - - return false; - } - - /** - * Retrieves the cached style if one exists - * - * @param mapBoxUrl - * @return

- The cached style JSON String, if it exists on local storage
- * - Empty String("") if the style is not cached

- */ - public String getCachedStyle(String mapBoxUrl) { - if (mapBoxUrl.matches(Constants.MAP_BOX_URL_FORMAT)) { - String[] mapBoxPaths = mapBoxUrl.replace("mapbox://styles/", "").split("/"); - String folder = mapBoxPaths[0]; - String filename = mapBoxPaths[1]; - - return readFile(folder, filename); - } - return null; - } - - /** - * Reads a style on local storage given the path using the format {@literal file://{file_path}} - * - * @param protocolledFilePath - * @return

- NULL if the file does not exist
- * - The style's JSON String if the file exists

- */ - public String readStyle(@NonNull String protocolledFilePath) { - String fileProtocolOrSth = "file://"; - if (protocolledFilePath.isEmpty() || !protocolledFilePath.startsWith(fileProtocolOrSth)) { - return null; - } - String folders = ""; - - if (protocolledFilePath.lastIndexOf(File.separator) > 6) { - folders = protocolledFilePath.substring( - fileProtocolOrSth.length(), - protocolledFilePath.lastIndexOf(File.separator)); - } - - String fileName = protocolledFilePath.substring( - protocolledFilePath.lastIndexOf(File.separator) + 1 - ); - - return readFile(folders, fileName, true); - } - - private String readFile(String folders, String filename) { - return readFile(folders, filename, false); - } - - /** - * Reads the contents of a file, returns them as a string - * - * @param folders The directory hierarchy for the file - * @param filename The name of the file to read - * @param isPathComplete - * @return NULL if unable to read the file or a String containing the contents of the file - */ - private String readFile(String folders, String filename, boolean isPathComplete) { - File fileFolders; - - if (isPathComplete) { - fileFolders = new File(folders); - } else { - fileFolders = new File(Environment.getExternalStorageDirectory() + folders); - } - - if (!fileFolders.exists()) { - fileFolders.mkdirs(); - } - - File finalFile; - if (isPathComplete) { - finalFile = new File(folders + File.separator + filename); - } else { - finalFile = new File(Environment.getExternalStorageDirectory(), folders + File.separator + filename); - } - StringBuilder text = new StringBuilder(); - - try { - BufferedReader br = new BufferedReader(new FileReader(finalFile)); - String line; - - while ((line = br.readLine()) != null) { - text.append(line); - text.append('\n'); - } - br.close(); - } catch (IOException e) { - Log.e(TAG, Log.getStackTraceString(e)); - return null; - } - - return text.toString(); - - } -} diff --git a/library/src/main/java/io/ona/kujaku/helpers/MapBoxWebServiceApi.java b/library/src/main/java/io/ona/kujaku/helpers/MapBoxWebServiceApi.java index 1bc366b37..72da3c77b 100644 --- a/library/src/main/java/io/ona/kujaku/helpers/MapBoxWebServiceApi.java +++ b/library/src/main/java/io/ona/kujaku/helpers/MapBoxWebServiceApi.java @@ -14,6 +14,7 @@ import java.io.File; +import io.ona.kujaku.helpers.storage.MapBoxStyleStorage; import io.ona.kujaku.utils.Constants; /** diff --git a/library/src/main/java/io/ona/kujaku/helpers/storage/BaseStorage.java b/library/src/main/java/io/ona/kujaku/helpers/storage/BaseStorage.java new file mode 100644 index 000000000..8106d9fa9 --- /dev/null +++ b/library/src/main/java/io/ona/kujaku/helpers/storage/BaseStorage.java @@ -0,0 +1,265 @@ +package io.ona.kujaku.helpers.storage; + +import android.os.Environment; +import android.util.Log; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; + +import io.ona.kujaku.activities.MapActivity; + +/** + * Created by Emmanuel OTIN - eo@novel-t.ch on 12/03/2019. + * + */ +public abstract class BaseStorage { + protected static final String TAG = BaseStorage.class.getSimpleName(); + + /** + * Writes content to a file on local storage + * + * @param folderName Folder name(s) eg. AppFiles where to create file + * @param fileName Filename with extension + * @param content String content to be written to the file + * @return Absolute Path to the Stored file if SUCCESSFUL eg /emulated/storage/... or NULL is the operation FAILS + * This operation fails if the file exists, permissions denied, invalid + */ + public String writeToFile(String folderName, String fileName, String content) { + File file = new File(Environment.getExternalStorageDirectory(), folderName + File.separator + fileName); + + new File(Environment.getExternalStorageDirectory(), folderName) + .mkdirs(); + + if (!file.exists()) { + try { + FileWriter fileWriter = new FileWriter(file.getAbsoluteFile()); + fileWriter.write(content); + fileWriter.flush(); + fileWriter.close(); + + return file.getAbsolutePath(); + } catch (IOException e) { + Log.e(TAG, Log.getStackTraceString(e)); + } + } + + return null; + } + + + /** + * Serialize in a file an object with the com.google.gson library + * + * @param folderName + * @param fileName + * @param object + * @return {@code TRUE} serialization worked, {@code FALSE} otherwise + */ + public boolean gsonWriteObject(String folderName, String fileName, Object object) { + File file = new File(Environment.getExternalStorageDirectory(), folderName + File.separator + fileName); + Gson gson = new GsonBuilder().create(); + + try { + FileWriter fw = new FileWriter(file); + gson.toJson(object, fw); + fw.close(); + + return true; + } catch (IOException ex) { + Log.e(TAG, "An error occurs when writing object with Gson", ex); + } + + return false; + } + + /** + * Deserialize from a file an object with the com.google.gson library + * + * @param folderName + * @param fileName + * @param objClass + * @param + * @return + */ + public T gsonReadObject(String folderName, String fileName, Class objClass) { + File file = new File(Environment.getExternalStorageDirectory(), folderName + File.separator + fileName); + Gson gson = new GsonBuilder().create(); + + try { + FileReader fr = new FileReader(file); + T obj = gson.fromJson(fr, objClass); + fr.close(); + return obj; + } catch (IOException ex) { + Log.e(TAG, "An error occurs when reading object with Gson", ex); + } + + return null ; + } + + /** + * Test if file exists + * + * @param folderName Folder name(s) eg. AppFiles where to create file + * @param fileName Filename with extension + * @return {@code TRUE} if file exists, {@code FALSE} otherwise + */ + public boolean fileExists(String folderName, String fileName) { + File file = new File(Environment.getExternalStorageDirectory(), folderName + File.separator + fileName); + return file.exists(); + } + + /** + * Test if directory exists + * + * @param folderName Folder name(s) eg. AppFiles where to create file + * @return {@code TRUE} if directory exists, {@code FALSE} otherwise + */ + public boolean directoryExists(String folderName) { + File dir = new File(Environment.getExternalStorageDirectory(), folderName); + return dir.exists() && dir.isDirectory(); + } + + /** + * Create file + * + * @param folderName Folder name(s) eg. AppFiles where to create file + * @param fileName Filename with extension + * @return result of File.creation method + */ + public boolean createFile(String folderName, String fileName) { + File file = new File(Environment.getExternalStorageDirectory(), folderName + File.separator + fileName); + + if (!fileExists(folderName, fileName)) { + try { + new File(Environment.getExternalStorageDirectory(), folderName) + .mkdirs(); + return file.createNewFile(); + } catch (Exception ex) { + Log.e(TAG, "The file already exists", ex); + } + } + + return false ; + } + + + /** + * Rename a file from the old file name to the new filename + * + * @param oldFolderName + * @param oldFileName + * @param newFolderName + * @param newFileName + * @return result of File.renameTo method + */ + public boolean renameFile(String oldFolderName, String oldFileName, String newFolderName, String newFileName) { + File from = new File(Environment.getExternalStorageDirectory(),oldFolderName + File.separator + oldFileName); + File to = new File(Environment.getExternalStorageDirectory(),newFolderName + File.separator + newFileName); + return from.renameTo(to); + } + + /** + * Deletes a file on external/shared storage given the path or file name + * This should be called on {@link MapActivity#onDestroy()} so as to clean up the resources created + * + * @param filePath Path to the file eg. /emulated/storage/style.json, style.json + * @param isCompletePath Flag indicating whether the Path is complete + * eg. /emulated/storage/style.json is a complete path + * style.json is not a complete path & will be resolved to /sdcard/{@link MapBoxStyleStorage#BASE_DIRECTORY}/styles.json + * @return {@code TRUE} if the operation was SUCCESSFUL, {@code FALSE} if it failed + */ + public boolean deleteFile(String filePath, boolean isCompletePath) { + if (!isCompletePath) { + filePath = Environment.getExternalStorageDirectory() + getDirectory() + File.separator + filePath; + } + return deleteFile(filePath); + } + + /** + * Deletes a file given the complete path + * This should be called on {@link MapActivity#onDestroy()} so as to clean up the resources created + * + * @param filePath Path to the file eg. /emulated/storage/style.json + * @return {@code TRUE} if the operation was SUCCESSFUL, {@code FALSE} if it failed + */ + public boolean deleteFile(String filePath) { + File file = new File(filePath); + + if (!file.exists()) { + return false; + } + return file.delete(); + } + + /** + * Reads the contents of a file, returns them as a string + * + * @param folders The directory hierarchy for the file + * @param filename The name of the file to read + * @param isPathComplete + * @return NULL if unable to read the file or a String containing the contents of the file + */ + public String readFile(String folders, String filename, boolean isPathComplete) { + File fileFolders; + + if (isPathComplete) { + fileFolders = new File(folders); + } else { + fileFolders = new File(Environment.getExternalStorageDirectory() + folders); + } + + if (!fileFolders.exists()) { + fileFolders.mkdirs(); + } + + File finalFile; + if (isPathComplete) { + finalFile = new File(folders + File.separator + filename); + } else { + finalFile = new File(Environment.getExternalStorageDirectory(), folders + File.separator + filename); + } + StringBuilder text = new StringBuilder(); + + try { + BufferedReader br = new BufferedReader(new FileReader(finalFile)); + String line; + + while ((line = br.readLine()) != null) { + text.append(line); + text.append('\n'); + } + br.close(); + } catch (IOException e) { + Log.e(TAG, Log.getStackTraceString(e)); + return null; + } + + return text.toString(); + + } + + /** + * Reads the contents of a file, returns them as a string + * + * @param folders The directory hierarchy for the file + * @param filename The name of the file to read + * @return NULL if unable to read the file or a String containing the contents of the file + */ + public String readFile(String folders, String filename) { + return readFile(folders, filename, false); + } + + /** + * Return the directory name that needs to be defined in children classes + * + * @return + */ + protected abstract String getDirectory(); +} diff --git a/library/src/main/java/io/ona/kujaku/helpers/storage/MapBoxStyleStorage.java b/library/src/main/java/io/ona/kujaku/helpers/storage/MapBoxStyleStorage.java new file mode 100644 index 000000000..62342e7ac --- /dev/null +++ b/library/src/main/java/io/ona/kujaku/helpers/storage/MapBoxStyleStorage.java @@ -0,0 +1,135 @@ +package io.ona.kujaku.helpers.storage; + +import android.support.annotation.NonNull; +import android.text.TextUtils; + +import java.io.File; +import java.util.UUID; + +import io.ona.kujaku.utils.Constants; + +/** + * Helps add the MapBox Style to the MapBox MapView when provided as string since the MapBox API only allows + * urls i.e. assets, network & storage. Mapbox style should follow the Mapbox Style Spec

+ *

+ * Basically stores the style on shared External Storage

+ *

+ * HOW TO USE
+ * ----------
+ *

+ * {@code MapBoxStyleStorage mapboxStyleStorage = new MapBoxStyleStorage()} + * {@code mapboxStyleStorage.getStyleURL("file:///storage/Downloads/style.json")} + * {@code mapboxStyleStorage.getStyleURL("asset://town_style.json")} + * {@code mapboxStyleStorage.getStyleURL("https://companysite.com/style/style.json")} + * {@code mapboxStyleStorage.getStyleURL("{ 'version': 8, 'name': 'kujaku-map', 'metadata': {}, }")}

+ * {@code mapboxStyleStorage.deleteFile("json.style", false)} + * {@code mapboxStyleStorage.deleteFile("/sdcard/Downloads/json.style", true)} + * {@code mapboxStyleStorage.deleteFile("/sdcard/Downloads/json.style")} + *

+ * Created by Ephraim Kigamba - ekigamba@ona.io on 09/11/2017. + */ + +public class MapBoxStyleStorage extends BaseStorage { + public static final String BASE_DIRECTORY = ".KujakuStyles"; + + /** + * Converts the Mapbox style supplied to a usable resource by the Mapbox API by ensuring that the + * Mapbox style passed is either a file, network url or android asset file. It then provides an + * appropriate resource link where one is not provided + * + * @param stylePathOrJSON Mapbox Style Path or Mapbox Style JSON String + * @return Path to the MapBox Style in the format {@code file://[Path_to_file] } + */ + public String getStyleURL(@NonNull String stylePathOrJSON) { + if (stylePathOrJSON.startsWith("file:") + || stylePathOrJSON.startsWith("asset:") + || stylePathOrJSON.startsWith("http:") + || stylePathOrJSON.startsWith("https:") + || stylePathOrJSON.matches(Constants.MAP_BOX_URL_FORMAT)) { + return stylePathOrJSON; + } + + String fileName = ""; + boolean fileCreated = false; + + while (!fileCreated) { + fileName = UUID.randomUUID().toString() + ".json"; + fileName = writeToFile(BASE_DIRECTORY, fileName, stylePathOrJSON); + fileCreated = !TextUtils.isEmpty(fileName); + } + + return "file://" + fileName; + } + + /** + * Caches Mapbox styles in Folder: {@value BASE_DIRECTORY} as user/stylename eg. mapbox://style/edwin/9iowdcjsd + * will be saved in {@value BASE_DIRECTORY}/edwin/9iowdcjsd + * + * @param mapBoxUrl + * @param mapBoxStyleJSON + * @return

- {@code TRUE} if the style is cached successfully
+ * - {@code FALSE} if the style is not cached successfully

+ */ + public boolean cacheStyle(@NonNull String mapBoxUrl, @NonNull String mapBoxStyleJSON) { + if (mapBoxUrl.matches(Constants.MAP_BOX_URL_FORMAT)) { + String[] mapBoxPaths = mapBoxUrl.replace("mapbox://styles/", "").split("/"); + String folder = mapBoxPaths[0]; + String filename = mapBoxPaths[1]; + + String fileAbsolutePath = writeToFile(BASE_DIRECTORY + File.separator + folder, filename, mapBoxStyleJSON); + return !TextUtils.isEmpty(fileAbsolutePath); + } + + return false; + } + + /** + * Retrieves the cached style if one exists + * + * @param mapBoxUrl + * @return

- The cached style JSON String, if it exists on local storage
+ * - Empty String("") if the style is not cached

+ */ + public String getCachedStyle(String mapBoxUrl) { + if (mapBoxUrl.matches(Constants.MAP_BOX_URL_FORMAT)) { + String[] mapBoxPaths = mapBoxUrl.replace("mapbox://styles/", "").split("/"); + String folder = mapBoxPaths[0]; + String filename = mapBoxPaths[1]; + + return readFile(folder, filename); + } + return null; + } + + /** + * Reads a style on local storage given the path using the format {@literal file://{file_path}} + * + * @param protocolledFilePath + * @return

- NULL if the file does not exist
+ * - The style's JSON String if the file exists

+ */ + public String readStyle(@NonNull String protocolledFilePath) { + String fileProtocolOrSth = "file://"; + if (protocolledFilePath.isEmpty() || !protocolledFilePath.startsWith(fileProtocolOrSth)) { + return null; + } + String folders = ""; + + if (protocolledFilePath.lastIndexOf(File.separator) > 6) { + folders = protocolledFilePath.substring( + fileProtocolOrSth.length(), + protocolledFilePath.lastIndexOf(File.separator)); + } + + String fileName = protocolledFilePath.substring( + protocolledFilePath.lastIndexOf(File.separator) + 1 + ); + + return readFile(folders, fileName, true); + } + + @Override + protected String getDirectory() { + return BASE_DIRECTORY; + } +} diff --git a/library/src/main/java/io/ona/kujaku/helpers/storage/TrackingStorage.java b/library/src/main/java/io/ona/kujaku/helpers/storage/TrackingStorage.java new file mode 100644 index 000000000..848636f77 --- /dev/null +++ b/library/src/main/java/io/ona/kujaku/helpers/storage/TrackingStorage.java @@ -0,0 +1,107 @@ +package io.ona.kujaku.helpers.storage; + +import android.location.Location; +import android.os.Environment; +import android.util.Log; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * Created by Emmanuel OTIN - eo@novel-t.ch on 12/03/2019. + * + */ +public class TrackingStorage extends BaseStorage { + private static final String BASE_DIRECTORY = ".KujakuTracking"; + + private static final String CURRENT_DIRECTORY = "Current"; + private static final String PREVIOUS_DIRECTORY = "Previous"; + + private static final String FILE_NAME = "Tracks"; + private static final String FILE_EXTENSION = ".json"; + + /** + * Write serialized StoreLocation in a file + * + * @param location + * @param index + */ + public void writeLocation(Location location, int index) { + String folderName = BASE_DIRECTORY + File.separator + CURRENT_DIRECTORY; + String fileName = FILE_NAME + "_" + index + FILE_EXTENSION; + + if (! fileExists(folderName, fileName)) { + createFile(folderName, fileName); + } + + gsonWriteObject(folderName, fileName, new StoreLocation(location)); + } + + /** + * Init TrackingService Store Location + */ + public void initLocationStorage() { + // If directory previous exists, delete it + String previousFolderName = BASE_DIRECTORY + File.separator + PREVIOUS_DIRECTORY; + if (directoryExists(previousFolderName)) { + deleteFile(previousFolderName, true); + } + + // Rename current directory to previous + renameFile(BASE_DIRECTORY, CURRENT_DIRECTORY, BASE_DIRECTORY, PREVIOUS_DIRECTORY); + } + + public List getCurrentRecordedLocations() { + String folderName = BASE_DIRECTORY + File.separator + CURRENT_DIRECTORY ; + + List result = new ArrayList(); + + if (directoryExists(folderName)) { + File directory = new File(Environment.getExternalStorageDirectory(), folderName); + if (directory.canRead()) { + File[] files = directory.listFiles(); + if (files != null) { + for (int i = 0; i < files.length; i++) { + StoreLocation storeLoc = gsonReadObject(folderName, files[i].getName(), StoreLocation.class); + if (storeLoc != null) { + result.add(StoreLocation.locationFromStoreLocation(storeLoc)); + } + } + } + } else { + Log.d(TAG, "Cannot read folder " + directory.getAbsolutePath()); + } + } + + return result; + } + + @Override + protected String getDirectory() { + return BASE_DIRECTORY; + } +} + +class StoreLocation { + + String provider; + double latitude; + double longitude; + + StoreLocation(Location location) { + this.provider = location.getProvider(); + this.latitude = location.getLatitude(); + this.longitude = location.getLongitude(); + + } + + static Location locationFromStoreLocation(StoreLocation storeLocation) { + Location location = new Location(storeLocation.provider); + location.setLatitude(storeLocation.latitude); + location.setLongitude(storeLocation.longitude); + + return location; + } +} + diff --git a/library/src/main/java/io/ona/kujaku/listeners/TrackingServiceListener.java b/library/src/main/java/io/ona/kujaku/listeners/TrackingServiceListener.java new file mode 100644 index 000000000..f3f510f09 --- /dev/null +++ b/library/src/main/java/io/ona/kujaku/listeners/TrackingServiceListener.java @@ -0,0 +1,46 @@ +package io.ona.kujaku.listeners; + +import android.location.Location; + +import io.ona.kujaku.services.TrackingService; + +/** + * Listener called by the Tracking Service + * + * Created by Emmanuel Otin - eo@novel-t.ch 03/07/19. + */ +public interface TrackingServiceListener { + + /** + * When first location is received + * + * @param location + */ + void onFirstLocationReceived(Location location); + + /** + * When a location is registered + * + * @param location + */ + void onNewLocationReceived(Location location); + + /** + * When the location recorder is close to the departure location + * + * @param location + */ + void onCloseToDepartureLocation(Location location); + + /** + * When the connection is done with the service + * + * @param service + */ + void onServiceConnected(TrackingService service); + + /** + * When the connection is closed + */ + void onServiceDisconnected(); +} diff --git a/library/src/main/java/io/ona/kujaku/services/TrackingService.java b/library/src/main/java/io/ona/kujaku/services/TrackingService.java new file mode 100644 index 000000000..265cb3d1e --- /dev/null +++ b/library/src/main/java/io/ona/kujaku/services/TrackingService.java @@ -0,0 +1,921 @@ +package io.ona.kujaku.services; + +import android.annotation.TargetApi; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; + +import android.content.ServiceConnection; +import android.graphics.Color; + +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.location.LocationProvider; +import android.os.Binder; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.PowerManager; +import android.support.annotation.NonNull; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.TaskStackBuilder; +import android.util.Log; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import io.ona.kujaku.R; +import io.ona.kujaku.helpers.storage.TrackingStorage; +import io.ona.kujaku.listeners.TrackingServiceListener; +import io.ona.kujaku.services.options.TrackingServiceOptions; +import io.ona.kujaku.services.options.TrackingServiceSaveBatteryOptions; + + +/** + * Tracking Service used in Foreground to avoid any memory cleaning from Android + * /!\ The application need to be set on Mode "No Battery optimization" in any case + * + * Created by Emmanuel Otin - eo@novel-t.ch 03/07/19. + */ +public class TrackingService extends Service { + private final static String TAG = TrackingService.class.getSimpleName(); + private final static String PARTIAL_WAKE_LOCK_TAG = "TrackingService:PartialWakeLock"; + + private final static String ACTIVITY_EXTRA_NAME = "launch_activity_class"; + private final static String OPTIONS_EXTRA_NAME = "tracking_service_options"; + + // Wait time in milli sec to wait for the service thread to exit + public final static long WAIT_TIME_SERVICE_THREAD = 400; + + private static volatile CountDownLatch serviceThreadRunningLatch; + + // Service status + private static volatile int serviceStatus = TrackingServiceStatus.STOPPED; + + // Location Manager + private volatile LocationManager locationManager; + + private volatile Handler gpsHandler; + + private volatile Location lastRecordedLocation; + private volatile Location pendingRecordingLocation; + private volatile Location lastBestLocation; + + // Store the recorded locations + private List recordedLocations; + + private volatile Location firstLocationReceived = null ; + + // Tracks Options parameters + public TrackingServiceOptions trackingServiceOptions; + + // Use for notification + private PendingIntent notificationPendingIntent; + + // Binder given to clients + private final IBinder binder = new LocalBinder(); + + // Listener to register to some Tracking functions + private TrackingServiceListener trackingServiceListener = null; + + // To prevent device from sleeping + private PowerManager.WakeLock wakeLock; + private PowerManager powerManager; + + //Physical Storage + private TrackingStorage storage; + + public static class TrackingServiceStatus { + // To record the service status + final static int STOPPED = 0; + final static int STOPPED_GPS = 1; + final static int WAITING_FIRST_FIX = 2; + final static int WAITING_FIRST_RECORD = 3; + final static int RUNNING = 4; + } + + + @Override + public void onCreate() { + super.onCreate(); + + Log.d(TAG, "Initializing tracking service."); + + powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE); + locationManager = (LocationManager) getSystemService(Context.LOCATION_SERVICE); + storage = new TrackingStorage(); + + recordedLocations = new ArrayList<>(); + } + + /** + * Initialize service + */ + private void Initialize() { + // Variables + lastRecordedLocation = null; + lastBestLocation = null; + pendingRecordingLocation = null; + firstLocationReceived = null ; + + // Storage + storage.initLocationStorage(); + } + + /** + * Get Tracking Service Options from the intent + * + * @param intent + */ + private void getTrackingServiceOptions(Intent intent) { + // Get parameters for Parcelable TrackingServiceOptions + TrackingServiceOptions options = intent.getParcelableExtra(OPTIONS_EXTRA_NAME); + if (options == null) { + trackingServiceOptions = new TrackingServiceSaveBatteryOptions(); + } else { + trackingServiceOptions = options; + } + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + + Log.d(TAG, "Main ThreadID: " + android.os.Process.myTid()); + + createNotificationPendingIntent(intent); + + getTrackingServiceOptions(intent); + + // Make sure we start clean. The service instance still exists after + // stopping and so the variable are not re-initialized. + Initialize(); + + // Start the service in foreground to avoid as much as + // possible that the service is killed by OS. + startServiceForeground(); + + Log.d(TAG, "Min distance gps setting: " + Float.toString(trackingServiceOptions.getMinDistance())); + Log.d(TAG, + "Tolerance interval distance setting: " + + Long.toString(trackingServiceOptions.getToleranceIntervalDistance())); + + switch (TrackingService.serviceStatus) { + case TrackingServiceStatus.RUNNING: + case TrackingServiceStatus.WAITING_FIRST_FIX: + case TrackingServiceStatus.WAITING_FIRST_RECORD: + Log.w(TAG, "Service thread is already running."); + return Service.START_STICKY; + + default: + Log.d(TAG, "Service starting."); + + // Prevent the device from sleeping + if (!this.getWakeLock().isHeld()) { + this.getWakeLock().acquire(); + } + + if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { + Log.i(TAG, "Start tracking service thread."); + + try { + // Set the latch that will be unset when the service thread exits + serviceThreadRunningLatch = new CountDownLatch(1); + // Start the thread processing notifications + serviceThread.start(); + + } catch (Exception e) { + + Log.e(TAG, "Failed to start service thread.", e); + setServiceStatus(TrackingServiceStatus.STOPPED); + + // Stop the service as there is something really + // wrong + stopSelf(); + + return Service.START_NOT_STICKY; + } + + setServiceStatus(TrackingServiceStatus.WAITING_FIRST_FIX); + + Log.i(TAG, "Tracking service running."); + + return Service.START_STICKY; + + } else { + + setServiceStatus(TrackingServiceStatus.STOPPED_GPS); + + // Creation not successful because either GPS is not enabled or + Log.w(TAG, + "Abort service when starting because GPS not enabled."); + + // Stop the service + stopSelf(); + + return Service.START_NOT_STICKY; + } + } + } + + @Override + public void onDestroy() { + + Log.d(TAG, "Tracking service stopping."); + + try { + // Remove listeners + if (locationManager != null) { + if (locationListener != null) { + Log.d(TAG, "Remove location manager updates."); + locationManager.removeUpdates(locationListener); + } + } + + // Stop the service thread by posting a runnable in the loop. + if (gpsHandler != null) { + Log.d(TAG, "Quitting looper"); + gpsHandler.post(stopServiceThread); + } + + if (wakeLock != null) { + Log.d(TAG, "Release wake lock."); + wakeLock.release(); + } + + } catch (Exception e) { + Log.e(TAG, "Failed to stop service properly.", e); + } + + Log.d(TAG, "Wait for the threads to exit."); + + // Wait for the threads to die. This is required to implement an async stop. See Utils. + try { + if (serviceThreadRunningLatch != null) { + if (!serviceThreadRunningLatch.await(WAIT_TIME_SERVICE_THREAD, TimeUnit.MILLISECONDS)) { + Log.w(TAG, "Time out waiting for service thread to exit."); + } + Log.d(TAG, "Service thread has stopped."); + } + + } catch (InterruptedException ie) { + Log.e(TAG, "Main application thread was interrupted.", ie); + } + + setServiceStatus(TrackingServiceStatus.STOPPED); + + super.onDestroy(); + + Log.i(TAG, "Tracking service stopped."); + } + + @Override + public IBinder onBind(Intent intent) { + return binder; + } + + /** + * + * + * @param intent + */ + private void createNotificationPendingIntent(Intent intent) { + Class cls = getActivityClassFromCanonicalName(intent); + + // Creates an explicit intent for an Activity + Intent startActivityIntent = new Intent(this, cls) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + + // The stack builder object will contain an artificial back stack for the started Activity. + // This ensures that navigating backward from the Activity leads out of + // your application to the Home screen. + // ALL THIS is required to start the service in the foreground! + TaskStackBuilder stackBuilder = TaskStackBuilder.create(this); + + // Adds the back stack for the Intent (but not the Intent itself) + if (cls != null) { + stackBuilder.addParentStack(cls); + } + + // Adds the Intent that starts the Activity to the top of the stack + stackBuilder.addNextIntent(startActivityIntent); + + notificationPendingIntent = stackBuilder.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT); + } + + /** + * Get the launching Activity class name from the intent + * + * @param intent + * @return + */ + private Class getActivityClassFromCanonicalName(Intent intent) { + Bundle extras = intent.getExtras(); + if (extras == null) { + return null ; + } + + String classname = extras.getString(ACTIVITY_EXTRA_NAME); + Class cls = null; + try { + cls = Class.forName(classname); + } catch (Exception ex) { + Log.e(TAG, "Launch activity class not found"); + } + + return cls; + } + + /*** + * Register LocationManager request locations updates + */ + @SuppressWarnings({"MissingPermission"}) + private void registerLocationListener() { + Log.d(TAG, "Register location update listener."); + // https://stackoverflow.com/questions/33022662/android-locationmanager-vs-google-play-services + // FusedLocationProviderClient fusedLocationClient = LocationServices.getFusedLocationProviderClient(this); + locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, + trackingServiceOptions.getMinTime(), + trackingServiceOptions.getGpsMinDistance() - trackingServiceOptions.getToleranceIntervalDistance(), + locationListener, Looper.myLooper()); + + Log.d(TAG, "Register GPS status listener."); + + } + + public void setServiceStatus(int status) { + serviceStatus = status; + } + + /** + * Process a new location + * + * @param location + */ + private synchronized void processLocation(Location location) { + double distanceBetweenLocations; + + try { + if (lastRecordedLocation == null) { + Log.d(TAG, "First location since service started or GPS was lost"); + + // Create pending + overwritePendingLocation(location); + + // Create a fake last recorded location to have a fix + // reference point to compare new tracks to. + // We cannot compare new tracks always to the pending + // one using toleranceInterval as the + // pending location can be updated for ever theoretically + // if the accuracy keeps being better + lastRecordedLocation = location; + + return; + } + + // lastRecordedLocation is not null + distanceBetweenLocations = location.distanceTo(lastRecordedLocation); + + Log.d(TAG, + "Distance to last recorded location (m) = " + + Double.toString(distanceBetweenLocations)); + + if ((distanceBetweenLocations < (trackingServiceOptions.getMinDistance() - trackingServiceOptions.getToleranceIntervalDistance()))) { + Log.d(TAG, "New location too close from last recorded location."); + return; + } + + if (distanceBetweenLocations < (trackingServiceOptions.getMinDistance() + trackingServiceOptions.getToleranceIntervalDistance())) { + Log.d(TAG, + "New location within distance tolerance from last recorded location."); + + // Check if there is a pending location + if (pendingRecordingLocation == null) { + Log.d(TAG, + "No pending location."); + overwritePendingLocation(location); + return; + } else { + if (selectLocation(location, pendingRecordingLocation)) { + overwritePendingLocation(location); + Log.d(TAG, "New location is better than pending location."); + + return; + + } else { + Log.d(TAG, + "New location has worse accuracy than pending one."); + return; + + } // end test on better location + + } // end test if pending + + } else { + Log.d(TAG, "New location out of distance tolerance."); + if (pendingRecordingLocation == null) { + // As this location is out of tolerance, the next one will also be. + // So we record it now. We cannot wait for better accuracy. + overwritePendingLocation(location); + recordPendingLocation(); + return; + + } else { + // Record pending which becomes the last location + recordPendingLocation(); + // Recursive call as we have a new lastRecordedLocation + processLocation(location); + return; + } + + } // End test new location within time tolerance + + } catch (Exception e) { + + Log.e(TAG, "Error when processing location.", e); + } + } + + /** + * Compare two track accuracy. Return true if new better than old + * + * @param newLocation + * @param oldLocation + * @return + */ + private boolean selectLocation(Location newLocation, Location oldLocation) { + double newDist = newLocation.distanceTo(lastRecordedLocation); + double oldDist = oldLocation.distanceTo(lastRecordedLocation); + + // Old track real, new track real => keep best accuracy + if (newLocation.getAccuracy() < oldLocation.getAccuracy()) { + return true; + } else { + if (newLocation.getAccuracy() == oldLocation.getAccuracy()) { + // Check if closer to targeted distance + return newDist < oldDist; + } else { + return false; + } + } + } + + /** + * Overwrite pending with a new location + * + * @param location + */ + private void overwritePendingLocation(Location location) { + Log.d(TAG, "Overwrite pending location."); + + pendingRecordingLocation = location; + + // Remember the last real Location. pendingRecordingLocation can be null but + // lastBestLocation cannot be. + // Each pending location will be recorded at some point and become the new + // reference to compare to + lastBestLocation = location; + } + + /** + * Register Location + * + */ + private void recordPendingLocation() { + if (pendingRecordingLocation != null) { + Log.d(TAG, "Record pending location."); + + // We store the location in our list + recordedLocations.add(pendingRecordingLocation); + + InformNewTrackReceivedListener(pendingRecordingLocation); + InformCloseToDepartureLocationListener(pendingRecordingLocation); + + storage.writeLocation(pendingRecordingLocation, recordedLocations.size()); + } else { + Log.d(TAG, "Service is not recording."); + } + + lastRecordedLocation = pendingRecordingLocation; + pendingRecordingLocation = null; + } + + /** + * Volatile because different methods are called from the main thread and serviceThread + */ + private volatile LocationListener locationListener = new LocationListener() { + @Override + public void onStatusChanged(String provider, int status, Bundle extras) { + + switch (status) { + case LocationProvider.AVAILABLE: + Log.d(TAG, "GPS available."); + break; + + case LocationProvider.TEMPORARILY_UNAVAILABLE: + Log.d(TAG, "GPS temporary unavailable."); + break; + + case LocationProvider.OUT_OF_SERVICE: + Log.d(TAG, "GPS out of service."); + break; + + default: + break; + } + } + + @Override + public void onProviderEnabled(String provider) { + // See GPS Broadcast receiver + } + + @Override + public void onProviderDisabled(String provider) { + Log.i(TAG, "GPS Provider has been disabled."); + Log.i(TAG, "Stopping tracking service."); + // Stop the service + TrackingService.this.stopSelf(); + } + + @Override + public void onLocationChanged(Location location) { + Log.d(TAG, "GPS position received"); + Log.d(TAG, "GPS Location ThreadID: " + android.os.Process.myTid()); + + // This should never happen, but just in case (we really don't + // want the service to crash): + if (location == null) { + Log.d(TAG, "No location available."); + return; + } + + // First Location received + InformFirstLocationReceivedListener(location); + + // Ignore if the accuracy is too bad: + if (location.getAccuracy() > trackingServiceOptions.getMinAccuracy()) { + Log.d(TAG, "Track ignored because of accuracy."); + return; + } + + if (lastBestLocation == null) { + lastBestLocation = location; + } else { + if (location.getAccuracy() <= lastBestLocation.getAccuracy()) { + // Remember this location is better than the previous one. + // lastBestLocation is rebased in overwritePendingLocation but an + // ignored location can have better accuracy + // even if not recorded + + Log.d(TAG, + "New location is used as latest best accuracy location."); + lastBestLocation = location; + } + } + + // process location received from GPS + processLocation(location); + } + + }; + + /** + * Service thread with looper to handle GPS notification Thread to process all notifications + */ + private volatile Thread serviceThread = new Thread("TrackingService") { + public void run() { + try { + Log.d(TAG, "Tracking thread started."); + // preparing a looper on current thread + // the current thread is being detected implicitly + Looper.prepare(); + + Log.d(TAG, "Register GPS status listener."); + + // No need to do it in thread as the listener only logs + // which is fast + // locationManager.addGpsStatusListener(gpsListener); + + // now, the handler will automatically bind to the + // Looper that is attached to the current thread + // You don't need to specify the Looper explicitly + gpsHandler = new Handler(); + + // Register the Location listener + registerLocationListener(); + + // After the following line the thread will start + // running the message loop and will not normally + // exit the loop unless a problem happens or you + // quit() the looper (see below) + Looper.loop(); + + } catch (Exception e) { + Log.e(TAG, "Service thread failed to run.", e); + // Stop the service as there is something really wrong + stopSelf(); + } + + Log.d(TAG, "Exiting looper."); + + if (pendingRecordingLocation != null) { + + Log.d(TAG, "Record last pending location."); + recordPendingLocation(); + } + + // Mark to notify thread is exiting + serviceThreadRunningLatch.countDown(); + } + }; + + /** + * Call by service Thread to stop itself + */ + private Runnable stopServiceThread = new Runnable() { + @Override + public void run() { + try { + Looper.myLooper().quit(); + } catch (Exception e) { + Log.e(TAG, "Cannot stop service thread.", e); + } + } + }; + + /** + * Get Wake Lock + * + * @return + */ + private PowerManager.WakeLock getWakeLock() { + if (this.wakeLock == null) { + // Create the wakeLock to prevent the device from sleeping + this.wakeLock = this.powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, + PARTIAL_WAKE_LOCK_TAG); + } + + return this.wakeLock; + } + + /** + * To ensure the service is not killed too easily + */ + protected void startServiceForeground() { + String channel; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + channel = createChannel(); + else { + channel = ""; + } + + NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(this, channel) + .setSmallIcon(android.R.drawable.ic_menu_mylocation) + .setContentTitle(getString(R.string.tracking_service_notification_title)) + .setOngoing(true) + .setWhen(System.currentTimeMillis()); + + Notification notification = mBuilder + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentIntent(notificationPendingIntent) + .build(); + + startForeground(1, notification); + } + + /** + * Create new channel for the Tracking Service + * + * @return + */ + @NonNull + @TargetApi(26) + private synchronized String createChannel() { + String channelId = getString(R.string.tracking_service_channel_id); + String channelName = getString(R.string.tracking_service_channel_name); + + NotificationManager mNotificationManager = (NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE); + NotificationChannel mChannel = new NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH); + + mChannel.enableLights(true); + mChannel.setLightColor(Color.BLUE); + mChannel.enableVibration(true); + if (mNotificationManager != null) { + mNotificationManager.createNotificationChannel(mChannel); + } else { + stopSelf(); + } + return channelId; + } + + /** TrackingService Listener methods **/ + + /** + * Register listener + * + * @param listener + */ + public void registerTrackingServiceListener(TrackingServiceListener listener) { + this.trackingServiceListener = listener; + } + + /** + * Inform listener that first location is received + * + * @param location + */ + private void InformFirstLocationReceivedListener(Location location) { + if (this.trackingServiceListener != null && this.firstLocationReceived == null) { + trackingServiceListener.onFirstLocationReceived(location); + this.firstLocationReceived = location ; + } + this.setServiceStatus(TrackingServiceStatus.WAITING_FIRST_RECORD); + } + + /** + * Inform listener that a new track is registered + * + * @param location + */ + private void InformNewTrackReceivedListener(Location location) { + if (this.trackingServiceListener != null && location != null) { + this.trackingServiceListener.onNewLocationReceived(location); + } + this.setServiceStatus(TrackingServiceStatus.RUNNING); + } + + /** + * Inform listener that location registered is close to the departure location + * + * @param location + */ + private void InformCloseToDepartureLocationListener(Location location) { + if (this.trackingServiceListener != null && location != null) { + if (this.getNumberOfLocationsRecorded() == 1) { + return; + } + + Location departure = this.getFirstLocationRecorded(); + + if (departure != null && departure.distanceTo(location) <= trackingServiceOptions.getDistanceFromDeparture()) { + this.trackingServiceListener.onCloseToDepartureLocation(location); + } + } + } + + /** + * Get First Location recorded + * @return + */ + private Location getFirstLocationRecorded() { + if (this.recordedLocations != null && this.recordedLocations.size() > 0) { + return this.recordedLocations.get(0); + } + + return null; + } + + /** + * Get number of locations recorded + * + * @return + */ + private int getNumberOfLocationsRecorded() { + if (this.recordedLocations != null) { + return this.recordedLocations.size(); + } + + return 0; + } + + + /**** Public methods that can be used after bind ***/ + + /** + * Return all locations recorded + * + * @return + */ + public List getRecordedLocations() { + return this.recordedLocations; + } + + /** + * Record pending Location + */ + public void takeLocation () { + if (pendingRecordingLocation != null) { + Location pendingLocation = pendingRecordingLocation; + recordPendingLocation(); + pendingRecordingLocation = pendingLocation; + } + } + + /** + * Class used for the client Binder. Because we know this service always + * runs in the same process as its clients, we don't need to deal with IPC. + */ + public class LocalBinder extends Binder { + public TrackingService getService() { + // Return this instance of LocalService so clients can call public methods + return TrackingService.this; + } + } + + /*** static methods ***/ + + /** + * Start Service and bind it + * + * @param context + * @param cls + * @param connection + * @param options + */ + public static void startAndBindService(Context context, Class cls, ServiceConnection connection, TrackingServiceOptions options) { + Intent mIntent = TrackingService.bindService(context, cls, connection, options); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(mIntent); + } else { + context.startService(mIntent); + } + } + + /** + * Bind service + * + * @param context + * @param cls + * @param connection + * @param options + * @return + */ + public static Intent bindService(Context context, Class cls, ServiceConnection connection, TrackingServiceOptions options) { + Intent mIntent = new Intent(context, TrackingService.class); + + if (cls != null) { + mIntent.putExtra(ACTIVITY_EXTRA_NAME, cls.getCanonicalName()); + } + + if (options != null) { + mIntent.putExtra(OPTIONS_EXTRA_NAME, options); + } + + context.bindService(mIntent, connection, BIND_AUTO_CREATE); + + return mIntent; + } + + /** + * Stop Service and unbind it + * + * @param context + * @param connection + */ + public static void stopAndUnbindService(Context context, ServiceConnection connection) { + Intent mIntent = new Intent(context, TrackingService.class); + context.stopService(mIntent); + TrackingService.unBindService(context, connection); + } + + /** + * Unbind service from the activity + * + * @param context + * @param connection + */ + public static void unBindService(Context context, ServiceConnection connection) { + context.unbindService(connection); + } + + /** + * Return {@code TRUE} if tracking service is running {@code FALSE} otherwise + * + * @return + */ + public static boolean isRunning() { + return TrackingService.serviceStatus == TrackingService.TrackingServiceStatus.RUNNING || + TrackingService.serviceStatus == TrackingService.TrackingServiceStatus.WAITING_FIRST_FIX || + TrackingService.serviceStatus == TrackingService.TrackingServiceStatus.WAITING_FIRST_RECORD; + } + + /** + * Return service status + * + * @return + */ + public static int getTrackingServiceStatus() { + return TrackingService.serviceStatus; + } + +} diff --git a/library/src/main/java/io/ona/kujaku/services/options/TrackingServiceHighAccuracyOptions.java b/library/src/main/java/io/ona/kujaku/services/options/TrackingServiceHighAccuracyOptions.java new file mode 100644 index 000000000..285e87122 --- /dev/null +++ b/library/src/main/java/io/ona/kujaku/services/options/TrackingServiceHighAccuracyOptions.java @@ -0,0 +1,38 @@ +package io.ona.kujaku.services.options; + +import android.os.Parcel; +import android.os.Parcelable; + +/** + * Options for Tracking Service . High Accuracy as we are asking the maximum of available locations from the GPS : gpsMinDistance = 0 + * + * Created by Emmanuel Otin - eo@novel-t.ch 03/07/19. + */ +public class TrackingServiceHighAccuracyOptions extends TrackingServiceOptions { + + public TrackingServiceHighAccuracyOptions() { + super(); + this.minDistance = 5; + this.gpsMinDistance = 0; + this.toleranceIntervalDistance = 1; + this.distanceFromDeparture = 10; + this.minAccuracy = 50; + } + + private TrackingServiceHighAccuracyOptions(Parcel in) { + super.createFromParcel(in); + } + + /** + * Creator for Parcelable class + */ + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + public TrackingServiceHighAccuracyOptions createFromParcel(Parcel in) { + return new TrackingServiceHighAccuracyOptions(in); + } + + public TrackingServiceHighAccuracyOptions[] newArray(int size) { + return new TrackingServiceHighAccuracyOptions[size]; + } + }; +} diff --git a/library/src/main/java/io/ona/kujaku/services/options/TrackingServiceOptions.java b/library/src/main/java/io/ona/kujaku/services/options/TrackingServiceOptions.java new file mode 100644 index 000000000..55135bb51 --- /dev/null +++ b/library/src/main/java/io/ona/kujaku/services/options/TrackingServiceOptions.java @@ -0,0 +1,116 @@ +package io.ona.kujaku.services.options; + +import android.app.PendingIntent; +import android.location.Criteria; +import android.location.Location; +import android.os.Parcel; +import android.os.Parcelable; + +/** + * Abstract Options for Tracking Service. + * + * Created by Emmanuel Otin - eo@novel-t.ch 03/07/19. + */ +abstract public class TrackingServiceOptions implements Parcelable { + private long minTime; + + long minDistance; + long gpsMinDistance; + long toleranceIntervalDistance; + long distanceFromDeparture; + long minAccuracy; + + TrackingServiceOptions() { + this.minTime = 0; + } + + /** + * Get the min distance in meters between 2 points registered by the TrackingService + * + * @return + */ + public long getMinDistance() { + return minDistance; + } + + /** + * Get the tolerance in meters (more or less) to the min distance between 2 points registered by the TrackingService + * + * @return + */ + public long getToleranceIntervalDistance() { + return toleranceIntervalDistance; + } + + /** + * Define the maximum distance in meters from the first point recorded to raise the + * {@link io.ona.kujaku.listeners.TrackingServiceListener#onCloseToDepartureLocation(Location)} function + * + * @return + */ + public long getDistanceFromDeparture() { + return distanceFromDeparture; + } + + /** + * Get the minimum accuracy in meters to record a location + * + * @return + */ + public long getMinAccuracy() { + return minAccuracy; + } + + /** + * Get the minimum time in seconds between getting a point from the {@link android.location.LocationManager#requestLocationUpdates(long, float, Criteria, PendingIntent)} + * + * @return + */ + public long getMinTime() { + return minTime; + } + + /** + * Get the minimum distance in meters between getting a point from the {@link android.location.LocationManager#requestLocationUpdates(long, float, Criteria, PendingIntent)} + * + * @return + */ + public long getGpsMinDistance() { + return gpsMinDistance; + } + + @Override + public int describeContents() { + return 0; + } + + /** + * Parcelable implementation of writeToParcel function + * + * @param dest + * @param flags + */ + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(minTime); + dest.writeLong(minDistance); + dest.writeLong(gpsMinDistance); + dest.writeLong(toleranceIntervalDistance); + dest.writeLong(distanceFromDeparture); + dest.writeLong(minAccuracy); + } + + /** + * Parcelable implementation of createFromParcel function called in subclasses + * + * @param in + */ + void createFromParcel(Parcel in) { + this.minTime = in.readLong(); + this.minDistance = in.readLong(); + this.gpsMinDistance = in.readLong(); + this.toleranceIntervalDistance = in.readLong(); + this.distanceFromDeparture = in.readLong(); + this.minAccuracy = in.readLong(); + } +} diff --git a/library/src/main/java/io/ona/kujaku/services/options/TrackingServiceSaveBatteryOptions.java b/library/src/main/java/io/ona/kujaku/services/options/TrackingServiceSaveBatteryOptions.java new file mode 100644 index 000000000..e6c766076 --- /dev/null +++ b/library/src/main/java/io/ona/kujaku/services/options/TrackingServiceSaveBatteryOptions.java @@ -0,0 +1,38 @@ +package io.ona.kujaku.services.options; + +import android.os.Parcel; +import android.os.Parcelable; + +/** + * Options for Tracking Service. Save Battery as we are asking locations updates from the GPS every 5 meters : gpsMinDistance = 5 + * + * Created by Emmanuel Otin - eo@novel-t.ch 03/07/19. + */ +public class TrackingServiceSaveBatteryOptions extends TrackingServiceOptions { + + public TrackingServiceSaveBatteryOptions() { + super(); + this.minDistance = 5; + this.gpsMinDistance = 5; + this.toleranceIntervalDistance = 1; + this.distanceFromDeparture = 10; + this.minAccuracy = 50; + } + + private TrackingServiceSaveBatteryOptions(Parcel in) { + super.createFromParcel(in); + } + + /** + * Creator for Parcelable class + */ + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + public TrackingServiceSaveBatteryOptions createFromParcel(Parcel in) { + return new TrackingServiceSaveBatteryOptions(in); + } + + public TrackingServiceSaveBatteryOptions[] newArray(int size) { + return new TrackingServiceSaveBatteryOptions[size]; + } + }; +} diff --git a/library/src/main/java/io/ona/kujaku/views/KujakuMapView.java b/library/src/main/java/io/ona/kujaku/views/KujakuMapView.java index aa366dc30..a1c094ff5 100644 --- a/library/src/main/java/io/ona/kujaku/views/KujakuMapView.java +++ b/library/src/main/java/io/ona/kujaku/views/KujakuMapView.java @@ -2,11 +2,14 @@ import android.Manifest; import android.app.Activity; +import android.content.ComponentName; import android.content.Context; import android.content.IntentSender; +import android.content.ServiceConnection; import android.content.res.TypedArray; import android.graphics.PointF; import android.location.Location; +import android.os.IBinder; import android.support.annotation.DrawableRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; @@ -72,7 +75,10 @@ import io.ona.kujaku.listeners.BoundsChangeListener; import io.ona.kujaku.listeners.OnFeatureClickListener; import io.ona.kujaku.listeners.OnLocationChanged; +import io.ona.kujaku.listeners.TrackingServiceListener; import io.ona.kujaku.location.clients.AndroidLocationClient; +import io.ona.kujaku.services.TrackingService; +import io.ona.kujaku.services.options.TrackingServiceOptions; import io.ona.kujaku.utils.Constants; import io.ona.kujaku.utils.LocationPermissionListener; import io.ona.kujaku.utils.LocationSettingsHelper; @@ -100,6 +106,7 @@ public class KujakuMapView extends MapView implements IKujakuMapView, MapboxMap. private Button cancelAddingPoint; private MapboxMap mapboxMap; private ImageButton currentLocationBtn; + private ImageView trackingServiceStatusButton; private ILocationClient locationClient; private Toast currentlyShownToast; @@ -108,8 +115,6 @@ public class KujakuMapView extends MapView implements IKujakuMapView, MapboxMap. private OnLocationChanged onLocationChangedListener; - private boolean isMapScrolled = false; - private static final int ANIMATE_TO_LOCATION_DURATION = 1000; protected Set droppedPoints; @@ -121,6 +126,7 @@ public class KujakuMapView extends MapView implements IKujakuMapView, MapboxMap. private MapboxLocationComponentWrapper mapboxLocationComponentWrapper; private boolean updateUserLocationOnMap = false; + private boolean updateCameraUserLocationOnMap = false; private final float DEFAULT_LOCATION_OUTER_CIRCLE_RADIUS = 25f; @@ -195,6 +201,7 @@ private void init(@Nullable AttributeSet attributeSet) { addPointButtonsLayout = findViewById(R.id.ll_mapview_locationSelectionBtns); addPointBtn = findViewById(R.id.imgBtn_mapview_locationAdditionBtn); currentLocationBtn = findViewById(R.id.ib_mapview_focusOnMyLocationIcon); + trackingServiceStatusButton = findViewById(R.id.iv_mapview_tracking_service_status); getMapboxMap(); cancelAddingPoint = findViewById(R.id.btn_mapview_locationSelectionCancelBtn); @@ -204,6 +211,7 @@ private void init(@Nullable AttributeSet attributeSet) { public void onClick(View v) { // Enable asking for enabling the location by resetting this flag in case it was true hasAlreadyRequestedEnableLocation = false; + updateCameraUserLocationOnMap = true; setWarmGps(true, null, null, new OnLocationServicesEnabledCallBack() { @Override public void onSuccess() { @@ -240,8 +248,11 @@ public void onGlobalLayout() { } private void showUpdatedUserLocation(Float radius) { - updateUserLocation(radius); - if (updateUserLocationOnMap || !isMapScrolled) { + if (updateUserLocationOnMap) { + updateUserLocation(radius); + } + + if (updateCameraUserLocationOnMap) { // Focus on the new location centerMap(latestLocationCoordinates, ANIMATE_TO_LOCATION_DURATION, getZoomToUse(mapboxMap, LOCATION_FOCUS_ZOOM)); } @@ -262,9 +273,7 @@ public void onLocationChanged(Location location) { onLocationChangedListener.onLocationChanged(location); } - if (updateUserLocationOnMap) { - showUpdatedUserLocation(locationBufferRadius); - } + showUpdatedUserLocation(locationBufferRadius); } }); } @@ -380,7 +389,7 @@ public void setViewVisibility(View view, boolean isVisible) { @Override public void enableAddPoint(boolean canAddPoint, @Nullable final OnLocationChanged onLocationChanged) { - isMapScrolled = false; + //isMapScrolled = false; this.enableAddPoint(canAddPoint); if (canAddPoint) { @@ -388,8 +397,9 @@ public void enableAddPoint(boolean canAddPoint, @Nullable final OnLocationChange // 1. Focus on the location for the first time is a must // 2. Any sub-sequent location updates are dependent on whether the user has touched the UI - // 3. Show the circle icon on the currrent position -> This will happen whenever there are location updates + // 3. Show the circle icon on the current position -> This will happen whenever there are location updates updateUserLocationOnMap = true; + updateCameraUserLocationOnMap = true; if (latestLocationCoordinates != null) { showUpdatedUserLocation(locationBufferRadius); } @@ -714,8 +724,8 @@ private void addMapScrollListenerAndBoundsChangeEmitterToMap(@NonNull MapboxMap mapboxMap.addOnMoveListener(new MapboxMap.OnMoveListener() { @Override public void onMoveBegin(@NonNull MoveGestureDetector detector) { - isMapScrolled = true; - + // isMapScrolled = true; + // updateCameraUserLocationOnMap = false; // We should assume the user no longer wants us to focus on their location focusOnUserLocation(false); } @@ -758,7 +768,7 @@ private void dropPointOnMap(@NonNull LatLng latLng) { dropPointOnMap(latLng, null); } - private void dropPointOnMap(@NonNull LatLng latLng, @Nullable MarkerOptions markerOptionsParam) { + public void dropPointOnMap(@NonNull LatLng latLng, @Nullable MarkerOptions markerOptionsParam) { MarkerOptions markerOptions = markerOptionsParam; if (markerOptions == null) { markerOptions = new MarkerOptions() @@ -868,17 +878,18 @@ public void focusOnUserLocation(boolean focusOnMyLocation) { @Override public void focusOnUserLocation(boolean focusOnMyLocation, Float radius) { if (focusOnMyLocation) { - isMapScrolled = false; + //isMapScrolled = false; changeImageButtonResource(currentLocationBtn, R.drawable.ic_cross_hair_blue); // Enable the listener & show the current user location updateUserLocationOnMap = true; + updateCameraUserLocationOnMap = true; if (latestLocationCoordinates != null) { showUpdatedUserLocation(radius); } } else { - updateUserLocationOnMap = false; + updateCameraUserLocationOnMap = false; changeImageButtonResource(currentLocationBtn, R.drawable.ic_cross_hair); } } @@ -1203,5 +1214,66 @@ private void resetRejectionDialogContent() { public MapboxLocationComponentWrapper getMapboxLocationComponentWrapper() { return mapboxLocationComponentWrapper; } + + + /************** Tracking Service ***************/ + public void resumeTrackingService(Context context, TrackingServiceListener listener) { + // TrackingService reconnection if connection was lost + if (! trackingServiceBound && TrackingService.isRunning()) { + this.trackingServiceListener = listener; + TrackingService.bindService(context, null, connection,null); + } + } + + public void startTrackingService(@NonNull Context context, @NonNull Class cls, @NonNull TrackingServiceListener trackingServiceListener, TrackingServiceOptions options) { + this.trackingServiceListener = trackingServiceListener; + TrackingService.startAndBindService(context, + cls, + connection, + options); + } + + public List stopTrackingService(@NonNull Context context) { + List locations = mService.getRecordedLocations(); + TrackingService.stopAndUnbindService(context, connection); + trackingServiceStatusButton.setImageResource(R.drawable.ic_recording_gray); + return locations; + } + + public void unBindTrackingService(@NonNull Context context) { + TrackingService.unBindService(context, connection); + } + + public void trackingServiceTakeLocation() { + mService.takeLocation(); + } + + private TrackingService mService = null; + private boolean trackingServiceBound = false; + private TrackingServiceListener trackingServiceListener = null ; + + private ServiceConnection connection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName className, + IBinder service) { + // We've bound to TrackingService, cast the IBinder and get TrackingService instance + TrackingService.LocalBinder binder = (TrackingService.LocalBinder) service; + mService = binder.getService(); + mService.registerTrackingServiceListener(trackingServiceListener); + trackingServiceBound = true; + trackingServiceStatusButton.setVisibility(VISIBLE); + trackingServiceStatusButton.setImageResource(R.drawable.ic_recording_red); + + trackingServiceListener.onServiceConnected(mService); + } + + @Override + public void onServiceDisconnected(ComponentName arg0) { + trackingServiceBound = false; + mService = null; + trackingServiceStatusButton.setImageResource(R.drawable.ic_recording_gray); + trackingServiceListener.onServiceDisconnected(); + } + }; } diff --git a/library/src/main/res/drawable-hdpi/ic_recording_gray.png b/library/src/main/res/drawable-hdpi/ic_recording_gray.png new file mode 100644 index 0000000000000000000000000000000000000000..d9edec5dcd7c0d586586c1525cd9f703bafaea3f GIT binary patch literal 2341 zcmV+=3EK9FP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D02y>eSaefwW^{L9 za%BK;VQFr3E^cLXAT%y8E-^XO*0N*(00@;yL_t(&L(Q7`PgUC$$8S2k<|X|XnlyP$ ze`xZ4Xn$&d5UjHpwQ;I6!IV}UFj}NWd1zwQnu;0`mBArM3ql;?P!)YpCCs2$A`Vkzx^?TupEz-%L`dJeefze4^yra(`t+%O_UxHu&!0aZH1l^D&+zQ6Teq}~?M3LS zRjYoza^=eJ*wcS_q^73+u&AhLaYI8x*PT0e^pht~21UGh@xn5tR7f+=8NtlK#?;l- zeN$Lim@1z9fxTSpfrs+*^MBve)YRMG->(M-1_s3#!Y^OGwCvTZS0T+j=M>6Zc=F!8 zd%Cf)u_rGtFDd38%*e?2>8VqvY6u;6*D1&;w$rX4jn4vJUKlTOF_YK3N+&G-@mWf=)HUQ{==8&2tOuZBH}z&p*Iwq z;^1J;9G=^=XU|`3y56vT`}QQx6cdnoKb9y1r#L)EwuD<}XQ$51&Hb%SJ2Xh?WIyha z$B!RdndxJSGH{B66BSEeU!T5k;lj;j%a;Avrtc2U75CKLyLYW*f!jX?Q3i$w5kz4C zN2;ERii)po3GUdjV@C=XBIk+_WM`rwit*-7VxuB~;lq4zgzNR|*Y(z|Tc_JH-UYQ^ zb93`8+)D_Of!GO`Eos!WoBmTjT<-WbLY;f za5oTcaD*$IrAwEFZ3P7d147WV_uRZz zU0r?2mh=W$Sy>6(3&g7*Z;^g;a&mNfdU`}Ay_ljP@CxHKbJisM3HLZ#Rv;iv;vO;~ zDb!rXUcH53rkB&vl7ElaE2oq$n1rMg@53D z6`AwJL|(&Cl8u@*YnDz*N{X3DruGz$COe`5Ek}jev0ENW^FD@>2B@<1P*2Z&L6_oCe2l(9M=YsC)>UyuoqGgqpmG)|(;c`Le z1Z3yaC`0^|RFpk#grZ@SCr`Hc32x2x3`BpBazs_g9q(Z zLIbYOBDk}&Q%U>tGpl=+EOOgV0s)&gZHjtGrlzJ^Uhsn8#L>{u&||eNKnmf%`)xFd zZHTxL^kY;Tgp(Hphap{xoUdG4Rv@ru&6-iX!orp2q7KJvl8qkGAi5C^$EHr5N>Z|C z9U5?ShGBPix5~`S{G~1H&C8tH+S>n`w2v3U@fu*)va+&#pSeYg7Ac9ENMGjs+m`ee z(wxmTUsfV{{npabqUO$>>q_3urcIkRP+nfns{nfjZ*Y1G&U`@0&CN}=<-En>#fv|e zD&$%uUNd0K#fukJUS1w;z8W)TjG`LBo1;gMrioPIUDepws8}OhxOqz=?pxOvrXW8* zf0pyCnGESioD+AW!%xx@p{XaEx)m8dqYV{$=`VGjW}-gk0s7c6r3a%sG_2x zuWY*B!FfAy;6O2H)OhYghvoZ3+25bDZK~meiyLvqbHp`=mW)KYjg>$nh&5P?HCdbR z!&8W2<87M$zy$oG5k6#dSjj}BSreMk6zCMh+^oS`tjXG(DPQ3i3Ub-bJb(UtAL*65 zgRo#H-2{ds(md}b2rsb~Ym)soZ{D0>(}^ymRsTXd?kX;Ec92pAfso(~5H?1n5gN+O zbBtjub1)ZkGB;~*`AW%k)N@LY1meBlN#Aps&!a5PuClviPa~3D#~|!x=6558v4k9R zF(*ljGAU$55evxK`>dd#V0KMS%_Vk~aN%%641x${=OOH7{Khjz3}cyt)`KdYg7&?& z--m+qb8*t(?c$>w7dwq2Wf?mUVT`Pbng8cEp5a*;Gf-b&-$Rr5PCxZ2LAr!5`938i zx5&&bLY-v>m5Z@5eWPbvQPx#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D02y>eSaefwW^{L9 za%BK;VQFr3E^cLXAT%y8E-^XO*0N*(00|LEL_t(&L(N(XY*SSjuJ}NW(HNt~M2$v8 zgMe>ARPYrOr$$A@4DOD7Wk4s*>>+e|M#8$KmYm9z0(8}3l}cDG%70U!SwX> zr~?NMY&9BFJ^U{r%M8aCmHBU|`6^-(a{0_ck{-Q}(P4LRYO?_4LY> zE64ER&pj3|UVK$*YHGB}WIEN_+DiNS`i4X}olcJtLVTLIPYA{sn3%l0ysDIxl*KHP zW4XJ~0})C}N_w}ju+Y}g(LwEY`;ZtxcyMsgV=kA=r-}QdLNOL1+1}nx3knLXiHV7G zLmt7Hn3!>g4jnQebuhb9K~f=Zw|kg*!~42as8k?)*lad>@ZiC}mMvR$2d5ESuqe%` zsHo^el#n*wf&@vmkcyNFgfI9kD=TZ;uwla-PA706^)fOtezaIDw5zMjGn2s-6h%;vnQjesfl)XcMpkz(@=;K@FlLXv61fFxl^lpd%%Oc zckj-?dXh#el!Ck=6)41Yc6L%s^o|`nepVGZq#p*rK*;k@rQT?eiUS9+<`B8<+qZwk z=_8O6#M|yjEJ(cNviV~2D z11EGWZEbC|yu7?=`SRtrar*MWy23tn`t)f}u|U`_1W^Kl2Qmna0XU-T$;!(5lS`1t zrcIj`U_->ZLJDGLf+C7N?_vwzWp_~O3T!OF2i(9BTpJo1==$~RXK@+Bf!?pEsHhp? zMGB$Z`6JHtp}^pV%Mh@pnEW72(meB0P#7duu7FUb==fZ2ZOEZ9YcbE*IgC zCvWgjtk!f6-G`Nvc1MvzO0^prhJr5w!Bd zFKK&r0YOy#fhZ*43eId>BCI=lkIOj&tgZa~{8NEM%QfI6miTR?^yNjg^dZf4h4iPo6 z)~r@5Wt;XiE=!|fhs5ougeak6GgcZcm(xkABEP0bpPCyKE1&rhakjMa(S1Wi4V=Lp z6_T}pn?0g%oM*@P4(wal_{6@Y6sNxbi5z)6oCeb>Klp;UU2a8b39jG_?)(gA@d#t* zoX6_x>xZSG(rERRSCNv*Gik{quLs?lf8Ec$_y)L2(-9|th=_=Zn)LMaj}bz#A^Vet zCl=$0ga&BYyLWH0#$Yg*M49(zebpzYlft{6r=c_yB_#l7Y1!B7^*T*qVPUnJ z!g2iF<-~ORGc=TT_~99B!E=W`;9Wr2o?HoGPH{Z(M_9>Yvq}EV(`cxTboGpyj^4tU zrUnfw^)4YyaYbbJ7;l_HjMq=0c{ffCiDB%V%O}wOnmX=uWJrZU3lm0E3(LLw0g`>u z-8ARQNz`!dBh+}^qd_slwGY#rD<)Blovj(CR+v+DDi+-NtjDo1{iWk+?v?jZ!_^N` z!!-~2XY3g~i!r2QW?wXpl8!F!ab$R>B0Hs6A*@DC@%TIJJu-}h#Pk>6;~|+db^=SM zi7W!JN!-}a_#3;1d-P-OksSkOykWAUuHRc&US8gDl>1yY<-YFQWLYx7w6YlyirXr$ za=%Z5&pm23=-#S-J!?#kP_V2i{SWTVH)J;GbsDW!8-+GRO-qHgM~+Z1EbpeNXI>?Y z0=brI%a$z*H26@*nqZ=)3GOU7O0&m=CTjJGY21r%fUC3#uC1*lk&%((J*Q_@MOxGp z5TGjJs}YNcT08PYICZlqD1mq3FquqN&#?tyjqomZ(2wx^)`utNe$LIy%5nnuZpO zg{)YyVhWda=4Fo2X#7`{eN-5Kq6WD7?X+@1EY+*1lzL|7r{5wDTT7rXbB=OJXTc6< zi^P|eKwiJKA1WY6*r^(1r6v_2HPfw6*&9=TW6qRMA|Q%O!52tVl>97cG`? z)v_;4fuy9Qx20=MR7h3gqyQ1S!|obzcDPk!ffV2ZPT(fi7GArpTeof|m+^n#Lt0i= zRyHams*E%x0~Qwq2vOh!ZlV(I+qW;1OBfk3F)??Rm6i31ltd}L5X4DEffKeSa5S6E ztxJ|HxrNjB4VIF#@fr)q8L{5Zr#Np>;DpV%rl!UnA0PiJr>zWWX=&f$wKv3JS3h6k zD4b(fF}tXq zLL_D#2EuYCt_v}E7Ac3ZFeZu?+9aPHMJT}5-et+j$?qEshDyvT(glkf#6S>2G4miS zXSjxYgcv-FF>v%imyU*Z#NO{*ft}~3u>;-~e00Ocj`t2|%P{jG3`13giQjPz_uyXk zj6FX;--<)xu=CV~1a?Z8i0@O3WIUUB9sUiLof>RKMMZ?074QA@di@`nnVIW1Z{9r5 f^Nu2@&}jYx`x&AkXzc2K00000NkvXXu0mjfJ;9u~ literal 0 HcmV?d00001 diff --git a/library/src/main/res/layout/mapbox_mapview_internal.xml b/library/src/main/res/layout/mapbox_mapview_internal.xml index 378df24ef..b93686260 100644 --- a/library/src/main/res/layout/mapbox_mapview_internal.xml +++ b/library/src/main/res/layout/mapbox_mapview_internal.xml @@ -47,7 +47,7 @@ android:visibility="gone" android:src="@drawable/mapbox_markerview_icon_default"/> - + + + + Map download could not be stopped Location service disabled Location]]> + Kujaku Tracking service + Kujaku Tracking service channel + @string/tracking_service_channel_name diff --git a/library/src/test/java/io/ona/kujaku/helpers/MapBoxStyleStorageTest.java b/library/src/test/java/io/ona/kujaku/helpers/MapBoxStyleStorageTest.java index 0c8477d9a..3719e65d7 100644 --- a/library/src/test/java/io/ona/kujaku/helpers/MapBoxStyleStorageTest.java +++ b/library/src/test/java/io/ona/kujaku/helpers/MapBoxStyleStorageTest.java @@ -10,6 +10,7 @@ import static org.junit.Assert.*; import io.ona.kujaku.BuildConfig; +import io.ona.kujaku.helpers.storage.MapBoxStyleStorage; /** * Created by Ephraim Kigamba - ekigamba@ona.io on 10/11/2017. diff --git a/sample/build.gradle b/sample/build.gradle index a39ef04ef..f291b35bb 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -139,7 +139,8 @@ android { dependencies { configuration -> - releaseTestKujakuImport(configuration) + //releaseTestKujakuImport(configuration) + developmentKujakuModulesImport(this, configuration) implementation 'com.cocoahero.android:geojson:1.0.1@jar' implementation "com.android.volley:volley:${rootProject.ext.volleyVersion}" diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index 8e63258b2..d63d16cb5 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -80,6 +80,10 @@ +
\ No newline at end of file diff --git a/sample/src/main/java/io/ona/kujaku/sample/activities/BaseNavigationDrawerActivity.java b/sample/src/main/java/io/ona/kujaku/sample/activities/BaseNavigationDrawerActivity.java index 34c03da48..d5249cb37 100644 --- a/sample/src/main/java/io/ona/kujaku/sample/activities/BaseNavigationDrawerActivity.java +++ b/sample/src/main/java/io/ona/kujaku/sample/activities/BaseNavigationDrawerActivity.java @@ -195,6 +195,12 @@ public boolean onNavigationItemSelected(MenuItem item) { startActivity(new Intent(this, CaseRelationshipActivity.class)); finish(); return true; + + case R.id.nav_passive_record_object: + startActivity(new Intent(this, PassiveRecordObjectActivity.class)); + finish(); + return true; + default: break; } diff --git a/sample/src/main/java/io/ona/kujaku/sample/activities/MainActivity.java b/sample/src/main/java/io/ona/kujaku/sample/activities/MainActivity.java index 878d38f82..8195a4fdf 100644 --- a/sample/src/main/java/io/ona/kujaku/sample/activities/MainActivity.java +++ b/sample/src/main/java/io/ona/kujaku/sample/activities/MainActivity.java @@ -35,7 +35,7 @@ import io.ona.kujaku.KujakuLibrary; import io.ona.kujaku.callables.AsyncTaskCallable; import io.ona.kujaku.domain.Point; -import io.ona.kujaku.helpers.MapBoxStyleStorage; +import io.ona.kujaku.helpers.storage.MapBoxStyleStorage; import io.ona.kujaku.helpers.MapBoxWebServiceApi; import io.ona.kujaku.helpers.OfflineServiceHelper; import io.ona.kujaku.listeners.OnFinishedListener; diff --git a/sample/src/main/java/io/ona/kujaku/sample/activities/PassiveRecordObjectActivity.java b/sample/src/main/java/io/ona/kujaku/sample/activities/PassiveRecordObjectActivity.java new file mode 100644 index 000000000..a445c335c --- /dev/null +++ b/sample/src/main/java/io/ona/kujaku/sample/activities/PassiveRecordObjectActivity.java @@ -0,0 +1,211 @@ +package io.ona.kujaku.sample.activities; + +import android.location.Location; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.Toast; + +import com.mapbox.mapboxsdk.geometry.LatLng; + +import java.util.List; + +import io.ona.kujaku.callbacks.OnLocationServicesEnabledCallBack; +import io.ona.kujaku.helpers.storage.TrackingStorage; +import io.ona.kujaku.listeners.TrackingServiceListener; +import io.ona.kujaku.sample.R; +import io.ona.kujaku.services.TrackingService; +import io.ona.kujaku.services.options.TrackingServiceHighAccuracyOptions; +import io.ona.kujaku.views.KujakuMapView; + +public class PassiveRecordObjectActivity extends BaseNavigationDrawerActivity implements TrackingServiceListener { + + private static final String TAG = PassiveRecordObjectActivity.class.getName(); + + private KujakuMapView kujakuMapView; + + private Button startStopBtn; + private Button forceLocationBtn; + + private TrackingService mService = null; + private boolean mBound = false; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + kujakuMapView = findViewById(R.id.kmv_passiveRecordObject_mapView); + kujakuMapView.onCreate(savedInstanceState); + kujakuMapView.showCurrentLocationBtn(true); + + this.startStopBtn = findViewById(R.id.btn_passiveRecordObject_StartStopRecording); + this.forceLocationBtn = findViewById(R.id.btn_passiveRecordObject_ForcePoint); + + this.startStopBtn.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (((Button)v).getText().equals("Start Recording")) { + + // Start Service + kujakuMapView.startTrackingService(getApplicationContext(), + PassiveRecordObjectActivity.class, + PassiveRecordObjectActivity.this, + new TrackingServiceHighAccuracyOptions()); + + ((Button)v).setText("Stop Recording"); + forceLocationBtn.setEnabled(true); + } else { + + // Get the Tracks recorded + List tracks = kujakuMapView.stopTrackingService(getApplicationContext()); + + List othersTracks = new TrackingStorage().getCurrentRecordedLocations(); + displayTracksRecorded(othersTracks); + + ((Button)v).setText("Start Recording"); + forceLocationBtn.setEnabled(false); + } + } + }); + + this.forceLocationBtn.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + kujakuMapView.trackingServiceTakeLocation(); + } + }); + + kujakuMapView.setWarmGps(true, null, null, new OnLocationServicesEnabledCallBack() { + @Override + public void onSuccess() { + kujakuMapView.focusOnUserLocation(true); + startStopBtn.setEnabled(true); + kujakuMapView.resumeTrackingService(getApplicationContext(), PassiveRecordObjectActivity.this); + } + }); + } + + + private void InitRecordingButton() { + if (TrackingService.isRunning()) { + startStopBtn.setText("Stop Recording"); + forceLocationBtn.setEnabled(true); + } else { + startStopBtn.setText("Start Recording"); + forceLocationBtn.setEnabled(true); + } + } + + private void displayTracksRecorded(List locations) { + this.runOnUiThread(new Runnable() { + public void run() { + for (Location location: locations) { + kujakuMapView.dropPointOnMap( new LatLng(location.getLatitude(), location.getLongitude()), null); + } + } + }); + } + + /**** TrackingServiceListener ****/ + @Override + public void onServiceDisconnected() { + Toast.makeText(getApplicationContext(), "Service disconnected", Toast.LENGTH_SHORT).show(); + mService = null; + mBound = false; + } + + @Override + public void onServiceConnected(TrackingService service) { + Toast.makeText(getApplicationContext(), "Service connected", Toast.LENGTH_SHORT).show(); + mService = service; + mBound = true; + + displayTracksRecorded(mService.getRecordedLocations()); + InitRecordingButton(); + } + + @Override + public void onFirstLocationReceived(Location location) { + this.runOnUiThread(new Runnable() { + public void run() { + Toast.makeText(getApplicationContext(), "First Location received", Toast.LENGTH_LONG).show(); + } + }); + } + + @Override + public void onNewLocationReceived(Location location) { + this.runOnUiThread(new Runnable() { + public void run() { + Toast.makeText(getApplicationContext(), "New Location received", Toast.LENGTH_SHORT).show(); + kujakuMapView.dropPointOnMap( new LatLng(location.getLatitude(), location.getLongitude()), null); + } + }); + } + + @Override + public void onCloseToDepartureLocation(Location location) { + this.runOnUiThread(new Runnable() { + public void run() { + Toast.makeText(getApplicationContext(), "Location recorded is closed to the departure location", Toast.LENGTH_LONG).show(); + } + }); + } + + /**** TrackingServiceListener END ****/ + + @Override + protected int getContentView() { + return R.layout.activity_passive_record_object_map_view; + } + + @Override + protected int getSelectedNavigationItem() { + return R.id.nav_passive_record_object; + } + + @Override + protected void onResume() { + super.onResume(); + if (kujakuMapView != null) kujakuMapView.onResume(); + } + + @Override + protected void onStart() { + super.onStart(); + if (kujakuMapView != null) kujakuMapView.onStart(); + } + + @Override + protected void onStop() { + super.onStop(); + if (kujakuMapView != null) kujakuMapView.onStop(); + } + + @Override + protected void onPause() { + super.onPause(); + if (kujakuMapView != null) kujakuMapView.onPause(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (kujakuMapView != null) kujakuMapView.onDestroy(); + + // Unbind Service to be sure we can stop the TrackingService + if (kujakuMapView != null) kujakuMapView.unBindTrackingService(getApplicationContext()); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + if (kujakuMapView != null) kujakuMapView.onSaveInstanceState(outState); + } + + @Override + public void onLowMemory() { + super.onLowMemory(); + if (kujakuMapView != null) kujakuMapView.onLowMemory(); + } +} diff --git a/sample/src/main/res/layout/activity_passive_record_object_map_view.xml b/sample/src/main/res/layout/activity_passive_record_object_map_view.xml new file mode 100644 index 000000000..767ed8191 --- /dev/null +++ b/sample/src/main/res/layout/activity_passive_record_object_map_view.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + +