Synchronize offline edits with a feature service.

Use case
A survey worker who works in an area without an internet connection could take a geodatabase of survey features offline at their office, make edits and add new features to the offline geodatabase in the field, and sync the updates with the online feature service after returning to the office.
How to use the sample
Pan and zoom into the desired area, making sure the area you want to take offline is within the current extent of the map view. Tap on the “Generate Geodatabase” button to take the area offline. When complete, the map will update with a red outline around the offline area. To edit features, tap to select a feature, and tap again anywhere else on the map to move the selected feature to the tapped location. To sync the edits with the feature service, click the “Sync geodatabase” button.
How it works
- Create a
GeodatabaseSyncTaskfrom a URL to a feature service. - Use
createDefaultGenerateGeodatabaseParameters()on the geodatabase sync task to createGenerateGeodatabaseParameters, passing in anEnvelopeextent as the parameter. - Create a
GenerateGeodatabaseJobfrom theGeodatabaseSyncTaskusingcreateGenerateGeodatabaseJob(...)passing in parameters and a path to the local geodatabase. - Start the job and get the result
Geodatabase. - Load the geodatabase and get its feature tables. Create feature layers from the feature tables and add them to the map’s operational layers collection.
- Create
SyncGeodatabaseParametersand set the sync direction. - Create a
SyncGeodatabaseJobfromGeodatabaseSyncTaskusing.createSyncGeodatabaseJob(...)passing in the parameters and geodatabase as arguments. - Start the sync job to synchronize the edits with
syncGeodatabaseJob.start().
Relevant API
- FeatureLayer
- FeatureTable
- GenerateGeodatabaseJob
- GenerateGeodatabaseParameters
- GeodatabaseSyncTask
- SyncGeodatabaseJob
- SyncGeodatabaseParameters
- SyncLayerOption
Additional information
This sample uses the GeoView-Compose Toolkit module to be able to implement a composable MapView.
Tags
feature service, geodatabase, geoview-compose, offline, synchronize
Sample Code
/* Copyright 2024 Esri * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */
package com.esri.arcgismaps.sample.editandsyncfeatureswithfeatureservice
import android.os.Bundleimport androidx.activity.ComponentActivityimport androidx.activity.compose.setContentimport androidx.activity.enableEdgeToEdgeimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.Surfaceimport androidx.compose.runtime.Composableimport com.arcgismaps.ApiKeyimport com.arcgismaps.ArcGISEnvironmentimport com.esri.arcgismaps.sample.sampleslib.theme.SampleAppThemeimport com.esri.arcgismaps.sample.editandsyncfeatureswithfeatureservice.screens.MainScreen
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // authentication with an API key or named user is // required to access basemaps and other location services ArcGISEnvironment.apiKey = ApiKey.create(BuildConfig.ACCESS_TOKEN)
enableEdgeToEdge() setContent { SampleAppTheme { SampleApp() } } }
@Composable private fun SampleApp() { Surface( color = MaterialTheme.colorScheme.background ) { MainScreen( sampleName = getString(R.string.edit_and_sync_features_with_feature_service_app_name) ) } }}/* Copyright 2024 Esri * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */
package com.esri.arcgismaps.sample.editandsyncfeatureswithfeatureservice.components
import android.app.Applicationimport androidx.compose.material3.SnackbarHostStateimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableIntStateOfimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.setValueimport androidx.compose.ui.unit.dpimport androidx.lifecycle.AndroidViewModelimport androidx.lifecycle.viewModelScopeimport com.arcgismaps.Colorimport com.arcgismaps.data.Featureimport com.arcgismaps.data.Geodatabaseimport com.arcgismaps.geometry.Envelopeimport com.arcgismaps.geometry.Pointimport com.arcgismaps.geometry.Polygonimport com.arcgismaps.geometry.SpatialReferenceimport com.arcgismaps.mapping.ArcGISMapimport com.arcgismaps.mapping.BasemapStyleimport com.arcgismaps.mapping.layers.FeatureLayerimport com.arcgismaps.mapping.symbology.SimpleLineSymbolimport com.arcgismaps.mapping.symbology.SimpleLineSymbolStyleimport com.arcgismaps.mapping.view.Graphicimport com.arcgismaps.mapping.view.GraphicsOverlayimport com.arcgismaps.mapping.view.ScreenCoordinateimport com.arcgismaps.mapping.view.SingleTapConfirmedEventimport com.arcgismaps.tasks.geodatabase.GenerateGeodatabaseJobimport com.arcgismaps.tasks.geodatabase.GeodatabaseSyncTaskimport com.arcgismaps.tasks.geodatabase.SyncDirectionimport com.arcgismaps.tasks.geodatabase.SyncGeodatabaseJobimport com.arcgismaps.tasks.geodatabase.SyncGeodatabaseParametersimport com.arcgismaps.tasks.geodatabase.SyncLayerOptionimport com.arcgismaps.toolkit.geoviewcompose.MapViewProxyimport com.esri.arcgismaps.sample.editandsyncfeatureswithfeatureservice.Rimport com.esri.arcgismaps.sample.sampleslib.components.MessageDialogViewModelimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.launchimport java.io.File
class MapViewModel(private val application: Application) : AndroidViewModel(application) {
// Create a red boundary line showing the filtered map extents for the features private val boundarySymbol = SimpleLineSymbol( style = SimpleLineSymbolStyle.Solid, color = Color.red, width = 5f )
// Create graphic overlay to add graphics val graphicsOverlay = GraphicsOverlay()
// Create a ViewModel to handle dialog interactions. val messageDialogVM = MessageDialogViewModel()
// Create a streets basemap layer val map = ArcGISMap(BasemapStyle.ArcGISStreets).apply {
// Set the max map extents to that of the feature service representing san fransisco area maxExtent = Envelope( xMin = -1.3641320770825155E7, yMin = 4524676.716562641, xMax = -1.3617221998199359E7, yMax = 4567228.901189857, spatialReference = SpatialReference.webMercator() ) }
// Create a MapViewProxy to handle MapView operations val mapViewProxy = MapViewProxy()
// Keep track of action button text through a string state var actionButtonText by mutableStateOf(application.getString(R.string.generate_button_text)) private set
// Create a button to keep track of action button enablement status var isActionButtonEnabled by mutableStateOf(false) private set
// Determinate GenerateGeodatabase job progress loading dialog visibility state var showGenerateGeodatabaseJobProgressDialog by mutableStateOf(false) private set
// Determine GenerateGeodatabase job progress percentage var generateGeodatabaseJobProgress by mutableIntStateOf(0) private set
// Determinate SyncGeodatabase job progress loading dialog visibility state var showSyncGeodatabaseJobProgressDialog by mutableStateOf(false) private set
// Determine SyncGeodatabase job progress percentage var syncGeodatabaseJobProgress by mutableIntStateOf(0) private set
// Create a local file path to the geodatabase private val geodatabaseFilePath by lazy { application.getExternalFilesDir(null)?.path + File.separator + application.getString(R.string.geodatabase_file) }
// Create a geodatabase sync task with the feature service url // This feature service illustrates a collection schema for wildfire information in the san fransisco area private val geodatabaseSyncTask by lazy { GeodatabaseSyncTask(application.getString(R.string.feature_server_url)) }
// Current edit state to track when feature edits can be performed on the geodatabase private var geodatabaseEditState = GeodatabaseEditState.NOT_READY
// List of selected features to edit private var selectedFeatures = mutableListOf<Feature>()
// Geodatabase instance that is loaded private var geodatabase: Geodatabase? = null
// Job used to generate geodatabase private var generateGeodatabaseJob: GenerateGeodatabaseJob? = null
// Job used to sync geodatabase private var syncGeodatabaseJob: SyncGeodatabaseJob? = null
// Initialize polygon to null as starting value and emit its state changes var polygon: Polygon? = null
// Create a SnackbarHostState to handle successful operation messages val snackbarHostState = SnackbarHostState()
init { viewModelScope.launch(Dispatchers.Main) { // If map load failed, show the error and return map.load().onFailure { return@launch messageDialogVM.showMessageDialog( title = "Unable to load map", description = it.message.toString() ) } // If the geodatabase sync from feature service fails to load, show the error and return geodatabaseSyncTask.load().onFailure { return@launch messageDialogVM.showMessageDialog( title = "Failed to fetch geodatabase metadata", description = it.message.toString() ) } // Enable the sync button since the task is now loaded isActionButtonEnabled = true } }
/** * Collect and handle map touch events */ fun onSingleTapConfirmed(event: SingleTapConfirmedEvent) { // Receive the point in map coordinates where the user tapped, event.mapPoint?.let { point -> // Perform an action based on the current edit state when (geodatabaseEditState) { GeodatabaseEditState.NOT_READY -> { // If not ready, show info message messageDialogVM.showMessageDialog("Can't edit yet. The geodatabase hasn't been generated!") }
GeodatabaseEditState.EDITING -> { // If edits have been performed, move the selected features moveSelectedFeatures(point) }
GeodatabaseEditState.READY -> { // If no edits are performed but geodatabase is ready, select the tapped features selectFeatures(screenCoordinate = event.screenCoordinate) } } } }
/** * Handle button click events */ fun onClickActionButton() { when (geodatabaseEditState) { // If geodatabase hasn't been generated GeodatabaseEditState.NOT_READY -> { polygon?.extent?.let { polygon -> generateGeodatabase(extents = polygon) } }
// Cannot sync in the middle of edit GeodatabaseEditState.EDITING -> showSuccessMessage("Features are currently being edited!")
// If the edits have been completed GeodatabaseEditState.READY -> syncGeodatabase() } }
/** * Starts a [GeodatabaseSyncTask] with the given [ArcGISMap] and [Envelope.extent], * runs a GenerateGeodatabaseJob and saves the geodatabase file into local storage */ private fun generateGeodatabase(extents: Envelope) { // Create a boundary representing the extents selected val boundary = Graphic( geometry = extents, symbol = boundarySymbol )
// Add this boundary to the graphics overlay graphicsOverlay.graphics.add(boundary)
with(viewModelScope) {
launch(Dispatchers.IO) { // Create generateGeodatabase parameters for the selected extents val defaultParameters = geodatabaseSyncTask.createDefaultGenerateGeodatabaseParameters(extents) .getOrElse { // Show the error and return if the sync task fails return@launch messageDialogVM.showMessageDialog(title = "Error creating geodatabase parameters") }.apply { // Set return attachments option to false // Indicates if any attachments are added to the geodatabase from the feature service returnAttachments = false }
// Create a generateGeodatabase job generateGeodatabaseJob = geodatabaseSyncTask.createGenerateGeodatabaseJob( parameters = defaultParameters, pathToGeodatabaseFile = geodatabaseFilePath )
generateGeodatabaseJob?.let { generateGeodatabaseJob ->
// Show the dialog of generateGeodatabase Job showGenerateGeodatabaseJobProgressDialog = true
// Create a flow-collection for the job's progress launch{ generateGeodatabaseJob.progress.collect { progress -> // Display the current job's progress value generateGeodatabaseJobProgress = progress } }
// Start the job generateGeodatabaseJob.start()
// If the job completed successfully, get the geodatabase from the result geodatabase = generateGeodatabaseJob.result().getOrElse { // Show an error and return if job failed messageDialogVM.showMessageDialog("Error fetching geodatabase: ${it.message}") // Dismiss the dialog showGenerateGeodatabaseJobProgressDialog = false // Clear any drawn boundary graphicsOverlay.graphics.clear()
return@launch }
// Load and display the geodatabase loadGeodatabase()
// Dismiss the dialog showGenerateGeodatabaseJobProgressDialog = false } } } }
/** * Loads the [Geodatabase] and renders the feature layers on to the [ArcGISMap] */ private suspend fun loadGeodatabase() { // Load the geodatabase geodatabase?.let { geodatabase -> geodatabase.load().onFailure { // If the load failed, show the error and return return messageDialogVM.showMessageDialog("Error loading geodatabase") }
// Add all of the geodatabase feature tables to the map as feature layers map.operationalLayers.addAll(geodatabase.featureTables.map { featureTable -> FeatureLayer.createWithFeatureTable(featureTable) }) }
// Update the sync button text to show a sync action actionButtonText = getApplication<Application>().getString(R.string.sync_button_text)
// Update the geodatabase edit state to indicate its ready for edits and syncs geodatabaseEditState = GeodatabaseEditState.READY
}
/** * Syncs changes made on either the local [Geodatabase] or web service geodatabase with each other */ private fun syncGeodatabase() { // Create parameters for the geodatabase sync task val syncGeodatabaseParameters = SyncGeodatabaseParameters().apply { geodatabaseSyncDirection = SyncDirection.Bidirectional shouldRollbackOnFailure = false }
geodatabase?.let { geodatabase -> // Set synchronization option for each layer in the geodatabase we want to synchronize syncGeodatabaseParameters.layerOptions.addAll(geodatabase.featureTables.map { featureTable -> // Create a new sync layer option with the layer id of the feature table SyncLayerOption(featureTable.serviceLayerId) })
syncGeodatabaseJob = geodatabaseSyncTask.createSyncGeodatabaseJob( parameters = syncGeodatabaseParameters, geodatabase = geodatabase )
syncGeodatabaseJob?.let { syncGeodatabaseJob ->
// Show the dialog showSyncGeodatabaseJobProgressDialog = true
// Start the job and wait for Job result syncGeodatabaseJob.start()
with(viewModelScope) {
// Create the SyncGeodatabaseJob using the parameters and the geodatabase launch(Dispatchers.IO) { syncGeodatabaseJob.result().onSuccess { showSuccessMessage("Sync Complete") } .onFailure { messageDialogVM.showMessageDialog("Database did not sync correctly") }
// Dismiss the dialog showSyncGeodatabaseJobProgressDialog = false
// Set the edit state to indicate geodatabase is ready for edits geodatabaseEditState = GeodatabaseEditState.READY } // Create a flow-collection for the job's progress launch(Dispatchers.Main) { syncGeodatabaseJob.progress.collect { progress -> // Display the current job's progress value syncGeodatabaseJobProgress = progress } } } } } }
/** * Queries and selects features on FeatureLayers at the tapped [ScreenCoordinate] on the [ArcGISMap] */ private fun selectFeatures(screenCoordinate: ScreenCoordinate) { // Set the current edit state to editing geodatabaseEditState = GeodatabaseEditState.EDITING
// Create a new coroutine to handle the selection viewModelScope.launch(Dispatchers.Main) { // Flag to indicate if any features were selected var featuresSelected = false
// For each feature layer in the map map.operationalLayers.filterIsInstance<FeatureLayer>().forEach { featureLayer ->
// Identify the layer at the tapped screenCoordinate val identifyLayerResult = mapViewProxy.identify( layer = featureLayer, screenCoordinate = screenCoordinate, tolerance = 12.dp, returnPopupsOnly = false ).getOrElse { // Show an error and return if the identifyLayer operation failed return@launch messageDialogVM.showMessageDialog( title = "Unable to identify selected layer", description = it.message.toString() ) }
// Get the identified features in the feature layer val identifiedFeatures = identifyLayerResult.geoElements.filterIsInstance<Feature>() if (identifiedFeatures.isNotEmpty()) { // Select the features on the map featureLayer.selectFeatures(identifiedFeatures) // Add the identified features to the selectedFeatures list selectedFeatures.addAll(identifiedFeatures) // Set the flag to true featuresSelected = true } }
// If no features were selected if (!featuresSelected) { // Show a message showSuccessMessage("No features found at the tapped location!")
// Reset the current edit state to ready geodatabaseEditState = GeodatabaseEditState.READY } } }
/** * Moves the selected features to a new [Point] on the [ArcGISMap] */ private fun moveSelectedFeatures(point: Point) { // Create a new coroutine to move the features selectedFeatures.forEach { feature -> // Update each selected features geometry feature.geometry = point
// Update the feature viewModelScope.launch(Dispatchers.IO) { feature.featureTable?.updateFeature(feature) } }
// Clear the list of selected features once all have been updated selectedFeatures.clear()
// Clear any selected features on the map map.operationalLayers.filterIsInstance<FeatureLayer>().forEach { featureLayer -> featureLayer.clearSelection() }
// Set the current edit state to ready geodatabaseEditState = GeodatabaseEditState.READY }
fun cancelGenerateGeodatabaseJob() { viewModelScope.launch(Dispatchers.IO) { generateGeodatabaseJob?.cancel() } }
fun cancelSyncGeodatabaseJob() { viewModelScope.launch(Dispatchers.IO) { syncGeodatabaseJob?.cancel() } }
private fun showSuccessMessage(message: String) { viewModelScope.launch(Dispatchers.Main) { snackbarHostState.showSnackbar(message) } }
/** * Enum state class to track editing of features */ enum class GeodatabaseEditState { NOT_READY, // geodatabase has not yet been generated EDITING, // a feature is in the process of being moved READY, // the geodatabase is ready for synchronization or further edits }}/* Copyright 2024 Esri * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */
package com.esri.arcgismaps.sample.editandsyncfeatureswithfeatureservice.screens
import androidx.compose.foundation.layout.Arrangementimport androidx.compose.foundation.layout.Columnimport androidx.compose.foundation.layout.Rowimport androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.foundation.layout.fillMaxWidthimport androidx.compose.foundation.layout.paddingimport androidx.compose.material3.Buttonimport androidx.compose.material3.Scaffoldimport androidx.compose.material3.SnackbarHostimport androidx.compose.material3.Textimport androidx.compose.runtime.Composableimport androidx.compose.runtime.rememberimport androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dpimport androidx.lifecycle.viewmodel.compose.viewModelimport com.arcgismaps.toolkit.geoviewcompose.MapViewimport com.esri.arcgismaps.sample.editandsyncfeatureswithfeatureservice.components.MapViewModelimport com.esri.arcgismaps.sample.sampleslib.components.JobLoadingDialogimport com.esri.arcgismaps.sample.sampleslib.components.MessageDialogimport com.esri.arcgismaps.sample.sampleslib.components.SampleTopAppBar
/** * Main screen layout for the sample app */@Composablefun MainScreen(sampleName: String) { // create a ViewModel to handle MapView interactions val mapViewModel: MapViewModel = viewModel()
// use viewModel SnackbarHostState to set SnackbarHost in Scaffold val snackbarHostState = remember { mapViewModel.snackbarHostState }
Scaffold( topBar = { SampleTopAppBar(title = sampleName) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, content = { // check generate offline map to see layout Column( modifier = Modifier .fillMaxSize() .padding(it) ) { MapView( modifier = Modifier .weight(1f) .fillMaxWidth(), arcGISMap = mapViewModel.map, mapViewProxy = mapViewModel.mapViewProxy, graphicsOverlays = listOf(mapViewModel.graphicsOverlay), onSingleTapConfirmed = mapViewModel::onSingleTapConfirmed, onVisibleAreaChanged = { newPolygon -> mapViewModel.polygon = newPolygon }, )
Row( modifier = Modifier .fillMaxWidth() .padding(12.dp), horizontalArrangement = Arrangement.Center ) { Button( onClick = mapViewModel::onClickActionButton, enabled = mapViewModel.isActionButtonEnabled ) { Text(text = mapViewModel.actionButtonText) } }
// Display progress dialog while generating geodatabase if (mapViewModel.showGenerateGeodatabaseJobProgressDialog) { JobLoadingDialog( title = "Generating geodatabase...", progress = mapViewModel.generateGeodatabaseJobProgress, cancelJobRequest = { mapViewModel.cancelGenerateGeodatabaseJob() } ) }
// Display progress dialog while syncing geodatabase if (mapViewModel.showSyncGeodatabaseJobProgressDialog) { JobLoadingDialog( title = "Syncing geodatabase...", progress = mapViewModel.syncGeodatabaseJobProgress, cancelJobRequest = { mapViewModel.cancelSyncGeodatabaseJob() } ) }
// Display a dialog if the sample encounters an error mapViewModel.messageDialogVM.apply { if (dialogStatus) { MessageDialog( title = messageTitle, description = messageDescription, onDismissRequest = ::dismissDialog ) } } } } )}