Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -221,29 +221,31 @@ class InstancesDataService(
instances: List<Instance>,
referrer: String,
overrideURL: String?,
cancelAfterAuthException: Boolean,
externalDeleteAfterUpload: Boolean?,
defaultSuccessMessage: String,
ensureActive: () -> Unit,
isCancelled: () -> Boolean,
onProgress: (current: Int, total: Int) -> Unit = { _, _ -> }
): List<InstanceUploadResult> {
val projectDependencyModule = projectDependencyModuleFactory.create(projectId)

return projectDependencyModule.instancesLock.withLock { acquiredLock: Boolean ->
if (acquiredLock) {
instanceSubmitter.submitInstances(
val result = instanceSubmitter.submitInstances(
projectId,
instances,
referrer,
overrideURL,
cancelAfterAuthException,
externalDeleteAfterUpload,
defaultSuccessMessage,
ensureActive,
onProgress
).also {
logResults(projectDependencyModule.formsRepository, it, auto = false)
}
cancelAfterAuthException = true,
externalDeleteAfterUpload = externalDeleteAfterUpload,
defaultSuccessMessage = defaultSuccessMessage,
isCancelled = isCancelled,
onProgress = onProgress
)

logResults(projectDependencyModule.formsRepository, result, auto = false)
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")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if we can introduce a generalization for this instead that builds on Cancellable - could be a CancellationToken class like this:

class CancellationToken : Cancellable {

    private var cancelled = false

    override fun cancel() {
        cancelled = true
    }

    fun ensureActive() {
        if (isCancelled()) {
            throw CancelledException()
        }
    }
}

Or just something similar!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I gave this a go, but I'd lean towards keeping the current approach. A CancellationToken would complicate the code, and it would decouple cancellation from the coroutine, so we'd drop the built-in mechanism (isCancelled = { !isActive }) we get for free.

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,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.odk.collect.android.instancemanagement.InstancesDataService
import org.odk.collect.android.utilities.WebCredentialsUtils
Expand Down Expand Up @@ -52,10 +52,9 @@ class InstanceUploadViewModel(
instancesToUpload,
referrer,
externalUrl,
true,
externalDeleteAfterUpload,
defaultSuccessMessage,
{ coroutineContext.ensureActive() }
isCancelled = { !isActive },
) { current, total ->
_state.postValue(UploadState.Progress(current, total))
}
Expand All @@ -71,7 +70,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 @@ -41,8 +41,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 @@ -167,6 +172,10 @@ class OpenRosaServerInstanceUploader(

val messageParser = ResponseMessageParser()

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

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

val responseCode = postResult.responseCode
messageParser.setMessageResponse(postResult.httpResponse)
Expand All @@ -200,6 +209,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.flush()

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