Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package org.odk.collect.android.instancemanagement

import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import org.odk.collect.analytics.Analytics
import org.odk.collect.android.analytics.AnalyticsEvents
import org.odk.collect.android.application.Collect
Expand Down Expand Up @@ -213,32 +215,34 @@ class InstancesDataService(
}
}

fun sendInstances(
suspend fun sendInstances(
projectId: String,
instances: List<Instance>,
referrer: String,
overrideURL: String?,
cancelAfterAuthException: Boolean,
externalDeleteAfterUpload: Boolean?,
defaultSuccessMessage: String,
ensureActive: () -> Unit,
onProgress: (current: Int, total: Int) -> Unit = { _, _ -> }
): List<InstanceUploadResult> {
val projectDependencyModule = projectDependencyModuleFactory.create(projectId)
val coroutineContext = currentCoroutineContext()

return projectDependencyModule.instancesLock.withLock { acquiredLock: Boolean ->
return projectDependencyModule.instancesLock.withLockSuspend { acquiredLock: Boolean ->
if (acquiredLock) {
instanceSubmitter.submitInstances(
val result = instanceSubmitter.submitInstances(
projectId,
instances,
referrer,
overrideURL,
cancelAfterAuthException,
externalDeleteAfterUpload,
defaultSuccessMessage,
ensureActive,
onProgress
cancelAfterAuthException = true,
externalDeleteAfterUpload = externalDeleteAfterUpload,
defaultSuccessMessage = defaultSuccessMessage,
isCancelled = { !coroutineContext.isActive },
onProgress = onProgress
)
update(projectId)

result
} else {
emptyList()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,10 @@ class FormUploadAuthRequestedException(
message: String,
val authRequestingServer: Uri
) : FormUploadException(message)

/**
* Thrown to indicate that an upload was interrupted because the submission attempt was cancelled.
* Unlike other [FormUploadException]s this should not be reported to the user as an error - it
* simply stops the current submission attempt.
*/
class FormUploadInterruptedException : FormUploadException("Upload interrupted")
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class InstanceSubmitter(
cancelAfterAuthException: Boolean = false,
externalDeleteAfterUpload: Boolean? = null,
defaultSuccessMessage: String? = null,
ensureActive: () -> Unit = {},
isCancelled: () -> Boolean = { false },
onProgress: (current: Int, total: Int) -> Unit = { _, _ -> }
): List<InstanceUploadResult> {
val projectDependencyModule = projectDependencyFactory.create(projectId)
Expand All @@ -40,14 +40,14 @@ class InstanceSubmitter(

val sortedInstances = toUpload.sortedBy { it.finalizationDate }
for ((index, instance) in sortedInstances.withIndex()) {
ensureActive()
onProgress( index + 1, sortedInstances.size)
onProgress(index + 1, sortedInstances.size)

try {
val resultMessage = instanceUploader.uploadOneSubmission(projectId, instance, deviceId, overrideURL, referrer)
val resultMessage = instanceUploader.uploadOneSubmission(projectId, instance, deviceId, overrideURL, referrer, isCancelled)
uploadResults.add(InstanceUploadResult.Success(instance, resultMessage ?: defaultSuccessMessage))

deleteInstance(instance, formsRepository, instancesRepository, generalSettings, externalDeleteAfterUpload)
} catch (_: FormUploadInterruptedException) {
break
} catch (e: FormUploadException) {
Timber.d(e)
uploadResults.add(InstanceUploadResult.Error(instance, e))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,12 @@ import org.odk.collect.forms.instances.Instance

interface InstanceUploader {
@Throws(FormUploadException::class)
fun uploadOneSubmission(projectId: String, instance: Instance, deviceId: String?, overrideURL: String?, referrer: String): String?
fun uploadOneSubmission(
projectId: String,
instance: Instance,
deviceId: String?,
overrideURL: String?,
referrer: String,
isCancelled: () -> Boolean
): String?
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import org.odk.collect.android.instancemanagement.InstancesDataService
import org.odk.collect.android.utilities.WebCredentialsUtils
Expand Down Expand Up @@ -52,10 +51,8 @@ class InstanceUploadViewModel(
instancesToUpload,
referrer,
externalUrl,
true,
externalDeleteAfterUpload,
defaultSuccessMessage,
{ coroutineContext.ensureActive() }
) { current, total ->
_state.postValue(UploadState.Progress(current, total))
}
Expand All @@ -71,7 +68,6 @@ class InstanceUploadViewModel(
}

clearTemporaryCredentials()
instancesDataService.update(projectId)
_state.postValue(UploadState.Completed(uploadResults))
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,13 @@ class OpenRosaServerInstanceUploader(
instance: Instance,
deviceId: String?,
overrideURL: String?,
referrer: String
referrer: String,
isCancelled: () -> Boolean
): String? {
if (isCancelled()) {
throw FormUploadInterruptedException()
}

val projectDependencyModule = projectDependencyFactory.create(projectId)
val unprotectedSettings = projectDependencyModule.generalSettings
val instancesRepository = projectDependencyModule.instancesRepository
Expand Down Expand Up @@ -170,6 +175,10 @@ class OpenRosaServerInstanceUploader(

val messageParser = ResponseMessageParser()

if (isCancelled()) {
throw FormUploadInterruptedException()
}

try {
val uri = URI.create(submissionUri.toString())
val postResult = httpInterface.uploadSubmissionAndFiles(
Expand All @@ -178,7 +187,7 @@ class OpenRosaServerInstanceUploader(
uri,
webCredentialsUtils.getCredentials(uri),
contentLength
)
) { isCancelled() }

val responseCode = postResult.responseCode
messageParser.setMessageResponse(postResult.httpResponse)
Expand All @@ -203,6 +212,9 @@ class OpenRosaServerInstanceUploader(
}

} catch (e: Exception) {
if (isCancelled()) {
throw FormUploadInterruptedException()
}
throw FormUploadException(e.message ?: e.toString())
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,102 @@ class InstanceUploadViewModelTest {
private lateinit var viewModel: InstanceUploadViewModel

@Test
fun `submitted instance is deleted even when upload is cancelled`() {
fun `instance is not submitted when upload is canceled`() {
val form = FormFixtures.form("1")
val formsRepository = InMemFormsRepository().apply {
save(form)
}

val instance = Instance.Builder()
.dbId(1)
.formId(form.formId)
.formVersion(form.version)
.status(Instance.STATUS_COMPLETE)
.finalizationDate(1)
.build()

val instancesRepository = InMemInstancesRepository().apply {
save(instance)
}

val projectsDependencyModuleFactory = CachingProjectDependencyModuleFactory { projectId ->
ProjectDependencyModule(
projectId,
{ InMemSettings() },
{ formsRepository },
{ instancesRepository },
mock(),
{ ChangeLocks(BooleanChangeLock(), BooleanChangeLock()) },
mock(),
mock(),
mock(),
mock(),
mock()
)
}

val submittedInstances = mutableListOf<Long>()

val instanceUploader = object : InstanceUploader {
override fun uploadOneSubmission(
projectId: String,
instance: Instance,
deviceId: String?,
overrideURL: String?,
referrer: String,
isCancelled: () -> Boolean
): String {
viewModel.cancel()
if (isCancelled()) {
throw FormUploadInterruptedException()
}

submittedInstances.add(instance.dbId)
instancesRepository.save(
Instance.Builder(instance)
.status(Instance.STATUS_SUBMITTED)
.build()
)
return "Success"
}
}

val instancesSubmitter = InstanceSubmitter(
instanceUploader,
projectsDependencyModuleFactory,
mock()
)
val instancesDataService = InstancesDataService(
AppState(),
mock(),
projectsDependencyModuleFactory,
mock(),
instancesSubmitter
) {}

val dispatcherProvider = TestDispatcherProvider()
viewModel = InstanceUploadViewModel(
dispatcherProvider,
mock(),
instancesRepository,
instancesDataService,
"projectId",
"",
null,
null,
null,
null,
"Success",
"Waiting"
)
viewModel.upload(listOf(instance.dbId))
dispatcherProvider.runBackground()

assertThat(submittedInstances.isEmpty(), equalTo(true))
}

@Test
fun `instances uploaded before cancellation get deleted`() {
val form = FormFixtures.form("1")
val formsRepository = InMemFormsRepository().apply {
save(form)
Expand Down Expand Up @@ -75,21 +170,29 @@ class InstanceUploadViewModelTest {

val submittedInstances = mutableListOf<Long>()

var progress = 0;
val instanceUploader = object : InstanceUploader {
override fun uploadOneSubmission(
projectId: String,
instance: Instance,
deviceId: String?,
overrideURL: String?,
referrer: String
referrer: String,
isCancelled: () -> Boolean
): String {
if (++progress == 2) {
viewModel.cancel()
}
if (isCancelled()) {
throw FormUploadInterruptedException()
}

submittedInstances.add(instance.dbId)
instancesRepository.save(
Instance.Builder(instance)
.status(Instance.STATUS_SUBMITTED)
.build()
)
viewModel.cancel()
return "Success"
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import java.io.IOException;
import java.net.URI;
import java.util.List;
import java.util.function.Supplier;

public interface OpenRosaHttpInterface {

Expand Down Expand Up @@ -66,6 +67,21 @@ HttpPostResult uploadSubmissionAndFiles(@NonNull File submissionFile,
@NonNull HttpCredentialsInterface credentials,
@NonNull long contentLength) throws Exception;

/**
* Variant of {@link #uploadSubmissionAndFiles} that aborts an in-progress upload when
* {@code isCancelled} starts returning {@code true}. The default delegates to the
* non-cancellable variant so existing implementations keep working.
*/
@NonNull
default HttpPostResult uploadSubmissionAndFiles(@NonNull File submissionFile,
@NonNull List<File> fileList,
@NonNull URI uri,
@NonNull HttpCredentialsInterface credentials,
@NonNull long contentLength,
@NonNull Supplier<Boolean> isCancelled) throws Exception {
return uploadSubmissionAndFiles(submissionFile, fileList, uri, credentials, contentLength);
}

interface FileToContentTypeMapper {

@NonNull
Expand Down
Loading