Add, delete, and download attachments for features from a service.

Use case
Attachments provide a flexible way to manage additional information that is related to your features. Attachments allow you to add files to individual features, including: PDFs, text documents, or any other type of file. For example, if you have a feature representing a building, you could use attachments to add multiple photographs of the building taken from several angles, along with PDF files containing the building’s deed and tax information.
How to use the sample
Tap a feature on the map to open a bottom sheet displaying the number of attachments. Select an entry from the list to download and view the attachment in the gallery. Tap on the add attachment button to add an attachment or long press to delete.
How it works
- Create a
ServiceFeatureTablefrom a URL. - Create a
FeatureLayerobject from the service feature table. - Select features from the feature layer with
selectFeature. - To fetch the feature’s attachments, cast to an
ArcGISFeatureand useArcGISFeature.fetchAttachments(). - To add an attachment to the selected ArcGISFeature, create an attachment and use
ArcGISFeature.addAttachment(). - To delete an attachment from the selected ArcGISFeature, use the
ArcGISFeature.deleteAttachment(). - After a change, apply the changes to the server using
ServiceFeatureTable.applyEdits().
Relevant API
- ArcGISFeature.deleteAttachment
- ArcGISFeature.fetchAttachments
- Attachment.fetchData
- FeatureLayer
- ServiceFeatureTable
- ServiceFeatureTable.applyEdits
- ServiceFeatureTable.updateFeature
Additional information
Attachments can only be added to and accessed on service feature tables when their hasAttachments property is true.
Tags
edit and manage data, image, jpeg, pdf, picture, png, txt
Sample Code
package com.esri.arcgismaps.sample.editfeatureattachments
import android.app.Activityimport android.view.Viewimport android.view.ViewGroupimport android.widget.AdapterViewimport android.widget.ArrayAdapterimport android.widget.TextViewimport androidx.lifecycle.lifecycleScopeimport com.arcgismaps.data.Attachmentimport com.esri.arcgismaps.sample.editfeatureattachments.databinding.AttachmentEditSheetBindingimport com.google.android.material.bottomsheet.BottomSheetBehaviorimport com.google.android.material.bottomsheet.BottomSheetDialogimport com.google.android.material.dialog.MaterialAlertDialogBuilderimport kotlinx.coroutines.launchimport java.util.Locale
class AttachmentsBottomSheet( context: MainActivity, bottomSheetBinding: AttachmentEditSheetBinding, attachments: List<Attachment>, damageType: String,) : BottomSheetDialog(context) { init { // clear and set bottom sheet content view to layout, // to be able to set the content view on each bottom sheet draw if (bottomSheetBinding.root.parent != null) { (bottomSheetBinding.root.parent as ViewGroup).removeAllViews() }
// set the state to be an expanded sheet behavior.state = BottomSheetBehavior.STATE_EXPANDED
bottomSheetBinding.apply { // set the selected feature's information damageStatus.text = String.format(Locale.getDefault(),"Damage type: %s", damageType) numberOfAttachments.text = String.format(Locale.getDefault(),"Number of attachments: %d", attachments.size) // get the adapter to display the list of attachments listView.adapter = AttachmentsAdapter(context, attachments)
// listener to add a new attachment addAttachmentButton.setOnClickListener { context.selectAttachment() }
// listener to download and open an attachment listView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> lifecycleScope.launch { context.fetchAttachment( attachments[position] ) } }
// listener to delete an attachment listView.onItemLongClickListener = AdapterView.OnItemLongClickListener { _, _, position, _ -> // create a dialog to display the delete query MaterialAlertDialogBuilder(context).apply { setMessage(context.getString(R.string.delete_query)) setCancelable(true) setPositiveButton(context.getString(R.string.yes)) { dialog, _ -> // user confirmed, delete selected attachment context.deleteAttachment(attachments[position]) dialog.dismiss() } setNegativeButton(context.getString(R.string.no)) { dialog, _ -> dialog.cancel() } }.show() true }
// set apply button to validate and apply contingency feature on map applyTv.setOnClickListener { this@AttachmentsBottomSheet.dismiss() } } }
class AttachmentsAdapter( private val context: Activity, private val attachmentName: List<Attachment>, ) : ArrayAdapter<Attachment>(context, R.layout.attachment_entry, attachmentName) { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val view = convertView ?: context.layoutInflater.inflate( R.layout.attachment_entry, parent, false ) val attachmentTextView = view.findViewById<TextView>(R.id.AttachmentName) attachmentTextView.text = attachmentName[position].name return view } }}/* Copyright 2023 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.editfeatureattachments
import android.content.Intentimport android.graphics.Bitmapimport android.graphics.BitmapFactoryimport android.graphics.drawable.BitmapDrawableimport android.net.Uriimport android.os.Bundleimport android.provider.MediaStoreimport android.util.Logimport android.view.ViewGroupimport androidx.activity.result.contract.ActivityResultContractsimport androidx.appcompat.app.AlertDialogimport com.esri.arcgismaps.sample.sampleslib.EdgeToEdgeCompatActivityimport androidx.core.content.FileProviderimport androidx.core.graphics.drawable.toDrawableimport androidx.databinding.DataBindingUtilimport androidx.lifecycle.lifecycleScopeimport com.arcgismaps.ApiKeyimport com.arcgismaps.ArcGISEnvironmentimport com.arcgismaps.data.ArcGISFeatureimport com.arcgismaps.data.Attachmentimport com.arcgismaps.data.FeatureRequestModeimport com.arcgismaps.data.ServiceFeatureTableimport com.arcgismaps.mapping.ArcGISMapimport com.arcgismaps.mapping.BasemapStyleimport com.arcgismaps.mapping.GeoElementimport com.arcgismaps.mapping.Viewpointimport com.arcgismaps.mapping.layers.FeatureLayerimport com.arcgismaps.mapping.view.IdentifyLayerResultimport com.esri.arcgismaps.sample.editfeatureattachments.databinding.AttachmentEditSheetBindingimport com.esri.arcgismaps.sample.editfeatureattachments.databinding.AttachmentLoadingDialogBindingimport com.esri.arcgismaps.sample.editfeatureattachments.databinding.EditFeatureAttachmentsActivityMainBindingimport com.google.android.material.bottomsheet.BottomSheetDialogimport com.google.android.material.dialog.MaterialAlertDialogBuilderimport com.google.android.material.snackbar.Snackbarimport kotlinx.coroutines.launchimport java.io.Fileimport java.io.FileOutputStream
class MainActivity : EdgeToEdgeCompatActivity() {
private val activityMainBinding: EditFeatureAttachmentsActivityMainBinding by lazy { DataBindingUtil.setContentView(this, R.layout.edit_feature_attachments_activity_main) }
private val mapView by lazy { activityMainBinding.mapView }
private val attachmentsSheetBinding by lazy { AttachmentEditSheetBinding.inflate(layoutInflater) }
private val loadingDialogBinding by lazy { AttachmentLoadingDialogBinding.inflate(layoutInflater) }
// load the Damage to Residential Buildings feature server private val serviceFeatureTable by lazy { ServiceFeatureTable(getString(R.string.edit_feature_attachments_sample_service_url)).apply { // set the feature request mode to request from the server as they are needed featureRequestMode = FeatureRequestMode.OnInteractionCache } }
// registers the activity for an image data result from the default image picker private val activityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> result.data?.data?.let { imageUri -> // add the image data as a feature attachment addFeatureAttachment(imageUri) } }
// tracks the selected ArcGISFeature and it's attachments private var selectedArcGISFeature: ArcGISFeature? = null
// tracks the instance of the bottom sheet private var bottomSheet: BottomSheetDialog? = null
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) lifecycle.addObserver(mapView)
// create the feature layer using the service feature table val featureLayer = FeatureLayer.createWithFeatureTable(serviceFeatureTable)
// create and add a map with a streets basemap style val streetsMap = ArcGISMap(BasemapStyle.ArcGISStreets).apply { operationalLayers.add(featureLayer) } // set the map and the viewpoint to the MapView mapView.apply { map = streetsMap setViewpoint(Viewpoint(40.0, -95.0, 1e8)) }
// identify feature selected on map tap lifecycleScope.launch { mapView.onSingleTapConfirmed.collect { tapConfirmedEvent -> // clear any previous selection featureLayer.clearSelection() // identify tapped feature val layerResult = mapView.identifyLayer( layer = featureLayer, screenCoordinate = tapConfirmedEvent.screenCoordinate, tolerance = 5.0, returnPopupsOnly = false, maximumResults = 1 ).getOrElse { exception -> showError("Failed to select feature: ${exception.message}") } as IdentifyLayerResult
// get a list of identified elements val resultGeoElements: List<GeoElement> = layerResult.geoElements // check if a feature was identified if (resultGeoElements.isNotEmpty() && resultGeoElements.first() is ArcGISFeature) { // retrieve and set the currently selected feature val selectedFeature = resultGeoElements.first() as ArcGISFeature // highlight the currently selected feature featureLayer.selectFeature(selectedFeature)
// show the bottom sheet layout createBottomSheet(selectedFeature)
// keep track of the selected feature selectedArcGISFeature = selectedFeature } } } }
/** * Creates and displays a bottom sheet to display and modify * the attachments of [selectedFeature]. Calls [AttachmentsBottomSheet] to * inflate bottom sheet and listen for interactions. */ private suspend fun createBottomSheet(selectedFeature: ArcGISFeature) { // get the number of attachments val attachmentList = selectedFeature.fetchAttachments().getOrElse { return showError(it.message.toString()) } // get the attribute "typdamage" of the selected feature val damageTypeAttribute = selectedFeature.attributes["typdamage"].toString()
// creates a new BottomSheetDialog bottomSheet = AttachmentsBottomSheet( context = this@MainActivity, bottomSheetBinding = attachmentsSheetBinding, attachments = attachmentList, damageType = damageTypeAttribute ) // set the content view to the root of the binding layout bottomSheet?.setContentView(attachmentsSheetBinding.root) // display the bottom sheet view bottomSheet?.show() }
/** * Retrieves the [attachment] data in the form of a byte array, converts it * to a [BitmapDrawable], caches the bitmap as a png image, and open's the * attachment image in the default image viewer. */ suspend fun fetchAttachment(attachment: Attachment) { // display loading dialog val dialog = createLoadingDialog("Fetching attachment data").show()
// create folder /ArcGIS/Attachments in external storage val fileDir = File(getExternalFilesDir(null)?.path + "/Attachments") fileDir.mkdirs() // create the file with the attachment name val file = File(fileDir, attachment.name)
// file provider URI val contentUri = FileProvider.getUriForFile( this, getString(R.string.edit_feature_attachments_provider_authority), file ) // open the file in gallery val imageIntent = Intent().apply { flags = Intent.FLAG_GRANT_READ_URI_PERMISSION action = Intent.ACTION_VIEW setDataAndType(contentUri, "image/png") }
// fetch the attachment data attachment.fetchData().onSuccess { // create a drawable from InputStream, then create the Bitmap val bitmapDrawable = BitmapFactory.decodeByteArray(it, 0, it.size).toDrawable(resources) // create a file output stream using the attachment file FileOutputStream(file).use { imageOutputStream -> // compress the bitmap to PNG format bitmapDrawable.bitmap.compress(Bitmap.CompressFormat.PNG, 90, imageOutputStream) // start activity using created intent startActivity(imageIntent) // dismiss dialog dialog.dismiss() } }.onFailure { // dismiss dialog dialog.dismiss() showError(it.message.toString()) } }
/** * Adds a new attachment to the [selectedArcGISFeature] using the [selectedImageUri] * and updates the changes with the feature service table */ private fun addFeatureAttachment(selectedImageUri: Uri) { // display a loading dialog val dialog = createLoadingDialog("Adding feature attachment").show()
// create an input stream at the selected URI contentResolver.openInputStream(selectedImageUri)?.use { imageInputStream -> // get the byte array of the image input stream val imageBytes: ByteArray = imageInputStream.readBytes() // create the attachment name with the current time val attachmentName = "attachment_${System.currentTimeMillis()}.png"
lifecycleScope.launch { selectedArcGISFeature?.let { arcGISFeature -> // add the attachment to the selected feature arcGISFeature.addAttachment( name = attachmentName, contentType = "image/png", data = imageBytes ).onFailure { return@launch showError(it.message.toString()) } // update the feature changes in the loaded service feature table serviceFeatureTable.updateFeature(arcGISFeature).getOrElse { return@launch showError(it.message.toString()) } } applyServerEdits(dialog) } } }
/** * Delete the [attachment] from the [selectedArcGISFeature] and update the changes * with the feature service table */ fun deleteAttachment(attachment: Attachment) { lifecycleScope.launch { val dialog = createLoadingDialog("Deleting feature attachment").show() selectedArcGISFeature?.let { arcGISFeature -> // delete the attachment from the selected feature arcGISFeature.deleteAttachment(attachment).getOrElse { return@launch showError(it.message.toString()) } // update the feature changes in the loaded service feature table serviceFeatureTable.updateFeature(arcGISFeature).getOrElse { return@launch showError(it.message.toString()) } } // apply changes back to the server applyServerEdits(dialog) } }
/** * Applies changes from a Service Feature Table to the server. * The [dialog] will be dismissed when changes are applied. */ private suspend fun applyServerEdits(dialog: AlertDialog) { // close the bottom sheet, as it will be created // after service changes are made bottomSheet?.dismiss()
// apply edits to the server val updatedServerResult = serviceFeatureTable.applyEdits() updatedServerResult.onSuccess { edits -> dialog.dismiss() // check that the feature table was successfully updated if (edits.isEmpty()) { return showError(getString(R.string.failure_edit_results)) } // if the edits were made successfully, create the bottom sheet to display new changes. selectedArcGISFeature?.let { createBottomSheet(it) } }.onFailure { showError(it.message.toString()) dialog.dismiss() } }
/** * Opens the default Android image selector */ fun selectAttachment() { val mediaIntent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) activityResultLauncher.launch(mediaIntent) }
/** * Creates a loading dialog with the [message] */ private fun createLoadingDialog(message: String): MaterialAlertDialogBuilder { // build and return a new alert dialog return MaterialAlertDialogBuilder(this).apply { // set message setMessage(message) // allow it to be cancellable setCancelable(false) // removes parent of the progressDialog layout, if previously assigned loadingDialogBinding.root.parent?.let { parent -> (parent as ViewGroup).removeAllViews() } // set the loading dialog layout to this alert dialog setView(loadingDialogBinding.root) } }
private fun showError(message: String) { Log.e(localClassName, message) Snackbar.make(mapView, message, Snackbar.LENGTH_SHORT).show() }}
// Custom FileProvider for fetching attachmentsclass EditFeatureAttachmentsProvider : FileProvider()