Edit feature attachments

View on GitHubSample viewer app

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

Image of edit feature attachments

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

  1. Create a ServiceFeatureTable from a URL.
  2. Create a FeatureLayer object from the service feature table.
  3. Select features from the feature layer with selectFeature.
  4. To fetch the feature's attachments, cast to an ArcGISFeature and use ArcGISFeature.fetchAttachments().
  5. To add an attachment to the selected ArcGISFeature, create an attachment and use ArcGISFeature.addAttachment().
  6. To delete an attachment from the selected ArcGISFeature, use the ArcGISFeature.deleteAttachment().
  7. 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

MainActivity.ktMainActivity.ktAttachmentsBottomSheet.kt
Use dark colors for code blocksCopy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
/* 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.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.os.Bundle
import android.provider.MediaStore
import android.util.Log
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.lifecycleScope
import com.arcgismaps.ApiKey
import com.arcgismaps.ArcGISEnvironment
import com.arcgismaps.data.ArcGISFeature
import com.arcgismaps.data.Attachment
import com.arcgismaps.data.FeatureRequestMode
import com.arcgismaps.data.ServiceFeatureTable
import com.arcgismaps.mapping.ArcGISMap
import com.arcgismaps.mapping.BasemapStyle
import com.arcgismaps.mapping.GeoElement
import com.arcgismaps.mapping.Viewpoint
import com.arcgismaps.mapping.layers.FeatureLayer
import com.arcgismaps.mapping.view.IdentifyLayerResult
import com.esri.arcgismaps.sample.editfeatureattachments.databinding.EditFeatureAttachmentsActivityMainBinding
import com.esri.arcgismaps.sample.editfeatureattachments.databinding.AttachmentEditSheetBinding
import com.esri.arcgismaps.sample.editfeatureattachments.databinding.AttachmentLoadingDialogBinding
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream

class MainActivity : AppCompatActivity() {

    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.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 = BitmapDrawable(
                resources,
                BitmapFactory.decodeByteArray(it, 0, it.size)
            )
            // 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()
    }
}

Your browser is no longer supported. Please upgrade your browser for the best experience. See our browser deprecation post for more details.