Repository: getodk/collect
Pull request #6627: Add support for `integrityUrl` checks
Author: login: seadowg
Labels: name: behavior verified
Created: 2025-02-28T14:15:34Z | Merged: 2025-03-17T16:58:24Z

Closes #6231 
~~Blocked by #6630~~

#### Why is this the best possible solution? Were any other approaches considered?

Comments inline.

#### How does this change affect users? Describe intentional changes to behavior and behavior that could have accidentally been affected by code changes. In other words, what are the regression risks?

Not a lot changes here outside of the cases described in the issue. Should be enough to verify that everything works as expected there.

#### Before submitting this PR, please make sure you have:
- [x] added or modified tests for any new or changed behavior
- [x] run `./gradlew connectedAndroidTest` (or `./gradlew testLab`) and confirmed all checks still pass
- [x] added a comment above any new strings describing it for translators
- [x] added any new strings with date formatting to `DateFormatsTest`
- [x] verified that any code or assets from external sources are properly credited in comments and/or in the [about file](https://github.com/getodk/collect/blob/master/collect_app/src/main/assets/open_source_licenses.html).
- [x] verified that any new UI elements use theme colors. [UI Components Style guidelines](https://github.com/getodk/collect/blob/master/docs/CODE-GUIDELINES.md#ui-components-style-guidelines)


Linked issue #6231: Locally created entities removed from the server should eventually be removed locally
~~Blocked by [Open Rosa spec](https://forum.getodk.org/t/openrosa-spec-proposal-support-offline-entities/48052)~~

Currently, entities that are deleted on the server, but that Collect has never seen in a server entity list will exist locally forever.

## Acceptance

- [ ] Given I've created an entity locally
And submitted the form that created it
When the entity is deleted on the server
And the entity list is then updated
Then the entity does not still appear in follow-up forms
And the entity does not still appear in "View local entities"

- [ ] Collect should limit the number of UUIDs included when constructing the request to `integrityUrl` so that the request is 2000 characters or fewer (to avoid problems with URL length limits)
Linked issue comments: user: login: seadowg
created_at: 2024-09-03T12:02:24Z
body: @lognaturel I'm moving this to v2024.4 for the moment as we've discussed that it should be ok to release it later.
author_association: MEMBER
user: login: seadowg
created_at: 2025-02-10T10:10:26Z
body: @lognaturel I'm removing "blocked" here assuming that the spec proposal has now been accepted.
author_association: MEMBER

Diff:
diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/EntityFormTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/EntityFormTest.kt
index aaf379daaa0..907b1577a79 100644
--- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/EntityFormTest.kt
+++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/EntityFormTest.kt
@@ -143,12 +143,12 @@ class EntityFormTest {
         testDependencies.server.apply {
             addForm(
                 "one-question-entity-update.xml",
-                listOf(EntityListItem("people.csv"))
+                listOf(EntityListItem("people.csv", "people.csv", 1))
             )
 
             addForm(
                 "one-question-entity-follow-up.xml",
-                listOf(EntityListItem("people.csv", "updated-people.csv"))
+                listOf(EntityListItem("people.csv", "updated-people.csv", 2))
             )
         }
 
@@ -232,4 +232,29 @@ class EntityFormTest {
             .pressBackAndDiscardForm()
             .startBlankForm("One Question Entity Registration")
     }
+
+    @Test
+    fun aLocallyCreatedEntity_thatIsDeletedOnTheServer_isNotAvailableToFollowUpForms() {
+        testDependencies.server.addForm("one-question-entity-registration-id.xml")
+        testDependencies.server.addForm(
+            "one-question-entity-update.xml",
+            listOf(EntityListItem("people.csv"))
+        )
+
+        rule.withMatchExactlyProject(testDependencies.server.url)
+            .startBlankForm("One Question Entity Registration")
+            .fillOutAndFinalize(FormEntryPage.QuestionAndAnswer("Name", "Logan Roy"))
+
+            .also {
+                testDependencies.server.deleteEntity("people.csv", "Logan Roy")
+            }
+
+            .clickFillBlankForm()
+            .clickRefresh()
+
+            .clickOnForm("One Question Entity Update")
+            .assertQuestion("Select person")
+            .assertText("Roman Roy")
+            .assertTextDoesNotExist("Logan Roy")
+    }
 }
diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/StubOpenRosaServer.java b/collect_app/src/androidTest/java/org/odk/collect/android/support/StubOpenRosaServer.java
index 56b746868b5..74ab4c77702 100644
--- a/collect_app/src/androidTest/java/org/odk/collect/android/support/StubOpenRosaServer.java
+++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/StubOpenRosaServer.java
@@ -31,6 +31,7 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.UUID;
 import java.util.stream.Collectors;
@@ -52,6 +53,7 @@ public class StubOpenRosaServer implements OpenRosaHttpInterface {
      * A list of submitted forms, maintained in the original order of submission, with the oldest forms appearing first.
      */
     private final List<File> submittedForms = new ArrayList<>();
+    private List<String> deletedEntities = new ArrayList<>();
 
     @NonNull
     @Override
@@ -82,6 +84,8 @@ public HttpGetResult executeGetRequest(@NonNull URI uri, @Nullable String conten
             }
         } else if (uri.getPath().startsWith("/mediaFile")) {
             return new HttpGetResult(getMediaFile(uri), new HashMap<>(), "", 200);
+        } else if (uri.getPath().startsWith("/integrityUrl")) {
+            return new HttpGetResult(getIntegrityResponse(uri), getStandardHeaders(), "", 200);
         } else {
             return new HttpGetResult(null, new HashMap<>(), "", 404);
         }
@@ -285,13 +289,20 @@ private InputStream getManifestResponse(@NonNull URI uri) throws IOException {
 
                 if (noHashPrefixInMediaFiles) {
                     stringBuilder.append("<hash>" + mediaFileHash + " </hash>\n");
+                } else if (mediaFile instanceof EntityListItem) {
+                    stringBuilder.append("<hash>md5:" + ((EntityListItem) mediaFile).getVersion() + " </hash>\n");
                 } else {
                     stringBuilder.append("<hash>md5:" + mediaFileHash + " </hash>\n");
                 }
 
                 stringBuilder
-                        .append("<downloadUrl>" + getURL() + "/mediaFile/" + formID + "/" + mediaFile.getId() + "</downloadUrl>\n")
-                        .append("</mediaFile>\n");
+                        .append("<downloadUrl>" + getURL() + "/mediaFile/" + formID + "/" + mediaFile.getId() + "</downloadUrl>\n");
+
+                if (mediaFile instanceof EntityListItem) {
+                    stringBuilder.append("<integrityUrl>" + getURL() + "/integrityUrl</integrityUrl>\n");
+                }
+
+                 stringBuilder.append("</mediaFile>\n");
             }
 
             stringBuilder.append("</manifest>");
@@ -299,6 +310,30 @@ private InputStream getManifestResponse(@NonNull URI uri) throws IOException {
         }
     }
 
+    @SuppressWarnings("PMD.ConsecutiveLiteralAppends")
+    private InputStream getIntegrityResponse(URI uri) {
+        String[] ids = uri.getQuery().split("=")[1].split(",");
+
+        StringBuilder stringBuilder = new StringBuilder();
+        stringBuilder
+                .append("<?xml version='1.0' encoding='UTF-8' ?>\n")
+                .append("<data>\n")
+                .append("<entities>\n");
+
+        for (String id : ids) {
+            stringBuilder
+                    .append("<entity id=\"" + id + "\">\n")
+                    .append("<deleted>" + deletedEntities.contains(id) + "</deleted>\n")
+                    .append("</entity>\n");
+        }
+
+        stringBuilder
+                .append("</entities>\n")
+                .append("</data>\n");
+
+        return new ByteArrayInputStream(stringBuilder.toString().getBytes());
+    }
+
     @NotNull
     private InputStream getFormXML(String formID) throws IOException {
         String xmlPath = forms.get(Integer.parseInt(formID)).getFormXML();
@@ -319,6 +354,20 @@ private void addFormFromInputStream(String formXML, List<MediaFileItem> mediaFil
         forms.add(new XFormItem(formMetadata.getTitle(), formXML, formMetadata.getId(), formMetadata.getVersion(), mediaFiles));
     }
 
+    public void deleteEntity(String list, @NotNull String id) {
+        deletedEntities.add(id);
+
+        for (XFormItem form : forms) {
+            Optional<MediaFileItem> entityList = form.getMediaFiles().stream().filter(mediaFileItem -> {
+                return mediaFileItem instanceof EntityListItem && mediaFileItem.getName().equals(list);
+            }).findFirst();
+
+            if (entityList.isPresent()) {
+                ((EntityListItem) entityList.get()).incrementVersion();
+            }
+        }
+    }
+
     private static class XFormItem {
 
         private final String formLabel;
@@ -399,13 +448,25 @@ public String getId() {
     }
 
     public static class EntityListItem extends MediaFileItem {
-        public EntityListItem(String name, String file) {
-            super(name, file, name);
+
+        private int version;
+
+        public EntityListItem(String name, String file, int version) {
+            super(name, file);
+            this.version = version;
         }
 
         public EntityListItem(String name) {
             super(name, name, name);
         }
+
+        public int getVersion() {
+            return version;
+        }
+
+        public void incrementVersion() {
+            this.version++;
+        }
     }
 
     private static class MapHeaders implements CaseInsensitiveHeaders {
diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/FormsDataService.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/FormsDataService.kt
index 6a8e08662db..a133ec14c00 100644
--- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/FormsDataService.kt
+++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/FormsDataService.kt
@@ -225,7 +225,8 @@ private fun formDownloader(
         projectDependencyModule.formsDir,
         FormMetadataParser,
         clock,
-        projectDependencyModule.entitiesRepository
+        projectDependencyModule.entitiesRepository,
+        projectDependencyModule.entitySource
     )
 }
 
diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/FormSourceProvider.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/OpenRosaClientProvider.kt
similarity index 74%
rename from collect_app/src/main/java/org/odk/collect/android/formmanagement/FormSourceProvider.kt
rename to collect_app/src/main/java/org/odk/collect/android/formmanagement/OpenRosaClientProvider.kt
index a48317a965c..4ebc0ef264e 100644
--- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/FormSourceProvider.kt
+++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/OpenRosaClientProvider.kt
@@ -1,20 +1,19 @@
 package org.odk.collect.android.formmanagement
 
 import org.odk.collect.android.utilities.WebCredentialsUtils
-import org.odk.collect.forms.FormSource
 import org.odk.collect.openrosa.forms.OpenRosaClient
 import org.odk.collect.openrosa.http.OpenRosaHttpInterface
-import org.odk.collect.openrosa.parse.OpenRosaResponseParserImpl
+import org.odk.collect.openrosa.parse.Kxml2OpenRosaResponseParser
 import org.odk.collect.projects.ProjectDependencyFactory
 import org.odk.collect.settings.keys.ProjectKeys
 import org.odk.collect.shared.settings.Settings
 
-class FormSourceProvider(
+class OpenRosaClientProvider(
     private val settingsFactory: ProjectDependencyFactory<Settings>,
     private val openRosaHttpInterface: OpenRosaHttpInterface
-) : ProjectDependencyFactory<FormSource> {
+) {
 
-    override fun create(projectId: String): FormSource {
+    fun create(projectId: String): OpenRosaClient {
         val settings = settingsFactory.create(projectId)
         val serverURL = settings.getString(ProjectKeys.KEY_SERVER_URL)!!
 
@@ -22,7 +21,7 @@ class FormSourceProvider(
             serverURL,
             openRosaHttpInterface,
             WebCredentialsUtils(settings),
-            OpenRosaResponseParserImpl()
+            Kxml2OpenRosaResponseParser
         )
     }
 }
diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/ServerFormUseCases.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/ServerFormUseCases.kt
index a9e37321f32..0bda206e745 100644
--- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/ServerFormUseCases.kt
+++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/ServerFormUseCases.kt
@@ -6,6 +6,7 @@ import org.odk.collect.android.formmanagement.download.FormDownloader
 import org.odk.collect.android.utilities.FileUtils
 import org.odk.collect.async.OngoingWorkListener
 import org.odk.collect.entities.LocalEntityUseCases
+import org.odk.collect.entities.server.EntitySource
 import org.odk.collect.entities.storage.EntitiesRepository
 import org.odk.collect.forms.Form
 import org.odk.collect.forms.FormSource
@@ -74,6 +75,7 @@ object ServerFormUseCases {
         tempMediaPath: String,
         tempDir: File,
         entitiesRepository: EntitiesRepository,
+        entitySource: EntitySource,
         stateListener: OngoingWorkListener
     ): MediaFilesDownloadResult {
         var newAttachmentsDownloaded = false
@@ -115,7 +117,14 @@ object ServerFormUseCases {
                  */
                 try {
                     val entityListName = getEntityListFromFileName(mediaFile)
-                    LocalEntityUseCases.updateLocalEntitiesFromServer(entityListName, tempMediaFile, entitiesRepository)
+                    LocalEntityUseCases.updateLocalEntitiesFromServer(
+                        entityListName,
+                        tempMediaFile,
+                        entitiesRepository,
+                        entitySource,
+                        mediaFile.hash,
+                        mediaFile.integrityUrl
+                    )
                     entitiesDownloaded = true
                 } catch (t: Throwable) {
                     throw EntityListUpdateException(t)
diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/download/ServerFormDownloader.java b/collect_app/src/main/java/org/odk/collect/android/formmanagement/download/ServerFormDownloader.java
index 3429f54b9c6..6a015e39d2c 100644
--- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/download/ServerFormDownloader.java
+++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/download/ServerFormDownloader.java
@@ -12,6 +12,7 @@
 import org.odk.collect.android.utilities.FormNameUtils;
 import org.odk.collect.androidshared.utils.Validator;
 import org.odk.collect.async.OngoingWorkListener;
+import org.odk.collect.entities.server.EntitySource;
 import org.odk.collect.entities.storage.EntitiesRepository;
 import org.odk.collect.forms.Form;
 import org.odk.collect.forms.FormSource;
@@ -41,8 +42,9 @@ public class ServerFormDownloader implements FormDownloader {
     private final FormMetadataParser formMetadataParser;
     private final Supplier<Long> clock;
     private final EntitiesRepository entitiesRepository;
+    private final EntitySource entitySource;
 
-    public ServerFormDownloader(FormSource formSource, FormsRepository formsRepository, File cacheDir, String formsDirPath, FormMetadataParser formMetadataParser, Supplier<Long> clock, EntitiesRepository entitiesRepository) {
+    public ServerFormDownloader(FormSource formSource, FormsRepository formsRepository, File cacheDir, String formsDirPath, FormMetadataParser formMetadataParser, Supplier<Long> clock, EntitiesRepository entitiesRepository, EntitySource entitySource) {
         this.formSource = formSource;
         this.cacheDir = cacheDir;
         this.formsDirPath = formsDirPath;
@@ -50,6 +52,7 @@ public ServerFormDownloader(FormSource formSource, FormsRepository formsReposito
         this.formMetadataParser = formMetadataParser;
         this.clock = clock;
         this.entitiesRepository = entitiesRepository;
+        this.entitySource = entitySource;
     }
 
     @Override
@@ -99,7 +102,7 @@ private void processOneForm(ServerFormDetails fd, OngoingWorkListener stateListe
 
             // download media files if there are any
             if (fd.getManifest() != null && !fd.getManifest().getMediaFiles().isEmpty()) {
-                mediaFilesDownloadResult = ServerFormUseCases.downloadMediaFiles(fd, formSource, formsRepository, tempMediaPath, tempDir, entitiesRepository, stateListener);
+                mediaFilesDownloadResult = ServerFormUseCases.downloadMediaFiles(fd, formSource, formsRepository, tempMediaPath, tempDir, entitiesRepository, entitySource, stateListener);
             } else {
                 mediaFilesDownloadResult = new MediaFilesDownloadResult(false, false);
             }
diff --git a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyComponent.java b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyComponent.java
index fa57f434bb2..6c6f47f178e 100644
--- a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyComponent.java
+++ b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyComponent.java
@@ -34,7 +34,7 @@
 import org.odk.collect.android.formentry.saving.SaveFormProgressDialogFragment;
 import org.odk.collect.android.formhierarchy.FormHierarchyFragmentHostActivity;
 import org.odk.collect.android.formlists.blankformlist.BlankFormListActivity;
-import org.odk.collect.android.formmanagement.FormSourceProvider;
+import org.odk.collect.android.formmanagement.OpenRosaClientProvider;
 import org.odk.collect.android.formmanagement.FormsDataService;
 import org.odk.collect.android.fragments.BarCodeScannerFragment;
 import org.odk.collect.android.fragments.dialogs.FormsDownloadResultDialog;
@@ -283,7 +283,7 @@ interface Builder {
 
     SavepointsRepositoryProvider savepointsRepositoryProvider();
 
-    FormSourceProvider formSourceProvider();
+    OpenRosaClientProvider formSourceProvider();
 
     ExistingProjectMigrator existingProjectMigrator();
 
diff --git a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java
index 96173c6f049..7a57e305158 100644
--- a/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java
+++ b/collect_app/src/main/java/org/odk/collect/android/injection/config/AppDependencyModule.java
@@ -47,7 +47,7 @@
 import org.odk.collect.android.formentry.FormSessionRepository;
 import org.odk.collect.android.formlists.blankformlist.BlankFormListViewModel;
 import org.odk.collect.android.formmanagement.CollectFormEntryControllerFactory;
-import org.odk.collect.android.formmanagement.FormSourceProvider;
+import org.odk.collect.android.formmanagement.OpenRosaClientProvider;
 import org.odk.collect.android.formmanagement.FormsDataService;
 import org.odk.collect.android.formmanagement.ServerFormsDetailsFetcher;
 import org.odk.collect.android.geo.MapConfiguratorProvider;
@@ -324,7 +324,7 @@ public QRCodeDecoder providesQRCodeDecoder() {
     }
 
     @Provides
-    public ServerFormsDetailsFetcher providesServerFormDetailsFetcher(FormsRepositoryProvider formsRepositoryProvider, FormSourceProvider formSourceProvider, ProjectsDataService projectsDataService) {
+    public ServerFormsDetailsFetcher providesServerFormDetailsFetcher(FormsRepositoryProvider formsRepositoryProvider, OpenRosaClientProvider formSourceProvider, ProjectsDataService projectsDataService) {
         Project.Saved currentProject = projectsDataService.requireCurrentProject();
         FormsRepository formsRepository = formsRepositoryProvider.create(currentProject.getUuid());
         return new ServerFormsDetailsFetcher(formsRepository, formSourceProvider.create(currentProject.getUuid()));
@@ -476,8 +476,8 @@ public AnalyticsInitializer providesAnalyticsInitializer(Analytics analytics, Ve
     }
 
     @Provides
-    public FormSourceProvider providesFormSourceProvider(SettingsProvider settingsProvider, OpenRosaHttpInterface openRosaHttpInterface) {
-        return new FormSourceProvider(settingsProvider::getUnprotectedSettings, openRosaHttpInterface);
+    public OpenRosaClientProvider providesFormSourceProvider(SettingsProvider settingsProvider, OpenRosaHttpInterface openRosaHttpInterface) {
+        return new OpenRosaClientProvider(settingsProvider::getUnprotectedSettings, openRosaHttpInterface);
     }
 
     @Provides
diff --git a/collect_app/src/main/java/org/odk/collect/android/injection/config/ProjectDependencyModuleFactory.kt b/collect_app/src/main/java/org/odk/collect/android/injection/config/ProjectDependencyModuleFactory.kt
index 4eb708037b9..1357a1f7eef 100644
--- a/collect_app/src/main/java/org/odk/collect/android/injection/config/ProjectDependencyModuleFactory.kt
+++ b/collect_app/src/main/java/org/odk/collect/android/injection/config/ProjectDependencyModuleFactory.kt
@@ -1,7 +1,7 @@
 package org.odk.collect.android.injection.config
 
 import org.odk.collect.android.entities.EntitiesRepositoryProvider
-import org.odk.collect.android.formmanagement.FormSourceProvider
+import org.odk.collect.android.formmanagement.OpenRosaClientProvider
 import org.odk.collect.android.projects.ProjectDependencyModule
 import org.odk.collect.android.storage.StoragePathProvider
 import org.odk.collect.android.utilities.ChangeLockProvider
@@ -18,7 +18,7 @@ class ProjectDependencyModuleFactory @Inject constructor(
     private val instancesRepositoryProvider: InstancesRepositoryProvider,
     private val storagePathProvider: StoragePathProvider,
     private val changeLockProvider: ChangeLockProvider,
-    private val formSourceProvider: FormSourceProvider,
+    private val openRosaClientProvider: OpenRosaClientProvider,
     private val savepointsRepositoryProvider: SavepointsRepositoryProvider,
     private val entitiesRepositoryProvider: EntitiesRepositoryProvider,
 ) : ProjectDependencyFactory<ProjectDependencyModule> {
@@ -30,9 +30,10 @@ class ProjectDependencyModuleFactory @Inject constructor(
             instancesRepositoryProvider,
             storagePathProvider,
             changeLockProvider,
-            formSourceProvider,
+            { openRosaClientProvider.create(projectId) },
             savepointsRepositoryProvider,
-            entitiesRepositoryProvider
+            entitiesRepositoryProvider,
+            { openRosaClientProvider.create(projectId) }
         )
     }
 }
diff --git a/collect_app/src/main/java/org/odk/collect/android/projects/ProjectDependencyModule.kt b/collect_app/src/main/java/org/odk/collect/android/projects/ProjectDependencyModule.kt
index c1b62646eb8..e0ef9cbba66 100644
--- a/collect_app/src/main/java/org/odk/collect/android/projects/ProjectDependencyModule.kt
+++ b/collect_app/src/main/java/org/odk/collect/android/projects/ProjectDependencyModule.kt
@@ -2,6 +2,7 @@ package org.odk.collect.android.projects
 
 import org.odk.collect.android.storage.StoragePaths
 import org.odk.collect.android.utilities.ChangeLocks
+import org.odk.collect.entities.server.EntitySource
 import org.odk.collect.entities.storage.EntitiesRepository
 import org.odk.collect.forms.FormSource
 import org.odk.collect.forms.FormsRepository
@@ -23,7 +24,8 @@ data class ProjectDependencyModule(
     private val changeLockFactory: ProjectDependencyFactory<ChangeLocks>,
     private val formSourceFactory: ProjectDependencyFactory<FormSource>,
     private val savepointsRepositoryFactory: ProjectDependencyFactory<SavepointsRepository>,
-    private val entitiesRepositoryFactory: ProjectDependencyFactory<EntitiesRepository>
+    private val entitiesRepositoryFactory: ProjectDependencyFactory<EntitiesRepository>,
+    private val entitySourceFactory: ProjectDependencyFactory<EntitySource>
 ) {
     val generalSettings by lazy { settingsFactory.create(projectId) }
     val formsRepository by lazy { formsRepositoryFactory.create(projectId) }
@@ -37,4 +39,5 @@ data class ProjectDependencyModule(
     val savepointsRepository by lazy { savepointsRepositoryFactory.create(projectId) }
     val rootDir by lazy { storagePathsFactory.create(projectId).rootDir }
     val instancesDir by lazy { storagePathsFactory.create(projectId).instancesDir }
+    val entitySource by lazy { entitySourceFactory.create(projectId) }
 }
diff --git a/collect_app/src/test/java/org/odk/collect/android/formmanagement/FormsDataServiceTest.kt b/collect_app/src/test/java/org/odk/collect/android/formmanagement/FormsDataServiceTest.kt
index 0eb845255ff..ecbdfc91c74 100644
--- a/collect_app/src/test/java/org/odk/collect/android/formmanagement/FormsDataServiceTest.kt
+++ b/collect_app/src/test/java/org/odk/collect/android/formmanagement/FormsDataServiceTest.kt
@@ -11,7 +11,6 @@ import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.mockito.Mockito.verifyNoInteractions
-import org.mockito.kotlin.any
 import org.mockito.kotlin.doAnswer
 import org.mockito.kotlin.doReturn
 import org.mockito.kotlin.mock
@@ -21,6 +20,7 @@ import org.odk.collect.analytics.Analytics
 import org.odk.collect.android.injection.DaggerUtils
 import org.odk.collect.android.injection.config.ProjectDependencyModuleFactory
 import org.odk.collect.android.notifications.Notifier
+import org.odk.collect.android.projects.ProjectDependencyModule
 import org.odk.collect.android.storage.StorageSubdirectory
 import org.odk.collect.android.utilities.ChangeLockProvider
 import org.odk.collect.androidshared.data.AppState
@@ -64,18 +64,18 @@ class FormsDataServiceTest {
     fun setup() {
         project = setupProject()
 
-        val formSourceProvider = mock<FormSourceProvider> { on { create(any()) } doReturn formSource }
-
-        val projectDependencyModule = ProjectDependencyModuleFactory(
-            settingsProvider,
+        val projectDependencyModule = ProjectDependencyModule(
+            project.uuid,
+            { settingsProvider.getUnprotectedSettings(project.uuid) },
             formsRepositoryProvider,
             mock(),
             storagePathProvider,
             changeLockProvider,
-            formSourceProvider,
+            { formSource },
+            mock(),
             mock(),
             mock()
-        ).create(project.uuid)
+        )
 
         val projectDependencyModuleFactory = mock<ProjectDependencyModuleFactory>()
         whenever(projectDependencyModuleFactory.create(project.uuid)).thenReturn(projectDependencyModule)
diff --git a/collect_app/src/test/java/org/odk/collect/android/formmanagement/ServerFormUseCasesTest.kt b/collect_app/src/test/java/org/odk/collect/android/formmanagement/ServerFormUseCasesTest.kt
index 67f6830bf60..8d2ffadd9bf 100644
--- a/collect_app/src/test/java/org/odk/collect/android/formmanagement/ServerFormUseCasesTest.kt
+++ b/collect_app/src/test/java/org/odk/collect/android/formmanagement/ServerFormUseCasesTest.kt
@@ -176,6 +176,7 @@ class ServerFormUseCasesTest {
             File(TempFiles.createTempDir(), "temp").absolutePath,
             TempFiles.createTempDir(),
             InMemEntitiesRepository(),
+            mock(),
             mock()
         )
 
@@ -219,6 +220,7 @@ class ServerFormUseCasesTest {
             File(TempFiles.createTempDir(), "temp").absolutePath,
             TempFiles.createTempDir(),
             InMemEntitiesRepository(),
+            mock(),
             mock()
         )
 
diff --git a/collect_app/src/test/java/org/odk/collect/android/formmanagement/download/ServerFormDownloaderTest.java b/collect_app/src/test/java/org/odk/collect/android/formmanagement/download/ServerFormDownloaderTest.java
index d83730bcc24..7179c125508 100644
--- a/collect_app/src/test/java/org/odk/collect/android/formmanagement/download/ServerFormDownloaderTest.java
+++ b/collect_app/src/test/java/org/odk/collect/android/formmanagement/download/ServerFormDownloaderTest.java
@@ -21,6 +21,7 @@
 import org.junit.Test;
 import org.odk.collect.android.formmanagement.metadata.FormMetadataParser;
 import org.odk.collect.android.formmanagement.ServerFormDetails;
+import org.odk.collect.entities.server.EntitySource;
 import org.odk.collect.entities.storage.EntitiesRepository;
 import org.odk.collect.entities.storage.InMemEntitiesRepository;
 import org.odk.collect.forms.Form;
@@ -51,6 +52,7 @@ public class ServerFormDownloaderTest {
     private final Supplier<Long> clock = () -> 123L;
 
     private final EntitiesRepository entitiesRepository = new InMemEntitiesRepository();
+    private final EntitySource entitySource = mock();
 
     @Test
     public void downloadsAndSavesForm() throws Exception {
@@ -68,7 +70,7 @@ public void downloadsAndSavesForm() throws Exception {
         FormSource formSource = mock(FormSource.class);
         when(formSource.fetchForm("http://downloadUrl")).thenReturn(new ByteArrayInputStream(xform.getBytes()));
 
-        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
         downloader.downloadForm(serverFormDetails, null, null);
 
         List<Form> allForms = formsRepository.getAll();
@@ -97,7 +99,7 @@ public void whenFormToDownloadIsUpdate_savesNewVersionAlongsideOldVersion() thro
         FormSource formSource = mock(FormSource.class);
         when(formSource.fetchForm("http://downloadUrl")).thenReturn(new ByteArrayInputStream(xform.getBytes()));
 
-        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
         downloader.downloadForm(serverFormDetails, null, null);
 
         String xformUpdate = createXFormBody("id", "updated");
@@ -141,7 +143,7 @@ public void whenFormToDownloadIsUpdate_withSameFormIdAndVersion_replacePreExisti
         when(formSource.fetchMediaFile("http://file1")).thenReturn(new ByteArrayInputStream("contents1".getBytes()));
         when(formSource.fetchForm("http://downloadUrl")).thenReturn(new ByteArrayInputStream(xform.getBytes()));
 
-        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
         downloader.downloadForm(serverFormDetails, null, null);
 
         List<Form> formsBeforeUpdate = formsRepository.getAllByFormIdAndVersion("id", "version");
@@ -194,7 +196,7 @@ public void whenFormListMissingHash_throwsError() throws Exception {
         FormSource formSource = mock(FormSource.class);
         when(formSource.fetchForm("http://downloadUrl")).thenReturn(new ByteArrayInputStream(xform.getBytes()));
 
-        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
         try {
             downloader.downloadForm(serverFormDetails, null, null);
             fail("Expected exception because of missing form hash");
@@ -224,7 +226,7 @@ public void whenFormHasMediaFiles_downloadsAndSavesFormAndMediaFiles() throws Ex
         when(formSource.fetchMediaFile("http://file1")).thenReturn(new ByteArrayInputStream("contents1".getBytes()));
         when(formSource.fetchMediaFile("http://file2")).thenReturn(new ByteArrayInputStream("contents2".getBytes()));
 
-        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
         downloader.downloadForm(serverFormDetails, null, null);
 
         List<Form> allForms = formsRepository.getAll();
@@ -266,7 +268,7 @@ public void whenFormHasMediaFiles_andIsFormToDownloadIsUpdate_doesNotRedownloadM
         when(formSource.fetchMediaFile("http://file1")).thenReturn(new ByteArrayInputStream("contents1".getBytes()));
         when(formSource.fetchMediaFile("http://file2")).thenReturn(new ByteArrayInputStream("contents2".getBytes()));
 
-        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
         downloader.downloadForm(serverFormDetails, null, null);
 
         String xformUpdate = createXFormBody("id", "updated");
@@ -311,7 +313,7 @@ public void whenFormHasMediaFiles_andIsFormToDownloadIsUpdate_downloadsFilesWith
         when(formSource.fetchMediaFile("http://file1")).thenReturn(new ByteArrayInputStream("contents1".getBytes()));
         when(formSource.fetchMediaFile("http://file2")).thenReturn(new ByteArrayInputStream("contents2".getBytes()));
 
-        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
         downloader.downloadForm(serverFormDetails, null, null);
 
         String xformUpdate = createXFormBody("id", "updated");
@@ -357,7 +359,7 @@ public void whenFormHasMediaFiles_andFetchingMediaFileFails_throwsFetchErrorAndD
         when(formSource.fetchForm("http://downloadUrl")).thenReturn(new ByteArrayInputStream(xform.getBytes()));
         when(formSource.fetchMediaFile("http://file1")).thenThrow(new FormSourceException.FetchError());
 
-        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
 
         try {
             downloader.downloadForm(serverFormDetails, null, null);
@@ -391,7 +393,7 @@ public void whenFormHasMediaFiles_andFileExistsInMediaDirPath_throwsDiskExceptio
         // Create file where media dir would go
         assertThat(new File(formsDir, "Form-media").createNewFile(), is(true));
 
-        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
 
         try {
             downloader.downloadForm(serverFormDetails, null, null);
@@ -424,7 +426,7 @@ public void beforeDownloadingEachMediaFile_reportsProgress() throws Exception {
         when(formSource.fetchMediaFile("http://file1")).thenReturn(new ByteArrayInputStream("contents".getBytes()));
         when(formSource.fetchMediaFile("http://file2")).thenReturn(new ByteArrayInputStream("contents".getBytes()));
 
-        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
         RecordingProgressReporter progressReporter = new RecordingProgressReporter();
         downloader.downloadForm(serverFormDetails, progressReporter, null);
 
@@ -453,7 +455,7 @@ public void whenFormIsSoftDeleted_unDeletesForm() throws Exception {
         FormSource formSource = mock(FormSource.class);
         when(formSource.fetchForm("http://downloadUrl")).thenReturn(new ByteArrayInputStream(xform.getBytes()));
 
-        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
         downloader.downloadForm(serverFormDetails, null, null);
         assertThat(formsRepository.get(1L).isDeleted(), is(false));
     }
@@ -485,7 +487,7 @@ public void whenMultipleFormsWithSameFormIdVersionDeleted_reDownloadUnDeletesFor
         FormSource formSource = mock(FormSource.class);
         when(formSource.fetchForm("http://downloadUrl")).thenReturn(new ByteArrayInputStream(xform2.getBytes()));
 
-        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
         downloader.downloadForm(serverFormDetails, null, null);
         assertThat(formsRepository.get(1L).isDeleted(), is(true));
         assertThat(formsRepository.get(2L).isDeleted(), is(false));
@@ -508,7 +510,7 @@ public void whenFormAlreadyDownloaded_formRemainsOnDevice() throws Exception {
         FormSource formSource = mock(FormSource.class);
         when(formSource.fetchForm("http://downloadUrl")).thenReturn(new ByteArrayInputStream(xform.getBytes()));
 
-        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
 
         // Initial download
         downloader.downloadForm(serverFormDetails, null, null);
@@ -553,7 +555,7 @@ public void whenFormAlreadyDownloaded_andFormHasNewMediaFiles_updatesMediaFilesA
         when(formSource.fetchForm("http://downloadUrl")).thenReturn(new ByteArrayInputStream(xform.getBytes()));
         when(formSource.fetchMediaFile("http://file1")).thenReturn(new ByteArrayInputStream("contents".getBytes()));
 
-        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
 
         // Initial download
         downloader.downloadForm(serverFormDetails, null, null);
@@ -616,7 +618,7 @@ public void whenFormAlreadyDownloaded_andFormHasNewMediaFiles_andMediaFetchFails
         when(formSource.fetchForm("http://downloadUrl")).thenReturn(new ByteArrayInputStream(xform.getBytes()));
         when(formSource.fetchMediaFile("http://file1")).thenReturn(new ByteArrayInputStream("contents".getBytes()));
 
-        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
 
         // Initial download
         downloader.downloadForm(serverFormDetails, null, null);
@@ -665,7 +667,7 @@ public void afterDownloadingXForm_cancelling_throwsDownloadingInterruptedExcepti
                 null);
 
         CancelAfterFormDownloadFormSource formListApi = new CancelAfterFormDownloadFormSource(xform);
-        ServerFormDownloader downloader = new ServerFormDownloader(formListApi, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formListApi, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
 
         try {
             downloader.downloadForm(serverFormDetails, null, formListApi);
@@ -694,7 +696,7 @@ public void afterDownloadingMediaFile_cancelling_throwsDownloadingInterruptedExc
                 )));
 
         CancelAfterMediaFileDownloadFormSource formListApi = new CancelAfterMediaFileDownloadFormSource(xform);
-        ServerFormDownloader downloader = new ServerFormDownloader(formListApi, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formListApi, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
 
         try {
             downloader.downloadForm(serverFormDetails, null, formListApi);
@@ -725,7 +727,7 @@ public void whenFormHasEntityAttachmentsButNoEntityBlock_downloadsAndSavesFormAs
         when(formSource.fetchForm("http://downloadUrl")).thenReturn(new ByteArrayInputStream(xform.getBytes()));
         when(formSource.fetchMediaFile("http://file1")).thenReturn(new ByteArrayInputStream("contents1".getBytes()));
 
-        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
         downloader.downloadForm(serverFormDetails, null, null);
 
         List<Form> allForms = formsRepository.getAll();
@@ -753,7 +755,7 @@ public void whenFormHasEntityAttachmentsAndEntityBlock_downloadsAndSavesFormAsAF
         when(formSource.fetchForm("http://downloadUrl")).thenReturn(new ByteArrayInputStream(xform.getBytes()));
         when(formSource.fetchMediaFile("http://file1")).thenReturn(new ByteArrayInputStream("contents1".getBytes()));
 
-        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
         downloader.downloadForm(serverFormDetails, null, null);
 
         List<Form> allForms = formsRepository.getAll();
@@ -781,7 +783,7 @@ public void whenFormLacksEntityAttachmentsButHasEntityBlock_downloadsAndSavesFor
         when(formSource.fetchForm("http://downloadUrl")).thenReturn(new ByteArrayInputStream(xform.getBytes()));
         when(formSource.fetchMediaFile("http://file1")).thenReturn(new ByteArrayInputStream("contents1".getBytes()));
 
-        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
         downloader.downloadForm(serverFormDetails, null, null);
 
         List<Form> allForms = formsRepository.getAll();
@@ -809,7 +811,7 @@ public void whenFormLacksEntityAttachmentsAndEntityBlock_downloadsAndSavesFormAs
         when(formSource.fetchForm("http://downloadUrl")).thenReturn(new ByteArrayInputStream(xform.getBytes()));
         when(formSource.fetchMediaFile("http://file1")).thenReturn(new ByteArrayInputStream("contents1".getBytes()));
 
-        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
         downloader.downloadForm(serverFormDetails, null, null);
 
         List<Form> allForms = formsRepository.getAll();
@@ -837,7 +839,7 @@ public void whenFormAlreadyDownloadedDoesNotHaveEntities_andOneOfTheAttachmentsB
         when(formSource.fetchForm("http://downloadUrl")).thenReturn(new ByteArrayInputStream(xform.getBytes()));
         when(formSource.fetchMediaFile("http://file1")).thenReturn(new ByteArrayInputStream("contents".getBytes()));
 
-        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository);
+        ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), FormMetadataParser.INSTANCE, clock, entitiesRepository, entitySource);
 
         // Initial download
         downloader.downloadForm(serverFormDetails, null, null);
diff --git a/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstancesDataServiceTest.kt b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstancesDataServiceTest.kt
index d1d2226092e..f9b2b33fcea 100644
--- a/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstancesDataServiceTest.kt
+++ b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstancesDataServiceTest.kt
@@ -48,6 +48,7 @@ class InstancesDataServiceTest {
             { ChangeLocks(BooleanChangeLock(), BooleanChangeLock()) },
             mock(),
             mock(),
+            mock(),
             mock()
         )
     }
diff --git a/entities/src/main/java/org/odk/collect/entities/LocalEntityUseCases.kt b/entities/src/main/java/org/odk/collect/entities/LocalEntityUseCases.kt
index 0eb54d3fe3e..c7d3e1327ef 100644
--- a/entities/src/main/java/org/odk/collect/entities/LocalEntityUseCases.kt
+++ b/entities/src/main/java/org/odk/collect/entities/LocalEntityUseCases.kt
@@ -5,10 +5,10 @@ import org.javarosa.core.model.instance.SecondaryInstanceCSVParserBuilder
 import org.odk.collect.entities.javarosa.finalization.EntitiesExtra
 import org.odk.collect.entities.javarosa.parse.EntitySchema
 import org.odk.collect.entities.javarosa.spec.EntityAction
+import org.odk.collect.entities.server.EntitySource
 import org.odk.collect.entities.storage.EntitiesRepository
 import org.odk.collect.entities.storage.Entity
 import org.odk.collect.shared.Query
-import org.odk.collect.shared.strings.Md5.getMd5Hash
 import java.io.File
 import java.util.UUID
 
@@ -62,11 +62,14 @@ object LocalEntityUseCases {
     fun updateLocalEntitiesFromServer(
         list: String,
         serverList: File,
-        entitiesRepository: EntitiesRepository
+        entitiesRepository: EntitiesRepository,
+        entitySource: EntitySource,
+        serverListHash: String,
+        integrityUrl: String?
     ) {
-        val listHash = getListHash(serverList)
-        val existingListVersion = entitiesRepository.getListHash(list)
-        if (listHash == existingListVersion) {
+        val newListHash = "server:$serverListHash"
+        val existingListHash = entitiesRepository.getListHash(list)
+        if (newListHash == existingListHash) {
             return
         }
 
@@ -112,18 +115,38 @@ object LocalEntityUseCases {
             }
         }
 
-        missingFromServer.values.forEach {
-            if (it.state == Entity.State.ONLINE) {
-                entitiesRepository.delete(list, it.id)
-            }
-        }
-
+        handleMissingEntities(
+            list,
+            missingFromServer.values,
+            entitiesRepository,
+            entitySource,
+            integrityUrl
+        )
         entitiesRepository.save(list, *newAndUpdated.toTypedArray())
-        entitiesRepository.updateListHash(list, listHash)
+        entitiesRepository.updateListHash(list, newListHash)
     }
 
-    private fun getListHash(serverList: File): String {
-        return "md5:${serverList.getMd5Hash()!!}"
+    private fun handleMissingEntities(
+        list: String,
+        missingFromServer: Collection<Entity.Saved>,
+        entitiesRepository: EntitiesRepository,
+        entitySource: EntitySource,
+        integrityUrl: String?
+    ) {
+        val missingOnline = missingFromServer.filter { it.state == Entity.State.ONLINE }
+        val missingOffline = missingFromServer.filter { it.state == Entity.State.OFFLINE }
+
+        missingOnline.forEach {
+            entitiesRepository.delete(list, it.id)
+        }
+
+        if (integrityUrl != null && missingOffline.isNotEmpty()) {
+            entitySource.fetchDeletedStates(integrityUrl, missingOffline.map { it.id }).forEach {
+                if (it.second) {
+                    entitiesRepository.delete(list, it.first)
+                }
+            }
+        }
     }
 
     private fun parseEntityFromRecord(record: CSVRecord): ServerEntity? {
diff --git a/entities/src/main/java/org/odk/collect/entities/server/EntitySource.kt b/entities/src/main/java/org/odk/collect/entities/server/EntitySource.kt
new file mode 100644
index 00000000000..005b1bf2212
--- /dev/null
+++ b/entities/src/main/java/org/odk/collect/entities/server/EntitySource.kt
@@ -0,0 +1,5 @@
+package org.odk.collect.entities.server
+
+interface EntitySource {
+    fun fetchDeletedStates(integrityUrl: String, ids: List<String>): List<Pair<String, Boolean>>
+}
diff --git a/entities/src/test/java/org/odk/collect/entities/LocalEntityUseCasesTest.kt b/entities/src/test/java/org/odk/collect/entities/LocalEntityUseCasesTest.kt
index 55d94bf984f..cb2121a434a 100644
--- a/entities/src/test/java/org/odk/collect/entities/LocalEntityUseCasesTest.kt
+++ b/entities/src/test/java/org/odk/collect/entities/LocalEntityUseCasesTest.kt
@@ -12,16 +12,19 @@ import org.odk.collect.entities.javarosa.finalization.EntitiesExtra
 import org.odk.collect.entities.javarosa.finalization.FormEntity
 import org.odk.collect.entities.javarosa.parse.EntitySchema
 import org.odk.collect.entities.javarosa.spec.EntityAction
+import org.odk.collect.entities.server.EntitySource
 import org.odk.collect.entities.storage.EntitiesRepository
 import org.odk.collect.entities.storage.Entity
 import org.odk.collect.entities.storage.InMemEntitiesRepository
 import org.odk.collect.shared.Query
 import org.odk.collect.shared.TempFiles
 import java.io.File
+import java.util.UUID
 
 class LocalEntityUseCasesTest {
 
     private val entitiesRepository = InMemEntitiesRepository()
+    private val entitySource = FakeEntitySource()
 
     @Test
     fun `updateLocalEntitiesFromForm saves a new entity on create`() {
@@ -211,7 +214,14 @@ class LocalEntityUseCasesTest {
             )
         )
 
-        LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository)
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            csv,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            null
+        )
         val songs = entitiesRepository.query("songs")
         assertThat(songs.size, equalTo(1))
         assertThat(songs[0].label, equalTo("Noah"))
@@ -228,7 +238,14 @@ class LocalEntityUseCasesTest {
         entitiesRepository.save("songs", offline)
         val csv = createEntityList(Entity.New("noah", "Noah", 2))
 
-        LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository)
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            csv,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            null
+        )
         val songs = entitiesRepository.query("songs")
         assertThat(songs.size, equalTo(1))
         assertThat(songs[0].label, equalTo("Noah"))
@@ -245,7 +262,14 @@ class LocalEntityUseCasesTest {
         entitiesRepository.save("songs", offline)
         val csv = createEntityList(Entity.New("noah", "Noah", 2))
 
-        LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository)
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            csv,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            null
+        )
         val songs = entitiesRepository.query("songs")
         assertThat(songs.size, equalTo(1))
         assertThat(songs[0].label, equalTo("Noah"))
@@ -262,7 +286,14 @@ class LocalEntityUseCasesTest {
         entitiesRepository.save("songs", offline)
         val csv = createEntityList(Entity.New("noah", "Noa", 1))
 
-        LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository)
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            csv,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            null
+        )
         val songs = entitiesRepository.query("songs")
         assertThat(songs.size, equalTo(1))
         assertThat(songs[0].label, equalTo("Noah"))
@@ -278,11 +309,25 @@ class LocalEntityUseCasesTest {
 
         val local = Entity.New("noah", "Noah", 2, properties = listOf("length" to "4:33"))
         val csv1 = createEntityList(local)
-        LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv1, entitiesRepository)
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            csv1,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            null
+        )
         assertThat(entitiesRepository.savedEntities, equalTo(1))
 
         val csv2 = createEntityList(local, Entity.New("perception", "Perception"))
-        LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv2, entitiesRepository)
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            csv2,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            null
+        )
         assertThat(entitiesRepository.savedEntities, equalTo(2))
     }
 
@@ -292,12 +337,26 @@ class LocalEntityUseCasesTest {
         entitiesRepository.save("songs", offline)
 
         val csv1 = createEntityList(Entity.New("noah", "Noah", 2))
-        LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv1, entitiesRepository)
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            csv1,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            null
+        )
 
         val onlineBranched = Entity.New("noah", "Noah", 3)
         entitiesRepository.save("songs", onlineBranched)
         val csv2 = createEntityList(Entity.New("noah", "Noah", 3))
-        LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv2, entitiesRepository)
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            csv2,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            null
+        )
 
         val songs = entitiesRepository.query("songs")
         assertThat(songs.size, equalTo(1))
@@ -321,7 +380,14 @@ class LocalEntityUseCasesTest {
             )
         )
 
-        LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository)
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            csv,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            null
+        )
         val songs = entitiesRepository.query("songs")
         assertThat(songs.size, equalTo(1))
         assertThat(songs[0].label, equalTo("Noa"))
@@ -339,7 +405,14 @@ class LocalEntityUseCasesTest {
         val csv =
             createEntityList(Entity.New("noah", "Noah", 2, listOf(Pair("length", "6:38"))))
 
-        LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository)
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            csv,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            null
+        )
         val songs = entitiesRepository.query("songs")
         assertThat(songs.size, equalTo(1))
         assertThat(songs[0].properties, equalTo(emptyList()))
@@ -354,7 +427,14 @@ class LocalEntityUseCasesTest {
         val csv =
             createEntityList(Entity.New("noah", "Noah", 2, listOf(Pair("length", "4:58"))))
 
-        LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository)
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            csv,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            null
+        )
         val songs = entitiesRepository.query("songs")
         assertThat(songs.size, equalTo(1))
         assertThat(songs[0].version, equalTo(2))
@@ -369,7 +449,14 @@ class LocalEntityUseCasesTest {
                 listOf("grisaille", "Grisaille")
             )
 
-        LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository)
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            csv,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            null
+        )
         assertThat(entitiesRepository.getLists().size, equalTo(0))
     }
 
@@ -381,7 +468,14 @@ class LocalEntityUseCasesTest {
                 listOf("Grisaille", "2")
             )
 
-        LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository)
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            csv,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            null
+        )
         assertThat(entitiesRepository.getLists().size, equalTo(0))
     }
 
@@ -393,7 +487,14 @@ class LocalEntityUseCasesTest {
                 listOf("grisaille", "2")
             )
 
-        LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository)
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            csv,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            null
+        )
         assertThat(entitiesRepository.getLists().size, equalTo(0))
     }
 
@@ -401,7 +502,14 @@ class LocalEntityUseCasesTest {
     fun `updateLocalEntitiesFromServer adds online entity when its label is blank`() {
         val csv = createEntityList(Entity.New("cathedrals", label = ""))
 
-        LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository)
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            csv,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            null
+        )
         val songs = entitiesRepository.query("songs")
         assertThat(songs.size, equalTo(1))
         assertThat(songs[0].label, equalTo(""))
@@ -411,7 +519,14 @@ class LocalEntityUseCasesTest {
     fun `updateLocalEntitiesFromServer does nothing if passed a non-CSV file`() {
         val file = TempFiles.createTempFile(".xml")
 
-        LocalEntityUseCases.updateLocalEntitiesFromServer("songs", file, entitiesRepository)
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            file,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            null
+        )
         assertThat(entitiesRepository.getLists().size, equalTo(0))
     }
 
@@ -420,20 +535,98 @@ class LocalEntityUseCasesTest {
         entitiesRepository.save("songs", Entity.New("noah", "Noah"))
         val csv = createEntityList(Entity.New("cathedrals", "Cathedrals"))
 
-        LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository)
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            csv,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            null
+        )
         val songs = entitiesRepository.query("songs")
         assertThat(songs.size, equalTo(2))
     }
 
+    @Test
+    fun `updateLocalEntitiesFromServer removes offline entities that are not in online entities but are deleted according to the entity source`() {
+        entitiesRepository.save("songs", Entity.New("noah", "Noah"))
+        entitiesRepository.save("songs", Entity.New("midnightCity", "Midnight City"))
+        entitySource.delete("noah")
+        entitySource.delete("midnightCity")
+
+        val csv = createEntityList(Entity.New("cathedrals", "Cathedrals"))
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            csv,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            entitySource.integrityUrl
+        )
+
+        val songs = entitiesRepository.query("songs")
+        assertThat(songs.size, equalTo(1))
+        assertThat(songs.first().id, equalTo("cathedrals"))
+    }
+
+    @Test
+    fun `updateLocalEntitiesFromServer only checks for deletions with the entity source once`() {
+        entitiesRepository.save("songs", Entity.New("noah", "Noah"))
+        entitiesRepository.save("songs", Entity.New("midnightCity", "Midnight City"))
+        entitySource.delete("noah")
+        entitySource.delete("midnightCity")
+
+        val csv = createEntityList()
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            csv,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            entitySource.integrityUrl
+        )
+
+        assertThat(entitySource.accesses, equalTo(1))
+    }
+
+    @Test
+    fun `updateLocalEntitiesFromServer does not check for deletions with the entity source if it does not need to`() {
+        val csv = createEntityList()
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            csv,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            entitySource.integrityUrl
+        )
+
+        assertThat(entitySource.accesses, equalTo(0))
+    }
+
     @Test
     fun `updateLocalEntitiesFromServer removes offline entity that was in online list, but isn't any longer`() {
         entitiesRepository.save("songs", Entity.New("cathedrals", "Cathedrals"))
 
         val firstCsv = createEntityList(Entity.New("cathedrals", "Cathedrals"))
-        LocalEntityUseCases.updateLocalEntitiesFromServer("songs", firstCsv, entitiesRepository)
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            firstCsv,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            null
+        )
 
         val secondCsv = createEntityList(Entity.New("noah", "Noah"))
-        LocalEntityUseCases.updateLocalEntitiesFromServer("songs", secondCsv, entitiesRepository)
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            secondCsv,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            null
+        )
 
         val songs = entitiesRepository.query("songs")
         assertThat(songs.size, equalTo(1))
@@ -446,15 +639,45 @@ class LocalEntityUseCasesTest {
 
         val firstCsv =
             createEntityList(Entity.New("cathedrals", "Cathedrals (A Song)", version = 2))
-        LocalEntityUseCases.updateLocalEntitiesFromServer("songs", firstCsv, entitiesRepository)
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            firstCsv,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            null
+        )
 
         val secondCsv = createEntityList()
-        LocalEntityUseCases.updateLocalEntitiesFromServer("songs", secondCsv, entitiesRepository)
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            secondCsv,
+            entitiesRepository,
+            entitySource,
+            UUID.randomUUID().toString(),
+            null
+        )
 
         val songs = entitiesRepository.query("songs")
         assertThat(songs.isEmpty(), equalTo(true))
     }
 
+    @Test
+    fun `updateLocalEntitiesFromServer updates the list hash with server prefix`() {
+        val csv = createEntityList()
+        LocalEntityUseCases.updateLocalEntitiesFromServer(
+            "songs",
+            csv,
+            entitiesRepository,
+            entitySource,
+            "hash",
+            null
+        )
+
+        val hash = entitiesRepository.getListHash("songs")
+        assertThat(hash, equalTo("server:hash"))
+    }
+
     private fun createEntityList(vararg entities: Entity): File {
         if (entities.isNotEmpty()) {
             val header = listOf(
@@ -552,3 +775,28 @@ private class MeasurableEntitiesRepository(private val wrapped: EntitiesReposito
         return wrapped.getListHash(list)
     }
 }
+
+private class FakeEntitySource : EntitySource {
+
+    val integrityUrl = "http://example.com/${UUID.randomUUID()}"
+    var accesses: Int = 0
+        private set
+
+    private val deleted = mutableListOf<String>()
+
+    override fun fetchDeletedStates(integrityUrl: String, ids: List<String>): List<Pair<String, Boolean>> {
+        accesses += 1
+
+        if (integrityUrl == this.integrityUrl) {
+            return ids.map {
+                Pair(it, deleted.contains(it))
+            }
+        } else {
+            throw IllegalArgumentException()
+        }
+    }
+
+    fun delete(id: String) {
+        deleted.add(id)
+    }
+}
diff --git a/forms/src/main/java/org/odk/collect/forms/MediaFile.kt b/forms/src/main/java/org/odk/collect/forms/MediaFile.kt
index 7b7e012bb11..ed4b4aa5828 100644
--- a/forms/src/main/java/org/odk/collect/forms/MediaFile.kt
+++ b/forms/src/main/java/org/odk/collect/forms/MediaFile.kt
@@ -21,5 +21,6 @@ data class MediaFile @JvmOverloads constructor(
     val filename: String,
     val hash: String,
     val downloadUrl: String,
-    val isEntityList: Boolean = false
+    val isEntityList: Boolean = false,
+    val integrityUrl: String? = null
 ) : Serializable
diff --git a/open-rosa/build.gradle.kts b/open-rosa/build.gradle.kts
index 790bac9bde7..d6e3ac79c31 100644
--- a/open-rosa/build.gradle.kts
+++ b/open-rosa/build.gradle.kts
@@ -42,6 +42,7 @@ android {
 }
 
 dependencies {
+    implementation(project(":entities"))
     coreLibraryDesugaring(libs.desugar)
 
     implementation(project(":shared"))
diff --git a/open-rosa/src/main/java/org/odk/collect/openrosa/forms/EntityIntegrity.kt b/open-rosa/src/main/java/org/odk/collect/openrosa/forms/EntityIntegrity.kt
new file mode 100644
index 00000000000..52e69587f40
--- /dev/null
+++ b/open-rosa/src/main/java/org/odk/collect/openrosa/forms/EntityIntegrity.kt
@@ -0,0 +1,3 @@
+package org.odk.collect.openrosa.forms
+
+data class EntityIntegrity(val id: String, val deleted: Boolean)
diff --git a/open-rosa/src/main/java/org/odk/collect/openrosa/forms/OpenRosaClient.kt b/open-rosa/src/main/java/org/odk/collect/openrosa/forms/OpenRosaClient.kt
index 97739ec74bd..4f31645e901 100644
--- a/open-rosa/src/main/java/org/odk/collect/openrosa/forms/OpenRosaClient.kt
+++ b/open-rosa/src/main/java/org/odk/collect/openrosa/forms/OpenRosaClient.kt
@@ -1,5 +1,7 @@
 package org.odk.collect.openrosa.forms
 
+import android.net.Uri
+import org.odk.collect.entities.server.EntitySource
 import org.odk.collect.forms.FormListItem
 import org.odk.collect.forms.FormSource
 import org.odk.collect.forms.FormSourceException
@@ -23,7 +25,7 @@ class OpenRosaClient(
     openRosaHttpInterface: OpenRosaHttpInterface?,
     private val webCredentialsProvider: WebCredentialsProvider,
     private val openRosaResponseParser: OpenRosaResponseParser
-) : FormSource {
+) : FormSource, EntitySource {
     private val openRosaXMLFetcher =
         OpenRosaXmlFetcher(openRosaHttpInterface, this.webCredentialsProvider)
 
@@ -149,4 +151,24 @@ class OpenRosaClient(
         downloadListUrl += OpenRosaConstants.FORM_LIST
         return downloadListUrl
     }
+
+    override fun fetchDeletedStates(integrityUrl: String, ids: List<String>): List<Pair<String, Boolean>> {
+        val uri = Uri.parse(integrityUrl)
+            .buildUpon()
+            .appendQueryParameter("id", ids.joinToString(","))
+            .build()
+
+        val result = openRosaXMLFetcher.getXML(uri.toString())
+        if (!result.isOpenRosaResponse) {
+            throw FormSourceException.ParseError(serverUrl)
+        }
+
+        val parsedResponse = openRosaResponseParser.parseIntegrityResponse(result.doc)
+
+        if (parsedResponse != null) {
+            return parsedResponse.map { Pair(it.id, it.deleted) }
+        } else {
+            throw FormSourceException.ParseError(serverUrl)
+        }
+    }
 }
diff --git a/open-rosa/src/main/java/org/odk/collect/openrosa/parse/OpenRosaResponseParserImpl.kt b/open-rosa/src/main/java/org/odk/collect/openrosa/parse/Kxml2OpenRosaResponseParser.kt
similarity index 77%
rename from open-rosa/src/main/java/org/odk/collect/openrosa/parse/OpenRosaResponseParserImpl.kt
rename to open-rosa/src/main/java/org/odk/collect/openrosa/parse/Kxml2OpenRosaResponseParser.kt
index 5d807064def..e1c2bd8ca4f 100644
--- a/open-rosa/src/main/java/org/odk/collect/openrosa/parse/OpenRosaResponseParserImpl.kt
+++ b/open-rosa/src/main/java/org/odk/collect/openrosa/parse/Kxml2OpenRosaResponseParser.kt
@@ -5,12 +5,20 @@ import org.kxml2.kdom.Document
 import org.kxml2.kdom.Element
 import org.odk.collect.forms.FormListItem
 import org.odk.collect.forms.MediaFile
+import org.odk.collect.openrosa.forms.EntityIntegrity
 import org.odk.collect.shared.strings.StringUtils.isBlank
 import java.io.File
 
-class OpenRosaResponseParserImpl :
+object Kxml2OpenRosaResponseParser :
     OpenRosaResponseParser {
 
+    private const val MD5_STRING_PREFIX = "md5:"
+
+    private const val NAMESPACE_OPENROSA_ORG_XFORMS_XFORMS_LIST =
+        "http://openrosa.org/xforms/xformsList"
+    private const val NAMESPACE_OPENROSA_ORG_XFORMS_XFORMS_MANIFEST =
+        "http://openrosa.org/xforms/xformsManifest"
+
     override fun parseFormList(document: Document): List<FormListItem>? {
         // Attempt OpenRosa 1.0 parsing
         val xformsElement = try {
@@ -23,7 +31,7 @@ class OpenRosaResponseParserImpl :
             return null
         }
 
-        if (!isXformsListNamespacedElement(xformsElement)) {
+        if (!hasNamespace(xformsElement, NAMESPACE_OPENROSA_ORG_XFORMS_XFORMS_LIST)) {
             return null
         }
 
@@ -36,7 +44,10 @@ class OpenRosaResponseParserImpl :
             }
 
             val xformElement = xformsElement.getElement(i)
-            if (!isXformsListNamespacedElement(xformElement)) {
+            if (!hasNamespace(
+                    xformElement,
+                    NAMESPACE_OPENROSA_ORG_XFORMS_XFORMS_LIST
+                )) {
                 // someone else's extension?
                 continue
             }
@@ -64,7 +75,7 @@ class OpenRosaResponseParserImpl :
                 }
 
                 val child = xformElement.getElement(j)
-                if (!isXformsListNamespacedElement(child)) {
+                if (!hasNamespace(child, NAMESPACE_OPENROSA_ORG_XFORMS_XFORMS_LIST)) {
                     // someone else's extension?
                     continue
                 }
@@ -140,7 +151,7 @@ class OpenRosaResponseParserImpl :
             return null
         }
 
-        if (!isXformsManifestNamespacedElement(manifestElement)) {
+        if (!hasNamespace(manifestElement, NAMESPACE_OPENROSA_ORG_XFORMS_XFORMS_MANIFEST)) {
             return null
         }
 
@@ -153,7 +164,7 @@ class OpenRosaResponseParserImpl :
             }
 
             val mediaFileElement = manifestElement.getElement(i)
-            if (!isXformsManifestNamespacedElement(mediaFileElement)) {
+            if (!hasNamespace(mediaFileElement, NAMESPACE_OPENROSA_ORG_XFORMS_XFORMS_MANIFEST)) {
                 // someone else's extension?
                 continue
             }
@@ -165,6 +176,7 @@ class OpenRosaResponseParserImpl :
                 var filename: String? = null
                 var hash: String? = null
                 var downloadUrl: String? = null
+                var integrityUrl: String? = null
                 // don't process descriptionUrl
                 val childCount = mediaFileElement.childCount
                 for (j in 0 until childCount) {
@@ -174,7 +186,7 @@ class OpenRosaResponseParserImpl :
                     }
 
                     val child = mediaFileElement.getElement(j)
-                    if (!isXformsManifestNamespacedElement(child)) {
+                    if (!hasNamespace(child, NAMESPACE_OPENROSA_ORG_XFORMS_XFORMS_MANIFEST)) {
                         // someone else's extension?
                         continue
                     }
@@ -203,6 +215,9 @@ class OpenRosaResponseParserImpl :
                                 downloadUrl = null
                             }
                         }
+                        "integrityUrl" -> {
+                            integrityUrl = XFormParser.getXMLText(child, true)
+                        }
                     }
                 }
 
@@ -210,30 +225,36 @@ class OpenRosaResponseParserImpl :
                     return null
                 }
 
-                files.add(MediaFile(filename, hash, downloadUrl, type == "entityList"))
+                val isEntityList = type == "entityList"
+                if (isEntityList && integrityUrl == null) {
+                    return null
+                }
+
+                files.add(MediaFile(filename, hash, downloadUrl, isEntityList, integrityUrl))
             }
         }
         return files
     }
 
-    companion object {
-
-        private const val MD5_STRING_PREFIX = "md5:"
-
-        private const val NAMESPACE_OPENROSA_ORG_XFORMS_XFORMS_LIST =
-            "http://openrosa.org/xforms/xformsList"
-        private const val NAMESPACE_OPENROSA_ORG_XFORMS_XFORMS_MANIFEST =
-            "http://openrosa.org/xforms/xformsManifest"
-
-        private fun isXformsListNamespacedElement(e: Element): Boolean {
-            return e.namespace.equals(NAMESPACE_OPENROSA_ORG_XFORMS_XFORMS_LIST, ignoreCase = true)
+    override fun parseIntegrityResponse(doc: Document): List<EntityIntegrity>? {
+        val data = doc.rootElement
+        if (data.name != "data") {
+            return null
         }
 
-        private fun isXformsManifestNamespacedElement(e: Element): Boolean {
-            return e.namespace.equals(
-                NAMESPACE_OPENROSA_ORG_XFORMS_XFORMS_MANIFEST,
-                ignoreCase = true
-            )
+        try {
+            val entities = data.getElement(null, "entities")
+            return 0.until(entities.childCount).map {
+                val entity = entities.getElement(null, "entity")
+                val deleted = entity.getElement(null, "deleted").getChild(0) as String
+                EntityIntegrity(entity.getAttributeValue(null, "id"), deleted.toBooleanStrict())
+            }
+        } catch (e: RuntimeException) {
+            return null
         }
     }
+
+    private fun hasNamespace(e: Element, namespace: String): Boolean {
+        return e.namespace.equals(namespace, ignoreCase = true)
+    }
 }
diff --git a/open-rosa/src/main/java/org/odk/collect/openrosa/parse/OpenRosaResponseParser.java b/open-rosa/src/main/java/org/odk/collect/openrosa/parse/OpenRosaResponseParser.java
deleted file mode 100644
index 7611f0990b1..00000000000
--- a/open-rosa/src/main/java/org/odk/collect/openrosa/parse/OpenRosaResponseParser.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package org.odk.collect.openrosa.parse;
-
-import org.jetbrains.annotations.Nullable;
-import org.kxml2.kdom.Document;
-import org.odk.collect.forms.FormListItem;
-import org.odk.collect.forms.MediaFile;
-
-import java.util.List;
-
-public interface OpenRosaResponseParser {
-
-    @Nullable List<FormListItem> parseFormList(Document document);
-    @Nullable List<MediaFile> parseManifest(Document document);
-}
diff --git a/open-rosa/src/main/java/org/odk/collect/openrosa/parse/OpenRosaResponseParser.kt b/open-rosa/src/main/java/org/odk/collect/openrosa/parse/OpenRosaResponseParser.kt
new file mode 100644
index 00000000000..3c7743472b0
--- /dev/null
+++ b/open-rosa/src/main/java/org/odk/collect/openrosa/parse/OpenRosaResponseParser.kt
@@ -0,0 +1,12 @@
+package org.odk.collect.openrosa.parse
+
+import org.kxml2.kdom.Document
+import org.odk.collect.forms.FormListItem
+import org.odk.collect.forms.MediaFile
+import org.odk.collect.openrosa.forms.EntityIntegrity
+
+interface OpenRosaResponseParser {
+    fun parseFormList(document: Document): List<FormListItem>?
+    fun parseManifest(document: Document): List<MediaFile>?
+    fun parseIntegrityResponse(doc: Document): List<EntityIntegrity>?
+}
diff --git a/open-rosa/src/test/java/org/odk/collect/openrosa/forms/OpenRosaClientTest.kt b/open-rosa/src/test/java/org/odk/collect/openrosa/forms/OpenRosaClientTest.kt
index 9bc2866d24c..22a861d6c29 100644
--- a/open-rosa/src/test/java/org/odk/collect/openrosa/forms/OpenRosaClientTest.kt
+++ b/open-rosa/src/test/java/org/odk/collect/openrosa/forms/OpenRosaClientTest.kt
@@ -1,13 +1,15 @@
 package org.odk.collect.openrosa.forms
 
+import androidx.test.ext.junit.runners.AndroidJUnit4
 import org.hamcrest.MatcherAssert.assertThat
 import org.hamcrest.Matchers.equalTo
 import org.junit.Assert.fail
 import org.junit.Test
-import org.mockito.ArgumentMatchers.any
-import org.mockito.ArgumentMatchers.eq
-import org.mockito.Mockito.mock
-import org.mockito.Mockito.verify
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.verify
 import org.mockito.kotlin.whenever
 import org.odk.collect.forms.FormSourceException
 import org.odk.collect.forms.FormSourceException.FetchError
@@ -24,6 +26,7 @@ import java.net.URI
 import java.net.UnknownHostException
 import javax.net.ssl.SSLException
 
+@RunWith(AndroidJUnit4::class)
 class OpenRosaClientTest {
     private val httpInterface = mock<OpenRosaHttpInterface>()
     private val webCredentialsProvider = StubWebCredentialsProvider()
@@ -301,7 +304,7 @@ class OpenRosaClientTest {
 
         try {
             whenever(
-                httpInterface.executeGetRequest(any(), any(), any())
+                httpInterface.executeGetRequest(any(), eq(null), any())
             ).thenReturn(HttpGetResult(null, HashMap(), "hash", 500))
 
             formListApi.fetchForm("http://blah.com/form")
@@ -320,7 +323,7 @@ class OpenRosaClientTest {
 
         try {
             whenever(
-                httpInterface.executeGetRequest(any(), any(), any())
+                httpInterface.executeGetRequest(any(), eq(null), any())
             ).thenReturn(HttpGetResult(null, HashMap(), "hash", 500))
 
             formListApi.fetchMediaFile("http://blah.com/mediaFile")
@@ -331,6 +334,60 @@ class OpenRosaClientTest {
         }
     }
 
+    @Test
+    fun fetchDeletedStates_whenNotOpenRosaResponse_throwsParseError() {
+        val client =
+            OpenRosaClient("http://blah.com", httpInterface, webCredentialsProvider, responseParser)
+
+        try {
+            whenever(
+                httpInterface.executeGetRequest(any(), any(), any())
+            ).thenReturn(
+                HttpGetResult(
+                    ByteArrayInputStream("<xml></xml>".toByteArray()),
+                    HashMap(),
+                    "hash",
+                    200
+                )
+            )
+
+            whenever(responseParser.parseIntegrityResponse(any())).thenReturn(emptyList())
+            client.fetchDeletedStates("http://blah.com/integrity", listOf("1", "2", "3"))
+            fail("No exception thrown!")
+        } catch (e: FormSourceException.ParseError) {
+            assertThat(e.serverUrl, equalTo("http://blah.com"))
+        }
+    }
+
+    @Test
+    fun fetchDeletedStates_whenOpenRosaResponse_whenParserFails_throwsParseError() {
+        val client =
+            OpenRosaClient("http://blah.com", httpInterface, webCredentialsProvider, responseParser)
+
+        try {
+            whenever(
+                httpInterface.executeGetRequest(any(), any(), any())
+            ).thenReturn(
+                HttpGetResult(
+                    ByteArrayInputStream("<xml></xml>".toByteArray()),
+                    object : HashMap<String?, String?>() {
+                        init {
+                            put(OpenRosaConstants.VERSION_HEADER, "1.0")
+                        }
+                    },
+                    "hash",
+                    200
+                )
+            )
+
+            whenever(responseParser.parseIntegrityResponse(any())).thenReturn(null)
+            client.fetchDeletedStates("http://blah.com/integrity", listOf("1", "2", "3"))
+            fail("No exception thrown!")
+        } catch (e: FormSourceException.ParseError) {
+            assertThat(e.serverUrl, equalTo("http://blah.com"))
+        }
+    }
+
     companion object {
         private fun join(vararg strings: String): String {
             val bob = StringBuilder()
diff --git a/open-rosa/src/test/java/org/odk/collect/openrosa/parse/Kxml2OpenRosaResponseParserTest.kt b/open-rosa/src/test/java/org/odk/collect/openrosa/parse/Kxml2OpenRosaResponseParserTest.kt
new file mode 100644
index 00000000000..b50435b75df
--- /dev/null
+++ b/open-rosa/src/test/java/org/odk/collect/openrosa/parse/Kxml2OpenRosaResponseParserTest.kt
@@ -0,0 +1,249 @@
+package org.odk.collect.openrosa.parse
+
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.equalTo
+import org.javarosa.xform.parse.XFormParser
+import org.junit.Test
+import org.kxml2.kdom.Document
+
+class Kxml2OpenRosaResponseParserTest {
+
+    @Test
+    fun `#parseFormList when document is empty, returns null`() {
+        val formList = Kxml2OpenRosaResponseParser.parseFormList(Document())
+        assertThat(formList, equalTo(null))
+    }
+
+    @Test
+    fun `#parseFormList when xform hash is missing prefix, returns null hash for item`() {
+        val response = """
+            <?xml version='1.0' encoding='UTF-8' ?>
+            <xforms xmlns="http://openrosa.org/xforms/xformsList">
+                <xform>
+                    <formID>id</formID>
+                    <name>form name</name>
+                    <version>1</version>
+                    <hash>blahblah</hash>
+                    <downloadUrl>http://example.com</downloadUrl>
+                </xform>
+            </xforms>
+        """.trimIndent()
+
+        val doc = XFormParser.getXMLDocument(response.reader())
+        val formList = Kxml2OpenRosaResponseParser.parseFormList(doc)
+        assertThat(formList!![0].hash, equalTo(null))
+    }
+
+    @Test
+    fun `#parseManifest when media file hash is empty, returns null`() {
+        val response = """
+            <?xml version='1.0' encoding='UTF-8' ?>
+            <manifest xmlns="http://openrosa.org/xforms/xformsManifest">
+                <mediaFile>
+                    <filename>badger.png</filename>
+                    <hash></hash>
+                    <downloadUrl>http://funk.appspot.com/binaryData?blobKey=%3A477e3</downloadUrl>
+                </mediaFile>
+            </manifest>
+        """.trimIndent()
+
+        val doc = XFormParser.getXMLDocument(response.reader())
+        val mediaFiles = Kxml2OpenRosaResponseParser.parseManifest(doc)
+        assertThat(mediaFiles, equalTo(null))
+    }
+
+    @Test
+    fun `#parseManifest when document is empty, returns null`() {
+        val formList = Kxml2OpenRosaResponseParser.parseManifest(Document())
+        assertThat(formList, equalTo(null))
+    }
+
+    @Test
+    fun `#parseManifest sanitizes media file names`() {
+        val response = """
+            <?xml version='1.0' encoding='UTF-8' ?>
+            <manifest xmlns="http://openrosa.org/xforms/xformsManifest">
+                <mediaFile>
+                    <filename>/../badgers.csv</filename>
+                    <hash>blah</hash>
+                    <downloadUrl>http://funk.appspot.com/binaryData?blobKey=%3A477e3</downloadUrl>
+                </mediaFile>
+            </manifest>
+        """.trimIndent()
+
+        val doc = XFormParser.getXMLDocument(response.reader())
+        val mediaFiles = Kxml2OpenRosaResponseParser.parseManifest(doc)!!
+        assertThat(mediaFiles.size, equalTo(1))
+        assertThat(mediaFiles[0].filename, equalTo("badgers.csv"))
+    }
+
+    @Test
+    fun `#parseManifest when media file has type entityList returns isEntityList as true`() {
+        val response = """
+            <?xml version='1.0' encoding='UTF-8' ?>
+            <manifest xmlns="http://openrosa.org/xforms/xformsManifest">
+                <mediaFile type="entityList">
+                    <filename>badgers.csv</filename>
+                    <hash>blah</hash>
+                    <downloadUrl>http://funk.appspot.com/binaryData?blobKey=%3A477e3</downloadUrl>
+                    <integrityUrl>https://some.server/forms/12/integrity</integrityUrl>
+                </mediaFile>
+            </manifest>
+        """.trimIndent()
+
+        val doc = XFormParser.getXMLDocument(response.reader())
+        val mediaFiles = Kxml2OpenRosaResponseParser.parseManifest(doc)!!
+        assertThat(mediaFiles.size, equalTo(1))
+        assertThat(mediaFiles[0].isEntityList, equalTo(true))
+    }
+
+    @Test
+    fun `#parseManifest when media file does not have type returns isEntityList as false`() {
+        val response = """
+            <?xml version='1.0' encoding='UTF-8' ?>
+            <manifest xmlns="http://openrosa.org/xforms/xformsManifest">
+                <mediaFile>
+                    <filename>badgers.csv</filename>
+                    <hash>blah</hash>
+                    <downloadUrl>http://funk.appspot.com/binaryData?blobKey=%3A477e3</downloadUrl>
+                </mediaFile>
+            </manifest>
+        """.trimIndent()
+
+        val doc = XFormParser.getXMLDocument(response.reader())
+        val mediaFiles = Kxml2OpenRosaResponseParser.parseManifest(doc)!!
+        assertThat(mediaFiles.size, equalTo(1))
+        assertThat(mediaFiles[0].isEntityList, equalTo(false))
+    }
+
+    @Test
+    fun `#parseManifest includes integrityUrl when there is one`() {
+        val response = """
+            <?xml version='1.0' encoding='UTF-8' ?>
+            <manifest xmlns="http://openrosa.org/xforms/xformsManifest">
+                <mediaFile type="entityList">
+                    <filename>badgers.csv</filename>
+                    <hash>blah</hash>
+                    <downloadUrl>http://funk.appspot.com/binaryData?blobKey=%3A477e3</downloadUrl>
+                    <integrityUrl>https://some.server/forms/12/integrity</integrityUrl>
+                </mediaFile>
+            </manifest>
+        """.trimIndent()
+
+        val doc = XFormParser.getXMLDocument(response.reader())
+        val mediaFiles = Kxml2OpenRosaResponseParser.parseManifest(doc)!!
+        assertThat(mediaFiles.size, equalTo(1))
+        assertThat(mediaFiles[0].integrityUrl, equalTo("https://some.server/forms/12/integrity"))
+    }
+
+    @Test
+    fun `#parseManifest does not include integrityUrl when there isn't one`() {
+        val response = """
+            <?xml version='1.0' encoding='UTF-8' ?>
+            <manifest xmlns="http://openrosa.org/xforms/xformsManifest">
+                <mediaFile>
+                    <filename>badgers.csv</filename>
+                    <hash>blah</hash>
+                    <downloadUrl>http://funk.appspot.com/binaryData?blobKey=%3A477e3</downloadUrl>
+                </mediaFile>
+            </manifest>
+        """.trimIndent()
+
+        val doc = XFormParser.getXMLDocument(response.reader())
+        val mediaFiles = Kxml2OpenRosaResponseParser.parseManifest(doc)!!
+        assertThat(mediaFiles.size, equalTo(1))
+        assertThat(mediaFiles[0].integrityUrl, equalTo(null))
+    }
+
+    @Test
+    fun `#parseManifest returns null if a media file with type entityList is missing integrityUrl`() {
+        val response = """
+            <?xml version='1.0' encoding='UTF-8' ?>
+            <manifest xmlns="http://openrosa.org/xforms/xformsManifest">
+                <mediaFile type="entityList">
+                    <filename>badgers.csv</filename>
+                    <hash>blah</hash>
+                    <downloadUrl>http://funk.appspot.com/binaryData?blobKey=%3A477e3</downloadUrl>
+                </mediaFile>
+            </manifest>
+        """.trimIndent()
+
+        val doc = XFormParser.getXMLDocument(response.reader())
+        assertThat(Kxml2OpenRosaResponseParser.parseManifest(doc), equalTo(null))
+    }
+
+    @Test
+    fun `#parseIntegrityResponse returns null when the response structure is incorrect`() {
+        val noData = """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <blah>
+                <entities>
+                    <entity id="958e00b2-43f5-4d21-8adb-3d27aaa045f5">
+                        <deleted>true</deleted>
+                    </entity>
+                </entities>
+            </blah>
+        """.trimIndent()
+
+        val noEntities = """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <data>
+                <stuff>
+                    <entity id="958e00b2-43f5-4d21-8adb-3d27aaa045f5">
+                        <deleted>true</deleted>
+                    </entity>
+                </stuff>
+            </data>
+        """.trimIndent()
+
+        val noEntity = """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <data>
+                <entities>
+                    <thing id="958e00b2-43f5-4d21-8adb-3d27aaa045f5">
+                        <deleted>true</deleted>
+                    </thing>
+                </entities>
+            </data>
+        """.trimIndent()
+
+        val noDeleted = """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <data>
+                <entities>
+                    <entity id="958e00b2-43f5-4d21-8adb-3d27aaa045f5">
+                        <blah>true</blah>
+                    </entity>
+                </entities>
+            </data>
+        """.trimIndent()
+
+        val emptyDeleted = """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <data>
+                <entities>
+                    <entity id="958e00b2-43f5-4d21-8adb-3d27aaa045f5">
+                        <deleted></deleted>
+                    </entity>
+                </entities>
+            </data>
+        """.trimIndent()
+
+        val invalidDeleted = """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <data>
+                <entities>
+                    <entity id="958e00b2-43f5-4d21-8adb-3d27aaa045f5">
+                        <deleted>yes</deleted>
+                    </entity>
+                </entities>
+            </data>
+        """.trimIndent()
+
+        listOf(noData, noEntities, noEntity, noDeleted, emptyDeleted, invalidDeleted).forEach {
+            val doc = XFormParser.getXMLDocument(it.reader())
+            val response = Kxml2OpenRosaResponseParser.parseIntegrityResponse(doc)
+            assertThat("The following should not be parsed:\n$it\n\n", response, equalTo(null))
+        }
+    }
+}
diff --git a/open-rosa/src/test/java/org/odk/collect/openrosa/parse/OpenRosaResponseParserImplTest.kt b/open-rosa/src/test/java/org/odk/collect/openrosa/parse/OpenRosaResponseParserImplTest.kt
deleted file mode 100644
index f40caa28251..00000000000
--- a/open-rosa/src/test/java/org/odk/collect/openrosa/parse/OpenRosaResponseParserImplTest.kt
+++ /dev/null
@@ -1,117 +0,0 @@
-package org.odk.collect.openrosa.parse
-
-import org.hamcrest.MatcherAssert.assertThat
-import org.hamcrest.Matchers.equalTo
-import org.javarosa.xform.parse.XFormParser
-import org.junit.Test
-import org.kxml2.kdom.Document
-
-class OpenRosaResponseParserImplTest {
-
-    @Test
-    fun `parseFormList() when document is empty, returns null`() {
-        val formList = OpenRosaResponseParserImpl().parseFormList(Document())
-        assertThat(formList, equalTo(null))
-    }
-
-    @Test
-    fun `parseFormList() when xform hash is missing prefix, returns null hash for item`() {
-        val response = StringBuilder()
-            .appendLine("<?xml version='1.0' encoding='UTF-8' ?>")
-            .appendLine("<xforms xmlns=\"http://openrosa.org/xforms/xformsList\">")
-            .appendLine("<xform>")
-            .appendLine("<formID>id</formID>")
-            .appendLine("<name>form name</name>")
-            .appendLine("<version>1</version>")
-            .appendLine("<hash>blahblah</hash>")
-            .appendLine("<downloadUrl>http://example.com</downloadUrl>")
-            .appendLine("</xform>")
-            .appendLine("</xforms>")
-            .toString()
-
-        val doc = XFormParser.getXMLDocument(response.reader())
-        val formList = OpenRosaResponseParserImpl().parseFormList(doc)
-        assertThat(formList!![0].hash, equalTo(null))
-    }
-
-    @Test
-    fun `parseManifest() when media file hash is empty, returns null`() {
-        val response = StringBuilder()
-            .appendLine("<?xml version='1.0' encoding='UTF-8' ?>")
-            .appendLine("<manifest xmlns=\"http://openrosa.org/xforms/xformsManifest\">")
-            .appendLine("<mediaFile>")
-            .appendLine("<filename>badger.png</filename>")
-            .appendLine("<hash></hash>")
-            .appendLine("<downloadUrl>http://funk.appspot.com/binaryData?blobKey=%3A477e3</downloadUrl>")
-            .appendLine("</mediaFile>")
-            .appendLine("</manifest>")
-            .toString()
-
-        val doc = XFormParser.getXMLDocument(response.reader())
-        val mediaFiles = OpenRosaResponseParserImpl().parseManifest(doc)
-        assertThat(mediaFiles, equalTo(null))
-    }
-
-    @Test
-    fun `parseManifest() when document is empty, returns null`() {
-        val formList = OpenRosaResponseParserImpl().parseManifest(Document())
-        assertThat(formList, equalTo(null))
-    }
-
-    @Test
-    fun `parseManifest() sanitizes media file names`() {
-        val response = StringBuilder()
-            .appendLine("<?xml version='1.0' encoding='UTF-8' ?>")
-            .appendLine("<manifest xmlns=\"http://openrosa.org/xforms/xformsManifest\">")
-            .appendLine("<mediaFile>")
-            .appendLine("<filename>/../badgers.csv</filename>")
-            .appendLine("<hash>blah</hash>")
-            .appendLine("<downloadUrl>http://funk.appspot.com/binaryData?blobKey=%3A477e3</downloadUrl>")
-            .appendLine("</mediaFile>")
-            .appendLine("</manifest>")
-            .toString()
-
-        val doc = XFormParser.getXMLDocument(response.reader())
-        val mediaFiles = OpenRosaResponseParserImpl().parseManifest(doc)!!
-        assertThat(mediaFiles.size, equalTo(1))
-        assertThat(mediaFiles[0].filename, equalTo("badgers.csv"))
-    }
-
-    @Test
-    fun `parseManifest() when media file has type entityList returns isEntityList as true`() {
-        val response = StringBuilder()
-            .appendLine("<?xml version='1.0' encoding='UTF-8' ?>")
-            .appendLine("<manifest xmlns=\"http://openrosa.org/xforms/xformsManifest\">")
-            .appendLine("<mediaFile type=\"entityList\">")
-            .appendLine("<filename>badgers.csv</filename>")
-            .appendLine("<hash>blah</hash>")
-            .appendLine("<downloadUrl>http://funk.appspot.com/binaryData?blobKey=%3A477e3</downloadUrl>")
-            .appendLine("</mediaFile>")
-            .appendLine("</manifest>")
-            .toString()
-
-        val doc = XFormParser.getXMLDocument(response.reader())
-        val mediaFiles = OpenRosaResponseParserImpl().parseManifest(doc)!!
-        assertThat(mediaFiles.size, equalTo(1))
-        assertThat(mediaFiles[0].isEntityList, equalTo(true))
-    }
-
-    @Test
-    fun `parseManifest() when media file does not have type returns isEntityList as false`() {
-        val response = StringBuilder()
-            .appendLine("<?xml version='1.0' encoding='UTF-8' ?>")
-            .appendLine("<manifest xmlns=\"http://openrosa.org/xforms/xformsManifest\">")
-            .appendLine("<mediaFile>")
-            .appendLine("<filename>badgers.csv</filename>")
-            .appendLine("<hash>blah</hash>")
-            .appendLine("<downloadUrl>http://funk.appspot.com/binaryData?blobKey=%3A477e3</downloadUrl>")
-            .appendLine("</mediaFile>")
-            .appendLine("</manifest>")
-            .toString()
-
-        val doc = XFormParser.getXMLDocument(response.reader())
-        val mediaFiles = OpenRosaResponseParserImpl().parseManifest(doc)!!
-        assertThat(mediaFiles.size, equalTo(1))
-        assertThat(mediaFiles[0].isEntityList, equalTo(false))
-    }
-}
diff --git a/test-forms/src/main/resources/forms/one-question-entity-registration-id.xml b/test-forms/src/main/resources/forms/one-question-entity-registration-id.xml
new file mode 100644
index 00000000000..f6f60c667c8
--- /dev/null
+++ b/test-forms/src/main/resources/forms/one-question-entity-registration-id.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0"?>
+<!--
+Like "one-question-entity-registration.xml except that the entity ID uses the name field so that
+it can be used in tests (as opposed to having the standard UUID generated ID).
+ -->
+<h:html xmlns="http://www.w3.org/2002/xforms" xmlns:ev="http://www.w3.org/2001/xml-events" xmlns:h="http://www.w3.org/1999/xhtml" xmlns:jr="http://openrosa.org/javarosa" xmlns:odk="http://www.opendatakit.org/xforms" xmlns:orx="http://openrosa.org/xforms" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:entities="http://www.opendatakit.org/xforms/entities">
+	<h:head>
+		<h:title>One Question Entity Registration</h:title>
+		<model odk:xforms-version="1.0.0" entities:entities-version="2024.1.0">
+			<instance>
+				<data id="one_question_entity" version="1">
+					<name/>
+					<meta>
+						<instanceID/>
+						<instanceName/>
+						<entity dataset="people" create="1" id="">
+							<label/>
+						</entity>
+					</meta>
+				</data>
+			</instance>
+			<bind nodeset="/data/name" type="string" entities:saveto="full_name"/>
+
+			<bind jr:preload="uid" nodeset="/data/meta/instanceID" readonly="true()" type="string"/>
+
+			<bind nodeset="/data/meta/entity/@id" type="string" calculate="/data/name"/>
+			<bind calculate="/data/name" nodeset="/data/meta/entity/label" type="string"/>
+		</model>
+	</h:head>
+	<h:body>
+		<input ref="/data/name">
+			<label>Name</label>
+		</input>
+	</h:body>
+</h:html>


Reviews:
user: login: seadowg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T13:47:00Z
user: login: grzesiek2010
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-16T14:02:57Z
user: login: seadowg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T12:58:56Z
user: login: seadowg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T13:05:22Z
user: login: seadowg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T14:39:12Z
user: login: seadowg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T14:45:49Z
user: login: grzesiek2010
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-17T16:07:01Z
user: login: seadowg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T16:19:43Z
user: login: seadowg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T16:23:24Z
user: login: grzesiek2010
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-17T16:57:59Z

Review comments:
diff_hunk: @@ -62,11 +62,14 @@ object LocalEntityUseCases {
     fun updateLocalEntitiesFromServer(
         list: String,
         serverList: File,
-        entitiesRepository: EntitiesRepository
+        entitiesRepository: EntitiesRepository,
+        entitySource: EntitySource,
+        serverListHash: String,
+        integrityUrl: String?
     ) {
-        val listHash = getListHash(serverList)
-        val existingListVersion = entitiesRepository.getListHash(list)
-        if (listHash == existingListVersion) {
+        val newListHash = "server:$serverListHash"
path: entities/src/main/java/org/odk/collect/entities/LocalEntityUseCases.kt
user: login: seadowg
body: We need to use the server hash here instead of the file hash to prevent problems coming up with a "new" entity list having the same contents as a previous one due to deletions.
created_at: 2025-03-11T13:47:00Z
author_association: MEMBER
diff_hunk: @@ -552,3 +775,28 @@ private class MeasurableEntitiesRepository(private val wrapped: EntitiesReposito
         return wrapped.getListHash(list)
     }
 }
+
+private class FakeEntitySource : EntitySource {
+
+    val integrityUrl = "http://example.com/${UUID.randomUUID()}"
+    var accesses: Int = 0
+        private set
+
+    private val deleted = mutableListOf<String>()
+
+    override fun isDeleted(integrityUrl: String, ids: List<String>): List<Pair<String, Boolean>> {
+        accesses += 1
+
+        if (integrityUrl == this.integrityUrl) {
+            return ids.map {
+                Pair(it, deleted.contains(it))
+            }
+        } else {
+            throw IllegalArgumentException()
path: entities/src/test/java/org/odk/collect/entities/LocalEntityUseCasesTest.kt
user: login: grzesiek2010
body: This case is ignored and not tested at all. We could probably remove it and not check `integrityUrl` in `FakeEntitySource`.
created_at: 2025-03-16T11:54:28Z
author_association: MEMBER
diff_hunk: @@ -203,37 +215,46 @@ class OpenRosaResponseParserImpl :
                                 downloadUrl = null
                             }
                         }
+                        "integrityUrl" -> {
+                            integrityUrl = XFormParser.getXMLText(child, true)
path: open-rosa/src/main/java/org/odk/collect/openrosa/parse/Kxml2OpenRosaResponseParser.kt
user: login: grzesiek2010
body: Don't we need the same check for null and empty value here as in the case of `downloadUrl` (see above)?
created_at: 2025-03-16T12:10:36Z
author_association: MEMBER
diff_hunk: @@ -232,4 +232,29 @@ class EntityFormTest {
             .pressBackAndDiscardForm()
             .startBlankForm("One Question Entity Registration")
     }
+
+    @Test
+    fun aLocallyCreatedEntity_thatIsDeletedOnTheServer_isNotAvailableToFollowUpForms() {
+        testDependencies.server.addForm("one-question-entity-registration-id.xml")
path: collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/EntityFormTest.kt
user: login: grzesiek2010
body: Do we really need another form that is nearly identical to `one-question-entity-registration.xml`? I always strive to reuse existing forms whenever possible; otherwise, distinguishing the subtle differences between them becomes challenging.
created_at: 2025-03-16T12:29:59Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,5 @@
+package org.odk.collect.entities.server
+
+interface EntitySource {
+    fun isDeleted(integrityUrl: String, ids: List<String>): List<Pair<String, Boolean>>
path: entities/src/main/java/org/odk/collect/entities/server/EntitySource.kt
user: login: grzesiek2010
body: The name `isDeleted` suggests a boolean return type or a single entity check, but this method returns a list of (`id, isDeleted`) pairs. I think a better name would be `fetchDeletionStates` hmm?
created_at: 2025-03-16T12:34:33Z
author_association: MEMBER
diff_hunk: @@ -149,4 +151,24 @@ class OpenRosaClient(
         downloadListUrl += OpenRosaConstants.FORM_LIST
         return downloadListUrl
     }
+
+    override fun isDeleted(integrityUrl: String, ids: List<String>): List<Pair<String, Boolean>> {
+        val uri = Uri.parse(integrityUrl)
+            .buildUpon()
+            .appendQueryParameter("id", ids.joinToString(","))
+            .build()
+
+        val result = openRosaXMLFetcher.getXML(uri.toString())
+        if (!result.isOpenRosaResponse) {
+            throw FormSourceException.ParseError(serverUrl)
+        }
+
+        val parsedResponse = openRosaResponseParser.parseIntegrityResponse(result.doc)
+
+        if (parsedResponse != null) {
+            return parsedResponse.map { Pair(it.id, it.deleted) }
+        } else {
+            throw FormSourceException.ParseError(serverUrl)
path: open-rosa/src/main/java/org/odk/collect/openrosa/forms/OpenRosaClient.kt
user: login: grzesiek2010
body: Perhaps we should create a dedicated exception for this new endpoint. Otherwise, if something goes wrong, the error message will remain generic.
created_at: 2025-03-16T12:42:13Z
author_association: MEMBER
diff_hunk: @@ -4,11 +4,14 @@
 import org.kxml2.kdom.Document;
 import org.odk.collect.forms.FormListItem;
 import org.odk.collect.forms.MediaFile;
+import org.odk.collect.openrosa.forms.EntityIntegrity;
 
 import java.util.List;
 
 public interface OpenRosaResponseParser {
 
     @Nullable List<FormListItem> parseFormList(Document document);
     @Nullable List<MediaFile> parseManifest(Document document);
+
+    @Nullable List<EntityIntegrity> parseIntegrityResponse(Document doc);
path: open-rosa/src/main/java/org/odk/collect/openrosa/parse/OpenRosaResponseParser.java
user: login: grzesiek2010
body: It's a small class, making it a good opportunity to convert it to Kotlin.
created_at: 2025-03-16T12:44:14Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,227 @@
+package org.odk.collect.openrosa.parse
+
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.equalTo
+import org.javarosa.xform.parse.XFormParser
+import org.junit.Test
+import org.kxml2.kdom.Document
+
+class Kxml2OpenRosaResponseParserTest {
+
+    @Test
+    fun `#parseFormList when document is empty, returns null`() {
+        val formList = Kxml2OpenRosaResponseParser.parseFormList(Document())
+        assertThat(formList, equalTo(null))
+    }
+
+    @Test
+    fun `#parseFormList when xform hash is missing prefix, returns null hash for item`() {
+        val response = """
+            <?xml version='1.0' encoding='UTF-8' ?>
+            <xforms xmlns="http://openrosa.org/xforms/xformsList">
+                <xform>
+                    <formID>id</formID>
+                    <name>form name</name>
+                    <version>1</version>
+                    <hash>blahblah</hash>
+                    <downloadUrl>http://example.com</downloadUrl>
+                </xform>
+            </xforms>
+        """.trimIndent()
+
+        val doc = XFormParser.getXMLDocument(response.reader())
+        val formList = Kxml2OpenRosaResponseParser.parseFormList(doc)
+        assertThat(formList!![0].hash, equalTo(null))
+    }
+
+    @Test
+    fun `#parseManifest when media file hash is empty, returns null`() {
+        val response = """
+            <?xml version='1.0' encoding='UTF-8' ?>
+            <manifest xmlns="http://openrosa.org/xforms/xformsManifest">
+                <mediaFile>
+                    <filename>badger.png</filename>
+                    <hash></hash>
+                    <downloadUrl>http://funk.appspot.com/binaryData?blobKey=%3A477e3</downloadUrl>
+                </mediaFile>
+            </manifest>
+        """.trimIndent()
+
+        val doc = XFormParser.getXMLDocument(response.reader())
+        val mediaFiles = Kxml2OpenRosaResponseParser.parseManifest(doc)
+        assertThat(mediaFiles, equalTo(null))
+    }
+
+    @Test
+    fun `#parseManifest when document is empty, returns null`() {
+        val formList = Kxml2OpenRosaResponseParser.parseManifest(Document())
+        assertThat(formList, equalTo(null))
+    }
+
+    @Test
+    fun `#parseManifest sanitizes media file names`() {
+        val response = """
+            <?xml version='1.0' encoding='UTF-8' ?>
+            <manifest xmlns="http://openrosa.org/xforms/xformsManifest">
+                <mediaFile>
+                    <filename>/../badgers.csv</filename>
+                    <hash>blah</hash>
+                    <downloadUrl>http://funk.appspot.com/binaryData?blobKey=%3A477e3</downloadUrl>
+                </mediaFile>
+            </manifest>
+        """.trimIndent()
+
+        val doc = XFormParser.getXMLDocument(response.reader())
+        val mediaFiles = Kxml2OpenRosaResponseParser.parseManifest(doc)!!
+        assertThat(mediaFiles.size, equalTo(1))
+        assertThat(mediaFiles[0].filename, equalTo("badgers.csv"))
+    }
+
+    @Test
+    fun `#parseManifest when media file has type entityList returns isEntityList as true`() {
+        val response = """
+            <?xml version='1.0' encoding='UTF-8' ?>
+            <manifest xmlns="http://openrosa.org/xforms/xformsManifest">
+                <mediaFile type="entityList">
+                    <filename>badgers.csv</filename>
+                    <hash>blah</hash>
+                    <downloadUrl>http://funk.appspot.com/binaryData?blobKey=%3A477e3</downloadUrl>
+                    <integrityUrl>https://some.server/forms/12/integrity</integrityUrl>
+                </mediaFile>
+            </manifest>
+        """.trimIndent()
+
+        val doc = XFormParser.getXMLDocument(response.reader())
+        val mediaFiles = Kxml2OpenRosaResponseParser.parseManifest(doc)!!
+        assertThat(mediaFiles.size, equalTo(1))
+        assertThat(mediaFiles[0].isEntityList, equalTo(true))
+    }
+
+    @Test
+    fun `#parseManifest when media file does not have type returns isEntityList as false`() {
+        val response = """
+            <?xml version='1.0' encoding='UTF-8' ?>
+            <manifest xmlns="http://openrosa.org/xforms/xformsManifest">
+                <mediaFile>
+                    <filename>badgers.csv</filename>
+                    <hash>blah</hash>
+                    <downloadUrl>http://funk.appspot.com/binaryData?blobKey=%3A477e3</downloadUrl>
+                </mediaFile>
+            </manifest>
+        """.trimIndent()
+
+        val doc = XFormParser.getXMLDocument(response.reader())
+        val mediaFiles = Kxml2OpenRosaResponseParser.parseManifest(doc)!!
+        assertThat(mediaFiles.size, equalTo(1))
+        assertThat(mediaFiles[0].isEntityList, equalTo(false))
+    }
+
+    @Test
+    fun `#parseManifest includes integrityUrl when there is one`() {
+        val response = """
+            <?xml version='1.0' encoding='UTF-8' ?>
+            <manifest xmlns="http://openrosa.org/xforms/xformsManifest">
+                <mediaFile type="entityList">
+                    <filename>badgers.csv</filename>
+                    <hash>blah</hash>
+                    <downloadUrl>http://funk.appspot.com/binaryData?blobKey=%3A477e3</downloadUrl>
+                    <integrityUrl>https://some.server/forms/12/integrity</integrityUrl>
+                </mediaFile>
+            </manifest>
+        """.trimIndent()
+
+        val doc = XFormParser.getXMLDocument(response.reader())
+        val mediaFiles = Kxml2OpenRosaResponseParser.parseManifest(doc)!!
+        assertThat(mediaFiles.size, equalTo(1))
+        assertThat(mediaFiles[0].integrityUrl, equalTo("https://some.server/forms/12/integrity"))
+    }
+
+    @Test
+    fun `#parseManifest does not include integrityUrl when there isn't one`() {
+        val response = """
+            <?xml version='1.0' encoding='UTF-8' ?>
+            <manifest xmlns="http://openrosa.org/xforms/xformsManifest">
+                <mediaFile>
+                    <filename>badgers.csv</filename>
+                    <hash>blah</hash>
+                    <downloadUrl>http://funk.appspot.com/binaryData?blobKey=%3A477e3</downloadUrl>
+                </mediaFile>
+            </manifest>
+        """.trimIndent()
+
+        val doc = XFormParser.getXMLDocument(response.reader())
+        val mediaFiles = Kxml2OpenRosaResponseParser.parseManifest(doc)!!
+        assertThat(mediaFiles.size, equalTo(1))
+        assertThat(mediaFiles[0].integrityUrl, equalTo(null))
+    }
+
+    @Test
+    fun `#parseManifest returns null if a media file with type entityList is missing integrityUrl`() {
+        val response = """
+            <?xml version='1.0' encoding='UTF-8' ?>
+            <manifest xmlns="http://openrosa.org/xforms/xformsManifest">
+                <mediaFile type="entityList">
+                    <filename>badgers.csv</filename>
+                    <hash>blah</hash>
+                    <downloadUrl>http://funk.appspot.com/binaryData?blobKey=%3A477e3</downloadUrl>
+                </mediaFile>
+            </manifest>
+        """.trimIndent()
+
+        val doc = XFormParser.getXMLDocument(response.reader())
+        assertThat(Kxml2OpenRosaResponseParser.parseManifest(doc), equalTo(null))
+    }
+
+    @Test
+    fun `#parseIntegrityResponse returns null when the response structure is incorrect`() {
path: open-rosa/src/test/java/org/odk/collect/openrosa/parse/Kxml2OpenRosaResponseParserTest.kt
user: login: grzesiek2010
body:  think we should also add a test to verify that if the value in `<deleted>...</deleted>` is not a boolean (eg. `blah` or an empty string), it's handled correctly.
created_at: 2025-03-16T13:03:02Z
author_association: MEMBER
diff_hunk: @@ -41,15 +42,17 @@ public class ServerFormDownloader implements FormDownloader {
     private final FormMetadataParser formMetadataParser;
     private final Supplier<Long> clock;
     private final EntitiesRepository entitiesRepository;
+    private EntitySource entitySource;
path: collect_app/src/main/java/org/odk/collect/android/formmanagement/download/ServerFormDownloader.java
user: login: grzesiek2010
body: This can be final, like all the other fields.
created_at: 2025-03-16T13:17:52Z
author_association: MEMBER
diff_hunk: @@ -399,13 +448,29 @@ public String getId() {
     }
 
     public static class EntityListItem extends MediaFileItem {
+
+        private int version = 1;
+
         public EntityListItem(String name, String file) {
path: collect_app/src/androidTest/java/org/odk/collect/android/support/StubOpenRosaServer.java
user: login: grzesiek2010
body: This is now unused.
created_at: 2025-03-16T14:01:44Z
author_association: MEMBER
diff_hunk: @@ -552,3 +775,28 @@ private class MeasurableEntitiesRepository(private val wrapped: EntitiesReposito
         return wrapped.getListHash(list)
     }
 }
+
+private class FakeEntitySource : EntitySource {
+
+    val integrityUrl = "http://example.com/${UUID.randomUUID()}"
+    var accesses: Int = 0
+        private set
+
+    private val deleted = mutableListOf<String>()
+
+    override fun isDeleted(integrityUrl: String, ids: List<String>): List<Pair<String, Boolean>> {
+        accesses += 1
+
+        if (integrityUrl == this.integrityUrl) {
+            return ids.map {
+                Pair(it, deleted.contains(it))
+            }
+        } else {
+            throw IllegalArgumentException()
path: entities/src/test/java/org/odk/collect/entities/LocalEntityUseCasesTest.kt
user: login: seadowg
body: This is part of the test: it ensures that the correct URL is passed to `isDeleted` in `updateLocalEntitiesFromServer`. You'll see if you change the URL there, an exception will be thrown. 
created_at: 2025-03-17T12:58:56Z
author_association: MEMBER
diff_hunk: @@ -203,37 +215,46 @@ class OpenRosaResponseParserImpl :
                                 downloadUrl = null
                             }
                         }
+                        "integrityUrl" -> {
+                            integrityUrl = XFormParser.getXMLText(child, true)
path: open-rosa/src/main/java/org/odk/collect/openrosa/parse/Kxml2OpenRosaResponseParser.kt
user: login: seadowg
body: I've added tests/fixes for the cases where deleted is blank or invalid, but I don't think we need to account for null here. Correct me if I'm wrong though!
created_at: 2025-03-17T13:05:22Z
author_association: MEMBER
diff_hunk: @@ -232,4 +232,29 @@ class EntityFormTest {
             .pressBackAndDiscardForm()
             .startBlankForm("One Question Entity Registration")
     }
+
+    @Test
+    fun aLocallyCreatedEntity_thatIsDeletedOnTheServer_isNotAvailableToFollowUpForms() {
+        testDependencies.server.addForm("one-question-entity-registration-id.xml")
path: collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/EntityFormTest.kt
user: login: seadowg
body: I need a predictable ID here so I can delete the entity on the "server". I do agree that it's unclear what the point of that form is though. I'll add a comment to the form XML.
created_at: 2025-03-17T14:39:12Z
author_association: MEMBER
diff_hunk: @@ -149,4 +151,24 @@ class OpenRosaClient(
         downloadListUrl += OpenRosaConstants.FORM_LIST
         return downloadListUrl
     }
+
+    override fun isDeleted(integrityUrl: String, ids: List<String>): List<Pair<String, Boolean>> {
+        val uri = Uri.parse(integrityUrl)
+            .buildUpon()
+            .appendQueryParameter("id", ids.joinToString(","))
+            .build()
+
+        val result = openRosaXMLFetcher.getXML(uri.toString())
+        if (!result.isOpenRosaResponse) {
+            throw FormSourceException.ParseError(serverUrl)
+        }
+
+        val parsedResponse = openRosaResponseParser.parseIntegrityResponse(result.doc)
+
+        if (parsedResponse != null) {
+            return parsedResponse.map { Pair(it.id, it.deleted) }
+        } else {
+            throw FormSourceException.ParseError(serverUrl)
path: open-rosa/src/main/java/org/odk/collect/openrosa/forms/OpenRosaClient.kt
user: login: seadowg
body: We're improving this as part of https://github.com/getodk/collect/pull/6645
created_at: 2025-03-17T14:45:48Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,35 @@
+<?xml version="1.0"?>
+<!--
+Like "one-question-entity-resgistration.xml except that the entity ID uses the name field so that
path: test-forms/src/main/resources/forms/one-question-entity-registration-id.xml
user: login: grzesiek2010
body: There are two typos:
resgistration -> registration
the -> then or just remove it.
created_at: 2025-03-17T15:38:14Z
author_association: MEMBER
diff_hunk: @@ -203,37 +215,46 @@ class OpenRosaResponseParserImpl :
                                 downloadUrl = null
                             }
                         }
+                        "integrityUrl" -> {
+                            integrityUrl = XFormParser.getXMLText(child, true)
+                        }
                     }
                 }
 
                 if (filename == null || downloadUrl == null || hash == null) {
                     return null
                 }
 
-                files.add(MediaFile(filename, hash, downloadUrl, type == "entityList"))
+                val isEntityList = type == "entityList"
+                if (isEntityList && integrityUrl == null) {
+                    return null
+                }
+
+                files.add(MediaFile(filename, hash, downloadUrl, isEntityList, integrityUrl))
             }
         }
         return files
     }
 
-    companion object {
-
-        private const val MD5_STRING_PREFIX = "md5:"
-
-        private const val NAMESPACE_OPENROSA_ORG_XFORMS_XFORMS_LIST =
-            "http://openrosa.org/xforms/xformsList"
-        private const val NAMESPACE_OPENROSA_ORG_XFORMS_XFORMS_MANIFEST =
-            "http://openrosa.org/xforms/xformsManifest"
-
-        private fun isXformsListNamespacedElement(e: Element): Boolean {
-            return e.namespace.equals(NAMESPACE_OPENROSA_ORG_XFORMS_XFORMS_LIST, ignoreCase = true)
+    override fun parseIntegrityResponse(doc: Document): List<EntityIntegrity>? {
+        val data = doc.rootElement
+        if (data.name != "data") {
+            return null
         }
 
-        private fun isXformsManifestNamespacedElement(e: Element): Boolean {
-            return e.namespace.equals(
-                NAMESPACE_OPENROSA_ORG_XFORMS_XFORMS_MANIFEST,
-                ignoreCase = true
-            )
+        try {
+            val entities = data.getElement(null, "entities")
+            return 0.until(entities.childCount).map {
+                val entity = entities.getElement(null, "entity")
+                val deleted = entity.getElement(null, "deleted").getChild(0) as String
+                EntityIntegrity(entity.getAttributeValue(null, "id"), deleted.toBooleanStrict())
path: open-rosa/src/main/java/org/odk/collect/openrosa/parse/Kxml2OpenRosaResponseParser.kt
user: login: grzesiek2010
body: Is it guaranteed that the values are always lowercase? It might be the case with Central, but in the future, other servers could implement this differently (we have seen such cases in the past with KOBO for example). `toBooleanStrict` will throw an exception for `True`, for example. Maybe we should use `deleted.lowercase().toBooleanStrict()` here and add a test for this scenario.
created_at: 2025-03-17T15:51:23Z
author_association: MEMBER
diff_hunk: @@ -552,3 +775,28 @@ private class MeasurableEntitiesRepository(private val wrapped: EntitiesReposito
         return wrapped.getListHash(list)
     }
 }
+
+private class FakeEntitySource : EntitySource {
+
+    val integrityUrl = "http://example.com/${UUID.randomUUID()}"
+    var accesses: Int = 0
+        private set
+
+    private val deleted = mutableListOf<String>()
+
+    override fun isDeleted(integrityUrl: String, ids: List<String>): List<Pair<String, Boolean>> {
+        accesses += 1
+
+        if (integrityUrl == this.integrityUrl) {
+            return ids.map {
+                Pair(it, deleted.contains(it))
+            }
+        } else {
+            throw IllegalArgumentException()
path: entities/src/test/java/org/odk/collect/entities/LocalEntityUseCasesTest.kt
user: login: grzesiek2010
body: But there is no test that checks the scenario when the URLs do not match right? The check above `integrityUrl == this.integrityUrl` is always true.
created_at: 2025-03-17T16:03:19Z
author_association: MEMBER
diff_hunk: @@ -203,37 +215,46 @@ class OpenRosaResponseParserImpl :
                                 downloadUrl = null
                             }
                         }
+                        "integrityUrl" -> {
+                            integrityUrl = XFormParser.getXMLText(child, true)
+                        }
                     }
                 }
 
                 if (filename == null || downloadUrl == null || hash == null) {
                     return null
                 }
 
-                files.add(MediaFile(filename, hash, downloadUrl, type == "entityList"))
+                val isEntityList = type == "entityList"
+                if (isEntityList && integrityUrl == null) {
+                    return null
+                }
+
+                files.add(MediaFile(filename, hash, downloadUrl, isEntityList, integrityUrl))
             }
         }
         return files
     }
 
-    companion object {
-
-        private const val MD5_STRING_PREFIX = "md5:"
-
-        private const val NAMESPACE_OPENROSA_ORG_XFORMS_XFORMS_LIST =
-            "http://openrosa.org/xforms/xformsList"
-        private const val NAMESPACE_OPENROSA_ORG_XFORMS_XFORMS_MANIFEST =
-            "http://openrosa.org/xforms/xformsManifest"
-
-        private fun isXformsListNamespacedElement(e: Element): Boolean {
-            return e.namespace.equals(NAMESPACE_OPENROSA_ORG_XFORMS_XFORMS_LIST, ignoreCase = true)
+    override fun parseIntegrityResponse(doc: Document): List<EntityIntegrity>? {
+        val data = doc.rootElement
+        if (data.name != "data") {
+            return null
         }
 
-        private fun isXformsManifestNamespacedElement(e: Element): Boolean {
-            return e.namespace.equals(
-                NAMESPACE_OPENROSA_ORG_XFORMS_XFORMS_MANIFEST,
-                ignoreCase = true
-            )
+        try {
+            val entities = data.getElement(null, "entities")
+            return 0.until(entities.childCount).map {
+                val entity = entities.getElement(null, "entity")
+                val deleted = entity.getElement(null, "deleted").getChild(0) as String
+                EntityIntegrity(entity.getAttributeValue(null, "id"), deleted.toBooleanStrict())
path: open-rosa/src/main/java/org/odk/collect/openrosa/parse/Kxml2OpenRosaResponseParser.kt
user: login: seadowg
body: These are "machine-readable" rather than "human-readable" documents, so I don't think there should be any fuzziness. [The spec is going to state that valid values will be "true" or "false"](https://github.com/getodk/docs/pull/1926). 
created_at: 2025-03-17T16:19:43Z
author_association: MEMBER
diff_hunk: @@ -552,3 +775,28 @@ private class MeasurableEntitiesRepository(private val wrapped: EntitiesReposito
         return wrapped.getListHash(list)
     }
 }
+
+private class FakeEntitySource : EntitySource {
+
+    val integrityUrl = "http://example.com/${UUID.randomUUID()}"
+    var accesses: Int = 0
+        private set
+
+    private val deleted = mutableListOf<String>()
+
+    override fun isDeleted(integrityUrl: String, ids: List<String>): List<Pair<String, Boolean>> {
+        accesses += 1
+
+        if (integrityUrl == this.integrityUrl) {
+            return ids.map {
+                Pair(it, deleted.contains(it))
+            }
+        } else {
+            throw IllegalArgumentException()
path: entities/src/test/java/org/odk/collect/entities/LocalEntityUseCasesTest.kt
user: login: seadowg
body: That scenario isn't possible - the check prevents it. `integrityUrl == this.integrityUrl` is only always true because the implementation is correct. The check is equivalent to using a mockito mock for `EntitySource` and adding the following to all of the tests that use `fetchDeletedStates`:

```kotlin
verify(entitySource.fetchDeletedStates(expectedIntegrityUrl, expectedEntityIds)
```

Does that make sense? Would you like me to rename `FakeEntitySource` to `MockEntitySource` to make it clearer?
created_at: 2025-03-17T16:23:24Z
author_association: MEMBER

PR comments:
user: login: dbemke
created_at: 2025-03-19T10:12:56Z
body: What is the expected behavior when a user uses Collect with version containing the integrityUrl part but at the same time uses Central without that part? We're testing using staging but I also checked what happens when I try to use the test instance of Central (which doesn't include the integrityUrl part). The list of entities is empty but it's possible to create them locally. Is there a way to prevent the case in which a user updated Collect but Central isn't updated at the same time and he isn't able to download entities?
author_association: NONE
user: login: seadowg
created_at: 2025-03-19T10:24:28Z
body: > What is the expected behavior when a user uses Collect with version containing the integrityUrl part but at the same time uses Central without that part? We're testing using staging but I also checked what happens when I try to use the test instance of Central (which doesn't include the integrityUrl part). The list of entities is empty but it's possible to create them locally. Is there a way to prevent the case in which a user updated Collect but Central isn't updated at the same time and he isn't able to download entities?

In that scenario everything should work as normal, but without the extra feature described in #6231. Is that not what you're seeing? 
author_association: MEMBER
user: login: dbemke
created_at: 2025-03-19T10:32:15Z
body: > Is that not what you're seeing?

There are no entities in forms although in Central there are entities in those datasets. I'll file an issue.
author_association: NONE
user: login: dbemke
created_at: 2025-03-21T14:44:45Z
body: **Tested with Success!**

Verified on a device with Android 10

**Verified cases:**
- deleting entities works as expected in AC in #6231
- deleting locally created entities in forms with the new entity spec and the newest version of Central
- deleting locally created entities in forms with the new entity spec and the older version of Central
- deleting entities in forms with the old entity spec
- restoring deleted entities
- restoring deleted entities after sending an update to the deleted entity
- hide old versions enabled and disabled when deleting entities
- drafts of update forms using a deleted entity
- datasets with special characters
- savepoints in update forms after deleting an entity
author_association: NONE
user: login: WKobus
created_at: 2025-03-21T14:45:15Z
body: **Tested with Success!**

Verified on a device with Android 15
author_association: NONE