diff --git a/.circleci/config.yml b/.circleci/config.yml index 85a93ad8fe3..21a441e12ce 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,7 +7,7 @@ references: android_config: &android_config working_directory: ~/work docker: - - image: cimg/android:2023.10.1 + - image: cimg/android:2024.01 resource_class: medium+ jobs: @@ -17,7 +17,7 @@ jobs: - checkout - run: name: Generate combined build.gradle file for cache key - command: cat build.gradle */build.gradle .circleci/gradle.properties .circleci/config.yml buildSrc/src/main/java/dependencies/Dependencies.kt buildSrc/src/main/java/dependencies/Versions.kt > deps.txt + command: cat build.gradle */build.gradle */build.gradle.kts .circleci/gradle.properties .circleci/config.yml buildSrc/src/main/java/dependencies/Dependencies.kt buildSrc/src/main/java/dependencies/Versions.kt > deps.txt - restore_cache: keys: - compile-deps-{{ checksum "deps.txt" }} @@ -230,8 +230,8 @@ jobs: command: ./gradlew assembleSelfSignedRelease - run: - name: Check APK size isn't larger than 10.6MB - command: if [ $(ls -l collect_app/build/outputs/apk/selfSignedRelease/*.apk | awk '{print $5}') -gt 10600000 ]; then exit 1; fi + name: Check APK size isn't larger than 11.5MB + command: ./check-size.sh - run: name: Copy APK to predictable path for artifact storage @@ -282,7 +282,7 @@ jobs: --type instrumentation \ --app collect_app/build/outputs/apk/debug/*.apk \ --test collect_app/build/outputs/apk/androidTest/debug/*.apk \ - --device model=MediumPhone.arm,version=30,locale=en,orientation=portrait \ + --device model=MediumPhone.arm,version=34,locale=en,orientation=portrait \ --results-bucket opendatakit-collect-test-results \ --directories-to-pull /sdcard --timeout 20m \ --test-targets "package org.odk.collect.android.feature.smoke" @@ -333,9 +333,10 @@ jobs: --num-uniform-shards=25 \ --app collect_app/build/outputs/apk/debug/*.apk \ --test collect_app/build/outputs/apk/androidTest/debug/*.apk \ - --device model=MediumPhone.arm,version=30,locale=en,orientation=portrait \ + --device model=MediumPhone.arm,version=34,locale=en,orientation=portrait \ --results-bucket opendatakit-collect-test-results \ - --directories-to-pull /sdcard --timeout 20m + --directories-to-pull /sdcard --timeout 20m \ + --test-targets "notPackage org.odk.collect.android.regression" fi no_output_timeout: 25m diff --git a/.circleci/test_modules.txt b/.circleci/test_modules.txt index e1d92308272..6be8921d337 100644 --- a/.circleci/test_modules.txt +++ b/.circleci/test_modules.txt @@ -1,10 +1,10 @@ shared -formstest +forms-test androidshared async strings -audioclips -audiorecorder +audio-clips +audio-recorder projects location geo @@ -21,3 +21,5 @@ metadata selfie-camera draw printer +lists +web-page \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000000..f70e84d0a9b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +root = true + +[*.{kt,kts}] +ktlint_standard_no-blank-lines-in-chained-method-calls = disabled +ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_trailing-comma-on-declaration-site = disabled +ktlint_standard_function-signature = disabled +ktlint_standard_no-empty-first-line-in-class-body = disabled +ktlint_standard_argument-list-wrapping = disabled +ktlint_standard_parameter-list-wrapping = disabled +ktlint_standard_multiline-expression-wrapping = disabled +ktlint_standard_max-line-length = disabled +ktlint_standard_string-template-indent = disabled +ktlint_standard_annotation = disabled +ktlint_standard_value-parameter-comment = disabled +ktlint_standard_property-naming = disabled +ktlint_standard_value-argument-comment = disabled +ktlint_standard_blank-line-before-declaration = disabled +ktlint_standard_no-consecutive-comments = disabled +ktlint_standard_enum-wrapping = disabled +ktlint_standard_statement-wrapping = disabled +ktlint_standard_try-catch-finally-spacing = disabled +ktlint_standard_wrapping = disabled diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 711c302cb3c..4a92861ba59 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -19,5 +19,6 @@ https://github.com/getodk/collect/blob/master/docs/CONTRIBUTING.md - [ ] added or modified tests for any new or changed behavior - [ ] run `./gradlew connectedAndroidTest` (or `./gradlew testLab`) and confirmed all checks still pass - [ ] added a comment above any new strings describing it for translators +- [ ] added any new strings with date formatting to `DateFormatsTest` - [ ] 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). - [ ] 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) diff --git a/analytics/build.gradle.kts b/analytics/build.gradle.kts index 0b1f861b86f..ba1e982748a 100644 --- a/analytics/build.gradle.kts +++ b/analytics/build.gradle.kts @@ -37,7 +37,5 @@ dependencies { implementation(Dependencies.kotlin_stdlib) implementation(Dependencies.androidx_core_ktx) implementation(Dependencies.firebase_crashlytics) - implementation(Dependencies.firebase_analytics) { - exclude(group = "com.google.guava") - } + implementation(Dependencies.firebase_analytics) } diff --git a/analytics/src/main/java/org/odk/collect/analytics/Analytics.kt b/analytics/src/main/java/org/odk/collect/analytics/Analytics.kt index 6bfefeb6080..033392ea33d 100644 --- a/analytics/src/main/java/org/odk/collect/analytics/Analytics.kt +++ b/analytics/src/main/java/org/odk/collect/analytics/Analytics.kt @@ -41,5 +41,9 @@ interface Analytics { fun setParam(key: String, value: String) { params[key] = value } + + fun setUserProperty(name: String, value: String) { + instance.setUserProperty(name, value) + } } } diff --git a/androidshared/build.gradle.kts b/androidshared/build.gradle.kts index 0d51c83389a..ba4af62ad8e 100644 --- a/androidshared/build.gradle.kts +++ b/androidshared/build.gradle.kts @@ -59,6 +59,7 @@ dependencies { implementation(Dependencies.play_services_location) testImplementation(project(":test-shared")) + testImplementation(project(":androidtest")) testImplementation(Dependencies.junit) testImplementation(Dependencies.androidx_test_ext_junit) testImplementation(Dependencies.androidx_test_espresso_core) diff --git a/androidshared/src/androidTest/java/org/odk/collect/androidshared/bitmap/ImageCompressorTest.kt b/androidshared/src/androidTest/java/org/odk/collect/androidshared/bitmap/ImageCompressorTest.kt index 72fee68da20..ce570081657 100644 --- a/androidshared/src/androidTest/java/org/odk/collect/androidshared/bitmap/ImageCompressorTest.kt +++ b/androidshared/src/androidTest/java/org/odk/collect/androidshared/bitmap/ImageCompressorTest.kt @@ -153,7 +153,6 @@ class ImageCompressorTest { ExifInterface.TAG_GPS_SATELLITES to "8", ExifInterface.TAG_GPS_STATUS to "A", ExifInterface.TAG_ORIENTATION to "1", - // unsupported exif tags ExifInterface.TAG_THUMBNAIL_IMAGE_LENGTH to "5", ExifInterface.TAG_DNG_VERSION to "100" diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/async/TrackableWorker.kt b/androidshared/src/main/java/org/odk/collect/androidshared/async/TrackableWorker.kt index b6ad2e521d0..2360b5fd63c 100644 --- a/androidshared/src/main/java/org/odk/collect/androidshared/async/TrackableWorker.kt +++ b/androidshared/src/main/java/org/odk/collect/androidshared/async/TrackableWorker.kt @@ -3,6 +3,7 @@ package org.odk.collect.androidshared.async import org.odk.collect.androidshared.livedata.MutableNonNullLiveData import org.odk.collect.androidshared.livedata.NonNullLiveData import org.odk.collect.async.Scheduler +import java.util.concurrent.atomic.AtomicInteger import java.util.function.Consumer import java.util.function.Supplier @@ -11,10 +12,15 @@ class TrackableWorker(private val scheduler: Scheduler) { private val _isWorking = MutableNonNullLiveData(false) val isWorking: NonNullLiveData = _isWorking + private var activeBackgroundJobsCounter = AtomicInteger(0) + fun immediate(background: Supplier, foreground: Consumer) { + activeBackgroundJobsCounter.incrementAndGet() _isWorking.value = true scheduler.immediate(background) { result -> - _isWorking.value = false + if (activeBackgroundJobsCounter.decrementAndGet() == 0) { + _isWorking.value = false + } foreground.accept(result) } } diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/bitmap/ImageFileUtils.kt b/androidshared/src/main/java/org/odk/collect/androidshared/bitmap/ImageFileUtils.kt index ad1b8d18310..50e41549441 100644 --- a/androidshared/src/main/java/org/odk/collect/androidshared/bitmap/ImageFileUtils.kt +++ b/androidshared/src/main/java/org/odk/collect/androidshared/bitmap/ImageFileUtils.kt @@ -150,12 +150,12 @@ object ImageFileUtils { } if (sourceFileExif == null || !EXIF_ORIENTATION_ROTATIONS.contains( - sourceFileExif - .getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_UNDEFINED - ) - ) + sourceFileExif + .getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_UNDEFINED + ) + ) ) { // Source Image doesn't have any EXIF Rotations, so a normal file copy will suffice sourceFile.copyTo(destFile, true) diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/data/AppState.kt b/androidshared/src/main/java/org/odk/collect/androidshared/data/AppState.kt index 3dbe391d097..e18ea2bddbf 100644 --- a/androidshared/src/main/java/org/odk/collect/androidshared/data/AppState.kt +++ b/androidshared/src/main/java/org/odk/collect/androidshared/data/AppState.kt @@ -8,6 +8,8 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow /** * [AppState] can be used as a shared store of state that lives at an "app"/"in-memory" level @@ -50,6 +52,10 @@ class AppState { return get(key, MutableLiveData(default)) } + fun getFlow(key: String, default: T): Flow { + return get(key, MutableStateFlow(default)) + } + fun set(key: String, value: Any?) { map[key] = value } @@ -58,6 +64,16 @@ class AppState { get(key, MutableLiveData()).postValue(value) } + fun setFlow(key: String, value: T) { + get>(key).let { + if (it != null) { + it.value = value + } else { + map[key] = MutableStateFlow(value) + } + } + } + fun clear() { map.clear() } diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/data/Consumable.kt b/androidshared/src/main/java/org/odk/collect/androidshared/data/Consumable.kt index a945258c59b..317bbb3c0cd 100644 --- a/androidshared/src/main/java/org/odk/collect/androidshared/data/Consumable.kt +++ b/androidshared/src/main/java/org/odk/collect/androidshared/data/Consumable.kt @@ -1,5 +1,8 @@ package org.odk.collect.androidshared.data +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData + /** * Useful for values that are read multiple times but only used * once (like an error that shows a dialog once). @@ -16,3 +19,12 @@ data class Consumable(val value: T) { consumed = true } } + +fun LiveData>.consume(lifecycleOwner: LifecycleOwner, consumer: (T) -> Unit) { + observe(lifecycleOwner) { consumable -> + if (!consumable.isConsumed()) { + consumable.consume() + consumer(consumable.value) + } + } +} diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/livedata/LiveDataUtils.java b/androidshared/src/main/java/org/odk/collect/androidshared/livedata/LiveDataUtils.java index 02f7235e032..71c2a6839ad 100644 --- a/androidshared/src/main/java/org/odk/collect/androidshared/livedata/LiveDataUtils.java +++ b/androidshared/src/main/java/org/odk/collect/androidshared/livedata/LiveDataUtils.java @@ -14,6 +14,7 @@ import java.util.function.Consumer; import java.util.function.Function; +import kotlin.Pair; import kotlin.Triple; public class LiveDataUtils { @@ -54,6 +55,13 @@ public static LiveData liveDataOf(T value) { return new MutableLiveData<>(value); } + public static LiveData> zip(LiveData one, LiveData two) { + return new ZippedLiveData<>( + new LiveData[]{one, two}, + values -> new Pair<>((T) values[0], (U) values[1]) + ); + } + public static LiveData> zip3(LiveData one, LiveData two, LiveData three) { return new ZippedLiveData<>( new LiveData[]{one, two, three}, diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/network/ConnectivityProvider.kt b/androidshared/src/main/java/org/odk/collect/androidshared/network/ConnectivityProvider.kt deleted file mode 100644 index 39faf56766e..00000000000 --- a/androidshared/src/main/java/org/odk/collect/androidshared/network/ConnectivityProvider.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.odk.collect.androidshared.network - -import android.content.Context -import android.net.ConnectivityManager -import android.net.NetworkInfo - -class ConnectivityProvider(private val context: Context) : NetworkStateProvider { - override val isDeviceOnline: Boolean - get() { - val networkInfo = networkInfo - return networkInfo != null && networkInfo.isConnected - } - - override val networkInfo: NetworkInfo? - get() = connectivityManager.activeNetworkInfo - - private val connectivityManager: ConnectivityManager - get() = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager -} diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/network/NetworkStateProvider.kt b/androidshared/src/main/java/org/odk/collect/androidshared/network/NetworkStateProvider.kt deleted file mode 100644 index d3b9e983c58..00000000000 --- a/androidshared/src/main/java/org/odk/collect/androidshared/network/NetworkStateProvider.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.odk.collect.androidshared.network - -import android.net.NetworkInfo - -interface NetworkStateProvider { - val isDeviceOnline: Boolean - val networkInfo: NetworkInfo? -} diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/system/UriExt.kt b/androidshared/src/main/java/org/odk/collect/androidshared/system/UriExt.kt new file mode 100644 index 00000000000..9244e13c53f --- /dev/null +++ b/androidshared/src/main/java/org/odk/collect/androidshared/system/UriExt.kt @@ -0,0 +1,93 @@ +package org.odk.collect.androidshared.system + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import androidx.core.net.toFile +import java.io.File +import java.io.FileOutputStream + +fun Uri.copyToFile(context: Context, dest: File) { + try { + context.contentResolver.openInputStream(this)?.use { inputStream -> + FileOutputStream(dest).use { outputStream -> + inputStream.copyTo(outputStream) + } + } + } catch (e: Exception) { + // ignore + } +} + +fun Uri.getFileExtension(context: Context): String? { + var extension = getFileName(context)?.substringAfterLast(".", "") + + if (extension.isNullOrEmpty()) { + val mimeType = context.contentResolver.getType(this) + + extension = if (scheme == ContentResolver.SCHEME_CONTENT) { + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) + } else { + MimeTypeMap.getFileExtensionFromUrl(toString()) + } + + if (extension.isNullOrEmpty()) { + extension = mimeType?.substringAfterLast("/", "") + } + } + + return if (extension.isNullOrEmpty()) { + null + } else { + ".$extension" + } +} + +fun Uri.getFileName(context: Context): String? { + var fileName: String? = null + + try { + when (scheme) { + ContentResolver.SCHEME_FILE -> fileName = toFile().name + ContentResolver.SCHEME_CONTENT -> { + val cursor = context.contentResolver.query(this, null, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + val fileNameColumnIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (fileNameColumnIndex != -1) { + fileName = it.getString(fileNameColumnIndex) + } + } + } + } + ContentResolver.SCHEME_ANDROID_RESOURCE -> { + // for uris like [android.resource://com.example.app/1234567890] + val resourceId = lastPathSegment?.toIntOrNull() + if (resourceId != null) { + fileName = context.resources.getResourceName(resourceId) + } else { + // for uris like [android.resource://com.example.app/raw/sample] + val packageName = authority + if (pathSegments.size >= 2) { + val resourceType = pathSegments[0] + val resourceEntryName = pathSegments[1] + val resId = context.resources.getIdentifier(resourceEntryName, resourceType, packageName) + if (resId != 0) { + fileName = context.resources.getResourceName(resId) + } + } + } + } + } + + if (fileName == null) { + fileName = path?.substringAfterLast("/") + } + } catch (e: Exception) { + // ignore + } + + return fileName +} diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/ui/DialogFragmentUtils.kt b/androidshared/src/main/java/org/odk/collect/androidshared/ui/DialogFragmentUtils.kt index 379b3582d58..3d4386cecbc 100644 --- a/androidshared/src/main/java/org/odk/collect/androidshared/ui/DialogFragmentUtils.kt +++ b/androidshared/src/main/java/org/odk/collect/androidshared/ui/DialogFragmentUtils.kt @@ -4,6 +4,7 @@ import android.os.Bundle import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentManager import timber.log.Timber +import kotlin.reflect.KClass object DialogFragmentUtils { @@ -86,4 +87,8 @@ object DialogFragmentUtils { } } } + + fun FragmentManager.showIfNotShowing(dialogClass: KClass) { + showIfNotShowing(dialogClass.java, this) + } } diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/ui/GroupClickListener.kt b/androidshared/src/main/java/org/odk/collect/androidshared/ui/GroupClickListener.kt index 358b13a3084..2a2017dd79d 100644 --- a/androidshared/src/main/java/org/odk/collect/androidshared/ui/GroupClickListener.kt +++ b/androidshared/src/main/java/org/odk/collect/androidshared/ui/GroupClickListener.kt @@ -4,10 +4,14 @@ import android.view.View import androidx.constraintlayout.widget.Group // https://stackoverflow.com/questions/59020818/group-multiple-views-in-constraint-layout-to-set-only-one-click-listener -object GroupClickListener { - fun Group.addOnClickListener(listener: (view: View) -> Unit) { - referencedIds.forEach { id -> - rootView.findViewById(id).setOnClickListener(listener) - } +fun Group.addOnClickListener(listener: (view: View) -> Unit) { + referencedIds.forEach { id -> + rootView.findViewById(id).setOnClickListener(listener) + } +} + +fun List.addOnClickListener(listener: (view: View) -> Unit) { + forEach { + it.setOnClickListener(listener) } } diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/ui/ListFragmentStateAdapter.kt b/androidshared/src/main/java/org/odk/collect/androidshared/ui/ListFragmentStateAdapter.kt new file mode 100644 index 00000000000..8749dc9c947 --- /dev/null +++ b/androidshared/src/main/java/org/odk/collect/androidshared/ui/ListFragmentStateAdapter.kt @@ -0,0 +1,24 @@ +package org.odk.collect.androidshared.ui + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter + +class ListFragmentStateAdapter( + activity: FragmentActivity, + private val fragments: List> +) : FragmentStateAdapter(activity) { + + private val fragmentFactory = activity.supportFragmentManager.fragmentFactory + + override fun createFragment(position: Int): Fragment { + return fragmentFactory.instantiate( + Thread.currentThread().contextClassLoader, + fragments[position].name + ) + } + + override fun getItemCount(): Int { + return fragments.size + } +} diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/ui/MultiSelectViewModel.kt b/androidshared/src/main/java/org/odk/collect/androidshared/ui/MultiSelectViewModel.kt deleted file mode 100644 index 8eee73ff923..00000000000 --- a/androidshared/src/main/java/org/odk/collect/androidshared/ui/MultiSelectViewModel.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.odk.collect.androidshared.ui - -import android.widget.Button -import androidx.lifecycle.ViewModel -import org.odk.collect.androidshared.livedata.MutableNonNullLiveData -import org.odk.collect.androidshared.livedata.NonNullLiveData - -class MultiSelectViewModel : ViewModel() { - - private val selected = MutableNonNullLiveData(emptySet()) - - fun select(item: Long) { - selected.value = selected.value + item - } - - fun getSelected(): NonNullLiveData> { - return selected - } - - fun unselect(item: Long) { - selected.value = selected.value - item - } - - fun unselectAll() { - selected.value = emptySet() - } - - fun toggle(item: Long) { - if (selected.value.contains(item)) { - unselect(item) - } else { - select(item) - } - } -} - -fun updateSelectAll(button: Button, itemCount: Int, selectedCount: Int): Boolean { - val allSelected = itemCount > 0 && selectedCount == itemCount - - if (allSelected) { - button.setText(org.odk.collect.strings.R.string.clear_all) - } else { - button.setText(org.odk.collect.strings.R.string.select_all) - } - - return allSelected -} diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/ui/SnackbarUtils.kt b/androidshared/src/main/java/org/odk/collect/androidshared/ui/SnackbarUtils.kt index f228695874c..0afe9da8320 100644 --- a/androidshared/src/main/java/org/odk/collect/androidshared/ui/SnackbarUtils.kt +++ b/androidshared/src/main/java/org/odk/collect/androidshared/ui/SnackbarUtils.kt @@ -133,12 +133,12 @@ object SnackbarUtils { data class Action(val text: String, val listener: () -> Unit) abstract class SnackbarPresenterObserver(private val parentView: View) : - Observer> { + Observer?> { abstract fun getSnackbarDetails(value: T): SnackbarDetails - override fun onChanged(consumable: Consumable) { - if (!consumable.isConsumed()) { + override fun onChanged(consumable: Consumable?) { + if (consumable != null && !consumable.isConsumed()) { showLongSnackbar(parentView, getSnackbarDetails(consumable.value)) consumable.consume() } diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/ui/multiclicksafe/DoubleClickSafeMaterialButton.kt b/androidshared/src/main/java/org/odk/collect/androidshared/ui/multiclicksafe/DoubleClickSafeMaterialButton.kt new file mode 100644 index 00000000000..d497a82e530 --- /dev/null +++ b/androidshared/src/main/java/org/odk/collect/androidshared/ui/multiclicksafe/DoubleClickSafeMaterialButton.kt @@ -0,0 +1,24 @@ +package org.odk.collect.androidshared.ui.multiclicksafe + +import android.content.Context +import android.util.AttributeSet +import com.google.android.material.button.MaterialButton + +class DoubleClickSafeMaterialButton @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : MaterialButton(context, attrs, defStyleAttr) { + + override fun performClick(): Boolean { + return allowClick() && super.performClick() + } + + /** + * Use [MultiClickGuard] with a scope unique to this object (class name + hash). + */ + private fun allowClick(): Boolean { + val scope = javaClass.name + hashCode() + return MultiClickGuard.allowClick(scope) + } +} diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/ui/multiclicksafe/MultiClickGuard.kt b/androidshared/src/main/java/org/odk/collect/androidshared/ui/multiclicksafe/MultiClickGuard.kt index bf5e0bf3efd..5bc1ffb6fca 100644 --- a/androidshared/src/main/java/org/odk/collect/androidshared/ui/multiclicksafe/MultiClickGuard.kt +++ b/androidshared/src/main/java/org/odk/collect/androidshared/ui/multiclicksafe/MultiClickGuard.kt @@ -15,21 +15,21 @@ object MultiClickGuard { } /** - * Debounce multiple clicks within the same screen + * Debounce multiple clicks within the same scope * - * @param screenName The name of the screen. If not provided, the Java class name of the element + * @param scope If not provided, the Java class name of the element * is used. However, this approach is imperfect, as elements on the same screen might belong to * different classes. Consequently, clicks on these elements are treated as interactions occurring * on two distinct screens, not protecting from rapid clicking. */ @JvmStatic @JvmOverloads - fun allowClick(screenName: String = javaClass.name, clickDebounceMs: Long = 1000): Boolean { + fun allowClick(scope: String = javaClass.name, clickDebounceMs: Long = 1000): Boolean { if (test) { return true } val elapsedRealtime = SystemClock.elapsedRealtime() - val isSameClass = screenName == lastClickName + val isSameClass = scope == lastClickName val isBeyondThreshold = elapsedRealtime - lastClickTime > clickDebounceMs val isBeyondTestThreshold = lastClickTime == 0L || lastClickTime == elapsedRealtime // just for tests @@ -38,7 +38,7 @@ object MultiClickGuard { if (allowClick) { lastClickTime = elapsedRealtime - lastClickName = screenName + lastClickName = scope } return allowClick } diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/ui/multiclicksafe/MultiClickSafeButton.kt b/androidshared/src/main/java/org/odk/collect/androidshared/ui/multiclicksafe/MultiClickSafeButton.kt deleted file mode 100644 index 8e6898cbd88..00000000000 --- a/androidshared/src/main/java/org/odk/collect/androidshared/ui/multiclicksafe/MultiClickSafeButton.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.odk.collect.androidshared.ui.multiclicksafe - -import android.content.Context -import android.util.AttributeSet -import com.google.android.material.button.MaterialButton -import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickGuard.allowClick - -class MultiClickSafeButton : MaterialButton { - constructor(context: Context) : super(context) - - constructor(context: Context, attrs: AttributeSet?) : super( - context, - attrs - ) - - override fun performClick(): Boolean { - return allowClick() && super.performClick() - } -} diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/ui/multiclicksafe/MultiClickSafeImageButton.kt b/androidshared/src/main/java/org/odk/collect/androidshared/ui/multiclicksafe/MultiClickSafeImageButton.kt deleted file mode 100644 index 109c7355cf4..00000000000 --- a/androidshared/src/main/java/org/odk/collect/androidshared/ui/multiclicksafe/MultiClickSafeImageButton.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.odk.collect.androidshared.ui.multiclicksafe - -import android.content.Context -import android.util.AttributeSet -import androidx.appcompat.widget.AppCompatImageButton -import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickGuard.allowClick - -class MultiClickSafeImageButton : AppCompatImageButton { - constructor(context: Context) : super(context) - - constructor(context: Context, attrs: AttributeSet?) : super( - context, - attrs - ) - - override fun performClick(): Boolean { - return allowClick() && super.performClick() - } -} diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/ui/multiclicksafe/MultiClickSafeMaterialButton.kt b/androidshared/src/main/java/org/odk/collect/androidshared/ui/multiclicksafe/MultiClickSafeMaterialButton.kt new file mode 100644 index 00000000000..9e67063e79d --- /dev/null +++ b/androidshared/src/main/java/org/odk/collect/androidshared/ui/multiclicksafe/MultiClickSafeMaterialButton.kt @@ -0,0 +1,26 @@ +package org.odk.collect.androidshared.ui.multiclicksafe + +import android.content.Context +import android.util.AttributeSet +import androidx.core.content.withStyledAttributes +import com.google.android.material.button.MaterialButton +import org.odk.collect.androidshared.R +import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickGuard.allowClick + +open class MultiClickSafeMaterialButton @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : MaterialButton(context, attrs, defStyleAttr) { + private lateinit var screenName: String + + init { + context.withStyledAttributes(attrs, R.styleable.MultiClickSafeMaterialButton) { + screenName = getString(R.styleable.MultiClickSafeMaterialButton_screenName) ?: javaClass.name + } + } + + override fun performClick(): Boolean { + return allowClick(screenName) && super.performClick() + } +} diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/utils/ColorUtils.kt b/androidshared/src/main/java/org/odk/collect/androidshared/utils/ColorUtils.kt new file mode 100644 index 00000000000..c3cf1bd3c95 --- /dev/null +++ b/androidshared/src/main/java/org/odk/collect/androidshared/utils/ColorUtils.kt @@ -0,0 +1,27 @@ +package org.odk.collect.androidshared.utils + +import android.graphics.Color +import androidx.annotation.ColorInt + +@ColorInt +fun String.toColorInt() = try { + var sanitizedColor = if (this.startsWith("#")) { + this + } else { + "#$this" + } + + if (sanitizedColor.length == 4) { + sanitizedColor = shorthandToLonghandHexColor(sanitizedColor) + } + + Color.parseColor(sanitizedColor) +} catch (e: IllegalArgumentException) { + null +} + +private fun shorthandToLonghandHexColor(shorthandColor: String): String { + return shorthandColor.substring(1).fold("#") { accum, char -> + "$accum$char$char" + } +} diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/utils/PathUtils.kt b/androidshared/src/main/java/org/odk/collect/androidshared/utils/PathUtils.kt new file mode 100644 index 00000000000..2f2be8c598c --- /dev/null +++ b/androidshared/src/main/java/org/odk/collect/androidshared/utils/PathUtils.kt @@ -0,0 +1,26 @@ +package org.odk.collect.androidshared.utils + +import timber.log.Timber +import java.io.File + +object PathUtils { + @JvmStatic + fun getAbsoluteFilePath(dirPath: String, filePath: String): String { + val absoluteFilePath = + if (filePath.startsWith(dirPath)) filePath else dirPath + File.separator + filePath + + val canonicalAbsoluteFilePath = File(absoluteFilePath).canonicalPath + val canonicalDirPath = File(dirPath).canonicalPath + if (!canonicalAbsoluteFilePath.startsWith(canonicalDirPath)) { + Timber.e( + "Attempt to access file outside of Collect directory:\n" + + "dirPath: $dirPath\n" + + "filePath: $filePath\n" + + "absoluteFilePath: $absoluteFilePath\n" + + "canonicalAbsoluteFilePath: $canonicalAbsoluteFilePath\n" + + "canonicalDirPath: $canonicalDirPath" + ) + } + return absoluteFilePath + } +} diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/utils/Validator.kt b/androidshared/src/main/java/org/odk/collect/androidshared/utils/Validator.kt index c9d53517bb2..1e4a3fea7f1 100644 --- a/androidshared/src/main/java/org/odk/collect/androidshared/utils/Validator.kt +++ b/androidshared/src/main/java/org/odk/collect/androidshared/utils/Validator.kt @@ -23,9 +23,9 @@ object Validator { /* There are lots of ways to validate email addresses and it's hard to find one perfect. That's why we use here a very simple approach just to confirm that passed string contains: - *any number of characters before @ (at least one) - *one @ char - *any number of characters after @ (at least one) + -any number of characters before @ (at least one) + -one @ char + -any number of characters after @ (at least one) */ @JvmStatic fun isEmailAddressValid(emailAddress: String): Boolean { diff --git a/collect_app/src/main/res/drawable/list_item_divider.xml b/androidshared/src/main/res/drawable/list_item_divider.xml similarity index 100% rename from collect_app/src/main/res/drawable/list_item_divider.xml rename to androidshared/src/main/res/drawable/list_item_divider.xml diff --git a/androidshared/src/main/res/drawable/radio_button_inset.xml b/androidshared/src/main/res/drawable/radio_button_inset.xml new file mode 100644 index 00000000000..1b1c88bac87 --- /dev/null +++ b/androidshared/src/main/res/drawable/radio_button_inset.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/androidshared/src/main/res/values/attrs.xml b/androidshared/src/main/res/values/attrs.xml new file mode 100644 index 00000000000..8345d36d90e --- /dev/null +++ b/androidshared/src/main/res/values/attrs.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/androidshared/src/main/res/values/styles.xml b/androidshared/src/main/res/values/styles.xml new file mode 100644 index 00000000000..dec58b91516 --- /dev/null +++ b/androidshared/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + + diff --git a/androidshared/src/test/java/org/odk/collect/androidshared/async/TrackableWorkerTest.kt b/androidshared/src/test/java/org/odk/collect/androidshared/async/TrackableWorkerTest.kt new file mode 100644 index 00000000000..4a66f7fc614 --- /dev/null +++ b/androidshared/src/test/java/org/odk/collect/androidshared/async/TrackableWorkerTest.kt @@ -0,0 +1,28 @@ +package org.odk.collect.androidshared.async + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.odk.collect.testshared.FakeScheduler + +@RunWith(AndroidJUnit4::class) +class TrackableWorkerTest { + private val scheduler = FakeScheduler() + private val trackableWorker = TrackableWorker(scheduler) + + @Test + fun `TrackableWorker counts work in progress`() { + trackableWorker.immediate {} + trackableWorker.immediate {} + + scheduler.runFirstBackground() + scheduler.runFirstForeground() + assertThat(trackableWorker.isWorking.value, equalTo(true)) + + scheduler.runFirstBackground() + scheduler.runFirstForeground() + assertThat(trackableWorker.isWorking.value, equalTo(false)) + } +} diff --git a/androidshared/src/test/java/org/odk/collect/androidshared/system/UriExtTest.kt b/androidshared/src/test/java/org/odk/collect/androidshared/system/UriExtTest.kt new file mode 100644 index 00000000000..9dafdde3ef4 --- /dev/null +++ b/androidshared/src/test/java/org/odk/collect/androidshared/system/UriExtTest.kt @@ -0,0 +1,44 @@ +package org.odk.collect.androidshared.system + +import android.app.Application +import androidx.core.net.toUri +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.odk.collect.shared.TempFiles + +@RunWith(AndroidJUnit4::class) +class UriExtTest { + private val context = ApplicationProvider.getApplicationContext() + + @Test + fun `copyToFile copies the source file to the target file`() { + val sourceFile = TempFiles.createTempFile().also { + it.writeText("blah") + } + val sourceFileUri = sourceFile.toUri() + val targetFile = TempFiles.createTempFile() + + sourceFileUri.copyToFile(context, targetFile) + assertThat(targetFile.readText(), equalTo(sourceFile.readText())) + } + + @Test + fun `getFileExtension returns file extension`() { + val file = TempFiles.createTempFile(".jpg") + val fileUri = file.toUri() + + assertThat(fileUri.getFileExtension(context), equalTo(".jpg")) + } + + @Test + fun `getFileName returns file name`() { + val file = TempFiles.createTempFile() + val fileUri = file.toUri() + + assertThat(fileUri.getFileName(context), equalTo(file.name)) + } +} diff --git a/androidshared/src/test/java/org/odk/collect/androidshared/ui/MultiSelectViewModelTest.kt b/androidshared/src/test/java/org/odk/collect/androidshared/ui/MultiSelectViewModelTest.kt deleted file mode 100644 index 76075be8583..00000000000 --- a/androidshared/src/test/java/org/odk/collect/androidshared/ui/MultiSelectViewModelTest.kt +++ /dev/null @@ -1,54 +0,0 @@ -package org.odk.collect.androidshared.ui - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.equalTo -import org.junit.Rule -import org.junit.Test - -class MultiSelectViewModelTest { - - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - - @Test - fun `getSelected returns selected`() { - val viewModel = MultiSelectViewModel() - viewModel.select(1) - viewModel.select(11) - - assertThat(viewModel.getSelected().value, equalTo(setOf(1, 11))) - } - - @Test - fun `getSelected does not return unselected items`() { - val viewModel = MultiSelectViewModel() - viewModel.select(1) - viewModel.select(11) - viewModel.unselect(1) - - assertThat(viewModel.getSelected().value, equalTo(setOf(11))) - } - - @Test - fun `unselectAll unselects all items`() { - val viewModel = MultiSelectViewModel() - viewModel.select(1) - viewModel.select(11) - viewModel.unselectAll() - - assertThat(viewModel.getSelected().value, equalTo(emptySet())) - } - - @Test - fun `toggle changes item back and forth`() { - val viewModel = MultiSelectViewModel() - - viewModel.toggle(1) - viewModel.toggle(11) - assertThat(viewModel.getSelected().value, equalTo(setOf(1, 11))) - - viewModel.toggle(11) - assertThat(viewModel.getSelected().value, equalTo(setOf(1))) - } -} diff --git a/androidshared/src/test/java/org/odk/collect/androidshared/utils/ColorUtilsTest.kt b/androidshared/src/test/java/org/odk/collect/androidshared/utils/ColorUtilsTest.kt new file mode 100644 index 00000000000..52c83e58885 --- /dev/null +++ b/androidshared/src/test/java/org/odk/collect/androidshared/utils/ColorUtilsTest.kt @@ -0,0 +1,45 @@ +package org.odk.collect.androidshared.utils + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ColorUtilsTest { + @Test + fun `return null when color is empty`() { + assertThat("".toColorInt(), equalTo(null)) + } + + @Test + fun `return null when color is blank`() { + assertThat(" ".toColorInt(), equalTo(null)) + } + + @Test + fun `return null when color is invalid`() { + assertThat("qwerty".toColorInt(), equalTo(null)) + } + + @Test + fun `return color int for valid hex color with # prefix`() { + assertThat("#aaccee".toColorInt(), equalTo(-5583634)) + } + + @Test + fun `return color int for valid hex color without # prefix`() { + assertThat("aaccee".toColorInt(), equalTo(-5583634)) + } + + @Test + fun `return color int for valid shorthand hex color with # prefix`() { + assertThat("#ace".toColorInt(), equalTo(-5583634)) + } + + @Test + fun `return color int for valid shorthand hex color without # prefix`() { + assertThat("ace".toColorInt(), equalTo(-5583634)) + } +} diff --git a/androidshared/src/test/java/org/odk/collect/androidshared/utils/PathUtilsTest.kt b/androidshared/src/test/java/org/odk/collect/androidshared/utils/PathUtilsTest.kt new file mode 100644 index 00000000000..472b285027d --- /dev/null +++ b/androidshared/src/test/java/org/odk/collect/androidshared/utils/PathUtilsTest.kt @@ -0,0 +1,38 @@ +package org.odk.collect.androidshared.utils + +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.Test +import org.odk.collect.shared.TempFiles +import java.io.File + +class PathUtilsTest { + @Test + fun `getAbsoluteFilePath() returns filePath prepended with dirPath`() { + val path = PathUtils.getAbsoluteFilePath("/anotherRoot/anotherDir", "root/dir/file") + assertThat(path, equalTo("/anotherRoot/anotherDir/root/dir/file")) + } + + @Test + fun `getAbsoluteFilePath() returns valid path when filePath does not start with seperator`() { + val path = PathUtils.getAbsoluteFilePath("/root/dir", "file") + assertThat(path, equalTo("/root/dir/file")) + } + + @Test + fun `getAbsoluteFilePath() returns filePath when it starts with dirPath`() { + val path = PathUtils.getAbsoluteFilePath("/root/dir", "/root/dir/file") + assertThat(path, equalTo("/root/dir/file")) + } + + @Test + fun `getAbsoluteFilePath() works when dirPath is not canonical`() { + val tempDir = TempFiles.createTempDir() + val nonCanonicalPath = + tempDir.canonicalPath + File.separator + ".." + File.separator + tempDir.name + assertThat(File(nonCanonicalPath).canonicalPath, equalTo(tempDir.canonicalPath)) + + val path = PathUtils.getAbsoluteFilePath(nonCanonicalPath, "file") + assertThat(path, equalTo(nonCanonicalPath + File.separator + "file")) + } +} diff --git a/androidshared/src/test/resources/robolectric.properties b/androidshared/src/test/resources/robolectric.properties index a333e53ef88..4f3945f61dc 100644 --- a/androidshared/src/test/resources/robolectric.properties +++ b/androidshared/src/test/resources/robolectric.properties @@ -1,3 +1 @@ -# Workaround for https://github.com/robolectric/robolectric/issues/6593 -instrumentedPackages=androidx.loader.content sdk=33 diff --git a/androidtest/src/main/java/org/odk/collect/androidtest/DrawableMatcher.kt b/androidtest/src/main/java/org/odk/collect/androidtest/DrawableMatcher.kt new file mode 100644 index 00000000000..102c417b456 --- /dev/null +++ b/androidtest/src/main/java/org/odk/collect/androidtest/DrawableMatcher.kt @@ -0,0 +1,68 @@ +package org.odk.collect.androidtest + +import android.app.Application +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.VectorDrawable +import android.os.StrictMode +import android.os.StrictMode.ThreadPolicy +import android.view.View +import android.widget.ImageView +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.graphics.drawable.toBitmap +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.matcher.BoundedMatcher +import org.hamcrest.Description +import org.hamcrest.Matcher + +object DrawableMatcher { + @JvmStatic + fun withImageDrawable(expectedResourceId: Int): Matcher { + return object : BoundedMatcher(ImageView::class.java) { + override fun describeTo(description: Description) { + description.appendText("has image drawable resource $expectedResourceId") + } + + public override fun matchesSafely(imageView: ImageView): Boolean { + val context = ApplicationProvider.getApplicationContext() + return imageView.drawable.toBitmap().rowBytes == (AppCompatResources.getDrawable(context, expectedResourceId) as VectorDrawable).toBitmap().rowBytes + } + } + } + + @JvmStatic + fun withBitmap(match: Bitmap?): Matcher { + return object : BoundedMatcher(ImageView::class.java) { + override fun describeTo(description: Description) { + description.appendText("bitmaps did not match") + } + + override fun matchesSafely(imageView: ImageView): Boolean { + val drawable = imageView.drawable + if (drawable == null && match == null) { + return true + } else if (drawable != null && match == null) { + return false + } else if (drawable == null) { + return false + } + + val actual = (drawable as BitmapDrawable).bitmap + + val originalThreadPolicy = StrictMode.getThreadPolicy() + + try { + // Permit slow calls to allow `sameAs` use + StrictMode.setThreadPolicy( + ThreadPolicy.Builder() + .permitCustomSlowCalls().build() + ) + + return actual.sameAs(match) + } finally { + StrictMode.setThreadPolicy(originalThreadPolicy) + } + } + } + } +} diff --git a/async/build.gradle.kts b/async/build.gradle.kts index 9c958ba7e33..d6eae2c48cb 100644 --- a/async/build.gradle.kts +++ b/async/build.gradle.kts @@ -42,6 +42,9 @@ dependencies { implementation(Dependencies.androidx_core_ktx) implementation(Dependencies.kotlinx_coroutines_android) implementation(Dependencies.androidx_work_runtime) + implementation(project(":analytics")) { + exclude("com.google.firebase") + } testImplementation(Dependencies.hamcrest) testImplementation(Dependencies.robolectric) diff --git a/async/src/main/AndroidManifest.xml b/async/src/main/AndroidManifest.xml index c7e7078dcda..d7546021b58 100644 --- a/async/src/main/AndroidManifest.xml +++ b/async/src/main/AndroidManifest.xml @@ -1,2 +1,3 @@ - \ No newline at end of file + + diff --git a/async/src/main/java/org/odk/collect/async/CoroutineAndWorkManagerScheduler.kt b/async/src/main/java/org/odk/collect/async/CoroutineAndWorkManagerScheduler.kt index 10ef02fc661..d07a2448abd 100644 --- a/async/src/main/java/org/odk/collect/async/CoroutineAndWorkManagerScheduler.kt +++ b/async/src/main/java/org/odk/collect/async/CoroutineAndWorkManagerScheduler.kt @@ -13,36 +13,72 @@ import kotlinx.coroutines.Dispatchers import java.util.concurrent.TimeUnit import kotlin.coroutines.CoroutineContext -class CoroutineAndWorkManagerScheduler(foregroundContext: CoroutineContext, backgroundContext: CoroutineContext, private val workManager: WorkManager) : CoroutineScheduler(foregroundContext, backgroundContext) { - - constructor(workManager: WorkManager) : this(Dispatchers.Main, Dispatchers.IO, workManager) // Needed for Java construction +class CoroutineAndWorkManagerScheduler( + foregroundContext: CoroutineContext, + backgroundContext: CoroutineContext, + private val workManager: WorkManager +) : CoroutineScheduler(foregroundContext, backgroundContext) { + + constructor(workManager: WorkManager) : this( + Dispatchers.Main, + Dispatchers.IO, + workManager + ) // Needed for Java construction + + override fun networkDeferred( + tag: String, + spec: TaskSpec, + inputData: Map, + networkConstraint: Scheduler.NetworkType? + ) { + val constraintNetworkType = when (networkConstraint) { + Scheduler.NetworkType.WIFI -> NetworkType.UNMETERED + Scheduler.NetworkType.CELLULAR -> NetworkType.METERED + else -> NetworkType.CONNECTED + } - override fun networkDeferred(tag: String, spec: TaskSpec, inputData: Map) { val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiredNetworkType(constraintNetworkType) .build() - val workManagerInputData = Data.Builder().putAll(inputData).build() + val workManagerInputData = Data.Builder() + .putString(TaskSpecWorker.DATA_TASK_SPEC_CLASS, spec.javaClass.name) + .putBoolean( + TaskSpecWorker.DATA_CELLULAR_ONLY, + networkConstraint == Scheduler.NetworkType.CELLULAR + ) + .putAll(inputData) + .build() - val worker = spec.getWorkManagerAdapter() - val workRequest = OneTimeWorkRequest.Builder(worker) + val workRequest = OneTimeWorkRequest.Builder(TaskSpecWorker::class.java) .addTag(tag) .setConstraints(constraints) .setInputData(workManagerInputData) .build() - workManager.beginUniqueWork(tag, ExistingWorkPolicy.APPEND_OR_REPLACE, workRequest).enqueue() + workManager.beginUniqueWork(tag, ExistingWorkPolicy.REPLACE, workRequest).enqueue() } - override fun networkDeferred(tag: String, spec: TaskSpec, repeatPeriod: Long, inputData: Map) { + override fun networkDeferredRepeat( + tag: String, + spec: TaskSpec, + repeatPeriod: Long, + inputData: Map + ) { val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() - val workManagerInputData = Data.Builder().putAll(inputData).build() + val workManagerInputData = Data.Builder() + .putString(TaskSpecWorker.DATA_TASK_SPEC_CLASS, spec.javaClass.name) + .putAll(inputData) + .build() - val worker = spec.getWorkManagerAdapter() - val builder = PeriodicWorkRequest.Builder(worker, repeatPeriod, TimeUnit.MILLISECONDS) + val builder = PeriodicWorkRequest.Builder( + TaskSpecWorker::class.java, + repeatPeriod, + TimeUnit.MILLISECONDS + ) .addTag(tag) .setInputData(workManagerInputData) .setConstraints(constraints) @@ -53,7 +89,11 @@ class CoroutineAndWorkManagerScheduler(foregroundContext: CoroutineContext, back } } - workManager.enqueueUniquePeriodicWork(tag, ExistingPeriodicWorkPolicy.REPLACE, builder.build()) + workManager.enqueueUniquePeriodicWork( + tag, + ExistingPeriodicWorkPolicy.REPLACE, + builder.build() + ) } override fun cancelDeferred(tag: String) { diff --git a/async/src/main/java/org/odk/collect/async/CoroutineScheduler.kt b/async/src/main/java/org/odk/collect/async/CoroutineScheduler.kt index 9a4387be73c..ed98e37dda4 100644 --- a/async/src/main/java/org/odk/collect/async/CoroutineScheduler.kt +++ b/async/src/main/java/org/odk/collect/async/CoroutineScheduler.kt @@ -20,8 +20,8 @@ open class CoroutineScheduler(private val foregroundContext: CoroutineContext, p } } - override fun immediate(background: Boolean, runnable: Runnable) { - val context = if (background) { + override fun immediate(foreground: Boolean, runnable: Runnable) { + val context = if (!foreground) { backgroundContext } else { foregroundContext @@ -53,11 +53,11 @@ open class CoroutineScheduler(private val foregroundContext: CoroutineContext, p throw UnsupportedOperationException() } - override fun networkDeferred(tag: String, spec: TaskSpec, inputData: Map) { + override fun networkDeferred(tag: String, spec: TaskSpec, inputData: Map, networkConstraint: Scheduler.NetworkType?) { throw UnsupportedOperationException() } - override fun networkDeferred(tag: String, spec: TaskSpec, repeatPeriod: Long, inputData: Map) { + override fun networkDeferredRepeat(tag: String, spec: TaskSpec, repeatPeriod: Long, inputData: Map) { throw UnsupportedOperationException() } diff --git a/async/src/main/java/org/odk/collect/async/Scheduler.kt b/async/src/main/java/org/odk/collect/async/Scheduler.kt index 170cc7a3a8f..2d80d54bb26 100644 --- a/async/src/main/java/org/odk/collect/async/Scheduler.kt +++ b/async/src/main/java/org/odk/collect/async/Scheduler.kt @@ -25,18 +25,19 @@ interface Scheduler { /** * Run work in the foreground or background. Cancelled if application closed. */ - fun immediate(background: Boolean = false, runnable: Runnable) + fun immediate(foreground: Boolean = false, runnable: Runnable) /** * Schedule a task to run in the background even if the app isn't running. The task * will only be run when the network is available. * * @param tag used to identify this task in future. If there is a previously scheduled task - * with the same tag then that task then nothing new will be scheduled (this becomes no-op) + * with the same tag then that task will be cancelled and this will replace it * @param spec defines the task to be run * @param inputData a map of input data that can be accessed by the task + * @param networkConstraint the specific kind of network required */ - fun networkDeferred(tag: String, spec: TaskSpec, inputData: Map) + fun networkDeferred(tag: String, spec: TaskSpec, inputData: Map, networkConstraint: NetworkType? = null) /** * Schedule a task to run in the background repeatedly even if the app isn't running. The task @@ -48,7 +49,7 @@ interface Scheduler { * @param repeatPeriod the period between each run of the task * @param inputData a map of input data that can be accessed by the task */ - fun networkDeferred( + fun networkDeferredRepeat( tag: String, spec: TaskSpec, repeatPeriod: Long, @@ -77,6 +78,12 @@ interface Scheduler { fun cancelAllDeferred() fun flowOnBackground(flow: Flow): Flow + + enum class NetworkType { + WIFI, + CELLULAR, + OTHER + } } fun Flow.flowOnBackground(scheduler: Scheduler): Flow { diff --git a/async/src/main/java/org/odk/collect/async/SchedulerAsyncTaskMimic.kt b/async/src/main/java/org/odk/collect/async/SchedulerAsyncTaskMimic.kt index a1983ba6066..c225e224d9c 100644 --- a/async/src/main/java/org/odk/collect/async/SchedulerAsyncTaskMimic.kt +++ b/async/src/main/java/org/odk/collect/async/SchedulerAsyncTaskMimic.kt @@ -69,8 +69,8 @@ abstract class SchedulerAsyncTaskMimic(private val sch } protected fun publishProgress(vararg values: Progress) { - scheduler.immediate( - runnable = { onProgressUpdate(*values) } - ) + scheduler.immediate(foreground = true) { + onProgressUpdate(*values) + } } } diff --git a/async/src/main/java/org/odk/collect/async/TaskSpec.kt b/async/src/main/java/org/odk/collect/async/TaskSpec.kt index 9a17d788a54..d6643e0842c 100644 --- a/async/src/main/java/org/odk/collect/async/TaskSpec.kt +++ b/async/src/main/java/org/odk/collect/async/TaskSpec.kt @@ -18,10 +18,4 @@ interface TaskSpec { * once instead of doing that after every single execution. */ fun getTask(context: Context, inputData: Map, isLastUniqueExecution: Boolean): Supplier - - /** - * Returns class that can be used to schedule this task using Android's - * WorkManager framework - */ - fun getWorkManagerAdapter(): Class } diff --git a/async/src/main/java/org/odk/collect/async/TaskSpecWorker.kt b/async/src/main/java/org/odk/collect/async/TaskSpecWorker.kt new file mode 100644 index 00000000000..c3e41880436 --- /dev/null +++ b/async/src/main/java/org/odk/collect/async/TaskSpecWorker.kt @@ -0,0 +1,47 @@ +package org.odk.collect.async + +import android.content.Context +import androidx.work.Worker +import androidx.work.WorkerParameters +import org.odk.collect.analytics.Analytics +import org.odk.collect.async.network.ConnectivityProvider + +class TaskSpecWorker( + context: Context, + workerParams: WorkerParameters +) : Worker(context, workerParams) { + + private val connectivityProvider: ConnectivityProvider = ConnectivityProvider(context) + + override fun doWork(): Result { + val cellularOnly = inputData.getBoolean(DATA_CELLULAR_ONLY, false) + if (cellularOnly && connectivityProvider.currentNetwork != Scheduler.NetworkType.CELLULAR) { + Analytics.setUserProperty("EncounteredMeteredNonCellularInTasks", "true") + return Result.retry() + } + + val specClass = inputData.getString(DATA_TASK_SPEC_CLASS)!! + val spec = Class.forName(specClass).getConstructor().newInstance() as TaskSpec + + val stringInputData = inputData.keyValueMap.mapValues { it.value.toString() } + val completed = + spec.getTask(applicationContext, stringInputData, isLastUniqueExecution(spec)).get() + val maxRetries = spec.maxRetries + + return if (completed) { + Result.success() + } else if (maxRetries == null || runAttemptCount < maxRetries) { + Result.retry() + } else { + Result.failure() + } + } + + private fun isLastUniqueExecution(spec: TaskSpec) = + spec.maxRetries?.let { runAttemptCount >= it } ?: true + + companion object { + const val DATA_TASK_SPEC_CLASS = "taskSpecClass" + const val DATA_CELLULAR_ONLY = "cellularOnly" + } +} diff --git a/async/src/main/java/org/odk/collect/async/WorkerAdapter.kt b/async/src/main/java/org/odk/collect/async/WorkerAdapter.kt deleted file mode 100644 index fc65d1575e0..00000000000 --- a/async/src/main/java/org/odk/collect/async/WorkerAdapter.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.odk.collect.async - -import android.content.Context -import androidx.work.Worker -import androidx.work.WorkerParameters - -abstract class WorkerAdapter( - private val spec: TaskSpec, - context: Context, - workerParams: WorkerParameters -) : Worker(context, workerParams) { - - override fun doWork(): Result { - val stringInputData = inputData.keyValueMap.mapValues { it.value.toString() } - val completed = spec.getTask(applicationContext, stringInputData, isLastUniqueExecution()).get() - val maxRetries = spec.maxRetries - - return if (completed) { - Result.success() - } else if (maxRetries == null || runAttemptCount < maxRetries) { - Result.retry() - } else { - Result.failure() - } - } - - private fun isLastUniqueExecution() = spec.maxRetries?.let { runAttemptCount >= it } ?: true -} diff --git a/async/src/main/java/org/odk/collect/async/network/ConnectivityProvider.kt b/async/src/main/java/org/odk/collect/async/network/ConnectivityProvider.kt new file mode 100644 index 00000000000..526ad50c363 --- /dev/null +++ b/async/src/main/java/org/odk/collect/async/network/ConnectivityProvider.kt @@ -0,0 +1,23 @@ +package org.odk.collect.async.network + +import android.content.Context +import android.net.ConnectivityManager +import org.odk.collect.async.Scheduler + +class ConnectivityProvider(private val context: Context) : NetworkStateProvider { + override val currentNetwork: Scheduler.NetworkType? + get() { + return if (connectivityManager.activeNetworkInfo?.isConnected == true) { + when (connectivityManager.activeNetworkInfo?.type) { + ConnectivityManager.TYPE_WIFI -> Scheduler.NetworkType.WIFI + ConnectivityManager.TYPE_MOBILE -> Scheduler.NetworkType.CELLULAR + else -> Scheduler.NetworkType.OTHER + } + } else { + null + } + } + + private val connectivityManager: ConnectivityManager + get() = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager +} diff --git a/async/src/main/java/org/odk/collect/async/network/NetworkStateProvider.kt b/async/src/main/java/org/odk/collect/async/network/NetworkStateProvider.kt new file mode 100644 index 00000000000..410454a5106 --- /dev/null +++ b/async/src/main/java/org/odk/collect/async/network/NetworkStateProvider.kt @@ -0,0 +1,12 @@ +package org.odk.collect.async.network + +import org.odk.collect.async.Scheduler + +interface NetworkStateProvider { + val currentNetwork: Scheduler.NetworkType? + + val isDeviceOnline: Boolean + get() { + return currentNetwork != null + } +} diff --git a/async/src/test/java/org/odk/collect/async/WorkerAdapterTest.kt b/async/src/test/java/org/odk/collect/async/TaskSpecWorkerTest.kt similarity index 51% rename from async/src/test/java/org/odk/collect/async/WorkerAdapterTest.kt rename to async/src/test/java/org/odk/collect/async/TaskSpecWorkerTest.kt index b5ad9d83c19..df69f12ee0f 100644 --- a/async/src/test/java/org/odk/collect/async/WorkerAdapterTest.kt +++ b/async/src/test/java/org/odk/collect/async/TaskSpecWorkerTest.kt @@ -3,118 +3,157 @@ package org.odk.collect.async import android.content.Context import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.BackoffPolicy +import androidx.work.Data import androidx.work.ListenableWorker import androidx.work.Worker -import androidx.work.WorkerParameters import androidx.work.testing.TestWorkerBuilder import org.hamcrest.CoreMatchers.`is` import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo import org.junit.Before import org.junit.Test 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 java.util.concurrent.Executors import java.util.function.Supplier @RunWith(AndroidJUnit4::class) -class WorkerAdapterTest { +class TaskSpecWorkerTest { private lateinit var worker: Worker - companion object { - private lateinit var spec: TaskSpec - } @Before fun setup() { - spec = mock() - worker = TestWorkerBuilder( + worker = TestWorkerBuilder( context = ApplicationProvider.getApplicationContext(), executor = Executors.newSingleThreadExecutor(), + inputData = Data.Builder() + .putString(TaskSpecWorker.DATA_TASK_SPEC_CLASS, TestTaskSpec::class.java.name) + .build(), runAttemptCount = 0 // without setting this explicitly attempts in tests are counted starting from 1 instead of 0 like in production code ).build() + + TestTaskSpec.reset() } @Test - fun `when task returns true should work succeed`() { - whenever(spec.getTask(any(), any(), any())).thenReturn(Supplier { true }) - + fun `when task returns true work should succeed`() { + TestTaskSpec.doReturn(true) assertThat(worker.doWork(), `is`(ListenableWorker.Result.success())) } @Test fun `when task returns false, retries if maxRetries not specified`() { - whenever(spec.getTask(any(), any(), any())).thenReturn(Supplier { false }) - whenever(spec.maxRetries).thenReturn(null) - + TestTaskSpec.doReturn(false) assertThat(worker.doWork(), `is`(ListenableWorker.Result.retry())) } @Test fun `when task returns false, retries if maxRetries is specified and is higher than runAttemptCount`() { - whenever(spec.getTask(any(), any(), any())).thenReturn(Supplier { false }) - whenever(spec.maxRetries).thenReturn(3) + TestTaskSpec + .withMaxRetries(1) + .doReturn(false) assertThat(worker.doWork(), `is`(ListenableWorker.Result.retry())) } @Test fun `when task returns false, fails if maxRetries is specified and is equal to runAttemptCount`() { - whenever(spec.getTask(any(), any(), any())).thenReturn(Supplier { false }) - whenever(spec.maxRetries).thenReturn(0) + TestTaskSpec + .withMaxRetries(0) + .doReturn(false) assertThat(worker.doWork(), `is`(ListenableWorker.Result.failure())) } @Test fun `when task returns false, fails if maxRetries is specified and is lower than runAttemptCount`() { - whenever(spec.getTask(any(), any(), any())).thenReturn(Supplier { false }) - whenever(spec.maxRetries).thenReturn(0) + TestTaskSpec + .withMaxRetries(-1) + .doReturn(false) assertThat(worker.doWork(), `is`(ListenableWorker.Result.failure())) } @Test fun `when maxRetries is not specified, task called with isLastUniqueExecution true`() { - whenever(spec.maxRetries).thenReturn(null) - whenever(spec.getTask(any(), any(), any())).thenReturn(Supplier { true }) + TestTaskSpec + .doReturn(false) worker.doWork() - - verify(spec).getTask(any(), any(), eq(true)) + assertThat(TestTaskSpec.wasLastUniqueExecution, equalTo(true)) } @Test fun `when maxRetries is specified and it is higher than runAttemptCount, task called with isLastUniqueExecution false`() { - whenever(spec.maxRetries).thenReturn(3) - whenever(spec.getTask(any(), any(), any())).thenReturn(Supplier { true }) + TestTaskSpec + .withMaxRetries(1) + .doReturn(false) worker.doWork() - - verify(spec).getTask(any(), any(), eq(false)) + assertThat(TestTaskSpec.wasLastUniqueExecution, equalTo(false)) } @Test fun `when maxRetries is specified and it is equal to runAttemptCount, task called with isLastUniqueExecution true`() { - whenever(spec.maxRetries).thenReturn(0) - whenever(spec.getTask(any(), any(), any())).thenReturn(Supplier { true }) + TestTaskSpec + .withMaxRetries(0) + .doReturn(false) worker.doWork() - - verify(spec).getTask(any(), any(), eq(true)) + assertThat(TestTaskSpec.wasLastUniqueExecution, equalTo(true)) } @Test fun `when maxRetries is specified and it is lower than runAttemptCount, task called with isLastUniqueExecution true`() { - whenever(spec.maxRetries).thenReturn(0) - whenever(spec.getTask(any(), any(), any())).thenReturn(Supplier { true }) + TestTaskSpec + .withMaxRetries(-1) + .doReturn(false) worker.doWork() + assertThat(TestTaskSpec.wasLastUniqueExecution, equalTo(true)) + } +} - verify(spec).getTask(any(), any(), eq(true)) +private class TestTaskSpec : TaskSpec { + + companion object { + + private var maxRetries: Int? = null + private var returnValue = true + + var wasLastUniqueExecution = false + private set + + fun reset() { + returnValue = true + maxRetries = null + wasLastUniqueExecution = false + } + + fun doReturn(value: Boolean): Companion { + returnValue = value + return this + } + + fun withMaxRetries(maxRetries: Int): Companion { + this.maxRetries = maxRetries + return this + } } - class TestWorker(context: Context, parameters: WorkerParameters) : WorkerAdapter(spec, context, parameters) + override val maxRetries: Int? = Companion.maxRetries + override val backoffPolicy: BackoffPolicy? = null + override val backoffDelay: Long? = null + + override fun getTask( + context: Context, + inputData: Map, + isLastUniqueExecution: Boolean + ): Supplier { + wasLastUniqueExecution = isLastUniqueExecution + + return Supplier { + returnValue + } + } } diff --git a/audioclips/.gitignore b/audio-clips/.gitignore similarity index 100% rename from audioclips/.gitignore rename to audio-clips/.gitignore diff --git a/audioclips/build.gradle.kts b/audio-clips/build.gradle.kts similarity index 100% rename from audioclips/build.gradle.kts rename to audio-clips/build.gradle.kts diff --git a/audioclips/consumer-rules.pro b/audio-clips/consumer-rules.pro similarity index 100% rename from audioclips/consumer-rules.pro rename to audio-clips/consumer-rules.pro diff --git a/audioclips/src/main/AndroidManifest.xml b/audio-clips/src/main/AndroidManifest.xml similarity index 100% rename from audioclips/src/main/AndroidManifest.xml rename to audio-clips/src/main/AndroidManifest.xml diff --git a/audioclips/src/main/java/org/odk/collect/audioclips/AudioClipViewModel.kt b/audio-clips/src/main/java/org/odk/collect/audioclips/AudioClipViewModel.kt similarity index 100% rename from audioclips/src/main/java/org/odk/collect/audioclips/AudioClipViewModel.kt rename to audio-clips/src/main/java/org/odk/collect/audioclips/AudioClipViewModel.kt diff --git a/audioclips/src/main/java/org/odk/collect/audioclips/Clip.kt b/audio-clips/src/main/java/org/odk/collect/audioclips/Clip.kt similarity index 100% rename from audioclips/src/main/java/org/odk/collect/audioclips/Clip.kt rename to audio-clips/src/main/java/org/odk/collect/audioclips/Clip.kt diff --git a/audioclips/src/main/java/org/odk/collect/audioclips/PlaybackFailedException.kt b/audio-clips/src/main/java/org/odk/collect/audioclips/PlaybackFailedException.kt similarity index 100% rename from audioclips/src/main/java/org/odk/collect/audioclips/PlaybackFailedException.kt rename to audio-clips/src/main/java/org/odk/collect/audioclips/PlaybackFailedException.kt diff --git a/audioclips/src/test/java/org/odk/collect/audioclips/AudioClipViewModelTest.kt b/audio-clips/src/test/java/org/odk/collect/audioclips/AudioClipViewModelTest.kt similarity index 100% rename from audioclips/src/test/java/org/odk/collect/audioclips/AudioClipViewModelTest.kt rename to audio-clips/src/test/java/org/odk/collect/audioclips/AudioClipViewModelTest.kt diff --git a/audiorecorder/.gitignore b/audio-recorder/.gitignore similarity index 100% rename from audiorecorder/.gitignore rename to audio-recorder/.gitignore diff --git a/audiorecorder/build.gradle.kts b/audio-recorder/build.gradle.kts similarity index 100% rename from audiorecorder/build.gradle.kts rename to audio-recorder/build.gradle.kts diff --git a/audiorecorder/consumer-rules.pro b/audio-recorder/consumer-rules.pro similarity index 100% rename from audiorecorder/consumer-rules.pro rename to audio-recorder/consumer-rules.pro diff --git a/audiorecorder/src/main/AndroidManifest.xml b/audio-recorder/src/main/AndroidManifest.xml similarity index 100% rename from audiorecorder/src/main/AndroidManifest.xml rename to audio-recorder/src/main/AndroidManifest.xml diff --git a/audiorecorder/src/main/java/org/odk/collect/audiorecorder/DaggerSetup.kt b/audio-recorder/src/main/java/org/odk/collect/audiorecorder/DaggerSetup.kt similarity index 100% rename from audiorecorder/src/main/java/org/odk/collect/audiorecorder/DaggerSetup.kt rename to audio-recorder/src/main/java/org/odk/collect/audiorecorder/DaggerSetup.kt diff --git a/audiorecorder/src/main/java/org/odk/collect/audiorecorder/mediarecorder/MediaRecorderRecordingResource.kt b/audio-recorder/src/main/java/org/odk/collect/audiorecorder/mediarecorder/MediaRecorderRecordingResource.kt similarity index 100% rename from audiorecorder/src/main/java/org/odk/collect/audiorecorder/mediarecorder/MediaRecorderRecordingResource.kt rename to audio-recorder/src/main/java/org/odk/collect/audiorecorder/mediarecorder/MediaRecorderRecordingResource.kt diff --git a/audiorecorder/src/main/java/org/odk/collect/audiorecorder/recorder/Recorder.kt b/audio-recorder/src/main/java/org/odk/collect/audiorecorder/recorder/Recorder.kt similarity index 100% rename from audiorecorder/src/main/java/org/odk/collect/audiorecorder/recorder/Recorder.kt rename to audio-recorder/src/main/java/org/odk/collect/audiorecorder/recorder/Recorder.kt diff --git a/audiorecorder/src/main/java/org/odk/collect/audiorecorder/recorder/RecordingResource.kt b/audio-recorder/src/main/java/org/odk/collect/audiorecorder/recorder/RecordingResource.kt similarity index 100% rename from audiorecorder/src/main/java/org/odk/collect/audiorecorder/recorder/RecordingResource.kt rename to audio-recorder/src/main/java/org/odk/collect/audiorecorder/recorder/RecordingResource.kt diff --git a/audiorecorder/src/main/java/org/odk/collect/audiorecorder/recorder/RecordingResourceRecorder.kt b/audio-recorder/src/main/java/org/odk/collect/audiorecorder/recorder/RecordingResourceRecorder.kt similarity index 100% rename from audiorecorder/src/main/java/org/odk/collect/audiorecorder/recorder/RecordingResourceRecorder.kt rename to audio-recorder/src/main/java/org/odk/collect/audiorecorder/recorder/RecordingResourceRecorder.kt diff --git a/audiorecorder/src/main/java/org/odk/collect/audiorecorder/recording/AudioRecorder.kt b/audio-recorder/src/main/java/org/odk/collect/audiorecorder/recording/AudioRecorder.kt similarity index 100% rename from audiorecorder/src/main/java/org/odk/collect/audiorecorder/recording/AudioRecorder.kt rename to audio-recorder/src/main/java/org/odk/collect/audiorecorder/recording/AudioRecorder.kt diff --git a/audiorecorder/src/main/java/org/odk/collect/audiorecorder/recording/AudioRecorderFactory.kt b/audio-recorder/src/main/java/org/odk/collect/audiorecorder/recording/AudioRecorderFactory.kt similarity index 100% rename from audiorecorder/src/main/java/org/odk/collect/audiorecorder/recording/AudioRecorderFactory.kt rename to audio-recorder/src/main/java/org/odk/collect/audiorecorder/recording/AudioRecorderFactory.kt diff --git a/audiorecorder/src/main/java/org/odk/collect/audiorecorder/recording/AudioRecorderService.kt b/audio-recorder/src/main/java/org/odk/collect/audiorecorder/recording/AudioRecorderService.kt similarity index 100% rename from audiorecorder/src/main/java/org/odk/collect/audiorecorder/recording/AudioRecorderService.kt rename to audio-recorder/src/main/java/org/odk/collect/audiorecorder/recording/AudioRecorderService.kt diff --git a/audiorecorder/src/main/java/org/odk/collect/audiorecorder/recording/internal/ForegroundServiceAudioRecorder.kt b/audio-recorder/src/main/java/org/odk/collect/audiorecorder/recording/internal/ForegroundServiceAudioRecorder.kt similarity index 100% rename from audiorecorder/src/main/java/org/odk/collect/audiorecorder/recording/internal/ForegroundServiceAudioRecorder.kt rename to audio-recorder/src/main/java/org/odk/collect/audiorecorder/recording/internal/ForegroundServiceAudioRecorder.kt diff --git a/audiorecorder/src/main/java/org/odk/collect/audiorecorder/recording/internal/RecordingForegroundServiceNotification.kt b/audio-recorder/src/main/java/org/odk/collect/audiorecorder/recording/internal/RecordingForegroundServiceNotification.kt similarity index 100% rename from audiorecorder/src/main/java/org/odk/collect/audiorecorder/recording/internal/RecordingForegroundServiceNotification.kt rename to audio-recorder/src/main/java/org/odk/collect/audiorecorder/recording/internal/RecordingForegroundServiceNotification.kt diff --git a/audiorecorder/src/main/java/org/odk/collect/audiorecorder/recording/internal/RecordingRepository.kt b/audio-recorder/src/main/java/org/odk/collect/audiorecorder/recording/internal/RecordingRepository.kt similarity index 100% rename from audiorecorder/src/main/java/org/odk/collect/audiorecorder/recording/internal/RecordingRepository.kt rename to audio-recorder/src/main/java/org/odk/collect/audiorecorder/recording/internal/RecordingRepository.kt diff --git a/audiorecorder/src/main/java/org/odk/collect/audiorecorder/testsupport/StubAudioRecorder.kt b/audio-recorder/src/main/java/org/odk/collect/audiorecorder/testsupport/StubAudioRecorder.kt similarity index 100% rename from audiorecorder/src/main/java/org/odk/collect/audiorecorder/testsupport/StubAudioRecorder.kt rename to audio-recorder/src/main/java/org/odk/collect/audiorecorder/testsupport/StubAudioRecorder.kt diff --git a/audiorecorder/src/test/java/org/odk/collect/audiorecorder/mediarecorder/AMRRecordingResourceTest.kt b/audio-recorder/src/test/java/org/odk/collect/audiorecorder/mediarecorder/AMRRecordingResourceTest.kt similarity index 100% rename from audiorecorder/src/test/java/org/odk/collect/audiorecorder/mediarecorder/AMRRecordingResourceTest.kt rename to audio-recorder/src/test/java/org/odk/collect/audiorecorder/mediarecorder/AMRRecordingResourceTest.kt diff --git a/audiorecorder/src/test/java/org/odk/collect/audiorecorder/recorder/RecordingResourceRecorderTest.kt b/audio-recorder/src/test/java/org/odk/collect/audiorecorder/recorder/RecordingResourceRecorderTest.kt similarity index 100% rename from audiorecorder/src/test/java/org/odk/collect/audiorecorder/recorder/RecordingResourceRecorderTest.kt rename to audio-recorder/src/test/java/org/odk/collect/audiorecorder/recorder/RecordingResourceRecorderTest.kt diff --git a/audiorecorder/src/test/java/org/odk/collect/audiorecorder/recording/AudioRecorderTest.kt b/audio-recorder/src/test/java/org/odk/collect/audiorecorder/recording/AudioRecorderTest.kt similarity index 100% rename from audiorecorder/src/test/java/org/odk/collect/audiorecorder/recording/AudioRecorderTest.kt rename to audio-recorder/src/test/java/org/odk/collect/audiorecorder/recording/AudioRecorderTest.kt diff --git a/audiorecorder/src/test/java/org/odk/collect/audiorecorder/recording/internal/AudioRecorderServiceTest.kt b/audio-recorder/src/test/java/org/odk/collect/audiorecorder/recording/internal/AudioRecorderServiceTest.kt similarity index 100% rename from audiorecorder/src/test/java/org/odk/collect/audiorecorder/recording/internal/AudioRecorderServiceTest.kt rename to audio-recorder/src/test/java/org/odk/collect/audiorecorder/recording/internal/AudioRecorderServiceTest.kt diff --git a/audiorecorder/src/test/java/org/odk/collect/audiorecorder/recording/internal/ForegroundServiceAudioRecorderTest.kt b/audio-recorder/src/test/java/org/odk/collect/audiorecorder/recording/internal/ForegroundServiceAudioRecorderTest.kt similarity index 100% rename from audiorecorder/src/test/java/org/odk/collect/audiorecorder/recording/internal/ForegroundServiceAudioRecorderTest.kt rename to audio-recorder/src/test/java/org/odk/collect/audiorecorder/recording/internal/ForegroundServiceAudioRecorderTest.kt diff --git a/audiorecorder/src/test/java/org/odk/collect/audiorecorder/recording/internal/RecordingForegroundServiceNotificationTest.kt b/audio-recorder/src/test/java/org/odk/collect/audiorecorder/recording/internal/RecordingForegroundServiceNotificationTest.kt similarity index 100% rename from audiorecorder/src/test/java/org/odk/collect/audiorecorder/recording/internal/RecordingForegroundServiceNotificationTest.kt rename to audio-recorder/src/test/java/org/odk/collect/audiorecorder/recording/internal/RecordingForegroundServiceNotificationTest.kt diff --git a/audiorecorder/src/test/java/org/odk/collect/audiorecorder/support/FakeRecorder.kt b/audio-recorder/src/test/java/org/odk/collect/audiorecorder/support/FakeRecorder.kt similarity index 100% rename from audiorecorder/src/test/java/org/odk/collect/audiorecorder/support/FakeRecorder.kt rename to audio-recorder/src/test/java/org/odk/collect/audiorecorder/support/FakeRecorder.kt diff --git a/audiorecorder/src/test/java/org/odk/collect/audiorecorder/testsupport/RobolectricApplication.kt b/audio-recorder/src/test/java/org/odk/collect/audiorecorder/testsupport/RobolectricApplication.kt similarity index 100% rename from audiorecorder/src/test/java/org/odk/collect/audiorecorder/testsupport/RobolectricApplication.kt rename to audio-recorder/src/test/java/org/odk/collect/audiorecorder/testsupport/RobolectricApplication.kt diff --git a/audiorecorder/src/test/java/org/odk/collect/audiorecorder/testsupport/StubAudioRecorderTest.kt b/audio-recorder/src/test/java/org/odk/collect/audiorecorder/testsupport/StubAudioRecorderTest.kt similarity index 100% rename from audiorecorder/src/test/java/org/odk/collect/audiorecorder/testsupport/StubAudioRecorderTest.kt rename to audio-recorder/src/test/java/org/odk/collect/audiorecorder/testsupport/StubAudioRecorderTest.kt diff --git a/audiorecorder/src/test/resources/robolectric.properties b/audio-recorder/src/test/resources/robolectric.properties similarity index 100% rename from audiorecorder/src/test/resources/robolectric.properties rename to audio-recorder/src/test/resources/robolectric.properties diff --git a/build.gradle b/build.gradle index 40642a14e1c..c77551c2a3b 100644 --- a/build.gradle +++ b/build.gradle @@ -16,13 +16,13 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:8.2.0' - classpath 'com.google.gms:google-services:4.4.0' + classpath 'com.android.tools.build:gradle:8.2.2' + classpath 'com.google.gms:google-services:4.4.1' classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.9' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.22" - classpath "org.jlleitschuh.gradle:ktlint-gradle:11.6.1" - classpath "com.github.ben-manes:gradle-versions-plugin:0.49.0" - classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.7.4" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.23" + classpath "org.jlleitschuh.gradle:ktlint-gradle:12.1.0" + classpath "com.github.ben-manes:gradle-versions-plugin:0.51.0" + classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.7.7" classpath "com.google.android.gms:oss-licenses-plugin:0.10.6" } } @@ -104,11 +104,13 @@ tasks.register('testLab') { executable 'gcloud' args('beta', 'firebase', 'test', 'android', 'run', '--type', 'instrumentation', - '--num-uniform-shards=50', + '--num-uniform-shards=25', '--app', 'collect_app/build/outputs/apk/debug/ODK-Collect-debug.apk', '--test', 'collect_app/build/outputs/apk/androidTest/debug/ODK-Collect-debug-androidTest.apk', - '--device', 'model=MediumPhone.arm,version=30,locale=en,orientation=portrait', - '--timeout', '10m' + '--device', 'model=MediumPhone.arm,version=34,locale=en,orientation=portrait', + '--timeout', '10m', + '--directories-to-pull', '/sdcard', + '--test-targets', "notPackage org.odk.collect.android.regression" ) } } diff --git a/buildSrc/src/main/java/dependencies/Dependencies.kt b/buildSrc/src/main/java/dependencies/Dependencies.kt index 9c27c077b9a..10a172b6172 100644 --- a/buildSrc/src/main/java/dependencies/Dependencies.kt +++ b/buildSrc/src/main/java/dependencies/Dependencies.kt @@ -1,42 +1,41 @@ package dependencies object Dependencies { - const val desugar = "com.android.tools:desugar_jdk_libs:2.0.3" + const val desugar = "com.android.tools:desugar_jdk_libs:2.0.4" const val androidx_startup = "androidx.startup:startup-runtime:1.1.1" const val androidx_lifecycle_runtime_ktx = "androidx.lifecycle:lifecycle-runtime-ktx:${Versions.lifecycle}" const val androidx_viewpager2= "androidx.viewpager2:viewpager2:1.0.0" const val androidx_lifecycle_livedata_ktx = "androidx.lifecycle:lifecycle-livedata-ktx:${Versions.lifecycle}" const val androidx_lifecycle_viewmodel_ktx = "androidx.lifecycle:lifecycle-viewmodel-ktx:${Versions.lifecycle}" - const val androidx_core_ktx = "androidx.core:core-ktx:1.9.0" - const val androidx_browser = "androidx.browser:browser:1.5.0" + const val androidx_core_ktx = "androidx.core:core-ktx:1.12.0" + const val androidx_browser = "androidx.browser:browser:1.7.0" const val androidx_recyclerview = "androidx.recyclerview:recyclerview:1.3.2" const val androidx_navigation_fragment_ktx = "androidx.navigation:navigation-fragment-ktx:${Versions.navigation}" const val androidx_navigation_ui = "androidx.navigation:navigation-ui-ktx:${Versions.navigation}" const val androidx_appcompat = "androidx.appcompat:appcompat:1.6.1" const val androidx_work_runtime = "androidx.work:work-runtime:${Versions.work}" - const val androidx_exinterface = "androidx.exifinterface:exifinterface:1.3.6" + const val androidx_exinterface = "androidx.exifinterface:exifinterface:1.3.7" const val androidx_multidex = "androidx.multidex:multidex:2.0.1" const val androidx_preference_ktx = "androidx.preference:preference-ktx:1.2.1" const val androidx_fragment_ktx = "androidx.fragment:fragment-ktx:${Versions.androidx_fragment}" - const val android_material = "com.google.android.material:material:1.9.0" + const val android_material = "com.google.android.material:material:1.11.0" const val android_flexbox = "com.google.android.flexbox:flexbox:3.0.0" const val play_services_maps = "com.google.android.gms:play-services-maps:18.2.0" const val play_services_location = "com.google.android.gms:play-services-location:20.0.0" // Check if map screens still work when upgrading and location works as expected https://github.com/getodk/collect/issues/6027, especially after moving to FusedLocationProviderClient. const val play_services_oss_licenses = "com.google.android.gms:play-services-oss-licenses:17.0.1" - const val mapbox_android_sdk = "com.mapbox.maps:android:10.16.1" - const val osmdroid = "org.osmdroid:osmdroid-android:6.1.17" - const val guava = "com.google.guava:guava:32.1.3-android" + const val mapbox_android_sdk = "com.mapbox.maps:android:10.16.4" + const val osmdroid = "org.osmdroid:osmdroid-android:6.1.18" const val squareup_okhttp = "com.squareup.okhttp3:okhttp:${Versions.okhttp3}" const val squareup_okhttp_tls = "com.squareup.okhttp3:okhttp-tls:${Versions.okhttp3}" const val burgstaller_okhttp_digest = "io.github.rburgst:okhttp-digest:3.1.0" const val persian_joda_time = "com.github.mohamadian:persianjodatime:1.2" - const val myanmar_calendar = "com.github.chanmratekoko:myanmar-calendar:1.0.6.RC3" + const val myanmar_calendar = "com.github.chanmratekoko:myanmar-calendar:1.0.6.RC3" // Check if https://github.com/chanmratekoko/mmcalendar/issues/4 no longer takes place before upgrading const val bikram_sambat = "bikramsambat:bikram-sambat:1.1.0" - const val danlew_android_joda = "net.danlew:android.joda:2.12.5" + const val danlew_android_joda = "net.danlew:android.joda:2.12.7" const val rarepebble_colorpicker = "com.github.martin-stone:hsv-alpha-color-picker-android:3.1.0" const val commons_io = "commons-io:commons-io:2.5" // Commons 2.6+ introduce java.nio usage that we can't access until our minSdkVersion >= 26 (https://developer.android.com/reference/java/io/File#toPath()) - const val opencsv = "com.opencsv:opencsv:5.8" - const val javarosa_online = "org.getodk:javarosa:4.3.2" + const val opencsv = "com.opencsv:opencsv:5.9" + const val javarosa_online = "org.getodk:javarosa:4.4.1" const val javarosa_local = "org.getodk:javarosa:local" const val javarosa = javarosa_online const val karumi_dexter = "com.karumi:dexter:6.2.3" @@ -51,29 +50,29 @@ object Dependencies { const val glide_compiler = "com.github.bumptech.glide:compiler:${Versions.glide}" const val caverock_androidsvg = "com.caverock:androidsvg-aar:1.4" const val mp4parser_muxer = "org.mp4parser:muxer:1.9.41" // Check if https://github.com/getodk/collect/issues/5323 no longer takes place before upgrading - const val kotlin_stdlib = "org.jetbrains.kotlin:kotlin-stdlib:1.8.22" + const val kotlin_stdlib = "org.jetbrains.kotlin:kotlin-stdlib:1.9.22" const val gson = "com.google.code.gson:gson:2.10.1" - const val firebase_analytics = "com.google.firebase:firebase-analytics:21.4.0" - const val firebase_crashlytics = "com.google.firebase:firebase-crashlytics:18.5.0" - const val leakcanary = "com.squareup.leakcanary:leakcanary-android:2.12" + const val firebase_analytics = "com.google.firebase:firebase-analytics:21.5.0" + const val firebase_crashlytics = "com.google.firebase:firebase-crashlytics:18.6.1" + const val leakcanary = "com.squareup.leakcanary:leakcanary-android:2.13" const val timber = "com.jakewharton.timber:timber:5.0.1" - const val slf4j_api = "org.slf4j:slf4j-api:2.0.9" + const val slf4j_api = "org.slf4j:slf4j-api:2.0.12" const val slf4j_timber = "com.arcao:slf4j-timber:3.1@aar" const val emoji_java = "com.vdurmont:emoji-java:5.1.1" - const val json_schema_validator = "com.networknt:json-schema-validator:1.0.87" + const val json_schema_validator = "com.networknt:json-schema-validator:1.3.2" const val splashscreen = "androidx.core:core-splashscreen:1.0.1" const val camerax_core = "androidx.camera:camera-core:${Versions.camerax}" const val camerax_view = "androidx.camera:camera-view:${Versions.camerax}" const val camerax_lifecycle = "androidx.camera:camera-lifecycle:${Versions.camerax}" const val camerax_camera2 = "androidx.camera:camera-camera2:${Versions.camerax}" const val camerax_video = "androidx.camera:camera-video:${Versions.camerax}" - const val jsoup = "org.jsoup:jsoup:1.16.2" + const val jsoup = "org.jsoup:jsoup:1.17.2" // Test dependencies const val junit = "junit:junit:4.13.2" - const val mockito_android = "org.mockito:mockito-android:5.6.0" + const val mockito_android = "org.mockito:mockito-android:5.10.0" const val mockito_inline = "org.mockito:mockito-inline:5.2.0" - const val mockito_kotlin = "org.mockito.kotlin:mockito-kotlin:5.1.0" + const val mockito_kotlin = "org.mockito.kotlin:mockito-kotlin:5.2.1" const val androidx_fragment_testing = "androidx.fragment:fragment-testing:${Versions.androidx_fragment}" const val androidx_arch_core_testing = "androidx.arch.core:core-testing:2.2.0" const val androidx_work_testing = "androidx.work:work-testing:${Versions.work}" diff --git a/buildSrc/src/main/java/dependencies/Versions.kt b/buildSrc/src/main/java/dependencies/Versions.kt index af66d92360e..857b32a9e3d 100644 --- a/buildSrc/src/main/java/dependencies/Versions.kt +++ b/buildSrc/src/main/java/dependencies/Versions.kt @@ -5,14 +5,14 @@ object Versions { const val android_min_sdk = 21 const val android_target_sdk = 33 - const val androidx_fragment = "1.6.1" - const val dagger = "2.48.1" + const val androidx_fragment = "1.6.2" + const val dagger = "2.50" const val espresso = "3.5.1" const val glide = "4.16.0" const val okhttp3 = "4.12.0" - const val robolectric = "4.9" - const val work = "2.8.1" - const val lifecycle = "2.6.2" - const val camerax = "1.3.0" - const val navigation = "2.7.4" + const val robolectric = "4.11.1" + const val work = "2.9.0" + const val lifecycle = "2.7.0" + const val camerax = "1.3.1" + const val navigation = "2.7.7" } diff --git a/check-size.sh b/check-size.sh new file mode 100755 index 00000000000..0c31a5c5433 --- /dev/null +++ b/check-size.sh @@ -0,0 +1,6 @@ +set -e + +if [ $(ls -l collect_app/build/outputs/apk/selfSignedRelease/*.apk | awk '{print $5}') -gt 11500000 ];then + echo "APK increased to $(ls -l collect_app/build/outputs/apk/selfSignedRelease/*.apk | awk '{print $5}') bytes!" + exit 1 +fi diff --git a/collect_app/build.gradle b/collect_app/build.gradle index 034ec599a82..603d08003dd 100644 --- a/collect_app/build.gradle +++ b/collect_app/build.gradle @@ -77,7 +77,7 @@ android { applicationId('org.koboc.collect.android') minSdkVersion Versions.android_min_sdk targetSdkVersion Versions.android_target_sdk - versionCode 4147 + versionCode 4148 versionName getVersionName() testInstrumentationRunner('androidx.test.runner.AndroidJUnitRunner') multiDexEnabled true @@ -238,9 +238,9 @@ dependencies { implementation project(':material') implementation project(':async') implementation project(':analytics') - implementation project(':audioclips') + implementation project(':audio-clips') implementation project(':forms') - implementation project(':audiorecorder') + implementation project(':audio-recorder') implementation project(':projects') implementation project(':location') implementation project(':geo') @@ -262,6 +262,8 @@ dependencies { implementation project(':google-maps') implementation project(':draw') implementation project(':printer') + implementation project(':lists') + implementation project(':web-page') if (getSecrets().getProperty('MAPBOX_DOWNLOADS_TOKEN', '') != '') { implementation project(':mapbox') @@ -277,7 +279,6 @@ dependencies { implementation Dependencies.androidx_preference_ktx implementation Dependencies.androidx_fragment_ktx - implementation Dependencies.android_material implementation Dependencies.android_flexbox implementation Dependencies.play_services_maps @@ -287,8 +288,6 @@ dependencies { implementation Dependencies.firebase_analytics implementation Dependencies.firebase_crashlytics - implementation Dependencies.guava - implementation Dependencies.squareup_okhttp implementation Dependencies.squareup_okhttp_tls implementation Dependencies.burgstaller_okhttp_digest @@ -341,7 +340,7 @@ dependencies { implementation Dependencies.splashscreen - testImplementation project(':formstest') + testImplementation project(':forms-test') // Testing-only dependencies testImplementation Dependencies.junit diff --git a/collect_app/google-services.json b/collect_app/google-services.json index b5fc294e230..1e6a5a9b311 100644 --- a/collect_app/google-services.json +++ b/collect_app/google-services.json @@ -1,34 +1,48 @@ { "project_info": { - "project_number": "640412404956", - "firebase_url": "https://opendatakit-forks.firebaseio.com", - "project_id": "opendatakit-forks", - "storage_bucket": "opendatakit-forks.appspot.com" + "project_number": "659901748622", + "firebase_url": "https://kobocollect-6cf1e.firebaseio.com", + "project_id": "kobocollect-6cf1e", + "storage_bucket": "kobocollect-6cf1e.appspot.com" }, "client": [ { "client_info": { - "mobilesdk_app_id": "1:640412404956:android:730483f389563845cb7819", + "mobilesdk_app_id": "1:659901748622:android:3b0a466019a89dac", "android_client_info": { - "package_name": "org.odk.collect.android" + "package_name": "org.koboc.collect.android" } }, "oauth_client": [ { - "client_id": "640412404956-ag8do5nic02k70qr2k1a0mv3du8222hv.apps.googleusercontent.com", + "client_id": "659901748622-iekvk6f4ct9g4iomgr3j5vvja9gp71l0.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "org.koboc.collect.android", + "certificate_hash": "df1bd727d0cc9ca347720a38f2249552dc1865ec" + } + }, + { + "client_id": "659901748622-n371b3mfc92hc42vka989l8ae8u6v1c8.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyA5tuKwVrlKEs-7iMSpdTmAy6qSH-w0lik" + "current_key": "AIzaSyCkT_yrsm0Keynz2o_sOAs5N_ahmFmtJsU" } ], "services": { + "analytics_service": { + "status": 2, + "analytics_property": { + "tracking_id": "UA-111083836-1" + } + }, "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "640412404956-ag8do5nic02k70qr2k1a0mv3du8222hv.apps.googleusercontent.com", + "client_id": "659901748622-n371b3mfc92hc42vka989l8ae8u6v1c8.apps.googleusercontent.com", "client_type": 3 } ] @@ -37,4 +51,4 @@ } ], "configuration_version": "1" -} \ No newline at end of file +} diff --git a/collect_app/proguard-rules.txt b/collect_app/proguard-rules.txt index 4ab6ab0ad6d..19ace210362 100644 --- a/collect_app/proguard-rules.txt +++ b/collect_app/proguard-rules.txt @@ -25,3 +25,6 @@ -keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase -dontwarn org.codehaus.mojo.animal_sniffer.* -dontwarn okhttp3.internal.platform.ConscryptPlatform + +# Keep line numbers for Crashlytics https://stackoverflow.com/questions/38529304/android-crashlytics-sending-incorrect-line-number +-keepattributes SourceFile,LineNumberTable diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/entitymanagement/DeleteEntitiesTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/entitymanagement/DeleteEntitiesTest.kt new file mode 100644 index 00000000000..b238db17016 --- /dev/null +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/entitymanagement/DeleteEntitiesTest.kt @@ -0,0 +1,33 @@ +package org.odk.collect.android.feature.entitymanagement + +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.odk.collect.android.support.TestDependencies +import org.odk.collect.android.support.pages.FormEntryPage +import org.odk.collect.android.support.rules.CollectTestRule +import org.odk.collect.android.support.rules.TestRuleChain +import org.odk.collect.strings.R.string + +class DeleteEntitiesTest { + + private val rule = CollectTestRule(useDemoProject = false) + private val testDependencies = TestDependencies() + + @get:Rule + val ruleChain: RuleChain = TestRuleChain.chain(testDependencies) + .around(rule) + + @Test + fun canClearAllEntities() { + testDependencies.server.addForm("one-question-entity-registration.xml") + + rule.withMatchExactlyProject(testDependencies.server.url) + .startBlankForm("One Question Entity Registration") + .fillOutAndFinalize(FormEntryPage.QuestionAndAnswer("Name", "Logan Roy")) + .openEntityBrowser() + .clickOptionsIcon(string.clear_entities) + .clickOnString(string.clear_entities) + .assertTextDoesNotExist("people") + } +} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/entitymanagement/ViewEntitiesTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/entitymanagement/ViewEntitiesTest.kt new file mode 100644 index 00000000000..ae690bb6b72 --- /dev/null +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/entitymanagement/ViewEntitiesTest.kt @@ -0,0 +1,48 @@ +package org.odk.collect.android.feature.entitymanagement + +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.odk.collect.android.support.StubOpenRosaServer +import org.odk.collect.android.support.TestDependencies +import org.odk.collect.android.support.pages.FormEntryPage +import org.odk.collect.android.support.rules.CollectTestRule +import org.odk.collect.android.support.rules.TestRuleChain + +class ViewEntitiesTest { + + private val rule = CollectTestRule(useDemoProject = false) + private val testDependencies = TestDependencies() + + @get:Rule + val ruleChain: RuleChain = TestRuleChain.chain(testDependencies) + .around(rule) + + @Test + fun canViewLocallyCreatedEntitiesInBrowser() { + testDependencies.server.addForm("one-question-entity-registration.xml") + + rule.withMatchExactlyProject(testDependencies.server.url) + .addEntityListInBrowser("people") + .startBlankForm("One Question Entity Registration") + .fillOutAndFinalize(FormEntryPage.QuestionAndAnswer("Name", "Logan Roy")) + .openEntityBrowser() + .clickOnList("people") + .assertEntity("Logan Roy", "full_name: Logan Roy") + } + + @Test + fun canViewListEntitiesInBrowser() { + testDependencies.server.addForm( + "one-question-entity-follow-up.xml", + listOf(StubOpenRosaServer.EntityListItem("people.csv")) + ) + + rule.withMatchExactlyProject(testDependencies.server.url) + .addEntityListInBrowser("people") + .refreshForms() + .openEntityBrowser() + .clickOnList("people") + .assertEntity("Roman Roy", "full_name: Roman Roy") + } +} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/external/InstanceUploadActionTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/external/InstanceUploadActionTest.kt index 1eebf01a831..6e729b6ead6 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/external/InstanceUploadActionTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/external/InstanceUploadActionTest.kt @@ -1,40 +1,68 @@ package org.odk.collect.android.feature.external +import android.content.Context import android.content.Intent +import android.provider.BaseColumns._ID +import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain import org.junit.runner.RunWith import org.odk.collect.android.external.InstancesContract -import org.odk.collect.android.instancemanagement.send.InstanceUploaderActivity +import org.odk.collect.android.support.TestDependencies +import org.odk.collect.android.support.pages.FormEntryPage import org.odk.collect.android.support.pages.OkDialog import org.odk.collect.android.support.rules.CollectTestRule import org.odk.collect.android.support.rules.TestRuleChain +import org.odk.collect.android.utilities.ApplicationConstants @RunWith(AndroidJUnit4::class) class InstanceUploadActionTest { - val collectTestRule = CollectTestRule() + private val rule = CollectTestRule() + private val context = ApplicationProvider.getApplicationContext() + private val testDependencies = TestDependencies() @get:Rule - val rule: RuleChain = TestRuleChain.chain() - .around(collectTestRule) + val chain: RuleChain = TestRuleChain.chain(testDependencies) + .around(rule) @Test - fun whenInstanceDoesNotExist_showsError() { - val instanceIds = longArrayOf(11) - instanceUploadAction(instanceIds) + fun whenIntentIncludesURLExtra_instancesAreUploadedToThatURL() { + rule.startAtMainMenu() + .copyForm("one-question.xml") + .startBlankForm("One Question") + .fillOutAndFinalize(FormEntryPage.QuestionAndAnswer("what is your age", "34")) - OkDialog() - .assertOnPage() - .assertText(org.odk.collect.strings.R.string.no_forms_uploaded) + val instanceId = + context.contentResolver.query(InstancesContract.getUri("DEMO"), null, null, null, null) + .use { + it!!.moveToFirst() + it.getLong(it.getColumnIndex(_ID)) + } + + val intent = Intent("org.odk.collect.android.INSTANCE_UPLOAD") + intent.type = InstancesContract.CONTENT_TYPE + intent.putExtra(ApplicationConstants.BundleKeys.URL, testDependencies.server.url) + intent.putExtra("instances", longArrayOf(instanceId)) + + rule.launch(intent, OkDialog()) + .assertTextInDialog("One Question - Success") + assertThat(testDependencies.server.submissions.size, equalTo(1)) } - private fun instanceUploadAction(instanceIds: LongArray) { + @Test + fun whenInstanceDoesNotExist_showsError() { + rule.startAtMainMenu() + val intent = Intent("org.odk.collect.android.INSTANCE_UPLOAD") intent.type = InstancesContract.CONTENT_TYPE - intent.putExtra("instances", instanceIds) - collectTestRule.launch(intent) + intent.putExtra("instances", longArrayOf(11)) + + rule.launch(intent, OkDialog()) + .assertText(org.odk.collect.strings.R.string.no_forms_uploaded) } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/DeletingRepeatGroupsTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/DeletingRepeatGroupsTest.java index c4d3639a629..bdbfeb80d4a 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/DeletingRepeatGroupsTest.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/DeletingRepeatGroupsTest.java @@ -4,6 +4,7 @@ import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.withId; +import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.rules.RuleChain; @@ -15,6 +16,7 @@ import org.odk.collect.android.support.rules.TestRuleChain; import org.odk.collect.testshared.RecyclerViewMatcher; +@Ignore public class DeletingRepeatGroupsTest { private static final String TEST_FORM = "repeat_groups.xml"; 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 120248393bb..1a6dd91bfec 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 @@ -5,27 +5,141 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain import org.junit.runner.RunWith +import org.odk.collect.android.support.StubOpenRosaServer.EntityListItem +import org.odk.collect.android.support.TestDependencies import org.odk.collect.android.support.pages.FormEntryPage +import org.odk.collect.android.support.pages.MainMenuPage import org.odk.collect.android.support.rules.CollectTestRule import org.odk.collect.android.support.rules.TestRuleChain @RunWith(AndroidJUnit4::class) class EntityFormTest { - private val rule = CollectTestRule() + private val rule = CollectTestRule(useDemoProject = false) + private val testDependencies = TestDependencies() @get:Rule - val ruleChain: RuleChain = TestRuleChain.chain() + val ruleChain: RuleChain = TestRuleChain.chain(testDependencies) .around(rule) @Test - fun fillingFormWithEntityCreateElement_createsAnEntity() { - rule.startAtMainMenu() - .copyForm("one-question-entity.xml") - .startBlankForm("One Question Entity") + fun fillingEntityRegistrationForm_beforeCreatingEntityList_doesNotCreateEntityForFollowUpForms() { + testDependencies.server.addForm("one-question-entity-registration.xml") + testDependencies.server.addForm( + "one-question-entity-update.xml", + listOf(EntityListItem("people.csv")) + ) + + rule.withMatchExactlyProject(testDependencies.server.url) + .enableLocalEntitiesInForms() + + .startBlankForm("One Question Entity Registration") + .fillOutAndFinalize(FormEntryPage.QuestionAndAnswer("Name", "Logan Roy")) + + .startBlankForm("One Question Entity Update") + .assertQuestion("Select person") + .assertText("Roman Roy") + .assertTextDoesNotExist("Logan Roy") + } + + @Test + fun fillingEntityRegistrationForm_createsEntityForFollowUpForms() { + testDependencies.server.addForm("one-question-entity-registration.xml") + testDependencies.server.addForm( + "one-question-entity-update.xml", + listOf(EntityListItem("people.csv")) + ) + + rule.withMatchExactlyProject(testDependencies.server.url) + .setupEntities("people") + + .startBlankForm("One Question Entity Registration") .fillOutAndFinalize(FormEntryPage.QuestionAndAnswer("Name", "Logan Roy")) - .openEntityBrowser() - .clickOnDataset("people") - .assertEntity("full_name: Logan Roy") + + .startBlankForm("One Question Entity Update") + .assertQuestion("Select person") + .assertText("Roman Roy") + .assertText("Logan Roy") + } + + @Test + fun fillingEntityRegistrationForm_createsEntityForFollowUpFormsWithCachedFormDefs() { + testDependencies.server.addForm("one-question-entity-registration.xml") + testDependencies.server.addForm( + "one-question-entity-update.xml", + listOf(EntityListItem("people.csv")) + ) + + rule.withMatchExactlyProject(testDependencies.server.url) + .setupEntities("people") + + .startBlankForm("One Question Entity Update") // Open to create cached form def + .pressBackAndDiscardForm() + + .startBlankForm("One Question Entity Registration") + .fillOutAndFinalize(FormEntryPage.QuestionAndAnswer("Name", "Logan Roy")) + + .startBlankForm("One Question Entity Update") + .assertQuestion("Select person") + .assertText("Roman Roy") + .assertText("Logan Roy") + } + + @Test + fun fillingEntityUpdateForm_updatesEntityForFollowUpForms() { + testDependencies.server.addForm( + "one-question-entity-update.xml", + listOf(EntityListItem("people.csv")) + ) + + rule.withMatchExactlyProject(testDependencies.server.url) + .setupEntities("people") + + .startBlankForm("One Question Entity Update") + .assertQuestion("Select person") + .clickOnText("Roman Roy") + .swipeToNextQuestion("Name") + .answerQuestion("Name", "Romulus Roy") + .swipeToEndScreen() + .clickFinalize() + + .startBlankForm("One Question Entity Update") + .assertText("Romulus Roy") + .assertTextDoesNotExist("Roman Roy") + } + + @Test + fun entityListSecondaryInstancesAreConsistentBetweenFollowUpForms() { + testDependencies.server.apply { + addForm( + "one-question-entity-update.xml", + listOf(EntityListItem("people.csv")) + ) + + addForm( + "one-question-entity-follow-up.xml", + listOf(EntityListItem("people.csv", "updated-people.csv")) + ) + } + + rule.withProject(testDependencies.server) + .enableLocalEntitiesInForms() + .addEntityListInBrowser("people") + + .clickGetBlankForm() + .clickClearAll() + .clickForm("One Question Entity Update") + .clickGetSelected() + .clickOK(MainMenuPage()) + .startBlankForm("One Question Entity Update") + .assertText("Roman Roy") + .pressBackAndDiscardForm() + + .clickGetBlankForm() + .clickGetSelected() // Collect automatically only selects the un-downloaded forms + .clickOK(MainMenuPage()) + .startBlankForm("One Question Entity Update") + .assertText("Ro-Ro Roy") + .assertTextDoesNotExist("Roman Roy") } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/FieldListUpdateTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/FieldListUpdateTest.java index c7bf56308fd..655a1ba3bad 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/FieldListUpdateTest.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/FieldListUpdateTest.java @@ -57,7 +57,6 @@ import androidx.test.espresso.matcher.ViewMatchers; import org.hamcrest.Matcher; -import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.rules.RuleChain; @@ -271,17 +270,17 @@ public void selectionChangeAtOneCascadeLevelWithMinimalAppearance_ShouldUpdateNe .clickOnQuestion("Level1") .assertTextsDoNotExist("A1", "B1", "C1", "A1A") // No choices should be shown for levels 2 and 3 when no selection is made for level 1 .openSelectMinimalDialog(0) - .clickOnText("C") // Selecting C for level 1 should only reveal options for C at level 2 + .selectItem("C") // Selecting C for level 1 should only reveal options for C at level 2 .assertTextsDoNotExist("A1", "B1") .openSelectMinimalDialog(1) - .clickOnText("C1") + .selectItem("C1") .assertTextDoesNotExist("A1A") .clickOnText("C") .clickOnText("A") // Selecting A for level 1 should reveal options for A at level 2 .openSelectMinimalDialog(1) .assertText("A1") .assertTextsDoNotExist("A1A", "B1", "C1") - .clickOnText("A1") // Selecting A1 for level 2 should reveal options for A1 at level 3 + .selectItem("A1") // Selecting A1 for level 2 should reveal options for A1 at level 3 .openSelectMinimalDialog(2) .assertText("A1A") .assertTextsDoNotExist("B1A", "B1", "C1"); @@ -317,10 +316,10 @@ public void questionsAppearingBeforeCurrentBinaryQuestion_ShouldNotChangeFocus() intending(not(isInternal())).respondWith(new Instrumentation.ActivityResult(Activity.RESULT_OK, null)); - onView(withId(R.id.capture_image)).perform(click()); + onView(withId(R.id.capture_button)).perform(click()); onView(withText("Target10-15")).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))); - onView(withId(R.id.capture_image)).check(matches(isCompletelyDisplayed())); + onView(withId(R.id.capture_button)).check(matches(isCompletelyDisplayed())); } @Test @@ -387,13 +386,12 @@ public void searchMinimalInFieldList() { .clickOnQuestion("Source15") .openSelectMinimalDialog() .assertTexts("Mango", "Oranges", "Strawberries") - .clickOnText("Strawberries") + .selectItem("Strawberries") .assertText("Target15") .assertSelectMinimalDialogAnswer("Strawberries"); } @Test - @Ignore("https://github.com/getodk/collect/issues/5996") public void listOfQuestionsShouldNotBeScrolledToTheLastEditedQuestionAfterClickingOnAQuestion() { new FormEntryPage("fieldlist-updates") .clickGoToArrow() @@ -401,8 +399,8 @@ public void listOfQuestionsShouldNotBeScrolledToTheLastEditedQuestionAfterClicki .clickOnGroup("Long list of questions") .clickOnQuestion("Question1") .answerQuestion(0, "X") - .activateTextQuestion(19) - .checkIsTranslationDisplayed("Question20"); + .clickOnQuestionField("Question20") + .assertText("Question20"); } @Test @@ -415,12 +413,28 @@ public void recordingAudio_ShouldChangeRelevanceOfRelatedField() { .assertTextDoesNotExist("Target16") .clickOnString(org.odk.collect.strings.R.string.capture_audio) .clickOnContentDescription(org.odk.collect.strings.R.string.stop_recording) - .checkIsTranslationDisplayed("Target16") + .assertText("Target16") .clickOnString(org.odk.collect.strings.R.string.delete_answer_file) - .clickOnButtonInDialog(org.odk.collect.strings.R.string.delete_answer_file, new FormEntryPage("fieldlist-updates")) + .clickOnTextInDialog(org.odk.collect.strings.R.string.delete_answer_file, new FormEntryPage("fieldlist-updates")) .assertTextDoesNotExist("Target16"); } + @Test + public void changeInValueUsedToDetermineIfAQuestionIsRequired_ShouldUpdateTheRelatedRequiredQuestion() { + new FormEntryPage("fieldlist-updates") + .clickGoToArrow() + .clickGoUpIcon() + .clickOnGroup("Dynamic required question") + .clickOnQuestion("Source17") + .assertQuestion("Target17") + .answerQuestion(0, "blah") + .assertQuestion("Target17", true) + .swipeToNextQuestionWithConstraintViolation(org.odk.collect.strings.R.string.required_answer_error) + .answerQuestion(0, "") + .assertQuestion("Target17") + .assertTextDoesNotExist(org.odk.collect.strings.R.string.required_answer_error); + } + // Scroll down until the desired group name is visible. This is needed to make the tests work // on devices with screens of different heights. private void jumpToGroupWithText(String text) { diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/FormHierarchyTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/FormHierarchyTest.java index be0a8b90bae..fe97c9a8ae1 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/FormHierarchyTest.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/FormHierarchyTest.java @@ -187,4 +187,17 @@ public void showRepeatsPickerWhenFirstRepeatIsEmpty() { .clickGoUpIcon() .assertTexts("Repeat", "Repeatable Group"); } + + @Test + //https://github.com/getodk/collect/issues/6015 + public void regularGroupThatWrapsARepeatableGroupShouldBeTreatedAsARegularOne() { + rule.startAtMainMenu() + .copyForm("repeat_group_wrapped_with_a_regular_group.xml") + .startBlankForm("Repeat group wrapped with a regular group") + .clickGoToArrow() + .clickGoUpIcon() + .clickGoUpIcon() + .assertPath("Outer") + .assertNotRemovableGroup(); + } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/FormStylingTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/FormStylingTest.kt index c77b843e437..8af8bb9dc84 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/FormStylingTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/FormStylingTest.kt @@ -223,7 +223,7 @@ class FormStylingTest { .startBlankForm(FORM_NAME) .clickGoToArrow() .clickOnGroup("selectOneQuestions") - .assertText("selectOneQuestions") + .assertPath("selectOneQuestions") .clickOnQuestion("Select one widget") .assertText("selectOneQuestions") } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/IntentGroupTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/IntentGroupTest.java index 5147b0655af..82184b09682 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/IntentGroupTest.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/IntentGroupTest.java @@ -208,8 +208,8 @@ public void collect_shouldNotCrashWhenAnyErrorIsThrownWhileReceivingAnswer() { private void assertImageWidgetWithoutAnswer() { onView(allOf(withTagValue(is("ImageView")), withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))).check(doesNotExist()); - onView(withId(R.id.capture_image)).check(doesNotExist()); - onView(withId(R.id.choose_image)).check(doesNotExist()); + onView(withId(R.id.capture_button)).check(matches(not(isDisplayed()))); + onView(withId(R.id.choose_button)).check(matches(not(isDisplayed()))); } private void assertAudioWidgetWithoutAnswer() { @@ -218,7 +218,7 @@ private void assertAudioWidgetWithoutAnswer() { private void assertVideoWidgetWithoutAnswer() { onView(withText(is("Video external"))).perform(scrollTo()).check(matches(isDisplayed())); - onView(withId(R.id.play_video)).check(matches(not(isDisplayed()))); + onView(withId(R.id.play_video_button)).check(matches(not(isDisplayed()))); } private void assertFileWidgetWithoutAnswer() { @@ -227,8 +227,8 @@ private void assertFileWidgetWithoutAnswer() { private void assertImageWidgetWithAnswer() { onView(allOf(withTagValue(is("ImageView")), withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))).check(matches(not(doesNotExist()))); - onView(withId(R.id.capture_image)).check(doesNotExist()); - onView(withId(R.id.choose_image)).check(doesNotExist()); + onView(withId(R.id.capture_button)).check(matches(not(isDisplayed()))); + onView(withId(R.id.choose_button)).check(matches(not(isDisplayed()))); } private void assertAudioWidgetWithAnswer() { @@ -236,8 +236,8 @@ private void assertAudioWidgetWithAnswer() { } private void assertVideoWidgetWithAnswer() { - onView(withId(R.id.play_video)).perform(scrollTo()).check(matches(isDisplayed())); - onView(withId(R.id.play_video)).check(matches(isEnabled())); + onView(withId(R.id.play_video_button)).perform(scrollTo()).check(matches(isDisplayed())); + onView(withId(R.id.play_video_button)).check(matches(isEnabled())); } private void assertFileWidgetWithAnswer() { diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/LikertTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/LikertTest.java index 0f04339c03f..cb71d61804a 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/LikertTest.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/LikertTest.java @@ -19,7 +19,6 @@ import org.junit.rules.RuleChain; import org.odk.collect.android.R; import org.odk.collect.android.support.rules.BlankFormTestRule; -import org.odk.collect.android.support.rules.ResetStateRule; import org.odk.collect.android.support.rules.TestRuleChain; import java.util.Collections; @@ -31,7 +30,6 @@ public class LikertTest { @Rule public RuleChain copyFormChain = TestRuleChain.chain() - .around(new ResetStateRule()) .around(activityTestRule); @Test diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/RequiredQuestionTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/RequiredQuestionTest.java index 3edc3ad119e..4c48504fb93 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/RequiredQuestionTest.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/RequiredQuestionTest.java @@ -104,7 +104,7 @@ public void ifRequiredQuestionIsInFieldListAndNotFirst_shouldBeValidatedProperly rule.startAtMainMenu() .copyForm("requiredQuestionInFieldList.xml") .startBlankForm("requiredQuestionInFieldList") - .answerQuestion(0, "Foo") + .answerQuestion("Foo", true, "blah") .swipeToNextQuestionWithConstraintViolation("Custom required message2") .clickOptionsIcon() .clickOnString(org.odk.collect.strings.R.string.validate) diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/SavePointTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/SavePointTest.kt index e89d53b009f..568aedafb04 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/SavePointTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/SavePointTest.kt @@ -13,15 +13,19 @@ import org.odk.collect.android.support.pages.FormEntryPage import org.odk.collect.android.support.pages.FormHierarchyPage import org.odk.collect.android.support.pages.SaveOrDiscardFormDialog import org.odk.collect.android.support.rules.FormEntryActivityTestRule +import org.odk.collect.android.support.rules.RecentAppsRule import org.odk.collect.android.support.rules.TestRuleChain @RunWith(AndroidJUnit4::class) class SavePointTest { private val rule = FormEntryActivityTestRule() + private val recentAppsRule = RecentAppsRule() @get:Rule - val ruleChain: RuleChain = TestRuleChain.chain().around(rule) + val ruleChain: RuleChain = TestRuleChain.chain() + .around(recentAppsRule) + .around(rule) @Test fun savePointIsCreatedWhenMovingForwardInForm() { @@ -34,7 +38,8 @@ class SavePointTest { .let { simulateBatteryDeath() } // Start blank form and check save point is loaded - rule.fillNewForm("two-question-audit.xml", FormHierarchyPage("Two Question")) + rule.fillNewFormWithSavepoint("two-question-audit.xml") + .clickRecover(FormHierarchyPage("Two Question")) .assertText("Alexei") .assertTextDoesNotExist("46") .pressBack(FormEntryPage("Two Question")) @@ -78,7 +83,8 @@ class SavePointTest { .let { simulateBatteryDeath() } // Edit instance and check save point is loaded - rule.editForm("two-question-audit.xml", "Two Question") + rule.editFormWithSavepoint("two-question-audit.xml") + .clickRecover(FormHierarchyPage("Two Question")) .assertText("Alexei") .assertText("52") .assertTextDoesNotExist("46") @@ -110,10 +116,12 @@ class SavePointTest { rule.setUpProjectAndCopyForm("two-question-audit.xml", listOf("external_data_10.zip")) .fillNewForm("two-question-audit.xml", "Two Question") .answerQuestion("What is your name?", "Alexei") - .let { simulateProcessDeath() } + + recentAppsRule.leaveAndKillApp() // Start blank form and check save point is loaded - rule.fillNewForm("two-question-audit.xml", FormHierarchyPage("Two Question")) + rule.fillNewFormWithSavepoint("two-question-audit.xml") + .clickRecover(FormHierarchyPage("Two Question")) .assertText("Alexei") .pressBack(FormEntryPage("Two Question")) .closeSoftKeyboard() @@ -150,10 +158,12 @@ class SavePointTest { rule.editForm("two-question-audit.xml", "Two Question") .clickGoToStart() .answerQuestion("What is your name?", "Alexei") - .let { simulateProcessDeath() } + + recentAppsRule.leaveAndKillApp() // Edit instance and check save point is loaded - rule.editForm("two-question-audit.xml", "Two Question") + rule.editFormWithSavepoint("two-question-audit.xml") + .clickRecover(FormHierarchyPage("Two Question")) .assertText("Alexei") .assertText("52") .pressBack(FormEntryPage("Two Question")) @@ -191,7 +201,8 @@ class SavePointTest { // Create save point for blank form rule.fillNewForm("two-question-audit.xml", "Two Question") .answerQuestion("What is your name?", "Alexei") - .let { simulateProcessDeath() } + + recentAppsRule.leaveAndKillApp() // Check editing instance doesn't load save point rule.editForm("two-question-audit.xml", "Two Question") @@ -215,12 +226,36 @@ class SavePointTest { rule.editForm("two-question-audit.xml", "Two Question") .clickGoToStart() .answerQuestion("What is your name?", "Alexei") - .let { simulateProcessDeath() } + + recentAppsRule.leaveAndKillApp() // Check starting blank form does not load save point rule.fillNewForm("two-question-audit.xml", "Two Question") } + @Test // https://github.com/getodk/collect/pull/6058 + fun whenBlankFormStartedThenSavedAndKilled_aSavepointShouldBeCreatedForASavedFormNotForTheBlankOne() { + // Start blank form, save it and create a savepoint + rule.setUpProjectAndCopyForm("two-question.xml") + .fillNewForm("two-question.xml", "Two Question") + .answerQuestion("What is your name?", "Alexei") + .clickSave() + .swipeToNextQuestion("What is your age?") + .answerQuestion("What is your age?", "46") + + recentAppsRule.leaveAndKillApp() + + // Start blank form and check save point is not loaded + rule.fillNewForm("two-question.xml", "Two Question") + .pressBackAndDiscardForm(AppClosedPage()) + + // Edit saved form and check save point is loaded + rule.editFormWithSavepoint("two-question.xml") + .clickRecover(FormHierarchyPage("Two Question")) + .assertText("Alexei") + .assertText("46") + } + /** * Simulates a case where the process is killed without lifecycle clean up (like a phone * being battery dying). @@ -228,13 +263,4 @@ class SavePointTest { private fun simulateBatteryDeath(): FormEntryActivityTestRule { return rule.simulateProcessRestart() } - - /** - * Simulate a "process death" case where an app in the background is killed - */ - private fun simulateProcessDeath(): FormEntryActivityTestRule { - return rule.navigateAwayFromActivity() - .destroyActivity() - .simulateProcessRestart() - } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/audit/AuditTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/audit/AuditTest.kt index 93fe1ac6fdf..b6f385aeb2b 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/audit/AuditTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/audit/AuditTest.kt @@ -13,15 +13,18 @@ import org.odk.collect.android.support.pages.FormEntryPage import org.odk.collect.android.support.pages.MainMenuPage import org.odk.collect.android.support.pages.ProjectSettingsPage import org.odk.collect.android.support.rules.CollectTestRule +import org.odk.collect.android.support.rules.RecentAppsRule import org.odk.collect.android.support.rules.TestRuleChain @RunWith(AndroidJUnit4::class) class AuditTest { private val rule = CollectTestRule() + private val recentAppsRule = RecentAppsRule() @get:Rule val ruleChain: RuleChain = TestRuleChain.chain() + .around(recentAppsRule) .around(rule) @Test @@ -64,7 +67,7 @@ class AuditTest { rule.startAtMainMenu() .copyForm("two-question-audit-track-changes.xml") .startBlankForm("One Question Audit Track Changes") - .fillOut(FormEntryPage.QuestionAndAnswer("What is your age", "31")) + .fillOut(FormEntryPage.QuestionAndAnswer("What is your age?", "31")) .clickOptionsIcon() .clickGeneralSettings() @@ -78,7 +81,7 @@ class AuditTest { rule.startAtMainMenu() .copyForm("two-question-audit-track-changes.xml") .startBlankForm("One Question Audit Track Changes") - .fillOut(FormEntryPage.QuestionAndAnswer("What is your age", "31")) + .fillOut(FormEntryPage.QuestionAndAnswer("What is your age?", "31")) .swipeToNextQuestion("What is your name?") .fillOut(FormEntryPage.QuestionAndAnswer("What is your name?", "Adam")) .swipeToEndScreen() @@ -104,8 +107,9 @@ class AuditTest { .pressBack(MainMenuPage()) .startBlankForm("One Question Audit") .fillOut(FormEntryPage.QuestionAndAnswer("what is your age", "31")) - .killAndReopenApp(rule, MainMenuPage()) - .startBlankForm("One Question Audit") + .killAndReopenApp(rule, recentAppsRule, MainMenuPage()) + .startBlankFormWithSavepoint("One Question Audit") + .clickRecover(FormEntryPage("One Question Audit")) .swipeToEndScreen() .clickFinalize() diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/backgroundlocation/SetGeopointActionTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/backgroundlocation/SetGeopointActionTest.java index 3a1d4174a7d..5e3d820106c 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/backgroundlocation/SetGeopointActionTest.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/backgroundlocation/SetGeopointActionTest.java @@ -1,12 +1,5 @@ package org.odk.collect.android.feature.formentry.backgroundlocation; -import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.assertion.ViewAssertions.matches; -import static androidx.test.espresso.matcher.ViewMatchers.withId; -import static androidx.test.espresso.matcher.ViewMatchers.withText; - -import androidx.test.core.app.ApplicationProvider; - import org.junit.Rule; import org.junit.Test; import org.junit.rules.RuleChain; @@ -25,8 +18,8 @@ public class SetGeopointActionTest { @Test public void locationCollectionSnackbar_ShouldBeDisplayedAtFormLaunch() { - onView(withId(com.google.android.material.R.id.snackbar_text)) - .check(matches(withText(String.format(ApplicationProvider.getApplicationContext().getString(org.odk.collect.strings.R.string.background_location_enabled), "⋮")))); + rule.startInFormEntry() + .checkIsSnackbarWithMessageDisplayed(org.odk.collect.strings.R.string.background_location_enabled, "⋮"); } /** diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt index 8b807bf2ad1..b50d46a28d5 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/BulkFinalizationTest.kt @@ -15,6 +15,7 @@ import org.odk.collect.android.support.pages.MainMenuPage import org.odk.collect.android.support.pages.ProjectSettingsPage import org.odk.collect.android.support.pages.SaveOrDiscardFormDialog import org.odk.collect.android.support.rules.CollectTestRule +import org.odk.collect.android.support.rules.RecentAppsRule import org.odk.collect.android.support.rules.TestRuleChain import org.odk.collect.strings.R.plurals import org.odk.collect.strings.R.string @@ -22,11 +23,14 @@ import org.odk.collect.strings.R.string @RunWith(AndroidJUnit4::class) class BulkFinalizationTest { - val testDependencies = TestDependencies() - val rule = CollectTestRule(useDemoProject = false) + private val testDependencies = TestDependencies() + private val recentAppsRule = RecentAppsRule() + private val rule = CollectTestRule(useDemoProject = false) @get:Rule - val chain: RuleChain = TestRuleChain.chain(testDependencies).around(rule) + val chain: RuleChain = TestRuleChain.chain(testDependencies) + .around(recentAppsRule) + .around(rule) @Test fun canBulkFinalizeDraftsInTheListOfDrafts() { @@ -99,7 +103,7 @@ class BulkFinalizationTest { .clickOptionsIcon(string.finalize_all_drafts) .clickOnString(string.finalize_all_drafts) - .clickOnButtonInDialog(string.finalize, EditSavedFormPage(false)) + .clickOnTextInDialog(string.finalize, EditSavedFormPage(false)) .checkIsSnackbarWithQuantityDisplayed(plurals.bulk_finalize_failure, 1) } @@ -113,7 +117,7 @@ class BulkFinalizationTest { .clickDrafts() .clickOnForm("One Question") - .killAndReopenApp(rule, MainMenuPage()) + .killAndReopenApp(rule, recentAppsRule, MainMenuPage()) .clickDrafts(false) .clickFinalizeAll(1) @@ -167,7 +171,10 @@ class BulkFinalizationTest { @Test fun whenAutoSendIsEnabled_draftsAreSentAfterFinalizing() { val mainMenuPage = rule.withProject(testDependencies.server.url) - .enableAutoSend(testDependencies.scheduler) + .enableAutoSend( + testDependencies.scheduler, + string.wifi_cellular_autosend + ) .copyForm("one-question.xml", testDependencies.server.hostName) .startBlankForm("One Question") @@ -186,6 +193,26 @@ class BulkFinalizationTest { assertThat(testDependencies.server.submissions.size, equalTo(1)) } + @Test + fun whenDraftFormHasAutoSendEnabled_draftsAreSentAfterFinalizing() { + val mainMenuPage = rule.withProject(testDependencies.server.url) + .copyForm("one-question-autosend.xml", testDependencies.server.hostName) + .startBlankForm("One Question Autosend") + .fillOutAndSave(QuestionAndAnswer("what is your age", "97")) + + .clickDrafts(1) + .clickFinalizeAll(1) + .clickFinalize() + .pressBack(MainMenuPage()) + + testDependencies.scheduler.runDeferredTasks() + + mainMenuPage.clickViewSentForm(1) + .assertText("One Question Autosend") + + assertThat(testDependencies.server.submissions.size, equalTo(1)) + } + @Test fun canCancel() { rule.withProject("http://example.com") diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/FormUpdateTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/FormUpdateTest.kt index b1290a3d789..27e00ca412e 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/FormUpdateTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/FormUpdateTest.kt @@ -5,6 +5,7 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain import org.junit.runner.RunWith +import org.odk.collect.android.support.StubOpenRosaServer import org.odk.collect.android.support.TestDependencies import org.odk.collect.android.support.pages.FormEntryPage import org.odk.collect.android.support.pages.MainMenuPage @@ -149,4 +150,32 @@ class FormUpdateTest { .startBlankForm("One Question Last Saved") .assertText("32") } + + @Test // https://github.com/getodk/collect/issues/6097 + fun itIsPossibleToDownloadAnUpdate_afterDownloadingAndOpeningAFormWithBrokenExternalDataset() { + testDependencies.server.addForm( + "external_select_csv.xml", + listOf(StubOpenRosaServer.MediaFileItem("external_data.csv", "external_data_broken.csv")) + ) + + val mainMenuPage = rule.withProject(testDependencies.server.url) + .clickGetBlankForm() + .clickGetSelected() + .clickOKOnDialog(MainMenuPage()) + .startBlankFormWithError("external select") + .assertText(org.odk.collect.strings.R.string.error_occured) + .clickOKOnDialog(MainMenuPage()) + + testDependencies.server.addForm( + "external_select_csv.xml", + listOf(StubOpenRosaServer.MediaFileItem("external_data.csv")) + ) + + mainMenuPage.clickGetBlankForm() + .clickGetSelected() + .clickOKOnDialog(MainMenuPage()) + .startBlankForm("external select") + .assertTextDoesNotExist("Error Occurred") + .assertTexts("One", "Two", "Three") + } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/MatchExactlyTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/MatchExactlyTest.kt index 37949c426f2..127529b69c9 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/MatchExactlyTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/MatchExactlyTest.kt @@ -8,7 +8,6 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain import org.junit.runner.RunWith -import org.odk.collect.android.R import org.odk.collect.android.support.TestDependencies import org.odk.collect.android.support.pages.ErrorPage import org.odk.collect.android.support.pages.FillBlankFormPage @@ -93,6 +92,7 @@ class MatchExactlyTest { .assertNotification("ODK Collect", "Form update failed", "Demo project") .clickAction( "ODK Collect", + "Form update failed", "Show details", ErrorPage() ).assertText(org.odk.collect.strings.R.string.form_update_error) diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/PreviouslyDownloadedOnlyTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/PreviouslyDownloadedOnlyTest.kt index d522bb2848a..bd58819eba6 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/PreviouslyDownloadedOnlyTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formmanagement/PreviouslyDownloadedOnlyTest.kt @@ -127,6 +127,7 @@ class PreviouslyDownloadedOnlyTest { ) .clickAction( "ODK Collect", + "Forms download failed", "Show details", ErrorPage() ) diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/instancemanagement/AutoSendTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/instancemanagement/AutoSendTest.kt index 4fb05cc8d11..bc4e5c1e37d 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/instancemanagement/AutoSendTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/instancemanagement/AutoSendTest.kt @@ -7,11 +7,14 @@ import org.junit.rules.RuleChain import org.junit.runner.RunWith import org.odk.collect.android.support.TestDependencies import org.odk.collect.android.support.pages.ErrorPage +import org.odk.collect.android.support.pages.FormEntryPage import org.odk.collect.android.support.pages.MainMenuPage import org.odk.collect.android.support.pages.ViewSentFormPage import org.odk.collect.android.support.rules.CollectTestRule import org.odk.collect.android.support.rules.NotificationDrawerRule import org.odk.collect.android.support.rules.TestRuleChain +import org.odk.collect.async.Scheduler +import org.odk.collect.strings.R @RunWith(AndroidJUnit4::class) class AutoSendTest { @@ -28,7 +31,10 @@ class AutoSendTest { fun whenAutoSendEnabled_fillingAndFinalizingForm_sendsFormAndNotifiesUser() { val mainMenuPage = rule.startAtMainMenu() .setServer(testDependencies.server.url) - .enableAutoSend(testDependencies.scheduler) + .enableAutoSend( + testDependencies.scheduler, + R.string.wifi_cellular_autosend + ) .copyForm("one-question.xml") .startBlankForm("One Question") .inputText("31") @@ -57,7 +63,10 @@ class AutoSendTest { val mainMenuPage = rule.startAtMainMenu() .setServer(testDependencies.server.url) - .enableAutoSend(testDependencies.scheduler) + .enableAutoSend( + testDependencies.scheduler, + R.string.wifi_cellular_autosend + ) .copyForm("one-question.xml") .startBlankForm("One Question") .inputText("31") @@ -74,21 +83,27 @@ class AutoSendTest { .assertNotification("ODK Collect", "Forms upload failed", "1 of 1 uploads failed!") .clickAction( "ODK Collect", + "Forms upload failed", "Show details", ErrorPage() ) } @Test - fun whenFormHasAutoSend_fillingAndFinalizingForm_sendsFormAndNotifiesUser() { + fun whenFormHasAutoSend_fillingAndFinalizingForm_sendsFormAndNotifiesUser_regardlessOfSetting() { val mainMenuPage = rule.startAtMainMenu() .setServer(testDependencies.server.url) + .enableAutoSend( + testDependencies.scheduler, + R.string.wifi_autosend + ) .copyForm("one-question-autosend.xml") .startBlankForm("One Question Autosend") .inputText("31") .swipeToEndScreen() .clickSend() + testDependencies.networkStateProvider.goOnline(Scheduler.NetworkType.CELLULAR) testDependencies.scheduler.runDeferredTasks() mainMenuPage @@ -106,17 +121,44 @@ class AutoSendTest { } @Test - fun whenFormHasAutoSend_fillingAndFinalizingForm_notifiesUserWhenSendingFails() { + fun whenFormHasAutoSend_canAutoSendMultipleForms() { + val mainMenuPage = rule.startAtMainMenu() + .setServer(testDependencies.server.url) + .copyForm("one-question-autosend.xml") + + .startBlankForm("One Question Autosend") + .inputText("31") + .swipeToEndScreen() + .clickSend() + + .startBlankForm("One Question Autosend") + .inputText("32") + .swipeToEndScreen() + .clickSend() + + testDependencies.scheduler.runDeferredTasks() + + mainMenuPage + .clickViewSentForm(2) + } + + @Test + fun whenFormHasAutoSend_fillingAndFinalizingForm_notifiesUserWhenSendingFails_regardlessOfSetting() { testDependencies.server.alwaysReturnError() val mainMenuPage = rule.startAtMainMenu() .setServer(testDependencies.server.url) + .enableAutoSend( + testDependencies.scheduler, + R.string.wifi_autosend + ) .copyForm("one-question-autosend.xml") .startBlankForm("One Question Autosend") .inputText("31") .swipeToEndScreen() .clickSend() + testDependencies.networkStateProvider.goOnline(Scheduler.NetworkType.CELLULAR) testDependencies.scheduler.runDeferredTasks() mainMenuPage.clickViewSentForm(1) @@ -127,8 +169,40 @@ class AutoSendTest { .assertNotification("ODK Collect", "Forms upload failed", "1 of 1 uploads failed!") .clickAction( "ODK Collect", + "Forms upload failed", "Show details", ErrorPage() ) } + + @Test + fun whenFormHasAutoSendDisabled_fillingAndFinalizingForm_doesNotSendForm_regardlessOfSetting() { + val mainMenuPage = rule.startAtMainMenu() + .setServer(testDependencies.server.url) + .enableAutoSend( + testDependencies.scheduler, + R.string.wifi_cellular_autosend + ) + .copyForm("one-question-autosend-disabled.xml") + .startBlankForm("One Question Autosend Disabled") + .inputText("31") + .swipeToEndScreen() + .clickFinalize() + + testDependencies.scheduler.runDeferredTasks() + mainMenuPage.assertNumberOfFinalizedForms(1) + } + + @Test + fun whenAutoSendDisabled_fillingAndFinalizingForm_doesNotSendFormAutomatically() { + val mainMenuPage = rule.startAtMainMenu() + .setServer(testDependencies.server.url) + .copyForm("one-question.xml") + .startBlankForm("One Question") + .fillOutAndFinalize(FormEntryPage.QuestionAndAnswer("what is your age", "31")) + + testDependencies.scheduler.runDeferredTasks() + + mainMenuPage.assertNumberOfFinalizedForms(1) + } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/instancemanagement/DeleteSavedFormTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/feature/instancemanagement/DeleteSavedFormTest.java index fa2120558b6..fbad0f5ad1b 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/instancemanagement/DeleteSavedFormTest.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/instancemanagement/DeleteSavedFormTest.java @@ -11,6 +11,7 @@ import org.odk.collect.android.support.rules.CollectTestRule; import org.odk.collect.android.support.rules.TestRuleChain; import org.odk.collect.android.support.pages.MainMenuPage; +import org.odk.collect.strings.R.string; @RunWith(AndroidJUnit4.class) public class DeleteSavedFormTest { @@ -34,6 +35,7 @@ public void deletingAForm_removesFormFromFinalizedForms() { .clickForm("One Question") .clickDeleteSelected(1) .clickDeleteForms() + .checkIsSnackbarWithMessageDisplayed(string.file_deleted_ok, 1) .assertTextDoesNotExist("One Question") .pressBack(new MainMenuPage()) .assertNumberOfFinalizedForms(0); @@ -50,6 +52,6 @@ public void accessingSortMenuInDeleteSavedInstancesShouldNotCrashTheAppAfterRota .clickDeleteSavedForm() .rotateToLandscape(new DeleteSavedFormPage()) .clickOnId(R.id.menu_sort) - .assertText(org.odk.collect.strings.R.string.sort_by); + .assertText(string.sort_by); } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/projects/GoogleDriveDeprecationTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/projects/GoogleDriveDeprecationTest.kt index 1aa4bd9e7f4..848085f50d1 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/projects/GoogleDriveDeprecationTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/projects/GoogleDriveDeprecationTest.kt @@ -9,13 +9,13 @@ import org.hamcrest.CoreMatchers.allOf import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain -import org.odk.collect.android.activities.WebViewActivity import org.odk.collect.android.injection.DaggerUtils import org.odk.collect.android.support.TestDependencies import org.odk.collect.android.support.rules.CollectTestRule import org.odk.collect.android.support.rules.TestRuleChain import org.odk.collect.androidtest.RecordedIntentsRule import org.odk.collect.projects.Project +import org.odk.collect.webpage.WebViewActivity class GoogleDriveDeprecationTest { private val rule = CollectTestRule() diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/projects/SwitchProjectTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/projects/SwitchProjectTest.kt index 97521b4c984..f7b2fbde1cd 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/projects/SwitchProjectTest.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/projects/SwitchProjectTest.kt @@ -47,7 +47,7 @@ class SwitchProjectTest { @Test fun switchingProject_switchesSettingsFormsInstancesAndEntities() { - testDependencies.server.addForm("One Question Entity", "one-question-entity", "1", "one-question-entity.xml") + testDependencies.server.addForm("One Question Entity Registration", "one-question-entity", "1", "one-question-entity-registration.xml") rule.startAtMainMenu() // Copy and fill form @@ -76,15 +76,16 @@ class SwitchProjectTest { .clickOKOnDialog(MainMenuPage()) // Fill form - .startBlankForm("One Question Entity") + .addEntityListInBrowser("people") + .startBlankForm("One Question Entity Registration") .fillOutAndFinalize(FormEntryPage.QuestionAndAnswer("Name", "Alice")) .clickSendFinalizedForm(1) - .assertText("One Question Entity") + .assertText("One Question Entity Registration") .pressBack(MainMenuPage()) .openEntityBrowser() - .clickOnDataset("people") - .assertEntity("full_name: Alice") + .clickOnList("people") + .assertEntity("Alice", "full_name: Alice") .pressBack(EntitiesPage()) .pressBack(ExperimentalPage()) .pressBack(ProjectSettingsPage()) diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/settings/ConfigureWithQRCodeTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/feature/settings/ConfigureWithQRCodeTest.java index 0aa8b6231d5..7c52401dc15 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/settings/ConfigureWithQRCodeTest.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/settings/ConfigureWithQRCodeTest.java @@ -1,10 +1,11 @@ package org.odk.collect.android.feature.settings; +import static androidx.test.core.app.ApplicationProvider.getApplicationContext; + import android.graphics.Bitmap; import android.graphics.BitmapFactory; import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.work.WorkManager; import org.junit.After; import org.junit.Rule; @@ -15,34 +16,31 @@ import org.odk.collect.android.configure.qr.AppConfigurationGenerator; import org.odk.collect.android.configure.qr.QRCodeGenerator; import org.odk.collect.android.injection.config.AppDependencyModule; -import org.odk.collect.android.support.rules.CollectTestRule; -import org.odk.collect.android.support.rules.ResetStateRule; -import org.odk.collect.android.support.rules.RunnableRule; import org.odk.collect.android.support.StubBarcodeViewDecoder; -import org.odk.collect.android.support.TestScheduler; -import org.odk.collect.android.support.pages.ProjectSettingsPage; +import org.odk.collect.android.support.TestDependencies; import org.odk.collect.android.support.pages.MainMenuPage; +import org.odk.collect.android.support.pages.ProjectSettingsPage; import org.odk.collect.android.support.pages.QRCodePage; +import org.odk.collect.android.support.rules.CollectTestRule; +import org.odk.collect.android.support.rules.ResetStateRule; +import org.odk.collect.android.support.rules.RunnableRule; import org.odk.collect.android.support.rules.TestRuleChain; import org.odk.collect.android.views.BarcodeViewDecoder; -import org.odk.collect.async.Scheduler; import java.io.File; import java.io.FileOutputStream; import java.util.Collection; -import static androidx.test.core.app.ApplicationProvider.getApplicationContext; - @RunWith(AndroidJUnit4.class) public class ConfigureWithQRCodeTest { + private final TestDependencies testDependencies = new TestDependencies(); private final CollectTestRule rule = new CollectTestRule(); private final StubQRCodeGenerator stubQRCodeGenerator = new StubQRCodeGenerator(); private final StubBarcodeViewDecoder stubBarcodeViewDecoder = new StubBarcodeViewDecoder(); - private final TestScheduler testScheduler = new TestScheduler(); @Rule - public RuleChain copyFormChain = TestRuleChain.chain() + public RuleChain copyFormChain = TestRuleChain.chain(testDependencies) .around(new ResetStateRule(new AppDependencyModule() { @Override @@ -54,11 +52,6 @@ public BarcodeViewDecoder providesBarcodeViewDecoder() { public QRCodeGenerator providesQRCodeGenerator() { return stubQRCodeGenerator; } - - @Override - public Scheduler providesScheduler(WorkManager workManager) { - return testScheduler; - } })) .around(new RunnableRule(stubQRCodeGenerator::setup)) .around(rule); diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/smoke/AllWidgetsFormTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/feature/smoke/AllWidgetsFormTest.java deleted file mode 100644 index 4d1bf674663..00000000000 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/smoke/AllWidgetsFormTest.java +++ /dev/null @@ -1,464 +0,0 @@ -package org.odk.collect.android.feature.smoke; - -import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.Espresso.pressBack; -import static androidx.test.espresso.action.ViewActions.click; -import static androidx.test.espresso.action.ViewActions.swipeLeft; -import static androidx.test.espresso.matcher.ViewMatchers.Visibility.VISIBLE; -import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; -import static androidx.test.espresso.matcher.ViewMatchers.withText; -import static org.hamcrest.Matchers.allOf; -import static org.hamcrest.Matchers.startsWith; - -import androidx.test.platform.app.InstrumentationRegistry; -import androidx.test.uiautomator.UiDevice; - -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.RuleChain; -import org.odk.collect.android.support.rules.BlankFormTestRule; -import org.odk.collect.android.support.rules.TestRuleChain; - -/** - * Integration test that runs through a form with all question types. - * - * screengrab is used to generate screenshots for - * documentation and releases. Calls to Screengrab.screenshot("image-name") trigger screenshot - * creation. - */ -public class AllWidgetsFormTest { - public BlankFormTestRule activityTestRule = new BlankFormTestRule("all-widgets.xml", "All widgets"); - - @Rule - public RuleChain copyFormChain = TestRuleChain.chain() - .around(activityTestRule); - //endregion - - //region Main test block. - @Test - public void testActivityOpen() { - skipInitialLabel(); - - testStringWidget(); - testStringNumberWidget(); - - testUrlWidget(); - testExStringWidget(); - testExPrinterWidget(); - - testIntegerWidget(); - testIntegerThousandSeparators(); - testExIntegerWidget(); - - testDecimalWidget(); - testExDecimalWidget(); - - // Doesn't work when sensor isn't available. - testBearingWidget(); - - testRangeIntegerWidget(); - testRangeDecimalWidget(); - testRangeVerticalAppearance(); - testRangePickerIntegerWidget(); - testRangeRatingIntegerWidget(); - - testImageWidget(); - testImageWithoutChooseWidget(); - testSelfieWidget(); - - testDrawWidget(); - testAnnotateWidget(); - testSignatureWidget(); - - testBarcodeWidget(); - - testAudioWidget(); - testVideoWidget(); - - testFileWidget(); - - testDateNoAppearanceWidget(); - testDateNoCalendarAppearance(); - testDateMonthYearAppearance(); - testDateYearAppearance(); - - testTimeNoAppearance(); - - testDateTimeNoAppearance(); - testDateTimeNoCalendarAppearance(); - - testEthiopianDateAppearance(); - testCopticDateAppearance(); - testIslamicDateAppearance(); - testBikramSambatDateAppearance(); - testMyanmarDateAppearance(); - testPersianDateAppearance(); - - testGeopointNoAppearance(); - testGeopointPlacementMapApperance(); - testGeopointMapsAppearance(); - - testGeotraceWidget(); - testGeoshapeWidget(); - - testOSMIntegrationOSMType(); - - testSelectOneNoAppearance(); - - testSpinnerWidget(); - - testSelectOneAutoAdvance(); - testSelectOneSearchAppearance(); - testSelectOneSearchAutoAdvance(); - - testGridSelectNoAppearance(); - testGridSelectCompactAppearance(); - testGridSelectCompact2Appearance(); - testGridSelectQuickCompactAppearance(); - testGridSelectQuickCompact2Appearance(); - - testImageSelectOne(); - - testLikertWidget(); - - testSelectOneFromMapWidget(); - - testMultiSelectWidget(); - testMultiSelectAutocompleteWidget(); - - testGridSelectMultipleCompact(); - testGridSelectCompact2(); - - testSpinnerSelectMultiple(); - - testImageSelectMultiple(); - - testLabelWidget(); - - testRankWidget(); - - testTriggerWidget(); - } - - //endregion - - //region Widget tests. - - public void skipInitialLabel() { - onView(withText(startsWith("Welcome to ODK Collect!"))).perform(swipeLeft()); - } - - public void testStringWidget() { - onView(withText("String widget")).perform(swipeLeft()); - } - - public void testStringNumberWidget() { - onView(withText("String number widget")).perform(swipeLeft()); - } - - public void testUrlWidget() { - onView(withText("URL widget")).perform(swipeLeft()); - } - - public void testExStringWidget() { - onView(withText("Ex string widget")).perform(swipeLeft()); - } - - public void testExPrinterWidget() { - onView(withText("Ex printer widget")).perform(swipeLeft()); - } - - public void testIntegerWidget() { - onView(withText("Integer widget")).perform(swipeLeft()); - } - - public void testIntegerThousandSeparators() { - onView(withText("Integer widget with thousands separators")).perform(swipeLeft()); - } - - public void testExIntegerWidget() { - onView(withText("Ex integer widget")).perform(swipeLeft()); - } - - public void testDecimalWidget() { - onView(withText("Decimal widget")).perform(swipeLeft()); - } - - public void testExDecimalWidget() { - onView(withText("Ex decimal widget")).perform(swipeLeft()); - } - - public void testBearingWidget() { - // - // intending(hasComponent(BearingActivity.class.getName())) - // .respondWith(cancelledResult()); - // - // onView(withText("Record Bearing")).perform(click()); - // onView(withId(R.id.answer_text)).check(matches(withText(""))); - // - // double degrees = BearingActivity.normalizeDegrees(randomDecimal()); - // String bearing = BearingActivity.formatDegrees(degrees); - // - // Intent data = new Intent(); - // data.putExtra(BEARING_RESULT, bearing); - // - // intending(hasComponent(BearingActivity.class.getName())) - // .respondWith(okResult(data)); - // - // onView(withText("Record Bearing")).perform(click()); - // onView(withId(R.id.answer_text)) - // .check(matches(allOf(isDisplayed(), withText(bearing)))); - // - // openWidgetList(); - // onView(withText("Bearing widget")).perform(click()); - // - // onView(withId(R.id.answer_text)).check(matches(withText(bearing))); - - onView(withText("Bearing widget")).perform(swipeLeft()); - } - - public void testRangeIntegerWidget() { - onView(withText("Range integer widget")).perform(swipeLeft()); - } - - public void testRangeVerticalAppearance() { - onView(withText("Range vertical integer widget")).perform(swipeLeft()); - } - - public void testRangeDecimalWidget() { - onView(withText("Range decimal widget")).perform(swipeLeft()); - } - - public void testRangePickerIntegerWidget() { - onView(withText("Range picker integer widget")).perform(swipeLeft()); - } - - public void testRangeRatingIntegerWidget() { - onView(withText("Range rating integer widget")).perform(swipeLeft()); - } - - public void testImageWidget() { - onView(withText("Image widget")).perform(swipeLeft()); - } - - public void testImageWithoutChooseWidget() { - onView(withText("Image widget without Choose button")).perform(swipeLeft()); - } - - public void testSelfieWidget() { - onView(withText("Take Picture")).perform(click()); - UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack(); - - onView(withText("Selfie widget")).perform(swipeLeft()); - } - - public void testDrawWidget() { - onView(withText("Draw widget")).perform(swipeLeft()); - } - - public void testAnnotateWidget() { - onView(withText("Annotate widget")).perform(swipeLeft()); - } - - public void testSignatureWidget() { - onView(withText("Signature widget")).perform(swipeLeft()); - } - - public void testBarcodeWidget() { - onView(withText("Barcode widget")).perform(swipeLeft()); - } - - public void testAudioWidget() { - onView(withText("Audio widget")).perform(swipeLeft()); - } - - public void testVideoWidget() { - onView(withText("Video widget")).perform(swipeLeft()); - } - - public void testFileWidget() { - onView(withText("File widget")).perform(swipeLeft()); - } - - public void testDateNoAppearanceWidget() { - onView(withText("Date widget")).perform(swipeLeft()); - } - - public void testDateNoCalendarAppearance() { - onView(withText("Date Widget")).perform(swipeLeft()); - } - - public void testDateMonthYearAppearance() { - onView(withText("Date widget")).perform(swipeLeft()); - } - - public void testDateYearAppearance() { - onView(withText("Date widget")).perform(swipeLeft()); - } - - public void testTimeNoAppearance() { - onView(withText("Time widget")).perform(swipeLeft()); - } - - public void testDateTimeNoAppearance() { - onView(allOf(withText("Date time widget"), withEffectiveVisibility(VISIBLE))) - .perform(swipeLeft()); - } - - public void testDateTimeNoCalendarAppearance() { - onView(allOf(withText("Date time widget"), withEffectiveVisibility(VISIBLE))) - .perform(swipeLeft()); - } - - public void testEthiopianDateAppearance() { - onView(allOf(withText("Ethiopian date widget"), withEffectiveVisibility(VISIBLE))) - .perform(swipeLeft()); - } - - public void testCopticDateAppearance() { - onView(allOf(withText("Coptic date widget"), withEffectiveVisibility(VISIBLE))) - .perform(swipeLeft()); - } - - public void testIslamicDateAppearance() { - onView(allOf(withText("Islamic date widget"), withEffectiveVisibility(VISIBLE))) - .perform(swipeLeft()); - } - - public void testBikramSambatDateAppearance() { - onView(allOf(withText("Bikram Sambat date widget"), withEffectiveVisibility(VISIBLE))) - .perform(swipeLeft()); - } - - public void testMyanmarDateAppearance() { - onView(allOf(withText("Myanmar date widget"), withEffectiveVisibility(VISIBLE))) - .perform(swipeLeft()); - } - - public void testPersianDateAppearance() { - onView(allOf(withText("Persian date widget"), withEffectiveVisibility(VISIBLE))) - .perform(swipeLeft()); - } - - public void testGeopointNoAppearance() { - onView(withText("Geopoint widget")).perform(swipeLeft()); - } - - public void testGeopointPlacementMapApperance() { - onView(withText("Geopoint widget")).perform(swipeLeft()); - } - - public void testGeopointMapsAppearance() { - onView(withText("Geopoint widget")).perform(swipeLeft()); - } - - public void testGeotraceWidget() { - onView(withText("Start GeoTrace")).perform(click()); - pressBack(); - - onView(withText("Geotrace widget")).perform(swipeLeft()); - } - - public void testGeoshapeWidget() { - onView(withText("Start GeoShape")).perform(click()); - pressBack(); - - onView(withText("Geoshape widget")).perform(swipeLeft()); - } - - public void testOSMIntegrationOSMType() { - onView(withText("OSM integration")).perform(swipeLeft()); - } - - public void testSelectOneNoAppearance() { - onView(withText("Select one widget")).perform(swipeLeft()); - } - - public void testSpinnerWidget() { - onView(withText("Spinner widget")).perform(swipeLeft()); - } - - public void testSelectOneAutoAdvance() { - onView(withText("Select one autoadvance widget")).perform(swipeLeft()); - } - - public void testSelectOneSearchAppearance() { - onView(withText("Select one search widget")).perform(swipeLeft()); - } - - public void testSelectOneSearchAutoAdvance() { - onView(withText("Select one search widget")).perform(swipeLeft()); - } - - public void testGridSelectNoAppearance() { - onView(withText("Grid select one widget")).perform(swipeLeft()); - } - - public void testGridSelectCompactAppearance() { - onView(withText("Grid select one widget")).perform(swipeLeft()); - } - - public void testGridSelectCompact2Appearance() { - onView(withText("Grid select one widget")).perform(swipeLeft()); - } - - public void testGridSelectQuickCompactAppearance() { - onView(withText("Grid select one widget")).perform(swipeLeft()); - } - - public void testGridSelectQuickCompact2Appearance() { - onView(withText("Grid select one widget")).perform(swipeLeft()); - } - - public void testImageSelectOne() { - onView(withText("Image select one widget")).perform(swipeLeft()); - } - - public void testLikertWidget() { - onView(withText("Likert widget")).perform(swipeLeft()); - } - - public void testSelectOneFromMapWidget() { - onView(withText("Select place")).perform(click()); - pressBack(); - - onView(withText("Select one from map widget")).perform(swipeLeft()); - } - - public void testMultiSelectWidget() { - onView(withText("Multi select widget")).perform(swipeLeft()); - } - - public void testMultiSelectAutocompleteWidget() { - onView(withText("Multi select autocomplete widget")).perform(swipeLeft()); - } - - public void testGridSelectMultipleCompact() { - onView(withText("Grid select multiple widget")).perform(swipeLeft()); - } - - public void testGridSelectCompact2() { - onView(withText("Grid select multiple widget")).perform(swipeLeft()); - } - - public void testSpinnerSelectMultiple() { - onView(withText("Spinner widget: select multiple")).perform(swipeLeft()); - } - - public void testImageSelectMultiple() { - onView(withText("Image select multiple widget")).perform(swipeLeft()); - } - - public void testLabelWidget() { - onView(withText("Label widget")).perform(swipeLeft()); - } - - public void testRankWidget() { - onView(withText("Rank widget")).perform(swipeLeft()); - } - - public void testTriggerWidget() { - onView(withText("Trigger widget")).perform(click()); - } - //endregion -} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/smoke/AllWidgetsFormTest.kt b/collect_app/src/androidTest/java/org/odk/collect/android/feature/smoke/AllWidgetsFormTest.kt new file mode 100644 index 00000000000..42610125565 --- /dev/null +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/smoke/AllWidgetsFormTest.kt @@ -0,0 +1,107 @@ +package org.odk.collect.android.feature.smoke + +import org.junit.Rule +import org.junit.Test +import org.odk.collect.android.support.pages.FormEntryPage +import org.odk.collect.android.support.rules.BlankFormTestRule +import org.odk.collect.android.support.rules.TestRuleChain.chain +import org.odk.collect.strings.R.string + +/** + * Integration test that runs through a form with all question types. + */ +class AllWidgetsFormTest { + + private val activityTestRule = BlankFormTestRule("all-widgets.xml", "All widgets") + + @get:Rule + val copyFormChain = chain() + .around(activityTestRule) + + @Test + fun testActivityOpen() { + activityTestRule.startInFormEntry() + .swipeToNextQuestion("String widget") + .swipeToNextQuestion("String number widget") + .swipeToNextQuestion("URL widget") + .swipeToNextQuestion("Ex string widget") + .swipeToNextQuestion("Ex printer widget") + + .swipeToNextQuestion("Integer widget") + .swipeToNextQuestion("Integer widget with thousands separators") + .swipeToNextQuestion("Ex integer widget") + .swipeToNextQuestion("Decimal widget") + .swipeToNextQuestion("Ex decimal widget") + .swipeToNextQuestion("Bearing widget") + + .swipeToNextQuestion("Range integer widget") + .swipeToNextQuestion("Range decimal widget") + .swipeToNextQuestion("Range vertical integer widget") + .swipeToNextQuestion("Range picker integer widget") + .swipeToNextQuestion("Range rating integer widget") + + .swipeToNextQuestion("Image widget") + .swipeToNextQuestion("Image widget without Choose button") + .swipeToNextQuestion("Selfie widget") + .clickOnText("Take Picture") + .pressBack(FormEntryPage("All widgets")) + .swipeToNextQuestion("Draw widget") + .swipeToNextQuestion("Annotate widget") + .swipeToNextQuestion("Signature widget") + .swipeToNextQuestion("Barcode widget") + .swipeToNextQuestion("Audio widget") + .swipeToNextQuestion("Video widget") + .swipeToNextQuestion("File widget") + + .swipeToNextQuestion("Date widget") + .swipeToNextQuestion("Date Widget") + .swipeToNextQuestion("Date widget") + .swipeToNextQuestion("Date widget") + .swipeToNextQuestion("Time widget") + .swipeToNextQuestion("Date time widget") + .swipeToNextQuestion("Date time widget") + .swipeToNextQuestion("Ethiopian date widget") + .swipeToNextQuestion("Coptic date widget") + .swipeToNextQuestion("Islamic date widget") + .swipeToNextQuestion("Bikram Sambat date widget") + .swipeToNextQuestion("Myanmar date widget") + .swipeToNextQuestion("Persian date widget") + + .swipeToNextQuestion("Geopoint widget") + .swipeToNextQuestion("Geopoint widget") + .swipeToNextQuestion("Geopoint widget") + .swipeToNextQuestion("Geotrace widget") + .clickOnString(string.get_line) + .pressBack(FormEntryPage("All widgets")) + .swipeToNextQuestion("Geoshape widget") + .clickOnString(string.get_polygon) + .pressBack(FormEntryPage("All widgets")) + .swipeToNextQuestion("OSM integration") + + .swipeToNextQuestion("Select one widget") + .swipeToNextQuestion("Spinner widget") + .swipeToNextQuestion("Select one autoadvance widget") + .swipeToNextQuestion("Select one search widget") + .swipeToNextQuestion("Select one search widget") + .swipeToNextQuestion("Grid select one widget") + .swipeToNextQuestion("Grid select one widget") + .swipeToNextQuestion("Grid select one widget") + .swipeToNextQuestion("Grid select one widget") + .swipeToNextQuestion("Grid select one widget") + .swipeToNextQuestion("Image select one widget") + .swipeToNextQuestion("Likert widget") + .swipeToNextQuestion("Select one from map widget") + .clickOnString(string.select_place) + .pressBack(FormEntryPage("All widgets")) + .swipeToNextQuestion("Multi select widget") + .swipeToNextQuestion("Multi select autocomplete widget") + .swipeToNextQuestion("Grid select multiple widget") + .swipeToNextQuestion("Grid select multiple widget") + .swipeToNextQuestion("Spinner widget: select multiple") + .swipeToNextQuestion("Image select multiple widget") + + .swipeToNextQuestion("Label widget") + .swipeToNextQuestion("Rank widget") + .swipeToNextQuestion("Trigger widget") + } +} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/forms/FormUtilsTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/forms/FormUtilsTest.java index 733a6abebb7..b6927cd11ed 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/forms/FormUtilsTest.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/forms/FormUtilsTest.java @@ -60,7 +60,7 @@ public void sessionRootTranslatorOrderDoesNotMatter() throws Exception { final Uri formUri = FormsContract.getUri("DEMO", form.getDbId()); // Load the form in order to populate the ReferenceManager - FormLoaderTask formLoaderTask = new FormLoaderTask(formUri, FormsContract.CONTENT_ITEM_TYPE, null, null, formEntryControllerFactory, mock()); + FormLoaderTask formLoaderTask = new FormLoaderTask(formUri, FormsContract.CONTENT_ITEM_TYPE, null, null, formEntryControllerFactory, mock(), mock()); formLoaderTask.executeSynchronously(); final File formXml = new File(formPath); diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/tasks/FormLoaderTaskTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/tasks/FormLoaderTaskTest.java index d19d9954d65..88e4a7cd6c6 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/tasks/FormLoaderTaskTest.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/instrumented/tasks/FormLoaderTaskTest.java @@ -73,7 +73,7 @@ public void loadSearchFromExternalCSVmultipleTimes() throws Exception { final Uri formUri = FormsContract.getUri("DEMO", form.getDbId()); // initial load with side effects - FormLoaderTask formLoaderTask = new FormLoaderTask(formUri, FormsContract.CONTENT_ITEM_TYPE, null, null, formEntryControllerFactory, mock()); + FormLoaderTask formLoaderTask = new FormLoaderTask(formUri, FormsContract.CONTENT_ITEM_TYPE, null, null, formEntryControllerFactory, mock(), mock()); FormLoaderTask.FECWrapper wrapper = formLoaderTask.executeSynchronously(); Assert.assertNotNull(wrapper); Assert.assertNotNull(wrapper.getController()); @@ -84,7 +84,7 @@ public void loadSearchFromExternalCSVmultipleTimes() throws Exception { long dbLastModified = dbFile.lastModified(); // subsequent load should succeed despite side effects from import - formLoaderTask = new FormLoaderTask(formUri, FormsContract.CONTENT_ITEM_TYPE, null, null, formEntryControllerFactory, mock()); + formLoaderTask = new FormLoaderTask(formUri, FormsContract.CONTENT_ITEM_TYPE, null, null, formEntryControllerFactory, mock(), mock()); wrapper = formLoaderTask.executeSynchronously(); Assert.assertNotNull(wrapper); Assert.assertNotNull(wrapper.getController()); diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/regression/AboutPageTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/regression/AboutPageTest.java index b35f3a295da..e672ff46f61 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/regression/AboutPageTest.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/regression/AboutPageTest.java @@ -14,7 +14,7 @@ import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.withText; -import static org.odk.collect.android.support.matchers.DrawableMatcher.withImageDrawable; +import static org.odk.collect.androidtest.DrawableMatcher.withImageDrawable; import static org.odk.collect.testshared.RecyclerViewMatcher.withRecyclerView; //Issue NODK-234 diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/regression/DrawWidgetTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/regression/DrawWidgetTest.java index b5e1995dc33..4374c1f6c63 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/regression/DrawWidgetTest.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/regression/DrawWidgetTest.java @@ -31,12 +31,12 @@ public void saveIgnoreDialog_ShouldUseBothOptions() { .clickGoToArrow() .clickOnText("Image widgets") .clickOnText("Draw widget") - .clickOnId(R.id.simple_button) + .clickOnId(R.id.draw_button) .waitForRotationToEnd() .pressBack(new SaveOrIgnoreDrawingDialog<>("Sketch Image", new FormEntryPage("All widgets"))) .clickDiscardChanges() .waitForRotationToEnd() - .clickOnId(R.id.simple_button) + .clickOnId(R.id.draw_button) .waitForRotationToEnd() .pressBack(new SaveOrIgnoreDrawingDialog<>("Sketch Image", new FormEntryPage("All widgets"))) .clickSaveChanges() @@ -55,7 +55,7 @@ public void setColor_ShouldSeeColorPicker() { .clickGoToArrow() .clickOnText("Image widgets") .clickOnText("Draw widget") - .clickOnId(R.id.simple_button) + .clickOnId(R.id.draw_button) .waitForRotationToEnd() .clickOnId(org.odk.collect.draw.R.id.fab_actions) .clickOnId(org.odk.collect.draw.R.id.fab_set_color) @@ -77,7 +77,7 @@ public void multiClickOnPlus_ShouldDisplayIcons() { .clickGoToArrow() .clickOnText("Image widgets") .clickOnText("Draw widget") - .clickOnId(R.id.simple_button) + .clickOnId(R.id.draw_button) .waitForRotationToEnd() .clickOnId(org.odk.collect.draw.R.id.fab_actions) .assertText(org.odk.collect.strings.R.string.set_color) diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/regression/FillBlankFormTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/regression/FillBlankFormTest.java index 99dd6df6ee2..f279c679a5e 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/regression/FillBlankFormTest.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/regression/FillBlankFormTest.java @@ -5,7 +5,7 @@ import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.withText; import static junit.framework.TestCase.assertNotSame; -import static org.odk.collect.android.support.matchers.DrawableMatcher.withImageDrawable; +import static org.odk.collect.androidtest.DrawableMatcher.withImageDrawable; import static org.odk.collect.testshared.RecyclerViewMatcher.withRecyclerView; import static java.util.Arrays.asList; import static java.util.Collections.singletonList; @@ -97,7 +97,7 @@ public void formsWithDate_ShouldSaveFormsWithSuccess() { rule.startAtMainMenu() .copyForm("1560_DateData.xml") .startBlankForm("1560_DateData") - .checkIsTranslationDisplayed("Jan 01, 1900", "01 ene. 1900") + .assertText("Jan 01, 1900") .swipeToEndScreen("01/01/00") .clickFinalize() @@ -419,13 +419,13 @@ public void bigForm_ShouldBeFilledSuccessfully() { .startBlankForm("Nigeria Wards") .assertQuestion("State") .openSelectMinimalDialog() - .clickOnText("Adamawa") + .selectItem("Adamawa") .swipeToNextQuestion("LGA", true) .openSelectMinimalDialog() - .clickOnText("Ganye") + .selectItem("Ganye") .swipeToNextQuestion("Ward", true) .openSelectMinimalDialog() - .clickOnText("Jaggu") + .selectItem("Jaggu") .swipeToNextQuestion("Comments") .swipeToEndScreen() .clickFinalize(); @@ -575,7 +575,7 @@ public void when_scrollQuestionsList_should_questionsNotDisappear() { .copyForm("3403.xml", asList("staff_list.csv", "staff_rights.csv")) .startBlankForm("3403_ODK Version 1.23.3 Tester") .clickOnText("New Farmer Registration") - .scrollToAndClickText("Insemination") + .clickOnText("Insemination") .assertText("New Farmer Registration"); } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/regression/FillBlankFormWithRepeatGroupTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/regression/FillBlankFormWithRepeatGroupTest.java index 27ce731e2d2..685f63a0dab 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/regression/FillBlankFormWithRepeatGroupTest.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/regression/FillBlankFormWithRepeatGroupTest.java @@ -62,7 +62,7 @@ public void dynamicGroupLabel_should_beCalculatedProperly() { .swipeToNextQuestion("Photo") .assertText("gr1 > 1 > Person: 25") .clickGoToArrow() - .assertText("gr1 > 1 > Person: 25") + .assertPath("gr1 > 1 > Person: 25") .clickOnQuestion("Photo") .swipeToNextQuestionWithRepeatGroup("gr1") .clickOnDoNotAdd(new FormEntryPage("Repeat titles 1648")) @@ -71,7 +71,7 @@ public void dynamicGroupLabel_should_beCalculatedProperly() { .swipeToNextQuestion("Date") .assertText("Part1 > 1 > Xxx: SecondPart") .clickGoToArrow() - .assertText("Part1 > 1 > Xxx: SecondPart") + .assertPath("Part1 > 1 > Xxx: SecondPart") .clickOnQuestion("Date") .swipeToNextQuestion("Multi Select") .swipeToNextQuestionWithRepeatGroup("Part1") @@ -254,7 +254,7 @@ public void when_navigateOnHierarchyView_should_breadcrumbPathBeVisible() { .swipeToNextQuestion("Age") .inputText("3") .clickGoToArrow() - .assertText("People > 3 > Person: C") + .assertPath("People > 3 > Person: C") .clickGoUpIcon() .assertText("3.\u200E Person: C") .clickJumpEndButton() diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/regression/SignatureWidgetTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/regression/SignatureWidgetTest.java index 09af9a4487b..381cc738bd8 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/regression/SignatureWidgetTest.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/regression/SignatureWidgetTest.java @@ -6,6 +6,7 @@ import org.junit.Test; import org.junit.rules.RuleChain; import org.junit.runner.RunWith; +import org.odk.collect.android.R; import org.odk.collect.android.support.pages.FormEntryPage; import org.odk.collect.android.support.pages.SaveOrIgnoreDrawingDialog; import org.odk.collect.android.support.rules.CollectTestRule; @@ -31,14 +32,14 @@ public void saveIgnoreDialog_ShouldUseBothOptions() { .clickGoToArrow() .clickOnText("Image widgets") .clickOnQuestion("Signature widget") - .clickWidgetButton() + .clickOnId(R.id.sign_button) .waitForRotationToEnd() .pressBack(new SaveOrIgnoreDrawingDialog<>("Gather Signature", new FormEntryPage("All widgets"))) - .checkIsTranslationDisplayed("Exit Gather Signature", "Salir Adjuntar firma") + .assertText("Exit Gather Signature") .assertText(org.odk.collect.strings.R.string.keep_changes) .clickDiscardChanges() .waitForRotationToEnd() - .clickWidgetButton() + .clickOnId(R.id.sign_button) .waitForRotationToEnd() .pressBack(new SaveOrIgnoreDrawingDialog<>("Gather Signature", new FormEntryPage("All widgets"))) .clickSaveChanges() @@ -58,7 +59,7 @@ public void multiClickOnPlus_ShouldDisplayIcons() { .clickGoToArrow() .clickOnText("Image widgets") .clickOnQuestion("Signature widget") - .clickWidgetButton() + .clickOnId(R.id.sign_button) .waitForRotationToEnd() .clickOnId(org.odk.collect.draw.R.id.fab_actions) .checkIsIdDisplayed(org.odk.collect.draw.R.id.fab_save_and_close) diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/DummyActivityLauncher.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/DummyActivityLauncher.kt new file mode 100644 index 00000000000..03ddf3084aa --- /dev/null +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/DummyActivityLauncher.kt @@ -0,0 +1,25 @@ +package org.odk.collect.android.support + +import android.content.Intent +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import org.odk.collect.shared.TimeInMs +import org.odk.collect.testshared.DummyActivity + +object DummyActivityLauncher { + + fun launch(block: (UiDevice) -> Unit) { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + InstrumentationRegistry.getInstrumentation().targetContext.apply { + val intent = Intent(this.applicationContext, DummyActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + device.wait(Until.hasObject(By.textStartsWith(DummyActivity.TEXT)), TimeInMs.ONE_SECOND) + } + + block(device) + device.pressBack() + } +} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/FakeClickableMapFragment.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/FakeClickableMapFragment.kt index 31df3597381..f2a7e6f5428 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/FakeClickableMapFragment.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/FakeClickableMapFragment.kt @@ -3,8 +3,10 @@ package org.odk.collect.android.support import android.os.Handler import android.os.Looper import androidx.fragment.app.Fragment +import org.odk.collect.maps.LineDescription import org.odk.collect.maps.MapFragment import org.odk.collect.maps.MapPoint +import org.odk.collect.maps.PolygonDescription import org.odk.collect.maps.markers.MarkerDescription import org.odk.collect.maps.markers.MarkerIconDescription @@ -57,16 +59,12 @@ class FakeClickableMapFragment : Fragment(), MapFragment { return MapPoint(0.0, 0.0) } - override fun addPolyLine( - points: MutableIterable, - closed: Boolean, - draggable: Boolean - ): Int { + override fun addPolyLine(lineDescription: LineDescription): Int { return -1 } - override fun addPolygon(points: MutableIterable): Int { - return addPolyLine(points, closed = true, draggable = false) + override fun addPolygon(polygonDescription: PolygonDescription): Int { + return -1 } override fun appendPointToPolyLine(featureId: Int, point: MapPoint) {} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/FakeNetworkStateProvider.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/FakeNetworkStateProvider.kt new file mode 100644 index 00000000000..395e59f4db8 --- /dev/null +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/FakeNetworkStateProvider.kt @@ -0,0 +1,20 @@ +package org.odk.collect.android.support + +import org.odk.collect.async.Scheduler +import org.odk.collect.async.network.NetworkStateProvider + +class FakeNetworkStateProvider : NetworkStateProvider { + + private var type: Scheduler.NetworkType? = Scheduler.NetworkType.WIFI + + fun goOnline(networkType: Scheduler.NetworkType) { + type = networkType + } + + fun goOffline() { + type = null + } + + override val currentNetwork: Scheduler.NetworkType? + get() = type +} 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 3ecaa086b05..1073a2dfc46 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 @@ -7,6 +7,9 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.javarosa.core.model.FormDef; +import org.javarosa.xform.parse.XFormParser; +import org.javarosa.xform.util.XFormUtils; import org.jetbrains.annotations.NotNull; import org.odk.collect.android.openrosa.CaseInsensitiveEmptyHeaders; import org.odk.collect.android.openrosa.CaseInsensitiveHeaders; @@ -32,6 +35,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; public class StubOpenRosaServer implements OpenRosaHttpInterface { @@ -75,7 +79,7 @@ public HttpGetResult executeGetRequest(@NonNull URI uri, @Nullable String conten } else { return new HttpGetResult(null, new HashMap<>(), "", 404); } - } else if (uri.getPath().equals("/mediaFile")) { + } else if (uri.getPath().startsWith("/mediaFile")) { return new HttpGetResult(getMediaFile(uri), new HashMap<>(), "", 200); } else { return new HttpGetResult(null, new HashMap<>(), "", 404); @@ -133,7 +137,22 @@ public void addForm(String formLabel, String id, String version, String formXML) } public void addForm(String formLabel, String id, String version, String formXML, List mediaFiles) { - forms.add(new XFormItem(formLabel, formXML, id, version, mediaFiles)); + forms.add(new XFormItem(formLabel, formXML, id, version, mediaFiles.stream().map(name -> new MediaFileItem(name, name)).collect(Collectors.toList()))); + } + + public void addForm(String formXML, List mediaFiles) { + try (InputStream formDefStream = getResourceAsStream("forms/" + formXML)) { + FormDef formDef = XFormUtils.getFormFromInputStream(formDefStream); + String formId = formDef.getMainInstance().getRoot().getAttributeValue(null, "id"); + String version = formDef.getMainInstance().getRoot().getAttributeValue(null, "version"); + forms.add(new XFormItem(formDef.getTitle(), formXML, formId, version, mediaFiles)); + } catch (IOException | XFormParser.ParseException e) { + throw new RuntimeException(e); + } + } + + public void addForm(String formXML) { + addForm(formXML, emptyList()); } public void removeForm(String formLabel) { @@ -245,18 +264,20 @@ private InputStream getManifestResponse(@NonNull URI uri) throws IOException { .append("\n") .append("\n"); - for (String mediaFile : xformItem.getMediaFiles()) { + for (MediaFileItem mediaFile : xformItem.getMediaFiles()) { String mediaFileHash; - if (randomHash) { + if (mediaFile.getHash() != null) { + mediaFileHash = mediaFile.getHash(); + } else if (randomHash) { mediaFileHash = RandomString.randomString(8); } else { - mediaFileHash = Md5.getMd5Hash(getResourceAsStream("media/" + mediaFile)); + mediaFileHash = Md5.getMd5Hash(getResourceAsStream("media/" + mediaFile.getFile())); } stringBuilder .append("") - .append("" + mediaFile + "\n"); + .append("" + mediaFile.getName() + "\n"); if (noHashPrefixInMediaFiles) { stringBuilder.append("" + mediaFileHash + " \n"); @@ -265,7 +286,7 @@ private InputStream getManifestResponse(@NonNull URI uri) throws IOException { } stringBuilder - .append("" + getURL() + "/mediaFile?name=" + mediaFile + "\n") + .append("" + getURL() + "/mediaFile/" + formID + "/" + mediaFile.getName() + "\n") .append("\n"); } @@ -282,8 +303,11 @@ private InputStream getFormXML(String formID) throws IOException { @NotNull private InputStream getMediaFile(URI uri) throws IOException { - String mediaFileName = uri.getQuery().split("=")[1]; - return getResourceAsStream("media/" + mediaFileName); + String formID = uri.getPath().split("/mediaFile/")[1].split("/")[0]; + String mediaFileName = uri.getPath().split("/mediaFile/")[1].split("/")[1]; + XFormItem xformItem = forms.get(Integer.parseInt(formID)); + String actualFileName = xformItem.getMediaFiles().stream().filter(mediaFile -> mediaFile.getName().equals(mediaFileName)).findFirst().get().getFile(); + return getResourceAsStream("media/" + actualFileName); } private static class XFormItem { @@ -292,13 +316,13 @@ private static class XFormItem { private final String formXML; private final String id; private final String version; - private final List mediaFiles; + private final List mediaFiles; XFormItem(String formLabel, String formXML, String id, String version) { this(formLabel, formXML, id, version, emptyList()); } - XFormItem(String formLabel, String formXML, String id, String version, List mediaFiles) { + XFormItem(String formLabel, String formXML, String id, String version, List mediaFiles) { this.formLabel = formLabel; this.formXML = formXML; this.id = id; @@ -322,11 +346,54 @@ public String getID() { return id; } - public List getMediaFiles() { + public List getMediaFiles() { return mediaFiles; } } + public static class MediaFileItem { + private final String name; + private final String file; + + private final String hash; + + public MediaFileItem(String name, String file, String hash) { + this.name = name; + this.file = file; + this.hash = hash; + } + + public MediaFileItem(String name, String file) { + this(name, file, null); + } + + public MediaFileItem(String name) { + this(name, name, null); + } + + public String getName() { + return name; + } + + public String getFile() { + return file; + } + + public String getHash() { + return hash; + } + } + + public static class EntityListItem extends MediaFileItem { + public EntityListItem(String name, String file) { + super(name, file, name); + } + + public EntityListItem(String name) { + super(name, name, name); + } + } + private static class MapHeaders implements CaseInsensitiveHeaders { private final Map headers; diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/TestDependencies.java b/collect_app/src/androidTest/java/org/odk/collect/android/support/TestDependencies.java index 0b3b8b3427f..440b5144c74 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/TestDependencies.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/TestDependencies.java @@ -1,6 +1,7 @@ package org.odk.collect.android.support; import android.app.Application; +import android.content.Context; import android.webkit.MimeTypeMap; import androidx.work.WorkManager; @@ -11,12 +12,14 @@ import org.odk.collect.android.version.VersionInformation; import org.odk.collect.android.views.BarcodeViewDecoder; import org.odk.collect.async.Scheduler; +import org.odk.collect.async.network.NetworkStateProvider; import org.odk.collect.utilities.UserAgentProvider; public class TestDependencies extends AppDependencyModule { public final StubOpenRosaServer server = new StubOpenRosaServer(); - public final TestScheduler scheduler = new TestScheduler(); + public final FakeNetworkStateProvider networkStateProvider = new FakeNetworkStateProvider(); + public final TestScheduler scheduler = new TestScheduler(networkStateProvider); public final StoragePathProvider storagePathProvider = new StoragePathProvider(); public final StubBarcodeViewDecoder stubBarcodeViewDecoder = new StubBarcodeViewDecoder(); @@ -34,4 +37,9 @@ public Scheduler providesScheduler(WorkManager workManager) { public BarcodeViewDecoder providesBarcodeViewDecoder() { return stubBarcodeViewDecoder; } + + @Override + public NetworkStateProvider providesNetworkStateProvider(Context context) { + return networkStateProvider; + } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/TestScheduler.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/TestScheduler.kt index 417aedce5a7..6a3c2092c72 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/TestScheduler.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/TestScheduler.kt @@ -11,11 +11,12 @@ import org.odk.collect.async.Cancellable import org.odk.collect.async.CoroutineAndWorkManagerScheduler import org.odk.collect.async.Scheduler import org.odk.collect.async.TaskSpec +import org.odk.collect.async.network.NetworkStateProvider import java.util.function.Consumer import java.util.function.Supplier import kotlin.coroutines.CoroutineContext -class TestScheduler : Scheduler, CoroutineDispatcher() { +class TestScheduler(private val networkStateProvider: NetworkStateProvider) : Scheduler, CoroutineDispatcher() { private val wrappedScheduler: Scheduler private val lock = Any() @@ -41,26 +42,32 @@ class TestScheduler : Scheduler, CoroutineDispatcher() { } } - override fun immediate(background: Boolean, runnable: Runnable) { + override fun immediate(foreground: Boolean, runnable: Runnable) { increment() - wrappedScheduler.immediate(background) { + wrappedScheduler.immediate(foreground) { runnable.run() decrement() } } - override fun networkDeferred(tag: String, spec: TaskSpec, inputData: Map) { - deferredTasks.add(DeferredTask(tag, spec, null, inputData)) + override fun networkDeferred( + tag: String, + spec: TaskSpec, + inputData: Map, + networkConstraint: Scheduler.NetworkType? + ) { + cancelDeferred(tag) + deferredTasks.add(DeferredTask(tag, spec, null, inputData, networkConstraint)) } - override fun networkDeferred( + override fun networkDeferredRepeat( tag: String, spec: TaskSpec, repeatPeriod: Long, inputData: Map ) { cancelDeferred(tag) - deferredTasks.add(DeferredTask(tag, spec, repeatPeriod, inputData)) + deferredTasks.add(DeferredTask(tag, spec, repeatPeriod, inputData, null)) } override fun cancelDeferred(tag: String) { @@ -72,13 +79,18 @@ class TestScheduler : Scheduler, CoroutineDispatcher() { } fun runDeferredTasks() { - val applicationContext = ApplicationProvider.getApplicationContext() - for (deferredTask in deferredTasks) { - deferredTask.spec.getTask(applicationContext, deferredTask.inputData, true).get() + if (networkStateProvider.isDeviceOnline) { + val applicationContext = ApplicationProvider.getApplicationContext() + deferredTasks.removeIf { deferredTask -> + if (deferredTask.networkConstraint == null || deferredTask.networkConstraint == networkStateProvider.currentNetwork) { + deferredTask.spec.getTask(applicationContext, deferredTask.inputData, true) + .get() + deferredTask.repeatPeriod == null + } else { + false + } + } } - - // Remove non repeating tasks - deferredTasks.removeIf { deferredTask: DeferredTask -> deferredTask.repeatPeriod == null } } fun setFinishedCallback(callback: Runnable?) { @@ -125,6 +137,7 @@ class TestScheduler : Scheduler, CoroutineDispatcher() { val tag: String, val spec: TaskSpec, val repeatPeriod: Long?, - val inputData: Map + val inputData: Map, + val networkConstraint: Scheduler.NetworkType? ) } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/matchers/CustomMatchers.java b/collect_app/src/androidTest/java/org/odk/collect/android/support/matchers/CustomMatchers.java deleted file mode 100644 index 01b669dd018..00000000000 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/matchers/CustomMatchers.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2019 Nafundi - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.odk.collect.android.support.matchers; - -import android.view.View; - -import org.hamcrest.Description; -import org.hamcrest.Matcher; -import org.hamcrest.TypeSafeMatcher; - -/** - * Grab bag of Hamcrest matchers. - */ -public final class CustomMatchers { - private CustomMatchers() { - - } - - /** - * Matches the view at the given index. Useful when several views have the same properties. - * https://stackoverflow.com/a/39756832 - */ - public static Matcher withIndex(final Matcher matcher, final int index) { - return new TypeSafeMatcher() { - int currentIndex; - - @Override - public void describeTo(Description description) { - description.appendText("with index: "); - description.appendValue(index); - matcher.describeTo(description); - } - - @Override - public boolean matchesSafely(View view) { - return matcher.matches(view) && currentIndex++ == index; - } - }; - } -} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/matchers/CustomMatchers.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/matchers/CustomMatchers.kt new file mode 100644 index 00000000000..7380003cef4 --- /dev/null +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/matchers/CustomMatchers.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2019 Nafundi + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.odk.collect.android.support.matchers + +import android.view.View +import android.widget.TextView +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.TypeSafeMatcher +import org.odk.collect.android.R +import org.odk.collect.android.widgets.QuestionWidget + +/** + * Grab bag of Hamcrest matchers. + */ +object CustomMatchers { + /** + * Matches the view at the given index. Useful when several views have the same properties. + * https://stackoverflow.com/a/39756832 + * + */ + @Deprecated("this matcher is stateful and will cause problems if used more than once") + @JvmStatic + fun withIndex(matcher: Matcher, index: Int): TypeSafeMatcher { + return object : TypeSafeMatcher() { + var currentIndex: Int = 0 + + override fun describeTo(description: Description) { + description.appendText("with index: ") + description.appendValue(index) + matcher.describeTo(description) + } + + override fun matchesSafely(view: View): Boolean { + return matcher.matches(view) && currentIndex++ == index + } + } + } + + @JvmStatic + fun isQuestionView(questionText: String): Matcher { + return object : TypeSafeMatcher() { + override fun describeTo(description: Description) { + description.appendText("is question view with text: ") + description.appendValue(questionText) + } + + override fun matchesSafely(item: View): Boolean { + return if (item is QuestionWidget) { + val questionTextView = item.findViewById(R.id.text_label) + questionTextView.text.toString() == questionText + } else { + false + } + } + } + } +} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/matchers/DrawableMatcher.java b/collect_app/src/androidTest/java/org/odk/collect/android/support/matchers/DrawableMatcher.java deleted file mode 100644 index 5c2a383e3e4..00000000000 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/matchers/DrawableMatcher.java +++ /dev/null @@ -1,57 +0,0 @@ -package org.odk.collect.android.support.matchers; - -import android.graphics.Bitmap; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.Drawable; -import android.view.View; -import android.widget.ImageView; - -import androidx.test.espresso.matcher.BoundedMatcher; - -import org.hamcrest.Description; -import org.hamcrest.Matcher; - -public final class DrawableMatcher { - - private DrawableMatcher() { - } - - public static Matcher withImageDrawable(final int expectedResourceId) { - return new BoundedMatcher(ImageView.class) { - @Override - public void describeTo(Description description) { - description.appendText("has image drawable resource " + expectedResourceId); - } - - @Override - public boolean matchesSafely(ImageView imageView) { - return expectedResourceId == (Integer) imageView.getTag(); - } - }; - } - - public static Matcher withBitmap(Bitmap match) { - return new BoundedMatcher(ImageView.class) { - @Override - public void describeTo(Description description) { - description.appendText("bitmaps did not match"); - } - - @Override - protected boolean matchesSafely(ImageView imageView) { - Drawable drawable = imageView.getDrawable(); - if (drawable == null && match == null) { - return true; - } else if (drawable != null && match == null) { - return false; - } else if (drawable == null && match != null) { - return false; - } - - Bitmap actual = ((BitmapDrawable) drawable).getBitmap(); - - return actual.sameAs(match); - } - }; - } -} \ No newline at end of file diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/AddNewRepeatDialog.java b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/AddNewRepeatDialog.java index 49bd6f80856..5bb573d10c4 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/AddNewRepeatDialog.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/AddNewRepeatDialog.java @@ -23,11 +23,11 @@ public AddNewRepeatDialog assertOnPage() { } public > D clickOnAdd(D destination) { - return clickOnButtonInDialog(org.odk.collect.strings.R.string.add_repeat, destination); + return clickOnTextInDialog(org.odk.collect.strings.R.string.add_repeat, destination); } public > D clickOnDoNotAdd(D destination) { - return clickOnButtonInDialog(org.odk.collect.strings.R.string.dont_add_repeat, destination); + return clickOnTextInDialog(org.odk.collect.strings.R.string.dont_add_repeat, destination); } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/AppClosedPage.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/AppClosedPage.kt index 30259bb3d80..bfc99e18a35 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/AppClosedPage.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/AppClosedPage.kt @@ -1,10 +1,10 @@ package org.odk.collect.android.support.pages import android.app.Activity -import androidx.test.espresso.core.internal.deps.guava.collect.Iterables import androidx.test.platform.app.InstrumentationRegistry import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry import androidx.test.runner.lifecycle.Stage +import com.google.common.collect.Iterables import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.MatcherAssert.assertThat diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/BulkFinalizationConfirmationDialogPage.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/BulkFinalizationConfirmationDialogPage.kt index 4585fea8df6..f72c414fd17 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/BulkFinalizationConfirmationDialogPage.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/BulkFinalizationConfirmationDialogPage.kt @@ -21,10 +21,10 @@ class BulkFinalizationConfirmationDialogPage(private val count: Int) : Page() { - - override fun assertOnPage(): DatasetPage { - assertToolbarTitle(datasetName) - return this - } - - fun assertEntity(fields: String): DatasetPage { - assertText(fields) - return this - } -} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/DeleteSelectedDialog.java b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/DeleteSelectedDialog.java index 22851f90221..c96bccebb9e 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/DeleteSelectedDialog.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/DeleteSelectedDialog.java @@ -12,12 +12,12 @@ public DeleteSelectedDialog(int numberSelected, DeleteSavedFormPage destination) @Override public DeleteSelectedDialog assertOnPage() { - assertText(getTranslatedString(org.odk.collect.strings.R.string.delete_confirm, numberSelected)); + assertTextInDialog(getTranslatedString(org.odk.collect.strings.R.string.delete_confirm, numberSelected)); return this; } public DeleteSavedFormPage clickDeleteForms() { - clickOnString(org.odk.collect.strings.R.string.delete_yes); + clickOnTextInDialog(org.odk.collect.strings.R.string.delete_yes); return destination.assertOnPage(); } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/EditSavedFormPage.java b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/EditSavedFormPage.java index de1f4f56a97..5e9b288f3e5 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/EditSavedFormPage.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/EditSavedFormPage.java @@ -29,13 +29,13 @@ import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.CoreMatchers.instanceOf; import static org.hamcrest.Matchers.not; +import static org.odk.collect.android.instancemanagement.InstanceExtKt.getInstanceIcon; import android.widget.RelativeLayout; import androidx.appcompat.widget.Toolbar; import org.odk.collect.android.R; -import org.odk.collect.android.adapters.InstanceListCursorAdapter; public class EditSavedFormPage extends Page { private final boolean firstOpen; @@ -53,13 +53,13 @@ public EditSavedFormPage assertOnPage() { private void closeDraftsPillsEducationDialog() { if (firstOpen) { - assertText(org.odk.collect.strings.R.string.new_feature); + assertTextInDialog(org.odk.collect.strings.R.string.new_feature); clickOKOnDialog(); } } public EditSavedFormPage checkInstanceState(String instanceName, String desiredStatus) { - int desiredImageId = InstanceListCursorAdapter.getFormStateImageResourceIdForStatus(desiredStatus); + int desiredImageId = getInstanceIcon(desiredStatus); onView(allOf(instanceOf(RelativeLayout.class), hasDescendant(withText(instanceName)), diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/EntitiesPage.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/EntitiesPage.kt index 1f4a4c91ece..ce08deb5a9d 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/EntitiesPage.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/EntitiesPage.kt @@ -1,7 +1,5 @@ package org.odk.collect.android.support.pages -import org.odk.collect.android.R - class EntitiesPage : Page() { override fun assertOnPage(): EntitiesPage { @@ -9,8 +7,8 @@ class EntitiesPage : Page() { return this } - fun clickOnDataset(datasetName: String): DatasetPage { - clickOnText(datasetName) - return DatasetPage(datasetName) + fun clickOnList(list: String): EntityListPage { + clickOnText(list) + return EntityListPage(list) } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/EntityListPage.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/EntityListPage.kt new file mode 100644 index 00000000000..e5dc2189640 --- /dev/null +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/EntityListPage.kt @@ -0,0 +1,15 @@ +package org.odk.collect.android.support.pages + +class EntityListPage(private val list: String) : Page() { + + override fun assertOnPage(): EntityListPage { + assertToolbarTitle(list) + return this + } + + fun assertEntity(label: String, properties: String): EntityListPage { + assertText(label) + assertText(properties) + return this + } +} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FillBlankFormPage.java b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FillBlankFormPage.java index fae7d6d7509..c1168f7915c 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FillBlankFormPage.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FillBlankFormPage.java @@ -19,7 +19,7 @@ import androidx.test.espresso.matcher.ViewMatchers; import org.odk.collect.android.R; -import org.odk.collect.android.support.WaitFor; +import org.odk.collect.testshared.WaitFor; public class FillBlankFormPage extends Page { diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FirstLaunchPage.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FirstLaunchPage.kt index dd53039c9a4..33451f2b6c4 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FirstLaunchPage.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FirstLaunchPage.kt @@ -1,26 +1,35 @@ package org.odk.collect.android.support.pages -import org.odk.collect.android.R +import androidx.test.espresso.matcher.ViewMatchers.withSubstring +import org.odk.collect.strings.R.string +import org.odk.collect.testshared.Interactions class FirstLaunchPage : Page() { override fun assertOnPage(): FirstLaunchPage { - assertText(org.odk.collect.strings.R.string.configure_with_qr_code) + assertText(string.configure_with_qr_code) return this } fun clickTryCollect(): MainMenuPage { - scrollToAndClickSubtext(org.odk.collect.strings.R.string.try_demo) - return MainMenuPage().assertOnPage() + Interactions.clickOn(withSubstring(getTranslatedString(string.try_demo))) { + MainMenuPage().assertOnPage() + } + + return MainMenuPage() } fun clickManuallyEnterProjectDetails(): ManualProjectCreatorDialogPage { - scrollToAndClickText(org.odk.collect.strings.R.string.configure_manually) - return ManualProjectCreatorDialogPage().assertOnPage() + return clickOnString( + string.configure_manually, + ManualProjectCreatorDialogPage() + ) } fun clickConfigureWithQrCode(): QrCodeProjectCreatorDialogPage { - scrollToAndClickText(org.odk.collect.strings.R.string.configure_with_qr_code) - return QrCodeProjectCreatorDialogPage().assertOnPage() + return clickOnString( + string.configure_with_qr_code, + QrCodeProjectCreatorDialogPage() + ) } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FormEndPage.java b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FormEndPage.java index e5f3386fca1..725526acfa9 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FormEndPage.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FormEndPage.java @@ -24,22 +24,20 @@ public FormEndPage assertOnPage() { } public > D clickSaveAsDraft(D destination) { - clickOnString(org.odk.collect.strings.R.string.save_as_draft); - return destination.assertOnPage(); + return clickOnString(org.odk.collect.strings.R.string.save_as_draft, destination); } public MainMenuPage clickSaveAsDraft() { - clickOnString(org.odk.collect.strings.R.string.save_as_draft); - return new MainMenuPage().assertOnPage(); + return clickSaveAsDraft(new MainMenuPage()); } public > D clickFinalize(D destination) { - clickOnString(org.odk.collect.strings.R.string.finalize); - return destination.assertOnPage(); + return clickOnString(org.odk.collect.strings.R.string.finalize, destination); } public MainMenuPage clickFinalize() { - return clickFinalize(new MainMenuPage()); + clickFinalize(new MainMenuPage()); + return new MainMenuPage(); } public MainMenuPage clickSend() { diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FormEntryPage.java b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FormEntryPage.java index 90374c07cf3..254bff24a09 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FormEntryPage.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FormEntryPage.java @@ -8,23 +8,29 @@ import static androidx.test.espresso.action.ViewActions.swipeLeft; import static androidx.test.espresso.action.ViewActions.swipeRight; import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.withClassName; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.not; import static org.hamcrest.core.StringEndsWith.endsWith; +import static org.odk.collect.android.support.matchers.CustomMatchers.isQuestionView; import static org.odk.collect.android.support.matchers.CustomMatchers.withIndex; import android.os.Build; +import android.view.View; +import androidx.annotation.NonNull; import androidx.test.core.app.ApplicationProvider; +import org.hamcrest.Matcher; import org.hamcrest.Matchers; import org.odk.collect.android.R; -import org.odk.collect.android.support.WaitFor; -import org.odk.collect.android.utilities.FlingRegister; +import org.odk.collect.testshared.Interactions; +import org.odk.collect.testshared.WaitFor; import java.util.concurrent.Callable; @@ -44,12 +50,15 @@ public FormEntryPage assertOnPage() { return null; }); - assertToolbarTitle(formName); + WaitFor.waitFor((Callable) () -> { + assertToolbarTitle(formName); + return null; + }); // Check we are not on the Form Hierarchy page assertTextDoesNotExist(org.odk.collect.strings.R.string.jump_to_beginning); assertTextDoesNotExist(org.odk.collect.strings.R.string.jump_to_end); - + return this; } @@ -141,6 +150,13 @@ public ErrorDialog swipeToNextQuestionWithError() { return new ErrorDialog().assertOnPage(); } + public FormEntryPage swipeToNextQuestionWithConstraintViolation(int constraintText) { + flingLeft(); + assertText(constraintText); + + return this; + } + public FormEntryPage swipeToNextQuestionWithConstraintViolation(String constraintText) { flingLeft(); assertText(constraintText); @@ -184,28 +200,31 @@ public FormEntryPage clickWidgetButton() { } public FormEntryPage clickRankingButton() { - onView(withId(R.id.simple_button)).perform(click()); + onView(withId(R.id.rank_items_button)).perform(click()); return this; } public FormEntryPage deleteGroup(String questionText) { - onView(withText(questionText)).perform(longClick()); - onView(withText(org.odk.collect.strings.R.string.delete_repeat)).perform(click()); - clickOnButtonInDialog(org.odk.collect.strings.R.string.discard_group, this); + longClickOnText(questionText); + clickOnTextInPopup(org.odk.collect.strings.R.string.delete_repeat); + clickOnTextInDialog(org.odk.collect.strings.R.string.discard_group, this); return this; } public FormEntryPage clickForwardButton() { + closeSoftKeyboard(); onView(withText(getTranslatedString(org.odk.collect.strings.R.string.form_forward))).perform(click()); return this; } public FormEndPage clickForwardButtonToEndScreen() { + closeSoftKeyboard(); onView(withText(getTranslatedString(org.odk.collect.strings.R.string.form_forward))).perform(click()); return new FormEndPage(formName).assertOnPage(); } public FormEntryPage clickBackwardButton() { + closeSoftKeyboard(); onView(withText(getTranslatedString(org.odk.collect.strings.R.string.form_backward))).perform(click()); return this; } @@ -249,18 +268,22 @@ public FormEntryPage longPressOnQuestion(String question) { } public FormEntryPage longPressOnQuestion(String question, boolean isRequired) { - if (isRequired) { - onView(withText("* " + question)).perform(longClick()); - } else { - onView(withText(question)).perform(longClick()); - } + WaitFor.tryAgainOnFail(() -> { + if (isRequired) { + onView(withText("* " + question)).perform(longClick()); + } else { + onView(withText(question)).perform(longClick()); + } + + assertText(org.odk.collect.strings.R.string.clear_answer); + }); return this; } public FormEntryPage removeResponse() { onView(withText(org.odk.collect.strings.R.string.clear_answer)).perform(click()); - return clickOnButtonInDialog(org.odk.collect.strings.R.string.discard_answer, this); + return clickOnTextInDialog(org.odk.collect.strings.R.string.discard_answer, this); } public AddNewRepeatDialog swipeToNextQuestionWithRepeatGroup(String repeatName) { @@ -279,26 +302,29 @@ public FormEntryPage answerQuestion(String question, String answer) { } public FormEntryPage answerQuestion(String question, boolean isRequired, String answer) { + String questionText; if (isRequired) { - assertQuestionText("* " + question); + questionText = "* " + question; } else { - assertQuestionText(question); + questionText = question; } - inputText(answer); - closeSoftKeyboard(); + Interactions.replaceText(getQuestionFieldMatcher(questionText), answer); return this; } + /** + * @deprecated Use {@link #answerQuestion(String, String)} instead + */ + @Deprecated public FormEntryPage answerQuestion(int index, String answer) { onView(withIndex(withClassName(endsWith("EditText")), index)).perform(scrollTo()); onView(withIndex(withClassName(endsWith("EditText")), index)).perform(replaceText(answer)); return this; } - public FormEntryPage activateTextQuestion(int index) { - onView(withIndex(withClassName(endsWith("EditText")), index)).perform(scrollTo()); - onView(withIndex(withClassName(endsWith("EditText")), index)).perform(click()); + public FormEntryPage clickOnQuestionField(String questionText) { + Interactions.clickOn(getQuestionFieldMatcher(questionText)); return this; } @@ -317,43 +343,24 @@ public FormEntryPage assertQuestion(String text, boolean isRequired) { } private void flingLeft() { - tryAgainOnFail(() -> { - FlingRegister.attemptingFling(); + tryFlakyAction(() -> { onView(withId(R.id.questionholder)).perform(swipeLeft()); - - WaitFor.waitFor(() -> { - if (FlingRegister.isFlingDetected()) { - return true; - } else { - throw new RuntimeException("Fling never detected!"); - } - }); - }, 5); + }); } private void flingRight() { - tryAgainOnFail(() -> { - FlingRegister.attemptingFling(); + tryFlakyAction(() -> { onView(withId(R.id.questionholder)).perform(swipeRight()); - - WaitFor.waitFor(() -> { - if (FlingRegister.isFlingDetected()) { - return true; - } else { - throw new RuntimeException("Fling never detected!"); - } - }); - }, 5); + }); } - public FormEntryPage openSelectMinimalDialog() { - openSelectMinimalDialog(0); - return this; + public SelectMinimalDialogPage openSelectMinimalDialog() { + return openSelectMinimalDialog(0); } - public FormEntryPage openSelectMinimalDialog(int index) { + public SelectMinimalDialogPage openSelectMinimalDialog(int index) { onView(withIndex(withClassName(Matchers.endsWith("TextInputEditText")), index)).perform(click()); - return this; + return new SelectMinimalDialogPage(formName).assertOnPage(); } public FormEntryPage assertSelectMinimalDialogAnswer(String answer) { @@ -409,6 +416,13 @@ public FormEntryPage assertBackgroundLocationSnackbarShown() { return this; } + private static @NonNull Matcher getQuestionFieldMatcher(String question) { + return allOf( + withClassName(endsWith("EditText")), + isDescendantOfA(isQuestionView(question)) + ); + } + public static class QuestionAndAnswer { private final String question; diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FormHierarchyPage.java b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FormHierarchyPage.java index e26ba41f1fb..a20c70ddff7 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FormHierarchyPage.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/FormHierarchyPage.java @@ -13,7 +13,7 @@ import androidx.test.espresso.contrib.RecyclerViewActions; import org.odk.collect.android.R; -import org.odk.collect.android.support.WaitFor; +import org.odk.collect.testshared.WaitFor; import java.util.concurrent.Callable; @@ -40,6 +40,11 @@ public FormHierarchyPage assertOnPage() { return this; } + public FormHierarchyPage assertNotRemovableGroup() { + onView(withId(R.id.menu_delete_child)).check(doesNotExist()); + return this; + } + public FormHierarchyPage clickGoUpIcon() { onView(withId(R.id.menu_go_up)).perform(click()); return this; @@ -62,7 +67,7 @@ public FormEntryPage addGroup() { public FormHierarchyPage deleteGroup() { onView(withId(R.id.menu_delete_child)).perform(click()); - return clickOnButtonInDialog(org.odk.collect.strings.R.string.delete_repeat, this); + return clickOnTextInDialog(org.odk.collect.strings.R.string.delete_repeat, this); } public FormEndPage clickJumpEndButton() { @@ -83,6 +88,11 @@ public FormHierarchyPage assertHierarchyItem(int position, String primaryText, S return this; } + public FormHierarchyPage assertPath(String text) { + onView(withId(R.id.pathtext)).check(matches(withText(text))); + return this; + } + public FormEntryPage clickOnQuestion(String questionLabel) { return clickOnQuestion(questionLabel, false); } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/GetBlankFormPage.java b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/GetBlankFormPage.java index 54d72e5a263..0ffa7351a68 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/GetBlankFormPage.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/GetBlankFormPage.java @@ -6,16 +6,33 @@ import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.withText; +import org.odk.collect.strings.R.string; + public class GetBlankFormPage extends Page { @Override public GetBlankFormPage assertOnPage() { - onView(withText(getTranslatedString(org.odk.collect.strings.R.string.get_forms))).check(matches(isDisplayed())); + onView(withText(getTranslatedString(string.get_forms))).check(matches(isDisplayed())); return this; } public FormsDownloadResultPage clickGetSelected() { - onView(withText(getTranslatedString(org.odk.collect.strings.R.string.download))).perform(click()); + onView(withText(getTranslatedString(string.download))).perform(click()); return new FormsDownloadResultPage().assertOnPage(); } + + public GetBlankFormPage clickClearAll() { + clickOnString(string.clear_all); + return this; + } + + public GetBlankFormPage clickSelectAll() { + clickOnString(string.select_all); + return this; + } + + public GetBlankFormPage clickForm(String formName) { + clickOnText(formName); + return this; + } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/IdentifyUserPromptPage.java b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/IdentifyUserPromptPage.java index f06c2f9c1b5..4051edce842 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/IdentifyUserPromptPage.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/IdentifyUserPromptPage.java @@ -4,11 +4,8 @@ import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.action.ViewActions.pressImeActionButton; import static androidx.test.espresso.action.ViewActions.replaceText; -import static androidx.test.espresso.assertion.ViewAssertions.matches; -import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription; import static androidx.test.espresso.matcher.ViewMatchers.withHint; -import static androidx.test.espresso.matcher.ViewMatchers.withText; public class IdentifyUserPromptPage extends Page { @@ -21,8 +18,8 @@ public IdentifyUserPromptPage(String formName) { @Override public IdentifyUserPromptPage assertOnPage() { - assertToolbarTitle(formName); - onView(withText(getTranslatedString(org.odk.collect.strings.R.string.enter_identity))).check(matches(isDisplayed())); + assertTextInDialog(formName); + assertTextInDialog(org.odk.collect.strings.R.string.enter_identity); return this; } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/ListPreferenceDialog.java b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/ListPreferenceDialog.java index e896e1fab8b..6ec4230d6e1 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/ListPreferenceDialog.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/ListPreferenceDialog.java @@ -17,6 +17,6 @@ public ListPreferenceDialog assertOnPage() { } public T clickOption(int option) { - return clickOnButtonInDialog(option, page); + return clickOnTextInDialog(option, page); } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/MainMenuPage.java b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/MainMenuPage.java index 90b8ff0a7ad..67dc23e4eb3 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/MainMenuPage.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/MainMenuPage.java @@ -12,10 +12,12 @@ import static org.hamcrest.core.AllOf.allOf; import static org.hamcrest.core.StringContains.containsString; +import org.jetbrains.annotations.NotNull; import org.odk.collect.android.R; import org.odk.collect.android.support.StorageUtils; import org.odk.collect.android.support.TestScheduler; -import org.odk.collect.android.support.WaitFor; +import org.odk.collect.testshared.WaitFor; +import org.odk.collect.strings.R.string; import java.io.IOException; import java.util.List; @@ -43,6 +45,11 @@ public FormEntryPage startBlankForm(String formName) { return new FormEntryPage(formName).assertOnPage(); } + public SavepointRecoveryDialogPage startBlankFormWithSavepoint(String formName) { + goToBlankForm(formName); + return new SavepointRecoveryDialogPage().assertOnPage(); + } + public AddNewRepeatDialog startBlankFormWithRepeatGroup(String formName, String repeatName) { goToBlankForm(formName); return new AddNewRepeatDialog(repeatName).assertOnPage(); @@ -59,7 +66,10 @@ public OkDialog startBlankFormWithDialog(String formName) { } public FillBlankFormPage clickFillBlankForm() { - onView(withId(R.id.enter_data)).perform(click()); + tryFlakyAction(() -> { + onView(withId(R.id.enter_data)).perform(click()); + }); + return new FillBlankFormPage().assertOnPage(); } @@ -181,12 +191,12 @@ public MainMenuPage enableMatchExactly() { .pressBack(new MainMenuPage()); } - public MainMenuPage enableAutoSend(TestScheduler scheduler) { + public MainMenuPage enableAutoSend(TestScheduler scheduler, int setting) { MainMenuPage mainMenuPage = openProjectSettingsDialog() .clickSettings() .clickFormManagement() .clickOnString(org.odk.collect.strings.R.string.autosend) - .clickOnString(org.odk.collect.strings.R.string.wifi_cellular_autosend) + .clickOnString(setting) .pressBack(new ProjectSettingsPage()) .pressBack(new MainMenuPage()); @@ -273,9 +283,43 @@ public EntitiesPage openEntityBrowser() { openProjectSettingsDialog() .clickSettings() .clickExperimental() - .clickOnString(org.odk.collect.strings.R.string.entities_title); + .clickOnString(org.odk.collect.strings.R.string.entity_browser_button); return new EntitiesPage().assertOnPage(); } + + public MainMenuPage addEntityListInBrowser(String entityList) { + return openEntityBrowser() + .clickOptionsIcon(string.add_entity_list) + .clickOnTextInPopup(string.add_entity_list) + .inputText(entityList) + .clickOnTextInDialog(string.add) + .assertText(entityList) + .pressBack(new ExperimentalPage()) + .pressBack(new ProjectSettingsPage()) + .pressBack(new MainMenuPage()); + } + + public MainMenuPage refreshForms() { + return clickFillBlankForm() + .clickRefresh() + .pressBack(new MainMenuPage()); + } + + public MainMenuPage setupEntities(String entityList) { + return enableLocalEntitiesInForms() + .addEntityListInBrowser(entityList) + .refreshForms(); + } + + @NotNull + public MainMenuPage enableLocalEntitiesInForms() { + return openProjectSettingsDialog() + .clickSettings() + .clickExperimental() + .clickOnString(org.odk.collect.strings.R.string.include_local_entities_setting) + .pressBack(new ProjectSettingsPage()) + .pressBack(new MainMenuPage()); + } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/ManualProjectCreatorDialogPage.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/ManualProjectCreatorDialogPage.kt index fce019a682d..cbd048301de 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/ManualProjectCreatorDialogPage.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/ManualProjectCreatorDialogPage.kt @@ -3,7 +3,7 @@ package org.odk.collect.android.support.pages import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.matcher.ViewMatchers.withText -import org.odk.collect.android.R +import org.odk.collect.testshared.WaitFor.tryAgainOnFail class ManualProjectCreatorDialogPage : Page() { override fun assertOnPage(): ManualProjectCreatorDialogPage { @@ -27,8 +27,12 @@ class ManualProjectCreatorDialogPage : Page() { } fun addProject(): MainMenuPage { - onView(withText(org.odk.collect.strings.R.string.add)).perform(click()) - return MainMenuPage().assertOnPage() + tryAgainOnFail { + clickOnString(org.odk.collect.strings.R.string.add) + MainMenuPage().assertOnPage() + } + + return MainMenuPage() } fun addProjectAndAssertDuplicateDialogShown(): ManualProjectCreatorDialogPage { diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/NotificationDrawer.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/NotificationDrawer.kt index 08267141366..e98ac7a7277 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/NotificationDrawer.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/NotificationDrawer.kt @@ -10,7 +10,7 @@ import org.hamcrest.CoreMatchers.`is` import org.hamcrest.CoreMatchers.not import org.hamcrest.CoreMatchers.nullValue import org.hamcrest.MatcherAssert.assertThat -import org.odk.collect.android.support.WaitFor.waitFor +import org.odk.collect.testshared.WaitFor.waitFor class NotificationDrawer { private var isOpen = false @@ -35,10 +35,7 @@ class NotificationDrawer { subtext: String? = null, body: String? = null ): NotificationDrawer { - val device = waitForNotification(appName) - - val titleElement = device.findObject(By.text(title)) - assertThat(titleElement, not(nullValue())) + val device = waitForNotification(appName, title) if (subtext != null) { assertExpandedText(device, appName, subtext) @@ -53,10 +50,11 @@ class NotificationDrawer { fun > clickAction( appName: String, + title: String, actionText: String, destination: D ): D { - val device = waitForNotification(appName) + val device = waitForNotification(appName, title) val actionElement = getExpandedElement(device, appName, actionText) ?: getExpandedElement(device, appName, actionText.uppercase()) if (actionElement != null) { @@ -79,9 +77,8 @@ class NotificationDrawer { title: String, destination: D ): D { - val device = waitForNotification(appName) - val titleElement = assertText(device, title) - titleElement.click() + val device = waitForNotification(appName, title) + device.findObject(By.text(title)).click() isOpen = false return waitFor { @@ -118,15 +115,6 @@ class NotificationDrawer { device.pressBack() } - private fun assertText(device: UiDevice, text: String): UiObject2 { - val element = device.findObject(By.text(text)) - if (element != null) { - return element - } else { - throw AssertionError("Could not find \"$text\"") - } - } - private fun assertExpandedText( device: UiDevice, appName: String, @@ -153,12 +141,13 @@ class NotificationDrawer { device.findObject(By.text(appName)).click() } - private fun waitForNotification(appName: String): UiDevice { + private fun waitForNotification(appName: String, title: String): UiDevice { return waitFor { val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - val result = device.wait(Until.hasObject(By.textStartsWith(appName)), 0L) + val result = device.wait(Until.hasObject(By.text(appName)), 0L) && + device.wait(Until.hasObject(By.text(title)), 0L) assertThat( - "No notification for app: $appName", + "No notification for app: $appName with title $title", result, `is`(true) ) diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/OkDialog.java b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/OkDialog.java index 16ff67360ec..2cac7db56b8 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/OkDialog.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/OkDialog.java @@ -31,6 +31,6 @@ public OkDialog assertOnPage() { } public > D clickOK(D destination) { - return clickOnButtonInDialog(org.odk.collect.strings.R.string.ok, destination); + return clickOnTextInDialog(org.odk.collect.strings.R.string.ok, destination); } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt index 43bbac371eb..63190d06212 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/Page.kt @@ -10,9 +10,9 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.Espresso import androidx.test.espresso.Espresso.onView import androidx.test.espresso.NoActivityResumedException -import androidx.test.espresso.NoMatchingViewException import androidx.test.espresso.ViewAction import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.longClick import androidx.test.espresso.action.ViewActions.replaceText import androidx.test.espresso.action.ViewActions.scrollTo import androidx.test.espresso.action.ViewActions.typeText @@ -20,6 +20,7 @@ import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.contrib.RecyclerViewActions import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.RootMatchers.isPlatformPopup import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.hasDescendant import androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA @@ -31,11 +32,9 @@ import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility import androidx.test.espresso.matcher.ViewMatchers.withHint import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withSubstring import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice -import androidx.test.uiautomator.UiSelector import org.hamcrest.CoreMatchers.not import org.hamcrest.Matcher import org.hamcrest.Matchers @@ -49,17 +48,20 @@ import org.odk.collect.android.R import org.odk.collect.android.application.Collect import org.odk.collect.android.storage.StoragePathProvider import org.odk.collect.android.support.ActivityHelpers.getLaunchIntent -import org.odk.collect.android.support.CollectHelpers -import org.odk.collect.android.support.WaitFor.wait250ms -import org.odk.collect.android.support.WaitFor.waitFor import org.odk.collect.android.support.actions.RotateAction import org.odk.collect.android.support.matchers.CustomMatchers.withIndex +import org.odk.collect.android.support.rules.RecentAppsRule +import org.odk.collect.android.utilities.ActionRegister import org.odk.collect.androidshared.ui.ToastUtils.popRecordedToasts import org.odk.collect.androidtest.ActivityScenarioLauncherRule import org.odk.collect.strings.localization.getLocalizedQuantityString import org.odk.collect.strings.localization.getLocalizedString -import org.odk.collect.testshared.EspressoHelpers +import org.odk.collect.testshared.Assertions +import org.odk.collect.testshared.Interactions import org.odk.collect.testshared.RecyclerViewMatcher +import org.odk.collect.testshared.WaitFor.tryAgainOnFail +import org.odk.collect.testshared.WaitFor.wait250ms +import org.odk.collect.testshared.WaitFor.waitFor import timber.log.Timber import java.io.File @@ -129,7 +131,7 @@ abstract class Page> { } fun assertText(text: String): T { - EspressoHelpers.assertText(text) + Assertions.assertText(withText(text)) return this as T } @@ -166,17 +168,6 @@ abstract class Page> { return this as T } - fun checkIsTranslationDisplayed(vararg text: String?): T { - for (s in text) { - try { - onView(withText(s)).check(matches(isDisplayed())) - } catch (e: NoMatchingViewException) { - Timber.i(e) - } - } - return this as T - } - fun closeSoftKeyboard(): T { Espresso.closeSoftKeyboard() return this as T @@ -194,7 +185,19 @@ abstract class Page> { } fun assertTextDoesNotExist(text: String?): T { - onView(allOf(withText(text), withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))).check(doesNotExist()) + onView( + allOf( + withText(text), + withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE) + ) + ).check(doesNotExist()) + return this as T + } + + fun assertTextDoesNotExistInDialog(text: String?): T { + onView(allOf(withText(text), withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) + .inRoot(isDialog()) + .check(doesNotExist()) return this as T } @@ -235,18 +238,26 @@ abstract class Page> { return checkIsToastWithMessageDisplayed(getTranslatedString(id, *formatArgs)) } + fun > clickOnString(stringID: Int, destination: D): D { + Interactions.clickOn(withText(getTranslatedString(stringID))) { + destination.assertOnPage() + } + + return destination + } + fun clickOnString(stringID: Int): T { clickOnText(getTranslatedString(stringID)) return this as T } fun clickOnText(text: String): T { - onView(withText(text)).perform(click()) + Interactions.clickOn(withText(text)) return this as T } fun clickOnId(id: Int): T { - onView(withId(id)).perform(click()) + Interactions.clickOn(withId(id)) return this as T } @@ -258,39 +269,48 @@ abstract class Page> { fun clickOKOnDialog(): T { closeSoftKeyboard() // Make sure to avoid issues with keyboard being up waitForDialogToSettle() - onView(withId(android.R.id.button1)) - .inRoot(isDialog()) - .perform(click()) + Interactions.clickOn(withId(android.R.id.button1), root = isDialog()) return this as T } fun ?> clickOKOnDialog(destination: D): D { closeSoftKeyboard() // Make sure to avoid issues with keyboard being up waitForDialogToSettle() - onView(withId(android.R.id.button1)) - .inRoot(isDialog()) - .perform(click()) + Interactions.clickOn(withId(android.R.id.button1), root = isDialog()) return destination!!.assertOnPage() } - fun ?> clickOnButtonInDialog(buttonText: Int, destination: D): D { + fun clickOnTextInDialog(text: String): T { waitForDialogToSettle() - onView(withText(getTranslatedString(buttonText))) - .inRoot(isDialog()) - .perform(click()) - return destination!!.assertOnPage() + Interactions.clickOn(withText(text), root = isDialog()) + return this as T + } + + fun clickOnTextInDialog(text: Int): T { + return clickOnTextInDialog(getTranslatedString(text)) + } + + fun > clickOnTextInDialog(text: Int, destination: D): D { + return clickOnTextInDialog(getTranslatedString(text), destination) + } + + fun > clickOnTextInDialog(text: String, destination: D): D { + clickOnTextInDialog(text) + return destination.assertOnPage() } fun getTranslatedString(id: Int?, vararg formatArgs: Any): String { - return ApplicationProvider.getApplicationContext().getLocalizedString(id!!, *formatArgs) + return ApplicationProvider.getApplicationContext() + .getLocalizedString(id!!, *formatArgs) } fun getTranslatedQuantityString(id: Int?, quantity: Int, vararg formatArgs: Any): String { - return ApplicationProvider.getApplicationContext().getLocalizedQuantityString(id!!, quantity, *formatArgs) + return ApplicationProvider.getApplicationContext() + .getLocalizedQuantityString(id!!, quantity, *formatArgs) } fun clickOnAreaWithIndex(clazz: String?, index: Int): T { - onView(withIndex(withClassName(endsWith(clazz)), index)).perform(click()) + Interactions.clickOn(withIndex(withClassName(endsWith(clazz)), index)) return this as T } @@ -353,40 +373,56 @@ abstract class Page> { } fun checkIsSnackbarErrorVisible(): T { - onView(allOf(withId(com.google.android.material.R.id.snackbar_text))).check(matches(isDisplayed())) - return this as T - } - - fun scrollToAndClickText(text: Int): T { - onView(withText(getTranslatedString(text))).perform(scrollTo(), click()) - return this as T - } - - fun scrollToAndClickSubtext(text: Int): T { - onView(withSubstring(getTranslatedString(text))).perform(scrollTo(), click()) - return this as T - } - - fun scrollToAndClickText(text: String?): T { - onView(withText(text)).perform(scrollTo(), click()) + onView(allOf(withId(com.google.android.material.R.id.snackbar_text))).check( + matches( + isDisplayed() + ) + ) return this as T } fun scrollToRecyclerViewItemAndClickText(text: String?): T { - onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(text)), scrollTo())) - onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(text)), click())) + onView(withId(androidx.preference.R.id.recycler_view)).perform( + RecyclerViewActions.actionOnItem( + hasDescendant(withText(text)), + scrollTo() + ) + ) + onView(withId(androidx.preference.R.id.recycler_view)).perform( + RecyclerViewActions.actionOnItem( + hasDescendant(withText(text)), + click() + ) + ) return this as T } fun scrollToRecyclerViewItemAndClickText(string: Int): T { - onView(ViewMatchers.isAssignableFrom(RecyclerView::class.java)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(getTranslatedString(string))), scrollTo())) - onView(ViewMatchers.isAssignableFrom(RecyclerView::class.java)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(getTranslatedString(string))), click())) + onView(ViewMatchers.isAssignableFrom(RecyclerView::class.java)).perform( + RecyclerViewActions.actionOnItem( + hasDescendant(withText(getTranslatedString(string))), + scrollTo() + ) + ) + onView(ViewMatchers.isAssignableFrom(RecyclerView::class.java)).perform( + RecyclerViewActions.actionOnItem( + hasDescendant(withText(getTranslatedString(string))), + click() + ) + ) return this as T } fun clickOnElementInHierarchy(index: Int): T { - onView(withId(R.id.list)).perform(RecyclerViewActions.scrollToPosition(index)) - onView(RecyclerViewMatcher.withRecyclerView(R.id.list).atPositionOnView(index, R.id.primary_text)).perform(click()) + onView(withId(R.id.list)).perform( + RecyclerViewActions.scrollToPosition( + index + ) + ) + onView( + RecyclerViewMatcher.withRecyclerView(R.id.list) + .atPositionOnView(index, R.id.primary_text) + ).perform(click()) return this as T } @@ -396,7 +432,10 @@ abstract class Page> { } fun checkIfElementInHierarchyMatchesToText(text: String?, index: Int): T { - onView(RecyclerViewMatcher.withRecyclerView(R.id.list).atPositionOnView(index, R.id.primary_text)).check(matches(withText(text))) + onView( + RecyclerViewMatcher.withRecyclerView(R.id.list) + .atPositionOnView(index, R.id.primary_text) + ).check(matches(withText(text))) return this as T } @@ -405,21 +444,6 @@ abstract class Page> { return this as T } - @JvmOverloads - fun tryAgainOnFail(action: Runnable, maxTimes: Int = 2) { - var failure: Exception? = null - for (i in 0 until maxTimes) { - try { - action.run() - return - } catch (e: Exception) { - failure = e - wait250ms() - } - } - throw RuntimeException("tryAgainOnFail failed", failure) - } - private fun waitForDialogToSettle() { wait250ms() // https://github.com/android/android-test/issues/444 } @@ -429,7 +453,12 @@ abstract class Page> { } protected fun assertToolbarTitle(title: String?) { - onView(allOf(withText(title), isDescendantOfA(withId(org.odk.collect.androidshared.R.id.toolbar)))).check(matches(isDisplayed())) + onView( + allOf( + withText(title), + isDescendantOfA(withId(org.odk.collect.androidshared.R.id.toolbar)) + ) + ).check(matches(isDisplayed())) } protected fun assertToolbarTitle(title: Int) { @@ -447,24 +476,31 @@ abstract class Page> { } fun clickOnContentDescription(string: Int): T { - EspressoHelpers.clickOnContentDescription(string) + Interactions.clickOn(withContentDescription(string)) return this as T } - fun assertFileWithProjectNameUpdated(sanitizedOldProjectName: String, sanitizedNewProjectName: String): T { + fun assertFileWithProjectNameUpdated( + sanitizedOldProjectName: String, + sanitizedNewProjectName: String + ): T { val storagePathProvider = StoragePathProvider() Assert.assertFalse(File(storagePathProvider.getProjectRootDirPath() + File.separator + sanitizedOldProjectName).exists()) Assert.assertTrue(File(storagePathProvider.getProjectRootDirPath() + File.separator + sanitizedNewProjectName).exists()) return this as T } - fun assertTextInDialog(text: Int): T { - onView(withText(getTranslatedString(text))).inRoot(isDialog()).check(matches(isDisplayed())) + fun assertTextInDialog(text: String): T { + onView(withText(text)).inRoot(isDialog()).check(matches(isDisplayed())) return this as T } + fun assertTextInDialog(text: Int): T { + return assertTextInDialog(getTranslatedString(text)) + } + fun closeSnackbar(): T { - onView(withContentDescription(org.odk.collect.strings.R.string.close_snackbar)).perform(click()) + Interactions.clickOn(withContentDescription(org.odk.collect.strings.R.string.close_snackbar)) return this as T } @@ -473,10 +509,9 @@ abstract class Page> { } fun clickOptionsIcon(expectedOptionString: String): T { - tryAgainOnFail({ - onView(OVERFLOW_BUTTON_MATCHER).perform(click()) + Interactions.clickOn(OVERFLOW_BUTTON_MATCHER) { assertText(expectedOptionString) - }) + } return this as T } @@ -495,19 +530,15 @@ abstract class Page> { return destination!!.assertOnPage() } - fun > killAndReopenApp(rule: ActivityScenarioLauncherRule, destination: D): D { - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - - // kill - device.pressRecentApps() - device - .findObject(UiSelector().descriptionContains("Collect")) - .swipeUp(10).also { - CollectHelpers.simulateProcessRestart() // the process is not restarted automatically (probably to keep the test running) so we have simulate it - } + fun > killAndReopenApp( + launcherRule: ActivityScenarioLauncherRule, + recentAppsRule: RecentAppsRule, + destination: D + ): D { + recentAppsRule.leaveAndKillApp() // reopen - rule.launch(getLaunchIntent()) + launcherRule.launch(getLaunchIntent()) return destination.assertOnPage() } @@ -516,6 +547,37 @@ abstract class Page> { return this as T } + fun longClickOnText(text: String): T { + onView(withText(text)).perform(longClick()) + return this as T + } + + fun clickOnTextInPopup(text: Int): T { + Interactions.clickOn(withText(text), root = isPlatformPopup()) + return this as T + } + + fun tryFlakyAction(action: Runnable) { + tryAgainOnFail { + ActionRegister.attemptingAction() + action.run() + waitFor { + if (!ActionRegister.isActionDetected) { + throw java.lang.RuntimeException("Action never detected!") + } + } + } + } + + fun > tryAgainOnFail(destination: D, action: Runnable): D { + tryAgainOnFail { + action.run() + destination.assertOnPage() + } + + return destination + } + companion object { private fun rotateToLandscape(): ViewAction { return RotateAction(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/ProjectSettingsDialogPage.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/ProjectSettingsDialogPage.kt index aeaa8e6b38c..f76de62c9db 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/ProjectSettingsDialogPage.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/ProjectSettingsDialogPage.kt @@ -10,8 +10,7 @@ import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withContentDescription import androidx.test.espresso.matcher.ViewMatchers.withText import org.hamcrest.Matchers.allOf -import org.odk.collect.android.R -import org.odk.collect.android.support.WaitFor +import org.odk.collect.testshared.WaitFor internal class ProjectSettingsDialogPage : Page() { @@ -21,21 +20,21 @@ internal class ProjectSettingsDialogPage : Page() { } fun clickSettings(): ProjectSettingsPage { - return clickOnButtonInDialog( + return clickOnTextInDialog( org.odk.collect.strings.R.string.settings, ProjectSettingsPage() ) } fun clickAbout(): AboutPage { - return clickOnButtonInDialog( + return clickOnTextInDialog( org.odk.collect.strings.R.string.about_preferences, AboutPage() ) } fun clickAddProject(): QrCodeProjectCreatorDialogPage { - return clickOnButtonInDialog( + return clickOnTextInDialog( org.odk.collect.strings.R.string.add_project, QrCodeProjectCreatorDialogPage() ) diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/QRCodePage.java b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/QRCodePage.java index f23b6e4e5e3..7ced75aecb9 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/QRCodePage.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/QRCodePage.java @@ -1,13 +1,5 @@ package org.odk.collect.android.support.pages; -import android.graphics.Bitmap; - -import androidx.test.espresso.Espresso; - -import org.odk.collect.android.support.ActivityHelpers; -import org.odk.collect.android.support.WaitFor; -import org.odk.collect.android.support.matchers.DrawableMatcher; - import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; @@ -16,6 +8,14 @@ import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; +import android.graphics.Bitmap; + +import androidx.test.espresso.Espresso; + +import org.odk.collect.android.support.ActivityHelpers; +import org.odk.collect.testshared.WaitFor; +import org.odk.collect.androidtest.DrawableMatcher; + public class QRCodePage extends Page { @Override public QRCodePage assertOnPage() { @@ -45,11 +45,9 @@ public QRCodePage assertImageViewShowsImage(int resourceid, Bitmap image) { } public QRCodePage clickOnMenu() { - tryAgainOnFail(() -> { + return tryAgainOnFail(this, () -> { Espresso.openActionBarOverflowOrOptionsMenu(ActivityHelpers.getActivity()); onView(withText(getTranslatedString(org.odk.collect.strings.R.string.import_qrcode_sd))).check(matches(isDisplayed())); }); - - return this; } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/QrCodeProjectCreatorDialogPage.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/QrCodeProjectCreatorDialogPage.kt index 4a7c98ce27f..bc47884d66f 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/QrCodeProjectCreatorDialogPage.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/QrCodeProjectCreatorDialogPage.kt @@ -5,7 +5,6 @@ import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withText -import org.odk.collect.android.R class QrCodeProjectCreatorDialogPage : Page() { override fun assertOnPage(): QrCodeProjectCreatorDialogPage { @@ -14,7 +13,7 @@ class QrCodeProjectCreatorDialogPage : Page() { } fun switchToManualMode(): ManualProjectCreatorDialogPage { - return clickOnButtonInDialog(org.odk.collect.strings.R.string.configure_manually, ManualProjectCreatorDialogPage()) + return clickOnTextInDialog(org.odk.collect.strings.R.string.configure_manually, ManualProjectCreatorDialogPage()) } fun assertDuplicateDialogShown(): QrCodeProjectCreatorDialogPage { @@ -26,10 +25,10 @@ class QrCodeProjectCreatorDialogPage : Page() { } fun switchToExistingProject(): MainMenuPage { - return clickOnButtonInDialog(org.odk.collect.strings.R.string.switch_to_existing, MainMenuPage()) + return clickOnTextInDialog(org.odk.collect.strings.R.string.switch_to_existing, MainMenuPage()) } fun addDuplicateProject(): MainMenuPage { - return clickOnButtonInDialog(org.odk.collect.strings.R.string.add_duplicate_project, MainMenuPage()) + return clickOnTextInDialog(org.odk.collect.strings.R.string.add_duplicate_project, MainMenuPage()) } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/SaveOrDiscardFormDialog.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/SaveOrDiscardFormDialog.kt index 5e2bb0d48cd..e8aedfe4ce7 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/SaveOrDiscardFormDialog.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/SaveOrDiscardFormDialog.kt @@ -1,7 +1,6 @@ package org.odk.collect.android.support.pages import android.os.Build -import org.odk.collect.android.R class SaveOrDiscardFormDialog> @JvmOverloads constructor( private val destination: D, @@ -10,37 +9,35 @@ class SaveOrDiscardFormDialog> @JvmOverloads constructor( override fun assertOnPage(): SaveOrDiscardFormDialog { if (saveAsDraftEnabled) { - assertText(org.odk.collect.strings.R.string.quit_form_title) + assertTextInDialog(org.odk.collect.strings.R.string.quit_form_title) } else { - assertText(org.odk.collect.strings.R.string.quit_form_continue_title) + assertTextInDialog(org.odk.collect.strings.R.string.quit_form_continue_title) } return this } fun clickSaveChanges(): D { - clickOnString(org.odk.collect.strings.R.string.save_as_draft) - return destination.assertOnPage() + return clickOnTextInDialog(org.odk.collect.strings.R.string.save_as_draft, destination) } fun clickSaveChangesWithError(errorMsg: Int): D { - clickOnString(org.odk.collect.strings.R.string.save_as_draft) + clickOnTextInDialog(org.odk.collect.strings.R.string.save_as_draft) if (Build.VERSION.SDK_INT < 30) { checkIsToastWithMessageDisplayed(errorMsg) } else { - assertText(errorMsg) + assertTextInDialog(errorMsg) clickOKOnDialog() } + return destination.assertOnPage() } fun clickDiscardForm(): D { - clickOnString(org.odk.collect.strings.R.string.do_not_save) - return destination.assertOnPage() + return clickOnTextInDialog(org.odk.collect.strings.R.string.do_not_save, destination) } fun clickDiscardChanges(): D { - clickOnString(org.odk.collect.strings.R.string.discard_changes) - return destination.assertOnPage() + return clickOnTextInDialog(org.odk.collect.strings.R.string.discard_changes, destination) } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/SaveOrIgnoreDrawingDialog.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/SaveOrIgnoreDrawingDialog.kt index 02b02e43505..b0559a07f5d 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/SaveOrIgnoreDrawingDialog.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/SaveOrIgnoreDrawingDialog.kt @@ -12,10 +12,10 @@ class SaveOrIgnoreDrawingDialog>( } fun clickSaveChanges(): D { - return clickOnButtonInDialog(org.odk.collect.strings.R.string.keep_changes, destination) + return clickOnTextInDialog(org.odk.collect.strings.R.string.keep_changes, destination) } fun clickDiscardChanges(): D { - return clickOnButtonInDialog(org.odk.collect.strings.R.string.discard_changes, destination) + return clickOnTextInDialog(org.odk.collect.strings.R.string.discard_changes, destination) } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/SavepointRecoveryDialogPage.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/SavepointRecoveryDialogPage.kt new file mode 100644 index 00000000000..76ee26e13be --- /dev/null +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/SavepointRecoveryDialogPage.kt @@ -0,0 +1,24 @@ +package org.odk.collect.android.support.pages + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import org.odk.collect.strings.R + +class SavepointRecoveryDialogPage : Page() { + override fun assertOnPage(): SavepointRecoveryDialogPage { + val title = getTranslatedString(R.string.savepoint_recovery_dialog_title) + onView(withText(title)).inRoot(isDialog()).check(matches(isDisplayed())) + return this + } + + fun > clickRecover(destination: D): D { + return this.clickOnTextInDialog(R.string.recover, destination) + } + + fun > clickDoNotRecover(destination: D): D { + return this.clickOnTextInDialog(R.string.do_not_recover, destination) + } +} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/SelectMinimalDialogPage.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/SelectMinimalDialogPage.kt new file mode 100644 index 00000000000..1a853b2385a --- /dev/null +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/pages/SelectMinimalDialogPage.kt @@ -0,0 +1,12 @@ +package org.odk.collect.android.support.pages + +class SelectMinimalDialogPage(private val formName: String) : Page() { + override fun assertOnPage(): SelectMinimalDialogPage { + assertTextDoesNotExistInDialog(formName) + return this + } + + fun selectItem(item: String): FormEntryPage { + return clickOnTextInDialog(item, FormEntryPage(formName)) + } +} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/CollectTestRule.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/CollectTestRule.kt index 169ab13e5ed..063e4de85d8 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/CollectTestRule.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/CollectTestRule.kt @@ -41,6 +41,17 @@ class CollectTestRule @JvmOverloads constructor( .addProject() } + fun withMatchExactlyProject(serverUrl: String): MainMenuPage { + return startAtFirstLaunch() + .clickManuallyEnterProjectDetails() + .inputUrl(serverUrl) + .addProject() + .enableMatchExactly() + .clickFillBlankForm() + .clickRefresh() + .pressBack(MainMenuPage()) + } + fun withProject(testServer: StubOpenRosaServer, vararg formFiles: String): MainMenuPage { val mainMenuPage = startAtFirstLaunch() .clickManuallyEnterProjectDetails() diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/FormEntryActivityTestRule.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/FormEntryActivityTestRule.kt index f40f59561b8..db6416e0bec 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/FormEntryActivityTestRule.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/FormEntryActivityTestRule.kt @@ -3,11 +3,9 @@ package org.odk.collect.android.support.rules import android.app.Activity import android.app.Application import android.content.Intent -import androidx.lifecycle.Lifecycle import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import org.junit.rules.ExternalResource -import org.odk.collect.android.activities.FormFillingActivity import org.odk.collect.android.external.FormsContract import org.odk.collect.android.formmanagement.FormFillingIntentFactory import org.odk.collect.android.injection.DaggerUtils @@ -17,7 +15,7 @@ import org.odk.collect.android.support.StorageUtils import org.odk.collect.android.support.pages.FormEntryPage import org.odk.collect.android.support.pages.FormHierarchyPage import org.odk.collect.android.support.pages.Page -import org.odk.collect.androidtest.ActivityScenarioExtensions.saveInstanceState +import org.odk.collect.android.support.pages.SavepointRecoveryDialogPage import timber.log.Timber import java.io.IOException @@ -61,21 +59,22 @@ open class FormEntryActivityTestRule : return fillNewForm(formFilename, FormEntryPage(formName)) } + fun fillNewFormWithSavepoint(formFilename: String): SavepointRecoveryDialogPage { + intent = createNewFormIntent(formFilename) + scenario = ActivityScenario.launch(intent) + return SavepointRecoveryDialogPage().assertOnPage() + } + fun editForm(formFilename: String, instanceName: String): FormHierarchyPage { intent = createEditFormIntent(formFilename) scenario = ActivityScenario.launch(intent) return FormHierarchyPage(instanceName).assertOnPage() } - fun navigateAwayFromActivity(): FormEntryActivityTestRule { - scenario.moveToState(Lifecycle.State.STARTED) - scenario.saveInstanceState() - return this - } - - fun destroyActivity(): FormEntryActivityTestRule { - scenario.moveToState(Lifecycle.State.DESTROYED) - return this + fun editFormWithSavepoint(formFilename: String): SavepointRecoveryDialogPage { + intent = createEditFormIntent(formFilename) + scenario = ActivityScenario.launch(intent) + return SavepointRecoveryDialogPage().assertOnPage() } fun simulateProcessRestart(): FormEntryActivityTestRule { @@ -94,8 +93,7 @@ open class FormEntryActivityTestRule : return FormFillingIntentFactory.newInstanceIntent( application, - FormsContract.getUri(projectId, form!!.dbId), - FormFillingActivity::class + FormsContract.getUri(projectId, form!!.dbId) ) } @@ -113,8 +111,7 @@ open class FormEntryActivityTestRule : return FormFillingIntentFactory.editInstanceIntent( application, projectId, - instance.dbId, - FormFillingActivity::class + instance.dbId ) } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/NotificationDrawerRule.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/NotificationDrawerRule.kt index be7b3a6d0fb..1fb3b27e3e3 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/NotificationDrawerRule.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/NotificationDrawerRule.kt @@ -1,5 +1,7 @@ package org.odk.collect.android.support.rules +import android.os.Build +import org.junit.Assert.assertTrue import org.junit.rules.TestRule import org.junit.runner.Description import org.junit.runners.model.Statement @@ -12,6 +14,11 @@ class NotificationDrawerRule : TestRule { return object : Statement() { @Throws(Throwable::class) override fun evaluate() { + assertTrue( + "${this.javaClass.simpleName} does not support this API level!", + SUPPORTED_SDKS.contains(Build.VERSION.SDK_INT) + ) + try { base.evaluate() } finally { @@ -24,4 +31,8 @@ class NotificationDrawerRule : TestRule { fun open(): NotificationDrawer { return notificationDrawer.open() } + + companion object { + private val SUPPORTED_SDKS = listOf(30, 34) + } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/PrepDeviceForTestsRule.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/PrepDeviceForTestsRule.kt index 94304bf3246..060f4156ce9 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/PrepDeviceForTestsRule.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/PrepDeviceForTestsRule.kt @@ -1,15 +1,10 @@ package org.odk.collect.android.support.rules -import android.content.Intent -import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation -import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice -import androidx.test.uiautomator.Until import org.junit.rules.TestRule import org.junit.runner.Description import org.junit.runners.model.Statement -import org.odk.collect.testshared.DummyActivity /** * Disables animations and sets long press timeout to 3 seconds in an attempt to avoid flakiness. @@ -21,48 +16,12 @@ class PrepDeviceForTestsRule : TestRule { override fun evaluate() { disableAnimations() increaseLongPressTimeout() - removeRecentAppsTooltips() - firstRun = false base.evaluate() } } } - /** - * Makes sure `Page#killAndReopenApp` doesn't run into problems with tooltips by opening - * Recent Apps and dismissing before any test runs. Only needs to run once per test process. - */ - private fun removeRecentAppsTooltips() { - if (firstRun) { - val device = UiDevice.getInstance(getInstrumentation()) - - // Open dummy activity so there is something in Recent Apps - getInstrumentation().targetContext.apply { - val intent = Intent(this.applicationContext, DummyActivity::class.java) - intent.addFlags(FLAG_ACTIVITY_NEW_TASK) - startActivity(intent) - device.wait(Until.hasObject(By.textStartsWith(DummyActivity.TEXT)), 1000) - } - - // Open Recent Apps and dismiss tooltips if they're there - device.pressRecentApps() - val foundToolTip = device.wait( - Until.hasObject(By.textStartsWith("Select text and images to copy")), - 1000 - ) - if (foundToolTip) { - device.pressBack() // the first time we open the list of recent apps, a tooltip might be displayed and we need to close it - } - - // Close recent apps - device.pressBack() - - // Close dummy activity - device.pressBack() - } - } - private fun increaseLongPressTimeout() { executeShellCommand("settings put secure long_press_timeout 3000") } @@ -76,8 +35,6 @@ class PrepDeviceForTestsRule : TestRule { } companion object { - var firstRun = true - private val ANIMATIONS: List = listOf( "transition_animation_scale", "window_animation_scale", diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/RecentAppsRule.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/RecentAppsRule.kt new file mode 100644 index 00000000000..c5428a3b874 --- /dev/null +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/RecentAppsRule.kt @@ -0,0 +1,82 @@ +package org.odk.collect.android.support.rules + +import android.os.Build +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector +import androidx.test.uiautomator.Until +import org.junit.Assert.assertTrue +import org.junit.rules.ExternalResource +import org.odk.collect.android.support.CollectHelpers +import org.odk.collect.android.support.DummyActivityLauncher +import org.odk.collect.shared.TimeInMs + +class RecentAppsRule : ExternalResource() { + + override fun before() { + assertTrue( + "${this.javaClass.simpleName} does not support this API level!", + SUPPORTED_SDKS.contains(Build.VERSION.SDK_INT) + ) + + if (Build.VERSION.SDK_INT == 30) { + removeRecentAppsTooltips() + } + } + + fun leaveAndKillApp() { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + if (Build.VERSION.SDK_INT == 30) { + device.pressRecentApps() + device.wait(Until.hasObject(By.descContains("Collect")), TimeInMs.ONE_SECOND) + device.findObject(UiSelector().descriptionContains("Collect")) + .swipeUp(10).also { + CollectHelpers.simulateProcessRestart() // the process is not restarted automatically (probably to keep the test running) so we have simulate it + } + } else if (Build.VERSION.SDK_INT == 34) { + device.pressHome() // Pressing recent apps does not actually "leave" the app on API 31+ (cause onPause etc). You need to go home or switch apps. + device.pressRecentApps() + device.wait(Until.hasObject(By.descContains("Screenshot")), TimeInMs.ONE_SECOND) + while (!device.wait(Until.hasObject(By.text("Clear all")), 0)) { + device.swipe( + device.displayWidth / 2, + device.displayHeight / 2, + device.displayWidth, + device.displayHeight / 2, + 5 + ) + } + + device.findObject(UiSelector().text("Clear all")).click() + CollectHelpers.simulateProcessRestart() // the process is not restarted automatically (probably to keep the test running) so we have simulate it + } + } + + /** + * Makes sure [leaveAndKillApp] doesn't run into problems with tooltips by opening + * Recent Apps and dismissing before any test runs. Only needs to run once per test process. + */ + private fun removeRecentAppsTooltips() { + // Open dummy activity so there is something in Recent Apps + DummyActivityLauncher.launch { device -> + // Open Recent Apps and dismiss tooltips if they're there + device.pressRecentApps() + val foundToolTip = device.wait( + Until.hasObject(By.textStartsWith("Select text and images to copy")), + TimeInMs.ONE_SECOND + ) + if (foundToolTip) { + device.pressBack() // the first time we open the list of recent apps, a tooltip might be displayed and we need to close it + } + + // Close recent apps + device.pressBack() + } + } + + companion object { + private val SUPPORTED_SDKS = listOf(30, 34) + } +} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/ResetRotationRule.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/ResetRotationRule.kt index 68cf4941b4d..edb21cf63ba 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/ResetRotationRule.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/ResetRotationRule.kt @@ -1,22 +1,15 @@ package org.odk.collect.android.support.rules -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.UiDevice -import org.junit.rules.TestRule -import org.junit.runner.Description -import org.junit.runners.model.Statement -import timber.log.Timber +import org.junit.rules.ExternalResource +import org.odk.collect.android.support.DummyActivityLauncher -class ResetRotationRule : TestRule { +class ResetRotationRule : ExternalResource() { - override fun apply(base: Statement, description: Description): Statement { - return object : Statement() { - override fun evaluate() { - Timber.d("Resetting rotation...") - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - device.setOrientationNatural() - base.evaluate() - } + override fun before() { + // Some devices are always portrait at the home screen so we need to launch something + DummyActivityLauncher.launch { device -> + device.setOrientationNatural() + device.pressBack() } } } diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/ResetStateRule.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/ResetStateRule.kt index 98e69aaed8d..19012cb09e3 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/ResetStateRule.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/ResetStateRule.kt @@ -5,18 +5,16 @@ import androidx.test.core.app.ApplicationProvider import org.junit.rules.TestRule import org.junit.runner.Description import org.junit.runners.model.Statement -import org.odk.collect.android.database.DatabaseConnection.Companion.closeAll +import org.odk.collect.android.database.DatabaseConnection import org.odk.collect.android.injection.DaggerUtils import org.odk.collect.android.injection.config.AppDependencyComponent import org.odk.collect.android.injection.config.AppDependencyModule import org.odk.collect.android.support.CollectHelpers import org.odk.collect.android.views.DecoratedBarcodeView -import org.odk.collect.androidshared.data.getState import org.odk.collect.androidshared.ui.ToastUtils import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickGuard import org.odk.collect.material.BottomSheetBehavior import org.odk.collect.shared.files.DirectoryUtils -import java.io.File import java.io.IOException private class ResetStateStatement( @@ -29,16 +27,12 @@ private class ResetStateStatement( val oldComponent = DaggerUtils.getComponent(application) clearPrefs(oldComponent) - clearDisk(oldComponent) + clearDisk() setTestState() CollectHelpers.simulateProcessRestart(appDependencyModule) base.evaluate() } - private fun clearAppState(application: Application) { - application.getState().clear() - } - private fun setTestState() { MultiClickGuard.test = true DecoratedBarcodeView.test = true @@ -46,13 +40,19 @@ private class ResetStateStatement( BottomSheetBehavior.DRAGGING_ENABLED = false } - private fun clearDisk(component: AppDependencyComponent) { + private fun clearDisk() { try { - DirectoryUtils.deleteDirectory(File(component.storagePathProvider().odkRootDirPath)) + val internalFilesDir = ApplicationProvider.getApplicationContext().filesDir + DirectoryUtils.deleteDirectory(internalFilesDir) + + val externalFilesDir = + ApplicationProvider.getApplicationContext().getExternalFilesDir(null)!! + DirectoryUtils.deleteDirectory(externalFilesDir) } catch (e: IOException) { throw RuntimeException(e) } - closeAll() + + DatabaseConnection.closeAll() } private fun clearPrefs(component: AppDependencyComponent) { diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/RetryOnDeviceErrorRule.kt b/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/RetryOnDeviceErrorRule.kt index 9406a8c90c7..164aa64eac7 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/RetryOnDeviceErrorRule.kt +++ b/collect_app/src/androidTest/java/org/odk/collect/android/support/rules/RetryOnDeviceErrorRule.kt @@ -1,5 +1,6 @@ package org.odk.collect.android.support.rules +import androidx.test.espresso.NoMatchingRootException import androidx.test.espresso.PerformException import org.junit.rules.TestRule import org.junit.runner.Description @@ -17,6 +18,10 @@ class RetryOnDeviceErrorRule : TestRule { Timber.w("RetryOnDeviceErrorRule: Retrying due to PerformException!") Timber.e(e) base.evaluate() + } else if (e is NoMatchingRootException) { + Timber.w("RetryOnDeviceErrorRule: Retrying due to NoMatchingRootException!") + Timber.e(e) + base.evaluate() } else if (e::class.simpleName == "RootViewWithoutFocusException") { Timber.w("RetryOnDeviceErrorRule: Retrying due to RootViewWithoutFocusException!") Timber.e(e) diff --git a/collect_app/src/debug/google-services.json b/collect_app/src/debug/google-services.json index dd5b72899b3..1e6a5a9b311 100644 --- a/collect_app/src/debug/google-services.json +++ b/collect_app/src/debug/google-services.json @@ -1,34 +1,48 @@ { "project_info": { - "project_number": "462257085307", - "firebase_url": "https://opendatakit-debug.firebaseio.com", - "project_id": "opendatakit-debug", - "storage_bucket": "opendatakit-debug.appspot.com" + "project_number": "659901748622", + "firebase_url": "https://kobocollect-6cf1e.firebaseio.com", + "project_id": "kobocollect-6cf1e", + "storage_bucket": "kobocollect-6cf1e.appspot.com" }, "client": [ { "client_info": { - "mobilesdk_app_id": "1:462257085307:android:64acb5e3c9e68ab5173bde", + "mobilesdk_app_id": "1:659901748622:android:3b0a466019a89dac", "android_client_info": { - "package_name": "org.odk.collect.android" + "package_name": "org.koboc.collect.android" } }, "oauth_client": [ { - "client_id": "462257085307-o9ll0ogq8jm4ta3q9av2mf2rh4ume9vj.apps.googleusercontent.com", + "client_id": "659901748622-iekvk6f4ct9g4iomgr3j5vvja9gp71l0.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "org.koboc.collect.android", + "certificate_hash": "df1bd727d0cc9ca347720a38f2249552dc1865ec" + } + }, + { + "client_id": "659901748622-n371b3mfc92hc42vka989l8ae8u6v1c8.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAClt29XaVTTO1m2R9u3WW-RimapSqjALg" + "current_key": "AIzaSyCkT_yrsm0Keynz2o_sOAs5N_ahmFmtJsU" } ], "services": { + "analytics_service": { + "status": 2, + "analytics_property": { + "tracking_id": "UA-111083836-1" + } + }, "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "462257085307-o9ll0ogq8jm4ta3q9av2mf2rh4ume9vj.apps.googleusercontent.com", + "client_id": "659901748622-n371b3mfc92hc42vka989l8ae8u6v1c8.apps.googleusercontent.com", "client_type": 3 } ] @@ -37,4 +51,4 @@ } ], "configuration_version": "1" -} \ No newline at end of file +} diff --git a/collect_app/src/main/AndroidManifest.xml b/collect_app/src/main/AndroidManifest.xml index 9b026aa9769..00380bf6748 100644 --- a/collect_app/src/main/AndroidManifest.xml +++ b/collect_app/src/main/AndroidManifest.xml @@ -77,7 +77,8 @@ the specific language governing permissions and limitations under the License. - + @@ -166,7 +167,7 @@ the specific language governing permissions and limitations under the License. - + tab.text = if (position == 0) { getString(org.odk.collect.strings.R.string.data) @@ -106,7 +125,8 @@ class DeleteSavedFormActivity : LocalizedActivity() { private val formsDataService: FormsDataService, private val scheduler: Scheduler, private val generalSettings: Settings, - private val projectId: String + private val projectId: String, + private val instancesDataService: InstancesDataService ) : ViewModelProvider.Factory { @@ -122,7 +142,13 @@ class DeleteSavedFormActivity : LocalizedActivity() { showAllVersions = true ) - MultiSelectViewModel::class.java -> MultiSelectViewModel() + SavedFormListViewModel::class.java -> SavedFormListViewModel( + scheduler, + generalSettings, + instancesDataService, + projectId + ) + else -> throw IllegalArgumentException() } as T } diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/FormDownloadListActivity.java b/collect_app/src/main/java/org/odk/collect/android/activities/FormDownloadListActivity.java index 7adc4004f5b..1693ff3a603 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/FormDownloadListActivity.java +++ b/collect_app/src/main/java/org/odk/collect/android/activities/FormDownloadListActivity.java @@ -39,16 +39,17 @@ import org.odk.collect.android.adapters.FormDownloadListAdapter; import org.odk.collect.android.formentry.RefreshFormListDialogFragment; import org.odk.collect.android.formlists.sorting.FormListSortingOption; -import org.odk.collect.android.formmanagement.FormDownloadException; -import org.odk.collect.android.formmanagement.FormDownloader; import org.odk.collect.android.formmanagement.FormSourceExceptionMapper; +import org.odk.collect.android.formmanagement.FormsDataService; import org.odk.collect.android.formmanagement.ServerFormDetails; import org.odk.collect.android.formmanagement.ServerFormsDetailsFetcher; +import org.odk.collect.android.formmanagement.download.FormDownloadException; import org.odk.collect.android.fragments.dialogs.FormsDownloadResultDialog; import org.odk.collect.android.injection.DaggerUtils; import org.odk.collect.android.listeners.DownloadFormsTaskListener; import org.odk.collect.android.listeners.FormListDownloaderListener; import org.odk.collect.android.openrosa.HttpCredentialsInterface; +import org.odk.collect.android.projects.ProjectsDataService; import org.odk.collect.android.tasks.DownloadFormListTask; import org.odk.collect.android.tasks.DownloadFormsTask; import org.odk.collect.android.utilities.ApplicationConstants; @@ -56,7 +57,7 @@ import org.odk.collect.android.utilities.DialogUtils; import org.odk.collect.android.utilities.WebCredentialsUtils; import org.odk.collect.android.views.DayNightProgressDialog; -import org.odk.collect.androidshared.network.NetworkStateProvider; +import org.odk.collect.async.network.NetworkStateProvider; import org.odk.collect.androidshared.ui.DialogFragmentUtils; import org.odk.collect.androidshared.ui.ToastUtils; import org.odk.collect.forms.FormSourceException; @@ -128,7 +129,10 @@ public class FormDownloadListActivity extends FormListActivity implements FormLi NetworkStateProvider connectivityProvider; @Inject - FormDownloader formDownloader; + FormsDataService formsDataService; + + @Inject + ProjectsDataService projectsDataService; @SuppressWarnings("unchecked") @Override @@ -395,7 +399,7 @@ private void startFormsDownload(@NonNull ArrayList filesToDow // show dialog box DialogFragmentUtils.showIfNotShowing(RefreshFormListDialogFragment.class, getSupportFragmentManager()); - downloadFormsTask = new DownloadFormsTask(formDownloader); + downloadFormsTask = new DownloadFormsTask(projectsDataService.getCurrentProject().getUuid(), formsDataService); downloadFormsTask.setDownloaderListener(this); if (viewModel.getUrl() != null) { diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/FormEntryViewModelFactory.kt b/collect_app/src/main/java/org/odk/collect/android/activities/FormEntryViewModelFactory.kt index e0f5b995a26..d75fa1be656 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/FormEntryViewModelFactory.kt +++ b/collect_app/src/main/java/org/odk/collect/android/activities/FormEntryViewModelFactory.kt @@ -19,12 +19,14 @@ import org.odk.collect.android.formentry.backgroundlocation.BackgroundLocationMa import org.odk.collect.android.formentry.backgroundlocation.BackgroundLocationViewModel import org.odk.collect.android.formentry.saving.DiskFormSaver import org.odk.collect.android.formentry.saving.FormSaveViewModel +import org.odk.collect.android.instancemanagement.InstancesDataService import org.odk.collect.android.instancemanagement.autosend.AutoSendSettingsProvider import org.odk.collect.android.projects.ProjectsDataService import org.odk.collect.android.utilities.ApplicationConstants import org.odk.collect.android.utilities.FormsRepositoryProvider import org.odk.collect.android.utilities.InstancesRepositoryProvider import org.odk.collect.android.utilities.MediaUtils +import org.odk.collect.android.utilities.SavepointsRepositoryProvider import org.odk.collect.async.Scheduler import org.odk.collect.audiorecorder.recording.AudioRecorder import org.odk.collect.location.LocationClient @@ -52,8 +54,10 @@ class FormEntryViewModelFactory( private val autoSendSettingsProvider: AutoSendSettingsProvider, private val formsRepositoryProvider: FormsRepositoryProvider, private val instancesRepositoryProvider: InstancesRepositoryProvider, + private val savepointsRepositoryProvider: SavepointsRepositoryProvider, private val qrCodeCreator: QRCodeCreator, - private val htmlPrinter: HtmlPrinter + private val htmlPrinter: HtmlPrinter, + private val instancesDataService: InstancesDataService ) : AbstractSavedStateViewModelFactory(owner, null) { override fun create( @@ -83,7 +87,9 @@ class FormEntryViewModelFactory( projectsDataService, formSessionRepository.get(sessionId), entitiesRepositoryProvider.get(projectId), - instancesRepositoryProvider.get(projectId) + instancesRepositoryProvider.get(projectId), + savepointsRepositoryProvider.get(projectId), + instancesDataService ) } @@ -123,10 +129,10 @@ class FormEntryViewModelFactory( fusedLocationClient, BackgroundLocationHelper( permissionsProvider, - settingsProvider.getUnprotectedSettings() - ) { - formSessionRepository.get(sessionId).value?.formController - } + settingsProvider.getUnprotectedSettings(), + formSessionRepository, + sessionId + ) ) BackgroundLocationViewModel(locationManager) diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/FormFillingActivity.java b/collect_app/src/main/java/org/odk/collect/android/activities/FormFillingActivity.java index aa804dd4d7b..edb78d0df28 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/FormFillingActivity.java +++ b/collect_app/src/main/java/org/odk/collect/android/activities/FormFillingActivity.java @@ -18,7 +18,6 @@ import static android.content.DialogInterface.BUTTON_POSITIVE; import static android.view.animation.AnimationUtils.loadAnimation; import static org.javarosa.form.api.FormEntryController.EVENT_PROMPT_NEW_REPEAT; -import static org.odk.collect.android.analytics.AnalyticsEvents.OPEN_MAP_KIT_RESPONSE; import static org.odk.collect.android.formentry.FormIndexAnimationHandler.Direction.BACKWARDS; import static org.odk.collect.android.formentry.FormIndexAnimationHandler.Direction.FORWARDS; import static org.odk.collect.android.utilities.AnimationUtils.areAnimationsEnabled; @@ -80,7 +79,6 @@ import org.jetbrains.annotations.NotNull; import org.joda.time.DateTime; import org.joda.time.LocalDateTime; -import org.odk.collect.analytics.Analytics; import org.odk.collect.android.R; import org.odk.collect.android.analytics.AnalyticsUtils; import org.odk.collect.android.application.Collect; @@ -88,7 +86,6 @@ import org.odk.collect.android.audio.AudioControllerView; import org.odk.collect.android.audio.AudioRecordingControllerFragment; import org.odk.collect.android.audio.M4AAppender; -import org.odk.collect.android.backgroundwork.InstanceSubmitScheduler; import org.odk.collect.android.dao.helpers.InstancesDaoHelper; import org.odk.collect.android.entities.EntitiesRepositoryProvider; import org.odk.collect.android.exception.JavaRosaException; @@ -135,6 +132,7 @@ import org.odk.collect.android.fragments.dialogs.NumberPickerDialog; import org.odk.collect.android.fragments.dialogs.RankingWidgetDialog; import org.odk.collect.android.fragments.dialogs.SelectMinimalDialog; +import org.odk.collect.android.instancemanagement.InstancesDataService; import org.odk.collect.android.instancemanagement.autosend.AutoSendSettingsProvider; import org.odk.collect.android.javarosawrapper.FailedValidationResult; import org.odk.collect.android.javarosawrapper.FormController; @@ -143,15 +141,16 @@ import org.odk.collect.android.javarosawrapper.ValidationResult; import org.odk.collect.android.listeners.AdvanceToNextListener; import org.odk.collect.android.listeners.FormLoaderListener; -import org.odk.collect.android.listeners.SavePointListener; import org.odk.collect.android.listeners.WidgetValueChangedListener; import org.odk.collect.android.logic.ImmutableDisplayableQuestion; import org.odk.collect.android.mainmenu.MainMenuActivity; import org.odk.collect.android.projects.ProjectsDataService; +import org.odk.collect.android.savepoints.SavepointListener; +import org.odk.collect.android.savepoints.SavepointTask; import org.odk.collect.android.storage.StoragePathProvider; +import org.odk.collect.android.storage.StorageSubdirectory; import org.odk.collect.android.tasks.FormLoaderTask; import org.odk.collect.android.tasks.SaveFormIndexTask; -import org.odk.collect.android.tasks.SavePointTask; import org.odk.collect.android.utilities.ApplicationConstants; import org.odk.collect.android.utilities.ContentUriHelper; import org.odk.collect.android.utilities.ControllableLifecyleOwner; @@ -159,6 +158,7 @@ import org.odk.collect.android.utilities.FormsRepositoryProvider; import org.odk.collect.android.utilities.InstancesRepositoryProvider; import org.odk.collect.android.utilities.MediaUtils; +import org.odk.collect.android.utilities.SavepointsRepositoryProvider; import org.odk.collect.android.utilities.ScreenContext; import org.odk.collect.android.utilities.SoftKeyboardController; import org.odk.collect.android.widgets.DateTimeWidget; @@ -224,7 +224,7 @@ @SuppressWarnings("PMD.CouplingBetweenObjects") public class FormFillingActivity extends LocalizedActivity implements AnimationListener, FormLoaderListener, AdvanceToNextListener, SwipeHandler.OnSwipeListener, - SavePointListener, NumberPickerDialog.NumberPickerListener, + SavepointListener, NumberPickerDialog.NumberPickerListener, RankingWidgetDialog.RankingListener, SaveFormIndexTask.SaveFormIndexListener, WidgetValueChangedListener, ScreenContext, FormLoadingDialogFragment.FormLoadingDialogFragmentListener, AudioControllerView.SwipableParent, FormIndexAnimationHandler.Listener, @@ -307,7 +307,7 @@ public void allowSwiping(boolean doSwipe) { PropertyManager propertyManager; @Inject - InstanceSubmitScheduler instanceSubmitScheduler; + InstancesDataService instancesDataService; @Inject Scheduler scheduler; @@ -360,6 +360,10 @@ public void allowSwiping(boolean doSwipe) { @Inject public InstancesRepositoryProvider instancesRepositoryProvider; + + @Inject + public SavepointsRepositoryProvider savepointsRepositoryProvider; + private final LocationProvidersReceiver locationProvidersReceiver = new LocationProvidersReceiver(); private SwipeHandler swipeHandler; @@ -425,8 +429,10 @@ public void onCreate(Bundle savedInstanceState) { autoSendSettingsProvider, formsRepositoryProvider, instancesRepositoryProvider, + savepointsRepositoryProvider, new QRCodeCreatorImpl(), - new HtmlPrinter() + new HtmlPrinter(), + instancesDataService ); this.getSupportFragmentManager().setFragmentFactory(new FragmentFactoryBuilder() @@ -552,8 +558,15 @@ private void setupViewModels(FormEntryViewModelFactory formEntryViewModelFactory formEntryViewModel = viewModelProvider.get(FormEntryViewModel.class); printerWidgetViewModel = viewModelProvider.get(PrinterWidgetViewModel.class); - formEntryViewModel.getCurrentIndex().observe(this, index -> { - formIndexAnimationHandler.handle(index); + formEntryViewModel.getCurrentIndex().observe(this, indexAndValidationResult -> { + if (indexAndValidationResult != null) { + FormIndex formIndex = indexAndValidationResult.component1(); + FailedValidationResult validationResult = indexAndValidationResult.component2(); + formIndexAnimationHandler.handle(formIndex); + if (validationResult != null) { + handleValidationResult(validationResult); + } + } }); formEntryViewModel.isLoading().observe(this, isLoading -> { @@ -574,16 +587,7 @@ private void setupViewModels(FormEntryViewModelFactory formEntryViewModelFactory return; } ValidationResult validationResult = consumable.getValue(); - if (validationResult instanceof FailedValidationResult failedValidationResult) { - String errorMessage = failedValidationResult.getCustomErrorMessage(); - if (errorMessage == null) { - errorMessage = getString(failedValidationResult.getDefaultErrorMessage()); - } - getCurrentViewIfODKView().setErrorForQuestionWithIndex(failedValidationResult.getIndex(), errorMessage); - swipeHandler.setBeenSwiped(false); - } else if (validationResult instanceof SuccessValidationResult) { - SnackbarUtils.showLongSnackbar(findViewById(R.id.llParent), getString(org.odk.collect.strings.R.string.success_form_validation), findViewById(R.id.buttonholder)); - } + handleValidationResult(validationResult); consumable.consume(); }); @@ -637,6 +641,19 @@ private void setupViewModels(FormEntryViewModelFactory formEntryViewModelFactory }); } + private void handleValidationResult(ValidationResult validationResult) { + if (validationResult instanceof FailedValidationResult failedValidationResult) { + String errorMessage = failedValidationResult.getCustomErrorMessage(); + if (errorMessage == null) { + errorMessage = getString(failedValidationResult.getDefaultErrorMessage()); + } + getCurrentViewIfODKView().setErrorForQuestionWithIndex(failedValidationResult.getIndex(), errorMessage); + swipeHandler.setBeenSwiped(false); + } else if (validationResult instanceof SuccessValidationResult) { + SnackbarUtils.showLongSnackbar(findViewById(R.id.llParent), getString(org.odk.collect.strings.R.string.success_form_validation), findViewById(R.id.buttonholder)); + } + } + private void formControllerAvailable(@NonNull FormController formController, @NonNull Form form, @Nullable Instance instance) { formSessionRepository.set(sessionId, formController, form, instance); AnalyticsUtils.setForm(formController); @@ -714,7 +731,7 @@ private void loadFromIntent(Intent intent) { uriMimeType = getContentResolver().getType(uri); } - formLoaderTask = new FormLoaderTask(uri, uriMimeType, startingXPath, waitingXPath, formEntryControllerFactory, scheduler); + formLoaderTask = new FormLoaderTask(uri, uriMimeType, startingXPath, waitingXPath, formEntryControllerFactory, scheduler, savepointsRepositoryProvider.get()); formLoaderTask.setFormLoaderListener(this); showIfNotShowing(FormLoadingDialogFragment.class, getSupportFragmentManager()); formLoaderTask.execute(); @@ -735,7 +752,21 @@ private void initToolbar() { */ private void nonblockingCreateSavePointData() { try { - SavePointTask savePointTask = new SavePointTask(this, getFormController()); + Long formDbId = formSessionRepository.get(sessionId).getValue().getForm().getDbId(); + Long instanceDbId = null; + Instance instance = formSessionRepository.get(sessionId).getValue().getInstance(); + if (instance != null) { + instanceDbId = instance.getDbId(); + } + SavepointTask savePointTask = new SavepointTask( + this, + getFormController(), + formDbId, + instanceDbId, + storagePathProvider.getOdkDirPath(StorageSubdirectory.CACHE), + savepointsRepositoryProvider.get(), + scheduler + ); savePointTask.execute(); if (!allowMovingBackwards) { @@ -832,7 +863,6 @@ protected void onActivityResult(int requestCode, int resultCode, final Intent in switch (requestCode) { case RequestCodes.OSM_CAPTURE: - Analytics.log(OPEN_MAP_KIT_RESPONSE, "form"); setWidgetData(intent.getStringExtra("OSM_FILE_NAME")); break; case RequestCodes.EX_ARBITRARY_FILE_CHOOSER: @@ -1540,15 +1570,12 @@ private void handleSaveResult(FormSaveViewModel.SaveResult result) { DialogFragmentUtils.dismissDialog(ChangesReasonPromptDialogFragment.class, getSupportFragmentManager()); if (result.getRequest().viewExiting()) { - if (result.getRequest().shouldFinalize()) { - instanceSubmitScheduler.scheduleSubmit(projectsDataService.getCurrentProject().getUuid()); - } - finishAndReturnInstance(); } else { showShortToast(this, org.odk.collect.strings.R.string.data_saved_ok); } + formSessionRepository.update(sessionId, formSaveViewModel.getInstance()); formSaveViewModel.resumeFormEntry(); break; diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java index 6d48df06655..ba83b0430cf 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java +++ b/collect_app/src/main/java/org/odk/collect/android/activities/InstanceChooserList.java @@ -38,28 +38,25 @@ import org.odk.collect.android.R; import org.odk.collect.android.adapters.InstanceListCursorAdapter; -import org.odk.collect.android.analytics.AnalyticsEvents; -import org.odk.collect.android.analytics.AnalyticsUtils; import org.odk.collect.android.dao.CursorLoaderFactory; +import org.odk.collect.android.database.DatabaseConnection; import org.odk.collect.android.database.instances.DatabaseInstanceColumns; import org.odk.collect.android.entities.EntitiesRepositoryProvider; import org.odk.collect.android.external.FormUriActivity; import org.odk.collect.android.external.InstancesContract; import org.odk.collect.android.formlists.sorting.FormListSortingOption; -import org.odk.collect.android.formmanagement.InstancesDataService; import org.odk.collect.android.formmanagement.drafts.BulkFinalizationViewModel; import org.odk.collect.android.formmanagement.drafts.DraftsMenuProvider; import org.odk.collect.android.injection.DaggerUtils; import org.odk.collect.android.instancemanagement.FinalizeAllSnackbarPresenter; +import org.odk.collect.android.instancemanagement.InstancesDataService; import org.odk.collect.android.projects.ProjectsDataService; import org.odk.collect.android.utilities.ApplicationConstants; import org.odk.collect.android.utilities.FormsRepositoryProvider; import org.odk.collect.android.utilities.InstancesRepositoryProvider; -import org.odk.collect.android.views.EmptyListView; import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickGuard; import org.odk.collect.async.Scheduler; -import org.odk.collect.forms.Form; -import org.odk.collect.forms.instances.Instance; +import org.odk.collect.lists.EmptyListView; import org.odk.collect.material.MaterialProgressDialogFragment; import org.odk.collect.settings.SettingsProvider; import org.odk.collect.settings.keys.MetaKeys; @@ -74,7 +71,10 @@ * * @author Yaw Anokwa (yanokwa@gmail.com) * @author Carl Hartung (carlhartung@gmail.com) + * @deprecated Uses {@link CursorLoaderFactory} and interacts with {@link DatabaseConnection} on the + * UI thread. */ +@Deprecated public class InstanceChooserList extends AppListActivity implements AdapterView.OnItemClickListener, LoaderManager.LoaderCallbacks { private static final String INSTANCE_LIST_ACTIVITY_SORTING_ORDER = "instanceListActivitySortingOrder"; private static final String VIEW_SENT_FORM_SORTING_ORDER = "ViewSentFormSortingOrder"; @@ -157,6 +157,7 @@ public void onCreate(Bundle savedInstanceState) { init(); BulkFinalizationViewModel bulkFinalizationViewModel = new BulkFinalizationViewModel( + projectsDataService.getCurrentProject().getUuid(), scheduler, instancesDataService, settingsProvider @@ -212,7 +213,6 @@ public void onItemClick(AdapterView parent, View view, int position, long id) intent.setData(instanceUri); String formMode = parentIntent.getStringExtra(ApplicationConstants.BundleKeys.FORM_MODE); if (formMode == null || ApplicationConstants.FormModes.EDIT_SAVED.equalsIgnoreCase(formMode)) { - logFormEdit(c); intent.putExtra(ApplicationConstants.BundleKeys.FORM_MODE, ApplicationConstants.FormModes.EDIT_SAVED); formLauncher.launch(intent); } else { @@ -228,21 +228,6 @@ public void onItemClick(AdapterView parent, View view, int position, long id) } } - private void logFormEdit(Cursor cursor) { - String status = cursor.getString(cursor.getColumnIndex(DatabaseInstanceColumns.STATUS)); - String formId = cursor.getString(cursor.getColumnIndex(DatabaseInstanceColumns.JR_FORM_ID)); - String version = cursor.getString(cursor.getColumnIndex(DatabaseInstanceColumns.JR_VERSION)); - - Form form = formsRepositoryProvider.get().getLatestByFormIdAndVersion(formId, version); - String formTitle = form != null ? form.getDisplayName() : ""; - - if (status.equals(Instance.STATUS_INCOMPLETE) || status.equals(Instance.STATUS_INVALID) || status.equals(Instance.STATUS_VALID)) { - AnalyticsUtils.logFormEvent(AnalyticsEvents.EDIT_NON_FINALIZED_FORM, formId, formTitle); - } else if (status.equals(Instance.STATUS_COMPLETE)) { - AnalyticsUtils.logFormEvent(AnalyticsEvents.EDIT_FINALIZED_FORM, formId, formTitle); - } - } - private void setupAdapter() { String[] data = {DatabaseInstanceColumns.DISPLAY_NAME, DatabaseInstanceColumns.DELETED_DATE}; int[] view = {R.id.form_title, R.id.form_subtitle2}; diff --git a/collect_app/src/main/java/org/odk/collect/android/adapters/DeleteFormsTabsAdapter.java b/collect_app/src/main/java/org/odk/collect/android/adapters/DeleteFormsTabsAdapter.java deleted file mode 100644 index 8bee22b73c2..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/adapters/DeleteFormsTabsAdapter.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.odk.collect.android.adapters; - -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; -import androidx.fragment.app.FragmentFactory; -import androidx.viewpager2.adapter.FragmentStateAdapter; - -import org.odk.collect.android.formlists.blankformlist.DeleteBlankFormFragment; -import org.odk.collect.android.fragments.SavedFormListFragment; - -public class DeleteFormsTabsAdapter extends FragmentStateAdapter { - - private FragmentFactory fragmentFactory; - private final boolean matchExactlyEnabled; - - public DeleteFormsTabsAdapter(FragmentActivity activity, boolean matchExactlyEnabled) { - super(activity); - this.fragmentFactory = activity.getSupportFragmentManager().getFragmentFactory(); - this.matchExactlyEnabled = matchExactlyEnabled; - } - - @NonNull - @Override - public Fragment createFragment(int position) { - switch (position) { - case 0: - return new SavedFormListFragment(); - case 1: - String className = DeleteBlankFormFragment.class.getName(); - return fragmentFactory.instantiate(Thread.currentThread().getContextClassLoader(), className); - default: - // should never reach here - throw new IllegalArgumentException("Fragment position out of bounds"); - } - } - - @Override - public int getItemCount() { - if (matchExactlyEnabled) { - return 1; - } else { - return 2; - } - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceListCursorAdapter.java b/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceListCursorAdapter.java index 023f29f035c..13617ef815d 100644 --- a/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceListCursorAdapter.java +++ b/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceListCursorAdapter.java @@ -22,7 +22,6 @@ import android.view.ViewGroup; import android.widget.SimpleCursorAdapter; -import org.odk.collect.android.R; import org.odk.collect.android.database.DatabaseObjectMapper; import org.odk.collect.android.instancemanagement.InstanceListItemView; import org.odk.collect.android.storage.StoragePathProvider; @@ -48,20 +47,4 @@ public View getView(int position, View convertView, ViewGroup parent) { InstanceListItemView.setInstance(view, instance, shouldCheckDisabled); return view; } - - public static int getFormStateImageResourceIdForStatus(String formStatus) { - switch (formStatus) { - case Instance.STATUS_INCOMPLETE: - case Instance.STATUS_INVALID: - return R.drawable.ic_form_state_saved; - case Instance.STATUS_COMPLETE: - return R.drawable.ic_form_state_finalized; - case Instance.STATUS_SUBMITTED: - return R.drawable.ic_form_state_submitted; - case Instance.STATUS_SUBMISSION_FAILED: - return R.drawable.ic_form_state_submission_failed; - } - - throw new IllegalArgumentException(); - } } diff --git a/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceUploaderAdapter.java b/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceUploaderAdapter.java index 3afec448dae..55ddd3c03a5 100644 --- a/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceUploaderAdapter.java +++ b/collect_app/src/main/java/org/odk/collect/android/adapters/InstanceUploaderAdapter.java @@ -1,25 +1,17 @@ package org.odk.collect.android.adapters; -import static org.odk.collect.forms.instances.Instance.STATUS_SUBMISSION_FAILED; -import static org.odk.collect.forms.instances.Instance.STATUS_SUBMITTED; - import android.content.Context; import android.database.Cursor; -import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.CheckBox; import android.widget.CursorAdapter; -import android.widget.FrameLayout; -import android.widget.ImageView; -import android.widget.TextView; -import org.odk.collect.android.R; -import org.odk.collect.android.application.Collect; -import org.odk.collect.android.database.instances.DatabaseInstanceColumns; -import org.odk.collect.android.instancemanagement.InstanceExtKt; +import org.odk.collect.android.database.DatabaseObjectMapper; +import org.odk.collect.android.formlists.savedformlist.SelectableSavedFormListItemViewHolder; +import org.odk.collect.android.storage.StoragePathProvider; +import org.odk.collect.android.storage.StorageSubdirectory; +import org.odk.collect.forms.instances.Instance; -import java.util.Date; import java.util.HashSet; import java.util.Set; import java.util.function.Consumer; @@ -31,64 +23,31 @@ public class InstanceUploaderAdapter extends CursorAdapter { public InstanceUploaderAdapter(Context context, Cursor cursor, Consumer onItemCheckboxClickListener) { super(context, cursor); this.onItemCheckboxClickListener = onItemCheckboxClickListener; - Collect.getInstance().getComponent().inject(this); } @Override public View newView(Context context, Cursor cursor, ViewGroup parent) { - View view = LayoutInflater.from(context).inflate(R.layout.form_chooser_list_item_multiple_choice, parent, false); - view.setTag(new ViewHolder(view)); - return view; + SelectableSavedFormListItemViewHolder viewHolder = new SelectableSavedFormListItemViewHolder(parent); + viewHolder.itemView.setTag(viewHolder); + return viewHolder.itemView; } @Override public void bindView(View view, Context context, Cursor cursor) { - ViewHolder viewHolder = (ViewHolder) view.getTag(); - - long lastStatusChangeDate = getCursor().getLong(getCursor().getColumnIndex(DatabaseInstanceColumns.LAST_STATUS_CHANGE_DATE)); - String status = cursor.getString(cursor.getColumnIndex(DatabaseInstanceColumns.STATUS)); - - viewHolder.formTitle.setText(cursor.getString(cursor.getColumnIndex(DatabaseInstanceColumns.DISPLAY_NAME))); - viewHolder.formSubtitle.setText(InstanceExtKt.getStatusDescription(context, status, new Date(lastStatusChangeDate))); - - switch (status) { - case STATUS_SUBMISSION_FAILED: - viewHolder.statusIcon.setImageResource(R.drawable.ic_form_state_submission_failed); - break; - - case STATUS_SUBMITTED: - viewHolder.statusIcon.setImageResource(R.drawable.ic_form_state_submitted); - break; - - default: - viewHolder.statusIcon.setImageResource(R.drawable.ic_form_state_finalized); - } - - long dbId = cursor.getLong(cursor.getColumnIndex(DatabaseInstanceColumns._ID)); - viewHolder.checkbox.setChecked(selected.contains(dbId)); - viewHolder.selectView.setOnClickListener(v -> onItemCheckboxClickListener.accept(dbId)); + SelectableSavedFormListItemViewHolder viewHolder = (SelectableSavedFormListItemViewHolder) view.getTag(); + Instance instance = DatabaseObjectMapper.getInstanceFromCurrentCursorPosition(cursor, new StoragePathProvider().getOdkDirPath(StorageSubdirectory.INSTANCES)); + viewHolder.setItem(instance); + + long dbId = instance.getDbId(); + viewHolder.getCheckbox().setChecked(selected.contains(dbId)); + viewHolder.setOnDetailsClickListener(() -> { + onItemCheckboxClickListener.accept(dbId); + return null; + }); } public void setSelected(Set ids) { this.selected = ids; notifyDataSetChanged(); } - - static class ViewHolder { - TextView formTitle; - TextView formSubtitle; - CheckBox checkbox; - ImageView statusIcon; - ImageView closeButton; - FrameLayout selectView; - - ViewHolder(View view) { - formTitle = view.findViewById(R.id.form_title); - formSubtitle = view.findViewById(R.id.form_subtitle); - checkbox = view.findViewById(R.id.checkbox); - statusIcon = view.findViewById(R.id.image); - closeButton = view.findViewById(R.id.close_box); - selectView = view.findViewById(R.id.selectView); - } - } } diff --git a/collect_app/src/main/java/org/odk/collect/android/adapters/RankingListAdapter.java b/collect_app/src/main/java/org/odk/collect/android/adapters/RankingListAdapter.java index b8ee10ab002..6ed6685844c 100644 --- a/collect_app/src/main/java/org/odk/collect/android/adapters/RankingListAdapter.java +++ b/collect_app/src/main/java/org/odk/collect/android/adapters/RankingListAdapter.java @@ -16,6 +16,7 @@ package org.odk.collect.android.adapters; +import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.GradientDrawable; import android.view.LayoutInflater; import android.view.View; @@ -77,23 +78,25 @@ public static class ItemViewHolder extends ViewHolder { final TextView textView; final ThemeUtils themeUtils; + private final ColorDrawable background; ItemViewHolder(View itemView) { super(itemView); textView = itemView.findViewById(R.id.rank_item_text); textView.setTextSize(QuestionFontSizeUtils.getQuestionFontSize()); themeUtils = new ThemeUtils(itemView.getContext()); + background = (ColorDrawable) itemView.getBackground(); } public void onItemSelected() { GradientDrawable border = new GradientDrawable(); border.setStroke(10, themeUtils.getAccentColor()); - border.setColor(textView.getContext().getResources().getColor(R.color.colorSurfaceContainerLow)); + border.setColor(background.getColor()); itemView.setBackground(border); } public void onItemClear() { - itemView.setBackgroundColor(textView.getContext().getResources().getColor(R.color.colorSurfaceContainerLow)); + itemView.setBackground(background); } } } diff --git a/collect_app/src/main/java/org/odk/collect/android/analytics/AnalyticsEvents.kt b/collect_app/src/main/java/org/odk/collect/android/analytics/AnalyticsEvents.kt index 0b87da966b6..b9a5030ad37 100644 --- a/collect_app/src/main/java/org/odk/collect/android/analytics/AnalyticsEvents.kt +++ b/collect_app/src/main/java/org/odk/collect/android/analytics/AnalyticsEvents.kt @@ -7,19 +7,6 @@ object AnalyticsEvents { */ const val SET_SERVER = "SetServer" - /** - * Track video requests with high resolution setting turned off. The action should be a hash of - * the form definition. - */ - const val REQUEST_VIDEO_NOT_HIGH_RES = "RequestVideoNotHighRes" - - /** - * Track video requests with high resolution setting turned on. This is tracked to contextualize - * the counts with the high resolution setting turned off since we expect that video is not very - * common overall. The action should be a hash of the form definition. - */ - const val REQUEST_HIGH_RES_VIDEO = "RequestHighResVideo" - /** * Track submission encryption. The action should be a hash of the form definition. */ @@ -31,28 +18,6 @@ object AnalyticsEvents { */ const val SUBMISSION = "Submission" - /** - * Tracks if any forms are being used as part of a workflow where instances are imported - * from disk - */ - const val IMPORT_INSTANCE = "ImportInstance" - - /** - * Tracks if any forms are being used as part of a workflow where instances are imported - * from disk and then encrypted - */ - const val IMPORT_AND_ENCRYPT_INSTANCE = "ImportAndEncryptInstance" - - /** - * Tracks responses from OpenMapKit to the OSMWidget - */ - const val OPEN_MAP_KIT_RESPONSE = "OpenMapKitResponse" - - /** - * Tracks how often users create shortcuts to forms - */ - const val CREATE_SHORTCUT = "CreateShortcut" - /** * Tracks how often instances that have been deleted on disk are opened for editing/viewing */ @@ -120,35 +85,8 @@ object AnalyticsEvents { const val INSTANCE_PROVIDER_INSERT = "InstanceProviderInsert" - const val INSTANCE_PROVIDER_UPDATE = "InstanceProviderUpdate" - const val INSTANCE_PROVIDER_DELETE = "InstanceProviderDelete" - /** - * Tracks how often non finalized forms are edited - */ - const val EDIT_NON_FINALIZED_FORM = "EditNonFinalizedForm" - - /** - * Tracks how often finalized forms are edited - */ - const val EDIT_FINALIZED_FORM = "EditFinalizedForm" - - /** - * Tracks how often form-level auto-delete setting is used - */ - const val FORM_LEVEL_AUTO_DELETE = "FormLevelAutoDelete" - - /** - * Tracks how often form-level auto-send setting is used - */ - const val FORM_LEVEL_AUTO_SEND = "FormLevelAutoSend" - - /** - * Tracks how often a form is finalized using a `ref` attribute on the `submission` element - */ - const val PARTIAL_FORM_FINALIZED = "PartialFormFinalized" - /** * Tracks how often drafts that can't be bulk finalized are attempted to be */ @@ -156,7 +94,14 @@ object AnalyticsEvents { const val BULK_FINALIZE_SAVE_POINT = "BulkFinalizeSavePoint" /** - * Tracks how often printing with the old ExPrinterWidget is triggered + * Tracks how often saved forms are manually deleted and in what number + */ + const val DELETE_SAVED_FORM_FEW = "DeleteSavedFormFew" // < 10 + const val DELETE_SAVED_FORM_TENS = "DeleteSavedFormTens" // >= 10 + const val DELETE_SAVED_FORM_HUNDREDS = "DeleteSavedFormHundreds" // >= 100 + + /** + * Tracks how often the INSTANCE_UPLOAD action is used with a custom server URL */ - const val ZEBRA_PRINTER_STARTED = "ZebraPrinterStarted" + const val INSTANCE_UPLOAD_CUSTOM_SERVER = "InstanceUploadCustomServer" } diff --git a/collect_app/src/main/java/org/odk/collect/android/application/Collect.java b/collect_app/src/main/java/org/odk/collect/android/application/Collect.java index 3d0d11a2fbb..66a6bb22b99 100644 --- a/collect_app/src/main/java/org/odk/collect/android/application/Collect.java +++ b/collect_app/src/main/java/org/odk/collect/android/application/Collect.java @@ -41,8 +41,9 @@ import org.odk.collect.android.utilities.LocaleHelper; import org.odk.collect.androidshared.data.AppState; import org.odk.collect.androidshared.data.StateStore; -import org.odk.collect.androidshared.network.NetworkStateProvider; +import org.odk.collect.async.network.NetworkStateProvider; import org.odk.collect.androidshared.system.ExternalFilesUtils; +import org.odk.collect.async.Scheduler; import org.odk.collect.audiorecorder.AudioRecorderDependencyComponent; import org.odk.collect.audiorecorder.AudioRecorderDependencyComponentProvider; import org.odk.collect.audiorecorder.DaggerAudioRecorderDependencyComponent; @@ -196,11 +197,11 @@ private void setupDagger() { .build(); projectsDependencyComponent = DaggerProjectsDependencyComponent.builder() - .projectsDependencyModule(new CollectProjectsDependencyModule(applicationComponent.projectsRepository())) + .projectsDependencyModule(new CollectProjectsDependencyModule(applicationComponent)) .build(); selfieCameraDependencyComponent = DaggerSelfieCameraDependencyComponent.builder() - .selfieCameraDependencyModule(new CollectSelfieCameraDependencyModule(applicationComponent::permissionsChecker)) + .selfieCameraDependencyModule(new CollectSelfieCameraDependencyModule(applicationComponent)) .build(); drawDependencyComponent = DaggerDrawDependencyComponent.builder() @@ -299,12 +300,7 @@ public GeoDependencyComponent getGeoDependencyComponent() { if (geoDependencyComponent == null) { geoDependencyComponent = DaggerGeoDependencyComponent.builder() .application(this) - .geoDependencyModule(new CollectGeoDependencyModule( - applicationComponent.mapFragmentFactory(), - applicationComponent.locationClient(), - applicationComponent.scheduler(), - applicationComponent.permissionsChecker() - )) + .geoDependencyModule(new CollectGeoDependencyModule(applicationComponent)) .build(); } @@ -316,11 +312,7 @@ public GeoDependencyComponent getGeoDependencyComponent() { public OsmDroidDependencyComponent getOsmDroidDependencyComponent() { if (osmDroidDependencyComponent == null) { osmDroidDependencyComponent = DaggerOsmDroidDependencyComponent.builder() - .osmDroidDependencyModule(new CollectOsmDroidDependencyModule( - applicationComponent.referenceLayerRepository(), - applicationComponent.locationClient(), - applicationComponent.settingsProvider() - )) + .osmDroidDependencyModule(new CollectOsmDroidDependencyModule(applicationComponent)) .build(); } @@ -345,6 +337,12 @@ public EntitiesRepository providesEntitiesRepository() { String projectId = applicationComponent.currentProjectProvider().getCurrentProject().getUuid(); return applicationComponent.entitiesRepositoryProvider().get(projectId); } + + @NonNull + @Override + public Scheduler providesScheduler() { + return applicationComponent.scheduler(); + } }) .build(); } @@ -363,11 +361,7 @@ public SelfieCameraDependencyComponent getSelfieCameraDependencyComponent() { public GoogleMapsDependencyComponent getGoogleMapsDependencyComponent() { if (googleMapsDependencyComponent == null) { googleMapsDependencyComponent = DaggerGoogleMapsDependencyComponent.builder() - .googleMapsDependencyModule(new CollectGoogleMapsDependencyModule( - applicationComponent.referenceLayerRepository(), - applicationComponent.locationClient(), - applicationComponent.settingsProvider() - )) + .googleMapsDependencyModule(new CollectGoogleMapsDependencyModule(applicationComponent)) .build(); } diff --git a/collect_app/src/main/java/org/odk/collect/android/application/initialization/ApplicationInitializer.kt b/collect_app/src/main/java/org/odk/collect/android/application/initialization/ApplicationInitializer.kt index bfb736ba9c0..2409c62f89d 100644 --- a/collect_app/src/main/java/org/odk/collect/android/application/initialization/ApplicationInitializer.kt +++ b/collect_app/src/main/java/org/odk/collect/android/application/initialization/ApplicationInitializer.kt @@ -8,6 +8,7 @@ import org.odk.collect.analytics.Analytics import org.odk.collect.android.BuildConfig import org.odk.collect.android.application.Collect import org.odk.collect.android.application.initialization.upgrade.UpgradeInitializer +import org.odk.collect.android.entities.EntitiesRepositoryProvider import org.odk.collect.metadata.PropertyManager import org.odk.collect.projects.ProjectsRepository import org.odk.collect.settings.SettingsProvider @@ -22,7 +23,8 @@ class ApplicationInitializer( private val analyticsInitializer: AnalyticsInitializer, private val mapsInitializer: MapsInitializer, private val projectsRepository: ProjectsRepository, - private val settingsProvider: SettingsProvider + private val settingsProvider: SettingsProvider, + private val entitiesRepositoryProvider: EntitiesRepositoryProvider ) { fun initialize() { initializeLocale() @@ -40,7 +42,7 @@ class ApplicationInitializer( context ).initialize() mapsInitializer.initialize() - JavaRosaInitializer(propertyManager).initialize() + JavaRosaInitializer(propertyManager, entitiesRepositoryProvider, settingsProvider).initialize() SystemThemeMismatchFixInitializer(context).initialize() } diff --git a/collect_app/src/main/java/org/odk/collect/android/application/initialization/JavaRosaInitializer.kt b/collect_app/src/main/java/org/odk/collect/android/application/initialization/JavaRosaInitializer.kt index 3df36673f01..a140ab0c781 100644 --- a/collect_app/src/main/java/org/odk/collect/android/application/initialization/JavaRosaInitializer.kt +++ b/collect_app/src/main/java/org/odk/collect/android/application/initialization/JavaRosaInitializer.kt @@ -9,10 +9,18 @@ import org.javarosa.xform.parse.XFormParser import org.javarosa.xform.parse.XFormParserFactory import org.javarosa.xform.util.XFormUtils import org.odk.collect.android.dynamicpreload.DynamicPreloadXFormParserFactory +import org.odk.collect.android.entities.EntitiesRepositoryProvider import org.odk.collect.android.logic.actions.setgeopoint.CollectSetGeopointActionHandler +import org.odk.collect.entities.LocalEntitiesExternalInstanceParserFactory import org.odk.collect.metadata.PropertyManager +import org.odk.collect.settings.SettingsProvider +import org.odk.collect.settings.keys.ProjectKeys -class JavaRosaInitializer(private val propertyManager: PropertyManager) { +class JavaRosaInitializer( + private val propertyManager: PropertyManager, + private val entitiesRepositoryProvider: EntitiesRepositoryProvider, + private val settingsProvider: SettingsProvider +) { fun initialize() { propertyManager.reload() @@ -37,5 +45,12 @@ class JavaRosaInitializer(private val propertyManager: PropertyManager) { DynamicPreloadXFormParserFactory(entityXFormParserFactory) XFormUtils.setXFormParserFactory(dynamicPreloadXFormParserFactory) + + val localEntitiesExternalInstanceParserFactory = LocalEntitiesExternalInstanceParserFactory( + entitiesRepositoryProvider::get, + { settingsProvider.getUnprotectedSettings().getBoolean(ProjectKeys.KEY_LOCAL_ENTITIES) } + ) + + XFormUtils.setExternalInstanceParserFactory(localEntitiesExternalInstanceParserFactory) } } diff --git a/collect_app/src/main/java/org/odk/collect/android/application/initialization/SavepointsImporter.kt b/collect_app/src/main/java/org/odk/collect/android/application/initialization/SavepointsImporter.kt new file mode 100644 index 00000000000..601e3504088 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/application/initialization/SavepointsImporter.kt @@ -0,0 +1,68 @@ +package org.odk.collect.android.application.initialization + +import org.odk.collect.android.projects.ProjectDependencyProviderFactory +import org.odk.collect.android.storage.StorageSubdirectory +import org.odk.collect.forms.FormsRepository +import org.odk.collect.forms.instances.InstancesRepository +import org.odk.collect.forms.savepoints.Savepoint +import org.odk.collect.forms.savepoints.SavepointsRepository +import org.odk.collect.projects.ProjectsRepository +import org.odk.collect.settings.keys.MetaKeys +import org.odk.collect.upgrade.Upgrade +import java.io.File + +class SavepointsImporter( + private val projectsRepository: ProjectsRepository, + private val projectDependencyProviderFactory: ProjectDependencyProviderFactory +) : Upgrade { + override fun key(): String { + return MetaKeys.OLD_SAVEPOINTS_IMPORTED + } + + override fun run() { + projectsRepository.getAll().forEach { project -> + val projectDependencyProvider = projectDependencyProviderFactory.create(project.uuid) + + val cacheDir = + File(projectDependencyProvider.storagePathProvider.getOdkDirPath(StorageSubdirectory.CACHE, project.uuid)) + val instancesDir = + File(projectDependencyProvider.storagePathProvider.getOdkDirPath(StorageSubdirectory.INSTANCES, project.uuid)) + + importSavepointsThatBelongToSavedForms(projectDependencyProvider.instancesRepository, projectDependencyProvider.formsRepository, projectDependencyProvider.savepointsRepository, cacheDir) + + importSavepointsThatBelongToBlankForms(projectDependencyProvider.formsRepository, projectDependencyProvider.savepointsRepository, cacheDir, instancesDir) + } + } + + private fun importSavepointsThatBelongToSavedForms(instancesRepository: InstancesRepository, formsRepository: FormsRepository, savepointsRepository: SavepointsRepository, cacheDir: File) { + instancesRepository.all.forEach { instance -> + if (instance.deletedDate == null) { + val savepointFile = File(cacheDir, File(instance.instanceFilePath).name.plus(".save")) + if (savepointFile.exists() && savepointFile.lastModified() > instance.lastStatusChangeDate) { + val form = formsRepository.getAllByFormIdAndVersion(instance.formId, instance.formVersion).firstOrNull() + if (form != null && !form.isDeleted) { + savepointsRepository.save(Savepoint(form.dbId, instance.dbId, savepointFile.absolutePath, instance.instanceFilePath)) + } + } + } + } + } + + private fun importSavepointsThatBelongToBlankForms(formsRepository: FormsRepository, savepointsRepository: SavepointsRepository, cacheDir: File, instancesDir: File) { + formsRepository.all.forEach { form -> + if (!form.isDeleted) { + val formFileName = File(form.formFilePath).name.substringBeforeLast(".xml") + + cacheDir.listFiles { file -> + val match = """${formFileName}_(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})(.xml.save)""".toRegex().matchEntire(file.name) + match != null + }?.forEach { savepointFile -> + if (savepointFile.lastModified() > form.date) { + val instanceFileName = savepointFile.name.substringBefore(".xml.save") + savepointsRepository.save(Savepoint(form.dbId, null, savepointFile.absolutePath, File(instancesDir, "$instanceFileName/$instanceFileName.xml").absolutePath)) + } + } + } + } + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/application/initialization/FormUpdatesUpgrade.kt b/collect_app/src/main/java/org/odk/collect/android/application/initialization/ScheduledWorkUpgrade.kt similarity index 57% rename from collect_app/src/main/java/org/odk/collect/android/application/initialization/FormUpdatesUpgrade.kt rename to collect_app/src/main/java/org/odk/collect/android/application/initialization/ScheduledWorkUpgrade.kt index f3067e6e869..bb2b89d513d 100644 --- a/collect_app/src/main/java/org/odk/collect/android/application/initialization/FormUpdatesUpgrade.kt +++ b/collect_app/src/main/java/org/odk/collect/android/application/initialization/ScheduledWorkUpgrade.kt @@ -1,14 +1,19 @@ package org.odk.collect.android.application.initialization import org.odk.collect.android.backgroundwork.FormUpdateScheduler +import org.odk.collect.android.backgroundwork.InstanceSubmitScheduler import org.odk.collect.async.Scheduler import org.odk.collect.projects.ProjectsRepository import org.odk.collect.upgrade.Upgrade -class FormUpdatesUpgrade( +/** + * Reschedule all background work to prevent problems with tag or class name changes etc + */ +class ScheduledWorkUpgrade( private val scheduler: Scheduler, private val projectsRepository: ProjectsRepository, - private val formUpdateScheduler: FormUpdateScheduler + private val formUpdateScheduler: FormUpdateScheduler, + private val instanceSubmitScheduler: InstanceSubmitScheduler ) : Upgrade { override fun key(): String? { @@ -20,6 +25,8 @@ class FormUpdatesUpgrade( projectsRepository.getAll().forEach { formUpdateScheduler.scheduleUpdates(it.uuid) + instanceSubmitScheduler.scheduleAutoSend(it.uuid) + instanceSubmitScheduler.scheduleFormAutoSend(it.uuid) } } } diff --git a/collect_app/src/main/java/org/odk/collect/android/application/initialization/UserPropertiesInitializer.kt b/collect_app/src/main/java/org/odk/collect/android/application/initialization/UserPropertiesInitializer.kt index 4a81f26c169..8be7aa1bb7f 100644 --- a/collect_app/src/main/java/org/odk/collect/android/application/initialization/UserPropertiesInitializer.kt +++ b/collect_app/src/main/java/org/odk/collect/android/application/initialization/UserPropertiesInitializer.kt @@ -3,10 +3,10 @@ package org.odk.collect.android.application.initialization import android.content.Context import org.odk.collect.analytics.Analytics import org.odk.collect.android.preferences.Defaults -import org.odk.collect.android.preferences.utilities.FormUpdateMode import org.odk.collect.projects.Project import org.odk.collect.projects.ProjectsRepository import org.odk.collect.settings.SettingsProvider +import org.odk.collect.settings.enums.FormUpdateMode import org.odk.collect.settings.keys.ProjectKeys class UserPropertiesInitializer( diff --git a/collect_app/src/main/java/org/odk/collect/android/application/initialization/upgrade/UpgradeInitializer.kt b/collect_app/src/main/java/org/odk/collect/android/application/initialization/upgrade/UpgradeInitializer.kt index 3a632408a83..d7e24270755 100644 --- a/collect_app/src/main/java/org/odk/collect/android/application/initialization/upgrade/UpgradeInitializer.kt +++ b/collect_app/src/main/java/org/odk/collect/android/application/initialization/upgrade/UpgradeInitializer.kt @@ -4,8 +4,9 @@ import android.content.Context import org.odk.collect.android.BuildConfig import org.odk.collect.android.application.initialization.ExistingProjectMigrator import org.odk.collect.android.application.initialization.ExistingSettingsMigrator -import org.odk.collect.android.application.initialization.FormUpdatesUpgrade import org.odk.collect.android.application.initialization.GoogleDriveProjectsDeleter +import org.odk.collect.android.application.initialization.SavepointsImporter +import org.odk.collect.android.application.initialization.ScheduledWorkUpgrade import org.odk.collect.settings.SettingsProvider import org.odk.collect.settings.keys.MetaKeys import org.odk.collect.upgrade.AppUpgrader @@ -15,8 +16,9 @@ class UpgradeInitializer( private val settingsProvider: SettingsProvider, private val existingProjectMigrator: ExistingProjectMigrator, private val existingSettingsMigrator: ExistingSettingsMigrator, - private val formUpdatesUpgrade: FormUpdatesUpgrade, - private val googleDriveProjectsDeleter: GoogleDriveProjectsDeleter + private val scheduledWorkUpgrade: ScheduledWorkUpgrade, + private val googleDriveProjectsDeleter: GoogleDriveProjectsDeleter, + private val savepointsImporter: SavepointsImporter ) { fun initialize() { @@ -28,8 +30,9 @@ class UpgradeInitializer( listOf( existingProjectMigrator, existingSettingsMigrator, - formUpdatesUpgrade, - googleDriveProjectsDeleter + scheduledWorkUpgrade, + googleDriveProjectsDeleter, + savepointsImporter ) ).upgradeIfNeeded() } diff --git a/collect_app/src/main/java/org/odk/collect/android/audio/AudioButton.java b/collect_app/src/main/java/org/odk/collect/android/audio/AudioButton.java index 5d447aab2c6..0f2db0a232a 100644 --- a/collect_app/src/main/java/org/odk/collect/android/audio/AudioButton.java +++ b/collect_app/src/main/java/org/odk/collect/android/audio/AudioButton.java @@ -15,25 +15,21 @@ package org.odk.collect.android.audio; import android.content.Context; -import android.content.res.ColorStateList; import android.util.AttributeSet; import android.view.View; -import com.google.android.material.button.MaterialButton; - import org.odk.collect.android.R; +import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickSafeMaterialButton; /** * @author ctsims * @author carlhartung */ -public class AudioButton extends MaterialButton implements View.OnClickListener { +public class AudioButton extends MultiClickSafeMaterialButton implements View.OnClickListener { private Listener listener; private Boolean playing = false; - private Integer playingColor; - private Integer idleColor; public AudioButton(Context context) { super(context, null); @@ -49,12 +45,6 @@ public Boolean isPlaying() { return playing; } - public void setColors(Integer idleColor, Integer playingColor) { - this.idleColor = idleColor; - this.playingColor = playingColor; - render(); - } - public void setPlaying(Boolean isPlaying) { playing = isPlaying; render(); @@ -81,16 +71,8 @@ private void initView() { private void render() { if (playing) { setIconResource(R.drawable.ic_stop_black_24dp); - - if (playingColor != null) { - setIconTint(ColorStateList.valueOf(playingColor)); - } } else { setIconResource(R.drawable.ic_volume_up_black_24dp); - - if (idleColor != null) { - setIconTint(ColorStateList.valueOf(idleColor)); - } } } diff --git a/collect_app/src/main/java/org/odk/collect/android/audio/AudioControllerView.java b/collect_app/src/main/java/org/odk/collect/android/audio/AudioControllerView.java index 2ffd53829b1..6c666ee11e1 100644 --- a/collect_app/src/main/java/org/odk/collect/android/audio/AudioControllerView.java +++ b/collect_app/src/main/java/org/odk/collect/android/audio/AudioControllerView.java @@ -24,10 +24,9 @@ import androidx.core.content.ContextCompat; -import com.google.android.material.button.MaterialButton; - import org.odk.collect.android.R; import org.odk.collect.android.databinding.AudioControllerLayoutBinding; +import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickSafeMaterialButton; import static java.lang.Math.max; import static java.lang.Math.min; @@ -39,7 +38,7 @@ public class AudioControllerView extends FrameLayout { private final TextView currentDurationLabel; private final TextView totalDurationLabel; - private final MaterialButton playButton; + private final MultiClickSafeMaterialButton playButton; private final SeekBar seekBar; private final SwipeListener swipeListener; diff --git a/collect_app/src/main/java/org/odk/collect/android/backgroundwork/AutoUpdateTaskSpec.kt b/collect_app/src/main/java/org/odk/collect/android/backgroundwork/AutoUpdateTaskSpec.kt index 4588d142eb7..067baeedc4c 100644 --- a/collect_app/src/main/java/org/odk/collect/android/backgroundwork/AutoUpdateTaskSpec.kt +++ b/collect_app/src/main/java/org/odk/collect/android/backgroundwork/AutoUpdateTaskSpec.kt @@ -17,11 +17,9 @@ package org.odk.collect.android.backgroundwork import android.content.Context import androidx.work.BackoffPolicy -import androidx.work.WorkerParameters import org.odk.collect.android.formmanagement.FormsDataService import org.odk.collect.android.injection.DaggerUtils import org.odk.collect.async.TaskSpec -import org.odk.collect.async.WorkerAdapter import java.util.function.Supplier import javax.inject.Inject @@ -45,11 +43,4 @@ class AutoUpdateTaskSpec : TaskSpec { } } } - - override fun getWorkManagerAdapter(): Class { - return Adapter::class.java - } - - class Adapter(context: Context, workerParams: WorkerParameters) : - WorkerAdapter(AutoUpdateTaskSpec(), context, workerParams) } diff --git a/collect_app/src/main/java/org/odk/collect/android/backgroundwork/FormUpdateAndInstanceSubmitScheduler.java b/collect_app/src/main/java/org/odk/collect/android/backgroundwork/FormUpdateAndInstanceSubmitScheduler.java index 92598f12850..1ae95cf086c 100644 --- a/collect_app/src/main/java/org/odk/collect/android/backgroundwork/FormUpdateAndInstanceSubmitScheduler.java +++ b/collect_app/src/main/java/org/odk/collect/android/backgroundwork/FormUpdateAndInstanceSubmitScheduler.java @@ -1,7 +1,7 @@ package org.odk.collect.android.backgroundwork; import static org.odk.collect.android.backgroundwork.BackgroundWorkUtils.getPeriodInMilliseconds; -import static org.odk.collect.android.preferences.utilities.SettingsUtils.getFormUpdateMode; +import static org.odk.collect.settings.enums.StringIdEnumUtils.getFormUpdateMode; import static org.odk.collect.settings.keys.ProjectKeys.KEY_PERIODIC_FORM_UPDATES_CHECK; import android.app.Application; @@ -9,6 +9,8 @@ import org.jetbrains.annotations.NotNull; import org.odk.collect.async.Scheduler; import org.odk.collect.settings.SettingsProvider; +import org.odk.collect.settings.enums.AutoSend; +import org.odk.collect.settings.enums.StringIdEnumUtils; import org.odk.collect.shared.settings.Settings; import java.util.HashMap; @@ -31,7 +33,7 @@ public void scheduleUpdates(String projectId) { String period = generalSettings.getString(KEY_PERIODIC_FORM_UPDATES_CHECK); long periodInMilliseconds = getPeriodInMilliseconds(period, application); - switch (getFormUpdateMode(application, generalSettings)) { + switch (getFormUpdateMode(generalSettings, application)) { case MANUAL: scheduler.cancelDeferred(getMatchExactlyTag(projectId)); scheduler.cancelDeferred(getAutoUpdateTag(projectId)); @@ -50,13 +52,13 @@ public void scheduleUpdates(String projectId) { private void scheduleAutoUpdate(long periodInMilliseconds, String projectId) { HashMap inputData = new HashMap<>(); inputData.put(TaskData.DATA_PROJECT_ID, projectId); - scheduler.networkDeferred(getAutoUpdateTag(projectId), new AutoUpdateTaskSpec(), periodInMilliseconds, inputData); + scheduler.networkDeferredRepeat(getAutoUpdateTag(projectId), new AutoUpdateTaskSpec(), periodInMilliseconds, inputData); } private void scheduleMatchExactly(long periodInMilliseconds, String projectId) { HashMap inputData = new HashMap<>(); inputData.put(TaskData.DATA_PROJECT_ID, projectId); - scheduler.networkDeferred(getMatchExactlyTag(projectId), new SyncFormsTaskSpec(), periodInMilliseconds, inputData); + scheduler.networkDeferredRepeat(getMatchExactlyTag(projectId), new SyncFormsTaskSpec(), periodInMilliseconds, inputData); } @Override @@ -66,10 +68,31 @@ public void cancelUpdates(String projectId) { } @Override - public void scheduleSubmit(String projectId) { + public void scheduleAutoSend(String projectId) { + Scheduler.NetworkType networkConstraint; + Settings settings = settingsProvider.getUnprotectedSettings(projectId); + AutoSend autoSendSetting = StringIdEnumUtils.getAutoSend(settings, application); + if (autoSendSetting == AutoSend.WIFI_ONLY) { + networkConstraint = Scheduler.NetworkType.WIFI; + } else if (autoSendSetting == AutoSend.CELLULAR_ONLY) { + networkConstraint = Scheduler.NetworkType.CELLULAR; + } else if (autoSendSetting == AutoSend.WIFI_AND_CELLULAR) { + networkConstraint = null; + } else { + return; + } + HashMap inputData = new HashMap<>(); inputData.put(TaskData.DATA_PROJECT_ID, projectId); - scheduler.networkDeferred(getAutoSendTag(projectId), new AutoSendTaskSpec(), inputData); + scheduler.networkDeferred(getAutoSendTag(projectId), new SendFormsTaskSpec(), inputData, networkConstraint); + } + + @Override + public void scheduleFormAutoSend(String projectId) { + HashMap inputData = new HashMap<>(); + inputData.put(TaskData.DATA_PROJECT_ID, projectId); + inputData.put(TaskData.DATA_FORM_AUTO_SEND, ""); + scheduler.networkDeferred(getAutoSendFormTag(projectId), new SendFormsTaskSpec(), inputData, null); } @Override @@ -82,6 +105,10 @@ public String getAutoSendTag(String projectId) { return "AutoSendWorker:" + projectId; } + public String getAutoSendFormTag(String projectId) { + return "auto_send_form:" + projectId; + } + @NotNull private String getMatchExactlyTag(String projectId) { return "match_exactly:" + projectId; diff --git a/collect_app/src/main/java/org/odk/collect/android/backgroundwork/InstanceSubmitScheduler.java b/collect_app/src/main/java/org/odk/collect/android/backgroundwork/InstanceSubmitScheduler.java index a2a16a6dc12..651750bbe99 100644 --- a/collect_app/src/main/java/org/odk/collect/android/backgroundwork/InstanceSubmitScheduler.java +++ b/collect_app/src/main/java/org/odk/collect/android/backgroundwork/InstanceSubmitScheduler.java @@ -2,7 +2,9 @@ public interface InstanceSubmitScheduler { - void scheduleSubmit(String projectId); + void scheduleAutoSend(String projectId); + + void scheduleFormAutoSend(String projectId); void cancelSubmit(String projectId); } diff --git a/collect_app/src/main/java/org/odk/collect/android/backgroundwork/AutoSendTaskSpec.kt b/collect_app/src/main/java/org/odk/collect/android/backgroundwork/SendFormsTaskSpec.kt similarity index 58% rename from collect_app/src/main/java/org/odk/collect/android/backgroundwork/AutoSendTaskSpec.kt rename to collect_app/src/main/java/org/odk/collect/android/backgroundwork/SendFormsTaskSpec.kt index df7782e55c7..6cedcb5aeb9 100644 --- a/collect_app/src/main/java/org/odk/collect/android/backgroundwork/AutoSendTaskSpec.kt +++ b/collect_app/src/main/java/org/odk/collect/android/backgroundwork/SendFormsTaskSpec.kt @@ -15,42 +15,34 @@ package org.odk.collect.android.backgroundwork import android.content.Context import androidx.work.BackoffPolicy -import androidx.work.WorkerParameters import org.odk.collect.android.injection.DaggerUtils -import org.odk.collect.android.instancemanagement.autosend.InstanceAutoSender -import org.odk.collect.android.projects.ProjectDependencyProviderFactory +import org.odk.collect.android.instancemanagement.InstancesDataService import org.odk.collect.async.TaskSpec -import org.odk.collect.async.WorkerAdapter import java.util.function.Supplier import javax.inject.Inject -class AutoSendTaskSpec : TaskSpec { +class SendFormsTaskSpec : TaskSpec { @Inject - lateinit var instanceAutoSender: InstanceAutoSender + lateinit var instancesDataService: InstancesDataService - @Inject - lateinit var projectDependencyProviderFactory: ProjectDependencyProviderFactory - - override val maxRetries: Int? = null - override val backoffPolicy: BackoffPolicy? = null - override val backoffDelay: Long? = null + override val maxRetries: Int = 13 // Stop trying when backoff is > 5 days + override val backoffPolicy = BackoffPolicy.EXPONENTIAL + override val backoffDelay: Long = 60_000 override fun getTask(context: Context, inputData: Map, isLastUniqueExecution: Boolean): Supplier { DaggerUtils.getComponent(context).inject(this) return Supplier { val projectId = inputData[TaskData.DATA_PROJECT_ID] + val formAutoSend = inputData[TaskData.DATA_FORM_AUTO_SEND] != null if (projectId != null) { - instanceAutoSender.autoSendInstances(projectDependencyProviderFactory.create(projectId)) + if (formAutoSend) { + instancesDataService.sendInstances(projectId, formAutoSend = true) + } else { + instancesDataService.sendInstances(projectId) + } } else { throw IllegalArgumentException("No project ID provided!") } } } - - override fun getWorkManagerAdapter(): Class { - return Adapter::class.java - } - - class Adapter(context: Context, workerParams: WorkerParameters) : - WorkerAdapter(AutoSendTaskSpec(), context, workerParams) } diff --git a/collect_app/src/main/java/org/odk/collect/android/backgroundwork/SyncFormsTaskSpec.kt b/collect_app/src/main/java/org/odk/collect/android/backgroundwork/SyncFormsTaskSpec.kt index 8e39bb62d0b..36d286ac0eb 100644 --- a/collect_app/src/main/java/org/odk/collect/android/backgroundwork/SyncFormsTaskSpec.kt +++ b/collect_app/src/main/java/org/odk/collect/android/backgroundwork/SyncFormsTaskSpec.kt @@ -2,11 +2,9 @@ package org.odk.collect.android.backgroundwork import android.content.Context import androidx.work.BackoffPolicy -import androidx.work.WorkerParameters import org.odk.collect.android.formmanagement.FormsDataService import org.odk.collect.android.injection.DaggerUtils import org.odk.collect.async.TaskSpec -import org.odk.collect.async.WorkerAdapter import java.util.function.Supplier import javax.inject.Inject @@ -29,11 +27,4 @@ class SyncFormsTaskSpec : TaskSpec { } } } - - override fun getWorkManagerAdapter(): Class { - return Adapter::class.java - } - - class Adapter(context: Context, workerParams: WorkerParameters) : - WorkerAdapter(SyncFormsTaskSpec(), context, workerParams) } diff --git a/collect_app/src/main/java/org/odk/collect/android/backgroundwork/TaskData.kt b/collect_app/src/main/java/org/odk/collect/android/backgroundwork/TaskData.kt index cc95ff0a663..3d71dac0d42 100644 --- a/collect_app/src/main/java/org/odk/collect/android/backgroundwork/TaskData.kt +++ b/collect_app/src/main/java/org/odk/collect/android/backgroundwork/TaskData.kt @@ -2,4 +2,5 @@ package org.odk.collect.android.backgroundwork object TaskData { const val DATA_PROJECT_ID = "projectId" + const val DATA_FORM_AUTO_SEND = "formAutoSend" } diff --git a/collect_app/src/main/java/org/odk/collect/android/configure/qr/QRCodeTabsActivity.kt b/collect_app/src/main/java/org/odk/collect/android/configure/qr/QRCodeTabsActivity.kt index 9ae4144ea9b..dbcaf79c589 100644 --- a/collect_app/src/main/java/org/odk/collect/android/configure/qr/QRCodeTabsActivity.kt +++ b/collect_app/src/main/java/org/odk/collect/android/configure/qr/QRCodeTabsActivity.kt @@ -10,6 +10,7 @@ import org.odk.collect.android.injection.DaggerUtils import org.odk.collect.android.projects.ProjectsDataService import org.odk.collect.android.utilities.FileProvider import org.odk.collect.androidshared.system.IntentLauncher +import org.odk.collect.androidshared.ui.ListFragmentStateAdapter import org.odk.collect.androidshared.utils.AppBarUtils.setupAppBarLayout import org.odk.collect.async.Scheduler import org.odk.collect.permissions.PermissionListener @@ -99,8 +100,10 @@ class QRCodeTabsActivity : LocalizedActivity() { val viewPager = findViewById(R.id.viewPager) val tabLayout = findViewById(R.id.tabLayout) - val adapter = QRCodeTabsAdapter(this) - viewPager.adapter = adapter + viewPager.adapter = ListFragmentStateAdapter( + this, + listOf(QRCodeScannerFragment::class.java, ShowQRCodeFragment::class.java) + ) TabLayoutMediator(tabLayout, viewPager) { tab: TabLayout.Tab, position: Int -> tab.text = fragmentTitleList[position] diff --git a/collect_app/src/main/java/org/odk/collect/android/configure/qr/QRCodeTabsAdapter.kt b/collect_app/src/main/java/org/odk/collect/android/configure/qr/QRCodeTabsAdapter.kt deleted file mode 100644 index b22e69c4ef6..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/configure/qr/QRCodeTabsAdapter.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.odk.collect.android.configure.qr - -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.viewpager2.adapter.FragmentStateAdapter -import java.lang.IllegalArgumentException - -class QRCodeTabsAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) { - - override fun createFragment(position: Int): Fragment { - return when (position) { - 0 -> QRCodeScannerFragment() - 1 -> ShowQRCodeFragment() - else -> throw IllegalArgumentException("Fragment position out of bounds") - } - } - - override fun getItemCount(): Int { - return 2 - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/dao/CursorLoaderFactory.java b/collect_app/src/main/java/org/odk/collect/android/dao/CursorLoaderFactory.java index 1ab53bd52a0..c19ada34d27 100644 --- a/collect_app/src/main/java/org/odk/collect/android/dao/CursorLoaderFactory.java +++ b/collect_app/src/main/java/org/odk/collect/android/dao/CursorLoaderFactory.java @@ -64,22 +64,6 @@ public CursorLoader createEditableInstancesCursorLoader(CharSequence charSequenc return cursorLoader; } - public CursorLoader createSavedInstancesCursorLoader(CharSequence charSequence, String sortOrder) { - CursorLoader cursorLoader; - if (charSequence.length() == 0) { - String selection = DatabaseInstanceColumns.DELETED_DATE + " IS NULL "; - cursorLoader = getInstancesCursorLoader(selection, null, sortOrder); - } else { - String selection = - DatabaseInstanceColumns.DELETED_DATE + " IS NULL and " - + DatabaseInstanceColumns.DISPLAY_NAME + " LIKE ?"; - String[] selectionArgs = {"%" + charSequence + "%"}; - cursorLoader = getInstancesCursorLoader(selection, selectionArgs, sortOrder); - } - - return cursorLoader; - } - public CursorLoader createFinalizedInstancesCursorLoader(CharSequence charSequence, String sortOrder) { CursorLoader cursorLoader; if (charSequence.length() == 0) { diff --git a/collect_app/src/main/java/org/odk/collect/android/database/DatabaseConnection.kt b/collect_app/src/main/java/org/odk/collect/android/database/DatabaseConnection.kt index 57129868717..802ef364d90 100644 --- a/collect_app/src/main/java/org/odk/collect/android/database/DatabaseConnection.kt +++ b/collect_app/src/main/java/org/odk/collect/android/database/DatabaseConnection.kt @@ -15,12 +15,13 @@ import java.io.File * * @param migrator used to migrate or create the database automatically before access */ -open class DatabaseConnection( +open class DatabaseConnection @JvmOverloads constructor( private val context: Context, private val path: String, private val name: String, private val migrator: DatabaseMigrator, - private val databaseVersion: Int + private val databaseVersion: Int, + private val strict: Boolean = false ) { val writeableDatabase: SQLiteDatabase @@ -31,6 +32,9 @@ open class DatabaseConnection( val readableDatabase: SQLiteDatabase get() { + if (strict) { + StrictMode.noteSlowCall("Accessing readable DB") + } return dbHelper.readableDatabase } diff --git a/collect_app/src/main/java/org/odk/collect/android/database/DatabaseConstants.java b/collect_app/src/main/java/org/odk/collect/android/database/DatabaseConstants.java index c56191fd326..c5c13407cde 100644 --- a/collect_app/src/main/java/org/odk/collect/android/database/DatabaseConstants.java +++ b/collect_app/src/main/java/org/odk/collect/android/database/DatabaseConstants.java @@ -5,12 +5,17 @@ public final class DatabaseConstants { public static final String FORMS_DATABASE_NAME = "forms.db"; public static final String FORMS_TABLE_NAME = "forms"; // Please always test upgrades manually when you change this value - public static final int FORMS_DATABASE_VERSION = 12; + public static final int FORMS_DATABASE_VERSION = 13; public static final String INSTANCES_DATABASE_NAME = "instances.db"; public static final String INSTANCES_TABLE_NAME = "instances"; // Please always test upgrades manually when you change this value - public static final int INSTANCES_DATABASE_VERSION = 6; + public static final int INSTANCES_DATABASE_VERSION = 7; + + public static final String SAVEPOINTS_DATABASE_NAME = "savepoints.db"; + public static final String SAVEPOINTS_TABLE_NAME = "savepoints"; + // Please always test upgrades manually when you change this value + public static final int SAVEPOINTS_DATABASE_VERSION = 1; private DatabaseConstants() { diff --git a/collect_app/src/main/java/org/odk/collect/android/database/DatabaseObjectMapper.kt b/collect_app/src/main/java/org/odk/collect/android/database/DatabaseObjectMapper.kt index b3cc87b1bb8..8015bcae310 100644 --- a/collect_app/src/main/java/org/odk/collect/android/database/DatabaseObjectMapper.kt +++ b/collect_app/src/main/java/org/odk/collect/android/database/DatabaseObjectMapper.kt @@ -5,9 +5,9 @@ import android.database.Cursor import android.provider.BaseColumns import org.odk.collect.android.database.forms.DatabaseFormColumns import org.odk.collect.android.database.instances.DatabaseInstanceColumns +import org.odk.collect.androidshared.utils.PathUtils.getAbsoluteFilePath import org.odk.collect.forms.Form import org.odk.collect.forms.instances.Instance -import org.odk.collect.shared.PathUtils.getAbsoluteFilePath import org.odk.collect.shared.PathUtils.getRelativeFilePath import java.lang.Boolean @@ -31,6 +31,7 @@ object DatabaseObjectMapper { values.put(DatabaseFormColumns.FORM_MEDIA_PATH, formMediaPath) values.put(DatabaseFormColumns.LANGUAGE, form.language) values.put(DatabaseFormColumns.AUTO_SEND, form.autoSend) + values.put(DatabaseFormColumns.DATE, form.date) values.put(DatabaseFormColumns.AUTO_DELETE, form.autoDelete) values.put(DatabaseFormColumns.GEOMETRY_XPATH, form.geometryXpath) values.put(DatabaseFormColumns.LAST_DETECTED_ATTACHMENTS_UPDATE_DATE, form.lastDetectedAttachmentsUpdateDate) diff --git a/collect_app/src/main/java/org/odk/collect/android/database/forms/DatabaseFormsRepository.java b/collect_app/src/main/java/org/odk/collect/android/database/forms/DatabaseFormsRepository.java index 9e009b06937..7b6b49eb771 100644 --- a/collect_app/src/main/java/org/odk/collect/android/database/forms/DatabaseFormsRepository.java +++ b/collect_app/src/main/java/org/odk/collect/android/database/forms/DatabaseFormsRepository.java @@ -29,6 +29,7 @@ import org.odk.collect.android.utilities.FileUtils; import org.odk.collect.forms.Form; import org.odk.collect.forms.FormsRepository; +import org.odk.collect.forms.savepoints.SavepointsRepository; import org.odk.collect.shared.files.DirectoryUtils; import org.odk.collect.shared.strings.Md5; @@ -50,8 +51,9 @@ public class DatabaseFormsRepository implements FormsRepository { private final String formsPath; private final String cachePath; private final Supplier clock; + private final SavepointsRepository savepointsRepository; - public DatabaseFormsRepository(Context context, String dbPath, String formsPath, String cachePath, Supplier clock) { + public DatabaseFormsRepository(Context context, String dbPath, String formsPath, String cachePath, Supplier clock, SavepointsRepository savepointsRepository) { this.formsPath = formsPath; this.cachePath = cachePath; this.clock = clock; @@ -62,6 +64,7 @@ public DatabaseFormsRepository(Context context, String dbPath, String formsPath, new FormDatabaseMigrator(), DatabaseConstants.FORMS_DATABASE_VERSION ); + this.savepointsRepository = savepointsRepository; } @Nullable @@ -182,6 +185,7 @@ public void softDelete(Long id) { ContentValues values = new ContentValues(); values.put(DELETED_DATE, System.currentTimeMillis()); updateForm(id, values); + savepointsRepository.delete(id, null); } @Override @@ -303,5 +307,7 @@ private void deleteFilesForForm(Form form) { mediaDir.delete(); } } + + savepointsRepository.delete(form.getDbId(), null); } } diff --git a/collect_app/src/main/java/org/odk/collect/android/database/forms/FormDatabaseMigrator.java b/collect_app/src/main/java/org/odk/collect/android/database/forms/FormDatabaseMigrator.java index eb0669a9f7e..6ebd221c4ba 100644 --- a/collect_app/src/main/java/org/odk/collect/android/database/forms/FormDatabaseMigrator.java +++ b/collect_app/src/main/java/org/odk/collect/android/database/forms/FormDatabaseMigrator.java @@ -42,7 +42,7 @@ public class FormDatabaseMigrator implements DatabaseMigrator { private static final String MODEL_VERSION = "modelVersion"; public void onCreate(SQLiteDatabase db) { - createFormsTableV12(db); + createFormsTableV13(db); } @SuppressWarnings({"checkstyle:FallThrough"}) @@ -72,14 +72,17 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion) throws SQLException { upgradeToVersion12(db); break; case 12: + upgradeToVersion13(db); + break; + case 13: // Remember to bump the database version number in {@link org.odk.collect.android.database.DatabaseConstants} - // upgradeToVersion13(db); + // upgradeToVersion14(db); } } public void onDowngrade(SQLiteDatabase db) throws SQLException { SQLiteUtils.dropTable(db, FORMS_TABLE_NAME); - createFormsTableV12(db); + createFormsTableV13(db); } private void upgradeToVersion2(SQLiteDatabase db) { @@ -265,6 +268,17 @@ private void upgradeToVersion12(SQLiteDatabase db) { SQLiteUtils.addColumn(db, FORMS_TABLE_NAME, LAST_DETECTED_ATTACHMENTS_UPDATE_DATE, "integer"); } + private void upgradeToVersion13(SQLiteDatabase db) { + String temporaryTable = FORMS_TABLE_NAME + "_tmp"; + SQLiteUtils.renameTable(db, FORMS_TABLE_NAME, temporaryTable); + createFormsTableV13(db); + SQLiteUtils.copyRows(db, temporaryTable, new String[]{_ID, DISPLAY_NAME, DESCRIPTION, + JR_FORM_ID, JR_VERSION, MD5_HASH, DATE, FORM_MEDIA_PATH, FORM_FILE_PATH, LANGUAGE, + SUBMISSION_URI, BASE64_RSA_PUBLIC_KEY, JRCACHE_FILE_PATH, AUTO_SEND, AUTO_DELETE, + GEOMETRY_XPATH, DELETED_DATE, LAST_DETECTED_ATTACHMENTS_UPDATE_DATE}, FORMS_TABLE_NAME); + SQLiteUtils.dropTable(db, temporaryTable); + } + private void createFormsTableV4(SQLiteDatabase db, String tableName) { db.execSQL("CREATE TABLE IF NOT EXISTS " + tableName + " (" + _ID + " integer primary key, " @@ -369,7 +383,7 @@ private void createFormsTableV11(SQLiteDatabase db) { + DELETED_DATE + " integer);"); } - private void createFormsTableV12(SQLiteDatabase db) { + public void createFormsTableV12(SQLiteDatabase db) { db.execSQL("CREATE TABLE IF NOT EXISTS " + FORMS_TABLE_NAME + " (" + _ID + " integer primary key, " + DISPLAY_NAME + " text not null, " @@ -390,4 +404,26 @@ private void createFormsTableV12(SQLiteDatabase db) { + DELETED_DATE + " integer, " + LAST_DETECTED_ATTACHMENTS_UPDATE_DATE + " integer);"); // milliseconds } + + private void createFormsTableV13(SQLiteDatabase db) { + db.execSQL("CREATE TABLE IF NOT EXISTS " + FORMS_TABLE_NAME + " (" + + _ID + " integer primary key autoincrement, " + + DISPLAY_NAME + " text not null, " + + DESCRIPTION + " text, " + + JR_FORM_ID + " text not null, " + + JR_VERSION + " text, " + + MD5_HASH + " text not null UNIQUE ON CONFLICT IGNORE, " + + DATE + " integer not null, " // milliseconds + + FORM_MEDIA_PATH + " text not null, " + + FORM_FILE_PATH + " text not null, " + + LANGUAGE + " text, " + + SUBMISSION_URI + " text, " + + BASE64_RSA_PUBLIC_KEY + " text, " + + JRCACHE_FILE_PATH + " text not null, " + + AUTO_SEND + " text, " + + AUTO_DELETE + " text, " + + GEOMETRY_XPATH + " text, " + + DELETED_DATE + " integer, " + + LAST_DETECTED_ATTACHMENTS_UPDATE_DATE + " integer);"); // milliseconds + } } diff --git a/collect_app/src/main/java/org/odk/collect/android/database/instances/InstanceDatabaseMigrator.java b/collect_app/src/main/java/org/odk/collect/android/database/instances/InstanceDatabaseMigrator.java index 8b78666230f..03e2b91aa9a 100644 --- a/collect_app/src/main/java/org/odk/collect/android/database/instances/InstanceDatabaseMigrator.java +++ b/collect_app/src/main/java/org/odk/collect/android/database/instances/InstanceDatabaseMigrator.java @@ -36,8 +36,7 @@ public class InstanceDatabaseMigrator implements DatabaseMigrator { public static final String[] CURRENT_VERSION_COLUMN_NAMES = COLUMN_NAMES_V6; public void onCreate(SQLiteDatabase db) { - createInstancesTableV5(db, INSTANCES_TABLE_NAME); - upgradeToVersion6(db, INSTANCES_TABLE_NAME); + createInstancesTableV7(db); } @SuppressWarnings({"checkstyle:FallThrough"}) @@ -56,19 +55,19 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion) { upgradeToVersion6(db, INSTANCES_TABLE_NAME); break; case 6: + upgradeToVersion7(db); + break; + case 7: // Remember to bump the database version number in {@link org.odk.collect.android.database.DatabaseConstants} - // upgradeToVersion7(db); + // upgradeToVersion8(db); default: Timber.i("Unknown version %d", oldVersion); } } public void onDowngrade(SQLiteDatabase db) { - String temporaryTableName = INSTANCES_TABLE_NAME + "_tmp"; - createInstancesTableV5(db, temporaryTableName); - upgradeToVersion6(db, temporaryTableName); - - dropObsoleteColumns(db, CURRENT_VERSION_COLUMN_NAMES, temporaryTableName); + SQLiteUtils.dropTable(db, INSTANCES_TABLE_NAME); + createInstancesTableV7(db); } private void upgradeToVersion2(SQLiteDatabase db) { @@ -137,6 +136,14 @@ private void upgradeToVersion6(SQLiteDatabase db, String name) { SQLiteUtils.addColumn(db, name, GEOMETRY_TYPE, "text"); } + private void upgradeToVersion7(SQLiteDatabase db) { + String temporaryTable = INSTANCES_TABLE_NAME + "_tmp"; + SQLiteUtils.renameTable(db, INSTANCES_TABLE_NAME, temporaryTable); + createInstancesTableV7(db); + SQLiteUtils.copyRows(db, temporaryTable, CURRENT_VERSION_COLUMN_NAMES, INSTANCES_TABLE_NAME); + SQLiteUtils.dropTable(db, temporaryTable); + } + private void createInstancesTableV5(SQLiteDatabase db, String name) { db.execSQL("CREATE TABLE IF NOT EXISTS " + name + " (" + _ID + " integer primary key, " @@ -150,4 +157,36 @@ private void createInstancesTableV5(SQLiteDatabase db, String name) { + LAST_STATUS_CHANGE_DATE + " date not null, " + DELETED_DATE + " date );"); } + + public void createInstancesTableV6(SQLiteDatabase db) { + db.execSQL("CREATE TABLE IF NOT EXISTS " + INSTANCES_TABLE_NAME + " (" + + _ID + " integer primary key, " + + DISPLAY_NAME + " text not null, " + + SUBMISSION_URI + " text, " + + CAN_EDIT_WHEN_COMPLETE + " text, " + + INSTANCE_FILE_PATH + " text not null, " + + JR_FORM_ID + " text not null, " + + JR_VERSION + " text, " + + STATUS + " text not null, " + + LAST_STATUS_CHANGE_DATE + " date not null, " + + DELETED_DATE + " date, " + + GEOMETRY + " text, " + + GEOMETRY_TYPE + " text);"); + } + + private void createInstancesTableV7(SQLiteDatabase db) { + db.execSQL("CREATE TABLE IF NOT EXISTS " + INSTANCES_TABLE_NAME + " (" + + _ID + " integer primary key autoincrement, " + + DISPLAY_NAME + " text not null, " + + SUBMISSION_URI + " text, " + + CAN_EDIT_WHEN_COMPLETE + " text, " + + INSTANCE_FILE_PATH + " text not null, " + + JR_FORM_ID + " text not null, " + + JR_VERSION + " text, " + + STATUS + " text not null, " + + LAST_STATUS_CHANGE_DATE + " date not null, " + + DELETED_DATE + " date, " + + GEOMETRY + " text, " + + GEOMETRY_TYPE + " text);"); + } } diff --git a/collect_app/src/main/java/org/odk/collect/android/database/savepoints/DatabaseSavepointsColumns.kt b/collect_app/src/main/java/org/odk/collect/android/database/savepoints/DatabaseSavepointsColumns.kt new file mode 100644 index 00000000000..90683f8a72e --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/database/savepoints/DatabaseSavepointsColumns.kt @@ -0,0 +1,25 @@ +package org.odk.collect.android.database.savepoints + +import android.provider.BaseColumns + +object DatabaseSavepointsColumns : BaseColumns { + /** + * The form db id of the blank form that the savepoint belongs to. + */ + const val FORM_DB_ID = "fromDbId" + + /** + * The instance db id of the saved form that the savepoint belongs to. + */ + const val INSTANCE_DB_ID = "instanceDbId" + + /** + * The relative path to the file containing a savepoint. + */ + const val SAVEPOINT_FILE_PATH = "savepointFilePath" + + /** + * The relative path to the instance file. + */ + const val INSTANCE_FILE_PATH = "instanceFilePath" +} diff --git a/collect_app/src/main/java/org/odk/collect/android/database/savepoints/DatabaseSavepointsRepository.kt b/collect_app/src/main/java/org/odk/collect/android/database/savepoints/DatabaseSavepointsRepository.kt new file mode 100644 index 00000000000..e134be02cb1 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/database/savepoints/DatabaseSavepointsRepository.kt @@ -0,0 +1,152 @@ +package org.odk.collect.android.database.savepoints + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.database.sqlite.SQLiteQueryBuilder +import org.odk.collect.android.database.DatabaseConnection +import org.odk.collect.android.database.DatabaseConstants +import org.odk.collect.android.database.DatabaseConstants.SAVEPOINTS_DATABASE_NAME +import org.odk.collect.android.database.DatabaseConstants.SAVEPOINTS_DATABASE_VERSION +import org.odk.collect.android.database.savepoints.DatabaseSavepointsColumns.FORM_DB_ID +import org.odk.collect.android.database.savepoints.DatabaseSavepointsColumns.INSTANCE_DB_ID +import org.odk.collect.androidshared.utils.PathUtils.getAbsoluteFilePath +import org.odk.collect.forms.savepoints.Savepoint +import org.odk.collect.forms.savepoints.SavepointsRepository +import org.odk.collect.shared.PathUtils +import java.io.File + +class DatabaseSavepointsRepository( + context: Context, + dbPath: String, + private val cachePath: String, + private val instancesPath: String +) : SavepointsRepository { + private val databaseConnection: DatabaseConnection = DatabaseConnection( + context, + dbPath, + SAVEPOINTS_DATABASE_NAME, + SavepointsDatabaseMigrator(), + SAVEPOINTS_DATABASE_VERSION, + true + ) + + override fun get(formDbId: Long, instanceDbId: Long?): Savepoint? { + val cursor = if (instanceDbId == null) { + queryAndReturnCursor( + "$FORM_DB_ID=? AND $INSTANCE_DB_ID IS NULL", + arrayOf(formDbId.toString()) + ) + } else { + queryAndReturnCursor( + "$FORM_DB_ID=? AND $INSTANCE_DB_ID=?", + arrayOf(formDbId.toString(), instanceDbId.toString()) + ) + } + val savepoints = getSavepointsFromCursor(cursor) + + return if (savepoints.isNotEmpty()) savepoints[0] else null + } + + override fun getAll(): List { + val cursor = queryAndReturnCursor() + return getSavepointsFromCursor(cursor) + } + + override fun save(savepoint: Savepoint) { + if (get(savepoint.formDbId, savepoint.instanceDbId) != null) { + return + } + + val values = getValuesFromSavepoint(savepoint, cachePath, instancesPath) + + databaseConnection + .writeableDatabase + .insertOrThrow(DatabaseConstants.SAVEPOINTS_TABLE_NAME, null, values) + } + + override fun delete(formDbId: Long, instanceDbId: Long?) { + val savepoint = get(formDbId, instanceDbId) ?: return + + val (selection, selectionArgs) = if (savepoint.instanceDbId == null) { + Pair( + "$FORM_DB_ID=? AND $INSTANCE_DB_ID IS NULL", + arrayOf(savepoint.formDbId.toString()) + ) + } else { + Pair( + "$FORM_DB_ID=? AND $INSTANCE_DB_ID=?", + arrayOf(savepoint.formDbId.toString(), savepoint.instanceDbId.toString()) + ) + } + + databaseConnection + .writeableDatabase + .delete(DatabaseConstants.SAVEPOINTS_TABLE_NAME, selection, selectionArgs) + + File(savepoint.savepointFilePath).delete() + } + + override fun deleteAll() { + getAll().forEach { + File(it.savepointFilePath).delete() + } + + databaseConnection + .writeableDatabase + .delete(DatabaseConstants.SAVEPOINTS_TABLE_NAME, null, null) + } + + private fun queryAndReturnCursor(selection: String? = null, selectionArgs: Array? = null): Cursor { + val readableDatabase = databaseConnection.readableDatabase + val qb = SQLiteQueryBuilder().apply { + tables = DatabaseConstants.SAVEPOINTS_TABLE_NAME + } + return qb.query(readableDatabase, null, selection, selectionArgs, null, null, null) + } + + private fun getSavepointsFromCursor(cursor: Cursor?): List { + val savepoints: MutableList = ArrayList() + if (cursor != null) { + cursor.moveToPosition(-1) + while (cursor.moveToNext()) { + val savepoint = getSavepointFromCurrentCursorPosition(cursor, cachePath, instancesPath) + savepoints.add(savepoint) + } + } + return savepoints + } + + private fun getSavepointFromCurrentCursorPosition( + cursor: Cursor, + cachePath: String, + instancesPath: String + ): Savepoint { + val formDbIdColumnIndex = cursor.getColumnIndex(FORM_DB_ID) + val instanceDbIdColumnIndex = cursor.getColumnIndex(INSTANCE_DB_ID) + val savepointFilePathColumnIndex = cursor.getColumnIndex(DatabaseSavepointsColumns.SAVEPOINT_FILE_PATH) + val instanceDirPathColumnIndex = cursor.getColumnIndex(DatabaseSavepointsColumns.INSTANCE_FILE_PATH) + + return Savepoint( + cursor.getLong(formDbIdColumnIndex), + if (cursor.isNull(instanceDbIdColumnIndex)) null else cursor.getLong(instanceDbIdColumnIndex), + getAbsoluteFilePath( + cachePath, + cursor.getString(savepointFilePathColumnIndex) + ), + getAbsoluteFilePath( + instancesPath, + cursor.getString(instanceDirPathColumnIndex) + ) + ) + } + + private fun getValuesFromSavepoint(savepoint: Savepoint, cachePath: String, instancesPath: String): ContentValues { + return ContentValues().apply { + put(FORM_DB_ID, savepoint.formDbId) + put(INSTANCE_DB_ID, savepoint.instanceDbId) + put(DatabaseSavepointsColumns.SAVEPOINT_FILE_PATH, PathUtils.getRelativeFilePath(cachePath, savepoint.savepointFilePath)) + put(DatabaseSavepointsColumns.INSTANCE_FILE_PATH, PathUtils.getRelativeFilePath(instancesPath, savepoint.instanceFilePath)) + } + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/database/savepoints/SavepointsDatabaseMigrator.kt b/collect_app/src/main/java/org/odk/collect/android/database/savepoints/SavepointsDatabaseMigrator.kt new file mode 100644 index 00000000000..77cfac6b30a --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/database/savepoints/SavepointsDatabaseMigrator.kt @@ -0,0 +1,42 @@ +package org.odk.collect.android.database.savepoints + +import android.database.sqlite.SQLiteDatabase +import android.provider.BaseColumns._ID +import org.odk.collect.android.database.DatabaseConstants.SAVEPOINTS_TABLE_NAME +import org.odk.collect.android.database.DatabaseMigrator +import org.odk.collect.android.database.savepoints.DatabaseSavepointsColumns.FORM_DB_ID +import org.odk.collect.android.database.savepoints.DatabaseSavepointsColumns.INSTANCE_DB_ID +import org.odk.collect.android.database.savepoints.DatabaseSavepointsColumns.INSTANCE_FILE_PATH +import org.odk.collect.android.database.savepoints.DatabaseSavepointsColumns.SAVEPOINT_FILE_PATH +import org.odk.collect.android.utilities.SQLiteUtils + +class SavepointsDatabaseMigrator : DatabaseMigrator { + override fun onCreate(db: SQLiteDatabase) { + createSavepointsTableV1(db) + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int) { + when (oldVersion) { + 1 -> { + // Remember to bump the database version number in {@link org.odk.collect.android.database.DatabaseConstants} + // upgradeToVersion2(db); + } + } + } + + override fun onDowngrade(db: SQLiteDatabase) { + SQLiteUtils.dropTable(db, SAVEPOINTS_TABLE_NAME) + createSavepointsTableV1(db) + } + + private fun createSavepointsTableV1(db: SQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS $SAVEPOINTS_TABLE_NAME (" + + "$_ID integer PRIMARY KEY, " + + "$FORM_DB_ID integer NOT NULL, " + + "$INSTANCE_DB_ID integer, " + + "$SAVEPOINT_FILE_PATH text NOT NULL, " + + "$INSTANCE_FILE_PATH text NOT NULL);" + ) + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/entities/EntitiesRepositoryProvider.kt b/collect_app/src/main/java/org/odk/collect/android/entities/EntitiesRepositoryProvider.kt index 6d62ec55388..d76ff7dc4d7 100644 --- a/collect_app/src/main/java/org/odk/collect/android/entities/EntitiesRepositoryProvider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/entities/EntitiesRepositoryProvider.kt @@ -1,22 +1,17 @@ package org.odk.collect.android.entities -import android.app.Application import org.odk.collect.android.projects.ProjectsDataService -import org.odk.collect.androidshared.data.getState +import org.odk.collect.android.storage.StoragePathProvider import org.odk.collect.entities.EntitiesRepository +import java.io.File -class EntitiesRepositoryProvider(application: Application, private val projectsDataService: ProjectsDataService) { - - private val repositories = - application.getState().get(MAP_KEY, mutableMapOf()) +class EntitiesRepositoryProvider( + private val projectsDataService: ProjectsDataService, + private val storagePathProvider: StoragePathProvider +) { fun get(projectId: String = projectsDataService.getCurrentProject().uuid): EntitiesRepository { - return repositories.getOrPut(projectId) { - InMemEntitiesRepository() - } - } - - companion object { - private const val MAP_KEY = "entities_repository_map" + val projectDir = File(storagePathProvider.getProjectRootDirPath(projectId)) + return JsonFileEntitiesRepository(projectDir) } } diff --git a/collect_app/src/main/java/org/odk/collect/android/entities/InMemEntitiesRepository.kt b/collect_app/src/main/java/org/odk/collect/android/entities/InMemEntitiesRepository.kt deleted file mode 100644 index 1e7d92d5221..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/entities/InMemEntitiesRepository.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.odk.collect.android.entities - -import org.odk.collect.entities.EntitiesRepository -import org.odk.collect.entities.Entity - -class InMemEntitiesRepository : EntitiesRepository { - - private val entities = mutableListOf() - - override fun getDatasets(): Set { - return entities.map { it.dataset }.toSet() - } - - override fun getEntities(dataset: String): List { - return entities.filter { it.dataset == dataset } - } - - override fun save(entity: Entity) { - entities.add(entity) - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/entities/JsonFileEntitiesRepository.kt b/collect_app/src/main/java/org/odk/collect/android/entities/JsonFileEntitiesRepository.kt new file mode 100644 index 00000000000..a9d0d92eeb0 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/entities/JsonFileEntitiesRepository.kt @@ -0,0 +1,170 @@ +package org.odk.collect.android.entities + +import android.os.StrictMode +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import org.odk.collect.entities.EntitiesRepository +import org.odk.collect.entities.Entity +import java.io.File + +class JsonFileEntitiesRepository(directory: File) : EntitiesRepository { + + private val entitiesFile = File(directory, "entities.json") + + override fun getLists(): Set { + return readJson().keys + } + + override fun getEntities(list: String): List { + return readEntities().filter { it.list == list } + } + + override fun save(vararg entities: Entity) { + val storedEntities = readEntities() + + entities.forEach { entity -> + val existing = storedEntities.find { it.id == entity.id } + + if (existing != null) { + val state = when (existing.state) { + Entity.State.OFFLINE -> entity.state + Entity.State.ONLINE -> Entity.State.ONLINE + } + + storedEntities.remove(existing) + storedEntities.add( + Entity( + entity.list, + entity.id, + entity.label ?: existing.label, + version = entity.version, + properties = mergeProperties(existing, entity), + state = state + ) + ) + } else { + storedEntities.add(entity) + } + } + + writeEntities(storedEntities) + } + + override fun clear() { + StrictMode.noteSlowCall("Writing to JSON file") + entitiesFile.delete() + } + + override fun addList(list: String) { + val existing = readJson() + if (!existing.containsKey(list)) { + existing[list] = mutableListOf() + } + + writeJson(existing) + } + + override fun delete(id: String) { + val existing = readEntities() + existing.removeIf { it.id == id } + writeEntities(existing) + } + + private fun writeEntities(entities: MutableList) { + val map = mutableMapOf>() + entities.forEach { + map.getOrPut(it.list) { mutableListOf() }.add(it.toJson()) + } + + writeJson(map) + } + + private fun readEntities(): MutableList { + return readJson().entries.flatMap { (list, entities) -> + entities.map { it.toEntity(list) } + }.toMutableList() + } + + private fun readJson(): MutableMap> { + StrictMode.noteSlowCall("Reading from JSON file") + + if (!entitiesFile.exists()) { + entitiesFile.parentFile.mkdirs() + entitiesFile.createNewFile() + } + + try { + val typeToken = TypeToken.getParameterized( + MutableMap::class.java, + String::class.java, + TypeToken.getParameterized(MutableList::class.java, JsonEntity::class.java).type + ) + + val json = entitiesFile.readText() + return if (json.isNotBlank()) { + val parsedJson = + Gson().fromJson>>(json, typeToken.type) + + parsedJson + } else { + mutableMapOf() + } + } catch (e: Exception) { + return mutableMapOf() + } + } + + private fun writeJson(map: MutableMap>) { + StrictMode.noteSlowCall("Writing to JSON file") + + val json = Gson().toJson(map) + entitiesFile.writeText(json) + } + + private fun mergeProperties( + existing: Entity, + new: Entity + ): List> { + val existingProperties = mutableMapOf(*existing.properties.toTypedArray()) + new.properties.forEach { + existingProperties[it.first] = it.second + } + + return existingProperties.map { Pair(it.key, it.value) } + } + + private data class JsonEntity( + val id: String, + val label: String?, + val version: Int, + val properties: Map, + val offline: Boolean + ) + + private fun JsonEntity.toEntity(list: String): Entity { + val state = if (this.offline) { + Entity.State.OFFLINE + } else { + Entity.State.ONLINE + } + + return Entity( + list, + this.id, + this.label, + this.version, + this.properties.entries.map { Pair(it.key, it.value) }, + state + ) + } + + private fun Entity.toJson(): JsonEntity { + return JsonEntity( + this.id, + this.label, + this.version, + this.properties.toMap(), + this.state == Entity.State.OFFLINE + ) + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/external/AndroidShortcutsActivity.kt b/collect_app/src/main/java/org/odk/collect/android/external/AndroidShortcutsActivity.kt index fe2d5394beb..e0c5d059422 100644 --- a/collect_app/src/main/java/org/odk/collect/android/external/AndroidShortcutsActivity.kt +++ b/collect_app/src/main/java/org/odk/collect/android/external/AndroidShortcutsActivity.kt @@ -21,8 +21,6 @@ import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.odk.collect.android.R -import org.odk.collect.android.analytics.AnalyticsEvents -import org.odk.collect.android.analytics.AnalyticsUtils import org.odk.collect.android.formlists.blankformlist.BlankFormListItem import org.odk.collect.android.formlists.blankformlist.BlankFormListViewModel import org.odk.collect.android.injection.DaggerUtils @@ -59,10 +57,6 @@ class AndroidShortcutsActivity : AppCompatActivity() { .map { it.formName } .toTypedArray() ) { _: DialogInterface?, item: Int -> - AnalyticsUtils.logServerEvent( - AnalyticsEvents.CREATE_SHORTCUT, - settingsProvider.getUnprotectedSettings() - ) val intent = getShortcutIntent(blankFormListItems, item) setResult(RESULT_OK, intent) finish() diff --git a/collect_app/src/main/java/org/odk/collect/android/external/FormUriActivity.kt b/collect_app/src/main/java/org/odk/collect/android/external/FormUriActivity.kt index 57ea84a4d61..d90043b2844 100644 --- a/collect_app/src/main/java/org/odk/collect/android/external/FormUriActivity.kt +++ b/collect_app/src/main/java/org/odk/collect/android/external/FormUriActivity.kt @@ -5,7 +5,6 @@ import android.content.Intent import android.content.res.Resources import android.net.Uri import android.os.Bundle -import androidx.activity.ComponentActivity import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.lifecycle.LiveData @@ -22,22 +21,28 @@ import org.odk.collect.android.injection.DaggerUtils import org.odk.collect.android.instancemanagement.InstanceDeleter import org.odk.collect.android.instancemanagement.canBeEdited import org.odk.collect.android.projects.ProjectsDataService +import org.odk.collect.android.savepoints.SavepointUseCases import org.odk.collect.android.utilities.ApplicationConstants import org.odk.collect.android.utilities.ContentUriHelper import org.odk.collect.android.utilities.FormsRepositoryProvider import org.odk.collect.android.utilities.InstancesRepositoryProvider +import org.odk.collect.android.utilities.SavepointsRepositoryProvider import org.odk.collect.async.Scheduler +import org.odk.collect.forms.savepoints.Savepoint import org.odk.collect.projects.ProjectsRepository import org.odk.collect.settings.SettingsProvider import org.odk.collect.strings.R.string +import org.odk.collect.strings.localization.LocalizedActivity import java.io.File +import java.text.SimpleDateFormat +import java.util.Locale import javax.inject.Inject /** * This class serves as a firewall for starting form filling. It should be used to do that * rather than [FormFillingActivity] directly as it ensures that the required data is valid. */ -class FormUriActivity : ComponentActivity() { +class FormUriActivity : LocalizedActivity() { @Inject lateinit var projectsDataService: ProjectsDataService @@ -51,6 +56,9 @@ class FormUriActivity : ComponentActivity() { @Inject lateinit var instanceRepositoryProvider: InstancesRepositoryProvider + @Inject + lateinit var savepointsRepositoryProvider: SavepointsRepositoryProvider + @Inject lateinit var settingsProvider: SettingsProvider @@ -76,6 +84,7 @@ class FormUriActivity : ComponentActivity() { contentResolver, formsRepositoryProvider, instanceRepositoryProvider, + savepointsRepositoryProvider, resources ) as T } @@ -87,21 +96,46 @@ class FormUriActivity : ComponentActivity() { DaggerUtils.getComponent(this).inject(this) setContentView(R.layout.circular_progress_indicator) - formUriViewModel.error.observe(this) { - if (it != null) { - displayErrorDialog(it) - } else if (savedInstanceState?.getBoolean(FORM_FILLING_ALREADY_STARTED) != true) { - startForm() + if (savedInstanceState?.getBoolean(FORM_FILLING_ALREADY_STARTED) == true) { + return + } + + formUriViewModel.formInspectionResult.observe(this) { + when (it) { + is FormInspectionResult.Error -> displayErrorDialog(it.error) + is FormInspectionResult.Savepoint -> displaySavePointRecoveryDialog(it.savepoint) + is FormInspectionResult.Valid -> startForm(intent.data!!) } } } - private fun startForm() { + private fun displaySavePointRecoveryDialog(savepoint: Savepoint) { + MaterialAlertDialogBuilder(this) + .setTitle(string.savepoint_recovery_dialog_title) + .setMessage(SimpleDateFormat(getString(string.savepoint_recovery_dialog_message), Locale.getDefault()).format(File(savepoint.savepointFilePath).lastModified())) + .setPositiveButton(string.recover) { _, _ -> + val uri = intent.data!! + val uriMimeType = contentResolver.getType(uri)!! + if (uriMimeType == FormsContract.CONTENT_ITEM_TYPE) { + startForm(FormsContract.getUri(projectsDataService.getCurrentProject().uuid, savepoint.formDbId)) + } else { + startForm(intent.data!!) + } + } + .setNegativeButton(string.do_not_recover) { _, _ -> + formUriViewModel.deleteSavepoint(savepoint) + } + .setOnCancelListener { finish() } + .create() + .show() + } + + private fun startForm(uri: Uri) { formFillingAlreadyStarted = true openForm.launch( Intent(this, FormFillingActivity::class.java).apply { action = intent.action - data = intent.data + data = uri intent.extras?.let { sourceExtras -> putExtras(sourceExtras) } if (!canFormBeEdited()) { putExtra( @@ -148,26 +182,37 @@ class FormUriActivity : ComponentActivity() { private class FormUriViewModel( private val uri: Uri?, - scheduler: Scheduler, + private val scheduler: Scheduler, private val projectsRepository: ProjectsRepository, private val projectsDataService: ProjectsDataService, private val contentResolver: ContentResolver, private val formsRepositoryProvider: FormsRepositoryProvider, private val instancesRepositoryProvider: InstancesRepositoryProvider, + private val savepointsRepositoryProvider: SavepointsRepositoryProvider, private val resources: Resources ) : ViewModel() { - private val _error = MutableLiveData() - val error: LiveData = _error + private val _formInspectionResult = MutableLiveData() + val formInspectionResult: LiveData = _formInspectionResult init { scheduler.immediate( background = { - assertProjectListNotEmpty() ?: assertCurrentProjectUsed() ?: assertValidUri() - ?: assertFormExists() ?: assertFormNotEncrypted() + val error = assertProjectListNotEmpty() + ?: assertCurrentProjectUsed() + ?: assertValidUri() + ?: assertFormExists() + ?: assertFormNotEncrypted() + if (error != null) { + FormInspectionResult.Error(error) + } else { + getSavePoint()?.let { + FormInspectionResult.Savepoint(it) + } ?: FormInspectionResult.Valid + } }, foreground = { - _error.value = it + _formInspectionResult.value = it } ) } @@ -271,4 +316,36 @@ private class FormUriViewModel( null } } + + private fun getSavePoint(): Savepoint? { + val uriMimeType = contentResolver.getType(uri!!)!! + + return SavepointUseCases.getSavepoint( + uri, + uriMimeType, + formsRepositoryProvider.get(), + instancesRepositoryProvider.get(), + savepointsRepositoryProvider.get() + ) + } + + fun deleteSavepoint(savepoint: Savepoint) { + scheduler.immediate( + background = { + if (savepoint.instanceDbId == null) { + File(savepoint.instanceFilePath).parentFile?.deleteRecursively() + } + savepointsRepositoryProvider.get().delete(savepoint.formDbId, savepoint.instanceDbId) + }, + foreground = { + _formInspectionResult.value = FormInspectionResult.Valid + } + ) + } +} + +private sealed class FormInspectionResult { + data class Error(val error: String) : FormInspectionResult() + data class Savepoint(val savepoint: org.odk.collect.forms.savepoints.Savepoint) : FormInspectionResult() + data object Valid : FormInspectionResult() } diff --git a/collect_app/src/main/java/org/odk/collect/android/external/FormsProvider.java b/collect_app/src/main/java/org/odk/collect/android/external/FormsProvider.java index 3f52998a244..9d6fab6eb5d 100644 --- a/collect_app/src/main/java/org/odk/collect/android/external/FormsProvider.java +++ b/collect_app/src/main/java/org/odk/collect/android/external/FormsProvider.java @@ -15,9 +15,6 @@ package org.odk.collect.android.external; import static android.provider.BaseColumns._ID; -import static org.odk.collect.android.database.DatabaseObjectMapper.getFormFromCurrentCursorPosition; -import static org.odk.collect.android.database.DatabaseObjectMapper.getFormFromValues; -import static org.odk.collect.android.database.DatabaseObjectMapper.getValuesFromForm; import static org.odk.collect.android.database.forms.DatabaseFormColumns.AUTO_DELETE; import static org.odk.collect.android.database.forms.DatabaseFormColumns.AUTO_SEND; import static org.odk.collect.android.database.forms.DatabaseFormColumns.BASE64_RSA_PUBLIC_KEY; @@ -52,11 +49,9 @@ import org.odk.collect.android.injection.DaggerUtils; import org.odk.collect.android.itemsets.FastExternalItemsetsRepository; import org.odk.collect.android.storage.StoragePathProvider; -import org.odk.collect.android.storage.StorageSubdirectory; import org.odk.collect.android.utilities.ContentUriHelper; import org.odk.collect.android.utilities.FormsRepositoryProvider; import org.odk.collect.android.utilities.InstancesRepositoryProvider; -import org.odk.collect.forms.Form; import org.odk.collect.forms.FormsRepository; import org.odk.collect.forms.instances.InstancesRepository; import org.odk.collect.projects.ProjectsRepository; @@ -189,20 +184,7 @@ public String getType(@NonNull Uri uri) { @Override public synchronized Uri insert(@NonNull Uri uri, ContentValues initialValues) { - deferDaggerInit(); - - // Validate the requested uri - if (URI_MATCHER.match(uri) != FORMS) { - throw new IllegalArgumentException("Unknown URI " + uri); - } - - String projectId = getProjectId(uri); - logServerEvent(projectId, AnalyticsEvents.FORMS_PROVIDER_INSERT); - - String formsPath = storagePathProvider.getOdkDirPath(StorageSubdirectory.FORMS, projectId); - String cachePath = storagePathProvider.getOdkDirPath(StorageSubdirectory.CACHE, projectId); - Form form = getFormsRepository(projectId).save(getFormFromValues(initialValues, formsPath, cachePath)); - return FormsContract.getUri(projectId, form.getDbId()); + return null; } /** @@ -248,53 +230,7 @@ public int delete(@NonNull Uri uri, String where, String[] whereArgs) { @Override public int update(Uri uri, ContentValues values, String where, String[] whereArgs) { - deferDaggerInit(); - - String projectId = getProjectId(uri); - logServerEvent(projectId, AnalyticsEvents.FORMS_PROVIDER_UPDATE); - - FormsRepository formsRepository = getFormsRepository(projectId); - String formsPath = storagePathProvider.getOdkDirPath(StorageSubdirectory.FORMS, projectId); - String cachePath = storagePathProvider.getOdkDirPath(StorageSubdirectory.CACHE, projectId); - - int count; - - switch (URI_MATCHER.match(uri)) { - case FORMS: - try (Cursor cursor = databaseQuery(projectId, null, where, whereArgs, null, null, null)) { - while (cursor.moveToNext()) { - Form form = getFormFromCurrentCursorPosition(cursor, formsPath, cachePath); - ContentValues existingValues = getValuesFromForm(form, formsPath); - existingValues.putAll(values); - - formsRepository.save(getFormFromValues(existingValues, formsPath, cachePath)); - } - - count = cursor.getCount(); - } - break; - - case FORM_ID: - Form form = formsRepository.get(ContentUriHelper.getIdFromUri(uri)); - if (form != null) { - ContentValues existingValues = getValuesFromForm(form, formsPath); - existingValues.putAll(values); - - formsRepository.save(getFormFromValues(existingValues, formsPath, cachePath)); - count = 1; - } else { - count = 0; - } - - break; - - default: - throw new IllegalArgumentException("Unknown URI " + uri); - } - - getContext().getContentResolver().notifyChange(uri, null); - - return count; + return 0; } @NotNull diff --git a/collect_app/src/main/java/org/odk/collect/android/external/InstanceProvider.java b/collect_app/src/main/java/org/odk/collect/android/external/InstanceProvider.java index 87288c3cf40..c3ef6e07ec9 100644 --- a/collect_app/src/main/java/org/odk/collect/android/external/InstanceProvider.java +++ b/collect_app/src/main/java/org/odk/collect/android/external/InstanceProvider.java @@ -14,9 +14,7 @@ package org.odk.collect.android.external; -import static org.odk.collect.android.database.DatabaseObjectMapper.getInstanceFromCurrentCursorPosition; import static org.odk.collect.android.database.DatabaseObjectMapper.getInstanceFromValues; -import static org.odk.collect.android.database.DatabaseObjectMapper.getValuesFromInstance; import static org.odk.collect.android.database.instances.DatabaseInstanceColumns._ID; import static org.odk.collect.android.external.InstancesContract.CONTENT_ITEM_TYPE; import static org.odk.collect.android.external.InstancesContract.CONTENT_TYPE; @@ -33,16 +31,15 @@ import org.odk.collect.android.analytics.AnalyticsEvents; import org.odk.collect.android.analytics.AnalyticsUtils; import org.odk.collect.android.dao.CursorLoaderFactory; +import org.odk.collect.android.database.instances.DatabaseInstanceColumns; import org.odk.collect.android.database.instances.DatabaseInstancesRepository; import org.odk.collect.android.injection.DaggerUtils; import org.odk.collect.android.instancemanagement.InstanceDeleter; import org.odk.collect.android.storage.StoragePathProvider; -import org.odk.collect.android.storage.StorageSubdirectory; import org.odk.collect.android.utilities.ContentUriHelper; import org.odk.collect.android.utilities.FormsRepositoryProvider; import org.odk.collect.android.utilities.InstancesRepositoryProvider; import org.odk.collect.forms.instances.Instance; -import org.odk.collect.forms.instances.InstancesRepository; import org.odk.collect.projects.ProjectsRepository; import org.odk.collect.settings.SettingsProvider; @@ -136,6 +133,10 @@ public Uri insert(@NonNull Uri uri, ContentValues initialValues) { throw new IllegalArgumentException("Unknown URI " + uri); } + if (initialValues.containsKey(DatabaseInstanceColumns.SUBMISSION_URI)) { + throw new SecurityException(); + } + Instance newInstance = instancesRepositoryProvider.get(projectId).save(getInstanceFromValues(initialValues)); return getUri(projectId, newInstance.getDbId()); } @@ -197,67 +198,7 @@ public int delete(@NonNull Uri uri, String where, String[] whereArgs) { @Override public int update(@NonNull Uri uri, ContentValues values, String where, String[] whereArgs) { - DaggerUtils.getComponent(getContext()).inject(this); - - String projectId = getProjectId(uri); - logServerEvent(projectId, AnalyticsEvents.INSTANCE_PROVIDER_UPDATE); - - InstancesRepository instancesRepository = instancesRepositoryProvider.get(projectId); - String instancesPath = storagePathProvider.getOdkDirPath(StorageSubdirectory.INSTANCES, projectId); - - int count; - - switch (URI_MATCHER.match(uri)) { - case INSTANCES: - try (Cursor cursor = dbQuery(projectId, null, where, whereArgs, null)) { - while (cursor.moveToNext()) { - Instance instance = getInstanceFromCurrentCursorPosition(cursor, instancesPath); - ContentValues existingValues = getValuesFromInstance(instance, instancesPath); - - existingValues.putAll(values); - instancesRepository.save(getInstanceFromValues(existingValues)); - } - - count = cursor.getCount(); - } - - break; - - case INSTANCE_ID: - long instanceId = ContentUriHelper.getIdFromUri(uri); - if (whereArgs == null || whereArgs.length == 0) { - Instance instance = instancesRepository.get(instanceId); - ContentValues existingValues = getValuesFromInstance(instance, instancesPath); - - existingValues.putAll(values); - instancesRepository.save(getInstanceFromValues(existingValues)); - count = 1; - } else { - try (Cursor cursor = dbQuery(projectId, new String[]{_ID}, where, whereArgs, null)) { - while (cursor.moveToNext()) { - if (cursor.getLong(cursor.getColumnIndex(_ID)) == instanceId) { - Instance instance = getInstanceFromCurrentCursorPosition(cursor, instancesPath); - ContentValues existingValues = getValuesFromInstance(instance, instancesPath); - - existingValues.putAll(values); - instancesRepository.save(getInstanceFromValues(existingValues)); - break; - } - } - } - - count = 1; - } - - break; - - default: - throw new IllegalArgumentException("Unknown URI " + uri); - } - - getContext().getContentResolver().notifyChange(uri, null); - - return count; + return 0; } private String getProjectId(@NonNull Uri uri) { diff --git a/collect_app/src/main/java/org/odk/collect/android/fastexternalitemset/ItemsetDbAdapter.java b/collect_app/src/main/java/org/odk/collect/android/fastexternalitemset/ItemsetDbAdapter.java index a45736fa8b4..7393d4f004f 100644 --- a/collect_app/src/main/java/org/odk/collect/android/fastexternalitemset/ItemsetDbAdapter.java +++ b/collect_app/src/main/java/org/odk/collect/android/fastexternalitemset/ItemsetDbAdapter.java @@ -1,5 +1,7 @@ package org.odk.collect.android.fastexternalitemset; +import static org.odk.collect.androidshared.utils.PathUtils.getAbsoluteFilePath; + import android.content.ContentValues; import android.database.Cursor; import android.database.SQLException; @@ -189,7 +191,7 @@ public void delete(String path) { if (c != null) { if (c.getCount() == 1) { c.moveToFirst(); - String table = getMd5FromString(PathUtils.getAbsoluteFilePath(storagePathProvider.getOdkDirPath(StorageSubdirectory.FORMS), c.getString(c.getColumnIndex(KEY_PATH)))); + String table = getMd5FromString(getAbsoluteFilePath(storagePathProvider.getOdkDirPath(StorageSubdirectory.FORMS), c.getString(c.getColumnIndex(KEY_PATH)))); db.execSQL("DROP TABLE IF EXISTS " + DATABASE_TABLE + table); } c.close(); diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEndView.kt b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEndView.kt index ba9178314bc..31ae76b9b4f 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEndView.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEndView.kt @@ -15,10 +15,10 @@ import androidx.core.text.underline import androidx.core.view.isVisible import androidx.core.widget.NestedScrollView import org.odk.collect.android.R -import org.odk.collect.android.activities.WebViewActivity import org.odk.collect.android.databinding.FormEntryEndBinding import org.odk.collect.androidshared.system.ContextUtils import org.odk.collect.strings.localization.getLocalizedString +import org.odk.collect.webpage.WebViewActivity class FormEndView( context: Context, diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt index 35780b20124..2899260f7a5 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryUseCases.kt @@ -16,6 +16,7 @@ import org.odk.collect.android.javarosawrapper.JavaRosaFormController import org.odk.collect.android.utilities.FileUtils import org.odk.collect.android.utilities.FormUtils import org.odk.collect.entities.EntitiesRepository +import org.odk.collect.entities.LocalEntityUseCases import org.odk.collect.forms.Form import org.odk.collect.forms.FormsRepository import org.odk.collect.forms.instances.Instance @@ -31,7 +32,8 @@ object FormEntryUseCases { formDefCache: FormDefCache ): Pair? { val form = - formsRepository.getAllByFormIdAndVersion(instance.formId, instance.formVersion).firstOrNull() + formsRepository.getAllByFormIdAndVersion(instance.formId, instance.formVersion) + .firstOrNull() return if (form == null) { null } else { @@ -102,23 +104,12 @@ object FormEntryUseCases { ) } - fun getSavePoint(formController: FormController, cacheDir: File): File? { - val instanceXml = formController.getInstanceFile()!! - val savepointFile = File(cacheDir, "${instanceXml.name}.save") - - return if (savepointFile.exists() && savepointFile.lastModified() > instanceXml.lastModified()) { - savepointFile - } else { - null - } - } - fun saveDraft( form: Form, formController: FormController, instancesRepository: InstancesRepository ): Instance { - saveFormToDisk(formController) + saveInstanceToDisk(formController) return instancesRepository.save( Instance.Builder() .formId(form.formId) @@ -138,10 +129,12 @@ object FormEntryUseCases { val instance = getInstanceFromFormController(formController, instancesRepository)!! - val valid = finalizeInstance(formController, entitiesRepository) + val validationResult = formController.validateAnswers(false) + val valid = validationResult !is FailedValidationResult return if (valid) { - saveFormToDisk(formController) + finalizeFormController(formController, entitiesRepository) + saveInstanceToDisk(formController) val instanceName = formController.getSubmissionMetadata()?.instanceName instancesRepository.save( @@ -162,6 +155,18 @@ object FormEntryUseCases { } } + @JvmStatic + fun finalizeFormController( + formController: FormController, + entitiesRepository: EntitiesRepository + ) { + formController.finalizeForm() + LocalEntityUseCases.updateLocalEntitiesFromForm( + formController.getEntities(), + entitiesRepository + ) + } + private fun getInstanceFromFormController( formController: FormController, instancesRepository: InstancesRepository @@ -170,26 +175,11 @@ object FormEntryUseCases { return instancesRepository.getOneByPath(instancePath) } - private fun saveFormToDisk(formController: FormController) { + private fun saveInstanceToDisk(formController: FormController) { val payload = formController.getSubmissionXml() FileUtils.write(formController.getInstanceFile(), payload!!.payloadBytes) } - @JvmStatic - private fun finalizeInstance( - formController: FormController, - entitiesRepository: EntitiesRepository - ): Boolean { - val validationResult = formController.validateAnswers(markCompleted = true, moveToInvalidIndex = false) - if (validationResult is FailedValidationResult) { - return false - } - - formController.finalizeForm() - formController.getEntities().forEach { entity -> entitiesRepository.save(entity) } - return true - } - private fun createFormDefFromCacheOrXml(xForm: File, formDefCache: FormDefCache): FormDef? { val formDefFromCache = formDefCache.readCache(xForm) if (formDefFromCache != null) { diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryViewModel.java b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryViewModel.java index ba70fa3fcb7..24343ab59b4 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryViewModel.java +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/FormEntryViewModel.java @@ -15,6 +15,7 @@ import org.javarosa.core.model.SelectChoice; import org.javarosa.core.model.actions.recordaudio.RecordAudioActionHandler; import org.javarosa.core.model.data.IAnswerData; +import org.javarosa.core.reference.ReferenceManager; import org.javarosa.form.api.FormEntryController; import org.javarosa.form.api.FormEntryPrompt; import org.javarosa.xpath.parser.XPathSyntaxException; @@ -43,13 +44,15 @@ import java.util.Map; import java.util.function.Supplier; +import kotlin.Pair; + public class FormEntryViewModel extends ViewModel implements SelectChoiceLoader { private final Supplier clock; private final MutableLiveData error = new MutableLiveData<>(null); private final MutableNonNullLiveData hasBackgroundRecording = new MutableNonNullLiveData<>(false); - private final MutableLiveData currentIndex = new MutableLiveData<>(null); + private final MutableLiveData> currentIndex = new MutableLiveData<>(null); private final MutableLiveData> validationResult = new MutableLiveData<>(new Consumable<>(null)); @NonNull @@ -102,7 +105,7 @@ public FormController getFormController() { return formController; } - public LiveData getCurrentIndex() { + public LiveData> getCurrentIndex() { return currentIndex; } @@ -126,7 +129,7 @@ public void promptForNewRepeat() { jumpBackIndex = formController.getFormIndex(); jumpToNewRepeat(); - updateIndex(false); + updateIndex(false, null); } public void jumpToNewRepeat() { @@ -154,7 +157,7 @@ public void addRepeat() { } } - updateIndex(false); + updateIndex(false, null); } public void cancelRepeatPrompt() { @@ -174,7 +177,7 @@ public void cancelRepeatPrompt() { } } - updateIndex(true); + updateIndex(true, null); return null; }, ignored -> {}); } @@ -204,7 +207,7 @@ public void moveForward(HashMap answers, Boolean evaluat try { formController.stepToNextScreenEvent(); formController.getAuditEventLogger().flush(); // Close events waiting for an end time - updateIndex(true); + updateIndex(true, null); } catch (JavaRosaException e) { error.postValue(new FormError.NonFatal(e.getCause().getMessage())); } @@ -223,7 +226,7 @@ public void moveBackward(HashMap answers) { try { formController.stepToPreviousScreenEvent(); formController.getAuditEventLogger().flush(); // Close events waiting for an end time - updateIndex(true); + updateIndex(true, null); } catch (JavaRosaException e) { error.postValue(new FormError.NonFatal(e.getCause().getMessage())); } @@ -307,17 +310,17 @@ protected void onCleared() { */ @Deprecated public void refreshSync() { - updateIndex(false); + updateIndex(false, null); } public void refresh() { worker.immediate((Supplier) () -> { - updateIndex(true); + updateIndex(true, null); return null; }, ignored -> {}); } - private void updateIndex(boolean isAsync) { + private void updateIndex(boolean isAsync, @Nullable FailedValidationResult validationResult) { choices.clear(); if (formController != null) { @@ -347,15 +350,16 @@ private void updateIndex(boolean isAsync) { AuditUtils.logCurrentScreen(formController, formController.getAuditEventLogger(), clock.get()); if (isAsync) { - currentIndex.postValue(formController.getFormIndex()); + currentIndex.postValue(new Pair<>(formController.getFormIndex(), validationResult)); } else { - currentIndex.setValue(formController.getFormIndex()); + currentIndex.setValue(new Pair<>(formController.getFormIndex(), validationResult)); } } } public void exit() { formSessionRepository.clear(sessionId); + ReferenceManager.instance().reset(); } public void validate() { @@ -363,20 +367,20 @@ public void validate() { () -> { ValidationResult result = null; try { - result = formController.validateAnswers(true, true); + result = formController.validateAnswers(true); } catch (JavaRosaException e) { error.postValue(new FormError.NonFatal(e.getMessage())); } // JavaRosa moves to the index where the contraint failed if (result instanceof FailedValidationResult) { - updateIndex(true); + updateIndex(true, (FailedValidationResult) result); + } else { + validationResult.postValue(new Consumable<>(result)); } - return result; - }, result -> { - validationResult.setValue(new Consumable<>(result)); - } + return null; + }, ignored -> {} ); } diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/FormIndexAnimationHandler.kt b/collect_app/src/main/java/org/odk/collect/android/formentry/FormIndexAnimationHandler.kt index 2af7b0f3e9f..069c3c694f2 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/FormIndexAnimationHandler.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/FormIndexAnimationHandler.kt @@ -14,11 +14,7 @@ class FormIndexAnimationHandler(private val listener: Listener) { private var lastIndex: FormIndex? = null - fun handle(index: FormIndex?) { - if (index == null) { - return - } - + fun handle(index: FormIndex) { if (lastIndex == null) { listener.onScreenRefresh() } else { diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/FormSessionRepository.kt b/collect_app/src/main/java/org/odk/collect/android/formentry/FormSessionRepository.kt index c8c27acdf74..4a2db0acf60 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/FormSessionRepository.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/FormSessionRepository.kt @@ -19,6 +19,8 @@ interface FormSessionRepository { } fun set(id: String, formController: FormController, form: Form, instance: Instance?) + + fun update(id: String, instance: Instance?) } class AppStateFormSessionRepository(application: Application) : FormSessionRepository { @@ -37,6 +39,13 @@ class AppStateFormSessionRepository(application: Application) : FormSessionRepos getLiveData(id).value = FormSession(formController, form, instance) } + override fun update(id: String, instance: Instance?) { + val liveData = getLiveData(id) + liveData.value?.let { + liveData.value = it.copy(instance = instance) + } + } + /** * Ensure the object gets completely removed. Simply nullifying it might cause memory leaks. * See: https://github.com/getodk/collect/issues/5777 diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/ODKView.java b/collect_app/src/main/java/org/odk/collect/android/formentry/ODKView.java index 1cc8361a1f1..4f21dec5e79 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/ODKView.java +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/ODKView.java @@ -42,8 +42,6 @@ import androidx.core.widget.NestedScrollView; import androidx.lifecycle.LifecycleOwner; -import com.google.android.material.button.MaterialButton; - import org.javarosa.core.model.Constants; import org.javarosa.core.model.FormIndex; import org.javarosa.core.model.IFormElement; @@ -84,6 +82,7 @@ import org.odk.collect.android.widgets.utilities.WaitingForDataRegistry; import org.odk.collect.androidshared.system.IntentLauncher; import org.odk.collect.androidshared.ui.ToastUtils; +import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickSafeMaterialButton; import org.odk.collect.audioclips.PlaybackFailedException; import org.odk.collect.audiorecorder.recording.AudioRecorder; import org.odk.collect.permissions.PermissionListener; @@ -425,9 +424,9 @@ private void addIntentLaunchButton(Context context, FormEntryPrompt[] questionPr errorString = (v != null) ? v : context.getString(org.odk.collect.strings.R.string.no_app); // set button formatting - MaterialButton launchIntentButton = findViewById(R.id.launchIntentButton); + MultiClickSafeMaterialButton launchIntentButton = findViewById(R.id.launchIntentButton); launchIntentButton.setText(buttonText); - launchIntentButton.setTextSize(TypedValue.COMPLEX_UNIT_DIP, QuestionFontSizeUtils.getQuestionFontSize() + 2); + launchIntentButton.setTextSize(QuestionFontSizeUtils.getFontSize(settingsProvider.getUnprotectedSettings(), QuestionFontSizeUtils.FontSize.BODY_LARGE)); launchIntentButton.setVisibility(VISIBLE); launchIntentButton.setOnClickListener(view -> { String intentName = ExternalAppsUtils.extractIntentName(intentString); diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/SwipeHandler.kt b/collect_app/src/main/java/org/odk/collect/android/formentry/SwipeHandler.kt index e78ad4482b0..eb65a560136 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/SwipeHandler.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/SwipeHandler.kt @@ -5,7 +5,7 @@ import android.view.GestureDetector import android.view.MotionEvent import android.widget.FrameLayout import androidx.core.widget.NestedScrollView -import org.odk.collect.android.utilities.FlingRegister +import org.odk.collect.android.utilities.ActionRegister import org.odk.collect.androidshared.utils.ScreenUtils import org.odk.collect.settings.keys.ProjectKeys import org.odk.collect.shared.settings.Settings @@ -64,7 +64,7 @@ class SwipeHandler(context: Context, generalSettings: Settings) { return false } - FlingRegister.flingDetected() + ActionRegister.actionDetected() if (e1 != null && generalSettings.getString(ProjectKeys.KEY_NAVIGATION)!!.contains(ProjectKeys.NAVIGATION_SWIPE) && allowSwiping) { // Looks for user swipes. If the user has swiped, move to the appropriate screen. diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/backgroundlocation/BackgroundLocationHelper.java b/collect_app/src/main/java/org/odk/collect/android/formentry/backgroundlocation/BackgroundLocationHelper.java index 9f41a88125b..f6b49ef9790 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/backgroundlocation/BackgroundLocationHelper.java +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/backgroundlocation/BackgroundLocationHelper.java @@ -6,6 +6,8 @@ import org.odk.collect.android.activities.FormFillingActivity; import org.odk.collect.android.application.Collect; +import org.odk.collect.android.formentry.FormSession; +import org.odk.collect.android.formentry.FormSessionRepository; import org.odk.collect.android.formentry.audit.AuditConfig; import org.odk.collect.android.formentry.audit.AuditEvent; import org.odk.collect.android.javarosawrapper.FormController; @@ -13,7 +15,7 @@ import org.odk.collect.permissions.PermissionsProvider; import org.odk.collect.shared.settings.Settings; -import java.util.function.Supplier; +import javax.annotation.Nullable; /** * Wrapper on resources needed by {@link BackgroundLocationManager} to make testing easier. @@ -29,12 +31,19 @@ public class BackgroundLocationHelper { private final PermissionsProvider permissionsProvider; private final Settings generalSettings; - private final Supplier formControllerProvider; - - public BackgroundLocationHelper(PermissionsProvider permissionsProvider, Settings generalSettings, Supplier formControllerProvider) { + private final FormSessionRepository formSessionRepository; + private final String sessionId; + + public BackgroundLocationHelper( + PermissionsProvider permissionsProvider, + Settings generalSettings, + FormSessionRepository formSessionRepository, + String sessionId + ) { this.permissionsProvider = permissionsProvider; this.generalSettings = generalSettings; - this.formControllerProvider = formControllerProvider; + this.formSessionRepository = formSessionRepository; + this.sessionId = sessionId; } boolean isAndroidLocationPermissionGranted() { @@ -53,7 +62,7 @@ boolean arePlayServicesAvailable() { * @return true if the global form controller has been initialized. */ boolean isCurrentFormSet() { - return formControllerProvider.get() != null; + return getFormController() != null; } /** @@ -62,7 +71,7 @@ boolean isCurrentFormSet() { * Precondition: the global form controller has been initialized. */ boolean currentFormCollectsBackgroundLocation() { - return formControllerProvider.get().currentFormCollectsBackgroundLocation(); + return getFormController().currentFormCollectsBackgroundLocation(); } /** @@ -72,7 +81,7 @@ boolean currentFormCollectsBackgroundLocation() { * Precondition: the global form controller has been initialized. */ boolean currentFormAuditsLocation() { - return formControllerProvider.get().currentFormAuditsLocation(); + return getFormController().currentFormAuditsLocation(); } /** @@ -81,7 +90,7 @@ boolean currentFormAuditsLocation() { * Precondition: the global form controller has been initialized. */ AuditConfig getCurrentFormAuditConfig() { - return formControllerProvider.get().getSubmissionMetadata().auditConfig; + return getFormController().getSubmissionMetadata().auditConfig; } /** @@ -90,7 +99,7 @@ AuditConfig getCurrentFormAuditConfig() { * Precondition: the global form controller has been initialized. */ void logAuditEvent(AuditEvent.AuditEventType eventType) { - formControllerProvider.get().getAuditEventLogger().logEvent(eventType, false, System.currentTimeMillis()); + getFormController().getAuditEventLogger().logEvent(eventType, false, System.currentTimeMillis()); } /** @@ -99,6 +108,12 @@ void logAuditEvent(AuditEvent.AuditEventType eventType) { * Precondition: the global form controller has been initialized. */ void provideLocationToAuditLogger(Location location) { - formControllerProvider.get().getAuditEventLogger().addLocation(location); + getFormController().getAuditEventLogger().addLocation(location); + } + + @Nullable + private FormController getFormController() { + FormSession formSession = formSessionRepository.get(sessionId).getValue(); + return formSession == null ? null : formSession.getFormController(); } } diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/questions/AudioVideoImageTextLabel.java b/collect_app/src/main/java/org/odk/collect/android/formentry/questions/AudioVideoImageTextLabel.java index 382c8dcc4ee..34ebd91fb43 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/questions/AudioVideoImageTextLabel.java +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/questions/AudioVideoImageTextLabel.java @@ -38,7 +38,6 @@ import org.odk.collect.android.utilities.FormEntryPromptUtils; import org.odk.collect.android.utilities.MediaUtils; import org.odk.collect.android.utilities.ScreenContext; -import org.odk.collect.android.utilities.ThemeUtils; import org.odk.collect.audioclips.Clip; import org.odk.collect.imageloader.ImageLoader; @@ -133,7 +132,6 @@ public void setVideo(@NonNull File videoFile) { public void setPlayTextColor(int textColor) { playTextColor = textColor; - binding.audioButton.setColors(new ThemeUtils(getContext()).getColorOnSurface(), playTextColor); } public void setMediaUtils(MediaUtils mediaUtils) { diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/questions/WidgetViewUtils.kt b/collect_app/src/main/java/org/odk/collect/android/formentry/questions/WidgetViewUtils.kt deleted file mode 100644 index 629cfc5caed..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/questions/WidgetViewUtils.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.odk.collect.android.formentry.questions - -import android.content.Context -import android.util.TypedValue -import android.view.LayoutInflater -import android.view.View -import android.widget.Button -import android.widget.ImageView -import android.widget.TableLayout -import android.widget.TextView -import androidx.annotation.IdRes -import com.google.android.material.button.MaterialButton -import org.odk.collect.android.R -import org.odk.collect.android.utilities.ThemeUtils -import org.odk.collect.android.widgets.QuestionWidget -import org.odk.collect.android.widgets.interfaces.ButtonClickListener -import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickGuard.allowClick - -object WidgetViewUtils { - @JvmStatic - fun createAnswerTextView(context: Context, text: CharSequence?, answerFontSize: Int): TextView { - return TextView(context).apply { - id = R.id.answer_text - setTextColor(ThemeUtils(context).colorOnSurface) - setTextSize(TypedValue.COMPLEX_UNIT_DIP, answerFontSize.toFloat()) - setPadding(20, 20, 20, 20) - setText(text) - } - } - - @JvmStatic - fun createAnswerImageView(context: Context): ImageView { - return ImageView(context).apply { - id = View.generateViewId() - tag = "ImageView" - setPadding(10, 10, 10, 10) - adjustViewBounds = true - } - } - - @JvmStatic - @JvmOverloads - fun createSimpleButton(context: Context, readOnly: Boolean, text: String?, listener: ButtonClickListener, addMargin: Boolean, @IdRes withId: Int = R.id.simple_button): Button { - val button = LayoutInflater - .from(context) - .inflate(R.layout.widget_answer_button, null, false) as MaterialButton - if (readOnly) { - button.visibility = View.GONE - } else { - button.id = withId - button.text = text - button.contentDescription = text - if (addMargin) { - val params = TableLayout.LayoutParams() - val marginStandard = context.resources.getDimension(org.odk.collect.androidshared.R.dimen.margin_standard).toInt() - params.setMargins(0, marginStandard, 0, 0) - button.layoutParams = params - } - button.setOnClickListener { v: View? -> - if (allowClick(QuestionWidget::class.java.name)) { - listener.onButtonClick(withId) - } - } - } - return button - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/formentry/saving/FormSaveViewModel.java b/collect_app/src/main/java/org/odk/collect/android/formentry/saving/FormSaveViewModel.java index 4d473e28f1d..f31e6be7281 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formentry/saving/FormSaveViewModel.java +++ b/collect_app/src/main/java/org/odk/collect/android/formentry/saving/FormSaveViewModel.java @@ -22,6 +22,7 @@ import org.odk.collect.android.formentry.FormSession; import org.odk.collect.android.formentry.audit.AuditEvent; import org.odk.collect.android.formentry.audit.AuditUtils; +import org.odk.collect.android.instancemanagement.InstancesDataService; import org.odk.collect.android.javarosawrapper.FormController; import org.odk.collect.android.projects.ProjectsDataService; import org.odk.collect.android.tasks.SaveFormToDisk; @@ -34,8 +35,10 @@ import org.odk.collect.async.Scheduler; import org.odk.collect.audiorecorder.recording.AudioRecorder; import org.odk.collect.entities.EntitiesRepository; +import org.odk.collect.forms.Form; import org.odk.collect.forms.instances.Instance; import org.odk.collect.forms.instances.InstancesRepository; +import org.odk.collect.forms.savepoints.SavepointsRepository; import org.odk.collect.material.MaterialProgressDialogFragment; import org.odk.collect.shared.strings.Md5; import org.odk.collect.utilities.Result; @@ -84,10 +87,18 @@ public class FormSaveViewModel extends ViewModel implements MaterialProgressDial private final ProjectsDataService projectsDataService; private final EntitiesRepository entitiesRepository; private final InstancesRepository instancesRepository; + private final SavepointsRepository savepointsRepository; + private Form form; private Instance instance; private final Cancellable formSessionObserver; - - public FormSaveViewModel(SavedStateHandle stateHandle, Supplier clock, FormSaver formSaver, MediaUtils mediaUtils, Scheduler scheduler, AudioRecorder audioRecorder, ProjectsDataService projectsDataService, LiveData formSession, EntitiesRepository entitiesRepository, InstancesRepository instancesRepository) { + private InstancesDataService instancesDataService; + + public FormSaveViewModel(SavedStateHandle stateHandle, Supplier clock, FormSaver formSaver, + MediaUtils mediaUtils, Scheduler scheduler, AudioRecorder audioRecorder, + ProjectsDataService projectsDataService, LiveData formSession, + EntitiesRepository entitiesRepository, InstancesRepository instancesRepository, + SavepointsRepository savepointsRepository, InstancesDataService instancesDataService + ) { this.stateHandle = stateHandle; this.clock = clock; this.formSaver = formSaver; @@ -97,6 +108,8 @@ public FormSaveViewModel(SavedStateHandle stateHandle, Supplier clock, For this.projectsDataService = projectsDataService; this.entitiesRepository = entitiesRepository; this.instancesRepository = instancesRepository; + this.savepointsRepository = savepointsRepository; + this.instancesDataService = instancesDataService; if (stateHandle.get(ORIGINAL_FILES) != null) { originalFiles = stateHandle.get(ORIGINAL_FILES); @@ -107,6 +120,7 @@ public FormSaveViewModel(SavedStateHandle stateHandle, Supplier clock, For formSessionObserver = LiveDataUtils.observe(formSession, it -> { formController = it.getFormController(); + form = it.getForm(); instance = it.getInstance(); }); } @@ -150,7 +164,8 @@ public void ignoreChanges() { formController.getAuditEventLogger().logEvent(AuditEvent.AuditEventType.FORM_EXIT, true, System.currentTimeMillis()); if (formController.getInstanceFile() != null) { - SaveFormToDisk.removeSavepointFiles(formController.getInstanceFile().getName()); + removeSavepoint(form.getDbId(), instance != null ? instance.getDbId() : null); + SaveFormToDisk.removeIndexFile(formController.getInstanceFile().getName()); // if it's not already saved, erase everything if (!InstancesDaoHelper.isInstanceAvailable(getAbsoluteInstancePath())) { @@ -241,6 +256,10 @@ private void handleTaskResult(SaveToDiskResult taskResult, SaveRequest saveReque return; } + if (taskResult.getSaveResult() == SAVED || taskResult.getSaveResult() == SAVED_AND_EXIT) { + removeSavepoint(form.getDbId(), instance != null ? instance.getDbId() : null); + } + instance = taskResult.getInstance(); switch (taskResult.getSaveResult()) { @@ -252,6 +271,8 @@ private void handleTaskResult(SaveToDiskResult taskResult, SaveRequest saveReque if (saveRequest.shouldFinalize) { formController.getAuditEventLogger().logEvent(AuditEvent.AuditEventType.FORM_EXIT, false, clock.get()); formController.getAuditEventLogger().logEvent(AuditEvent.AuditEventType.FORM_FINALIZE, true, clock.get()); + + instancesDataService.instanceFinalized(projectsDataService.getCurrentProject().getUuid(), form); } else { formController.getAuditEventLogger().logEvent(AuditEvent.AuditEventType.FORM_EXIT, true, clock.get()); } @@ -411,6 +432,14 @@ public Instance getInstance() { return instance; } + private void removeSavepoint(long formDbId, @Nullable Long instanceDbId) { + scheduler.immediate(() -> { + savepointsRepository.delete(formDbId, instanceDbId); + return null; + }, result -> { + }); + } + public static class SaveResult { private final State state; private final String message; diff --git a/collect_app/src/main/java/org/odk/collect/android/formhierarchy/FormHierarchyActivity.java b/collect_app/src/main/java/org/odk/collect/android/formhierarchy/FormHierarchyActivity.java index 822fc242110..d601e58f94d 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formhierarchy/FormHierarchyActivity.java +++ b/collect_app/src/main/java/org/odk/collect/android/formhierarchy/FormHierarchyActivity.java @@ -54,6 +54,7 @@ import org.odk.collect.android.formentry.ODKView; import org.odk.collect.android.formentry.repeats.DeleteRepeatDialogFragment; import org.odk.collect.android.injection.DaggerUtils; +import org.odk.collect.android.instancemanagement.InstancesDataService; import org.odk.collect.android.instancemanagement.autosend.AutoSendSettingsProvider; import org.odk.collect.android.javarosawrapper.FormController; import org.odk.collect.android.javarosawrapper.JavaRosaFormController; @@ -64,12 +65,13 @@ import org.odk.collect.android.utilities.HtmlUtils; import org.odk.collect.android.utilities.InstancesRepositoryProvider; import org.odk.collect.android.utilities.MediaUtils; -import org.odk.collect.android.views.EmptyListView; +import org.odk.collect.android.utilities.SavepointsRepositoryProvider; import org.odk.collect.androidshared.ui.DialogFragmentUtils; import org.odk.collect.androidshared.ui.FragmentFactoryBuilder; import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickGuard; import org.odk.collect.async.Scheduler; import org.odk.collect.audiorecorder.recording.AudioRecorder; +import org.odk.collect.lists.EmptyListView; import org.odk.collect.location.LocationClient; import org.odk.collect.permissions.PermissionsChecker; import org.odk.collect.permissions.PermissionsProvider; @@ -190,6 +192,12 @@ public class FormHierarchyActivity extends LocalizedActivity implements DeleteRe @Inject public FormsRepositoryProvider formsRepositoryProvider; + @Inject + public SavepointsRepositoryProvider savepointsRepositoryProvider; + + @Inject + public InstancesDataService instancesDataService; + protected final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { @@ -223,8 +231,10 @@ public void onCreate(Bundle savedInstanceState) { autoSendSettingsProvider, formsRepositoryProvider, instancesRepositoryProvider, + savepointsRepositoryProvider, new QRCodeCreatorImpl(), - new HtmlPrinter() + new HtmlPrinter(), + instancesDataService ); this.getSupportFragmentManager().setFragmentFactory(new FragmentFactoryBuilder() @@ -327,7 +337,7 @@ private void updateOptionsMenu() { boolean isAtBeginning = screenIndex.isBeginningOfFormIndex() && !shouldShowRepeatGroupPicker(); boolean shouldShowPicker = shouldShowRepeatGroupPicker(); - boolean isInRepeat = formController.indexContainsRepeatableGroup(); + boolean isInRepeat = formController.indexContainsRepeatableGroup(screenIndex); boolean isGroupSizeLocked = shouldShowPicker ? isGroupSizeLocked(repeatGroupPickerIndex) : isGroupSizeLocked(screenIndex); @@ -488,15 +498,7 @@ protected void goUpLevel() { */ private CharSequence getCurrentPath() { FormController formController = formEntryViewModel.getFormController(); - FormIndex index = formController.getFormIndex(); - - // Step out to the enclosing group if the current index is something - // we don't want to display in the path (e.g. a question name or the - // very first group in a form which is auto-entered). - if (formController.getEvent(index) == FormEntryController.EVENT_QUESTION - || getPreviousLevel(index) == null) { - index = getPreviousLevel(index); - } + FormIndex index = screenIndex; List groups = new ArrayList<>(); @@ -616,7 +618,7 @@ private void refreshView(boolean isGoingUp) { groupPathTextView.setVisibility(View.VISIBLE); groupPathTextView.setText(getCurrentPath()); - if (formController.indexContainsRepeatableGroup() || shouldShowRepeatGroupPicker()) { + if (formController.indexContainsRepeatableGroup(screenIndex) || shouldShowRepeatGroupPicker()) { groupIcon.setImageDrawable(ContextCompat.getDrawable(this, R.drawable.ic_repeat)); } else { groupIcon.setImageDrawable(ContextCompat.getDrawable(this, R.drawable.ic_folder_open)); diff --git a/collect_app/src/main/java/org/odk/collect/android/formhierarchy/QuestionAnswerProcessor.kt b/collect_app/src/main/java/org/odk/collect/android/formhierarchy/QuestionAnswerProcessor.kt index d93c3e68981..3604bda99a7 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formhierarchy/QuestionAnswerProcessor.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formhierarchy/QuestionAnswerProcessor.kt @@ -20,11 +20,19 @@ import java.util.Date object QuestionAnswerProcessor { @JvmStatic fun getQuestionAnswer(fep: FormEntryPrompt, context: Context, formController: FormController): String { - val appearance: String? = fep.question.appearanceAttr + val appearance: String? = fep.appearanceHint if (appearance == Appearances.PRINTER) { return "" } + if (!fep.answerText.isNullOrBlank() && + Appearances.isMasked(fep) && + fep.controlType == Constants.CONTROL_INPUT && + fep.dataType == Constants.DATATYPE_TEXT + ) { + return "••••••••••" + } + val data = fep.answerValue if (data is MultipleItemsData) { val answerText = StringBuilder() diff --git a/collect_app/src/main/java/org/odk/collect/android/formlists/FormListRecyclerView.kt b/collect_app/src/main/java/org/odk/collect/android/formlists/FormListRecyclerView.kt deleted file mode 100644 index f1217951166..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/formlists/FormListRecyclerView.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.odk.collect.android.formlists - -import android.content.Context -import android.util.AttributeSet -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import org.odk.collect.android.R - -class FormListRecyclerView(context: Context, attrs: AttributeSet?) : RecyclerView(context, attrs) { - - constructor(context: Context) : this(context, null) - - init { - layoutManager = LinearLayoutManager(context) - val itemDecoration = DividerItemDecoration(getContext(), DividerItemDecoration.VERTICAL) - itemDecoration.setDrawable(ContextCompat.getDrawable(getContext(), R.drawable.list_item_divider)!!) - addItemDecoration(itemDecoration) - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListActivity.kt b/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListActivity.kt index d5254f0017a..2b17b3226a8 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListActivity.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListActivity.kt @@ -7,16 +7,18 @@ import android.view.View import android.widget.ProgressBar import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import org.odk.collect.android.R import org.odk.collect.android.activities.FormMapActivity import org.odk.collect.android.formmanagement.FormFillingIntentFactory import org.odk.collect.android.injection.DaggerUtils import org.odk.collect.android.preferences.dialogs.ServerAuthDialogFragment -import org.odk.collect.android.views.EmptyListView -import org.odk.collect.androidshared.network.NetworkStateProvider import org.odk.collect.androidshared.ui.DialogFragmentUtils import org.odk.collect.androidshared.ui.SnackbarUtils +import org.odk.collect.async.network.NetworkStateProvider +import org.odk.collect.lists.EmptyListView +import org.odk.collect.lists.RecyclerViewUtils import org.odk.collect.permissions.PermissionListener import org.odk.collect.permissions.PermissionsProvider import org.odk.collect.strings.localization.LocalizedActivity @@ -53,7 +55,10 @@ class BlankFormListActivity : LocalizedActivity(), OnFormItemClickListener { val menuProvider = BlankFormListMenuProvider(this, viewModel, networkStateProvider) addMenuProvider(menuProvider, this) - findViewById(R.id.form_list).adapter = adapter + val list = findViewById(R.id.form_list) + list.layoutManager = LinearLayoutManager(this) + list.addItemDecoration(RecyclerViewUtils.verticalLineDivider(this)) + list.adapter = adapter initObservers() } diff --git a/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListAdapter.kt b/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListAdapter.kt index 3c85a016a97..b6786a8b440 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListAdapter.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListAdapter.kt @@ -7,22 +7,24 @@ import android.widget.Button import androidx.recyclerview.widget.RecyclerView import org.odk.collect.android.R import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickGuard +import org.odk.collect.lists.RecyclerViewUtils.matchParentWidth class BlankFormListAdapter( val listener: OnFormItemClickListener -) : RecyclerView.Adapter() { +) : RecyclerView.Adapter() { private var formItems = emptyList() - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BlankFormListItemViewHolder { - return BlankFormListItemViewHolder(parent).also { - it.setTrailingView(R.layout.map_button) - } + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BlankFormListItemWithMapViewHolder { + return BlankFormListItemWithMapViewHolder(parent) } - override fun onBindViewHolder(holder: BlankFormListItemViewHolder, position: Int) { + override fun onBindViewHolder(holder: BlankFormListItemWithMapViewHolder, position: Int) { val item = formItems[position] - holder.blankFormListItem = item + holder.setItem(item) holder.itemView.setOnClickListener { if (MultiClickGuard.allowClick(javaClass.name)) { @@ -51,6 +53,20 @@ class BlankFormListAdapter( this.formItems = blankFormItems.toList() notifyDataSetChanged() } + + class BlankFormListItemWithMapViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder( + BlankFormListItemView(parent.context).also { + it.setTrailingView(R.layout.map_button) + } + ) { + fun setItem(item: BlankFormListItem) { + (itemView as BlankFormListItemView).setItem(item) + } + + init { + matchParentWidth() + } + } } interface OnFormItemClickListener { diff --git a/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListItemView.kt b/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListItemView.kt new file mode 100644 index 00000000000..d84af13bc1b --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListItemView.kt @@ -0,0 +1,54 @@ +package org.odk.collect.android.formlists.blankformlist + +import android.content.Context +import android.view.LayoutInflater +import android.widget.FrameLayout +import org.odk.collect.android.databinding.BlankFormListItemBinding +import org.odk.collect.strings.R.string +import timber.log.Timber +import java.text.SimpleDateFormat +import java.util.Locale + +class BlankFormListItemView(context: Context) : FrameLayout(context) { + + val binding = BlankFormListItemBinding.inflate(LayoutInflater.from(context), this, true) + + fun setItem(item: BlankFormListItem) { + binding.formTitle.text = item.formName + + binding.formVersion.text = + binding.root.context.getString( + string.version_number, + item.formVersion + ) + binding.formVersion.visibility = + if (item.formVersion.isNotBlank()) VISIBLE else GONE + + binding.formId.text = + binding.root.context.getString( + string.id_number, + item.formId + ) + + binding.formHistory.text = try { + if (item.dateOfLastDetectedAttachmentsUpdate != null) { + SimpleDateFormat( + binding.root.context.getString(string.updated_on_date_at_time), + Locale.getDefault() + ).format(item.dateOfLastDetectedAttachmentsUpdate) + } else { + SimpleDateFormat( + binding.root.context.getString(string.added_on_date_at_time), + Locale.getDefault() + ).format(item.dateOfCreation) + } + } catch (e: IllegalArgumentException) { + Timber.e(e) + "" + } + } + + fun setTrailingView(layoutId: Int) { + inflate(context, layoutId, binding.trailingView) + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListItemViewHolder.kt b/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListItemViewHolder.kt deleted file mode 100644 index cb02eb7cb96..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListItemViewHolder.kt +++ /dev/null @@ -1,61 +0,0 @@ -package org.odk.collect.android.formlists.blankformlist - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import androidx.recyclerview.widget.RecyclerView -import org.odk.collect.android.R -import org.odk.collect.android.databinding.BlankFormListItemBinding -import timber.log.Timber -import java.text.SimpleDateFormat -import java.util.Locale - -class BlankFormListItemViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder( - BlankFormListItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ).root -) { - - val binding = BlankFormListItemBinding.bind(this.itemView) - - var blankFormListItem: BlankFormListItem? = null - set(value) { - field = value - - field?.let { - binding.formTitle.text = it.formName - - binding.formVersion.text = - binding.root.context.getString(org.odk.collect.strings.R.string.version_number, it.formVersion) - binding.formVersion.visibility = - if (it.formVersion.isNotBlank()) View.VISIBLE else View.GONE - - binding.formId.text = - binding.root.context.getString(org.odk.collect.strings.R.string.id_number, it.formId) - - binding.formHistory.text = try { - if (it.dateOfLastDetectedAttachmentsUpdate != null) { - SimpleDateFormat( - binding.root.context.getString(org.odk.collect.strings.R.string.updated_on_date_at_time), - Locale.getDefault() - ).format(it.dateOfLastDetectedAttachmentsUpdate) - } else { - SimpleDateFormat( - binding.root.context.getString(org.odk.collect.strings.R.string.added_on_date_at_time), - Locale.getDefault() - ).format(it.dateOfCreation) - } - } catch (e: IllegalArgumentException) { - Timber.e(e) - "" - } - } - } - - fun setTrailingView(layoutId: Int) { - FrameLayout.inflate(itemView.context, layoutId, binding.trailingView) - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListMenuProvider.kt b/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListMenuProvider.kt index 1741d8e6918..502ff038e32 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListMenuProvider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListMenuProvider.kt @@ -9,9 +9,9 @@ import androidx.core.view.MenuProvider import org.odk.collect.android.R import org.odk.collect.android.formlists.sorting.FormListSortingBottomSheetDialog import org.odk.collect.android.formlists.sorting.FormListSortingOption -import org.odk.collect.androidshared.network.NetworkStateProvider import org.odk.collect.androidshared.ui.ToastUtils import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickGuard +import org.odk.collect.async.network.NetworkStateProvider class BlankFormListMenuProvider( private val activity: ComponentActivity, @@ -35,7 +35,7 @@ class BlankFormListMenuProvider( } override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.form_list_menu, menu) + inflater.inflate(R.menu.blank_form_list_menu, menu) menu.findItem(R.id.menu_filter).apply { setOnActionExpandListener(object : MenuItem.OnActionExpandListener { @@ -97,34 +97,15 @@ class BlankFormListMenuProvider( } true } + R.id.menu_sort -> { FormListSortingBottomSheetDialog( activity, - listOf( - FormListSortingOption( - R.drawable.ic_sort_by_alpha, - org.odk.collect.strings.R.string.sort_by_name_asc - ), - FormListSortingOption( - R.drawable.ic_sort_by_alpha, - org.odk.collect.strings.R.string.sort_by_name_desc - ), - FormListSortingOption( - R.drawable.ic_access_time, - org.odk.collect.strings.R.string.sort_by_date_desc - ), - FormListSortingOption( - R.drawable.ic_access_time, - org.odk.collect.strings.R.string.sort_by_date_asc - ), - FormListSortingOption( - R.drawable.ic_sort_by_last_saved, - org.odk.collect.strings.R.string.sort_by_last_saved - ) - ), - viewModel.sortingOrder + BlankFormListViewModel.SortOrder.entries.map { getForListSortingOption(it) }, + viewModel.sortingOrder.ordinal ) { newSortingOrder -> - viewModel.sortingOrder = newSortingOrder + viewModel.sortingOrder = + BlankFormListViewModel.SortOrder.entries[newSortingOrder] }.show() true @@ -132,4 +113,32 @@ class BlankFormListMenuProvider( else -> false } } + + private fun getForListSortingOption(it: BlankFormListViewModel.SortOrder) = + when (it) { + BlankFormListViewModel.SortOrder.NAME_ASC -> FormListSortingOption( + R.drawable.ic_sort_by_alpha, + org.odk.collect.strings.R.string.sort_by_name_asc + ) + + BlankFormListViewModel.SortOrder.NAME_DESC -> FormListSortingOption( + R.drawable.ic_sort_by_alpha, + org.odk.collect.strings.R.string.sort_by_name_desc + ) + + BlankFormListViewModel.SortOrder.DATE_DESC -> FormListSortingOption( + R.drawable.ic_access_time, + org.odk.collect.strings.R.string.sort_by_date_desc + ) + + BlankFormListViewModel.SortOrder.DATE_ASC -> FormListSortingOption( + R.drawable.ic_access_time, + org.odk.collect.strings.R.string.sort_by_date_asc + ) + + BlankFormListViewModel.SortOrder.LAST_SAVED -> FormListSortingOption( + R.drawable.ic_sort_by_last_saved, + org.odk.collect.strings.R.string.sort_by_last_saved + ) + } } diff --git a/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListViewModel.kt b/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListViewModel.kt index 8243dbce292..8fdebcd7dcf 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListViewModel.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/BlankFormListViewModel.kt @@ -11,14 +11,14 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import org.odk.collect.android.formmanagement.FormsDataService -import org.odk.collect.android.preferences.utilities.FormUpdateMode -import org.odk.collect.android.preferences.utilities.SettingsUtils import org.odk.collect.async.Scheduler import org.odk.collect.async.flowOnBackground import org.odk.collect.forms.Form import org.odk.collect.forms.FormSourceException import org.odk.collect.forms.FormSourceException.AuthRequired import org.odk.collect.forms.instances.InstancesRepository +import org.odk.collect.settings.enums.FormUpdateMode +import org.odk.collect.settings.enums.StringIdEnumUtils.getFormUpdateMode import org.odk.collect.settings.keys.ProjectKeys import org.odk.collect.shared.settings.Settings @@ -33,7 +33,7 @@ class BlankFormListViewModel( ) : ViewModel() { private val _filterText = MutableStateFlow("") - private val _sortingOrder = MutableStateFlow(generalSettings.getInt("formChooserListSortingOrder")) + private val _sortingOrder = MutableStateFlow(getSortOrder()) private val filteredForms = formsDataService.getForms(projectId) .combine(_filterText) { forms, filter -> Pair(forms, filter) @@ -49,12 +49,12 @@ class BlankFormListViewModel( val syncResult: LiveData = formsDataService.getDiskError(projectId) val isLoading: LiveData = formsDataService.isSyncing(projectId) - var sortingOrder: Int = generalSettings.getInt("formChooserListSortingOrder") - get() { return generalSettings.getInt("formChooserListSortingOrder") } + var sortingOrder: SortOrder = getSortOrder() + get() { return getSortOrder() } set(value) { field = value - generalSettings.save("formChooserListSortingOrder", value) + generalSettings.save(ProjectKeys.KEY_BLANK_FORM_SORT_ORDER, value.ordinal) _sortingOrder.value = value } @@ -85,10 +85,7 @@ class BlankFormListViewModel( } fun isMatchExactlyEnabled(): Boolean { - return SettingsUtils.getFormUpdateMode( - application, - generalSettings - ) == FormUpdateMode.MATCH_EXACTLY + return generalSettings.getFormUpdateMode(application) == FormUpdateMode.MATCH_EXACTLY } fun isOutOfSyncWithServer(): LiveData { @@ -120,7 +117,7 @@ class BlankFormListViewModel( private fun filterAndSortForms( forms: List
, - sort: Int?, + sort: SortOrder, filter: String ): List { var newListOfForms = forms @@ -141,23 +138,23 @@ class BlankFormListViewModel( } return when (sort) { - 0 -> newListOfForms.sortedBy { it.formName.lowercase() } - 1 -> newListOfForms.sortedByDescending { it.formName.lowercase() } - 2 -> newListOfForms.sortedByDescending { + SortOrder.NAME_ASC -> newListOfForms.sortedBy { it.formName.lowercase() } + SortOrder.NAME_DESC -> newListOfForms.sortedByDescending { it.formName.lowercase() } + SortOrder.DATE_DESC -> newListOfForms.sortedByDescending { it.dateOfLastDetectedAttachmentsUpdate ?: it.dateOfCreation } - 3 -> newListOfForms.sortedBy { + SortOrder.DATE_ASC -> newListOfForms.sortedBy { it.dateOfLastDetectedAttachmentsUpdate ?: it.dateOfCreation } - 4 -> newListOfForms.sortedByDescending { it.dateOfLastUsage } - else -> { - newListOfForms - } + SortOrder.LAST_SAVED -> newListOfForms.sortedByDescending { it.dateOfLastUsage } }.filter { filter.isBlank() || it.formName.contains(filter, true) } } + private fun getSortOrder() = + SortOrder.entries[generalSettings.getInt(ProjectKeys.KEY_BLANK_FORM_SORT_ORDER)] + class Factory( private val instancesRepository: InstancesRepository, private val application: Application, @@ -179,4 +176,12 @@ class BlankFormListViewModel( ) as T } } + + enum class SortOrder { + NAME_ASC, + NAME_DESC, + DATE_DESC, + DATE_ASC, + LAST_SAVED + } } diff --git a/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/DeleteBlankFormFragment.kt b/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/DeleteBlankFormFragment.kt index 76aaaebfda1..a56beebaa52 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/DeleteBlankFormFragment.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/DeleteBlankFormFragment.kt @@ -5,32 +5,70 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.CheckBox import androidx.core.view.MenuHost -import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle.State import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.map import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.odk.collect.android.R -import org.odk.collect.android.databinding.DeleteBlankFormLayoutBinding -import org.odk.collect.androidshared.ui.MultiSelectViewModel -import org.odk.collect.androidshared.ui.updateSelectAll +import org.odk.collect.androidshared.ui.FragmentFactoryBuilder +import org.odk.collect.lists.RecyclerViewUtils +import org.odk.collect.lists.RecyclerViewUtils.matchParentWidth +import org.odk.collect.lists.selects.MultiSelectAdapter +import org.odk.collect.lists.selects.MultiSelectControlsFragment +import org.odk.collect.lists.selects.MultiSelectListFragment +import org.odk.collect.lists.selects.MultiSelectViewModel +import org.odk.collect.lists.selects.SelectItem +import org.odk.collect.strings.R.string class DeleteBlankFormFragment( private val viewModelFactory: ViewModelProvider.Factory, private val menuHost: MenuHost ) : Fragment() { - private lateinit var blankFormListViewModel: BlankFormListViewModel - private lateinit var multiSelectViewModel: MultiSelectViewModel - - private var allSelected = false + private val blankFormListViewModel: BlankFormListViewModel by viewModels { viewModelFactory } + private val multiSelectViewModel: MultiSelectViewModel by viewModels { + MultiSelectViewModel.Factory( + blankFormListViewModel.formsToDisplay.map { + it.map { blankForm -> + SelectItem( + blankForm.databaseId.toString(), + blankForm + ) + } + } + ) + } override fun onAttach(context: Context) { super.onAttach(context) - val viewModelProvider = ViewModelProvider(this, viewModelFactory) - blankFormListViewModel = viewModelProvider[BlankFormListViewModel::class.java] - multiSelectViewModel = viewModelProvider[MultiSelectViewModel::class.java] + + childFragmentManager.fragmentFactory = FragmentFactoryBuilder() + .forClass(MultiSelectListFragment::class) { + MultiSelectListFragment( + getString(string.delete_file), + multiSelectViewModel, + ::SelectableBlankFormListItemViewHolder + ) { + it.empty.setIcon(R.drawable.ic_baseline_delete_72) + it.empty.setTitle(getString(string.empty_list_of_forms_to_delete_title)) + it.empty.setSubtitle(getString(string.empty_list_of_blank_forms_to_delete_subtitle)) + + it.list.addItemDecoration(RecyclerViewUtils.verticalLineDivider(context)) + } + } + .build() + + childFragmentManager.setFragmentResultListener( + MultiSelectControlsFragment.REQUEST_ACTION, + this + ) { _, result -> + val selected = result.getStringArray(MultiSelectControlsFragment.RESULT_SELECTED)!! + onDeleteSelected(selected.map { it.toLong() }.toLongArray()) + } } override fun onCreateView( @@ -38,65 +76,49 @@ class DeleteBlankFormFragment( container: ViewGroup?, savedInstanceState: Bundle? ): View { - return inflater.inflate(R.layout.delete_blank_form_layout, container, false) + return inflater.inflate(R.layout.delete_form_layout, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val binding = DeleteBlankFormLayoutBinding.bind(view) - val recyclerView = binding.list - val adapter = SelectableBlankFormListAdapter { databaseId -> - multiSelectViewModel.toggle(databaseId) - } - - recyclerView.adapter = adapter - blankFormListViewModel.formsToDisplay.observe(viewLifecycleOwner) { - adapter.formItems = it - - binding.empty.isVisible = it.isEmpty() - binding.buttons.isVisible = it.isNotEmpty() - - updateAllSelected(binding, adapter) - } - - multiSelectViewModel.getSelected().observe(viewLifecycleOwner) { - binding.deleteSelected.isEnabled = it.isNotEmpty() - adapter.selected = it - - updateAllSelected(binding, adapter) - } + val blankFormListMenuProvider = + BlankFormListMenuProvider(requireActivity(), blankFormListViewModel) + menuHost.addMenuProvider(blankFormListMenuProvider, viewLifecycleOwner, State.RESUMED) + } - binding.selectAll.setOnClickListener { - if (allSelected) { + private fun onDeleteSelected(selected: LongArray) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(string.delete_file) + .setMessage( + getString( + string.delete_confirm, + selected.size.toString() + ) + ) + .setPositiveButton(getString(string.delete_yes)) { _, _ -> + blankFormListViewModel.deleteForms(*selected) multiSelectViewModel.unselectAll() - } else { - adapter.formItems.forEach { - multiSelectViewModel.select(it.databaseId) - } } - } + .setNegativeButton(getString(string.delete_no), null) + .show() + } +} - binding.deleteSelected.setOnClickListener { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(org.odk.collect.strings.R.string.delete_file) - .setMessage(getString(org.odk.collect.strings.R.string.delete_confirm, adapter.selected.size.toString())) - .setPositiveButton(getString(org.odk.collect.strings.R.string.delete_yes)) { _, _ -> - blankFormListViewModel.deleteForms(*adapter.selected.toLongArray()) - multiSelectViewModel.unselectAll() - } - .setNegativeButton(getString(org.odk.collect.strings.R.string.delete_no), null) - .show() +private class SelectableBlankFormListItemViewHolder(parent: ViewGroup) : + MultiSelectAdapter.ViewHolder( + BlankFormListItemView(parent.context).also { + it.setTrailingView(R.layout.checkbox) } + ) { - val blankFormListMenuProvider = - BlankFormListMenuProvider(requireActivity(), blankFormListViewModel) - menuHost.addMenuProvider(blankFormListMenuProvider, viewLifecycleOwner, State.RESUMED) + init { + matchParentWidth() } - private fun updateAllSelected( - binding: DeleteBlankFormLayoutBinding, - adapter: SelectableBlankFormListAdapter - ) { - allSelected = - updateSelectAll(binding.selectAll, adapter.formItems.size, adapter.selected.size) + override fun setItem(item: BlankFormListItem) { + (itemView as BlankFormListItemView).setItem(item) + } + + override fun getCheckbox(): CheckBox { + return itemView.findViewById(R.id.checkbox) } } diff --git a/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/SelectableBlankFormListAdapter.kt b/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/SelectableBlankFormListAdapter.kt deleted file mode 100644 index c7ba0e8edd0..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/formlists/blankformlist/SelectableBlankFormListAdapter.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.odk.collect.android.formlists.blankformlist - -import android.view.ViewGroup -import android.widget.CheckBox -import androidx.recyclerview.widget.RecyclerView -import org.odk.collect.android.R - -class SelectableBlankFormListAdapter(private val onItemClickListener: (Long) -> Unit) : - RecyclerView.Adapter() { - - var selected: Set = emptySet() - set(value) { - field = value - notifyDataSetChanged() - } - - var formItems = emptyList() - set(value) { - field = value.toList() - notifyDataSetChanged() - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BlankFormListItemViewHolder { - return BlankFormListItemViewHolder(parent).also { - it.setTrailingView(R.layout.checkbox) - } - } - - override fun onBindViewHolder(holder: BlankFormListItemViewHolder, position: Int) { - val item = formItems[position] - holder.blankFormListItem = item - - val checkbox = holder.itemView.findViewById(R.id.checkbox).also { - it.isChecked = selected.contains(item.databaseId) - it.setOnClickListener { - onItemClickListener(item.databaseId) - } - } - - holder.itemView.setOnClickListener { - checkbox.performClick() - } - } - - override fun getItemCount() = formItems.size -} diff --git a/collect_app/src/main/java/org/odk/collect/android/formlists/savedformlist/DeleteSavedFormFragment.kt b/collect_app/src/main/java/org/odk/collect/android/formlists/savedformlist/DeleteSavedFormFragment.kt new file mode 100644 index 00000000000..ee119721afb --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/formlists/savedformlist/DeleteSavedFormFragment.kt @@ -0,0 +1,147 @@ +package org.odk.collect.android.formlists.savedformlist + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.MenuHost +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.map +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.odk.collect.analytics.Analytics +import org.odk.collect.android.R +import org.odk.collect.android.analytics.AnalyticsEvents +import org.odk.collect.androidshared.ui.FragmentFactoryBuilder +import org.odk.collect.androidshared.ui.SnackbarUtils +import org.odk.collect.androidshared.ui.SnackbarUtils.SnackbarPresenterObserver +import org.odk.collect.forms.instances.Instance +import org.odk.collect.lists.RecyclerViewUtils +import org.odk.collect.lists.selects.MultiSelectControlsFragment +import org.odk.collect.lists.selects.MultiSelectListFragment +import org.odk.collect.lists.selects.MultiSelectViewModel +import org.odk.collect.lists.selects.SelectItem +import org.odk.collect.material.MaterialProgressDialogFragment +import org.odk.collect.strings.R.string + +class DeleteSavedFormFragment( + private val viewModelFactory: ViewModelProvider.Factory, + private val menuHost: MenuHost? = null +) : Fragment() { + + private val savedFormListViewModel: SavedFormListViewModel by viewModels { viewModelFactory } + private val multiSelectViewModel: MultiSelectViewModel by viewModels { + MultiSelectViewModel.Factory( + savedFormListViewModel.formsToDisplay.map { + it.map { instance -> + SelectItem( + instance.dbId.toString(), + instance + ) + } + } + ) + } + + override fun onAttach(context: Context) { + super.onAttach(context) + + childFragmentManager.fragmentFactory = FragmentFactoryBuilder() + .forClass(MultiSelectListFragment::class) { + MultiSelectListFragment( + getString(string.delete_file), + multiSelectViewModel, + ::SelectableSavedFormListItemViewHolder + ) { + it.empty.setIcon(R.drawable.ic_baseline_delete_72) + it.empty.setTitle(getString(string.empty_list_of_forms_to_delete_title)) + it.empty.setSubtitle(getString(string.empty_list_of_saved_forms_to_delete_subtitle)) + + it.list.addItemDecoration(RecyclerViewUtils.verticalLineDivider(context)) + } + } + .build() + + childFragmentManager.setFragmentResultListener( + MultiSelectControlsFragment.REQUEST_ACTION, + this + ) { _, result -> + val selected = result.getStringArray(MultiSelectControlsFragment.RESULT_SELECTED)!! + onDeleteSelected(selected.map { it.toLong() }.toLongArray()) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return inflater.inflate( + R.layout.delete_form_layout, + container, + false + ) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + menuHost?.addMenuProvider( + SavedFormListListMenuProvider(requireContext(), savedFormListViewModel), + viewLifecycleOwner, + Lifecycle.State.RESUMED + ) + + MaterialProgressDialogFragment.showOn( + viewLifecycleOwner, + savedFormListViewModel.isDeleting, + childFragmentManager + ) { + MaterialProgressDialogFragment().also { + it.message = getString(string.form_delete_message) + } + } + } + + private fun onDeleteSelected(selected: LongArray) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(string.delete_file) + .setMessage( + getString( + string.delete_confirm, + selected.size.toString() + ) + ) + .setPositiveButton(getString(string.delete_yes)) { _, _ -> + logDelete(selected.size) + + multiSelectViewModel.unselectAll() + savedFormListViewModel.deleteForms(selected).observe( + viewLifecycleOwner, + object : SnackbarPresenterObserver(requireView()) { + override fun getSnackbarDetails(value: Int): SnackbarUtils.SnackbarDetails { + return SnackbarUtils.SnackbarDetails( + getString( + string.file_deleted_ok, + value.toString() + ) + ) + } + } + ) + } + .setNegativeButton(getString(string.delete_no), null) + .show() + } + + private fun logDelete(size: Int) { + val event = when { + size >= 100 -> AnalyticsEvents.DELETE_SAVED_FORM_HUNDREDS + size >= 10 -> AnalyticsEvents.DELETE_SAVED_FORM_TENS + else -> AnalyticsEvents.DELETE_SAVED_FORM_FEW + } + + Analytics.log(event) + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/formlists/savedformlist/SavedFormListItemView.kt b/collect_app/src/main/java/org/odk/collect/android/formlists/savedformlist/SavedFormListItemView.kt new file mode 100644 index 00000000000..7a2b33be785 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/formlists/savedformlist/SavedFormListItemView.kt @@ -0,0 +1,31 @@ +package org.odk.collect.android.formlists.savedformlist + +import android.content.Context +import android.view.LayoutInflater +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import org.odk.collect.android.R +import org.odk.collect.android.databinding.FormChooserListItemMultipleChoiceBinding +import org.odk.collect.android.instancemanagement.getIcon +import org.odk.collect.android.instancemanagement.getStatusDescription +import org.odk.collect.forms.instances.Instance +import java.util.Date + +class SavedFormListItemView(context: Context) : FrameLayout(context) { + + val binding = + FormChooserListItemMultipleChoiceBinding.inflate(LayoutInflater.from(context), this, true) + + fun setItem(value: Instance) { + val lastStatusChangeDate = value.lastStatusChangeDate + val status = value.status + + binding.root.findViewById(R.id.form_title).text = value.displayName + binding.root.findViewById(R.id.form_subtitle).text = + getStatusDescription(context, status, Date(lastStatusChangeDate)) + + val statusIcon = binding.root.findViewById(R.id.image) + statusIcon.setImageResource(value.getIcon()) + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/formlists/savedformlist/SavedFormListListMenuProvider.kt b/collect_app/src/main/java/org/odk/collect/android/formlists/savedformlist/SavedFormListListMenuProvider.kt new file mode 100644 index 00000000000..17f871cdee3 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/formlists/savedformlist/SavedFormListListMenuProvider.kt @@ -0,0 +1,88 @@ +package org.odk.collect.android.formlists.savedformlist + +import android.content.Context +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.MenuItem.OnActionExpandListener +import androidx.appcompat.widget.SearchView +import androidx.core.view.MenuProvider +import org.odk.collect.android.R +import org.odk.collect.android.formlists.sorting.FormListSortingBottomSheetDialog +import org.odk.collect.android.formlists.sorting.FormListSortingOption +import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickGuard + +class SavedFormListListMenuProvider(private val context: Context, private val viewModel: SavedFormListViewModel) : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.saved_form_list_menu, menu) + + menu.findItem(R.id.menu_filter).apply { + setOnActionExpandListener(object : OnActionExpandListener { + override fun onMenuItemActionExpand(menuItem: MenuItem): Boolean { + menu.findItem(R.id.menu_sort).isVisible = false + return true + } + + override fun onMenuItemActionCollapse(menuItem: MenuItem): Boolean { + menu.findItem(R.id.menu_sort).isVisible = true + return true + } + }) + + (actionView as SearchView).apply { + setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String) = false + + override fun onQueryTextChange(newText: String): Boolean { + viewModel.filterText = newText + return false + } + }) + } + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + if (!MultiClickGuard.allowClick(javaClass.name)) { + return true + } + + return when (menuItem.itemId) { + R.id.menu_sort -> { + FormListSortingBottomSheetDialog( + context, + SavedFormListViewModel.SortOrder.entries.map { getFormListSortingOption(it) }, + viewModel.sortOrder.ordinal + ) { + viewModel.sortOrder = SavedFormListViewModel.SortOrder.entries[it] + }.show() + true + } + + else -> false + } + } + + private fun getFormListSortingOption(it: SavedFormListViewModel.SortOrder) = + when (it) { + SavedFormListViewModel.SortOrder.NAME_ASC -> FormListSortingOption( + R.drawable.ic_sort_by_alpha, + org.odk.collect.strings.R.string.sort_by_name_asc + ) + + SavedFormListViewModel.SortOrder.NAME_DESC -> FormListSortingOption( + R.drawable.ic_sort_by_alpha, + org.odk.collect.strings.R.string.sort_by_name_desc + ) + + SavedFormListViewModel.SortOrder.DATE_DESC -> FormListSortingOption( + R.drawable.ic_access_time, + org.odk.collect.strings.R.string.sort_by_date_desc + ) + + SavedFormListViewModel.SortOrder.DATE_ASC -> FormListSortingOption( + R.drawable.ic_access_time, + org.odk.collect.strings.R.string.sort_by_date_asc + ) + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/formlists/savedformlist/SavedFormListViewModel.kt b/collect_app/src/main/java/org/odk/collect/android/formlists/savedformlist/SavedFormListViewModel.kt new file mode 100644 index 00000000000..810036322f4 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/formlists/savedformlist/SavedFormListViewModel.kt @@ -0,0 +1,93 @@ +package org.odk.collect.android.formlists.savedformlist + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import org.odk.collect.android.instancemanagement.InstancesDataService +import org.odk.collect.androidshared.async.TrackableWorker +import org.odk.collect.androidshared.data.Consumable +import org.odk.collect.async.Scheduler +import org.odk.collect.async.flowOnBackground +import org.odk.collect.forms.instances.Instance +import org.odk.collect.settings.keys.ProjectKeys +import org.odk.collect.shared.settings.Settings + +class SavedFormListViewModel( + scheduler: Scheduler, + private val settings: Settings, + private val instancesDataService: InstancesDataService, + val projectId: String +) : ViewModel() { + + private val _sortOrder = + MutableStateFlow(SortOrder.entries[settings.getInt(ProjectKeys.KEY_SAVED_FORM_SORT_ORDER)]) + var sortOrder: SortOrder = _sortOrder.value + set(value) { + settings.save(ProjectKeys.KEY_SAVED_FORM_SORT_ORDER, value.ordinal) + _sortOrder.value = value + field = value + } + + private val _filterText = MutableStateFlow("") + var filterText: String = "" + set(value) { + field = value + _filterText.value = value + } + + val formsToDisplay: LiveData> = instancesDataService.getInstances(projectId) + .map { instances -> instances.filter { instance -> instance.deletedDate == null } } + .combine(_sortOrder) { instances, order -> + when (order) { + SortOrder.NAME_DESC -> { + instances.sortedByDescending { it.displayName } + } + + SortOrder.DATE_DESC -> { + instances.sortedByDescending { it.lastStatusChangeDate } + } + + SortOrder.NAME_ASC -> { + instances.sortedBy { it.displayName } + } + + SortOrder.DATE_ASC -> { + instances.sortedBy { it.lastStatusChangeDate } + } + } + }.combine(_filterText) { instances, filter -> + instances.filter { it.displayName.contains(filter, ignoreCase = true) } + }.flowOnBackground(scheduler).asLiveData() + + private val worker = TrackableWorker(scheduler) + val isDeleting: LiveData = worker.isWorking + + fun deleteForms(databaseIds: LongArray): LiveData?> { + val result = MutableLiveData?>(null) + worker.immediate( + background = { + instancesDataService.deleteInstances(projectId, databaseIds) + }, + foreground = { instancesDeleted -> + if (instancesDeleted) { + result.value = Consumable(databaseIds.count()) + } else { + result.value = Consumable(0) + } + } + ) + + return result + } + + enum class SortOrder { + NAME_ASC, + NAME_DESC, + DATE_DESC, + DATE_ASC + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/formlists/savedformlist/SelectableSavedFormListItemViewHolder.kt b/collect_app/src/main/java/org/odk/collect/android/formlists/savedformlist/SelectableSavedFormListItemViewHolder.kt new file mode 100644 index 00000000000..8e08ee23837 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/formlists/savedformlist/SelectableSavedFormListItemViewHolder.kt @@ -0,0 +1,37 @@ +package org.odk.collect.android.formlists.savedformlist + +import android.view.View +import android.view.ViewGroup +import android.widget.CheckBox +import org.odk.collect.android.R +import org.odk.collect.forms.instances.Instance +import org.odk.collect.lists.RecyclerViewUtils.matchParentWidth +import org.odk.collect.lists.selects.MultiSelectAdapter + +class SelectableSavedFormListItemViewHolder(parent: ViewGroup) : + MultiSelectAdapter.ViewHolder( + SavedFormListItemView(parent.context) + ) { + private var selectView = itemView + + init { + matchParentWidth() + } + + override fun setItem(item: Instance) { + (itemView as SavedFormListItemView).setItem(item) + } + + override fun getCheckbox(): CheckBox { + return (itemView as SavedFormListItemView).binding.checkbox + } + + override fun getSelectArea(): View { + return selectView + } + + fun setOnDetailsClickListener(listener: () -> Unit) { + selectView = itemView.findViewById(R.id.selectView) + selectView.setOnClickListener { listener() } + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/formlists/sorting/FormListSortingBottomSheetDialog.kt b/collect_app/src/main/java/org/odk/collect/android/formlists/sorting/FormListSortingBottomSheetDialog.kt index 5a28e220f87..4a25e7c5d03 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formlists/sorting/FormListSortingBottomSheetDialog.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formlists/sorting/FormListSortingBottomSheetDialog.kt @@ -12,9 +12,9 @@ import java.util.function.Consumer class FormListSortingBottomSheetDialog( context: Context, - private val options: List, - private val selectedOption: Int, - private val onSelectedOptionChanged: Consumer + val options: List, + val selectedOption: Int, + val onSelectedOptionChanged: Consumer ) : BottomSheetDialog(context) { override fun onCreate(savedInstanceState: Bundle?) { diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/FormUpdateDownloader.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/FormUpdateDownloader.kt deleted file mode 100644 index d3c857e4577..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/FormUpdateDownloader.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.odk.collect.android.formmanagement - -import org.odk.collect.shared.locks.ChangeLock - -class FormUpdateDownloader { - - fun downloadUpdates( - updatedForms: List, - changeLock: ChangeLock, - formDownloader: FormDownloader - ): Map { - val results = mutableMapOf() - - changeLock.withLock { acquiredLock: Boolean -> - if (acquiredLock) { - for (serverFormDetails in updatedForms) { - try { - formDownloader.downloadForm(serverFormDetails, null, null) - results[serverFormDetails] = null - } catch (e: FormDownloadException.DownloadingInterrupted) { - break - } catch (e: FormDownloadException) { - results[serverFormDetails] = e - } - } - } - } - - return results - } -} 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 25a19f65aa0..70ce2310d30 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 @@ -4,6 +4,8 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import org.odk.collect.android.formmanagement.download.FormDownloadException +import org.odk.collect.android.formmanagement.download.ServerFormDownloader import org.odk.collect.android.formmanagement.matchexactly.ServerFormsSynchronizer import org.odk.collect.android.notifications.Notifier import org.odk.collect.android.projects.ProjectDependencyProvider @@ -43,6 +45,25 @@ class FormsDataService( getServerErrorLiveData(projectId).value = null } + fun downloadForms( + projectId: String, + forms: List, + progressReporter: (Int, Int) -> Unit, + isCancelled: () -> Boolean + ): Map { + val projectDependencyProvider = projectDependencyProviderFactory.create(projectId) + val formDownloader = + formDownloader(projectDependencyProvider, clock) + + return ServerFormUseCases.downloadForms( + forms, + projectDependencyProvider.formsLock, + formDownloader, + progressReporter, + isCancelled + ) + } + /** * Downloads updates for the project's already downloaded forms. If Automatic download is * disabled the user will just be notified that there are updates available. @@ -64,8 +85,7 @@ class FormsDataService( .collect(Collectors.toList()) if (updatedForms.isNotEmpty()) { if (projectDependencies.generalSettings.getBoolean(ProjectKeys.KEY_AUTOMATIC_UPDATE)) { - val formUpdateDownloader = FormUpdateDownloader() - val results = formUpdateDownloader.downloadUpdates( + val results = ServerFormUseCases.downloadForms( updatedForms, projectDependencies.formsLock, formDownloader @@ -207,7 +227,8 @@ private fun formDownloader( File(projectDependencyProvider.cacheDir), projectDependencyProvider.formsDir, FormMetadataParser(), - clock + clock, + projectDependencyProvider.entitiesRepository ) } diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt deleted file mode 100644 index 7229b484c16..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/InstancesDataService.kt +++ /dev/null @@ -1,132 +0,0 @@ -package org.odk.collect.android.formmanagement - -import androidx.lifecycle.LiveData -import org.odk.collect.analytics.Analytics -import org.odk.collect.android.analytics.AnalyticsEvents -import org.odk.collect.android.application.Collect -import org.odk.collect.android.backgroundwork.InstanceSubmitScheduler -import org.odk.collect.android.entities.EntitiesRepositoryProvider -import org.odk.collect.android.formentry.FormEntryUseCases -import org.odk.collect.android.projects.ProjectsDataService -import org.odk.collect.android.storage.StoragePathProvider -import org.odk.collect.android.storage.StorageSubdirectory -import org.odk.collect.android.utilities.ExternalizableFormDefCache -import org.odk.collect.android.utilities.FormsRepositoryProvider -import org.odk.collect.android.utilities.InstancesRepositoryProvider -import org.odk.collect.androidshared.data.AppState -import org.odk.collect.forms.instances.Instance -import java.io.File - -class InstancesDataService( - private val appState: AppState, - private val formsRepositoryProvider: FormsRepositoryProvider, - private val instancesRepositoryProvider: InstancesRepositoryProvider, - private val entitiesRepositoryProvider: EntitiesRepositoryProvider, - private val storagePathProvider: StoragePathProvider, - private val instanceSubmitScheduler: InstanceSubmitScheduler, - private val projectsDataService: ProjectsDataService, - private val onUpdate: () -> Unit -) { - val editableCount: LiveData = appState.getLive(EDITABLE_COUNT_KEY, 0) - val sendableCount: LiveData = appState.getLive(SENDABLE_COUNT_KEY, 0) - val sentCount: LiveData = appState.getLive(SENT_COUNT_KEY, 0) - - fun update() { - val instancesRepository = instancesRepositoryProvider.get() - - val sendableInstances = instancesRepository.getCountByStatus( - Instance.STATUS_COMPLETE, - Instance.STATUS_SUBMISSION_FAILED - ) - val sentInstances = instancesRepository.getCountByStatus( - Instance.STATUS_SUBMITTED, - Instance.STATUS_SUBMISSION_FAILED - ) - val editableInstances = instancesRepository.getCountByStatus( - Instance.STATUS_INCOMPLETE, - Instance.STATUS_INVALID, - Instance.STATUS_VALID - ) - - appState.setLive(EDITABLE_COUNT_KEY, editableInstances) - appState.setLive(SENDABLE_COUNT_KEY, sendableInstances) - appState.setLive(SENT_COUNT_KEY, sentInstances) - - onUpdate() - } - - fun finalizeAllDrafts(): FinalizeAllResult { - val instancesRepository = instancesRepositoryProvider.get() - val formsRepository = formsRepositoryProvider.get() - val entitiesRepository = entitiesRepositoryProvider.get() - val projectRootDir = File(storagePathProvider.getProjectRootDirPath()) - val cacheDir = storagePathProvider.getOdkDirPath(StorageSubdirectory.CACHE) - - val instances = instancesRepository.getAllByStatus( - Instance.STATUS_INCOMPLETE, - Instance.STATUS_INVALID, - Instance.STATUS_VALID - ) - - val result = instances.fold(FinalizeAllResult(0, 0, false)) { result, instance -> - val formDefAndForm = FormEntryUseCases.loadFormDef( - instance, - formsRepository, - projectRootDir, - ExternalizableFormDefCache() - ) - - if (formDefAndForm == null) { - result.copy(failureCount = result.failureCount + 1) - } else { - val (formDef, form) = formDefAndForm - - val formMediaDir = File(form.formMediaPath) - val formEntryController = - CollectFormEntryControllerFactory().create(formDef, formMediaDir) - val formController = FormEntryUseCases.loadDraft(form, instance, formEntryController) - if (formController == null) { - result.copy(failureCount = result.failureCount + 1) - } else { - val savePoint = FormEntryUseCases.getSavePoint(formController, File(cacheDir)) - val needsEncrypted = form.basE64RSAPublicKey != null - val newResult = if (savePoint != null) { - Analytics.log(AnalyticsEvents.BULK_FINALIZE_SAVE_POINT) - result.copy(failureCount = result.failureCount + 1, unsupportedInstances = true) - } else if (needsEncrypted) { - Analytics.log(AnalyticsEvents.BULK_FINALIZE_ENCRYPTED_FORM) - result.copy(failureCount = result.failureCount + 1, unsupportedInstances = true) - } else { - val finalizedInstance = FormEntryUseCases.finalizeDraft( - formController, - instancesRepository, - entitiesRepository - ) - - if (finalizedInstance == null) { - result.copy(failureCount = result.failureCount + 1) - } else { - result - } - } - - Collect.getInstance().externalDataManager?.close() - newResult - } - } - } - - update() - instanceSubmitScheduler.scheduleSubmit(projectsDataService.getCurrentProject().uuid) - - return result.copy(successCount = instances.size - result.failureCount) - } - - companion object { - private const val EDITABLE_COUNT_KEY = "instancesEditableCount" - private const val SENDABLE_COUNT_KEY = "instancesSendableCount" - private const val SENT_COUNT_KEY = "instancesSentCount" - } -} - -data class FinalizeAllResult(val successCount: Int, val failureCount: Int, val unsupportedInstances: Boolean) diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/ServerFormDownloaderUseCases.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/ServerFormUseCases.kt similarity index 58% rename from collect_app/src/main/java/org/odk/collect/android/formmanagement/ServerFormDownloaderUseCases.kt rename to collect_app/src/main/java/org/odk/collect/android/formmanagement/ServerFormUseCases.kt index 6e395a887be..53480f30d30 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/ServerFormDownloaderUseCases.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/ServerFormUseCases.kt @@ -1,44 +1,85 @@ package org.odk.collect.android.formmanagement +import org.odk.collect.android.formmanagement.download.FormDownloadException +import org.odk.collect.android.formmanagement.download.FormDownloader import org.odk.collect.android.utilities.FileUtils -import org.odk.collect.android.utilities.FileUtils.LAST_SAVED_FILENAME import org.odk.collect.async.OngoingWorkListener +import org.odk.collect.entities.EntitiesRepository +import org.odk.collect.entities.LocalEntityUseCases import org.odk.collect.forms.Form import org.odk.collect.forms.FormSource import org.odk.collect.forms.FormSourceException import org.odk.collect.forms.FormsRepository import org.odk.collect.forms.MediaFile +import org.odk.collect.shared.locks.ChangeLock import org.odk.collect.shared.strings.Md5 import java.io.File import java.io.IOException -object ServerFormDownloaderUseCases { +object ServerFormUseCases { + + fun downloadForms( + forms: List, + changeLock: ChangeLock, + formDownloader: FormDownloader, + progressReporter: ((Int, Int) -> Unit)? = null, + isCancelled: (() -> Boolean)? = null, + ): Map { + val results = mutableMapOf() + changeLock.withLock { acquiredLock: Boolean -> + if (acquiredLock) { + for (index in forms.indices) { + val form = forms[index] + + try { + formDownloader.downloadForm( + form, + object : FormDownloader.ProgressReporter { + override fun onDownloadingMediaFile(count: Int) { + progressReporter?.invoke(index, count) + } + }, + { isCancelled?.invoke() ?: false } + ) + + results[form] = null + } catch (e: FormDownloadException.DownloadingInterrupted) { + break + } catch (e: FormDownloadException) { + results[form] = e + } + } + } + } + + return results + } + @JvmStatic fun copySavedFileFromPreviousFormVersionIfExists(formsRepository: FormsRepository, formId: String, mediaDirPath: String) { val lastSavedFile: File? = formsRepository .getAllByFormId(formId) .maxByOrNull { form -> form.date } ?.let { - File(it.formMediaPath, LAST_SAVED_FILENAME) + File(it.formMediaPath, FileUtils.LAST_SAVED_FILENAME) } if (lastSavedFile != null && lastSavedFile.exists()) { File(mediaDirPath).mkdir() - FileUtils.copyFile(lastSavedFile, File(mediaDirPath, LAST_SAVED_FILENAME)) + FileUtils.copyFile(lastSavedFile, File(mediaDirPath, FileUtils.LAST_SAVED_FILENAME)) } } @JvmStatic - @JvmOverloads @Throws(IOException::class, FormSourceException::class, InterruptedException::class) - fun download( - formsRepository: FormsRepository, - formSource: FormSource, + fun downloadMediaFiles( formToDownload: ServerFormDetails, + formSource: FormSource, + formsRepository: FormsRepository, tempMediaPath: String, tempDir: File, - stateListener: OngoingWorkListener, - test: Boolean = false + entitiesRepository: EntitiesRepository, + stateListener: OngoingWorkListener ): Boolean { var atLeastOneNewMediaFileDetected = false val tempMediaDir = File(tempMediaPath).also { it.mkdir() } @@ -49,7 +90,7 @@ object ServerFormDownloaderUseCases { val tempMediaFile = File(tempMediaDir, mediaFile.filename) val existingFile = searchForExistingMediaFile(formsRepository, formToDownload, mediaFile) - existingFile.let { + existingFile.also { if (it != null) { if (Md5.getMd5Hash(it).contentEquals(mediaFile.hash)) { FileUtils.copyFile(it, tempMediaFile) @@ -59,21 +100,20 @@ object ServerFormDownloaderUseCases { FileUtils.interuptablyWriteFile(file, tempMediaFile, tempDir, stateListener) if (!Md5.getMd5Hash(tempMediaFile).contentEquals(existingFileHash)) { - if (test) { - throw Exception("Content does not equal") - } atLeastOneNewMediaFileDetected = true } } } else { - if (test) { - throw Exception("File does not exist") - } val file = formSource.fetchMediaFile(mediaFile.downloadUrl) FileUtils.interuptablyWriteFile(file, tempMediaFile, tempDir, stateListener) atLeastOneNewMediaFileDetected = true } } + + val dataset = mediaFile.filename.substringBefore(".csv") + if (entitiesRepository.getLists().contains(dataset)) { + LocalEntityUseCases.updateLocalEntitiesFromServer(dataset, tempMediaFile, entitiesRepository) + } } return atLeastOneNewMediaFileDetected diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/FormDownloadException.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/download/FormDownloadException.kt similarity index 89% rename from collect_app/src/main/java/org/odk/collect/android/formmanagement/FormDownloadException.kt rename to collect_app/src/main/java/org/odk/collect/android/formmanagement/download/FormDownloadException.kt index 2c5aa8cd3f3..7a5efae50e3 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/FormDownloadException.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/download/FormDownloadException.kt @@ -1,4 +1,4 @@ -package org.odk.collect.android.formmanagement +package org.odk.collect.android.formmanagement.download import org.odk.collect.forms.FormSourceException diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/FormDownloadExceptionMapper.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/download/FormDownloadExceptionMapper.kt similarity index 93% rename from collect_app/src/main/java/org/odk/collect/android/formmanagement/FormDownloadExceptionMapper.kt rename to collect_app/src/main/java/org/odk/collect/android/formmanagement/download/FormDownloadExceptionMapper.kt index 8d5716bbc76..800259122bd 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/FormDownloadExceptionMapper.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/download/FormDownloadExceptionMapper.kt @@ -1,7 +1,7 @@ -package org.odk.collect.android.formmanagement +package org.odk.collect.android.formmanagement.download import android.content.Context -import org.odk.collect.android.R +import org.odk.collect.android.formmanagement.FormSourceExceptionMapper import org.odk.collect.strings.localization.getLocalizedString class FormDownloadExceptionMapper(private val context: Context) { diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/FormDownloader.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/download/FormDownloader.kt similarity index 74% rename from collect_app/src/main/java/org/odk/collect/android/formmanagement/FormDownloader.kt rename to collect_app/src/main/java/org/odk/collect/android/formmanagement/download/FormDownloader.kt index f203914d8c1..aa18ce2ff1a 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/FormDownloader.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/download/FormDownloader.kt @@ -1,5 +1,6 @@ -package org.odk.collect.android.formmanagement +package org.odk.collect.android.formmanagement.download +import org.odk.collect.android.formmanagement.ServerFormDetails import java.util.function.Supplier interface FormDownloader { diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/ServerFormDownloader.java b/collect_app/src/main/java/org/odk/collect/android/formmanagement/download/ServerFormDownloader.java similarity index 94% rename from collect_app/src/main/java/org/odk/collect/android/formmanagement/ServerFormDownloader.java rename to collect_app/src/main/java/org/odk/collect/android/formmanagement/download/ServerFormDownloader.java index b68b57400f2..61cba25d64a 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/ServerFormDownloader.java +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/download/ServerFormDownloader.java @@ -1,13 +1,17 @@ -package org.odk.collect.android.formmanagement; +package org.odk.collect.android.formmanagement.download; import static org.odk.collect.android.utilities.FileUtils.interuptablyWriteFile; import org.javarosa.xform.parse.XFormParser; import org.jetbrains.annotations.NotNull; +import org.odk.collect.android.formmanagement.FormMetadataParser; +import org.odk.collect.android.formmanagement.ServerFormDetails; +import org.odk.collect.android.formmanagement.ServerFormUseCases; import org.odk.collect.android.utilities.FileUtils; 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.EntitiesRepository; import org.odk.collect.forms.Form; import org.odk.collect.forms.FormSource; import org.odk.collect.forms.FormSourceException; @@ -36,14 +40,16 @@ public class ServerFormDownloader implements FormDownloader { private final String formsDirPath; private final FormMetadataParser formMetadataParser; private final Supplier clock; + private final EntitiesRepository entitiesRepository; - public ServerFormDownloader(FormSource formSource, FormsRepository formsRepository, File cacheDir, String formsDirPath, FormMetadataParser formMetadataParser, Supplier clock) { + public ServerFormDownloader(FormSource formSource, FormsRepository formsRepository, File cacheDir, String formsDirPath, FormMetadataParser formMetadataParser, Supplier clock, EntitiesRepository entitiesRepository) { this.formSource = formSource; this.cacheDir = cacheDir; this.formsDirPath = formsDirPath; this.formsRepository = formsRepository; this.formMetadataParser = formMetadataParser; this.clock = clock; + this.entitiesRepository = entitiesRepository; } @Override @@ -93,10 +99,10 @@ private void processOneForm(ServerFormDetails fd, OngoingWorkListener stateListe // download media files if there are any if (fd.getManifest() != null && !fd.getManifest().getMediaFiles().isEmpty()) { - newAttachmentsDetected = ServerFormDownloaderUseCases.download(formsRepository, formSource, fd, tempMediaPath, tempDir, stateListener); + newAttachmentsDetected = ServerFormUseCases.downloadMediaFiles(fd, formSource, formsRepository, tempMediaPath, tempDir, entitiesRepository, stateListener); } - ServerFormDownloaderUseCases.copySavedFileFromPreviousFormVersionIfExists(formsRepository, fd.getFormId(), tempMediaPath); + ServerFormUseCases.copySavedFileFromPreviousFormVersionIfExists(formsRepository, fd.getFormId(), tempMediaPath); } catch (FormDownloadException.DownloadingInterrupted | InterruptedException e) { Timber.i(e); cleanUp(fileResult, tempMediaPath); diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt index 7e96f787d12..f5f2995164d 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/drafts/BulkFinalizationViewModel.kt @@ -2,8 +2,8 @@ package org.odk.collect.android.formmanagement.drafts import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import org.odk.collect.android.formmanagement.FinalizeAllResult -import org.odk.collect.android.formmanagement.InstancesDataService +import org.odk.collect.android.instancemanagement.FinalizeAllResult +import org.odk.collect.android.instancemanagement.InstancesDataService import org.odk.collect.androidshared.data.Consumable import org.odk.collect.androidshared.livedata.MutableNonNullLiveData import org.odk.collect.androidshared.livedata.NonNullLiveData @@ -12,9 +12,10 @@ import org.odk.collect.settings.SettingsProvider import org.odk.collect.settings.keys.ProtectedProjectKeys class BulkFinalizationViewModel( + private val projectId: String, private val scheduler: Scheduler, private val instancesDataService: InstancesDataService, - private val settingsProvider: SettingsProvider + settingsProvider: SettingsProvider ) { private val _finalizedForms = MutableLiveData>() val finalizedForms: LiveData> = _finalizedForms @@ -31,7 +32,7 @@ class BulkFinalizationViewModel( scheduler.immediate( background = { - instancesDataService.finalizeAllDrafts() + instancesDataService.finalizeAllDrafts(projectId) }, foreground = { _isFinalizing.value = false diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/formmap/FormMapViewModel.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/formmap/FormMapViewModel.kt index 1a93b331073..720f070dd20 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/formmap/FormMapViewModel.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/formmap/FormMapViewModel.kt @@ -16,8 +16,11 @@ import org.odk.collect.forms.Form import org.odk.collect.forms.FormsRepository import org.odk.collect.forms.instances.Instance import org.odk.collect.forms.instances.InstancesRepository +import org.odk.collect.geo.selection.IconifiedText import org.odk.collect.geo.selection.MappableSelectItem import org.odk.collect.geo.selection.SelectionMapData +import org.odk.collect.geo.selection.Status +import org.odk.collect.maps.MapPoint import org.odk.collect.settings.SettingsProvider import org.odk.collect.settings.keys.ProtectedProjectKeys import timber.log.Timber @@ -111,21 +114,14 @@ class FormMapViewModel( Locale.getDefault() ) - val info = dateFormat.format(instance.deletedDate) - MappableSelectItem.WithInfo( + val info = "$instanceLastStatusChangeDate\n${dateFormat.format(instance.deletedDate)}" + MappableSelectItem.MappableSelectPoint( instance.dbId, - latitude, - longitude, - getDrawableIdForStatus(instance.status, false), - getDrawableIdForStatus(instance.status, true), instance.displayName, - listOf( - MappableSelectItem.IconifiedText( - getSubmissionSummaryStatusIcon(instance.status), - instanceLastStatusChangeDate - ) - ), - info + point = MapPoint(latitude, longitude), + smallIcon = getDrawableIdForStatus(instance.status, false), + largeIcon = getDrawableIdForStatus(instance.status, true), + info = info ) } else if (!instance.canEditWhenComplete() && listOf( Instance.STATUS_COMPLETE, @@ -133,21 +129,14 @@ class FormMapViewModel( Instance.STATUS_SUBMITTED ).contains(instance.status) ) { - val info = resources.getString(org.odk.collect.strings.R.string.cannot_edit_completed_form) - MappableSelectItem.WithInfo( + val info = "$instanceLastStatusChangeDate\n${resources.getString(org.odk.collect.strings.R.string.cannot_edit_completed_form)}" + MappableSelectItem.MappableSelectPoint( instance.dbId, - latitude, - longitude, - getDrawableIdForStatus(instance.status, false), - getDrawableIdForStatus(instance.status, true), instance.displayName, - listOf( - MappableSelectItem.IconifiedText( - getSubmissionSummaryStatusIcon(instance.status), - instanceLastStatusChangeDate - ) - ), - info + point = MapPoint(latitude, longitude), + smallIcon = getDrawableIdForStatus(instance.status, false), + largeIcon = getDrawableIdForStatus(instance.status, true), + info = info ) } else { val action = @@ -157,36 +146,39 @@ class FormMapViewModel( createViewAction() } - MappableSelectItem.WithAction( + MappableSelectItem.MappableSelectPoint( instance.dbId, - latitude, - longitude, - getDrawableIdForStatus(instance.status, false), - getDrawableIdForStatus(instance.status, true), instance.displayName, - listOf( - MappableSelectItem.IconifiedText( - getSubmissionSummaryStatusIcon(instance.status), - instanceLastStatusChangeDate - ) - ), - action + point = MapPoint(latitude, longitude), + smallIcon = getDrawableIdForStatus(instance.status, false), + largeIcon = getDrawableIdForStatus(instance.status, true), + info = instanceLastStatusChangeDate, + action = action, + status = instanceStatusToMappableSelectionItemStatus(instance) ) } } - private fun createViewAction(): MappableSelectItem.IconifiedText { - return MappableSelectItem.IconifiedText( + private fun instanceStatusToMappableSelectionItemStatus(instance: Instance): Status? { + return when (instance.status) { + Instance.STATUS_INVALID, Instance.STATUS_INCOMPLETE -> Status.ERRORS + Instance.STATUS_VALID -> Status.NO_ERRORS + else -> null + } + } + + private fun createViewAction(): IconifiedText { + return IconifiedText( R.drawable.ic_visibility, resources.getString(org.odk.collect.strings.R.string.view_data) ) } - private fun createEditAction(): MappableSelectItem.IconifiedText { + private fun createEditAction(): IconifiedText { val canEditSaved = settingsProvider.getProtectedSettings() .getBoolean(ProtectedProjectKeys.KEY_EDIT_SAVED) - return MappableSelectItem.IconifiedText( + return IconifiedText( if (canEditSaved) R.drawable.ic_edit else R.drawable.ic_visibility, resources.getString(if (canEditSaved) org.odk.collect.strings.R.string.edit_data else org.odk.collect.strings.R.string.view_data) ) @@ -201,13 +193,4 @@ class FormMapViewModel( else -> org.odk.collect.icons.R.drawable.ic_map_point } } - - private fun getSubmissionSummaryStatusIcon(instanceStatus: String?): Int { - return when (instanceStatus) { - Instance.STATUS_COMPLETE -> R.drawable.ic_form_state_finalized - Instance.STATUS_SUBMITTED -> R.drawable.ic_form_state_submitted - Instance.STATUS_SUBMISSION_FAILED -> R.drawable.ic_form_state_submission_failed - else -> R.drawable.ic_form_state_saved - } - } } diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/matchexactly/ServerFormsSynchronizer.java b/collect_app/src/main/java/org/odk/collect/android/formmanagement/matchexactly/ServerFormsSynchronizer.java index 3351351d22a..7edc3f36ce9 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/matchexactly/ServerFormsSynchronizer.java +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/matchexactly/ServerFormsSynchronizer.java @@ -1,8 +1,8 @@ package org.odk.collect.android.formmanagement.matchexactly; import org.odk.collect.android.formmanagement.LocalFormUseCases; -import org.odk.collect.android.formmanagement.FormDownloadException; -import org.odk.collect.android.formmanagement.FormDownloader; +import org.odk.collect.android.formmanagement.download.FormDownloadException; +import org.odk.collect.android.formmanagement.download.FormDownloader; import org.odk.collect.android.formmanagement.ServerFormDetails; import org.odk.collect.android.formmanagement.ServerFormsDetailsFetcher; import org.odk.collect.forms.Form; diff --git a/collect_app/src/main/java/org/odk/collect/android/fragments/AppListFragment.java b/collect_app/src/main/java/org/odk/collect/android/fragments/AppListFragment.java deleted file mode 100644 index d2ef1d0d231..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/fragments/AppListFragment.java +++ /dev/null @@ -1,248 +0,0 @@ -/* - -Copyright 2017 Shobhit -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package org.odk.collect.android.fragments; - -import android.database.Cursor; -import android.os.Bundle; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.widget.Button; -import android.widget.ListView; -import android.widget.SimpleCursorAdapter; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.widget.SearchView; -import androidx.core.content.ContextCompat; -import androidx.core.view.MenuItemCompat; -import androidx.fragment.app.ListFragment; -import org.odk.collect.android.R; -import org.odk.collect.android.database.instances.DatabaseInstanceColumns; -import org.odk.collect.android.formlists.sorting.FormListSortingBottomSheetDialog; -import org.odk.collect.android.formlists.sorting.FormListSortingOption; -import org.odk.collect.android.injection.DaggerUtils; -import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickGuard; -import org.odk.collect.settings.SettingsProvider; - -import java.util.ArrayList; -import java.util.LinkedHashSet; -import java.util.List; - -import javax.inject.Inject; - -public abstract class AppListFragment extends ListFragment { - - @Inject - SettingsProvider settingsProvider; - - protected List sortingOptions; - protected SimpleCursorAdapter listAdapter; - protected LinkedHashSet selectedInstances = new LinkedHashSet<>(); - protected View rootView; - private Integer selectedSortingOrder; - private String filterText; - - // toggles to all checked or all unchecked - // returns: - // true if result is all checked - // false if result is all unchecked - // - // Toggle behavior is as follows: - // if ANY items are unchecked, check them all - // if ALL items are checked, uncheck them all - public static boolean toggleChecked(ListView lv) { - // shortcut null case - if (lv == null) { - return false; - } - - boolean newCheckState = lv.getCount() > lv.getCheckedItemCount(); - setAllToCheckedState(lv, newCheckState); - return newCheckState; - } - - public static void setAllToCheckedState(ListView lv, boolean check) { - // no-op if ListView null - if (lv == null) { - return; - } - for (int x = 0; x < lv.getCount(); x++) { - lv.setItemChecked(x, check); - } - } - - // Function to toggle button label - public static void toggleButtonLabel(Button toggleButton, ListView lv) { - if (lv.getCheckedItemCount() != lv.getCount()) { - toggleButton.setText(org.odk.collect.strings.R.string.select_all); - } else { - toggleButton.setText(org.odk.collect.strings.R.string.clear_all); - } - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - DaggerUtils.getComponent(requireActivity()).inject(this); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - ListView listView = getListView(); - listView.setDivider(ContextCompat.getDrawable(getContext(), R.drawable.list_item_divider)); - listView.setDividerHeight(1); - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - super.onCreateOptionsMenu(menu, inflater); - menu.clear(); - inflater.inflate(R.menu.form_list_menu, menu); - - final MenuItem sortItem = menu.findItem(R.id.menu_sort); - final MenuItem searchItem = menu.findItem(R.id.menu_filter); - final SearchView searchView = (SearchView) MenuItemCompat.getActionView(searchItem); - searchView.setQueryHint(getResources().getString(org.odk.collect.strings.R.string.search)); - searchView.setMaxWidth(Integer.MAX_VALUE); - - searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { - @Override - public boolean onQueryTextSubmit(String query) { - filterText = query; - updateAdapter(); - searchView.clearFocus(); - return false; - } - - @Override - public boolean onQueryTextChange(String newText) { - filterText = newText; - updateAdapter(); - return false; - } - }); - - MenuItemCompat.setOnActionExpandListener(searchItem, new MenuItemCompat.OnActionExpandListener() { - @Override - public boolean onMenuItemActionExpand(MenuItem item) { - sortItem.setVisible(false); - return true; - } - - @Override - public boolean onMenuItemActionCollapse(MenuItem item) { - sortItem.setVisible(true); - return true; - } - }); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - if (!MultiClickGuard.allowClick(getClass().getName())) { - return true; - } - - if (item.getItemId() == R.id.menu_sort) { - new FormListSortingBottomSheetDialog( - requireContext(), - sortingOptions, - getSelectedSortingOrder(), - selectedOption -> { - saveSelectedSortingOrder(selectedOption); - updateAdapter(); - } - ).show(); - return true; - } - return super.onOptionsItemSelected(item); - } - - protected void checkPreviouslyCheckedItems() { - getListView().clearChoices(); - List selectedPositions = new ArrayList<>(); - int listViewPosition = 0; - Cursor cursor = listAdapter.getCursor(); - if (cursor != null && cursor.moveToFirst()) { - do { - long instanceId = cursor.getLong(cursor.getColumnIndex(DatabaseInstanceColumns._ID)); - if (selectedInstances.contains(instanceId)) { - selectedPositions.add(listViewPosition); - } - listViewPosition++; - } while (cursor.moveToNext()); - } - - for (int position : selectedPositions) { - getListView().setItemChecked(position, true); - } - } - - protected abstract void updateAdapter(); - - protected abstract String getSortingOrderKey(); - - protected boolean areCheckedItems() { - return getCheckedCount() > 0; - } - - /** - * Returns the IDs of the checked items, as an array of Long - */ - protected Long[] getCheckedIdObjects() { - // This method could be simplified by using getCheckedItemIds, if one ensured that - // IDs were “stable” (see the getCheckedItemIds doc). - ListView lv = getListView(); - int itemCount = lv.getCount(); - int checkedItemCount = lv.getCheckedItemCount(); - Long[] checkedIds = new Long[checkedItemCount]; - int resultIndex = 0; - for (int posIdx = 0; posIdx < itemCount; posIdx++) { - if (lv.isItemChecked(posIdx)) { - checkedIds[resultIndex] = lv.getItemIdAtPosition(posIdx); - resultIndex++; - } - } - return checkedIds; - } - - protected int getCheckedCount() { - return getListView().getCheckedItemCount(); - } - - private void saveSelectedSortingOrder(int selectedStringOrder) { - selectedSortingOrder = selectedStringOrder; - settingsProvider.getUnprotectedSettings().save(getSortingOrderKey(), selectedStringOrder); - } - - protected void restoreSelectedSortingOrder() { - selectedSortingOrder = settingsProvider.getUnprotectedSettings().getInt(getSortingOrderKey()); - } - - protected int getSelectedSortingOrder() { - if (selectedSortingOrder == null) { - restoreSelectedSortingOrder(); - } - return selectedSortingOrder; - } - - protected CharSequence getFilterText() { - return filterText != null ? filterText : ""; - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/fragments/FileManagerFragment.java b/collect_app/src/main/java/org/odk/collect/android/fragments/FileManagerFragment.java deleted file mode 100644 index bde692758aa..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/fragments/FileManagerFragment.java +++ /dev/null @@ -1,165 +0,0 @@ -/* - -Copyright 2017 Shobhit -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package org.odk.collect.android.fragments; - -import android.database.Cursor; -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.loader.app.LoaderManager; -import androidx.loader.content.CursorLoader; -import androidx.loader.content.Loader; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.LinearLayout; -import android.widget.ListView; -import android.widget.ProgressBar; - -import org.odk.collect.android.R; -import org.odk.collect.android.formlists.sorting.FormListSortingOption; - -import java.util.Arrays; - -public abstract class FileManagerFragment extends AppListFragment implements LoaderManager.LoaderCallbacks { - private static final int LOADER_ID = 0x01; - protected Button deleteButton; - protected Button toggleButton; - protected LinearLayout llParent; - protected ProgressBar progressBar; - protected boolean canHideProgressBar; - private boolean progressBarVisible; - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - rootView = inflater.inflate(R.layout.file_manager_list, container, false); - deleteButton = rootView.findViewById(R.id.delete_button); - deleteButton.setText(getString(org.odk.collect.strings.R.string.delete_file)); - toggleButton = rootView.findViewById(R.id.toggle_button); - llParent = rootView.findViewById(R.id.llParent); - progressBar = getActivity().findViewById(R.id.progressBar); - - setHasOptionsMenu(true); - return rootView; - } - - @Override - public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { - getListView().setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); - getListView().setItemsCanFocus(false); - deleteButton.setEnabled(false); - - sortingOptions = Arrays.asList( - new FormListSortingOption( - R.drawable.ic_sort_by_alpha, - org.odk.collect.strings.R.string.sort_by_name_asc - ), - new FormListSortingOption( - R.drawable.ic_sort_by_alpha, - org.odk.collect.strings.R.string.sort_by_name_desc - ), - new FormListSortingOption( - R.drawable.ic_access_time, - org.odk.collect.strings.R.string.sort_by_date_desc - ), - new FormListSortingOption( - R.drawable.ic_access_time, - org.odk.collect.strings.R.string.sort_by_date_asc - ) - ); - getLoaderManager().initLoader(LOADER_ID, null, this); - super.onViewCreated(view, savedInstanceState); - } - - @Override - public void onViewStateRestored(@Nullable Bundle bundle) { - super.onViewStateRestored(bundle); - deleteButton.setEnabled(areCheckedItems()); - } - - @Override - public void onListItemClick(ListView l, View v, int position, long rowId) { - super.onListItemClick(l, v, position, rowId); - - if (getListView().isItemChecked(position)) { - selectedInstances.add(getListView().getItemIdAtPosition(position)); - } else { - selectedInstances.remove(getListView().getItemIdAtPosition(position)); - } - - toggleButtonLabel(toggleButton, getListView()); - deleteButton.setEnabled(areCheckedItems()); - } - - @Override - protected void updateAdapter() { - getLoaderManager().restartLoader(LOADER_ID, null, this); - } - - @NonNull - @Override - public Loader onCreateLoader(int id, Bundle args) { - showProgressBar(); - return getCursorLoader(); - } - - @Override - public void onLoadFinished(@NonNull Loader loader, Cursor cursor) { - hideProgressBarIfAllowed(); - listAdapter.swapCursor(cursor); - - checkPreviouslyCheckedItems(); - toggleButtonLabel(toggleButton, getListView()); - deleteButton.setEnabled(areCheckedItems()); - - if (getListView().getCount() == 0) { - getView().findViewById(R.id.buttons).setVisibility(View.GONE); - } else { - getView().findViewById(R.id.buttons).setVisibility(View.VISIBLE); - toggleButton.setEnabled(true); - } - } - - @Override - public void onLoaderReset(@NonNull Loader loader) { - listAdapter.swapCursor(null); - } - - protected abstract CursorLoader getCursorLoader(); - - protected void hideProgressBarIfAllowed() { - if (canHideProgressBar && progressBarVisible) { - hideProgressBar(); - } - } - - protected void hideProgressBarAndAllow() { - this.canHideProgressBar = true; - hideProgressBar(); - } - - private void hideProgressBar() { - progressBar.setVisibility(View.GONE); - progressBarVisible = false; - } - - protected void showProgressBar() { - progressBar.setVisibility(View.VISIBLE); - progressBarVisible = true; - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/fragments/SavedFormListFragment.java b/collect_app/src/main/java/org/odk/collect/android/fragments/SavedFormListFragment.java deleted file mode 100644 index cf7fab6bdaa..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/fragments/SavedFormListFragment.java +++ /dev/null @@ -1,291 +0,0 @@ -/* - * Copyright (C) 2017 University of Washington - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the License - * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing permissions and limitations under - * the License. - */ - -package org.odk.collect.android.fragments; - -import static org.odk.collect.android.utilities.ApplicationConstants.SortingOrder.BY_DATE_ASC; -import static org.odk.collect.android.utilities.ApplicationConstants.SortingOrder.BY_DATE_DESC; -import static org.odk.collect.android.utilities.ApplicationConstants.SortingOrder.BY_NAME_ASC; -import static org.odk.collect.android.utilities.ApplicationConstants.SortingOrder.BY_NAME_DESC; -import static org.odk.collect.android.utilities.ApplicationConstants.SortingOrder.BY_STATUS_ASC; -import static org.odk.collect.android.utilities.ApplicationConstants.SortingOrder.BY_STATUS_DESC; - -import android.app.ProgressDialog; -import android.content.Context; -import android.content.DialogInterface; -import android.database.Cursor; -import android.os.AsyncTask; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ListView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.loader.content.CursorLoader; -import androidx.loader.content.Loader; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import org.jetbrains.annotations.NotNull; -import org.odk.collect.android.R; -import org.odk.collect.android.adapters.InstanceListCursorAdapter; -import org.odk.collect.android.dao.CursorLoaderFactory; -import org.odk.collect.android.database.instances.DatabaseInstanceColumns; -import org.odk.collect.android.injection.DaggerUtils; -import org.odk.collect.android.listeners.DeleteInstancesListener; -import org.odk.collect.android.projects.ProjectsDataService; -import org.odk.collect.android.tasks.DeleteInstancesTask; -import org.odk.collect.android.utilities.FormsRepositoryProvider; -import org.odk.collect.android.utilities.InstancesRepositoryProvider; -import org.odk.collect.android.views.DayNightProgressDialog; -import org.odk.collect.androidshared.ui.ToastUtils; - -import javax.inject.Inject; - -import timber.log.Timber; - -/** - * Responsible for displaying and deleting all the saved form instances - * directory. - * - * @author Carl Hartung (carlhartung@gmail.com) - * @author Yaw Anokwa (yanokwa@gmail.com) - */ -public class SavedFormListFragment extends FileManagerFragment implements DeleteInstancesListener, View.OnClickListener { - private static final String DATA_MANAGER_LIST_SORTING_ORDER = "dataManagerListSortingOrder"; - - DeleteInstancesTask deleteInstancesTask; - private AlertDialog alertDialog; - private ProgressDialog progressDialog; - - @Inject - InstancesRepositoryProvider instancesRepositoryProvider; - - @Inject - FormsRepositoryProvider formsRepositoryProvider; - - @Inject - ProjectsDataService projectsDataService; - - @Override - public void onAttach(@NonNull Context context) { - super.onAttach(context); - DaggerUtils.getComponent(context).inject(this); - } - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - return super.onCreateView(inflater, container, savedInstanceState); - } - - @Override - public void onViewCreated(@NonNull View rootView, Bundle savedInstanceState) { - deleteButton.setOnClickListener(this); - toggleButton.setOnClickListener(this); - - setupAdapter(); - - super.onViewCreated(rootView, savedInstanceState); - } - - @Override - public void onResume() { - listAdapter.notifyDataSetChanged(); - - // hook up to receive completion events - if (deleteInstancesTask != null) { - deleteInstancesTask.setDeleteListener(this); - } - super.onResume(); - // async task may have completed while we were reorienting... - if (deleteInstancesTask != null - && deleteInstancesTask.getStatus() == AsyncTask.Status.FINISHED) { - deleteComplete(deleteInstancesTask.getDeleteCount()); - } - } - - @Override - public void onPause() { - if (deleteInstancesTask != null) { - deleteInstancesTask.setDeleteListener(null); - } - if (alertDialog != null && alertDialog.isShowing()) { - alertDialog.dismiss(); - } - super.onPause(); - } - - private void setupAdapter() { - String[] data = {DatabaseInstanceColumns.DISPLAY_NAME}; - int[] view = {R.id.form_title}; - - listAdapter = new InstanceListCursorAdapter(getActivity(), - R.layout.form_chooser_list_item_multiple_choice, null, data, view, false); - setListAdapter(listAdapter); - checkPreviouslyCheckedItems(); - } - - @Override - protected String getSortingOrderKey() { - return DATA_MANAGER_LIST_SORTING_ORDER; - } - - @Override - protected CursorLoader getCursorLoader() { - return new CursorLoaderFactory(projectsDataService).createSavedInstancesCursorLoader(getFilterText(), getSortingOrder()); - } - - /** - * Create the instance delete dialog - */ - private void createDeleteInstancesDialog() { - alertDialog = new MaterialAlertDialogBuilder(getContext()).create(); - alertDialog.setTitle(getString(org.odk.collect.strings.R.string.delete_file)); - alertDialog.setMessage(getString(org.odk.collect.strings.R.string.delete_confirm, - String.valueOf(getCheckedCount()))); - DialogInterface.OnClickListener dialogYesNoListener = - (dialog, i) -> { - if (i == DialogInterface.BUTTON_POSITIVE) { // delete - deleteSelectedInstances(); - if (getListView().getCount() == getCheckedCount()) { - toggleButton.setEnabled(false); - } - } - }; - alertDialog.setCancelable(false); - alertDialog.setButton(DialogInterface.BUTTON_POSITIVE, getString(org.odk.collect.strings.R.string.delete_yes), - dialogYesNoListener); - alertDialog.setButton(DialogInterface.BUTTON_NEGATIVE, getString(org.odk.collect.strings.R.string.delete_no), - dialogYesNoListener); - alertDialog.show(); - } - - @Override - public void progressUpdate(int progress, int total) { - String message = String.format(getResources().getString(org.odk.collect.strings.R.string.deleting_form_dialog_update_message), progress, total); - progressDialog.setMessage(message); - } - - /** - * Deletes the selected files. Content provider handles removing the files - * from the filesystem. - */ - private void deleteSelectedInstances() { - if (deleteInstancesTask == null) { - progressDialog = new DayNightProgressDialog(getContext()); - progressDialog.setMessage(getResources().getString(org.odk.collect.strings.R.string.form_delete_message)); - progressDialog.setIndeterminate(true); - progressDialog.setCancelable(false); - progressDialog.show(); - - deleteInstancesTask = new DeleteInstancesTask(instancesRepositoryProvider.get(), formsRepositoryProvider.get()); - deleteInstancesTask.setDeleteListener(this); - deleteInstancesTask.execute(getCheckedIdObjects()); - } else { - ToastUtils.showLongToast(requireContext(), org.odk.collect.strings.R.string.file_delete_in_progress); - } - } - - @Override - public void onListItemClick(ListView l, View v, int position, long rowId) { - super.onListItemClick(l, v, position, rowId); - } - - @Override - public void deleteComplete(int deletedInstances) { - Timber.i("Delete instances complete"); - final int toDeleteCount = deleteInstancesTask.getToDeleteCount(); - - if (deletedInstances == toDeleteCount) { - // all deletes were successful - ToastUtils.showShortToast(requireContext(), getString(org.odk.collect.strings.R.string.file_deleted_ok, String.valueOf(deletedInstances))); - } else { - // had some failures - Timber.e(new Error("Failed to delete " + (toDeleteCount - deletedInstances) + " instances")); - ToastUtils.showLongToast(requireContext(), getString(org.odk.collect.strings.R.string.file_deleted_error, - String.valueOf(toDeleteCount - deletedInstances), - String.valueOf(toDeleteCount))); - } - - deleteInstancesTask = null; - getListView().clearChoices(); // doesn't unset the checkboxes - for (int i = 0; i < getListView().getCount(); ++i) { - getListView().setItemChecked(i, false); - } - deleteButton.setEnabled(false); - - updateAdapter(); - progressDialog.dismiss(); - } - - @Override - public void onClick(View v) { - if (v.getId() == R.id.delete_button) { - int checkedItemCount = getCheckedCount(); - if (checkedItemCount > 0) { - createDeleteInstancesDialog(); - } else { - ToastUtils.showShortToast(requireContext(), org.odk.collect.strings.R.string.noselect_error); - } - } else if (v.getId() == R.id.toggle_button) { - ListView lv = getListView(); - boolean allChecked = toggleChecked(lv); - if (allChecked) { - for (int i = 0; i < lv.getCount(); i++) { - selectedInstances.add(lv.getItemIdAtPosition(i)); - } - } else { - selectedInstances.clear(); - } - toggleButtonLabel(toggleButton, getListView()); - deleteButton.setEnabled(allChecked); - } - } - - @Override - public void onLoadFinished(@NonNull @NotNull Loader loader, Cursor cursor) { - super.onLoadFinished(loader, cursor); - hideProgressBarAndAllow(); - } - - private String getSortingOrder() { - String sortOrder = DatabaseInstanceColumns.DISPLAY_NAME + " COLLATE NOCASE ASC, " + DatabaseInstanceColumns.STATUS + " DESC"; - switch (getSelectedSortingOrder()) { - case BY_NAME_ASC: - sortOrder = DatabaseInstanceColumns.DISPLAY_NAME + " COLLATE NOCASE ASC, " + DatabaseInstanceColumns.STATUS + " DESC"; - break; - case BY_NAME_DESC: - sortOrder = DatabaseInstanceColumns.DISPLAY_NAME + " COLLATE NOCASE DESC, " + DatabaseInstanceColumns.STATUS + " DESC"; - break; - case BY_DATE_ASC: - sortOrder = DatabaseInstanceColumns.LAST_STATUS_CHANGE_DATE + " ASC"; - break; - case BY_DATE_DESC: - sortOrder = DatabaseInstanceColumns.LAST_STATUS_CHANGE_DATE + " DESC"; - break; - case BY_STATUS_ASC: - sortOrder = DatabaseInstanceColumns.STATUS + " ASC, " + DatabaseInstanceColumns.DISPLAY_NAME + " COLLATE NOCASE ASC"; - break; - case BY_STATUS_DESC: - sortOrder = DatabaseInstanceColumns.STATUS + " DESC, " + DatabaseInstanceColumns.DISPLAY_NAME + " COLLATE NOCASE ASC"; - break; - } - return sortOrder; - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/fragments/dialogs/FormsDownloadResultDialog.kt b/collect_app/src/main/java/org/odk/collect/android/fragments/dialogs/FormsDownloadResultDialog.kt index f9d37952830..60135537434 100644 --- a/collect_app/src/main/java/org/odk/collect/android/fragments/dialogs/FormsDownloadResultDialog.kt +++ b/collect_app/src/main/java/org/odk/collect/android/fragments/dialogs/FormsDownloadResultDialog.kt @@ -6,9 +6,8 @@ import android.content.Intent import android.os.Bundle import androidx.fragment.app.DialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.odk.collect.android.R -import org.odk.collect.android.formmanagement.FormDownloadException import org.odk.collect.android.formmanagement.ServerFormDetails +import org.odk.collect.android.formmanagement.download.FormDownloadException import org.odk.collect.android.injection.DaggerUtils import org.odk.collect.android.utilities.FormsDownloadResultInterpreter import org.odk.collect.errors.ErrorActivity 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 cbdb89c1696..245cf06bada 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 @@ -5,26 +5,23 @@ import org.javarosa.core.reference.ReferenceManager; import org.odk.collect.android.activities.AboutActivity; import org.odk.collect.android.activities.AppListActivity; -import org.odk.collect.android.activities.DeleteSavedFormActivity; +import org.odk.collect.android.activities.DeleteFormsActivity; import org.odk.collect.android.activities.FirstLaunchActivity; import org.odk.collect.android.activities.FormDownloadListActivity; import org.odk.collect.android.activities.FormFillingActivity; -import org.odk.collect.android.formhierarchy.FormHierarchyActivity; import org.odk.collect.android.activities.FormMapActivity; import org.odk.collect.android.activities.InstanceChooserList; -import org.odk.collect.android.adapters.InstanceUploaderAdapter; import org.odk.collect.android.application.Collect; import org.odk.collect.android.application.initialization.ApplicationInitializer; import org.odk.collect.android.application.initialization.ExistingProjectMigrator; import org.odk.collect.android.audio.AudioRecordingControllerFragment; import org.odk.collect.android.audio.AudioRecordingErrorDialogFragment; -import org.odk.collect.android.backgroundwork.AutoSendTaskSpec; +import org.odk.collect.android.backgroundwork.SendFormsTaskSpec; import org.odk.collect.android.backgroundwork.AutoUpdateTaskSpec; import org.odk.collect.android.backgroundwork.SyncFormsTaskSpec; import org.odk.collect.android.configure.qr.QRCodeScannerFragment; import org.odk.collect.android.configure.qr.QRCodeTabsActivity; import org.odk.collect.android.configure.qr.ShowQRCodeFragment; -import org.odk.collect.draw.DrawActivity; import org.odk.collect.android.entities.EntitiesRepositoryProvider; import org.odk.collect.android.external.AndroidShortcutsActivity; import org.odk.collect.android.external.FormUriActivity; @@ -35,19 +32,17 @@ import org.odk.collect.android.formentry.repeats.DeleteRepeatDialogFragment; import org.odk.collect.android.formentry.saving.SaveAnswerFileErrorDialogFragment; import org.odk.collect.android.formentry.saving.SaveFormProgressDialogFragment; +import org.odk.collect.android.formhierarchy.FormHierarchyActivity; import org.odk.collect.android.formlists.blankformlist.BlankFormListActivity; import org.odk.collect.android.formmanagement.FormSourceProvider; import org.odk.collect.android.formmanagement.FormsDataService; -import org.odk.collect.android.fragments.AppListFragment; import org.odk.collect.android.fragments.BarCodeScannerFragment; -import org.odk.collect.android.fragments.SavedFormListFragment; import org.odk.collect.android.fragments.dialogs.FormsDownloadResultDialog; import org.odk.collect.android.fragments.dialogs.SelectMinimalDialog; import org.odk.collect.android.instancemanagement.send.InstanceUploaderActivity; import org.odk.collect.android.instancemanagement.send.InstanceUploaderListActivity; import org.odk.collect.android.mainmenu.MainMenuActivity; import org.odk.collect.android.openrosa.OpenRosaHttpInterface; -import org.odk.collect.android.preferences.CaptionedListPreference; import org.odk.collect.android.preferences.dialogs.AdminPasswordDialogFragment; import org.odk.collect.android.preferences.dialogs.ChangeAdminPasswordDialog; import org.odk.collect.android.preferences.dialogs.ResetDialogPreferenceFragmentCompat; @@ -67,24 +62,25 @@ import org.odk.collect.android.preferences.screens.ServerPreferencesFragment; import org.odk.collect.android.preferences.screens.UserInterfacePreferencesFragment; import org.odk.collect.android.projects.ManualProjectCreatorDialog; +import org.odk.collect.android.projects.ProjectDependencyProviderFactory; +import org.odk.collect.android.projects.ProjectResetter; import org.odk.collect.android.projects.ProjectSettingsDialog; import org.odk.collect.android.projects.ProjectsDataService; import org.odk.collect.android.projects.QrCodeProjectCreatorDialog; import org.odk.collect.android.storage.StoragePathProvider; import org.odk.collect.android.tasks.DownloadFormListTask; -import org.odk.collect.android.tasks.InstanceServerUploaderTask; +import org.odk.collect.android.tasks.InstanceUploaderTask; import org.odk.collect.android.tasks.MediaLoadingTask; -import org.odk.collect.android.upload.InstanceUploader; import org.odk.collect.android.utilities.AuthDialogUtility; import org.odk.collect.android.utilities.FormsRepositoryProvider; import org.odk.collect.android.utilities.InstancesRepositoryProvider; -import org.odk.collect.android.utilities.ProjectResetter; +import org.odk.collect.android.utilities.SavepointsRepositoryProvider; import org.odk.collect.android.utilities.ThemeUtils; -import org.odk.collect.android.widgets.ExStringWidget; import org.odk.collect.android.widgets.QuestionWidget; import org.odk.collect.android.widgets.items.SelectOneFromMapDialogFragment; -import org.odk.collect.androidshared.network.NetworkStateProvider; +import org.odk.collect.async.network.NetworkStateProvider; import org.odk.collect.async.Scheduler; +import org.odk.collect.draw.DrawActivity; import org.odk.collect.googlemaps.GoogleMapFragment; import org.odk.collect.location.LocationClient; import org.odk.collect.maps.MapFragmentFactory; @@ -94,6 +90,7 @@ import org.odk.collect.projects.ProjectsRepository; import org.odk.collect.settings.ODKAppSettingsImporter; import org.odk.collect.settings.SettingsProvider; +import org.odk.collect.webpage.ExternalWebPageHelper; import javax.inject.Singleton; @@ -140,13 +137,9 @@ interface Builder { void inject(AboutActivity aboutActivity); - void inject(InstanceUploaderAdapter instanceUploaderAdapter); - - void inject(SavedFormListFragment savedFormListFragment); - void inject(FormFillingActivity formFillingActivity); - void inject(InstanceServerUploaderTask uploader); + void inject(InstanceUploaderTask uploader); void inject(ServerPreferencesFragment serverPreferencesFragment); @@ -162,8 +155,6 @@ interface Builder { void inject(QuestionWidget questionWidget); - void inject(ExStringWidget exStringWidget); - void inject(ODKView odkView); void inject(FormMetadataPreferencesFragment formMetadataPreferencesFragment); @@ -178,7 +169,7 @@ interface Builder { void inject(ShowQRCodeFragment showQRCodeFragment); - void inject(AutoSendTaskSpec autoSendTaskSpec); + void inject(SendFormsTaskSpec sendFormsTaskSpec); void inject(AdminPasswordDialogFragment adminPasswordDialogFragment); @@ -214,7 +205,7 @@ interface Builder { void inject(ProjectPreferencesFragment projectPreferencesFragment); - void inject(DeleteSavedFormActivity deleteSavedFormActivity); + void inject(DeleteFormsActivity deleteFormsActivity); void inject(SelectMinimalDialog selectMinimalDialog); @@ -232,8 +223,6 @@ interface Builder { void inject(BackgroundAudioPermissionDialogFragment backgroundAudioPermissionDialogFragment); - void inject(AppListFragment appListFragment); - void inject(ChangeAdminPasswordDialog changeAdminPasswordDialog); void inject(MediaLoadingTask mediaLoadingTask); @@ -244,8 +233,6 @@ interface Builder { void inject(BaseAdminPreferencesFragment baseAdminPreferencesFragment); - void inject(CaptionedListPreference captionedListPreference); - void inject(AndroidShortcutsActivity androidShortcutsActivity); void inject(ProjectSettingsDialog projectSettingsDialog); @@ -256,8 +243,6 @@ interface Builder { void inject(FirstLaunchActivity firstLaunchActivity); - void inject(InstanceUploader instanceUploader); - void inject(FormUriActivity formUriActivity); void inject(MapsPreferencesFragment mapsPreferencesFragment); @@ -296,6 +281,8 @@ interface Builder { InstancesRepositoryProvider instancesRepositoryProvider(); + SavepointsRepositoryProvider savepointsRepositoryProvider(); + FormSourceProvider formSourceProvider(); ExistingProjectMigrator existingProjectMigrator(); @@ -319,4 +306,8 @@ interface Builder { EntitiesRepositoryProvider entitiesRepositoryProvider(); FormsDataService formsDataService(); + + ProjectDependencyProviderFactory projectDependencyProviderFactory(); + + ExternalWebPageHelper externalWebPageHelper(); } 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 825deb4807f..77da92bd5d7 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 @@ -29,9 +29,10 @@ import org.odk.collect.android.application.initialization.ApplicationInitializer; import org.odk.collect.android.application.initialization.ExistingProjectMigrator; import org.odk.collect.android.application.initialization.ExistingSettingsMigrator; -import org.odk.collect.android.application.initialization.FormUpdatesUpgrade; import org.odk.collect.android.application.initialization.GoogleDriveProjectsDeleter; import org.odk.collect.android.application.initialization.MapsInitializer; +import org.odk.collect.android.application.initialization.SavepointsImporter; +import org.odk.collect.android.application.initialization.ScheduledWorkUpgrade; import org.odk.collect.android.application.initialization.upgrade.UpgradeInitializer; import org.odk.collect.android.backgroundwork.FormUpdateAndInstanceSubmitScheduler; import org.odk.collect.android.backgroundwork.FormUpdateScheduler; @@ -48,17 +49,13 @@ import org.odk.collect.android.formentry.media.ScreenContextAudioHelperFactory; import org.odk.collect.android.formlists.blankformlist.BlankFormListViewModel; import org.odk.collect.android.formmanagement.CollectFormEntryControllerFactory; -import org.odk.collect.android.formmanagement.FormDownloader; -import org.odk.collect.android.formmanagement.FormMetadataParser; import org.odk.collect.android.formmanagement.FormSourceProvider; import org.odk.collect.android.formmanagement.FormsDataService; -import org.odk.collect.android.formmanagement.InstancesDataService; -import org.odk.collect.android.formmanagement.ServerFormDownloader; import org.odk.collect.android.formmanagement.ServerFormsDetailsFetcher; +import org.odk.collect.android.geo.MapConfiguratorProvider; import org.odk.collect.android.geo.MapFragmentFactoryImpl; +import org.odk.collect.android.instancemanagement.InstancesDataService; import org.odk.collect.android.instancemanagement.autosend.AutoSendSettingsProvider; -import org.odk.collect.android.instancemanagement.autosend.InstanceAutoSendFetcher; -import org.odk.collect.android.instancemanagement.autosend.InstanceAutoSender; import org.odk.collect.android.instancemanagement.send.ReadyToSendViewModel; import org.odk.collect.android.itemsets.FastExternalItemsetsRepository; import org.odk.collect.android.mainmenu.MainMenuViewModelFactory; @@ -73,10 +70,11 @@ import org.odk.collect.android.preferences.ProjectPreferencesViewModel; import org.odk.collect.android.preferences.source.SettingsStore; import org.odk.collect.android.preferences.source.SharedPreferencesSettingsProvider; -import org.odk.collect.android.projects.ProjectsDataService; import org.odk.collect.android.projects.ProjectCreator; import org.odk.collect.android.projects.ProjectDeleter; import org.odk.collect.android.projects.ProjectDependencyProviderFactory; +import org.odk.collect.android.projects.ProjectResetter; +import org.odk.collect.android.projects.ProjectsDataService; import org.odk.collect.android.storage.StoragePathProvider; import org.odk.collect.android.storage.StorageSubdirectory; import org.odk.collect.android.tasks.FormLoaderTask; @@ -86,20 +84,19 @@ import org.odk.collect.android.utilities.CodeCaptureManagerFactory; import org.odk.collect.android.utilities.ContentUriProvider; import org.odk.collect.android.utilities.ExternalAppIntentProvider; -import org.odk.collect.android.utilities.ExternalWebPageHelper; import org.odk.collect.android.utilities.FileProvider; import org.odk.collect.android.utilities.FormsRepositoryProvider; import org.odk.collect.android.utilities.ImageCompressionController; import org.odk.collect.android.utilities.InstancesRepositoryProvider; import org.odk.collect.android.utilities.MediaUtils; -import org.odk.collect.android.utilities.ProjectResetter; +import org.odk.collect.android.utilities.SavepointsRepositoryProvider; import org.odk.collect.android.utilities.SoftKeyboardController; import org.odk.collect.android.utilities.WebCredentialsUtils; import org.odk.collect.android.version.VersionInformation; import org.odk.collect.android.views.BarcodeViewDecoder; import org.odk.collect.androidshared.bitmap.ImageCompressor; -import org.odk.collect.androidshared.network.ConnectivityProvider; -import org.odk.collect.androidshared.network.NetworkStateProvider; +import org.odk.collect.async.network.ConnectivityProvider; +import org.odk.collect.async.network.NetworkStateProvider; import org.odk.collect.androidshared.system.IntentLauncher; import org.odk.collect.androidshared.system.IntentLauncherImpl; import org.odk.collect.androidshared.utils.ScreenUtils; @@ -124,8 +121,8 @@ import org.odk.collect.permissions.PermissionsProvider; import org.odk.collect.projects.ProjectsRepository; import org.odk.collect.projects.SharedPreferencesProjectsRepository; -import org.odk.collect.qrcode.QRCodeDecoder; import org.odk.collect.qrcode.QRCodeCreatorImpl; +import org.odk.collect.qrcode.QRCodeDecoder; import org.odk.collect.qrcode.QRCodeDecoderImpl; import org.odk.collect.settings.ODKAppSettingsImporter; import org.odk.collect.settings.ODKAppSettingsMigrator; @@ -137,6 +134,7 @@ import org.odk.collect.settings.keys.ProjectKeys; import org.odk.collect.shared.strings.UUIDGenerator; import org.odk.collect.utilities.UserAgentProvider; +import org.odk.collect.webpage.ExternalWebPageHelper; import java.io.File; @@ -189,11 +187,6 @@ WebCredentialsUtils provideWebCredentials(SettingsProvider settingsProvider) { return new WebCredentialsUtils(settingsProvider.getUnprotectedSettings()); } - @Provides - public FormDownloader providesFormDownloader(FormSourceProvider formSourceProvider, FormsRepositoryProvider formsRepositoryProvider, StoragePathProvider storagePathProvider) { - return new ServerFormDownloader(formSourceProvider.get(), formsRepositoryProvider.get(), new File(storagePathProvider.getOdkDirPath(StorageSubdirectory.CACHE)), storagePathProvider.getOdkDirPath(StorageSubdirectory.FORMS), new FormMetadataParser(), System::currentTimeMillis); - } - @Provides @Singleton public Analytics providesAnalytics(Application application) { @@ -368,8 +361,8 @@ public AudioRecorder providesAudioRecorder(Application application) { @Provides @Singleton - public EntitiesRepositoryProvider provideEntitiesRepositoryProvider(Application application, ProjectsDataService projectsDataService) { - return new EntitiesRepositoryProvider(application, projectsDataService); + public EntitiesRepositoryProvider provideEntitiesRepositoryProvider(ProjectsDataService projectsDataService, StoragePathProvider storagePathProvider) { + return new EntitiesRepositoryProvider(projectsDataService, storagePathProvider); } @Provides @@ -440,7 +433,7 @@ public UUIDGenerator providesUUIDGenerator() { } @Provides - public InstancesDataService providesInstancesDataService(Application application, InstancesRepositoryProvider instancesRepositoryProvider, ProjectsDataService projectsDataService, FormsRepositoryProvider formsRepositoryProvider, EntitiesRepositoryProvider entitiesRepositoryProvider, StoragePathProvider storagePathProvider, InstanceSubmitScheduler instanceSubmitScheduler) { + public InstancesDataService providesInstancesDataService(Application application, ProjectsDataService projectsDataService, InstanceSubmitScheduler instanceSubmitScheduler, ProjectDependencyProviderFactory projectsDependencyProviderFactory, Notifier notifier, PropertyManager propertyManager, OpenRosaHttpInterface httpInterface) { Function0 onUpdate = () -> { application.getContentResolver().notifyChange( InstancesContract.getUri(projectsDataService.getCurrentProject().getUuid()), @@ -450,7 +443,7 @@ public InstancesDataService providesInstancesDataService(Application application return null; }; - return new InstancesDataService(getState(application), formsRepositoryProvider, instancesRepositoryProvider, entitiesRepositoryProvider, storagePathProvider, instanceSubmitScheduler, projectsDataService, onUpdate); + return new InstancesDataService(getState(application), instanceSubmitScheduler, projectsDependencyProviderFactory, notifier, propertyManager, httpInterface, onUpdate); } @Provides @@ -473,6 +466,11 @@ public InstancesRepositoryProvider providesInstancesRepositoryProvider(Context c return new InstancesRepositoryProvider(context, storagePathProvider); } + @Provides + public SavepointsRepositoryProvider providesSavepointsRepositoryProvider(Context context, StoragePathProvider storagePathProvider) { + return new SavepointsRepositoryProvider(context, storagePathProvider); + } + @Provides public ProjectPreferencesViewModel.Factory providesProjectPreferencesViewModel(AdminPasswordProvider adminPasswordProvider) { return new ProjectPreferencesViewModel.Factory(adminPasswordProvider); @@ -490,7 +488,7 @@ public MainMenuViewModelFactory providesMainMenuViewModelFactory(VersionInformat AnalyticsInitializer analyticsInitializer, PermissionsChecker permissionChecker, FormsRepositoryProvider formsRepositoryProvider, InstancesRepositoryProvider instancesRepositoryProvider, AutoSendSettingsProvider autoSendSettingsProvider) { - return new MainMenuViewModelFactory(versionInformation, application, settingsProvider, instancesDataService, scheduler, projectsDataService, analyticsInitializer, permissionChecker, formsRepositoryProvider, instancesRepositoryProvider, autoSendSettingsProvider); + return new MainMenuViewModelFactory(versionInformation, application, settingsProvider, instancesDataService, scheduler, projectsDataService, permissionChecker, formsRepositoryProvider, instancesRepositoryProvider, autoSendSettingsProvider); } @Provides @@ -509,14 +507,8 @@ public FormsDataService providesFormsUpdater(Application application, Notifier n } @Provides - public InstanceAutoSender providesInstanceAutoSender(AutoSendSettingsProvider autoSendSettingsProvider, Notifier notifier, InstancesDataService instancesDataService, PropertyManager propertyManager) { - InstanceAutoSendFetcher instanceAutoSendFetcher = new InstanceAutoSendFetcher(autoSendSettingsProvider); - return new InstanceAutoSender(instanceAutoSendFetcher, notifier, instancesDataService, propertyManager); - } - - @Provides - public AutoSendSettingsProvider providesAutoSendSettingsProvider(NetworkStateProvider networkStateProvider, SettingsProvider settingsProvider) { - return new AutoSendSettingsProvider(networkStateProvider, settingsProvider); + public AutoSendSettingsProvider providesAutoSendSettingsProvider(Application application, SettingsProvider settingsProvider, NetworkStateProvider networkStateProvider) { + return new AutoSendSettingsProvider(application, networkStateProvider, settingsProvider); } @Provides @@ -530,8 +522,8 @@ public ExistingProjectMigrator providesExistingProjectMigrator(Context context, } @Provides - public FormUpdatesUpgrade providesFormUpdatesUpgrader(Scheduler scheduler, ProjectsRepository projectsRepository, FormUpdateScheduler formUpdateScheduler) { - return new FormUpdatesUpgrade(scheduler, projectsRepository, formUpdateScheduler); + public ScheduledWorkUpgrade providesFormUpdatesUpgrader(Scheduler scheduler, ProjectsRepository projectsRepository, FormUpdateScheduler formUpdateScheduler, InstanceSubmitScheduler instanceSubmitScheduler) { + return new ScheduledWorkUpgrade(scheduler, projectsRepository, formUpdateScheduler, instanceSubmitScheduler); } @Provides @@ -545,20 +537,21 @@ public GoogleDriveProjectsDeleter providesGoogleDriveProjectsDeleter(ProjectsRep } @Provides - public UpgradeInitializer providesUpgradeInitializer(Context context, SettingsProvider settingsProvider, ExistingProjectMigrator existingProjectMigrator, ExistingSettingsMigrator existingSettingsMigrator, FormUpdatesUpgrade formUpdatesUpgrade, GoogleDriveProjectsDeleter googleDriveProjectsDeleter) { + public UpgradeInitializer providesUpgradeInitializer(Context context, SettingsProvider settingsProvider, ExistingProjectMigrator existingProjectMigrator, ExistingSettingsMigrator existingSettingsMigrator, ScheduledWorkUpgrade scheduledWorkUpgrade, GoogleDriveProjectsDeleter googleDriveProjectsDeleter, ProjectsRepository projectsRepository, ProjectDependencyProviderFactory projectDependencyProviderFactory) { return new UpgradeInitializer( context, settingsProvider, existingProjectMigrator, existingSettingsMigrator, - formUpdatesUpgrade, - googleDriveProjectsDeleter + scheduledWorkUpgrade, + googleDriveProjectsDeleter, + new SavepointsImporter(projectsRepository, projectDependencyProviderFactory) ); } @Provides - public ApplicationInitializer providesApplicationInitializer(Application context, UserAgentProvider userAgentProvider, PropertyManager propertyManager, Analytics analytics, UpgradeInitializer upgradeInitializer, AnalyticsInitializer analyticsInitializer, ProjectsRepository projectsRepository, SettingsProvider settingsProvider, MapsInitializer mapsInitializer) { - return new ApplicationInitializer(context, propertyManager, analytics, upgradeInitializer, analyticsInitializer, mapsInitializer, projectsRepository, settingsProvider); + public ApplicationInitializer providesApplicationInitializer(Application context, PropertyManager propertyManager, Analytics analytics, UpgradeInitializer upgradeInitializer, AnalyticsInitializer analyticsInitializer, ProjectsRepository projectsRepository, SettingsProvider settingsProvider, MapsInitializer mapsInitializer, EntitiesRepositoryProvider entitiesRepositoryProvider) { + return new ApplicationInitializer(context, propertyManager, analytics, upgradeInitializer, analyticsInitializer, mapsInitializer, projectsRepository, settingsProvider, entitiesRepositoryProvider); } @Provides @@ -567,8 +560,8 @@ public ProjectDeleter providesProjectDeleter(ProjectsRepository projectsReposito } @Provides - public ProjectResetter providesProjectResetter(StoragePathProvider storagePathProvider, PropertyManager propertyManager, SettingsProvider settingsProvider, InstancesRepositoryProvider instancesRepositoryProvider, FormsRepositoryProvider formsRepositoryProvider) { - return new ProjectResetter(storagePathProvider, propertyManager, settingsProvider, instancesRepositoryProvider, formsRepositoryProvider); + public ProjectResetter providesProjectResetter(StoragePathProvider storagePathProvider, PropertyManager propertyManager, SettingsProvider settingsProvider, FormsRepositoryProvider formsRepositoryProvider, SavepointsRepositoryProvider savepointsRepositoryProvider, InstancesDataService instancesDataService, ProjectsDataService projectsDataService) { + return new ProjectResetter(storagePathProvider, propertyManager, settingsProvider, formsRepositoryProvider, savepointsRepositoryProvider, instancesDataService, projectsDataService.getCurrentProject().getUuid()); } @Provides @@ -577,10 +570,13 @@ public PreferenceVisibilityHandler providesDisabledPreferencesRemover(SettingsPr } @Provides - public ReferenceLayerRepository providesReferenceLayerRepository(StoragePathProvider storagePathProvider) { + public ReferenceLayerRepository providesReferenceLayerRepository(StoragePathProvider storagePathProvider, SettingsProvider settingsProvider) { return new DirectoryReferenceLayerRepository( + storagePathProvider.getOdkDirPath(StorageSubdirectory.SHARED_LAYERS), storagePathProvider.getOdkDirPath(StorageSubdirectory.LAYERS), - storagePathProvider.getOdkDirPath(StorageSubdirectory.SHARED_LAYERS) + () -> MapConfiguratorProvider.getConfigurator( + settingsProvider.getUnprotectedSettings().getString(ProjectKeys.KEY_BASEMAP_SOURCE) + ) ); } @@ -616,8 +612,8 @@ public ImageLoader providesImageLoader() { } @Provides - public ProjectDependencyProviderFactory providesProjectDependencyProviderFactory(SettingsProvider settingsProvider, FormsRepositoryProvider formsRepositoryProvider, InstancesRepositoryProvider instancesRepositoryProvider, StoragePathProvider storagePathProvider, ChangeLockProvider changeLockProvider, FormSourceProvider formSourceProvider) { - return new ProjectDependencyProviderFactory(settingsProvider, formsRepositoryProvider, instancesRepositoryProvider, storagePathProvider, changeLockProvider, formSourceProvider); + public ProjectDependencyProviderFactory providesProjectDependencyProviderFactory(SettingsProvider settingsProvider, FormsRepositoryProvider formsRepositoryProvider, InstancesRepositoryProvider instancesRepositoryProvider, StoragePathProvider storagePathProvider, ChangeLockProvider changeLockProvider, FormSourceProvider formSourceProvider, SavepointsRepositoryProvider savepointsRepositoryProvider, EntitiesRepositoryProvider entitiesRepositoryProvider) { + return new ProjectDependencyProviderFactory(settingsProvider, formsRepositoryProvider, instancesRepositoryProvider, storagePathProvider, changeLockProvider, formSourceProvider, savepointsRepositoryProvider, entitiesRepositoryProvider); } @Provides diff --git a/collect_app/src/main/java/org/odk/collect/android/injection/config/CollectGeoDependencyModule.kt b/collect_app/src/main/java/org/odk/collect/android/injection/config/CollectGeoDependencyModule.kt index ba7d252f7e4..f5373dc0344 100644 --- a/collect_app/src/main/java/org/odk/collect/android/injection/config/CollectGeoDependencyModule.kt +++ b/collect_app/src/main/java/org/odk/collect/android/injection/config/CollectGeoDependencyModule.kt @@ -3,36 +3,25 @@ package org.odk.collect.android.injection.config import android.app.Application import android.content.Context import android.location.LocationManager -import androidx.fragment.app.FragmentActivity -import org.odk.collect.android.preferences.screens.MapsPreferencesFragment import org.odk.collect.async.Scheduler import org.odk.collect.geo.GeoDependencyModule -import org.odk.collect.geo.ReferenceLayerSettingsNavigator import org.odk.collect.location.LocationClient import org.odk.collect.location.satellites.GpsStatusSatelliteInfoClient import org.odk.collect.location.satellites.SatelliteInfoClient import org.odk.collect.location.tracker.ForegroundServiceLocationTracker import org.odk.collect.location.tracker.LocationTracker import org.odk.collect.maps.MapFragmentFactory +import org.odk.collect.maps.layers.ReferenceLayerRepository import org.odk.collect.permissions.PermissionsChecker +import org.odk.collect.settings.SettingsProvider +import org.odk.collect.webpage.ExternalWebPageHelper class CollectGeoDependencyModule( - private val mapFragmentFactory: MapFragmentFactory, - private val locationClient: LocationClient, - private val scheduler: Scheduler, - private val permissionChecker: PermissionsChecker + private val appDependencyComponent: AppDependencyComponent ) : GeoDependencyModule() { - override fun providesReferenceLayerSettingsNavigator(): ReferenceLayerSettingsNavigator { - return object : ReferenceLayerSettingsNavigator { - override fun navigateToReferenceLayerSettings(activity: FragmentActivity) { - MapsPreferencesFragment.showReferenceLayerDialog(activity) - } - } - } - override fun providesMapFragmentFactory(): MapFragmentFactory { - return mapFragmentFactory + return appDependencyComponent.mapFragmentFactory() } override fun providesLocationTracker(application: Application): LocationTracker { @@ -40,11 +29,11 @@ class CollectGeoDependencyModule( } override fun providesLocationClient(): LocationClient { - return locationClient + return appDependencyComponent.locationClient() } override fun providesScheduler(): Scheduler { - return scheduler + return appDependencyComponent.scheduler() } override fun providesSatelliteInfoClient(context: Context): SatelliteInfoClient { @@ -54,6 +43,18 @@ class CollectGeoDependencyModule( } override fun providesPermissionChecker(context: Context): PermissionsChecker { - return permissionChecker + return appDependencyComponent.permissionsChecker() + } + + override fun providesReferenceLayerRepository(): ReferenceLayerRepository { + return appDependencyComponent.referenceLayerRepository() + } + + override fun providesSettingsProvider(): SettingsProvider { + return appDependencyComponent.settingsProvider() + } + + override fun providesExternalWebPageHelper(): ExternalWebPageHelper { + return appDependencyComponent.externalWebPageHelper() } } diff --git a/collect_app/src/main/java/org/odk/collect/android/injection/config/CollectGoogleMapsDependencyModule.kt b/collect_app/src/main/java/org/odk/collect/android/injection/config/CollectGoogleMapsDependencyModule.kt index 3cb17b3f5a8..ff322a6bbb3 100644 --- a/collect_app/src/main/java/org/odk/collect/android/injection/config/CollectGoogleMapsDependencyModule.kt +++ b/collect_app/src/main/java/org/odk/collect/android/injection/config/CollectGoogleMapsDependencyModule.kt @@ -6,19 +6,17 @@ import org.odk.collect.maps.layers.ReferenceLayerRepository import org.odk.collect.settings.SettingsProvider class CollectGoogleMapsDependencyModule( - private val referenceLayerRepository: ReferenceLayerRepository, - private val locationClient: LocationClient, - private val settingsProvider: SettingsProvider + private val appDependencyComponent: AppDependencyComponent ) : GoogleMapsDependencyModule() { override fun providesReferenceLayerRepository(): ReferenceLayerRepository { - return referenceLayerRepository + return appDependencyComponent.referenceLayerRepository() } override fun providesLocationClient(): LocationClient { - return locationClient + return appDependencyComponent.locationClient() } override fun providesSettingsProvider(): SettingsProvider { - return settingsProvider + return appDependencyComponent.settingsProvider() } } diff --git a/collect_app/src/main/java/org/odk/collect/android/injection/config/CollectOsmDroidDependencyModule.kt b/collect_app/src/main/java/org/odk/collect/android/injection/config/CollectOsmDroidDependencyModule.kt index 08dfe459ae9..77630904a55 100644 --- a/collect_app/src/main/java/org/odk/collect/android/injection/config/CollectOsmDroidDependencyModule.kt +++ b/collect_app/src/main/java/org/odk/collect/android/injection/config/CollectOsmDroidDependencyModule.kt @@ -9,25 +9,23 @@ import org.odk.collect.settings.SettingsProvider import org.odk.collect.settings.keys.ProjectKeys class CollectOsmDroidDependencyModule( - private val referenceLayerRepository: ReferenceLayerRepository, - private val locationClient: LocationClient, - private val settingsProvider: SettingsProvider + private val appDependencyComponent: AppDependencyComponent ) : OsmDroidDependencyModule() { override fun providesReferenceLayerRepository(): ReferenceLayerRepository { - return referenceLayerRepository + return appDependencyComponent.referenceLayerRepository() } override fun providesLocationClient(): LocationClient { - return locationClient + return appDependencyComponent.locationClient() } override fun providesMapConfigurator(): MapConfigurator { return MapConfiguratorProvider.getConfigurator( - settingsProvider.getUnprotectedSettings().getString(ProjectKeys.KEY_BASEMAP_SOURCE) + appDependencyComponent.settingsProvider().getUnprotectedSettings().getString(ProjectKeys.KEY_BASEMAP_SOURCE) ) } override fun providesSettingsProvider(): SettingsProvider { - return settingsProvider + return appDependencyComponent.settingsProvider() } } diff --git a/collect_app/src/main/java/org/odk/collect/android/injection/config/CollectProjectsDependencyModule.kt b/collect_app/src/main/java/org/odk/collect/android/injection/config/CollectProjectsDependencyModule.kt index f14ca0780c4..e9c2fdd374f 100644 --- a/collect_app/src/main/java/org/odk/collect/android/injection/config/CollectProjectsDependencyModule.kt +++ b/collect_app/src/main/java/org/odk/collect/android/injection/config/CollectProjectsDependencyModule.kt @@ -3,9 +3,10 @@ package org.odk.collect.android.injection.config import org.odk.collect.projects.ProjectsDependencyModule import org.odk.collect.projects.ProjectsRepository -class CollectProjectsDependencyModule(private val projectsRepository: ProjectsRepository) : - ProjectsDependencyModule() { +class CollectProjectsDependencyModule( + private val appDependencyComponent: AppDependencyComponent +) : ProjectsDependencyModule() { override fun providesProjectsRepository(): ProjectsRepository { - return projectsRepository + return appDependencyComponent.projectsRepository() } } diff --git a/collect_app/src/main/java/org/odk/collect/android/injection/config/CollectSelfieCameraDependencyModule.kt b/collect_app/src/main/java/org/odk/collect/android/injection/config/CollectSelfieCameraDependencyModule.kt index 2ca543ecb67..f4c27f545a8 100644 --- a/collect_app/src/main/java/org/odk/collect/android/injection/config/CollectSelfieCameraDependencyModule.kt +++ b/collect_app/src/main/java/org/odk/collect/android/injection/config/CollectSelfieCameraDependencyModule.kt @@ -3,8 +3,10 @@ package org.odk.collect.android.injection.config import org.odk.collect.permissions.PermissionsChecker import org.odk.collect.selfiecamera.SelfieCameraDependencyModule -class CollectSelfieCameraDependencyModule(private val permissionsChecker: () -> PermissionsChecker) : SelfieCameraDependencyModule() { +class CollectSelfieCameraDependencyModule( + private val appDependencyComponent: AppDependencyComponent +) : SelfieCameraDependencyModule() { override fun providesPermissionChecker(): PermissionsChecker { - return permissionsChecker() + return appDependencyComponent.permissionsChecker() } } diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/FinalizeAllSnackbarPresenter.kt b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/FinalizeAllSnackbarPresenter.kt index 371a88af47e..8c15e0ec215 100644 --- a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/FinalizeAllSnackbarPresenter.kt +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/FinalizeAllSnackbarPresenter.kt @@ -2,7 +2,6 @@ package org.odk.collect.android.instancemanagement import android.content.Context import android.view.View -import org.odk.collect.android.formmanagement.FinalizeAllResult import org.odk.collect.androidshared.ui.SnackbarUtils.SnackbarDetails import org.odk.collect.androidshared.ui.SnackbarUtils.SnackbarPresenterObserver import org.odk.collect.strings.R diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceDeleter.kt b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceDeleter.kt index aa93c3e76b0..af5b860d914 100644 --- a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceDeleter.kt +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceDeleter.kt @@ -8,6 +8,12 @@ class InstanceDeleter( private val instancesRepository: InstancesRepository, private val formsRepository: FormsRepository ) { + fun delete(ids: Array) { + ids.forEach { + delete(it) + } + } + fun delete(id: Long?) { instancesRepository[id]?.let { instance -> if (instance.status == Instance.STATUS_SUBMITTED) { diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceDiskSynchronizer.java b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceDiskSynchronizer.java index 79b06569cfc..ea8dd695898 100644 --- a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceDiskSynchronizer.java +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceDiskSynchronizer.java @@ -14,11 +14,11 @@ package org.odk.collect.android.instancemanagement; +import static org.odk.collect.strings.localization.LocalizedApplicationKt.getLocalizedString; + import android.net.Uri; import org.apache.commons.io.FileUtils; -import org.odk.collect.android.analytics.AnalyticsEvents; -import org.odk.collect.android.analytics.AnalyticsUtils; import org.odk.collect.android.application.Collect; import org.odk.collect.android.exception.EncryptionException; import org.odk.collect.android.external.InstancesContract; @@ -50,8 +50,6 @@ import timber.log.Timber; -import static org.odk.collect.strings.localization.LocalizedApplicationKt.getLocalizedString; - public class InstanceDiskSynchronizer { private static int counter; @@ -187,22 +185,11 @@ private String getInstanceIdFromInstance(final String instancePath) { private void encryptInstanceIfNeeded(Form form, Instance instance) throws EncryptionException, IOException { if (instance != null) { if (shouldInstanceBeEncrypted(form)) { - logImportAndEncrypt(form); encryptInstance(instance); - } else { - logImport(form); } } } - private void logImport(Form form) { - AnalyticsUtils.logFormEvent(AnalyticsEvents.IMPORT_INSTANCE, form.getFormId(), form.getDisplayName()); - } - - private void logImportAndEncrypt(Form form) { - AnalyticsUtils.logFormEvent(AnalyticsEvents.IMPORT_AND_ENCRYPT_INSTANCE, form.getFormId(), form.getDisplayName()); - } - private void encryptInstance(Instance instance) throws EncryptionException, IOException { String instancePath = instance.getInstanceFilePath(); File instanceXml = new File(instancePath); diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceExt.kt b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceExt.kt index f2db5351d2e..a5c23a535de 100644 --- a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceExt.kt +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceExt.kt @@ -77,6 +77,20 @@ private fun getStatusDescription(resources: Resources, state: String?, date: Dat } } +fun Instance.getIcon(): Int { + return getInstanceIcon(this.status) +} + +fun getInstanceIcon(status: String): Int { + return when (status) { + Instance.STATUS_INCOMPLETE, Instance.STATUS_INVALID, Instance.STATUS_VALID -> org.odk.collect.android.R.drawable.ic_form_state_saved + Instance.STATUS_COMPLETE -> org.odk.collect.android.R.drawable.ic_form_state_finalized + Instance.STATUS_SUBMITTED -> org.odk.collect.android.R.drawable.ic_form_state_submitted + Instance.STATUS_SUBMISSION_FAILED -> org.odk.collect.android.R.drawable.ic_form_state_submission_failed + else -> throw IllegalArgumentException() + } +} + private val draftStatuses = arrayOf( Instance.STATUS_INCOMPLETE, Instance.STATUS_INVALID, diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt index df9a2420e1c..07e81220127 100644 --- a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceListItemView.kt @@ -4,12 +4,10 @@ import android.content.Context import android.view.View import android.widget.ImageView import android.widget.TextView -import com.google.android.material.color.MaterialColors import org.odk.collect.android.R import org.odk.collect.android.utilities.FormsRepositoryProvider -import org.odk.collect.androidshared.system.ContextUtils.getThemeAttributeValue import org.odk.collect.forms.instances.Instance -import org.odk.collect.material.MaterialPill +import org.odk.collect.material.ErrorsPill import org.odk.collect.strings.R.string import timber.log.Timber import java.text.SimpleDateFormat @@ -29,28 +27,12 @@ object InstanceListItemView { setImageFromStatus(imageView, instance) setUpSubtext(view, instance, context) - val pill = view.findViewById(R.id.chip) + val pill = view.findViewById(R.id.chip) if (pill != null) { when (instance.status) { - Instance.STATUS_INVALID, Instance.STATUS_INCOMPLETE -> { - pill.visibility = View.VISIBLE - pill.setIcon(org.odk.collect.icons.R.drawable.ic_baseline_rule_24) - pill.setText(string.draft_errors) - pill.setPillBackgroundColor(MaterialColors.getColor(pill, com.google.android.material.R.attr.colorErrorContainer)) - pill.setTextColor(getThemeAttributeValue(context, com.google.android.material.R.attr.colorOnErrorContainer)) - pill.setIconTint(getThemeAttributeValue(context, com.google.android.material.R.attr.colorOnErrorContainer)) - } - Instance.STATUS_VALID -> { - pill.visibility = View.VISIBLE - pill.setIcon(R.drawable.baseline_check_24) - pill.setText(string.draft_no_errors) - pill.setPillBackgroundColor(MaterialColors.getColor(pill, com.google.android.material.R.attr.colorSurfaceContainerHighest)) - pill.setTextColor(getThemeAttributeValue(context, com.google.android.material.R.attr.colorOnSurface)) - pill.setIconTint(getThemeAttributeValue(context, com.google.android.material.R.attr.colorOnSurface)) - } - else -> { - pill.visibility = View.GONE - } + Instance.STATUS_INVALID, Instance.STATUS_INCOMPLETE -> pill.errors = true + Instance.STATUS_VALID -> pill.errors = false + else -> pill.visibility = View.GONE } } @@ -127,20 +109,8 @@ object InstanceListItemView { } private fun setImageFromStatus(imageView: ImageView, instance: Instance) { - val formStatus = instance.status - val imageResourceId = getFormStateImageResourceIdForStatus(formStatus) + val imageResourceId = instance.getIcon() imageView.setImageResource(imageResourceId) imageView.tag = imageResourceId } - - private fun getFormStateImageResourceIdForStatus(formStatus: String?): Int { - when (formStatus) { - Instance.STATUS_INCOMPLETE, Instance.STATUS_INVALID, Instance.STATUS_VALID -> return R.drawable.ic_form_state_saved - Instance.STATUS_COMPLETE -> return R.drawable.ic_form_state_finalized - Instance.STATUS_SUBMITTED -> return R.drawable.ic_form_state_submitted - Instance.STATUS_SUBMISSION_FAILED -> return R.drawable.ic_form_state_submission_failed - } - - throw java.lang.IllegalArgumentException() - } } diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceSubmitter.kt b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceSubmitter.kt index ee4e2949140..a2ddddd42a5 100644 --- a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceSubmitter.kt +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstanceSubmitter.kt @@ -3,6 +3,7 @@ package org.odk.collect.android.instancemanagement import org.odk.collect.analytics.Analytics import org.odk.collect.android.analytics.AnalyticsEvents import org.odk.collect.android.application.Collect +import org.odk.collect.android.openrosa.OpenRosaHttpInterface import org.odk.collect.android.upload.FormUploadException import org.odk.collect.android.upload.InstanceServerUploader import org.odk.collect.android.upload.InstanceUploader @@ -12,6 +13,7 @@ import org.odk.collect.android.utilities.InstancesRepositoryProvider import org.odk.collect.android.utilities.WebCredentialsUtils import org.odk.collect.forms.FormsRepository import org.odk.collect.forms.instances.Instance +import org.odk.collect.forms.instances.InstancesRepository import org.odk.collect.metadata.PropertyManager import org.odk.collect.metadata.PropertyManager.Companion.PROPMGR_DEVICE_ID import org.odk.collect.settings.keys.ProjectKeys @@ -21,14 +23,12 @@ import timber.log.Timber class InstanceSubmitter( private val formsRepository: FormsRepository, private val generalSettings: Settings, - private val propertyManager: PropertyManager + private val propertyManager: PropertyManager, + private val httpInterface: OpenRosaHttpInterface, + private val instancesRepository: InstancesRepository ) { - @Throws(SubmitException::class) fun submitInstances(toUpload: List): Map { - if (toUpload.isEmpty()) { - throw SubmitException - } val result = mutableMapOf() val deviceId = propertyManager.getSingularProperty(PROPMGR_DEVICE_ID) @@ -51,11 +51,11 @@ class InstanceSubmitter( } private fun setUpODKUploader(): InstanceUploader { - val httpInterface = Collect.getInstance().component.openRosaHttpInterface() return InstanceServerUploader( httpInterface, WebCredentialsUtils(generalSettings), - generalSettings + generalSettings, + instancesRepository ) } diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstancesDataService.kt b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstancesDataService.kt new file mode 100644 index 00000000000..d20610d424b --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/InstancesDataService.kt @@ -0,0 +1,241 @@ +package org.odk.collect.android.instancemanagement + +import androidx.lifecycle.LiveData +import kotlinx.coroutines.flow.Flow +import org.odk.collect.analytics.Analytics +import org.odk.collect.android.analytics.AnalyticsEvents +import org.odk.collect.android.application.Collect +import org.odk.collect.android.backgroundwork.InstanceSubmitScheduler +import org.odk.collect.android.formentry.FormEntryUseCases +import org.odk.collect.android.formmanagement.CollectFormEntryControllerFactory +import org.odk.collect.android.instancemanagement.autosend.FormAutoSendMode +import org.odk.collect.android.instancemanagement.autosend.InstanceAutoSendFetcher +import org.odk.collect.android.instancemanagement.autosend.getAutoSendMode +import org.odk.collect.android.notifications.Notifier +import org.odk.collect.android.openrosa.OpenRosaHttpInterface +import org.odk.collect.android.projects.ProjectDependencyProviderFactory +import org.odk.collect.android.utilities.ExternalizableFormDefCache +import org.odk.collect.android.utilities.FormsUploadResultInterpreter +import org.odk.collect.androidshared.data.AppState +import org.odk.collect.forms.Form +import org.odk.collect.forms.instances.Instance +import org.odk.collect.metadata.PropertyManager +import java.io.File + +class InstancesDataService( + private val appState: AppState, + private val instanceSubmitScheduler: InstanceSubmitScheduler, + private val projectDependencyProviderFactory: ProjectDependencyProviderFactory, + private val notifier: Notifier, + private val propertyManager: PropertyManager, + private val httpInterface: OpenRosaHttpInterface, + private val onUpdate: () -> Unit +) { + val editableCount: LiveData = appState.getLive(EDITABLE_COUNT_KEY, 0) + val sendableCount: LiveData = appState.getLive(SENDABLE_COUNT_KEY, 0) + val sentCount: LiveData = appState.getLive(SENT_COUNT_KEY, 0) + + fun getInstances(projectId: String): Flow> { + return appState.getFlow("instances:$projectId", emptyList()) + } + + fun update(projectId: String) { + val projectDependencyProvider = projectDependencyProviderFactory.create(projectId) + val instancesRepository = projectDependencyProvider.instancesRepository + + val sendableInstances = instancesRepository.getCountByStatus( + Instance.STATUS_COMPLETE, + Instance.STATUS_SUBMISSION_FAILED + ) + val sentInstances = instancesRepository.getCountByStatus( + Instance.STATUS_SUBMITTED, + Instance.STATUS_SUBMISSION_FAILED + ) + val editableInstances = instancesRepository.getCountByStatus( + Instance.STATUS_INCOMPLETE, + Instance.STATUS_INVALID, + Instance.STATUS_VALID + ) + + appState.setLive(EDITABLE_COUNT_KEY, editableInstances) + appState.setLive(SENDABLE_COUNT_KEY, sendableInstances) + appState.setLive(SENT_COUNT_KEY, sentInstances) + appState.setFlow("instances:$projectId", instancesRepository.all) + + onUpdate() + } + + fun finalizeAllDrafts(projectId: String): FinalizeAllResult { + val projectDependencyProvider = projectDependencyProviderFactory.create(projectId) + val instancesRepository = projectDependencyProvider.instancesRepository + val formsRepository = projectDependencyProvider.formsRepository + val storagePathProvider = projectDependencyProvider.storagePathProvider + val savepointsRepository = projectDependencyProvider.savepointsRepository + val entitiesRepository = projectDependencyProvider.entitiesRepository + + val projectRootDir = File(storagePathProvider.getProjectRootDirPath()) + + val instances = instancesRepository.getAllByStatus( + Instance.STATUS_INCOMPLETE, + Instance.STATUS_INVALID, + Instance.STATUS_VALID + ) + + val result = instances.fold(FinalizeAllResult(0, 0, false)) { result, instance -> + val formDefAndForm = FormEntryUseCases.loadFormDef( + instance, + formsRepository, + projectRootDir, + ExternalizableFormDefCache() + ) + + if (formDefAndForm == null) { + result.copy(failureCount = result.failureCount + 1) + } else { + val (formDef, form) = formDefAndForm + + val formMediaDir = File(form.formMediaPath) + val formEntryController = + CollectFormEntryControllerFactory().create(formDef, formMediaDir) + val formController = + FormEntryUseCases.loadDraft(form, instance, formEntryController) + if (formController == null) { + result.copy(failureCount = result.failureCount + 1) + } else { + val savePoint = savepointsRepository.get(form.dbId, instance.dbId) + val needsEncrypted = form.basE64RSAPublicKey != null + val newResult = if (savePoint != null) { + Analytics.log(AnalyticsEvents.BULK_FINALIZE_SAVE_POINT) + result.copy( + failureCount = result.failureCount + 1, + unsupportedInstances = true + ) + } else if (needsEncrypted) { + Analytics.log(AnalyticsEvents.BULK_FINALIZE_ENCRYPTED_FORM) + result.copy( + failureCount = result.failureCount + 1, + unsupportedInstances = true + ) + } else { + val finalizedInstance = FormEntryUseCases.finalizeDraft( + formController, + instancesRepository, + entitiesRepository + ) + + if (finalizedInstance == null) { + result.copy(failureCount = result.failureCount + 1) + } else { + instanceFinalized(projectId, form) + result + } + } + + Collect.getInstance().externalDataManager?.close() + newResult + } + } + } + + update(projectId) + + return result.copy(successCount = instances.size - result.failureCount) + } + + fun deleteInstances(projectId: String, instanceIds: LongArray): Boolean { + val projectDependencyProvider = projectDependencyProviderFactory.create(projectId) + val instancesRepository = projectDependencyProvider.instancesRepository + val formsRepository = projectDependencyProvider.formsRepository + + return projectDependencyProvider.instancesLock.withLock { acquiredLock: Boolean -> + if (acquiredLock) { + instanceIds.forEach { instanceId -> + InstanceDeleter( + instancesRepository, + formsRepository + ).delete( + instanceId + ) + } + + update(projectId) + true + } else { + false + } + } + } + + fun deleteAll(projectId: String): Boolean { + val projectDependencyProvider = + projectDependencyProviderFactory.create(projectId) + val instancesRepository = projectDependencyProvider.instancesRepository + + return projectDependencyProvider.instancesLock.withLock { acquiredLock: Boolean -> + if (acquiredLock) { + instancesRepository.deleteAll() + update(projectId) + true + } else { + false + } + } + } + + fun sendInstances(projectId: String, formAutoSend: Boolean = false): Boolean { + val projectDependencyProvider = + projectDependencyProviderFactory.create(projectId) + + val instanceSubmitter = InstanceSubmitter( + projectDependencyProvider.formsRepository, + projectDependencyProvider.generalSettings, + propertyManager, + httpInterface, + projectDependencyProvider.instancesRepository + ) + + return projectDependencyProvider.changeLockProvider.getInstanceLock( + projectDependencyProvider.projectId + ).withLock { acquiredLock: Boolean -> + if (acquiredLock) { + val toUpload = InstanceAutoSendFetcher.getInstancesToAutoSend( + projectDependencyProvider.instancesRepository, + projectDependencyProvider.formsRepository, + formAutoSend + ) + + if (toUpload.isNotEmpty()) { + val results = instanceSubmitter.submitInstances(toUpload) + notifier.onSubmission(results, projectDependencyProvider.projectId) + update(projectId) + + FormsUploadResultInterpreter.allFormsUploadedSuccessfully(results) + } else { + true + } + } else { + false + } + } + } + + fun instanceFinalized(projectId: String, form: Form) { + if (form.getAutoSendMode() == FormAutoSendMode.FORCED) { + instanceSubmitScheduler.scheduleFormAutoSend(projectId) + } else { + instanceSubmitScheduler.scheduleAutoSend(projectId) + } + } + + companion object { + private const val EDITABLE_COUNT_KEY = "instancesEditableCount" + private const val SENDABLE_COUNT_KEY = "instancesSendableCount" + private const val SENT_COUNT_KEY = "instancesSentCount" + } +} + +data class FinalizeAllResult( + val successCount: Int, + val failureCount: Int, + val unsupportedInstances: Boolean +) diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/SubmitException.kt b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/SubmitException.kt deleted file mode 100644 index e58f3cb2f17..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/SubmitException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.odk.collect.android.instancemanagement - -import java.lang.Exception - -object SubmitException : Exception() diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/autosend/AutoSendSettingsProvider.kt b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/autosend/AutoSendSettingsProvider.kt index 210550cd51a..46c4eadb201 100644 --- a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/autosend/AutoSendSettingsProvider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/autosend/AutoSendSettingsProvider.kt @@ -1,27 +1,31 @@ package org.odk.collect.android.instancemanagement.autosend -import android.net.ConnectivityManager -import org.odk.collect.androidshared.network.NetworkStateProvider +import android.app.Application +import org.odk.collect.async.Scheduler +import org.odk.collect.async.network.NetworkStateProvider import org.odk.collect.settings.SettingsProvider -import org.odk.collect.settings.keys.ProjectKeys +import org.odk.collect.settings.enums.AutoSend +import org.odk.collect.settings.enums.StringIdEnumUtils.getAutoSend class AutoSendSettingsProvider( + private val application: Application, private val networkStateProvider: NetworkStateProvider, private val settingsProvider: SettingsProvider ) { fun isAutoSendEnabledInSettings(projectId: String? = null): Boolean { - val currentNetworkInfo = networkStateProvider.networkInfo ?: return false + val currentNetworkType = networkStateProvider.currentNetwork ?: return false - val autosend = settingsProvider.getUnprotectedSettings(projectId).getString(ProjectKeys.KEY_AUTOSEND) - var sendwifi = autosend == "wifi_only" - var sendnetwork = autosend == "cellular_only" + val autosend = settingsProvider.getUnprotectedSettings(projectId).getAutoSend(application) + var sendwifi = autosend == AutoSend.WIFI_ONLY + var sendnetwork = autosend == AutoSend.CELLULAR_ONLY - if (autosend == "wifi_and_cellular") { + if (autosend == AutoSend.WIFI_AND_CELLULAR) { sendwifi = true sendnetwork = true } - return currentNetworkInfo.type == ConnectivityManager.TYPE_WIFI && - sendwifi || currentNetworkInfo.type == ConnectivityManager.TYPE_MOBILE && sendnetwork + + return currentNetworkType == Scheduler.NetworkType.WIFI && + sendwifi || currentNetworkType == Scheduler.NetworkType.CELLULAR && sendnetwork } } diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/autosend/FormExt.kt b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/autosend/FormExt.kt index 5edfdad7bdd..8b88a59c852 100644 --- a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/autosend/FormExt.kt +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/autosend/FormExt.kt @@ -1,17 +1,27 @@ package org.odk.collect.android.instancemanagement.autosend -import org.odk.collect.android.analytics.AnalyticsEvents -import org.odk.collect.android.analytics.AnalyticsUtils import org.odk.collect.forms.Form fun Form.shouldFormBeSentAutomatically(isAutoSendEnabledInSettings: Boolean): Boolean { - if (!autoSend.isNullOrEmpty()) { - AnalyticsUtils.logFormEvent(AnalyticsEvents.FORM_LEVEL_AUTO_SEND, formId, displayName) + return if (isAutoSendEnabledInSettings) { + getAutoSendMode() != FormAutoSendMode.OPT_OUT + } else { + getAutoSendMode() == FormAutoSendMode.FORCED } +} - return if (isAutoSendEnabledInSettings) { - autoSend == null || autoSend.trim().lowercase() != "false" +fun Form.getAutoSendMode(): FormAutoSendMode { + return if (autoSend?.trim()?.lowercase() == "false") { + FormAutoSendMode.OPT_OUT + } else if (autoSend?.trim()?.lowercase() == "true") { + FormAutoSendMode.FORCED } else { - autoSend != null && autoSend.trim().lowercase() == "true" + FormAutoSendMode.NEUTRAL } } + +enum class FormAutoSendMode { + OPT_OUT, + FORCED, + NEUTRAL +} diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/autosend/InstanceAutoSendFetcher.kt b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/autosend/InstanceAutoSendFetcher.kt index dec7fe0e58c..746d2beb702 100644 --- a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/autosend/InstanceAutoSendFetcher.kt +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/autosend/InstanceAutoSendFetcher.kt @@ -1,19 +1,31 @@ package org.odk.collect.android.instancemanagement.autosend +import org.odk.collect.forms.Form import org.odk.collect.forms.FormsRepository import org.odk.collect.forms.instances.Instance import org.odk.collect.forms.instances.InstancesRepository -class InstanceAutoSendFetcher(private val autoSendSettingsProvider: AutoSendSettingsProvider) { +object InstanceAutoSendFetcher { - fun getInstancesToAutoSend(projectId: String, instancesRepository: InstancesRepository, formsRepository: FormsRepository): List { - val allFinalizedForms = instancesRepository.getAllByStatus(Instance.STATUS_COMPLETE, Instance.STATUS_SUBMISSION_FAILED) + fun getInstancesToAutoSend( + instancesRepository: InstancesRepository, + formsRepository: FormsRepository, + forcedOnly: Boolean = false + ): List { + val allFinalizedForms = instancesRepository.getAllByStatus( + Instance.STATUS_COMPLETE, + Instance.STATUS_SUBMISSION_FAILED + ) + + val filter: (Form) -> Boolean = if (forcedOnly) { + { form -> form.getAutoSendMode() == FormAutoSendMode.FORCED } + } else { + { form -> form.getAutoSendMode() == FormAutoSendMode.NEUTRAL } + } - val isAutoSendEnabledInSettings = autoSendSettingsProvider.isAutoSendEnabledInSettings(projectId) return allFinalizedForms.filter { - formsRepository.getLatestByFormIdAndVersion(it.formId, it.formVersion)?.let { form -> - form.shouldFormBeSentAutomatically(isAutoSendEnabledInSettings) - } ?: false + formsRepository.getLatestByFormIdAndVersion(it.formId, it.formVersion) + ?.let { form -> filter(form) } ?: false } } } diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/autosend/InstanceAutoSender.kt b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/autosend/InstanceAutoSender.kt deleted file mode 100644 index 9d94fbe4a72..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/autosend/InstanceAutoSender.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.odk.collect.android.instancemanagement.autosend - -import org.odk.collect.android.formmanagement.InstancesDataService -import org.odk.collect.android.instancemanagement.InstanceSubmitter -import org.odk.collect.android.instancemanagement.SubmitException -import org.odk.collect.android.notifications.Notifier -import org.odk.collect.android.projects.ProjectDependencyProvider -import org.odk.collect.android.upload.FormUploadException -import org.odk.collect.forms.instances.Instance -import org.odk.collect.metadata.PropertyManager - -class InstanceAutoSender( - private val instanceAutoSendFetcher: InstanceAutoSendFetcher, - private val notifier: Notifier, - private val instancesDataService: InstancesDataService, - private val propertyManager: PropertyManager -) { - fun autoSendInstances(projectDependencyProvider: ProjectDependencyProvider): Boolean { - val instanceSubmitter = InstanceSubmitter( - projectDependencyProvider.formsRepository, - projectDependencyProvider.generalSettings, - propertyManager - ) - return projectDependencyProvider.changeLockProvider.getInstanceLock(projectDependencyProvider.projectId).withLock { acquiredLock: Boolean -> - if (acquiredLock) { - val toUpload = instanceAutoSendFetcher.getInstancesToAutoSend( - projectDependencyProvider.projectId, - projectDependencyProvider.instancesRepository, - projectDependencyProvider.formsRepository - ) - - try { - val result: Map = instanceSubmitter.submitInstances(toUpload) - notifier.onSubmission(result, projectDependencyProvider.projectId) - } catch (e: SubmitException) { - // do nothing - } - instancesDataService.update() - true - } else { - false - } - } - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/send/InstanceUploaderActivity.java b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/send/InstanceUploaderActivity.java index 22ec10a87c4..fae6a5cf3ed 100644 --- a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/send/InstanceUploaderActivity.java +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/send/InstanceUploaderActivity.java @@ -27,7 +27,7 @@ import org.odk.collect.android.injection.DaggerUtils; import org.odk.collect.android.listeners.InstanceUploaderListener; import org.odk.collect.android.openrosa.OpenRosaConstants; -import org.odk.collect.android.tasks.InstanceServerUploaderTask; +import org.odk.collect.android.tasks.InstanceUploaderTask; import org.odk.collect.android.utilities.ApplicationConstants; import org.odk.collect.android.utilities.ArrayUtils; import org.odk.collect.android.utilities.AuthDialogUtility; @@ -67,7 +67,7 @@ public class InstanceUploaderActivity extends LocalizedActivity implements Insta private String alertMsg; - private InstanceServerUploaderTask instanceServerUploaderTask; + private InstanceUploaderTask instanceUploaderTask; // maintain a list of what we've yet to send, in case we're interrupted by auth requests private Long[] instancesToSend; @@ -169,34 +169,34 @@ private void init(Bundle savedInstanceState) { // Get the task if there was a configuration change but the app did not go out of memory. // If the app went out of memory, the task is null but the simple state was saved so // the task status is reconstructed from that state. - instanceServerUploaderTask = (InstanceServerUploaderTask) getLastCustomNonConfigurationInstance(); + instanceUploaderTask = (InstanceUploaderTask) getLastCustomNonConfigurationInstance(); - if (instanceServerUploaderTask == null) { + if (instanceUploaderTask == null) { // set up dialog and upload task showDialog(PROGRESS_DIALOG); - instanceServerUploaderTask = new InstanceServerUploaderTask(); + instanceUploaderTask = new InstanceUploaderTask(); if (url != null) { - instanceServerUploaderTask.setCompleteDestinationUrl(url + OpenRosaConstants.SUBMISSION); + instanceUploaderTask.setCompleteDestinationUrl(url + OpenRosaConstants.SUBMISSION); if (deleteInstanceAfterUpload != null) { - instanceServerUploaderTask.setDeleteInstanceAfterSubmission(deleteInstanceAfterUpload); + instanceUploaderTask.setDeleteInstanceAfterSubmission(deleteInstanceAfterUpload); } String host = Uri.parse(url).getHost(); if (host != null) { // We do not need to clear the cookies since they are cleared before any request is made and the Credentials provider is used if (password != null && username != null) { - instanceServerUploaderTask.setCustomUsername(username); - instanceServerUploaderTask.setCustomPassword(password); + instanceUploaderTask.setCustomUsername(username); + instanceUploaderTask.setCustomPassword(password); } } } // register this activity with the new uploader task - instanceServerUploaderTask.setUploaderListener(this); - instanceServerUploaderTask.setRepositories(instancesRepository, formsRepository, settingsProvider); - instanceServerUploaderTask.execute(instancesToSend); + instanceUploaderTask.setUploaderListener(this); + instanceUploaderTask.setRepositories(instancesRepository, formsRepository, settingsProvider); + instanceUploaderTask.execute(instancesToSend); } } @@ -205,8 +205,8 @@ protected void onResume() { if (instancesToSend != null) { Timber.i("onResume: Resuming upload of %d instances!", instancesToSend.length); } - if (instanceServerUploaderTask != null) { - instanceServerUploaderTask.setUploaderListener(this); + if (instanceUploaderTask != null) { + instanceUploaderTask.setUploaderListener(this); } super.onResume(); } @@ -237,13 +237,13 @@ protected void onSaveInstanceState(Bundle outState) { @Override public Object onRetainCustomNonConfigurationInstance() { - return instanceServerUploaderTask; + return instanceUploaderTask; } @Override protected void onDestroy() { - if (instanceServerUploaderTask != null) { - instanceServerUploaderTask.setUploaderListener(null); + if (instanceUploaderTask != null) { + instanceUploaderTask.setUploaderListener(null); } super.onDestroy(); } @@ -284,8 +284,8 @@ protected Dialog onCreateDialog(int id) { @Override public void onClick(DialogInterface dialog, int which) { dialog.dismiss(); - instanceServerUploaderTask.cancel(true); - instanceServerUploaderTask.setUploaderListener(null); + instanceUploaderTask.cancel(true); + instanceUploaderTask.setUploaderListener(null); finish(); } }; @@ -367,19 +367,19 @@ private void createUploadInstancesResultDialog(String message) { @Override public void updatedCredentials() { showDialog(PROGRESS_DIALOG); - instanceServerUploaderTask = new InstanceServerUploaderTask(); + instanceUploaderTask = new InstanceUploaderTask(); // register this activity with the new uploader task - instanceServerUploaderTask.setUploaderListener(this); + instanceUploaderTask.setUploaderListener(this); // In the case of credentials set via intent extras, the credentials are stored in the // global WebCredentialsUtils but the task also needs to know what server to set to // TODO: is this really needed here? When would the task not have gotten a server set in // init already? if (url != null) { - instanceServerUploaderTask.setCompleteDestinationUrl(url + OpenRosaConstants.SUBMISSION, false); + instanceUploaderTask.setCompleteDestinationUrl(url + OpenRosaConstants.SUBMISSION, false); } - instanceServerUploaderTask.setRepositories(instancesRepository, formsRepository, settingsProvider); - instanceServerUploaderTask.execute(instancesToSend); + instanceUploaderTask.setRepositories(instancesRepository, formsRepository, settingsProvider); + instanceUploaderTask.execute(instancesToSend); } @Override diff --git a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/send/InstanceUploaderListActivity.java b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/send/InstanceUploaderListActivity.java index c470b3c813f..de51332ac52 100644 --- a/collect_app/src/main/java/org/odk/collect/android/instancemanagement/send/InstanceUploaderListActivity.java +++ b/collect_app/src/main/java/org/odk/collect/android/instancemanagement/send/InstanceUploaderListActivity.java @@ -20,7 +20,7 @@ import static org.odk.collect.android.utilities.ApplicationConstants.SortingOrder.BY_DATE_DESC; import static org.odk.collect.android.utilities.ApplicationConstants.SortingOrder.BY_NAME_ASC; import static org.odk.collect.android.utilities.ApplicationConstants.SortingOrder.BY_NAME_DESC; -import static org.odk.collect.androidshared.ui.MultiSelectViewModelKt.updateSelectAll; +import static org.odk.collect.lists.selects.MultiSelectViewModelKt.updateSelectAll; import android.content.Intent; import android.database.Cursor; @@ -63,17 +63,19 @@ import org.odk.collect.android.mainmenu.MainMenuActivity; import org.odk.collect.android.preferences.screens.ProjectPreferencesActivity; import org.odk.collect.android.projects.ProjectsDataService; -import org.odk.collect.androidshared.network.NetworkStateProvider; import org.odk.collect.androidshared.ui.MenuExtKt; -import org.odk.collect.androidshared.ui.MultiSelectViewModel; import org.odk.collect.androidshared.ui.ToastUtils; import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickGuard; +import org.odk.collect.async.network.NetworkStateProvider; +import org.odk.collect.lists.selects.MultiSelectViewModel; import org.odk.collect.settings.SettingsProvider; +import org.odk.collect.settings.keys.ProjectKeys; import org.odk.collect.strings.localization.LocalizedActivity; import java.util.Arrays; import java.util.List; import java.util.Set; +import java.util.stream.Collectors; import javax.inject.Inject; @@ -90,7 +92,6 @@ public class InstanceUploaderListActivity extends LocalizedActivity implements OnLongClickListener, AdapterView.OnItemClickListener, LoaderManager.LoaderCallbacks { private static final String SHOW_ALL_MODE = "showAllMode"; - private static final String INSTANCE_UPLOADER_LIST_SORTING_ORDER = "instanceUploaderListSortingOrder"; private static final String IS_SEARCH_BOX_SHOWN = "isSearchBoxShown"; private static final String SEARCH_TEXT = "searchText"; @@ -127,7 +128,7 @@ public class InstanceUploaderListActivity extends LocalizedActivity implements private ProgressBar progressBar; private String filterText; - private MultiSelectViewModel multiSelectViewModel; + private MultiSelectViewModel multiSelectViewModel; private ReadyToSendViewModel readyToSendViewModel; private boolean allSelected; @@ -152,8 +153,7 @@ public void onCreate(Bundle savedInstanceState) { multiSelectViewModel.getSelected().observe(this, ids -> { binding.uploadButton.setEnabled(!ids.isEmpty()); allSelected = updateSelectAll(binding.toggleButton, listAdapter.getCount(), ids.size()); - - listAdapter.setSelected(ids); + listAdapter.setSelected(ids.stream().map(Long::valueOf).collect(Collectors.toSet())); }); readyToSendViewModel = new ViewModelProvider(this, factory).get(ReadyToSendViewModel.class); readyToSendViewModel.getData().observe(this, data -> binding.readyToSendBanner.setData(data)); @@ -179,7 +179,7 @@ public void onUploadButtonsClicked() { return; } - Set selectedItems = multiSelectViewModel.getSelected().getValue(); + Set selectedItems = multiSelectViewModel.getSelected().getValue().stream().map(Long::valueOf).collect(Collectors.toSet()); if (!selectedItems.isEmpty()) { binding.uploadButton.setEnabled(false); @@ -201,7 +201,7 @@ public void setContentView(View view) { progressBar = findViewById(R.id.progressBar); // Use the nicer-looking drawable with Material Design insets. - listView.setDivider(ContextCompat.getDrawable(this, R.drawable.list_item_divider)); + listView.setDivider(ContextCompat.getDrawable(this, org.odk.collect.androidshared.R.drawable.list_item_divider)); listView.setDividerHeight(1); setSupportActionBar(findViewById(R.id.toolbar)); @@ -216,7 +216,7 @@ void init() { binding.toggleButton.setOnClickListener(v -> { if (!allSelected) { for (int i = 0; i < listView.getCount(); i++) { - multiSelectViewModel.select(listView.getItemIdAtPosition(i)); + multiSelectViewModel.select(String.valueOf(listView.getItemIdAtPosition(i))); } } else { multiSelectViewModel.unselectAll(); @@ -289,7 +289,7 @@ private void uploadSelectedFiles(long[] instanceIds) { public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.instance_uploader_menu, menu); - getMenuInflater().inflate(R.menu.form_list_menu, menu); + getMenuInflater().inflate(R.menu.blank_form_list_menu, menu); MenuExtKt.enableIconsVisibility(menu); final MenuItem sortItem = menu.findItem(R.id.menu_sort); @@ -350,10 +350,6 @@ public boolean onOptionsItemSelected(MenuItem item) { return true; } - if (!MultiClickGuard.allowClick(getClass().getName())) { - return true; - } - if (item.getItemId() == R.id.menu_sort) { new FormListSortingBottomSheetDialog( this, @@ -425,14 +421,14 @@ protected void onActivityResult(int requestCode, int resultCode, Intent intent) private void setupAdapter() { listAdapter = new InstanceUploaderAdapter(this, null, dbId -> { - multiSelectViewModel.toggle(dbId); + multiSelectViewModel.toggle(dbId.toString()); }); listView.setAdapter(listAdapter); } private String getSortingOrderKey() { - return INSTANCE_UPLOADER_LIST_SORTING_ORDER; + return ProjectKeys.KEY_SAVED_FORM_SORT_ORDER; } private void updateAdapter() { diff --git a/collect_app/src/main/java/org/odk/collect/android/javarosawrapper/FormController.kt b/collect_app/src/main/java/org/odk/collect/android/javarosawrapper/FormController.kt index 32d86753a3a..97b4dd63009 100644 --- a/collect_app/src/main/java/org/odk/collect/android/javarosawrapper/FormController.kt +++ b/collect_app/src/main/java/org/odk/collect/android/javarosawrapper/FormController.kt @@ -5,14 +5,13 @@ import org.javarosa.core.model.FormIndex import org.javarosa.core.model.data.IAnswerData import org.javarosa.core.model.instance.TreeReference import org.javarosa.core.services.transport.payload.ByteArrayPayload +import org.javarosa.entities.internal.Entities import org.javarosa.form.api.FormEntryCaption import org.javarosa.form.api.FormEntryPrompt import org.odk.collect.android.exception.JavaRosaException import org.odk.collect.android.formentry.audit.AuditEventLogger -import org.odk.collect.entities.Entity import java.io.File import java.io.IOException -import java.util.stream.Stream interface FormController { fun getFormDef(): FormDef? @@ -139,7 +138,7 @@ interface FormController { * type. */ @Throws(JavaRosaException::class) - fun validateAnswers(markCompleted: Boolean, moveToInvalidIndex: Boolean): ValidationResult + fun validateAnswers(moveToInvalidIndex: Boolean): ValidationResult /** * saveAnswer attempts to save the current answer into the data model without doing any @@ -275,6 +274,8 @@ interface FormController { */ fun indexContainsRepeatableGroup(): Boolean + fun indexContainsRepeatableGroup(formIndex: FormIndex?): Boolean + /** * The count of the closest group that repeats or -1. */ @@ -307,7 +308,7 @@ interface FormController { * enables a filled-in form to be re-opened and edited. */ @Throws(IOException::class) - fun getFilledInFormXml(): ByteArrayPayload? + fun getFilledInFormXml(): ByteArrayPayload /** * Extract the portion of the form that should be uploaded to the server. @@ -333,5 +334,5 @@ interface FormController { fun getAnswer(treeReference: TreeReference?): IAnswerData? - fun getEntities(): Stream + fun getEntities(): Entities? } diff --git a/collect_app/src/main/java/org/odk/collect/android/javarosawrapper/JavaRosaFormController.java b/collect_app/src/main/java/org/odk/collect/android/javarosawrapper/JavaRosaFormController.java index 385a3a18161..457e81a7934 100644 --- a/collect_app/src/main/java/org/odk/collect/android/javarosawrapper/JavaRosaFormController.java +++ b/collect_app/src/main/java/org/odk/collect/android/javarosawrapper/JavaRosaFormController.java @@ -47,14 +47,13 @@ import org.javarosa.xform.parse.XFormParser; import org.javarosa.xpath.XPathParseTool; import org.javarosa.xpath.expr.XPathExpression; -import org.odk.collect.android.exception.JavaRosaException; import org.odk.collect.android.dynamicpreload.ExternalDataUtil; +import org.odk.collect.android.exception.JavaRosaException; import org.odk.collect.android.formentry.audit.AsyncTaskAuditEventWriter; import org.odk.collect.android.formentry.audit.AuditConfig; import org.odk.collect.android.formentry.audit.AuditEventLogger; import org.odk.collect.android.utilities.Appearances; import org.odk.collect.android.utilities.FileUtils; -import org.odk.collect.entities.Entity; import java.io.File; import java.io.IOException; @@ -62,7 +61,6 @@ import java.util.HashMap; import java.util.List; import java.util.Locale; -import java.util.stream.Stream; import timber.log.Timber; @@ -395,9 +393,9 @@ public int answerQuestion(FormIndex index, IAnswerData data) throws JavaRosaExce } } - public ValidationResult validateAnswers(boolean markCompleted, boolean moveToInvalidIndex) throws JavaRosaException { + public ValidationResult validateAnswers(boolean moveToInvalidIndex) throws JavaRosaException { try { - ValidateOutcome validateOutcome = getFormDef().validate(markCompleted); + ValidateOutcome validateOutcome = getFormDef().validate(); if (validateOutcome != null) { if (moveToInvalidIndex) { this.jumpToIndex(validateOutcome.failedPrompt); @@ -904,7 +902,11 @@ public FormEntryCaption[] getGroupsForCurrentIndex() { } public boolean indexContainsRepeatableGroup() { - FormEntryCaption[] groups = getCaptionHierarchy(); + return indexContainsRepeatableGroup(getFormIndex()); + } + + public boolean indexContainsRepeatableGroup(FormIndex formIndex) { + FormEntryCaption[] groups = getCaptionHierarchy(formIndex); if (groups.length == 0) { return false; } @@ -1107,12 +1109,7 @@ public IAnswerData getAnswer(TreeReference treeReference) { return getFormDef().getMainInstance().resolveReference(treeReference).getValue(); } - public Stream getEntities() { - Entities extra = formEntryController.getModel().getExtras().get(Entities.class); - if (extra != null) { - return extra.getEntities().stream().map(entity -> new Entity(entity.dataset, entity.properties)); - } else { - return Stream.empty(); - } + public Entities getEntities() { + return formEntryController.getModel().getExtras().get(Entities.class); } } diff --git a/collect_app/src/main/java/org/odk/collect/android/listeners/DownloadFormsTaskListener.java b/collect_app/src/main/java/org/odk/collect/android/listeners/DownloadFormsTaskListener.java index c8adbe86d82..9eff8f59235 100644 --- a/collect_app/src/main/java/org/odk/collect/android/listeners/DownloadFormsTaskListener.java +++ b/collect_app/src/main/java/org/odk/collect/android/listeners/DownloadFormsTaskListener.java @@ -14,7 +14,7 @@ package org.odk.collect.android.listeners; -import org.odk.collect.android.formmanagement.FormDownloadException; +import org.odk.collect.android.formmanagement.download.FormDownloadException; import org.odk.collect.android.formmanagement.ServerFormDetails; import java.util.Map; diff --git a/collect_app/src/main/java/org/odk/collect/android/listeners/SavePointListener.java b/collect_app/src/main/java/org/odk/collect/android/listeners/SavePointListener.java deleted file mode 100644 index 7bda6b54a58..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/listeners/SavePointListener.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (C) 2014 University of Washington - * - * Originally developed by Dobility, Inc. (as part of SurveyCTO) - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package org.odk.collect.android.listeners; - -/** - * Author: Meletis Margaritis - * Date: 27/6/2013 - * Time: 7:15 μμ - */ -public interface SavePointListener { - - void onSavePointError(String errorMessage); -} diff --git a/collect_app/src/main/java/org/odk/collect/android/logic/ImmutableDisplayableQuestion.java b/collect_app/src/main/java/org/odk/collect/android/logic/ImmutableDisplayableQuestion.java index 32d5ecf7cd4..02007a78119 100644 --- a/collect_app/src/main/java/org/odk/collect/android/logic/ImmutableDisplayableQuestion.java +++ b/collect_app/src/main/java/org/odk/collect/android/logic/ImmutableDisplayableQuestion.java @@ -63,6 +63,11 @@ public class ImmutableDisplayableQuestion { */ private final boolean isReadOnly; + /** + * Whether the question is required. + */ + private final boolean isRequired; + /** * The choices displayed to a user if this question is of a type that has choices. */ @@ -78,6 +83,7 @@ public ImmutableDisplayableQuestion(FormEntryPrompt question) { guidanceText = question.getSpecialFormQuestionText(question.getQuestion().getHelpTextID(), "guidance"); answerText = question.getAnswerText(); isReadOnly = question.isReadOnly(); + isRequired = question.isRequired(); List choices = question.getSelectChoices(); if (choices != null) { @@ -106,7 +112,8 @@ public boolean sameAs(FormEntryPrompt question) { && (getGuidanceHintText(question) == null ? guidanceText == null : getGuidanceHintText(question).equals(guidanceText)) && (question.getAnswerText() == null ? answerText == null : question.getAnswerText().equals(answerText)) && (question.isReadOnly() == isReadOnly) - && selectChoiceListsEqual(question.getSelectChoices(), selectChoices); + && selectChoiceListsEqual(question.getSelectChoices(), selectChoices) + && question.isRequired() == isRequired; } private static boolean selectChoiceListsEqual(List selectChoiceList1, List selectChoiceList2) { diff --git a/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuActivity.kt b/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuActivity.kt index a3c898a6849..b1807ad3e7b 100644 --- a/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuActivity.kt +++ b/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuActivity.kt @@ -37,7 +37,7 @@ class MainMenuActivity : LocalizedActivity() { /* Don't reopen if the app is already open - allows entry points like notifications to use this Activity as a target to reopen the app without interrupting an ongoing session - */ + */ if (!isTaskRoot) { super.onCreate(null) finish() @@ -89,7 +89,7 @@ class MainMenuActivity : LocalizedActivity() { We don't need the `installSplashScreen` call on Android 12+ (the system handles the splash screen for us) and it causes problems if we later switch between dark/light themes with the ThemeUtils#setDarkModeForCurrentProject call. - */ + */ if (Build.VERSION.SDK_INT < 31) { installSplashScreen() } else { diff --git a/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuButton.kt b/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuButton.kt index 2914e14bc77..dc3acda24f9 100644 --- a/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuButton.kt +++ b/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuButton.kt @@ -5,12 +5,12 @@ import android.graphics.Typeface import android.util.AttributeSet import android.view.LayoutInflater import android.widget.FrameLayout +import androidx.core.content.withStyledAttributes import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeUtils import com.google.android.material.badge.ExperimentalBadgeUtils import org.odk.collect.android.R import org.odk.collect.android.databinding.MainMenuButtonBinding -import org.odk.collect.android.utilities.ApplicationConstants import org.odk.collect.androidshared.system.ContextUtils.getThemeAttributeValue import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickGuard @@ -20,25 +20,16 @@ class MainMenuButton(context: Context, attrs: AttributeSet?) : FrameLayout(conte private val binding = MainMenuButtonBinding.inflate(LayoutInflater.from(context), this, true) private val badge: BadgeDrawable - private val highlightable: Boolean + private var highlightable: Boolean = false init { - context.theme.obtainStyledAttributes( - attrs, - R.styleable.MainMenuButton, - 0, - 0 - ).apply { - try { - val buttonIcon = this.getResourceId(R.styleable.MainMenuButton_icon, 0) - val buttonName = this.getString(R.styleable.MainMenuButton_name) - highlightable = this.getBoolean(R.styleable.MainMenuButton_highlightable, false) + context.withStyledAttributes(attrs, R.styleable.MainMenuButton) { + val buttonIcon = this.getResourceId(R.styleable.MainMenuButton_icon, 0) + val buttonName = this.getString(R.styleable.MainMenuButton_name) + highlightable = this.getBoolean(R.styleable.MainMenuButton_highlightable, false) - binding.icon.setImageResource(buttonIcon) - binding.name.text = buttonName - } finally { - recycle() - } + binding.icon.setImageResource(buttonIcon) + binding.name.text = buttonName } badge = BadgeDrawable.create(context).apply { @@ -51,7 +42,7 @@ class MainMenuButton(context: Context, attrs: AttributeSet?) : FrameLayout(conte get() = binding.name.text.toString() override fun performClick(): Boolean { - return MultiClickGuard.allowClick(ApplicationConstants.ScreenName.MAIN_MENU.name) && super.performClick() + return MultiClickGuard.allowClick(context.getString(R.string.main_menu_screen)) && super.performClick() } fun setNumberOfForms(number: Int) { diff --git a/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuFragment.kt b/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuFragment.kt index 6def4ff4f42..5361c8d8e84 100644 --- a/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuFragment.kt +++ b/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuFragment.kt @@ -2,7 +2,6 @@ package org.odk.collect.android.mainmenu import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.Menu @@ -15,10 +14,9 @@ import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.Toolbar import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider -import org.odk.collect.android.activities.DeleteSavedFormActivity +import org.odk.collect.android.activities.DeleteFormsActivity import org.odk.collect.android.activities.FormDownloadListActivity import org.odk.collect.android.activities.InstanceChooserList -import org.odk.collect.android.activities.WebViewActivity import org.odk.collect.android.application.MapboxClassInstanceCreator import org.odk.collect.android.databinding.MainMenuBinding import org.odk.collect.android.formlists.blankformlist.BlankFormListActivity @@ -26,13 +24,16 @@ import org.odk.collect.android.formmanagement.FormFillingIntentFactory import org.odk.collect.android.instancemanagement.send.InstanceUploaderListActivity import org.odk.collect.android.projects.ProjectIconView import org.odk.collect.android.projects.ProjectSettingsDialog +import org.odk.collect.android.utilities.ActionRegister import org.odk.collect.android.utilities.ApplicationConstants +import org.odk.collect.androidshared.data.consume import org.odk.collect.androidshared.ui.DialogFragmentUtils import org.odk.collect.androidshared.ui.SnackbarUtils import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickGuard import org.odk.collect.projects.Project import org.odk.collect.settings.SettingsProvider import org.odk.collect.strings.R.string +import org.odk.collect.webpage.WebViewActivity class MainMenuFragment( private val viewModelFactory: ViewModelProvider.Factory, @@ -44,8 +45,9 @@ class MainMenuFragment( private lateinit var permissionsViewModel: RequestPermissionsViewModel private val formEntryFlowLauncher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - displayFormSavedSnackbar(it.data?.data) + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val uri = result.data?.data + mainMenuViewModel.setSavedForm(uri) } override fun onAttach(context: Context) { @@ -84,6 +86,24 @@ class MainMenuFragment( this.parentFragmentManager ) } + + mainMenuViewModel.savedForm.consume(viewLifecycleOwner) { value -> + SnackbarUtils.showLongSnackbar( + requireView(), + getString(value.message), + action = value.action?.let { action -> + SnackbarUtils.Action(getString(action)) { + formEntryFlowLauncher.launch( + FormFillingIntentFactory.editInstanceIntent( + requireContext(), + value.uri + ) + ) + } + }, + displayDismissButton = true + ) + } } override fun onResume() { @@ -144,6 +164,8 @@ class MainMenuFragment( private fun initButtons(binding: MainMenuBinding) { binding.enterData.setOnClickListener { + ActionRegister.actionDetected() + formEntryFlowLauncher.launch( Intent(requireActivity(), BlankFormListActivity::class.java) ) @@ -186,7 +208,7 @@ class MainMenuFragment( } binding.manageForms.setOnClickListener { - startActivity(Intent(requireContext(), DeleteSavedFormActivity::class.java)) + startActivity(Intent(requireContext(), DeleteFormsActivity::class.java)) } mainMenuViewModel.sendableInstancesCount.observe(viewLifecycleOwner) { finalized: Int -> @@ -240,30 +262,4 @@ class MainMenuFragment( binding.googleDriveDeprecationBanner.root.visibility = View.GONE } } - - private fun displayFormSavedSnackbar(uri: Uri?) { - if (uri == null) { - return - } - - val formSavedSnackbarDetails = mainMenuViewModel.getFormSavedSnackbarDetails(uri) - - formSavedSnackbarDetails?.let { - SnackbarUtils.showLongSnackbar( - requireView(), - getString(it.first), - action = it.second?.let { action -> - SnackbarUtils.Action(getString(action)) { - formEntryFlowLauncher.launch( - FormFillingIntentFactory.editInstanceIntent( - requireContext(), - uri - ) - ) - } - }, - displayDismissButton = true - ) - } - } } diff --git a/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuViewModel.kt b/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuViewModel.kt index 9dce57dc7e5..aba49bddcb7 100644 --- a/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuViewModel.kt +++ b/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuViewModel.kt @@ -3,22 +3,26 @@ package org.odk.collect.android.mainmenu import android.app.Application import android.net.Uri import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import org.odk.collect.android.formmanagement.InstancesDataService +import androidx.lifecycle.map import org.odk.collect.android.instancemanagement.InstanceDiskSynchronizer +import org.odk.collect.android.instancemanagement.InstancesDataService import org.odk.collect.android.instancemanagement.autosend.AutoSendSettingsProvider import org.odk.collect.android.instancemanagement.autosend.shouldFormBeSentAutomatically import org.odk.collect.android.instancemanagement.canBeEdited import org.odk.collect.android.instancemanagement.isDraft -import org.odk.collect.android.preferences.utilities.FormUpdateMode -import org.odk.collect.android.preferences.utilities.SettingsUtils +import org.odk.collect.android.projects.ProjectsDataService import org.odk.collect.android.utilities.ContentUriHelper import org.odk.collect.android.utilities.FormsRepositoryProvider import org.odk.collect.android.utilities.InstancesRepositoryProvider import org.odk.collect.android.version.VersionInformation +import org.odk.collect.androidshared.data.Consumable import org.odk.collect.async.Scheduler import org.odk.collect.forms.instances.Instance import org.odk.collect.settings.SettingsProvider +import org.odk.collect.settings.enums.FormUpdateMode +import org.odk.collect.settings.enums.StringIdEnumUtils.getFormUpdateMode import org.odk.collect.settings.keys.ProtectedProjectKeys class MainMenuViewModel( @@ -29,7 +33,8 @@ class MainMenuViewModel( private val scheduler: Scheduler, private val formsRepositoryProvider: FormsRepositoryProvider, private val instancesRepositoryProvider: InstancesRepositoryProvider, - private val autoSendSettingsProvider: AutoSendSettingsProvider + private val autoSendSettingsProvider: AutoSendSettingsProvider, + private val projectsDataService: ProjectsDataService ) : ViewModel() { val version: String @@ -40,7 +45,10 @@ class MainMenuViewModel( var commitDescription = "" if (versionInformation.commitCount != null) { commitDescription = - appendToCommitDescription(commitDescription, versionInformation.commitCount.toString()) + appendToCommitDescription( + commitDescription, + versionInformation.commitCount.toString() + ) } if (versionInformation.commitSHA != null) { commitDescription = @@ -56,29 +64,38 @@ class MainMenuViewModel( } } + private val _savedForm = MutableLiveData() + val savedForm: LiveData> = _savedForm.map { Consumable(it) } + fun shouldEditSavedFormButtonBeVisible(): Boolean { - return settingsProvider.getProtectedSettings().getBoolean(ProtectedProjectKeys.KEY_EDIT_SAVED) + return settingsProvider.getProtectedSettings() + .getBoolean(ProtectedProjectKeys.KEY_EDIT_SAVED) } fun shouldSendFinalizedFormButtonBeVisible(): Boolean { - return settingsProvider.getProtectedSettings().getBoolean(ProtectedProjectKeys.KEY_SEND_FINALIZED) + return settingsProvider.getProtectedSettings() + .getBoolean(ProtectedProjectKeys.KEY_SEND_FINALIZED) } fun shouldViewSentFormButtonBeVisible(): Boolean { - return settingsProvider.getProtectedSettings().getBoolean(ProtectedProjectKeys.KEY_VIEW_SENT) + return settingsProvider.getProtectedSettings() + .getBoolean(ProtectedProjectKeys.KEY_VIEW_SENT) } fun shouldGetBlankFormButtonBeVisible(): Boolean { - val buttonEnabled = settingsProvider.getProtectedSettings().getBoolean(ProtectedProjectKeys.KEY_GET_BLANK) + val buttonEnabled = + settingsProvider.getProtectedSettings().getBoolean(ProtectedProjectKeys.KEY_GET_BLANK) return !isMatchExactlyEnabled() && buttonEnabled } fun shouldDeleteSavedFormButtonBeVisible(): Boolean { - return settingsProvider.getProtectedSettings().getBoolean(ProtectedProjectKeys.KEY_DELETE_SAVED) + return settingsProvider.getProtectedSettings() + .getBoolean(ProtectedProjectKeys.KEY_DELETE_SAVED) } private fun isMatchExactlyEnabled(): Boolean { - return SettingsUtils.getFormUpdateMode(application, settingsProvider.getUnprotectedSettings()) == FormUpdateMode.MATCH_EXACTLY + return settingsProvider.getUnprotectedSettings() + .getFormUpdateMode(application) == FormUpdateMode.MATCH_EXACTLY } private fun appendToCommitDescription(commitDescription: String, part: String): String { @@ -92,7 +109,7 @@ class MainMenuViewModel( fun refreshInstances() { scheduler.immediate({ InstanceDiskSynchronizer(settingsProvider).doInBackground() - instancesDataService.update() + instancesDataService.update(projectsDataService.getCurrentProject().uuid) null }) { } } @@ -106,13 +123,27 @@ class MainMenuViewModel( val sentInstancesCount: LiveData get() = instancesDataService.sentCount - fun getFormSavedSnackbarDetails(uri: Uri): Pair? { + fun setSavedForm(uri: Uri?) { + if (uri == null) { + return + } + + scheduler.immediate { + val details = getFormSavedSnackbarDetails(uri) + if (details != null) { + _savedForm.postValue(SavedForm(uri, details.first, details.second)) + } + } + } + + private fun getFormSavedSnackbarDetails(uri: Uri): Pair? { val instance = instancesRepositoryProvider.get().get(ContentUriHelper.getIdFromUri(uri)) return if (instance != null) { val message = if (instance.isDraft()) { org.odk.collect.strings.R.string.form_saved_as_draft } else if (instance.status == Instance.STATUS_COMPLETE || instance.status == Instance.STATUS_SUBMISSION_FAILED) { - val form = formsRepositoryProvider.get().getAllByFormIdAndVersion(instance.formId, instance.formVersion).first() + val form = formsRepositoryProvider.get() + .getAllByFormIdAndVersion(instance.formId, instance.formVersion).first() if (form.shouldFormBeSentAutomatically(autoSendSettingsProvider.isAutoSendEnabledInSettings())) { org.odk.collect.strings.R.string.form_sending } else { @@ -137,4 +168,6 @@ class MainMenuViewModel( null } } + + data class SavedForm(val uri: Uri, val message: Int, val action: Int?) } diff --git a/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuViewModelFactory.kt b/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuViewModelFactory.kt index f95c25c82c7..83804d15fc7 100644 --- a/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuViewModelFactory.kt +++ b/collect_app/src/main/java/org/odk/collect/android/mainmenu/MainMenuViewModelFactory.kt @@ -3,8 +3,7 @@ package org.odk.collect.android.mainmenu import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import org.odk.collect.android.application.initialization.AnalyticsInitializer -import org.odk.collect.android.formmanagement.InstancesDataService +import org.odk.collect.android.instancemanagement.InstancesDataService import org.odk.collect.android.instancemanagement.autosend.AutoSendSettingsProvider import org.odk.collect.android.projects.ProjectsDataService import org.odk.collect.android.utilities.FormsRepositoryProvider @@ -21,7 +20,6 @@ open class MainMenuViewModelFactory( private val instancesDataService: InstancesDataService, private val scheduler: Scheduler, private val projectsDataService: ProjectsDataService, - private val analyticsInitializer: AnalyticsInitializer, private val permissionChecker: PermissionsChecker, private val formsRepositoryProvider: FormsRepositoryProvider, private val instancesRepositoryProvider: InstancesRepositoryProvider, @@ -37,7 +35,8 @@ open class MainMenuViewModelFactory( scheduler, formsRepositoryProvider, instancesRepositoryProvider, - autoSendSettingsProvider + autoSendSettingsProvider, + projectsDataService ) CurrentProjectViewModel::class.java -> CurrentProjectViewModel( diff --git a/collect_app/src/main/java/org/odk/collect/android/mainmenu/StartNewFormButton.kt b/collect_app/src/main/java/org/odk/collect/android/mainmenu/StartNewFormButton.kt index ee4c5cf31a7..1fc3abb04be 100644 --- a/collect_app/src/main/java/org/odk/collect/android/mainmenu/StartNewFormButton.kt +++ b/collect_app/src/main/java/org/odk/collect/android/mainmenu/StartNewFormButton.kt @@ -5,7 +5,6 @@ import android.util.AttributeSet import android.widget.FrameLayout import android.widget.TextView import org.odk.collect.android.R -import org.odk.collect.android.utilities.ApplicationConstants import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickGuard class StartNewFormButton(context: Context, attrs: AttributeSet?) : FrameLayout(context, attrs) { @@ -20,6 +19,6 @@ class StartNewFormButton(context: Context, attrs: AttributeSet?) : FrameLayout(c get() = findViewById(R.id.name).text.toString() override fun performClick(): Boolean { - return MultiClickGuard.allowClick(ApplicationConstants.ScreenName.MAIN_MENU.name) && super.performClick() + return MultiClickGuard.allowClick(context.getString(R.string.main_menu_screen)) && super.performClick() } } diff --git a/collect_app/src/main/java/org/odk/collect/android/notifications/NotificationManagerNotifier.kt b/collect_app/src/main/java/org/odk/collect/android/notifications/NotificationManagerNotifier.kt index 61cb9cfcac0..fa089b0e298 100644 --- a/collect_app/src/main/java/org/odk/collect/android/notifications/NotificationManagerNotifier.kt +++ b/collect_app/src/main/java/org/odk/collect/android/notifications/NotificationManagerNotifier.kt @@ -5,8 +5,8 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context import android.os.Build -import org.odk.collect.android.formmanagement.FormDownloadException import org.odk.collect.android.formmanagement.ServerFormDetails +import org.odk.collect.android.formmanagement.download.FormDownloadException import org.odk.collect.android.notifications.builders.FormUpdatesAvailableNotificationBuilder import org.odk.collect.android.notifications.builders.FormUpdatesDownloadedNotificationBuilder import org.odk.collect.android.notifications.builders.FormsSubmissionNotificationBuilder diff --git a/collect_app/src/main/java/org/odk/collect/android/notifications/Notifier.kt b/collect_app/src/main/java/org/odk/collect/android/notifications/Notifier.kt index a6b02af0229..617eedda5e0 100644 --- a/collect_app/src/main/java/org/odk/collect/android/notifications/Notifier.kt +++ b/collect_app/src/main/java/org/odk/collect/android/notifications/Notifier.kt @@ -1,7 +1,7 @@ package org.odk.collect.android.notifications -import org.odk.collect.android.formmanagement.FormDownloadException import org.odk.collect.android.formmanagement.ServerFormDetails +import org.odk.collect.android.formmanagement.download.FormDownloadException import org.odk.collect.android.upload.FormUploadException import org.odk.collect.forms.FormSourceException import org.odk.collect.forms.instances.Instance diff --git a/collect_app/src/main/java/org/odk/collect/android/notifications/builders/FormUpdatesDownloadedNotificationBuilder.kt b/collect_app/src/main/java/org/odk/collect/android/notifications/builders/FormUpdatesDownloadedNotificationBuilder.kt index 6b77d028c54..49cc5f5ee18 100644 --- a/collect_app/src/main/java/org/odk/collect/android/notifications/builders/FormUpdatesDownloadedNotificationBuilder.kt +++ b/collect_app/src/main/java/org/odk/collect/android/notifications/builders/FormUpdatesDownloadedNotificationBuilder.kt @@ -4,8 +4,8 @@ import android.app.Application import android.app.Notification import androidx.core.app.NotificationCompat import org.odk.collect.android.R -import org.odk.collect.android.formmanagement.FormDownloadException import org.odk.collect.android.formmanagement.ServerFormDetails +import org.odk.collect.android.formmanagement.download.FormDownloadException import org.odk.collect.android.notifications.NotificationManagerNotifier import org.odk.collect.android.notifications.NotificationUtils import org.odk.collect.android.utilities.FormsDownloadResultInterpreter diff --git a/collect_app/src/main/java/org/odk/collect/android/openrosa/OpenRosaResponseParserImpl.kt b/collect_app/src/main/java/org/odk/collect/android/openrosa/OpenRosaResponseParserImpl.kt index 862a61dc63c..2d2dfc12d9a 100644 --- a/collect_app/src/main/java/org/odk/collect/android/openrosa/OpenRosaResponseParserImpl.kt +++ b/collect_app/src/main/java/org/odk/collect/android/openrosa/OpenRosaResponseParserImpl.kt @@ -6,6 +6,7 @@ import org.kxml2.kdom.Element import org.odk.collect.forms.FormListItem import org.odk.collect.forms.MediaFile import org.odk.collect.shared.strings.StringUtils.isBlank +import java.io.File class OpenRosaResponseParserImpl : OpenRosaResponseParser { @@ -179,6 +180,9 @@ class OpenRosaResponseParserImpl : OpenRosaResponseParser { if (filename != null && filename.isEmpty()) { filename = null } + if (filename != null) { + filename = File(filename).name + } } "hash" -> { hash = XFormParser.getXMLText(child, true) diff --git a/collect_app/src/main/java/org/odk/collect/android/preferences/CaptionedListPreference.java b/collect_app/src/main/java/org/odk/collect/android/preferences/CaptionedListPreference.java deleted file mode 100644 index a8c1cabaf64..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/preferences/CaptionedListPreference.java +++ /dev/null @@ -1,126 +0,0 @@ -package org.odk.collect.android.preferences; - -import android.content.Context; -import android.graphics.Rect; -import android.util.AttributeSet; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.RadioButton; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.preference.ListPreference; - -import org.odk.collect.android.R; -import org.odk.collect.android.injection.DaggerUtils; -import org.odk.collect.android.preferences.dialogs.ReferenceLayerPreferenceDialog; -import org.odk.collect.settings.SettingsProvider; - -import java.util.List; -import java.util.Objects; - -import javax.inject.Inject; - -/** A ListPreference where each item has a caption **/ -public class CaptionedListPreference extends ListPreference { - - private CharSequence[] captions; - private int clickedIndex = -1; - - @Inject - SettingsProvider settingsProvider; - - public CaptionedListPreference(Context context, AttributeSet attrs) { - super(context, attrs); - DaggerUtils.getComponent(context).inject(this); - } - - @Override - public int getDialogLayoutResource() { - return R.layout.captioned_list_dialog; - } - - /** Sets the values, labels, and captions for the items in the dialog. */ - public void setItems(List items) { - int count = items.size(); - String[] values = new String[count]; - String[] labels = new String[count]; - String[] captions = new String[count]; - for (int i = 0; i < count; i++) { - values[i] = items.get(i).value; - labels[i] = items.get(i).label; - captions[i] = items.get(i).caption; - } - setEntryValues(values); - setEntries(labels); - setCaptions(captions); - } - - /** Sets the list of items to offer as choices in the dialog. */ - public void setCaptions(CharSequence[] captions) { - this.captions = captions; - } - - /** Updates the contents of the dialog to show the items passed in by setItems etc.*/ - public void updateContent() { - CharSequence[] values = getEntryValues(); - CharSequence[] labels = getEntries(); - - if (ReferenceLayerPreferenceDialog.listView != null && values != null && labels != null && captions != null) { - ReferenceLayerPreferenceDialog.listView.removeAllViews(); - for (int i = 0; i < values.length; i++) { - inflateItem(ReferenceLayerPreferenceDialog.listView, i, values[i], labels[i], captions[i]); - } - } - } - - /** Creates the view for one item in the list. */ - protected void inflateItem(ViewGroup parent, final int i, Object value, Object label, Object caption) { - View item = LayoutInflater.from(getContext()).inflate(R.layout.captioned_item, null); - RadioButton button = item.findViewById(R.id.button); - TextView labelView = item.findViewById(R.id.label); - TextView captionView = item.findViewById(R.id.caption); - labelView.setText(String.valueOf(label)); - captionView.setText(String.valueOf(caption)); - button.setOnClickListener(view -> onItemClicked(i)); - item.setOnClickListener(view -> onItemClicked(i)); - parent.addView(item); - if (Objects.equals(value, settingsProvider.getUnprotectedSettings().getString(getKey()))) { - button.setChecked(true); - item.post(() -> item.requestRectangleOnScreen(new Rect(0, 0, item.getWidth(), item.getHeight()))); - } - } - - /** Saves the selected value to the preferences when the dialog is closed. */ - protected void onDialogClosed() { - CharSequence[] values = getEntryValues(); - if (clickedIndex >= 0 && values != null) { - Object value = values[clickedIndex]; - if (callChangeListener(value)) { - setValue(value != null ? value.toString() : null); - } - } - } - - /** When an item is clicked, record which item and then dismiss the dialog. */ - protected void onItemClicked(int index) { - clickedIndex = index; - onDialogClosed(); - } - - public static class Item { - public final @Nullable - String value; - public final @NonNull - String label; - public final @NonNull String caption; - - public Item(@Nullable String value, @Nullable String label, @Nullable String caption) { - this.value = value; - this.label = label != null ? label : ""; - this.caption = caption != null ? caption : ""; - } - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/preferences/Defaults.kt b/collect_app/src/main/java/org/odk/collect/android/preferences/Defaults.kt index 381fcbd49cf..9b9038c6919 100644 --- a/collect_app/src/main/java/org/odk/collect/android/preferences/Defaults.kt +++ b/collect_app/src/main/java/org/odk/collect/android/preferences/Defaults.kt @@ -50,6 +50,8 @@ object Defaults { hashMap[ProjectKeys.KEY_USGS_MAP_STYLE] = "topographic" hashMap[ProjectKeys.KEY_GOOGLE_MAP_STYLE] = GoogleMap.MAP_TYPE_NORMAL.toString() hashMap[ProjectKeys.KEY_MAPBOX_MAP_STYLE] = "mapbox://styles/mapbox/streets-v11" + // experimental_preferences.xml + hashMap[ProjectKeys.KEY_LOCAL_ENTITIES] = false return hashMap } diff --git a/collect_app/src/main/java/org/odk/collect/android/preferences/dialogs/ReferenceLayerPreferenceDialog.java b/collect_app/src/main/java/org/odk/collect/android/preferences/dialogs/ReferenceLayerPreferenceDialog.java deleted file mode 100644 index 579b0018b96..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/preferences/dialogs/ReferenceLayerPreferenceDialog.java +++ /dev/null @@ -1,88 +0,0 @@ -package org.odk.collect.android.preferences.dialogs; - -import android.content.DialogInterface; -import android.net.Uri; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.LinearLayout; - -import androidx.appcompat.app.AlertDialog; -import androidx.preference.ListPreferenceDialogFragmentCompat; - -import org.odk.collect.android.R; -import org.odk.collect.android.preferences.CaptionedListPreference; -import org.odk.collect.android.utilities.ExternalWebPageHelper; - -public class ReferenceLayerPreferenceDialog extends ListPreferenceDialogFragmentCompat implements DialogInterface.OnClickListener { - - /** - * Views should not be stored statically like this. The relationship on how the list is setup - * here should be inverted - {@link ReferenceLayerPreferenceDialog} should be asking - * {@link CaptionedListPreference} for items rather than having them pushed through this static - * field. - */ - @Deprecated - public static ViewGroup listView; - - public static ReferenceLayerPreferenceDialog newInstance(String key) { - ReferenceLayerPreferenceDialog fragment = new ReferenceLayerPreferenceDialog(); - Bundle b = new Bundle(1); - b.putString(ARG_KEY, key); - fragment.setArguments(b); - return fragment; - } - - @Override - protected void onPrepareDialogBuilder(AlertDialog.Builder builder) { - // Selecting an item will close the dialog, so we don't need the "OK" button. - builder.setPositiveButton(null, null); - } - - /** Called just after the dialog's main view has been created. */ - @Override - protected void onBindDialogView(View view) { - CaptionedListPreference preference = null; - if (getPreference() instanceof CaptionedListPreference) { - preference = (CaptionedListPreference) getPreference(); - } - - addHelpFooter(view); - - listView = view.findViewById(R.id.list); - - if (preference != null) { - preference.updateContent(); - } - - super.onBindDialogView(view); - } - - @Override - public void onClick(DialogInterface dialog, int which) { - super.onClick(dialog, which); - listView = null; - if (getDialog() != null) { - getDialog().dismiss(); - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - listView = null; - if (getDialog() != null) { - getDialog().dismiss(); - } - } - - private void addHelpFooter(View view) { - LinearLayout layout = (LinearLayout) view; - View helpFooter = LayoutInflater.from(requireContext()).inflate(R.layout.reference_layer_help_footer, layout, false); - helpFooter.findViewById(R.id.help_button).setOnClickListener(v -> { - new ExternalWebPageHelper().openWebPageInCustomTab(requireActivity(), Uri.parse("https://docs.getodk.org/collect-offline-maps/#transferring-offline-tilesets-to-devices")); - }); - layout.addView(helpFooter); - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/preferences/dialogs/ResetDialogPreferenceFragmentCompat.java b/collect_app/src/main/java/org/odk/collect/android/preferences/dialogs/ResetDialogPreferenceFragmentCompat.java index f861847f6a0..566489844b3 100644 --- a/collect_app/src/main/java/org/odk/collect/android/preferences/dialogs/ResetDialogPreferenceFragmentCompat.java +++ b/collect_app/src/main/java/org/odk/collect/android/preferences/dialogs/ResetDialogPreferenceFragmentCompat.java @@ -1,7 +1,7 @@ package org.odk.collect.android.preferences.dialogs; import static org.odk.collect.android.fragments.dialogs.ResetSettingsResultDialog.RESET_SETTINGS_RESULT_DIALOG_TAG; -import static org.odk.collect.android.utilities.ProjectResetter.ResetAction.RESET_PREFERENCES; +import static org.odk.collect.android.projects.ProjectResetter.ResetAction.RESET_PREFERENCES; import android.content.Context; import android.content.DialogInterface; @@ -19,7 +19,7 @@ import org.odk.collect.android.fragments.dialogs.ResetSettingsResultDialog; import org.odk.collect.android.injection.DaggerUtils; import org.odk.collect.android.preferences.screens.ProjectPreferencesActivity; -import org.odk.collect.android.utilities.ProjectResetter; +import org.odk.collect.android.projects.ProjectResetter; import org.odk.collect.androidshared.ui.DialogFragmentUtils; import java.util.ArrayList; diff --git a/collect_app/src/main/java/org/odk/collect/android/preferences/screens/FormManagementPreferencesFragment.java b/collect_app/src/main/java/org/odk/collect/android/preferences/screens/FormManagementPreferencesFragment.java index 7d542d9ee0d..7828144fba0 100644 --- a/collect_app/src/main/java/org/odk/collect/android/preferences/screens/FormManagementPreferencesFragment.java +++ b/collect_app/src/main/java/org/odk/collect/android/preferences/screens/FormManagementPreferencesFragment.java @@ -15,7 +15,7 @@ package org.odk.collect.android.preferences.screens; import static org.odk.collect.android.preferences.utilities.PreferencesUtils.displayDisabled; -import static org.odk.collect.android.preferences.utilities.SettingsUtils.getFormUpdateMode; +import static org.odk.collect.settings.enums.StringIdEnumUtils.getFormUpdateMode; import static org.odk.collect.settings.keys.ProjectKeys.KEY_AUTOMATIC_UPDATE; import static org.odk.collect.settings.keys.ProjectKeys.KEY_AUTOSEND; import static org.odk.collect.settings.keys.ProjectKeys.KEY_CONSTRAINT_BEHAVIOR; @@ -39,6 +39,8 @@ import org.odk.collect.android.application.Collect; import org.odk.collect.android.backgroundwork.FormUpdateScheduler; import org.odk.collect.android.backgroundwork.InstanceSubmitScheduler; +import org.odk.collect.settings.enums.AutoSend; +import org.odk.collect.settings.enums.StringIdEnumUtils; import org.odk.collect.shared.settings.Settings; import javax.inject.Inject; @@ -80,8 +82,8 @@ public void onSettingChanged(@NotNull String key) { updateDisabledPrefs(); } - if (key.equals(KEY_AUTOSEND) && !settingsProvider.getUnprotectedSettings().getString(KEY_AUTOSEND).equals("off")) { - instanceSubmitScheduler.scheduleSubmit(projectsDataService.getCurrentProject().getUuid()); + if (key.equals(KEY_AUTOSEND) && !StringIdEnumUtils.getAutoSend(settingsProvider.getUnprotectedSettings(), requireContext()).equals(AutoSend.OFF)) { + instanceSubmitScheduler.scheduleAutoSend(projectsDataService.getCurrentProject().getUuid()); } } @@ -92,7 +94,7 @@ private void updateDisabledPrefs() { @Nullable Preference updateFrequency = findPreference(KEY_PERIODIC_FORM_UPDATES_CHECK); @Nullable CheckBoxPreference automaticDownload = findPreference(KEY_AUTOMATIC_UPDATE); - switch (getFormUpdateMode(requireContext(), generalSettings)) { + switch (getFormUpdateMode(generalSettings, requireContext())) { case MANUAL: if (automaticDownload != null) { displayDisabled(automaticDownload, false); diff --git a/collect_app/src/main/java/org/odk/collect/android/preferences/screens/MainMenuAccessPreferencesFragment.kt b/collect_app/src/main/java/org/odk/collect/android/preferences/screens/MainMenuAccessPreferencesFragment.kt index 47bcdde0c61..202c4e240b9 100644 --- a/collect_app/src/main/java/org/odk/collect/android/preferences/screens/MainMenuAccessPreferencesFragment.kt +++ b/collect_app/src/main/java/org/odk/collect/android/preferences/screens/MainMenuAccessPreferencesFragment.kt @@ -3,9 +3,9 @@ package org.odk.collect.android.preferences.screens import android.os.Bundle import androidx.preference.Preference import org.odk.collect.android.R -import org.odk.collect.android.preferences.utilities.FormUpdateMode import org.odk.collect.android.preferences.utilities.PreferencesUtils -import org.odk.collect.android.preferences.utilities.SettingsUtils +import org.odk.collect.settings.enums.FormUpdateMode +import org.odk.collect.settings.enums.StringIdEnumUtils.getFormUpdateMode import org.odk.collect.settings.keys.ProtectedProjectKeys class MainMenuAccessPreferencesFragment : BaseAdminPreferencesFragment() { @@ -17,7 +17,7 @@ class MainMenuAccessPreferencesFragment : BaseAdminPreferencesFragment() { findPreference(ProtectedProjectKeys.KEY_EDIT_SAVED)!!.isEnabled = settingsProvider.getProtectedSettings().getBoolean(ProtectedProjectKeys.ALLOW_OTHER_WAYS_OF_EDITING_FORM) - val formUpdateMode = SettingsUtils.getFormUpdateMode(requireContext(), settingsProvider.getUnprotectedSettings()) + val formUpdateMode = settingsProvider.getUnprotectedSettings().getFormUpdateMode(requireContext()) if (formUpdateMode == FormUpdateMode.MATCH_EXACTLY) { PreferencesUtils.displayDisabled(findPreference(ProtectedProjectKeys.KEY_GET_BLANK), false) } diff --git a/collect_app/src/main/java/org/odk/collect/android/preferences/screens/MapsPreferencesFragment.kt b/collect_app/src/main/java/org/odk/collect/android/preferences/screens/MapsPreferencesFragment.kt index 5784fc3eeac..88d9914a9f3 100644 --- a/collect_app/src/main/java/org/odk/collect/android/preferences/screens/MapsPreferencesFragment.kt +++ b/collect_app/src/main/java/org/odk/collect/android/preferences/screens/MapsPreferencesFragment.kt @@ -15,50 +15,65 @@ package org.odk.collect.android.preferences.screens import android.content.Context import android.os.Bundle -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.FragmentActivity import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.PreferenceCategory import org.odk.collect.android.R import org.odk.collect.android.geo.MapConfiguratorProvider import org.odk.collect.android.injection.DaggerUtils -import org.odk.collect.android.preferences.CaptionedListPreference -import org.odk.collect.android.preferences.dialogs.ReferenceLayerPreferenceDialog -import org.odk.collect.android.preferences.screens.ReferenceLayerPreferenceUtils.populateReferenceLayerPref +import org.odk.collect.androidshared.ui.DialogFragmentUtils +import org.odk.collect.androidshared.ui.FragmentFactoryBuilder import org.odk.collect.androidshared.ui.PrefUtils import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickGuard.allowClick +import org.odk.collect.async.Scheduler import org.odk.collect.maps.MapConfigurator +import org.odk.collect.maps.layers.OfflineMapLayersPicker import org.odk.collect.maps.layers.ReferenceLayerRepository +import org.odk.collect.settings.keys.ProjectKeys import org.odk.collect.settings.keys.ProjectKeys.CATEGORY_BASEMAP import org.odk.collect.settings.keys.ProjectKeys.KEY_BASEMAP_SOURCE -import java.io.File +import org.odk.collect.strings.localization.getLocalizedString +import org.odk.collect.webpage.ExternalWebPageHelper import javax.inject.Inject -class MapsPreferencesFragment : BaseProjectPreferencesFragment() { +class MapsPreferencesFragment : BaseProjectPreferencesFragment(), Preference.OnPreferenceClickListener { private lateinit var basemapSourcePref: ListPreference - private var referenceLayerPref: CaptionedListPreference? = null - private var autoShowReferenceLayerDialog = false - @Inject lateinit var referenceLayerRepository: ReferenceLayerRepository - override fun onDisplayPreferenceDialog(preference: Preference) { - if (allowClick(javaClass.name)) { - var dialogFragment: DialogFragment? = null - if (preference is CaptionedListPreference) { - dialogFragment = ReferenceLayerPreferenceDialog.newInstance(preference.getKey()) - } else { - super.onDisplayPreferenceDialog(preference) + @Inject + lateinit var scheduler: Scheduler + + @Inject + lateinit var externalWebPageHelper: ExternalWebPageHelper + + override fun onAttach(context: Context) { + super.onAttach(context) + DaggerUtils.getComponent(context).inject(this) + } + + override fun onCreate(savedInstanceState: Bundle?) { + childFragmentManager.fragmentFactory = FragmentFactoryBuilder() + .forClass(OfflineMapLayersPicker::class) { + OfflineMapLayersPicker(requireActivity().activityResultRegistry, referenceLayerRepository, scheduler, settingsProvider, externalWebPageHelper) } - if (dialogFragment != null) { - dialogFragment.setTargetFragment(this, 0) - dialogFragment.show( - parentFragmentManager, - ReferenceLayerPreferenceDialog::class.java.name - ) + .build() + + super.onCreate(savedInstanceState) + } + + override fun onSettingChanged(key: String) { + super.onSettingChanged(key) + if (key == ProjectKeys.KEY_REFERENCE_LAYER) { + findPreference(ProjectKeys.KEY_REFERENCE_LAYER)!!.summary = getLayerName() + } else if (key == KEY_BASEMAP_SOURCE) { + val cftor = MapConfiguratorProvider.getConfigurator(settingsProvider.getUnprotectedSettings().getString(key)) + if (!cftor.isAvailable(requireContext())) { + cftor.showUnavailableMessage(requireContext()) + } else { + onBasemapSourceChanged(cftor) } } } @@ -67,31 +82,22 @@ class MapsPreferencesFragment : BaseProjectPreferencesFragment() { super.onCreatePreferences(savedInstanceState, rootKey) setPreferencesFromResource(R.xml.maps_preferences, rootKey) initBasemapSourcePref() - initReferenceLayerPref() - if (autoShowReferenceLayerDialog) { - populateReferenceLayerPref(requireContext(), referenceLayerRepository, referenceLayerPref!!) - /** Opens the dialog programmatically, rather than by a click from the user. */ - onDisplayPreferenceDialog( - preferenceManager.findPreference("reference_layer")!! - ) - } + initLayersPref() } - override fun onAttach(context: Context) { - super.onAttach(context) - DaggerUtils.getComponent(context).inject(this) - } - - override fun onViewStateRestored(savedInstanceState: Bundle?) { - super.onViewStateRestored(savedInstanceState) - if (referenceLayerPref != null) { - populateReferenceLayerPref(requireContext(), referenceLayerRepository, referenceLayerPref!!) + override fun onPreferenceClick(preference: Preference): Boolean { + if (allowClick(javaClass.name)) { + when (preference.key) { + ProjectKeys.KEY_REFERENCE_LAYER -> { + DialogFragmentUtils.showIfNotShowing( + OfflineMapLayersPicker::class.java, + childFragmentManager + ) + } + } + return true } - } - - override fun onDestroyView() { - super.onDestroyView() - referenceLayerPref = null + return false } /** @@ -110,15 +116,21 @@ class MapsPreferencesFragment : BaseProjectPreferencesFragment() { basemapSourcePref.setIconSpaceReserved(false) onBasemapSourceChanged(MapConfiguratorProvider.getConfigurator()) - basemapSourcePref.setOnPreferenceChangeListener { _: Preference?, value: Any -> - val cftor = MapConfiguratorProvider.getConfigurator(value.toString()) - if (!cftor.isAvailable(context)) { - cftor.showUnavailableMessage(context) - false - } else { - onBasemapSourceChanged(cftor) - true - } + } + + private fun initLayersPref() { + findPreference(ProjectKeys.KEY_REFERENCE_LAYER)?.apply { + onPreferenceClickListener = this@MapsPreferencesFragment + summary = getLayerName() + } + } + + private fun getLayerName(): String { + val layerId = settingsProvider.getUnprotectedSettings().getString(ProjectKeys.KEY_REFERENCE_LAYER) + return if (layerId == null) { + requireContext().getLocalizedString(org.odk.collect.strings.R.string.none) + } else { + referenceLayerRepository.get(layerId)!!.name } } @@ -128,86 +140,18 @@ class MapsPreferencesFragment : BaseProjectPreferencesFragment() { val baseCategory = findPreference(CATEGORY_BASEMAP) baseCategory!!.removeAll() baseCategory.addPreference(basemapSourcePref) - for (pref in cftor.createPrefs(context, settingsProvider.getUnprotectedSettings())) { + for (pref in cftor.createPrefs(requireContext(), settingsProvider.getUnprotectedSettings())) { pref.isIconSpaceReserved = false baseCategory.addPreference(pref) } - // Clear the reference layer if it isn't supported by the new basemap. - if (referenceLayerPref != null) { - val path = referenceLayerPref!!.value - if (path != null && !cftor.supportsLayer(File(path))) { - referenceLayerPref!!.value = null - updateReferenceLayerSummary(null) - } - } - } - - /** Sets up listeners for the Reference Layer preference widget. */ - private fun initReferenceLayerPref() { - referenceLayerPref = findPreference("reference_layer") - referenceLayerPref!!.onPreferenceClickListener = - Preference.OnPreferenceClickListener { preference: Preference? -> - populateReferenceLayerPref(requireContext(), referenceLayerRepository, referenceLayerPref!!) - false - } - if (referenceLayerPref!!.value == null || referenceLayerRepository.get( - referenceLayerPref!!.value - ) != null - ) { - updateReferenceLayerSummary(referenceLayerPref!!.value) - } else { - referenceLayerPref!!.value = null - updateReferenceLayerSummary(null) - } - referenceLayerPref!!.onPreferenceChangeListener = - Preference.OnPreferenceChangeListener { preference: Preference?, newValue: Any? -> - updateReferenceLayerSummary(newValue) - val dialogFragment = parentFragmentManager.findFragmentByTag( - ReferenceLayerPreferenceDialog::class.java.name - ) as DialogFragment? - dialogFragment?.dismiss() - true + // Clear the reference layer if it does not exist or it isn't supported by the new basemap. + val layerId = settingsProvider.getUnprotectedSettings().getString(ProjectKeys.KEY_REFERENCE_LAYER) + if (layerId != null) { + val layer = referenceLayerRepository.get(layerId) + if (layer == null) { + settingsProvider.getUnprotectedSettings().save(ProjectKeys.KEY_REFERENCE_LAYER, null) } - } - - /** Sets the summary text for the reference layer to show the selected file. */ - private fun updateReferenceLayerSummary(value: Any?) { - if (referenceLayerPref != null) { - val summary: String = if (value == null) { - getString(org.odk.collect.strings.R.string.none) - } else { - val referenceLayer = referenceLayerRepository.get(value.toString()) - - if (referenceLayer != null) { - val path = referenceLayer.file.absolutePath - val cftor = MapConfiguratorProvider.getConfigurator() - cftor.getDisplayName(File(path)) - } else { - getString(org.odk.collect.strings.R.string.none) - } - } - - referenceLayerPref!!.summary = summary - } - } - - companion object { - - /** Pops up the preference dialog that lets the user choose a reference layer. */ - @JvmStatic - fun showReferenceLayerDialog(activity: FragmentActivity) { - // Unfortunately, the Preference class is designed so that it is impossible - // to just open a preference dialog without building a PreferenceFragment - // and attaching it to an activity. So, we instantiate a MapsPreference - // fragment that is configured to immediately open the dialog when it's - // attached, then instantiate it and attach it. - val prefs = MapsPreferencesFragment() - prefs.autoShowReferenceLayerDialog = true // makes dialog open immediately - activity.supportFragmentManager - .beginTransaction() - .add(prefs, null) - .commit() } } } diff --git a/collect_app/src/main/java/org/odk/collect/android/preferences/screens/ReferenceLayerPreferenceUtils.kt b/collect_app/src/main/java/org/odk/collect/android/preferences/screens/ReferenceLayerPreferenceUtils.kt deleted file mode 100644 index fd19222e3fb..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/preferences/screens/ReferenceLayerPreferenceUtils.kt +++ /dev/null @@ -1,76 +0,0 @@ -package org.odk.collect.android.preferences.screens - -import android.content.Context -import org.odk.collect.android.R -import org.odk.collect.android.geo.MapConfiguratorProvider -import org.odk.collect.android.preferences.CaptionedListPreference -import org.odk.collect.android.utilities.FileUtils -import org.odk.collect.maps.MapConfigurator -import org.odk.collect.maps.layers.ReferenceLayer -import org.odk.collect.maps.layers.ReferenceLayerRepository -import java.io.File -import java.util.Collections - -object ReferenceLayerPreferenceUtils { - - /** Sets up the contents of the reference layer selection dialog. */ - fun populateReferenceLayerPref( - context: Context, - referenceLayerRepository: ReferenceLayerRepository, - referenceLayerPref: CaptionedListPreference - ) { - val cftor = MapConfiguratorProvider.getConfigurator() - val items: MutableList = ArrayList() - items.add(CaptionedListPreference.Item(null, context.getString(org.odk.collect.strings.R.string.none), "")) - val supportedLayerFiles = getSupportedLayerFiles(cftor, referenceLayerRepository) - - for ((id, file) in supportedLayerFiles) { - val path = FileUtils.expandAndroidStoragePath(file.path) - val name = cftor.getDisplayName(File(file.absolutePath)) - items.add(CaptionedListPreference.Item(id, name, path)) - } - - // Sort by display name, then by path for files with identical names. - Collections.sort(items) { a: CaptionedListPreference.Item, b: CaptionedListPreference.Item -> - if (a.value == null != (b.value == null)) { - // one or the other is null - return@sort if (a.value == null) -1 else 1 - } - - if (!a.label.equals(b.label, ignoreCase = true)) { - return@sort a.label.compareTo(b.label, ignoreCase = true) - } - - if (a.label != b.label) { - return@sort a.label.compareTo(b.label) - } - - if (a.value != null && b.value != null) { - return@sort FileUtils.comparePaths(a.value, b.value) - } - - 0 // both a.value and b.value are null - } - - if (referenceLayerPref.value != null && referenceLayerRepository.get(referenceLayerPref.value) == null) { - referenceLayerPref.value = null - } - - referenceLayerPref.setItems(items) - referenceLayerPref.updateContent() - } - - /** Gets the list of layer data files supported by the current MapConfigurator. */ - private fun getSupportedLayerFiles( - cftor: MapConfigurator, - referenceLayerRepository: ReferenceLayerRepository - ): List { - val supportedLayers: MutableList = ArrayList() - for (layer in referenceLayerRepository.getAll()) { - if (cftor.supportsLayer(layer.file)) { - supportedLayers.add(layer) - } - } - return supportedLayers - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/preferences/utilities/FormUpdateMode.java b/collect_app/src/main/java/org/odk/collect/android/preferences/utilities/FormUpdateMode.java deleted file mode 100644 index dc8fe64960a..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/preferences/utilities/FormUpdateMode.java +++ /dev/null @@ -1,30 +0,0 @@ -package org.odk.collect.android.preferences.utilities; - -import android.content.Context; - -public enum FormUpdateMode { - - MANUAL(org.odk.collect.strings.R.string.form_update_mode_manual), - PREVIOUSLY_DOWNLOADED_ONLY(org.odk.collect.strings.R.string.form_update_mode_previously_downloaded), - MATCH_EXACTLY(org.odk.collect.strings.R.string.form_update_mode_match_exactly); - - private final int string; - - FormUpdateMode(int string) { - this.string = string; - } - - public static FormUpdateMode parse(Context context, String value) { - if (MATCH_EXACTLY.getValue(context).equals(value)) { - return MATCH_EXACTLY; - } else if (PREVIOUSLY_DOWNLOADED_ONLY.getValue(context).equals(value)) { - return PREVIOUSLY_DOWNLOADED_ONLY; - } else { - return MANUAL; - } - } - - public String getValue(Context context) { - return context.getString(string); - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/preferences/utilities/SettingsUtils.java b/collect_app/src/main/java/org/odk/collect/android/preferences/utilities/SettingsUtils.java deleted file mode 100644 index ace8521a618..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/preferences/utilities/SettingsUtils.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.odk.collect.android.preferences.utilities; - -import android.content.Context; - -import androidx.annotation.NonNull; - -import org.odk.collect.settings.keys.ProjectKeys; -import org.odk.collect.shared.settings.Settings; - -public final class SettingsUtils { - - private SettingsUtils() { - - } - - @NonNull - public static FormUpdateMode getFormUpdateMode(Context context, Settings generalSettings) { - String mode = generalSettings.getString(ProjectKeys.KEY_FORM_UPDATE_MODE); - return FormUpdateMode.parse(context, mode); - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/projects/ProjectDependencyProvider.kt b/collect_app/src/main/java/org/odk/collect/android/projects/ProjectDependencyProvider.kt index 5467f90bd09..65e34b3030d 100644 --- a/collect_app/src/main/java/org/odk/collect/android/projects/ProjectDependencyProvider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/projects/ProjectDependencyProvider.kt @@ -1,11 +1,13 @@ package org.odk.collect.android.projects +import org.odk.collect.android.entities.EntitiesRepositoryProvider import org.odk.collect.android.formmanagement.FormSourceProvider import org.odk.collect.android.storage.StoragePathProvider import org.odk.collect.android.storage.StorageSubdirectory import org.odk.collect.android.utilities.ChangeLockProvider import org.odk.collect.android.utilities.FormsRepositoryProvider import org.odk.collect.android.utilities.InstancesRepositoryProvider +import org.odk.collect.android.utilities.SavepointsRepositoryProvider import org.odk.collect.settings.SettingsProvider /** @@ -19,15 +21,20 @@ data class ProjectDependencyProvider( val instancesRepositoryProvider: InstancesRepositoryProvider, val storagePathProvider: StoragePathProvider, val changeLockProvider: ChangeLockProvider, - val formSourceProvider: FormSourceProvider + val formSourceProvider: FormSourceProvider, + val savepointsRepositoryProvider: SavepointsRepositoryProvider, + val entitiesRepositoryProvider: EntitiesRepositoryProvider ) { val generalSettings by lazy { settingsProvider.getUnprotectedSettings(projectId) } val formsRepository by lazy { formsRepositoryProvider.get(projectId) } val instancesRepository by lazy { instancesRepositoryProvider.get(projectId) } val formSource by lazy { formSourceProvider.get(projectId) } val formsLock by lazy { changeLockProvider.getFormLock(projectId) } + val instancesLock by lazy { changeLockProvider.getInstanceLock(projectId) } val formsDir by lazy { storagePathProvider.getOdkDirPath(StorageSubdirectory.FORMS, projectId) } val cacheDir by lazy { storagePathProvider.getOdkDirPath(StorageSubdirectory.CACHE, projectId) } + val entitiesRepository by lazy { entitiesRepositoryProvider.get(projectId) } + val savepointsRepository by lazy { savepointsRepositoryProvider.get(projectId) } } class ProjectDependencyProviderFactory( @@ -36,7 +43,9 @@ class ProjectDependencyProviderFactory( private val instancesRepositoryProvider: InstancesRepositoryProvider, private val storagePathProvider: StoragePathProvider, private val changeLockProvider: ChangeLockProvider, - private val formSourceProvider: FormSourceProvider + private val formSourceProvider: FormSourceProvider, + private val savepointsRepositoryProvider: SavepointsRepositoryProvider, + private val entitiesRepositoryProvider: EntitiesRepositoryProvider, ) { fun create(projectId: String) = ProjectDependencyProvider( projectId, @@ -45,6 +54,8 @@ class ProjectDependencyProviderFactory( instancesRepositoryProvider, storagePathProvider, changeLockProvider, - formSourceProvider + formSourceProvider, + savepointsRepositoryProvider, + entitiesRepositoryProvider ) } diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/ProjectResetter.kt b/collect_app/src/main/java/org/odk/collect/android/projects/ProjectResetter.kt similarity index 85% rename from collect_app/src/main/java/org/odk/collect/android/utilities/ProjectResetter.kt rename to collect_app/src/main/java/org/odk/collect/android/projects/ProjectResetter.kt index f9d9e1d8f22..2f5104adc3f 100644 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/ProjectResetter.kt +++ b/collect_app/src/main/java/org/odk/collect/android/projects/ProjectResetter.kt @@ -13,11 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.odk.collect.android.utilities +package org.odk.collect.android.projects import org.odk.collect.android.fastexternalitemset.ItemsetDbAdapter +import org.odk.collect.android.instancemanagement.InstancesDataService import org.odk.collect.android.storage.StoragePathProvider import org.odk.collect.android.storage.StorageSubdirectory +import org.odk.collect.android.utilities.FormsRepositoryProvider +import org.odk.collect.android.utilities.SavepointsRepositoryProvider +import org.odk.collect.android.utilities.WebCredentialsUtils import org.odk.collect.metadata.PropertyManager import org.odk.collect.settings.SettingsProvider import java.io.File @@ -26,8 +30,10 @@ class ProjectResetter( private val storagePathProvider: StoragePathProvider, private val propertyManager: PropertyManager, private val settingsProvider: SettingsProvider, - private val instancesRepositoryProvider: InstancesRepositoryProvider, - private val formsRepositoryProvider: FormsRepositoryProvider + private val formsRepositoryProvider: FormsRepositoryProvider, + private val savepointsRepositoryProvider: SavepointsRepositoryProvider, + private val instancesDataService: InstancesDataService, + private val projectId: String ) { private var failedResetActions = mutableListOf() @@ -61,9 +67,8 @@ class ProjectResetter( } private fun resetInstances() { - instancesRepositoryProvider.get().deleteAll() - - if (!deleteFolderContent(storagePathProvider.getOdkDirPath(StorageSubdirectory.INSTANCES))) { + if (!instancesDataService.deleteAll(projectId) || + !deleteFolderContent(storagePathProvider.getOdkDirPath(StorageSubdirectory.INSTANCES))) { failedResetActions.add(ResetAction.RESET_INSTANCES) } } @@ -85,6 +90,7 @@ class ProjectResetter( } private fun resetCache() { + savepointsRepositoryProvider.get().deleteAll() if (!deleteFolderContent(storagePathProvider.getOdkDirPath(StorageSubdirectory.CACHE))) { failedResetActions.add(ResetAction.RESET_CACHE) } diff --git a/collect_app/src/main/java/org/odk/collect/android/savepoints/SavepointTask.kt b/collect_app/src/main/java/org/odk/collect/android/savepoints/SavepointTask.kt new file mode 100644 index 00000000000..9eca0146b2f --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/savepoints/SavepointTask.kt @@ -0,0 +1,64 @@ +package org.odk.collect.android.savepoints + +import org.odk.collect.android.javarosawrapper.FormController +import org.odk.collect.async.Scheduler +import org.odk.collect.async.SchedulerAsyncTaskMimic +import org.odk.collect.forms.savepoints.Savepoint +import org.odk.collect.forms.savepoints.SavepointsRepository +import org.odk.collect.shared.files.FileUtils +import timber.log.Timber +import java.io.File + +class SavepointTask( + private var listener: SavepointListener?, + private val formController: FormController, + private val formDbId: Long, + private val instanceDbId: Long?, + private val cacheDir: String, + private val savepointsRepository: SavepointsRepository, + val scheduler: Scheduler +) : SchedulerAsyncTaskMimic(scheduler) { + private var priority: Int = ++lastPriorityUsed + + override fun onPreExecute() = Unit + + override fun doInBackground(vararg params: Unit): String? { + if (priority < lastPriorityUsed) { + return null + } + + return try { + val savepointFile = File(cacheDir, "${formController.getInstanceFile()!!.name}.save") + val savepoint = Savepoint(formDbId, instanceDbId, savepointFile.absolutePath, formController.getInstanceFile()!!.absolutePath) + + if (priority == lastPriorityUsed) { + FileUtils.saveToFile(formController.getFilledInFormXml().payloadStream, savepointFile.absolutePath) + savepointsRepository.save(savepoint) + } + + null + } catch (e: Exception) { + Timber.e(e.message) + e.message + } + } + + override fun onPostExecute(result: String?) { + if (result != null) { + listener?.onSavePointError(result) + listener = null + } + } + + override fun onProgressUpdate(vararg values: Unit) = Unit + + override fun onCancelled() = Unit + + companion object { + private var lastPriorityUsed: Int = 0 + } +} + +interface SavepointListener { + fun onSavePointError(errorMessage: String?) +} diff --git a/collect_app/src/main/java/org/odk/collect/android/savepoints/SavepointUseCases.kt b/collect_app/src/main/java/org/odk/collect/android/savepoints/SavepointUseCases.kt new file mode 100644 index 00000000000..34201781557 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/savepoints/SavepointUseCases.kt @@ -0,0 +1,48 @@ +package org.odk.collect.android.savepoints + +import android.net.Uri +import org.odk.collect.android.external.FormsContract +import org.odk.collect.android.utilities.ContentUriHelper +import org.odk.collect.forms.FormsRepository +import org.odk.collect.forms.instances.InstancesRepository +import org.odk.collect.forms.savepoints.Savepoint +import org.odk.collect.forms.savepoints.SavepointsRepository +import java.io.File + +object SavepointUseCases { + fun getSavepoint( + uri: Uri, + uriMimeType: String, + formsRepository: FormsRepository, + instanceRepository: InstancesRepository, + savepointsRepository: SavepointsRepository + ): Savepoint? { + return if (uriMimeType == FormsContract.CONTENT_ITEM_TYPE) { + val selectedForm = formsRepository.get(ContentUriHelper.getIdFromUri(uri))!! + + formsRepository.getAllByFormId(selectedForm.formId) + .filter { it.date <= selectedForm.date } + .sortedByDescending { it.date } + .forEach { form -> + val savepoint = savepointsRepository.get(form.dbId, null) + if (savepoint != null && File(savepoint.savepointFilePath).exists()) { + return savepoint + } + } + null + } else { + val instance = instanceRepository.get(ContentUriHelper.getIdFromUri(uri))!! + val form = formsRepository.getLatestByFormIdAndVersion(instance.formId, instance.formVersion)!! + + val savepoint = savepointsRepository.get(form.dbId, instance.dbId) + if (savepoint != null && + File(savepoint.savepointFilePath).exists() && + File(savepoint.savepointFilePath).lastModified() > instance.lastStatusChangeDate + ) { + savepoint + } else { + null + } + } + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/tasks/DeleteInstancesTask.java b/collect_app/src/main/java/org/odk/collect/android/tasks/DeleteInstancesTask.java deleted file mode 100644 index 03b057a3f8e..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/tasks/DeleteInstancesTask.java +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright (C) 2012 University of Washington - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the License - * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing permissions and limitations under - * the License. - */ - -package org.odk.collect.android.tasks; - -import android.os.AsyncTask; - -import org.odk.collect.android.instancemanagement.InstanceDeleter; -import org.odk.collect.forms.instances.InstancesRepository; -import org.odk.collect.android.listeners.DeleteInstancesListener; -import org.odk.collect.forms.FormsRepository; - -import timber.log.Timber; - -/** - * Task responsible for deleting selected instances. - * - * @author norman86@gmail.com - * @author mitchellsundt@gmail.com - */ -public class DeleteInstancesTask extends AsyncTask { - - private DeleteInstancesListener deleteInstancesListener; - - private int successCount; - private int toDeleteCount; - - private final InstancesRepository instancesRepository; - private final FormsRepository formsRepository; - - public DeleteInstancesTask(InstancesRepository instancesRepository, FormsRepository formsRepository) { - this.instancesRepository = instancesRepository; - this.formsRepository = formsRepository; - } - - @Override - protected Integer doInBackground(Long... params) { - int deleted = 0; - - if (params == null) { - return deleted; - } - - toDeleteCount = params.length; - - InstanceDeleter instanceDeleter = new InstanceDeleter(instancesRepository, formsRepository); - // delete files from database and then from file system - for (Long param : params) { - if (isCancelled()) { - break; - } - try { - instanceDeleter.delete(param); - deleted++; - - successCount++; - publishProgress(successCount, toDeleteCount); - - } catch (Exception ex) { - Timber.e(new Error("Exception during delete of: " + param.toString() + " exception: " + ex)); - } - } - successCount = deleted; - return deleted; - } - - @Override - protected void onProgressUpdate(Integer... values) { - synchronized (this) { - if (deleteInstancesListener != null) { - deleteInstancesListener.progressUpdate(values[0], values[1]); - } - } - } - - @Override - protected void onPostExecute(Integer result) { - if (deleteInstancesListener != null) { - deleteInstancesListener.deleteComplete(result); - } - super.onPostExecute(result); - } - - @Override - protected void onCancelled() { - if (deleteInstancesListener != null) { - deleteInstancesListener.deleteComplete(successCount); - } - } - - public void setDeleteListener(DeleteInstancesListener listener) { - deleteInstancesListener = listener; - } - - public int getDeleteCount() { - return successCount; - } - - public int getToDeleteCount() { - return toDeleteCount; - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/tasks/DownloadFormsTask.java b/collect_app/src/main/java/org/odk/collect/android/tasks/DownloadFormsTask.java index 210a275c60f..30b1460e453 100644 --- a/collect_app/src/main/java/org/odk/collect/android/tasks/DownloadFormsTask.java +++ b/collect_app/src/main/java/org/odk/collect/android/tasks/DownloadFormsTask.java @@ -15,19 +15,16 @@ package org.odk.collect.android.tasks; import static org.odk.collect.strings.localization.LocalizedApplicationKt.getLocalizedString; -import static java.util.Collections.emptyMap; import android.os.AsyncTask; import org.odk.collect.android.application.Collect; -import org.odk.collect.android.formmanagement.FormDownloadException; -import org.odk.collect.android.formmanagement.FormDownloader; import org.odk.collect.android.formmanagement.FormsDataService; import org.odk.collect.android.formmanagement.ServerFormDetails; +import org.odk.collect.android.formmanagement.download.FormDownloadException; import org.odk.collect.android.listeners.DownloadFormsTaskListener; import java.util.ArrayList; -import java.util.HashMap; import java.util.Map; /** @@ -43,45 +40,28 @@ public class DownloadFormsTask extends AsyncTask, String, Map> { - private final FormDownloader formDownloader; + private final String projectId; + private final FormsDataService formsDataService; private DownloadFormsTaskListener stateListener; - public DownloadFormsTask(FormDownloader formDownloader) { - this.formDownloader = formDownloader; + public DownloadFormsTask(String projectId, FormsDataService formsDataService) { + this.projectId = projectId; + this.formsDataService = formsDataService; } @Override protected Map doInBackground(ArrayList... values) { - HashMap results = new HashMap<>(); - - int index = 1; - for (ServerFormDetails serverFormDetails : values[0]) { - try { - String currentFormNumber = String.valueOf(index); - String totalForms = String.valueOf(values[0].size()); - publishProgress(serverFormDetails.getFormName(), currentFormNumber, totalForms); - - formDownloader.downloadForm(serverFormDetails, count -> { - String message = getLocalizedString(Collect.getInstance(), org.odk.collect.strings.R.string.form_download_progress, - serverFormDetails.getFormName(), - String.valueOf(count), - String.valueOf(serverFormDetails.getManifest().getMediaFiles().size()) - ); - - publishProgress(message, currentFormNumber, totalForms); - }, this::isCancelled); - - results.put(serverFormDetails, null); - } catch (FormDownloadException.DownloadingInterrupted e) { - return emptyMap(); - } catch (FormDownloadException e) { - results.put(serverFormDetails, e); - } - - index++; - } - - return results; + return formsDataService.downloadForms(projectId, values[0], (index, count) -> { + ServerFormDetails serverFormDetails = values[0].get(index); + String message = getLocalizedString(Collect.getInstance(), org.odk.collect.strings.R.string.form_download_progress, + serverFormDetails.getFormName(), + String.valueOf(count), + String.valueOf(serverFormDetails.getManifest().getMediaFiles().size()) + ); + + publishProgress(message, String.valueOf(index), String.valueOf(values[0].size())); + return null; + }, this::isCancelled); } @Override diff --git a/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java b/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java index 14153ea8870..ef9d2cedcd8 100644 --- a/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java +++ b/collect_app/src/main/java/org/odk/collect/android/tasks/FormLoaderTask.java @@ -48,8 +48,6 @@ import org.odk.collect.android.javarosawrapper.FormController; import org.odk.collect.android.javarosawrapper.JavaRosaFormController; import org.odk.collect.android.listeners.FormLoaderListener; -import org.odk.collect.android.storage.StoragePathProvider; -import org.odk.collect.android.storage.StorageSubdirectory; import org.odk.collect.android.utilities.ContentUriHelper; import org.odk.collect.android.utilities.ExternalizableFormDefCache; import org.odk.collect.android.utilities.FileUtils; @@ -60,6 +58,8 @@ import org.odk.collect.async.SchedulerAsyncTaskMimic; import org.odk.collect.forms.Form; import org.odk.collect.forms.instances.Instance; +import org.odk.collect.forms.savepoints.Savepoint; +import org.odk.collect.forms.savepoints.SavepointsRepository; import org.odk.collect.shared.strings.Md5; import java.io.File; @@ -97,6 +97,8 @@ public class FormLoaderTask extends SchedulerAsyncTaskMimic candidateForms = new FormsRepositoryProvider(Collect.getInstance()).get().getAllByFormIdAndVersion(instance.getFormId(), instance.getFormVersion()); form = candidateForms.get(0); + savepoint = savepointsRepository.get(form.getDbId(), instance.getDbId()); } else if (uriMimeType != null && uriMimeType.equals(FormsContract.CONTENT_ITEM_TYPE)) { form = new FormsRepositoryProvider(Collect.getInstance()).get().get(ContentUriHelper.getIdFromUri(uri)); if (form == null) { @@ -171,13 +175,8 @@ protected FECWrapper doInBackground(Void... ignored) { return null; } - /** - * This is the fill-blank-form code path.See if there is a savepoint for this form - * that has never been explicitly saved by the user. If there is, open this savepoint(resume this filled-in form). - * Savepoints for forms that were explicitly saved will be recovered when that - * explicitly saved instance is edited via edit-saved-form. - */ - instancePath = loadSavePoint(); + savepoint = savepointsRepository.get(form.getDbId(), null); + instancePath = savepoint != null ? savepoint.getInstanceFilePath() : null; } if (form.getFormFilePath() == null) { @@ -198,7 +197,7 @@ protected FECWrapper doInBackground(Void... ignored) { } catch (StackOverflowError e) { Timber.e(e); errorMsg = getLocalizedString(Collect.getInstance(), org.odk.collect.strings.R.string.too_complex_form); - } catch (Exception | Error e) { + } catch (Exception e) { Timber.w(e); errorMsg = "An unknown error has occurred. Please ask your project leadership to email support@kobotoolbox.org with information about this form."; errorMsg += "\n\n" + e.getMessage(); @@ -373,14 +372,11 @@ private boolean initializeForm(FormDef formDef, FormEntryController fec) throws if (instancePath != null) { File instanceXml = new File(instancePath); - // Use the savepoint file only if it's newer than the last manual save - final File savepointFile = SaveFormToDisk.getSavepointFile(instanceXml.getName()); - if (savepointFile.exists() - && savepointFile.lastModified() > instanceXml.lastModified()) { + if (savepoint != null) { + final File savepointFile = new File(savepoint.getSavepointFilePath()); usedSavepoint = true; instanceXml = savepointFile; - Timber.w("Loading instance from savepoint file: %s", - savepointFile.getAbsolutePath()); + Timber.w("Loading instance from savepoint file: %s", savepointFile.getAbsolutePath()); } if (instanceXml.exists()) { @@ -579,51 +575,6 @@ private void readCSV(File csv, String formHash, String pathHash) { } } - private String loadSavePoint() { - final String filePrefix = form.getFormFilePath().substring( - form.getFormFilePath().lastIndexOf('/') + 1, - form.getFormFilePath().lastIndexOf('.')) - + "_"; - final String fileSuffix = ".xml.save"; - File cacheDir = new File(new StoragePathProvider().getOdkDirPath(StorageSubdirectory.CACHE)); - File[] files = cacheDir.listFiles(pathname -> { - String name = pathname.getName(); - return name.startsWith(filePrefix) - && name.endsWith(fileSuffix); - }); - - if (files != null) { - /** - * See if any of these savepoints are for a filled-in form that has never - * been explicitly saved by the user. - */ - for (File candidate : files) { - String instanceDirName = candidate.getName() - .substring( - 0, - candidate.getName().length() - - fileSuffix.length()); - File instanceDir = new File( - new StoragePathProvider().getOdkDirPath(StorageSubdirectory.INSTANCES) + File.separator - + instanceDirName); - File instanceFile = new File(instanceDir, - instanceDirName + ".xml"); - if (instanceDir.exists() - && instanceDir.isDirectory() - && !instanceFile.exists()) { - // yes! -- use this savepoint file - return instanceFile - .getAbsolutePath(); - } - } - - } else { - Timber.e(new Error("Couldn't access cache directory when looking for save points!")); - } - - return null; - } - public FormDef getFormDef() { return formDef; } diff --git a/collect_app/src/main/java/org/odk/collect/android/tasks/InstanceServerUploaderTask.java b/collect_app/src/main/java/org/odk/collect/android/tasks/InstanceServerUploaderTask.java deleted file mode 100644 index ee40f8a059d..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/tasks/InstanceServerUploaderTask.java +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright (C) 2009 University of Washington - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the License - * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing permissions and limitations under - * the License. - */ - -package org.odk.collect.android.tasks; - -import org.odk.collect.analytics.Analytics; -import org.odk.collect.android.application.Collect; -import org.odk.collect.forms.instances.Instance; -import org.odk.collect.android.openrosa.OpenRosaHttpInterface; -import org.odk.collect.android.upload.InstanceServerUploader; -import org.odk.collect.android.upload.FormUploadAuthRequestedException; -import org.odk.collect.android.upload.FormUploadException; -import org.odk.collect.android.utilities.WebCredentialsUtils; -import org.odk.collect.metadata.PropertyManager; - -import java.util.List; - -import javax.inject.Inject; - -import static org.odk.collect.android.analytics.AnalyticsEvents.SUBMISSION; -import static org.odk.collect.strings.localization.LocalizedApplicationKt.getLocalizedString; - -/** - * Background task for uploading completed forms. - * - * @author Carl Hartung (carlhartung@gmail.com) - */ -public class InstanceServerUploaderTask extends InstanceUploaderTask { - @Inject - OpenRosaHttpInterface httpInterface; - - @Inject - WebCredentialsUtils webCredentialsUtils; - - @Inject - PropertyManager propertyManager; - - // Custom submission URL, username and password that can be sent via intent extras by external - // applications - private String completeDestinationUrl; - private String customUsername; - private String customPassword; - - public InstanceServerUploaderTask() { - Collect.getInstance().getComponent().inject(this); - } - - @Override - public Outcome doInBackground(Long... instanceIdsToUpload) { - Outcome outcome = new Outcome(); - - InstanceServerUploader uploader = new InstanceServerUploader(httpInterface, webCredentialsUtils, settingsProvider.getUnprotectedSettings()); - List instancesToUpload = uploader.getInstancesFromIds(instanceIdsToUpload); - - String deviceId = propertyManager.getSingularProperty(PropertyManager.PROPMGR_DEVICE_ID); - - for (int i = 0; i < instancesToUpload.size(); i++) { - if (isCancelled()) { - return outcome; - } - Instance instance = instancesToUpload.get(i); - - publishProgress(i + 1, instancesToUpload.size()); - - try { - String destinationUrl = uploader.getUrlToSubmitTo(instance, deviceId, completeDestinationUrl, null); - String customMessage = uploader.uploadOneSubmission(instance, destinationUrl); - outcome.messagesByInstanceId.put(instance.getDbId().toString(), - customMessage != null ? customMessage : getLocalizedString(Collect.getInstance(), org.odk.collect.strings.R.string.success)); - - Analytics.log(SUBMISSION, "HTTP", Collect.getFormIdentifierHash(instance.getFormId(), instance.getFormVersion())); - } catch (FormUploadAuthRequestedException e) { - outcome.authRequestingServer = e.getAuthRequestingServer(); - // Don't add the instance that caused an auth request to the map because we want to - // retry. Items present in the map are considered already attempted and won't be - // retried. - } catch (FormUploadException e) { - outcome.messagesByInstanceId.put(instance.getDbId().toString(), - e.getMessage()); - } - } - - return outcome; - } - - @Override - protected void onPostExecute(Outcome outcome) { - super.onPostExecute(outcome); - - // Clear temp credentials - clearTemporaryCredentials(); - } - - @Override - protected void onCancelled() { - clearTemporaryCredentials(); - } - - public void setCompleteDestinationUrl(String completeDestinationUrl) { - setCompleteDestinationUrl(completeDestinationUrl, true); - } - - public void setCompleteDestinationUrl(String completeDestinationUrl, boolean clearPreviousConfig) { - this.completeDestinationUrl = completeDestinationUrl; - if (clearPreviousConfig) { - setTemporaryCredentials(); - } - } - - public void setCustomUsername(String customUsername) { - this.customUsername = customUsername; - setTemporaryCredentials(); - } - - public void setCustomPassword(String customPassword) { - this.customPassword = customPassword; - setTemporaryCredentials(); - } - - private void setTemporaryCredentials() { - if (customUsername != null && customPassword != null) { - webCredentialsUtils.saveCredentials(completeDestinationUrl, customUsername, customPassword); - } else { - // In the case for anonymous logins, clear the previous credentials for that host - webCredentialsUtils.clearCredentials(completeDestinationUrl); - } - } - - private void clearTemporaryCredentials() { - if (customUsername != null && customPassword != null) { - webCredentialsUtils.clearCredentials(completeDestinationUrl); - } - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/tasks/InstanceUploaderTask.java b/collect_app/src/main/java/org/odk/collect/android/tasks/InstanceUploaderTask.java index d86b7861d72..f33aaad70a5 100644 --- a/collect_app/src/main/java/org/odk/collect/android/tasks/InstanceUploaderTask.java +++ b/collect_app/src/main/java/org/odk/collect/android/tasks/InstanceUploaderTask.java @@ -1,80 +1,153 @@ /* - * Copyright 2016 Nafundi + * Copyright (C) 2009 University of Washington * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. */ package org.odk.collect.android.tasks; +import static org.odk.collect.android.analytics.AnalyticsEvents.SUBMISSION; +import static org.odk.collect.strings.localization.LocalizedApplicationKt.getLocalizedString; + import android.net.Uri; import android.os.AsyncTask; +import org.odk.collect.analytics.Analytics; +import org.odk.collect.android.analytics.AnalyticsEvents; import org.odk.collect.android.application.Collect; +import org.odk.collect.android.instancemanagement.InstanceDeleter; +import org.odk.collect.android.instancemanagement.InstancesDataService; import org.odk.collect.android.listeners.InstanceUploaderListener; +import org.odk.collect.android.openrosa.OpenRosaHttpInterface; +import org.odk.collect.android.projects.ProjectsDataService; +import org.odk.collect.android.upload.FormUploadAuthRequestedException; +import org.odk.collect.android.upload.FormUploadException; +import org.odk.collect.android.upload.InstanceServerUploader; import org.odk.collect.android.utilities.InstanceAutoDeleteChecker; import org.odk.collect.android.utilities.InstancesRepositoryProvider; +import org.odk.collect.android.utilities.WebCredentialsUtils; import org.odk.collect.forms.FormsRepository; import org.odk.collect.forms.instances.Instance; import org.odk.collect.forms.instances.InstancesRepository; +import org.odk.collect.metadata.PropertyManager; import org.odk.collect.settings.SettingsProvider; import org.odk.collect.settings.keys.ProjectKeys; import java.util.HashMap; +import java.util.List; import java.util.Set; import java.util.stream.Stream; -public abstract class InstanceUploaderTask extends AsyncTask { +import javax.inject.Inject; + +/** + * Background task for uploading completed forms. + * + * @author Carl Hartung (carlhartung@gmail.com) + */ +public class InstanceUploaderTask extends AsyncTask { + @Inject + OpenRosaHttpInterface httpInterface; + + @Inject + WebCredentialsUtils webCredentialsUtils; + + @Inject + PropertyManager propertyManager; + + @Inject + InstancesDataService instancesDataService; + + @Inject + ProjectsDataService projectsDataService; + // Custom submission URL, username and password that can be sent via intent extras by external + // applications + private String completeDestinationUrl; + private String customUsername; + private String customPassword; private InstancesRepository instancesRepository; private FormsRepository formsRepository; - protected SettingsProvider settingsProvider; + private SettingsProvider settingsProvider; private InstanceUploaderListener stateListener; private Boolean deleteInstanceAfterSubmission; + public InstanceUploaderTask() { + Collect.getInstance().getComponent().inject(this); + } + @Override - protected void onPostExecute(Outcome outcome) { - synchronized (this) { - if (outcome != null && stateListener != null) { - if (outcome.authRequestingServer != null) { - stateListener.authRequest(outcome.authRequestingServer, outcome.messagesByInstanceId); - } else { - stateListener.uploadingComplete(outcome.messagesByInstanceId); + public Outcome doInBackground(Long... instanceIdsToUpload) { + Outcome outcome = new Outcome(); - // Delete instances that were successfully sent and that need to be deleted - // either because app-level auto-delete is enabled or because the form - // specifies it. - Set instanceIds = outcome.messagesByInstanceId.keySet(); + InstanceServerUploader uploader = new InstanceServerUploader(httpInterface, webCredentialsUtils, settingsProvider.getUnprotectedSettings(), instancesRepository); + List instancesToUpload = uploader.getInstancesFromIds(instanceIdsToUpload); - boolean isFormAutoDeleteOptionEnabled; + String deviceId = propertyManager.getSingularProperty(PropertyManager.PROPMGR_DEVICE_ID); - // The custom configuration from the third party app overrides - // the app preferences set for delete after submission - if (deleteInstanceAfterSubmission != null) { - isFormAutoDeleteOptionEnabled = deleteInstanceAfterSubmission; - } else { - isFormAutoDeleteOptionEnabled = settingsProvider.getUnprotectedSettings().getBoolean(ProjectKeys.KEY_DELETE_AFTER_SEND); - } + for (int i = 0; i < instancesToUpload.size(); i++) { + if (isCancelled()) { + return outcome; + } + Instance instance = instancesToUpload.get(i); - Stream instancesToDelete = instanceIds.stream() - .map(id -> new InstancesRepositoryProvider(Collect.getInstance()).get().get(Long.parseLong(id))) - .filter(instance -> instance.getStatus().equals(Instance.STATUS_SUBMITTED)) - .filter(instance -> InstanceAutoDeleteChecker.shouldInstanceBeDeleted(formsRepository, isFormAutoDeleteOptionEnabled, instance)); + publishProgress(i + 1, instancesToUpload.size()); - DeleteInstancesTask dit = new DeleteInstancesTask(instancesRepository, formsRepository); - dit.execute(instancesToDelete.map(Instance::getDbId).toArray(Long[]::new)); - } + if (completeDestinationUrl != null) { + Analytics.log(AnalyticsEvents.INSTANCE_UPLOAD_CUSTOM_SERVER); + } + + try { + String destinationUrl = uploader.getUrlToSubmitTo(instance, deviceId, completeDestinationUrl, null); + String customMessage = uploader.uploadOneSubmission(instance, destinationUrl); + outcome.messagesByInstanceId.put(instance.getDbId().toString(), + customMessage != null ? customMessage : getLocalizedString(Collect.getInstance(), org.odk.collect.strings.R.string.success)); + + Analytics.log(SUBMISSION, "HTTP", Collect.getFormIdentifierHash(instance.getFormId(), instance.getFormVersion())); + } catch (FormUploadAuthRequestedException e) { + outcome.authRequestingServer = e.getAuthRequestingServer(); + // Don't add the instance that caused an auth request to the map because we want to + // retry. Items present in the map are considered already attempted and won't be + // retried. + } catch (FormUploadException e) { + outcome.messagesByInstanceId.put(instance.getDbId().toString(), + e.getMessage()); } } + + // Delete instances that were successfully sent and that need to be deleted + // either because app-level auto-delete is enabled or because the form + // specifies it. + Set instanceIds = outcome.messagesByInstanceId.keySet(); + + boolean isFormAutoDeleteOptionEnabled; + + // The custom configuration from the third party app overrides + // the app preferences set for delete after submission + if (deleteInstanceAfterSubmission != null) { + isFormAutoDeleteOptionEnabled = deleteInstanceAfterSubmission; + } else { + isFormAutoDeleteOptionEnabled = settingsProvider.getUnprotectedSettings().getBoolean(ProjectKeys.KEY_DELETE_AFTER_SEND); + } + + Stream instancesToDelete = instanceIds.stream() + .map(id -> new InstancesRepositoryProvider(Collect.getInstance()).get().get(Long.parseLong(id))) + .filter(instance -> instance.getStatus().equals(Instance.STATUS_SUBMITTED)) + .filter(instance -> InstanceAutoDeleteChecker.shouldInstanceBeDeleted(formsRepository, isFormAutoDeleteOptionEnabled, instance)); + + InstanceDeleter instanceDeleter = new InstanceDeleter(instancesRepository, formsRepository); + instanceDeleter.delete(instancesToDelete.map(Instance::getDbId).toArray(Long[]::new)); + + instancesDataService.update(projectsDataService.getCurrentProject().getUuid()); + return outcome; } @Override @@ -86,6 +159,27 @@ protected void onProgressUpdate(Integer... values) { } } + @Override + protected void onPostExecute(Outcome outcome) { + synchronized (this) { + if (outcome != null && stateListener != null) { + if (outcome.authRequestingServer != null) { + stateListener.authRequest(outcome.authRequestingServer, outcome.messagesByInstanceId); + } else { + stateListener.uploadingComplete(outcome.messagesByInstanceId); + } + } + } + + // Clear temp credentials + clearTemporaryCredentials(); + } + + @Override + protected void onCancelled() { + clearTemporaryCredentials(); + } + public void setUploaderListener(InstanceUploaderListener sl) { synchronized (this) { stateListener = sl; @@ -102,6 +196,42 @@ public void setRepositories(InstancesRepository instancesRepository, FormsReposi this.settingsProvider = settingsProvider; } + public void setCompleteDestinationUrl(String completeDestinationUrl) { + setCompleteDestinationUrl(completeDestinationUrl, true); + } + + public void setCompleteDestinationUrl(String completeDestinationUrl, boolean clearPreviousConfig) { + this.completeDestinationUrl = completeDestinationUrl; + if (clearPreviousConfig) { + setTemporaryCredentials(); + } + } + + public void setCustomUsername(String customUsername) { + this.customUsername = customUsername; + setTemporaryCredentials(); + } + + public void setCustomPassword(String customPassword) { + this.customPassword = customPassword; + setTemporaryCredentials(); + } + + private void setTemporaryCredentials() { + if (customUsername != null && customPassword != null) { + webCredentialsUtils.saveCredentials(completeDestinationUrl, customUsername, customPassword); + } else { + // In the case for anonymous logins, clear the previous credentials for that host + webCredentialsUtils.clearCredentials(completeDestinationUrl); + } + } + + private void clearTemporaryCredentials() { + if (customUsername != null && customPassword != null) { + webCredentialsUtils.clearCredentials(completeDestinationUrl); + } + } + /** * Represents the results of a submission attempt triggered by explicit user action (as opposed * to auto-send). A submission attempt can include finalized forms going to several different diff --git a/collect_app/src/main/java/org/odk/collect/android/tasks/SaveFormToDisk.java b/collect_app/src/main/java/org/odk/collect/android/tasks/SaveFormToDisk.java index 4bc3956a65d..c40fd464a0a 100644 --- a/collect_app/src/main/java/org/odk/collect/android/tasks/SaveFormToDisk.java +++ b/collect_app/src/main/java/org/odk/collect/android/tasks/SaveFormToDisk.java @@ -39,11 +39,11 @@ import org.json.JSONException; import org.json.JSONObject; import org.odk.collect.analytics.Analytics; -import org.odk.collect.android.analytics.AnalyticsEvents; import org.odk.collect.android.application.Collect; import org.odk.collect.android.database.instances.DatabaseInstanceColumns; import org.odk.collect.android.exception.EncryptionException; import org.odk.collect.android.external.InstancesContract; +import org.odk.collect.android.formentry.FormEntryUseCases; import org.odk.collect.android.formentry.saving.FormSaver; import org.odk.collect.android.javarosawrapper.FailedValidationResult; import org.odk.collect.android.javarosawrapper.FormController; @@ -63,8 +63,6 @@ import java.io.File; import java.io.IOException; -import java.io.InputStream; -import java.io.RandomAccessFile; import java.util.ArrayList; import timber.log.Timber; @@ -115,7 +113,7 @@ public SaveToDiskResult saveForm(FormSaver.ProgressListener progressListener) { ValidationResult validationResult; try { - validationResult = formController.validateAnswers(true, shouldFinalize); + validationResult = formController.validateAnswers(shouldFinalize); if (shouldFinalize && validationResult instanceof FailedValidationResult) { // validation failed, pass specific failure saveToDiskResult.setSaveResult(((FailedValidationResult) validationResult).getStatus(), shouldFinalize); @@ -129,8 +127,7 @@ public SaveToDiskResult saveForm(FormSaver.ProgressListener progressListener) { } if (shouldFinalize) { - formController.finalizeForm(); - formController.getEntities().forEach(entitiesRepository::save); + FormEntryUseCases.finalizeFormController(formController, entitiesRepository); } // close all open databases of external data. @@ -147,7 +144,7 @@ public SaveToDiskResult saveForm(FormSaver.ProgressListener progressListener) { Instance instance = exportData(shouldFinalize, progressListener, validationResult); if (formController.getInstanceFile() != null) { - removeSavepointFiles(formController.getInstanceFile().getName()); + removeIndexFile(formController.getInstanceFile().getName()); } saveToDiskResult.setSaveResult(saveAndExit ? SAVED_AND_EXIT : SAVED, shouldFinalize); @@ -312,14 +309,6 @@ private JSONObject toGeoJson(GeoPointData data) throws JSONException { return geometry; } - /** - * Return the savepoint file for a given instance. - */ - static File getSavepointFile(String instanceName) { - File tempDir = new File(new StoragePathProvider().getOdkDirPath(StorageSubdirectory.CACHE)); - return new File(tempDir, instanceName + ".save"); - } - /** * Return the formIndex file for a given instance. */ @@ -328,10 +317,8 @@ public static File getFormIndexFile(String instanceName) { return new File(tempDir, instanceName + ".index"); } - public static void removeSavepointFiles(String instanceName) { - File savepointFile = getSavepointFile(instanceName); + public static void removeIndexFile(String instanceName) { File formIndexFile = getFormIndexFile(instanceName); - FileUtils.deleteAndReport(savepointFile); FileUtils.deleteAndReport(formIndexFile); } @@ -369,10 +356,6 @@ private Instance exportData(boolean markCompleted, FormSaver.ProgressListener pr // now see if the packaging of the data for the server would make it // non-reopenable (e.g., encryption or other fraction of the form). boolean canEditAfterCompleted = formController.isSubmissionEntireForm(); - if (!canEditAfterCompleted) { - Analytics.log(AnalyticsEvents.PARTIAL_FORM_FINALIZED, "form"); - } - boolean isEncrypted = false; // build a submission.xml to hold the data being submitted @@ -502,36 +485,7 @@ public static void manageFilesAfterSavingEncryptedForm(File instanceXml, File su /** * Writes payload contents to the disk. */ - static void writeFile(ByteArrayPayload payload, String path) throws IOException { - File file = new File(path); - if (file.exists() && !file.delete()) { - throw new IOException("Cannot overwrite " + path + ". Perhaps the file is locked?"); - } - - // create data stream - InputStream is = payload.getPayloadStream(); - int len = (int) payload.getLength(); - - // read from data stream - byte[] data = new byte[len]; - int read = is.read(data, 0, len); - if (read > 0) { - // Make sure the directory path to this file exists. - file.getParentFile().mkdirs(); - // write xml file - RandomAccessFile randomAccessFile = null; - try { - randomAccessFile = new RandomAccessFile(file, "rws"); - randomAccessFile.write(data); - } finally { - if (randomAccessFile != null) { - try { - randomAccessFile.close(); - } catch (IOException e) { - Timber.e(e, "Error closing RandomAccessFile: %s", path); - } - } - } - } + public static void writeFile(ByteArrayPayload payload, String path) throws IOException { + org.odk.collect.shared.files.FileUtils.saveToFile(payload.getPayloadStream(), path); } } diff --git a/collect_app/src/main/java/org/odk/collect/android/tasks/SavePointTask.java b/collect_app/src/main/java/org/odk/collect/android/tasks/SavePointTask.java deleted file mode 100644 index 183cfc52f8a..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/tasks/SavePointTask.java +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright (C) 2014 University of Washington - * - * Originally developed by Dobility, Inc. (as part of SurveyCTO) - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. You may obtain a copy of - * the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. - */ - -package org.odk.collect.android.tasks; - -import android.os.AsyncTask; - -import org.javarosa.core.services.transport.payload.ByteArrayPayload; -import org.odk.collect.android.javarosawrapper.FormController; -import org.odk.collect.android.listeners.SavePointListener; - -import java.io.File; - -import timber.log.Timber; - -/** - * Author: Meletis Margaritis - * Date: 27/6/2013 - * Time: 6:46 μμ - */ -public class SavePointTask extends AsyncTask { - - private static final Object LOCK = new Object(); - private static int lastPriorityUsed; - - private final SavePointListener listener; - private final FormController formController; - private final int priority; - - public SavePointTask(SavePointListener listener, FormController formController) { - this.listener = listener; - this.formController = formController; - this.priority = ++lastPriorityUsed; - } - - @Override - protected String doInBackground(Void... params) { - synchronized (LOCK) { - if (priority < lastPriorityUsed) { - Timber.w("Savepoint thread (p=%d) was cancelled (a) because another one is waiting (p=%d)", priority, lastPriorityUsed); - return null; - } - - long start = System.currentTimeMillis(); - - try { - File temp = SaveFormToDisk.getSavepointFile(formController.getInstanceFile().getName()); - ByteArrayPayload payload = formController.getFilledInFormXml(); - - if (priority < lastPriorityUsed) { - Timber.w("Savepoint thread (p=%d) was cancelled (b) because another one is waiting (p=%d)", priority, lastPriorityUsed); - return null; - } - - // write out xml - SaveFormToDisk.writeFile(payload, temp.getAbsolutePath()); - - long end = System.currentTimeMillis(); - Timber.i("Savepoint ms: %s to %s", Long.toString(end - start), temp.toString()); - - return null; - } catch (Exception e) { - String msg = e.getMessage(); - Timber.e(e); - return msg; - } - } - } - - @Override - protected void onPostExecute(String errorMessage) { - super.onPostExecute(errorMessage); - - if (listener != null && errorMessage != null) { - listener.onSavePointError(errorMessage); - } - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/upload/InstanceServerUploader.java b/collect_app/src/main/java/org/odk/collect/android/upload/InstanceServerUploader.java index 2c27c491ba3..110b8c82987 100644 --- a/collect_app/src/main/java/org/odk/collect/android/upload/InstanceServerUploader.java +++ b/collect_app/src/main/java/org/odk/collect/android/upload/InstanceServerUploader.java @@ -14,6 +14,8 @@ package org.odk.collect.android.upload; +import static org.odk.collect.strings.localization.LocalizedApplicationKt.getLocalizedString; + import android.net.Uri; import androidx.annotation.NonNull; @@ -27,6 +29,7 @@ import org.odk.collect.android.utilities.ResponseMessageParser; import org.odk.collect.android.utilities.WebCredentialsUtils; import org.odk.collect.forms.instances.Instance; +import org.odk.collect.forms.instances.InstancesRepository; import org.odk.collect.settings.keys.ProjectKeys; import org.odk.collect.shared.settings.Settings; @@ -44,8 +47,6 @@ import timber.log.Timber; -import static org.odk.collect.strings.localization.LocalizedApplicationKt.getLocalizedString; - public class InstanceServerUploader extends InstanceUploader { private static final String URL_PATH_SEP = "/"; @@ -56,7 +57,8 @@ public class InstanceServerUploader extends InstanceUploader { public InstanceServerUploader(OpenRosaHttpInterface httpInterface, WebCredentialsUtils webCredentialsUtils, - Settings generalSettings) { + Settings generalSettings, InstancesRepository instancesRepository) { + super(instancesRepository); this.httpInterface = httpInterface; this.webCredentialsUtils = webCredentialsUtils; this.generalSettings = generalSettings; diff --git a/collect_app/src/main/java/org/odk/collect/android/upload/InstanceUploader.java b/collect_app/src/main/java/org/odk/collect/android/upload/InstanceUploader.java index c11673f7702..1dbde1b27bd 100644 --- a/collect_app/src/main/java/org/odk/collect/android/upload/InstanceUploader.java +++ b/collect_app/src/main/java/org/odk/collect/android/upload/InstanceUploader.java @@ -17,27 +17,18 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import org.odk.collect.android.application.Collect; -import org.odk.collect.android.formmanagement.InstancesDataService; -import org.odk.collect.android.injection.DaggerUtils; -import org.odk.collect.android.utilities.InstancesRepositoryProvider; import org.odk.collect.forms.instances.Instance; +import org.odk.collect.forms.instances.InstancesRepository; import java.util.ArrayList; import java.util.List; -import javax.inject.Inject; - public abstract class InstanceUploader { - @Inject - InstancesRepositoryProvider instancesRepositoryProvider; - - @Inject - InstancesDataService instancesDataService; + private final InstancesRepository instancesRepository; - public InstanceUploader() { - DaggerUtils.getComponent(Collect.getInstance()).inject(this); + public InstanceUploader(InstancesRepository instancesRepository) { + this.instancesRepository = instancesRepository; } public static final String FAIL = "Error: "; @@ -61,31 +52,25 @@ public List getInstancesFromIds(Long... instanceDatabaseIds) { List instances = new ArrayList<>(); for (Long id : instanceDatabaseIds) { - instances.add(instancesRepositoryProvider.get().get(id)); + instances.add(instancesRepository.get(id)); } return instances; } public void markSubmissionFailed(Instance instance) { - instancesRepositoryProvider - .get() + instancesRepository .save(new Instance.Builder(instance) .status(Instance.STATUS_SUBMISSION_FAILED) .build() ); - - instancesDataService.update(); } public void markSubmissionComplete(Instance instance) { - instancesRepositoryProvider - .get() + instancesRepository .save(new Instance.Builder(instance) .status(Instance.STATUS_SUBMITTED) .build() ); - - instancesDataService.update(); } } diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/ActionRegister.kt b/collect_app/src/main/java/org/odk/collect/android/utilities/ActionRegister.kt new file mode 100644 index 00000000000..02ddab87a71 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/ActionRegister.kt @@ -0,0 +1,21 @@ +package org.odk.collect.android.utilities + +/** + * Used to allow tests to understand whether a UI action has been successfully detected or not + */ +object ActionRegister { + + @JvmStatic + var isActionDetected = false + private set + + @JvmStatic + fun attemptingAction() { + isActionDetected = false + } + + @JvmStatic + fun actionDetected() { + isActionDetected = true + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/Appearances.kt b/collect_app/src/main/java/org/odk/collect/android/utilities/Appearances.kt index 2b542ec12ef..12f3331f62e 100644 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/Appearances.kt +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/Appearances.kt @@ -16,10 +16,10 @@ package org.odk.collect.android.utilities import android.content.res.Configuration +import org.javarosa.core.model.Constants import org.javarosa.form.api.FormEntryPrompt import org.odk.collect.android.dynamicpreload.ExternalDataUtil import org.odk.collect.androidshared.utils.ScreenUtils -import java.lang.Exception object Appearances { // Date appearances @@ -87,6 +87,7 @@ object Appearances { const val NUMBERS = "numbers" const val URL = "url" const val RATING = "rating" + const val MASKED = "masked" // Get appearance hint and clean it up so it is lower case, without the search function and never null. @JvmStatic @@ -169,7 +170,7 @@ object Appearances { @JvmStatic fun useThousandSeparator(prompt: FormEntryPrompt): Boolean { - return getSanitizedAppearanceHint(prompt).contains(THOUSANDS_SEP) + return getSanitizedAppearanceHint(prompt).contains(THOUSANDS_SEP) && !isMasked(prompt) } @JvmStatic @@ -190,4 +191,12 @@ object Appearances { val appearance = getSanitizedAppearanceHint(prompt) return appearance.contains(SEARCH) || appearance.contains(AUTOCOMPLETE) } + + @JvmStatic + fun isMasked(prompt: FormEntryPrompt): Boolean { + val appearance = getSanitizedAppearanceHint(prompt) + return appearance.contains(MASKED) && + !appearance.contains(NUMBERS) && + prompt.dataType == Constants.DATATYPE_TEXT + } } diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/ApplicationConstants.java b/collect_app/src/main/java/org/odk/collect/android/utilities/ApplicationConstants.java index 5aa30edf7c0..9ea64e46145 100644 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/ApplicationConstants.java +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/ApplicationConstants.java @@ -84,8 +84,4 @@ public abstract static class Namespaces { public static final String XML_OPENROSA_NAMESPACE = "http://openrosa.org/xforms"; public static final String XML_OPENDATAKIT_NAMESPACE = "http://www.opendatakit.org/xforms"; } - - public enum ScreenName { - MAIN_MENU - } } diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/ChangeLockProvider.kt b/collect_app/src/main/java/org/odk/collect/android/utilities/ChangeLockProvider.kt index 0a091fcaaba..42f9702dadf 100644 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/ChangeLockProvider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/ChangeLockProvider.kt @@ -5,15 +5,15 @@ import org.odk.collect.shared.locks.ReentrantLockChangeLock import javax.inject.Singleton @Singleton -class ChangeLockProvider { +class ChangeLockProvider(private val changeLockFactory: () -> ChangeLock = { ReentrantLockChangeLock() }) { private val locks: MutableMap = mutableMapOf() fun getFormLock(projectId: String): ChangeLock { - return locks.getOrPut("form:$projectId") { ReentrantLockChangeLock() } + return locks.getOrPut("form:$projectId") { changeLockFactory() } } fun getInstanceLock(projectId: String): ChangeLock { - return locks.getOrPut("instance:$projectId") { ReentrantLockChangeLock() } + return locks.getOrPut("instance:$projectId") { changeLockFactory() } } } diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/ExternalAppIntentProvider.java b/collect_app/src/main/java/org/odk/collect/android/utilities/ExternalAppIntentProvider.java index e4162fcdac4..603b1bc9542 100644 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/ExternalAppIntentProvider.java +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/ExternalAppIntentProvider.java @@ -17,7 +17,16 @@ public class ExternalAppIntentProvider { private static final String URI_KEY = "uri_data"; public Intent getIntentToRunExternalApp(FormController formController, FormEntryPrompt formEntryPrompt) throws ExternalParamsException, XPathSyntaxException { - String exSpec = formEntryPrompt.getAppearanceHint().replaceFirst("^ex[:]", ""); + String appearance = formEntryPrompt.getAppearanceHint(); + + String exSpec = appearance.substring(appearance.indexOf(Appearances.EX)); + if (exSpec.contains(")")) { + exSpec = exSpec.substring(0, exSpec.lastIndexOf(')') + 1); + } else if (exSpec.contains(" ")) { + exSpec = exSpec.substring(0, exSpec.indexOf(' ')); + } + exSpec = exSpec.replaceFirst("^ex[:]", ""); + final String intentName = ExternalAppsUtils.extractIntentName(exSpec); final Map exParams = ExternalAppsUtils.extractParameters(exSpec); diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/FlingRegister.java b/collect_app/src/main/java/org/odk/collect/android/utilities/FlingRegister.java deleted file mode 100644 index 008ee6ac3c9..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/FlingRegister.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.odk.collect.android.utilities; - -/** - * Used to allow tests to understand whether fling gestures have been - * successfully detected or not - */ - -public final class FlingRegister { - - private static boolean flingDetected; - - private FlingRegister() { - - } - - public static void attemptingFling() { - flingDetected = false; - } - - public static void flingDetected() { - flingDetected = true; - } - - public static boolean isFlingDetected() { - return flingDetected; - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/FormsDownloadResultInterpreter.kt b/collect_app/src/main/java/org/odk/collect/android/utilities/FormsDownloadResultInterpreter.kt index 20893c20cd7..1aa5c4a54a7 100644 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/FormsDownloadResultInterpreter.kt +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/FormsDownloadResultInterpreter.kt @@ -1,10 +1,9 @@ package org.odk.collect.android.utilities import android.content.Context -import org.odk.collect.android.R -import org.odk.collect.android.formmanagement.FormDownloadException -import org.odk.collect.android.formmanagement.FormDownloadExceptionMapper import org.odk.collect.android.formmanagement.ServerFormDetails +import org.odk.collect.android.formmanagement.download.FormDownloadException +import org.odk.collect.android.formmanagement.download.FormDownloadExceptionMapper import org.odk.collect.errors.ErrorItem import org.odk.collect.strings.localization.getLocalizedString diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/FormsRepositoryProvider.kt b/collect_app/src/main/java/org/odk/collect/android/utilities/FormsRepositoryProvider.kt index f06f05f512b..1e09160ce36 100644 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/FormsRepositoryProvider.kt +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/FormsRepositoryProvider.kt @@ -8,7 +8,8 @@ import org.odk.collect.forms.FormsRepository class FormsRepositoryProvider @JvmOverloads constructor( private val context: Context, - private val storagePathProvider: StoragePathProvider = StoragePathProvider() + private val storagePathProvider: StoragePathProvider = StoragePathProvider(), + private val savepointsRepositoryProvider: SavepointsRepositoryProvider = SavepointsRepositoryProvider(context, storagePathProvider) ) { private val clock = { System.currentTimeMillis() } @@ -18,6 +19,8 @@ class FormsRepositoryProvider @JvmOverloads constructor( val dbPath = storagePathProvider.getOdkDirPath(StorageSubdirectory.METADATA, projectId) val formsPath = storagePathProvider.getOdkDirPath(StorageSubdirectory.FORMS, projectId) val cachePath = storagePathProvider.getOdkDirPath(StorageSubdirectory.CACHE, projectId) - return DatabaseFormsRepository(context, dbPath, formsPath, cachePath, clock) + val savepointsRepository = savepointsRepositoryProvider.get(projectId) + + return DatabaseFormsRepository(context, dbPath, formsPath, cachePath, clock, savepointsRepository) } } diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/HtmlUtils.java b/collect_app/src/main/java/org/odk/collect/android/utilities/HtmlUtils.java index d911b601670..90d6ccfe053 100644 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/HtmlUtils.java +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/HtmlUtils.java @@ -87,10 +87,7 @@ static String markdownToHtml(String text) { text = text.replaceAll("(?s)\\*\\*(.*?)\\*\\*", "$1"); // emphasis using underscore - text = text.replaceAll("\\s_([^\\s][^_\n]*)_\\s", " $1 "); - text = text.replaceAll("^_([^\\s][^_\n]*)_$", "$1"); - text = text.replaceAll("^_([^\\s][^_\n]*)_\\s", "$1 "); - text = text.replaceAll("\\s_([^\\s][^_\n]*)_$", " $1"); + text = text.replaceAll("(^|[\\s]|[^\\w])_([^\\s][^_\n]*)_($|[\\s]|[^\\w])", "$1$2$3"); // emphasis using asterisk text = text.replaceAll("\\*([^\\s][^\\*\n]*)\\*", "$1"); diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/ImageCompressionController.kt b/collect_app/src/main/java/org/odk/collect/android/utilities/ImageCompressionController.kt index a4953c82e68..c7a19f9944b 100644 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/ImageCompressionController.kt +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/ImageCompressionController.kt @@ -27,12 +27,13 @@ class ImageCompressionController(private val imageCompressor: ImageCompressor) { for (bindAttribute in questionWidget.formEntryPrompt.bindAttributes) { if ("max-pixels" == bindAttribute.name && ApplicationConstants.Namespaces.XML_OPENROSA_NAMESPACE == bindAttribute.namespace) { try { - return bindAttribute.attributeValue.toInt() + return bindAttribute.attributeValue?.toInt() } catch (e: NumberFormatException) { Timber.i(e) } } } + return null } diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/InstanceAutoDeleteChecker.kt b/collect_app/src/main/java/org/odk/collect/android/utilities/InstanceAutoDeleteChecker.kt index 0b05398d560..2202958c87b 100644 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/InstanceAutoDeleteChecker.kt +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/InstanceAutoDeleteChecker.kt @@ -1,7 +1,5 @@ package org.odk.collect.android.utilities -import org.odk.collect.android.analytics.AnalyticsEvents -import org.odk.collect.android.analytics.AnalyticsUtils import org.odk.collect.forms.FormsRepository import org.odk.collect.forms.instances.Instance import java.util.Locale @@ -21,10 +19,6 @@ object InstanceAutoDeleteChecker { instance: Instance ): Boolean { formsRepository.getLatestByFormIdAndVersion(instance.formId, instance.formVersion)?.let { form -> - if (!form.autoDelete.isNullOrEmpty()) { - AnalyticsUtils.logFormEvent(AnalyticsEvents.FORM_LEVEL_AUTO_DELETE, form.formId, form.displayName) - } - return if (isAutoDeleteEnabledInProjectSettings) { form.autoDelete == null || form.autoDelete.trim().lowercase(Locale.US) != "false" } else { diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/LocaleHelper.kt b/collect_app/src/main/java/org/odk/collect/android/utilities/LocaleHelper.kt index 6b7a50afc2c..b346fd595b0 100644 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/LocaleHelper.kt +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/LocaleHelper.kt @@ -10,7 +10,8 @@ object LocaleHelper { "af", "am", "ar", "bg", "bn", "ca", "cs", "da", "de", "en", "es", "et", "fa", "fa_AF", "fi", "fr", "hi", "in", "it", "ja", "ht", "ka", "km", "ln", "lo_LA", "lt", "mg", "ml", "mr", "ms", "my", "ne_NP", "nl", "no", "pl", "ps", "pt", "ro", "ru", "rw", "si", "sl", "so", "sq", "sr", - "sv_SE", "sw", "sw_KE", "te", "th_TH", "ti", "tl", "tr", "uk", "ur", "ur_PK", "vi", "zh", "zu" + "sv_SE", "sw", "sw_KE", "te", "th_TH", "ti", "tl", "tr", "uk", "ur", "ur_PK", "vi", "zh", + "zh_TW", "zu" ) @JvmStatic diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/SavepointsRepositoryProvider.kt b/collect_app/src/main/java/org/odk/collect/android/utilities/SavepointsRepositoryProvider.kt new file mode 100644 index 00000000000..32f110fcc5c --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/SavepointsRepositoryProvider.kt @@ -0,0 +1,22 @@ +package org.odk.collect.android.utilities + +import android.content.Context +import org.odk.collect.android.database.savepoints.DatabaseSavepointsRepository +import org.odk.collect.android.storage.StoragePathProvider +import org.odk.collect.android.storage.StorageSubdirectory +import org.odk.collect.forms.savepoints.SavepointsRepository + +class SavepointsRepositoryProvider( + private val context: Context, + private val storagePathProvider: StoragePathProvider +) { + + @JvmOverloads + fun get(projectId: String? = null): SavepointsRepository { + val dbPath = storagePathProvider.getOdkDirPath(StorageSubdirectory.METADATA, projectId) + val cachePath = storagePathProvider.getOdkDirPath(StorageSubdirectory.CACHE, projectId) + val instancesPath = storagePathProvider.getOdkDirPath(StorageSubdirectory.INSTANCES, projectId) + + return DatabaseSavepointsRepository(context, dbPath, cachePath, instancesPath) + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/WebCredentialsUtils.java b/collect_app/src/main/java/org/odk/collect/android/utilities/WebCredentialsUtils.java index 5e9ea5746e0..6029ce1f024 100644 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/WebCredentialsUtils.java +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/WebCredentialsUtils.java @@ -64,7 +64,7 @@ public void clearCredentials(@NonNull String url) { } } - static void clearAllCredentials() { + public static void clearAllCredentials() { HOST_CREDENTIALS.clear(); } diff --git a/collect_app/src/main/java/org/odk/collect/android/views/EmptyListView.kt b/collect_app/src/main/java/org/odk/collect/android/views/EmptyListView.kt deleted file mode 100644 index ba385638ad1..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/views/EmptyListView.kt +++ /dev/null @@ -1,48 +0,0 @@ -package org.odk.collect.android.views - -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.widget.FrameLayout -import androidx.annotation.DrawableRes -import org.odk.collect.android.R -import org.odk.collect.android.databinding.EmptyListViewBinding - -class EmptyListView(context: Context, attrs: AttributeSet?) : FrameLayout(context, attrs) { - constructor(context: Context) : this(context, null) - - private val binding = EmptyListViewBinding.inflate(LayoutInflater.from(context), this, true) - - init { - context.theme.obtainStyledAttributes( - attrs, - R.styleable.EmptyListView, - 0, - 0 - ).apply { - try { - val icon = this.getResourceId(R.styleable.EmptyListView_icon, 0) - val title = this.getString(R.styleable.EmptyListView_title) - val subtitle = this.getString(R.styleable.EmptyListView_subtitle) - - binding.icon.setImageResource(icon) - binding.title.text = title - binding.subtitle.text = subtitle - } finally { - recycle() - } - } - } - - fun setIcon(@DrawableRes icon: Int) { - binding.icon.setImageResource(icon) - } - - fun setTitle(title: String) { - binding.title.text = title - } - - fun setSubtitle(subtitle: String) { - binding.subtitle.text = subtitle - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/views/WidgetAnswerText.kt b/collect_app/src/main/java/org/odk/collect/android/views/WidgetAnswerText.kt index 44238269e94..e43a8bc9c04 100644 --- a/collect_app/src/main/java/org/odk/collect/android/views/WidgetAnswerText.kt +++ b/collect_app/src/main/java/org/odk/collect/android/views/WidgetAnswerText.kt @@ -8,6 +8,7 @@ import android.text.InputType import android.text.Selection import android.text.TextWatcher import android.text.method.DigitsKeyListener +import android.text.method.PasswordTransformationMethod import android.text.method.TextKeyListener import android.util.AttributeSet import android.util.TypedValue @@ -32,7 +33,7 @@ class WidgetAnswerText(context: Context, attrs: AttributeSet?) : FrameLayout(con val binding = WidgetAnswerTextBinding.inflate(LayoutInflater.from(context), this, true) - fun init(fontSize: Float, readOnly: Boolean, numberOfRows: Int?, afterTextChanged: Runnable) { + fun init(fontSize: Float, readOnly: Boolean, numberOfRows: Int?, isMasked: Boolean, afterTextChanged: Runnable) { binding.editText.id = generateViewId() binding.textView.id = generateViewId() @@ -57,6 +58,11 @@ class WidgetAnswerText(context: Context, attrs: AttributeSet?) : FrameLayout(con afterTextChanged.run() } }) + if (isMasked) { + binding.editText.inputType = binding.editText.inputType or InputType.TYPE_TEXT_VARIATION_PASSWORD + binding.editText.transformationMethod = PasswordTransformationMethod.getInstance() + binding.textView.transformationMethod = PasswordTransformationMethod.getInstance() + } } fun updateState(readOnly: Boolean) { @@ -75,7 +81,6 @@ class WidgetAnswerText(context: Context, attrs: AttributeSet?) : FrameLayout(con binding.editText.addTextChangedListener(ThousandsSeparatorTextWatcher(binding.editText)) } - binding.editText.inputType = InputType.TYPE_NUMBER_FLAG_SIGNED binding.editText.keyListener = DigitsKeyListener(true, false) // only allows numbers and no periods // ints can only hold 2,147,483,648. we allow 999,999,999 @@ -97,7 +102,6 @@ class WidgetAnswerText(context: Context, attrs: AttributeSet?) : FrameLayout(con binding.editText.addTextChangedListener(ThousandsSeparatorTextWatcher(binding.editText)) } - binding.editText.inputType = InputType.TYPE_NUMBER_FLAG_SIGNED binding.editText.keyListener = object : DigitsKeyListener() { override fun getAcceptedChars(): CharArray { return charArrayOf( @@ -116,7 +120,6 @@ class WidgetAnswerText(context: Context, attrs: AttributeSet?) : FrameLayout(con binding.editText.addTextChangedListener(ThousandsSeparatorTextWatcher(binding.editText)) } - binding.editText.inputType = InputType.TYPE_NUMBER_FLAG_DECIMAL binding.editText.keyListener = DigitsKeyListener(true, true) // only numbers are allowed // only 15 characters allowed diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/AnnotateWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/AnnotateWidget.java index 65026144bfb..c2752e05db8 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/AnnotateWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/AnnotateWidget.java @@ -16,7 +16,6 @@ import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE; import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; -import static org.odk.collect.android.formentry.questions.WidgetViewUtils.createSimpleButton; import static org.odk.collect.android.utilities.ApplicationConstants.RequestCodes; import android.annotation.SuppressLint; @@ -26,19 +25,16 @@ import android.graphics.Bitmap; import android.graphics.drawable.BitmapDrawable; import android.view.View; -import android.widget.Button; - -import org.odk.collect.android.R; +import org.javarosa.form.api.FormEntryPrompt; +import org.odk.collect.android.databinding.AnnotateWidgetBinding; import org.odk.collect.draw.DrawActivity; import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.android.utilities.Appearances; import org.odk.collect.android.utilities.FileUtils; import org.odk.collect.android.utilities.QuestionMediaManager; -import org.odk.collect.android.widgets.interfaces.ButtonClickListener; import org.odk.collect.android.widgets.utilities.ImageCaptureIntentCreator; import org.odk.collect.android.widgets.utilities.WaitingForDataRegistry; import org.odk.collect.androidshared.ui.ToastUtils; - import java.io.File; import java.util.Locale; @@ -50,47 +46,60 @@ * @author Yaw Anokwa (yanokwa@gmail.com) */ @SuppressLint("ViewConstructor") -public class AnnotateWidget extends BaseImageWidget implements ButtonClickListener { - - Button captureButton; - Button chooseButton; - Button annotateButton; +public class AnnotateWidget extends BaseImageWidget { + AnnotateWidgetBinding binding; public AnnotateWidget(Context context, QuestionDetails prompt, QuestionMediaManager questionMediaManager, WaitingForDataRegistry waitingForDataRegistry, String tmpImageFilePath) { super(context, prompt, questionMediaManager, waitingForDataRegistry, tmpImageFilePath); - render(); - imageClickHandler = new DrawImageClickHandler(DrawActivity.OPTION_ANNOTATE, RequestCodes.ANNOTATE_IMAGE, org.odk.collect.strings.R.string.annotate_image); imageCaptureHandler = new ImageCaptureHandler(); - setUpLayout(); - updateAnswer(); - adjustAnnotateButtonAvailability(); - addAnswerView(answerLayout); + + render(); } @Override - protected void setUpLayout() { - super.setUpLayout(); - captureButton = createSimpleButton(getContext(), questionDetails.isReadOnly(), getContext().getString(org.odk.collect.strings.R.string.capture_image), this, false, R.id.capture_image); - - chooseButton = createSimpleButton(getContext(), questionDetails.isReadOnly(), getContext().getString(org.odk.collect.strings.R.string.choose_image), this, true, R.id.choose_image); + protected View onCreateAnswerView(Context context, FormEntryPrompt prompt, int answerFontSize) { + binding = AnnotateWidgetBinding.inflate(((Activity) context).getLayoutInflater()); + errorTextView = binding.errorMessage; + imageView = binding.image; + updateAnswer(); - annotateButton = createSimpleButton(getContext(), questionDetails.isReadOnly(), getContext().getString(org.odk.collect.strings.R.string.markup_image), this, true, R.id.markup_image); + if (getFormEntryPrompt().getAppearanceHint() != null && getFormEntryPrompt().getAppearanceHint().toLowerCase(Locale.ENGLISH).contains(Appearances.NEW)) { + binding.chooseButton.setVisibility(View.GONE); + } - annotateButton.setOnClickListener(v -> imageClickHandler.clickImage("annotateButton")); + if (binaryName == null || binding.image.getVisibility() == GONE) { + binding.annotateButton.setEnabled(false); + } - answerLayout.addView(captureButton); - answerLayout.addView(chooseButton); - answerLayout.addView(annotateButton); - answerLayout.addView(errorTextView); - answerLayout.addView(imageView); + binding.captureButton.setOnClickListener(v -> getPermissionsProvider().requestCameraPermission((Activity) getContext(), () -> { + Intent intent = ImageCaptureIntentCreator.imageCaptureIntent(getFormEntryPrompt(), getContext(), tmpImageFilePath); + imageCaptureHandler.captureImage(intent, RequestCodes.IMAGE_CAPTURE, org.odk.collect.strings.R.string.annotate_image); + })); + binding.chooseButton.setOnClickListener(v -> imageCaptureHandler.chooseImage(org.odk.collect.strings.R.string.annotate_image)); + binding.annotateButton.setOnClickListener(v -> imageClickHandler.clickImage("annotateButton")); + binding.image.setOnClickListener(v -> imageClickHandler.clickImage("viewImage")); + + if (questionDetails.isReadOnly()) { + binding.captureButton.setVisibility(View.GONE); + binding.chooseButton.setVisibility(View.GONE); + binding.annotateButton.setVisibility(View.GONE); + } - hideButtonsIfNeeded(); + return binding.getRoot(); } @Override public Intent addExtrasToIntent(Intent intent) { - intent.putExtra(DrawActivity.SCREEN_ORIENTATION, calculateScreenOrientation()); + Bitmap bmp = null; + if (binding.image.getDrawable() != null) { + bmp = ((BitmapDrawable) binding.image.getDrawable()).getBitmap(); + } + + int screenOrientation = bmp != null && bmp.getHeight() > bmp.getWidth() ? + SCREEN_ORIENTATION_PORTRAIT : SCREEN_ORIENTATION_LANDSCAPE; + + intent.putExtra(DrawActivity.SCREEN_ORIENTATION, screenOrientation); return intent; } @@ -102,63 +111,24 @@ protected boolean doesSupportDefaultValues() { @Override public void clearAnswer() { super.clearAnswer(); - annotateButton.setEnabled(false); - - // reset buttons - captureButton.setText(getContext().getString(org.odk.collect.strings.R.string.capture_image)); + binding.annotateButton.setEnabled(false); + binding.captureButton.setText(getContext().getString(org.odk.collect.strings.R.string.capture_image)); } @Override public void setOnLongClickListener(OnLongClickListener l) { - captureButton.setOnLongClickListener(l); - chooseButton.setOnLongClickListener(l); - annotateButton.setOnLongClickListener(l); + binding.captureButton.setOnLongClickListener(l); + binding.chooseButton.setOnLongClickListener(l); + binding.annotateButton.setOnLongClickListener(l); super.setOnLongClickListener(l); } @Override public void cancelLongPress() { super.cancelLongPress(); - captureButton.cancelLongPress(); - chooseButton.cancelLongPress(); - annotateButton.cancelLongPress(); - } - - @Override - public void onButtonClick(int buttonId) { - if (buttonId == R.id.capture_image) { - getPermissionsProvider().requestCameraPermission((Activity) getContext(), this::captureImage); - } else if (buttonId == R.id.choose_image) { - imageCaptureHandler.chooseImage(org.odk.collect.strings.R.string.annotate_image); - } - } - - private void adjustAnnotateButtonAvailability() { - if (binaryName == null || imageView.getVisibility() == GONE) { - annotateButton.setEnabled(false); - } - } - - private void hideButtonsIfNeeded() { - if (getFormEntryPrompt().getAppearanceHint() != null - && getFormEntryPrompt().getAppearanceHint().toLowerCase(Locale.ENGLISH).contains(Appearances.NEW)) { - chooseButton.setVisibility(View.GONE); - } - } - - private int calculateScreenOrientation() { - Bitmap bmp = null; - if (imageView.getDrawable() != null) { - bmp = ((BitmapDrawable) imageView.getDrawable()).getBitmap(); - } - - return bmp != null && bmp.getHeight() > bmp.getWidth() ? - SCREEN_ORIENTATION_PORTRAIT : SCREEN_ORIENTATION_LANDSCAPE; - } - - private void captureImage() { - Intent intent = ImageCaptureIntentCreator.imageCaptureIntent(getFormEntryPrompt(), getContext(), tmpImageFilePath); - imageCaptureHandler.captureImage(intent, RequestCodes.IMAGE_CAPTURE, org.odk.collect.strings.R.string.annotate_image); + binding.captureButton.cancelLongPress(); + binding.chooseButton.cancelLongPress(); + binding.annotateButton.cancelLongPress(); } @Override @@ -169,7 +139,7 @@ public void setData(Object newImageObj) { ToastUtils.showLongToast(getContext(), org.odk.collect.strings.R.string.gif_not_supported); } else { super.setData(newImageObj); - annotateButton.setEnabled(binaryName != null); + binding.annotateButton.setEnabled(binaryName != null); } } } diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/AudioWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/AudioWidget.java index 0d7fba4cf12..381c3507f8f 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/AudioWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/AudioWidget.java @@ -81,8 +81,8 @@ public AudioWidget(Context context, QuestionDetails questionDetails, QuestionMed updatePlayerMedia(); recordingStatusHandler.onBlockedStatusChange(isRecordingBlocked -> { - binding.captureButton.setEnabled(!isRecordingBlocked); - binding.chooseButton.setEnabled(!isRecordingBlocked); + binding.recordAudioButton.setEnabled(!isRecordingBlocked); + binding.chooseAudioButton.setEnabled(!isRecordingBlocked); }); recordingStatusHandler.onRecordingStatusChange(getFormEntryPrompt(), session -> { @@ -104,12 +104,12 @@ public AudioWidget(Context context, QuestionDetails questionDetails, QuestionMed protected View onCreateAnswerView(Context context, FormEntryPrompt prompt, int answerFontSize) { binding = AudioWidgetAnswerBinding.inflate(LayoutInflater.from(context)); - binding.captureButton.setOnClickListener(v -> { + binding.recordAudioButton.setOnClickListener(v -> { hideError(); binding.audioPlayer.waveform.clear(); recordingRequester.requestRecording(getFormEntryPrompt()); }); - binding.chooseButton.setOnClickListener(v -> audioFileRequester.requestFile(getFormEntryPrompt())); + binding.chooseAudioButton.setOnClickListener(v -> audioFileRequester.requestFile(getFormEntryPrompt())); return binding.getRoot(); } @@ -161,32 +161,32 @@ public void setData(Object object) { private void updateVisibilities() { if (recordingInProgress) { - binding.captureButton.setVisibility(GONE); - binding.chooseButton.setVisibility(GONE); + binding.recordAudioButton.setVisibility(GONE); + binding.chooseAudioButton.setVisibility(GONE); binding.audioPlayer.recordingDuration.setVisibility(VISIBLE); binding.audioPlayer.waveform.setVisibility(VISIBLE); binding.audioPlayer.audioController.setVisibility(GONE); } else if (getAnswer() == null) { - binding.captureButton.setVisibility(VISIBLE); - binding.chooseButton.setVisibility(VISIBLE); + binding.recordAudioButton.setVisibility(VISIBLE); + binding.chooseAudioButton.setVisibility(VISIBLE); binding.audioPlayer.recordingDuration.setVisibility(GONE); binding.audioPlayer.waveform.setVisibility(GONE); binding.audioPlayer.audioController.setVisibility(GONE); } else { - binding.captureButton.setVisibility(GONE); - binding.chooseButton.setVisibility(GONE); + binding.recordAudioButton.setVisibility(GONE); + binding.chooseAudioButton.setVisibility(GONE); binding.audioPlayer.recordingDuration.setVisibility(GONE); binding.audioPlayer.waveform.setVisibility(GONE); binding.audioPlayer.audioController.setVisibility(VISIBLE); } if (questionDetails.isReadOnly()) { - binding.captureButton.setVisibility(GONE); - binding.chooseButton.setVisibility(GONE); + binding.recordAudioButton.setVisibility(GONE); + binding.chooseAudioButton.setVisibility(GONE); } if (getFormEntryPrompt().getAppearanceHint() != null && getFormEntryPrompt().getAppearanceHint().toLowerCase(Locale.ENGLISH).contains(Appearances.NEW)) { - binding.chooseButton.setVisibility(GONE); + binding.chooseAudioButton.setVisibility(GONE); } } @@ -236,15 +236,15 @@ private Integer getDurationOfFile(String uri) { @Override public void setOnLongClickListener(OnLongClickListener l) { - binding.captureButton.setOnLongClickListener(l); - binding.chooseButton.setOnLongClickListener(l); + binding.recordAudioButton.setOnLongClickListener(l); + binding.chooseAudioButton.setOnLongClickListener(l); } @Override public void cancelLongPress() { super.cancelLongPress(); - binding.captureButton.cancelLongPress(); - binding.chooseButton.cancelLongPress(); + binding.recordAudioButton.cancelLongPress(); + binding.chooseAudioButton.cancelLongPress(); } /** diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/BaseImageWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/BaseImageWidget.java index 53e7362ce76..434d77ce6f3 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/BaseImageWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/BaseImageWidget.java @@ -16,8 +16,6 @@ package org.odk.collect.android.widgets; -import static org.odk.collect.android.formentry.questions.WidgetViewUtils.createAnswerImageView; - import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.Context; @@ -69,6 +67,8 @@ public BaseImageWidget(Context context, QuestionDetails prompt, QuestionMediaMan this.questionMediaManager = questionMediaManager; this.waitingForDataRegistry = waitingForDataRegistry; this.tmpImageFilePath = tmpImageFilePath; + + binaryName = getFormEntryPrompt().getAnswerText(); } @Override @@ -150,24 +150,6 @@ public void onLoadSucceeded() { } } - protected void setUpLayout() { - errorTextView = new TextView(getContext()); - errorTextView.setId(View.generateViewId()); - errorTextView.setText(org.odk.collect.strings.R.string.selected_invalid_image); - - answerLayout = new LinearLayout(getContext()); - answerLayout.setOrientation(LinearLayout.VERTICAL); - - binaryName = getFormEntryPrompt().getAnswerText(); - - imageView = createAnswerImageView(getContext()); - imageView.setOnClickListener(v -> { - if (imageClickHandler != null) { - imageClickHandler.clickImage("viewImage"); - } - }); - } - /** * Enables a subclass to add extras to the intent before launching the draw activity. * diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/BearingWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/BearingWidget.java index 9d687615c62..54e188faa26 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/BearingWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/BearingWidget.java @@ -65,7 +65,7 @@ protected View onCreateAnswerView(Context context, FormEntryPrompt prompt, int a } else { binding.bearingButton.setOnClickListener(v -> onButtonClick()); } - binding.widgetAnswerText.init(answerFontSize, true, null, this::widgetValueChanged); + binding.widgetAnswerText.init(answerFontSize, true, null, false, this::widgetValueChanged); Double answer = StringWidgetUtils.getDoubleAnswerValueFromIAnswerData(questionDetails.getPrompt().getAnswerValue()); binding.widgetAnswerText.setDecimalType(false, answer); diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/DateTimeWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/DateTimeWidget.java index d6a60ad6b4f..4aacfddc2cc 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/DateTimeWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/DateTimeWidget.java @@ -59,7 +59,7 @@ public DateTimeWidget(Context context, QuestionDetails prompt, DateTimeWidgetUti @Override protected View onCreateAnswerView(Context context, FormEntryPrompt prompt, int answerFontSize) { binding = DateTimeWidgetAnswerBinding.inflate(((Activity) context).getLayoutInflater()); - datePickerDetails = DateTimeWidgetUtils.getDatePickerDetails(prompt.getQuestion().getAppearanceAttr()); + datePickerDetails = DateTimeWidgetUtils.getDatePickerDetails(prompt.getAppearanceHint()); if (prompt.isReadOnly()) { binding.dateWidget.dateButton.setVisibility(GONE); diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/DateWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/DateWidget.java index c485220d3d5..bb09ceebbbd 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/DateWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/DateWidget.java @@ -53,7 +53,7 @@ public DateWidget(Context context, QuestionDetails prompt, DateTimeWidgetUtils w @Override protected View onCreateAnswerView(Context context, FormEntryPrompt prompt, int answerFontSize) { binding = DateWidgetAnswerBinding.inflate(((Activity) context).getLayoutInflater()); - datePickerDetails = DateTimeWidgetUtils.getDatePickerDetails(prompt.getQuestion().getAppearanceAttr()); + datePickerDetails = DateTimeWidgetUtils.getDatePickerDetails(prompt.getAppearanceHint()); if (prompt.isReadOnly()) { binding.dateButton.setVisibility(GONE); diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/DecimalWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/DecimalWidget.java index 55d2929832a..a92f7963559 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/DecimalWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/DecimalWidget.java @@ -30,7 +30,6 @@ public class DecimalWidget extends StringWidget { public DecimalWidget(Context context, QuestionDetails questionDetails) { super(context, questionDetails); - render(); boolean useThousandSeparator = Appearances.useThousandSeparator(questionDetails.getPrompt()); Double answer = StringWidgetUtils.getDoubleAnswerValueFromIAnswerData(questionDetails.getPrompt().getAnswerValue()); diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/DrawWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/DrawWidget.java index 0263b172cfa..e6055b7ab51 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/DrawWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/DrawWidget.java @@ -15,17 +15,17 @@ package org.odk.collect.android.widgets; import android.annotation.SuppressLint; +import android.app.Activity; import android.content.Context; import android.content.Intent; -import android.widget.Button; - +import android.view.View; +import org.javarosa.form.api.FormEntryPrompt; +import org.odk.collect.android.databinding.DrawWidgetBinding; import org.odk.collect.draw.DrawActivity; import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.android.utilities.QuestionMediaManager; -import org.odk.collect.android.widgets.interfaces.ButtonClickListener; import org.odk.collect.android.widgets.utilities.WaitingForDataRegistry; -import static org.odk.collect.android.formentry.questions.WidgetViewUtils.createSimpleButton; import static org.odk.collect.android.utilities.ApplicationConstants.RequestCodes; /** @@ -34,28 +34,31 @@ * @author BehrAtherton@gmail.com */ @SuppressLint("ViewConstructor") -public class DrawWidget extends BaseImageWidget implements ButtonClickListener { - - Button drawButton; +public class DrawWidget extends BaseImageWidget { + DrawWidgetBinding binding; public DrawWidget(Context context, QuestionDetails prompt, QuestionMediaManager questionMediaManager, WaitingForDataRegistry waitingForDataRegistry, String tmpImageFilePath) { super(context, prompt, questionMediaManager, waitingForDataRegistry, tmpImageFilePath); - render(); - imageClickHandler = new DrawImageClickHandler(DrawActivity.OPTION_DRAW, RequestCodes.DRAW_IMAGE, org.odk.collect.strings.R.string.draw_image); - setUpLayout(); + + render(); updateAnswer(); - addAnswerView(answerLayout); } @Override - protected void setUpLayout() { - super.setUpLayout(); - drawButton = createSimpleButton(getContext(), questionDetails.isReadOnly(), getContext().getString(org.odk.collect.strings.R.string.draw_image), this, false); + protected View onCreateAnswerView(Context context, FormEntryPrompt prompt, int answerFontSize) { + binding = DrawWidgetBinding.inflate(((Activity) context).getLayoutInflater()); + binding.drawButton.setOnClickListener(v -> imageClickHandler.clickImage("drawButton")); + binding.image.setOnClickListener(v -> imageClickHandler.clickImage("viewImage")); - answerLayout.addView(drawButton); - answerLayout.addView(errorTextView); - answerLayout.addView(imageView); + if (questionDetails.isReadOnly()) { + binding.drawButton.setVisibility(View.GONE); + } + + errorTextView = binding.errorMessage; + imageView = binding.image; + + return binding.getRoot(); } @Override @@ -71,24 +74,18 @@ protected boolean doesSupportDefaultValues() { @Override public void clearAnswer() { super.clearAnswer(); - // reset buttons - drawButton.setText(getContext().getString(org.odk.collect.strings.R.string.draw_image)); + binding.drawButton.setText(getContext().getString(org.odk.collect.strings.R.string.draw_image)); } @Override public void setOnLongClickListener(OnLongClickListener l) { - drawButton.setOnLongClickListener(l); + binding.drawButton.setOnLongClickListener(l); super.setOnLongClickListener(l); } @Override public void cancelLongPress() { super.cancelLongPress(); - drawButton.cancelLongPress(); - } - - @Override - public void onButtonClick(int buttonId) { - imageClickHandler.clickImage("drawButton"); + binding.drawButton.cancelLongPress(); } } diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/ExDecimalWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/ExDecimalWidget.java index a4faaedc2a4..0dc3d1dc1dc 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/ExDecimalWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/ExDecimalWidget.java @@ -41,11 +41,10 @@ public class ExDecimalWidget extends ExStringWidget { public ExDecimalWidget(Context context, QuestionDetails questionDetails, WaitingForDataRegistry waitingForDataRegistry, StringRequester stringRequester) { super(context, questionDetails, waitingForDataRegistry, stringRequester); - render(); boolean useThousandSeparator = Appearances.useThousandSeparator(questionDetails.getPrompt()); Double answer = StringWidgetUtils.getDoubleAnswerValueFromIAnswerData(questionDetails.getPrompt().getAnswerValue()); - widgetAnswerText.setDecimalType(useThousandSeparator, answer); + binding.widgetAnswerText.setDecimalType(useThousandSeparator, answer); } @Override @@ -60,12 +59,12 @@ protected int getRequestCode() { @Override public IAnswerData getAnswer() { - return StringWidgetUtils.getDecimalData(widgetAnswerText.getAnswer(), getFormEntryPrompt()); + return StringWidgetUtils.getDecimalData(binding.widgetAnswerText.getAnswer(), getFormEntryPrompt()); } @Override public void setData(Object answer) { DecimalData decimalData = ExternalAppsUtils.asDecimalData(answer); - widgetAnswerText.setAnswer(decimalData == null ? null : decimalData.getValue().toString()); + binding.widgetAnswerText.setAnswer(decimalData == null ? null : decimalData.getValue().toString()); } } diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/ExIntegerWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/ExIntegerWidget.java index 6714d74caaf..33a64406217 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/ExIntegerWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/ExIntegerWidget.java @@ -41,11 +41,10 @@ public class ExIntegerWidget extends ExStringWidget { public ExIntegerWidget(Context context, QuestionDetails questionDetails, WaitingForDataRegistry waitingForDataRegistry, StringRequester stringRequester) { super(context, questionDetails, waitingForDataRegistry, stringRequester); - render(); boolean useThousandSeparator = Appearances.useThousandSeparator(questionDetails.getPrompt()); Integer answer = StringWidgetUtils.getIntegerAnswerValueFromIAnswerData(questionDetails.getPrompt().getAnswerValue()); - widgetAnswerText.setIntegerType(useThousandSeparator, answer); + binding.widgetAnswerText.setIntegerType(useThousandSeparator, answer); } @Override @@ -60,12 +59,12 @@ protected int getRequestCode() { @Override public IAnswerData getAnswer() { - return StringWidgetUtils.getIntegerData(widgetAnswerText.getAnswer(), getFormEntryPrompt()); + return StringWidgetUtils.getIntegerData(binding.widgetAnswerText.getAnswer(), getFormEntryPrompt()); } @Override public void setData(Object answer) { IntegerData integerData = ExternalAppsUtils.asIntegerData(answer); - widgetAnswerText.setAnswer(integerData == null ? null : integerData.getValue().toString()); + binding.widgetAnswerText.setAnswer(integerData == null ? null : integerData.getValue().toString()); } } diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/ExPrinterWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/ExPrinterWidget.java index 2fdc75e3ecc..af2c2cadb2a 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/ExPrinterWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/ExPrinterWidget.java @@ -14,23 +14,22 @@ package org.odk.collect.android.widgets; -import static org.odk.collect.android.formentry.questions.WidgetViewUtils.createSimpleButton; - import android.annotation.SuppressLint; +import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.os.Bundle; import android.view.KeyEvent; -import android.widget.Button; -import android.widget.LinearLayout; +import android.view.View; import android.widget.Toast; +import androidx.annotation.NonNull; + import org.javarosa.core.model.data.IAnswerData; -import org.odk.collect.analytics.Analytics; -import org.odk.collect.android.analytics.AnalyticsEvents; +import org.javarosa.form.api.FormEntryPrompt; +import org.odk.collect.android.databinding.ExPrinterWidgetBinding; import org.odk.collect.android.formentry.questions.QuestionDetails; -import org.odk.collect.android.widgets.interfaces.ButtonClickListener; import org.odk.collect.android.widgets.interfaces.WidgetDataReceiver; import org.odk.collect.android.widgets.utilities.WaitingForDataRegistry; @@ -118,72 +117,31 @@ * @author mitchellsundt@gmail.com */ @SuppressLint("ViewConstructor") -public class ExPrinterWidget extends QuestionWidget implements WidgetDataReceiver, ButtonClickListener { - - final Button launchIntentButton; +public class ExPrinterWidget extends QuestionWidget implements WidgetDataReceiver { + ExPrinterWidgetBinding binding; private final WaitingForDataRegistry waitingForDataRegistry; public ExPrinterWidget(Context context, QuestionDetails prompt, WaitingForDataRegistry waitingForDataRegistry) { super(context, prompt); - render(); - this.waitingForDataRegistry = waitingForDataRegistry; - - String v = getFormEntryPrompt().getSpecialFormQuestionText("buttonText"); - String buttonText = (v != null) ? v : context.getString(org.odk.collect.strings.R.string.launch_printer); - launchIntentButton = createSimpleButton(getContext(), getFormEntryPrompt().isReadOnly(), buttonText, this, false); - - // finish complex layout - LinearLayout printLayout = new LinearLayout(getContext()); - printLayout.setOrientation(LinearLayout.VERTICAL); - printLayout.addView(launchIntentButton); - addAnswerView(printLayout); + render(); } - protected void firePrintingActivity(String intentName) throws ActivityNotFoundException { - - String s = getFormEntryPrompt().getAnswerText(); - - Intent i = new Intent(intentName); - getContext().startActivity(i); - - String[] splits; - if (s != null) { - splits = s.split("
"); - } else { - splits = null; + @Override + protected View onCreateAnswerView(@NonNull Context context, @NonNull FormEntryPrompt prompt, int answerFontSize) { + binding = ExPrinterWidgetBinding.inflate(((Activity) context).getLayoutInflater()); + binding.printButton.setOnClickListener(v -> onButtonClick()); + String customButtonText = getFormEntryPrompt().getSpecialFormQuestionText("buttonText"); + if (customButtonText != null) { + binding.printButton.setText(customButtonText); + binding.printButton.setContentDescription(customButtonText); } - Bundle printDataBundle = new Bundle(); - - String e; - if (splits != null) { - if (splits.length >= 1) { - e = splits[0]; - if (e.length() > 0) { - printDataBundle.putString("BARCODE", e); - } - } - if (splits.length >= 2) { - e = splits[1]; - if (e.length() > 0) { - printDataBundle.putString("QRCODE", e); - } - } - if (splits.length > 2) { - String[] text = new String[splits.length - 2]; - for (int j = 2; j < splits.length; ++j) { - e = splits[j]; - text[j - 2] = e; - } - printDataBundle.putStringArray("TEXT-STRINGS", text); - } + if (questionDetails.isReadOnly()) { + binding.printButton.setVisibility(View.GONE); } - //send the printDataBundle to the activity via broadcast intent - Intent bcastIntent = new Intent(intentName + ".data"); - bcastIntent.putExtra("DATA", printDataBundle); - getContext().sendBroadcast(bcastIntent); + return binding.getRoot(); } @Override @@ -207,17 +165,16 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { @Override public void setOnLongClickListener(OnLongClickListener l) { - launchIntentButton.setOnLongClickListener(l); + binding.printButton.setOnLongClickListener(l); } @Override public void cancelLongPress() { super.cancelLongPress(); - launchIntentButton.cancelLongPress(); + binding.printButton.cancelLongPress(); } - @Override - public void onButtonClick(int buttonId) { + private void onButtonClick() { String appearance = getFormEntryPrompt().getAppearanceHint(); String[] attrs = appearance.split(":"); final String intentName = (attrs.length < 2 || attrs[1].length() == 0) @@ -228,7 +185,6 @@ public void onButtonClick(int buttonId) { try { waitingForDataRegistry.waitForData(getFormEntryPrompt().getIndex()); firePrintingActivity(intentName); - Analytics.log(AnalyticsEvents.ZEBRA_PRINTER_STARTED, "form"); } catch (ActivityNotFoundException e) { waitingForDataRegistry.cancelWaitingForData(); Toast.makeText(getContext(), @@ -236,4 +192,49 @@ public void onButtonClick(int buttonId) { .show(); } } + + private void firePrintingActivity(String intentName) throws ActivityNotFoundException { + String s = getFormEntryPrompt().getAnswerText(); + + Intent i = new Intent(intentName); + getContext().startActivity(i); + + String[] splits; + if (s != null) { + splits = s.split("
"); + } else { + splits = null; + } + + Bundle printDataBundle = new Bundle(); + + String e; + if (splits != null) { + if (splits.length >= 1) { + e = splits[0]; + if (e.length() > 0) { + printDataBundle.putString("BARCODE", e); + } + } + if (splits.length >= 2) { + e = splits[1]; + if (e.length() > 0) { + printDataBundle.putString("QRCODE", e); + } + } + if (splits.length > 2) { + String[] text = new String[splits.length - 2]; + for (int j = 2; j < splits.length; ++j) { + e = splits[j]; + text[j - 2] = e; + } + printDataBundle.putStringArray("TEXT-STRINGS", text); + } + } + + //send the printDataBundle to the activity via broadcast intent + Intent bcastIntent = new Intent(intentName + ".data"); + bcastIntent.putExtra("DATA", printDataBundle); + getContext().sendBroadcast(bcastIntent); + } } diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/ExStringWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/ExStringWidget.java index 55df95ad7cf..7f5ee7206f2 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/ExStringWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/ExStringWidget.java @@ -14,28 +14,33 @@ package org.odk.collect.android.widgets; -import static org.odk.collect.android.formentry.questions.WidgetViewUtils.createSimpleButton; -import static org.odk.collect.android.injection.DaggerUtils.getComponent; import static org.odk.collect.android.utilities.ApplicationConstants.RequestCodes; import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; import android.view.ViewGroup; -import android.widget.Button; -import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; +import org.javarosa.core.model.data.IAnswerData; import org.javarosa.core.model.data.StringData; +import org.javarosa.form.api.FormEntryPrompt; +import org.odk.collect.android.databinding.ExStringQuestionTypeBinding; import org.odk.collect.android.dynamicpreload.ExternalAppsUtils; import org.odk.collect.android.R; import org.odk.collect.android.formentry.questions.QuestionDetails; -import org.odk.collect.android.widgets.interfaces.ButtonClickListener; +import org.odk.collect.android.utilities.Appearances; import org.odk.collect.android.widgets.interfaces.WidgetDataReceiver; +import org.odk.collect.android.widgets.utilities.QuestionFontSizeUtils; import org.odk.collect.android.widgets.utilities.StringRequester; +import org.odk.collect.android.widgets.utilities.StringWidgetUtils; import org.odk.collect.android.widgets.utilities.WaitingForDataRegistry; import java.io.Serializable; @@ -82,11 +87,11 @@ * */ @SuppressLint("ViewConstructor") -public class ExStringWidget extends StringWidget implements WidgetDataReceiver, ButtonClickListener { +public class ExStringWidget extends QuestionWidget implements WidgetDataReceiver { + public ExStringQuestionTypeBinding binding; private final WaitingForDataRegistry waitingForDataRegistry; private boolean hasExApp = true; - public Button launchIntentButton; private final StringRequester stringRequester; public ExStringWidget(Context context, QuestionDetails questionDetails, WaitingForDataRegistry waitingForDataRegistry, StringRequester stringRequester) { @@ -95,27 +100,32 @@ public ExStringWidget(Context context, QuestionDetails questionDetails, WaitingF this.waitingForDataRegistry = waitingForDataRegistry; this.stringRequester = stringRequester; - getComponent(context).inject(this); } @Override - protected void setUpLayout(Context context) { - launchIntentButton = createSimpleButton(getContext(), getFormEntryPrompt().isReadOnly(), getButtonText(), this, false); - - widgetAnswerText.setAnswer(getFormEntryPrompt().getAnswerText()); - ViewGroup.MarginLayoutParams params = new ViewGroup.MarginLayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT + protected View onCreateAnswerView(@NonNull Context context, @NonNull FormEntryPrompt prompt, int answerFontSize) { + binding = ExStringQuestionTypeBinding.inflate(LayoutInflater.from(context)); + binding.launchAppButton.setText(getButtonText()); + binding.launchAppButton.setOnClickListener(v -> { + waitingForDataRegistry.waitForData(getFormEntryPrompt().getIndex()); + stringRequester.launch((Activity) getContext(), getRequestCode(), getFormEntryPrompt(), getAnswerForIntent(), (String errorMsg) -> { + onException(errorMsg); + return null; + }); + }); + if (questionDetails.isReadOnly()) { + binding.launchAppButton.setVisibility(GONE); + } + binding.widgetAnswerText.init( + QuestionFontSizeUtils.getFontSize(settings, QuestionFontSizeUtils.FontSize.HEADLINE_6), + true, + StringWidgetUtils.getNumberOfRows(questionDetails.getPrompt()), + Appearances.isMasked(prompt), + this::widgetValueChanged ); - int marginTop = (int) getContext().getResources().getDimension(org.odk.collect.androidshared.R.dimen.margin_standard); - params.setMargins(0, marginTop, 0, 0); - widgetAnswerText.setLayoutParams(params); + binding.widgetAnswerText.setAnswer(getFormEntryPrompt().getAnswerText()); - LinearLayout answerLayout = new LinearLayout(getContext()); - answerLayout.setOrientation(LinearLayout.VERTICAL); - answerLayout.addView(launchIntentButton); - answerLayout.addView(widgetAnswerText); - addAnswerView(answerLayout); + return binding.getRoot(); } private String getButtonText() { @@ -131,21 +141,33 @@ protected int getRequestCode() { return RequestCodes.EX_STRING_CAPTURE; } + @Nullable + @Override + public IAnswerData getAnswer() { + String answer = binding.widgetAnswerText.getAnswer(); + return !answer.isEmpty() ? new StringData(answer) : null; + } + + @Override + public void clearAnswer() { + binding.widgetAnswerText.clearAnswer(); + } + @Override public void setData(Object answer) { StringData stringData = ExternalAppsUtils.asStringData(answer); - widgetAnswerText.setAnswer(stringData == null ? null : stringData.getValue().toString()); + binding.widgetAnswerText.setAnswer(stringData == null ? null : stringData.getValue().toString()); } @Override public void setFocus(Context context) { if (hasExApp) { - widgetAnswerText.setFocus(false); + binding.widgetAnswerText.setFocus(false); // focus on launch button - launchIntentButton.requestFocus(); + binding.launchAppButton.requestFocus(); } else { if (!getFormEntryPrompt().isReadOnly()) { - widgetAnswerText.setFocus(true); + binding.widgetAnswerText.setFocus(true); /* * If you do a multi-question screen after a "add another group" dialog, this won't * automatically pop up. It's an Android issue. @@ -157,44 +179,52 @@ public void setFocus(Context context) { * is focused before the dialog pops up, everything works fine. great. */ } else { - widgetAnswerText.setFocus(false); + binding.widgetAnswerText.setFocus(false); } } } @Override public void setOnLongClickListener(OnLongClickListener l) { - widgetAnswerText.setOnLongClickListener(l); - launchIntentButton.setOnLongClickListener(l); + binding.widgetAnswerText.setOnLongClickListener(l); + binding.launchAppButton.setOnLongClickListener(l); } @Override public void cancelLongPress() { super.cancelLongPress(); - widgetAnswerText.cancelLongPress(); - launchIntentButton.cancelLongPress(); + binding.widgetAnswerText.cancelLongPress(); + binding.launchAppButton.cancelLongPress(); } + /** + * Registers all subviews except for the answer_container (which contains the EditText) to clear on long press. + * This makes it possible to long-press to paste or perform other text editing functions. + */ @Override - public void onButtonClick(int buttonId) { - waitingForDataRegistry.waitForData(getFormEntryPrompt().getIndex()); - stringRequester.launch((Activity) getContext(), getRequestCode(), getFormEntryPrompt(), getAnswerForIntent(), (String errorMsg) -> { - onException(errorMsg); - return null; - }); + protected void registerToClearAnswerOnLongPress(Activity activity, ViewGroup viewGroup) { + ViewGroup view = findViewById(R.id.question_widget_container); + for (int i = 0; i < view.getChildCount(); i++) { + View childView = view.getChildAt(i); + if (childView.getId() != R.id.answer_container) { + childView.setTag(childView.getId()); + childView.setId(getId()); + activity.registerForContextMenu(childView); + } + } } private void focusAnswer() { - widgetAnswerText.setFocus(true); + binding.widgetAnswerText.setFocus(true); } private void onException(String toastText) { hasExApp = false; if (!getFormEntryPrompt().isReadOnly()) { - widgetAnswerText.updateState(false); + binding.widgetAnswerText.updateState(false); } - launchIntentButton.setEnabled(false); - launchIntentButton.setFocusable(false); + binding.launchAppButton.setEnabled(false); + binding.launchAppButton.setFocusable(false); waitingForDataRegistry.cancelWaitingForData(); Toast.makeText(getContext(), @@ -207,15 +237,16 @@ private void onException(String toastText) { @Override public void hideError() { super.hideError(); - errorLayout.setVisibility(GONE); + binding.widgetAnswerText.setError(null); } @Override public void displayError(String errorMessage) { hideError(); - if (widgetAnswerText.isEditableState()) { - super.displayError(errorMessage); + if (binding.widgetAnswerText.isEditableState()) { + binding.widgetAnswerText.setError(errorMessage); + setBackground(ContextCompat.getDrawable(getContext(), R.drawable.question_with_error_border)); } else { ((TextView) errorLayout.findViewById(R.id.error_message)).setText(errorMessage); errorLayout.setVisibility(VISIBLE); diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/GeoPointMapWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/GeoPointMapWidget.java index 2dedd6d162e..cc9b9e776f8 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/GeoPointMapWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/GeoPointMapWidget.java @@ -24,7 +24,7 @@ import org.javarosa.core.model.data.IAnswerData; import org.javarosa.form.api.FormEntryPrompt; -import org.odk.collect.android.databinding.GeoWidgetAnswerBinding; +import org.odk.collect.android.databinding.GeopointQuestionBinding; import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.android.widgets.interfaces.WidgetDataReceiver; import org.odk.collect.android.widgets.interfaces.GeoDataRequester; @@ -33,7 +33,7 @@ @SuppressLint("ViewConstructor") public class GeoPointMapWidget extends QuestionWidget implements WidgetDataReceiver { - GeoWidgetAnswerBinding binding; + GeopointQuestionBinding binding; private final WaitingForDataRegistry waitingForDataRegistry; private final GeoDataRequester geoDataRequester; @@ -51,7 +51,7 @@ public GeoPointMapWidget(Context context, QuestionDetails questionDetails, @Override protected View onCreateAnswerView(Context context, FormEntryPrompt prompt, int answerFontSize) { - binding = GeoWidgetAnswerBinding.inflate(((Activity) context).getLayoutInflater()); + binding = GeopointQuestionBinding.inflate(((Activity) context).getLayoutInflater()); binding.geoAnswerText.setTextSize(TypedValue.COMPLEX_UNIT_DIP, answerFontSize); @@ -69,9 +69,9 @@ protected View onCreateAnswerView(Context context, FormEntryPrompt prompt, int a answerText = null; } else { if (getFormEntryPrompt().isReadOnly()) { - binding.simpleButton.setText(org.odk.collect.strings.R.string.geopoint_view_read_only); + binding.simpleButton.setText(org.odk.collect.strings.R.string.view_point); } else { - binding.simpleButton.setText(org.odk.collect.strings.R.string.view_change_location); + binding.simpleButton.setText(org.odk.collect.strings.R.string.view_or_change_point); } binding.geoAnswerText.setText(answerToDisplay); @@ -123,7 +123,7 @@ public void setData(Object answer) { answerText = answer.toString(); binding.geoAnswerText.setText(answerToDisplay); binding.geoAnswerText.setVisibility(VISIBLE); - binding.simpleButton.setText(org.odk.collect.strings.R.string.view_change_location); + binding.simpleButton.setText(org.odk.collect.strings.R.string.view_or_change_point); } widgetValueChanged(); } diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/GeoPointWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/GeoPointWidget.java index 4919a388a20..d9b05db4c2c 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/GeoPointWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/GeoPointWidget.java @@ -23,7 +23,7 @@ import org.javarosa.core.model.data.GeoPointData; import org.javarosa.core.model.data.IAnswerData; import org.javarosa.form.api.FormEntryPrompt; -import org.odk.collect.android.databinding.GeoWidgetAnswerBinding; +import org.odk.collect.android.databinding.GeopointQuestionBinding; import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.android.widgets.interfaces.GeoDataRequester; import org.odk.collect.android.widgets.interfaces.WidgetDataReceiver; @@ -32,7 +32,7 @@ @SuppressLint("ViewConstructor") public class GeoPointWidget extends QuestionWidget implements WidgetDataReceiver { - GeoWidgetAnswerBinding binding; + GeopointQuestionBinding binding; private final WaitingForDataRegistry waitingForDataRegistry; private final GeoDataRequester geoDataRequester; @@ -50,7 +50,7 @@ public GeoPointWidget(Context context, QuestionDetails questionDetails, WaitingF @Override protected View onCreateAnswerView(Context context, FormEntryPrompt prompt, int answerFontSize) { - binding = GeoWidgetAnswerBinding.inflate(((Activity) context).getLayoutInflater()); + binding = GeopointQuestionBinding.inflate(((Activity) context).getLayoutInflater()); binding.geoAnswerText.setTextSize(TypedValue.COMPLEX_UNIT_DIP, answerFontSize); if (prompt.isReadOnly()) { @@ -67,7 +67,7 @@ protected View onCreateAnswerView(Context context, FormEntryPrompt prompt, int a answerText = null; } else { binding.geoAnswerText.setText(answerToDisplay); - binding.simpleButton.setText(org.odk.collect.strings.R.string.change_location); + binding.simpleButton.setText(org.odk.collect.strings.R.string.change_point); } binding.geoAnswerText.setVisibility(binding.geoAnswerText.getText().toString().isBlank() ? GONE : VISIBLE); @@ -116,7 +116,7 @@ public void setData(Object answer) { answerText = answer.toString(); binding.geoAnswerText.setText(answerToDisplay); binding.geoAnswerText.setVisibility(VISIBLE); - binding.simpleButton.setText(org.odk.collect.strings.R.string.change_location); + binding.simpleButton.setText(org.odk.collect.strings.R.string.change_point); } widgetValueChanged(); } diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/GeoShapeWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/GeoShapeWidget.java index 2fcaa31abfe..1f17396935c 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/GeoShapeWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/GeoShapeWidget.java @@ -23,7 +23,7 @@ import org.javarosa.core.model.data.StringData; import org.javarosa.form.api.FormEntryPrompt; -import org.odk.collect.android.databinding.GeoWidgetAnswerBinding; +import org.odk.collect.android.databinding.GeoshapeQuestionBinding; import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.android.widgets.interfaces.WidgetDataReceiver; import org.odk.collect.android.widgets.interfaces.GeoDataRequester; @@ -32,7 +32,7 @@ @SuppressLint("ViewConstructor") public class GeoShapeWidget extends QuestionWidget implements WidgetDataReceiver { - GeoWidgetAnswerBinding binding; + GeoshapeQuestionBinding binding; private final WaitingForDataRegistry waitingForDataRegistry; private final GeoDataRequester geoDataRequester; @@ -48,7 +48,7 @@ public GeoShapeWidget(Context context, QuestionDetails questionDetails, WaitingF @Override protected View onCreateAnswerView(Context context, FormEntryPrompt prompt, int answerFontSize) { - binding = GeoWidgetAnswerBinding.inflate(((Activity) context).getLayoutInflater()); + binding = GeoshapeQuestionBinding.inflate(((Activity) context).getLayoutInflater()); binding.geoAnswerText.setTextSize(TypedValue.COMPLEX_UNIT_DIP, answerFontSize); @@ -63,15 +63,15 @@ protected View onCreateAnswerView(Context context, FormEntryPrompt prompt, int a if (getFormEntryPrompt().isReadOnly()) { if (dataAvailable) { - binding.simpleButton.setText(org.odk.collect.strings.R.string.geoshape_view_read_only); + binding.simpleButton.setText(org.odk.collect.strings.R.string.view_polygon); } else { binding.simpleButton.setVisibility(View.GONE); } } else { if (dataAvailable) { - binding.simpleButton.setText(org.odk.collect.strings.R.string.geoshape_view_change_location); + binding.simpleButton.setText(org.odk.collect.strings.R.string.view_or_change_polygon); } else { - binding.simpleButton.setText(org.odk.collect.strings.R.string.get_shape); + binding.simpleButton.setText(org.odk.collect.strings.R.string.get_polygon); } } @@ -87,7 +87,7 @@ public IAnswerData getAnswer() { public void clearAnswer() { binding.geoAnswerText.setText(null); binding.geoAnswerText.setVisibility(GONE); - binding.simpleButton.setText(org.odk.collect.strings.R.string.get_shape); + binding.simpleButton.setText(org.odk.collect.strings.R.string.get_polygon); widgetValueChanged(); } @@ -108,7 +108,7 @@ public void cancelLongPress() { public void setData(Object answer) { binding.geoAnswerText.setText(answer.toString()); binding.geoAnswerText.setVisibility(binding.geoAnswerText.getText().toString().isBlank() ? GONE : VISIBLE); - binding.simpleButton.setText(answer.toString().isEmpty() ? org.odk.collect.strings.R.string.get_shape : org.odk.collect.strings.R.string.geoshape_view_change_location); + binding.simpleButton.setText(answer.toString().isEmpty() ? org.odk.collect.strings.R.string.get_polygon : org.odk.collect.strings.R.string.view_or_change_polygon); widgetValueChanged(); } diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/GeoTraceWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/GeoTraceWidget.java index f227028a151..d811656846e 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/GeoTraceWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/GeoTraceWidget.java @@ -23,7 +23,7 @@ import org.javarosa.core.model.data.IAnswerData; import org.javarosa.core.model.data.StringData; import org.javarosa.form.api.FormEntryPrompt; -import org.odk.collect.android.databinding.GeoWidgetAnswerBinding; +import org.odk.collect.android.databinding.GeotraceQuestionBinding; import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.maps.MapConfigurator; import org.odk.collect.android.widgets.interfaces.GeoDataRequester; @@ -37,7 +37,7 @@ */ @SuppressLint("ViewConstructor") public class GeoTraceWidget extends QuestionWidget implements WidgetDataReceiver { - GeoWidgetAnswerBinding binding; + GeotraceQuestionBinding binding; private final WaitingForDataRegistry waitingForDataRegistry; private final MapConfigurator mapConfigurator; @@ -55,7 +55,7 @@ public GeoTraceWidget(Context context, QuestionDetails questionDetails, WaitingF @Override protected View onCreateAnswerView(Context context, FormEntryPrompt prompt, int answerFontSize) { - binding = GeoWidgetAnswerBinding.inflate(((Activity) context).getLayoutInflater()); + binding = GeotraceQuestionBinding.inflate(((Activity) context).getLayoutInflater()); binding.geoAnswerText.setTextSize(TypedValue.COMPLEX_UNIT_DIP, answerFontSize); @@ -75,15 +75,15 @@ protected View onCreateAnswerView(Context context, FormEntryPrompt prompt, int a if (getFormEntryPrompt().isReadOnly()) { if (dataAvailable) { - binding.simpleButton.setText(org.odk.collect.strings.R.string.geotrace_view_read_only); + binding.simpleButton.setText(org.odk.collect.strings.R.string.view_line); } else { binding.simpleButton.setVisibility(View.GONE); } } else { if (dataAvailable) { - binding.simpleButton.setText(org.odk.collect.strings.R.string.geotrace_view_change_location); + binding.simpleButton.setText(org.odk.collect.strings.R.string.view_or_change_line); } else { - binding.simpleButton.setText(org.odk.collect.strings.R.string.get_trace); + binding.simpleButton.setText(org.odk.collect.strings.R.string.get_line); } } @@ -105,7 +105,7 @@ public void setOnLongClickListener(OnLongClickListener l) { public void clearAnswer() { binding.geoAnswerText.setText(null); binding.geoAnswerText.setVisibility(GONE); - binding.simpleButton.setText(org.odk.collect.strings.R.string.get_trace); + binding.simpleButton.setText(org.odk.collect.strings.R.string.get_line); widgetValueChanged(); } @@ -120,7 +120,7 @@ public void cancelLongPress() { public void setData(Object answer) { binding.geoAnswerText.setText(answer.toString()); binding.geoAnswerText.setVisibility(binding.geoAnswerText.getText().toString().isBlank() ? GONE : VISIBLE); - binding.simpleButton.setText(answer.toString().isEmpty() ? org.odk.collect.strings.R.string.get_trace : org.odk.collect.strings.R.string.geotrace_view_change_location); + binding.simpleButton.setText(answer.toString().isEmpty() ? org.odk.collect.strings.R.string.get_line : org.odk.collect.strings.R.string.view_or_change_line); widgetValueChanged(); } diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/ImageWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/ImageWidget.java index d9d76996a7e..d5797786ae5 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/ImageWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/ImageWidget.java @@ -14,7 +14,6 @@ package org.odk.collect.android.widgets; -import static org.odk.collect.android.formentry.questions.WidgetViewUtils.createSimpleButton; import static org.odk.collect.android.utilities.ApplicationConstants.RequestCodes; import android.annotation.SuppressLint; @@ -22,20 +21,17 @@ import android.content.Context; import android.content.Intent; import android.view.View; -import android.widget.Button; - -import org.odk.collect.android.R; +import org.javarosa.form.api.FormEntryPrompt; +import org.odk.collect.android.databinding.ImageWidgetBinding; import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.android.storage.StoragePathProvider; import org.odk.collect.android.storage.StorageSubdirectory; import org.odk.collect.android.utilities.Appearances; import org.odk.collect.android.utilities.QuestionMediaManager; -import org.odk.collect.android.widgets.interfaces.ButtonClickListener; import org.odk.collect.android.widgets.utilities.ImageCaptureIntentCreator; import org.odk.collect.android.widgets.utilities.WaitingForDataRegistry; import org.odk.collect.androidshared.system.CameraUtils; import org.odk.collect.selfiecamera.CaptureSelfieActivity; - import java.util.Locale; /** @@ -46,41 +42,43 @@ */ @SuppressLint("ViewConstructor") -public class ImageWidget extends BaseImageWidget implements ButtonClickListener { - - Button captureButton; - Button chooseButton; +public class ImageWidget extends BaseImageWidget { + ImageWidgetBinding binding; private boolean selfie; public ImageWidget(Context context, final QuestionDetails prompt, QuestionMediaManager questionMediaManager, WaitingForDataRegistry waitingForDataRegistry, String tmpImageFilePath) { super(context, prompt, questionMediaManager, waitingForDataRegistry, tmpImageFilePath); - render(); - imageClickHandler = new ViewImageClickHandler(); imageCaptureHandler = new ImageCaptureHandler(); - setUpLayout(); + + render(); updateAnswer(); - addAnswerView(answerLayout); } @Override - protected void setUpLayout() { - super.setUpLayout(); + protected View onCreateAnswerView(Context context, FormEntryPrompt prompt, int answerFontSize) { + binding = ImageWidgetBinding.inflate(((Activity) context).getLayoutInflater()); - String appearance = getFormEntryPrompt().getAppearanceHint(); - selfie = Appearances.isFrontCameraAppearance(getFormEntryPrompt()); + String appearance = prompt.getAppearanceHint(); + selfie = Appearances.isFrontCameraAppearance(prompt); + if (selfie || ((appearance != null && appearance.toLowerCase(Locale.ENGLISH).contains(Appearances.NEW)))) { + binding.chooseButton.setVisibility(View.GONE); + } - captureButton = createSimpleButton(getContext(), questionDetails.isReadOnly(), getContext().getString(org.odk.collect.strings.R.string.capture_image), this, false, R.id.capture_image); + binding.captureButton.setOnClickListener(v -> getPermissionsProvider().requestCameraPermission((Activity) getContext(), this::captureImage)); + binding.chooseButton.setOnClickListener(v -> imageCaptureHandler.chooseImage(org.odk.collect.strings.R.string.choose_image)); + binding.image.setOnClickListener(v -> imageClickHandler.clickImage("viewImage")); - chooseButton = createSimpleButton(getContext(), questionDetails.isReadOnly(), getContext().getString(org.odk.collect.strings.R.string.choose_image), this, true, R.id.choose_image); + if (questionDetails.isReadOnly()) { + binding.captureButton.setVisibility(View.GONE); + binding.chooseButton.setVisibility(View.GONE); + } - answerLayout.addView(captureButton); - answerLayout.addView(chooseButton); - answerLayout.addView(errorTextView); - answerLayout.addView(imageView); + errorTextView = binding.errorMessage; + imageView = binding.image; - hideButtonsIfNeeded(appearance); + return binding.getRoot(); } @Override @@ -96,38 +94,21 @@ protected boolean doesSupportDefaultValues() { @Override public void clearAnswer() { super.clearAnswer(); - // reset buttons - captureButton.setText(getContext().getString(org.odk.collect.strings.R.string.capture_image)); + binding.captureButton.setText(getContext().getString(org.odk.collect.strings.R.string.capture_image)); } @Override public void setOnLongClickListener(OnLongClickListener l) { - captureButton.setOnLongClickListener(l); - chooseButton.setOnLongClickListener(l); + binding.captureButton.setOnLongClickListener(l); + binding.chooseButton.setOnLongClickListener(l); super.setOnLongClickListener(l); } @Override public void cancelLongPress() { super.cancelLongPress(); - captureButton.cancelLongPress(); - chooseButton.cancelLongPress(); - } - - @Override - public void onButtonClick(int buttonId) { - if (buttonId == R.id.capture_image) { - getPermissionsProvider().requestCameraPermission((Activity) getContext(), this::captureImage); - } else if (buttonId == R.id.choose_image) { - imageCaptureHandler.chooseImage(org.odk.collect.strings.R.string.choose_image); - } - } - - private void hideButtonsIfNeeded(String appearance) { - if (selfie || ((appearance != null - && appearance.toLowerCase(Locale.ENGLISH).contains(Appearances.NEW)))) { - chooseButton.setVisibility(View.GONE); - } + binding.captureButton.cancelLongPress(); + binding.chooseButton.cancelLongPress(); } private void captureImage() { @@ -140,5 +121,4 @@ private void captureImage() { imageCaptureHandler.captureImage(intent, RequestCodes.IMAGE_CAPTURE, org.odk.collect.strings.R.string.capture_image); } } - } diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/IntegerWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/IntegerWidget.java index adc9eaaedc7..23e9a75c8ed 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/IntegerWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/IntegerWidget.java @@ -30,7 +30,6 @@ public class IntegerWidget extends StringWidget { public IntegerWidget(Context context, QuestionDetails questionDetails) { super(context, questionDetails); - render(); boolean useThousandSeparator = Appearances.useThousandSeparator(questionDetails.getPrompt()); Integer answer = StringWidgetUtils.getIntegerAnswerValueFromIAnswerData(questionDetails.getPrompt().getAnswerValue()); diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/PrinterWidget.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/PrinterWidget.kt index feddaf95abe..64df71b147c 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/PrinterWidget.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/PrinterWidget.kt @@ -5,13 +5,13 @@ import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import com.google.android.material.button.MaterialButton import org.javarosa.core.model.data.IAnswerData import org.javarosa.form.api.FormEntryPrompt import org.odk.collect.android.R import org.odk.collect.android.formentry.questions.QuestionDetails import org.odk.collect.android.utilities.QuestionMediaManager import org.odk.collect.android.widgets.interfaces.Printer +import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickSafeMaterialButton class PrinterWidget( context: Context, @@ -27,7 +27,7 @@ class PrinterWidget( override fun onCreateAnswerView(context: Context, prompt: FormEntryPrompt, answerFontSize: Int): View { val answerView = LayoutInflater.from(context).inflate(R.layout.printer_widget, null) answerView - .findViewById(R.id.printer_button) + .findViewById(R.id.printer_button) .setOnClickListener { print() } diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/QuestionWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/QuestionWidget.java index 80630660e2b..b1cb2636d71 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/QuestionWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/QuestionWidget.java @@ -75,7 +75,7 @@ public abstract class QuestionWidget extends FrameLayout implements Widget { private final View guidanceTextLayout; private final View textLayout; private final TextView warningText; - protected final View errorLayout; + public final View errorLayout; protected final Settings settings; private AtomicBoolean expanded; protected final ThemeUtils themeUtils; @@ -145,7 +145,15 @@ public void render() { ); if (answerView != null) { - addAnswerView(answerView); + ViewGroup answerContainer = findViewById(R.id.answer_container); + + RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + params.addRule(RelativeLayout.ALIGN_PARENT_LEFT, RelativeLayout.TRUE); + + answerContainer.addView(answerView, params); + + adjustButtonFontSize(answerContainer); } } @@ -305,22 +313,6 @@ private TextView setupHelpText(TextView helpText, FormEntryPrompt prompt) { } } - /** - * Widget should use {@link #onCreateAnswerView} to define answer view - */ - @Deprecated - protected final void addAnswerView(View v) { - ViewGroup answerContainer = findViewById(R.id.answer_container); - - RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); - params.addRule(RelativeLayout.ALIGN_PARENT_LEFT, RelativeLayout.TRUE); - - answerContainer.addView(v, params); - - adjustButtonFontSize(answerContainer); - } - private void hideAnswerContainerIfNeeded() { if (questionDetails.isReadOnly() && formEntryPrompt.getAnswerValue() == null) { findViewById(R.id.answer_container).setVisibility(GONE); diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/SignatureWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/SignatureWidget.java index 17b4852848d..efd7e4eb159 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/SignatureWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/SignatureWidget.java @@ -14,18 +14,18 @@ package org.odk.collect.android.widgets; -import static org.odk.collect.android.formentry.questions.WidgetViewUtils.createSimpleButton; import static org.odk.collect.android.utilities.ApplicationConstants.RequestCodes; import android.annotation.SuppressLint; +import android.app.Activity; import android.content.Context; import android.content.Intent; -import android.widget.Button; - +import android.view.View; +import org.javarosa.form.api.FormEntryPrompt; +import org.odk.collect.android.databinding.SignatureWidgetBinding; import org.odk.collect.draw.DrawActivity; import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.android.utilities.QuestionMediaManager; -import org.odk.collect.android.widgets.interfaces.ButtonClickListener; import org.odk.collect.android.widgets.utilities.WaitingForDataRegistry; /** @@ -34,28 +34,31 @@ * @author BehrAtherton@gmail.com */ @SuppressLint("ViewConstructor") -public class SignatureWidget extends BaseImageWidget implements ButtonClickListener { - - Button signButton; +public class SignatureWidget extends BaseImageWidget { + SignatureWidgetBinding binding; public SignatureWidget(Context context, QuestionDetails prompt, QuestionMediaManager questionMediaManager, WaitingForDataRegistry waitingForDataRegistry, String tmpImageFilePath) { super(context, prompt, questionMediaManager, waitingForDataRegistry, tmpImageFilePath); - render(); - imageClickHandler = new DrawImageClickHandler(DrawActivity.OPTION_SIGNATURE, RequestCodes.SIGNATURE_CAPTURE, org.odk.collect.strings.R.string.signature_capture); - setUpLayout(); + + render(); updateAnswer(); - addAnswerView(answerLayout); } @Override - protected void setUpLayout() { - super.setUpLayout(); - signButton = createSimpleButton(getContext(), questionDetails.isReadOnly(), getContext().getString(org.odk.collect.strings.R.string.sign_button), this, false); + protected View onCreateAnswerView(Context context, FormEntryPrompt prompt, int answerFontSize) { + binding = SignatureWidgetBinding.inflate(((Activity) context).getLayoutInflater()); + binding.signButton.setOnClickListener(v -> imageClickHandler.clickImage("signButton")); + binding.image.setOnClickListener(v -> imageClickHandler.clickImage("viewImage")); - answerLayout.addView(signButton); - answerLayout.addView(errorTextView); - answerLayout.addView(imageView); + if (questionDetails.isReadOnly()) { + binding.signButton.setVisibility(View.GONE); + } + + errorTextView = binding.errorMessage; + imageView = binding.image; + + return binding.getRoot(); } @Override @@ -71,24 +74,18 @@ protected boolean doesSupportDefaultValues() { @Override public void clearAnswer() { super.clearAnswer(); - // reset buttons - signButton.setText(getContext().getString(org.odk.collect.strings.R.string.sign_button)); + binding.signButton.setText(getContext().getString(org.odk.collect.strings.R.string.sign_button)); } @Override public void setOnLongClickListener(OnLongClickListener l) { - signButton.setOnLongClickListener(l); + binding.signButton.setOnLongClickListener(l); super.setOnLongClickListener(l); } @Override public void cancelLongPress() { super.cancelLongPress(); - signButton.cancelLongPress(); - } - - @Override - public void onButtonClick(int buttonId) { - imageClickHandler.clickImage("signButton"); + binding.signButton.cancelLongPress(); } } diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/StringNumberWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/StringNumberWidget.java index 0a57e121903..f5e3c8b1f1b 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/StringNumberWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/StringNumberWidget.java @@ -30,7 +30,6 @@ public class StringNumberWidget extends StringWidget { public StringNumberWidget(Context context, QuestionDetails questionDetails) { super(context, questionDetails); - render(); boolean useThousandSeparator = Appearances.useThousandSeparator(questionDetails.getPrompt()); String answer = questionDetails.getPrompt().getAnswerValue() == null diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/StringWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/StringWidget.java index 07207da40ad..7ed1b1d9fbc 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/StringWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/StringWidget.java @@ -23,16 +23,15 @@ import androidx.annotation.NonNull; import androidx.core.content.ContextCompat; -import org.javarosa.core.model.QuestionDef; import org.javarosa.core.model.data.IAnswerData; import org.javarosa.core.model.data.StringData; import org.javarosa.form.api.FormEntryPrompt; import org.odk.collect.android.R; import org.odk.collect.android.formentry.questions.QuestionDetails; +import org.odk.collect.android.utilities.Appearances; import org.odk.collect.android.views.WidgetAnswerText; import org.odk.collect.android.widgets.utilities.QuestionFontSizeUtils; - -import timber.log.Timber; +import org.odk.collect.android.widgets.utilities.StringWidgetUtils; /** * The most basic widget that allows for entry of any text. @@ -47,17 +46,19 @@ protected StringWidget(Context context, QuestionDetails questionDetails) { widgetAnswerText = new WidgetAnswerText(context); widgetAnswerText.init( QuestionFontSizeUtils.getFontSize(settings, QuestionFontSizeUtils.FontSize.HEADLINE_6), - questionDetails.isReadOnly() || this instanceof ExStringWidget, - getNumberOfRows(questionDetails.getPrompt()), + questionDetails.isReadOnly(), + StringWidgetUtils.getNumberOfRows(questionDetails.getPrompt()), + Appearances.isMasked(questionDetails.getPrompt()), this::widgetValueChanged ); - setUpLayout(context); + render(); } - protected void setUpLayout(Context context) { + @Override + protected View onCreateAnswerView(@NonNull Context context, @NonNull FormEntryPrompt prompt, int answerFontSize) { setDisplayValueFromModel(); - addAnswerView(widgetAnswerText); + return widgetAnswerText; } @Override @@ -117,32 +118,6 @@ public void setDisplayValueFromModel() { } } - private Integer getNumberOfRows(FormEntryPrompt prompt) { - QuestionDef questionDef = prompt.getQuestion(); - if (questionDef != null) { - /* - * If a 'rows' attribute is on the input tag, set the minimum number of lines - * to display in the field to that value. - * - * I.e., - * - * ... - * - * - * will set the height of the EditText box to 5 rows high. - */ - String height = questionDef.getAdditionalAttribute(null, "rows"); - if (height != null && height.length() != 0) { - try { - return Integer.parseInt(height); - } catch (Exception e) { - Timber.e(new Error("Unable to process the rows setting for the answerText field: " + e)); - } - } - } - return null; - } - @Override public void hideError() { widgetAnswerText.setError(null); diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/UrlWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/UrlWidget.java index d1458f5a61e..d6e8579f928 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/UrlWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/UrlWidget.java @@ -25,8 +25,8 @@ import org.javarosa.form.api.FormEntryPrompt; import org.odk.collect.android.databinding.UrlWidgetAnswerBinding; import org.odk.collect.android.formentry.questions.QuestionDetails; -import org.odk.collect.android.utilities.ExternalWebPageHelper; import org.odk.collect.androidshared.ui.ToastUtils; +import org.odk.collect.webpage.ExternalWebPageHelper; @SuppressLint("ViewConstructor") public class UrlWidget extends QuestionWidget { diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/VideoWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/VideoWidget.java index 6a133c247a9..eca5256f0e8 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/VideoWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/VideoWidget.java @@ -14,9 +14,6 @@ package org.odk.collect.android.widgets; -import static org.odk.collect.android.analytics.AnalyticsEvents.REQUEST_HIGH_RES_VIDEO; -import static org.odk.collect.android.analytics.AnalyticsEvents.REQUEST_VIDEO_NOT_HIGH_RES; -import static org.odk.collect.android.formentry.questions.WidgetViewUtils.createSimpleButton; import static org.odk.collect.android.utilities.ApplicationConstants.RequestCodes; import android.annotation.SuppressLint; @@ -26,18 +23,17 @@ import android.content.Intent; import android.provider.MediaStore; import android.view.View; -import android.widget.Button; -import android.widget.LinearLayout; import android.widget.Toast; +import androidx.annotation.NonNull; + import org.javarosa.core.model.data.IAnswerData; import org.javarosa.core.model.data.StringData; -import org.odk.collect.analytics.Analytics; -import org.odk.collect.android.R; +import org.javarosa.form.api.FormEntryPrompt; +import org.odk.collect.android.databinding.VideoWidgetBinding; import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.android.utilities.Appearances; import org.odk.collect.android.utilities.QuestionMediaManager; -import org.odk.collect.android.widgets.interfaces.ButtonClickListener; import org.odk.collect.android.widgets.interfaces.FileWidget; import org.odk.collect.android.widgets.interfaces.WidgetDataReceiver; import org.odk.collect.android.widgets.utilities.WaitingForDataRegistry; @@ -56,53 +52,50 @@ * @author Yaw Anokwa (yanokwa@gmail.com) */ @SuppressLint("ViewConstructor") -public class VideoWidget extends QuestionWidget implements FileWidget, ButtonClickListener, WidgetDataReceiver { +public class VideoWidget extends QuestionWidget implements FileWidget, WidgetDataReceiver { private final WaitingForDataRegistry waitingForDataRegistry; private final QuestionMediaManager questionMediaManager; - - Button captureButton; - Button playButton; - Button chooseButton; private String binaryName; + VideoWidgetBinding binding; public VideoWidget(Context context, QuestionDetails prompt, QuestionMediaManager questionMediaManager, WaitingForDataRegistry waitingForDataRegistry) { this(context, prompt, waitingForDataRegistry, questionMediaManager); - render(); } public VideoWidget(Context context, QuestionDetails questionDetails, WaitingForDataRegistry waitingForDataRegistry, QuestionMediaManager questionMediaManager) { super(context, questionDetails); - render(); - this.waitingForDataRegistry = waitingForDataRegistry; this.questionMediaManager = questionMediaManager; + binaryName = questionDetails.getPrompt().getAnswerText(); + render(); + } - captureButton = createSimpleButton(getContext(), questionDetails.isReadOnly(), getContext().getString(org.odk.collect.strings.R.string.capture_video), this, false, R.id.capture_video); - - chooseButton = createSimpleButton(getContext(), questionDetails.isReadOnly(), getContext().getString(org.odk.collect.strings.R.string.choose_video), this, true, R.id.choose_video); + @Override + protected View onCreateAnswerView(@NonNull Context context, @NonNull FormEntryPrompt prompt, int answerFontSize) { + binding = VideoWidgetBinding.inflate(((Activity) context).getLayoutInflater()); - playButton = createSimpleButton(getContext(), false, getContext().getString(org.odk.collect.strings.R.string.play_video), this, true, R.id.play_video); - playButton.setVisibility(VISIBLE); + binding.recordVideoButton.setOnClickListener(v -> getPermissionsProvider().requestCameraPermission((Activity) getContext(), this::captureVideo)); + binding.chooseVideoButton.setOnClickListener(v -> chooseVideo()); + binding.playVideoButton.setEnabled(binaryName != null); + binding.playVideoButton.setOnClickListener(v -> playVideoFile()); - // retrieve answer from data model and update ui - binaryName = questionDetails.getPrompt().getAnswerText(); - playButton.setEnabled(binaryName != null); + if (questionDetails.isReadOnly()) { + binding.recordVideoButton.setVisibility(View.GONE); + binding.chooseVideoButton.setVisibility(View.GONE); + } - // finish complex layout - LinearLayout answerLayout = new LinearLayout(getContext()); - answerLayout.setOrientation(LinearLayout.VERTICAL); - answerLayout.addView(captureButton); - answerLayout.addView(chooseButton); - answerLayout.addView(playButton); - addAnswerView(answerLayout); + if (getFormEntryPrompt().getAppearanceHint() != null + && getFormEntryPrompt().getAppearanceHint().toLowerCase(Locale.ENGLISH).contains(Appearances.NEW)) { + binding.chooseVideoButton.setVisibility(View.GONE); + } - hideButtonsIfNeeded(); + return binding.getRoot(); } @Override public void deleteFile() { questionMediaManager.deleteAnswerFile(getFormEntryPrompt().getIndex().toString(), - questionMediaManager.getAnswerFile(binaryName).getAbsolutePath()); + questionMediaManager.getAnswerFile(binaryName).getAbsolutePath()); binaryName = null; } @@ -110,10 +103,7 @@ public void deleteFile() { public void clearAnswer() { // remove the file deleteFile(); - - // reset buttons - playButton.setEnabled(false); - + binding.playVideoButton.setEnabled(false); widgetValueChanged(); } @@ -138,7 +128,7 @@ public void setData(Object object) { questionMediaManager.replaceAnswerFile(getFormEntryPrompt().getIndex().toString(), newVideo.getAbsolutePath()); binaryName = newVideo.getName(); widgetValueChanged(); - playButton.setEnabled(binaryName != null); + binding.playVideoButton.setEnabled(binaryName != null); } else { Timber.e(new Error("Inserting Video file FAILED")); } @@ -147,37 +137,19 @@ public void setData(Object object) { } } - private void hideButtonsIfNeeded() { - if (getFormEntryPrompt().getAppearanceHint() != null - && getFormEntryPrompt().getAppearanceHint().toLowerCase(Locale.ENGLISH).contains(Appearances.NEW)) { - chooseButton.setVisibility(View.GONE); - } - } - @Override public void setOnLongClickListener(OnLongClickListener l) { - captureButton.setOnLongClickListener(l); - chooseButton.setOnLongClickListener(l); - playButton.setOnLongClickListener(l); + binding.recordVideoButton.setOnLongClickListener(l); + binding.chooseVideoButton.setOnLongClickListener(l); + binding.playVideoButton.setOnLongClickListener(l); } @Override public void cancelLongPress() { super.cancelLongPress(); - captureButton.cancelLongPress(); - chooseButton.cancelLongPress(); - playButton.cancelLongPress(); - } - - @Override - public void onButtonClick(int id) { - if (id == R.id.capture_video) { - getPermissionsProvider().requestCameraPermission((Activity) getContext(), this::captureVideo); - } else if (id == R.id.choose_video) { - chooseVideo(); - } else if (id == R.id.play_video) { - playVideoFile(); - } + binding.recordVideoButton.cancelLongPress(); + binding.chooseVideoButton.cancelLongPress(); + binding.playVideoButton.cancelLongPress(); } private void captureVideo() { @@ -188,10 +160,8 @@ private void captureVideo() { boolean highResolution = settingsProvider.getUnprotectedSettings().getBoolean(ProjectKeys.KEY_HIGH_RESOLUTION); if (highResolution) { i.putExtra(MediaStore.EXTRA_VIDEO_QUALITY, 1); - Analytics.log(REQUEST_HIGH_RES_VIDEO, "form"); - } else { - Analytics.log(REQUEST_VIDEO_NOT_HIGH_RES, "form"); } + try { waitingForDataRegistry.waitForData(getFormEntryPrompt().getIndex()); ((Activity) getContext()).startActivityForResult(i, requestCode); diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/WidgetFactory.java b/collect_app/src/main/java/org/odk/collect/android/widgets/WidgetFactory.java index 0b6f5977a1b..c37f7da344b 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/WidgetFactory.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/WidgetFactory.java @@ -34,7 +34,6 @@ import org.odk.collect.android.listeners.AdvanceToNextListener; import org.odk.collect.android.storage.StoragePathProvider; import org.odk.collect.android.utilities.Appearances; -import org.odk.collect.android.utilities.ExternalWebPageHelper; import org.odk.collect.android.utilities.QuestionMediaManager; import org.odk.collect.android.widgets.items.LabelWidget; import org.odk.collect.android.widgets.items.LikertWidget; @@ -66,6 +65,7 @@ import org.odk.collect.androidshared.system.IntentLauncherImpl; import org.odk.collect.audiorecorder.recording.AudioRecorder; import org.odk.collect.permissions.PermissionsProvider; +import org.odk.collect.webpage.ExternalWebPageHelper; /** * Convenience class that handles creation of widgets. @@ -143,7 +143,7 @@ public QuestionWidget createWidgetFromPrompt(FormEntryPrompt prompt, Permissions questionWidget = new TimeWidget(activity, questionDetails, new DateTimeWidgetUtils(), waitingForDataRegistry); break; case Constants.DATATYPE_DECIMAL: - if (appearance.startsWith(Appearances.EX)) { + if (appearance.contains(Appearances.EX)) { questionWidget = new ExDecimalWidget(activity, questionDetails, waitingForDataRegistry, stringRequester); } else if (appearance.equals(Appearances.BEARING)) { questionWidget = new BearingWidget(activity, questionDetails, waitingForDataRegistry, @@ -153,7 +153,7 @@ public QuestionWidget createWidgetFromPrompt(FormEntryPrompt prompt, Permissions } break; case Constants.DATATYPE_INTEGER: - if (appearance.startsWith(Appearances.EX)) { + if (appearance.contains(Appearances.EX)) { questionWidget = new ExIntegerWidget(activity, questionDetails, waitingForDataRegistry, stringRequester); } else { questionWidget = new IntegerWidget(activity, questionDetails); @@ -187,7 +187,7 @@ public QuestionWidget createWidgetFromPrompt(FormEntryPrompt prompt, Permissions questionWidget = new PrinterWidget(activity, questionDetails, printerWidgetViewModel, questionMediaManager); } else if (appearance.startsWith(Appearances.PRINTER)) { questionWidget = new ExPrinterWidget(activity, questionDetails, waitingForDataRegistry); - } else if (appearance.startsWith(Appearances.EX)) { + } else if (appearance.contains(Appearances.EX)) { questionWidget = new ExStringWidget(activity, questionDetails, waitingForDataRegistry, stringRequester); } else if (appearance.contains(Appearances.NUMBERS)) { questionWidget = new StringNumberWidget(activity, questionDetails); @@ -275,14 +275,14 @@ public QuestionWidget createWidgetFromPrompt(FormEntryPrompt prompt, Permissions } else { switch (prompt.getDataType()) { case Constants.DATATYPE_INTEGER: - if (prompt.getQuestion().getAppearanceAttr() != null && prompt.getQuestion().getAppearanceAttr().contains(PICKER_APPEARANCE)) { + if (prompt.getAppearanceHint() != null && prompt.getAppearanceHint().contains(PICKER_APPEARANCE)) { questionWidget = new RangePickerIntegerWidget(activity, questionDetails); } else { questionWidget = new RangeIntegerWidget(activity, questionDetails); } break; case Constants.DATATYPE_DECIMAL: - if (prompt.getQuestion().getAppearanceAttr() != null && prompt.getQuestion().getAppearanceAttr().contains(PICKER_APPEARANCE)) { + if (prompt.getAppearanceHint() != null && prompt.getAppearanceHint().contains(PICKER_APPEARANCE)) { questionWidget = new RangePickerDecimalWidget(activity, questionDetails); } else { questionWidget = new RangeDecimalWidget(activity, questionDetails); diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/interfaces/ButtonClickListener.java b/collect_app/src/main/java/org/odk/collect/android/widgets/interfaces/ButtonClickListener.java deleted file mode 100644 index f884120c8e0..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/interfaces/ButtonClickListener.java +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2017 Nafundi - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.odk.collect.android.widgets.interfaces; - -public interface ButtonClickListener { - void onButtonClick(int buttonId); -} diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/items/LikertWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/items/LikertWidget.java index c6ba7d25bdc..8994dcfe7d5 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/items/LikertWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/items/LikertWidget.java @@ -18,6 +18,8 @@ import android.widget.RelativeLayout; import android.widget.TextView; +import androidx.annotation.NonNull; + import com.google.android.material.radiobutton.MaterialRadioButton; import org.javarosa.core.model.SelectChoice; @@ -27,6 +29,7 @@ import org.javarosa.core.reference.InvalidReferenceException; import org.javarosa.core.reference.ReferenceManager; import org.javarosa.form.api.FormEntryCaption; +import org.javarosa.form.api.FormEntryPrompt; import org.odk.collect.android.dynamicpreload.ExternalSelectChoice; import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.android.utilities.HtmlUtils; @@ -42,7 +45,6 @@ @SuppressLint("ViewConstructor") public class LikertWidget extends QuestionWidget { - LinearLayout view; private RadioButton checkedButton; private final LinearLayout.LayoutParams linearLayoutParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT, 1); @@ -56,16 +58,18 @@ public class LikertWidget extends QuestionWidget { public LikertWidget(Context context, QuestionDetails questionDetails, SelectChoiceLoader selectChoiceLoader) { super(context, questionDetails); - render(); - items = ItemsWidgetUtils.loadItemsAndHandleErrors(this, questionDetails.getPrompt(), selectChoiceLoader); setMainViewLayoutParameters(); setStructures(); - setButtonListener(); setSavedButton(); - addAnswerView(view); + render(); + } + + @Override + protected View onCreateAnswerView(@NonNull Context context, @NonNull FormEntryPrompt prompt, int answerFontSize) { + return view; } public void setMainViewLayoutParameters() { diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/items/RankingWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/items/RankingWidget.java index afd7607b503..ce245e0e35d 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/items/RankingWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/items/RankingWidget.java @@ -16,51 +16,68 @@ package org.odk.collect.android.widgets.items; -import static org.odk.collect.android.formentry.questions.WidgetViewUtils.createAnswerTextView; -import static org.odk.collect.android.formentry.questions.WidgetViewUtils.createSimpleButton; - import android.annotation.SuppressLint; +import android.app.Activity; import android.content.Context; -import android.widget.Button; -import android.widget.LinearLayout; -import android.widget.TextView; - +import android.util.TypedValue; +import android.view.View; +import androidx.annotation.NonNull; import org.javarosa.core.model.SelectChoice; import org.javarosa.core.model.data.IAnswerData; import org.javarosa.core.model.data.SelectMultiData; import org.javarosa.core.model.data.helper.Selection; +import org.javarosa.form.api.FormEntryPrompt; import org.odk.collect.android.activities.FormFillingActivity; +import org.odk.collect.android.databinding.RankingWidgetBinding; import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.android.fragments.dialogs.RankingWidgetDialog; import org.odk.collect.android.utilities.HtmlUtils; import org.odk.collect.android.widgets.QuestionWidget; -import org.odk.collect.android.widgets.interfaces.ButtonClickListener; import org.odk.collect.android.widgets.interfaces.SelectChoiceLoader; import org.odk.collect.android.widgets.interfaces.WidgetDataReceiver; -import org.odk.collect.android.widgets.utilities.QuestionFontSizeUtils; import org.odk.collect.android.widgets.utilities.WaitingForDataRegistry; import org.odk.collect.android.widgets.warnings.SpacesInUnderlyingValuesWarning; - import java.util.ArrayList; import java.util.List; @SuppressLint("ViewConstructor") -public class RankingWidget extends QuestionWidget implements WidgetDataReceiver, ButtonClickListener { +public class RankingWidget extends QuestionWidget implements WidgetDataReceiver { private final WaitingForDataRegistry waitingForDataRegistry; private List savedItems; - Button showRankingDialogButton; - private TextView answerTextView; private final List items; + RankingWidgetBinding binding; public RankingWidget(Context context, QuestionDetails prompt, WaitingForDataRegistry waitingForDataRegistry, SelectChoiceLoader selectChoiceLoader) { super(context, prompt); - render(); - this.waitingForDataRegistry = waitingForDataRegistry; items = ItemsWidgetUtils.loadItemsAndHandleErrors(this, questionDetails.getPrompt(), selectChoiceLoader); + readSavedItems(); + render(); + } + + @Override + protected View onCreateAnswerView(@NonNull Context context, @NonNull FormEntryPrompt prompt, int answerFontSize) { + binding = RankingWidgetBinding.inflate(((Activity) context).getLayoutInflater()); + + binding.rankItemsButton.setOnClickListener(v -> { + waitingForDataRegistry.waitForData(getFormEntryPrompt().getIndex()); + RankingWidgetDialog rankingWidgetDialog = new RankingWidgetDialog(savedItems == null ? items : savedItems, getFormEntryPrompt()); + rankingWidgetDialog.show(((FormFillingActivity) getContext()).getSupportFragmentManager(), "RankingDialog"); + }); + binding.answer.setText(getAnswerText()); + binding.answer.setTextSize(TypedValue.COMPLEX_UNIT_DIP, answerFontSize); + binding.answer.setVisibility(binding.answer.getText().toString().isBlank() ? GONE : VISIBLE); + + if (questionDetails.isReadOnly()) { + binding.rankItemsButton.setVisibility(View.GONE); + } + + SpacesInUnderlyingValuesWarning + .forQuestionWidget(this) + .renderWarningIfNecessary(savedItems == null ? items : savedItems); - setUpLayout(getOrderedItems()); + return binding.getRoot(); } @Override @@ -78,47 +95,39 @@ public IAnswerData getAnswer() { @Override public void clearAnswer() { savedItems = null; - answerTextView.setText(getAnswerText()); - answerTextView.setVisibility(GONE); + binding.answer.setText(null); + binding.answer.setVisibility(GONE); widgetValueChanged(); } @Override public void setOnLongClickListener(OnLongClickListener l) { - showRankingDialogButton.setOnLongClickListener(l); + binding.rankItemsButton.setOnLongClickListener(l); + binding.answer.setOnLongClickListener(l); } @Override public void cancelLongPress() { super.cancelLongPress(); - showRankingDialogButton.cancelLongPress(); + binding.rankItemsButton.cancelLongPress(); + binding.answer.cancelLongPress(); } @Override public void setData(Object values) { savedItems = (List) values; - answerTextView.setText(getAnswerText()); - answerTextView.setVisibility(answerTextView.getText().toString().isBlank() ? GONE : VISIBLE); + binding.answer.setText(getAnswerText()); + binding.answer.setVisibility(binding.answer.getText().toString().isBlank() ? GONE : VISIBLE); widgetValueChanged(); } - @Override - public void onButtonClick(int buttonId) { - waitingForDataRegistry.waitForData(getFormEntryPrompt().getIndex()); - - RankingWidgetDialog rankingWidgetDialog = new RankingWidgetDialog(savedItems == null ? items : savedItems, getFormEntryPrompt()); - rankingWidgetDialog.show(((FormFillingActivity) getContext()).getSupportFragmentManager(), "RankingDialog"); - } - - private List getOrderedItems() { + private void readSavedItems() { List savedOrderedItems = getFormEntryPrompt().getAnswerValue() == null ? new ArrayList<>() : (List) getFormEntryPrompt().getAnswerValue().getValue(); - if (savedOrderedItems.isEmpty()) { - return items; - } else { + if (!savedOrderedItems.isEmpty()) { savedItems = new ArrayList<>(); for (Selection selection : savedOrderedItems) { for (SelectChoice selectChoice : items) { @@ -134,27 +143,9 @@ private List getOrderedItems() { savedItems.add(selectChoice); } } - - return savedItems; } } - private void setUpLayout(List items) { - showRankingDialogButton = createSimpleButton(getContext(), getFormEntryPrompt().isReadOnly(), getContext().getString(org.odk.collect.strings.R.string.rank_items), this, false); - answerTextView = createAnswerTextView(getContext(), getAnswerText(), QuestionFontSizeUtils.getFontSize(settings, QuestionFontSizeUtils.FontSize.HEADLINE_6)); - answerTextView.setVisibility(answerTextView.getText().toString().isBlank() ? GONE : VISIBLE); - - LinearLayout widgetLayout = new LinearLayout(getContext()); - widgetLayout.setOrientation(LinearLayout.VERTICAL); - widgetLayout.addView(showRankingDialogButton); - widgetLayout.addView(answerTextView); - - addAnswerView(widgetLayout); - SpacesInUnderlyingValuesWarning - .forQuestionWidget(this) - .renderWarningIfNecessary(items); - } - private CharSequence getAnswerText() { StringBuilder answerText = new StringBuilder(); if (savedItems != null) { diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/items/SelectOneFromMapDialogFragment.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/items/SelectOneFromMapDialogFragment.kt index 439e2dfa039..08b8a5e9466 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/items/SelectOneFromMapDialogFragment.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/items/SelectOneFromMapDialogFragment.kt @@ -28,6 +28,7 @@ import org.odk.collect.androidshared.livedata.MutableNonNullLiveData import org.odk.collect.androidshared.livedata.NonNullLiveData import org.odk.collect.androidshared.ui.FragmentFactoryBuilder import org.odk.collect.async.Scheduler +import org.odk.collect.geo.selection.IconifiedText import org.odk.collect.geo.selection.MappableSelectItem import org.odk.collect.geo.selection.SelectionMapData import org.odk.collect.geo.selection.SelectionMapFragment @@ -135,7 +136,7 @@ internal class SelectChoicesMapData( prompt: FormEntryPrompt ): List { return selectChoices.foldIndexed(emptyList()) { index, list, selectChoice -> - val geometry = selectChoice.getChild("geometry") + val geometry = selectChoice.getChild(GEOMETRY) if (geometry != null) { try { @@ -149,29 +150,60 @@ internal class SelectChoicesMapData( val properties = selectChoice.additionalChildren.filter { it.first != GeojsonFeature.GEOMETRY_CHILD_NAME }.map { - MappableSelectItem.IconifiedText(null, "${it.first}: ${it.second}") + IconifiedText(null, "${it.first}: ${it.second}") } - val markerColor = - selectChoice.additionalChildren.firstOrNull { it.first == "marker-color" }?.second - val markerSymbol = - selectChoice.additionalChildren.firstOrNull { it.first == "marker-symbol" }?.second - - list + MappableSelectItem.WithAction( - index.toLong(), - points, - if (markerSymbol == null) org.odk.collect.icons.R.drawable.ic_map_marker_with_hole_small else org.odk.collect.icons.R.drawable.ic_map_marker_small, - if (markerSymbol == null) org.odk.collect.icons.R.drawable.ic_map_marker_with_hole_big else org.odk.collect.icons.R.drawable.ic_map_marker_big, - prompt.getSelectChoiceText(selectChoice), - properties, - MappableSelectItem.IconifiedText( - org.odk.collect.icons.R.drawable.ic_save, - resources.getString(org.odk.collect.strings.R.string.select_item) - ), - selectChoice.index == selectedIndex, - markerColor, - markerSymbol - ) + if (points.size == 1) { + val markerColor = + getPropertyValue(selectChoice, MARKER_COLOR) + val markerSymbol = + getPropertyValue(selectChoice, MARKER_SYMBOL) + + list + MappableSelectItem.MappableSelectPoint( + index.toLong(), + prompt.getSelectChoiceText(selectChoice), + properties, + selectChoice.index == selectedIndex, + point = points[0], + smallIcon = if (markerSymbol == null) org.odk.collect.icons.R.drawable.ic_map_marker_with_hole_small else org.odk.collect.icons.R.drawable.ic_map_marker_small, + largeIcon = if (markerSymbol == null) org.odk.collect.icons.R.drawable.ic_map_marker_with_hole_big else org.odk.collect.icons.R.drawable.ic_map_marker_big, + color = markerColor, + symbol = markerSymbol, + action = IconifiedText( + org.odk.collect.icons.R.drawable.ic_save, + resources.getString(org.odk.collect.strings.R.string.select_item) + ) + ) + } else if (points.first() != points.last()) { + list + MappableSelectItem.MappableSelectLine( + index.toLong(), + prompt.getSelectChoiceText(selectChoice), + properties, + selectChoice.index == selectedIndex, + points = points, + action = IconifiedText( + org.odk.collect.icons.R.drawable.ic_save, + resources.getString(org.odk.collect.strings.R.string.select_item) + ), + strokeWidth = getPropertyValue(selectChoice, STROKE_WIDTH), + strokeColor = getPropertyValue(selectChoice, STROKE) + ) + } else { + list + MappableSelectItem.MappableSelectPolygon( + index.toLong(), + prompt.getSelectChoiceText(selectChoice), + properties, + selectChoice.index == selectedIndex, + points = points, + action = IconifiedText( + org.odk.collect.icons.R.drawable.ic_save, + resources.getString(org.odk.collect.strings.R.string.select_item) + ), + strokeWidth = getPropertyValue(selectChoice, STROKE_WIDTH), + strokeColor = getPropertyValue(selectChoice, STROKE), + fillColor = getPropertyValue(selectChoice, FILL) + ) + } } else { list } @@ -187,6 +219,10 @@ internal class SelectChoicesMapData( } } + private fun getPropertyValue(selectChoice: SelectChoice, propertyName: String): String? { + return selectChoice.additionalChildren.firstOrNull { it.first == propertyName }?.second + } + override fun isLoading(): NonNullLiveData { return isLoading } @@ -206,4 +242,13 @@ internal class SelectChoicesMapData( override fun getMappableItems(): LiveData?> { return items } + + companion object PropertyNames { + const val GEOMETRY = "geometry" + const val MARKER_COLOR = "marker-color" + const val MARKER_SYMBOL = "marker-symbol" + const val STROKE = "stroke" + const val STROKE_WIDTH = "stroke-width" + const val FILL = "fill" + } } diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/RangeWidgetUtils.java b/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/RangeWidgetUtils.java index 6c5ff25a211..9e9be262625 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/RangeWidgetUtils.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/RangeWidgetUtils.java @@ -62,7 +62,7 @@ public static RangeWidgetLayoutElements setUpLayoutElements(Context context, For TextView minValue; TextView maxValue; - String appearance = prompt.getQuestion().getAppearanceAttr(); + String appearance = prompt.getAppearanceHint(); if (appearance != null && appearance.contains(VERTICAL_APPEARANCE)) { RangeWidgetVerticalBinding rangeWidgetVerticalBinding = RangeWidgetVerticalBinding @@ -127,7 +127,7 @@ public static BigDecimal setUpSlider(FormEntryPrompt prompt, TrackingTouchSlider slider.setValueTo(rangeStart.floatValue()); } - if (prompt.getQuestion().getAppearanceAttr() == null || !prompt.getQuestion().getAppearanceAttr().contains(NO_TICKS_APPEARANCE)) { + if (prompt.getAppearanceHint() == null || !prompt.getAppearanceHint().contains(NO_TICKS_APPEARANCE)) { if (isIntegerType) { slider.setStepSize(rangeStep.intValue()); } else { @@ -177,7 +177,7 @@ public static BigDecimal getActualValue(FormEntryPrompt prompt, Slider slider, f } BigDecimal actualValue = BigDecimal.valueOf(value); - if (prompt.getQuestion().getAppearanceAttr() != null && prompt.getQuestion().getAppearanceAttr().contains(NO_TICKS_APPEARANCE)) { + if (prompt.getAppearanceHint() != null && prompt.getAppearanceHint().contains(NO_TICKS_APPEARANCE)) { int progress = (actualValue.subtract(rangeStart).abs().divide(rangeStep)).intValue(); actualValue = rangeStart.add(rangeStep.multiply(new BigDecimal(String.valueOf(progress)))); diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/StringWidgetUtils.java b/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/StringWidgetUtils.java index 6331a54cd0c..33bcaae6f8b 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/StringWidgetUtils.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/StringWidgetUtils.java @@ -1,5 +1,6 @@ package org.odk.collect.android.widgets.utilities; +import org.javarosa.core.model.QuestionDef; import org.javarosa.core.model.data.DecimalData; import org.javarosa.core.model.data.IAnswerData; import org.javarosa.core.model.data.IntegerData; @@ -8,6 +9,8 @@ import org.odk.collect.android.listeners.ThousandsSeparatorTextWatcher; import org.odk.collect.android.utilities.Appearances; +import timber.log.Timber; + public final class StringWidgetUtils { private StringWidgetUtils() { @@ -97,4 +100,30 @@ public static StringData getStringNumberData(String answer, FormEntryPrompt prom } } } + + public static Integer getNumberOfRows(FormEntryPrompt prompt) { + QuestionDef questionDef = prompt.getQuestion(); + if (questionDef != null) { + /* + * If a 'rows' attribute is on the input tag, set the minimum number of lines + * to display in the field to that value. + * + * I.e., + * + * ... + * + * + * will set the height of the EditText box to 5 rows high. + */ + String height = questionDef.getAdditionalAttribute(null, "rows"); + if (height != null && height.length() != 0) { + try { + return Integer.parseInt(height); + } catch (Exception e) { + Timber.e(new Error("Unable to process the rows setting for the answerText field: " + e)); + } + } + } + return null; + } } diff --git a/collect_app/src/main/res/drawable-anydpi-v24/ic_stat_name.xml b/collect_app/src/main/res/drawable-anydpi-v24/ic_stat_name.xml new file mode 100644 index 00000000000..2f6d1ecb9e7 --- /dev/null +++ b/collect_app/src/main/res/drawable-anydpi-v24/ic_stat_name.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/collect_app/src/main/res/drawable-anydpi/ic_action_name.xml b/collect_app/src/main/res/drawable-anydpi/ic_action_name.xml new file mode 100644 index 00000000000..3eae901552b --- /dev/null +++ b/collect_app/src/main/res/drawable-anydpi/ic_action_name.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/collect_app/src/main/res/drawable-hdpi/ic_action_name.png b/collect_app/src/main/res/drawable-hdpi/ic_action_name.png new file mode 100644 index 00000000000..3730847f419 Binary files /dev/null and b/collect_app/src/main/res/drawable-hdpi/ic_action_name.png differ diff --git a/collect_app/src/main/res/drawable-hdpi/ic_stat_name.png b/collect_app/src/main/res/drawable-hdpi/ic_stat_name.png new file mode 100644 index 00000000000..84d049f2c07 Binary files /dev/null and b/collect_app/src/main/res/drawable-hdpi/ic_stat_name.png differ diff --git a/collect_app/src/main/res/drawable-mdpi/ic_action_name.png b/collect_app/src/main/res/drawable-mdpi/ic_action_name.png new file mode 100644 index 00000000000..995e2f1ba92 Binary files /dev/null and b/collect_app/src/main/res/drawable-mdpi/ic_action_name.png differ diff --git a/collect_app/src/main/res/drawable-mdpi/ic_stat_name.png b/collect_app/src/main/res/drawable-mdpi/ic_stat_name.png new file mode 100644 index 00000000000..a3b44e5cf7c Binary files /dev/null and b/collect_app/src/main/res/drawable-mdpi/ic_stat_name.png differ diff --git a/collect_app/src/main/res/drawable-xhdpi/ic_action_name.png b/collect_app/src/main/res/drawable-xhdpi/ic_action_name.png new file mode 100644 index 00000000000..abb1053625a Binary files /dev/null and b/collect_app/src/main/res/drawable-xhdpi/ic_action_name.png differ diff --git a/collect_app/src/main/res/drawable-xhdpi/ic_stat_name.png b/collect_app/src/main/res/drawable-xhdpi/ic_stat_name.png new file mode 100644 index 00000000000..275fb82de58 Binary files /dev/null and b/collect_app/src/main/res/drawable-xhdpi/ic_stat_name.png differ diff --git a/collect_app/src/main/res/drawable-xxhdpi/ic_action_name.png b/collect_app/src/main/res/drawable-xxhdpi/ic_action_name.png new file mode 100644 index 00000000000..804603a7f9c Binary files /dev/null and b/collect_app/src/main/res/drawable-xxhdpi/ic_action_name.png differ diff --git a/collect_app/src/main/res/drawable-xxhdpi/ic_stat_name.png b/collect_app/src/main/res/drawable-xxhdpi/ic_stat_name.png new file mode 100644 index 00000000000..1e2b2e60352 Binary files /dev/null and b/collect_app/src/main/res/drawable-xxhdpi/ic_stat_name.png differ diff --git a/collect_app/src/main/res/drawable/ic_launcher_foreground.xml b/collect_app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000000..85864291581 --- /dev/null +++ b/collect_app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/collect_app/src/main/res/drawable/ic_splash_screen_icon.xml b/collect_app/src/main/res/drawable/ic_splash_screen_icon.xml index 2ce74a7e35a..ed8f8f6a8ae 100644 --- a/collect_app/src/main/res/drawable/ic_splash_screen_icon.xml +++ b/collect_app/src/main/res/drawable/ic_splash_screen_icon.xml @@ -1,7 +1,7 @@ - - - - - - + + + + + + + diff --git a/collect_app/src/main/res/drawable/kobologo_symbol_cropped.xml b/collect_app/src/main/res/drawable/kobologo_symbol_cropped.xml new file mode 100644 index 00000000000..e17fc8e5030 --- /dev/null +++ b/collect_app/src/main/res/drawable/kobologo_symbol_cropped.xml @@ -0,0 +1,12 @@ + + + + diff --git a/collect_app/src/main/res/layout/activity_blank_form_list.xml b/collect_app/src/main/res/layout/activity_blank_form_list.xml index 7cff889b8ba..a6b2c618fd1 100644 --- a/collect_app/src/main/res/layout/activity_blank_form_list.xml +++ b/collect_app/src/main/res/layout/activity_blank_form_list.xml @@ -7,12 +7,12 @@ - - + + + + + + + + + + + + \ No newline at end of file diff --git a/collect_app/src/main/res/layout/arbitrary_file_widget_answer.xml b/collect_app/src/main/res/layout/arbitrary_file_widget_answer.xml index c570bb12fb6..589895c7539 100644 --- a/collect_app/src/main/res/layout/arbitrary_file_widget_answer.xml +++ b/collect_app/src/main/res/layout/arbitrary_file_widget_answer.xml @@ -1,15 +1,17 @@ - + android:layout_height="wrap_content" + app:icon="@drawable/ic_baseline_attach_file_white_24" /> - - - - - - + android:layout_height="wrap_content" + app:icon="@drawable/ic_baseline_mic_white_24" /> - + android:layout_height="wrap_content" + app:icon="@drawable/ic_baseline_library_music_white_24" /> - + android:layout_height="wrap_content" + app:icon="@drawable/ic_baseline_barcode_scanner_white_24" /> - + android:layout_height="wrap_content" + app:icon="@drawable/ic_baseline_explore_white_24" /> + android:layout_height="wrap_content" + android:background="?android:attr/selectableItemBackground"> diff --git a/collect_app/src/main/res/layout/date_widget_answer.xml b/collect_app/src/main/res/layout/date_widget_answer.xml index 794c6b37c94..75646f6ac46 100644 --- a/collect_app/src/main/res/layout/date_widget_answer.xml +++ b/collect_app/src/main/res/layout/date_widget_answer.xml @@ -1,14 +1,17 @@ - + android:layout_height="wrap_content" + app:icon="@drawable/ic_baseline_calendar_today_white_24" /> - - - - - - - - - - - - - - diff --git a/collect_app/src/main/res/layout/reference_layer_help_footer.xml b/collect_app/src/main/res/layout/delete_form_layout.xml similarity index 53% rename from collect_app/src/main/res/layout/reference_layer_help_footer.xml rename to collect_app/src/main/res/layout/delete_form_layout.xml index b2c91068a9a..7505e92a5ec 100644 --- a/collect_app/src/main/res/layout/reference_layer_help_footer.xml +++ b/collect_app/src/main/res/layout/delete_form_layout.xml @@ -4,15 +4,13 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - - diff --git a/collect_app/src/main/res/layout/draw_widget.xml b/collect_app/src/main/res/layout/draw_widget.xml new file mode 100644 index 00000000000..4c4b73e014d --- /dev/null +++ b/collect_app/src/main/res/layout/draw_widget.xml @@ -0,0 +1,41 @@ + + + + + + + + + \ No newline at end of file diff --git a/collect_app/src/main/res/layout/ex_arbitrary_file_widget_answer.xml b/collect_app/src/main/res/layout/ex_arbitrary_file_widget_answer.xml index d79d812e43f..fef988bd2dc 100644 --- a/collect_app/src/main/res/layout/ex_arbitrary_file_widget_answer.xml +++ b/collect_app/src/main/res/layout/ex_arbitrary_file_widget_answer.xml @@ -1,15 +1,17 @@ - + android:layout_height="wrap_content" + app:icon="@drawable/ic_baseline_open_in_new_white_24" /> - + android:layout_height="wrap_content" + app:icon="@drawable/ic_baseline_open_in_new_white_24" /> - + android:layout_height="wrap_content" + app:icon="@drawable/ic_baseline_open_in_new_white_24" /> + + + + \ No newline at end of file diff --git a/collect_app/src/main/res/layout/ex_string_question_type.xml b/collect_app/src/main/res/layout/ex_string_question_type.xml new file mode 100644 index 00000000000..f12c85dc3b1 --- /dev/null +++ b/collect_app/src/main/res/layout/ex_string_question_type.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/collect_app/src/main/res/layout/ex_video_widget_answer.xml b/collect_app/src/main/res/layout/ex_video_widget_answer.xml index d5b200487a1..07fa539423c 100644 --- a/collect_app/src/main/res/layout/ex_video_widget_answer.xml +++ b/collect_app/src/main/res/layout/ex_video_widget_answer.xml @@ -1,21 +1,24 @@ - + android:layout_height="wrap_content" + app:icon="@drawable/ic_baseline_open_in_new_white_24" /> - + android:layout_marginTop="@dimen/margin_standard" + app:icon="@drawable/ic_baseline_play_circle_white_24" /> diff --git a/collect_app/src/main/res/layout/file_manager_list.xml b/collect_app/src/main/res/layout/file_manager_list.xml deleted file mode 100644 index effde0391c2..00000000000 --- a/collect_app/src/main/res/layout/file_manager_list.xml +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/collect_app/src/main/res/layout/first_launch_layout.xml b/collect_app/src/main/res/layout/first_launch_layout.xml index 14ca68c8a20..2314b237587 100644 --- a/collect_app/src/main/res/layout/first_launch_layout.xml +++ b/collect_app/src/main/res/layout/first_launch_layout.xml @@ -28,7 +28,7 @@ android:layout_height="wrap_content" android:adjustViewBounds="true" android:contentDescription="@string/collect_app_name" - android:src="@drawable/odk_logo" + android:src="@drawable/kobologo_symbol_cropped" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> @@ -43,7 +43,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/logo" /> - - - - - - - + app:iconGravity="textStart" + app:screenName="@string/form_end_screen" /> - + app:iconGravity="textStart" + app:screenName="@string/form_end_screen" /> diff --git a/collect_app/src/main/res/layout/fragment_scan.xml b/collect_app/src/main/res/layout/fragment_scan.xml index c9d48130feb..4cf6b990278 100644 --- a/collect_app/src/main/res/layout/fragment_scan.xml +++ b/collect_app/src/main/res/layout/fragment_scan.xml @@ -11,7 +11,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> - + + + + + + + diff --git a/collect_app/src/main/res/layout/geoshape_question.xml b/collect_app/src/main/res/layout/geoshape_question.xml new file mode 100644 index 00000000000..5123976665c --- /dev/null +++ b/collect_app/src/main/res/layout/geoshape_question.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/collect_app/src/main/res/layout/geo_widget_answer.xml b/collect_app/src/main/res/layout/geotrace_question.xml similarity index 58% rename from collect_app/src/main/res/layout/geo_widget_answer.xml rename to collect_app/src/main/res/layout/geotrace_question.xml index 9cd003c7977..4cfcce050d3 100644 --- a/collect_app/src/main/res/layout/geo_widget_answer.xml +++ b/collect_app/src/main/res/layout/geotrace_question.xml @@ -1,14 +1,17 @@ - + android:layout_height="wrap_content" + app:icon="@drawable/ic_outline_polyline_white_24" /> - - - - - - \ No newline at end of file + diff --git a/collect_app/src/main/res/layout/identify_user_dialog.xml b/collect_app/src/main/res/layout/identify_user_dialog.xml index 9302801cf4c..a9aa3ae640e 100644 --- a/collect_app/src/main/res/layout/identify_user_dialog.xml +++ b/collect_app/src/main/res/layout/identify_user_dialog.xml @@ -61,7 +61,7 @@ diff --git a/collect_app/src/main/res/layout/image_widget.xml b/collect_app/src/main/res/layout/image_widget.xml new file mode 100644 index 00000000000..fdd41d353bc --- /dev/null +++ b/collect_app/src/main/res/layout/image_widget.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/collect_app/src/main/res/layout/instance_uploader_list.xml b/collect_app/src/main/res/layout/instance_uploader_list.xml index d4dfc5d1559..c68d84782e9 100644 --- a/collect_app/src/main/res/layout/instance_uploader_list.xml +++ b/collect_app/src/main/res/layout/instance_uploader_list.xml @@ -43,7 +43,7 @@ the License. app:layout_constraintBottom_toTopOf="@id/buttonholder" app:layout_constraintTop_toBottomOf="@id/ready_to_send_banner" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/collect_app/src/main/res/layout/odk_view.xml b/collect_app/src/main/res/layout/odk_view.xml index 43d876f1da5..ee5283447ae 100644 --- a/collect_app/src/main/res/layout/odk_view.xml +++ b/collect_app/src/main/res/layout/odk_view.xml @@ -14,6 +14,7 @@ limitations under the License. --> @@ -28,20 +29,21 @@ limitations under the License. android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="@dimen/margin_extra_small" + android:layout_marginBottom="@dimen/margin_extra_small" android:layout_marginHorizontal="@dimen/margin_standard" android:visibility="gone" style="@style/TextAppearance.Collect.Subtitle1.MediumEmphasis" tools:text="Group text" tools:visibility="visible" /> - + app:icon="@drawable/ic_baseline_open_in_new_white_24" /> - diff --git a/collect_app/src/main/res/layout/printer_widget.xml b/collect_app/src/main/res/layout/printer_widget.xml index cd1cabf25d4..f0c5da9239b 100644 --- a/collect_app/src/main/res/layout/printer_widget.xml +++ b/collect_app/src/main/res/layout/printer_widget.xml @@ -4,12 +4,13 @@ android:layout_width="match_parent" android:layout_height="wrap_content"> - - - - - - - - - - - + android:layout_height="wrap_content" + app:icon="@drawable/ic_baseline_format_list_numbered_white_24" /> + android:background="?colorSurfaceContainerHighest"> + + + + + + \ No newline at end of file diff --git a/collect_app/src/main/res/layout/select_one_from_map_widget_answer.xml b/collect_app/src/main/res/layout/select_one_from_map_widget_answer.xml index a0433ede0ec..5251b11b6bf 100644 --- a/collect_app/src/main/res/layout/select_one_from_map_widget_answer.xml +++ b/collect_app/src/main/res/layout/select_one_from_map_widget_answer.xml @@ -5,10 +5,11 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - + + + + + + + + \ No newline at end of file diff --git a/collect_app/src/main/res/layout/time_widget_answer.xml b/collect_app/src/main/res/layout/time_widget_answer.xml index e5c151526f0..5e1d265e148 100644 --- a/collect_app/src/main/res/layout/time_widget_answer.xml +++ b/collect_app/src/main/res/layout/time_widget_answer.xml @@ -1,14 +1,17 @@ - + android:layout_height="wrap_content" + app:icon="@drawable/ic_baseline_time_filled_white_24" /> - + android:layout_height="wrap_content" + app:icon="@drawable/ic_baseline_open_in_new_white_24" /> diff --git a/collect_app/src/main/res/layout/video_widget.xml b/collect_app/src/main/res/layout/video_widget.xml new file mode 100644 index 00000000000..5d7f45ad491 --- /dev/null +++ b/collect_app/src/main/res/layout/video_widget.xml @@ -0,0 +1,44 @@ + + + + + + + + + \ No newline at end of file diff --git a/collect_app/src/main/res/layout/widget_answer_button.xml b/collect_app/src/main/res/layout/widget_answer_button.xml deleted file mode 100644 index ae0d6307a41..00000000000 --- a/collect_app/src/main/res/layout/widget_answer_button.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/collect_app/src/main/res/layout/widget_answer_text.xml b/collect_app/src/main/res/layout/widget_answer_text.xml index df8c1d436f9..bcd57043e09 100644 --- a/collect_app/src/main/res/layout/widget_answer_text.xml +++ b/collect_app/src/main/res/layout/widget_answer_text.xml @@ -5,11 +5,10 @@ xmlns:app="http://schemas.android.com/apk/res-auto"> - \ No newline at end of file + diff --git a/collect_app/src/main/res/menu/saved_form_list_menu.xml b/collect_app/src/main/res/menu/saved_form_list_menu.xml new file mode 100644 index 00000000000..8d52c709ad7 --- /dev/null +++ b/collect_app/src/main/res/menu/saved_form_list_menu.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/collect_app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/collect_app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000000..7353dbd1fd8 --- /dev/null +++ b/collect_app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/collect_app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/collect_app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000000..7353dbd1fd8 --- /dev/null +++ b/collect_app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/collect_app/src/main/res/mipmap-hdpi/ic_launcher.webp b/collect_app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000000..b0bc443414e Binary files /dev/null and b/collect_app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/collect_app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/collect_app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000000..120ad198d08 Binary files /dev/null and b/collect_app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/collect_app/src/main/res/mipmap-mdpi/ic_launcher.webp b/collect_app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000000..2d49216c661 Binary files /dev/null and b/collect_app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/collect_app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/collect_app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000000..b1c0b8a33df Binary files /dev/null and b/collect_app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/collect_app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/collect_app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000000..5d45b275f6b Binary files /dev/null and b/collect_app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/collect_app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/collect_app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000000..189a9ac2c87 Binary files /dev/null and b/collect_app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/collect_app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/collect_app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000000..e721b73ab00 Binary files /dev/null and b/collect_app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/collect_app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/collect_app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000000..417e4fef36b Binary files /dev/null and b/collect_app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/collect_app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/collect_app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000000..61b76df67b8 Binary files /dev/null and b/collect_app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/collect_app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/collect_app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000000..bad3ecadc97 Binary files /dev/null and b/collect_app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/collect_app/src/main/res/values-night/colors.xml b/collect_app/src/main/res/values-night/colors.xml index 6c914796f19..b808166e84b 100644 --- a/collect_app/src/main/res/values-night/colors.xml +++ b/collect_app/src/main/res/values-night/colors.xml @@ -15,8 +15,11 @@ #40484c #d3e5ef #c0c8cd + #001117 #293134 - #4cbcf0 + #293134 + #293134 + #162f3b #BFE9FF #001F2A diff --git a/collect_app/src/main/res/values/arrays.xml b/collect_app/src/main/res/values/arrays.xml index c6610559fd1..76d6ff9c546 100644 --- a/collect_app/src/main/res/values/arrays.xml +++ b/collect_app/src/main/res/values/arrays.xml @@ -72,10 +72,10 @@ the specific language governing permissions and limitations under the License. - @string/wifi_cellular_autosend - off - wifi_only - cellular_only - wifi_and_cellular + @string/auto_send_off + @string/auto_send_wifi_only + @string/auto_send_cellular_only + @string/auto_send_wifi_and_cellular @string/guidance_no diff --git a/collect_app/src/main/res/values/attrs.xml b/collect_app/src/main/res/values/attrs.xml index a0c2e998146..56910fa9f34 100644 --- a/collect_app/src/main/res/values/attrs.xml +++ b/collect_app/src/main/res/values/attrs.xml @@ -17,14 +17,7 @@ - - - - - - - - - + + diff --git a/collect_app/src/main/res/values/buttons.xml b/collect_app/src/main/res/values/buttons.xml index 36c44c29f19..31b53dccda4 100644 --- a/collect_app/src/main/res/values/buttons.xml +++ b/collect_app/src/main/res/values/buttons.xml @@ -1,9 +1,11 @@ - - - diff --git a/collect_app/src/main/res/values/theme.xml b/collect_app/src/main/res/values/theme.xml index b7c0e075697..6a7eedf6beb 100644 --- a/collect_app/src/main/res/values/theme.xml +++ b/collect_app/src/main/res/values/theme.xml @@ -59,9 +59,11 @@ @color/color_on_surface_medium_emphasis @color/colorErrorContainer @color/colorOnErrorContainer - @color/elevationOverlayColor + @color/colorSurfaceContainerLowest @color/colorSurfaceContainerLow - @color/color_primary_low_emphasis + @color/colorSurfaceContainer + @color/colorSurfaceContainerHigh + @color/colorSurfaceContainerHighest @style/TextAppearance.Material3.DisplayLarge @style/TextAppearance.Material3.DisplayMedium @@ -98,9 +100,10 @@ @style/Widget.Collect.Button.OutlinedButton @style/Widget.Collect.Button.TextButton @style/Theme.Collect.Dialog.Alert + @style/Theme.Collect.BottomSheet - @style/Widget.Collect.Button.QuestionWidget + @style/Widget.Collect.Button.Icon.QuestionWidget @style/TextAppearance.Collect.LabelExtraLarge @@ -147,4 +150,8 @@ + + diff --git a/collect_app/src/main/res/xml/experimental_preferences.xml b/collect_app/src/main/res/xml/experimental_preferences.xml index 60071d806f3..08702a250c6 100644 --- a/collect_app/src/main/res/xml/experimental_preferences.xml +++ b/collect_app/src/main/res/xml/experimental_preferences.xml @@ -2,16 +2,30 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:title="@string/experimental"> - - + android:title="@string/dev_tools" + app:persistent="false" /> + + + + + + + + diff --git a/collect_app/src/main/res/xml/maps_preferences.xml b/collect_app/src/main/res/xml/maps_preferences.xml index 955c33a8045..7e4c776fe71 100644 --- a/collect_app/src/main/res/xml/maps_preferences.xml +++ b/collect_app/src/main/res/xml/maps_preferences.xml @@ -9,14 +9,14 @@ app:iconSpaceReserved="false" /> - diff --git a/collect_app/src/test/java/org/odk/collect/android/activities/FormFillingActivityTest.kt b/collect_app/src/test/java/org/odk/collect/android/activities/FormFillingActivityTest.kt index a3336914708..03ac6ab1f3f 100644 --- a/collect_app/src/test/java/org/odk/collect/android/activities/FormFillingActivityTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/activities/FormFillingActivityTest.kt @@ -7,6 +7,9 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.fragment.app.DialogFragment import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.withContentDescription +import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.work.WorkManager import org.hamcrest.MatcherAssert.assertThat @@ -36,12 +39,9 @@ import org.odk.collect.formstest.FormFixtures.form import org.odk.collect.strings.R import org.odk.collect.testshared.ActivityControllerRule import org.odk.collect.testshared.AssertIntentsHelper -import org.odk.collect.testshared.EspressoHelpers.assertText -import org.odk.collect.testshared.EspressoHelpers.assertTextInDialog -import org.odk.collect.testshared.EspressoHelpers.clickOnContentDescription -import org.odk.collect.testshared.EspressoHelpers.clickOnText -import org.odk.collect.testshared.EspressoHelpers.clickOnTextInDialog +import org.odk.collect.testshared.Assertions.assertText import org.odk.collect.testshared.FakeScheduler +import org.odk.collect.testshared.Interactions import org.odk.collect.testshared.RobolectricHelpers.recreateWithProcessRestore import org.robolectric.Shadows.shadowOf import java.io.File @@ -92,12 +92,12 @@ class FormFillingActivityTest { // Start activity val initial = activityControllerRule.build(FormFillingActivity::class.java, intent).setup() scheduler.flush() - assertText("Two Question") - assertText("What is your name?") + assertText(withText("Two Question")) + assertText(withText("What is your name?")) - clickOnText(R.string.form_forward) + Interactions.clickOn(withText(R.string.form_forward)) scheduler.flush() - assertText("What is your age?") + assertText(withText("What is your age?")) // Recreate and assert we start FormHierarchyActivity val recreated = activityControllerRule.add { @@ -112,8 +112,8 @@ class FormFillingActivityTest { shadowOf(recreated.get()).receiveResult(hierarchyIntent, Activity.RESULT_CANCELED, null) scheduler.flush() - assertText("Two Question") - assertText("What is your age?") + assertText(withText("Two Question")) + assertText(withText("What is your age?")) } @Test @@ -130,14 +130,14 @@ class FormFillingActivityTest { // Start activity val initial = activityControllerRule.build(FormFillingActivity::class.java, intent).setup() scheduler.flush() - assertText("Two Question") - assertText("What is your name?") + assertText(withText("Two Question")) + assertText(withText("What is your name?")) - clickOnText(R.string.form_forward) + Interactions.clickOn(withText(R.string.form_forward)) scheduler.flush() - assertText("What is your age?") + assertText(withText("What is your age?")) - clickOnContentDescription(R.string.view_hierarchy) + Interactions.clickOn(withContentDescription(R.string.view_hierarchy)) assertIntentsHelper.assertNewIntent(FormHierarchyActivity::class) // Recreate and assert we start FormHierarchyActivity @@ -153,8 +153,8 @@ class FormFillingActivityTest { shadowOf(recreated.get()).receiveResult(hierarchyIntent, Activity.RESULT_CANCELED, null) scheduler.flush() - assertText("Two Question") - assertText("What is your age?") + assertText(withText("Two Question")) + assertText(withText("What is your age?")) } @Test @@ -171,12 +171,12 @@ class FormFillingActivityTest { // Start activity val initial = activityControllerRule.build(FormFillingActivity::class.java, intent).setup() scheduler.flush() - assertText("Two Question") - assertText("What is your name?") + assertText(withText("Two Question")) + assertText(withText("What is your name?")) - clickOnText(R.string.form_forward) + Interactions.clickOn(withText(R.string.form_forward)) scheduler.flush() - assertText("What is your age?") + assertText(withText("What is your age?")) val initialFragmentManager = initial.get().supportFragmentManager DialogFragmentUtils.showIfNotShowing(TestDialogFragment::class.java, initialFragmentManager) @@ -198,8 +198,8 @@ class FormFillingActivityTest { shadowOf(recreated.get()).receiveResult(hierarchyIntent, Activity.RESULT_CANCELED, null) scheduler.flush() - assertText("Two Question") - assertText("What is your age?") + assertText(withText("Two Question")) + assertText(withText("What is your age?")) } @Test @@ -216,15 +216,15 @@ class FormFillingActivityTest { // Start activity val initial = activityControllerRule.build(FormFillingActivity::class.java, intent).setup() scheduler.flush() - assertText("Two Question") - assertText("What is your name?") + assertText(withText("Two Question")) + assertText(withText("What is your name?")) - clickOnText(R.string.form_forward) + Interactions.clickOn(withText(R.string.form_forward)) scheduler.flush() - assertText("What is your age?") + assertText(withText("What is your age?")) // Open external app - clickOnContentDescription(R.string.launch_app) + Interactions.clickOn(withContentDescription(R.string.launch_app)) assertIntentsHelper.assertNewIntent(hasAction("com.example.EXAMPLE")) // Recreate with result @@ -236,9 +236,9 @@ class FormFillingActivityTest { scheduler.flush() assertIntentsHelper.assertNoNewIntent() - assertText("Two Question") - assertText("What is your age?") - assertText("159") + assertText(withText("Two Question")) + assertText(withText("What is your age?")) + assertText(withText("159")) } /** @@ -257,9 +257,12 @@ class FormFillingActivityTest { val scenario = scenarioLauncherRule.launch(intent) scheduler.flush() - assertTextInDialog("This form no longer exists, please email support@getodk.org with a description of what you were doing when this happened.") + assertText( + withText("This form no longer exists, please email support@getodk.org with a description of what you were doing when this happened."), + root = isDialog() + ) - clickOnTextInDialog(R.string.ok) + Interactions.clickOn(withText(R.string.ok), root = isDialog()) assertThat(scenario.isFinishing, equalTo(true)) } diff --git a/collect_app/src/test/java/org/odk/collect/android/application/initialization/FormUpdatesUpgradeTest.kt b/collect_app/src/test/java/org/odk/collect/android/application/initialization/FormUpdatesUpgradeTest.kt deleted file mode 100644 index 35c3de430f8..00000000000 --- a/collect_app/src/test/java/org/odk/collect/android/application/initialization/FormUpdatesUpgradeTest.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.odk.collect.android.application.initialization - -import org.junit.Test -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify -import org.odk.collect.android.backgroundwork.FormUpdateScheduler -import org.odk.collect.async.Scheduler -import org.odk.collect.projects.InMemProjectsRepository -import org.odk.collect.projects.Project - -class FormUpdatesUpgradeTest { - - @Test - fun `cancels all existing background jobs`() { - val scheduler = mock() - val formUpdatesUpgrade = FormUpdatesUpgrade(scheduler, InMemProjectsRepository(), mock()) - - formUpdatesUpgrade.run() - verify(scheduler).cancelAllDeferred() - } - - @Test - fun `schedules updates for all projects`() { - val projectsRepository = InMemProjectsRepository() - val project1 = projectsRepository.save(Project.New("1", "1", "#ffffff")) - val project2 = projectsRepository.save(Project.New("2", "2", "#ffffff")) - - val formUpdateScheduler = mock() - val formUpdatesUpgrade = FormUpdatesUpgrade(mock(), projectsRepository, formUpdateScheduler) - - formUpdatesUpgrade.run() - verify(formUpdateScheduler).scheduleUpdates(project1.uuid) - verify(formUpdateScheduler).scheduleUpdates(project2.uuid) - } -} diff --git a/collect_app/src/test/java/org/odk/collect/android/application/initialization/SavepointsImporterTest.kt b/collect_app/src/test/java/org/odk/collect/android/application/initialization/SavepointsImporterTest.kt new file mode 100644 index 00000000000..09cfcbef96d --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/application/initialization/SavepointsImporterTest.kt @@ -0,0 +1,309 @@ +package org.odk.collect.android.application.initialization + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.contains +import org.hamcrest.Matchers.equalTo +import org.junit.Test +import org.junit.runner.RunWith +import org.odk.collect.android.injection.DaggerUtils +import org.odk.collect.android.storage.StorageSubdirectory +import org.odk.collect.forms.Form +import org.odk.collect.forms.instances.Instance +import org.odk.collect.forms.savepoints.Savepoint +import org.odk.collect.formstest.FormFixtures +import org.odk.collect.formstest.InstanceFixtures +import org.odk.collect.projects.Project +import org.odk.collect.shared.TimeInMs +import org.odk.collect.shared.strings.RandomString +import java.io.File + +@RunWith(AndroidJUnit4::class) +class SavepointsImporterTest { + private val component = DaggerUtils.getComponent(ApplicationProvider.getApplicationContext() as Application) + + private val projectsRepository = component.projectsRepository() + private val projectDependencyProviderFactory = component.projectDependencyProviderFactory() + + private val savepointsImporter = + SavepointsImporter(projectsRepository, projectDependencyProviderFactory) + + private val project = projectsRepository.save(Project.DEMO_PROJECT) + private val projectDependencyProvider = projectDependencyProviderFactory.create(project.uuid) + private val savepointsRepository = projectDependencyProvider.savepointsRepository + private val storagePathProvider = projectDependencyProvider.storagePathProvider + private val formsRepository = projectDependencyProvider.formsRepository + private val instancesRepository = projectDependencyProvider.instancesRepository + + @Test + fun ifABlankFormHasNoSavepoint_nothingShouldBeImported() { + // create blank forms + createBlankForm(project, "sampleForm", "1") + + // trigger importing + savepointsImporter.run() + + // verify import + val savepoints = savepointsRepository.getAll() + assertThat(savepoints.isEmpty(), equalTo(true)) + } + + @Test + fun ifABlankFormHasASavepointCreatedEarlierThanTheForm_nothingShouldBeImported() { + // create blank forms + createBlankForm(project, "sampleForm", "1", date = System.currentTimeMillis() + TimeInMs.ONE_HOUR) + + // create savepoints + createFileInCache(project, "sampleForm_2024-04-10_01-35-41.xml.save") + + // trigger importing + savepointsImporter.run() + + // verify import + val savepoints = savepointsRepository.getAll() + assertThat(savepoints.isEmpty(), equalTo(true)) + } + + @Test + fun ifABlankFormHasASavepointButTheFormIsSoftDeleted_nothingShouldBeImported() { + // create blank forms + createBlankForm(project, "sampleForm", "1", deleted = true) + + // create savepoints + createFileInCache(project, "sampleForm_2024-04-10_01-35-41.xml.save") + + // trigger importing + savepointsImporter.run() + + // verify import + val savepoints = savepointsRepository.getAll() + assertThat(savepoints.isEmpty(), equalTo(true)) + } + + @Test + fun ifAFileForABlankFormExistsWithMatchingName_butIncorrectSuffix_nothingShouldBeImported() { + val formName = "sampleForm" + + // create blank forms + createBlankForm(project, formName, "1", "1") + + // create savepoints + createFileInCache(project, "${formName}_2024-04-10_01-35-41.xml") + + // trigger importing + savepointsImporter.run() + + // verify import + val savepoints = savepointsRepository.getAll() + assertThat(savepoints.isEmpty(), equalTo(true)) + } + + @Test + fun ifThereAreMultipleDifferentBlankFormsWithSavepoints_allSavepointsShouldBeImported() { + val form1Name = "sampleForm1" + val form2Name = "sampleForm2" + + // create blank forms + val blankForm1 = createBlankForm(project, form1Name, "1") + val blankForm2 = createBlankForm(project, form2Name, "2") + + // create savepoints + val savepointFile1 = createFileInCache(project, "${form1Name}_2024-04-10_01-35-41.xml.save") + val savepointFile2 = createFileInCache(project, "${form2Name}_2024-04-10_01-35-42.xml.save") + + // trigger importing + savepointsImporter.run() + + // verify import + val savepoints = savepointsRepository.getAll() + val expectedSavepoint1 = + Savepoint(blankForm1.dbId, null, savepointFile1.absolutePath, "${storagePathProvider.getOdkDirPath(StorageSubdirectory.INSTANCES, project.uuid)}/${form1Name}_2024-04-10_01-35-41/${form1Name}_2024-04-10_01-35-41.xml") + val expectedSavepoint2 = + Savepoint(blankForm2.dbId, null, savepointFile2.absolutePath, "${storagePathProvider.getOdkDirPath(StorageSubdirectory.INSTANCES, project.uuid)}/${form2Name}_2024-04-10_01-35-42/${form2Name}_2024-04-10_01-35-42.xml") + assertThat(savepoints, contains(expectedSavepoint1, expectedSavepoint2)) + } + + @Test + fun ifThereAreMultipleVersionsOfTheSameBlankFormWithSavepoints_allSavepointsShouldBeImported() { + val form1Name = "sampleForm" + val form2Name = "sampleForm_1" + + // create blank forms + val blankForm1 = createBlankForm(project, form1Name, "1", "1", date = 1) + val blankForm2 = createBlankForm(project, form2Name, "1", "2", date = 2) + + // create savepoints + val savepointFile1 = createFileInCache(project, "${form1Name}_2024-04-10_01-35-41.xml.save") + val savepointFile2 = createFileInCache(project, "${form2Name}_2024-04-10_01-35-42.xml.save") + + // trigger importing + savepointsImporter.run() + + // verify import + val savepoints = savepointsRepository.getAll() + val expectedSavepoint1 = + Savepoint(blankForm1.dbId, null, savepointFile1.absolutePath, "${storagePathProvider.getOdkDirPath(StorageSubdirectory.INSTANCES, project.uuid)}/${form1Name}_2024-04-10_01-35-41/${form1Name}_2024-04-10_01-35-41.xml") + val expectedSavepoint2 = + Savepoint(blankForm2.dbId, null, savepointFile2.absolutePath, "${storagePathProvider.getOdkDirPath(StorageSubdirectory.INSTANCES, project.uuid)}/${form2Name}_2024-04-10_01-35-42/${form2Name}_2024-04-10_01-35-42.xml") + assertThat(savepoints, contains(expectedSavepoint1, expectedSavepoint2)) + } + + @Test + fun ifASavedFormHasNoSavepoint_nothingShouldBeImported() { + // create blank forms + val form = createBlankForm(project, "sampleForm", "1") + + // create saved forms + createSavedForm("sampleForm", form) + + // trigger importing + savepointsImporter.run() + + // verify import + val savepoints = savepointsRepository.getAll() + assertThat(savepoints.isEmpty(), equalTo(true)) + } + + @Test + fun ifASavedFormHasASavepointCreatedEarlierThanTheForm_nothingShouldBeImported() { + // create blank forms + val form = createBlankForm(project, "sampleForm", "1") + + // create saved forms + val savedForm = createSavedForm("sampleForm", form, lastStatusChangeDate = System.currentTimeMillis() + TimeInMs.ONE_HOUR) + + // create savepoints + createFileInCache(project, "${File(savedForm.instanceFilePath).name}.save") + + // trigger importing + savepointsImporter.run() + + // verify import + val savepoints = savepointsRepository.getAll() + assertThat(savepoints.isEmpty(), equalTo(true)) + } + + @Test + fun ifASavedFormHasASavepointButItsBlankFormIsSoftDeleted_nothingShouldBeImported() { + // create blank forms + val form = createBlankForm(project, "sampleForm", "1", deleted = true) + + // create saved forms + val savedForm = createSavedForm("sampleForm", form) + + // create savepoints + createFileInCache(project, "${File(savedForm.instanceFilePath).name}.save") + + // trigger importing + savepointsImporter.run() + + // verify import + val savepoints = savepointsRepository.getAll() + assertThat(savepoints.isEmpty(), equalTo(true)) + } + + @Test + fun ifASavedFormHasASavepointButItsBlankFormDoesNotExist_nothingShouldBeImported() { + // create saved forms + val savedForm = createSavedForm("sampleForm", FormFixtures.form("1")) + + // create savepoints + createFileInCache(project, "${File(savedForm.instanceFilePath).name}.save") + + // trigger importing + savepointsImporter.run() + + // verify import + val savepoints = savepointsRepository.getAll() + assertThat(savepoints.isEmpty(), equalTo(true)) + } + + @Test + fun ifASavedFormHasASavepointButTheFormIsSoftDeleted_nothingShouldBeImported() { + // create blank forms + val form = createBlankForm(project, "sampleForm", "1") + + // create saved forms + val savedForm = createSavedForm("sampleForm", form, deletedDate = System.currentTimeMillis()) + + // create savepoints + createFileInCache(project, "${File(savedForm.instanceFilePath).name}.save") + + // trigger importing + savepointsImporter.run() + + // verify import + val savepoints = savepointsRepository.getAll() + assertThat(savepoints.isEmpty(), equalTo(true)) + } + + @Test + fun ifAFileForASavedFormExistsWithMatchingName_butIncorrectSuffix_nothingShouldBeImported() { + val formName = "sampleForm" + + // create blank forms + val form = createBlankForm(project, formName, "1") + + // create saved forms + val savedForm = createSavedForm(formName, form) + + // create savepoints + createFileInCache(project, File(savedForm.instanceFilePath).name) + + // trigger importing + savepointsImporter.run() + + // verify import + val savepoints = savepointsRepository.getAll() + assertThat(savepoints.isEmpty(), equalTo(true)) + } + + @Test + fun ifThereAreMultipleDifferentSavedFormsWithSavepoints_allSavepointsShouldBeImported() { + val form1Name = "sampleForm1" + val form2Name = "sampleForm2" + + // create blank forms + val form1 = createBlankForm(project, form1Name, "1") + val form2 = createBlankForm(project, form2Name, "2") + + // create saved forms + val savedForm1 = createSavedForm(form1Name, form1) + val savedForm2 = createSavedForm(form2Name, form2) + + // create savepoints + val savepointFile1 = createFileInCache(project, "${File(savedForm1.instanceFilePath).name}.save") + val savepointFile2 = createFileInCache(project, "${File(savedForm2.instanceFilePath).name}.save") + + // trigger importing + savepointsImporter.run() + + // verify import + val savepoints = savepointsRepository.getAll() + val expectedSavepoint1 = Savepoint(1, 1, savepointFile1.absolutePath, savedForm1.instanceFilePath) + val expectedSavepoint2 = Savepoint(2, 2, savepointFile2.absolutePath, savedForm2.instanceFilePath) + assertThat(savepoints, contains(expectedSavepoint1, expectedSavepoint2)) + } + + private fun createBlankForm(project: Project.Saved, formName: String, formId: String, formVersion: String = "1", date: Long = 0, deleted: Boolean = false): Form { + val formFile = File(storagePathProvider.getOdkDirPath(StorageSubdirectory.FORMS, project.uuid), "$formName.xml").also { + it.writeText(RandomString.randomString(10)) + } + val blankForm = formsRepository.save(FormFixtures.form(formId, formVersion, formFile.absolutePath)) + return formsRepository.save(Form.Builder(blankForm).date(date).deleted(deleted).build()) + } + + private fun createSavedForm(formName: String, form: Form, lastStatusChangeDate: Long = System.currentTimeMillis() - TimeInMs.ONE_HOUR, deletedDate: Long? = null): Instance { + return instancesRepository.save(InstanceFixtures.instance(displayName = formName, form = form, lastStatusChangeDate = lastStatusChangeDate, deletedDate = deletedDate)) + } + + private fun createFileInCache(project: Project.Saved, fileName: String): File { + val cacheDir = File(storagePathProvider.getOdkDirPath(StorageSubdirectory.CACHE, project.uuid)) + return File(cacheDir, fileName).also { + it.writeText(RandomString.randomString(10)) + } + } +} diff --git a/collect_app/src/test/java/org/odk/collect/android/application/initialization/ScheduledWorkUpgradeTest.kt b/collect_app/src/test/java/org/odk/collect/android/application/initialization/ScheduledWorkUpgradeTest.kt new file mode 100644 index 00000000000..984d2164725 --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/application/initialization/ScheduledWorkUpgradeTest.kt @@ -0,0 +1,67 @@ +package org.odk.collect.android.application.initialization + +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.odk.collect.android.backgroundwork.FormUpdateScheduler +import org.odk.collect.android.backgroundwork.InstanceSubmitScheduler +import org.odk.collect.async.Scheduler +import org.odk.collect.projects.InMemProjectsRepository +import org.odk.collect.projects.Project + +class ScheduledWorkUpgradeTest { + + @Test + fun `cancels all existing background jobs`() { + val scheduler = mock() + val scheduledWorkUpgrade = ScheduledWorkUpgrade( + scheduler, + InMemProjectsRepository(), + mock(), + mock() + ) + + scheduledWorkUpgrade.run() + verify(scheduler).cancelAllDeferred() + } + + @Test + fun `schedules updates for all projects`() { + val projectsRepository = InMemProjectsRepository() + val project1 = projectsRepository.save(Project.New("1", "1", "#ffffff")) + val project2 = projectsRepository.save(Project.New("2", "2", "#ffffff")) + + val formUpdateScheduler = mock() + val scheduledWorkUpgrade = ScheduledWorkUpgrade( + mock(), + projectsRepository, + formUpdateScheduler, + mock() + ) + + scheduledWorkUpgrade.run() + verify(formUpdateScheduler).scheduleUpdates(project1.uuid) + verify(formUpdateScheduler).scheduleUpdates(project2.uuid) + } + + @Test + fun `schedules submits for all projects`() { + val projectsRepository = InMemProjectsRepository() + val project1 = projectsRepository.save(Project.New("1", "1", "#ffffff")) + val project2 = projectsRepository.save(Project.New("2", "2", "#ffffff")) + + val instanceSubmitScheduler = mock() + val scheduledWorkUpgrade = ScheduledWorkUpgrade( + mock(), + projectsRepository, + mock(), + instanceSubmitScheduler + ) + + scheduledWorkUpgrade.run() + verify(instanceSubmitScheduler).scheduleAutoSend(project1.uuid) + verify(instanceSubmitScheduler).scheduleFormAutoSend(project1.uuid) + verify(instanceSubmitScheduler).scheduleAutoSend(project2.uuid) + verify(instanceSubmitScheduler).scheduleFormAutoSend(project2.uuid) + } +} diff --git a/collect_app/src/test/java/org/odk/collect/android/audio/AudioRecordingControllerFragmentTest.java b/collect_app/src/test/java/org/odk/collect/android/audio/AudioRecordingControllerFragmentTest.java index 9cb6c98cb05..76a105857a6 100644 --- a/collect_app/src/test/java/org/odk/collect/android/audio/AudioRecordingControllerFragmentTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/audio/AudioRecordingControllerFragmentTest.java @@ -28,13 +28,13 @@ import org.odk.collect.android.formentry.FormEntryViewModel; import org.odk.collect.android.injection.config.AppDependencyModule; import org.odk.collect.android.support.CollectHelpers; -import org.odk.collect.android.utilities.ExternalWebPageHelper; import org.odk.collect.androidshared.livedata.MutableNonNullLiveData; import org.odk.collect.androidshared.ui.FragmentFactoryBuilder; import org.odk.collect.audiorecorder.recorder.Output; import org.odk.collect.audiorecorder.recording.AudioRecorder; import org.odk.collect.audiorecorder.testsupport.StubAudioRecorder; import org.odk.collect.fragmentstest.FragmentScenarioLauncherRule; +import org.odk.collect.webpage.ExternalWebPageHelper; import org.robolectric.annotation.Config; import java.io.File; diff --git a/collect_app/src/test/java/org/odk/collect/android/backgroundwork/AutoSendTaskSpecTest.kt b/collect_app/src/test/java/org/odk/collect/android/backgroundwork/AutoSendTaskSpecTest.kt deleted file mode 100644 index e685d823ac0..00000000000 --- a/collect_app/src/test/java/org/odk/collect/android/backgroundwork/AutoSendTaskSpecTest.kt +++ /dev/null @@ -1,85 +0,0 @@ -package org.odk.collect.android.backgroundwork - -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.hamcrest.CoreMatchers.`is` -import org.hamcrest.CoreMatchers.nullValue -import org.hamcrest.MatcherAssert.assertThat -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import org.odk.collect.android.TestSettingsProvider -import org.odk.collect.android.formmanagement.FormSourceProvider -import org.odk.collect.android.formmanagement.InstancesDataService -import org.odk.collect.android.injection.config.AppDependencyModule -import org.odk.collect.android.instancemanagement.autosend.AutoSendSettingsProvider -import org.odk.collect.android.instancemanagement.autosend.InstanceAutoSender -import org.odk.collect.android.notifications.Notifier -import org.odk.collect.android.projects.ProjectDependencyProvider -import org.odk.collect.android.projects.ProjectDependencyProviderFactory -import org.odk.collect.android.storage.StoragePathProvider -import org.odk.collect.android.support.CollectHelpers -import org.odk.collect.android.utilities.ChangeLockProvider -import org.odk.collect.android.utilities.FormsRepositoryProvider -import org.odk.collect.android.utilities.InstancesRepositoryProvider -import org.odk.collect.metadata.PropertyManager -import org.odk.collect.settings.SettingsProvider -import org.odk.collect.settings.keys.ProjectKeys -import org.odk.collect.testshared.RobolectricHelpers - -@RunWith(AndroidJUnit4::class) -class AutoSendTaskSpecTest { - - private val instanceAutoSender = mock() - private val projectDependencyProvider = mock() - private val projectDependencyProviderFactory = mock() - - private lateinit var projectId: String - - @Before - fun setup() { - CollectHelpers.overrideAppDependencyModule(object : AppDependencyModule() { - override fun providesInstanceAutoSender( - autoSendSettingsProvider: AutoSendSettingsProvider?, - notifier: Notifier?, - instancesDataService: InstancesDataService?, - propertyManager: PropertyManager? - ): InstanceAutoSender { - return instanceAutoSender - } - - override fun providesProjectDependencyProviderFactory( - settingsProvider: SettingsProvider?, - formsRepositoryProvider: FormsRepositoryProvider?, - instancesRepositoryProvider: InstancesRepositoryProvider?, - storagePathProvider: StoragePathProvider?, - changeLockProvider: ChangeLockProvider?, - formSourceProvider: FormSourceProvider? - ): ProjectDependencyProviderFactory { - return projectDependencyProviderFactory - } - }) - - RobolectricHelpers.mountExternalStorage() - projectId = CollectHelpers.setupDemoProject() - TestSettingsProvider.getUnprotectedSettings(projectId) - .save(ProjectKeys.KEY_AUTOSEND, "wifi_and_cellular") - - whenever(projectDependencyProviderFactory.create(projectId)).thenReturn(projectDependencyProvider) - } - - @Test - fun `passes projectDependencyProvider with proper project id`() { - val inputData = mapOf(TaskData.DATA_PROJECT_ID to projectId) - AutoSendTaskSpec().getTask(ApplicationProvider.getApplicationContext(), inputData, true).get() - verify(instanceAutoSender).autoSendInstances(projectDependencyProvider) - } - - @Test - fun `maxRetries should not be limited`() { - assertThat(AutoSendTaskSpec().maxRetries, `is`(nullValue())) - } -} diff --git a/collect_app/src/test/java/org/odk/collect/android/backgroundwork/FormUpdateAndInstanceSubmitSchedulerTest.kt b/collect_app/src/test/java/org/odk/collect/android/backgroundwork/FormUpdateAndInstanceSubmitSchedulerTest.kt index 0373f6f6577..545e5a622c4 100644 --- a/collect_app/src/test/java/org/odk/collect/android/backgroundwork/FormUpdateAndInstanceSubmitSchedulerTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/backgroundwork/FormUpdateAndInstanceSubmitSchedulerTest.kt @@ -9,11 +9,12 @@ import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify -import org.odk.collect.android.R +import org.mockito.kotlin.verifyNoInteractions import org.odk.collect.android.TestSettingsProvider -import org.odk.collect.android.preferences.utilities.FormUpdateMode.MATCH_EXACTLY -import org.odk.collect.android.preferences.utilities.FormUpdateMode.PREVIOUSLY_DOWNLOADED_ONLY import org.odk.collect.async.Scheduler +import org.odk.collect.settings.enums.FormUpdateMode.MATCH_EXACTLY +import org.odk.collect.settings.enums.FormUpdateMode.PREVIOUSLY_DOWNLOADED_ONLY +import org.odk.collect.settings.keys.ProjectKeys import org.odk.collect.settings.keys.ProjectKeys.KEY_FORM_UPDATE_MODE import org.odk.collect.settings.keys.ProjectKeys.KEY_PERIODIC_FORM_UPDATES_CHECK @@ -37,7 +38,7 @@ class FormUpdateAndInstanceSubmitSchedulerTest { val manager = FormUpdateAndInstanceSubmitScheduler(scheduler, settingsProvider, application) manager.scheduleUpdates("myProject") - verify(scheduler).networkDeferred( + verify(scheduler).networkDeferredRepeat( eq("serverPollingJob:myProject"), any(), eq(3600000), @@ -74,7 +75,7 @@ class FormUpdateAndInstanceSubmitSchedulerTest { val manager = FormUpdateAndInstanceSubmitScheduler(scheduler, settingsProvider, application) manager.scheduleUpdates("myProject") - verify(scheduler).networkDeferred( + verify(scheduler).networkDeferredRepeat( eq("match_exactly:myProject"), any(), eq(3600000), @@ -83,17 +84,60 @@ class FormUpdateAndInstanceSubmitSchedulerTest { } @Test - fun `scheduleSubmit passes current project ID`() { + fun `scheduleAutoSend passes current project ID`() { + settingsProvider.getUnprotectedSettings("myProject") + .save(ProjectKeys.KEY_AUTOSEND, "wifi_and_cellular") val manager = FormUpdateAndInstanceSubmitScheduler(scheduler, settingsProvider, application) - manager.scheduleSubmit("myProject") + manager.scheduleAutoSend("myProject") verify(scheduler).networkDeferred( eq("AutoSendWorker:myProject"), - any(), - eq(mapOf(TaskData.DATA_PROJECT_ID to "myProject")) + any(), + eq(mapOf(TaskData.DATA_PROJECT_ID to "myProject")), + eq(null) + ) + } + + @Test + fun `scheduleAutoSend uses wifi network type when set in settings`() { + settingsProvider.getUnprotectedSettings("myProject") + .save(ProjectKeys.KEY_AUTOSEND, "wifi_only") + val manager = FormUpdateAndInstanceSubmitScheduler(scheduler, settingsProvider, application) + + manager.scheduleAutoSend("myProject") + verify(scheduler).networkDeferred( + eq("AutoSendWorker:myProject"), + any(), + eq(mapOf(TaskData.DATA_PROJECT_ID to "myProject")), + eq(Scheduler.NetworkType.WIFI) + ) + } + + @Test + fun `scheduleAutoSend uses cellular network type when set in settings`() { + settingsProvider.getUnprotectedSettings("myProject") + .save(ProjectKeys.KEY_AUTOSEND, "cellular_only") + val manager = FormUpdateAndInstanceSubmitScheduler(scheduler, settingsProvider, application) + + manager.scheduleAutoSend("myProject") + verify(scheduler).networkDeferred( + eq("AutoSendWorker:myProject"), + any(), + eq(mapOf(TaskData.DATA_PROJECT_ID to "myProject")), + eq(Scheduler.NetworkType.CELLULAR) ) } + @Test + fun `scheduleAutoSend does nothing if auto send is disabled`() { + settingsProvider.getUnprotectedSettings("myProject") + .save(ProjectKeys.KEY_AUTOSEND, "off") + val manager = FormUpdateAndInstanceSubmitScheduler(scheduler, settingsProvider, application) + + manager.scheduleAutoSend("myProject") + verifyNoInteractions(scheduler) + } + @Test fun `cancelSubmit cancels auto send for current project`() { val manager = FormUpdateAndInstanceSubmitScheduler(scheduler, settingsProvider, application) diff --git a/collect_app/src/test/java/org/odk/collect/android/backgroundwork/SendFormsTaskSpecTest.kt b/collect_app/src/test/java/org/odk/collect/android/backgroundwork/SendFormsTaskSpecTest.kt new file mode 100644 index 00000000000..915ef9b1192 --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/backgroundwork/SendFormsTaskSpecTest.kt @@ -0,0 +1,69 @@ +package org.odk.collect.android.backgroundwork + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.odk.collect.android.injection.config.AppDependencyModule +import org.odk.collect.android.instancemanagement.InstancesDataService +import org.odk.collect.android.notifications.Notifier +import org.odk.collect.android.openrosa.OpenRosaHttpInterface +import org.odk.collect.android.projects.ProjectDependencyProviderFactory +import org.odk.collect.android.projects.ProjectsDataService +import org.odk.collect.android.support.CollectHelpers +import org.odk.collect.metadata.PropertyManager +import org.odk.collect.testshared.RobolectricHelpers + +@RunWith(AndroidJUnit4::class) +class SendFormsTaskSpecTest { + + private val instancesDataService = mock() + private lateinit var projectId: String + + @Before + fun setup() { + CollectHelpers.overrideAppDependencyModule(object : AppDependencyModule() { + override fun providesInstancesDataService( + application: Application?, + projectsDataService: ProjectsDataService?, + instanceSubmitScheduler: InstanceSubmitScheduler?, + projectsDependencyProviderFactory: ProjectDependencyProviderFactory?, + notifier: Notifier?, + propertyManager: PropertyManager?, + httpInterface: OpenRosaHttpInterface + ): InstancesDataService { + return instancesDataService + } + }) + + RobolectricHelpers.mountExternalStorage() + projectId = CollectHelpers.setupDemoProject() + } + + @Test + fun `returns false if sending instances fails`() { + whenever(instancesDataService.sendInstances(projectId)).doReturn(false) + + val inputData = mapOf(TaskData.DATA_PROJECT_ID to projectId) + val spec = SendFormsTaskSpec() + val task = spec.getTask(ApplicationProvider.getApplicationContext(), inputData, true) + assertThat(task.get(), equalTo(false)) + } + + @Test + fun `returns true if sending instances succeeds`() { + whenever(instancesDataService.sendInstances(projectId)).doReturn(true) + + val inputData = mapOf(TaskData.DATA_PROJECT_ID to projectId) + val spec = SendFormsTaskSpec() + val task = spec.getTask(ApplicationProvider.getApplicationContext(), inputData, true) + assertThat(task.get(), equalTo(true)) + } +} diff --git a/collect_app/src/test/java/org/odk/collect/android/database/DatabaseFormsRepositoryTest.java b/collect_app/src/test/java/org/odk/collect/android/database/DatabaseFormsRepositoryTest.java index d88c8dd04e6..344f7a304cd 100644 --- a/collect_app/src/test/java/org/odk/collect/android/database/DatabaseFormsRepositoryTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/database/DatabaseFormsRepositoryTest.java @@ -21,12 +21,12 @@ public class DatabaseFormsRepositoryTest extends FormsRepositoryTest { @Override public FormsRepository buildSubject() { - return new DatabaseFormsRepository(ApplicationProvider.getApplicationContext(), dbDir.getAbsolutePath(), formsDir.getAbsolutePath(), cacheDir.getAbsolutePath(), System::currentTimeMillis); + return new DatabaseFormsRepository(ApplicationProvider.getApplicationContext(), dbDir.getAbsolutePath(), formsDir.getAbsolutePath(), cacheDir.getAbsolutePath(), System::currentTimeMillis, savepointsRepository); } @Override public FormsRepository buildSubject(Supplier clock) { - return new DatabaseFormsRepository(ApplicationProvider.getApplicationContext(), dbDir.getAbsolutePath(), formsDir.getAbsolutePath(), cacheDir.getAbsolutePath(), clock); + return new DatabaseFormsRepository(ApplicationProvider.getApplicationContext(), dbDir.getAbsolutePath(), formsDir.getAbsolutePath(), cacheDir.getAbsolutePath(), clock, savepointsRepository); } @Override diff --git a/collect_app/src/test/java/org/odk/collect/android/database/DatabaseSavepointsRepositoryTest.kt b/collect_app/src/test/java/org/odk/collect/android/database/DatabaseSavepointsRepositoryTest.kt new file mode 100644 index 00000000000..7ee27cbc293 --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/database/DatabaseSavepointsRepositoryTest.kt @@ -0,0 +1,21 @@ +package org.odk.collect.android.database + +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.runner.RunWith +import org.odk.collect.android.database.savepoints.DatabaseSavepointsRepository +import org.odk.collect.forms.savepoints.SavepointsRepository +import org.odk.collect.formstest.SavepointsRepositoryTest +import org.odk.collect.shared.TempFiles + +@RunWith(AndroidJUnit4::class) +class DatabaseSavepointsRepositoryTest : SavepointsRepositoryTest() { + override fun buildSubject(cacheDirPath: String, instancesDirPath: String): SavepointsRepository { + return DatabaseSavepointsRepository( + ApplicationProvider.getApplicationContext(), + TempFiles.createTempDir().absolutePath, + cacheDirPath, + instancesDirPath + ) + } +} diff --git a/collect_app/src/test/java/org/odk/collect/android/database/FormDatabaseMigratorTest.java b/collect_app/src/test/java/org/odk/collect/android/database/FormDatabaseMigratorTest.java index b255629e60d..f2a4d23ccea 100644 --- a/collect_app/src/test/java/org/odk/collect/android/database/FormDatabaseMigratorTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/database/FormDatabaseMigratorTest.java @@ -53,7 +53,7 @@ public class FormDatabaseMigratorTest { @Before public void setup() { - assertThat("Test expects different Forms DB version", DatabaseConstants.FORMS_DATABASE_VERSION, is(12)); + assertThat("Test expects different Forms DB version", DatabaseConstants.FORMS_DATABASE_VERSION, is(13)); database = SQLiteDatabase.create(null); } @@ -62,6 +62,67 @@ public void teardown() { database.close(); } + @Test + public void databaseIdsShouldNotBeReused() { + FormDatabaseMigrator formDatabaseMigrator = new FormDatabaseMigrator(); + formDatabaseMigrator.onCreate(database); + + ContentValues contentValues = getContentValuesForFormV12(); + database.insert(FORMS_TABLE_NAME, null, contentValues); + try (Cursor cursor = database.rawQuery("SELECT * FROM " + FORMS_TABLE_NAME + ";", new String[]{})) { + assertThat(cursor.getCount(), is(1)); + cursor.moveToFirst(); + assertThat(cursor.getInt(cursor.getColumnIndex(_ID)), is(1)); + } + + database.delete(FORMS_TABLE_NAME, null, null); + database.insert(FORMS_TABLE_NAME, null, contentValues); + try (Cursor cursor = database.rawQuery("SELECT * FROM " + FORMS_TABLE_NAME + ";", new String[]{})) { + assertThat(cursor.getCount(), is(1)); + cursor.moveToFirst(); + assertThat(cursor.getInt(cursor.getColumnIndex(_ID)), is(2)); + } + } + + @Test + public void onUpgrade_fromVersion12() { + int oldVersion = 12; + database.setVersion(oldVersion); + FormDatabaseMigrator formDatabaseMigrator = new FormDatabaseMigrator(); + + formDatabaseMigrator.createFormsTableV12(database); + ContentValues contentValues = getContentValuesForFormV12(); + database.insert(FORMS_TABLE_NAME, null, contentValues); + + formDatabaseMigrator.onUpgrade(database, oldVersion); + + try (Cursor cursor = database.rawQuery("SELECT * FROM " + FORMS_TABLE_NAME + ";", new String[]{})) { + assertThat(cursor.getColumnCount(), is(18)); + assertThat(cursor.getCount(), is(1)); + + cursor.moveToFirst(); + + assertThat(cursor.getInt(cursor.getColumnIndex(_ID)), is(1)); + assertThat(cursor.getString(cursor.getColumnIndex(DISPLAY_NAME)), is(contentValues.getAsString(DISPLAY_NAME))); + assertThat(cursor.getString(cursor.getColumnIndex(DESCRIPTION)), is(contentValues.getAsString(DESCRIPTION))); + assertThat(cursor.getString(cursor.getColumnIndex(JR_FORM_ID)), is(contentValues.getAsString(JR_FORM_ID))); + assertThat(cursor.getString(cursor.getColumnIndex(JR_VERSION)), is(contentValues.getAsString(JR_VERSION))); + assertThat(cursor.getString(cursor.getColumnIndex(MD5_HASH)), is(contentValues.getAsString(MD5_HASH))); + assertThat(cursor.getInt(cursor.getColumnIndex(DATE)), is(contentValues.getAsInteger(DATE))); + assertThat(cursor.getString(cursor.getColumnIndex(FORM_MEDIA_PATH)), is(contentValues.getAsString(FORM_MEDIA_PATH))); + assertThat(cursor.getString(cursor.getColumnIndex(FORM_FILE_PATH)), is(contentValues.getAsString(FORM_FILE_PATH))); + assertThat(cursor.getString(cursor.getColumnIndex(LANGUAGE)), is(contentValues.getAsString(LANGUAGE))); + assertThat(cursor.getString(cursor.getColumnIndex(SUBMISSION_URI)), is(contentValues.getAsString(SUBMISSION_URI))); + assertThat(cursor.getString(cursor.getColumnIndex(BASE64_RSA_PUBLIC_KEY)), is(contentValues.getAsString(BASE64_RSA_PUBLIC_KEY))); + assertThat(cursor.getString(cursor.getColumnIndex(JRCACHE_FILE_PATH)), is(contentValues.getAsString(JRCACHE_FILE_PATH))); + assertThat(cursor.getString(cursor.getColumnIndex(AUTO_SEND)), is(contentValues.getAsString(AUTO_SEND))); + assertThat(cursor.getString(cursor.getColumnIndex(AUTO_DELETE)), is(contentValues.getAsString(AUTO_DELETE))); + assertThat(cursor.getString(cursor.getColumnIndex(GEOMETRY_XPATH)), is(contentValues.getAsString(GEOMETRY_XPATH))); + assertThat(cursor.getInt(cursor.getColumnIndex(DELETED_DATE)), is(contentValues.getAsInteger(DELETED_DATE))); + assertThat(cursor.getInt(cursor.getColumnIndex(LAST_DETECTED_ATTACHMENTS_UPDATE_DATE)), is(contentValues.getAsInteger(LAST_DETECTED_ATTACHMENTS_UPDATE_DATE))); + } + } + @Test public void onUpgrade_fromVersion11() { int oldVersion = 11; @@ -405,6 +466,28 @@ private ContentValues createVersion11Form() { return contentValues; } + private ContentValues getContentValuesForFormV12() { + ContentValues contentValues = new ContentValues(); + contentValues.put(DISPLAY_NAME, "DisplayName"); + contentValues.put(DESCRIPTION, "Description"); + contentValues.put(JR_FORM_ID, "FormId"); + contentValues.put(JR_VERSION, "FormVersion"); + contentValues.put(MD5_HASH, "Md5Hash"); + contentValues.put(DATE, 0); + contentValues.put(FORM_MEDIA_PATH, "Form/Media/Path"); + contentValues.put(FORM_FILE_PATH, "Form/File/Path"); + contentValues.put(LANGUAGE, "Language"); + contentValues.put(SUBMISSION_URI, "submission.uri"); + contentValues.put(BASE64_RSA_PUBLIC_KEY, "Base64RsaPublicKey"); + contentValues.put(JRCACHE_FILE_PATH, "Jr/Cache/File/Path"); + contentValues.put(AUTO_SEND, "AutoSend"); + contentValues.put(AUTO_DELETE, "AutoDelete"); + contentValues.put(GEOMETRY_XPATH, "GeometryXPath"); + contentValues.put(DELETED_DATE, 0); + contentValues.put(LAST_DETECTED_ATTACHMENTS_UPDATE_DATE, 0); + return contentValues; + } + private void createVersion7Database(SQLiteDatabase database) { database.execSQL("CREATE TABLE IF NOT EXISTS " + FORMS_TABLE_NAME + " (" + _ID + " integer primary key, " diff --git a/collect_app/src/test/java/org/odk/collect/android/database/InstanceDatabaseMigratorTest.kt b/collect_app/src/test/java/org/odk/collect/android/database/InstanceDatabaseMigratorTest.kt new file mode 100644 index 00000000000..f7b5346862f --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/database/InstanceDatabaseMigratorTest.kt @@ -0,0 +1,110 @@ +package org.odk.collect.android.database + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import android.provider.BaseColumns._ID +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.odk.collect.android.database.instances.DatabaseInstanceColumns.CAN_EDIT_WHEN_COMPLETE +import org.odk.collect.android.database.instances.DatabaseInstanceColumns.DELETED_DATE +import org.odk.collect.android.database.instances.DatabaseInstanceColumns.DISPLAY_NAME +import org.odk.collect.android.database.instances.DatabaseInstanceColumns.GEOMETRY +import org.odk.collect.android.database.instances.DatabaseInstanceColumns.GEOMETRY_TYPE +import org.odk.collect.android.database.instances.DatabaseInstanceColumns.INSTANCE_FILE_PATH +import org.odk.collect.android.database.instances.DatabaseInstanceColumns.JR_FORM_ID +import org.odk.collect.android.database.instances.DatabaseInstanceColumns.JR_VERSION +import org.odk.collect.android.database.instances.DatabaseInstanceColumns.LAST_STATUS_CHANGE_DATE +import org.odk.collect.android.database.instances.DatabaseInstanceColumns.STATUS +import org.odk.collect.android.database.instances.DatabaseInstanceColumns.SUBMISSION_URI +import org.odk.collect.android.database.instances.InstanceDatabaseMigrator + +@RunWith(AndroidJUnit4::class) +class InstanceDatabaseMigratorTest { + + private var database = SQLiteDatabase.create(null) + private var instancesDatabaseMigrator = InstanceDatabaseMigrator() + + @Before + fun setup() { + assertThat("Test expects different Instances DB version", DatabaseConstants.INSTANCES_DATABASE_VERSION, equalTo(7)) + } + + @After + fun teardown() { + database.close() + } + + @Test + fun databaseIdsShouldNotBeReused() { + instancesDatabaseMigrator.onCreate(database) + val contentValues = getContentValuesForInstanceV6() + + database.insert(DatabaseConstants.INSTANCES_TABLE_NAME, null, contentValues) + database.rawQuery("SELECT * FROM " + DatabaseConstants.INSTANCES_TABLE_NAME + ";", arrayOf()).use { cursor -> + assertThat(cursor.count, equalTo(1)) + cursor.moveToFirst() + assertThat(cursor.getInt(cursor.getColumnIndex(_ID)), equalTo(1)) + } + + database.delete(DatabaseConstants.INSTANCES_TABLE_NAME, null, null) + database.insert(DatabaseConstants.INSTANCES_TABLE_NAME, null, contentValues) + + database.rawQuery("SELECT * FROM " + DatabaseConstants.INSTANCES_TABLE_NAME + ";", arrayOf()).use { cursor -> + assertThat(cursor.count, equalTo(1)) + cursor.moveToFirst() + assertThat(cursor.getInt(cursor.getColumnIndex(_ID)), equalTo(2)) + } + } + + @Test + fun onUpgrade_fromVersion6() { + val oldVersion = 6 + database.version = oldVersion + instancesDatabaseMigrator.createInstancesTableV6(database) + + val contentValues = getContentValuesForInstanceV6() + + database.insert(DatabaseConstants.INSTANCES_TABLE_NAME, null, contentValues) + instancesDatabaseMigrator.onUpgrade(database, oldVersion) + database.rawQuery("SELECT * FROM " + DatabaseConstants.INSTANCES_TABLE_NAME + ";", arrayOf()).use { cursor -> + assertThat(cursor.columnCount, equalTo(12)) + assertThat(cursor.count, equalTo(1)) + + cursor.moveToFirst() + + assertThat(cursor.getInt(cursor.getColumnIndex(_ID)), equalTo(1)) + assertThat(cursor.getString(cursor.getColumnIndex(DISPLAY_NAME)), equalTo(contentValues.getAsString(DISPLAY_NAME))) + assertThat(cursor.getString(cursor.getColumnIndex(SUBMISSION_URI)), equalTo(contentValues.getAsString(SUBMISSION_URI))) + assertThat(cursor.getString(cursor.getColumnIndex(CAN_EDIT_WHEN_COMPLETE)), equalTo(contentValues.getAsString(CAN_EDIT_WHEN_COMPLETE))) + assertThat(cursor.getString(cursor.getColumnIndex(INSTANCE_FILE_PATH)), equalTo(contentValues.getAsString(INSTANCE_FILE_PATH))) + assertThat(cursor.getString(cursor.getColumnIndex(JR_FORM_ID)), equalTo(contentValues.getAsString(JR_FORM_ID))) + assertThat(cursor.getString(cursor.getColumnIndex(JR_VERSION)), equalTo(contentValues.getAsString(JR_VERSION))) + assertThat(cursor.getString(cursor.getColumnIndex(STATUS)), equalTo(contentValues.getAsString(STATUS))) + assertThat(cursor.getInt(cursor.getColumnIndex(LAST_STATUS_CHANGE_DATE)), equalTo(contentValues.getAsInteger(LAST_STATUS_CHANGE_DATE))) + assertThat(cursor.getInt(cursor.getColumnIndex(DELETED_DATE)), equalTo(contentValues.getAsInteger(DELETED_DATE))) + assertThat(cursor.getString(cursor.getColumnIndex(GEOMETRY)), equalTo(contentValues.getAsString(GEOMETRY))) + assertThat(cursor.getString(cursor.getColumnIndex(GEOMETRY_TYPE)), equalTo(contentValues.getAsString(GEOMETRY_TYPE))) + } + } + + private fun getContentValuesForInstanceV6(): ContentValues { + return ContentValues().apply { + put(DISPLAY_NAME, "DisplayName") + put(SUBMISSION_URI, "SubmissionUri") + put(CAN_EDIT_WHEN_COMPLETE, "True") + put(INSTANCE_FILE_PATH, "InstanceFilePath") + put(JR_FORM_ID, "JrFormId") + put(JR_VERSION, "JrVersion") + put(STATUS, "Status") + put(LAST_STATUS_CHANGE_DATE, 0) + put(DELETED_DATE, 0) + put(GEOMETRY, "Geometry") + put(GEOMETRY_TYPE, "GeometryType") + } + } +} diff --git a/collect_app/src/test/java/org/odk/collect/android/dynamicpreload/DynamicPreloadParseProcessorTest.kt b/collect_app/src/test/java/org/odk/collect/android/dynamicpreload/DynamicPreloadParseProcessorTest.kt index 0d4d7130648..dca9400f80d 100644 --- a/collect_app/src/test/java/org/odk/collect/android/dynamicpreload/DynamicPreloadParseProcessorTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/dynamicpreload/DynamicPreloadParseProcessorTest.kt @@ -70,7 +70,7 @@ class DynamicPreloadParseProcessorTest { } private fun createQuestion(appearance: String): QuestionDef { - return mock() { + return mock { on { appearanceAttr } doReturn appearance } } diff --git a/collect_app/src/test/java/org/odk/collect/android/entities/EntitiesRepositoryTest.kt b/collect_app/src/test/java/org/odk/collect/android/entities/EntitiesRepositoryTest.kt index 189eff5fa5d..7ffff307686 100644 --- a/collect_app/src/test/java/org/odk/collect/android/entities/EntitiesRepositoryTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/entities/EntitiesRepositoryTest.kt @@ -1,6 +1,8 @@ package org.odk.collect.android.entities import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.contains +import org.hamcrest.Matchers.containsInAnyOrder import org.hamcrest.Matchers.equalTo import org.junit.Test import org.odk.collect.entities.EntitiesRepository @@ -11,11 +13,23 @@ abstract class EntitiesRepositoryTest { abstract fun buildSubject(): EntitiesRepository @Test - fun `getEntities returns entities for dataset`() { + fun `#getLists returns lists for saved entities`() { val repository = buildSubject() - val wine = Entity("wines", emptyList()) - val whisky = Entity("whiskys", emptyList()) + val wine = Entity("wines", "1", "Léoville Barton 2008") + val whisky = Entity("whiskys", "2", "Lagavulin 16") + repository.save(wine) + repository.save(whisky) + + assertThat(repository.getLists(), containsInAnyOrder("wines", "whiskys")) + } + + @Test + fun `#getEntities returns entities for list`() { + val repository = buildSubject() + + val wine = Entity("wines", "1", "Léoville Barton 2008") + val whisky = Entity("whiskys", "2", "Lagavulin 16") repository.save(wine) repository.save(whisky) @@ -27,4 +41,206 @@ abstract class EntitiesRepositoryTest { assertThat(whiskys.size, equalTo(1)) assertThat(whiskys[0], equalTo(whisky)) } + + @Test + fun `#save updates existing entity with matching id`() { + val repository = buildSubject() + + val wine = Entity("wines", "1", "Léoville Barton 2008", version = 1) + repository.save(wine) + + val updatedWine = Entity("wines", wine.id, "Léoville Barton 2009", version = 2) + repository.save(updatedWine) + + val wines = repository.getEntities("wines") + assertThat(wines, contains(updatedWine)) + } + + @Test + fun `#save updates existing entity with matching id in different list`() { + val repository = buildSubject() + + val wine = Entity("wines", "1", "Léoville Barton 2008", version = 1) + repository.save(wine) + + val updatedWine = Entity("whisky", wine.id, "Edradour 10", version = 2) + repository.save(updatedWine) + + val wines = repository.getEntities("wines") + assertThat(wines.size, equalTo(0)) + val whiskys = repository.getEntities("whisky") + assertThat(whiskys, contains(updatedWine)) + } + + @Test + fun `#save updates existing entity with matching id and version`() { + val repository = buildSubject() + + val wine = Entity("wines", "1", "Léoville Barton 2008", version = 1) + repository.save(wine) + + val updatedWine = wine.copy(label = "Léoville Barton 2009") + repository.save(updatedWine) + + val wines = repository.getEntities("wines") + assertThat(wines, contains(updatedWine)) + } + + @Test + fun `#save updates state on existing entity when it is offline`() { + val repository = buildSubject() + + val wine = Entity("wines", "1", "Léoville Barton 2008", state = Entity.State.OFFLINE) + repository.save(wine) + + val updatedWine = wine.copy(state = Entity.State.ONLINE) + repository.save(updatedWine) + + val wines = repository.getEntities("wines") + assertThat(wines, contains(updatedWine)) + } + + @Test + fun `#save does not update state on existing entity when it is online`() { + val repository = buildSubject() + + val wine = Entity("wines", "1", "Léoville Barton 2008", state = Entity.State.ONLINE) + repository.save(wine) + + val updatedWine = wine.copy(state = Entity.State.OFFLINE) + repository.save(updatedWine) + + val wines = repository.getEntities("wines") + assertThat(wines, contains(wine)) + } + + @Test + fun `#save adds new properties`() { + val repository = buildSubject() + + val wine = Entity( + "wines", + "1", + "Léoville Barton 2008", + properties = listOf("window" to "2019-2038"), + version = 1 + ) + repository.save(wine) + + val updatedWine = Entity( + "wines", + wine.id, + "Léoville Barton 2008", + properties = listOf("score" to "92"), + version = 2 + ) + repository.save(updatedWine) + + val wines = repository.getEntities("wines") + assertThat(wines.size, equalTo(1)) + assertThat(wines[0].properties, contains("window" to "2019-2038", "score" to "92")) + } + + @Test + fun `#save updates existing properties`() { + val repository = buildSubject() + + val wine = Entity( + "wines", + "1", + "Léoville Barton 2008", + properties = listOf("window" to "2019-2038"), + version = 1 + ) + repository.save(wine) + + val updatedWine = Entity( + "wines", + wine.id, + "Léoville Barton 2008", + properties = listOf("window" to "2019-2042"), + version = 2 + ) + repository.save(updatedWine) + + val wines = repository.getEntities("wines") + assertThat(wines.size, equalTo(1)) + assertThat(wines[0].properties, contains("window" to "2019-2042")) + } + + @Test + fun `#save does not update existing label if new one is null`() { + val repository = buildSubject() + + val wine = Entity( + "wines", + "1", + "Léoville Barton 2008", + properties = listOf("window" to "2019-2038"), + version = 1 + ) + repository.save(wine) + + val updatedWine = Entity( + "wines", + wine.id, + null, + properties = listOf("window" to "2019-2042"), + version = 2 + ) + repository.save(updatedWine) + + val wines = repository.getEntities("wines") + assertThat(wines.size, equalTo(1)) + assertThat(wines[0].label, equalTo(wine.label)) + assertThat(wines[0].properties, equalTo(updatedWine.properties)) + } + + @Test + fun `#clear deletes all entities`() { + val repository = buildSubject() + + val wine = Entity("wines", "1", "Léoville Barton 2008") + val whisky = Entity("whiskys", "2", "Lagavulin 16") + repository.save(wine) + repository.save(whisky) + + repository.clear() + assertThat(repository.getLists().size, equalTo(0)) + assertThat(repository.getEntities("wines").size, equalTo(0)) + assertThat(repository.getEntities("whiskys").size, equalTo(0)) + } + + @Test + fun `#save can save multiple entities`() { + val repository = buildSubject() + + val wine = Entity("wines", "1", "Léoville Barton 2008") + val whisky = Entity("whiskys", "2", "Lagavulin 16") + repository.save(wine, whisky) + + assertThat(repository.getLists(), containsInAnyOrder("wines", "whiskys")) + } + + @Test + fun `#addList adds a list with no entities`() { + val repository = buildSubject() + + repository.addList("wine") + assertThat(repository.getLists(), containsInAnyOrder("wine")) + assertThat(repository.getEntities("wine").size, equalTo(0)) + } + + @Test + fun `#delete removes an entity`() { + val repository = buildSubject() + + val leoville = Entity("wines", "1", "Léoville Barton 2008") + val canet = Entity("wines", "2", "Pontet-Canet 2014") + repository.save(leoville, canet) + + repository.delete("1") + + assertThat(repository.getEntities("wines"), containsInAnyOrder(canet)) + } } diff --git a/collect_app/src/test/java/org/odk/collect/android/entities/InMemEntitiesRepositoryTest.kt b/collect_app/src/test/java/org/odk/collect/android/entities/InMemEntitiesRepositoryTest.kt index d62cf48a1a0..ac623d393b7 100644 --- a/collect_app/src/test/java/org/odk/collect/android/entities/InMemEntitiesRepositoryTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/entities/InMemEntitiesRepositoryTest.kt @@ -1,6 +1,7 @@ package org.odk.collect.android.entities import org.odk.collect.entities.EntitiesRepository +import org.odk.collect.entities.InMemEntitiesRepository class InMemEntitiesRepositoryTest : EntitiesRepositoryTest() { diff --git a/collect_app/src/test/java/org/odk/collect/android/entities/JsonFileEntitiesRepositoryTest.kt b/collect_app/src/test/java/org/odk/collect/android/entities/JsonFileEntitiesRepositoryTest.kt new file mode 100644 index 00000000000..d54a3fc8f5f --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/entities/JsonFileEntitiesRepositoryTest.kt @@ -0,0 +1,50 @@ +package org.odk.collect.android.entities + +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.contains +import org.hamcrest.Matchers.equalTo +import org.junit.Test +import org.odk.collect.entities.EntitiesRepository +import org.odk.collect.entities.Entity +import org.odk.collect.shared.TempFiles +import java.io.File + +class JsonFileEntitiesRepositoryTest : EntitiesRepositoryTest() { + + private val directory = TempFiles.createTempDir() + + override fun buildSubject(): EntitiesRepository { + return JsonFileEntitiesRepository(directory) + } + + @Test + fun `two repositories with the same directory have the same data`() { + val directory = File(TempFiles.getPathInTempDir()) + val one = JsonFileEntitiesRepository(directory) + val two = JsonFileEntitiesRepository(directory) + val three = JsonFileEntitiesRepository(File(TempFiles.getPathInTempDir())) + + val entity = Entity("stuff", "1", "A thing") + one.save(entity) + assertThat(two.getLists(), contains("stuff")) + assertThat(two.getEntities("stuff"), contains(entity)) + assertThat(three.getLists().size, equalTo(0)) + } + + @Test + fun `clears data if backing file can't be parsed by current code`() { + val repository = buildSubject() + repository.addList("stuff") + repository.save(Entity("stuff", "123", null)) + + val filesInDir = directory.listFiles() + assertThat(filesInDir!!.size, equalTo(1)) + val backingFile = filesInDir[0] + backingFile.writeText("blah") + + assertThat(repository.getEntities("stuff").size, equalTo(0)) + + repository.save(Entity("stuff", "123", null)) + assertThat(repository.getEntities("stuff").size, equalTo(1)) + } +} diff --git a/collect_app/src/test/java/org/odk/collect/android/external/FormUriActivityTest.kt b/collect_app/src/test/java/org/odk/collect/android/external/FormUriActivityTest.kt index 2e92d0d42f8..84aacd7470f 100644 --- a/collect_app/src/test/java/org/odk/collect/android/external/FormUriActivityTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/external/FormUriActivityTest.kt @@ -44,13 +44,16 @@ import org.odk.collect.android.support.CollectHelpers import org.odk.collect.android.utilities.ApplicationConstants import org.odk.collect.android.utilities.FormsRepositoryProvider import org.odk.collect.android.utilities.InstancesRepositoryProvider +import org.odk.collect.android.utilities.SavepointsRepositoryProvider import org.odk.collect.androidtest.ActivityScenarioLauncherRule import org.odk.collect.androidtest.RecordedIntentsRule import org.odk.collect.async.Scheduler import org.odk.collect.forms.instances.Instance +import org.odk.collect.forms.savepoints.Savepoint import org.odk.collect.formstest.FormUtils import org.odk.collect.formstest.InMemFormsRepository import org.odk.collect.formstest.InMemInstancesRepository +import org.odk.collect.formstest.InMemSavepointsRepository import org.odk.collect.projects.InMemProjectsRepository import org.odk.collect.projects.Project import org.odk.collect.projects.ProjectsRepository @@ -61,6 +64,8 @@ import org.odk.collect.shared.TempFiles import org.odk.collect.shared.strings.UUIDGenerator import org.odk.collect.testshared.FakeScheduler import java.io.File +import java.text.SimpleDateFormat +import java.util.Locale @RunWith(AndroidJUnit4::class) class FormUriActivityTest { @@ -69,13 +74,17 @@ class FormUriActivityTest { private val projectsRepository = InMemProjectsRepository() private val projectsDataService = mock() private val formsRepository = InMemFormsRepository() - private val instancesRepository = InMemInstancesRepository() + private val instancesRepository = InMemInstancesRepository { 0 } private val fakeScheduler = FakeScheduler() - private val settingsProvider = InMemSettingsProvider().apply { getProtectedSettings().save(ProtectedProjectKeys.KEY_EDIT_SAVED, true) } + private val savepointsRepository = InMemSavepointsRepository() + private val savepointsRepositoryProvider = mock().apply { + whenever(get()).thenReturn(savepointsRepository) + } + @get:Rule val activityRule = RecordedIntentsRule() @@ -125,6 +134,10 @@ class FormUriActivityTest { override fun providesScheduler(workManager: WorkManager?): Scheduler { return fakeScheduler } + + override fun providesSavepointsRepositoryProvider(context: Context?, storagePathProvider: StoragePathProvider?): SavepointsRepositoryProvider { + return savepointsRepositoryProvider + } }) } @@ -393,7 +406,7 @@ class FormUriActivityTest { val expectedMessage = context.getString( org.odk.collect.strings.R.string.parent_form_not_present, "${instance.formId}\n${ - context.getString(org.odk.collect.strings.R.string.version) + context.getString(org.odk.collect.strings.R.string.version) } ${instance.formVersion}" ) @@ -825,6 +838,199 @@ class FormUriActivityTest { ) } + @Test + fun `If there is a savepoint, display a recovery dialog before starting a blank form`() { + val project = Project.Saved("123", "First project", "A", "#cccccc") + projectsRepository.save(project) + whenever(projectsDataService.getCurrentProject()).thenReturn(project) + + val form = formsRepository.save( + FormUtils.buildForm( + "1", + "1", + TempFiles.createTempDir().absolutePath + ).build() + ) + val savepointFile = TempFiles.createTempFile() + val savepoint = Savepoint(form.dbId, null, savepointFile.absolutePath, TempFiles.createTempFile().absolutePath) + savepointsRepository.save(savepoint) + + launcherRule.launch(getBlankFormIntent(project.uuid, form.dbId)) + fakeScheduler.flush() + + assertSavepointRecoveryDialog(savepointFile) + } + + @Test + fun `If there is a savepoint, display a recovery dialog before starting a saved form`() { + val project = Project.Saved("123", "First project", "A", "#cccccc") + projectsRepository.save(project) + whenever(projectsDataService.getCurrentProject()).thenReturn(project) + + val form = formsRepository.save( + FormUtils.buildForm( + "1", + "1", + TempFiles.createTempDir().absolutePath + ).build() + ) + val instance = instancesRepository.save( + Instance.Builder() + .formId("1") + .formVersion("1") + .instanceFilePath(TempFiles.createTempFile(TempFiles.createTempDir()).absolutePath) + .status(Instance.STATUS_INCOMPLETE) + .build() + ) + + val savepointFile = TempFiles.createTempFile() + val savepoint = Savepoint(form.dbId, instance.dbId, savepointFile.absolutePath, TempFiles.createTempFile().absolutePath) + savepointsRepository.save(savepoint) + + launcherRule.launch(getSavedIntent(project.uuid, instance.dbId)) + fakeScheduler.flush() + + assertSavepointRecoveryDialog(savepointFile) + } + + @Test + fun `If there is a savepoint for older version of the blank form, display a recovery dialog and start the old version of the blank form if a user accepts`() { + val project = Project.Saved("123", "First project", "A", "#cccccc") + projectsRepository.save(project) + whenever(projectsDataService.getCurrentProject()).thenReturn(project) + + val formV1 = formsRepository.save( + FormUtils.buildForm( + "1", + "1", + TempFiles.createTempDir().absolutePath + ).build() + ) + + val formV2 = formsRepository.save( + FormUtils.buildForm( + "1", + "2", + TempFiles.createTempDir().absolutePath + ).build() + ) + + val savepointFile = TempFiles.createTempFile() + val savepoint = Savepoint(formV1.dbId, null, savepointFile.absolutePath, TempFiles.createTempFile().absolutePath) + savepointsRepository.save(savepoint) + + launcherRule.launch(getBlankFormIntent(project.uuid, formV2.dbId)) + fakeScheduler.flush() + + assertSavepointRecoveryDialog(savepointFile) + onView(withText(org.odk.collect.strings.R.string.recover)).perform(click()) + assertStartBlankFormIntent(project.uuid, formV1.dbId) + } + + @Test + fun `If there is a savepoint for older version of the blank form, display a recovery dialog and start the new version of the blank form if a user declines`() { + val project = Project.Saved("123", "First project", "A", "#cccccc") + projectsRepository.save(project) + whenever(projectsDataService.getCurrentProject()).thenReturn(project) + + val formV1 = formsRepository.save( + FormUtils.buildForm( + "1", + "1", + TempFiles.createTempDir().absolutePath + ).build() + ) + + val formV2 = formsRepository.save( + FormUtils.buildForm( + "1", + "2", + TempFiles.createTempDir().absolutePath + ).build() + ) + + val savepointFile = TempFiles.createTempFile() + val savepoint = Savepoint(formV1.dbId, null, savepointFile.absolutePath, TempFiles.createTempFile().absolutePath) + savepointsRepository.save(savepoint) + + launcherRule.launch(getBlankFormIntent(project.uuid, formV2.dbId)) + fakeScheduler.flush() + + assertSavepointRecoveryDialog(savepointFile) + onView(withText(org.odk.collect.strings.R.string.do_not_recover)).perform(click()) + fakeScheduler.flush() + assertStartBlankFormIntent(project.uuid, formV2.dbId) + } + + @Test + fun `An existing savepoint for a blank form should be removed when a user declines`() { + val project = Project.Saved("123", "First project", "A", "#cccccc") + projectsRepository.save(project) + whenever(projectsDataService.getCurrentProject()).thenReturn(project) + + val form = formsRepository.save( + FormUtils.buildForm( + "1", + "1", + TempFiles.createTempDir().absolutePath + ).build() + ) + val savepointFile = TempFiles.createTempFile() + val instanceDirFile = TempFiles.createTempDir() + val instanceFile = TempFiles.createTempFile(instanceDirFile) + val savepoint = Savepoint(form.dbId, null, savepointFile.absolutePath, instanceFile.absolutePath) + savepointsRepository.save(savepoint) + + launcherRule.launch(getBlankFormIntent(project.uuid, form.dbId)) + fakeScheduler.flush() + + assertSavepointRecoveryDialog(savepointFile) + onView(withText(org.odk.collect.strings.R.string.do_not_recover)).perform(click()) + fakeScheduler.flush() + assertThat(savepointsRepository.getAll().isEmpty(), equalTo(true)) + assertThat(instanceDirFile.exists(), equalTo(false)) + assertThat(instanceFile.exists(), equalTo(false)) + } + + @Test + fun `An existing savepoint for a saved form should be removed when a user declines`() { + val project = Project.Saved("123", "First project", "A", "#cccccc") + projectsRepository.save(project) + whenever(projectsDataService.getCurrentProject()).thenReturn(project) + + val form = formsRepository.save( + FormUtils.buildForm( + "1", + "1", + TempFiles.createTempDir().absolutePath + ).build() + ) + val instance = instancesRepository.save( + Instance.Builder() + .formId("1") + .formVersion("1") + .instanceFilePath(TempFiles.createTempFile(TempFiles.createTempDir()).absolutePath) + .status(Instance.STATUS_INCOMPLETE) + .build() + ) + + val savepointFile = TempFiles.createTempFile() + val instanceDirFile = TempFiles.createTempDir() + val instanceFile = TempFiles.createTempFile(instanceDirFile) + val savepoint = Savepoint(form.dbId, instance.dbId, savepointFile.absolutePath, instanceFile.absolutePath) + savepointsRepository.save(savepoint) + + launcherRule.launch(getSavedIntent(project.uuid, instance.dbId)) + fakeScheduler.flush() + + assertSavepointRecoveryDialog(savepointFile) + onView(withText(org.odk.collect.strings.R.string.do_not_recover)).perform(click()) + fakeScheduler.flush() + assertThat(savepointsRepository.getAll().isEmpty(), equalTo(true)) + assertThat(instanceDirFile.exists(), equalTo(true)) + assertThat(instanceFile.exists(), equalTo(true)) + } + private fun getBlankFormIntent(projectId: String?, dbId: Long) = Intent(context, FormUriActivity::class.java).apply { data = if (projectId == null) { @@ -881,6 +1087,13 @@ class FormUriActivityTest { } } + private fun assertSavepointRecoveryDialog(savepointFile: File) { + onView(withText(org.odk.collect.strings.R.string.savepoint_recovery_dialog_title)).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText(SimpleDateFormat(context.getString(org.odk.collect.strings.R.string.savepoint_recovery_dialog_message), Locale.getDefault()).format(savepointFile.lastModified()))).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText(org.odk.collect.strings.R.string.recover)).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText(org.odk.collect.strings.R.string.do_not_recover)).inRoot(isDialog()).check(matches(isDisplayed())) + } + private fun assertStartBlankFormIntent(projectId: String?, dbId: Long) { Intents.intended(hasComponent(FormFillingActivity::class.java.name)) if (projectId == null) { diff --git a/collect_app/src/test/java/org/odk/collect/android/external/FormsProviderTest.java b/collect_app/src/test/java/org/odk/collect/android/external/FormsProviderTest.java index 490311974b6..1a7c94521e0 100644 --- a/collect_app/src/test/java/org/odk/collect/android/external/FormsProviderTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/external/FormsProviderTest.java @@ -1,5 +1,20 @@ package org.odk.collect.android.external; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.isOneOf; +import static org.odk.collect.android.database.forms.DatabaseFormColumns.DISPLAY_NAME; +import static org.odk.collect.android.database.forms.DatabaseFormColumns.FORM_FILE_PATH; +import static org.odk.collect.android.database.forms.DatabaseFormColumns.FORM_MEDIA_PATH; +import static org.odk.collect.android.database.forms.DatabaseFormColumns.JRCACHE_FILE_PATH; +import static org.odk.collect.android.database.forms.DatabaseFormColumns.JR_FORM_ID; +import static org.odk.collect.android.database.forms.DatabaseFormColumns.JR_VERSION; +import static org.odk.collect.android.database.forms.DatabaseFormColumns.LANGUAGE; +import static org.odk.collect.android.external.FormsContract.CONTENT_ITEM_TYPE; +import static org.odk.collect.android.external.FormsContract.CONTENT_TYPE; +import static org.odk.collect.android.external.FormsContract.getUri; + import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; @@ -14,10 +29,14 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.odk.collect.android.injection.DaggerUtils; +import org.odk.collect.android.injection.config.AppDependencyComponent; import org.odk.collect.android.storage.StoragePathProvider; import org.odk.collect.android.storage.StorageSubdirectory; import org.odk.collect.android.support.CollectHelpers; import org.odk.collect.android.utilities.FileUtils; +import org.odk.collect.android.utilities.FormsRepositoryProvider; +import org.odk.collect.forms.Form; +import org.odk.collect.forms.FormsRepository; import org.odk.collect.formstest.FormUtils; import org.odk.collect.projects.Project; import org.odk.collect.shared.strings.Md5; @@ -25,129 +44,57 @@ import java.io.File; import java.io.IOException; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.isOneOf; -import static org.hamcrest.Matchers.notNullValue; -import static org.odk.collect.android.database.forms.DatabaseFormColumns.DATE; -import static org.odk.collect.android.database.forms.DatabaseFormColumns.DISPLAY_NAME; -import static org.odk.collect.android.database.forms.DatabaseFormColumns.FORM_FILE_PATH; -import static org.odk.collect.android.database.forms.DatabaseFormColumns.FORM_MEDIA_PATH; -import static org.odk.collect.android.database.forms.DatabaseFormColumns.JRCACHE_FILE_PATH; -import static org.odk.collect.android.database.forms.DatabaseFormColumns.JR_FORM_ID; -import static org.odk.collect.android.database.forms.DatabaseFormColumns.JR_VERSION; -import static org.odk.collect.android.database.forms.DatabaseFormColumns.LANGUAGE; -import static org.odk.collect.android.database.forms.DatabaseFormColumns.MD5_HASH; -import static org.odk.collect.android.external.FormsContract.CONTENT_ITEM_TYPE; -import static org.odk.collect.android.external.FormsContract.CONTENT_TYPE; -import static org.odk.collect.android.external.FormsContract.getUri; - @RunWith(AndroidJUnit4.class) public class FormsProviderTest { private ContentResolver contentResolver; private StoragePathProvider storagePathProvider; private String firstProjectId; + private AppDependencyComponent component; @Before public void setup() { Context context = ApplicationProvider.getApplicationContext(); - storagePathProvider = DaggerUtils.getComponent(context).storagePathProvider(); + component = DaggerUtils.getComponent(context); + storagePathProvider = component.storagePathProvider(); firstProjectId = CollectHelpers.createDemoProject(); contentResolver = context.getContentResolver(); } @Test - public void insert_addsForm() { + public void insert_doesNotInsertForms_andReturnsNull() { String formId = "external_app_form"; String formVersion = "1"; String formName = "External app form"; File formFile = addFormToFormsDir(firstProjectId, formId, formVersion, formName); - String md5Hash = Md5.getMd5Hash(formFile); ContentValues values = getContentValues(formId, formVersion, formName, formFile); - contentResolver.insert(getUri(firstProjectId), values); + Uri uri = contentResolver.insert(getUri(firstProjectId), values); + assertThat(uri, equalTo(null)); try (Cursor cursor = contentResolver.query(getUri(firstProjectId), null, null, null, null)) { - assertThat(cursor.getCount(), is(1)); - - cursor.moveToNext(); - assertThat(cursor.getString(cursor.getColumnIndex(DISPLAY_NAME)), is(formName)); - assertThat(cursor.getString(cursor.getColumnIndex(JR_FORM_ID)), is(formId)); - assertThat(cursor.getString(cursor.getColumnIndex(JR_VERSION)), is(formVersion)); - assertThat(cursor.getString(cursor.getColumnIndex(FORM_FILE_PATH)), is(formFile.getName())); - - assertThat(cursor.getString(cursor.getColumnIndex(DATE)), is(notNullValue())); - assertThat(cursor.getString(cursor.getColumnIndex(MD5_HASH)), is(md5Hash)); - assertThat(cursor.getString(cursor.getColumnIndex(JRCACHE_FILE_PATH)), is(md5Hash + ".formdef")); - assertThat(cursor.getString(cursor.getColumnIndex(FORM_MEDIA_PATH)), is(mediaPathForFormFile(formFile))); - } - } - - @Test - public void insert_returnsFormUri() { - String formId = "external_app_form"; - String formVersion = "1"; - String formName = "External app form"; - File formFile = addFormToFormsDir(firstProjectId, formId, formVersion, formName); - - ContentValues values = getContentValues(formId, formVersion, formName, formFile); - Uri newFormUri = contentResolver.insert(getUri(firstProjectId), values); - - try (Cursor cursor = contentResolver.query(newFormUri, null, null, null, null)) { - assertThat(cursor.getCount(), is(1)); + assertThat(cursor.getCount(), is(0)); } } @Test - public void update_updatesForm_andReturns1() { + public void update_doesNotUpdateForms_andReturns0() { Uri formUri = addFormsToDirAndDb(firstProjectId, "external_app_form", "External app form", "1"); ContentValues contentValues = new ContentValues(); contentValues.put(LANGUAGE, "English"); int updateCount = contentResolver.update(formUri, contentValues, null, null); - assertThat(updateCount, is(1)); + assertThat(updateCount, is(0)); try (Cursor cursor = contentResolver.query(formUri, null, null, null)) { assertThat(cursor.getCount(), is(1)); cursor.moveToNext(); - assertThat(cursor.getString(cursor.getColumnIndex(LANGUAGE)), is("English")); - } - } - - @Test - public void update_withSelection_onlyUpdatesMatchingForms() { - addFormsToDirAndDb(firstProjectId, "form1", "Matching form", "1"); - addFormsToDirAndDb(firstProjectId, "form2", "Not matching form", "1"); - addFormsToDirAndDb(firstProjectId, "form3", "Matching form", "1"); - - ContentValues contentValues = new ContentValues(); - contentValues.put(LANGUAGE, "English"); - - contentResolver.update(getUri(firstProjectId), contentValues, DISPLAY_NAME + "=?", new String[]{"Matching form"}); - try (Cursor cursor = contentResolver.query(getUri(firstProjectId), null, null, null)) { - assertThat(cursor.getCount(), is(3)); - - cursor.moveToNext(); - if (cursor.getString(cursor.getColumnIndex(DISPLAY_NAME)).equals("Matching form")) { - assertThat(cursor.getString(cursor.getColumnIndex(LANGUAGE)), is("English")); - } else { - assertThat(cursor.isNull(cursor.getColumnIndex(LANGUAGE)), is(true)); - } + assertThat(cursor.getString(cursor.getColumnIndex(LANGUAGE)), equalTo(null)); } } - @Test - public void update_whenFormDoesNotExist_returns0() { - ContentValues contentValues = new ContentValues(); - contentValues.put(LANGUAGE, "English"); - - int updatedCount = contentResolver.update(Uri.withAppendedPath(getUri(firstProjectId), String.valueOf(1)), contentValues, null, null); - assertThat(updatedCount, is(0)); - } - @Test public void delete_deletesForm() { Uri formUri = addFormsToDirAndDb(firstProjectId, "form1", "Matching form", "1"); @@ -289,8 +236,19 @@ public void getType_returnsFormAndAllFormsTypes() { private Uri addFormsToDirAndDb(String projectId, String id, String name, String version) { File formFile = addFormToFormsDir(projectId, id, version, name); - ContentValues values = getContentValues(id, version, name, formFile); - return contentResolver.insert(getUri(projectId), values); + + FormsRepositoryProvider formsRepositoryProvider = component.formsRepositoryProvider(); + FormsRepository formsRepository = formsRepositoryProvider.get(projectId); + Form form = formsRepository.save( + new Form.Builder() + .formId(id) + .displayName(name) + .version(version) + .formFilePath(formFile.getAbsolutePath()) + .build() + ); + + return FormsContract.getUri(projectId, form.getDbId()); } @NotNull @@ -342,10 +300,6 @@ private void createExtraFormFiles(String projectId, File formFile, String md5Has } } - private String mediaPathForFormFile(File newFile) { - return newFile.getName().substring(0, newFile.getName().lastIndexOf(".")) + "-media"; - } - @NotNull private String getFormsDirPath(String projectId) { return storagePathProvider.getOdkDirPath(StorageSubdirectory.FORMS, projectId) + File.separator; diff --git a/collect_app/src/test/java/org/odk/collect/android/external/InstanceProviderTest.java b/collect_app/src/test/java/org/odk/collect/android/external/InstanceProviderTest.java index 6b4a41c4fc4..f2c1db59297 100644 --- a/collect_app/src/test/java/org/odk/collect/android/external/InstanceProviderTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/external/InstanceProviderTest.java @@ -13,6 +13,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.odk.collect.android.database.forms.DatabaseFormColumns; +import org.odk.collect.android.database.instances.DatabaseInstanceColumns; import org.odk.collect.android.injection.DaggerUtils; import org.odk.collect.android.storage.StoragePathProvider; import org.odk.collect.android.storage.StorageSubdirectory; @@ -27,9 +28,9 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.fail; import static org.odk.collect.android.database.instances.DatabaseInstanceColumns.DELETED_DATE; import static org.odk.collect.android.database.instances.DatabaseInstanceColumns.DISPLAY_NAME; import static org.odk.collect.android.database.instances.DatabaseInstanceColumns.GEOMETRY; @@ -82,40 +83,30 @@ public void insert_addsInstance() { } @Test - public void insert_returnsInstanceUri() { + public void insert_withSubmissionUri_throwsException() { ContentValues values = getContentValues("/blah", "External app form", "external_app_form", "1"); - Uri uri = contentResolver.insert(getUri(firstProjectId), values); + values.put(DatabaseInstanceColumns.SUBMISSION_URI, "https://blah.com/submission"); - try (Cursor cursor = contentResolver.query(uri, null, null, null)) { - assertThat(cursor.getCount(), is(1)); + try { + contentResolver.insert(getUri(firstProjectId), values); + fail(); + } catch (SecurityException e) { + // Expected } } @Test - public void update_updatesInstance_andReturns1() { + public void insert_returnsInstanceUri() { ContentValues values = getContentValues("/blah", "External app form", "external_app_form", "1"); + Uri uri = contentResolver.insert(getUri(firstProjectId), values); - long originalStatusChangeDate = 0L; - values.put(LAST_STATUS_CHANGE_DATE, originalStatusChangeDate); - Uri instanceUri = contentResolver.insert(getUri(firstProjectId), values); - - ContentValues updateValues = new ContentValues(); - updateValues.put(STATUS, STATUS_COMPLETE); - - int updatedCount = contentResolver.update(instanceUri, updateValues, null, null); - assertThat(updatedCount, is(1)); - try (Cursor cursor = contentResolver.query(instanceUri, null, null, null)) { + try (Cursor cursor = contentResolver.query(uri, null, null, null)) { assertThat(cursor.getCount(), is(1)); - - cursor.moveToNext(); - assertThat(cursor.getString(cursor.getColumnIndex(STATUS)), is(STATUS_COMPLETE)); - assertThat(cursor.getLong(cursor.getColumnIndex(LAST_STATUS_CHANGE_DATE)), is(not(originalStatusChangeDate))); - assertThat(cursor.getLong(cursor.getColumnIndex(LAST_STATUS_CHANGE_DATE)), is(notNullValue())); } } @Test - public void update_whenDeletedDateIsIncluded_doesNotUpdateStatusChangeDate() { + public void update_doesNotUpdateInstance_andReturns0() { ContentValues values = getContentValues("/blah", "External app form", "external_app_form", "1"); long originalStatusChangeDate = 0L; @@ -123,58 +114,17 @@ public void update_whenDeletedDateIsIncluded_doesNotUpdateStatusChangeDate() { Uri instanceUri = contentResolver.insert(getUri(firstProjectId), values); ContentValues updateValues = new ContentValues(); - updateValues.put(DELETED_DATE, 123L); + updateValues.put(STATUS, STATUS_COMPLETE); - contentResolver.update(instanceUri, updateValues, null, null); + int updatedCount = contentResolver.update(instanceUri, updateValues, null, null); + assertThat(updatedCount, is(0)); try (Cursor cursor = contentResolver.query(instanceUri, null, null, null)) { assertThat(cursor.getCount(), is(1)); cursor.moveToNext(); + assertThat(cursor.getString(cursor.getColumnIndex(STATUS)), is(STATUS_INCOMPLETE)); assertThat(cursor.getLong(cursor.getColumnIndex(LAST_STATUS_CHANGE_DATE)), is(originalStatusChangeDate)); - } - } - - @Test - public void update_withSelection_onlyUpdatesMatchingInstance() { - addInstanceToDb(firstProjectId, "/blah1", "Instance 1"); - addInstanceToDb(firstProjectId, "/blah2", "Instance 2"); - - ContentValues updateValues = new ContentValues(); - updateValues.put(STATUS, STATUS_COMPLETE); - contentResolver.update(getUri(firstProjectId), updateValues, INSTANCE_FILE_PATH + "=?", new String[]{"/blah2"}); - - try (Cursor cursor = contentResolver.query(getUri(firstProjectId), null, null, null, null)) { - assertThat(cursor.getCount(), is(2)); - - while (cursor.moveToNext()) { - if (cursor.getString(cursor.getColumnIndex(INSTANCE_FILE_PATH)).equals("/blah2")) { - assertThat(cursor.getString(cursor.getColumnIndex(STATUS)), is(STATUS_COMPLETE)); - } else { - assertThat(cursor.getString(cursor.getColumnIndex(STATUS)), is(STATUS_INCOMPLETE)); - } - } - } - } - - /** - * It's not clear when this is used. A hypothetical might be updating an instance but wanting - * that to be a no-op if it has already been soft deleted. - */ - @Test - public void update_withInstanceUri_andSelection_doesNotUpdateInstanceThatDoesNotMatchSelection() { - Uri uri = addInstanceToDb(firstProjectId, "/blah1", "Instance 1"); - addInstanceToDb(firstProjectId, "/blah1", "Instance 2"); - - ContentValues updateValues = new ContentValues(); - updateValues.put(STATUS, STATUS_COMPLETE); - contentResolver.update(uri, updateValues, DISPLAY_NAME + "=?", new String[]{"Instance 2"}); - - try (Cursor cursor = contentResolver.query(getUri(firstProjectId), null, null, null, null)) { - assertThat(cursor.getCount(), is(2)); - - while (cursor.moveToNext()) { - assertThat(cursor.getString(cursor.getColumnIndex(STATUS)), is(STATUS_INCOMPLETE)); - } + assertThat(cursor.getLong(cursor.getColumnIndex(LAST_STATUS_CHANGE_DATE)), is(0L)); } } @@ -200,11 +150,11 @@ public void delete_deletesInstanceDir() { @Test public void delete_whenStatusIsSubmitted_deletesFilesButSoftDeletesInstance() { File instanceFile = createInstanceDirAndFile(firstProjectId); - Uri uri = addInstanceToDb(firstProjectId, instanceFile.getAbsolutePath(), "Instance 1"); - ContentValues updateValues = new ContentValues(); - updateValues.put(STATUS, STATUS_SUBMITTED); - contentResolver.update(uri, updateValues, null, null); + ContentValues values = getContentValues(instanceFile.getAbsolutePath(), "Instance 1", "external_app_form", "1"); + values.put(STATUS, STATUS_SUBMITTED); + + Uri uri = contentResolver.insert(getUri(firstProjectId), values); contentResolver.delete(uri, null, null); assertThat(instanceFile.getParentFile().exists(), is(false)); @@ -220,13 +170,13 @@ public void delete_whenStatusIsSubmitted_deletesFilesButSoftDeletesInstance() { @Test public void delete_whenStatusIsSubmitted_clearsGeometryFields() { File instanceFile = createInstanceDirAndFile(firstProjectId); - Uri uri = addInstanceToDb(firstProjectId, instanceFile.getAbsolutePath(), "Instance 1"); - ContentValues updateValues = new ContentValues(); - updateValues.put(STATUS, STATUS_SUBMITTED); - updateValues.put(GEOMETRY, "something"); - updateValues.put(GEOMETRY_TYPE, "something else"); - contentResolver.update(uri, updateValues, null, null); + ContentValues values = getContentValues(instanceFile.getAbsolutePath(), "Instance 1", "external_app_form", "1"); + values.put(STATUS, STATUS_SUBMITTED); + values.put(GEOMETRY, "something"); + values.put(GEOMETRY_TYPE, "something else"); + + Uri uri = contentResolver.insert(getUri(firstProjectId), values); contentResolver.delete(uri, null, null); assertThat(instanceFile.getParentFile().exists(), is(false)); diff --git a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEndViewTest.kt b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEndViewTest.kt index 818eda3df5e..d34e8690c43 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEndViewTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEndViewTest.kt @@ -5,7 +5,6 @@ import android.view.View import android.widget.TextView import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.android.material.button.MaterialButton import com.google.android.material.card.MaterialCardView import com.google.android.material.textview.MaterialTextView import org.hamcrest.MatcherAssert.assertThat @@ -16,6 +15,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.odk.collect.android.R +import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickSafeMaterialButton @RunWith(AndroidJUnit4::class) class FormEndViewTest { @@ -45,7 +45,7 @@ class FormEndViewTest { whenever(formEndViewModel.isSaveDraftEnabled()).thenReturn(true) val view = FormEndView(context, "blah", formEndViewModel, listener) assertThat( - view.findViewById(R.id.save_as_draft).visibility, + view.findViewById(R.id.save_as_draft).visibility, equalTo(View.VISIBLE) ) } @@ -55,7 +55,7 @@ class FormEndViewTest { whenever(formEndViewModel.isSaveDraftEnabled()).thenReturn(false) val view = FormEndView(context, "blah", formEndViewModel, listener) assertThat( - view.findViewById(R.id.save_as_draft).visibility, + view.findViewById(R.id.save_as_draft).visibility, equalTo(View.GONE) ) } @@ -64,7 +64,7 @@ class FormEndViewTest { fun `when 'Save as draft' button is clicked then onSaveClicked is called with false value`() { whenever(formEndViewModel.isSaveDraftEnabled()).thenReturn(true) val view = FormEndView(context, "blah", formEndViewModel, listener) - view.findViewById(R.id.save_as_draft).performClick() + view.findViewById(R.id.save_as_draft).performClick() verify(listener).onSaveClicked(false) } @@ -73,7 +73,7 @@ class FormEndViewTest { whenever(formEndViewModel.isFinalizeEnabled()).thenReturn(true) val view = FormEndView(context, "blah", formEndViewModel, listener) assertThat( - view.findViewById(R.id.finalize).visibility, + view.findViewById(R.id.finalize).visibility, equalTo(View.VISIBLE) ) } @@ -82,14 +82,14 @@ class FormEndViewTest { fun `when finalizing forms is disabled in settings should 'Finalize' button be hidden`() { whenever(formEndViewModel.isFinalizeEnabled()).thenReturn(false) val view = FormEndView(context, "blah", formEndViewModel, listener) - assertThat(view.findViewById(R.id.finalize).visibility, equalTo(View.GONE)) + assertThat(view.findViewById(R.id.finalize).visibility, equalTo(View.GONE)) } @Test fun `when 'Finalize' button is clicked then onSaveClicked is called with true value`() { whenever(formEndViewModel.isFinalizeEnabled()).thenReturn(true) val view = FormEndView(context, "blah", formEndViewModel, listener) - view.findViewById(R.id.finalize).performClick() + view.findViewById(R.id.finalize).performClick() verify(listener).onSaveClicked(true) } @@ -98,7 +98,7 @@ class FormEndViewTest { whenever(formEndViewModel.shouldFormBeSentAutomatically()).thenReturn(false) val view = FormEndView(context, "blah", formEndViewModel, listener) assertThat( - view.findViewById(R.id.finalize).text, + view.findViewById(R.id.finalize).text, equalTo(context.getString(org.odk.collect.strings.R.string.finalize)) ) } @@ -108,7 +108,7 @@ class FormEndViewTest { whenever(formEndViewModel.shouldFormBeSentAutomatically()).thenReturn(true) val view = FormEndView(context, "blah", formEndViewModel, listener) assertThat( - view.findViewById(R.id.finalize).text, + view.findViewById(R.id.finalize).text, equalTo( context.getString( org.odk.collect.strings.R.string.send diff --git a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt index b5e860aeec6..32df90f178b 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/formentry/FormEntryUseCasesTest.kt @@ -12,9 +12,9 @@ import org.junit.Test import org.kxml2.io.KXmlParser import org.kxml2.kdom.Document import org.mockito.kotlin.mock -import org.odk.collect.android.entities.InMemEntitiesRepository import org.odk.collect.android.javarosawrapper.FormController import org.odk.collect.android.utilities.FileUtils +import org.odk.collect.entities.InMemEntitiesRepository import org.odk.collect.forms.Form import org.odk.collect.forms.instances.Instance import org.odk.collect.formstest.FormFixtures diff --git a/collect_app/src/test/java/org/odk/collect/android/formentry/QuitFormDialogTest.kt b/collect_app/src/test/java/org/odk/collect/android/formentry/QuitFormDialogTest.kt index a30866ad277..753d199277a 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formentry/QuitFormDialogTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/formentry/QuitFormDialogTest.kt @@ -28,7 +28,7 @@ import java.util.Locale @Config(shadows = [ShadowAndroidXAlertDialog::class]) class QuitFormDialogTest { - private val formSaveViewModel = mock() { + private val formSaveViewModel = mock { on { lastSavedTime } doReturn null } diff --git a/collect_app/src/test/java/org/odk/collect/android/formentry/audit/FormSaveViewModelTest.java b/collect_app/src/test/java/org/odk/collect/android/formentry/audit/FormSaveViewModelTest.java index 6c193b1671a..a6d19e7d108 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formentry/audit/FormSaveViewModelTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/formentry/audit/FormSaveViewModelTest.java @@ -45,7 +45,9 @@ import org.odk.collect.forms.Form; import org.odk.collect.forms.instances.Instance; import org.odk.collect.forms.instances.InstancesRepository; +import org.odk.collect.forms.savepoints.SavepointsRepository; import org.odk.collect.formstest.InMemInstancesRepository; +import org.odk.collect.formstest.InMemSavepointsRepository; import org.odk.collect.projects.Project; import org.odk.collect.shared.TempFiles; import org.odk.collect.testshared.FakeScheduler; @@ -76,6 +78,7 @@ public class FormSaveViewModelTest { private final EntitiesRepository entitiesRepository = mock(EntitiesRepository.class); private final InstancesRepository instancesRepository = new InMemInstancesRepository(); + private final SavepointsRepository savepointsRepository = new InMemSavepointsRepository(); private MutableLiveData formSession; @Before @@ -98,7 +101,7 @@ public void setup() { when(projectsDataService.getCurrentProject()).thenReturn(Project.Companion.getDEMO_PROJECT()); formSession = new MutableLiveData<>(new FormSession(formController, form)); - viewModel = new FormSaveViewModel(savedStateHandle, () -> CURRENT_TIME, formSaver, mediaUtils, scheduler, audioRecorder, projectsDataService, formSession, entitiesRepository, instancesRepository); + viewModel = new FormSaveViewModel(savedStateHandle, () -> CURRENT_TIME, formSaver, mediaUtils, scheduler, audioRecorder, projectsDataService, formSession, entitiesRepository, instancesRepository, savepointsRepository, mock()); CollectHelpers.createDemoProject(); // Needed to deal with `new StoragePathProvider()` calls in `FormSaveViewModel` } @@ -383,7 +386,7 @@ public void deleteAnswerFile_whenAnswerFileHasAlreadyBeenDeleted_actuallyDeletes public void deleteAnswerFile_whenAnswerFileHasAlreadyBeenDeleted_onRecreatingViewModel_actuallyDeletesNewFile() { viewModel.deleteAnswerFile("index", "blah1"); - FormSaveViewModel restoredViewModel = new FormSaveViewModel(savedStateHandle, () -> CURRENT_TIME, formSaver, mediaUtils, scheduler, mock(AudioRecorder.class), projectsDataService, liveDataOf(new FormSession(formController, form)), entitiesRepository, instancesRepository); + FormSaveViewModel restoredViewModel = new FormSaveViewModel(savedStateHandle, () -> CURRENT_TIME, formSaver, mediaUtils, scheduler, mock(AudioRecorder.class), projectsDataService, liveDataOf(new FormSession(formController, form)), entitiesRepository, instancesRepository, savepointsRepository, mock()); restoredViewModel.deleteAnswerFile("index", "blah2"); verify(mediaUtils).deleteMediaFile("blah2"); @@ -405,7 +408,7 @@ public void replaceAnswerFile_whenAnswerFileHasAlreadyBeenReplaced_deletesPrevio public void replaceAnswerFile_whenAnswerFileHasAlreadyBeenReplaced_afterRecreatingViewModel_deletesPreviousReplacement() { viewModel.replaceAnswerFile("index", "blah1"); - FormSaveViewModel restoredViewModel = new FormSaveViewModel(savedStateHandle, () -> CURRENT_TIME, formSaver, mediaUtils, scheduler, mock(AudioRecorder.class), projectsDataService, liveDataOf(new FormSession(formController, form)), entitiesRepository, instancesRepository); + FormSaveViewModel restoredViewModel = new FormSaveViewModel(savedStateHandle, () -> CURRENT_TIME, formSaver, mediaUtils, scheduler, mock(AudioRecorder.class), projectsDataService, liveDataOf(new FormSession(formController, form)), entitiesRepository, instancesRepository, savepointsRepository, mock()); restoredViewModel.replaceAnswerFile("index", "blah2"); verify(mediaUtils).deleteMediaFile("blah1"); @@ -479,7 +482,7 @@ public void isSavingFileAnswerFile_isTrueWhenWhileIsSaving() throws Exception { @Test public void ignoreChanges_whenFormControllerNotSet_doesNothing() { - FormSaveViewModel viewModel = new FormSaveViewModel(savedStateHandle, () -> CURRENT_TIME, formSaver, mediaUtils, scheduler, mock(AudioRecorder.class), projectsDataService, liveDataOf(new FormSession(formController, form)), entitiesRepository, instancesRepository); + FormSaveViewModel viewModel = new FormSaveViewModel(savedStateHandle, () -> CURRENT_TIME, formSaver, mediaUtils, scheduler, mock(AudioRecorder.class), projectsDataService, liveDataOf(new FormSession(formController, form)), entitiesRepository, instancesRepository, savepointsRepository, mock()); viewModel.ignoreChanges(); // Checks nothing explodes } diff --git a/collect_app/src/test/java/org/odk/collect/android/formentry/support/InMemFormSessionRepository.kt b/collect_app/src/test/java/org/odk/collect/android/formentry/support/InMemFormSessionRepository.kt index cd84896791a..6d0076d7f67 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formentry/support/InMemFormSessionRepository.kt +++ b/collect_app/src/test/java/org/odk/collect/android/formentry/support/InMemFormSessionRepository.kt @@ -25,6 +25,13 @@ class InMemFormSessionRepository : FormSessionRepository { getLiveData(id).value = FormSession(formController, form, instance) } + override fun update(id: String, instance: Instance?) { + val liveData = getLiveData(id) + liveData.value?.let { + liveData.value = it.copy(instance = instance) + } + } + override fun clear(id: String) { getLiveData(id).value = null map.remove(id) diff --git a/collect_app/src/test/java/org/odk/collect/android/formhierarchy/QuestionAnswerProcessorTest.kt b/collect_app/src/test/java/org/odk/collect/android/formhierarchy/QuestionAnswerProcessorTest.kt index 73e28d06a3c..d615b0f6023 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formhierarchy/QuestionAnswerProcessorTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/formhierarchy/QuestionAnswerProcessorTest.kt @@ -2,11 +2,11 @@ package org.odk.collect.android.formhierarchy import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo +import org.javarosa.core.model.Constants import org.javarosa.core.model.QuestionDef -import org.javarosa.form.api.FormEntryPrompt import org.junit.Test import org.mockito.Mockito.mock -import org.mockito.kotlin.whenever +import org.odk.collect.android.support.MockFormEntryPromptBuilder import org.odk.collect.android.utilities.Appearances // TODO: Add tests for other question/data types @@ -14,13 +14,121 @@ class QuestionAnswerProcessorTest { @Test fun noAnswerShouldBeDisplayedForThePrinterWidget() { - val prompt = mock() val question = mock() - whenever(prompt.question).thenReturn(question) - whenever(question.appearanceAttr).thenReturn(Appearances.PRINTER) + val prompt = MockFormEntryPromptBuilder() + .withQuestion(question) + .withAnswerDisplayText("blah>") + .withAppearance(Appearances.PRINTER) + .build() val answer = QuestionAnswerProcessor.getQuestionAnswer(prompt, mock(), mock()) assertThat(answer, equalTo("")) } + + @Test + fun noAnswerShouldBeDisplayedIfItDoesNotExistAndMaskedAppearanceIsUsedForTextDataTypes() { + val question = mock() + val prompt = MockFormEntryPromptBuilder() + .withQuestion(question) + .withDataType(Constants.DATATYPE_TEXT) + .withControlType(Constants.CONTROL_INPUT) + .withAppearance(Appearances.MASKED) + .build() + + val answer = QuestionAnswerProcessor.getQuestionAnswer(prompt, mock(), mock()) + + assertThat(answer, equalTo("")) + } + + @Test + fun noAnswerShouldBeDisplayedIfItIsEmptyAndMaskedAppearanceIsUsedForTextDataTypes() { + val question = mock() + val prompt = MockFormEntryPromptBuilder() + .withQuestion(question) + .withAnswerDisplayText("") + .withAppearance(Appearances.MASKED) + .withControlType(Constants.CONTROL_INPUT) + .withDataType(Constants.DATATYPE_TEXT) + .build() + + val answer = QuestionAnswerProcessor.getQuestionAnswer(prompt, mock(), mock()) + + assertThat(answer, equalTo("")) + } + + @Test + fun maskedAnswerShouldBeDisplayedIfItExistAndMaskedAppearanceIsUsedForTextDataTypes() { + val question = mock() + val prompt = MockFormEntryPromptBuilder() + .withQuestion(question) + .withAnswerDisplayText("blah") + .withAppearance(Appearances.MASKED) + .withControlType(Constants.CONTROL_INPUT) + .withDataType(Constants.DATATYPE_TEXT) + .build() + + val answer = QuestionAnswerProcessor.getQuestionAnswer(prompt, mock(), mock()) + + assertThat(answer, equalTo("••••••••••")) + } + + @Test + fun originalAnswerShouldBeDisplayedIfItExistAndMaskedAppearanceIsUsedForDataTypesOtherThanText() { + listOf( + Constants.DATATYPE_INTEGER, + Constants.DATATYPE_DECIMAL, + Constants.DATATYPE_DATE_TIME, + Constants.DATATYPE_DATE, + Constants.DATATYPE_TIME, + Constants.DATATYPE_GEOPOINT, + Constants.DATATYPE_GEOSHAPE, + Constants.DATATYPE_GEOSHAPE, + Constants.DATATYPE_GEOTRACE, + Constants.DATATYPE_BARCODE, + Constants.DATATYPE_BARCODE + ).forEach { + val question = mock() + val prompt = MockFormEntryPromptBuilder() + .withQuestion(question) + .withAnswerDisplayText("blah") + .withAppearance(Appearances.MASKED) + .withControlType(Constants.CONTROL_INPUT) + .withDataType(it) + .build() + + val answer = QuestionAnswerProcessor.getQuestionAnswer(prompt, mock(), mock()) + + assertThat(answer, equalTo("blah")) + } + } + + @Test + fun originalAnswerShouldBeDisplayedIfItExistAndMaskedAppearanceIsUsedForDataTypesAndControlTypeOtherThanInput() { + listOf( + Constants.CONTROL_RANGE, + Constants.CONTROL_RANK, + Constants.CONTROL_TRIGGER, + Constants.CONTROL_SELECT_MULTI, + Constants.CONTROL_SELECT_ONE, + Constants.CONTROL_VIDEO_CAPTURE, + Constants.CONTROL_AUDIO_CAPTURE, + Constants.CONTROL_OSM_CAPTURE, + Constants.CONTROL_IMAGE_CHOOSE, + Constants.CONTROL_FILE_CAPTURE + ).forEach { + val question = mock() + val prompt = MockFormEntryPromptBuilder() + .withQuestion(question) + .withAnswerDisplayText("blah") + .withAppearance(Appearances.MASKED) + .withControlType(it) + .withDataType(Constants.DATATYPE_TEXT) + .build() + + val answer = QuestionAnswerProcessor.getQuestionAnswer(prompt, mock(), mock()) + + assertThat(answer, equalTo("blah")) + } + } } diff --git a/collect_app/src/test/java/org/odk/collect/android/formlists/DeleteBlankFormFragmentTest.kt b/collect_app/src/test/java/org/odk/collect/android/formlists/DeleteBlankFormFragmentTest.kt index 7c96dc40d79..744062ea9cc 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formlists/DeleteBlankFormFragmentTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/formlists/DeleteBlankFormFragmentTest.kt @@ -4,6 +4,7 @@ import android.app.Application import android.net.Uri import androidx.core.view.MenuHost import androidx.core.view.MenuProvider +import androidx.fragment.app.testing.FragmentScenario import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.MutableLiveData @@ -13,13 +14,10 @@ import androidx.lifecycle.viewmodel.CreationExtras import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.ViewMatchers.isChecked import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.isEnabled -import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import org.hamcrest.MatcherAssert.assertThat @@ -40,7 +38,6 @@ import org.odk.collect.android.formlists.blankformlist.BlankFormListMenuProvider import org.odk.collect.android.formlists.blankformlist.BlankFormListViewModel import org.odk.collect.android.formlists.blankformlist.DeleteBlankFormFragment import org.odk.collect.androidshared.ui.FragmentFactoryBuilder -import org.odk.collect.androidshared.ui.MultiSelectViewModel import org.odk.collect.fragmentstest.FragmentScenarioLauncherRule import org.odk.collect.testshared.RecyclerViewMatcher.Companion.withRecyclerView import org.odk.collect.testshared.ViewActions.clickOnItemWith @@ -52,8 +49,6 @@ class DeleteBlankFormFragmentTest { private val context = ApplicationProvider.getApplicationContext() private val menuHost = RecordingMenuHost() - private val multiSelectViewModel = MultiSelectViewModel() - private val formsToDisplay = MutableLiveData>(emptyList()) private val blankFormListViewModel = mock { on { formsToDisplay } doReturn formsToDisplay @@ -65,7 +60,6 @@ class DeleteBlankFormFragmentTest { override fun create(modelClass: Class, extras: CreationExtras): T { return when (modelClass) { BlankFormListViewModel::class.java -> blankFormListViewModel - MultiSelectViewModel::class.java -> multiSelectViewModel else -> throw IllegalArgumentException() } as T } @@ -79,111 +73,16 @@ class DeleteBlankFormFragmentTest { }.build() ) - @Test - fun `selected forms are checked`() { - fragmentScenarioLauncherRule.launchInContainer(DeleteBlankFormFragment::class.java) - formsToDisplay.value = listOf( - blankFormListItem(databaseId = 1, formName = "Form 1"), - blankFormListItem(databaseId = 2, formName = "Form 2") - ) - - multiSelectViewModel.select(2) - - onView(withRecyclerView(R.id.list).atPositionOnView(1, R.id.form_title)).check(matches(withText("Form 2"))) - onView(withRecyclerView(R.id.list).atPositionOnView(1, R.id.checkbox)).check(matches(isChecked())) - } - - @Test - fun `clicking forms selects them`() { - fragmentScenarioLauncherRule.launchInContainer(DeleteBlankFormFragment::class.java) - formsToDisplay.value = listOf( - blankFormListItem(databaseId = 1, formName = "Form 1"), - blankFormListItem(databaseId = 2, formName = "Form 2"), - blankFormListItem(databaseId = 3, formName = "Form 3") - ) - - onView(recyclerView()).perform(clickOnItemWith(withText("Form 1"))) - onView(recyclerView()).perform(clickOnItemWith(withText("Form 3"))) - - assertThat(multiSelectViewModel.getSelected().value, equalTo(setOf(1, 3))) - } - - @Test - fun `clicking selected forms unselects them`() { - fragmentScenarioLauncherRule.launchInContainer(DeleteBlankFormFragment::class.java) - formsToDisplay.value = listOf( - blankFormListItem(databaseId = 1, formName = "Form 1"), - blankFormListItem(databaseId = 2, formName = "Form 2") - ) - - onView(recyclerView()).perform(clickOnItemWith(withText("Form 1"))) - onView(recyclerView()).perform(clickOnItemWith(withText("Form 2"))) - - onView(recyclerView()).perform(clickOnItemWith(withText("Form 2"))) - - assertThat(multiSelectViewModel.getSelected().value, equalTo(setOf(1))) - } - - @Test - fun `clicking select all selects all forms`() { - fragmentScenarioLauncherRule.launchInContainer(DeleteBlankFormFragment::class.java) - formsToDisplay.value = listOf( - blankFormListItem(databaseId = 1, formName = "Form 1"), - blankFormListItem(databaseId = 2, formName = "Form 2") - ) - - onView(withText(org.odk.collect.strings.R.string.select_all)).perform(click()) - - assertThat(multiSelectViewModel.getSelected().value, equalTo(setOf(1, 2))) - } - - @Test - fun `can click select all after selecting some forms`() { - fragmentScenarioLauncherRule.launchInContainer(DeleteBlankFormFragment::class.java) - formsToDisplay.value = listOf( - blankFormListItem(databaseId = 1, formName = "Form 1"), - blankFormListItem(databaseId = 2, formName = "Form 2") - ) - - multiSelectViewModel.select(1) - onView(withText(org.odk.collect.strings.R.string.select_all)).perform(click()) - - multiSelectViewModel.unselect(1) - onView(withText(org.odk.collect.strings.R.string.select_all)).perform(click()) - - assertThat(multiSelectViewModel.getSelected().value, equalTo(setOf(1, 2))) - } - - @Test - fun `clicking clear all selects no forms`() { - fragmentScenarioLauncherRule.launchInContainer(DeleteBlankFormFragment::class.java) - formsToDisplay.value = listOf( - blankFormListItem(databaseId = 1, formName = "Form 1"), - blankFormListItem(databaseId = 2, formName = "Form 2") - ) - - onView(withText(org.odk.collect.strings.R.string.clear_all)).check(doesNotExist()) - onView(withText(org.odk.collect.strings.R.string.select_all)).perform(click()) - - onView(withText(org.odk.collect.strings.R.string.select_all)).check(doesNotExist()) - onView(withText(org.odk.collect.strings.R.string.clear_all)).perform(click()) - - assertThat(multiSelectViewModel.getSelected().value, equalTo(emptySet())) - - onView(withText(org.odk.collect.strings.R.string.select_all)).check(matches(isDisplayed())) - onView(withText(org.odk.collect.strings.R.string.clear_all)).check(doesNotExist()) - } - @Test fun `clicking delete selected and then accepting deletes selected forms`() { - fragmentScenarioLauncherRule.launchInContainer(DeleteBlankFormFragment::class.java) + launchFragment() formsToDisplay.value = listOf( blankFormListItem(databaseId = 11, formName = "Form 1"), blankFormListItem(databaseId = 12, formName = "Form 2") ) - multiSelectViewModel.select(11) - multiSelectViewModel.select(12) + onView(recyclerView()).perform(clickOnItemWith(withText("Form 1"))) + onView(recyclerView()).perform(clickOnItemWith(withText("Form 2"))) onView(withText(org.odk.collect.strings.R.string.delete_file)).perform(click()) onView(withText(context.getString(org.odk.collect.strings.R.string.delete_confirm, 2))) @@ -196,14 +95,14 @@ class DeleteBlankFormFragmentTest { @Test fun `clicking delete selected and then cancelling does nothing`() { - fragmentScenarioLauncherRule.launchInContainer(DeleteBlankFormFragment::class.java) + launchFragment() formsToDisplay.value = listOf( blankFormListItem(databaseId = 11, formName = "Form 1"), blankFormListItem(databaseId = 12, formName = "Form 2") ) - multiSelectViewModel.select(11) - multiSelectViewModel.select(12) + onView(recyclerView()).perform(clickOnItemWith(withText("Form 1"))) + onView(recyclerView()).perform(clickOnItemWith(withText("Form 2"))) onView(withText(org.odk.collect.strings.R.string.delete_file)).perform(click()) onView(withText(context.getString(org.odk.collect.strings.R.string.delete_confirm, 2))) @@ -216,13 +115,13 @@ class DeleteBlankFormFragmentTest { @Test fun `clicking delete selected unselects forms`() { - fragmentScenarioLauncherRule.launchInContainer(DeleteBlankFormFragment::class.java) + launchFragment() formsToDisplay.value = listOf( blankFormListItem(databaseId = 11, formName = "Form 1"), blankFormListItem(databaseId = 12, formName = "Form 2") ) - multiSelectViewModel.select(11) + onView(recyclerView()).perform(clickOnItemWith(withText("Form 1"))) onView(withText(org.odk.collect.strings.R.string.delete_file)).perform(click()) onView(withText(context.getString(org.odk.collect.strings.R.string.delete_confirm, 1))) @@ -230,45 +129,13 @@ class DeleteBlankFormFragmentTest { .check(matches(isDisplayed())) onView(withText(org.odk.collect.strings.R.string.delete_yes)).inRoot(isDialog()).perform(click()) - assertThat(multiSelectViewModel.getSelected().value, equalTo(emptySet())) - } - - @Test - fun `delete selected is disabled and enabled when forms are selected or not`() { - fragmentScenarioLauncherRule.launchInContainer(DeleteBlankFormFragment::class.java) - - onView(withText(org.odk.collect.strings.R.string.delete_file)).check(matches(not(isEnabled()))) - - multiSelectViewModel.select(11) - onView(withText(org.odk.collect.strings.R.string.delete_file)).check(matches(isEnabled())) - - multiSelectViewModel.unselectAll() - onView(withText(org.odk.collect.strings.R.string.delete_file)).check(matches(not(isEnabled()))) - } - - @Test - fun `empty message shows when there are no forms`() { - fragmentScenarioLauncherRule.launchInContainer(DeleteBlankFormFragment::class.java) - - onView(withText(org.odk.collect.strings.R.string.empty_list_of_forms_to_delete_title)).check(matches(isDisplayed())) - - formsToDisplay.value = listOf(blankFormListItem(databaseId = 1, formName = "Form 1")) - - onView(withText(org.odk.collect.strings.R.string.empty_list_of_forms_to_delete_title)).check(matches(not(isDisplayed()))) - } - - @Test - fun `bottom buttons are hidden when there are no forms`() { - fragmentScenarioLauncherRule.launchInContainer(DeleteBlankFormFragment::class.java) - onView(withId(R.id.buttons)).check(matches(not(isDisplayed()))) - - formsToDisplay.value = listOf(blankFormListItem(databaseId = 1, formName = "Form 1")) - onView(withId(R.id.buttons)).check(matches(isDisplayed())) + onView(withRecyclerView(R.id.list).atPositionOnView(0, R.id.form_title)).check(matches(withText("Form 1"))) + onView(withRecyclerView(R.id.list).atPositionOnView(0, R.id.checkbox)).check(matches(not(isChecked()))) } @Test fun `provides blank form menu`() { - fragmentScenarioLauncherRule.launchInContainer(DeleteBlankFormFragment::class.java) + launchFragment() val menuProviders = menuHost.getMenuProviders() assertThat(menuProviders.size, equalTo(1)) @@ -276,6 +143,10 @@ class DeleteBlankFormFragmentTest { assertThat(menuProviders[0].second, instanceOf(BlankFormListMenuProvider::class.java)) } + private fun launchFragment(): FragmentScenario<*> { + return fragmentScenarioLauncherRule.launchInContainer(DeleteBlankFormFragment::class.java) + } + private fun blankFormListItem(databaseId: Long = 1, formName: String = "Form 1") = BlankFormListItem( databaseId = databaseId, diff --git a/collect_app/src/test/java/org/odk/collect/android/formlists/blankformlist/BlankFormListItemViewHolderTest.kt b/collect_app/src/test/java/org/odk/collect/android/formlists/blankformlist/BlankFormListItemViewHolderTest.kt deleted file mode 100644 index 2178751e9f1..00000000000 --- a/collect_app/src/test/java/org/odk/collect/android/formlists/blankformlist/BlankFormListItemViewHolderTest.kt +++ /dev/null @@ -1,77 +0,0 @@ -package org.odk.collect.android.formlists.blankformlist - -import android.content.Context -import android.net.Uri -import android.view.View -import android.widget.FrameLayout -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.equalTo -import org.junit.Test -import org.junit.runner.RunWith -import org.odk.collect.android.R -import org.odk.collect.android.databinding.BlankFormListItemBinding - -@RunWith(AndroidJUnit4::class) -class BlankFormListItemViewHolderTest { - - @Test - fun `displays form version`() { - val context = ApplicationProvider.getApplicationContext() - val parent = FrameLayout(context) - val viewHolder = BlankFormListItemViewHolder(parent) - - viewHolder.blankFormListItem = blankFormListItem(formId = "myId", formVersion = "myVersion") - - val binding = BlankFormListItemBinding.bind(viewHolder.itemView) - assertThat( - binding.formVersion.text, - equalTo(context.getString(org.odk.collect.strings.R.string.version_number, "myVersion")) - ) - } - - @Test - fun `hides version when form version is blank`() { - val context = ApplicationProvider.getApplicationContext() - val parent = FrameLayout(context) - val viewHolder = BlankFormListItemViewHolder(parent) - - viewHolder.blankFormListItem = blankFormListItem(formId = "myId", formVersion = "") - - val binding = BlankFormListItemBinding.bind(viewHolder.itemView) - assertThat(binding.formVersion.visibility, equalTo(View.GONE)) - } - - @Test - fun `displays form id`() { - val context = ApplicationProvider.getApplicationContext() - val parent = FrameLayout(context) - val viewHolder = BlankFormListItemViewHolder(parent) - - viewHolder.blankFormListItem = blankFormListItem(formId = "myId") - - val binding = BlankFormListItemBinding.bind(viewHolder.itemView) - assertThat( - binding.formId.text, - equalTo(context.getString(org.odk.collect.strings.R.string.id_number, "myId")) - ) - } -} - -private fun blankFormListItem( - formId: String = "formId", - formVersion: String = "formVersion" -): BlankFormListItem { - return BlankFormListItem( - databaseId = 0, - formId = formId, - formName = "formName", - formVersion = formVersion, - geometryPath = "", - dateOfCreation = 0, - dateOfLastUsage = 0, - dateOfLastDetectedAttachmentsUpdate = null, - contentUri = Uri.parse("http://example.com") - ) -} diff --git a/collect_app/src/test/java/org/odk/collect/android/formlists/blankformlist/BlankFormListItemViewTest.kt b/collect_app/src/test/java/org/odk/collect/android/formlists/blankformlist/BlankFormListItemViewTest.kt new file mode 100644 index 00000000000..49b3ed9f94b --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/formlists/blankformlist/BlankFormListItemViewTest.kt @@ -0,0 +1,64 @@ +package org.odk.collect.android.formlists.blankformlist + +import android.content.Context +import android.net.Uri +import android.view.View +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BlankFormListItemViewTest { + + private val context = ApplicationProvider.getApplicationContext() + + @Test + fun `displays form version`() { + val view = BlankFormListItemView(context) + view.setItem(blankFormListItem(formId = "myId", formVersion = "myVersion")) + + assertThat( + view.binding.formVersion.text, + equalTo(context.getString(org.odk.collect.strings.R.string.version_number, "myVersion")) + ) + } + + @Test + fun `hides version when form version is blank`() { + val view = BlankFormListItemView(context) + view.setItem(blankFormListItem(formId = "myId", formVersion = "")) + + assertThat(view.binding.formVersion.visibility, equalTo(View.GONE)) + } + + @Test + fun `displays form id`() { + val view = BlankFormListItemView(context) + view.setItem(blankFormListItem(formId = "myId")) + + assertThat( + view.binding.formId.text, + equalTo(context.getString(org.odk.collect.strings.R.string.id_number, "myId")) + ) + } +} + +private fun blankFormListItem( + formId: String = "formId", + formVersion: String = "formVersion" +): BlankFormListItem { + return BlankFormListItem( + databaseId = 0, + formId = formId, + formName = "formName", + formVersion = formVersion, + geometryPath = "", + dateOfCreation = 0, + dateOfLastUsage = 0, + dateOfLastDetectedAttachmentsUpdate = null, + contentUri = Uri.parse("http://example.com") + ) +} diff --git a/collect_app/src/test/java/org/odk/collect/android/formlists/blankformlist/BlankFormListMenuProviderTest.kt b/collect_app/src/test/java/org/odk/collect/android/formlists/blankformlist/BlankFormListMenuProviderTest.kt index 9f043d9c6ec..b324cf20346 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formlists/blankformlist/BlankFormListMenuProviderTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/formlists/blankformlist/BlankFormListMenuProviderTest.kt @@ -15,6 +15,7 @@ import org.hamcrest.MatcherAssert.assertThat import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -22,7 +23,7 @@ import org.mockito.kotlin.whenever import org.odk.collect.android.R import org.odk.collect.android.formlists.sorting.FormListSortingBottomSheetDialog import org.odk.collect.android.support.CollectHelpers -import org.odk.collect.androidshared.network.NetworkStateProvider +import org.odk.collect.async.network.NetworkStateProvider import org.robolectric.Shadows import org.robolectric.fakes.RoboMenuItem import org.robolectric.shadows.ShadowDialog @@ -32,7 +33,10 @@ import org.robolectric.shadows.ShadowToast class BlankFormListMenuProviderTest { private lateinit var activity: FragmentActivity - private val viewModel: BlankFormListViewModel = mock() + private val viewModel: BlankFormListViewModel = mock { + on { sortingOrder } doReturn BlankFormListViewModel.SortOrder.NAME_ASC + } + private val networkStateProvider: NetworkStateProvider = mock() private val menuInflater: SupportMenuInflater @@ -220,7 +224,7 @@ class BlankFormListMenuProviderTest { private fun createdMenu(): Menu { val menu: SupportMenu = MenuBuilder(activity) - menuInflater.inflate(R.menu.form_list_menu, menu) + menuInflater.inflate(R.menu.blank_form_list_menu, menu) return menu } } diff --git a/collect_app/src/test/java/org/odk/collect/android/formlists/blankformlist/BlankFormListViewModelTest.kt b/collect_app/src/test/java/org/odk/collect/android/formlists/blankformlist/BlankFormListViewModelTest.kt index cf88bdd5125..b58ce8638cc 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formlists/blankformlist/BlankFormListViewModelTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/formlists/blankformlist/BlankFormListViewModelTest.kt @@ -17,13 +17,13 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.odk.collect.android.formmanagement.FormsDataService -import org.odk.collect.android.preferences.utilities.FormUpdateMode import org.odk.collect.android.utilities.ChangeLockProvider import org.odk.collect.forms.Form import org.odk.collect.forms.FormSourceException import org.odk.collect.forms.instances.Instance import org.odk.collect.formstest.FormUtils import org.odk.collect.formstest.InMemInstancesRepository +import org.odk.collect.settings.enums.FormUpdateMode import org.odk.collect.settings.keys.ProjectKeys import org.odk.collect.shared.settings.InMemSettings import org.odk.collect.testshared.BooleanChangeLock @@ -78,6 +78,11 @@ class BlankFormListViewModelTest { @Test fun `isMatchExactlyEnabled returns correct value based on settings`() { + generalSettings.save( + ProjectKeys.KEY_FORM_UPDATE_MODE, + FormUpdateMode.MANUAL.getValue(context) + ) + createViewModel() assertThat(viewModel.isMatchExactlyEnabled(), `is`(false)) @@ -184,7 +189,7 @@ class BlankFormListViewModelTest { createViewModel() - viewModel.sortingOrder = 0 + viewModel.sortingOrder = BlankFormListViewModel.SortOrder.NAME_ASC assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 1, formId = "1", formName = "1Form")) assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 5, formId = "5", formName = "2Form")) @@ -205,7 +210,7 @@ class BlankFormListViewModelTest { createViewModel() - viewModel.sortingOrder = 1 + viewModel.sortingOrder = BlankFormListViewModel.SortOrder.NAME_DESC assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 2, formId = "2", formName = "BForm")) assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 3, formId = "3", formName = "aForm")) @@ -226,7 +231,7 @@ class BlankFormListViewModelTest { createViewModel() - viewModel.sortingOrder = 2 + viewModel.sortingOrder = BlankFormListViewModel.SortOrder.DATE_DESC assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 4, formId = "4", formName = "AForm", lastDetectedAttachmentsUpdateDate = 7)) assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 2, formId = "2", formName = "BForm", lastDetectedAttachmentsUpdateDate = 6)) @@ -247,7 +252,7 @@ class BlankFormListViewModelTest { createViewModel() - viewModel.sortingOrder = 3 + viewModel.sortingOrder = BlankFormListViewModel.SortOrder.DATE_ASC assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 1, formId = "1", formName = "1Form")) assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 3, formId = "3", formName = "aForm")) @@ -276,7 +281,7 @@ class BlankFormListViewModelTest { createViewModel() - viewModel.sortingOrder = 4 + viewModel.sortingOrder = BlankFormListViewModel.SortOrder.LAST_SAVED assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 2, formId = "2", formName = "BForm"), 5L) assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 4, formId = "4", formName = "AForm"), 4L) @@ -297,7 +302,7 @@ class BlankFormListViewModelTest { createViewModel() - viewModel.sortingOrder = 4 + viewModel.sortingOrder = BlankFormListViewModel.SortOrder.LAST_SAVED assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 1, formId = "1", formName = "1Form")) assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 2, formId = "2", formName = "BForm")) @@ -323,7 +328,7 @@ class BlankFormListViewModelTest { createViewModel() - viewModel.sortingOrder = 4 + viewModel.sortingOrder = BlankFormListViewModel.SortOrder.LAST_SAVED assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 3, formId = "3", formName = "aForm"), 2L) assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 1, formId = "1", formName = "1Form"), 1L) @@ -348,7 +353,7 @@ class BlankFormListViewModelTest { createViewModel(showAllVersions = true) - viewModel.sortingOrder = 4 + viewModel.sortingOrder = BlankFormListViewModel.SortOrder.LAST_SAVED assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[0], form(dbId = 2, formId = "1", formName = "AForm v2", version = "2"), 3L) assertFormItem(viewModel.formsToDisplay.getOrAwaitValue(scheduler)[1], form(dbId = 3, formId = "2", formName = "BForm"), 2L) @@ -411,7 +416,7 @@ class BlankFormListViewModelTest { form(dbId = 3, formId = "3", formName = "Form 2x") ) - viewModel.sortingOrder = 1 + viewModel.sortingOrder = BlankFormListViewModel.SortOrder.NAME_DESC assertThat(viewModel.formsToDisplay.getOrAwaitValue(scheduler).size, `is`(2)) assertFormItem( diff --git a/collect_app/src/test/java/org/odk/collect/android/formlists/savedformlist/DeleteSavedFormFragmentTest.kt b/collect_app/src/test/java/org/odk/collect/android/formlists/savedformlist/DeleteSavedFormFragmentTest.kt new file mode 100644 index 00000000000..39f973a7d08 --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/formlists/savedformlist/DeleteSavedFormFragmentTest.kt @@ -0,0 +1,116 @@ +package org.odk.collect.android.formlists.savedformlist + +import android.content.Context +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isChecked +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.Matchers.not +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.odk.collect.android.R +import org.odk.collect.androidshared.ui.FragmentFactoryBuilder +import org.odk.collect.forms.instances.Instance +import org.odk.collect.formstest.InstanceFixtures +import org.odk.collect.fragmentstest.FragmentScenarioLauncherRule +import org.odk.collect.strings.R.string +import org.odk.collect.testshared.RecyclerViewMatcher.Companion.withRecyclerView +import org.odk.collect.testshared.ViewActions.clickOnItemWith +import org.odk.collect.testshared.ViewMatchers.recyclerView + +@RunWith(AndroidJUnit4::class) +class DeleteSavedFormFragmentTest { + + private val context = ApplicationProvider.getApplicationContext() + + private val formsToDisplay = MutableLiveData>(emptyList()) + private val isDeleting = MutableLiveData(false) + private val savedFormListViewModel = mock { + on { formsToDisplay } doReturn formsToDisplay + on { isDeleting } doReturn isDeleting + on { deleteForms(any()) } doReturn MutableLiveData(null) + } + + private val viewModelFactory = viewModelFactory { + addInitializer(SavedFormListViewModel::class) { savedFormListViewModel } + } + + @get:Rule + val fragmentScenarioLauncherRule = FragmentScenarioLauncherRule( + FragmentFactoryBuilder() + .forClass(DeleteSavedFormFragment::class) { + DeleteSavedFormFragment(viewModelFactory) + }.build() + ) + + @Test + fun `clicking delete selected and then cancelling does nothing`() { + fragmentScenarioLauncherRule.launchInContainer(DeleteSavedFormFragment::class.java) + formsToDisplay.value = listOf( + InstanceFixtures.instance(dbId = 1, displayName = "Form 1"), + InstanceFixtures.instance(dbId = 2, displayName = "Form 2") + ) + + onView(recyclerView()).perform(clickOnItemWith(withText("Form 1"))) + onView(recyclerView()).perform(clickOnItemWith(withText("Form 2"))) + + onView(withText(string.delete_file)).perform(click()) + onView(withText(context.getString(string.delete_confirm, 2))) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + onView(withText(string.delete_no)) + .inRoot(isDialog()) + .perform(click()) + + verify(savedFormListViewModel, never()).deleteForms(any()) + } + + @Test + fun `clicking delete selected unselects forms`() { + fragmentScenarioLauncherRule.launchInContainer(DeleteSavedFormFragment::class.java) + formsToDisplay.value = listOf( + InstanceFixtures.instance(dbId = 1, displayName = "Form 1"), + InstanceFixtures.instance(dbId = 2, displayName = "Form 2") + ) + + onView(recyclerView()).perform(clickOnItemWith(withText("Form 1"))) + + onView(withText(string.delete_file)).perform(click()) + onView(withText(context.getString(string.delete_confirm, 1))) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + onView(withText(string.delete_yes)) + .inRoot(isDialog()) + .perform(click()) + + onView(withRecyclerView(R.id.list).atPositionOnView(0, R.id.form_title)) + .check(matches(withText("Form 1"))) + onView(withRecyclerView(R.id.list).atPositionOnView(0, R.id.checkbox)) + .check(matches(not(isChecked()))) + } + + @Test + fun `shows progress while deleting forms`() { + fragmentScenarioLauncherRule.launchInContainer(DeleteSavedFormFragment::class.java) + formsToDisplay.value = listOf(InstanceFixtures.instance(dbId = 1)) + + isDeleting.value = true + onView(withText(string.form_delete_message)).inRoot(isDialog()).check(matches(isDisplayed())) + + isDeleting.value = false + onView(withText(string.delete_file)).check(matches(isDisplayed())) + } +} diff --git a/collect_app/src/test/java/org/odk/collect/android/formlists/savedformlist/SavedFormListListMenuProviderTest.kt b/collect_app/src/test/java/org/odk/collect/android/formlists/savedformlist/SavedFormListListMenuProviderTest.kt new file mode 100644 index 00000000000..1bb1e1e252e --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/formlists/savedformlist/SavedFormListListMenuProviderTest.kt @@ -0,0 +1,103 @@ +package org.odk.collect.android.formlists.savedformlist + +import androidx.appcompat.view.SupportMenuInflater +import androidx.appcompat.view.menu.MenuBuilder +import androidx.appcompat.widget.SearchView +import androidx.fragment.app.FragmentActivity +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.CoreMatchers.instanceOf +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.contains +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.odk.collect.android.R +import org.odk.collect.android.formlists.sorting.FormListSortingBottomSheetDialog +import org.odk.collect.android.formlists.sorting.FormListSortingOption +import org.odk.collect.android.support.CollectHelpers +import org.robolectric.fakes.RoboMenuItem +import org.robolectric.shadows.ShadowDialog + +@RunWith(AndroidJUnit4::class) +class SavedFormListListMenuProviderTest { + + private val activity: FragmentActivity = + CollectHelpers.createThemedActivity(FragmentActivity::class.java) + + private val viewModel: SavedFormListViewModel = mock() + private val menuInflater: SupportMenuInflater + get() = SupportMenuInflater(activity) + + @Test + fun `changing search text should set filterText in viewModel`() { + val menu = MenuBuilder(activity) + val menuProvider = SavedFormListListMenuProvider(activity, viewModel) + + menuProvider.onCreateMenu(menu, menuInflater) + menuProvider.onPrepareMenu(menu) + + val searchView = + (menu.findItem(R.id.menu_filter).actionView as SearchView).findViewById( + androidx.appcompat.R.id.search_src_text + ) + searchView.setText("abc") + verify(viewModel).filterText = "abc" + } + + @Test + fun `clicking search hides sort and hiding search shows it again`() { + val menu = MenuBuilder(activity) + val menuProvider = SavedFormListListMenuProvider(activity, viewModel) + + menuProvider.onCreateMenu(menu, menuInflater) + menuProvider.onPrepareMenu(menu) + + menu.findItem(R.id.menu_filter).expandActionView() + assertThat(menu.findItem(R.id.menu_sort).isVisible, equalTo(false)) + + menu.findItem(R.id.menu_filter).collapseActionView() + assertThat(menu.findItem(R.id.menu_sort).isVisible, equalTo(true)) + } + + @Test + fun `clicking sort displays sorting dialog`() { + whenever(viewModel.sortOrder).doReturn(SavedFormListViewModel.SortOrder.DATE_DESC) + + val menuProvider = SavedFormListListMenuProvider(activity, viewModel) + menuProvider.onMenuItemSelected(RoboMenuItem(R.id.menu_sort)) + + val dialog = ShadowDialog.getLatestDialog() + assertThat(dialog, instanceOf(FormListSortingBottomSheetDialog::class.java)) + + val formListSortingBottomSheetDialog = dialog as FormListSortingBottomSheetDialog + assertThat(dialog.selectedOption, equalTo(2)) + assertThat( + formListSortingBottomSheetDialog.options, + contains( + FormListSortingOption( + R.drawable.ic_sort_by_alpha, + org.odk.collect.strings.R.string.sort_by_name_asc + ), + FormListSortingOption( + R.drawable.ic_sort_by_alpha, + org.odk.collect.strings.R.string.sort_by_name_desc + ), + FormListSortingOption( + R.drawable.ic_access_time, + org.odk.collect.strings.R.string.sort_by_date_desc + ), + FormListSortingOption( + R.drawable.ic_access_time, + org.odk.collect.strings.R.string.sort_by_date_asc + ) + ) + ) + + formListSortingBottomSheetDialog.onSelectedOptionChanged.accept(3) + verify(viewModel).sortOrder = SavedFormListViewModel.SortOrder.DATE_ASC + } +} diff --git a/collect_app/src/test/java/org/odk/collect/android/formlists/savedformlist/SavedFormListViewModelTest.kt b/collect_app/src/test/java/org/odk/collect/android/formlists/savedformlist/SavedFormListViewModelTest.kt new file mode 100644 index 00000000000..7f0caafca86 --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/formlists/savedformlist/SavedFormListViewModelTest.kt @@ -0,0 +1,230 @@ +package org.odk.collect.android.formlists.savedformlist + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.flow.MutableStateFlow +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.contains +import org.hamcrest.Matchers.equalTo +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.odk.collect.android.formlists.savedformlist.SavedFormListViewModel.SortOrder +import org.odk.collect.android.instancemanagement.InstancesDataService +import org.odk.collect.forms.instances.Instance +import org.odk.collect.formstest.InstanceFixtures +import org.odk.collect.shared.settings.InMemSettings +import org.odk.collect.testshared.FakeScheduler +import org.odk.collect.testshared.getOrAwaitValue + +@RunWith(AndroidJUnit4::class) +class SavedFormListViewModelTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val scheduler = FakeScheduler() + private val settings = InMemSettings() + + private val instancesDataService: InstancesDataService = mock { + on { getInstances(any()) } doReturn MutableStateFlow(emptyList()) + } + + @Test + fun `formsToDisplay should not include deleted forms`() { + val myForm = InstanceFixtures.instance(displayName = "My form", deletedDate = 1) + val yourForm = InstanceFixtures.instance(displayName = "Your form") + saveForms("projectId", listOf(myForm, yourForm)) + + val viewModel = SavedFormListViewModel(scheduler, settings, instancesDataService, "projectId") + + assertThat( + viewModel.formsToDisplay.getOrAwaitValue(scheduler), + contains(yourForm) + ) + } + + @Test + fun `setting filterText filters forms on display name`() { + val myForm = InstanceFixtures.instance(displayName = "My form") + val yourForm = InstanceFixtures.instance(displayName = "Your form") + saveForms("projectId", listOf(myForm, yourForm),) + + val viewModel = + SavedFormListViewModel(scheduler, settings, instancesDataService, "projectId") + + viewModel.filterText = "Your" + assertThat( + viewModel.formsToDisplay.getOrAwaitValue(scheduler), + contains(yourForm) + ) + + viewModel.filterText = "form" + assertThat( + viewModel.formsToDisplay.getOrAwaitValue(scheduler), + contains(myForm, yourForm) + ) + } + + @Test + fun `clearing filterText does not filter forms`() { + val myForm = InstanceFixtures.instance(displayName = "My form") + val yourForm = InstanceFixtures.instance(displayName = "Your form") + saveForms("projectId", listOf(myForm, yourForm),) + + val viewModel = + SavedFormListViewModel(scheduler, settings, instancesDataService, "projectId") + + viewModel.filterText = "blah" + assertThat( + viewModel.formsToDisplay.getOrAwaitValue(scheduler), + equalTo(emptyList()) + ) + + viewModel.filterText = "" + assertThat( + viewModel.formsToDisplay.getOrAwaitValue(scheduler), + contains(myForm, yourForm) + ) + } + + @Test + fun `filtering forms is not case sensitive`() { + val myForm = InstanceFixtures.instance(displayName = "My form") + val yourForm = InstanceFixtures.instance(displayName = "Your form") + saveForms("projectId", listOf(myForm, yourForm),) + + val viewModel = + SavedFormListViewModel(scheduler, settings, instancesDataService, "projectId") + + viewModel.filterText = "my" + assertThat( + viewModel.formsToDisplay.getOrAwaitValue(scheduler), + contains(myForm) + ) + } + + @Test + fun `can sort forms by ascending name`() { + val a = InstanceFixtures.instance(displayName = "A") + val b = InstanceFixtures.instance(displayName = "B") + saveForms("projectId", listOf(b, a),) + + val viewModel = + SavedFormListViewModel(scheduler, settings, instancesDataService, "projectId") + + viewModel.sortOrder = SortOrder.NAME_ASC + assertThat( + viewModel.formsToDisplay.getOrAwaitValue(scheduler), + contains(a, b) + ) + } + + @Test + fun `can sort forms by descending name`() { + val a = InstanceFixtures.instance(displayName = "A") + val b = InstanceFixtures.instance(displayName = "B") + saveForms("projectId", listOf(a, b),) + + val viewModel = + SavedFormListViewModel(scheduler, settings, instancesDataService, "projectId") + + viewModel.sortOrder = SortOrder.NAME_DESC + assertThat( + viewModel.formsToDisplay.getOrAwaitValue(scheduler), + contains(b, a) + ) + } + + @Test + fun `can sort forms by descending date`() { + val a = InstanceFixtures.instance(displayName = "A", lastStatusChangeDate = 0) + val b = InstanceFixtures.instance(displayName = "B", lastStatusChangeDate = 1) + saveForms("projectId", listOf(a, b),) + + val viewModel = + SavedFormListViewModel(scheduler, settings, instancesDataService, "projectId") + + viewModel.sortOrder = SortOrder.DATE_DESC + assertThat( + viewModel.formsToDisplay.getOrAwaitValue(scheduler), + contains(b, a) + ) + } + + @Test + fun `can sort forms by ascending date`() { + val a = InstanceFixtures.instance(displayName = "A", lastStatusChangeDate = 0) + val b = InstanceFixtures.instance(displayName = "B", lastStatusChangeDate = 1) + saveForms("projectId", listOf(b, a),) + + val viewModel = + SavedFormListViewModel(scheduler, settings, instancesDataService, "projectId") + + viewModel.sortOrder = SortOrder.DATE_ASC + assertThat( + viewModel.formsToDisplay.getOrAwaitValue(scheduler), + contains(a, b) + ) + } + + @Test + fun `sort order is retained between view models`() { + val a = InstanceFixtures.instance(displayName = "A", lastStatusChangeDate = 0) + val b = InstanceFixtures.instance(displayName = "B", lastStatusChangeDate = 1) + saveForms("projectId", listOf(b, a),) + + val viewModel = + SavedFormListViewModel(scheduler, settings, instancesDataService, "projectId") + viewModel.sortOrder = SortOrder.DATE_ASC + + val newViewModel = + SavedFormListViewModel(scheduler, settings, instancesDataService, "projectId") + assertThat(newViewModel.sortOrder, equalTo(SortOrder.DATE_ASC)) + assertThat( + newViewModel.formsToDisplay.getOrAwaitValue(scheduler), + contains(a, b) + ) + } + + @Test + fun `isDeleting is true while deleting forms`() { + val viewModel = + SavedFormListViewModel(scheduler, settings, instancesDataService, "projectId") + assertThat(viewModel.isDeleting.getOrAwaitValue(), equalTo(false)) + + viewModel.deleteForms(longArrayOf(1)) + assertThat(viewModel.isDeleting.getOrAwaitValue(), equalTo(true)) + + scheduler.flush() + assertThat(viewModel.isDeleting.getOrAwaitValue(), equalTo(false)) + } + + @Test + fun `deleteForms should return 0 if instances can not be deleted`() { + val viewModel = + SavedFormListViewModel(scheduler, settings, instancesDataService, "projectId") + whenever(instancesDataService.deleteInstances(any(), any())).thenReturn(false) + + val result = viewModel.deleteForms(longArrayOf(1)) + assertThat(result.getOrAwaitValue(scheduler)!!.value, equalTo(0)) + } + + @Test + fun `deleteForms should return the number of instances after deleting`() { + val viewModel = + SavedFormListViewModel(scheduler, settings, instancesDataService, "projectId") + whenever(instancesDataService.deleteInstances(any(), any())).thenReturn(true) + + val result = viewModel.deleteForms(longArrayOf(1)) + assertThat(result.getOrAwaitValue(scheduler)!!.value, equalTo(1)) + } + + private fun saveForms(projectId: String, instances: List) { + whenever(instancesDataService.getInstances(projectId)).doReturn(MutableStateFlow(instances)) + } +} diff --git a/collect_app/src/test/java/org/odk/collect/android/formmanagement/FormUpdateDownloaderTest.kt b/collect_app/src/test/java/org/odk/collect/android/formmanagement/FormUpdateDownloaderTest.kt deleted file mode 100644 index 61deb926234..00000000000 --- a/collect_app/src/test/java/org/odk/collect/android/formmanagement/FormUpdateDownloaderTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -package org.odk.collect.android.formmanagement - -import androidx.test.espresso.matcher.ViewMatchers.assertThat -import org.hamcrest.Matchers.equalTo -import org.hamcrest.Matchers.`is` -import org.junit.Test -import org.mockito.Mockito.any -import org.mockito.Mockito.doAnswer -import org.mockito.Mockito.never -import org.mockito.invocation.InvocationOnMock -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify -import org.mockito.stubbing.Answer -import org.odk.collect.forms.ManifestFile -import org.odk.collect.testshared.BooleanChangeLock - -class FormUpdateDownloaderTest { - - private val changeLock = BooleanChangeLock() - private val formDownloader = mock() - - @Test - fun `does not download when change lock locked`() { - changeLock.lock() - - val serverForm = - ServerFormDetails("", "", "", "", "", false, true, ManifestFile("", emptyList())) - - FormUpdateDownloader().downloadUpdates( - listOf(serverForm), - changeLock, - formDownloader - ) - - verify(formDownloader, never()).downloadForm(any(), any(), any()) - } - - @Test - fun `returns completed downloads when cancelled`() { - val serverForms = listOf( - ServerFormDetails("", "", "", "", "", false, true, ManifestFile("", emptyList())), - ServerFormDetails("", "", "", "", "", false, true, ManifestFile("", emptyList())) - ) - - // Cancel form download after downloading one form - doAnswer(object : Answer { - private var calledBefore = false - - @Throws(Throwable::class) - override fun answer(invocation: InvocationOnMock) { - calledBefore = if (!calledBefore) { - true - } else { - throw FormDownloadException.DownloadingInterrupted() - } - } - }).`when`(formDownloader).downloadForm(any(), any(), any()) - - val results = FormUpdateDownloader().downloadUpdates( - serverForms, - changeLock, - formDownloader - ) - - assertThat(results.size, `is`(1)) - assertThat(results[serverForms[0]], equalTo(null)) - } -} 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 eff9c85f765..5439bf1f908 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 @@ -76,7 +76,9 @@ class FormsDataServiceTest { mock(), storagePathProvider, changeLockProvider, - formSourceProvider + formSourceProvider, + mock(), + mock() ) val projectDependencyProviderFactory = mock() diff --git a/collect_app/src/test/java/org/odk/collect/android/formmanagement/ServerFormDownloaderUseCasesTest.kt b/collect_app/src/test/java/org/odk/collect/android/formmanagement/ServerFormUseCasesTest.kt similarity index 61% rename from collect_app/src/test/java/org/odk/collect/android/formmanagement/ServerFormDownloaderUseCasesTest.kt rename to collect_app/src/test/java/org/odk/collect/android/formmanagement/ServerFormUseCasesTest.kt index 1aed4c89fdb..90ae36c5fcc 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formmanagement/ServerFormDownloaderUseCasesTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/formmanagement/ServerFormUseCasesTest.kt @@ -1,12 +1,20 @@ package org.odk.collect.android.formmanagement -import org.apache.commons.io.FileUtils import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.MatcherAssert.assertThat import org.junit.Test +import org.mockito.Mockito.any +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.never +import org.mockito.invocation.InvocationOnMock import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock -import org.odk.collect.android.utilities.FileUtils.LAST_SAVED_FILENAME +import org.mockito.kotlin.verify +import org.mockito.stubbing.Answer +import org.odk.collect.android.formmanagement.download.FormDownloadException +import org.odk.collect.android.formmanagement.download.FormDownloader +import org.odk.collect.android.utilities.FileUtils +import org.odk.collect.entities.InMemEntitiesRepository import org.odk.collect.forms.Form import org.odk.collect.forms.FormSource import org.odk.collect.forms.ManifestFile @@ -16,15 +24,74 @@ import org.odk.collect.formstest.FormUtils import org.odk.collect.formstest.InMemFormsRepository import org.odk.collect.shared.TempFiles import org.odk.collect.shared.strings.Md5 +import org.odk.collect.testshared.BooleanChangeLock import java.io.File -class ServerFormDownloaderUseCasesTest { +class ServerFormUseCasesTest { + + @Test + fun `downloadUpdates does not download when change lock locked`() { + val changeLock = BooleanChangeLock() + val formDownloader = mock() + + changeLock.lock() + + val serverForm = + ServerFormDetails("", "", "", "", "", false, true, ManifestFile("", emptyList())) + + ServerFormUseCases.downloadForms( + listOf(serverForm), + changeLock, + formDownloader + ) + + verify(formDownloader, never()).downloadForm( + any(), + any(), + any() + ) + } + + @Test + fun `downloadUpdates returns completed downloads when cancelled`() { + val changeLock = BooleanChangeLock() + val formDownloader = mock() + + val serverForms = listOf( + ServerFormDetails("", "", "", "", "", false, true, ManifestFile("", emptyList())), + ServerFormDetails("", "", "", "", "", false, true, ManifestFile("", emptyList())) + ) + + // Cancel form download after downloading one form + doAnswer(object : Answer { + private var calledBefore = false + + @Throws(Throwable::class) + override fun answer(invocation: InvocationOnMock) { + calledBefore = if (!calledBefore) { + true + } else { + throw FormDownloadException.DownloadingInterrupted() + } + } + }).`when`(formDownloader).downloadForm(any(), any(), any()) + + val results = ServerFormUseCases.downloadForms( + serverForms, + changeLock, + formDownloader + ) + + assertThat(results.size, equalTo(1)) + assertThat(results[serverForms[0]], equalTo(null)) + } + @Test fun `copySavedFileFromPreviousFormVersionIfExists does not copy any file if there is no matching last-saved file`() { val destinationMediaDirPath = TempFiles.createTempDir().absolutePath - ServerFormDownloaderUseCases.copySavedFileFromPreviousFormVersionIfExists(InMemFormsRepository(), "1", destinationMediaDirPath) + ServerFormUseCases.copySavedFileFromPreviousFormVersionIfExists(InMemFormsRepository(), "1", destinationMediaDirPath) - val resultFile = File(destinationMediaDirPath, LAST_SAVED_FILENAME) + val resultFile = File(destinationMediaDirPath, FileUtils.LAST_SAVED_FILENAME) assertThat(resultFile.exists(), equalTo(false)) } @@ -32,19 +99,19 @@ class ServerFormDownloaderUseCasesTest { fun `copySavedFileFromPreviousFormVersionIfExists copies the newest matching last-saved file for given formId`() { val tempDir1 = TempFiles.createTempDir() val file1 = TempFiles.createTempFile(tempDir1, "last-saved", ".xml") - FileUtils.writeByteArrayToFile(file1, "file1".toByteArray()) + org.apache.commons.io.FileUtils.writeByteArrayToFile(file1, "file1".toByteArray()) val tempDir2 = TempFiles.createTempDir() val file2 = TempFiles.createTempFile(tempDir2, "last-saved", ".xml") - FileUtils.writeByteArrayToFile(file2, "file2".toByteArray()) + org.apache.commons.io.FileUtils.writeByteArrayToFile(file2, "file2".toByteArray()) val tempDir3 = TempFiles.createTempDir() val file3 = TempFiles.createTempFile(tempDir3, "last-saved", ".xml") - FileUtils.writeByteArrayToFile(file3, "file3".toByteArray()) + org.apache.commons.io.FileUtils.writeByteArrayToFile(file3, "file3".toByteArray()) val tempDir4 = TempFiles.createTempDir() val file4 = TempFiles.createTempFile(tempDir4, "last-saved", ".xml") - FileUtils.writeByteArrayToFile(file4, "file4".toByteArray()) + org.apache.commons.io.FileUtils.writeByteArrayToFile(file4, "file4".toByteArray()) val formsRepository = InMemFormsRepository().also { it.save( @@ -93,14 +160,14 @@ class ServerFormDownloaderUseCasesTest { } val destinationMediaDirPath = TempFiles.createTempDir().absolutePath - ServerFormDownloaderUseCases.copySavedFileFromPreviousFormVersionIfExists(formsRepository, "1", destinationMediaDirPath) + ServerFormUseCases.copySavedFileFromPreviousFormVersionIfExists(formsRepository, "1", destinationMediaDirPath) - val resultFile = File(destinationMediaDirPath, LAST_SAVED_FILENAME) + val resultFile = File(destinationMediaDirPath, FileUtils.LAST_SAVED_FILENAME) assertThat(resultFile.readText(), equalTo("file2")) } @Test - fun `download returns false when there is an existing copy of a media file and an older one`() { + fun `downloadMediaFiles returns false when there is an existing copy of a media file and an older one`() { var date: Long = 0 // Save forms val formsRepository = InMemFormsRepository { @@ -130,21 +197,21 @@ class ServerFormDownloaderUseCasesTest { .inputStream() } - val result = ServerFormDownloaderUseCases.download( - formsRepository, - formSource, + val result = ServerFormUseCases.downloadMediaFiles( serverFormDetails, + formSource, + formsRepository, File(TempFiles.createTempDir(), "temp").absolutePath, TempFiles.createTempDir(), - mock(), - true + InMemEntitiesRepository(), + mock() ) assertThat(result, equalTo(false)) } @Test - fun `download returns false when there is an existing copy of a media file and an older one and media file list hash doesn't match existing copy`() { + fun `downloadMediaFiles returns false when there is an existing copy of a media file and an older one and media file list hash doesn't match existing copy`() { // Save forms var date: Long = 0 val formsRepository = InMemFormsRepository { @@ -173,12 +240,13 @@ class ServerFormDownloaderUseCasesTest { .inputStream() } - val result = ServerFormDownloaderUseCases.download( - formsRepository, - formSource, + val result = ServerFormUseCases.downloadMediaFiles( serverFormDetails, + formSource, + formsRepository, File(TempFiles.createTempDir(), "temp").absolutePath, TempFiles.createTempDir(), + InMemEntitiesRepository(), mock() ) diff --git a/collect_app/src/test/java/org/odk/collect/android/formmanagement/ServerFormsSynchronizerTest.java b/collect_app/src/test/java/org/odk/collect/android/formmanagement/ServerFormsSynchronizerTest.java index 99c719ebc67..c141cfcde0a 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formmanagement/ServerFormsSynchronizerTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/formmanagement/ServerFormsSynchronizerTest.java @@ -1,6 +1,8 @@ package org.odk.collect.android.formmanagement; import org.junit.Test; +import org.odk.collect.android.formmanagement.download.FormDownloadException; +import org.odk.collect.android.formmanagement.download.FormDownloader; import org.odk.collect.android.formmanagement.matchexactly.ServerFormsSynchronizer; import org.odk.collect.forms.Form; import org.odk.collect.forms.FormSourceException; diff --git a/collect_app/src/test/java/org/odk/collect/android/formmanagement/FormDownloadExceptionMapperTest.kt b/collect_app/src/test/java/org/odk/collect/android/formmanagement/download/FormDownloadExceptionMapperTest.kt similarity index 96% rename from collect_app/src/test/java/org/odk/collect/android/formmanagement/FormDownloadExceptionMapperTest.kt rename to collect_app/src/test/java/org/odk/collect/android/formmanagement/download/FormDownloadExceptionMapperTest.kt index d3e22a56b1d..1764a0b313c 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formmanagement/FormDownloadExceptionMapperTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/formmanagement/download/FormDownloadExceptionMapperTest.kt @@ -1,4 +1,4 @@ -package org.odk.collect.android.formmanagement +package org.odk.collect.android.formmanagement.download import android.content.Context import androidx.test.core.app.ApplicationProvider @@ -8,7 +8,6 @@ import org.hamcrest.Matchers.`is` import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.odk.collect.android.R @RunWith(AndroidJUnit4::class) class FormDownloadExceptionMapperTest { diff --git a/collect_app/src/test/java/org/odk/collect/android/formmanagement/ServerFormDownloaderTest.java b/collect_app/src/test/java/org/odk/collect/android/formmanagement/download/ServerFormDownloaderTest.java similarity index 96% rename from collect_app/src/test/java/org/odk/collect/android/formmanagement/ServerFormDownloaderTest.java rename to collect_app/src/test/java/org/odk/collect/android/formmanagement/download/ServerFormDownloaderTest.java index 8cafc77b680..c3b41b4baf3 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formmanagement/ServerFormDownloaderTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/formmanagement/download/ServerFormDownloaderTest.java @@ -1,4 +1,4 @@ -package org.odk.collect.android.formmanagement; +package org.odk.collect.android.formmanagement.download; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.contains; @@ -12,9 +12,9 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.odk.collect.android.utilities.FileUtils.read; +import static org.odk.collect.androidshared.utils.PathUtils.getAbsoluteFilePath; import static org.odk.collect.formstest.FormUtils.buildForm; import static org.odk.collect.formstest.FormUtils.createXFormBody; -import static org.odk.collect.shared.PathUtils.getAbsoluteFilePath; import static java.util.Arrays.asList; import static java.util.Arrays.stream; import static java.util.stream.Collectors.toList; @@ -23,6 +23,10 @@ import org.javarosa.xform.parse.XFormParser; import org.junit.Test; +import org.odk.collect.android.formmanagement.FormMetadataParser; +import org.odk.collect.android.formmanagement.ServerFormDetails; +import org.odk.collect.entities.EntitiesRepository; +import org.odk.collect.entities.InMemEntitiesRepository; import org.odk.collect.forms.Form; import org.odk.collect.forms.FormListItem; import org.odk.collect.forms.FormSource; @@ -51,6 +55,8 @@ public class ServerFormDownloaderTest { private final File formsDir = Files.createTempDir(); private final Supplier clock = () -> 123L; + private final EntitiesRepository entitiesRepository = new InMemEntitiesRepository(); + @Test public void downloadsAndSavesForm() throws Exception { String xform = createXFormBody("id", "version"); @@ -67,7 +73,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(), new FormMetadataParser(), clock); + ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), new FormMetadataParser(), clock, entitiesRepository); downloader.downloadForm(serverFormDetails, null, null); List allForms = formsRepository.getAll(); @@ -96,7 +102,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(), new FormMetadataParser(), clock); + ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), new FormMetadataParser(), clock, entitiesRepository); downloader.downloadForm(serverFormDetails, null, null); String xformUpdate = createXFormBody("id", "updated"); @@ -140,7 +146,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(), new FormMetadataParser(), clock); + ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), new FormMetadataParser(), clock, entitiesRepository); downloader.downloadForm(serverFormDetails, null, null); List formsBeforeUpdate = formsRepository.getAllByFormIdAndVersion("id", "version"); @@ -193,7 +199,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(), new FormMetadataParser(), clock); + ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), new FormMetadataParser(), clock, entitiesRepository); try { downloader.downloadForm(serverFormDetails, null, null); fail("Expected exception because of missing form hash"); @@ -223,7 +229,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(), new FormMetadataParser(), clock); + ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), new FormMetadataParser(), clock, entitiesRepository); downloader.downloadForm(serverFormDetails, null, null); List allForms = formsRepository.getAll(); @@ -265,7 +271,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(), new FormMetadataParser(), clock); + ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), new FormMetadataParser(), clock, entitiesRepository); downloader.downloadForm(serverFormDetails, null, null); String xformUpdate = createXFormBody("id", "updated"); @@ -310,7 +316,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(), new FormMetadataParser(), clock); + ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), new FormMetadataParser(), clock, entitiesRepository); downloader.downloadForm(serverFormDetails, null, null); String xformUpdate = createXFormBody("id", "updated"); @@ -373,7 +379,7 @@ public Map parse(File file, File mediaDir) throws XFormParser.Pa } }; - ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), formMetadataParser, clock); + ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), formMetadataParser, clock, entitiesRepository); downloader.downloadForm(serverFormDetails, null, null); } @@ -396,7 +402,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(), new FormMetadataParser(), clock); + ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), new FormMetadataParser(), clock, entitiesRepository); try { downloader.downloadForm(serverFormDetails, null, null); @@ -430,7 +436,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(), new FormMetadataParser(), clock); + ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), new FormMetadataParser(), clock, entitiesRepository); try { downloader.downloadForm(serverFormDetails, null, null); @@ -463,7 +469,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(), new FormMetadataParser(), clock); + ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), new FormMetadataParser(), clock, entitiesRepository); RecordingProgressReporter progressReporter = new RecordingProgressReporter(); downloader.downloadForm(serverFormDetails, progressReporter, null); @@ -492,7 +498,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(), new FormMetadataParser(), clock); + ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), new FormMetadataParser(), clock, entitiesRepository); downloader.downloadForm(serverFormDetails, null, null); assertThat(formsRepository.get(1L).isDeleted(), is(false)); } @@ -524,7 +530,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(), new FormMetadataParser(), clock); + ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), new FormMetadataParser(), clock, entitiesRepository); downloader.downloadForm(serverFormDetails, null, null); assertThat(formsRepository.get(1L).isDeleted(), is(true)); assertThat(formsRepository.get(2L).isDeleted(), is(false)); @@ -547,7 +553,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(), new FormMetadataParser(), clock); + ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), new FormMetadataParser(), clock, entitiesRepository); // Initial download downloader.downloadForm(serverFormDetails, null, null); @@ -592,7 +598,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(), new FormMetadataParser(), clock); + ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), new FormMetadataParser(), clock, entitiesRepository); // Initial download downloader.downloadForm(serverFormDetails, null, null); @@ -655,7 +661,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(), new FormMetadataParser(), clock); + ServerFormDownloader downloader = new ServerFormDownloader(formSource, formsRepository, cacheDir, formsDir.getAbsolutePath(), new FormMetadataParser(), clock, entitiesRepository); // Initial download downloader.downloadForm(serverFormDetails, null, null); @@ -704,7 +710,7 @@ public void afterDownloadingXForm_cancelling_throwsDownloadingInterruptedExcepti null); CancelAfterFormDownloadFormSource formListApi = new CancelAfterFormDownloadFormSource(xform); - ServerFormDownloader downloader = new ServerFormDownloader(formListApi, formsRepository, cacheDir, formsDir.getAbsolutePath(), new FormMetadataParser(), clock); + ServerFormDownloader downloader = new ServerFormDownloader(formListApi, formsRepository, cacheDir, formsDir.getAbsolutePath(), new FormMetadataParser(), clock, entitiesRepository); try { downloader.downloadForm(serverFormDetails, null, formListApi); @@ -733,7 +739,7 @@ public void afterDownloadingMediaFile_cancelling_throwsDownloadingInterruptedExc ))); CancelAfterMediaFileDownloadFormSource formListApi = new CancelAfterMediaFileDownloadFormSource(xform); - ServerFormDownloader downloader = new ServerFormDownloader(formListApi, formsRepository, cacheDir, formsDir.getAbsolutePath(), new FormMetadataParser(), clock); + ServerFormDownloader downloader = new ServerFormDownloader(formListApi, formsRepository, cacheDir, formsDir.getAbsolutePath(), new FormMetadataParser(), clock, entitiesRepository); try { downloader.downloadForm(serverFormDetails, null, formListApi); diff --git a/collect_app/src/test/java/org/odk/collect/android/formmanagement/formmap/FormMapViewModelTest.kt b/collect_app/src/test/java/org/odk/collect/android/formmanagement/formmap/FormMapViewModelTest.kt index c389968393c..12324d344fb 100644 --- a/collect_app/src/test/java/org/odk/collect/android/formmanagement/formmap/FormMapViewModelTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/formmanagement/formmap/FormMapViewModelTest.kt @@ -4,6 +4,7 @@ import android.app.Application import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.containsString import org.hamcrest.Matchers.equalTo import org.junit.Test import org.junit.runner.RunWith @@ -14,7 +15,10 @@ import org.odk.collect.formstest.FormUtils import org.odk.collect.formstest.InMemFormsRepository import org.odk.collect.formstest.InMemInstancesRepository import org.odk.collect.formstest.InstanceUtils +import org.odk.collect.geo.selection.IconifiedText import org.odk.collect.geo.selection.MappableSelectItem +import org.odk.collect.geo.selection.Status +import org.odk.collect.maps.MapPoint import org.odk.collect.settings.InMemSettingsProvider import org.odk.collect.settings.keys.ProtectedProjectKeys import org.odk.collect.shared.TempFiles @@ -102,26 +106,21 @@ class FormMapViewModelTest { val viewModel = createAndLoadViewModel(form) assertThat(viewModel.getMappableItems().value!!.size, equalTo(1)) - val expectedItem = MappableSelectItem.WithAction( + val expectedItem = MappableSelectItem.MappableSelectPoint( instanceWithPoint.dbId, - 2.0, - 1.0, - R.drawable.ic_room_form_state_incomplete_24dp, - R.drawable.ic_room_form_state_incomplete_48dp, instanceWithPoint.displayName, - listOf( - MappableSelectItem.IconifiedText( - R.drawable.ic_form_state_saved, - formatDate( - org.odk.collect.strings.R.string.saved_on_date_at_time, - instanceWithPoint.lastStatusChangeDate - ) - ) - ), - action = MappableSelectItem.IconifiedText( + point = MapPoint(2.0, 1.0), + smallIcon = R.drawable.ic_room_form_state_incomplete_24dp, + largeIcon = R.drawable.ic_room_form_state_incomplete_48dp, + action = IconifiedText( R.drawable.ic_edit, application.getString(org.odk.collect.strings.R.string.edit_data) - ) + ), + info = formatDate( + org.odk.collect.strings.R.string.saved_on_date_at_time, + instanceWithPoint.lastStatusChangeDate + ), + status = Status.ERRORS ) assertThat(viewModel.getMappableItems().value!![0], equalTo(expectedItem)) } @@ -146,32 +145,27 @@ class FormMapViewModelTest { ) val viewModel = createAndLoadViewModel(form) - val expectedItem = MappableSelectItem.WithAction( + val expectedItem = MappableSelectItem.MappableSelectPoint( instance.dbId, - 2.0, - 1.0, - R.drawable.ic_room_form_state_incomplete_24dp, - R.drawable.ic_room_form_state_incomplete_48dp, instance.displayName, - listOf( - MappableSelectItem.IconifiedText( - R.drawable.ic_form_state_saved, - formatDate( - org.odk.collect.strings.R.string.saved_on_date_at_time, - instance.lastStatusChangeDate - ) - ) - ), - action = MappableSelectItem.IconifiedText( + point = MapPoint(2.0, 1.0), + smallIcon = R.drawable.ic_room_form_state_incomplete_24dp, + largeIcon = R.drawable.ic_room_form_state_incomplete_48dp, + action = IconifiedText( R.drawable.ic_edit, application.getString(org.odk.collect.strings.R.string.edit_data) - ) + ), + info = formatDate( + org.odk.collect.strings.R.string.saved_on_date_at_time, + instance.lastStatusChangeDate + ), + status = Status.NO_ERRORS ) assertThat(viewModel.getMappableItems().value!![0], equalTo(expectedItem)) } @Test - fun `invalid drafts with geometry have proper icons, actions and no info`() { + fun `invalid drafts with geometry have proper icons, actions, status and no info`() { val form = formsRepository.save( FormUtils.buildForm("id", "version", TempFiles.createTempDir().absolutePath) .build() @@ -190,26 +184,21 @@ class FormMapViewModelTest { ) val viewModel = createAndLoadViewModel(form) - val expectedItem = MappableSelectItem.WithAction( + val expectedItem = MappableSelectItem.MappableSelectPoint( instance.dbId, - 2.0, - 1.0, - R.drawable.ic_room_form_state_incomplete_24dp, - R.drawable.ic_room_form_state_incomplete_48dp, instance.displayName, - listOf( - MappableSelectItem.IconifiedText( - R.drawable.ic_form_state_saved, - formatDate( - org.odk.collect.strings.R.string.saved_on_date_at_time, - instance.lastStatusChangeDate - ) - ) - ), - action = MappableSelectItem.IconifiedText( + point = MapPoint(2.0, 1.0), + smallIcon = R.drawable.ic_room_form_state_incomplete_24dp, + largeIcon = R.drawable.ic_room_form_state_incomplete_48dp, + action = IconifiedText( R.drawable.ic_edit, application.getString(org.odk.collect.strings.R.string.edit_data) - ) + ), + info = formatDate( + org.odk.collect.strings.R.string.saved_on_date_at_time, + instance.lastStatusChangeDate + ), + status = Status.ERRORS ) assertThat(viewModel.getMappableItems().value!![0], equalTo(expectedItem)) } @@ -234,25 +223,19 @@ class FormMapViewModelTest { ) val viewModel = createAndLoadViewModel(form) - val expectedItem = MappableSelectItem.WithAction( + val expectedItem = MappableSelectItem.MappableSelectPoint( instance.dbId, - 2.0, - 1.0, - R.drawable.ic_room_form_state_complete_24dp, - R.drawable.ic_room_form_state_complete_48dp, instance.displayName, - listOf( - MappableSelectItem.IconifiedText( - R.drawable.ic_form_state_finalized, - formatDate( - org.odk.collect.strings.R.string.finalized_on_date_at_time, - instance.lastStatusChangeDate - ) - ) - ), - action = MappableSelectItem.IconifiedText( + point = MapPoint(2.0, 1.0), + smallIcon = R.drawable.ic_room_form_state_complete_24dp, + largeIcon = R.drawable.ic_room_form_state_complete_48dp, + action = IconifiedText( R.drawable.ic_visibility, application.getString(org.odk.collect.strings.R.string.view_data) + ), + info = formatDate( + org.odk.collect.strings.R.string.finalized_on_date_at_time, + instance.lastStatusChangeDate ) ) assertThat(viewModel.getMappableItems().value!![0], equalTo(expectedItem)) @@ -280,25 +263,19 @@ class FormMapViewModelTest { settingsProvider.getProtectedSettings().save(ProtectedProjectKeys.KEY_EDIT_SAVED, false) val viewModel = createAndLoadViewModel(form) - val expectedItem = MappableSelectItem.WithAction( + val expectedItem = MappableSelectItem.MappableSelectPoint( instance.dbId, - 2.0, - 1.0, - R.drawable.ic_room_form_state_complete_24dp, - R.drawable.ic_room_form_state_complete_48dp, instance.displayName, - listOf( - MappableSelectItem.IconifiedText( - R.drawable.ic_form_state_finalized, - formatDate( - org.odk.collect.strings.R.string.finalized_on_date_at_time, - instance.lastStatusChangeDate - ) - ) - ), - action = MappableSelectItem.IconifiedText( + point = MapPoint(2.0, 1.0), + smallIcon = R.drawable.ic_room_form_state_complete_24dp, + largeIcon = R.drawable.ic_room_form_state_complete_48dp, + action = IconifiedText( R.drawable.ic_visibility, application.getString(org.odk.collect.strings.R.string.view_data) + ), + info = formatDate( + org.odk.collect.strings.R.string.finalized_on_date_at_time, + instance.lastStatusChangeDate ) ) assertThat(viewModel.getMappableItems().value!![0], equalTo(expectedItem)) @@ -324,23 +301,20 @@ class FormMapViewModelTest { ) val viewModel = createAndLoadViewModel(form) - val expectedItem = MappableSelectItem.WithInfo( + val expectedItem = MappableSelectItem.MappableSelectPoint( instance.dbId, - 2.0, - 1.0, - R.drawable.ic_room_form_state_incomplete_24dp, - R.drawable.ic_room_form_state_incomplete_48dp, instance.displayName, - listOf( - MappableSelectItem.IconifiedText( - R.drawable.ic_form_state_saved, - formatDate( - org.odk.collect.strings.R.string.saved_on_date_at_time, - instance.lastStatusChangeDate - ) + point = MapPoint(2.0, 1.0), + smallIcon = R.drawable.ic_room_form_state_incomplete_24dp, + largeIcon = R.drawable.ic_room_form_state_incomplete_48dp, + info = formatDate( + org.odk.collect.strings.R.string.saved_on_date_at_time, + instance.lastStatusChangeDate + ) + "\n" + + formatDate( + org.odk.collect.strings.R.string.deleted_on_date_at_time, + 123L ) - ), - info = formatDate(org.odk.collect.strings.R.string.deleted_on_date_at_time, 123L) ) assertThat(viewModel.getMappableItems().value!![0], equalTo(expectedItem)) } @@ -365,25 +339,19 @@ class FormMapViewModelTest { ) val viewModel = createAndLoadViewModel(form) - val expectedItem = MappableSelectItem.WithAction( + val expectedItem = MappableSelectItem.MappableSelectPoint( instance.dbId, - 2.0, - 1.0, - R.drawable.ic_room_form_state_submitted_24dp, - R.drawable.ic_room_form_state_submitted_48dp, instance.displayName, - listOf( - MappableSelectItem.IconifiedText( - R.drawable.ic_form_state_submitted, - formatDate( - org.odk.collect.strings.R.string.sent_on_date_at_time, - instance.lastStatusChangeDate - ) - ) - ), - action = MappableSelectItem.IconifiedText( + point = MapPoint(2.0, 1.0), + smallIcon = R.drawable.ic_room_form_state_submitted_24dp, + largeIcon = R.drawable.ic_room_form_state_submitted_48dp, + action = IconifiedText( R.drawable.ic_visibility, application.getString(org.odk.collect.strings.R.string.view_data) + ), + info = formatDate( + org.odk.collect.strings.R.string.sent_on_date_at_time, + instance.lastStatusChangeDate ) ) assertThat(viewModel.getMappableItems().value!![0], equalTo(expectedItem)) @@ -409,25 +377,19 @@ class FormMapViewModelTest { ) val viewModel = createAndLoadViewModel(form) - val expectedItem = MappableSelectItem.WithAction( + val expectedItem = MappableSelectItem.MappableSelectPoint( instance.dbId, - 2.0, - 1.0, - R.drawable.ic_room_form_state_submission_failed_24dp, - R.drawable.ic_room_form_state_submission_failed_48dp, instance.displayName, - listOf( - MappableSelectItem.IconifiedText( - R.drawable.ic_form_state_submission_failed, - formatDate( - org.odk.collect.strings.R.string.sending_failed_on_date_at_time, - instance.lastStatusChangeDate - ) - ) - ), - action = MappableSelectItem.IconifiedText( + point = MapPoint(2.0, 1.0), + smallIcon = R.drawable.ic_room_form_state_submission_failed_24dp, + largeIcon = R.drawable.ic_room_form_state_submission_failed_48dp, + action = IconifiedText( R.drawable.ic_visibility, application.getString(org.odk.collect.strings.R.string.view_data) + ), + info = formatDate( + org.odk.collect.strings.R.string.sending_failed_on_date_at_time, + instance.lastStatusChangeDate ) ) assertThat(viewModel.getMappableItems().value!![0], equalTo(expectedItem)) @@ -480,18 +442,18 @@ class FormMapViewModelTest { val items = viewModel.getMappableItems().value assertThat( - (items!![0] as MappableSelectItem.WithInfo).info, - equalTo(application.getString(org.odk.collect.strings.R.string.cannot_edit_completed_form)) + items!![0].info, + containsString(application.getString(org.odk.collect.strings.R.string.cannot_edit_completed_form)) ) assertThat( - (items[1] as MappableSelectItem.WithInfo).info, - equalTo(application.getString(org.odk.collect.strings.R.string.cannot_edit_completed_form)) + items[1].info, + containsString(application.getString(org.odk.collect.strings.R.string.cannot_edit_completed_form)) ) assertThat( - (items[2] as MappableSelectItem.WithInfo).info, - equalTo(application.getString(org.odk.collect.strings.R.string.cannot_edit_completed_form)) + items[2].info, + containsString(application.getString(org.odk.collect.strings.R.string.cannot_edit_completed_form)) ) } diff --git a/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/BikramSambatDatePickerDialogTest.java b/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/BikramSambatDatePickerDialogTest.java index d85445021ae..de786134abd 100644 --- a/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/BikramSambatDatePickerDialogTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/BikramSambatDatePickerDialogTest.java @@ -56,7 +56,7 @@ public void dialogShouldShowCorrectDate_forYearMode() { dialogFragment.show(fragmentManager, "TAG"); RobolectricHelpers.runLooper(); - DialogFragmentHelpers.assertDialogShowsCorrectDateForYearMode(2077, "2076 (2020)"); + DialogFragmentHelpers.assertDialogShowsCorrectDateForYearMode(2077, "2077 (2020)"); } @Test @@ -67,7 +67,7 @@ public void dialogShouldShowCorrectDate_forMonthMode() { dialogFragment.show(fragmentManager, "TAG"); RobolectricHelpers.runLooper(); - DialogFragmentHelpers.assertDialogShowsCorrectDateForMonthMode(2077, 0, "चैत 2076 (Apr 2020)"); + DialogFragmentHelpers.assertDialogShowsCorrectDateForMonthMode(2077, 0, "बैशाख 2077 (Apr 2020)"); } @Test @@ -75,12 +75,12 @@ public void settingDateInDatePicker_changesDateShownInTextView() { dialogFragment.show(fragmentManager, "TAG"); RobolectricHelpers.runLooper(); - DialogFragmentHelpers.assertDialogTextViewUpdatesDate("30 बैशाख 2077 (May 12, 2020)"); + DialogFragmentHelpers.assertDialogTextViewUpdatesDate("30 बैशाख 2077 (May 12, 2020)", 2077, 0, 30); } @Test public void whenScreenIsRotated_dialogShouldRetainDateInDatePickerAndTextView() { - DialogFragmentHelpers.assertDialogRetainsDateOnScreenRotation(dialogFragment, "12 असोज 2020 (Sep 28, 1963)"); + DialogFragmentHelpers.assertDialogRetainsDateOnScreenRotation(dialogFragment, "30 बैशाख 2077 (May 12, 2020)", 2077, 0, 30); } @Test @@ -88,7 +88,7 @@ public void clickingOk_updatesDateInActivity() { dialogFragment.show(fragmentManager, "TAG"); RobolectricHelpers.runLooper(); - DialogFragmentHelpers.assertDateUpdateInActivity(activity, 1963, 9, 28); + DialogFragmentHelpers.assertDateUpdateInActivity(activity, 2077, 0, 30); } @Test diff --git a/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/CopticDatePickerDialogTest.java b/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/CopticDatePickerDialogTest.java index cb4ed882524..fa512d6e1cd 100644 --- a/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/CopticDatePickerDialogTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/CopticDatePickerDialogTest.java @@ -73,12 +73,12 @@ public void settingDateInDatePicker_changesDateShownInTextView() { dialogFragment.show(fragmentManager, "TAG"); RobolectricHelpers.runLooper(); - DialogFragmentHelpers.assertDialogTextViewUpdatesDate("4 Pashons 1736 (May 12, 2020)"); + DialogFragmentHelpers.assertDialogTextViewUpdatesDate("4 Pashons 1736 (May 12, 2020)", 1736, 8, 4); } @Test public void whenScreenIsRotated_dialogShouldRetainDateInDatePickerAndTextView() { - DialogFragmentHelpers.assertDialogRetainsDateOnScreenRotation(dialogFragment, "12 Meshir 2020 (Feb 23, 2304)"); + DialogFragmentHelpers.assertDialogRetainsDateOnScreenRotation(dialogFragment, "4 Pashons 1736 (May 12, 2020)", 1736, 8, 4); } @Test @@ -86,7 +86,7 @@ public void clickingOk_updatesDateInActivity() { dialogFragment.show(fragmentManager, "TAG"); RobolectricHelpers.runLooper(); - DialogFragmentHelpers.assertDateUpdateInActivity(activity, 2304, 2, 23); + DialogFragmentHelpers.assertDateUpdateInActivity(activity, 1736, 8, 4); } @Test diff --git a/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/EthiopianDatePickerDialogTest.java b/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/EthiopianDatePickerDialogTest.java index 2949b2997a8..19a4e718bf4 100644 --- a/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/EthiopianDatePickerDialogTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/EthiopianDatePickerDialogTest.java @@ -73,12 +73,12 @@ public void settingDateInDatePicker_changesDateShownInTextView() { dialogFragment.show(fragmentManager, "TAG"); RobolectricHelpers.runLooper(); - DialogFragmentHelpers.assertDialogTextViewUpdatesDate("4 Ginbot 2012 (May 12, 2020)"); + DialogFragmentHelpers.assertDialogTextViewUpdatesDate("4 Ginbot 2012 (May 12, 2020)", 2012, 8, 4); } @Test public void whenScreenIsRotated_dialogShouldRetainDateInDatePickerAndTextView() { - DialogFragmentHelpers.assertDialogRetainsDateOnScreenRotation(dialogFragment, "12 Yekatit 2020 (Feb 20, 2028)"); + DialogFragmentHelpers.assertDialogRetainsDateOnScreenRotation(dialogFragment, "4 Ginbot 2012 (May 12, 2020)", 2012, 8, 4); } @Test @@ -86,7 +86,7 @@ public void clickingOk_updatesDateInActivity() { dialogFragment.show(fragmentManager, "TAG"); RobolectricHelpers.runLooper(); - DialogFragmentHelpers.assertDateUpdateInActivity(activity, 2028, 2, 20); + DialogFragmentHelpers.assertDateUpdateInActivity(activity, 2012, 8, 4); } @Test diff --git a/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/FormsDownloadResultDialogTest.kt b/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/FormsDownloadResultDialogTest.kt index d04fd4fe855..448006964c2 100644 --- a/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/FormsDownloadResultDialogTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/FormsDownloadResultDialogTest.kt @@ -18,10 +18,9 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.mock import org.mockito.kotlin.verify -import org.odk.collect.android.R import org.odk.collect.android.application.Collect -import org.odk.collect.android.formmanagement.FormDownloadException import org.odk.collect.android.formmanagement.ServerFormDetails +import org.odk.collect.android.formmanagement.download.FormDownloadException import org.odk.collect.fragmentstest.FragmentScenarioLauncherRule import org.odk.collect.testshared.RobolectricHelpers import org.robolectric.Shadows diff --git a/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/IslamicDatePickerDialogTest.java b/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/IslamicDatePickerDialogTest.java index ef9b55936fd..d203e4f4443 100644 --- a/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/IslamicDatePickerDialogTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/IslamicDatePickerDialogTest.java @@ -73,12 +73,12 @@ public void settingDateInDatePicker_changesDateShownInTextView() { dialogFragment.show(fragmentManager, "TAG"); RobolectricHelpers.runLooper(); - DialogFragmentHelpers.assertDialogTextViewUpdatesDate("19 Ramadan 1441 (May 12, 2020)"); + DialogFragmentHelpers.assertDialogTextViewUpdatesDate("19 Ramadan 1441 (May 12, 2020)", 1441, 8, 19); } @Test public void whenScreenIsRotated_dialogShouldRetainDateInDatePickerAndTextView() { - DialogFragmentHelpers.assertDialogRetainsDateOnScreenRotation(dialogFragment, "12 Jumada al-thani 2020 (Nov 10, 2581)"); + DialogFragmentHelpers.assertDialogRetainsDateOnScreenRotation(dialogFragment, "19 Ramadan 1441 (May 12, 2020)", 1441, 8, 19); } @Test @@ -86,7 +86,7 @@ public void clickingOk_updatesDateInActivity() { dialogFragment.show(fragmentManager, "TAG"); RobolectricHelpers.runLooper(); - DialogFragmentHelpers.assertDateUpdateInActivity(activity, 2581, 11, 10); + DialogFragmentHelpers.assertDateUpdateInActivity(activity, 1441, 8, 19); } @Test diff --git a/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/MyanmarDatePickerDialogTest.java b/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/MyanmarDatePickerDialogTest.java index f8d66832e06..3ddd47fb7f2 100644 --- a/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/MyanmarDatePickerDialogTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/MyanmarDatePickerDialogTest.java @@ -65,7 +65,7 @@ public void dialogShouldShowCorrectDate_forMonthMode() { dialogFragment.show(fragmentManager, "TAG"); RobolectricHelpers.runLooper(); - DialogFragmentHelpers.assertDialogShowsCorrectDateForMonthMode(1382, 1, "တန်ခူး 1382 (Apr 2020)"); + DialogFragmentHelpers.assertDialogShowsCorrectDateForMonthMode(1382, 1, "ကဆုန် 1382 (Apr 2020)"); } @Test @@ -73,12 +73,12 @@ public void settingDateInDatePicker_changesDateShownInTextView() { dialogFragment.show(fragmentManager, "TAG"); RobolectricHelpers.runLooper(); - DialogFragmentHelpers.assertDialogTextViewUpdatesDate("21 ကဆုန် 1382 (May 12, 2020)"); + DialogFragmentHelpers.assertDialogTextViewUpdatesDate("21 ကဆုန် 1382 (May 12, 2020)", 1382, 1, 21); } @Test public void whenScreenIsRotated_dialogShouldRetainDateInDatePickerAndTextView() { - DialogFragmentHelpers.assertDialogRetainsDateOnScreenRotation(dialogFragment, "12 တော်သလင်း 2020 (Sep 30, 2658)"); + DialogFragmentHelpers.assertDialogRetainsDateOnScreenRotation(dialogFragment, "21 ကဆုန် 1382 (May 12, 2020)", 1382, 1, 21); } @Test @@ -86,7 +86,7 @@ public void clickingOk_updatesDateInActivity() { dialogFragment.show(fragmentManager, "TAG"); RobolectricHelpers.runLooper(); - DialogFragmentHelpers.assertDateUpdateInActivity(activity, 2658, 9, 30); + DialogFragmentHelpers.assertDateUpdateInActivity(activity, 1382, 1, 21); } @Test diff --git a/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/PersianDatePickerDialogTest.java b/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/PersianDatePickerDialogTest.java index d157b3e71c5..63d8fde898c 100644 --- a/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/PersianDatePickerDialogTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/fragments/dialogs/PersianDatePickerDialogTest.java @@ -73,12 +73,12 @@ public void settingDateInDatePicker_changesDateShownInTextView() { dialogFragment.show(fragmentManager, "TAG"); RobolectricHelpers.runLooper(); - DialogFragmentHelpers.assertDialogTextViewUpdatesDate("23 Ordibehesht 1399 (May 12, 2020)"); + DialogFragmentHelpers.assertDialogTextViewUpdatesDate("23 Ordibehesht 1399 (May 12, 2020)", 1399, 1, 23); } @Test public void whenScreenIsRotated_dialogShouldRetainDateInDatePickerAndTextView() { - DialogFragmentHelpers.assertDialogRetainsDateOnScreenRotation(dialogFragment, "12 Shahrivar 2020 (Sep 03, 2641)"); + DialogFragmentHelpers.assertDialogRetainsDateOnScreenRotation(dialogFragment, "23 Ordibehesht 1399 (May 12, 2020)", 1399, 1, 23); } @Test @@ -86,7 +86,7 @@ public void clickingOk_updatesDateInActivity() { dialogFragment.show(fragmentManager, "TAG"); RobolectricHelpers.runLooper(); - DialogFragmentHelpers.assertDateUpdateInActivity(activity, 2641, 9, 3); + DialogFragmentHelpers.assertDateUpdateInActivity(activity, 1399, 1, 23); } @Test diff --git a/collect_app/src/test/java/org/odk/collect/android/fragments/support/DialogFragmentHelpers.java b/collect_app/src/test/java/org/odk/collect/android/fragments/support/DialogFragmentHelpers.java index 2a4717453d9..1764a9015e1 100644 --- a/collect_app/src/test/java/org/odk/collect/android/fragments/support/DialogFragmentHelpers.java +++ b/collect_app/src/test/java/org/odk/collect/android/fragments/support/DialogFragmentHelpers.java @@ -63,34 +63,34 @@ public static void assertDialogShowsCorrectDate(int year, int month, int day, St public static void assertDialogShowsCorrectDateForYearMode(int year, String date) { AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog(); - assertDatePickerValue(dialog, year, 0, 0); + assertDatePickerValue(dialog, year, 0, 1); assertThat(((TextView) dialog.findViewById(R.id.date_gregorian)).getText().toString(), equalTo(date)); } public static void assertDialogShowsCorrectDateForMonthMode(int year, int month, String date) { AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog(); - assertDatePickerValue(dialog, year, month, 0); + assertDatePickerValue(dialog, year, month, 1); assertThat(((TextView) dialog.findViewById(R.id.date_gregorian)).getText().toString(), equalTo(date)); } - public static void assertDialogTextViewUpdatesDate(String date) { + public static void assertDialogTextViewUpdatesDate(String date, int year, int month, int day) { AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog(); - setDatePickerValue(dialog, 2020, 5, 12); + setDatePickerValue(dialog, year, month, day); assertThat(((TextView) dialog.findViewById(R.id.date_gregorian)).getText().toString(), equalTo(date)); } public static void assertDateUpdateInActivity(DatePickerTestActivity activity, int year, int month, int day) { AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog(); - setDatePickerValue(dialog, 2020, 5, 12); + setDatePickerValue(dialog, year, month, day); dialog.getButton(DialogInterface.BUTTON_POSITIVE).performClick(); RobolectricHelpers.runLooper(); - assertThat(activity.selectedDate.getYear(), equalTo(year)); - assertThat(activity.selectedDate.getMonthOfYear(), equalTo(month)); - assertThat(activity.selectedDate.getDayOfMonth(), equalTo(day)); + assertThat(activity.selectedDate.getYear(), equalTo(2020)); + assertThat(activity.selectedDate.getMonthOfYear(), equalTo(5)); + assertThat(activity.selectedDate.getDayOfMonth(), equalTo(12)); } public static void assertDialogIsDismissedOnButtonClick(int dialogButton) { @@ -104,21 +104,21 @@ public static void assertDialogIsDismissedOnButtonClick(int dialogButton) { * @deprecated should use {@link FragmentScenario#recreate()} instead of Robolectric for this */ @Deprecated - public static void assertDialogRetainsDateOnScreenRotation(T dialogFragment, String date) { + public static void assertDialogRetainsDateOnScreenRotation(T dialogFragment, String date, int year, int month, int day) { ActivityController activityController = Robolectric.buildActivity(FragmentActivity.class); activityController.setup(); dialogFragment.show(activityController.get().getSupportFragmentManager(), "TAG"); RobolectricHelpers.runLooper(); AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog(); - setDatePickerValue(dialog, 2020, 5, 12); + setDatePickerValue(dialog, year, month, day); activityController.recreate(); T restoredFragment = (T) activityController.get().getSupportFragmentManager().findFragmentByTag("TAG"); AlertDialog restoredDialog = (AlertDialog) restoredFragment.getDialog(); - assertDatePickerValue(restoredDialog, 2020, 5, 12); + assertDatePickerValue(restoredDialog, year, month, day); assertThat(((TextView) restoredDialog.findViewById(R.id.date_gregorian)).getText().toString(), equalTo(date)); } diff --git a/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceListItemViewTest.kt b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceListItemViewTest.kt index 0d8a35f58cc..f4cf40ded80 100644 --- a/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceListItemViewTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstanceListItemViewTest.kt @@ -14,7 +14,6 @@ import org.odk.collect.android.R import org.odk.collect.android.databinding.FormChooserListItemBinding import org.odk.collect.forms.instances.Instance import org.odk.collect.formstest.InstanceFixtures -import org.odk.collect.strings.R.string @RunWith(AndroidJUnit4::class) class InstanceListItemViewTest { @@ -28,40 +27,40 @@ class InstanceListItemViewTest { } @Test - fun whenInstanceIsInvalid_showsIncompleteChip() { + fun `show an error chip if the status is STATUS_INVALID`() { val binding = FormChooserListItemBinding.inflate(layoutInflater) val instance = InstanceFixtures.instance(status = Instance.STATUS_INVALID) InstanceListItemView.setInstance(binding.root, instance, false) assertThat(binding.chip.visibility, equalTo(View.VISIBLE)) - assertThat(binding.chip.text, equalTo(context.getString(string.draft_errors))) + assertThat(binding.chip.errors, equalTo(true)) } @Test - fun whenInstanceIsValid_showsCompleteChip() { + fun `show a no-error chip if the status is STATUS_VALID`() { val binding = FormChooserListItemBinding.inflate(layoutInflater) val instance = InstanceFixtures.instance(status = Instance.STATUS_VALID) InstanceListItemView.setInstance(binding.root, instance, false) assertThat(binding.chip.visibility, equalTo(View.VISIBLE)) - assertThat(binding.chip.text, equalTo(context.getString(string.draft_no_errors))) + assertThat(binding.chip.errors, equalTo(false)) } @Test - fun whenInstanceIsIncomplete_showsIncompleteChip() { + fun `show an error chip if the status is STATUS_INCOMPLETE`() { val binding = FormChooserListItemBinding.inflate(layoutInflater) val instance = InstanceFixtures.instance(status = Instance.STATUS_INCOMPLETE) InstanceListItemView.setInstance(binding.root, instance, false) assertThat(binding.chip.visibility, equalTo(View.VISIBLE)) - assertThat(binding.chip.text, equalTo(context.getString(string.draft_errors))) + assertThat(binding.chip.errors, equalTo(true)) } @Test - fun whenInstanceIsComplete_doesNotShowIncompleteChip() { + fun `do not show a chip if the status is STATUS_COMPLETE`() { val binding = FormChooserListItemBinding.inflate(layoutInflater) val instance = InstanceFixtures.instance(status = Instance.STATUS_COMPLETE) @@ -69,20 +68,4 @@ class InstanceListItemViewTest { assertThat(binding.chip.visibility, equalTo(View.GONE)) } - - @Test - fun chipCanBeRecycled() { - val binding = FormChooserListItemBinding.inflate(layoutInflater) - val valid = InstanceFixtures.instance(status = Instance.STATUS_VALID) - InstanceListItemView.setInstance(binding.root, valid, false) - - assertThat(binding.chip.visibility, equalTo(View.VISIBLE)) - assertThat(binding.chip.text, equalTo(context.getString(string.draft_no_errors))) - - val invalid = InstanceFixtures.instance(status = Instance.STATUS_INVALID) - InstanceListItemView.setInstance(binding.root, invalid, false) - - assertThat(binding.chip.visibility, equalTo(View.VISIBLE)) - assertThat(binding.chip.text, equalTo(context.getString(string.draft_errors))) - } } 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 new file mode 100644 index 00000000000..b88b3eed413 --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/InstancesDataServiceTest.kt @@ -0,0 +1,120 @@ +package org.odk.collect.android.instancemanagement + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.odk.collect.android.notifications.Notifier +import org.odk.collect.android.openrosa.HttpGetResult +import org.odk.collect.android.openrosa.OpenRosaHttpInterface +import org.odk.collect.android.projects.ProjectDependencyProviderFactory +import org.odk.collect.android.utilities.ChangeLockProvider +import org.odk.collect.android.utilities.FormsRepositoryProvider +import org.odk.collect.android.utilities.InstancesRepositoryProvider +import org.odk.collect.androidshared.data.AppState +import org.odk.collect.forms.instances.Instance.STATUS_COMPLETE +import org.odk.collect.formstest.FormFixtures +import org.odk.collect.formstest.InMemFormsRepository +import org.odk.collect.formstest.InMemInstancesRepository +import org.odk.collect.formstest.InstanceFixtures +import org.odk.collect.projects.Project +import org.odk.collect.settings.InMemSettingsProvider +import org.odk.collect.settings.keys.ProjectKeys +import org.odk.collect.testshared.BooleanChangeLock + +@RunWith(AndroidJUnit4::class) +class InstancesDataServiceTest { + + private val application = ApplicationProvider.getApplicationContext() + + val project = Project.Saved("1", "Test", "T", "#000000") + + private val formsRepositoryProvider = mock().apply { + whenever(get(project.uuid)).thenReturn(InMemFormsRepository()) + } + + private val instancesRepositoryProvider = mock().apply { + whenever(get(project.uuid)).thenReturn(InMemInstancesRepository()) + } + + private val changeLockProvider = ChangeLockProvider { BooleanChangeLock() } + + val settingsProvider = InMemSettingsProvider().also { + it.getUnprotectedSettings(project.uuid) + .save(ProjectKeys.KEY_SERVER_URL, "http://example.com") + } + + private val projectsDependencyProviderFactory = ProjectDependencyProviderFactory( + settingsProvider, + formsRepositoryProvider, + instancesRepositoryProvider, + mock(), + changeLockProvider, + mock(), + mock(), + mock() + ) + + private val projectDependencyProvider = projectsDependencyProviderFactory.create(project.uuid) + private val httpInterface = mock() + private val notifier = mock() + + private val instancesDataService = + InstancesDataService( + AppState(), + mock(), + projectsDependencyProviderFactory, + notifier, + mock(), + httpInterface, + mock() + ) + + @Test + fun `instances should not be deleted if the instances database is locked`() { + (projectDependencyProvider.instancesLock as BooleanChangeLock).lock() + val result = instancesDataService.deleteInstances(project.uuid, longArrayOf(1)) + assertThat(result, equalTo(false)) + } + + @Test + fun `instances should be deleted if the instances database is not locked`() { + val result = instancesDataService.deleteInstances(project.uuid, longArrayOf(1)) + assertThat(result, equalTo(true)) + } + + @Test + fun `sendInstances() returns true when there are no instances to send`() { + val result = instancesDataService.sendInstances(project.uuid) + assertThat(result, equalTo(true)) + } + + @Test + fun `sendInstances() does not notify when there are no instances to send`() { + instancesDataService.sendInstances(project.uuid) + verifyNoInteractions(notifier) + } + + @Test + fun `sendInstances() returns false when an instance fails to send`() { + val formsRepository = projectDependencyProvider.formsRepository + val form = formsRepository.save(FormFixtures.form()) + + val instancesRepository = projectDependencyProvider.instancesRepository + instancesRepository.save(InstanceFixtures.instance(form = form, status = STATUS_COMPLETE)) + + whenever(httpInterface.executeGetRequest(any(), any(), any())) + .doReturn(HttpGetResult(null, emptyMap(), "", 500)) + + val result = instancesDataService.sendInstances(project.uuid) + assertThat(result, equalTo(false)) + } +} diff --git a/collect_app/src/test/java/org/odk/collect/android/instancemanagement/autosend/AutoSendSettingsProviderTest.kt b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/autosend/AutoSendSettingsProviderTest.kt index 12108a88613..b41b652a45e 100644 --- a/collect_app/src/test/java/org/odk/collect/android/instancemanagement/autosend/AutoSendSettingsProviderTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/autosend/AutoSendSettingsProviderTest.kt @@ -1,27 +1,33 @@ package org.odk.collect.android.instancemanagement.autosend -import android.net.ConnectivityManager -import android.net.NetworkInfo +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import org.junit.runner.RunWith import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -import org.odk.collect.androidshared.network.NetworkStateProvider +import org.odk.collect.async.Scheduler +import org.odk.collect.async.network.NetworkStateProvider import org.odk.collect.projects.Project import org.odk.collect.settings.InMemSettingsProvider +import org.odk.collect.settings.enums.AutoSend import org.odk.collect.settings.keys.ProjectKeys +@RunWith(AndroidJUnit4::class) class AutoSendSettingsProviderTest { + private val networkStateProvider: NetworkStateProvider = mock() private val settingsProvider = InMemSettingsProvider() - private val projectId = Project.DEMO_PROJECT_NAME + private val application = ApplicationProvider.getApplicationContext() @Test fun `return false when autosend is disabled in settings and network is not available`() { val autoSendSettingsProvider = setupAutoSendSettingProvider( - autoSendOption = "off", + autoSendOption = AutoSend.OFF.getValue(application), networkType = null ) @@ -31,8 +37,8 @@ class AutoSendSettingsProviderTest { @Test fun `return false when autosend is disabled in settings and network type is wifi`() { val autoSendSettingsProvider = setupAutoSendSettingProvider( - autoSendOption = "off", - networkType = ConnectivityManager.TYPE_WIFI + autoSendOption = AutoSend.OFF.getValue(application), + networkType = Scheduler.NetworkType.WIFI ) assertFalse(autoSendSettingsProvider.isAutoSendEnabledInSettings(projectId)) @@ -41,8 +47,8 @@ class AutoSendSettingsProviderTest { @Test fun `return false when autosend is disabled in settings and network type is cellular`() { val autoSendSettingsProvider = setupAutoSendSettingProvider( - autoSendOption = "off", - networkType = ConnectivityManager.TYPE_MOBILE + autoSendOption = AutoSend.OFF.getValue(application), + networkType = Scheduler.NetworkType.CELLULAR ) assertFalse(autoSendSettingsProvider.isAutoSendEnabledInSettings(projectId)) @@ -51,7 +57,7 @@ class AutoSendSettingsProviderTest { @Test fun `return false when autosend is enabled for 'wifi_only' and network is not available`() { val autoSendSettingsProvider = setupAutoSendSettingProvider( - autoSendOption = "wifi_only", + autoSendOption = AutoSend.WIFI_ONLY.getValue(application), networkType = null ) @@ -61,8 +67,8 @@ class AutoSendSettingsProviderTest { @Test fun `return false when autosend is enabled for 'wifi_only' and network type is cellular`() { val autoSendSettingsProvider = setupAutoSendSettingProvider( - autoSendOption = "wifi_only", - networkType = ConnectivityManager.TYPE_MOBILE + autoSendOption = AutoSend.WIFI_ONLY.getValue(application), + networkType = Scheduler.NetworkType.CELLULAR ) assertFalse(autoSendSettingsProvider.isAutoSendEnabledInSettings(projectId)) @@ -71,8 +77,8 @@ class AutoSendSettingsProviderTest { @Test fun `return true when autosend is enabled for 'wifi_only' and network type is wifi`() { val autoSendSettingsProvider = setupAutoSendSettingProvider( - autoSendOption = "wifi_only", - networkType = ConnectivityManager.TYPE_WIFI + autoSendOption = AutoSend.WIFI_ONLY.getValue(application), + networkType = Scheduler.NetworkType.WIFI ) assertTrue(autoSendSettingsProvider.isAutoSendEnabledInSettings(projectId)) @@ -81,7 +87,7 @@ class AutoSendSettingsProviderTest { @Test fun `return false when autosend is enabled for 'cellular_only' and network is not available`() { val autoSendSettingsProvider = setupAutoSendSettingProvider( - autoSendOption = "cellular_only", + autoSendOption = AutoSend.CELLULAR_ONLY.getValue(application), networkType = null ) @@ -91,8 +97,8 @@ class AutoSendSettingsProviderTest { @Test fun `return false when autosend is enabled for 'cellular_only' and network type is wifi`() { val autoSendSettingsProvider = setupAutoSendSettingProvider( - autoSendOption = "cellular_only", - networkType = ConnectivityManager.TYPE_WIFI + autoSendOption = AutoSend.CELLULAR_ONLY.getValue(application), + networkType = Scheduler.NetworkType.WIFI ) assertFalse(autoSendSettingsProvider.isAutoSendEnabledInSettings(projectId)) @@ -101,8 +107,8 @@ class AutoSendSettingsProviderTest { @Test fun `return true when autosend is enabled for 'cellular_only' and network type is cellular`() { val autoSendSettingsProvider = setupAutoSendSettingProvider( - autoSendOption = "cellular_only", - networkType = ConnectivityManager.TYPE_MOBILE + autoSendOption = AutoSend.CELLULAR_ONLY.getValue(application), + networkType = Scheduler.NetworkType.CELLULAR ) assertTrue(autoSendSettingsProvider.isAutoSendEnabledInSettings(projectId)) @@ -111,7 +117,7 @@ class AutoSendSettingsProviderTest { @Test fun `return false when autosend is enabled for 'wifi_and_cellular' and network is not available`() { val autoSendSettingsProvider = setupAutoSendSettingProvider( - autoSendOption = "wifi_and_cellular", + autoSendOption = AutoSend.WIFI_AND_CELLULAR.getValue(application), networkType = null ) @@ -121,8 +127,8 @@ class AutoSendSettingsProviderTest { @Test fun `return true when autosend is enabled for 'wifi_and_cellular' and network type is wifi`() { val autoSendSettingsProvider = setupAutoSendSettingProvider( - autoSendOption = "wifi_and_cellular", - networkType = ConnectivityManager.TYPE_WIFI + autoSendOption = AutoSend.WIFI_AND_CELLULAR.getValue(application), + networkType = Scheduler.NetworkType.WIFI ) assertTrue(autoSendSettingsProvider.isAutoSendEnabledInSettings(projectId)) @@ -131,8 +137,8 @@ class AutoSendSettingsProviderTest { @Test fun `return true when autosend is enabled for 'wifi_and_cellular' and network type is cellular`() { val autoSendSettingsProvider = setupAutoSendSettingProvider( - autoSendOption = "wifi_and_cellular", - networkType = ConnectivityManager.TYPE_MOBILE + autoSendOption = AutoSend.WIFI_AND_CELLULAR.getValue(application), + networkType = Scheduler.NetworkType.CELLULAR ) assertTrue(autoSendSettingsProvider.isAutoSendEnabledInSettings(projectId)) @@ -140,17 +146,11 @@ class AutoSendSettingsProviderTest { private fun setupAutoSendSettingProvider( autoSendOption: String? = null, - networkType: Int? = null + networkType: Scheduler.NetworkType? = null ): AutoSendSettingsProvider { - var networkInfo: NetworkInfo? = null - networkType?.let { - networkInfo = mock().also { - whenever(it.type).thenReturn(networkType) - } - } - whenever(networkStateProvider.networkInfo).thenReturn(networkInfo) + whenever(networkStateProvider.currentNetwork).thenReturn(networkType) settingsProvider.getUnprotectedSettings(projectId).save(ProjectKeys.KEY_AUTOSEND, autoSendOption) - return AutoSendSettingsProvider(networkStateProvider, settingsProvider) + return AutoSendSettingsProvider(application, networkStateProvider, settingsProvider) } } diff --git a/collect_app/src/test/java/org/odk/collect/android/instancemanagement/autosend/FormExtTest.kt b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/autosend/FormExtTest.kt index e2cc86781ad..c3806db3bc1 100644 --- a/collect_app/src/test/java/org/odk/collect/android/instancemanagement/autosend/FormExtTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/autosend/FormExtTest.kt @@ -7,72 +7,74 @@ import org.odk.collect.formstest.FormFixtures class FormExtTest { @Test - fun `should return true when auto send is not set on a form level and enabled in settings`() { + fun `#shouldFormBeSentAutomatically returns true when auto send is not set on a form level and enabled in settings`() { val form = FormFixtures.form() val result = form.shouldFormBeSentAutomatically(true) assertThat(result, equalTo(true)) } @Test - fun `should return true when auto send is enabled on a form level and enabled in settings`() { + fun `#shouldFormBeSentAutomatically returns true when auto send is enabled on a form level and enabled in settings`() { val form = FormFixtures.form(autoSend = "true") val result = form.shouldFormBeSentAutomatically(true) assertThat(result, equalTo(true)) } @Test - fun `should return true when auto send is enabled on a form level but not sanitized and enabled in settings`() { - val form = FormFixtures.form(autoSend = " True ") - val result = form.shouldFormBeSentAutomatically(true) - assertThat(result, equalTo(true)) - } - - @Test - fun `should return true when auto send is set on a form level but with a wrong value and enabled in settings`() { - val form = FormFixtures.form(autoSend = "something") - val result = form.shouldFormBeSentAutomatically(true) - assertThat(result, equalTo(true)) - } - - @Test - fun `should return true when auto send is enabled on a form level and disabled in settings`() { + fun `#shouldFormBeSentAutomatically returns true when auto send is enabled on a form level and disabled in settings`() { val form = FormFixtures.form(autoSend = "true") val result = form.shouldFormBeSentAutomatically(false) assertThat(result, equalTo(true)) } @Test - fun `should return true when auto send is enabled on a form level but not sanitized and disabled in settings`() { - val form = FormFixtures.form(autoSend = " True ") - val result = form.shouldFormBeSentAutomatically(false) - assertThat(result, equalTo(true)) - } - - @Test - fun `should return false when auto send is not set on a form level and disabled in settings`() { + fun `#shouldFormBeSentAutomatically returns false when auto send is not set on a form level and disabled in settings`() { val form = FormFixtures.form() val result = form.shouldFormBeSentAutomatically(false) assertThat(result, equalTo(false)) } @Test - fun `should return false when auto send is disabled on a form level and disabled in settings`() { + fun `#shouldFormBeSentAutomatically returns false when auto send is disabled on a form level and disabled in settings`() { val form = FormFixtures.form(autoSend = "false") val result = form.shouldFormBeSentAutomatically(false) assertThat(result, equalTo(false)) } @Test - fun `should return false when auto send is disabled on a form level and enabled in settings`() { + fun `#shouldFormBeSentAutomatically returns false when auto send is disabled on a form level and enabled in settings`() { val form = FormFixtures.form(autoSend = "false") val result = form.shouldFormBeSentAutomatically(true) assertThat(result, equalTo(false)) } @Test - fun `should return false when auto send is set on a form level but with a wrong value and disabled in settings`() { - val form = FormFixtures.form(autoSend = "something") - val result = form.shouldFormBeSentAutomatically(false) - assertThat(result, equalTo(false)) + fun `#getAutoSendMode returns NEUTRAL when autoSend is unsupported`() { + val form = FormFixtures.form(autoSend = "blah") + assertThat(form.getAutoSendMode(), equalTo(FormAutoSendMode.NEUTRAL)) + } + + @Test + fun `#getAutoSendMode returns FORCED when autoSend is true but incorrectly cased`() { + val form = FormFixtures.form(autoSend = "TRUE") + assertThat(form.getAutoSendMode(), equalTo(FormAutoSendMode.FORCED)) + } + + @Test + fun `#getAutoSendMode returns FORCED when autoSend is true but with whitespace`() { + val form = FormFixtures.form(autoSend = " true ") + assertThat(form.getAutoSendMode(), equalTo(FormAutoSendMode.FORCED)) + } + + @Test + fun `#getAutoSendMode returns OPT_OUT when autoSend is false but incorrectly cased`() { + val form = FormFixtures.form(autoSend = "FALSE") + assertThat(form.getAutoSendMode(), equalTo(FormAutoSendMode.OPT_OUT)) + } + + @Test + fun `#getAutoSendMode returns OPT_OUT when autoSend is false but with whitespace`() { + val form = FormFixtures.form(autoSend = " false ") + assertThat(form.getAutoSendMode(), equalTo(FormAutoSendMode.OPT_OUT)) } } diff --git a/collect_app/src/test/java/org/odk/collect/android/instancemanagement/autosend/InstanceAutoSendFetcherTest.kt b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/autosend/InstanceAutoSendFetcherTest.kt index 590cf346816..3503d5ef101 100644 --- a/collect_app/src/test/java/org/odk/collect/android/instancemanagement/autosend/InstanceAutoSendFetcherTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/instancemanagement/autosend/InstanceAutoSendFetcherTest.kt @@ -1,23 +1,20 @@ package org.odk.collect.android.instancemanagement.autosend +import androidx.test.ext.junit.runners.AndroidJUnit4 import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.core.Is.`is` -import org.junit.Assert.assertTrue +import org.hamcrest.Matchers.contains import org.junit.Test -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever +import org.junit.runner.RunWith import org.odk.collect.forms.instances.Instance import org.odk.collect.formstest.FormUtils.buildForm import org.odk.collect.formstest.InMemFormsRepository import org.odk.collect.formstest.InMemInstancesRepository import org.odk.collect.formstest.InstanceUtils.buildInstance -import org.odk.collect.projects.Project import org.odk.collect.shared.TempFiles.createTempDir +@RunWith(AndroidJUnit4::class) class InstanceAutoSendFetcherTest { - private val autoSendSettingsProvider: AutoSendSettingsProvider = mock() - private val instanceAutoSendFetcher = InstanceAutoSendFetcher(autoSendSettingsProvider) - private val projectId = Project.DEMO_PROJECT_NAME + private val instancesRepository = InMemInstancesRepository() private val formsRepository = InMemFormsRepository() @@ -50,9 +47,7 @@ class InstanceAutoSendFetcherTest { private val instanceOfFormWithCustomAutoSendSubmitted = buildInstance("4", "1", "instance 4", Instance.STATUS_SUBMITTED, null, createTempDir().absolutePath).build() @Test - fun `when auto-send enabled in settings return all finalized instances of forms that do not have auto send disabled on a form level`() { - whenever(autoSendSettingsProvider.isAutoSendEnabledInSettings(projectId)).thenReturn(true) - + fun `return all finalized instances of forms that do not have auto send on a form level`() { formsRepository.save(formWithEnabledAutoSend) formsRepository.save(formWithoutSpecifiedAutoSend) formsRepository.save(formWithDisabledAutoSend) @@ -80,24 +75,24 @@ class InstanceAutoSendFetcherTest { save(instanceOfFormWithCustomAutoSendSubmitted) } - val instancesToSend = instanceAutoSendFetcher.getInstancesToAutoSend(projectId, instancesRepository, formsRepository) - - assertThat(instancesToSend.size, `is`(6)) - - assertTrue(instancesToSend.contains(instanceOfFormWithEnabledAutoSendComplete)) - assertTrue(instancesToSend.contains(instanceOfFormWithEnabledAutoSendSubmissionFailed)) - - assertTrue(instancesToSend.contains(instanceOfFormWithoutSpecifiedAutoSendComplete)) - assertTrue(instancesToSend.contains(instanceOfFormWithoutSpecifiedAutoSendSubmissionFailed)) - - assertTrue(instancesToSend.contains(instanceOfFormWithCustomAutoSendComplete)) - assertTrue(instancesToSend.contains(instanceOfFormWithCustomAutoSendSubmissionFailed)) + val instancesToSend = InstanceAutoSendFetcher.getInstancesToAutoSend( + instancesRepository, + formsRepository + ) + + assertThat( + instancesToSend.map { it.instanceFilePath }, + contains( + instanceOfFormWithoutSpecifiedAutoSendComplete.instanceFilePath, + instanceOfFormWithoutSpecifiedAutoSendSubmissionFailed.instanceFilePath, + instanceOfFormWithCustomAutoSendComplete.instanceFilePath, + instanceOfFormWithCustomAutoSendSubmissionFailed.instanceFilePath + ) + ) } @Test - fun `when auto-send disabled in settings return only those instances with auto-send enabled on a form level`() { - whenever(autoSendSettingsProvider.isAutoSendEnabledInSettings(projectId)).thenReturn(false) - + fun `return all finalized forms with autosend when formAutoSend is true`() { formsRepository.save(formWithEnabledAutoSend) formsRepository.save(formWithoutSpecifiedAutoSend) formsRepository.save(formWithDisabledAutoSend) @@ -125,32 +120,18 @@ class InstanceAutoSendFetcherTest { save(instanceOfFormWithCustomAutoSendSubmitted) } - val instancesToSend = instanceAutoSendFetcher.getInstancesToAutoSend(projectId, instancesRepository, formsRepository) - - assertThat(instancesToSend.size, `is`(2)) - assertTrue(instancesToSend.contains(instanceOfFormWithEnabledAutoSendComplete)) - assertTrue(instancesToSend.contains(instanceOfFormWithEnabledAutoSendSubmissionFailed)) - } - - @Test - fun `if there are multiple versions of one form and only one has auto-send enabled take only instances of that form`() { - val formWithEnabledAutoSendV1 = buildForm("1", "1", createTempDir().absolutePath, autosend = "false").build() - val instanceOfFormWithEnabledAutoSendCompleteV1 = buildInstance("1", "1", "instance 2", Instance.STATUS_COMPLETE, null, createTempDir().absolutePath).build() - - val formWithEnabledAutoSendV2 = buildForm("1", "2", createTempDir().absolutePath, autosend = "true").build() - val instanceOfFormWithEnabledAutoSendCompleteV2 = buildInstance("1", "2", "instance 2", Instance.STATUS_COMPLETE, null, createTempDir().absolutePath).build() - - formsRepository.save(formWithEnabledAutoSendV1) - formsRepository.save(formWithEnabledAutoSendV2) - - instancesRepository.apply { - save(instanceOfFormWithEnabledAutoSendCompleteV1) - save(instanceOfFormWithEnabledAutoSendCompleteV2) - } - - val instancesToSend = instanceAutoSendFetcher.getInstancesToAutoSend(projectId, instancesRepository, formsRepository) - - assertThat(instancesToSend.size, `is`(1)) - assertTrue(instancesToSend.contains(instanceOfFormWithEnabledAutoSendCompleteV2)) + val instancesToSend = InstanceAutoSendFetcher.getInstancesToAutoSend( + instancesRepository, + formsRepository, + forcedOnly = true + ) + + assertThat( + instancesToSend.map { it.instanceFilePath }, + contains( + instanceOfFormWithEnabledAutoSendComplete.instanceFilePath, + instanceOfFormWithEnabledAutoSendSubmissionFailed.instanceFilePath, + ) + ) } } diff --git a/collect_app/src/test/java/org/odk/collect/android/javarosawrapper/FakeFormController.java b/collect_app/src/test/java/org/odk/collect/android/javarosawrapper/FakeFormController.java index 614c141897f..1015352c0ca 100644 --- a/collect_app/src/test/java/org/odk/collect/android/javarosawrapper/FakeFormController.java +++ b/collect_app/src/test/java/org/odk/collect/android/javarosawrapper/FakeFormController.java @@ -113,7 +113,7 @@ public FormEntryPrompt getQuestionPrompt() { @NonNull @Override - public ValidationResult validateAnswers(boolean markCompleted, boolean moveToInvalidIndex) throws JavaRosaException { + public ValidationResult validateAnswers(boolean moveToInvalidIndex) throws JavaRosaException { if (validationError != null) { throw validationError; } else { diff --git a/collect_app/src/test/java/org/odk/collect/android/javarosawrapper/FormControllerTest.java b/collect_app/src/test/java/org/odk/collect/android/javarosawrapper/FormControllerTest.java index f7c950ff49e..7e6236b51c5 100644 --- a/collect_app/src/test/java/org/odk/collect/android/javarosawrapper/FormControllerTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/javarosawrapper/FormControllerTest.java @@ -33,7 +33,7 @@ public void validateAnswers_shouldNotChangeFormIndexToTheIndexOfInvalidQuestionI formController.stepToNextScreenEvent(); assertThat(formController.getFormIndex().toString(), equalTo("0, ")); - formController.validateAnswers(true, false); + formController.validateAnswers(false); assertThat(formController.getFormIndex().toString(), equalTo("0, ")); } @@ -48,7 +48,7 @@ public void validateAnswers_shouldChangeFormIndexToTheIndexOfInvalidQuestionIfAs formController.stepToNextScreenEvent(); assertThat(formController.getFormIndex().toString(), equalTo("0, ")); - formController.validateAnswers(true, true); + formController.validateAnswers(true); assertThat(formController.getFormIndex().toString(), equalTo("1, ")); } diff --git a/collect_app/src/test/java/org/odk/collect/android/mainmenu/MainMenuActivityTest.kt b/collect_app/src/test/java/org/odk/collect/android/mainmenu/MainMenuActivityTest.kt index aebce300dbc..e1a206b1292 100644 --- a/collect_app/src/test/java/org/odk/collect/android/mainmenu/MainMenuActivityTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/mainmenu/MainMenuActivityTest.kt @@ -25,14 +25,14 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import org.odk.collect.android.R import org.odk.collect.android.activities.CrashHandlerActivity -import org.odk.collect.android.activities.DeleteSavedFormActivity +import org.odk.collect.android.activities.DeleteFormsActivity import org.odk.collect.android.activities.FormDownloadListActivity import org.odk.collect.android.activities.InstanceChooserList import org.odk.collect.android.application.initialization.AnalyticsInitializer import org.odk.collect.android.fakes.FakePermissionsProvider import org.odk.collect.android.formlists.blankformlist.BlankFormListActivity -import org.odk.collect.android.formmanagement.InstancesDataService import org.odk.collect.android.injection.config.AppDependencyModule +import org.odk.collect.android.instancemanagement.InstancesDataService import org.odk.collect.android.instancemanagement.autosend.AutoSendSettingsProvider import org.odk.collect.android.instancemanagement.send.InstanceUploaderListActivity import org.odk.collect.android.projects.ProjectsDataService @@ -61,6 +61,7 @@ class MainMenuActivityTest { on { sendableInstancesCount } doReturn MutableLiveData(0) on { sentInstancesCount } doReturn MutableLiveData(0) on { editableInstancesCount } doReturn MutableLiveData(0) + on { savedForm } doReturn MutableLiveData() } private val currentProjectViewModel = mock { @@ -68,7 +69,7 @@ class MainMenuActivityTest { on { currentProject } doReturn MutableNonNullLiveData(project) } - private val permissionsViewModel = mock() { + private val permissionsViewModel = mock { on { shouldAskForPermissions() } doReturn false } @@ -102,7 +103,6 @@ class MainMenuActivityTest { instancesDataService, scheduler, projectsDataService, - analyticsInitializer, permissionChecker, formsRepositoryProvider, instancesRepositoryProvider, @@ -305,7 +305,7 @@ class MainMenuActivityTest { button.performClick() assertThat( Intents.getIntents()[0], - hasComponent(DeleteSavedFormActivity::class.java.name) + hasComponent(DeleteFormsActivity::class.java.name) ) Intents.release() diff --git a/collect_app/src/test/java/org/odk/collect/android/mainmenu/MainMenuViewModelTest.kt b/collect_app/src/test/java/org/odk/collect/android/mainmenu/MainMenuViewModelTest.kt index 80763e34b2b..07e9cc43536 100644 --- a/collect_app/src/test/java/org/odk/collect/android/mainmenu/MainMenuViewModelTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/mainmenu/MainMenuViewModelTest.kt @@ -1,18 +1,22 @@ package org.odk.collect.android.mainmenu +import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.ext.junit.runners.AndroidJUnit4 import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -import org.odk.collect.android.R import org.odk.collect.android.external.InstancesContract import org.odk.collect.android.instancemanagement.autosend.AutoSendSettingsProvider +import org.odk.collect.android.projects.ProjectsDataService import org.odk.collect.android.utilities.FormsRepositoryProvider import org.odk.collect.android.utilities.InstancesRepositoryProvider import org.odk.collect.android.version.VersionInformation +import org.odk.collect.androidtest.getOrAwaitValue import org.odk.collect.forms.instances.Instance import org.odk.collect.formstest.FormUtils import org.odk.collect.formstest.InMemFormsRepository @@ -21,6 +25,8 @@ import org.odk.collect.projects.Project import org.odk.collect.settings.InMemSettingsProvider import org.odk.collect.settings.keys.ProtectedProjectKeys import org.odk.collect.shared.TempFiles +import org.odk.collect.testshared.FakeScheduler +import java.util.concurrent.TimeoutException @RunWith(AndroidJUnit4::class) class MainMenuViewModelTest { @@ -35,6 +41,15 @@ class MainMenuViewModelTest { private val autoSendSettingsProvider = mock() private val settingsProvider = InMemSettingsProvider() + private val projectsDataService = mock { + on { getCurrentProject() } doReturn Project.DEMO_PROJECT + } + + private val scheduler = FakeScheduler() + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + @Test fun `version when beta release returns semantic version with prefix and beta version`() { val viewModel = createViewModelWithVersion("v1.23.0-beta.1") @@ -96,7 +111,7 @@ class MainMenuViewModelTest { } @Test - fun `getFormSavedSnackbarDetails should return proper message and action when the corresponding instance is saved as draft, editing drafts is enabled and encryption is disabled`() { + fun `setSavedForm should set proper message and action when the corresponding instance is saved as draft, editing drafts is enabled and encryption is disabled`() { val viewModel = createViewModelWithVersion("") settingsProvider.getProtectedSettings().save(ProtectedProjectKeys.KEY_EDIT_SAVED, true) @@ -111,13 +126,16 @@ class MainMenuViewModelTest { ) val uri = InstancesContract.getUri(Project.DEMO_PROJECT_ID, instance.dbId) - val formSavedSnackbarType = viewModel.getFormSavedSnackbarDetails(uri)!! - assertThat(formSavedSnackbarType.first, equalTo(org.odk.collect.strings.R.string.form_saved_as_draft)) - assertThat(formSavedSnackbarType.second, equalTo(org.odk.collect.strings.R.string.edit_form)) + viewModel.setSavedForm(uri) + scheduler.flush() + + val formSavedSnackbarType = viewModel.savedForm.getOrAwaitValue().value!! + assertThat(formSavedSnackbarType.message, equalTo(org.odk.collect.strings.R.string.form_saved_as_draft)) + assertThat(formSavedSnackbarType.action, equalTo(org.odk.collect.strings.R.string.edit_form)) } @Test - fun `getFormSavedSnackbarDetails should return proper message and action when the corresponding instance is saved as draft, editing drafts is enabled and encryption is enabled`() { + fun `setSavedForm should set proper message and action when the corresponding instance is saved as draft, editing drafts is enabled and encryption is enabled`() { val viewModel = createViewModelWithVersion("") settingsProvider.getProtectedSettings().save(ProtectedProjectKeys.KEY_EDIT_SAVED, true) @@ -133,13 +151,17 @@ class MainMenuViewModelTest { ) val uri = InstancesContract.getUri(Project.DEMO_PROJECT_ID, instance.dbId) - val formSavedSnackbarType = viewModel.getFormSavedSnackbarDetails(uri)!! - assertThat(formSavedSnackbarType.first, equalTo(org.odk.collect.strings.R.string.form_saved_as_draft)) - assertThat(formSavedSnackbarType.second, equalTo(org.odk.collect.strings.R.string.edit_form)) + + viewModel.setSavedForm(uri) + scheduler.flush() + + val formSavedSnackbarType = viewModel.savedForm.getOrAwaitValue().value!! + assertThat(formSavedSnackbarType.message, equalTo(org.odk.collect.strings.R.string.form_saved_as_draft)) + assertThat(formSavedSnackbarType.action, equalTo(org.odk.collect.strings.R.string.edit_form)) } @Test - fun `getFormSavedSnackbarDetails should return proper message and action when the corresponding instance is saved as draft, editing drafts is disabled and encryption is disabled`() { + fun `setSavedForm should set proper message and action when the corresponding instance is saved as draft, editing drafts is disabled and encryption is disabled`() { val viewModel = createViewModelWithVersion("") settingsProvider.getProtectedSettings().save(ProtectedProjectKeys.KEY_EDIT_SAVED, false) @@ -154,13 +176,17 @@ class MainMenuViewModelTest { ) val uri = InstancesContract.getUri(Project.DEMO_PROJECT_ID, instance.dbId) - val formSavedSnackbarType = viewModel.getFormSavedSnackbarDetails(uri)!! - assertThat(formSavedSnackbarType.first, equalTo(org.odk.collect.strings.R.string.form_saved_as_draft)) - assertThat(formSavedSnackbarType.second, equalTo(org.odk.collect.strings.R.string.view_form)) + + viewModel.setSavedForm(uri) + scheduler.flush() + + val formSavedSnackbarType = viewModel.savedForm.getOrAwaitValue().value!! + assertThat(formSavedSnackbarType.message, equalTo(org.odk.collect.strings.R.string.form_saved_as_draft)) + assertThat(formSavedSnackbarType.action, equalTo(org.odk.collect.strings.R.string.view_form)) } @Test - fun `getFormSavedSnackbarDetails should return proper message and action when the corresponding instance is saved as draft, editing drafts is disabled and encryption is enabled`() { + fun `setSavedForm should set proper message and action when the corresponding instance is saved as draft, editing drafts is disabled and encryption is enabled`() { val viewModel = createViewModelWithVersion("") settingsProvider.getProtectedSettings().save(ProtectedProjectKeys.KEY_EDIT_SAVED, false) @@ -176,13 +202,17 @@ class MainMenuViewModelTest { ) val uri = InstancesContract.getUri(Project.DEMO_PROJECT_ID, instance.dbId) - val formSavedSnackbarType = viewModel.getFormSavedSnackbarDetails(uri)!! - assertThat(formSavedSnackbarType.first, equalTo(org.odk.collect.strings.R.string.form_saved_as_draft)) - assertThat(formSavedSnackbarType.second, equalTo(org.odk.collect.strings.R.string.view_form)) + + viewModel.setSavedForm(uri) + scheduler.flush() + + val formSavedSnackbarType = viewModel.savedForm.getOrAwaitValue().value!! + assertThat(formSavedSnackbarType.message, equalTo(org.odk.collect.strings.R.string.form_saved_as_draft)) + assertThat(formSavedSnackbarType.action, equalTo(org.odk.collect.strings.R.string.view_form)) } @Test - fun `getFormSavedSnackbarDetails should return proper message and action when the corresponding instance is finalized, auto send is disabled and encryption is disabled`() { + fun `setSavedForm should set proper message and action when the corresponding instance is finalized, auto send is disabled and encryption is disabled`() { val viewModel = createViewModelWithVersion("") formsRepository.save(FormUtils.buildForm("1", "1", TempFiles.createTempDir().absolutePath).build()) @@ -197,13 +227,17 @@ class MainMenuViewModelTest { whenever(autoSendSettingsProvider.isAutoSendEnabledInSettings()).thenReturn(false) val uri = InstancesContract.getUri(Project.DEMO_PROJECT_ID, instance.dbId) - val formSavedSnackbarDetails = viewModel.getFormSavedSnackbarDetails(uri)!! - assertThat(formSavedSnackbarDetails.first, equalTo(org.odk.collect.strings.R.string.form_saved)) - assertThat(formSavedSnackbarDetails.second, equalTo(org.odk.collect.strings.R.string.view_form)) + + viewModel.setSavedForm(uri) + scheduler.flush() + + val formSavedSnackbarType = viewModel.savedForm.getOrAwaitValue().value!! + assertThat(formSavedSnackbarType.message, equalTo(org.odk.collect.strings.R.string.form_saved)) + assertThat(formSavedSnackbarType.action, equalTo(org.odk.collect.strings.R.string.view_form)) } @Test - fun `getFormSavedSnackbarDetails should return proper message and action when the corresponding instance is finalized, auto send is disabled and encryption is enabled`() { + fun `setSavedForm should set proper message and action when the corresponding instance is finalized, auto send is disabled and encryption is enabled`() { val viewModel = createViewModelWithVersion("") formsRepository.save(FormUtils.buildForm("1", "1", TempFiles.createTempDir().absolutePath).build()) @@ -219,13 +253,17 @@ class MainMenuViewModelTest { whenever(autoSendSettingsProvider.isAutoSendEnabledInSettings()).thenReturn(false) val uri = InstancesContract.getUri(Project.DEMO_PROJECT_ID, instance.dbId) - val formSavedSnackbarDetails = viewModel.getFormSavedSnackbarDetails(uri)!! - assertThat(formSavedSnackbarDetails.first, equalTo(org.odk.collect.strings.R.string.form_saved)) - assertThat(formSavedSnackbarDetails.second, equalTo(null)) + + viewModel.setSavedForm(uri) + scheduler.flush() + + val formSavedSnackbarType = viewModel.savedForm.getOrAwaitValue().value!! + assertThat(formSavedSnackbarType.message, equalTo(org.odk.collect.strings.R.string.form_saved)) + assertThat(formSavedSnackbarType.action, equalTo(null)) } @Test - fun `getFormSavedSnackbarDetails should return proper message and action when the corresponding instance is finalized, auto send is enabled and encryption is disabled`() { + fun `setSavedForm should set proper message and action when the corresponding instance is finalized, auto send is enabled and encryption is disabled`() { val viewModel = createViewModelWithVersion("") formsRepository.save(FormUtils.buildForm("1", "1", TempFiles.createTempDir().absolutePath).build()) @@ -241,13 +279,17 @@ class MainMenuViewModelTest { whenever(autoSendSettingsProvider.isAutoSendEnabledInSettings()).thenReturn(true) val uri = InstancesContract.getUri(Project.DEMO_PROJECT_ID, instance.dbId) - val formSavedSnackbarDetails = viewModel.getFormSavedSnackbarDetails(uri)!! - assertThat(formSavedSnackbarDetails.first, equalTo(org.odk.collect.strings.R.string.form_sending)) - assertThat(formSavedSnackbarDetails.second, equalTo(org.odk.collect.strings.R.string.view_form)) + + viewModel.setSavedForm(uri) + scheduler.flush() + + val formSavedSnackbarType = viewModel.savedForm.getOrAwaitValue().value!! + assertThat(formSavedSnackbarType.message, equalTo(org.odk.collect.strings.R.string.form_sending)) + assertThat(formSavedSnackbarType.action, equalTo(org.odk.collect.strings.R.string.view_form)) } @Test - fun `getFormSavedSnackbarDetails should return proper message and action when the corresponding instance is finalized, auto send is enabled and encryption is enabled`() { + fun `setSavedForm should set proper message and action when the corresponding instance is finalized, auto send is enabled and encryption is enabled`() { val viewModel = createViewModelWithVersion("") formsRepository.save(FormUtils.buildForm("1", "1", TempFiles.createTempDir().absolutePath).build()) @@ -264,13 +306,17 @@ class MainMenuViewModelTest { whenever(autoSendSettingsProvider.isAutoSendEnabledInSettings()).thenReturn(true) val uri = InstancesContract.getUri(Project.DEMO_PROJECT_ID, instance.dbId) - val formSavedSnackbarDetails = viewModel.getFormSavedSnackbarDetails(uri)!! - assertThat(formSavedSnackbarDetails.first, equalTo(org.odk.collect.strings.R.string.form_sending)) - assertThat(formSavedSnackbarDetails.second, equalTo(null)) + + viewModel.setSavedForm(uri) + scheduler.flush() + + val formSavedSnackbarType = viewModel.savedForm.getOrAwaitValue().value!! + assertThat(formSavedSnackbarType.message, equalTo(org.odk.collect.strings.R.string.form_sending)) + assertThat(formSavedSnackbarType.action, equalTo(null)) } - @Test - fun `getFormSavedSnackbarDetails should return null when the corresponding instance is already sent`() { + @Test(expected = TimeoutException::class) + fun `setSavedForm should not set when the corresponding instance is already sent`() { val viewModel = createViewModelWithVersion("") formsRepository.save(FormUtils.buildForm("1", "1", TempFiles.createTempDir().absolutePath).build()) @@ -286,12 +332,14 @@ class MainMenuViewModelTest { whenever(autoSendSettingsProvider.isAutoSendEnabledInSettings()).thenReturn(true) val uri = InstancesContract.getUri(Project.DEMO_PROJECT_ID, instance.dbId) - val formSavedSnackbarDetails = viewModel.getFormSavedSnackbarDetails(uri) - assertThat(formSavedSnackbarDetails, equalTo(null)) + viewModel.setSavedForm(uri) + scheduler.flush() + + viewModel.savedForm.getOrAwaitValue() } @Test - fun `getFormSavedSnackbarDetails should return proper message and action when the corresponding instance failed to sent`() { + fun `setSavedForm should set proper message and action when the corresponding instance failed to sent`() { val viewModel = createViewModelWithVersion("") formsRepository.save(FormUtils.buildForm("1", "1", TempFiles.createTempDir().absolutePath).build()) @@ -307,12 +355,16 @@ class MainMenuViewModelTest { whenever(autoSendSettingsProvider.isAutoSendEnabledInSettings()).thenReturn(true) val uri = InstancesContract.getUri(Project.DEMO_PROJECT_ID, instance.dbId) - val formSavedSnackbarDetails = viewModel.getFormSavedSnackbarDetails(uri)!! - assertThat(formSavedSnackbarDetails.first, equalTo(org.odk.collect.strings.R.string.form_sending)) - assertThat(formSavedSnackbarDetails.second, equalTo(org.odk.collect.strings.R.string.view_form)) + + viewModel.setSavedForm(uri) + scheduler.flush() + + val formSavedSnackbarType = viewModel.savedForm.getOrAwaitValue().value!! + assertThat(formSavedSnackbarType.message, equalTo(org.odk.collect.strings.R.string.form_sending)) + assertThat(formSavedSnackbarType.action, equalTo(org.odk.collect.strings.R.string.view_form)) } private fun createViewModelWithVersion(version: String): MainMenuViewModel { - return MainMenuViewModel(mock(), VersionInformation { version }, settingsProvider, mock(), mock(), formsRepositoryProvider, instancesRepositoryProvider, autoSendSettingsProvider) + return MainMenuViewModel(mock(), VersionInformation { version }, settingsProvider, mock(), scheduler, formsRepositoryProvider, instancesRepositoryProvider, autoSendSettingsProvider, projectsDataService) } } diff --git a/collect_app/src/test/java/org/odk/collect/android/mainmenu/PermissionsDialogFragmentTest.kt b/collect_app/src/test/java/org/odk/collect/android/mainmenu/PermissionsDialogFragmentTest.kt index 4d51bf9b661..51d1dd4f894 100644 --- a/collect_app/src/test/java/org/odk/collect/android/mainmenu/PermissionsDialogFragmentTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/mainmenu/PermissionsDialogFragmentTest.kt @@ -23,7 +23,7 @@ import org.odk.collect.fragmentstest.FragmentScenarioLauncherRule class PermissionsDialogFragmentTest { private val permissionsProvider = FakePermissionsProvider() - private val requestPermissionsViewModel = mock() { + private val requestPermissionsViewModel = mock { on { permissions } doReturn arrayOf("blah") on { shouldAskForPermissions() } doReturn true } diff --git a/collect_app/src/test/java/org/odk/collect/android/mainmenu/RequestPermissionsViewModelTest.kt b/collect_app/src/test/java/org/odk/collect/android/mainmenu/RequestPermissionsViewModelTest.kt index 535139bc3a3..1f0f1fecf24 100644 --- a/collect_app/src/test/java/org/odk/collect/android/mainmenu/RequestPermissionsViewModelTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/mainmenu/RequestPermissionsViewModelTest.kt @@ -18,7 +18,7 @@ import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) class RequestPermissionsViewModelTest { - private val permissionChecker = mock() { + private val permissionChecker = mock { on { isPermissionGranted(any()) } doReturn false } diff --git a/collect_app/src/test/java/org/odk/collect/android/openrosa/OpenRosaResponseParserImplTest.kt b/collect_app/src/test/java/org/odk/collect/android/openrosa/OpenRosaResponseParserImplTest.kt index fef35c8ead3..17359bc975c 100644 --- a/collect_app/src/test/java/org/odk/collect/android/openrosa/OpenRosaResponseParserImplTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/openrosa/OpenRosaResponseParserImplTest.kt @@ -71,4 +71,29 @@ class OpenRosaResponseParserImplTest { val formList = OpenRosaResponseParserImpl().parseManifest(Document()) assertThat(formList, equalTo(null)) } + + @Test + fun `parseManifest() sanitizes media file names`() { + val response = StringBuilder() + .appendLine("") + .appendLine("") + .appendLine("") + .appendLine("/../badgers.csv") + .appendLine("blah") + .appendLine("http://funk.appspot.com/binaryData?blobKey=%3A477e3") + .appendLine("") + .appendLine("") + .toString() + + val doc = StringReader(response).use { reader -> + val parser = KXmlParser() + parser.setInput(reader) + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true) + Document().also { it.parse(parser) } + } + + val mediaFiles = OpenRosaResponseParserImpl().parseManifest(doc)!! + assertThat(mediaFiles.size, equalTo(1)) + assertThat(mediaFiles[0].filename, equalTo("badgers.csv")) + } } diff --git a/collect_app/src/test/java/org/odk/collect/android/preferences/screens/FormManagementPreferencesFragmentTest.kt b/collect_app/src/test/java/org/odk/collect/android/preferences/screens/FormManagementPreferencesFragmentTest.kt index bd3efd3b1d8..bf04357f08a 100644 --- a/collect_app/src/test/java/org/odk/collect/android/preferences/screens/FormManagementPreferencesFragmentTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/preferences/screens/FormManagementPreferencesFragmentTest.kt @@ -24,12 +24,13 @@ import org.odk.collect.android.TestSettingsProvider import org.odk.collect.android.backgroundwork.InstanceSubmitScheduler import org.odk.collect.android.injection.config.AppDependencyModule import org.odk.collect.android.preferences.ProjectPreferencesViewModel -import org.odk.collect.android.preferences.utilities.FormUpdateMode import org.odk.collect.android.support.CollectHelpers import org.odk.collect.android.utilities.AdminPasswordProvider import org.odk.collect.async.Scheduler import org.odk.collect.fragmentstest.FragmentScenarioLauncherRule import org.odk.collect.settings.SettingsProvider +import org.odk.collect.settings.enums.AutoSend +import org.odk.collect.settings.enums.FormUpdateMode import org.odk.collect.settings.keys.ProjectKeys import org.odk.collect.settings.keys.ProtectedProjectKeys import org.odk.collect.shared.settings.Settings @@ -452,17 +453,17 @@ class FormManagementPreferencesFragmentTest { fun `When Auto send preference is enabled, finalized forms should be scheduled for submission`() { val scenario = launcherRule.launch(FormManagementPreferencesFragment::class.java) scenario.onFragment { fragment: FormManagementPreferencesFragment -> - fragment.findPreference(ProjectKeys.KEY_AUTOSEND)!!.value = "wifi" + fragment.findPreference(ProjectKeys.KEY_AUTOSEND)!!.value = AutoSend.WIFI_ONLY.getValue(context) } - verify(instanceSubmitScheduler).scheduleSubmit(projectID) + verify(instanceSubmitScheduler).scheduleAutoSend(projectID) } @Test fun `When Auto send preference is disabled, no submissions should be scheduled`() { val scenario = launcherRule.launch(FormManagementPreferencesFragment::class.java) scenario.onFragment { fragment: FormManagementPreferencesFragment -> - fragment.findPreference(ProjectKeys.KEY_AUTOSEND)!!.value = "off" + fragment.findPreference(ProjectKeys.KEY_AUTOSEND)!!.value = AutoSend.OFF.getValue(context) } - verify(instanceSubmitScheduler, never()).scheduleSubmit(projectID) + verify(instanceSubmitScheduler, never()).scheduleAutoSend(projectID) } } diff --git a/collect_app/src/test/java/org/odk/collect/android/preferences/screens/MainMenuAccessPreferencesTest.kt b/collect_app/src/test/java/org/odk/collect/android/preferences/screens/MainMenuAccessPreferencesTest.kt index 5bc34023b16..6812d78cf7d 100644 --- a/collect_app/src/test/java/org/odk/collect/android/preferences/screens/MainMenuAccessPreferencesTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/preferences/screens/MainMenuAccessPreferencesTest.kt @@ -11,9 +11,9 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.odk.collect.android.TestSettingsProvider -import org.odk.collect.android.preferences.utilities.FormUpdateMode import org.odk.collect.android.support.CollectHelpers import org.odk.collect.fragmentstest.FragmentScenarioLauncherRule +import org.odk.collect.settings.enums.FormUpdateMode import org.odk.collect.settings.keys.ProjectKeys import org.odk.collect.settings.keys.ProtectedProjectKeys import org.odk.collect.shared.settings.Settings diff --git a/collect_app/src/test/java/org/odk/collect/android/preferences/screens/MapsPreferencesFragmentTest.kt b/collect_app/src/test/java/org/odk/collect/android/preferences/screens/MapsPreferencesFragmentTest.kt new file mode 100644 index 00000000000..3a345d4b137 --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/preferences/screens/MapsPreferencesFragmentTest.kt @@ -0,0 +1,108 @@ +package org.odk.collect.android.preferences.screens + +import android.content.Context +import androidx.preference.Preference +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.gson.Gson +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.odk.collect.android.application.initialization.AnalyticsInitializer +import org.odk.collect.android.application.initialization.MapsInitializer +import org.odk.collect.android.injection.config.AppDependencyModule +import org.odk.collect.android.projects.ProjectsDataService +import org.odk.collect.android.storage.StoragePathProvider +import org.odk.collect.android.support.CollectHelpers +import org.odk.collect.fragmentstest.FragmentScenarioLauncherRule +import org.odk.collect.maps.layers.ReferenceLayer +import org.odk.collect.maps.layers.ReferenceLayerRepository +import org.odk.collect.projects.Project +import org.odk.collect.projects.ProjectsRepository +import org.odk.collect.settings.InMemSettingsProvider +import org.odk.collect.settings.SettingsProvider +import org.odk.collect.settings.keys.ProjectKeys +import org.odk.collect.shared.TempFiles +import org.odk.collect.shared.strings.UUIDGenerator + +@RunWith(AndroidJUnit4::class) +class MapsPreferencesFragmentTest { + + @get:Rule + val launcherRule = FragmentScenarioLauncherRule() + + private val project = Project.DEMO_PROJECT + private val projectsDataService = mock().apply { + whenever(getCurrentProject()).thenReturn(project) + } + private val projectsRepository = mock().apply { + whenever(get(project.uuid)).thenReturn(project) + } + private val referenceLayerRepository = mock() + private val settingsProvider = InMemSettingsProvider() + + @Before + fun setup() { + CollectHelpers.overrideAppDependencyModule(object : AppDependencyModule() { + override fun providesCurrentProjectProvider( + settingsProvider: SettingsProvider, + projectsRepository: ProjectsRepository, + analyticsInitializer: AnalyticsInitializer, + context: Context, + mapsInitializer: MapsInitializer + ): ProjectsDataService { + return projectsDataService + } + + override fun providesProjectsRepository( + uuidGenerator: UUIDGenerator, + gson: Gson, + settingsProvider: SettingsProvider + ): ProjectsRepository { + return projectsRepository + } + + override fun providesSettingsProvider(context: Context): SettingsProvider { + return settingsProvider + } + + override fun providesReferenceLayerRepository( + storagePathProvider: StoragePathProvider, + settingsProvider: SettingsProvider + ): ReferenceLayerRepository { + return referenceLayerRepository + } + }) + } + + @Test + fun `if saved layer does not exist it is set to 'none'`() { + val settings = settingsProvider.getUnprotectedSettings() + settings.save(ProjectKeys.KEY_REFERENCE_LAYER, "blah") + + launcherRule.launch(MapsPreferencesFragment::class.java) + + assertThat(settings.getString(ProjectKeys.KEY_REFERENCE_LAYER), equalTo(null)) + } + + @Test + fun `if saved layer exist its name is displayed`() { + val settings = settingsProvider.getUnprotectedSettings() + settings.save(ProjectKeys.KEY_REFERENCE_LAYER, "blah") + val layer = ReferenceLayer("blah", TempFiles.createTempFile(), "blah") + whenever(referenceLayerRepository.get("blah")).thenReturn(layer) + + val scenario = launcherRule.launch(MapsPreferencesFragment::class.java) + + scenario.onFragment { + assertThat( + it.findPreference(ProjectKeys.KEY_REFERENCE_LAYER)!!.summary, + equalTo("blah") + ) + } + } +} diff --git a/collect_app/src/test/java/org/odk/collect/android/preferences/screens/ProjectDisplayPreferencesFragmentTest.kt b/collect_app/src/test/java/org/odk/collect/android/preferences/screens/ProjectDisplayPreferencesFragmentTest.kt index 3bd5c86306c..dd7d779d21a 100644 --- a/collect_app/src/test/java/org/odk/collect/android/preferences/screens/ProjectDisplayPreferencesFragmentTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/preferences/screens/ProjectDisplayPreferencesFragmentTest.kt @@ -116,7 +116,6 @@ class ProjectDisplayPreferencesFragmentTest { assertThat( it.findPreference(ProjectDisplayPreferencesFragment.PROJECT_ICON_KEY)!!.title, `is`( - ApplicationProvider.getApplicationContext().getLocalizedString( org.odk.collect.strings.R.string.project_icon ) diff --git a/collect_app/src/test/java/org/odk/collect/android/preferences/screens/ReferenceLayerPreferenceUtilsTest.kt b/collect_app/src/test/java/org/odk/collect/android/preferences/screens/ReferenceLayerPreferenceUtilsTest.kt deleted file mode 100644 index 5e7b82c5a32..00000000000 --- a/collect_app/src/test/java/org/odk/collect/android/preferences/screens/ReferenceLayerPreferenceUtilsTest.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.odk.collect.android.preferences.screens - -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify -import org.odk.collect.android.preferences.CaptionedListPreference -import org.odk.collect.maps.layers.DirectoryReferenceLayerRepository -import org.odk.collect.shared.TempFiles - -@RunWith(AndroidJUnit4::class) -class ReferenceLayerPreferenceUtilsTest { - - @Test - fun populateReferenceLayerPref_whenPrefValueNotInReferenceLayers_clearsPref() { - val context = ApplicationProvider.getApplicationContext() - val tempDirPath = TempFiles.createTempDir().absolutePath - val referenceLayerRepository = DirectoryReferenceLayerRepository(tempDirPath) - - // Use mock to avoid explosions constructing pref in Robolectric - val pref = mock { - on { getValue() } doReturn "something" - } - - ReferenceLayerPreferenceUtils.populateReferenceLayerPref( - context, - referenceLayerRepository, - pref - ) - - verify(pref).setValue(null) - } -} diff --git a/collect_app/src/test/java/org/odk/collect/android/utilities/ProjectResetterTest.kt b/collect_app/src/test/java/org/odk/collect/android/projects/ProjectResetterTest.kt similarity index 87% rename from collect_app/src/test/java/org/odk/collect/android/utilities/ProjectResetterTest.kt rename to collect_app/src/test/java/org/odk/collect/android/projects/ProjectResetterTest.kt index 8ab10367b70..f68bddc62d4 100644 --- a/collect_app/src/test/java/org/odk/collect/android/utilities/ProjectResetterTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/projects/ProjectResetterTest.kt @@ -1,4 +1,4 @@ -package org.odk.collect.android.utilities +package org.odk.collect.android.projects import android.app.Application import android.content.Context @@ -14,14 +14,20 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import org.odk.collect.android.injection.DaggerUtils import org.odk.collect.android.injection.config.AppDependencyModule import org.odk.collect.android.preferences.Defaults import org.odk.collect.android.storage.StoragePathProvider import org.odk.collect.android.storage.StorageSubdirectory import org.odk.collect.android.support.CollectHelpers +import org.odk.collect.android.utilities.ChangeLockProvider +import org.odk.collect.android.utilities.FormsRepositoryProvider +import org.odk.collect.android.utilities.InstancesRepositoryProvider +import org.odk.collect.android.utilities.SavepointsRepositoryProvider import org.odk.collect.forms.Form import org.odk.collect.forms.instances.Instance +import org.odk.collect.forms.savepoints.Savepoint import org.odk.collect.metadata.InstallIDProvider import org.odk.collect.metadata.PropertyManager import org.odk.collect.projects.Project @@ -29,6 +35,7 @@ import org.odk.collect.settings.SettingsProvider import org.odk.collect.settings.keys.ProjectKeys import org.odk.collect.settings.keys.ProtectedProjectKeys import org.odk.collect.shared.settings.Settings +import org.odk.collect.testshared.BooleanChangeLock import java.io.File @RunWith(AndroidJUnit4::class) @@ -38,10 +45,13 @@ class ProjectResetterTest { private lateinit var settingsProvider: SettingsProvider private lateinit var formsRepositoryProvider: FormsRepositoryProvider private lateinit var instancesRepositoryProvider: InstancesRepositoryProvider + private lateinit var savepointsRepositoryProvider: SavepointsRepositoryProvider private lateinit var currentProjectId: String private lateinit var anotherProjectId: String private val propertyManager = mock() + private val changeLockProvider = mock() + private val changeLock = BooleanChangeLock() @Before fun setup() { @@ -52,6 +62,10 @@ class ProjectResetterTest { ): PropertyManager { return propertyManager } + + override fun providesChangeLockProvider(): ChangeLockProvider { + return changeLockProvider + } }) currentProjectId = CollectHelpers.setupDemoProject() @@ -63,6 +77,9 @@ class ProjectResetterTest { settingsProvider = component.settingsProvider() formsRepositoryProvider = component.formsRepositoryProvider() instancesRepositoryProvider = component.instancesRepositoryProvider() + savepointsRepositoryProvider = component.savepointsRepositoryProvider() + + whenever(changeLockProvider.getInstanceLock(currentProjectId)).thenReturn(changeLock) } @Test @@ -184,6 +201,19 @@ class ProjectResetterTest { assertTrue(File(storagePathProvider.getOdkDirPath(StorageSubdirectory.METADATA, anotherProjectId) + "/itemsets.db").exists()) } + @Test + fun `Reset instances does not clear instances if the instances database is locked`() { + saveTestInstanceFiles(currentProjectId) + setupTestInstancesDatabase(currentProjectId) + + changeLock.lock() + val failedResetActions = projectResetter.reset(listOf(ProjectResetter.ResetAction.RESET_INSTANCES)) + assertEquals(1, failedResetActions.size) + + assertEquals(1, instancesRepositoryProvider.get(currentProjectId).all.size) + assertTestInstanceFiles(currentProjectId) + } + @Test fun `Reset instances clears instances for current project`() { saveTestInstanceFiles(currentProjectId) @@ -225,20 +255,24 @@ class ProjectResetterTest { } @Test - fun `Reset cache clears cache for project`() { + fun `Reset cache clears cache and savepoints db for current project`() { + setupTestSavepointsDatabase(currentProjectId) saveTestCacheFiles(currentProjectId) resetAppState(listOf(ProjectResetter.ResetAction.RESET_CACHE)) + assertEquals(0, savepointsRepositoryProvider.get(currentProjectId).getAll().size) assertFolderEmpty(storagePathProvider.getOdkDirPath(StorageSubdirectory.CACHE, currentProjectId)) } @Test - fun `Reset cache does not clear cache for another projects`() { + fun `Reset cache does not clear cache and savepoints db for another projects`() { + setupTestSavepointsDatabase(anotherProjectId) saveTestCacheFiles(anotherProjectId) resetAppState(listOf(ProjectResetter.ResetAction.RESET_CACHE)) + assertEquals(1, savepointsRepositoryProvider.get(anotherProjectId).getAll().size) assertTestCacheFiles(anotherProjectId) } @@ -290,6 +324,13 @@ class ProjectResetterTest { assertEquals(1, instancesRepositoryProvider.get(uuid).all.size) } + private fun setupTestSavepointsDatabase(uuid: String) { + SavepointsRepositoryProvider(ApplicationProvider.getApplicationContext(), storagePathProvider).get(uuid).save( + Savepoint(1, 1, "blah", "blah") + ) + assertEquals(1, savepointsRepositoryProvider.get(uuid).getAll().size) + } + private fun createTestItemsetsDatabaseFile(uuid: String) { assertTrue(File(storagePathProvider.getOdkDirPath(StorageSubdirectory.METADATA, uuid) + "/itemsets.db").createNewFile()) } diff --git a/collect_app/src/test/java/org/odk/collect/android/savepoints/SavepointUseCasesTest.kt b/collect_app/src/test/java/org/odk/collect/android/savepoints/SavepointUseCasesTest.kt new file mode 100644 index 00000000000..0cb95a05d46 --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/savepoints/SavepointUseCasesTest.kt @@ -0,0 +1,316 @@ +package org.odk.collect.android.savepoints + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.Test +import org.junit.runner.RunWith +import org.odk.collect.android.external.FormsContract +import org.odk.collect.android.external.InstancesContract +import org.odk.collect.forms.Form +import org.odk.collect.forms.instances.Instance +import org.odk.collect.forms.savepoints.Savepoint +import org.odk.collect.formstest.FormUtils +import org.odk.collect.formstest.InMemFormsRepository +import org.odk.collect.formstest.InMemInstancesRepository +import org.odk.collect.formstest.InMemSavepointsRepository +import org.odk.collect.shared.TempFiles +import java.io.File + +@RunWith(AndroidJUnit4::class) +class SavepointUseCasesTest { + private var formV1: Form + private var formV2: Form + + private var instance1: Instance + private var instance2: Instance + + private val formsRepository = InMemFormsRepository().apply { + formV1 = save( + FormUtils.buildForm( + "1", + "1", + TempFiles.createTempDir().absolutePath + ).build() + ) + + Thread.sleep(100) // make sure the two forms have different creation dates + + formV2 = save( + FormUtils.buildForm( + "1", + "2", + TempFiles.createTempDir().absolutePath + ).build() + ) + } + private val instancesRepository = InMemInstancesRepository().apply { + instance1 = save( + Instance.Builder() + .formId("1") + .formVersion("2") + .instanceFilePath(TempFiles.createTempFile(TempFiles.createTempDir()).absolutePath) + .status(Instance.STATUS_INCOMPLETE) + .build() + ) + + Thread.sleep(100) // make sure the two instances have different creation dates + + instance2 = save( + Instance.Builder() + .formId("1") + .formVersion("2") + .instanceFilePath(TempFiles.createTempFile(TempFiles.createTempDir()).absolutePath) + .status(Instance.STATUS_INCOMPLETE) + .build() + ) + } + private val savepointsRepository = InMemSavepointsRepository() + + @Test + fun `getSavepoint called with the old form version uri returns null if only the new form version has a savepoint`() { + val savepointFile = createSavepointFile() + val savepoint = Savepoint(formV2.dbId, null, savepointFile.absolutePath, "") + savepointsRepository.save(savepoint) + + if (savepointsRepository.getAll().size > 1) { + throw(Error("WTF?")) + } + + assertThat( + SavepointUseCases.getSavepoint( + FormsContract.getUri("1", formV1.dbId), + FormsContract.CONTENT_ITEM_TYPE, + formsRepository, + instancesRepository, + savepointsRepository + ), + equalTo(null) + ) + } + + @Test + fun `getSavepoint called with the old form version uri returns savepoint if it has one`() { + val savepointFile = createSavepointFile() + val savepoint = Savepoint(formV1.dbId, null, savepointFile.absolutePath, "") + savepointsRepository.save(savepoint) + + assertThat( + SavepointUseCases.getSavepoint( + FormsContract.getUri("1", formV1.dbId), + FormsContract.CONTENT_ITEM_TYPE, + formsRepository, + instancesRepository, + savepointsRepository + ), + equalTo(savepoint) + ) + } + + @Test + fun `getSavepoint called with the old form version uri returns savepoint that belongs to the old form if both form versions have ones`() { + val savepointFile1 = createSavepointFile() + val savepoint1 = Savepoint(formV1.dbId, null, savepointFile1.absolutePath, "") + savepointsRepository.save(savepoint1) + + val savepointFile2 = createSavepointFile() + val savepoint2 = Savepoint(formV2.dbId, null, savepointFile2.absolutePath, "") + savepointsRepository.save(savepoint2) + + assertThat( + SavepointUseCases.getSavepoint( + FormsContract.getUri("1", formV1.dbId), + FormsContract.CONTENT_ITEM_TYPE, + formsRepository, + instancesRepository, + savepointsRepository + ), + equalTo(savepoint1) + ) + } + + @Test + fun `getSavepoint called with the new form version uri returns savepoint if it has one`() { + val savepointFile = createSavepointFile() + val savepoint = Savepoint(formV2.dbId, null, savepointFile.absolutePath, "") + savepointsRepository.save(savepoint) + + assertThat( + SavepointUseCases.getSavepoint( + FormsContract.getUri("1", formV2.dbId), + FormsContract.CONTENT_ITEM_TYPE, + formsRepository, + instancesRepository, + savepointsRepository + ), + equalTo(savepoint) + ) + } + + @Test + fun `getSavepoint called with the new form version uri returns savepoint that belongs to the old form if only the old form has one`() { + val savepointFile = createSavepointFile() + val savepoint = Savepoint(formV1.dbId, null, savepointFile.absolutePath, "") + savepointsRepository.save(savepoint) + + assertThat( + SavepointUseCases.getSavepoint( + FormsContract.getUri("1", formV2.dbId), + FormsContract.CONTENT_ITEM_TYPE, + formsRepository, + instancesRepository, + savepointsRepository + ), + equalTo(savepoint) + ) + } + + @Test + fun `getSavepoint called with the new form version uri returns savepoint that belongs to the new form if both form versions have ones`() { + val savepointFile1 = createSavepointFile() + val savepoint1 = Savepoint(formV1.dbId, null, savepointFile1.absolutePath, "") + savepointsRepository.save(savepoint1) + + val savepointFile2 = createSavepointFile() + val savepoint2 = Savepoint(formV2.dbId, null, savepointFile2.absolutePath, "") + savepointsRepository.save(savepoint2) + + assertThat( + SavepointUseCases.getSavepoint( + FormsContract.getUri("1", formV2.dbId), + FormsContract.CONTENT_ITEM_TYPE, + formsRepository, + instancesRepository, + savepointsRepository + ), + equalTo(savepoint2) + ) + } + + @Test + fun `getSavepoint returns null if savepoint exists in the database but the file does not`() { + val savepointFile = createSavepointFile() + val savepoint = Savepoint(formV2.dbId, null, savepointFile.absolutePath, "") + savepointsRepository.save(savepoint) + savepointFile.delete() + + assertThat( + SavepointUseCases.getSavepoint( + FormsContract.getUri("1", formV2.dbId), + FormsContract.CONTENT_ITEM_TYPE, + formsRepository, + instancesRepository, + savepointsRepository + ), + equalTo(null) + ) + } + + @Test + fun `getSavepoint called for a saved form uri returns null if only its blank form has a savepoint`() { + val savepointFile = createSavepointFile() + val savepoint = Savepoint(formV2.dbId, null, savepointFile.absolutePath, instance1.instanceFilePath) + savepointsRepository.save(savepoint) + + assertThat( + SavepointUseCases.getSavepoint( + InstancesContract.getUri("1", instance1.dbId), + InstancesContract.CONTENT_ITEM_TYPE, + formsRepository, + instancesRepository, + savepointsRepository + ), + equalTo(null) + ) + } + + @Test + fun `getSavepoint called for a saved form uri returns savepoint if it has one`() { + val savepointFile = createSavepointFile() + val savepoint = Savepoint(formV2.dbId, instance1.dbId, savepointFile.absolutePath, instance1.instanceFilePath) + savepointsRepository.save(savepoint) + + assertThat( + SavepointUseCases.getSavepoint( + InstancesContract.getUri("1", instance1.dbId), + InstancesContract.CONTENT_ITEM_TYPE, + formsRepository, + instancesRepository, + savepointsRepository + ), + equalTo(savepoint) + ) + } + + @Test + fun `getSavepoint called for a saved form uri returns null if savepoint exists in the database but the file does not`() { + val savepointFile = createSavepointFile() + val savepoint = Savepoint(formV2.dbId, instance1.dbId, savepointFile.absolutePath, instance1.instanceFilePath) + savepointsRepository.save(savepoint) + savepointFile.delete() + + assertThat( + SavepointUseCases.getSavepoint( + InstancesContract.getUri("1", instance1.dbId), + InstancesContract.CONTENT_ITEM_TYPE, + formsRepository, + instancesRepository, + savepointsRepository + ), + equalTo(null) + ) + } + + @Test + fun `getSavepoint called for a saved form uri returns null if it has one but the instance file has been modified later`() { + val savepointFile = createSavepointFile() + val savepoint = Savepoint(formV2.dbId, instance1.dbId, savepointFile.absolutePath, instance1.instanceFilePath) + savepointsRepository.save(savepoint) + + instancesRepository.save(instance1) + + assertThat( + SavepointUseCases.getSavepoint( + InstancesContract.getUri("1", instance1.dbId), + InstancesContract.CONTENT_ITEM_TYPE, + formsRepository, + instancesRepository, + savepointsRepository + ), + equalTo(null) + ) + } + + @Test + fun `getSavepoint called for a saved form uri returns savepoint that belongs to that saved form if there are more savepoints in the database created for saved forms`() { + val savepointFile1 = createSavepointFile() + val savepoint1 = Savepoint(formV2.dbId, instance1.dbId, savepointFile1.absolutePath, instance1.instanceFilePath) + savepointsRepository.save(savepoint1) + + val savepointFile2 = createSavepointFile() + val savepoint2 = Savepoint(formV2.dbId, instance2.dbId, savepointFile2.absolutePath, instance2.instanceFilePath) + savepointsRepository.save(savepoint2) + + assertThat( + SavepointUseCases.getSavepoint( + InstancesContract.getUri("1", instance1.dbId), + InstancesContract.CONTENT_ITEM_TYPE, + formsRepository, + instancesRepository, + savepointsRepository + ), + equalTo(savepoint1) + ) + } + + /** + * Tests seem to run fast enough so that sometimes two files created in succession end up with + * the same creation date. This isn't ideal as we depend on distinguishing the older files. + */ + private fun createSavepointFile(): File { + Thread.sleep(100) + val savepointFile = TempFiles.createTempFile() + Thread.sleep(100) + return savepointFile + } +} diff --git a/collect_app/src/test/java/org/odk/collect/android/support/MockFormEntryPromptBuilder.java b/collect_app/src/test/java/org/odk/collect/android/support/MockFormEntryPromptBuilder.java index 0d9ca289679..635e9fe97cb 100644 --- a/collect_app/src/test/java/org/odk/collect/android/support/MockFormEntryPromptBuilder.java +++ b/collect_app/src/test/java/org/odk/collect/android/support/MockFormEntryPromptBuilder.java @@ -92,6 +92,11 @@ public MockFormEntryPromptBuilder withControlType(int controlType) { return this; } + public MockFormEntryPromptBuilder withDataType(int dataType) { + when(prompt.getDataType()).thenReturn(dataType); + return this; + } + public FormEntryPrompt build() { return prompt; } diff --git a/collect_app/src/test/java/org/odk/collect/android/utilities/AppearancesTest.kt b/collect_app/src/test/java/org/odk/collect/android/utilities/AppearancesTest.kt index 1ae5776c013..017504a632c 100644 --- a/collect_app/src/test/java/org/odk/collect/android/utilities/AppearancesTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/utilities/AppearancesTest.kt @@ -19,6 +19,7 @@ import android.content.res.Configuration import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertFalse import junit.framework.TestCase.assertTrue +import org.javarosa.core.model.Constants import org.javarosa.form.api.FormEntryPrompt import org.junit.Test import org.mockito.kotlin.mock @@ -232,6 +233,13 @@ class AppearancesTest { assertFalse(Appearances.useThousandSeparator(formEntryPrompt)) } + @Test + fun `useThousandSeparator returns false when 'thousands-sep' appearance is found but mixed with 'masked' for text questions`() { + whenever(formEntryPrompt.dataType).thenReturn(Constants.DATATYPE_TEXT) + whenever(formEntryPrompt.appearanceHint).thenReturn("thousands-sep masked") + assertFalse(Appearances.useThousandSeparator(formEntryPrompt)) + } + @Test fun `useThousandSeparator returns true when 'thousands-sep' appearance is found`() { whenever(formEntryPrompt.appearanceHint).thenReturn("THOUSANDS-SEP") @@ -360,4 +368,44 @@ class AppearancesTest { whenever(formEntryPrompt.appearanceHint).thenReturn("search") assertTrue(Appearances.isAutocomplete(formEntryPrompt)) } + + @Test + fun `isMasked returns false when there is no appearance`() { + whenever(formEntryPrompt.dataType).thenReturn(Constants.DATATYPE_TEXT) + assertFalse(Appearances.isMasked(formEntryPrompt)) + + whenever(formEntryPrompt.appearanceHint).thenReturn("") + assertFalse(Appearances.isMasked(formEntryPrompt)) + } + + @Test + fun `isMasked returns false when non of supported appearances is found`() { + whenever(formEntryPrompt.dataType).thenReturn(Constants.DATATYPE_TEXT) + whenever(formEntryPrompt.appearanceHint).thenReturn("blah") + assertFalse(Appearances.isMasked(formEntryPrompt)) + } + + @Test + fun `isMasked returns true when 'masked' appearance is found for text questions`() { + whenever(formEntryPrompt.dataType).thenReturn(Constants.DATATYPE_TEXT) + whenever(formEntryPrompt.appearanceHint).thenReturn("masked") + assertTrue(Appearances.isMasked(formEntryPrompt)) + } + + @Test + fun `isMasked returns false when 'masked' appearance is found for numeric questions`() { + whenever(formEntryPrompt.dataType).thenReturn(Constants.DATATYPE_INTEGER) + whenever(formEntryPrompt.appearanceHint).thenReturn("masked") + assertFalse(Appearances.isMasked(formEntryPrompt)) + + whenever(formEntryPrompt.dataType).thenReturn(Constants.DATATYPE_DECIMAL) + assertFalse(Appearances.isMasked(formEntryPrompt)) + } + + @Test + fun `isMasked returns false when 'masked' appearance is found for text questions with 'numbers' appearance`() { + whenever(formEntryPrompt.dataType).thenReturn(Constants.DATATYPE_TEXT) + whenever(formEntryPrompt.appearanceHint).thenReturn("masked numbers") + assertFalse(Appearances.isMasked(formEntryPrompt)) + } } diff --git a/collect_app/src/test/java/org/odk/collect/android/utilities/DaylightSavingTest.java b/collect_app/src/test/java/org/odk/collect/android/utilities/DaylightSavingTest.java index 00ecb791b71..c9d16ac594e 100644 --- a/collect_app/src/test/java/org/odk/collect/android/utilities/DaylightSavingTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/utilities/DaylightSavingTest.java @@ -112,7 +112,7 @@ private DateWidget prepareDateWidget(int year, int month, int day) { when(iformElementStub.getAdditionalAttribute(anyString(), anyString())).thenReturn(null); when(formEntryPromptStub.getQuestion()).thenReturn(questionDefStub); when(formEntryPromptStub.getFormElement()).thenReturn(iformElementStub); - when(formEntryPromptStub.getQuestion().getAppearanceAttr()).thenReturn("no-calendar"); + when(formEntryPromptStub.getAppearanceHint()).thenReturn("no-calendar"); DatePickerDialog datePickerDialog = mock(DatePickerDialog.class); DatePicker datePicker = mock(DatePicker.class); @@ -135,7 +135,7 @@ private DateTimeWidget prepareDateTimeWidget(int year, int month, int day, int h when(iformElementStub.getAdditionalAttribute(anyString(), anyString())).thenReturn(null); when(formEntryPromptStub.getQuestion()).thenReturn(questionDefStub); when(formEntryPromptStub.getFormElement()).thenReturn(iformElementStub); - when(formEntryPromptStub.getQuestion().getAppearanceAttr()).thenReturn("no-calendar"); + when(formEntryPromptStub.getAppearanceHint()).thenReturn("no-calendar"); DateTimeWidget dateTimeWidget = new DateTimeWidget(widgetActivity, new QuestionDetails(formEntryPromptStub), widgetUtils, null); dateTimeWidget.setData(new LocalDateTime().withDate(year, month, day)); diff --git a/collect_app/src/test/java/org/odk/collect/android/utilities/ExternalAppIntentProviderTest.kt b/collect_app/src/test/java/org/odk/collect/android/utilities/ExternalAppIntentProviderTest.kt index 7b0c3c4fa47..765725f7c40 100644 --- a/collect_app/src/test/java/org/odk/collect/android/utilities/ExternalAppIntentProviderTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/utilities/ExternalAppIntentProviderTest.kt @@ -8,8 +8,8 @@ import org.javarosa.form.api.FormEntryPrompt import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mockito.`when` import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) class ExternalAppIntentProviderTest { @@ -20,26 +20,33 @@ class ExternalAppIntentProviderTest { fun setup() { formEntryPrompt = mock() externalAppIntentProvider = ExternalAppIntentProvider() - `when`(formEntryPrompt.index).thenReturn(mock()) + whenever(formEntryPrompt.index).thenReturn(mock()) } @Test - fun intentAction_shouldBeSetProperly() { - `when`(formEntryPrompt.appearanceHint).thenReturn("ex:com.example.collectanswersprovider()") + fun intentAction_shouldBeSetProperlyIfThePackageNameEndsWithBrackets() { + whenever(formEntryPrompt.appearanceHint).thenReturn("ex:com.example.collectanswersprovider()") + val resultIntent = externalAppIntentProvider.getIntentToRunExternalApp(null, formEntryPrompt) + assertThat(resultIntent.action, `is`("com.example.collectanswersprovider")) + } + + @Test + fun intentAction_shouldBeSetProperlyIfThePackageNameDoesNotEndWithBrackets() { + whenever(formEntryPrompt.appearanceHint).thenReturn("ex:com.example.collectanswersprovider") val resultIntent = externalAppIntentProvider.getIntentToRunExternalApp(null, formEntryPrompt) assertThat(resultIntent.action, `is`("com.example.collectanswersprovider")) } @Test fun whenNoParamsSpecified_shouldIntentHaveNoExtras() { - `when`(formEntryPrompt.appearanceHint).thenReturn("ex:com.example.collectanswersprovider()") + whenever(formEntryPrompt.appearanceHint).thenReturn("ex:com.example.collectanswersprovider()") val resultIntent = externalAppIntentProvider.getIntentToRunExternalApp(null, formEntryPrompt) assertThat(resultIntent.extras, nullValue()) } @Test fun whenParamsSpecified_shouldIntentHaveExtras() { - `when`(formEntryPrompt.appearanceHint) + whenever(formEntryPrompt.appearanceHint) .thenReturn("ex:com.example.collectanswersprovider(param1='value1', param2='value2')") val resultIntent = externalAppIntentProvider.getIntentToRunExternalApp(null, formEntryPrompt) assertThat(resultIntent.extras!!.keySet().size, `is`(2)) @@ -49,11 +56,30 @@ class ExternalAppIntentProviderTest { @Test fun whenParamsContainUri_shouldThatUriBeAddedAsIntentData() { - `when`(formEntryPrompt.appearanceHint) + whenever(formEntryPrompt.appearanceHint) .thenReturn("ex:com.example.collectanswersprovider(param1='value1', uri_data='file:///tmp/android.txt')") val resultIntent = externalAppIntentProvider.getIntentToRunExternalApp(null, formEntryPrompt) assertThat(resultIntent.data.toString(), `is`("file:///tmp/android.txt")) assertThat(resultIntent.extras!!.keySet().size, `is`(1)) assertThat(resultIntent.extras!!.getString("param1"), `is`("value1")) } + + @Test + fun packageNameCanBeMixedWithOtherAppearances() { + whenever(formEntryPrompt.appearanceHint) + .thenReturn("masked ex:com.example.collectanswersprovider(param1='value1', param2='value2') thousands-sep") + val resultIntent = externalAppIntentProvider.getIntentToRunExternalApp(null, formEntryPrompt) + assertThat(resultIntent.action, `is`("com.example.collectanswersprovider")) + assertThat(resultIntent.extras!!.keySet().size, `is`(2)) + assertThat(resultIntent.extras!!.getString("param1"), `is`("value1")) + assertThat(resultIntent.extras!!.getString("param2"), `is`("value2")) + } + + @Test + fun parametersCanContainParentheses() { + whenever(formEntryPrompt.appearanceHint) + .thenReturn("ex:com.example.collectanswersprovider(param='blah()')") + val resultIntent = externalAppIntentProvider.getIntentToRunExternalApp(null, formEntryPrompt) + assertThat(resultIntent.extras!!.getString("param"), `is`("blah()")) + } } diff --git a/collect_app/src/test/java/org/odk/collect/android/utilities/FormsDownloadResultInterpreterTest.kt b/collect_app/src/test/java/org/odk/collect/android/utilities/FormsDownloadResultInterpreterTest.kt index 8ce8deeda40..b93d1300e59 100644 --- a/collect_app/src/test/java/org/odk/collect/android/utilities/FormsDownloadResultInterpreterTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/utilities/FormsDownloadResultInterpreterTest.kt @@ -7,10 +7,9 @@ import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.`is` import org.junit.Test import org.junit.runner.RunWith -import org.odk.collect.android.R -import org.odk.collect.android.formmanagement.FormDownloadException -import org.odk.collect.android.formmanagement.FormDownloadExceptionMapper import org.odk.collect.android.formmanagement.ServerFormDetails +import org.odk.collect.android.formmanagement.download.FormDownloadException +import org.odk.collect.android.formmanagement.download.FormDownloadExceptionMapper @RunWith(AndroidJUnit4::class) class FormsDownloadResultInterpreterTest { diff --git a/collect_app/src/test/java/org/odk/collect/android/utilities/FormsRepositoryProviderTest.kt b/collect_app/src/test/java/org/odk/collect/android/utilities/FormsRepositoryProviderTest.kt index ddbc1ea84e4..f462f55f5ee 100644 --- a/collect_app/src/test/java/org/odk/collect/android/utilities/FormsRepositoryProviderTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/utilities/FormsRepositoryProviderTest.kt @@ -34,7 +34,7 @@ class FormsRepositoryProviderTest { on { getOdkDirPath(CACHE, projectId) } doReturn cacheDir.absolutePath } - val formsRepositoryProvider = FormsRepositoryProvider(context, storagePathProvider) + val formsRepositoryProvider = FormsRepositoryProvider(context, storagePathProvider, mock()) val repository = formsRepositoryProvider.get(projectId) val form = repository.save(buildForm("id", "version", formsDir.absolutePath).build()) diff --git a/collect_app/src/test/java/org/odk/collect/android/utilities/HtmlUtilsTest.java b/collect_app/src/test/java/org/odk/collect/android/utilities/HtmlUtilsTest.java index 2c75128f42f..066c048c18b 100644 --- a/collect_app/src/test/java/org/odk/collect/android/utilities/HtmlUtilsTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/utilities/HtmlUtilsTest.java @@ -35,7 +35,13 @@ public void markDownToHtmlEscapesBackslash() { {"A _B_ C", "A B C"}, {"A_B_ C", "A_B_ C"}, {"A _B_C", "A _B_C"}, + {"_A_5", "_A_5"}, {"_A_", "A"}, + {"(_A_)", "(A)"}, + {"_A_?", "A?"}, + {"*_A_", "*A"}, + {"blah _A_!", "blah A!"}, + {" _A_! blah", " A! blah"}, {"\\_ _AB\\__", "_ AB_"}, {"\\\\ _AB\\_\\\\_", "\\ AB_\\"}, {"A\\*B\\*C", "A*B*C"}, diff --git a/collect_app/src/test/java/org/odk/collect/android/utilities/StubFormController.kt b/collect_app/src/test/java/org/odk/collect/android/utilities/StubFormController.kt index b68da29fcf8..06dd75f7418 100644 --- a/collect_app/src/test/java/org/odk/collect/android/utilities/StubFormController.kt +++ b/collect_app/src/test/java/org/odk/collect/android/utilities/StubFormController.kt @@ -5,6 +5,7 @@ import org.javarosa.core.model.FormIndex import org.javarosa.core.model.data.IAnswerData import org.javarosa.core.model.instance.TreeReference import org.javarosa.core.services.transport.payload.ByteArrayPayload +import org.javarosa.entities.internal.Entities import org.javarosa.form.api.FormEntryCaption import org.javarosa.form.api.FormEntryPrompt import org.odk.collect.android.exception.JavaRosaException @@ -13,9 +14,7 @@ import org.odk.collect.android.javarosawrapper.FormController import org.odk.collect.android.javarosawrapper.InstanceMetadata import org.odk.collect.android.javarosawrapper.SuccessValidationResult import org.odk.collect.android.javarosawrapper.ValidationResult -import org.odk.collect.entities.Entity import java.io.File -import java.util.stream.Stream open class StubFormController : FormController { override fun getFormDef(): FormDef? = null @@ -77,7 +76,7 @@ open class StubFormController : FormController { override fun answerQuestion(index: FormIndex?, data: IAnswerData?): Int = -1 @Throws(JavaRosaException::class) - override fun validateAnswers(markCompleted: Boolean, moveToInvalidIndex: Boolean): ValidationResult = SuccessValidationResult + override fun validateAnswers(moveToInvalidIndex: Boolean): ValidationResult = SuccessValidationResult override fun saveAnswer(index: FormIndex?, data: IAnswerData?): Boolean = false @@ -135,6 +134,8 @@ open class StubFormController : FormController { override fun indexContainsRepeatableGroup(): Boolean = false + override fun indexContainsRepeatableGroup(formIndex: FormIndex?): Boolean = false + override fun getLastRepeatedGroupRepeatCount(): Int = -1 override fun getLastRepeatedGroupName(): String? = null @@ -143,7 +144,7 @@ open class StubFormController : FormController { override fun isSubmissionEntireForm(): Boolean = false - override fun getFilledInFormXml(): ByteArrayPayload? = null + override fun getFilledInFormXml(): ByteArrayPayload = ByteArrayPayload() override fun getSubmissionXml(): ByteArrayPayload? = null @@ -155,5 +156,5 @@ open class StubFormController : FormController { override fun getAnswer(treeReference: TreeReference?): IAnswerData? = null - override fun getEntities(): Stream = Stream.empty() + override fun getEntities(): Entities? = null } diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/AnnotateWidgetTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/AnnotateWidgetTest.java index d586f195a47..646ef1052ff 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/AnnotateWidgetTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/AnnotateWidgetTest.java @@ -89,14 +89,14 @@ public Object createBinaryData(@NotNull StringData answerData) { public void buttonsShouldLaunchCorrectIntentsWhenThereIsNoCustomPackage() { stubAllRuntimePermissionsGranted(true); - Intent intent = getIntentLaunchedByClick(R.id.capture_image); + Intent intent = getIntentLaunchedByClick(R.id.capture_button); assertActionEquals(MediaStore.ACTION_IMAGE_CAPTURE, intent); assertThat(intent.getPackage(), equalTo(null)); - intent = getIntentLaunchedByClick(R.id.choose_image); + intent = getIntentLaunchedByClick(R.id.choose_button); assertActionEquals(Intent.ACTION_GET_CONTENT, intent); - intent = getIntentLaunchedByClick(R.id.markup_image); + intent = getIntentLaunchedByClick(R.id.annotate_button); assertComponentEquals(activity, DrawActivity.class, intent); assertExtraEquals(DrawActivity.OPTION, DrawActivity.OPTION_ANNOTATE, intent); } @@ -109,11 +109,11 @@ public void buttonsShouldLaunchCorrectIntentsWhenCustomPackageIsSet() { stubAllRuntimePermissionsGranted(true); - Intent intent = getIntentLaunchedByClick(R.id.capture_image); + Intent intent = getIntentLaunchedByClick(R.id.capture_button); assertActionEquals(MediaStore.ACTION_IMAGE_CAPTURE, intent); assertThat(intent.getPackage(), equalTo("com.customcameraapp")); - intent = getIntentLaunchedByClick(R.id.choose_image); + intent = getIntentLaunchedByClick(R.id.choose_button); assertActionEquals(Intent.ACTION_GET_CONTENT, intent); assertTypeEquals("image/*", intent); } @@ -122,16 +122,16 @@ public void buttonsShouldLaunchCorrectIntentsWhenCustomPackageIsSet() { public void buttonsShouldNotLaunchIntentsWhenPermissionsDenied() { stubAllRuntimePermissionsGranted(false); - assertNull(getIntentLaunchedByClick(R.id.capture_image)); + assertNull(getIntentLaunchedByClick(R.id.capture_button)); } @Test public void usingReadOnlyOptionShouldMakeAllClickableElementsDisabled() { when(formEntryPrompt.isReadOnly()).thenReturn(true); - assertThat(getSpyWidget().captureButton.getVisibility(), is(View.GONE)); - assertThat(getSpyWidget().chooseButton.getVisibility(), is(View.GONE)); - assertThat(getSpyWidget().annotateButton.getVisibility(), is(View.GONE)); + assertThat(getSpyWidget().binding.captureButton.getVisibility(), is(View.GONE)); + assertThat(getSpyWidget().binding.chooseButton.getVisibility(), is(View.GONE)); + assertThat(getSpyWidget().binding.annotateButton.getVisibility(), is(View.GONE)); } @Test @@ -139,9 +139,9 @@ public void whenReadOnlyOverrideOptionIsUsed_shouldAllClickableElementsBeDisable readOnlyOverride = true; when(formEntryPrompt.isReadOnly()).thenReturn(false); - assertThat(getSpyWidget().captureButton.getVisibility(), is(View.GONE)); - assertThat(getSpyWidget().chooseButton.getVisibility(), is(View.GONE)); - assertThat(getSpyWidget().annotateButton.getVisibility(), is(View.GONE)); + assertThat(getSpyWidget().binding.captureButton.getVisibility(), is(View.GONE)); + assertThat(getSpyWidget().binding.chooseButton.getVisibility(), is(View.GONE)); + assertThat(getSpyWidget().binding.annotateButton.getVisibility(), is(View.GONE)); } @Test @@ -262,13 +262,13 @@ public void markupButtonShouldBeDisabledIfImageAbsent() throws Exception { .withAnswerDisplayText(DrawWidgetTest.DEFAULT_IMAGE_ANSWER) .build(); - assertThat(getWidget().annotateButton.isEnabled(), is(false)); + assertThat(getWidget().binding.annotateButton.isEnabled(), is(false)); formEntryPrompt = new MockFormEntryPromptBuilder() .withAnswerDisplayText(DrawWidgetTest.USER_SPECIFIED_IMAGE_ANSWER) .build(); - assertThat(getWidget().annotateButton.isEnabled(), is(false)); + assertThat(getWidget().binding.annotateButton.isEnabled(), is(false)); } @Test @@ -295,7 +295,7 @@ public ImageLoader providesImageLoader() { .withAnswerDisplayText(DrawWidgetTest.DEFAULT_IMAGE_ANSWER) .build(); - Intent intent = getIntentLaunchedByClick(R.id.markup_image); + Intent intent = getIntentLaunchedByClick(R.id.annotate_button); assertComponentEquals(activity, DrawActivity.class, intent); assertExtraEquals(DrawActivity.OPTION, DrawActivity.OPTION_ANNOTATE, intent); assertExtraEquals(DrawActivity.REF_IMAGE, Uri.fromFile(file), intent); @@ -317,7 +317,7 @@ public ReferenceManager providesReferenceManager() { .withAnswerDisplayText(DrawWidgetTest.DEFAULT_IMAGE_ANSWER) .build(); - Intent intent = getIntentLaunchedByClick(R.id.markup_image); + Intent intent = getIntentLaunchedByClick(R.id.annotate_button); assertComponentEquals(activity, DrawActivity.class, intent); assertExtraEquals(DrawActivity.OPTION, DrawActivity.OPTION_ANNOTATE, intent); assertThat(intent.hasExtra(DrawActivity.REF_IMAGE), is(false)); @@ -325,7 +325,7 @@ public ReferenceManager providesReferenceManager() { @Test public void whenThereIsNoAnswer_doNotPassUriToDrawActivity() { - Intent intent = getIntentLaunchedByClick(R.id.markup_image); + Intent intent = getIntentLaunchedByClick(R.id.annotate_button); assertComponentEquals(activity, DrawActivity.class, intent); assertExtraEquals(DrawActivity.OPTION, DrawActivity.OPTION_ANNOTATE, intent); assertThat(intent.hasExtra(DrawActivity.REF_IMAGE), is(false)); diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/AudioWidgetTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/AudioWidgetTest.java index 425f17f8c1e..d849b244353 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/AudioWidgetTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/AudioWidgetTest.java @@ -78,8 +78,8 @@ public void whenPromptDoesNotHaveAnswer_showsButtons() { assertThat(widget.binding.audioPlayer.audioController.getVisibility(), is(GONE)); assertThat(widget.binding.audioPlayer.recordingDuration.getVisibility(), is(GONE)); assertThat(widget.binding.audioPlayer.waveform.getVisibility(), is(GONE)); - assertThat(widget.binding.captureButton.getVisibility(), is(VISIBLE)); - assertThat(widget.binding.chooseButton.getVisibility(), is(VISIBLE)); + assertThat(widget.binding.recordAudioButton.getVisibility(), is(VISIBLE)); + assertThat(widget.binding.chooseAudioButton.getVisibility(), is(VISIBLE)); } @Test @@ -87,8 +87,8 @@ public void whenPromptHasAnswer_showsAudioController() throws Exception { File answerFile = questionMediaManager.addAnswerFile(File.createTempFile("blah", ".mp3")); AudioWidget widget = createWidget(promptWithAnswer(new StringData(answerFile.getName()))); - assertThat(widget.binding.captureButton.getVisibility(), is(GONE)); - assertThat(widget.binding.chooseButton.getVisibility(), is(GONE)); + assertThat(widget.binding.recordAudioButton.getVisibility(), is(GONE)); + assertThat(widget.binding.chooseAudioButton.getVisibility(), is(GONE)); assertThat(widget.binding.audioPlayer.waveform.getVisibility(), is(GONE)); assertThat(widget.binding.audioPlayer.recordingDuration.getVisibility(), is(GONE)); assertThat(widget.binding.audioPlayer.audioController.getVisibility(), is(VISIBLE)); @@ -97,8 +97,8 @@ public void whenPromptHasAnswer_showsAudioController() throws Exception { @Test public void usingReadOnlyOption_doesNotShowCaptureAndChooseButtons() { AudioWidget widget = createWidget(promptWithReadOnly()); - assertThat(widget.binding.captureButton.getVisibility(), equalTo(GONE)); - assertThat(widget.binding.chooseButton.getVisibility(), equalTo(GONE)); + assertThat(widget.binding.recordAudioButton.getVisibility(), equalTo(GONE)); + assertThat(widget.binding.chooseAudioButton.getVisibility(), equalTo(GONE)); } @Test @@ -120,7 +120,7 @@ public void whenWidgetIsNew_chooseSoundButtonIsNotShown() { when(prompt.getAppearanceHint()).thenReturn(Appearances.NEW); AudioWidget widget = createWidget(prompt); - assertThat(widget.binding.chooseButton.getVisibility(), equalTo(GONE)); + assertThat(widget.binding.chooseAudioButton.getVisibility(), equalTo(GONE)); } @Test @@ -266,8 +266,8 @@ public void setData_whenFileExists_hidesButtonsAndShowsAudioController() throws File answerFile = questionMediaManager.addAnswerFile(File.createTempFile("blah", ".mp3")); widget.setData(answerFile); - assertThat(widget.binding.captureButton.getVisibility(), is(GONE)); - assertThat(widget.binding.captureButton.getVisibility(), is(GONE)); + assertThat(widget.binding.recordAudioButton.getVisibility(), is(GONE)); + assertThat(widget.binding.recordAudioButton.getVisibility(), is(GONE)); assertThat(widget.binding.audioPlayer.audioController.getVisibility(), is(VISIBLE)); } @@ -277,11 +277,11 @@ public void clickingButtonsForLong_callsOnLongClickListeners() { AudioWidget widget = createWidget(promptWithAnswer(null)); widget.setOnLongClickListener(listener); - widget.binding.captureButton.performLongClick(); - widget.binding.chooseButton.performLongClick(); + widget.binding.recordAudioButton.performLongClick(); + widget.binding.chooseAudioButton.performLongClick(); - verify(listener).onLongClick(widget.binding.captureButton); - verify(listener).onLongClick(widget.binding.chooseButton); + verify(listener).onLongClick(widget.binding.recordAudioButton); + verify(listener).onLongClick(widget.binding.chooseAudioButton); } @Test @@ -289,7 +289,7 @@ public void clickingChooseButton_requestsAudioFile() { FormEntryPrompt prompt = promptWithAnswer(null); AudioWidget widget = createWidget(prompt); - widget.binding.chooseButton.performClick(); + widget.binding.chooseAudioButton.performClick(); verify(audioFileRequester).requestFile(prompt); } @@ -299,7 +299,7 @@ public void clickingCaptureButton_clearsWaveform() { AudioWidget widget = createWidget(prompt); recordingRequester.setAmplitude(prompt.getIndex().toString(), 11); - widget.binding.captureButton.performClick(); + widget.binding.recordAudioButton.performClick(); assertThat(widget.binding.audioPlayer.waveform.getLatestAmplitude(), nullValue()); } @@ -310,7 +310,7 @@ public void clickingCaptureButton_clearsError() { widget.displayError("Required question!"); assertThat(widget.errorLayout.getVisibility(), equalTo(VISIBLE)); - widget.binding.captureButton.performClick(); + widget.binding.recordAudioButton.performClick(); assertThat(widget.errorLayout.getVisibility(), equalTo(GONE)); } @@ -319,12 +319,12 @@ public void whenRecordingRequesterStopsRecording_enablesButtons() { AudioWidget widget = createWidget(promptWithAnswer(null)); recordingRequester.startRecording(); - assertThat(widget.binding.captureButton.isEnabled(), is(false)); - assertThat(widget.binding.chooseButton.isEnabled(), is(false)); + assertThat(widget.binding.recordAudioButton.isEnabled(), is(false)); + assertThat(widget.binding.chooseAudioButton.isEnabled(), is(false)); recordingRequester.stopRecording(); - assertThat(widget.binding.captureButton.isEnabled(), is(true)); - assertThat(widget.binding.chooseButton.isEnabled(), is(true)); + assertThat(widget.binding.recordAudioButton.isEnabled(), is(true)); + assertThat(widget.binding.chooseAudioButton.isEnabled(), is(true)); } @Test @@ -333,8 +333,8 @@ public void whenRecordingInProgress_showsDurationAndWaveform() { AudioWidget widget = createWidget(prompt); recordingRequester.setDuration(prompt.getIndex().toString(), 0); - assertThat(widget.binding.captureButton.getVisibility(), is(GONE)); - assertThat(widget.binding.chooseButton.getVisibility(), is(GONE)); + assertThat(widget.binding.recordAudioButton.getVisibility(), is(GONE)); + assertThat(widget.binding.chooseAudioButton.getVisibility(), is(GONE)); assertThat(widget.binding.audioPlayer.audioController.getVisibility(), is(GONE)); assertThat(widget.binding.audioPlayer.recordingDuration.getVisibility(), is(VISIBLE)); assertThat(widget.binding.audioPlayer.waveform.getVisibility(), is(VISIBLE)); @@ -375,8 +375,8 @@ public void whenRecordingFinished_showsButtons() { assertThat(widget.binding.audioPlayer.audioController.getVisibility(), is(GONE)); assertThat(widget.binding.audioPlayer.recordingDuration.getVisibility(), is(GONE)); assertThat(widget.binding.audioPlayer.waveform.getVisibility(), is(GONE)); - assertThat(widget.binding.captureButton.getVisibility(), is(VISIBLE)); - assertThat(widget.binding.chooseButton.getVisibility(), is(VISIBLE)); + assertThat(widget.binding.recordAudioButton.getVisibility(), is(VISIBLE)); + assertThat(widget.binding.chooseAudioButton.getVisibility(), is(VISIBLE)); } @Test @@ -469,8 +469,8 @@ public void clickingRemove_andConfirming_hidesAudioControllerAndShowsButtons() t dialog.getButton(AlertDialog.BUTTON_POSITIVE).performClick(); RobolectricHelpers.runLooper(); assertThat(widget.binding.audioPlayer.audioController.getVisibility(), is(GONE)); - assertThat(widget.binding.captureButton.getVisibility(), is(VISIBLE)); - assertThat(widget.binding.chooseButton.getVisibility(), is(VISIBLE)); + assertThat(widget.binding.recordAudioButton.getVisibility(), is(VISIBLE)); + assertThat(widget.binding.chooseAudioButton.getVisibility(), is(VISIBLE)); } @Test @@ -479,8 +479,8 @@ public void usingReadOnlyOptionShouldMakeAllClickableElementsDisabled() throws E AudioWidget widget = createWidget(promptWithReadOnlyAndAnswer(new StringData(answerFile.getName()))); widget.binding.audioPlayer.audioController.binding.remove.performClick(); - assertThat(widget.binding.captureButton.getVisibility(), is(View.GONE)); - assertThat(widget.binding.chooseButton.getVisibility(), is(View.GONE)); + assertThat(widget.binding.recordAudioButton.getVisibility(), is(View.GONE)); + assertThat(widget.binding.chooseAudioButton.getVisibility(), is(View.GONE)); } @Test @@ -489,8 +489,8 @@ public void whenReadOnlyOverrideOptionIsUsed_shouldAllClickableElementsBeDisable AudioWidget widget = createWidget(promptWithAnswer(new StringData(answerFile.getName())), true); widget.binding.audioPlayer.audioController.binding.remove.performClick(); - assertThat(widget.binding.captureButton.getVisibility(), is(View.GONE)); - assertThat(widget.binding.chooseButton.getVisibility(), is(View.GONE)); + assertThat(widget.binding.recordAudioButton.getVisibility(), is(View.GONE)); + assertThat(widget.binding.chooseAudioButton.getVisibility(), is(View.GONE)); } public AudioWidget createWidget(FormEntryPrompt prompt) { diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/DateTimeWidgetTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/DateTimeWidgetTest.java index f2cfbfbe633..0d6d39defbc 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/DateTimeWidgetTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/DateTimeWidgetTest.java @@ -112,7 +112,7 @@ public void whenPromptDoesNotHaveAnswer_answerTextViewShowsNoValueSelected() { @Test public void whenPromptHasAnswer_answerTextViewShowsCorrectDateAndTime() { FormEntryPrompt prompt = promptWithQuestionDefAndAnswer(questionDef, new DateTimeData(localDateTime.toDate())); - DatePickerDetails datePickerDetails = DateTimeWidgetUtils.getDatePickerDetails(prompt.getQuestion().getAppearanceAttr()); + DatePickerDetails datePickerDetails = DateTimeWidgetUtils.getDatePickerDetails(prompt.getAppearanceHint()); DateTimeWidget widget = createWidget(prompt); assertEquals(widget.binding.dateWidget.dateAnswerText.getText(), @@ -127,7 +127,7 @@ public void clickingSetDateButton_callsDisplayDatePickerDialogWithCurrentDate_wh widget.binding.dateWidget.dateButton.performClick(); verify(widgetUtils).showDatePickerDialog(widgetActivity, DateTimeWidgetUtils.getDatePickerDetails( - prompt.getQuestion().getAppearanceAttr()), DateTimeUtils.getCurrentDateTime()); + prompt.getAppearanceHint()), DateTimeUtils.getCurrentDateTime()); } @Test @@ -145,7 +145,7 @@ public void clickingSetDateButton_callsDisplayDatePickerDialogWithSelectedDate_w DateTimeWidget widget = createWidget(prompt); widget.binding.dateWidget.dateButton.performClick(); - verify(widgetUtils).showDatePickerDialog(widgetActivity, DateTimeWidgetUtils.getDatePickerDetails(prompt.getQuestion().getAppearanceAttr()), + verify(widgetUtils).showDatePickerDialog(widgetActivity, DateTimeWidgetUtils.getDatePickerDetails(prompt.getAppearanceHint()), DateTimeUtils.getSelectedDate(localDateTime, new LocalDateTime().withTime(0, 0, 0, 0))); } @@ -204,7 +204,7 @@ public void clickingAnswerTextViewForLong_callsLongClickListener() { @Test public void setDateData_updatesValueShownInDateAnswerTextView() { FormEntryPrompt prompt = promptWithQuestionDefAndAnswer(questionDef, null); - DatePickerDetails datePickerDetails = DateTimeWidgetUtils.getDatePickerDetails(prompt.getQuestion().getAppearanceAttr()); + DatePickerDetails datePickerDetails = DateTimeWidgetUtils.getDatePickerDetails(prompt.getAppearanceHint()); DateTimeWidget widget = createWidget(prompt); widget.setData(new LocalDateTime().withDate(2010, 5, 12)); diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/DateWidgetTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/DateWidgetTest.java index b1b8d1ea2b8..f5206eae27a 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/DateWidgetTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/DateWidgetTest.java @@ -82,7 +82,7 @@ public void whenPromptDoesNotHaveAnswer_answerTextViewShowsNoDateSelected() { @Test public void whenPromptHasAnswer_answerTextViewShowsCorrectDate() { FormEntryPrompt prompt = promptWithQuestionDefAndAnswer(questionDef, new DateData(dateAnswer.toDate())); - DatePickerDetails datePickerDetails = DateTimeWidgetUtils.getDatePickerDetails(prompt.getQuestion().getAppearanceAttr()); + DatePickerDetails datePickerDetails = DateTimeWidgetUtils.getDatePickerDetails(prompt.getAppearanceHint()); DateWidget widget = createWidget(prompt); assertEquals(widget.binding.dateAnswerText.getText(), @@ -96,7 +96,7 @@ public void clickingButton_callsDisplayDatePickerDialogWithCurrentDate_whenPromp widget.binding.dateButton.performClick(); verify(widgetUtils).showDatePickerDialog(widgetActivity, DateTimeWidgetUtils.getDatePickerDetails( - prompt.getQuestion().getAppearanceAttr()), DateTimeUtils.getCurrentDateTime()); + prompt.getAppearanceHint()), DateTimeUtils.getCurrentDateTime()); } @Test @@ -106,7 +106,7 @@ public void clickingButton_callsDisplayDatePickerDialogWithSelectedDate_whenProm widget.binding.dateButton.performClick(); verify(widgetUtils).showDatePickerDialog(widgetActivity, DateTimeWidgetUtils.getDatePickerDetails( - prompt.getQuestion().getAppearanceAttr()), dateAnswer); + prompt.getAppearanceHint()), dateAnswer); } @Test @@ -146,7 +146,7 @@ public void setData_updatesWidgetAnswer() { @Test public void setData_updatesValueDisplayedInAnswerTextView() { FormEntryPrompt prompt = promptWithQuestionDefAndAnswer(questionDef, null); - DatePickerDetails datePickerDetails = DateTimeWidgetUtils.getDatePickerDetails(prompt.getQuestion().getAppearanceAttr()); + DatePickerDetails datePickerDetails = DateTimeWidgetUtils.getDatePickerDetails(prompt.getAppearanceHint()); DateWidget widget = createWidget(prompt); widget.setData(dateAnswer); diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/DecimalWidgetTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/DecimalWidgetTest.java index c74437ff005..bbe404e7979 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/DecimalWidgetTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/DecimalWidgetTest.java @@ -1,7 +1,18 @@ package org.odk.collect.android.widgets; +import static junit.framework.TestCase.assertEquals; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.core.Is.is; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.when; +import static org.odk.collect.android.utilities.Appearances.THOUSANDS_SEP; + +import android.text.InputType; + import androidx.annotation.NonNull; +import org.javarosa.core.model.Constants; import org.javarosa.core.model.QuestionDef; import org.javarosa.core.model.data.DecimalData; import org.javarosa.core.model.data.IAnswerData; @@ -14,14 +25,6 @@ import java.util.Locale; import java.util.Random; -import static junit.framework.TestCase.assertEquals; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.core.Is.is; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThat; -import static org.mockito.Mockito.when; -import static org.odk.collect.android.utilities.Appearances.THOUSANDS_SEP; - public class DecimalWidgetTest extends GeneralStringWidgetTest { private final Random random = new Random(); @@ -35,6 +38,7 @@ public class DecimalWidgetTest extends GeneralStringWidgetTest() { whenever(formEntryPrompt.answerText).thenReturn("blah") val widget = createWidget() - widget.findViewById(R.id.printer_button).performClick() + widget.findViewById(R.id.printer_button).performClick() scheduler.runBackground() scheduler.runForeground() @@ -45,7 +45,7 @@ class PrinterWidgetTest : QuestionWidgetTest() { whenever(formEntryPrompt.answerText).thenReturn(null) val widget = createWidget() - widget.findViewById(R.id.printer_button).performClick() + widget.findViewById(R.id.printer_button).performClick() verifyNoInteractions(htmlPrinter) } diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/SignatureWidgetTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/SignatureWidgetTest.java index ab3e0ac8df1..4532f081033 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/SignatureWidgetTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/SignatureWidgetTest.java @@ -78,7 +78,7 @@ public StringData getNextAnswer() { public void usingReadOnlyOptionShouldMakeAllClickableElementsDisabled() { when(formEntryPrompt.isReadOnly()).thenReturn(true); - assertThat(getSpyWidget().signButton.getVisibility(), is(View.GONE)); + assertThat(getSpyWidget().binding.signButton.getVisibility(), is(View.GONE)); } @Test @@ -86,7 +86,7 @@ public void whenReadOnlyOverrideOptionIsUsed_shouldAllClickableElementsBeDisable readOnlyOverride = true; when(formEntryPrompt.isReadOnly()).thenReturn(false); - assertThat(getSpyWidget().signButton.getVisibility(), is(View.GONE)); + assertThat(getSpyWidget().binding.signButton.getVisibility(), is(View.GONE)); } @Test @@ -206,7 +206,7 @@ public ImageLoader providesImageLoader() { .withAnswerDisplayText(DrawWidgetTest.DEFAULT_IMAGE_ANSWER) .build(); - Intent intent = getIntentLaunchedByClick(R.id.simple_button); + Intent intent = getIntentLaunchedByClick(R.id.sign_button); assertComponentEquals(activity, DrawActivity.class, intent); assertExtraEquals(DrawActivity.OPTION, DrawActivity.OPTION_SIGNATURE, intent); assertExtraEquals(DrawActivity.REF_IMAGE, Uri.fromFile(file), intent); @@ -228,7 +228,7 @@ public ReferenceManager providesReferenceManager() { .withAnswerDisplayText(DrawWidgetTest.DEFAULT_IMAGE_ANSWER) .build(); - Intent intent = getIntentLaunchedByClick(R.id.simple_button); + Intent intent = getIntentLaunchedByClick(R.id.sign_button); assertComponentEquals(activity, DrawActivity.class, intent); assertExtraEquals(DrawActivity.OPTION, DrawActivity.OPTION_SIGNATURE, intent); assertThat(intent.hasExtra(DrawActivity.REF_IMAGE), is(false)); @@ -236,7 +236,7 @@ public ReferenceManager providesReferenceManager() { @Test public void whenThereIsNoAnswer_doNotPassUriToDrawActivity() { - Intent intent = getIntentLaunchedByClick(R.id.simple_button); + Intent intent = getIntentLaunchedByClick(R.id.sign_button); assertComponentEquals(activity, DrawActivity.class, intent); assertExtraEquals(DrawActivity.OPTION, DrawActivity.OPTION_SIGNATURE, intent); assertThat(intent.hasExtra(DrawActivity.REF_IMAGE), is(false)); diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/StringNumberWidgetTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/StringNumberWidgetTest.java index 6228d255e05..2d6abc64c55 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/StringNumberWidgetTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/StringNumberWidgetTest.java @@ -1,18 +1,23 @@ package org.odk.collect.android.widgets; +import static junit.framework.TestCase.assertEquals; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Mockito.when; +import static org.odk.collect.android.utilities.Appearances.THOUSANDS_SEP; + +import android.text.InputType; + import androidx.annotation.NonNull; import net.bytebuddy.utility.RandomString; +import org.javarosa.core.model.Constants; import org.javarosa.core.model.data.StringData; -import org.odk.collect.android.formentry.questions.QuestionDetails; import org.junit.Test; +import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.android.widgets.base.GeneralStringWidgetTest; -import static junit.framework.TestCase.assertEquals; -import static org.mockito.Mockito.when; -import static org.odk.collect.android.utilities.Appearances.THOUSANDS_SEP; - /** * @author James Knight */ @@ -21,6 +26,7 @@ public class StringNumberWidgetTest extends GeneralStringWidgetTest viewsRegisterForContextMenu = ((WidgetTestActivity) activity).viewsRegisterForContextMenu; assertThat(viewsRegisterForContextMenu.size(), is(3)); @@ -64,4 +65,27 @@ public void widgetShouldBeRegisteredForContextMenu() { assertThat(viewsRegisterForContextMenu.get(0).getId(), is(widget.getId())); assertThat(viewsRegisterForContextMenu.get(1).getId(), is(widget.getId())); } + + @Test + public void errorDisappearsOnSetData() { + ExStringWidget widget = getWidget(); + widget.displayError("blah"); + widget.setData("answer"); + + assertThat(widget.errorLayout.getVisibility(), equalTo(TextView.GONE)); + assertThat(widget.getBackground(), equalTo(null)); + } + + @Test + public void errorDisappearsOnAddingAnswerManuallyViaTheTextField() { + ExStringWidget widget = getWidget(); + widget.displayError("blah"); + widget.binding.widgetAnswerText.getBinding().editText.setText("answer"); + + assertThat(widget.errorLayout.getVisibility(), equalTo(TextView.GONE)); + assertThat(widget.getBackground(), equalTo(null)); + } + + @Test + public abstract void verifyInputType(); } diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/base/GeneralStringWidgetTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/base/GeneralStringWidgetTest.java index 1edb5ee1397..c1e0e2affe6 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/base/GeneralStringWidgetTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/base/GeneralStringWidgetTest.java @@ -4,10 +4,8 @@ import android.view.View; -import org.javarosa.core.model.QuestionDef; import org.javarosa.core.model.data.IAnswerData; import org.junit.Test; -import org.mockito.Mock; import org.odk.collect.android.R; import org.odk.collect.android.support.WidgetTestActivity; import org.odk.collect.android.widgets.StringWidget; @@ -25,15 +23,6 @@ public abstract class GeneralStringWidgetTest extends QuestionWidgetTest { - @Mock - QuestionDef questionDef; - - @Override - public void setUp() throws Exception { - super.setUp(); - when(formEntryPrompt.getQuestion()).thenReturn(questionDef); - } - @Override public void callingClearShouldRemoveTheExistingAnswer() { super.callingClearShouldRemoveTheExistingAnswer(); @@ -102,4 +91,7 @@ public void widgetShouldBeRegisteredForContextMenu() { assertThat(viewsRegisterForContextMenu.get(0).getId(), is(widget.getId())); assertThat(viewsRegisterForContextMenu.get(1).getId(), is(widget.getId())); } -} \ No newline at end of file + + @Test + public abstract void verifyInputType(); +} diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/base/WidgetTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/base/WidgetTest.java index 3058d239794..728a076d4b0 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/base/WidgetTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/base/WidgetTest.java @@ -37,6 +37,9 @@ public abstract class WidgetTest { protected final SettingsProvider settingsProvider = TestSettingsProvider.getSettingsProvider(); + @Mock + protected QuestionDef questionDef; + @Before @OverridingMethodsMustInvokeSuper public void setUp() throws Exception { @@ -48,7 +51,7 @@ public void setUp() throws Exception { when(formEntryPrompt.getIndex()).thenReturn(mock(FormIndex.class)); when(formEntryPrompt.getIndex().toString()).thenReturn("0, 0"); when(formEntryPrompt.getFormElement()).thenReturn(formElement); - when(formEntryPrompt.getQuestion()).thenReturn(mock(QuestionDef.class)); + when(formEntryPrompt.getQuestion()).thenReturn(questionDef); } @Test diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/items/RankingWidgetTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/items/RankingWidgetTest.java index c420994b78f..b5f70a90950 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/items/RankingWidgetTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/items/RankingWidgetTest.java @@ -53,7 +53,7 @@ public MultipleItemsData getNextAnswer() { public void usingReadOnlyOptionShouldMakeAllClickableElementsDisabled() { when(formEntryPrompt.isReadOnly()).thenReturn(true); - assertThat(getSpyWidget().showRankingDialogButton.getVisibility(), is(View.GONE)); + assertThat(getSpyWidget().binding.rankItemsButton.getVisibility(), is(View.GONE)); } @Test diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/items/SelectChoicesMapDataTest.kt b/collect_app/src/test/java/org/odk/collect/android/widgets/items/SelectChoicesMapDataTest.kt index 355647a3463..50292fa3f9e 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/items/SelectChoicesMapDataTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/items/SelectChoicesMapDataTest.kt @@ -15,7 +15,8 @@ import org.odk.collect.android.support.MockFormEntryPromptBuilder import org.odk.collect.android.widgets.support.FormElementFixtures.selectChoice import org.odk.collect.android.widgets.support.FormElementFixtures.treeElement import org.odk.collect.androidtest.getOrAwaitValue -import org.odk.collect.geo.selection.MappableSelectItem.IconifiedText +import org.odk.collect.geo.selection.IconifiedText +import org.odk.collect.geo.selection.MappableSelectItem import org.odk.collect.maps.MapPoint import org.odk.collect.testshared.FakeScheduler @@ -33,7 +34,7 @@ class SelectChoicesMapDataTest { selectChoice( value = "a", item = treeElement( - children = listOf(treeElement("geometry", "12.0 -1.0 3 4; 12.1 -1.0 3 4")) + children = listOf(treeElement(SelectChoicesMapData.GEOMETRY, "12.0 -1.0 3 4; 12.1 -1.0 3 4")) ) ) ) @@ -50,7 +51,7 @@ class SelectChoicesMapDataTest { val mappableItems = data.getMappableItems().getOrAwaitValue()!! assertThat(mappableItems.size, equalTo(1)) - val points = mappableItems[0].points + val points = (mappableItems[0] as MappableSelectItem.MappableSelectLine).points assertThat( points, equalTo(listOf(MapPoint(12.0, -1.0, 3.0, 4.0), MapPoint(12.1, -1.0, 3.0, 4.0))) @@ -63,7 +64,7 @@ class SelectChoicesMapDataTest { selectChoice( value = "a", item = treeElement( - children = listOf(treeElement("geometry", "12.0 -1.0 3 4; 12.1 -1.0 3 4; 12.0 -1.0 3 4")) + children = listOf(treeElement(SelectChoicesMapData.GEOMETRY, "12.0 -1.0 3 4; 12.1 -1.0 3 4; 12.0 -1.0 3 4")) ) ) ) @@ -80,7 +81,7 @@ class SelectChoicesMapDataTest { val mappableItems = data.getMappableItems().getOrAwaitValue()!! assertThat(mappableItems.size, equalTo(1)) - val points = mappableItems[0].points + val points = (mappableItems[0] as MappableSelectItem.MappableSelectPolygon).points assertThat( points, equalTo(listOf(MapPoint(12.0, -1.0, 3.0, 4.0), MapPoint(12.1, -1.0, 3.0, 4.0), MapPoint(12.0, -1.0, 3.0, 4.0))) @@ -92,7 +93,7 @@ class SelectChoicesMapDataTest { val choices = listOf( selectChoice( value = "a", - item = treeElement(children = listOf(treeElement("geometry", "12.0 -1.0 305 0"))) + item = treeElement(children = listOf(treeElement(SelectChoicesMapData.GEOMETRY, "12.0 -1.0 305 0"))) ), selectChoice( value = "b", @@ -119,7 +120,7 @@ class SelectChoicesMapDataTest { value = "a", item = treeElement( children = listOf( - treeElement("geometry", "12.0 -1.0 305 0"), + treeElement(SelectChoicesMapData.GEOMETRY, "12.0 -1.0 305 0"), treeElement("property", "blah") ) ) @@ -164,7 +165,7 @@ class SelectChoicesMapDataTest { value = "a", item = treeElement( children = listOf( - treeElement("geometry", "0 170.00 0 0") + treeElement(SelectChoicesMapData.GEOMETRY, "0 170.00 0 0") ) ) ), @@ -173,7 +174,7 @@ class SelectChoicesMapDataTest { value = "b", item = treeElement( children = listOf( - treeElement("geometry", "blah") + treeElement(SelectChoicesMapData.GEOMETRY, "blah") ) ) ), @@ -182,7 +183,7 @@ class SelectChoicesMapDataTest { value = "c", item = treeElement( children = listOf( - treeElement("geometry", "0 180.1 0 0") + treeElement(SelectChoicesMapData.GEOMETRY, "0 180.1 0 0") ) ) ), @@ -191,7 +192,7 @@ class SelectChoicesMapDataTest { value = "c", item = treeElement( children = listOf( - treeElement("geometry", "0 180 0 0; 0 180.1 0 0") + treeElement(SelectChoicesMapData.GEOMETRY, "0 180 0 0; 0 180.1 0 0") ) ) ) @@ -218,9 +219,9 @@ class SelectChoicesMapDataTest { value = "a", item = treeElement( children = listOf( - treeElement("geometry", "12.0 -1.0 305 0"), - treeElement("marker-symbol", "A"), - treeElement("marker-color", "#ffffff") + treeElement(SelectChoicesMapData.GEOMETRY, "12.0 -1.0 305 0"), + treeElement(SelectChoicesMapData.MARKER_SYMBOL, "A"), + treeElement(SelectChoicesMapData.MARKER_COLOR, "#ffffff") ) ) ) @@ -233,11 +234,75 @@ class SelectChoicesMapDataTest { .build() val data = loadDataForPrompt(prompt) - val item = data.getMappableItems().getOrAwaitValue()!![0] + val item = data.getMappableItems().getOrAwaitValue()!![0] as MappableSelectItem.MappableSelectPoint assertThat(item.symbol, equalTo("A")) assertThat(item.color, equalTo("#ffffff")) } + /** + * Attributes names come from properties defined at + * https://github.com/mapbox/simplestyle-spec/tree/master/1.1.0. + */ + @Test + fun `line stroke color is pulled from simple style attributes`() { + val choices = listOf( + selectChoice( + value = "a", + item = treeElement( + children = listOf( + treeElement(SelectChoicesMapData.GEOMETRY, "12.0 -1.0 3 4; 12.1 -1.0 3 4"), + treeElement(SelectChoicesMapData.STROKE_WIDTH, "10"), + treeElement(SelectChoicesMapData.STROKE, "#ffffff") + ) + ) + ) + ) + + val prompt = MockFormEntryPromptBuilder() + .withLongText("Which is your favourite place?") + .withSelectChoices(choices) + .withSelectChoiceText(mapOf(choices[0] to "A")) + .build() + + val data = loadDataForPrompt(prompt) + val item = data.getMappableItems().getOrAwaitValue()!![0] as MappableSelectItem.MappableSelectLine + assertThat(item.strokeWidth, equalTo("10")) + assertThat(item.strokeColor, equalTo("#ffffff")) + } + + /** + * Attributes names come from properties defined at + * https://github.com/mapbox/simplestyle-spec/tree/master/1.1.0. + */ + @Test + fun `polygon stroke width, stroke color and fill color are pulled from simple style attributes`() { + val choices = listOf( + selectChoice( + value = "a", + item = treeElement( + children = listOf( + treeElement(SelectChoicesMapData.GEOMETRY, "12.0 -1.0 3 4; 12.1 -1.0 3 4; 12.0 -1.0 3 4"), + treeElement(SelectChoicesMapData.STROKE_WIDTH, "10"), + treeElement(SelectChoicesMapData.STROKE, "#000000"), + treeElement(SelectChoicesMapData.FILL, "#ffffff") + ) + ) + ) + ) + + val prompt = MockFormEntryPromptBuilder() + .withLongText("Which is your favourite place?") + .withSelectChoices(choices) + .withSelectChoiceText(mapOf(choices[0] to "A")) + .build() + + val data = loadDataForPrompt(prompt) + val item = data.getMappableItems().getOrAwaitValue()!![0] as MappableSelectItem.MappableSelectPolygon + assertThat(item.strokeWidth, equalTo("10")) + assertThat(item.strokeColor, equalTo("#000000")) + assertThat(item.fillColor, equalTo("#ffffff")) + } + @Test fun `uses different icon if marker-symbol is defined`() { val choices = listOf( @@ -245,8 +310,8 @@ class SelectChoicesMapDataTest { value = "a", item = treeElement( children = listOf( - treeElement("geometry", "12.0 -1.0 305 0"), - treeElement("marker-symbol", "A") + treeElement(SelectChoicesMapData.GEOMETRY, "12.0 -1.0 305 0"), + treeElement(SelectChoicesMapData.MARKER_SYMBOL, "A") ) ) ) @@ -259,7 +324,7 @@ class SelectChoicesMapDataTest { .build() val data = loadDataForPrompt(prompt) - val item = data.getMappableItems().getOrAwaitValue()!![0] + val item = data.getMappableItems().getOrAwaitValue()!![0] as MappableSelectItem.MappableSelectPoint assertThat(item.smallIcon, equalTo(org.odk.collect.icons.R.drawable.ic_map_marker_small)) assertThat(item.largeIcon, equalTo(org.odk.collect.icons.R.drawable.ic_map_marker_big)) } diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/items/SelectOneFromMapDialogFragmentTest.kt b/collect_app/src/test/java/org/odk/collect/android/widgets/items/SelectOneFromMapDialogFragmentTest.kt index a5c5218bc72..c53e9bae76f 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/items/SelectOneFromMapDialogFragmentTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/items/SelectOneFromMapDialogFragmentTest.kt @@ -26,6 +26,7 @@ import org.mockito.kotlin.whenever import org.odk.collect.android.databinding.SelectOneFromMapDialogLayoutBinding import org.odk.collect.android.formentry.FormEntryViewModel import org.odk.collect.android.injection.config.AppDependencyModule +import org.odk.collect.android.storage.StoragePathProvider import org.odk.collect.android.support.CollectHelpers import org.odk.collect.android.support.MockFormEntryPromptBuilder import org.odk.collect.android.utilities.Appearances @@ -37,13 +38,14 @@ import org.odk.collect.android.widgets.support.NoOpMapFragment import org.odk.collect.androidshared.ui.FragmentFactoryBuilder import org.odk.collect.async.Scheduler import org.odk.collect.fragmentstest.FragmentScenarioLauncherRule +import org.odk.collect.geo.selection.IconifiedText import org.odk.collect.geo.selection.MappableSelectItem -import org.odk.collect.geo.selection.MappableSelectItem.IconifiedText import org.odk.collect.geo.selection.SelectionMapFragment import org.odk.collect.geo.selection.SelectionMapFragment.Companion.REQUEST_SELECT_ITEM import org.odk.collect.maps.MapFragment import org.odk.collect.maps.MapFragmentFactory import org.odk.collect.maps.MapPoint +import org.odk.collect.maps.layers.ReferenceLayerRepository import org.odk.collect.settings.SettingsProvider import org.odk.collect.testshared.FakeScheduler @@ -53,11 +55,11 @@ class SelectOneFromMapDialogFragmentTest { private val selectChoices = listOf( selectChoice( value = "a", - item = treeElement(children = listOf(treeElement("geometry", "12.0 -1.0 305 0"))) + item = treeElement(children = listOf(treeElement(SelectChoicesMapData.GEOMETRY, "12.0 -1.0 305 0"))) ), selectChoice( value = "b", - item = treeElement(children = listOf(treeElement("geometry", "13.0 -1.0 305 0"))) + item = treeElement(children = listOf(treeElement(SelectChoicesMapData.GEOMETRY, "13.0 -1.0 305 0"))) ) ) @@ -110,6 +112,13 @@ class SelectOneFromMapDialogFragmentTest { override fun providesScheduler(workManager: WorkManager?): Scheduler { return scheduler } + + override fun providesReferenceLayerRepository( + storagePathProvider: StoragePathProvider, + settingsProvider: SettingsProvider + ): ReferenceLayerRepository { + return mock() + } }) } @@ -170,46 +179,40 @@ class SelectOneFromMapDialogFragmentTest { assertThat(data.getMapTitle().value, equalTo(prompt.longText)) assertThat(data.getItemCount().value, equalTo(prompt.selectChoices.size)) - val firstFeatureGeometry = selectChoices[0].getChild("geometry")!!.split(" ") - val secondFeatureGeometry = selectChoices[1].getChild("geometry")!!.split(" ") + val firstFeatureGeometry = selectChoices[0].getChild(SelectChoicesMapData.GEOMETRY)!!.split(" ") + val secondFeatureGeometry = selectChoices[1].getChild(SelectChoicesMapData.GEOMETRY)!!.split(" ") assertThat( data.getMappableItems().value, equalTo( listOf( - MappableSelectItem.WithAction( + MappableSelectItem.MappableSelectPoint( 0, - listOf( - MapPoint( - firstFeatureGeometry[0].toDouble(), - firstFeatureGeometry[1].toDouble(), - firstFeatureGeometry[2].toDouble(), - firstFeatureGeometry[3].toDouble() - ) - ), - org.odk.collect.icons.R.drawable.ic_map_marker_with_hole_small, - org.odk.collect.icons.R.drawable.ic_map_marker_with_hole_big, "A", - emptyList(), - IconifiedText( + point = MapPoint( + firstFeatureGeometry[0].toDouble(), + firstFeatureGeometry[1].toDouble(), + firstFeatureGeometry[2].toDouble(), + firstFeatureGeometry[3].toDouble() + ), + smallIcon = org.odk.collect.icons.R.drawable.ic_map_marker_with_hole_small, + largeIcon = org.odk.collect.icons.R.drawable.ic_map_marker_with_hole_big, + action = IconifiedText( org.odk.collect.icons.R.drawable.ic_save, application.getString(org.odk.collect.strings.R.string.select_item) ) ), - MappableSelectItem.WithAction( + MappableSelectItem.MappableSelectPoint( 1, - listOf( - MapPoint( - secondFeatureGeometry[0].toDouble(), - secondFeatureGeometry[1].toDouble(), - secondFeatureGeometry[2].toDouble(), - secondFeatureGeometry[3].toDouble() - ) - ), - org.odk.collect.icons.R.drawable.ic_map_marker_with_hole_small, - org.odk.collect.icons.R.drawable.ic_map_marker_with_hole_big, "B", - emptyList(), - IconifiedText( + point = MapPoint( + secondFeatureGeometry[0].toDouble(), + secondFeatureGeometry[1].toDouble(), + secondFeatureGeometry[2].toDouble(), + secondFeatureGeometry[3].toDouble() + ), + smallIcon = org.odk.collect.icons.R.drawable.ic_map_marker_with_hole_small, + largeIcon = org.odk.collect.icons.R.drawable.ic_map_marker_with_hole_big, + action = IconifiedText( org.odk.collect.icons.R.drawable.ic_save, application.getString(org.odk.collect.strings.R.string.select_item) ) diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/items/SelectOneFromMapWidgetTest.kt b/collect_app/src/test/java/org/odk/collect/android/widgets/items/SelectOneFromMapWidgetTest.kt index 2635e06f304..b76851ecf46 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/items/SelectOneFromMapWidgetTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/items/SelectOneFromMapWidgetTest.kt @@ -24,6 +24,7 @@ import org.odk.collect.android.formentry.questions.QuestionDetails import org.odk.collect.android.injection.config.AppDependencyModule import org.odk.collect.android.listeners.AdvanceToNextListener import org.odk.collect.android.preferences.GuidanceHint +import org.odk.collect.android.storage.StoragePathProvider import org.odk.collect.android.support.CollectHelpers import org.odk.collect.android.support.MockFormEntryPromptBuilder import org.odk.collect.android.support.WidgetTestActivity @@ -36,6 +37,7 @@ import org.odk.collect.android.widgets.utilities.QuestionFontSizeUtils.FontSize import org.odk.collect.androidshared.ui.FragmentFactoryBuilder import org.odk.collect.maps.MapFragment import org.odk.collect.maps.MapFragmentFactory +import org.odk.collect.maps.layers.ReferenceLayerRepository import org.odk.collect.permissions.PermissionsChecker import org.odk.collect.permissions.PermissionsProvider import org.odk.collect.settings.InMemSettingsProvider @@ -75,6 +77,13 @@ class SelectOneFromMapWidgetTest { } } } + + override fun providesReferenceLayerRepository( + storagePathProvider: StoragePathProvider, + settingsProvider: SettingsProvider + ): ReferenceLayerRepository { + return mock() + } }) } diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/range/RangeDecimalWidgetTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/range/RangeDecimalWidgetTest.java index d7e0b632d07..a1893fcc0bd 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/range/RangeDecimalWidgetTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/range/RangeDecimalWidgetTest.java @@ -14,6 +14,7 @@ import org.junit.runner.RunWith; import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.android.listeners.WidgetValueChangedListener; +import org.odk.collect.android.support.MockFormEntryPromptBuilder; import java.math.BigDecimal; @@ -98,8 +99,12 @@ public void whenSliderIsDiscrete_widgetShowsCorrectSlider() { @Test public void whenSliderIsContinuous_widgetShowsCorrectSlider() { - when(rangeQuestion.getAppearanceAttr()).thenReturn(NO_TICKS_APPEARANCE); - RangeDecimalWidget widget = createWidget(promptWithQuestionDefAndAnswer(rangeQuestion, new StringData("2.5"))); + FormEntryPrompt prompt = new MockFormEntryPromptBuilder() + .withQuestion(rangeQuestion) + .withAnswer(new StringData("2.5")) + .withAppearance(NO_TICKS_APPEARANCE) + .build(); + RangeDecimalWidget widget = createWidget(prompt); assertThat(widget.slider.getValueFrom(), equalTo(1.5F)); assertThat(widget.slider.getValueTo(), equalTo(5.5F)); diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/range/RangeIntegerWidgetTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/range/RangeIntegerWidgetTest.java index 175631000eb..79ddeab2051 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/range/RangeIntegerWidgetTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/range/RangeIntegerWidgetTest.java @@ -14,6 +14,7 @@ import org.junit.runner.RunWith; import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.android.listeners.WidgetValueChangedListener; +import org.odk.collect.android.support.MockFormEntryPromptBuilder; import java.math.BigDecimal; @@ -99,8 +100,12 @@ public void whenSliderIsDiscrete_widgetShowsCorrectSliderValues() { @Test public void whenSliderIsContinuous_widgetShowsCorrectSliderValues() { - when(rangeQuestion.getAppearanceAttr()).thenReturn(NO_TICKS_APPEARANCE); - RangeIntegerWidget widget = createWidget(promptWithQuestionDefAndAnswer(rangeQuestion, new StringData("4"))); + FormEntryPrompt prompt = new MockFormEntryPromptBuilder() + .withQuestion(rangeQuestion) + .withAnswer(new StringData("4")) + .withAppearance(NO_TICKS_APPEARANCE) + .build(); + RangeIntegerWidget widget = createWidget(prompt); assertThat(widget.slider.getValueFrom(), equalTo(1.0F)); assertThat(widget.slider.getValueTo(), equalTo(10.0F)); diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/support/NoOpMapFragment.kt b/collect_app/src/test/java/org/odk/collect/android/widgets/support/NoOpMapFragment.kt index a09636c0e6b..479ab65d6a2 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/support/NoOpMapFragment.kt +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/support/NoOpMapFragment.kt @@ -1,8 +1,10 @@ package org.odk.collect.android.widgets.support import androidx.fragment.app.Fragment +import org.odk.collect.maps.LineDescription import org.odk.collect.maps.MapFragment import org.odk.collect.maps.MapPoint +import org.odk.collect.maps.PolygonDescription import org.odk.collect.maps.markers.MarkerDescription import org.odk.collect.maps.markers.MarkerIconDescription @@ -53,15 +55,11 @@ class NoOpMapFragment : Fragment(), MapFragment { TODO("Not yet implemented") } - override fun addPolyLine( - points: MutableIterable, - closed: Boolean, - draggable: Boolean - ): Int { + override fun addPolyLine(lineDescription: LineDescription): Int { TODO("Not yet implemented") } - override fun addPolygon(points: MutableIterable): Int { + override fun addPolygon(polygonDescription: PolygonDescription): Int { TODO("Not yet implemented") } diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/RangeWidgetUtilsTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/RangeWidgetUtilsTest.java index aa781440b50..664e4d395c3 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/RangeWidgetUtilsTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/RangeWidgetUtilsTest.java @@ -28,6 +28,7 @@ import org.odk.collect.android.databinding.RangePickerWidgetAnswerBinding; import org.odk.collect.android.fragments.dialogs.NumberPickerDialog; import org.odk.collect.android.support.CollectHelpers; +import org.odk.collect.android.support.MockFormEntryPromptBuilder; import org.odk.collect.android.support.WidgetTestActivity; import org.odk.collect.android.views.TrackingTouchSlider; import org.odk.collect.testshared.RobolectricHelpers; @@ -178,9 +179,12 @@ public void setUpLayoutElements_forHorizontalSliderWidget_shouldShowCorrectSlide @Test public void setUpLayoutElements_forVerticalSliderWidget_shouldShowCorrectSlider() { - when(rangeQuestion.getAppearanceAttr()).thenReturn(VERTICAL_APPEARANCE); + FormEntryPrompt prompt = new MockFormEntryPromptBuilder() + .withQuestion(rangeQuestion) + .withAppearance(VERTICAL_APPEARANCE) + .build(); RangeWidgetUtils.RangeWidgetLayoutElements layoutElements = RangeWidgetUtils.setUpLayoutElements( - widgetTestActivity(), promptWithReadOnlyAndQuestionDef(rangeQuestion)); + widgetTestActivity(), prompt); assertThat(layoutElements.getSlider().getRotation(), equalTo(270.0F)); } @@ -241,7 +245,7 @@ public void whenPromptHasInvalidWidgetParameters_pickerButtonIsDisabled() { @Test public void clickingPickerButton_showsNumberPickerDialog() { WidgetTestActivity activity = CollectHelpers.createThemedActivity(WidgetTestActivity.class); - RangeWidgetUtils.showNumberPickerDialog(activity, new String[]{}, 0, 0); + RangeWidgetUtils.showNumberPickerDialog(activity, new String[]{"1", "2", "3"}, 0, 0); RobolectricHelpers.runLooper(); NumberPickerDialog numberPickerDialog = (NumberPickerDialog) activity.getSupportFragmentManager() .findFragmentByTag(NumberPickerDialog.NUMBER_PICKER_DIALOG_TAG); diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/warnings/SpacesInUnderlyingValuesTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/warnings/SpacesInUnderlyingValuesTest.java index 934b74606a9..93a19fcb087 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/warnings/SpacesInUnderlyingValuesTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/warnings/SpacesInUnderlyingValuesTest.java @@ -9,6 +9,8 @@ import java.util.List; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -74,22 +76,24 @@ public void returnsInvalidValues() { } @Test - public void detectsSpaceInTheBeginningOfUnderlyingValue() { + public void spaceInTheBeginningOfUnderlyingValueIsTrimmed() { List items = Lists.newArrayList( new SelectChoice("label", " before") ); subject.check(items); - assertTrue(subject.hasInvalidValues()); + assertThat(items.get(0).getValue(), equalTo("before")); + assertFalse(subject.hasInvalidValues()); } @Test - public void detectsSpaceInTheEndOfUnderlyingValue() { + public void spaceInTheTheEndOfUnderlyingValueIsTrimmed() { List items = Lists.newArrayList( new SelectChoice("label", "after ") ); subject.check(items); - assertTrue(subject.hasInvalidValues()); + assertThat(items.get(0).getValue(), equalTo("after")); + assertFalse(subject.hasInvalidValues()); } } \ No newline at end of file diff --git a/collect_app/src/test/resources/robolectric.properties b/collect_app/src/test/resources/robolectric.properties index b4a45b8f5ec..091da7dcc42 100644 --- a/collect_app/src/test/resources/robolectric.properties +++ b/collect_app/src/test/resources/robolectric.properties @@ -1,4 +1,3 @@ application=org.odk.collect.android.application.RobolectricApplication -instrumentedPackages=androidx.loader.content sdk=33 diff --git a/config/quality.gradle b/config/quality.gradle index 5e16af2f6a7..8dcfbdb160a 100644 --- a/config/quality.gradle +++ b/config/quality.gradle @@ -5,7 +5,7 @@ def reportsDir = "${project.buildDir}/reports" apply plugin: 'checkstyle' -checkstyle.toolVersion = '10.12.4' +checkstyle.toolVersion = '10.13.0' tasks.register("checkstyle", Checkstyle) { configFile file("$configDir/checkstyle.xml") @@ -19,6 +19,13 @@ tasks.register("checkstyle", Checkstyle) { classpath = files() } +// https://github.com/gradle/gradle/issues/27035 +configurations.checkstyle { + resolutionStrategy.capabilitiesResolution.withCapability("com.google.collections:google-collections") { + select("com.google.guava:guava:0") + } +} + //------------------------Pmd------------------------// apply plugin: 'pmd' @@ -53,5 +60,5 @@ tasks.register("pmd", Pmd) { apply plugin: "org.jlleitschuh.gradle.ktlint" ktlint { - disabledRules.set(["no-blank-lines-in-chained-method-calls"]) + version = "1.1.1" } \ No newline at end of file diff --git a/docs/ANALYTICS-QUESTIONS.md b/docs/ANALYTICS-QUESTIONS.md new file mode 100644 index 00000000000..00a44b5fcd7 --- /dev/null +++ b/docs/ANALYTICS-QUESTIONS.md @@ -0,0 +1,15 @@ +# Analytics questions + +A list of questions asked and answered via analytics already sectioned by the date (with a 90 day recording window). + +## June 2024 + +- How often is high-res video disabled? 2% of users using video widget. +- How often is `OSMWidget` used? Once. +- How many users create shortcuts? 126. +- How many user are using ADB to add forms/instances? 904 for Forms, 2728 for instances (500k events). +- How many users are importing encrypted instances? 10. +- How often is partial form finalization used? Never. +- How many forms use auto send attribute? 200+ (max recordable) with 1.5k users. +- How many forms use auto delete attribute? 200+ (max recordable) with 1k users. +- How often is old printer widget (`ExPrinterWidget`) used? Never. diff --git a/docs/CODE-GUIDELINES.md b/docs/CODE-GUIDELINES.md index 85b46ddec5f..c2daebe7584 100644 --- a/docs/CODE-GUIDELINES.md +++ b/docs/CODE-GUIDELINES.md @@ -28,6 +28,29 @@ assertThat(ClassToTest.methodToTest("input"), equalTo("expected")); assertThat(ClassToTest.methodReturnsNull(), equalTo(null)); ``` +### Backtick test naming + +Tests written in Kotlin should use [backtick enclosed method names](https://kotlinlang.org/docs/coding-conventions.html#names-for-test-methods) and use `#` JavaDoc syntax to refer to an object member's in unit tests: + +```kotlin +@Test +fun `#getHello returns hello string`() { + assertThat(subject.getHello(), equalTo("hello")) +} +``` + +Test naming structure is hard to put strict rules around, but in general conditions ("given") should go after the action ("when") and assertion ("then") in this style of method naming: + +```kotlin +@Test +fun `#getHello returns goodbye string when subject is angry`() { + subject.setAngry(true) + assertThat(subject.getHello(), equalTo("goodbye")) +} +``` + +Also, to keep test names short, it's best to avoid words like "should" or "will" (`#getHello should return hello string`) and instead use definitive statements with [third person singular verb conjugations](https://www.grammarly.com/blog/verb-forms/) ("returns", "sets", "fetches" etc). + ## XML style guidelines Follow these naming conventions in Android XML files: @@ -155,12 +178,18 @@ Collect is a multi module Gradle project. Modules should have a focused feature There's no easy way to define exactly when a new module should be pulled out of an existing one or when new code calls for a new module - it's best to discuss that with the team before making any decisions. Once a structure has been agreed on, to add a new module: 1. Click `File > New > New module...` in Android Studio -1. Decide whether the new module should be an "Android Library" or "Java or Kotlin Library" - ideally as much code as possible could avoid relying on Android but a lot of features will require at least one Android Library module -1. Review the generated `build.gradle` and remove any unnecessary dependencies or setup -1. Add quality checks to the module's `build.gradle`: +2. Decide whether the new module should be an "Android Library" or "Java or Kotlin Library" - ideally as much code as possible could avoid relying on Android but a lot of features will require at least one Android Library module +3. Review the generated `build.gradle` and remove any unnecessary dependencies or setup +4. Add quality checks to the module's `build.gradle`: ``` apply from: '../config/quality.gradle' ``` -1. If the module will have tests, make sure they get run on CI by adding a line to `test_modules.txt` with `:test` for a Java Library or `:testDebug` for an Android library +5. If the module will have tests, make sure they get run on CI by adding a line to `test_modules.txt` with `` and if it's a non-Android module, registering the `testDebug` task in its `build.gradle` file: + + ``` + tasks.register("testDebug") { + dependsOn("test") + } + ``` diff --git a/docs/STATE.md b/docs/STATE.md index 32a72df39ee..dd1c3d99fb9 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -1,8 +1,6 @@ # State of the union -The purpose of this document is to give anyone who reads it a quick overview -of both the current state and the direction of the code. The community should try -and update this document as the code evolves. +The purpose of this document is to give anyone who reads it a quick overview of both the current state and the direction of the code. The community should try and update this document as the code evolves. ## How we got here @@ -21,28 +19,29 @@ and update this document as the code evolves. * UI is "iconic" (old) but with a lot of inconsistencies and quirks and is best adapted to small screens (although often used on tablets) * A lot of code lives in between one "god" Activity (`FormFillingActivity`) and a process singleton (`FormController`) * Core form entry flow uses custom side-to-side swipe view (in `FormFillingActivity` made up of `ODKView`) -* Questions are rendered using a view "framework" of implementations inheriting from `QuestionWidget` (which is documented at in [WIDGETS.MD](WIDGETS.md)) -* Async/reactivity handled with a mixture of callbacks and LiveData +* Questions are rendered using a view "framework" of implementations inheriting from `QuestionWidget` (which is documented in [WIDGETS.MD](WIDGETS.md)) which are also used to store UI state during form entry * App mostly stores data in flat files indexed in SQLite * Access to data in SQLite happens through repository objects which deal in data/domain objects (`FormsRepository` and `Form` for example) * Settings UIs for the app use Android's Preferences abstraction -* App uses [Material 2 Theming](https://material.io/develop/android/theming/theming-overview) so [Material components](https://material.io/components?platform=android) (using [Material 3 components](https://m3.material.io/) styles) are preferred over custom or platform ones. +* App uses [Material 3 Theming](https://m3.material.io/foundations/customization) so [Material components](https://material.io/components?platform=android) are preferred over custom or platform ones. * Dagger2 is used to inject "black box" objects such as Activity and just uses a very basic setup * Http is handled using OkHttp3 and https client abstractions are generally wrapped in Android's AsyncTask -* Geo activities use three engines (Mapbox, osmdroid, Google Maps) depending on the selected basemap even though Mapbox could do everything osmdroid does +* Geo activities use three engines (Mapbox, osmdroid, Google Maps) depending on the selected basemap * Code goes through static analysis using CheckStyle, PMD, ktlint and Android Lint -* Forms get into the app from three different sources (Open Rosa servers, Google Drive and disk) but the logic for this is disparate and they don't sit behind a common interface +* Forms get into the app from two different sources (Open Rosa servers and disk) but the logic for this is disparate and they don't sit behind a common interface * Instances are linked to the forms they are instances of through formid and version. However, the same formid and version combination could represent multiple forms in storage * `SharedPreferences` is wrapped in app's own `Settings` abstraction +* The form hierarchy is rendered using `FormHierarchyActivity`, which hasn't been seriously touched (at a code or design) level for a few years ## Where we're going -* General effort to increase test coverage and quality while working on anything and pushing more for tests in PR review -* Slowly moving responsibilities out of `FormFillingActivity` -* Writing pretty much all new code in Kotlin +* General effort to increase test coverage and quality while working on anything and enforcing tests for new code in PR review +* Moving responsibilities out of `FormFillingActivity` +* Writing all new code in Kotlin * Writing new code using a [multi-module approach](CODE-GUIDELINES.md#gradle-sub-modules) (feature modules, mini frameworks etc) and breaking old code out into modules when opportunities come up * Trying to remove technical debt flagged with `@Deprecated` -* Replacing async work such as `AsyncTask` with `LiveData` + `Scheduler` abstraction +* Replacing async work such as `AsyncTask` with `Flow`/`LiveData` + `Scheduler` abstraction * Gradually removing use of `CursorLoader` (all remaining uses are in `CursorLoaderFactory`) * Using AndroidX Test in new local tests and migrating other local tests as we touch them (from classic Robolectric) -* Replacing uses of Google Guava with similar helpers in Kotlin Standard Library +* Moving towards a ["data services"](data_services_architecture.pdf) oriented architecture that has emerged over time that uses AndroidX Architecture Components for the core of the UI (Fragment, View, ViewModel etc.) +* Improving the `MapFragment` abstraction so more logic can be shared between the map engines diff --git a/docs/data_services_architecture.pdf b/docs/data_services_architecture.pdf new file mode 100644 index 00000000000..0e07beff0c5 Binary files /dev/null and b/docs/data_services_architecture.pdf differ diff --git a/download-robolectric-deps.sh b/download-robolectric-deps.sh index 645e004c3b2..7012d25620f 100755 --- a/download-robolectric-deps.sh +++ b/download-robolectric-deps.sh @@ -9,17 +9,19 @@ wget -nc https://repo1.maven.org/maven2/org/robolectric/android-all-instrumented wget -nc https://repo1.maven.org/maven2/org/robolectric/android-all-instrumented/12-robolectric-7732740-i4/android-all-instrumented-12-robolectric-7732740-i4.jar -P robolectric-deps wget -nc https://repo1.maven.org/maven2/org/robolectric/android-all-instrumented/12.1-robolectric-8229987-i4/android-all-instrumented-12.1-robolectric-8229987-i4.jar -P robolectric-deps wget -nc https://repo1.maven.org/maven2/org/robolectric/android-all-instrumented/13-robolectric-9030017-i4/android-all-instrumented-13-robolectric-9030017-i4.jar -P robolectric-deps +wget -nc https://repo1.maven.org/maven2/org/robolectric/android-all-instrumented/14-robolectric-10818077-i4/android-all-instrumented-14-robolectric-10818077-i4.jar -P robolectric-deps -mkdir -p collect_app/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p audiorecorder/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p projects/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p location/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p androidshared/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p geo/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p permissions/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p settings/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p maps/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p errors/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p selfie-camera/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p qr-code/src/test/resources && cp robolectric-deps.properties "$_" -mkdir -p draw/src/test/resources && cp robolectric-deps.properties "$_" +dest_dir="src/test/resources" +mkdir -p collect_app/$dest_dir && cp robolectric-deps.properties collect_app/$dest_dir +mkdir -p audiorecorder/$dest_dir && cp robolectric-deps.properties audiorecorder/$dest_dir +mkdir -p projects/$dest_dir && cp robolectric-deps.properties projects/$dest_dir +mkdir -p location/$dest_dir && cp robolectric-deps.properties location/$dest_dir +mkdir -p androidshared/$dest_dir && cp robolectric-deps.properties androidshared/$dest_dir +mkdir -p geo/$dest_dir && cp robolectric-deps.properties geo/$dest_dir +mkdir -p permissions/$dest_dir && cp robolectric-deps.properties permissions/$dest_dir +mkdir -p settings/$dest_dir && cp robolectric-deps.properties settings/$dest_dir +mkdir -p maps/$dest_dir && cp robolectric-deps.properties maps/$dest_dir +mkdir -p errors/$dest_dir && cp robolectric-deps.properties errors/$dest_dir +mkdir -p selfie-camera/$dest_dir && cp robolectric-deps.properties selfie-camera/$dest_dir +mkdir -p qr-code/$dest_dir && cp robolectric-deps.properties qr-code/$dest_dir +mkdir -p draw/$dest_dir && cp robolectric-deps.properties draw/$dest_dir diff --git a/entities/build.gradle.kts b/entities/build.gradle.kts index 5fb9a9bbd69..363e37f9253 100644 --- a/entities/build.gradle.kts +++ b/entities/build.gradle.kts @@ -51,8 +51,15 @@ dependencies { implementation(project(":strings")) implementation(project(":shared")) implementation(project(":androidshared")) + implementation(project(":material")) + implementation(project(":async")) + implementation(project(":lists")) implementation(Dependencies.kotlin_stdlib) + implementation(Dependencies.javarosa) { + exclude(group = "joda-time") + exclude(group = "org.hamcrest", module = "hamcrest-all") + } implementation(Dependencies.androidx_appcompat) implementation(Dependencies.android_material) implementation(Dependencies.androidx_navigation_fragment_ktx) @@ -62,4 +69,6 @@ dependencies { testImplementation(Dependencies.junit) testImplementation(Dependencies.robolectric) + testImplementation(Dependencies.hamcrest) + testImplementation(Dependencies.mockito_kotlin) } diff --git a/entities/src/main/AndroidManifest.xml b/entities/src/main/AndroidManifest.xml index f9e0de9f94d..5a97c45de2e 100644 --- a/entities/src/main/AndroidManifest.xml +++ b/entities/src/main/AndroidManifest.xml @@ -4,7 +4,7 @@ + android:theme="@style/Theme.Material3.DayNight"/> diff --git a/entities/src/main/java/org/odk/collect/entities/DaggerSetup.kt b/entities/src/main/java/org/odk/collect/entities/DaggerSetup.kt index cd714ae8508..6a6ee1d1e56 100644 --- a/entities/src/main/java/org/odk/collect/entities/DaggerSetup.kt +++ b/entities/src/main/java/org/odk/collect/entities/DaggerSetup.kt @@ -3,6 +3,7 @@ package org.odk.collect.entities import dagger.Component import dagger.Module import dagger.Provides +import org.odk.collect.async.Scheduler import javax.inject.Singleton interface EntitiesDependencyComponentProvider { @@ -21,8 +22,7 @@ interface EntitiesDependencyComponent { fun build(): EntitiesDependencyComponent } - fun inject(datasetsFragment: DatasetsFragment) - fun inject(datasetsFragment: EntitiesFragment) + fun inject(entityBrowserActivity: EntityBrowserActivity) } @Module @@ -32,4 +32,9 @@ open class EntitiesDependencyModule { open fun providesEntitiesRepository(): EntitiesRepository { throw UnsupportedOperationException("This should be overridden by dependent application") } + + @Provides + open fun providesScheduler(): Scheduler { + throw UnsupportedOperationException("This should be overridden by dependent application") + } } diff --git a/entities/src/main/java/org/odk/collect/entities/DatasetsFragment.kt b/entities/src/main/java/org/odk/collect/entities/DatasetsFragment.kt deleted file mode 100644 index ec468b81e8c..00000000000 --- a/entities/src/main/java/org/odk/collect/entities/DatasetsFragment.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.odk.collect.entities - -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.navigation.findNavController -import org.odk.collect.entities.databinding.ListItemLayoutBinding -import org.odk.collect.entities.databinding.ListLayoutBinding -import javax.inject.Inject - -class DatasetsFragment : Fragment() { - - @Inject - lateinit var entitiesRepository: EntitiesRepository - - override fun onAttach(context: Context) { - super.onAttach(context) - (context.applicationContext as EntitiesDependencyComponentProvider).entitiesDependencyComponent.inject(this) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return ListLayoutBinding.inflate(inflater, container, false).root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val binding = ListLayoutBinding.bind(view) - - entitiesRepository.getDatasets().forEach { dataset -> - val item = ListItemLayoutBinding.inflate(layoutInflater) - item.content.text = dataset - - item.root.setOnClickListener { - view.findNavController().navigate(R.id.datasets_to_entities, EntitiesFragmentArgs(dataset).toBundle()) - } - - binding.list.addView(item.root) - } - } -} diff --git a/entities/src/main/java/org/odk/collect/entities/EntitiesFragment.kt b/entities/src/main/java/org/odk/collect/entities/EntitiesFragment.kt index b05e37cd405..f0daa09f365 100644 --- a/entities/src/main/java/org/odk/collect/entities/EntitiesFragment.kt +++ b/entities/src/main/java/org/odk/collect/entities/EntitiesFragment.kt @@ -6,20 +6,18 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder import org.odk.collect.entities.databinding.ListLayoutBinding -import javax.inject.Inject +import org.odk.collect.lists.RecyclerViewUtils +import org.odk.collect.lists.RecyclerViewUtils.matchParentWidth -class EntitiesFragment : Fragment() { +class EntitiesFragment(private val viewModelFactory: ViewModelProvider.Factory) : Fragment() { - @Inject - lateinit var entitiesRepository: EntitiesRepository - - override fun onAttach(context: Context) { - super.onAttach(context) - - (context.applicationContext as EntitiesDependencyComponentProvider) - .entitiesDependencyComponent.inject(this) - } + private val entitiesViewModel by viewModels { viewModelFactory } override fun onCreateView( inflater: LayoutInflater, @@ -30,14 +28,41 @@ class EntitiesFragment : Fragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val dataset = EntitiesFragmentArgs.fromBundle(requireArguments()).dataset val binding = ListLayoutBinding.bind(view) + binding.list.layoutManager = LinearLayoutManager(requireContext()) + binding.list.addItemDecoration(RecyclerViewUtils.verticalLineDivider(requireContext())) - entitiesRepository.getEntities(dataset).forEach { entity -> - val item = EntityItemView(view.context) - item.setEntity(entity) - - binding.list.addView(item) + val list = EntitiesFragmentArgs.fromBundle(requireArguments()).list + entitiesViewModel.getEntities(list).observe(viewLifecycleOwner) { + binding.list.adapter = EntitiesAdapter(it) } } } + +private class EntitiesAdapter(private val data: List) : + RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, position: Int): EntityViewHolder { + return EntityViewHolder(parent.context) + } + + override fun getItemCount(): Int { + return data.size + } + + override fun onBindViewHolder(viewHolder: EntityViewHolder, position: Int) { + val entity = data[position] + viewHolder.setEntity(entity) + } +} + +private class EntityViewHolder(context: Context) : ViewHolder(EntityItemView(context)) { + + init { + matchParentWidth() + } + + fun setEntity(entity: Entity) { + (itemView as EntityItemView).setEntity(entity) + } +} diff --git a/entities/src/main/java/org/odk/collect/entities/EntitiesRepository.kt b/entities/src/main/java/org/odk/collect/entities/EntitiesRepository.kt index 71d2183a561..c72ae786503 100644 --- a/entities/src/main/java/org/odk/collect/entities/EntitiesRepository.kt +++ b/entities/src/main/java/org/odk/collect/entities/EntitiesRepository.kt @@ -1,7 +1,10 @@ package org.odk.collect.entities interface EntitiesRepository { - fun save(entity: Entity) - fun getDatasets(): Set - fun getEntities(dataset: String): List + fun save(vararg entities: Entity) + fun getLists(): Set + fun getEntities(list: String): List + fun clear() + fun addList(list: String) + fun delete(id: String) } diff --git a/entities/src/main/java/org/odk/collect/entities/EntitiesViewModel.kt b/entities/src/main/java/org/odk/collect/entities/EntitiesViewModel.kt new file mode 100644 index 00000000000..104390d6074 --- /dev/null +++ b/entities/src/main/java/org/odk/collect/entities/EntitiesViewModel.kt @@ -0,0 +1,44 @@ +package org.odk.collect.entities + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import org.odk.collect.async.Scheduler + +class EntitiesViewModel( + private val scheduler: Scheduler, + private val entitiesRepository: EntitiesRepository +) : ViewModel() { + + private val _lists = MutableLiveData>(emptyList()) + val lists: LiveData> = _lists + + init { + scheduler.immediate { + _lists.postValue(entitiesRepository.getLists().toList()) + } + } + + fun getEntities(list: String): LiveData> { + val result = MutableLiveData>(emptyList()) + scheduler.immediate { + result.postValue(entitiesRepository.getEntities(list)) + } + + return result + } + + fun clearAll() { + scheduler.immediate { + entitiesRepository.clear() + _lists.postValue(entitiesRepository.getLists().toList()) + } + } + + fun addEntityList(name: String) { + scheduler.immediate { + entitiesRepository.addList(name) + _lists.postValue(entitiesRepository.getLists().toList()) + } + } +} diff --git a/entities/src/main/java/org/odk/collect/entities/Entity.kt b/entities/src/main/java/org/odk/collect/entities/Entity.kt index dc88cce54d8..3f812fb7ed9 100644 --- a/entities/src/main/java/org/odk/collect/entities/Entity.kt +++ b/entities/src/main/java/org/odk/collect/entities/Entity.kt @@ -1,3 +1,15 @@ package org.odk.collect.entities -data class Entity(val dataset: String, val properties: List>) +data class Entity @JvmOverloads constructor( + val list: String, + val id: String, + val label: String?, + val version: Int = 1, + val properties: List> = emptyList(), + val state: State = State.OFFLINE +) { + enum class State { + OFFLINE, + ONLINE + } +} diff --git a/entities/src/main/java/org/odk/collect/entities/EntityBrowserActivity.kt b/entities/src/main/java/org/odk/collect/entities/EntityBrowserActivity.kt index cb0190a9dd3..d3a3f2ae60c 100644 --- a/entities/src/main/java/org/odk/collect/entities/EntityBrowserActivity.kt +++ b/entities/src/main/java/org/odk/collect/entities/EntityBrowserActivity.kt @@ -2,15 +2,38 @@ package org.odk.collect.entities import android.os.Bundle import androidx.appcompat.widget.Toolbar +import androidx.lifecycle.viewmodel.viewModelFactory import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.setupWithNavController +import org.odk.collect.androidshared.ui.FragmentFactoryBuilder +import org.odk.collect.async.Scheduler import org.odk.collect.strings.localization.LocalizedActivity +import javax.inject.Inject class EntityBrowserActivity : LocalizedActivity() { + @Inject + lateinit var scheduler: Scheduler + + @Inject + lateinit var entitiesRepository: EntitiesRepository + + val viewModelFactory = viewModelFactory { + addInitializer(EntitiesViewModel::class) { + EntitiesViewModel(scheduler, entitiesRepository) + } + } + override fun onCreate(savedInstanceState: Bundle?) { + supportFragmentManager.fragmentFactory = FragmentFactoryBuilder() + .forClass(EntityListsFragment::class) { EntityListsFragment(viewModelFactory, ::getToolbar) } + .forClass(EntitiesFragment::class) { EntitiesFragment(viewModelFactory) } + .build() + super.onCreate(savedInstanceState) + (applicationContext as EntitiesDependencyComponentProvider) + .entitiesDependencyComponent.inject(this) setContentView(R.layout.entities_layout) @@ -19,7 +42,8 @@ class EntityBrowserActivity : LocalizedActivity() { val navController = navHostFragment.navController val appBarConfiguration = AppBarConfiguration(navController.graph) - val toolbar = findViewById(org.odk.collect.androidshared.R.id.toolbar) - toolbar.setupWithNavController(navController, appBarConfiguration) + getToolbar().setupWithNavController(navController, appBarConfiguration) } + + private fun getToolbar() = findViewById(org.odk.collect.androidshared.R.id.toolbar) } diff --git a/entities/src/main/java/org/odk/collect/entities/EntityItemElement.kt b/entities/src/main/java/org/odk/collect/entities/EntityItemElement.kt new file mode 100644 index 00000000000..9179eb8da10 --- /dev/null +++ b/entities/src/main/java/org/odk/collect/entities/EntityItemElement.kt @@ -0,0 +1,7 @@ +package org.odk.collect.entities + +internal object EntityItemElement { + const val ID = "name" + const val LABEL = "label" + const val VERSION = "__version" +} diff --git a/entities/src/main/java/org/odk/collect/entities/EntityItemView.kt b/entities/src/main/java/org/odk/collect/entities/EntityItemView.kt index 128026c3ace..3c349347fac 100644 --- a/entities/src/main/java/org/odk/collect/entities/EntityItemView.kt +++ b/entities/src/main/java/org/odk/collect/entities/EntityItemView.kt @@ -3,15 +3,18 @@ package org.odk.collect.entities import android.content.Context import android.view.LayoutInflater import android.widget.FrameLayout +import androidx.core.view.isVisible import org.odk.collect.entities.databinding.EntityItemLayoutBinding class EntityItemView(context: Context) : FrameLayout(context) { - private val binding = EntityItemLayoutBinding.inflate(LayoutInflater.from(context), this, true) + val binding = EntityItemLayoutBinding.inflate(LayoutInflater.from(context), this, true) fun setEntity(entity: Entity) { + binding.label.text = entity.label binding.properties.text = entity.properties .sortedBy { it.first } - .joinToString(separator = ", ") { "${it.first}: ${it.second}" } + .joinToString(separator = "\n") { "${it.first}: ${it.second}" } + binding.offlinePill.isVisible = entity.state == Entity.State.OFFLINE } } diff --git a/entities/src/main/java/org/odk/collect/entities/EntityListsFragment.kt b/entities/src/main/java/org/odk/collect/entities/EntityListsFragment.kt new file mode 100644 index 00000000000..6e1470fe508 --- /dev/null +++ b/entities/src/main/java/org/odk/collect/entities/EntityListsFragment.kt @@ -0,0 +1,126 @@ +package org.odk.collect.entities + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.core.view.MenuHost +import androidx.core.view.MenuProvider +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.NavController +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.odk.collect.entities.databinding.AddEntitiesDialogLayoutBinding +import org.odk.collect.entities.databinding.EntityListItemLayoutBinding +import org.odk.collect.entities.databinding.ListLayoutBinding +import org.odk.collect.lists.RecyclerViewUtils + +class EntityListsFragment( + private val viewModelFactory: ViewModelProvider.Factory, + private val menuHost: () -> MenuHost +) : Fragment() { + + private val entitiesViewModel by viewModels { viewModelFactory } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ListLayoutBinding.inflate(inflater, container, false).root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = ListLayoutBinding.bind(view) + binding.list.layoutManager = LinearLayoutManager(requireContext()) + binding.list.addItemDecoration(RecyclerViewUtils.verticalLineDivider(requireContext())) + + entitiesViewModel.lists.observe(viewLifecycleOwner) { + binding.list.adapter = ListsAdapter(it, findNavController()) + } + + menuHost().addMenuProvider( + ListsMenuProvider(entitiesViewModel, requireContext()), + viewLifecycleOwner + ) + } +} + +private class ListsMenuProvider( + private val entitiesViewModel: EntitiesViewModel, + private val context: Context +) : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.entity_lists, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.clear_entities -> { + entitiesViewModel.clearAll() + true + } + + R.id.add_entity_list -> { + val binding = AddEntitiesDialogLayoutBinding.inflate(LayoutInflater.from(context)) + MaterialAlertDialogBuilder(context) + .setView(binding.root) + .setTitle(org.odk.collect.strings.R.string.add_entity_list) + .setPositiveButton(org.odk.collect.strings.R.string.add) { _, _ -> + entitiesViewModel.addEntityList(binding.entityListName.text.toString()) + } + .show() + true + } + + else -> false + } + } +} + +private class ListsAdapter( + private val data: List, + private val navController: NavController +) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, position: Int): ListViewHolder { + return ListViewHolder(parent) + } + + override fun getItemCount(): Int { + return data.size + } + + override fun onBindViewHolder(viewHolder: ListViewHolder, position: Int) { + val list = data[position] + viewHolder.setList(list) + viewHolder.itemView.setOnClickListener { + navController.navigate( + R.id.lists_to_entities, + EntitiesFragmentArgs(list).toBundle() + ) + } + } +} + +private class ListViewHolder(parent: ViewGroup) : ViewHolder( + EntityListItemLayoutBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ).root +) { + + fun setList(list: String) { + EntityListItemLayoutBinding.bind(itemView).name.text = list + } +} diff --git a/entities/src/main/java/org/odk/collect/entities/InMemEntitiesRepository.kt b/entities/src/main/java/org/odk/collect/entities/InMemEntitiesRepository.kt new file mode 100644 index 00000000000..6b1127bded9 --- /dev/null +++ b/entities/src/main/java/org/odk/collect/entities/InMemEntitiesRepository.kt @@ -0,0 +1,68 @@ +package org.odk.collect.entities + +class InMemEntitiesRepository : EntitiesRepository { + + private val lists = mutableSetOf() + private val entities = mutableListOf() + + override fun getLists(): Set { + return lists + } + + override fun getEntities(list: String): List { + return entities.filter { it.list == list } + } + + override fun clear() { + entities.clear() + lists.clear() + } + + override fun addList(list: String) { + lists.add(list) + } + + override fun delete(id: String) { + entities.removeIf { it.id == id } + } + + override fun save(vararg entities: Entity) { + entities.forEach { entity -> + lists.add(entity.list) + val existing = this.entities.find { it.id == entity.id } + + if (existing != null) { + val state = when (existing.state) { + Entity.State.OFFLINE -> entity.state + Entity.State.ONLINE -> Entity.State.ONLINE + } + + this.entities.remove(existing) + this.entities.add( + Entity( + entity.list, + entity.id, + entity.label ?: existing.label, + version = entity.version, + properties = mergeProperties(existing, entity), + state = state + ) + ) + } else { + this.entities.add(entity) + } + } + } + + private fun mergeProperties( + existing: Entity, + new: Entity + ): List> { + val existingProperties = mutableMapOf(*existing.properties.toTypedArray()) + new.properties.forEach { + existingProperties[it.first] = it.second + } + + return existingProperties.map { Pair(it.key, it.value) } + } +} diff --git a/entities/src/main/java/org/odk/collect/entities/LocalEntitiesExternalInstanceParserFactory.kt b/entities/src/main/java/org/odk/collect/entities/LocalEntitiesExternalInstanceParserFactory.kt new file mode 100644 index 00000000000..09c0430690f --- /dev/null +++ b/entities/src/main/java/org/odk/collect/entities/LocalEntitiesExternalInstanceParserFactory.kt @@ -0,0 +1,71 @@ +package org.odk.collect.entities + +import org.javarosa.core.model.data.StringData +import org.javarosa.core.model.instance.TreeElement +import org.javarosa.xform.parse.ExternalInstanceParser +import org.javarosa.xform.parse.ExternalInstanceParser.FileInstanceParser +import org.javarosa.xform.parse.ExternalInstanceParserFactory + +class LocalEntitiesExternalInstanceParserFactory( + private val entitiesRepositoryProvider: () -> EntitiesRepository, + private val enabled: () -> Boolean +) : ExternalInstanceParserFactory { + override fun getExternalInstanceParser(): ExternalInstanceParser { + val parser = ExternalInstanceParser() + + if (enabled()) { + parser.addFileInstanceParser(LocalEntitiesFileInstanceParser(entitiesRepositoryProvider)) + } + + return parser + } +} + +internal class LocalEntitiesFileInstanceParser(private val entitiesRepositoryProvider: () -> EntitiesRepository) : + FileInstanceParser { + + override fun parse(instanceId: String, path: String): TreeElement { + val root = TreeElement("root", 0) + + val entitiesRepository = entitiesRepositoryProvider() + entitiesRepository.getEntities(instanceId).forEachIndexed { index, entity -> + val name = TreeElement(EntityItemElement.ID) + name.value = StringData(entity.id) + + val label = TreeElement(EntityItemElement.LABEL) + label.value = StringData(entity.label) + + val version = TreeElement(EntityItemElement.VERSION) + version.value = StringData(entity.version.toString()) + + val item = TreeElement("item", index) + item.addChild(name) + item.addChild(label) + item.addChild(version) + + entity.properties.forEach { property -> + addChild(item, property) + } + + root.addChild(item) + } + + return root + } + + override fun isSupported(instanceId: String, instanceSrc: String): Boolean { + val entitiesRepository = entitiesRepositoryProvider() + return entitiesRepository.getLists().contains(instanceId) + } + + private fun addChild( + element: TreeElement, + nameAndValue: Pair + ) { + element.addChild( + TreeElement(nameAndValue.first).also { + it.value = StringData(nameAndValue.second) + } + ) + } +} diff --git a/entities/src/main/java/org/odk/collect/entities/LocalEntityUseCases.kt b/entities/src/main/java/org/odk/collect/entities/LocalEntityUseCases.kt new file mode 100644 index 00000000000..647728688c5 --- /dev/null +++ b/entities/src/main/java/org/odk/collect/entities/LocalEntityUseCases.kt @@ -0,0 +1,103 @@ +package org.odk.collect.entities + +import org.javarosa.core.model.instance.CsvExternalInstance +import org.javarosa.core.model.instance.TreeElement +import org.javarosa.entities.EntityAction +import org.javarosa.entities.internal.Entities +import java.io.File + +object LocalEntityUseCases { + + @JvmStatic + fun updateLocalEntitiesFromForm( + formEntities: Entities?, + entitiesRepository: EntitiesRepository + ) { + formEntities?.entities?.forEach { formEntity -> + val id = formEntity.id + if (id != null && entitiesRepository.getLists().contains(formEntity.dataset)) { + if (formEntity.action != EntityAction.UPDATE || entitiesRepository.getEntities(formEntity.dataset).any { it.id == id }) { + val entity = Entity( + formEntity.dataset, + id, + formEntity.label, + formEntity.version, + formEntity.properties + ) + + entitiesRepository.save(entity) + } + } + } + } + + fun updateLocalEntitiesFromServer( + list: String, + serverList: File, + entitiesRepository: EntitiesRepository + ) { + val root = try { + CsvExternalInstance().parse(list, serverList.absolutePath) + } catch (e: Exception) { + return + } + + val localEntities = entitiesRepository.getEntities(list) + val serverEntities = root.getChildrenWithName("item") + + val accumulator = + Pair(arrayOf(), localEntities.associateBy { it.id }.toMutableMap()) + val (newAndUpdated, missingFromServer) = serverEntities.fold(accumulator) { (new, missing), item -> + val entity = parseEntityFromItem(item, list) ?: return + val existing = missing.remove(entity.id) + + if (existing == null || existing.version < entity.version) { + Pair(new + entity, missing) + } else if (existing.state == Entity.State.OFFLINE) { + Pair(new + existing.copy(state = Entity.State.ONLINE), missing) + } else { + Pair(new, missing) + } + } + + missingFromServer.values.forEach { + if (it.state == Entity.State.ONLINE) { + entitiesRepository.delete(it.id) + } + } + + entitiesRepository.save(*newAndUpdated) + } + + private fun parseEntityFromItem( + item: TreeElement, + list: String + ): Entity? { + val id = item.getFirstChild(EntityItemElement.ID)?.value?.value as? String + val label = item.getFirstChild(EntityItemElement.LABEL)?.value?.value as? String + val version = + (item.getFirstChild(EntityItemElement.VERSION)?.value?.value as? String)?.toInt() + if (id == null || label == null || version == null) { + return null + } + + val properties = 0.until(item.numChildren) + .fold(emptyList>()) { properties, index -> + val child = item.getChildAt(index) + + if (!listOf( + EntityItemElement.ID, + EntityItemElement.LABEL, + EntityItemElement.VERSION + ).contains(child.name) + ) { + properties + Pair(child.name, child.value!!.value as String) + } else { + properties + } + } + + val entity = Entity(list, id, label, version, properties, state = Entity.State.ONLINE) + return entity + } +} diff --git a/entities/src/main/res/layout/add_entities_dialog_layout.xml b/entities/src/main/res/layout/add_entities_dialog_layout.xml new file mode 100644 index 00000000000..f6fa56cc157 --- /dev/null +++ b/entities/src/main/res/layout/add_entities_dialog_layout.xml @@ -0,0 +1,24 @@ + + + + + + + + + + diff --git a/entities/src/main/res/layout/entity_item_layout.xml b/entities/src/main/res/layout/entity_item_layout.xml index 16679c9d0a4..c862bb186b7 100644 --- a/entities/src/main/res/layout/entity_item_layout.xml +++ b/entities/src/main/res/layout/entity_item_layout.xml @@ -3,15 +3,47 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="match_parent" - android:paddingHorizontal="@dimen/margin_standard" - android:paddingTop="@dimen/margin_standard"> + android:layout_height="wrap_content" + android:background="?android:attr/selectableItemBackground"> + + + + + + + android:layout_marginTop="@dimen/margin_extra_small" + android:layout_marginBottom="@dimen/margin_standard" + android:textAppearance="?textAppearanceBodyMedium" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="@id/guideline_start" + app:layout_constraintTop_toBottomOf="@id/label" + tools:text="property1: value1" /> diff --git a/entities/src/main/res/layout/list_item_layout.xml b/entities/src/main/res/layout/entity_list_item_layout.xml similarity index 65% rename from entities/src/main/res/layout/list_item_layout.xml rename to entities/src/main/res/layout/entity_list_item_layout.xml index e896852648b..f955c263a68 100644 --- a/entities/src/main/res/layout/list_item_layout.xml +++ b/entities/src/main/res/layout/entity_list_item_layout.xml @@ -3,14 +3,15 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="match_parent"> + android:layout_height="wrap_content" + android:padding="@dimen/margin_standard" + android:background="?android:attr/selectableItemBackground"> - diff --git a/entities/src/main/res/layout/list_layout.xml b/entities/src/main/res/layout/list_layout.xml index f2c72626702..a39946adb4e 100644 --- a/entities/src/main/res/layout/list_layout.xml +++ b/entities/src/main/res/layout/list_layout.xml @@ -1,14 +1,13 @@ - - + android:layout_height="match_parent" + android:orientation="vertical" /> - + diff --git a/entities/src/main/res/menu/entity_lists.xml b/entities/src/main/res/menu/entity_lists.xml new file mode 100644 index 00000000000..1f7fbec5a80 --- /dev/null +++ b/entities/src/main/res/menu/entity_lists.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/entities/src/main/res/navigation/entities_nav.xml b/entities/src/main/res/navigation/entities_nav.xml index c2275335da3..6bb20869435 100644 --- a/entities/src/main/res/navigation/entities_nav.xml +++ b/entities/src/main/res/navigation/entities_nav.xml @@ -2,23 +2,23 @@ + app:startDestination="@id/lists"> + android:label="{list}"> diff --git a/entities/src/test/java/org/odk/collect/entities/EntityItemViewTest.kt b/entities/src/test/java/org/odk/collect/entities/EntityItemViewTest.kt index 666e1e71b0a..9199c8b7828 100644 --- a/entities/src/test/java/org/odk/collect/entities/EntityItemViewTest.kt +++ b/entities/src/test/java/org/odk/collect/entities/EntityItemViewTest.kt @@ -1,6 +1,6 @@ package org.odk.collect.entities -import android.widget.TextView +import androidx.core.view.isVisible import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.core.IsEqual.equalTo import org.junit.Test @@ -11,14 +11,35 @@ import org.robolectric.RuntimeEnvironment @RunWith(RobolectricTestRunner::class) class EntityItemViewTest { - private val context = RuntimeEnvironment.getApplication() + private val context = RuntimeEnvironment.getApplication().also { + it.setTheme(com.google.android.material.R.style.Theme_Material3_DayNight) + } @Test - fun sortsOrderOfProperties() { + fun `sorts order of properties`() { val view = EntityItemView(context) - view.setEntity(Entity("songs", listOf(Pair("name", "S.D.O.S"), Pair("length", "2:50")))) + view.setEntity( + Entity( + "songs", + "1", + "S.D.O.S", + properties = listOf(Pair("name", "S.D.O.S"), Pair("length", "2:50")) + ) + ) + + val propertiesView = view.binding.properties + assertThat(propertiesView.text, equalTo("length: 2:50\nname: S.D.O.S")) + } + + @Test + fun `shows offline pill when entity is offline`() { + val view = EntityItemView(context) + val entity = Entity("songs", "1", "S.D.O.S") + + view.setEntity(entity.copy(state = Entity.State.OFFLINE)) + assertThat(view.binding.offlinePill.isVisible, equalTo(true)) - val propertiesView = view.findViewById(R.id.properties) - assertThat(propertiesView.text, equalTo("length: 2:50, name: S.D.O.S")) + view.setEntity(entity.copy(state = Entity.State.ONLINE)) + assertThat(view.binding.offlinePill.isVisible, equalTo(false)) } } diff --git a/entities/src/test/java/org/odk/collect/entities/LocalEntitiesFileInstanceParserTest.kt b/entities/src/test/java/org/odk/collect/entities/LocalEntitiesFileInstanceParserTest.kt new file mode 100644 index 00000000000..8d3a57d0349 --- /dev/null +++ b/entities/src/test/java/org/odk/collect/entities/LocalEntitiesFileInstanceParserTest.kt @@ -0,0 +1,51 @@ +package org.odk.collect.entities + +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Test + +class LocalEntitiesFileInstanceParserTest { + + private val entitiesRepository = InMemEntitiesRepository() + + @Test + fun `includes properties in local entity elements`() { + val entity = + Entity( + "people", + "1", + "Shiv Roy", + properties = listOf(Pair("age", "35"), Pair("born", "England")) + ) + entitiesRepository.save(entity) + + val parser = LocalEntitiesFileInstanceParser { entitiesRepository } + val instance = parser.parse("people", "people.csv") + assertThat(instance.numChildren, equalTo(1)) + + val item = instance.getChildAt(0)!! + assertThat(item.numChildren, equalTo(5)) + assertThat(item.getFirstChild("age")?.value?.value, equalTo("35")) + assertThat(item.getFirstChild("born")?.value?.value, equalTo("England")) + } + + @Test + fun `includes version in local entity elements`() { + val entity = + Entity( + "people", + "1", + "Shiv Roy", + version = 1 + ) + entitiesRepository.save(entity) + + val parser = LocalEntitiesFileInstanceParser { entitiesRepository } + val instance = parser.parse("people", "people.csv") + assertThat(instance.numChildren, equalTo(1)) + + val item = instance.getChildAt(0)!! + assertThat(item.numChildren, equalTo(3)) + assertThat(item.getFirstChild(EntityItemElement.VERSION)?.value?.value, equalTo("1")) + } +} diff --git a/entities/src/test/java/org/odk/collect/entities/LocalEntityUseCasesTest.kt b/entities/src/test/java/org/odk/collect/entities/LocalEntityUseCasesTest.kt new file mode 100644 index 00000000000..3c1171e67ff --- /dev/null +++ b/entities/src/test/java/org/odk/collect/entities/LocalEntityUseCasesTest.kt @@ -0,0 +1,299 @@ +package org.odk.collect.entities + +import org.apache.commons.csv.CSVFormat +import org.apache.commons.csv.CSVPrinter +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.containsInAnyOrder +import org.hamcrest.Matchers.equalTo +import org.javarosa.entities.EntityAction +import org.javarosa.entities.internal.Entities +import org.junit.Test +import org.odk.collect.entities.Entity.State.ONLINE +import org.odk.collect.shared.TempFiles +import java.io.File + +class LocalEntityUseCasesTest { + + private val entitiesRepository = InMemEntitiesRepository() + + @Test + fun `updateLocalEntitiesFromForm does not save updated entity that doesn't already exist`() { + val entity = + org.javarosa.entities.Entity(EntityAction.UPDATE, "things", "1", "1", 1, emptyList()) + val formEntities = Entities(listOf(entity)) + entitiesRepository.addList("things") + + LocalEntityUseCases.updateLocalEntitiesFromForm(formEntities, entitiesRepository) + assertThat(entitiesRepository.getEntities("things").size, equalTo(0)) + } + + @Test + fun `updateLocalEntitiesFromForm does not save entity that doesn't have an ID`() { + val entity = + org.javarosa.entities.Entity(EntityAction.CREATE, "things", null, "1", 1, emptyList()) + val formEntities = Entities(listOf(entity)) + entitiesRepository.addList("things") + + LocalEntityUseCases.updateLocalEntitiesFromForm(formEntities, entitiesRepository) + assertThat(entitiesRepository.getEntities("things").size, equalTo(0)) + } + + @Test + fun `updateLocalEntitiesFromServer overrides offline version if the online version is newer`() { + entitiesRepository.save(Entity("songs", "noah", "Noa", 1)) + val csv = createEntityList(Entity("songs", "noah", "Noah", 2)) + + LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository) + val songs = entitiesRepository.getEntities("songs") + assertThat(songs, containsInAnyOrder(Entity("songs", "noah", "Noah", 2, state = ONLINE))) + } + + @Test + fun `updateLocalEntitiesFromServer does not override offline version if the online version is older`() { + entitiesRepository.save(Entity("songs", "noah", "Noah", 2)) + val csv = createEntityList(Entity("songs", "noah", "Noa", 1)) + + LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository) + val songs = entitiesRepository.getEntities("songs") + assertThat(songs, containsInAnyOrder(Entity("songs", "noah", "Noah", 2, state = ONLINE))) + } + + @Test + fun `updateLocalEntitiesFromServer does not override offline version if the online version is the same`() { + entitiesRepository.save(Entity("songs", "noah", "Noah", 2)) + val csv = createEntityList(Entity("songs", "noah", "Noa", 2)) + + LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository) + val songs = entitiesRepository.getEntities("songs") + assertThat(songs, containsInAnyOrder(Entity("songs", "noah", "Noah", 2, state = ONLINE))) + } + + @Test + fun `updateLocalEntitiesFromServer ignores properties not in offline version from older online version`() { + entitiesRepository.save(Entity("songs", "noah", "Noah", 3)) + val csv = + createEntityList(Entity("songs", "noah", "Noah", 2, listOf(Pair("length", "6:38")))) + + LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository) + val songs = entitiesRepository.getEntities("songs") + assertThat( + songs, + containsInAnyOrder(Entity("songs", "noah", "Noah", 3, state = ONLINE)) + ) + } + + @Test + fun `updateLocalEntitiesFromServer overrides properties in offline version from newer list version`() { + entitiesRepository.save(Entity("songs", "noah", "Noah", 1, listOf(Pair("length", "6:38")))) + val csv = + createEntityList(Entity("songs", "noah", "Noah", 2, listOf(Pair("length", "4:58")))) + + LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository) + val songs = entitiesRepository.getEntities("songs") + assertThat( + songs, + containsInAnyOrder( + Entity( + "songs", + "noah", + "Noah", + 2, + listOf(Pair("length", "4:58")), + state = ONLINE + ) + ) + ) + } + + @Test + fun `updateLocalEntitiesFromServer does nothing if version does not exist in online entities`() { + val csv = + createCsv( + listOf("name", "label"), + listOf("grisaille", "Grisaille") + ) + + LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository) + assertThat(entitiesRepository.getLists().size, equalTo(0)) + } + + @Test + fun `updateLocalEntitiesFromServer does nothing if name does not exist in online entities`() { + val csv = + createCsv( + listOf("label", "__version"), + listOf("Grisaille", "2") + ) + + LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository) + assertThat(entitiesRepository.getLists().size, equalTo(0)) + } + + @Test + fun `updateLocalEntitiesFromServer does nothing if label does not exist in online entities`() { + val csv = + createCsv( + listOf("name", "__version"), + listOf("grisaille", "2") + ) + + LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository) + assertThat(entitiesRepository.getLists().size, equalTo(0)) + } + + @Test + fun `updateLocalEntitiesFromServer adds online entity when its label is blank`() { + val csv = createEntityList(Entity("songs", "cathedrals", label = "")) + + LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository) + val songs = entitiesRepository.getEntities("songs") + assertThat( + songs, + containsInAnyOrder(Entity("songs", "cathedrals", label = "", state = ONLINE)) + ) + } + + @Test + fun `updateLocalEntitiesFromServer does nothing if passed a non-CSV file`() { + val file = TempFiles.createTempFile(".xml") + + LocalEntityUseCases.updateLocalEntitiesFromServer("songs", file, entitiesRepository) + assertThat(entitiesRepository.getLists().size, equalTo(0)) + } + + @Test + fun `updateLocalEntitiesFromServer accesses entities repo only twice when saving multiple entities`() { + val csv = createEntityList( + Entity("songs", "noah", "Noah"), + Entity("songs", "seven-trumpets", "Seven Trumpets") + ) + + val entitiesRepository = MeasurableEntitiesRepository(entitiesRepository) + LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository) + assertThat(entitiesRepository.accesses, equalTo(2)) + } + + @Test + fun `updateLocalEntitiesFromServer does not remove offline entities that are not in online entities`() { + entitiesRepository.save(Entity("songs", "noah", "Noah")) + val csv = createEntityList(Entity("songs", "cathedrals", "Cathedrals")) + + LocalEntityUseCases.updateLocalEntitiesFromServer("songs", csv, entitiesRepository) + val songs = entitiesRepository.getEntities("songs") + assertThat( + songs, + containsInAnyOrder( + Entity("songs", "cathedrals", "Cathedrals", state = ONLINE), + Entity("songs", "noah", "Noah") + ) + ) + } + + @Test + fun `updateLocalEntitiesFromServer removes offline entity that was in online list, but isn't any longer`() { + entitiesRepository.save(Entity("songs", "cathedrals", "Cathedrals")) + + val firstCsv = createEntityList(Entity("songs", "cathedrals", "Cathedrals")) + LocalEntityUseCases.updateLocalEntitiesFromServer("songs", firstCsv, entitiesRepository) + + val secondCsv = createEntityList(Entity("songs", "noah", "Noah")) + LocalEntityUseCases.updateLocalEntitiesFromServer("songs", secondCsv, entitiesRepository) + + val songs = entitiesRepository.getEntities("songs") + assertThat(songs, containsInAnyOrder(Entity("songs", "noah", "Noah", state = ONLINE))) + } + + @Test + fun `updateLocalEntitiesFromServer removes offline entity that was updated in online list, but isn't any longer`() { + entitiesRepository.save(Entity("songs", "cathedrals", "Cathedrals", version = 1)) + + val firstCsv = + createEntityList(Entity("songs", "cathedrals", "Cathedrals (A Song)", version = 2)) + LocalEntityUseCases.updateLocalEntitiesFromServer("songs", firstCsv, entitiesRepository) + + val secondCsv = createEntityList() + LocalEntityUseCases.updateLocalEntitiesFromServer("songs", secondCsv, entitiesRepository) + + val songs = entitiesRepository.getEntities("songs") + assertThat(songs.isEmpty(), equalTo(true)) + } + + private fun createEntityList(vararg entities: Entity): File { + if (entities.isNotEmpty()) { + val header = listOf( + EntityItemElement.ID, + EntityItemElement.LABEL, + EntityItemElement.VERSION + ) + entities[0].properties.map { it.first } + + val rows = entities.map { entity -> + listOf( + entity.id, + entity.label, + entity.version.toString() + ) + entity.properties.map { it.second } + }.toTypedArray() + + return createCsv(header, *rows) + } else { + val header = listOf( + EntityItemElement.ID, + EntityItemElement.LABEL, + EntityItemElement.VERSION + ) + + return createCsv(header) + } + } + + private fun createCsv(header: List, vararg rows: List): File { + val csv = TempFiles.createTempFile() + csv.writer().use { it -> + val csvPrinter = CSVPrinter(it, CSVFormat.DEFAULT) + csvPrinter.printRecord(header) + + rows.forEach { + csvPrinter.printRecord(it) + } + } + + return csv + } +} + +private class MeasurableEntitiesRepository(private val wrapped: EntitiesRepository) : + EntitiesRepository { + + var accesses: Int = 0 + private set + + override fun save(vararg entities: Entity) { + accesses += 1 + wrapped.save(*entities) + } + + override fun getLists(): Set { + accesses += 1 + return wrapped.getLists() + } + + override fun getEntities(list: String): List { + accesses += 1 + return wrapped.getEntities(list) + } + + override fun clear() { + accesses += 1 + wrapped.clear() + } + + override fun addList(list: String) { + accesses += 1 + wrapped.addList(list) + } + + override fun delete(id: String) { + accesses += 1 + wrapped.delete(id) + } +} diff --git a/errors/src/test/resources/robolectric.properties b/errors/src/test/resources/robolectric.properties index a333e53ef88..4f3945f61dc 100644 --- a/errors/src/test/resources/robolectric.properties +++ b/errors/src/test/resources/robolectric.properties @@ -1,3 +1 @@ -# Workaround for https://github.com/robolectric/robolectric/issues/6593 -instrumentedPackages=androidx.loader.content sdk=33 diff --git a/fastlane/Screengrabfile b/fastlane/Screengrabfile deleted file mode 100644 index 3e644907afd..00000000000 --- a/fastlane/Screengrabfile +++ /dev/null @@ -1,15 +0,0 @@ -# remove the leading '#' to uncomment lines - -app_package_name 'org.odk.collect.android' -# use_tests_in_packages ['your.screenshot.tests.package'] - -#app_apk_path 'collect_app/build/outputs/apk/debug/apk-name' -#tests_apk_path 'collect_app/build/outputs/apk/androidTest/debug/test-apk-name' - -locales ['en-US'] - -# clear all previously generated screenshots in your local output directory before creating new ones -clear_previous_screenshots true - -# For more information about all available options run -# screengrab --help diff --git a/formstest/.gitignore b/forms-test/.gitignore similarity index 100% rename from formstest/.gitignore rename to forms-test/.gitignore diff --git a/formstest/README.md b/forms-test/README.md similarity index 100% rename from formstest/README.md rename to forms-test/README.md diff --git a/formstest/build.gradle.kts b/forms-test/build.gradle.kts similarity index 100% rename from formstest/build.gradle.kts rename to forms-test/build.gradle.kts diff --git a/formstest/src/main/java/org/odk/collect/formstest/FormFixtures.kt b/forms-test/src/main/java/org/odk/collect/formstest/FormFixtures.kt similarity index 100% rename from formstest/src/main/java/org/odk/collect/formstest/FormFixtures.kt rename to forms-test/src/main/java/org/odk/collect/formstest/FormFixtures.kt diff --git a/formstest/src/main/java/org/odk/collect/formstest/FormUtils.kt b/forms-test/src/main/java/org/odk/collect/formstest/FormUtils.kt similarity index 100% rename from formstest/src/main/java/org/odk/collect/formstest/FormUtils.kt rename to forms-test/src/main/java/org/odk/collect/formstest/FormUtils.kt diff --git a/formstest/src/main/java/org/odk/collect/formstest/FormsRepositoryTest.java b/forms-test/src/main/java/org/odk/collect/formstest/FormsRepositoryTest.java similarity index 80% rename from formstest/src/main/java/org/odk/collect/formstest/FormsRepositoryTest.java rename to forms-test/src/main/java/org/odk/collect/formstest/FormsRepositoryTest.java index dcd92cc6d83..2301451d895 100644 --- a/formstest/src/main/java/org/odk/collect/formstest/FormsRepositoryTest.java +++ b/forms-test/src/main/java/org/odk/collect/formstest/FormsRepositoryTest.java @@ -4,6 +4,8 @@ import org.junit.Test; import org.odk.collect.forms.Form; import org.odk.collect.forms.FormsRepository; +import org.odk.collect.forms.savepoints.Savepoint; +import org.odk.collect.forms.savepoints.SavepointsRepository; import org.odk.collect.shared.strings.Md5; import java.io.File; @@ -24,6 +26,7 @@ import static org.odk.collect.formstest.FormUtils.createXFormBody; public abstract class FormsRepositoryTest { + protected final SavepointsRepository savepointsRepository = new InMemSavepointsRepository(); public abstract FormsRepository buildSubject(); @@ -132,6 +135,22 @@ public void softDelete_marksDeletedAsTrue() { assertThat(formsRepository.get(1L).isDeleted(), is(true)); } + @Test + public void softDelete_deletesTheSavepointThatBelongsToTheFormThatShouldBeDeleted() { + FormsRepository formsRepository = buildSubject(); + Form form1 = formsRepository.save(FormUtils.buildForm("id1", "version", getFormFilesPath()).build()); + Form form2 = formsRepository.save(FormUtils.buildForm("id2", "version", getFormFilesPath()).build()); + + Savepoint savepoint1 = new Savepoint(form1.getDbId(), null, "", ""); + Savepoint savepoint2 = new Savepoint(form2.getDbId(), null, "", ""); + savepointsRepository.save(savepoint1); + savepointsRepository.save(savepoint2); + + formsRepository.softDelete(form1.getDbId()); + + assertThat(savepointsRepository.getAll(), contains(savepoint2)); + } + @Test public void restore_marksDeletedAsFalse() { FormsRepository formsRepository = buildSubject(); @@ -254,6 +273,22 @@ public void delete_deletesFiles() throws Exception { assertThat(cacheFile.exists(), is(false)); } + @Test + public void delete_deletesTheSavepointThatBelongsToTheFormThatShouldBeDeleted() { + FormsRepository formsRepository = buildSubject(); + Form form1 = formsRepository.save(FormUtils.buildForm("id1", "version", getFormFilesPath()).build()); + Form form2 = formsRepository.save(FormUtils.buildForm("id2", "version", getFormFilesPath()).build()); + + Savepoint savepoint1 = new Savepoint(form1.getDbId(), null, "", ""); + Savepoint savepoint2 = new Savepoint(form2.getDbId(), null, "", ""); + savepointsRepository.save(savepoint1); + savepointsRepository.save(savepoint2); + + formsRepository.delete(form1.getDbId()); + + assertThat(savepointsRepository.getAll(), contains(savepoint2)); + } + @Test public void delete_whenMediaPathIsFile_deletesFiles() throws Exception { FormsRepository formsRepository = buildSubject(); @@ -290,6 +325,22 @@ public void deleteAll_deletesAllForms() { } } + @Test + public void deleteAll_deletesAllSavepointsThatBelongToFormsThatShouldBeDeleted() { + FormsRepository formsRepository = buildSubject(); + Form form1 = formsRepository.save(FormUtils.buildForm("id1", "version", getFormFilesPath()).build()); + Form form2 = formsRepository.save(FormUtils.buildForm("id2", "version", getFormFilesPath()).build()); + + Savepoint savepoint1 = new Savepoint(form1.getDbId(), null, "", ""); + Savepoint savepoint2 = new Savepoint(form2.getDbId(), null, "", ""); + savepointsRepository.save(savepoint1); + savepointsRepository.save(savepoint2); + + formsRepository.deleteAll(); + + assertThat(savepointsRepository.getAll().isEmpty(), equalTo(true)); + } + @Test public void deleteByMd5Hash_deletesFormsWithMatchingHash() { FormsRepository formsRepository = buildSubject(); @@ -303,6 +354,23 @@ public void deleteByMd5Hash_deletesFormsWithMatchingHash() { assertThat(formsRepository.getAll().get(0).getFormId(), is("id2")); } + @Test + public void deleteByMd5Hash_deletesTheSavepointThatBelongsToTheFormThatShouldBeDeleted() { + FormsRepository formsRepository = buildSubject(); + Form form1 = formsRepository.save(FormUtils.buildForm("id1", "version", getFormFilesPath(), createXFormBody("id1", "version", "Form1")).build()); + Form form2 = formsRepository.save(FormUtils.buildForm("id2", "version", getFormFilesPath(), createXFormBody("id2", "version", "Form2")).build()); + + Savepoint savepoint1 = new Savepoint(form1.getDbId(), null, "", ""); + Savepoint savepoint2 = new Savepoint(form2.getDbId(), null, "", ""); + savepointsRepository.save(savepoint1); + savepointsRepository.save(savepoint2); + + List id1Forms = formsRepository.getAllByFormIdAndVersion("id1", "version"); + formsRepository.deleteByMd5Hash(id1Forms.get(0).getMD5Hash()); + + assertThat(savepointsRepository.getAll(), contains(savepoint2)); + } + @Test(expected = Exception.class) public void getOneByMd5Hash_whenHashIsNull_explodes() { buildSubject().getOneByMd5Hash(null); diff --git a/formstest/src/main/java/org/odk/collect/formstest/InMemFormsRepository.java b/forms-test/src/main/java/org/odk/collect/formstest/InMemFormsRepository.java similarity index 87% rename from formstest/src/main/java/org/odk/collect/formstest/InMemFormsRepository.java rename to forms-test/src/main/java/org/odk/collect/formstest/InMemFormsRepository.java index b4098c6358c..05fc83b3766 100644 --- a/formstest/src/main/java/org/odk/collect/formstest/InMemFormsRepository.java +++ b/forms-test/src/main/java/org/odk/collect/formstest/InMemFormsRepository.java @@ -4,6 +4,7 @@ import org.jetbrains.annotations.Nullable; import org.odk.collect.forms.Form; import org.odk.collect.forms.FormsRepository; +import org.odk.collect.forms.savepoints.SavepointsRepository; import org.odk.collect.shared.files.DirectoryUtils; import org.odk.collect.shared.strings.Md5; import org.odk.collect.shared.TempFiles; @@ -24,13 +25,23 @@ public class InMemFormsRepository implements FormsRepository { private long idCounter = 1L; private final Supplier clock; + private final SavepointsRepository savepointsRepository; public InMemFormsRepository() { - this.clock = System::currentTimeMillis; + this(System::currentTimeMillis, new InMemSavepointsRepository()); } public InMemFormsRepository(Supplier clock) { + this(clock, new InMemSavepointsRepository()); + } + + public InMemFormsRepository(SavepointsRepository savepointsRepository) { + this(System::currentTimeMillis, savepointsRepository); + } + + public InMemFormsRepository(Supplier clock, SavepointsRepository savepointsRepository) { this.clock = clock; + this.savepointsRepository = savepointsRepository; } @Nullable @@ -155,12 +166,17 @@ public void softDelete(Long id) { forms.add(new Form.Builder(form) .deleted(true) .build()); + savepointsRepository.delete(id, null); } } @Override public void deleteByMd5Hash(@NotNull String md5Hash) { - forms.removeIf(f -> f.getMD5Hash().equals(md5Hash)); + Form form = forms.stream().filter(f -> f.getMD5Hash().equals(md5Hash)).findFirst().orElse(null); + if (form != null) { + forms.remove(form); + savepointsRepository.delete(form.getDbId(), null); + } } @Override @@ -205,5 +221,7 @@ private void deleteFilesForForm(Form form) { mediaDir.delete(); } } + + savepointsRepository.delete(form.getDbId(), null); } } diff --git a/formstest/src/main/java/org/odk/collect/formstest/InMemInstancesRepository.java b/forms-test/src/main/java/org/odk/collect/formstest/InMemInstancesRepository.java similarity index 100% rename from formstest/src/main/java/org/odk/collect/formstest/InMemInstancesRepository.java rename to forms-test/src/main/java/org/odk/collect/formstest/InMemInstancesRepository.java diff --git a/forms-test/src/main/java/org/odk/collect/formstest/InMemSavepointsRepository.kt b/forms-test/src/main/java/org/odk/collect/formstest/InMemSavepointsRepository.kt new file mode 100644 index 00000000000..022b7761433 --- /dev/null +++ b/forms-test/src/main/java/org/odk/collect/formstest/InMemSavepointsRepository.kt @@ -0,0 +1,40 @@ +package org.odk.collect.formstest + +import org.odk.collect.forms.savepoints.Savepoint +import org.odk.collect.forms.savepoints.SavepointsRepository +import java.io.File + +class InMemSavepointsRepository : SavepointsRepository { + private val savepoints = mutableListOf() + + override fun get(formDbId: Long, instanceDbId: Long?): Savepoint? { + return savepoints.find { savepoint -> savepoint.formDbId == formDbId && savepoint.instanceDbId == instanceDbId } + } + + override fun getAll(): List { + return savepoints + } + + override fun save(savepoint: Savepoint) { + if (savepoints.any { it.formDbId == savepoint.formDbId && it.instanceDbId == savepoint.instanceDbId }) { + return + } + savepoints.add(savepoint) + savepoints.indexOf(savepoint).toLong() + } + + override fun delete(formDbId: Long, instanceDbId: Long?) { + val savepoint = get(formDbId, instanceDbId) + if (savepoint != null) { + File(savepoint.savepointFilePath).delete() + savepoints.remove(get(formDbId, instanceDbId)) + } + } + + override fun deleteAll() { + savepoints.forEach { + File(it.savepointFilePath).delete() + } + savepoints.clear() + } +} diff --git a/forms-test/src/main/java/org/odk/collect/formstest/InstanceFixtures.kt b/forms-test/src/main/java/org/odk/collect/formstest/InstanceFixtures.kt new file mode 100644 index 00000000000..d21991db344 --- /dev/null +++ b/forms-test/src/main/java/org/odk/collect/formstest/InstanceFixtures.kt @@ -0,0 +1,31 @@ +package org.odk.collect.formstest + +import org.odk.collect.forms.Form +import org.odk.collect.forms.instances.Instance +import org.odk.collect.shared.TempFiles + +object InstanceFixtures { + + fun instance( + status: String = Instance.STATUS_INCOMPLETE, + lastStatusChangeDate: Long = 0, + displayName: String? = null, + dbId: Long? = null, + form: Form? = null, + deletedDate: Long? = null + ): Instance { + val instancesDir = TempFiles.createTempDir() + return InstanceUtils.buildInstance("formId", "version", instancesDir.absolutePath) + .status(status) + .lastStatusChangeDate(lastStatusChangeDate) + .displayName(displayName) + .dbId(dbId).also { + if (form != null) { + it.formId(form.formId) + it.formVersion(form.version) + } + } + .deletedDate(deletedDate) + .build() + } +} diff --git a/formstest/src/main/java/org/odk/collect/formstest/InstanceUtils.kt b/forms-test/src/main/java/org/odk/collect/formstest/InstanceUtils.kt similarity index 100% rename from formstest/src/main/java/org/odk/collect/formstest/InstanceUtils.kt rename to forms-test/src/main/java/org/odk/collect/formstest/InstanceUtils.kt diff --git a/formstest/src/main/java/org/odk/collect/formstest/InstancesRepositoryTest.java b/forms-test/src/main/java/org/odk/collect/formstest/InstancesRepositoryTest.java similarity index 100% rename from formstest/src/main/java/org/odk/collect/formstest/InstancesRepositoryTest.java rename to forms-test/src/main/java/org/odk/collect/formstest/InstancesRepositoryTest.java diff --git a/forms-test/src/main/java/org/odk/collect/formstest/SavepointsRepositoryTest.kt b/forms-test/src/main/java/org/odk/collect/formstest/SavepointsRepositoryTest.kt new file mode 100644 index 00000000000..10c703232d2 --- /dev/null +++ b/forms-test/src/main/java/org/odk/collect/formstest/SavepointsRepositoryTest.kt @@ -0,0 +1,150 @@ +package org.odk.collect.formstest + +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.contains +import org.hamcrest.Matchers.equalTo +import org.junit.Test +import org.odk.collect.forms.savepoints.Savepoint +import org.odk.collect.forms.savepoints.SavepointsRepository +import org.odk.collect.shared.TempFiles +import java.io.File + +abstract class SavepointsRepositoryTest { + private val cacheDirPath = TempFiles.createTempDir().absolutePath + private val instancesDirPath = TempFiles.createTempDir().absolutePath + + abstract fun buildSubject(cacheDirPath: String, instancesDirPath: String): SavepointsRepository + + private fun getSavepointFile(relativeFilePath: String): File { + return File(cacheDirPath, relativeFilePath) + } + + private fun getInstanceFile(relativeFilePath: String): File { + return File(instancesDirPath, relativeFilePath) + } + + @Test + fun `get returns null if the database is empty`() { + val savepointsRepository = buildSubject(cacheDirPath, instancesDirPath) + + assertThat(savepointsRepository.get(1, null), equalTo(null)) + } + + @Test + fun `get returns null if there is no savepoint for give formDbId and instanceDbId`() { + val savepointsRepository = buildSubject(cacheDirPath, instancesDirPath) + + savepointsRepository.save(Savepoint(1, 1, getSavepointFile("foo").absolutePath, getInstanceFile("foo").absolutePath)) + + assertThat(savepointsRepository.get(1, 2), equalTo(null)) + } + + @Test + fun `get returns savepoint if one with given formDbId and instanceDbId exists`() { + val savepointsRepository = buildSubject(cacheDirPath, instancesDirPath) + + val savepoint1 = Savepoint(1, 1, getSavepointFile("foo").absolutePath, getInstanceFile("foo").absolutePath) + val savepoint2 = Savepoint(1, 2, getSavepointFile("bar").absolutePath, getInstanceFile("bar").absolutePath) + savepointsRepository.save(savepoint1) + savepointsRepository.save(savepoint2) + + assertThat(savepointsRepository.get(1, 1), equalTo(savepoint1)) + } + + @Test + fun `getAll returns an empty list if the database is empty`() { + val savepointsRepository = buildSubject(cacheDirPath, instancesDirPath) + + assertThat(savepointsRepository.getAll().isEmpty(), equalTo(true)) + } + + @Test + fun `getAll returns all savepoints stored in the database`() { + val savepointsRepository = buildSubject(cacheDirPath, instancesDirPath) + + val savepoint1 = Savepoint(1, null, getSavepointFile("foo").absolutePath, getInstanceFile("foo").absolutePath) + val savepoint2 = Savepoint(1, 1, getSavepointFile("bar").absolutePath, getInstanceFile("bar").absolutePath) + savepointsRepository.save(savepoint1) + savepointsRepository.save(savepoint2) + + assertThat(savepointsRepository.getAll(), contains(savepoint1, savepoint2)) + } + + @Test + fun `save does not save two savepoints with the same formDbId and instanceDbId`() { + val savepointsRepository = buildSubject(cacheDirPath, instancesDirPath) + + val savepoint1 = Savepoint(1, null, getSavepointFile("foo").absolutePath, getInstanceFile("foo").absolutePath) + savepointsRepository.save(savepoint1) + savepointsRepository.save(Savepoint(1, null, getSavepointFile("bar2").absolutePath, getInstanceFile("bar2").absolutePath)) + + assertThat(savepointsRepository.getAll(), contains(savepoint1)) + } + + @Test + fun `delete removes savepoint from the database and its savepoint file for given formDbId and instanceDbId if instanceDbId is null`() { + val savepointsRepository = buildSubject(cacheDirPath, instancesDirPath) + + val savepointFile1 = getSavepointFile("foo") + savepointFile1.createNewFile() + val savepoint1 = Savepoint(1, null, savepointFile1.absolutePath, getInstanceFile("foo").absolutePath) + + val savepointFile2 = getSavepointFile("bar") + savepointFile2.createNewFile() + val savepoint2 = Savepoint(2, null, savepointFile2.absolutePath, getInstanceFile("bar").absolutePath) + + savepointsRepository.save(savepoint1) + savepointsRepository.save(savepoint2) + + savepointsRepository.delete(savepoint1.formDbId, savepoint1.instanceDbId) + + assertThat(savepointsRepository.getAll(), contains(savepoint2)) + assertThat(savepointFile1.exists(), equalTo(false)) + assertThat(savepointFile2.exists(), equalTo(true)) + } + + @Test + fun `delete removes savepoint from the database and its savepoint file for given formDbId and instanceDbId if instanceDbId is not null`() { + val savepointsRepository = buildSubject(cacheDirPath, instancesDirPath) + + val savepointFile1 = getSavepointFile("foo") + savepointFile1.createNewFile() + val savepoint1 = Savepoint(1, 1, savepointFile1.absolutePath, getInstanceFile("foo").absolutePath) + + val savepointFile2 = getSavepointFile("bar") + savepointFile2.createNewFile() + val savepoint2 = Savepoint(2, 1, savepointFile2.absolutePath, getInstanceFile("bar").absolutePath) + + savepointsRepository.save(savepoint1) + savepointsRepository.save(savepoint2) + + savepointsRepository.delete(savepoint1.formDbId, savepoint1.instanceDbId) + + assertThat(savepointsRepository.getAll().size, equalTo(1)) + assertThat(savepointsRepository.getAll()[0].formDbId, equalTo(savepoint2.formDbId)) + assertThat(savepointFile1.exists(), equalTo(false)) + assertThat(savepointFile2.exists(), equalTo(true)) + } + + @Test + fun `deleteAll removes all savepoints and all savepoint files`() { + val savepointsRepository = buildSubject(cacheDirPath, instancesDirPath) + + val savepointFile1 = getSavepointFile("foo") + savepointFile1.createNewFile() + val savepoint1 = Savepoint(1, null, savepointFile1.absolutePath, "") + + val savepointFile2 = getSavepointFile("bar") + savepointFile2.createNewFile() + val savepoint2 = Savepoint(2, 1, savepointFile2.absolutePath, "") + + savepointsRepository.save(savepoint1) + savepointsRepository.save(savepoint2) + + savepointsRepository.deleteAll() + + assertThat(savepointsRepository.getAll().isEmpty(), equalTo(true)) + assertThat(savepointFile1.exists(), equalTo(false)) + assertThat(savepointFile2.exists(), equalTo(false)) + } +} diff --git a/formstest/src/test/java/org/odk/collect/formstest/InMemFormsRepositoryTest.java b/forms-test/src/test/java/org/odk/collect/formstest/InMemFormsRepositoryTest.java similarity index 82% rename from formstest/src/test/java/org/odk/collect/formstest/InMemFormsRepositoryTest.java rename to forms-test/src/test/java/org/odk/collect/formstest/InMemFormsRepositoryTest.java index d687bd644f8..8caf6fba58d 100644 --- a/formstest/src/test/java/org/odk/collect/formstest/InMemFormsRepositoryTest.java +++ b/forms-test/src/test/java/org/odk/collect/formstest/InMemFormsRepositoryTest.java @@ -17,12 +17,12 @@ public void setup() { @Override public FormsRepository buildSubject() { - return new InMemFormsRepository(); + return new InMemFormsRepository(savepointsRepository); } @Override public FormsRepository buildSubject(Supplier clock) { - return new InMemFormsRepository(clock); + return new InMemFormsRepository(clock, savepointsRepository); } @Override diff --git a/formstest/src/test/java/org/odk/collect/formstest/InMemInstancesRepositoryTest.java b/forms-test/src/test/java/org/odk/collect/formstest/InMemInstancesRepositoryTest.java similarity index 100% rename from formstest/src/test/java/org/odk/collect/formstest/InMemInstancesRepositoryTest.java rename to forms-test/src/test/java/org/odk/collect/formstest/InMemInstancesRepositoryTest.java diff --git a/forms-test/src/test/java/org/odk/collect/formstest/InMemSavepointsRepositoryTest.kt b/forms-test/src/test/java/org/odk/collect/formstest/InMemSavepointsRepositoryTest.kt new file mode 100644 index 00000000000..d1fbccf20b1 --- /dev/null +++ b/forms-test/src/test/java/org/odk/collect/formstest/InMemSavepointsRepositoryTest.kt @@ -0,0 +1,9 @@ +package org.odk.collect.formstest + +import org.odk.collect.forms.savepoints.SavepointsRepository + +class InMemSavepointsRepositoryTest : SavepointsRepositoryTest() { + override fun buildSubject(cacheDirPath: String, instancesDirPath: String): SavepointsRepository { + return InMemSavepointsRepository() + } +} diff --git a/forms/src/main/java/org/odk/collect/forms/Form.java b/forms/src/main/java/org/odk/collect/forms/Form.java index 62578b725d6..017ad003a42 100644 --- a/forms/src/main/java/org/odk/collect/forms/Form.java +++ b/forms/src/main/java/org/odk/collect/forms/Form.java @@ -262,6 +262,7 @@ public String getLanguage() { return language; } + @Nullable public String getAutoSend() { return autoSend; } diff --git a/forms/src/main/java/org/odk/collect/forms/instances/Instance.java b/forms/src/main/java/org/odk/collect/forms/instances/Instance.java index bf203c1fbe6..4283f3e1583 100644 --- a/forms/src/main/java/org/odk/collect/forms/instances/Instance.java +++ b/forms/src/main/java/org/odk/collect/forms/instances/Instance.java @@ -18,6 +18,8 @@ import org.jetbrains.annotations.Nullable; +import java.util.Objects; + /** * A filled form stored on the device. *

@@ -213,13 +215,21 @@ public Long getDbId() { } @Override - public boolean equals(Object other) { - return other == this || other instanceof Instance - && this.instanceFilePath.equals(((Instance) other).instanceFilePath); + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + Instance instance = (Instance) o; + return canEditWhenComplete == instance.canEditWhenComplete && Objects.equals(displayName, instance.displayName) && Objects.equals(submissionUri, instance.submissionUri) && Objects.equals(instanceFilePath, instance.instanceFilePath) && Objects.equals(formId, instance.formId) && Objects.equals(formVersion, instance.formVersion) && Objects.equals(status, instance.status) && Objects.equals(lastStatusChangeDate, instance.lastStatusChangeDate) && Objects.equals(deletedDate, instance.deletedDate) && Objects.equals(geometryType, instance.geometryType) && Objects.equals(geometry, instance.geometry) && Objects.equals(dbId, instance.dbId); } @Override public int hashCode() { - return instanceFilePath.hashCode(); + return Objects.hash(displayName, submissionUri, canEditWhenComplete, instanceFilePath, formId, formVersion, status, lastStatusChangeDate, deletedDate, geometryType, geometry, dbId); } } diff --git a/forms/src/main/java/org/odk/collect/forms/savepoints/Savepoint.kt b/forms/src/main/java/org/odk/collect/forms/savepoints/Savepoint.kt new file mode 100644 index 00000000000..826d8773fb9 --- /dev/null +++ b/forms/src/main/java/org/odk/collect/forms/savepoints/Savepoint.kt @@ -0,0 +1,8 @@ +package org.odk.collect.forms.savepoints + +data class Savepoint( + val formDbId: Long, + val instanceDbId: Long?, + val savepointFilePath: String, + val instanceFilePath: String +) diff --git a/forms/src/main/java/org/odk/collect/forms/savepoints/SavepointsRepository.kt b/forms/src/main/java/org/odk/collect/forms/savepoints/SavepointsRepository.kt new file mode 100644 index 00000000000..167fbb17a8f --- /dev/null +++ b/forms/src/main/java/org/odk/collect/forms/savepoints/SavepointsRepository.kt @@ -0,0 +1,13 @@ +package org.odk.collect.forms.savepoints + +interface SavepointsRepository { + fun get(formDbId: Long, instanceDbId: Long?): Savepoint? + + fun getAll(): List + + fun save(savepoint: Savepoint) + + fun delete(formDbId: Long, instanceDbId: Long?) + + fun deleteAll() +} diff --git a/formstest/src/main/java/org/odk/collect/formstest/InstanceFixtures.kt b/formstest/src/main/java/org/odk/collect/formstest/InstanceFixtures.kt deleted file mode 100644 index c712d242a68..00000000000 --- a/formstest/src/main/java/org/odk/collect/formstest/InstanceFixtures.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.odk.collect.formstest - -import org.odk.collect.forms.instances.Instance -import org.odk.collect.shared.TempFiles - -object InstanceFixtures { - - fun instance(status: String = Instance.STATUS_INCOMPLETE, lastStatusChangeDate: Long = 0): Instance { - val instancesDir = TempFiles.createTempDir() - return InstanceUtils.buildInstance("formId", "version", instancesDir.absolutePath) - .status(status) - .lastStatusChangeDate(lastStatusChangeDate) - .build() - } -} diff --git a/geo/build.gradle.kts b/geo/build.gradle.kts index 67d10e41fb8..b7f7abb17eb 100644 --- a/geo/build.gradle.kts +++ b/geo/build.gradle.kts @@ -55,12 +55,13 @@ dependencies { implementation(project(":async")) implementation(project(":analytics")) implementation(project(":permissions")) + implementation(project(":settings")) implementation(project(":maps")) implementation(project(":material")) + implementation(project(":web-page")) implementation(Dependencies.kotlin_stdlib) implementation(Dependencies.androidx_appcompat) implementation(Dependencies.androidx_lifecycle_livedata_ktx) - implementation(Dependencies.android_material) implementation(Dependencies.timber) implementation(Dependencies.play_services_location) implementation(Dependencies.androidx_fragment_ktx) @@ -70,6 +71,7 @@ dependencies { debugImplementation(project(":fragments-test")) testImplementation(project(":androidtest")) + testImplementation(project(":settings")) testImplementation(project(":test-shared")) testImplementation(Dependencies.junit) testImplementation(Dependencies.hamcrest) diff --git a/geo/src/main/java/org/odk/collect/geo/DaggerSetup.kt b/geo/src/main/java/org/odk/collect/geo/DaggerSetup.kt index edf0d0099f8..0ff45d1b808 100644 --- a/geo/src/main/java/org/odk/collect/geo/DaggerSetup.kt +++ b/geo/src/main/java/org/odk/collect/geo/DaggerSetup.kt @@ -19,7 +19,10 @@ import org.odk.collect.location.LocationClient import org.odk.collect.location.satellites.SatelliteInfoClient import org.odk.collect.location.tracker.LocationTracker import org.odk.collect.maps.MapFragmentFactory +import org.odk.collect.maps.layers.ReferenceLayerRepository import org.odk.collect.permissions.PermissionsChecker +import org.odk.collect.settings.SettingsProvider +import org.odk.collect.webpage.ExternalWebPageHelper import javax.inject.Singleton interface GeoDependencyComponentProvider { @@ -50,7 +53,6 @@ interface GeoDependencyComponent { val scheduler: Scheduler val locationTracker: LocationTracker val satelliteInfoClient: SatelliteInfoClient - val referenceLayerSettingsNavigator: ReferenceLayerSettingsNavigator } @Module @@ -66,11 +68,6 @@ open class GeoDependencyModule { throw UnsupportedOperationException("This should be overridden by dependent application") } - @Provides - open fun providesReferenceLayerSettingsNavigator(): ReferenceLayerSettingsNavigator { - throw UnsupportedOperationException("This should be overridden by dependent application") - } - @Provides open fun providesLocationTracker(application: Application): LocationTracker { throw UnsupportedOperationException("This should be overridden by dependent application") @@ -112,4 +109,19 @@ open class GeoDependencyModule { } } } + + @Provides + open fun providesReferenceLayerRepository(): ReferenceLayerRepository { + throw UnsupportedOperationException("This should be overridden by dependent application") + } + + @Provides + open fun providesSettingsProvider(): SettingsProvider { + throw UnsupportedOperationException("This should be overridden by dependent application") + } + + @Provides + open fun providesExternalWebPageHelper(): ExternalWebPageHelper { + throw UnsupportedOperationException("This should be overridden by dependent application") + } } diff --git a/geo/src/main/java/org/odk/collect/geo/ReferenceLayerSettingsNavigator.kt b/geo/src/main/java/org/odk/collect/geo/ReferenceLayerSettingsNavigator.kt deleted file mode 100644 index 32c239d99e3..00000000000 --- a/geo/src/main/java/org/odk/collect/geo/ReferenceLayerSettingsNavigator.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.odk.collect.geo - -import androidx.fragment.app.FragmentActivity - -interface ReferenceLayerSettingsNavigator { - - fun navigateToReferenceLayerSettings(activity: FragmentActivity) -} diff --git a/geo/src/main/java/org/odk/collect/geo/geopoint/GeoPointActivity.kt b/geo/src/main/java/org/odk/collect/geo/geopoint/GeoPointActivity.kt index 66213cd6c1b..b08eadca281 100644 --- a/geo/src/main/java/org/odk/collect/geo/geopoint/GeoPointActivity.kt +++ b/geo/src/main/java/org/odk/collect/geo/geopoint/GeoPointActivity.kt @@ -47,7 +47,7 @@ class GeoPointActivity : LocalizedActivity(), GeoPointDialogFragment.Listener { this.intent.extras?.get( EXTRA_UNACCEPTABLE_ACCURACY_THRESHOLD ) as? Float - ) + ) ) } diff --git a/geo/src/main/java/org/odk/collect/geo/geopoint/GeoPointMapActivity.java b/geo/src/main/java/org/odk/collect/geo/geopoint/GeoPointMapActivity.java index 29fc1f97404..c10e981b998 100644 --- a/geo/src/main/java/org/odk/collect/geo/geopoint/GeoPointMapActivity.java +++ b/geo/src/main/java/org/odk/collect/geo/geopoint/GeoPointMapActivity.java @@ -32,19 +32,24 @@ import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentContainerView; +import org.odk.collect.androidshared.ui.DialogFragmentUtils; import org.odk.collect.androidshared.ui.FragmentFactoryBuilder; import org.odk.collect.androidshared.ui.ToastUtils; +import org.odk.collect.async.Scheduler; import org.odk.collect.externalapp.ExternalAppUtils; import org.odk.collect.geo.GeoDependencyComponentProvider; import org.odk.collect.geo.GeoUtils; import org.odk.collect.geo.R; -import org.odk.collect.geo.ReferenceLayerSettingsNavigator; import org.odk.collect.maps.MapFragment; import org.odk.collect.maps.MapFragmentFactory; import org.odk.collect.maps.MapPoint; +import org.odk.collect.maps.layers.OfflineMapLayersPicker; +import org.odk.collect.maps.layers.ReferenceLayerRepository; import org.odk.collect.maps.markers.MarkerDescription; import org.odk.collect.maps.markers.MarkerIconDescription; +import org.odk.collect.settings.SettingsProvider; import org.odk.collect.strings.localization.LocalizedActivity; +import org.odk.collect.webpage.ExternalWebPageHelper; import java.text.DecimalFormat; @@ -84,7 +89,16 @@ public class GeoPointMapActivity extends LocalizedActivity { MapFragmentFactory mapFragmentFactory; @Inject - ReferenceLayerSettingsNavigator referenceLayerSettingsNavigator; + ReferenceLayerRepository referenceLayerRepository; + + @Inject + Scheduler scheduler; + + @Inject + SettingsProvider settingsProvider; + + @Inject + ExternalWebPageHelper externalWebPageHelper; private MapFragment map; private int featureId = -1; // will be a positive featureId once map is ready @@ -123,19 +137,18 @@ public class GeoPointMapActivity extends LocalizedActivity { @Override public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - + ((GeoDependencyComponentProvider) getApplication()).getGeoDependencyComponent().inject(this); getSupportFragmentManager().setFragmentFactory(new FragmentFactoryBuilder() .forClass(MapFragment.class, () -> (Fragment) mapFragmentFactory.createMapFragment()) + .forClass(OfflineMapLayersPicker.class, () -> new OfflineMapLayersPicker(getActivityResultRegistry(), referenceLayerRepository, scheduler, settingsProvider, externalWebPageHelper)) .build() ); + super.onCreate(savedInstanceState); requireLocationPermissions(this); previousState = savedInstanceState; - ((GeoDependencyComponentProvider) getApplication()).getGeoDependencyComponent().inject(this); - requestWindowFeature(Window.FEATURE_NO_TITLE); try { setContentView(R.layout.geopoint_layout); @@ -229,7 +242,7 @@ public void initMap(MapFragment newMapFragment) { // Menu Layer Toggle findViewById(R.id.layer_menu).setOnClickListener(v -> { - referenceLayerSettingsNavigator.navigateToReferenceLayerSettings(this); + DialogFragmentUtils.showIfNotShowing(OfflineMapLayersPicker.class, getSupportFragmentManager()); }); clearButton = findViewById(R.id.clear); diff --git a/geo/src/main/java/org/odk/collect/geo/geopoly/GeoPolyActivity.java b/geo/src/main/java/org/odk/collect/geo/geopoly/GeoPolyActivity.java index 374151c9746..487ef0158f5 100644 --- a/geo/src/main/java/org/odk/collect/geo/geopoly/GeoPolyActivity.java +++ b/geo/src/main/java/org/odk/collect/geo/geopoly/GeoPolyActivity.java @@ -35,18 +35,24 @@ import org.odk.collect.androidshared.ui.DialogFragmentUtils; import org.odk.collect.androidshared.ui.FragmentFactoryBuilder; import org.odk.collect.androidshared.ui.ToastUtils; +import org.odk.collect.async.Scheduler; import org.odk.collect.externalapp.ExternalAppUtils; import org.odk.collect.geo.Constants; import org.odk.collect.geo.GeoDependencyComponentProvider; import org.odk.collect.geo.GeoUtils; import org.odk.collect.geo.R; -import org.odk.collect.geo.ReferenceLayerSettingsNavigator; import org.odk.collect.location.Location; import org.odk.collect.location.tracker.LocationTracker; +import org.odk.collect.maps.LineDescription; +import org.odk.collect.maps.MapConsts; import org.odk.collect.maps.MapFragment; import org.odk.collect.maps.MapFragmentFactory; import org.odk.collect.maps.MapPoint; +import org.odk.collect.maps.layers.OfflineMapLayersPicker; +import org.odk.collect.maps.layers.ReferenceLayerRepository; +import org.odk.collect.settings.SettingsProvider; import org.odk.collect.strings.localization.LocalizedActivity; +import org.odk.collect.webpage.ExternalWebPageHelper; import java.util.ArrayList; import java.util.List; @@ -70,7 +76,7 @@ public class GeoPolyActivity extends LocalizedActivity implements GeoPolySetting public enum OutputMode { GEOTRACE, GEOSHAPE } - private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + private final ScheduledExecutorService executorServiceScheduler = Executors.newSingleThreadScheduledExecutor(); private ScheduledFuture schedulerHandler; private OutputMode outputMode; @@ -82,18 +88,28 @@ public enum OutputMode { GEOTRACE, GEOSHAPE } LocationTracker locationTracker; @Inject - ReferenceLayerSettingsNavigator referenceLayerSettingsNavigator; + ReferenceLayerRepository referenceLayerRepository; + + @Inject + Scheduler scheduler; + + @Inject + SettingsProvider settingsProvider; + + @Inject + ExternalWebPageHelper externalWebPageHelper; private MapFragment map; private int featureId = -1; // will be a positive featureId once map is ready private List originalPoly; private ImageButton zoomButton; - private ImageButton playButton; - private ImageButton clearButton; + ImageButton playButton; + ImageButton clearButton; private Button recordButton; private ImageButton pauseButton; - private ImageButton backspaceButton; + ImageButton backspaceButton; + ImageButton saveButton; private TextView locationStatus; private TextView collectionStatus; @@ -125,7 +141,7 @@ public enum OutputMode { GEOTRACE, GEOSHAPE } private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { - if (map != null && !originalPoly.equals(map.getPolyLinePoints(featureId))) { + if (!intentReadOnly && map != null && !originalPoly.equals(map.getPolyLinePoints(featureId))) { showBackDialog(); } else { finish(); @@ -134,19 +150,20 @@ public void handleOnBackPressed() { }; @Override public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); + ((GeoDependencyComponentProvider) getApplication()).getGeoDependencyComponent().inject(this); getSupportFragmentManager().setFragmentFactory(new FragmentFactoryBuilder() .forClass(MapFragment.class, () -> (Fragment) mapFragmentFactory.createMapFragment()) + .forClass(OfflineMapLayersPicker.class, () -> new OfflineMapLayersPicker(getActivityResultRegistry(), referenceLayerRepository, scheduler, settingsProvider, externalWebPageHelper)) .build() ); + super.onCreate(savedInstanceState); + requireLocationPermissions(this); previousState = savedInstanceState; - ((GeoDependencyComponentProvider) getApplication()).getGeoDependencyComponent().inject(this); - if (savedInstanceState != null) { restoredPoints = savedInstanceState.getParcelableArrayList(POINTS_KEY); inputActive = savedInstanceState.getBoolean(INPUT_ACTIVE_KEY, false); @@ -223,7 +240,7 @@ public void initMap(MapFragment newMapFragment) { backspaceButton = findViewById(R.id.backspace); backspaceButton.setOnClickListener(v -> removeLastPoint()); - ImageButton saveButton = findViewById(R.id.save); + saveButton = findViewById(R.id.save); saveButton.setOnClickListener(v -> { if (!map.getPolyLinePoints(featureId).isEmpty()) { if (outputMode == OutputMode.GEOTRACE) { @@ -249,7 +266,7 @@ public void initMap(MapFragment newMapFragment) { recordButton.setOnClickListener(v -> recordPoint(map.getGpsLocation())); findViewById(R.id.layers).setOnClickListener(v -> { - referenceLayerSettingsNavigator.navigateToReferenceLayerSettings(this); + DialogFragmentUtils.showIfNotShowing(OfflineMapLayersPicker.class, getSupportFragmentManager()); }); zoomButton = findViewById(R.id.zoom); @@ -274,7 +291,7 @@ public void initMap(MapFragment newMapFragment) { if (restoredPoints != null) { points = restoredPoints; } - featureId = map.addPolyLine(points, outputMode == OutputMode.GEOSHAPE, true); + featureId = map.addPolyLine(new LineDescription(points, String.valueOf(MapConsts.DEFAULT_STROKE_WIDTH), null, !intentReadOnly, outputMode == OutputMode.GEOSHAPE)); if (inputActive && !intentReadOnly) { startInput(); @@ -285,7 +302,7 @@ public void initMap(MapFragment newMapFragment) { map.setLongPressListener(this::onClick); map.setGpsLocationEnabled(true); map.setGpsLocationListener(this::onGpsLocation); - + if (!map.hasCenter()) { if (!points.isEmpty()) { map.zoomToBoundingBox(points, 0.6, false); @@ -333,7 +350,7 @@ public void startInput() { locationTracker.start(retainMockAccuracy); recordPoint(map.getGpsLocation()); - schedulerHandler = scheduler.scheduleAtFixedRate(() -> runOnUiThread(() -> { + schedulerHandler = executorServiceScheduler.scheduleAtFixedRate(() -> runOnUiThread(() -> { Location currentLocation = locationTracker.getCurrentLocation(); if (currentLocation != null) { @@ -442,7 +459,7 @@ private void removeLastPoint() { private void clear() { map.clearFeatures(); - featureId = map.addPolyLine(new ArrayList<>(), outputMode == OutputMode.GEOSHAPE, true); + featureId = map.addPolyLine(new LineDescription(new ArrayList<>(), String.valueOf(MapConsts.DEFAULT_STROKE_WIDTH), null, !intentReadOnly, outputMode == OutputMode.GEOSHAPE)); inputActive = false; updateUi(); } @@ -468,6 +485,7 @@ private void updateUi() { playButton.setEnabled(false); backspaceButton.setEnabled(false); clearButton.setEnabled(false); + saveButton.setEnabled(false); } // Settings dialog diff --git a/geo/src/main/java/org/odk/collect/geo/selection/MappableSelectItem.kt b/geo/src/main/java/org/odk/collect/geo/selection/MappableSelectItem.kt index cc57a33f6e3..743ddb36f6d 100644 --- a/geo/src/main/java/org/odk/collect/geo/selection/MappableSelectItem.kt +++ b/geo/src/main/java/org/odk/collect/geo/selection/MappableSelectItem.kt @@ -2,95 +2,58 @@ package org.odk.collect.geo.selection import org.odk.collect.maps.MapPoint -sealed interface MappableSelectItem { - - val id: Long - val points: List - val smallIcon: Int - val largeIcon: Int - val name: String - val properties: List - val selected: Boolean - val color: String? - val symbol: String? - - data class WithInfo( +sealed class MappableSelectItem { + abstract val id: Long + abstract val name: String + abstract val properties: List + abstract val selected: Boolean + abstract val info: String? + abstract val action: IconifiedText? + abstract val status: Status? + + data class MappableSelectPoint( override val id: Long, - override val points: List, - override val smallIcon: Int, - override val largeIcon: Int, override val name: String, - override val properties: List, - val info: String, + override val properties: List = emptyList(), override val selected: Boolean = false, - override val color: String? = null, - override val symbol: String? = null - ) : MappableSelectItem { - - constructor( - id: Long, - latitude: Double, - longitude: Double, - smallIcon: Int, - largeIcon: Int, - name: String, - properties: List, - info: String, - selected: Boolean = false, - color: String? = null, - symbol: String? = null - ) : this( - id, - listOf(MapPoint(latitude, longitude)), - smallIcon, - largeIcon, - name, - properties, - info, - selected, - color, - symbol - ) - } - - data class WithAction( + override val info: String? = null, + override val action: IconifiedText? = null, + override val status: Status? = null, + val point: MapPoint, + val smallIcon: Int, + val largeIcon: Int, + val color: String? = null, + val symbol: String? = null + ) : MappableSelectItem() + + data class MappableSelectLine( override val id: Long, - override val points: List, - override val smallIcon: Int, - override val largeIcon: Int, override val name: String, - override val properties: List, - val action: IconifiedText, + override val properties: List = emptyList(), override val selected: Boolean = false, - override val color: String? = null, - override val symbol: String? = null - ) : MappableSelectItem { + override val info: String? = null, + override val action: IconifiedText? = null, + override val status: Status? = null, + val points: List, + val strokeWidth: String? = null, + val strokeColor: String? = null + ) : MappableSelectItem() + + data class MappableSelectPolygon( + override val id: Long, + override val name: String, + override val properties: List = emptyList(), + override val selected: Boolean = false, + override val info: String? = null, + override val action: IconifiedText? = null, + override val status: Status? = null, + val points: List, + val strokeWidth: String? = null, + val strokeColor: String? = null, + val fillColor: String? = null + ) : MappableSelectItem() +} - constructor( - id: Long, - latitude: Double, - longitude: Double, - smallIcon: Int, - largeIcon: Int, - name: String, - properties: List, - action: IconifiedText, - selected: Boolean = false, - color: String? = null, - symbol: String? = null - ) : this( - id, - listOf(MapPoint(latitude, longitude)), - smallIcon, - largeIcon, - name, - properties, - action, - selected, - color, - symbol - ) - } +data class IconifiedText(val icon: Int?, val text: String) - data class IconifiedText(val icon: Int?, val text: String) -} +enum class Status { ERRORS, NO_ERRORS } diff --git a/geo/src/main/java/org/odk/collect/geo/selection/SelectionMapFragment.kt b/geo/src/main/java/org/odk/collect/geo/selection/SelectionMapFragment.kt index 64322ede7cd..1aebdbe9a67 100644 --- a/geo/src/main/java/org/odk/collect/geo/selection/SelectionMapFragment.kt +++ b/geo/src/main/java/org/odk/collect/geo/selection/SelectionMapFragment.kt @@ -18,20 +18,27 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCa import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN import org.odk.collect.androidshared.livedata.NonNullLiveData +import org.odk.collect.androidshared.ui.DialogFragmentUtils import org.odk.collect.androidshared.ui.FragmentFactoryBuilder import org.odk.collect.androidshared.ui.ToastUtils import org.odk.collect.androidshared.ui.multiclicksafe.setMultiClickSafeOnClickListener +import org.odk.collect.async.Scheduler import org.odk.collect.geo.GeoDependencyComponentProvider -import org.odk.collect.geo.ReferenceLayerSettingsNavigator import org.odk.collect.geo.databinding.SelectionMapLayoutBinding +import org.odk.collect.maps.LineDescription import org.odk.collect.maps.MapFragment import org.odk.collect.maps.MapFragmentFactory import org.odk.collect.maps.MapPoint +import org.odk.collect.maps.PolygonDescription +import org.odk.collect.maps.layers.OfflineMapLayersPicker +import org.odk.collect.maps.layers.ReferenceLayerRepository import org.odk.collect.maps.markers.MarkerDescription import org.odk.collect.maps.markers.MarkerIconDescription import org.odk.collect.material.BottomSheetBehavior import org.odk.collect.material.MaterialProgressDialogFragment import org.odk.collect.permissions.PermissionsChecker +import org.odk.collect.settings.SettingsProvider +import org.odk.collect.webpage.ExternalWebPageHelper import javax.inject.Inject /** @@ -50,10 +57,19 @@ class SelectionMapFragment( lateinit var mapFragmentFactory: MapFragmentFactory @Inject - lateinit var referenceLayerSettingsNavigator: ReferenceLayerSettingsNavigator + lateinit var permissionsChecker: PermissionsChecker @Inject - lateinit var permissionsChecker: PermissionsChecker + lateinit var referenceLayerRepository: ReferenceLayerRepository + + @Inject + lateinit var scheduler: Scheduler + + @Inject + lateinit var settingsProvider: SettingsProvider + + @Inject + lateinit var externalWebPageHelper: ExternalWebPageHelper private val selectedItemViewModel by viewModels() @@ -80,6 +96,9 @@ class SelectionMapFragment( .forClass(MapFragment::class.java) { mapFragmentFactory.createMapFragment() as Fragment } + .forClass(OfflineMapLayersPicker::class) { + OfflineMapLayersPicker(requireActivity().activityResultRegistry, referenceLayerRepository, scheduler, settingsProvider, externalWebPageHelper) + } .build() super.onCreate(savedInstanceState) @@ -178,7 +197,10 @@ class SelectionMapFragment( } binding.layerMenu.setMultiClickSafeOnClickListener { - referenceLayerSettingsNavigator.navigateToReferenceLayerSettings(requireActivity()) + DialogFragmentUtils.showIfNotShowing( + OfflineMapLayersPicker::class.java, + childFragmentManager + ) } if (showNewItemButton) { @@ -237,7 +259,9 @@ class SelectionMapFragment( val selectedItem = selectedItemViewModel.getSelectedItem() if (newState == STATE_HIDDEN && selectedItem != null) { selectedItemViewModel.setSelectedItem(null) - resetIcon(selectedItem) + if (selectedItem is MappableSelectItem.MappableSelectPoint) { + resetIcon(selectedItem) + } closeSummarySheet.isEnabled = false } else { @@ -269,7 +293,7 @@ class SelectionMapFragment( val selectedItem = selectedItemViewModel.getSelectedItem() if (item != null) { - if (selectedItem != null && selectedItem.id != item.id) { + if (selectedItem != null && selectedItem.id != item.id && selectedItem is MappableSelectItem.MappableSelectPoint) { resetIcon(selectedItem) } @@ -281,23 +305,25 @@ class SelectionMapFragment( } ) } else { - if (item.points.size > 1) { - map.zoomToBoundingBox(item.points, 0.8, true) - } else { - val point = item.points[0] + when (item) { + is MappableSelectItem.MappableSelectLine -> map.zoomToBoundingBox(item.points, 0.8, true) + is MappableSelectItem.MappableSelectPolygon -> map.zoomToBoundingBox(item.points, 0.8, true) + is MappableSelectItem.MappableSelectPoint -> { + val point = item.point + + if (maintainZoom) { + map.zoomToPoint(MapPoint(point.latitude, point.longitude), map.zoom, true) + } else { + map.zoomToPoint(MapPoint(point.latitude, point.longitude), true) + } - if (maintainZoom) { - map.zoomToPoint(MapPoint(point.latitude, point.longitude), map.zoom, true) - } else { - map.zoomToPoint(MapPoint(point.latitude, point.longitude), true) + map.setMarkerIcon( + featureId, + MarkerIconDescription(item.largeIcon, item.color, item.symbol) + ) } } - map.setMarkerIcon( - featureId, - MarkerIconDescription(item.largeIcon, item.color, item.symbol) - ) - summarySheet.setItem(item) summarySheetBehavior.state = STATE_COLLAPSED @@ -349,7 +375,7 @@ class SelectionMapFragment( } } - private fun resetIcon(selectedItem: MappableSelectItem) { + private fun resetIcon(selectedItem: MappableSelectItem.MappableSelectPoint) { val featureId = featureIdsByItemId[selectedItem.id] if (featureId != null) { map.setMarkerIcon( @@ -367,14 +393,13 @@ class SelectionMapFragment( map.clearFeatures() itemsByFeatureId.clear() - val singlePoints = items.filter { it.points.size == 1 } - val polys = items.filter { it.points.size != 1 } + val singlePoints = items.filterIsInstance() + val lines = items.filterIsInstance() + val polygons = items.filterIsInstance() val markerDescriptions = singlePoints.map { - val point = it.points[0] - MarkerDescription( - MapPoint(point.latitude, point.longitude), + MapPoint(it.point.latitude, it.point.longitude), false, MapFragment.BOTTOM, MarkerIconDescription(it.smallIcon, it.color, it.symbol) @@ -382,18 +407,21 @@ class SelectionMapFragment( } val pointIds = map.addMarkers(markerDescriptions) - val polyIds = polys.fold(listOf()) { ids, item -> - if (item.points.first() == item.points.last()) { - ids + map.addPolygon(item.points) - } else { - ids + map.addPolyLine(item.points, false, false) - } + val lineIds = lines.fold(listOf()) { ids, item -> + ids + map.addPolyLine(LineDescription(item.points, item.strokeWidth, item.strokeColor)) + } + val polygonIds = polygons.fold(listOf()) { ids, item -> + ids + map.addPolygon(PolygonDescription(item.points, item.strokeWidth, item.strokeColor, item.fillColor)) } - (singlePoints + polys).zip(pointIds + polyIds).forEach { (item, featureId) -> + (singlePoints + lines + polygons).zip(pointIds + lineIds + polygonIds).forEach { (item, featureId) -> itemsByFeatureId[featureId] = item featureIdsByItemId[item.id] = featureId - points.addAll(item.points) + when (item) { + is MappableSelectItem.MappableSelectPoint -> points.add(item.point) + is MappableSelectItem.MappableSelectLine -> points.addAll(item.points) + is MappableSelectItem.MappableSelectPolygon -> points.addAll(item.points) + } } featureCount = items.size diff --git a/geo/src/main/java/org/odk/collect/geo/selection/SelectionSummarySheet.kt b/geo/src/main/java/org/odk/collect/geo/selection/SelectionSummarySheet.kt index 9086fb1cf00..80fc815a3fb 100644 --- a/geo/src/main/java/org/odk/collect/geo/selection/SelectionSummarySheet.kt +++ b/geo/src/main/java/org/odk/collect/geo/selection/SelectionSummarySheet.kt @@ -46,6 +46,11 @@ internal class SelectionSummarySheet(context: Context, attrs: AttributeSet?) : fun setItem(item: MappableSelectItem) { itemId = item.id + when (item.status) { + Status.ERRORS -> binding.statusChip.errors = true + Status.NO_ERRORS -> binding.statusChip.errors = false + else -> binding.statusChip.visibility = View.GONE + } binding.name.text = item.name @@ -67,23 +72,19 @@ internal class SelectionSummarySheet(context: Context, attrs: AttributeSet?) : binding.properties.addView(property.root) } - when (item) { - is MappableSelectItem.WithAction -> { - binding.action.text = item.action.text - - if (item.action.icon != null) { - binding.action.icon = ContextCompat.getDrawable(context, item.action.icon) - } + item.action?.let { + binding.action.text = it.text - binding.action.visibility = View.VISIBLE - binding.info.visibility = View.GONE + if (it.icon != null) { + binding.action.icon = ContextCompat.getDrawable(context, it.icon) } - is MappableSelectItem.WithInfo -> { - binding.info.text = item.info - binding.info.visibility = View.VISIBLE - binding.action.visibility = View.GONE - } + binding.action.visibility = View.VISIBLE + } + + item.info?.let { + binding.info.text = item.info + binding.info.visibility = View.VISIBLE } } diff --git a/geo/src/main/res/layout/selection_map_layout.xml b/geo/src/main/res/layout/selection_map_layout.xml index 668de0e4878..51ce05edce8 100644 --- a/geo/src/main/res/layout/selection_map_layout.xml +++ b/geo/src/main/res/layout/selection_map_layout.xml @@ -103,7 +103,9 @@ android:layout_height="match_parent" android:background="?colorSurface" app:behavior_hideable="true" + android:maxWidth="@null" app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" + style="@style/Widget.Material3.BottomSheet" tools:visibility="gone" /> diff --git a/geo/src/main/res/layout/selection_summary_sheet_layout.xml b/geo/src/main/res/layout/selection_summary_sheet_layout.xml index a401ac626ce..aa328fab7ec 100644 --- a/geo/src/main/res/layout/selection_summary_sheet_layout.xml +++ b/geo/src/main/res/layout/selection_summary_sheet_layout.xml @@ -9,13 +9,6 @@ android:layout_height="wrap_content" android:paddingBottom="@dimen/margin_standard"> - - + + + + + tools:text="Info" + tools:visibility="visible" /> - - - diff --git a/geo/src/test/java/org/odk/collect/geo/geopoint/GeoPointMapActivityTest.java b/geo/src/test/java/org/odk/collect/geo/geopoint/GeoPointMapActivityTest.java index 919106f66ef..3ab36f9f2bd 100644 --- a/geo/src/test/java/org/odk/collect/geo/geopoint/GeoPointMapActivityTest.java +++ b/geo/src/test/java/org/odk/collect/geo/geopoint/GeoPointMapActivityTest.java @@ -1,10 +1,14 @@ package org.odk.collect.geo.geopoint; import static android.app.Activity.RESULT_OK; +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.matcher.ViewMatchers.withId; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; import static org.odk.collect.geo.Constants.EXTRA_RETAIN_MOCK_ACCURACY; import static org.robolectric.Shadows.shadowOf; @@ -21,15 +25,19 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.odk.collect.androidtest.ActivityScenarioLauncherRule; +import org.odk.collect.async.Scheduler; import org.odk.collect.externalapp.ExternalAppUtils; import org.odk.collect.geo.DaggerGeoDependencyComponent; import org.odk.collect.geo.GeoDependencyModule; import org.odk.collect.geo.R; -import org.odk.collect.geo.ReferenceLayerSettingsNavigator; import org.odk.collect.geo.support.FakeMapFragment; import org.odk.collect.geo.support.RobolectricApplication; import org.odk.collect.maps.MapFragmentFactory; import org.odk.collect.maps.MapPoint; +import org.odk.collect.maps.layers.ReferenceLayerRepository; +import org.odk.collect.settings.InMemSettingsProvider; +import org.odk.collect.settings.SettingsProvider; +import org.odk.collect.webpage.ExternalWebPageHelper; import org.robolectric.shadows.ShadowApplication; import java.util.List; @@ -60,8 +68,26 @@ public MapFragmentFactory providesMapFragmentFactory() { @NonNull @Override - public ReferenceLayerSettingsNavigator providesReferenceLayerSettingsNavigator() { - return activity -> { }; + public ReferenceLayerRepository providesReferenceLayerRepository() { + return mock(); + } + + @NonNull + @Override + public Scheduler providesScheduler() { + return mock(); + } + + @NonNull + @Override + public SettingsProvider providesSettingsProvider() { + return new InMemSettingsProvider(); + } + + @NonNull + @Override + public ExternalWebPageHelper providesExternalWebPageHelper() { + return mock(); } }) .build(); @@ -143,4 +169,14 @@ public void passingRetainMockAccuracyExtra_showSetItOnLocationClient() { assertThat(mapFragment.isRetainMockAccuracy(), is(false)); } + + @Test + public void recreatingTheActivityWithTheLayersDialogDisplayedDoesNotCrashTheApp() { + ActivityScenario scenario = launcherRule.launch(GeoPointMapActivity.class); + mapFragment.ready(); + + onView(withId(R.id.layer_menu)).perform(click()); + + scenario.recreate(); + } } diff --git a/geo/src/test/java/org/odk/collect/geo/geopoly/GeoPolyActivityTest.java b/geo/src/test/java/org/odk/collect/geo/geopoly/GeoPolyActivityTest.java deleted file mode 100644 index 5701f0ff721..00000000000 --- a/geo/src/test/java/org/odk/collect/geo/geopoly/GeoPolyActivityTest.java +++ /dev/null @@ -1,254 +0,0 @@ -/* - * Copyright (C) 2018 Nafundi - * - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the License - * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing permissions and limitations under - * the License. - */ - -package org.odk.collect.geo.geopoly; - -import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.action.ViewActions.click; -import static androidx.test.espresso.assertion.ViewAssertions.matches; -import static androidx.test.espresso.matcher.RootMatchers.isDialog; -import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.withId; -import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.not; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.odk.collect.androidtest.ActivityScenarioExtensions.isFinishing; -import static org.robolectric.Shadows.shadowOf; - -import android.app.Application; -import android.content.Intent; - -import androidx.annotation.NonNull; -import androidx.lifecycle.Lifecycle; -import androidx.test.core.app.ActivityScenario; -import androidx.test.core.app.ApplicationProvider; -import androidx.test.espresso.Espresso; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.odk.collect.androidtest.ActivityScenarioLauncherRule; -import org.odk.collect.geo.Constants; -import org.odk.collect.geo.DaggerGeoDependencyComponent; -import org.odk.collect.geo.GeoDependencyModule; -import org.odk.collect.geo.R; -import org.odk.collect.geo.ReferenceLayerSettingsNavigator; -import org.odk.collect.geo.support.FakeMapFragment; -import org.odk.collect.geo.support.RobolectricApplication; -import org.odk.collect.location.tracker.LocationTracker; -import org.odk.collect.maps.MapFragmentFactory; -import org.odk.collect.maps.MapPoint; -import org.robolectric.shadows.ShadowApplication; - -import java.util.ArrayList; -import java.util.List; - -@RunWith(AndroidJUnit4.class) -public class GeoPolyActivityTest { - - private final FakeMapFragment mapFragment = new FakeMapFragment(); - private final LocationTracker locationTracker = mock(LocationTracker.class); - - @Rule - public ActivityScenarioLauncherRule launcherRule = new ActivityScenarioLauncherRule(); - - @Before - public void setUp() { - ShadowApplication shadowApplication = shadowOf(ApplicationProvider.getApplicationContext()); - shadowApplication.grantPermissions("android.permission.ACCESS_FINE_LOCATION"); - shadowApplication.grantPermissions("android.permission.ACCESS_COARSE_LOCATION"); - - RobolectricApplication application = ApplicationProvider.getApplicationContext(); - application.geoDependencyComponent = DaggerGeoDependencyComponent.builder() - .application(application) - .geoDependencyModule(new GeoDependencyModule() { - @NonNull - @Override - public MapFragmentFactory providesMapFragmentFactory() { - return () -> mapFragment; - } - - @NonNull - @Override - public ReferenceLayerSettingsNavigator providesReferenceLayerSettingsNavigator() { - return (activity) -> { - }; - } - - @NonNull - @Override - public LocationTracker providesLocationTracker(@NonNull Application application) { - return locationTracker; - } - }) - .build(); - } - - @Test - public void testLocationTrackerLifecycle() { - ActivityScenario scenario = launcherRule.launch(GeoPolyActivity.class); - mapFragment.ready(); - - // Stopping the activity should stop the location tracker - scenario.moveToState(Lifecycle.State.DESTROYED); - verify(locationTracker).stop(); - } - - @Test - public void recordButton_should_beHiddenForAutomaticMode() { - launcherRule.launch(GeoPolyActivity.class); - mapFragment.ready(); - - startInput(R.id.automatic_mode); - onView(withId(R.id.record_button)).check(matches(not(isDisplayed()))); - } - - @Test - public void recordButton_should_beVisibleForManualMode() { - launcherRule.launch(GeoPolyActivity.class); - mapFragment.ready(); - - startInput(R.id.manual_mode); - onView(withId(R.id.record_button)).check(matches(isDisplayed())); - } - - @Test - public void whenPolygonExtraPresent_showsPoly() { - Intent intent = new Intent(ApplicationProvider.getApplicationContext(), GeoPolyActivity.class); - - ArrayList polygon = new ArrayList<>(); - polygon.add(new MapPoint(1.0, 2.0, 3, 4)); - intent.putExtra(GeoPolyActivity.EXTRA_POLYGON, polygon); - launcherRule.launch(intent); - - mapFragment.ready(); - - List> polys = mapFragment.getPolyLines(); - assertThat(polys.size(), equalTo(1)); - assertThat(polys.get(0), equalTo(polygon)); - } - - @Test - public void whenPolygonExtraPresent_andOutputModeIsShape_showsClosedPoly() { - Intent intent = new Intent(ApplicationProvider.getApplicationContext(), GeoPolyActivity.class); - - ArrayList polygon = new ArrayList<>(); - polygon.add(new MapPoint(1.0, 2.0, 3, 4)); - polygon.add(new MapPoint(2.0, 3.0, 3, 4)); - polygon.add(new MapPoint(1.0, 2.0, 3, 4)); - intent.putExtra(GeoPolyActivity.EXTRA_POLYGON, polygon); - intent.putExtra(GeoPolyActivity.OUTPUT_MODE_KEY, GeoPolyActivity.OutputMode.GEOSHAPE); - launcherRule.launch(intent); - - mapFragment.ready(); - - List> polys = mapFragment.getPolyLines(); - assertThat(polys.size(), equalTo(1)); - - ArrayList expectedPolygon = new ArrayList<>(); - expectedPolygon.add(new MapPoint(1.0, 2.0, 3, 4)); - expectedPolygon.add(new MapPoint(2.0, 3.0, 3, 4)); - assertThat(polys.get(0), equalTo(expectedPolygon)); - assertThat(mapFragment.isPolyClosed(0), equalTo(true)); - } - - @Test - public void whenPolygonExtraPresent_andPolyIsEmpty_andOutputModeIsShape_doesNotShowPoly() { - Intent intent = new Intent(ApplicationProvider.getApplicationContext(), GeoPolyActivity.class); - - ArrayList polygon = new ArrayList<>(); - intent.putExtra(GeoPolyActivity.EXTRA_POLYGON, polygon); - intent.putExtra(GeoPolyActivity.OUTPUT_MODE_KEY, GeoPolyActivity.OutputMode.GEOSHAPE); - launcherRule.launch(intent); - - mapFragment.ready(); - - List> polys = mapFragment.getPolyLines(); - assertThat(polys.size(), equalTo(1)); - assertThat(polys.get(0).isEmpty(), equalTo(true)); - } - - @Test - public void whenPolygonExtraPresent_andPolyIsEmpty_pressingBack_finishes() { - Intent intent = new Intent(ApplicationProvider.getApplicationContext(), GeoPolyActivity.class); - - ArrayList polygon = new ArrayList<>(); - intent.putExtra(GeoPolyActivity.EXTRA_POLYGON, polygon); - intent.putExtra(GeoPolyActivity.OUTPUT_MODE_KEY, GeoPolyActivity.OutputMode.GEOSHAPE); - ActivityScenario scenario = launcherRule.launch(intent); - - mapFragment.ready(); - Espresso.pressBack(); - assertThat(isFinishing(scenario), equalTo(true)); - } - - @Test - public void startingInput_usingAutomaticMode_usesRetainMockAccuracyTrueToStartLocationTracker() { - Intent intent = new Intent(ApplicationProvider.getApplicationContext(), GeoPolyActivity.class); - - intent.putExtra(Constants.EXTRA_RETAIN_MOCK_ACCURACY, true); - launcherRule.launch(intent); - - mapFragment.ready(); - startInput(R.id.automatic_mode); - verify(locationTracker).start(true); - } - - @Test - public void startingInput_usingAutomaticMode_usesRetainMockAccuracyFalseToStartLocationTracker() { - Intent intent = new Intent(ApplicationProvider.getApplicationContext(), GeoPolyActivity.class); - - intent.putExtra(Constants.EXTRA_RETAIN_MOCK_ACCURACY, false); - launcherRule.launch(intent); - - mapFragment.ready(); - startInput(R.id.automatic_mode); - verify(locationTracker).start(false); - } - - @Test - public void recordingPointManually_whenPointIsADuplicateOfTheLastPoint_skipsPoint() { - launcherRule.launch(GeoPolyActivity.class); - mapFragment.ready(); - - startInput(R.id.manual_mode); - - mapFragment.setLocation(new MapPoint(1, 1)); - onView(withId(R.id.record_button)).perform(click()); - onView(withId(R.id.record_button)).perform(click()); - assertThat(mapFragment.getPolyLines().get(0).size(), equalTo(1)); - } - - @Test - public void placingPoint_whenPointIsADuplicateOfTheLastPoint_skipsPoint() { - launcherRule.launch(GeoPolyActivity.class); - mapFragment.ready(); - - startInput(R.id.placement_mode); - - mapFragment.click(new MapPoint(1, 1)); - mapFragment.click(new MapPoint(1, 1)); - assertThat(mapFragment.getPolyLines().get(0).size(), equalTo(1)); - } - - private void startInput(int mode) { - onView(withId(R.id.play)).perform(click()); - onView(withId(mode)).inRoot(isDialog()).perform(click()); - onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click()); - } -} diff --git a/geo/src/test/java/org/odk/collect/geo/geopoly/GeoPolyActivityTest.kt b/geo/src/test/java/org/odk/collect/geo/geopoly/GeoPolyActivityTest.kt new file mode 100644 index 00000000000..bdf2cbc061d --- /dev/null +++ b/geo/src/test/java/org/odk/collect/geo/geopoly/GeoPolyActivityTest.kt @@ -0,0 +1,301 @@ +package org.odk.collect.geo.geopoly + +import android.app.Activity +import android.app.Application +import android.content.Intent +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.assertThat +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.not +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.odk.collect.androidtest.ActivityScenarioExtensions.isFinishing +import org.odk.collect.androidtest.ActivityScenarioLauncherRule +import org.odk.collect.async.Scheduler +import org.odk.collect.geo.Constants +import org.odk.collect.geo.DaggerGeoDependencyComponent +import org.odk.collect.geo.GeoDependencyModule +import org.odk.collect.geo.R +import org.odk.collect.geo.support.FakeMapFragment +import org.odk.collect.geo.support.RobolectricApplication +import org.odk.collect.location.tracker.LocationTracker +import org.odk.collect.maps.MapFragment +import org.odk.collect.maps.MapFragmentFactory +import org.odk.collect.maps.MapPoint +import org.odk.collect.maps.layers.ReferenceLayerRepository +import org.odk.collect.settings.InMemSettingsProvider +import org.odk.collect.settings.SettingsProvider +import org.odk.collect.webpage.ExternalWebPageHelper +import org.robolectric.Shadows + +@RunWith(AndroidJUnit4::class) +class GeoPolyActivityTest { + private val mapFragment = FakeMapFragment() + private val locationTracker = mock() + + @get:Rule + val launcherRule = ActivityScenarioLauncherRule() + + @Before + fun setUp() { + val shadowApplication = Shadows.shadowOf(ApplicationProvider.getApplicationContext()) + shadowApplication.grantPermissions("android.permission.ACCESS_FINE_LOCATION") + shadowApplication.grantPermissions("android.permission.ACCESS_COARSE_LOCATION") + val application = ApplicationProvider.getApplicationContext() + application.geoDependencyComponent = DaggerGeoDependencyComponent.builder() + .application(application) + .geoDependencyModule(object : GeoDependencyModule() { + override fun providesMapFragmentFactory(): MapFragmentFactory { + return object : MapFragmentFactory { + override fun createMapFragment(): MapFragment { + return mapFragment + } + } + } + + override fun providesLocationTracker(application: Application): LocationTracker { + return locationTracker + } + + override fun providesReferenceLayerRepository(): ReferenceLayerRepository { + return mock() + } + + override fun providesScheduler(): Scheduler { + return mock() + } + + override fun providesSettingsProvider(): SettingsProvider { + return InMemSettingsProvider() + } + + override fun providesExternalWebPageHelper(): ExternalWebPageHelper { + return mock() + } + }) + .build() + } + + @Test + fun testLocationTrackerLifecycle() { + val scenario = launcherRule.launch( + GeoPolyActivity::class.java + ) + mapFragment.ready() + + // Stopping the activity should stop the location tracker + scenario.moveToState(Lifecycle.State.DESTROYED) + Mockito.verify(locationTracker).stop() + } + + @Test + fun recordButton_should_beHiddenForAutomaticMode() { + launcherRule.launch(GeoPolyActivity::class.java) + mapFragment.ready() + startInput(R.id.automatic_mode) + onView(withId(R.id.record_button)).check(matches(not(isDisplayed()))) + } + + @Test + fun recordButton_should_beVisibleForManualMode() { + launcherRule.launch(GeoPolyActivity::class.java) + mapFragment.ready() + startInput(R.id.manual_mode) + onView(withId(R.id.record_button)).check(matches(isDisplayed())) + } + + @Test + fun whenPolygonExtraPresent_showsPoly() { + val intent = + Intent(ApplicationProvider.getApplicationContext(), GeoPolyActivity::class.java) + val polygon = ArrayList() + polygon.add(MapPoint(1.0, 2.0, 3.0, 4.0)) + intent.putExtra(GeoPolyActivity.EXTRA_POLYGON, polygon) + launcherRule.launch(intent) + mapFragment.ready() + val polys = mapFragment.getPolyLines() + assertThat(polys.size, equalTo(1)) + assertThat(polys[0].points, equalTo(polygon)) + } + + @Test + fun whenPolygonExtraPresent_andOutputModeIsShape_showsClosedPoly() { + val intent = + Intent(ApplicationProvider.getApplicationContext(), GeoPolyActivity::class.java) + val polygon = ArrayList() + polygon.add(MapPoint(1.0, 2.0, 3.0, 4.0)) + polygon.add(MapPoint(2.0, 3.0, 3.0, 4.0)) + polygon.add(MapPoint(1.0, 2.0, 3.0, 4.0)) + intent.putExtra(GeoPolyActivity.EXTRA_POLYGON, polygon) + intent.putExtra(GeoPolyActivity.OUTPUT_MODE_KEY, GeoPolyActivity.OutputMode.GEOSHAPE) + launcherRule.launch(intent) + mapFragment.ready() + val polys = mapFragment.getPolyLines() + assertThat(polys.size, equalTo(1)) + val expectedPolygon = ArrayList() + expectedPolygon.add(MapPoint(1.0, 2.0, 3.0, 4.0)) + expectedPolygon.add(MapPoint(2.0, 3.0, 3.0, 4.0)) + assertThat(polys[0].points, equalTo(expectedPolygon)) + assertThat(mapFragment.isPolyClosed(0), equalTo(true)) + } + + @Test + fun whenPolygonExtraPresent_andPolyIsEmpty_andOutputModeIsShape_doesNotShowPoly() { + val intent = + Intent(ApplicationProvider.getApplicationContext(), GeoPolyActivity::class.java) + val polygon = ArrayList() + intent.putExtra(GeoPolyActivity.EXTRA_POLYGON, polygon) + intent.putExtra(GeoPolyActivity.OUTPUT_MODE_KEY, GeoPolyActivity.OutputMode.GEOSHAPE) + launcherRule.launch(intent) + mapFragment.ready() + val polys = mapFragment.getPolyLines() + assertThat(polys.size, equalTo(1)) + assertThat(polys[0].points.isEmpty(), equalTo(true)) + } + + @Test + fun whenPolygonExtraPresent_andPolyIsEmpty_pressingBack_finishes() { + val intent = + Intent(ApplicationProvider.getApplicationContext(), GeoPolyActivity::class.java) + val polygon = ArrayList() + intent.putExtra(GeoPolyActivity.EXTRA_POLYGON, polygon) + intent.putExtra(GeoPolyActivity.OUTPUT_MODE_KEY, GeoPolyActivity.OutputMode.GEOSHAPE) + val scenario = launcherRule.launch(intent) + mapFragment.ready() + Espresso.pressBack() + assertThat(scenario.isFinishing, equalTo(true)) + } + + @Test + fun startingInput_usingAutomaticMode_usesRetainMockAccuracyTrueToStartLocationTracker() { + val intent = + Intent(ApplicationProvider.getApplicationContext(), GeoPolyActivity::class.java) + intent.putExtra(Constants.EXTRA_RETAIN_MOCK_ACCURACY, true) + launcherRule.launch(intent) + mapFragment.ready() + startInput(R.id.automatic_mode) + verify(locationTracker).start(true) + } + + @Test + fun startingInput_usingAutomaticMode_usesRetainMockAccuracyFalseToStartLocationTracker() { + val intent = + Intent(ApplicationProvider.getApplicationContext(), GeoPolyActivity::class.java) + intent.putExtra(Constants.EXTRA_RETAIN_MOCK_ACCURACY, false) + launcherRule.launch(intent) + mapFragment.ready() + startInput(R.id.automatic_mode) + verify(locationTracker).start(false) + } + + @Test + fun recordingPointManually_whenPointIsADuplicateOfTheLastPoint_skipsPoint() { + launcherRule.launch(GeoPolyActivity::class.java) + mapFragment.ready() + startInput(R.id.manual_mode) + mapFragment.setLocation(MapPoint(1.0, 1.0)) + onView(withId(R.id.record_button)).perform(click()) + onView(withId(R.id.record_button)).perform(click()) + assertThat(mapFragment.getPolyLines()[0].points.size, equalTo(1)) + } + + @Test + fun placingPoint_whenPointIsADuplicateOfTheLastPoint_skipsPoint() { + launcherRule.launch(GeoPolyActivity::class.java) + mapFragment.ready() + startInput(R.id.placement_mode) + mapFragment.click(MapPoint(1.0, 1.0)) + mapFragment.click(MapPoint(1.0, 1.0)) + assertThat(mapFragment.getPolyLines()[0].points.size, equalTo(1)) + } + + @Test + fun buttonsShouldBeEnabledInEditableMode() { + val polyline = ArrayList() + polyline.add(MapPoint(1.0, 2.0, 3.0, 4.0)) + val intent = + Intent(ApplicationProvider.getApplicationContext(), GeoPolyActivity::class.java) + intent.putExtra(GeoPolyActivity.EXTRA_POLYGON, polyline) + val scenario = launcherRule.launch(intent) + mapFragment.ready() + scenario.onActivity { activity: GeoPolyActivity -> + assertThat(activity.playButton.isEnabled, equalTo(true)) + assertThat(activity.backspaceButton.isEnabled, equalTo(true)) + assertThat(activity.clearButton.isEnabled, equalTo(true)) + assertThat(activity.saveButton.isEnabled, equalTo(true)) + } + } + + @Test + fun buttonsShouldBeDisabledInReadOnlyMode() { + val polygon = ArrayList() + polygon.add(MapPoint(1.0, 2.0, 3.0, 4.0)) + val intent = + Intent(ApplicationProvider.getApplicationContext(), GeoPolyActivity::class.java) + intent.putExtra(GeoPolyActivity.EXTRA_POLYGON, polygon) + intent.putExtra(Constants.EXTRA_READ_ONLY, true) + val scenario = launcherRule.launch(intent) + mapFragment.ready() + scenario.onActivity { activity: GeoPolyActivity -> + assertThat(activity.playButton.isEnabled, equalTo(false)) + assertThat(activity.backspaceButton.isEnabled, equalTo(false)) + assertThat(activity.clearButton.isEnabled, equalTo(false)) + assertThat(activity.saveButton.isEnabled, equalTo(false)) + } + } + + @Test + fun polyShouldBeDraggableInEditableMode() { + val polyline = ArrayList() + polyline.add(MapPoint(1.0, 2.0, 3.0, 4.0)) + val intent = + Intent(ApplicationProvider.getApplicationContext(), GeoPolyActivity::class.java) + intent.putExtra(GeoPolyActivity.EXTRA_POLYGON, polyline) + launcherRule.launch(intent) + mapFragment.ready() + assertThat(mapFragment.isPolyDraggable(0), equalTo(true)) + } + + @Test + fun polyShouldNotBeDraggableInReadOnlyMode() { + val polygon = ArrayList() + polygon.add(MapPoint(1.0, 2.0, 3.0, 4.0)) + val intent = + Intent(ApplicationProvider.getApplicationContext(), GeoPolyActivity::class.java) + intent.putExtra(GeoPolyActivity.EXTRA_POLYGON, polygon) + intent.putExtra(Constants.EXTRA_READ_ONLY, true) + launcherRule.launch(intent) + mapFragment.ready() + assertThat(mapFragment.isPolyDraggable(0), equalTo(false)) + } + + @Test + fun recreatingTheActivityWithTheLayersDialogDisplayedDoesNotCrashTheApp() { + val scenario = launcherRule.launch(GeoPolyActivity::class.java) + mapFragment.ready() + + onView(withId(R.id.layers)).perform(click()) + + scenario.recreate() + } + + private fun startInput(mode: Int) { + onView(withId(R.id.play)).perform(click()) + onView(withId(mode)).inRoot(isDialog()).perform(click()) + onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click()) + } +} diff --git a/geo/src/test/java/org/odk/collect/geo/selection/SelectionMapFragmentTest.kt b/geo/src/test/java/org/odk/collect/geo/selection/SelectionMapFragmentTest.kt index 4b4ff5b6d4a..c941ff75858 100644 --- a/geo/src/test/java/org/odk/collect/geo/selection/SelectionMapFragmentTest.kt +++ b/geo/src/test/java/org/odk/collect/geo/selection/SelectionMapFragmentTest.kt @@ -22,6 +22,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.instanceOf import org.hamcrest.Matchers.not import org.hamcrest.Matchers.notNullValue import org.hamcrest.Matchers.nullValue @@ -31,25 +32,29 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock -import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.odk.collect.androidshared.livedata.MutableNonNullLiveData import org.odk.collect.androidshared.ui.FragmentFactoryBuilder +import org.odk.collect.async.Scheduler import org.odk.collect.fragmentstest.FragmentScenarioLauncherRule import org.odk.collect.geo.DaggerGeoDependencyComponent import org.odk.collect.geo.GeoDependencyModule import org.odk.collect.geo.R -import org.odk.collect.geo.ReferenceLayerSettingsNavigator import org.odk.collect.geo.support.FakeMapFragment import org.odk.collect.geo.support.Fixtures import org.odk.collect.geo.support.RobolectricApplication import org.odk.collect.maps.MapFragment import org.odk.collect.maps.MapFragmentFactory import org.odk.collect.maps.MapPoint +import org.odk.collect.maps.layers.OfflineMapLayersPicker +import org.odk.collect.maps.layers.ReferenceLayerRepository import org.odk.collect.material.BottomSheetBehavior import org.odk.collect.material.MaterialProgressDialogFragment import org.odk.collect.permissions.PermissionsChecker +import org.odk.collect.settings.InMemSettingsProvider +import org.odk.collect.settings.SettingsProvider import org.odk.collect.testshared.RobolectricHelpers.getFragmentByClass +import org.odk.collect.webpage.ExternalWebPageHelper @RunWith(AndroidJUnit4::class) class SelectionMapFragmentTest { @@ -57,7 +62,6 @@ class SelectionMapFragmentTest { private val application = ApplicationProvider.getApplicationContext() private lateinit var map: FakeMapFragment - private val referenceLayerSettingsNavigator: ReferenceLayerSettingsNavigator = mock() private val data = mock { on { isLoading() } doReturn MutableNonNullLiveData(false) on { getMapTitle() } doReturn MutableLiveData("") @@ -100,8 +104,21 @@ class SelectionMapFragmentTest { } } - override fun providesReferenceLayerSettingsNavigator() = - referenceLayerSettingsNavigator + override fun providesReferenceLayerRepository(): ReferenceLayerRepository { + return mock() + } + + override fun providesScheduler(): Scheduler { + return mock() + } + + override fun providesSettingsProvider(): SettingsProvider { + return InMemSettingsProvider() + } + + override fun providesExternalWebPageHelper(): ExternalWebPageHelper { + return mock() + } }).build() BottomSheetBehavior.DRAGGING_ENABLED = false @@ -128,8 +145,8 @@ class SelectionMapFragmentTest { @Test fun `updates markers when items update`() { val items: List = listOf( - Fixtures.actionMappableSelectItem().copy(id = 0, points = listOf(MapPoint(40.0, 0.0))), - Fixtures.actionMappableSelectItem().copy(id = 1, points = listOf(MapPoint(41.0, 0.0))) + Fixtures.actionMappableSelectPoint().copy(id = 0, point = MapPoint(40.0, 0.0)), + Fixtures.actionMappableSelectPoint().copy(id = 1, point = MapPoint(41.0, 0.0)) ) val itemsLiveData = MutableLiveData(items) whenever(data.getMappableItems()).thenReturn(itemsLiveData) @@ -137,7 +154,7 @@ class SelectionMapFragmentTest { launcherRule.launchInContainer(SelectionMapFragment::class.java) map.ready() - assertThat(map.getMarkers(), equalTo(itemsLiveData.value?.map { it.toMapPoint() })) + assertThat(map.getMarkers(), equalTo(itemsLiveData.value?.map { (it as MappableSelectItem.MappableSelectPoint).toMapPoint() })) itemsLiveData.value = emptyList() assertThat(map.getMarkers(), equalTo(emptyList())) @@ -146,8 +163,8 @@ class SelectionMapFragmentTest { @Test fun `updates item count when items update`() { val items: List = listOf( - Fixtures.actionMappableSelectItem().copy(id = 0), - Fixtures.actionMappableSelectItem().copy(id = 1) + Fixtures.actionMappableSelectPoint().copy(id = 0), + Fixtures.actionMappableSelectPoint().copy(id = 1) ) val itemsLiveData = MutableLiveData(items) @@ -167,12 +184,14 @@ class SelectionMapFragmentTest { @Test fun `shows polyline when item has multiple points`() { val items: List = listOf( - Fixtures.actionMappableSelectItem().copy( + Fixtures.actionMappableSelectLine().copy( id = 0, points = listOf( MapPoint(40.0, 0.0), MapPoint(41.0, 0.0) - ) + ), + strokeWidth = "10", + strokeColor = "#ffffff" ) ) @@ -181,9 +200,9 @@ class SelectionMapFragmentTest { launcherRule.launchInContainer(SelectionMapFragment::class.java) map.ready() - - assertThat(map.getPolyLines(), equalTo(itemsLiveData.value?.map { it.points })) - assertThat(map.isPolyDraggable(0), equalTo(false)) + assertThat(map.getPolyLines()[0].points, equalTo(itemsLiveData.value?.map { (it as MappableSelectItem.MappableSelectLine).points }?.first())) + assertThat(map.getPolyLines()[0].getStrokeWidth(), equalTo(10f)) + assertThat(map.getPolyLines()[0].getStrokeColor(), equalTo(-1)) onView(withText(application.getString(org.odk.collect.strings.R.string.select_item_count, "Things", 0, 1))) .check(matches(isDisplayed())) } @@ -191,13 +210,16 @@ class SelectionMapFragmentTest { @Test fun `shows polygon when item has multiple closed points`() { val items: List = listOf( - Fixtures.actionMappableSelectItem().copy( + Fixtures.actionMappableSelectPolygon().copy( id = 0, points = listOf( MapPoint(40.0, 0.0), MapPoint(41.0, 0.0), MapPoint(40.0, 0.0) - ) + ), + strokeWidth = "10", + strokeColor = "#aaccee", + fillColor = "#ffffff" ) ) @@ -207,7 +229,10 @@ class SelectionMapFragmentTest { launcherRule.launchInContainer(SelectionMapFragment::class.java) map.ready() - assertThat(map.getPolygons(), equalTo(itemsLiveData.value?.map { it.points })) + assertThat(map.getPolygons()[0].points, equalTo(itemsLiveData.value?.map { (it as MappableSelectItem.MappableSelectPolygon).points }?.first())) + assertThat(map.getPolygons()[0].getStrokeWidth(), equalTo(10f)) + assertThat(map.getPolygons()[0].getStrokeColor(), equalTo(-5583634)) + assertThat(map.getPolygons()[0].getFillColor(), equalTo(1157627903)) onView(withText(application.getString(org.odk.collect.strings.R.string.select_item_count, "Things", 0, 1))) .check(matches(isDisplayed())) } @@ -215,8 +240,8 @@ class SelectionMapFragmentTest { @Test fun `zooms to fit all items`() { val items = listOf( - Fixtures.actionMappableSelectItem().copy(id = 0, points = listOf(MapPoint(40.0, 0.0))), - Fixtures.actionMappableSelectItem().copy(id = 1, points = listOf(MapPoint(41.0, 0.0))) + Fixtures.actionMappableSelectPoint().copy(id = 0, point = MapPoint(40.0, 0.0)), + Fixtures.actionMappableSelectPoint().copy(id = 1, point = MapPoint(41.0, 0.0)) ) whenever(data.getMappableItems()).thenReturn(MutableLiveData(items)) @@ -232,7 +257,7 @@ class SelectionMapFragmentTest { fun `zooms to fit all points in for item with multiple points`() { val points = listOf(MapPoint(40.0, 0.0), MapPoint(41.0, 0.0)) val items: List = listOf( - Fixtures.actionMappableSelectItem().copy(id = 0, points = points) + Fixtures.actionMappableSelectLine().copy(id = 0, points = points) ) whenever(data.getMappableItems()).thenReturn(MutableLiveData(items)) @@ -246,7 +271,7 @@ class SelectionMapFragmentTest { @Test fun `does not zoom to fit all items again when they change`() { val originalItems = - listOf(Fixtures.actionMappableSelectItem().copy(id = 0, points = listOf(MapPoint(40.0, 0.0)))) + listOf(Fixtures.actionMappableSelectPoint().copy(id = 0, point = MapPoint(40.0, 0.0))) val itemsLiveData: MutableLiveData?> = MutableLiveData(originalItems) whenever(data.getMappableItems()).thenReturn(itemsLiveData) @@ -255,7 +280,7 @@ class SelectionMapFragmentTest { map.ready() itemsLiveData.value = - listOf(Fixtures.actionMappableSelectItem().copy(id = 0, points = listOf(MapPoint(52.0, 0.0)))) + listOf(Fixtures.actionMappableSelectPoint().copy(id = 0, point = MapPoint(52.0, 0.0))) val points = originalItems.map { it.toMapPoint() } assertThat(map.getZoomBoundingBox(), equalTo(Pair(points, 0.8))) @@ -263,7 +288,7 @@ class SelectionMapFragmentTest { @Test fun `does not zoom to fit all items if map already has center`() { - val items = listOf(Fixtures.actionMappableSelectItem().copy(id = 0, points = listOf(MapPoint(40.0, 0.0)))) + val items = listOf(Fixtures.actionMappableSelectPoint().copy(id = 0, point = MapPoint(40.0, 0.0))) val itemsLiveData: MutableLiveData?> = MutableLiveData(items) whenever(data.getMappableItems()).thenReturn(itemsLiveData) @@ -278,8 +303,8 @@ class SelectionMapFragmentTest { @Test fun `zooms to current location when zoomToFitItems is false`() { val items = listOf( - Fixtures.actionMappableSelectItem().copy(id = 0, points = listOf(MapPoint(40.0, 0.0))), - Fixtures.actionMappableSelectItem().copy(id = 1, points = listOf(MapPoint(41.0, 0.0))) + Fixtures.actionMappableSelectPoint().copy(id = 0, point = MapPoint(40.0, 0.0)), + Fixtures.actionMappableSelectPoint().copy(id = 1, point = MapPoint(41.0, 0.0)) ) whenever(data.getMappableItems()).thenReturn(MutableLiveData(items)) @@ -335,7 +360,7 @@ class SelectionMapFragmentTest { @Test fun `does not zoom to current location when items change`() { val originalItems = - listOf(Fixtures.actionMappableSelectItem().copy(id = 0, points = listOf(MapPoint(40.0, 0.0)))) + listOf(Fixtures.actionMappableSelectPoint().copy(id = 0, point = MapPoint(40.0, 0.0))) val itemsLiveData: MutableLiveData?> = MutableLiveData(originalItems) whenever(data.getMappableItems()).thenReturn(itemsLiveData) @@ -345,7 +370,7 @@ class SelectionMapFragmentTest { map.setLocation(MapPoint(67.0, 48.0)) itemsLiveData.value = - listOf(Fixtures.actionMappableSelectItem().copy(id = 0, points = listOf(MapPoint(52.0, 0.0)))) + listOf(Fixtures.actionMappableSelectPoint().copy(id = 0, point = MapPoint(52.0, 0.0))) val points = originalItems.map { it.toMapPoint() } assertThat(map.getZoomBoundingBox(), equalTo(Pair(points, 0.8))) @@ -366,8 +391,8 @@ class SelectionMapFragmentTest { @Test fun `clicking zoom to fit button zooms to fit all items`() { val items = listOf( - Fixtures.actionMappableSelectItem().copy(id = 0, points = listOf(MapPoint(40.0, 0.0))), - Fixtures.actionMappableSelectItem().copy(id = 1, points = listOf(MapPoint(41.0, 0.0))) + Fixtures.actionMappableSelectPoint().copy(id = 0, point = MapPoint(40.0, 0.0)), + Fixtures.actionMappableSelectPoint().copy(id = 1, point = MapPoint(41.0, 0.0)) ) whenever(data.getMappableItems()).thenReturn(MutableLiveData(items)) @@ -384,7 +409,7 @@ class SelectionMapFragmentTest { fun `clicking zoom to fit button zooms to fit all polys`() { val points = listOf(MapPoint(40.0, 0.0), MapPoint(41.0, 0.0)) val items: List = listOf( - Fixtures.actionMappableSelectItem().copy(id = 0, points = points) + Fixtures.actionMappableSelectLine().copy(id = 0, points = points) ) whenever(data.getMappableItems()).thenReturn(MutableLiveData(items)) @@ -401,17 +426,19 @@ class SelectionMapFragmentTest { map.ready() onView(withId(R.id.layer_menu)).perform(click()) - scenario.onFragment { - verify(referenceLayerSettingsNavigator).navigateToReferenceLayerSettings(it.requireActivity()) + assertThat( + it.childFragmentManager.findFragmentByTag(OfflineMapLayersPicker::class.java.name), + instanceOf(OfflineMapLayersPicker::class.java) + ) } } @Test fun `clicking on item centers on that item with current zoom level`() { val items = listOf( - Fixtures.actionMappableSelectItem().copy(id = 0, points = listOf(MapPoint(40.0, 0.0))), - Fixtures.actionMappableSelectItem().copy(id = 1, points = listOf(MapPoint(41.0, 0.0))) + Fixtures.actionMappableSelectPoint().copy(id = 0, point = MapPoint(40.0, 0.0)), + Fixtures.actionMappableSelectPoint().copy(id = 1, point = MapPoint(41.0, 0.0)) ) whenever(data.getMappableItems()).thenReturn(MutableLiveData(items)) @@ -429,8 +456,8 @@ class SelectionMapFragmentTest { fun `clicking on item with multiple points zooms to fit all item points`() { val itemPoints = listOf(MapPoint(40.0, 0.0), MapPoint(41.0, 0.0)) val items = listOf( - Fixtures.actionMappableSelectItem().copy(id = 0, points = listOf(MapPoint(40.0, 0.0))), - Fixtures.actionMappableSelectItem().copy(id = 1, points = itemPoints) + Fixtures.actionMappableSelectLine().copy(id = 0, points = listOf(MapPoint(40.0, 0.0))), + Fixtures.actionMappableSelectLine().copy(id = 1, points = itemPoints) ) whenever(data.getMappableItems()).thenReturn(MutableLiveData(items)) @@ -448,29 +475,29 @@ class SelectionMapFragmentTest { @Test fun `clicking on item always selects correct item`() { val items = listOf( - Fixtures.actionMappableSelectItem().copy(id = 0, points = listOf(MapPoint(40.0, 0.0), MapPoint(41.0, 0.0))), - Fixtures.actionMappableSelectItem().copy(id = 1, points = listOf(MapPoint(45.0, 0.0))) + Fixtures.actionMappableSelectLine().copy(id = 0, points = listOf(MapPoint(40.0, 0.0), MapPoint(41.0, 0.0))), + Fixtures.actionMappableSelectPoint().copy(id = 1, point = MapPoint(45.0, 0.0)) ) whenever(data.getMappableItems()).thenReturn(MutableLiveData(items)) launcherRule.launchInContainer(SelectionMapFragment::class.java) map.ready() - map.clickOnFeatureId(map.getFeatureId(items[1].points)) - assertThat(map.center, equalTo(items[1].points[0])) + map.clickOnFeatureId(map.getFeatureId(listOf((items[1] as MappableSelectItem.MappableSelectPoint).point))) + assertThat(map.center, equalTo((items[1] as MappableSelectItem.MappableSelectPoint).point)) } @Test fun `clicking on item switches item marker to large icon`() { val items = listOf( - Fixtures.actionMappableSelectItem().copy( + Fixtures.actionMappableSelectPoint().copy( id = 0, smallIcon = android.R.drawable.ic_lock_idle_charging, largeIcon = android.R.drawable.ic_lock_idle_alarm, symbol = "A", color = "#ffffff" ), - Fixtures.actionMappableSelectItem().copy( + Fixtures.actionMappableSelectPoint().copy( id = 1, smallIcon = android.R.drawable.ic_lock_idle_charging, largeIcon = android.R.drawable.ic_lock_idle_alarm, @@ -499,14 +526,14 @@ class SelectionMapFragmentTest { @Test fun `clicking on item when another has been tapped switches the first one back to its small icon`() { val items = listOf( - Fixtures.actionMappableSelectItem().copy( + Fixtures.actionMappableSelectPoint().copy( id = 0, smallIcon = android.R.drawable.ic_lock_idle_charging, largeIcon = android.R.drawable.ic_lock_idle_alarm, symbol = "A", color = "#ffffff" ), - Fixtures.actionMappableSelectItem().copy( + Fixtures.actionMappableSelectPoint().copy( id = 1, smallIcon = android.R.drawable.ic_lock_idle_charging, largeIcon = android.R.drawable.ic_lock_idle_alarm, @@ -536,8 +563,8 @@ class SelectionMapFragmentTest { @Test fun `clicking on item sets item on summary sheet`() { val items = listOf( - Fixtures.actionMappableSelectItem().copy(id = 0, name = "Blah1"), - Fixtures.actionMappableSelectItem().copy(id = 1, name = "Blah2") + Fixtures.actionMappableSelectPoint().copy(id = 0, name = "Blah1"), + Fixtures.actionMappableSelectPoint().copy(id = 1, name = "Blah2") ) whenever(data.getMappableItems()).thenReturn(MutableLiveData(items)) @@ -552,8 +579,8 @@ class SelectionMapFragmentTest { @Test fun `clicking on item returns item ID as result when skipSummary is true`() { val items = listOf( - Fixtures.actionMappableSelectItem().copy(id = 0), - Fixtures.actionMappableSelectItem().copy(id = 1) + Fixtures.actionMappableSelectPoint().copy(id = 0), + Fixtures.actionMappableSelectPoint().copy(id = 1) ) whenever(data.getMappableItems()).thenReturn(MutableLiveData(items)) @@ -589,7 +616,7 @@ class SelectionMapFragmentTest { @Test fun `clicking map with an item selected deselects it`() { - val item = Fixtures.actionMappableSelectItem().copy(id = 0, name = "Blah1") + val item = Fixtures.actionMappableSelectPoint().copy(id = 0, name = "Blah1") whenever(data.getMappableItems()).thenReturn(MutableLiveData(listOf(item))) launcherRule.launchInContainer(SelectionMapFragment::class.java) @@ -605,7 +632,7 @@ class SelectionMapFragmentTest { @Test fun `pressing back with an item selected deselects it`() { - val item = Fixtures.actionMappableSelectItem() + val item = Fixtures.actionMappableSelectPoint() .copy(id = 0, name = "Blah1", symbol = "A", color = "#ffffff") whenever(data.getMappableItems()).thenReturn(MutableLiveData(listOf(item))) @@ -627,7 +654,7 @@ class SelectionMapFragmentTest { @Test fun `pressing back after deselecting item disables back callbacks`() { - val item = Fixtures.actionMappableSelectItem().copy(id = 0, name = "Blah1") + val item = Fixtures.actionMappableSelectPoint().copy(id = 0, name = "Blah1") whenever(data.getMappableItems()).thenReturn(MutableLiveData(listOf(item))) val scenario = launcherRule.launchInContainer(SelectionMapFragment::class.java) @@ -642,7 +669,7 @@ class SelectionMapFragmentTest { @Test fun `recreating after deselecting item has no item selected`() { - val items = listOf(Fixtures.actionMappableSelectItem().copy(id = 0, name = "Point1")) + val items = listOf(Fixtures.actionMappableSelectPoint().copy(id = 0, name = "Point1")) whenever(data.getMappableItems()).thenReturn(MutableLiveData(items)) val scenario = launcherRule.launchInContainer(SelectionMapFragment::class.java) @@ -663,10 +690,10 @@ class SelectionMapFragmentTest { @Test fun `clicking action hides summary sheet`() { val items = listOf( - Fixtures.actionMappableSelectItem().copy( + Fixtures.actionMappableSelectPoint().copy( id = 0, name = "Item", - action = MappableSelectItem.IconifiedText(null, "Action") + action = IconifiedText(null, "Action") ) ) whenever(data.getMappableItems()).thenReturn(MutableLiveData(items)) @@ -682,8 +709,8 @@ class SelectionMapFragmentTest { @Test fun `centers on already selected item`() { val items = listOf( - Fixtures.actionMappableSelectItem().copy(id = 0, points = listOf(MapPoint(40.0, 0.0))), - Fixtures.actionMappableSelectItem().copy(id = 1, points = listOf(MapPoint(40.1, 0.0)), selected = true) + Fixtures.actionMappableSelectPoint().copy(id = 0, point = MapPoint(40.0, 0.0)), + Fixtures.actionMappableSelectPoint().copy(id = 1, point = MapPoint(40.1, 0.0), selected = true) ) whenever(data.getMappableItems()).thenReturn(MutableLiveData(items)) @@ -697,8 +724,8 @@ class SelectionMapFragmentTest { @Test fun `does not move when location changes when centered on already selected item`() { val items = listOf( - Fixtures.actionMappableSelectItem().copy(id = 0, points = listOf(MapPoint(40.0, 0.0))), - Fixtures.actionMappableSelectItem().copy(id = 1, points = listOf(MapPoint(41.0, 0.0)), selected = true) + Fixtures.actionMappableSelectPoint().copy(id = 0, point = MapPoint(40.0, 0.0)), + Fixtures.actionMappableSelectPoint().copy(id = 1, point = MapPoint(41.0, 0.0), selected = true) ) whenever(data.getMappableItems()).thenReturn(MutableLiveData(items)) @@ -739,8 +766,8 @@ class SelectionMapFragmentTest { @Test fun `recreating maintains selection`() { val items = listOf( - Fixtures.actionMappableSelectItem().copy(id = 0, points = listOf(MapPoint(40.0, 0.0)), name = "Point1"), - Fixtures.actionMappableSelectItem().copy(id = 1, points = listOf(MapPoint(41.0, 0.0)), name = "Point2") + Fixtures.actionMappableSelectPoint().copy(id = 0, point = MapPoint(40.0, 0.0), name = "Point1"), + Fixtures.actionMappableSelectPoint().copy(id = 1, point = MapPoint(41.0, 0.0), name = "Point2") ) whenever(data.getMappableItems()).thenReturn(MutableLiveData(items)) @@ -759,9 +786,9 @@ class SelectionMapFragmentTest { @Test fun `recreating with initial selection maintains new selection`() { val items = listOf( - Fixtures.actionMappableSelectItem() - .copy(id = 0, points = listOf(MapPoint(40.0, 0.0)), name = "Point1", selected = true), - Fixtures.actionMappableSelectItem().copy(id = 1, points = listOf(MapPoint(41.0, 0.0)), name = "Point2") + Fixtures.actionMappableSelectPoint() + .copy(id = 0, point = MapPoint(40.0, 0.0), name = "Point1", selected = true), + Fixtures.actionMappableSelectPoint().copy(id = 1, point = MapPoint(41.0, 0.0), name = "Point2") ) whenever(data.getMappableItems()).thenReturn(MutableLiveData(items)) @@ -807,8 +834,8 @@ class SelectionMapFragmentTest { @Test fun `opening the map with already selected item when skipSummary is true does not close the map`() { val items = listOf( - Fixtures.actionMappableSelectItem().copy(id = 0, points = listOf(MapPoint(40.0, 0.0))), - Fixtures.actionMappableSelectItem().copy(id = 1, points = listOf(MapPoint(41.0, 0.0)), selected = true) + Fixtures.actionMappableSelectPoint().copy(id = 0, point = MapPoint(40.0, 0.0)), + Fixtures.actionMappableSelectPoint().copy(id = 1, point = MapPoint(41.0, 0.0), selected = true) ) whenever(data.getMappableItems()).thenReturn(MutableLiveData(items)) @@ -841,8 +868,8 @@ class SelectionMapFragmentTest { @Test fun `recreating the map with already selected item when skipSummary is true does not close the map`() { val items = listOf( - Fixtures.actionMappableSelectItem().copy(id = 0, points = listOf(MapPoint(40.0, 0.0))), - Fixtures.actionMappableSelectItem().copy(id = 1, points = listOf(MapPoint(41.0, 0.0)), selected = true) + Fixtures.actionMappableSelectPoint().copy(id = 0, point = MapPoint(40.0, 0.0)), + Fixtures.actionMappableSelectPoint().copy(id = 1, point = MapPoint(41.0, 0.0), selected = true) ) whenever(data.getMappableItems()).thenReturn(MutableLiveData(items)) @@ -873,7 +900,7 @@ class SelectionMapFragmentTest { assertThat(actualResult, equalTo(null)) } - private fun MappableSelectItem.toMapPoint(): MapPoint { - return MapPoint(this.points[0].latitude, this.points[0].longitude) + private fun MappableSelectItem.MappableSelectPoint.toMapPoint(): MapPoint { + return MapPoint(this.point.latitude, this.point.longitude) } } diff --git a/geo/src/test/java/org/odk/collect/geo/selection/SelectionSummarySheetTest.kt b/geo/src/test/java/org/odk/collect/geo/selection/SelectionSummarySheetTest.kt index ecb669f216b..f098d005304 100644 --- a/geo/src/test/java/org/odk/collect/geo/selection/SelectionSummarySheetTest.kt +++ b/geo/src/test/java/org/odk/collect/geo/selection/SelectionSummarySheetTest.kt @@ -8,7 +8,6 @@ import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.junit.Test import org.junit.runner.RunWith -import org.odk.collect.geo.R import org.odk.collect.geo.databinding.PropertyBinding import org.odk.collect.geo.support.Fixtures import org.odk.collect.testshared.RobolectricHelpers.getCreatedFromResId @@ -17,14 +16,48 @@ import org.odk.collect.testshared.RobolectricHelpers.getCreatedFromResId class SelectionSummarySheetTest { private val application = getApplicationContext().also { - it.setTheme(com.google.android.material.R.style.Theme_MaterialComponents) + it.setTheme(com.google.android.material.R.style.Theme_Material3_Light) + } + + @Test + fun `setItem shows an error chip when the status is ERRORS`() { + val selectionSummarySheet = SelectionSummarySheet(application) + selectionSummarySheet.setItem( + Fixtures.actionMappableSelectPoint().copy( + status = Status.ERRORS + ) + ) + + assertThat(selectionSummarySheet.binding.statusChip.visibility, equalTo(View.VISIBLE)) + assertThat(selectionSummarySheet.binding.statusChip.errors, equalTo(true)) + } + + @Test + fun `setItem shows a no-error chip when the status is NO_ERRORS`() { + val selectionSummarySheet = SelectionSummarySheet(application) + selectionSummarySheet.setItem( + Fixtures.actionMappableSelectPoint().copy( + status = Status.NO_ERRORS + ) + ) + + assertThat(selectionSummarySheet.binding.statusChip.visibility, equalTo(View.VISIBLE)) + assertThat(selectionSummarySheet.binding.statusChip.errors, equalTo(false)) + } + + @Test + fun `setItem does not show a chip if the status is null`() { + val selectionSummarySheet = SelectionSummarySheet(application) + selectionSummarySheet.setItem(Fixtures.actionMappableSelectPoint()) + + assertThat(selectionSummarySheet.binding.statusChip.visibility, equalTo(View.GONE)) } @Test fun `setItem shows name`() { val selectionSummarySheet = SelectionSummarySheet(application) selectionSummarySheet.setItem( - Fixtures.actionMappableSelectItem().copy( + Fixtures.actionMappableSelectPoint().copy( name = "Cosmic Dread" ) ) @@ -36,13 +69,13 @@ class SelectionSummarySheetTest { fun `setItem shows properties`() { val selectionSummarySheet = SelectionSummarySheet(application) selectionSummarySheet.setItem( - Fixtures.actionMappableSelectItem().copy( + Fixtures.actionMappableSelectPoint().copy( properties = listOf( - MappableSelectItem.IconifiedText( + IconifiedText( android.R.drawable.ic_btn_speak_now, "Emotion" ), - MappableSelectItem.IconifiedText( + IconifiedText( android.R.drawable.ic_dialog_info, "Mystery" ) @@ -67,9 +100,9 @@ class SelectionSummarySheetTest { fun `properties without icons have hidden icon view`() { val selectionSummarySheet = SelectionSummarySheet(application) selectionSummarySheet.setItem( - Fixtures.actionMappableSelectItem().copy( + Fixtures.actionMappableSelectPoint().copy( properties = listOf( - MappableSelectItem.IconifiedText( + IconifiedText( null, "Emotion" ) @@ -86,9 +119,9 @@ class SelectionSummarySheetTest { fun `properties reset between items`() { val selectionSummarySheet = SelectionSummarySheet(application) selectionSummarySheet.setItem( - Fixtures.actionMappableSelectItem().copy( + Fixtures.actionMappableSelectPoint().copy( properties = listOf( - MappableSelectItem.IconifiedText( + IconifiedText( android.R.drawable.ic_btn_speak_now, "Emotion" ) @@ -97,9 +130,9 @@ class SelectionSummarySheetTest { ) selectionSummarySheet.setItem( - Fixtures.actionMappableSelectItem().copy( + Fixtures.actionMappableSelectPoint().copy( properties = listOf( - MappableSelectItem.IconifiedText( + IconifiedText( android.R.drawable.ic_dialog_info, "Mystery" ) @@ -120,7 +153,7 @@ class SelectionSummarySheetTest { fun `setItem shows info and hides action when it is non-null`() { val selectionSummarySheet = SelectionSummarySheet(application) selectionSummarySheet.setItem( - Fixtures.infoMappableSelectItem().copy( + Fixtures.infoMappableSelectPoint().copy( info = "Don't even bother looking" ) ) @@ -134,8 +167,8 @@ class SelectionSummarySheetTest { fun `setItem shows action and hides info when it is non-null`() { val selectionSummarySheet = SelectionSummarySheet(application) selectionSummarySheet.setItem( - Fixtures.actionMappableSelectItem().copy( - action = MappableSelectItem.IconifiedText( + Fixtures.actionMappableSelectPoint().copy( + action = IconifiedText( android.R.drawable.ic_btn_speak_now, "Come on in" ) diff --git a/geo/src/test/java/org/odk/collect/geo/support/FakeMapFragment.kt b/geo/src/test/java/org/odk/collect/geo/support/FakeMapFragment.kt index 094c33b130b..397ece4b54e 100644 --- a/geo/src/test/java/org/odk/collect/geo/support/FakeMapFragment.kt +++ b/geo/src/test/java/org/odk/collect/geo/support/FakeMapFragment.kt @@ -1,11 +1,13 @@ package org.odk.collect.geo.support import androidx.fragment.app.Fragment +import org.odk.collect.maps.LineDescription import org.odk.collect.maps.MapFragment import org.odk.collect.maps.MapFragment.FeatureListener import org.odk.collect.maps.MapFragment.PointListener import org.odk.collect.maps.MapFragment.ReadyListener import org.odk.collect.maps.MapPoint +import org.odk.collect.maps.PolygonDescription import org.odk.collect.maps.markers.MarkerDescription import org.odk.collect.maps.markers.MarkerIconDescription import kotlin.random.Random @@ -24,10 +26,8 @@ class FakeMapFragment : Fragment(), MapFragment { private var featureClickListener: FeatureListener? = null private val markers = mutableMapOf() private val markerIcons = mutableMapOf() - private val polyLines = mutableMapOf>() - private val polyClosed: MutableList = ArrayList() - private val polyDraggable: MutableList = ArrayList() - private val polygons = mutableMapOf>() + private val polyLines = mutableMapOf() + private val polygons = mutableMapOf() private var hasCenter = false private val featureIds = mutableListOf() @@ -103,44 +103,37 @@ class FakeMapFragment : Fragment(), MapFragment { markerIcons[featureId] = markerIconDescription } - override fun getMarkerPoint(featureId: Int): MapPoint { - return markers[featureId]!! + override fun getMarkerPoint(featureId: Int): MapPoint? { + return markers[featureId] } - override fun addPolyLine( - points: Iterable, - closed: Boolean, - draggable: Boolean - ): Int { + override fun addPolyLine(lineDescription: LineDescription): Int { val featureId = generateFeatureId() - polyLines[featureId] = points.toList() - polyClosed.add(closed) - polyDraggable.add(draggable) - + polyLines[featureId] = lineDescription featureIds.add(featureId) return featureId } - override fun addPolygon(points: MutableIterable): Int { + override fun addPolygon(polygonDescription: PolygonDescription): Int { val featureId = generateFeatureId() - polygons[featureId] = points.toList() + polygons[featureId] = polygonDescription featureIds.add(featureId) return featureId } override fun appendPointToPolyLine(featureId: Int, point: MapPoint) { val poly = polyLines[featureId]!! - polyLines[featureId] = poly + point + polyLines[featureId] = poly.copy(points = poly.points + point) } override fun removePolyLineLastPoint(featureId: Int) { val poly = polyLines[featureId]!! - polyLines[featureId] = poly.dropLast(1) + polyLines[featureId] = poly.copy(points = poly.points.dropLast(1)) } override fun getPolyLinePoints(featureId: Int): List { - return polyLines[featureId]!! + return polyLines[featureId]!!.points } override fun clearFeatures() { @@ -223,16 +216,16 @@ class FakeMapFragment : Fragment(), MapFragment { return zoomBoundingBox } - fun getPolyLines(): List> { + fun getPolyLines(): List { return polyLines.values.toList() } fun isPolyClosed(index: Int): Boolean { - return polyClosed[index] + return polyLines[featureIds[index]]!!.closed } fun isPolyDraggable(index: Int): Boolean { - return polyDraggable[index] + return polyLines[featureIds[index]]!!.draggable } fun getFeatureId(points: List): Int { @@ -242,7 +235,7 @@ class FakeMapFragment : Fragment(), MapFragment { }!!.key } else { polyLines.entries.find { - it.value == points + it.value.points == points }!!.key } } @@ -256,7 +249,7 @@ class FakeMapFragment : Fragment(), MapFragment { return featureId } - fun getPolygons(): List> { + fun getPolygons(): List { return polygons.values.toList() } diff --git a/geo/src/test/java/org/odk/collect/geo/support/Fixtures.kt b/geo/src/test/java/org/odk/collect/geo/support/Fixtures.kt index feaf2f5f639..50feb36efa4 100644 --- a/geo/src/test/java/org/odk/collect/geo/support/Fixtures.kt +++ b/geo/src/test/java/org/odk/collect/geo/support/Fixtures.kt @@ -1,32 +1,52 @@ package org.odk.collect.geo.support import android.R +import org.odk.collect.geo.selection.IconifiedText import org.odk.collect.geo.selection.MappableSelectItem +import org.odk.collect.maps.MapPoint object Fixtures { - fun actionMappableSelectItem(): MappableSelectItem.WithAction { - return MappableSelectItem.WithAction( + fun actionMappableSelectPoint(): MappableSelectItem.MappableSelectPoint { + return MappableSelectItem.MappableSelectPoint( 0, - 0.0, - 0.0, - R.drawable.ic_lock_power_off, - R.drawable.ic_lock_idle_charging, "0", - listOf(MappableSelectItem.IconifiedText(R.drawable.ic_lock_idle_charging, "An item")), - MappableSelectItem.IconifiedText(R.drawable.ic_delete, "Action") + listOf(IconifiedText(R.drawable.ic_lock_idle_charging, "An item")), + point = MapPoint(0.0, 0.0), + smallIcon = R.drawable.ic_lock_power_off, + largeIcon = R.drawable.ic_lock_idle_charging, + action = IconifiedText(R.drawable.ic_delete, "Action") ) } - fun infoMappableSelectItem(): MappableSelectItem.WithInfo { - return MappableSelectItem.WithInfo( + fun infoMappableSelectPoint(): MappableSelectItem.MappableSelectPoint { + return MappableSelectItem.MappableSelectPoint( 0, - 0.0, - 0.0, - R.drawable.ic_lock_power_off, - R.drawable.ic_lock_idle_charging, "0", - listOf(MappableSelectItem.IconifiedText(R.drawable.ic_lock_idle_charging, "An item")), - "Info" + listOf(IconifiedText(R.drawable.ic_lock_idle_charging, "An item")), + point = MapPoint(0.0, 0.0), + smallIcon = R.drawable.ic_lock_power_off, + largeIcon = R.drawable.ic_lock_idle_charging, + info = "Info" + ) + } + + fun actionMappableSelectLine(): MappableSelectItem.MappableSelectLine { + return MappableSelectItem.MappableSelectLine( + 0, + "0", + listOf(IconifiedText(R.drawable.ic_lock_idle_charging, "An item")), + points = listOf(MapPoint(0.0, 0.0), MapPoint(1.0, 1.0)), + action = IconifiedText(R.drawable.ic_delete, "Action") + ) + } + + fun actionMappableSelectPolygon(): MappableSelectItem.MappableSelectPolygon { + return MappableSelectItem.MappableSelectPolygon( + 0, + "0", + listOf(IconifiedText(R.drawable.ic_lock_idle_charging, "An item")), + points = listOf(MapPoint(0.0, 0.0), MapPoint(1.0, 1.0)), + action = IconifiedText(R.drawable.ic_delete, "Action") ) } } diff --git a/geo/src/test/java/org/odk/collect/geo/support/RobolectricApplication.kt b/geo/src/test/java/org/odk/collect/geo/support/RobolectricApplication.kt index ab515825873..e841c43bf13 100644 --- a/geo/src/test/java/org/odk/collect/geo/support/RobolectricApplication.kt +++ b/geo/src/test/java/org/odk/collect/geo/support/RobolectricApplication.kt @@ -2,6 +2,7 @@ package org.odk.collect.geo.support import android.app.Application import org.odk.collect.androidshared.ui.Animations +import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickGuard import org.odk.collect.geo.GeoDependencyComponent import org.odk.collect.geo.GeoDependencyComponentProvider @@ -12,5 +13,8 @@ class RobolectricApplication : Application(), GeoDependencyComponentProvider { override fun onCreate() { super.onCreate() Animations.DISABLE_ANIMATIONS = true + + // We don't want any clicks to be blocked + MultiClickGuard.test = true } } diff --git a/geo/src/test/resources/robolectric.properties b/geo/src/test/resources/robolectric.properties index 375f65896f2..7bfb7b8a741 100644 --- a/geo/src/test/resources/robolectric.properties +++ b/geo/src/test/resources/robolectric.properties @@ -1,5 +1,2 @@ application=org.odk.collect.geo.support.RobolectricApplication - -# Workaround for https://github.com/robolectric/robolectric/issues/6593 -instrumentedPackages=androidx.loader.content sdk=33 \ No newline at end of file diff --git a/google-maps/build.gradle.kts b/google-maps/build.gradle.kts index 35881d5a999..4f26ae708a3 100644 --- a/google-maps/build.gradle.kts +++ b/google-maps/build.gradle.kts @@ -56,7 +56,6 @@ dependencies { implementation(project(":icons")) implementation(Dependencies.androidx_preference_ktx) - implementation(Dependencies.guava) implementation(Dependencies.play_services_maps) implementation(Dependencies.play_services_location) implementation(Dependencies.timber) diff --git a/google-maps/src/main/java/org/odk/collect/googlemaps/GoogleMapConfigurator.java b/google-maps/src/main/java/org/odk/collect/googlemaps/GoogleMapConfigurator.java index ed4555c53ba..9e4b76999dc 100644 --- a/google-maps/src/main/java/org/odk/collect/googlemaps/GoogleMapConfigurator.java +++ b/google-maps/src/main/java/org/odk/collect/googlemaps/GoogleMapConfigurator.java @@ -2,6 +2,7 @@ import static org.odk.collect.androidshared.ui.PrefUtils.createListPref; import static org.odk.collect.androidshared.ui.PrefUtils.getInt; +import static kotlin.collections.SetsKt.setOf; import android.app.ActivityManager; import android.content.Context; @@ -12,7 +13,6 @@ import androidx.preference.Preference; import com.google.android.gms.maps.GoogleMap; -import com.google.common.collect.ImmutableSet; import org.odk.collect.androidshared.system.PlayServicesChecker; import org.odk.collect.androidshared.ui.ToastUtils; @@ -88,8 +88,8 @@ private static boolean isGoogleMapsSdkAvailable(Context context) { } @Override public Set getPrefKeys() { - return prefKey.isEmpty() ? ImmutableSet.of(ProjectKeys.KEY_REFERENCE_LAYER) : - ImmutableSet.of(prefKey, ProjectKeys.KEY_REFERENCE_LAYER); + return prefKey.isEmpty() ? setOf(ProjectKeys.KEY_REFERENCE_LAYER) : + setOf(prefKey, ProjectKeys.KEY_REFERENCE_LAYER); } @Override public Bundle buildConfig(Settings prefs) { diff --git a/google-maps/src/main/java/org/odk/collect/googlemaps/GoogleMapFragment.java b/google-maps/src/main/java/org/odk/collect/googlemaps/GoogleMapFragment.java index d72eed16798..e2c62e6cc59 100644 --- a/google-maps/src/main/java/org/odk/collect/googlemaps/GoogleMapFragment.java +++ b/google-maps/src/main/java/org/odk/collect/googlemaps/GoogleMapFragment.java @@ -14,9 +14,6 @@ package org.odk.collect.googlemaps; -import static org.odk.collect.maps.MapConsts.POLYGON_FILL_COLOR_OPACITY; -import static org.odk.collect.maps.MapConsts.POLYLINE_STROKE_WIDTH; - import android.annotation.SuppressLint; import android.content.Context; import android.location.Location; @@ -25,7 +22,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.core.graphics.ColorUtils; import com.google.android.gms.location.LocationListener; import com.google.android.gms.maps.CameraUpdate; @@ -51,10 +47,12 @@ import org.odk.collect.androidshared.ui.ToastUtils; import org.odk.collect.googlemaps.GoogleMapConfigurator.GoogleMapTypeOption; import org.odk.collect.location.LocationClient; +import org.odk.collect.maps.LineDescription; import org.odk.collect.maps.MapConfigurator; import org.odk.collect.maps.MapFragment; import org.odk.collect.maps.MapFragmentDelegate; import org.odk.collect.maps.MapPoint; +import org.odk.collect.maps.PolygonDescription; import org.odk.collect.maps.layers.MapFragmentReferenceLayerUtils; import org.odk.collect.maps.layers.ReferenceLayerRepository; import org.odk.collect.maps.markers.MarkerDescription; @@ -306,20 +304,20 @@ public List addMarkers(List markers) { return feature instanceof MarkerFeature ? ((MarkerFeature) feature).getPoint() : null; } - @Override public int addPolyLine(@NonNull Iterable points, boolean closed, boolean draggable) { + @Override public int addPolyLine(LineDescription lineDescription) { int featureId = nextFeatureId++; - if (draggable) { - features.put(featureId, new DynamicPolyLineFeature(getActivity(), points, closed, map)); + if (lineDescription.getDraggable()) { + features.put(featureId, new DynamicPolyLineFeature(getActivity(), lineDescription, map)); } else { - features.put(featureId, new StaticPolyLineFeature(getActivity(), points, closed, map)); + features.put(featureId, new StaticPolyLineFeature(lineDescription, map)); } return featureId; } @Override - public int addPolygon(@NonNull Iterable points) { + public int addPolygon(PolygonDescription polygonDescription) { int featureId = nextFeatureId++; - features.put(featureId, new StaticPolygonFeature(map, points, requireContext().getResources().getColor(org.odk.collect.icons.R.color.mapLineColor))); + features.put(featureId, new StaticPolygonFeature(map, polygonDescription)); return featureId; } @@ -332,9 +330,10 @@ public int addPolygon(@NonNull Iterable points) { @Override public @NonNull List getPolyLinePoints(int featureId) { MapFeature feature = features.get(featureId); - if (feature instanceof DynamicPolyLineFeature) { - return ((DynamicPolyLineFeature) feature).getPoints(); + if (feature instanceof LineFeature) { + return ((LineFeature) feature).getPoints(); } + return new ArrayList<>(); } @@ -773,27 +772,34 @@ public void dispose() { } } + private interface LineFeature extends MapFeature { + + List getPoints(); + } + /** A polyline or polygon that can not be manipulated by dragging markers at its vertices. */ - private static class StaticPolyLineFeature implements MapFeature { + private static class StaticPolyLineFeature implements LineFeature { + private List points; private Polyline polyline; - StaticPolyLineFeature(Context context, Iterable points, boolean closedPolygon, GoogleMap map) { + StaticPolyLineFeature(LineDescription lineDescription, GoogleMap map) { if (map == null) { // during Robolectric tests, map will be null return; } + points = lineDescription.getPoints(); List latLngs = StreamSupport.stream(points.spliterator(), false).map(mapPoint -> new LatLng(mapPoint.latitude, mapPoint.longitude)).collect(Collectors.toList()); - if (closedPolygon && !latLngs.isEmpty()) { + if (lineDescription.getClosed() && !latLngs.isEmpty()) { latLngs.add(latLngs.get(0)); } if (latLngs.isEmpty()) { clearPolyline(); } else if (polyline == null) { polyline = map.addPolyline(new PolylineOptions() - .color(context.getResources().getColor(org.odk.collect.icons.R.color.mapLineColor)) + .color(lineDescription.getStrokeColor()) .zIndex(1) - .width(POLYLINE_STROKE_WIDTH) + .width(lineDescription.getStrokeWidth()) .addAll(latLngs) .clickable(true) ); @@ -832,27 +838,32 @@ private void clearPolyline() { polyline = null; } } + + @Override + public List getPoints() { + return points; + } } /** A polyline or polygon that can be manipulated by dragging markers at its vertices. */ - private static class DynamicPolyLineFeature implements MapFeature { + private static class DynamicPolyLineFeature implements LineFeature { private final Context context; private final GoogleMap map; private final List markers = new ArrayList<>(); - private final boolean closedPolygon; + private final LineDescription lineDescription; private Polyline polyline; - DynamicPolyLineFeature(Context context, Iterable points, boolean closedPolygon, GoogleMap map) { + DynamicPolyLineFeature(Context context, LineDescription lineDescription, GoogleMap map) { this.context = context; + this.lineDescription = lineDescription; this.map = map; - this.closedPolygon = closedPolygon; if (map == null) { // during Robolectric tests, map will be null return; } - for (MapPoint point : points) { + for (MapPoint point : lineDescription.getPoints()) { markers.add(createMarker(context, new MarkerDescription(point, true, CENTER, new MarkerIconDescription(org.odk.collect.icons.R.drawable.ic_map_point)), map)); } @@ -880,16 +891,16 @@ public void update() { for (Marker marker : markers) { latLngs.add(marker.getPosition()); } - if (closedPolygon && !latLngs.isEmpty()) { + if (lineDescription.getClosed() && !latLngs.isEmpty()) { latLngs.add(latLngs.get(0)); } if (markers.isEmpty()) { clearPolyline(); } else if (polyline == null) { polyline = map.addPolyline(new PolylineOptions() - .color(context.getResources().getColor(org.odk.collect.icons.R.color.mapLineColor)) + .color(lineDescription.getStrokeColor()) .zIndex(1) - .width(POLYLINE_STROKE_WIDTH) + .width(lineDescription.getStrokeWidth()) .addAll(latLngs) .clickable(true) ); @@ -943,12 +954,12 @@ private void clearPolyline() { private static class StaticPolygonFeature implements MapFeature { private Polygon polygon; - StaticPolygonFeature(GoogleMap map, Iterable points, int strokeLineColor) { + StaticPolygonFeature(GoogleMap map, PolygonDescription polygonDescription) { polygon = map.addPolygon(new PolygonOptions() - .addAll(StreamSupport.stream(points.spliterator(), false).map(mapPoint -> new LatLng(mapPoint.latitude, mapPoint.longitude)).collect(Collectors.toList())) - .strokeColor(strokeLineColor) - .strokeWidth(POLYLINE_STROKE_WIDTH) - .fillColor(ColorUtils.setAlphaComponent(strokeLineColor, POLYGON_FILL_COLOR_OPACITY)) + .addAll(StreamSupport.stream(polygonDescription.getPoints().spliterator(), false).map(mapPoint -> new LatLng(mapPoint.latitude, mapPoint.longitude)).collect(Collectors.toList())) + .strokeColor(polygonDescription.getStrokeColor()) + .strokeWidth(polygonDescription.getStrokeWidth()) + .fillColor(polygonDescription.getFillColor()) .clickable(true) ); } diff --git a/gradle.properties b/gradle.properties index c75d1812be1..2afa9221073 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,8 +3,8 @@ android.useAndroidX=true android.enableJetifier=true #https://issuetracker.google.com/issues/283715193 android.jetifier.ignorelist = jackson-core -org.gradle.jvmargs=-Xmx1536m +org.gradle.jvmargs=-Xmx4096m org.gradle.parallel=true -test.heap.max=1g +test.heap.max=4g android.nonTransitiveRClass=true android.injected.androidTest.leaveApksInstalledAfterRun=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0ca1147e79e..9d283f41611 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -4,5 +4,5 @@ distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists android.enableD8.desugaring=true -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-all.zip org.gradle.configureondemand=false diff --git a/icons/src/main/res/drawable/ic_baseline_attach_file_white_24.xml b/icons/src/main/res/drawable/ic_baseline_attach_file_white_24.xml new file mode 100644 index 00000000000..206c0548c7a --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_attach_file_white_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/icons/src/main/res/drawable/ic_baseline_barcode_scanner_white_24.xml b/icons/src/main/res/drawable/ic_baseline_barcode_scanner_white_24.xml new file mode 100644 index 00000000000..fa5db359b24 --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_barcode_scanner_white_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/icons/src/main/res/drawable/ic_baseline_calendar_today_white_24.xml b/icons/src/main/res/drawable/ic_baseline_calendar_today_white_24.xml new file mode 100644 index 00000000000..ac6cd641811 --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_calendar_today_white_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/collect_app/src/main/res/drawable/baseline_check_24.xml b/icons/src/main/res/drawable/ic_baseline_check_24.xml similarity index 100% rename from collect_app/src/main/res/drawable/baseline_check_24.xml rename to icons/src/main/res/drawable/ic_baseline_check_24.xml diff --git a/icons/src/main/res/drawable/ic_baseline_collapse_24.xml b/icons/src/main/res/drawable/ic_baseline_collapse_24.xml new file mode 100644 index 00000000000..aa1bf3d0920 --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_collapse_24.xml @@ -0,0 +1,13 @@ + + + + diff --git a/icons/src/main/res/drawable/ic_baseline_draw_white_24.xml b/icons/src/main/res/drawable/ic_baseline_draw_white_24.xml new file mode 100644 index 00000000000..f234f152d47 --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_draw_white_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/icons/src/main/res/drawable/ic_baseline_expand_24.xml b/icons/src/main/res/drawable/ic_baseline_expand_24.xml new file mode 100644 index 00000000000..52067c970f8 --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_expand_24.xml @@ -0,0 +1,13 @@ + + + + diff --git a/icons/src/main/res/drawable/ic_baseline_explore_white_24.xml b/icons/src/main/res/drawable/ic_baseline_explore_white_24.xml new file mode 100644 index 00000000000..7ef512487b9 --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_explore_white_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/icons/src/main/res/drawable/ic_baseline_format_list_bulleted_white_24.xml b/icons/src/main/res/drawable/ic_baseline_format_list_bulleted_white_24.xml new file mode 100644 index 00000000000..b7319cb5c01 --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_format_list_bulleted_white_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/icons/src/main/res/drawable/ic_baseline_format_list_numbered_white_24.xml b/icons/src/main/res/drawable/ic_baseline_format_list_numbered_white_24.xml new file mode 100644 index 00000000000..e2022a80579 --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_format_list_numbered_white_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/icons/src/main/res/drawable/ic_baseline_layers_24.xml b/icons/src/main/res/drawable/ic_baseline_layers_24.xml new file mode 100644 index 00000000000..2a93aa2f714 --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_layers_24.xml @@ -0,0 +1,13 @@ + + + + diff --git a/icons/src/main/res/drawable/ic_baseline_library_music_white_24.xml b/icons/src/main/res/drawable/ic_baseline_library_music_white_24.xml new file mode 100644 index 00000000000..9c4340e65b8 --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_library_music_white_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/icons/src/main/res/drawable/ic_baseline_location_on_white_24.xml b/icons/src/main/res/drawable/ic_baseline_location_on_white_24.xml new file mode 100644 index 00000000000..d7aeb4cd1e3 --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_location_on_white_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/icons/src/main/res/drawable/ic_baseline_markup_white_24.xml b/icons/src/main/res/drawable/ic_baseline_markup_white_24.xml new file mode 100644 index 00000000000..f3154b210a6 --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_markup_white_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/icons/src/main/res/drawable/ic_baseline_mic_white_24.xml b/icons/src/main/res/drawable/ic_baseline_mic_white_24.xml new file mode 100644 index 00000000000..53625b8020c --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_mic_white_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/icons/src/main/res/drawable/ic_baseline_my_location_white_24.xml b/icons/src/main/res/drawable/ic_baseline_my_location_white_24.xml new file mode 100644 index 00000000000..72fe247d121 --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_my_location_white_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/icons/src/main/res/drawable/ic_baseline_open_in_new_white_24.xml b/icons/src/main/res/drawable/ic_baseline_open_in_new_white_24.xml new file mode 100644 index 00000000000..0d40b306de6 --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_open_in_new_white_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/icons/src/main/res/drawable/ic_baseline_photo_camera_white_24.xml b/icons/src/main/res/drawable/ic_baseline_photo_camera_white_24.xml new file mode 100644 index 00000000000..687eed4bb8f --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_photo_camera_white_24.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/icons/src/main/res/drawable/ic_baseline_photo_library_white_24.xml b/icons/src/main/res/drawable/ic_baseline_photo_library_white_24.xml new file mode 100644 index 00000000000..09124a928b4 --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_photo_library_white_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/icons/src/main/res/drawable/ic_baseline_play_circle_white_24.xml b/icons/src/main/res/drawable/ic_baseline_play_circle_white_24.xml new file mode 100644 index 00000000000..9cf4ebaa9b8 --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_play_circle_white_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/icons/src/main/res/drawable/ic_baseline_print_white_24.xml b/icons/src/main/res/drawable/ic_baseline_print_white_24.xml new file mode 100644 index 00000000000..705e1c7e262 --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_print_white_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/icons/src/main/res/drawable/ic_baseline_signature_white_24.xml b/icons/src/main/res/drawable/ic_baseline_signature_white_24.xml new file mode 100644 index 00000000000..9f6d8f66849 --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_signature_white_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/icons/src/main/res/drawable/ic_baseline_time_filled_white_24.xml b/icons/src/main/res/drawable/ic_baseline_time_filled_white_24.xml new file mode 100644 index 00000000000..354d7c5f43d --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_time_filled_white_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/icons/src/main/res/drawable/ic_baseline_video_library_white_24.xml b/icons/src/main/res/drawable/ic_baseline_video_library_white_24.xml new file mode 100644 index 00000000000..9b643b86fab --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_video_library_white_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/icons/src/main/res/drawable/ic_baseline_videocam_white_24.xml b/icons/src/main/res/drawable/ic_baseline_videocam_white_24.xml new file mode 100644 index 00000000000..038b054fd31 --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_videocam_white_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/androidshared/src/main/res/drawable/ic_baseline_warning_24.xml b/icons/src/main/res/drawable/ic_baseline_warning_24.xml similarity index 100% rename from androidshared/src/main/res/drawable/ic_baseline_warning_24.xml rename to icons/src/main/res/drawable/ic_baseline_warning_24.xml diff --git a/icons/src/main/res/drawable/ic_baseline_wifi_off_24.xml b/icons/src/main/res/drawable/ic_baseline_wifi_off_24.xml new file mode 100644 index 00000000000..cbcdfdb6f26 --- /dev/null +++ b/icons/src/main/res/drawable/ic_baseline_wifi_off_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/collect_app/src/main/res/drawable/ic_delete_24.xml b/icons/src/main/res/drawable/ic_delete_24.xml similarity index 100% rename from collect_app/src/main/res/drawable/ic_delete_24.xml rename to icons/src/main/res/drawable/ic_delete_24.xml diff --git a/icons/src/main/res/drawable/ic_map_point.xml b/icons/src/main/res/drawable/ic_map_point.xml index b133ad2c9ca..f74cc68c255 100644 --- a/icons/src/main/res/drawable/ic_map_point.xml +++ b/icons/src/main/res/drawable/ic_map_point.xml @@ -3,6 +3,6 @@ android:height="36dp" android:viewportWidth="36" android:viewportHeight="36"> - + diff --git a/collect_app/src/main/res/drawable/ic_outline_info_24.xml b/icons/src/main/res/drawable/ic_outline_info_24.xml similarity index 100% rename from collect_app/src/main/res/drawable/ic_outline_info_24.xml rename to icons/src/main/res/drawable/ic_outline_info_24.xml diff --git a/icons/src/main/res/drawable/ic_outline_polygon_white_24.xml b/icons/src/main/res/drawable/ic_outline_polygon_white_24.xml new file mode 100644 index 00000000000..fa999a315fc --- /dev/null +++ b/icons/src/main/res/drawable/ic_outline_polygon_white_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/icons/src/main/res/drawable/ic_outline_polyline_white_24.xml b/icons/src/main/res/drawable/ic_outline_polyline_white_24.xml new file mode 100644 index 00000000000..e2cc77e9258 --- /dev/null +++ b/icons/src/main/res/drawable/ic_outline_polyline_white_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/icons/src/main/res/values/map_colors.xml b/icons/src/main/res/values/map_colors.xml deleted file mode 100644 index f8a25398e7c..00000000000 --- a/icons/src/main/res/values/map_colors.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - #ffff0000 - diff --git a/lists/.gitignore b/lists/.gitignore new file mode 100644 index 00000000000..42afabfd2ab --- /dev/null +++ b/lists/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/lists/build.gradle.kts b/lists/build.gradle.kts new file mode 100644 index 00000000000..f1171f8e479 --- /dev/null +++ b/lists/build.gradle.kts @@ -0,0 +1,67 @@ +import dependencies.Dependencies +import dependencies.Versions + +plugins { + id("com.android.library") + id("kotlin-android") +} + +apply(from = "../config/quality.gradle") + +android { + compileSdk = Versions.android_compile_sdk + + defaultConfig { + minSdk = Versions.android_min_sdk + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + + buildFeatures { + viewBinding = true + } + + namespace = "org.odk.collect.lists" +} + +dependencies { + coreLibraryDesugaring(Dependencies.desugar) + + implementation(project(":androidshared")) + implementation(project(":material")) + implementation(project(":strings")) + implementation(project(":icons")) + implementation(Dependencies.kotlin_stdlib) + implementation(Dependencies.androidx_lifecycle_livedata_ktx) + implementation(Dependencies.androidx_lifecycle_viewmodel_ktx) + implementation(Dependencies.androidx_recyclerview) + + testImplementation(project(":test-shared")) + testImplementation(project(":androidtest")) + testImplementation(Dependencies.junit) + testImplementation(Dependencies.androidx_test_ext_junit) + testImplementation(Dependencies.androidx_test_espresso_core) + testImplementation(Dependencies.robolectric) + testImplementation(Dependencies.androidx_arch_core_testing) + + debugImplementation(project(":fragments-test")) +} diff --git a/lists/consumer-rules.pro b/lists/consumer-rules.pro new file mode 100644 index 00000000000..e69de29bb2d diff --git a/lists/proguard-rules.pro b/lists/proguard-rules.pro new file mode 100644 index 00000000000..481bb434814 --- /dev/null +++ b/lists/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/lists/src/main/AndroidManifest.xml b/lists/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..8bdb7e14b38 --- /dev/null +++ b/lists/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/lists/src/main/java/org/odk/collect/lists/EmptyListView.kt b/lists/src/main/java/org/odk/collect/lists/EmptyListView.kt new file mode 100644 index 00000000000..cc5af9d3108 --- /dev/null +++ b/lists/src/main/java/org/odk/collect/lists/EmptyListView.kt @@ -0,0 +1,39 @@ +package org.odk.collect.lists + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.FrameLayout +import androidx.annotation.DrawableRes +import androidx.core.content.withStyledAttributes +import org.odk.collect.lists.databinding.EmptyListViewBinding + +class EmptyListView(context: Context, attrs: AttributeSet?) : FrameLayout(context, attrs) { + constructor(context: Context) : this(context, null) + + private val binding = EmptyListViewBinding.inflate(LayoutInflater.from(context), this, true) + + init { + context.withStyledAttributes(attrs, R.styleable.EmptyListView) { + val icon = this.getResourceId(R.styleable.EmptyListView_icon, 0) + val title = this.getString(R.styleable.EmptyListView_title) + val subtitle = this.getString(R.styleable.EmptyListView_subtitle) + + binding.icon.setImageResource(icon) + binding.title.text = title + binding.subtitle.text = subtitle + } + } + + fun setIcon(@DrawableRes icon: Int) { + binding.icon.setImageResource(icon) + } + + fun setTitle(title: String) { + binding.title.text = title + } + + fun setSubtitle(subtitle: String) { + binding.subtitle.text = subtitle + } +} diff --git a/lists/src/main/java/org/odk/collect/lists/RecyclerViewUtils.kt b/lists/src/main/java/org/odk/collect/lists/RecyclerViewUtils.kt new file mode 100644 index 00000000000..930cf30dd6e --- /dev/null +++ b/lists/src/main/java/org/odk/collect/lists/RecyclerViewUtils.kt @@ -0,0 +1,26 @@ +package org.odk.collect.lists + +import android.content.Context +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.RecyclerView +import org.odk.collect.androidshared.R + +object RecyclerViewUtils { + + fun verticalLineDivider(context: Context): DividerItemDecoration { + val itemDecoration = DividerItemDecoration(context, DividerItemDecoration.VERTICAL) + val drawable = ContextCompat.getDrawable(context, R.drawable.list_item_divider)!! + itemDecoration.setDrawable(drawable) + + return itemDecoration + } + + fun RecyclerView.ViewHolder.matchParentWidth() { + itemView.layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } +} diff --git a/lists/src/main/java/org/odk/collect/lists/selects/MultiSelectAdapter.kt b/lists/src/main/java/org/odk/collect/lists/selects/MultiSelectAdapter.kt new file mode 100644 index 00000000000..b08c6c1e3ce --- /dev/null +++ b/lists/src/main/java/org/odk/collect/lists/selects/MultiSelectAdapter.kt @@ -0,0 +1,59 @@ +package org.odk.collect.lists.selects + +import android.view.View +import android.view.ViewGroup +import android.widget.CheckBox +import androidx.recyclerview.widget.RecyclerView + +/** + * An adapter for creating multi select lists with `MultiSelectViewModel` and `RecyclerView`. + */ +class MultiSelectAdapter>( + private val multiSelectViewModel: MultiSelectViewModel<*>, + private val viewHolderFactory: (ViewGroup) -> VH +) : RecyclerView.Adapter() { + + var selected: Set = emptySet() + set(value) { + field = value + notifyDataSetChanged() + } + + var data = emptyList>() + set(value) { + field = value.toList() + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { + return viewHolderFactory(parent) + } + + override fun onBindViewHolder(holder: VH, position: Int) { + val item = data[position] + holder.setItem(item.item) + + val checkbox = holder.getCheckbox().also { + it.isChecked = selected.contains(item.id) + it.setOnClickListener { + multiSelectViewModel.toggle(item.id) + } + } + + holder.getSelectArea().setOnClickListener { + checkbox.performClick() + } + } + + override fun getItemCount(): Int { + return data.size + } + + abstract class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + abstract fun setItem(item: T) + abstract fun getCheckbox(): CheckBox + open fun getSelectArea(): View { + return itemView + } + } +} diff --git a/lists/src/main/java/org/odk/collect/lists/selects/MultiSelectControlsFragment.kt b/lists/src/main/java/org/odk/collect/lists/selects/MultiSelectControlsFragment.kt new file mode 100644 index 00000000000..eb41edb7f69 --- /dev/null +++ b/lists/src/main/java/org/odk/collect/lists/selects/MultiSelectControlsFragment.kt @@ -0,0 +1,126 @@ +package org.odk.collect.lists.selects + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.fragment.app.Fragment +import org.odk.collect.lists.databinding.MultiSelectControlsLayoutBinding + +/** + * A control UI for performing "select all" and "clear all" on multi select lists using + * `MultiSelectViewModel`. Also supports an action that's text can be defined (via the + * constructor) and can be reacted to by responding to the `"action"` Fragment result. + */ +class MultiSelectControlsFragment( + private val actionText: String, + private val multiSelectViewModel: MultiSelectViewModel<*> +) : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return MultiSelectControlsView(requireContext()) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val controls = view as MultiSelectControlsView + + controls.actionText = actionText + + multiSelectViewModel.getSelected().observe(viewLifecycleOwner) { + controls.selected = it + } + + multiSelectViewModel.isAllSelected().observe(viewLifecycleOwner) { + controls.isAllSelected = it + } + + controls.listener = object : MultiSelectControlsView.Listener { + override fun onSelectAll() { + multiSelectViewModel.selectAll() + } + + override fun onClearAll() { + multiSelectViewModel.unselectAll() + } + + override fun onAction(selected: Set) { + parentFragmentManager.setFragmentResult( + REQUEST_ACTION, + Bundle().apply { + putStringArray( + RESULT_SELECTED, + selected.toTypedArray() + ) + } + ) + } + } + } + + companion object { + const val REQUEST_ACTION = "action" + const val RESULT_SELECTED = "selected" + } +} + +private class MultiSelectControlsView(context: Context) : + FrameLayout(context) { + + var selected = emptySet() + set(value) { + field = value + render() + } + + var isAllSelected = false + set(value) { + field = value + render() + } + + var listener: Listener? = null + var actionText: String = "" + set(value) { + field = value + render() + } + + private val binding = + MultiSelectControlsLayoutBinding.inflate(LayoutInflater.from(context), this, true) + + init { + render() + } + + private fun render() { + if (isAllSelected) { + binding.selectAll.setText(org.odk.collect.strings.R.string.clear_all) + binding.selectAll.setOnClickListener { + listener?.onClearAll() + } + } else { + binding.selectAll.setText(org.odk.collect.strings.R.string.select_all) + binding.selectAll.setOnClickListener { + listener?.onSelectAll() + } + } + + binding.action.text = actionText + binding.action.isEnabled = selected.isNotEmpty() + binding.action.setOnClickListener { + listener?.onAction(selected) + } + } + + interface Listener { + fun onSelectAll() + fun onClearAll() + fun onAction(selected: Set) + } +} diff --git a/lists/src/main/java/org/odk/collect/lists/selects/MultiSelectListFragment.kt b/lists/src/main/java/org/odk/collect/lists/selects/MultiSelectListFragment.kt new file mode 100644 index 00000000000..389cc356d21 --- /dev/null +++ b/lists/src/main/java/org/odk/collect/lists/selects/MultiSelectListFragment.kt @@ -0,0 +1,71 @@ +package org.odk.collect.lists.selects + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import org.odk.collect.androidshared.ui.FragmentFactoryBuilder +import org.odk.collect.lists.R +import org.odk.collect.lists.databinding.MultiSelectListBinding + +class MultiSelectListFragment>( + private val actionText: String, + private val multiSelectViewModel: MultiSelectViewModel, + private val viewHolderFactory: (ViewGroup) -> VH, + private val onViewCreated: (MultiSelectListBinding) -> Unit = {} +) : Fragment() { + + override fun onAttach(context: Context) { + super.onAttach(context) + + childFragmentManager.fragmentFactory = FragmentFactoryBuilder() + .forClass(MultiSelectControlsFragment::class) { + MultiSelectControlsFragment( + actionText, + multiSelectViewModel + ) + }.build() + + childFragmentManager.setFragmentResultListener( + MultiSelectControlsFragment.REQUEST_ACTION, + this + ) { _, result -> + parentFragmentManager.setFragmentResult( + MultiSelectControlsFragment.REQUEST_ACTION, + result + ) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return inflater.inflate(R.layout.multi_select_list, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = MultiSelectListBinding.bind(view) + onViewCreated(binding) + + binding.list.layoutManager = LinearLayoutManager(requireContext()) + val adapter = MultiSelectAdapter( + multiSelectViewModel, + viewHolderFactory + ) + binding.list.adapter = adapter + multiSelectViewModel.getData().observe(viewLifecycleOwner) { + adapter.data = it + binding.empty.isVisible = it.isEmpty() + binding.buttons.isVisible = it.isNotEmpty() + } + multiSelectViewModel.getSelected().observe(viewLifecycleOwner) { + adapter.selected = it + } + } +} diff --git a/lists/src/main/java/org/odk/collect/lists/selects/MultiSelectViewModel.kt b/lists/src/main/java/org/odk/collect/lists/selects/MultiSelectViewModel.kt new file mode 100644 index 00000000000..106f1c1d69d --- /dev/null +++ b/lists/src/main/java/org/odk/collect/lists/selects/MultiSelectViewModel.kt @@ -0,0 +1,87 @@ +package org.odk.collect.lists.selects + +import android.widget.Button +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.map +import androidx.lifecycle.viewmodel.CreationExtras +import org.odk.collect.androidshared.livedata.LiveDataUtils +import org.odk.collect.androidshared.livedata.MutableNonNullLiveData +import org.odk.collect.androidshared.livedata.NonNullLiveData + +/** + * A `ViewModel` for holding state around selected items (identified by `Long` ids). This can + * optionally also handle the data for the list which allows the `ViewModel` to perform a select + * all and determine whether all items are selected or not. + */ +class MultiSelectViewModel( + private val data: LiveData>> = MutableLiveData(emptyList()) +) : ViewModel() { + + private val selected = MutableNonNullLiveData(emptySet()) + private val isAllSelected = LiveDataUtils.zip(data, selected).map { (data, selected) -> + data.isNotEmpty() && data.size == selected.size + } + + fun getData(): LiveData>> { + return data + } + + fun select(item: String) { + updateSelected(selected.value + item) + } + + fun getSelected(): NonNullLiveData> { + return selected + } + + fun unselect(item: String) { + updateSelected(selected.value - item) + } + + fun unselectAll() { + updateSelected(emptySet()) + } + + fun selectAll() { + updateSelected(data.value?.map { it.id }?.toSet() ?: emptySet()) + } + + fun isAllSelected(): LiveData { + return isAllSelected + } + + fun toggle(item: String) { + if (selected.value.contains(item)) { + unselect(item) + } else { + select(item) + } + } + + private fun updateSelected(new: Set) { + selected.value = new + } + + class Factory(private val data: LiveData>> = MutableLiveData(emptyList())) : + ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class, extras: CreationExtras): VM { + return MultiSelectViewModel(data) as VM + } + } +} + +fun updateSelectAll(button: Button, itemCount: Int, selectedCount: Int): Boolean { + val allSelected = itemCount > 0 && selectedCount == itemCount + + if (allSelected) { + button.setText(org.odk.collect.strings.R.string.clear_all) + } else { + button.setText(org.odk.collect.strings.R.string.select_all) + } + + return allSelected +} diff --git a/lists/src/main/java/org/odk/collect/lists/selects/SelectItem.kt b/lists/src/main/java/org/odk/collect/lists/selects/SelectItem.kt new file mode 100644 index 00000000000..e3b0fc27825 --- /dev/null +++ b/lists/src/main/java/org/odk/collect/lists/selects/SelectItem.kt @@ -0,0 +1,3 @@ +package org.odk.collect.lists.selects + +data class SelectItem(val id: String, val item: T) diff --git a/lists/src/main/java/org/odk/collect/lists/selects/SingleSelectViewModel.kt b/lists/src/main/java/org/odk/collect/lists/selects/SingleSelectViewModel.kt new file mode 100644 index 00000000000..e9f4cec7bae --- /dev/null +++ b/lists/src/main/java/org/odk/collect/lists/selects/SingleSelectViewModel.kt @@ -0,0 +1,30 @@ +package org.odk.collect.lists.selects + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.map +import org.odk.collect.androidshared.livedata.LiveDataUtils + +class SingleSelectViewModel( + selected: String?, + data: LiveData>> +) : ViewModel() { + + private val _selected = MutableLiveData(selected) + private val selected = LiveDataUtils.zip(_selected, data).map { (selected, data) -> + selected.takeIf { id -> data.any { it.id == id } } + } + + fun getSelected(): LiveData { + return selected + } + + fun select(item: String) { + _selected.value = item + } + + fun clear() { + _selected.value = null + } +} diff --git a/collect_app/src/main/res/layout/empty_list_view.xml b/lists/src/main/res/layout/empty_list_view.xml similarity index 97% rename from collect_app/src/main/res/layout/empty_list_view.xml rename to lists/src/main/res/layout/empty_list_view.xml index 855d67aa9ae..a88b3fc4298 100644 --- a/collect_app/src/main/res/layout/empty_list_view.xml +++ b/lists/src/main/res/layout/empty_list_view.xml @@ -18,7 +18,7 @@ app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toTopOf="@+id/title" app:layout_constraintVertical_chainStyle="packed" - tools:src="@drawable/ic_baseline_edit_72" /> + tools:src="@drawable/ic_baseline_qr_code_2_add_24" /> + + + + + + + + diff --git a/lists/src/main/res/layout/multi_select_list.xml b/lists/src/main/res/layout/multi_select_list.xml new file mode 100644 index 00000000000..af7e8e06329 --- /dev/null +++ b/lists/src/main/res/layout/multi_select_list.xml @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/lists/src/main/res/values/attrs.xml b/lists/src/main/res/values/attrs.xml new file mode 100644 index 00000000000..f15cc57ea93 --- /dev/null +++ b/lists/src/main/res/values/attrs.xml @@ -0,0 +1,8 @@ + + + + + + + s + diff --git a/collect_app/src/test/java/org/odk/collect/android/views/EmptyListViewTest.kt b/lists/src/test/java/org/odk/collect/lists/EmptyListViewTest.kt similarity index 91% rename from collect_app/src/test/java/org/odk/collect/android/views/EmptyListViewTest.kt rename to lists/src/test/java/org/odk/collect/lists/EmptyListViewTest.kt index 1c5ee9efdab..0365688cec2 100644 --- a/collect_app/src/test/java/org/odk/collect/android/views/EmptyListViewTest.kt +++ b/lists/src/test/java/org/odk/collect/lists/EmptyListViewTest.kt @@ -1,4 +1,4 @@ -package org.odk.collect.android.views +package org.odk.collect.lists import android.app.Application import android.graphics.drawable.VectorDrawable @@ -11,7 +11,7 @@ import com.google.android.material.textview.MaterialTextView import org.hamcrest.CoreMatchers.equalTo import org.junit.Test import org.junit.runner.RunWith -import org.odk.collect.android.R +import org.odk.collect.icons.R.drawable import org.robolectric.Robolectric @RunWith(AndroidJUnit4::class) @@ -30,11 +30,11 @@ class EmptyListViewTest { @Test fun `when icon attribute is used then it is set correctly`() { - val attrs = Robolectric.buildAttributeSet().addAttribute(R.attr.icon, "@drawable/ic_baseline_edit_72").build() + val attrs = Robolectric.buildAttributeSet().addAttribute(R.attr.icon, "@drawable/ic_baseline_warning_24").build() val emptyListView = EmptyListView(context, attrs) assertThat( - (emptyListView.findViewById(R.id.icon).drawable as VectorDrawable).toBitmap().sameAs((context.getDrawable(R.drawable.ic_baseline_edit_72) as VectorDrawable).toBitmap()), + (emptyListView.findViewById(R.id.icon).drawable as VectorDrawable).toBitmap().sameAs((context.getDrawable(drawable.ic_baseline_warning_24) as VectorDrawable).toBitmap()), equalTo(true) ) } @@ -72,10 +72,10 @@ class EmptyListViewTest { @Test fun `icon can be set programmatically`() { val emptyListView = EmptyListView(context) - emptyListView.setIcon(R.drawable.ic_baseline_edit_72) + emptyListView.setIcon(drawable.ic_baseline_warning_24) assertThat( - (emptyListView.findViewById(R.id.icon).drawable as VectorDrawable).toBitmap().sameAs((context.getDrawable(R.drawable.ic_baseline_edit_72) as VectorDrawable).toBitmap()), + (emptyListView.findViewById(R.id.icon).drawable as VectorDrawable).toBitmap().sameAs((context.getDrawable(drawable.ic_baseline_warning_24) as VectorDrawable).toBitmap()), equalTo(true) ) } diff --git a/lists/src/test/java/org/odk/collect/lists/RobolectricApplication.kt b/lists/src/test/java/org/odk/collect/lists/RobolectricApplication.kt new file mode 100644 index 00000000000..1110cfce0c9 --- /dev/null +++ b/lists/src/test/java/org/odk/collect/lists/RobolectricApplication.kt @@ -0,0 +1,13 @@ +package org.odk.collect.lists + +import android.app.Application +import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickGuard + +class RobolectricApplication : Application() { + override fun onCreate() { + super.onCreate() + + // We don't want any clicks to be blocked + MultiClickGuard.test = true + } +} diff --git a/lists/src/test/java/org/odk/collect/lists/selects/MultiSelectAdapterTest.kt b/lists/src/test/java/org/odk/collect/lists/selects/MultiSelectAdapterTest.kt new file mode 100644 index 00000000000..c50a2936b98 --- /dev/null +++ b/lists/src/test/java/org/odk/collect/lists/selects/MultiSelectAdapterTest.kt @@ -0,0 +1,99 @@ +package org.odk.collect.lists.selects + +import android.content.Context +import android.widget.FrameLayout +import androidx.lifecycle.MutableLiveData +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.odk.collect.lists.selects.support.TextAndCheckBoxViewHolder + +@RunWith(AndroidJUnit4::class) +class MultiSelectAdapterTest { + + private val context = ApplicationProvider.getApplicationContext() + + @Test + fun `selected items are checked`() { + val data = MutableLiveData(listOf(SelectItem("1", 1), SelectItem("2", 2))) + val multiSelectViewModel = MultiSelectViewModel(data) + multiSelectViewModel.select("1") + + val adapter = MultiSelectAdapter(multiSelectViewModel) { + TextAndCheckBoxViewHolder(it.context) + } + + adapter.data = multiSelectViewModel.getData().value!! + adapter.selected = multiSelectViewModel.getSelected().value + + val holders = createAndBindList(adapter) + assertThat(holders.size, equalTo(2)) + assertThat(holders[0].view.checkBox.isChecked, equalTo(true)) + assertThat(holders[1].view.checkBox.isChecked, equalTo(false)) + } + + @Test + fun `checking an item selects it`() { + val data = MutableLiveData(listOf(SelectItem("1", 1), SelectItem("2", 2))) + val multiSelectViewModel = MultiSelectViewModel(data) + + val adapter = MultiSelectAdapter(multiSelectViewModel) { + TextAndCheckBoxViewHolder(it.context) + } + + adapter.data = multiSelectViewModel.getData().value!! + adapter.selected = multiSelectViewModel.getSelected().value + + val holders = createAndBindList(adapter) + holders[0].view.checkBox.performClick() + assertThat(multiSelectViewModel.getSelected().value, equalTo(setOf("1"))) + } + + @Test + fun `clicking an item selects it`() { + val data = MutableLiveData(listOf(SelectItem("1", 1), SelectItem("2", 2))) + val multiSelectViewModel = MultiSelectViewModel(data) + + val adapter = MultiSelectAdapter(multiSelectViewModel) { + TextAndCheckBoxViewHolder(it.context) + } + + adapter.data = multiSelectViewModel.getData().value!! + adapter.selected = multiSelectViewModel.getSelected().value + + val holders = createAndBindList(adapter) + holders[0].view.performClick() + assertThat(multiSelectViewModel.getSelected().value, equalTo(setOf("1"))) + } + + @Test + fun `unchecking an item selects it`() { + val data = MutableLiveData(listOf(SelectItem("1", 1), SelectItem("2", 2))) + val multiSelectViewModel = MultiSelectViewModel(data) + multiSelectViewModel.select("1") + + val adapter = MultiSelectAdapter(multiSelectViewModel) { + TextAndCheckBoxViewHolder(it.context) + } + + adapter.data = multiSelectViewModel.getData().value!! + adapter.selected = multiSelectViewModel.getSelected().value + + val holders = createAndBindList(adapter) + holders[0].view.checkBox.performClick() + assertThat(multiSelectViewModel.getSelected().value, equalTo(setOf())) + } + + private fun createAndBindList(adapter: RecyclerView.Adapter): List { + return 0.rangeUntil(adapter.itemCount).map { position -> + adapter.onCreateViewHolder(FrameLayout(context), position).also { holder -> + adapter.onBindViewHolder(holder, position) + } + } + } +} diff --git a/lists/src/test/java/org/odk/collect/lists/selects/MultiSelectControlsFragmentTest.kt b/lists/src/test/java/org/odk/collect/lists/selects/MultiSelectControlsFragmentTest.kt new file mode 100644 index 00000000000..58cd769537c --- /dev/null +++ b/lists/src/test/java/org/odk/collect/lists/selects/MultiSelectControlsFragmentTest.kt @@ -0,0 +1,63 @@ +package org.odk.collect.lists.selects + +import androidx.lifecycle.MutableLiveData +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.doesNotExist +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.assertThat +import androidx.test.espresso.matcher.ViewMatchers.isEnabled +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.CoreMatchers.not +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.odk.collect.androidshared.ui.FragmentFactoryBuilder +import org.odk.collect.androidtest.getOrAwaitValue +import org.odk.collect.fragmentstest.FragmentScenarioLauncherRule +import org.odk.collect.strings.R.string + +@RunWith(AndroidJUnit4::class) +class MultiSelectControlsFragmentTest { + + private val data = MutableLiveData(listOf(SelectItem("1", null), SelectItem("2", null))) + private val multiSelectViewModel = MultiSelectViewModel(data) + + @get:Rule + val fragmentScenarioLauncherRule = FragmentScenarioLauncherRule( + FragmentFactoryBuilder() + .forClass(MultiSelectControlsFragment::class) { + MultiSelectControlsFragment("Action", multiSelectViewModel) + }.build() + ) + + @Test + fun `clicking select all selects all items`() { + fragmentScenarioLauncherRule.launchInContainer(MultiSelectControlsFragment::class.java) + + onView(withText(string.select_all)).perform(click()) + assertThat(multiSelectViewModel.getSelected().getOrAwaitValue(), equalTo(setOf("1", "2"))) + } + + @Test + fun `clicking clear all unselects all items`() { + fragmentScenarioLauncherRule.launchInContainer(MultiSelectControlsFragment::class.java) + + onView(withText(string.select_all)).perform(click()) + onView(withText(string.select_all)).check(doesNotExist()) + + onView(withText(string.clear_all)).perform(click()) + assertThat(multiSelectViewModel.getSelected().getOrAwaitValue(), equalTo(emptySet())) + } + + @Test + fun `action is disabled when nothing is selected`() { + fragmentScenarioLauncherRule.launchInContainer(MultiSelectControlsFragment::class.java) + onView(withText("Action")).check(matches(not(isEnabled()))) + + multiSelectViewModel.select("1") + onView(withText("Action")).check(matches(isEnabled())) + } +} diff --git a/lists/src/test/java/org/odk/collect/lists/selects/MultiSelectListFragmentTest.kt b/lists/src/test/java/org/odk/collect/lists/selects/MultiSelectListFragmentTest.kt new file mode 100644 index 00000000000..31e5a89194a --- /dev/null +++ b/lists/src/test/java/org/odk/collect/lists/selects/MultiSelectListFragmentTest.kt @@ -0,0 +1,74 @@ +package org.odk.collect.lists.selects + +import androidx.lifecycle.MutableLiveData +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isChecked +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.Matchers.not +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.odk.collect.androidshared.ui.FragmentFactoryBuilder +import org.odk.collect.fragmentstest.FragmentScenarioLauncherRule +import org.odk.collect.lists.R +import org.odk.collect.lists.selects.support.TextAndCheckBoxView +import org.odk.collect.lists.selects.support.TextAndCheckBoxViewHolder +import org.odk.collect.testshared.RecyclerViewMatcher.Companion.withRecyclerView +import org.odk.collect.testshared.ViewActions.clickOnItemWith +import org.odk.collect.testshared.ViewMatchers.recyclerView + +@RunWith(AndroidJUnit4::class) +class MultiSelectListFragmentTest { + + private val data = MutableLiveData>>(emptyList()) + private val multiSelectViewModel = MultiSelectViewModel(data) + + @get:Rule + val fragmentScenarioLauncherRule = FragmentScenarioLauncherRule( + FragmentFactoryBuilder() + .forClass(MultiSelectListFragment::class) { + MultiSelectListFragment( + "Action", + multiSelectViewModel, + { parent -> TextAndCheckBoxViewHolder(parent.context) } + ) + }.build() + ) + + @Test + fun `empty message shows when there are no forms`() { + fragmentScenarioLauncherRule.launchInContainer(MultiSelectListFragment::class.java) + onView(withId(R.id.empty)).check(matches(isDisplayed())) + + data.value = listOf(SelectItem("1", "Blah")) + onView(withId(R.id.empty)).check(matches(not(isDisplayed()))) + } + + @Test + fun `bottom buttons are hidden when there are no forms`() { + fragmentScenarioLauncherRule.launchInContainer(MultiSelectListFragment::class.java) + onView(withId(R.id.buttons)).check(matches(not(isDisplayed()))) + + data.value = listOf(SelectItem("1", "Blah")) + onView(withId(R.id.buttons)).check(matches(isDisplayed())) + } + + @Test + fun `recreating maintains selection`() { + val scenario = + fragmentScenarioLauncherRule.launchInContainer(MultiSelectListFragment::class.java) + data.value = listOf(SelectItem("1", "Blah 1"), SelectItem("1", "Blah 2")) + + onView(recyclerView()).perform(clickOnItemWith(withText("Blah 2"))) + + scenario.recreate() + onView(withRecyclerView(R.id.list).atPositionOnView(1, TextAndCheckBoxView.TEXT_VIEW_ID)) + .check(matches(withText("Blah 2"))) + onView(withRecyclerView(R.id.list).atPositionOnView(1, TextAndCheckBoxView.CHECK_BOX_ID)) + .check(matches(isChecked())) + } +} diff --git a/lists/src/test/java/org/odk/collect/lists/selects/MultiSelectViewModelTest.kt b/lists/src/test/java/org/odk/collect/lists/selects/MultiSelectViewModelTest.kt new file mode 100644 index 00000000000..75b53d34931 --- /dev/null +++ b/lists/src/test/java/org/odk/collect/lists/selects/MultiSelectViewModelTest.kt @@ -0,0 +1,85 @@ +package org.odk.collect.lists.selects + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.MutableLiveData +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.Rule +import org.junit.Test +import org.odk.collect.androidtest.getOrAwaitValue + +class MultiSelectViewModelTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `getSelected returns selected`() { + val viewModel = MultiSelectViewModel() + viewModel.select("1") + viewModel.select("11") + + assertThat(viewModel.getSelected().getOrAwaitValue(), equalTo(setOf("1", "11"))) + } + + @Test + fun `getSelected does not return unselected items`() { + val viewModel = MultiSelectViewModel() + viewModel.select("1") + viewModel.select("11") + viewModel.unselect("1") + + assertThat(viewModel.getSelected().getOrAwaitValue(), equalTo(setOf("11"))) + } + + @Test + fun `unselectAll unselects all items`() { + val viewModel = MultiSelectViewModel() + viewModel.select("1") + viewModel.select("11") + viewModel.unselectAll() + + assertThat(viewModel.getSelected().getOrAwaitValue(), equalTo(emptySet())) + } + + @Test + fun `toggle changes item back and forth`() { + val viewModel = MultiSelectViewModel() + + viewModel.toggle("1") + viewModel.toggle("11") + assertThat(viewModel.getSelected().getOrAwaitValue(), equalTo(setOf("1", "11"))) + + viewModel.toggle("11") + assertThat(viewModel.getSelected().getOrAwaitValue(), equalTo(setOf("1"))) + } + + @Test + fun `selectAll selects all data`() { + val data = MutableLiveData(listOf(SelectItem("1", 1), SelectItem("2", 2))) + val viewModel = MultiSelectViewModel(data) + + viewModel.selectAll() + assertThat(viewModel.getSelected().getOrAwaitValue(), equalTo(setOf("1", "2"))) + } + + @Test + fun `isAllSelected is true when all data selected`() { + val data = MutableLiveData(listOf(SelectItem("1", 1), SelectItem("2", 2))) + val viewModel = MultiSelectViewModel(data) + assertThat(viewModel.isAllSelected().getOrAwaitValue(), equalTo(false)) + + viewModel.select("1") + assertThat(viewModel.isAllSelected().getOrAwaitValue(), equalTo(false)) + + viewModel.select("2") + assertThat(viewModel.isAllSelected().getOrAwaitValue(), equalTo(true)) + } + + @Test + fun `isAllSelected returns false when no data`() { + val data = MutableLiveData(listOf>()) + val viewModel = MultiSelectViewModel(data) + assertThat(viewModel.isAllSelected().getOrAwaitValue(), equalTo(false)) + } +} diff --git a/lists/src/test/java/org/odk/collect/lists/selects/SingleSelectViewModelTest.kt b/lists/src/test/java/org/odk/collect/lists/selects/SingleSelectViewModelTest.kt new file mode 100644 index 00000000000..30c8ed4c8c2 --- /dev/null +++ b/lists/src/test/java/org/odk/collect/lists/selects/SingleSelectViewModelTest.kt @@ -0,0 +1,68 @@ +package org.odk.collect.lists.selects + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.Rule +import org.junit.Test +import org.odk.collect.androidtest.getOrAwaitValue + +class SingleSelectViewModelTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `nothing is selected on viewmodel initialization if selected item id is null`() { + val selected = null + val data: LiveData>> = MutableLiveData(listOf(SelectItem("1", 1), SelectItem("2", 2))) + val viewModel = SingleSelectViewModel(selected, data) + + assertThat(viewModel.getSelected().getOrAwaitValue(), equalTo(null)) + } + + @Test + fun `nothing is selected on viewmodel initialization if selected item id is not null but there is no item witch matching id`() { + val selected = "0" + val data: LiveData>> = MutableLiveData(listOf(SelectItem("1", 1), SelectItem("2", 2))) + val viewModel = SingleSelectViewModel(selected, data) + + assertThat(viewModel.getSelected().getOrAwaitValue(), equalTo(null)) + } + + @Test + fun `proper item is selected on viewmodel initialization if selected item id is not null`() { + val selected = "1" + val data: LiveData>> = MutableLiveData(listOf(SelectItem("1", 1), SelectItem("2", 2))) + val viewModel = SingleSelectViewModel(selected, data) + + assertThat(viewModel.getSelected().getOrAwaitValue(), equalTo("1")) + } + + @Test + fun `getSelected returns selected item id`() { + val selected = null + val data: LiveData>> = MutableLiveData(listOf(SelectItem("1", 1), SelectItem("2", 2))) + val viewModel = SingleSelectViewModel(selected, data) + + viewModel.select("1") + assertThat(viewModel.getSelected().getOrAwaitValue(), equalTo("1")) + + viewModel.select("2") + assertThat(viewModel.getSelected().getOrAwaitValue(), equalTo("2")) + } + + @Test + fun `clear unselects selected item`() { + val selected = null + val data: LiveData>> = MutableLiveData(listOf(SelectItem("1", 1), SelectItem("2", 2))) + val viewModel = SingleSelectViewModel(selected, data) + + viewModel.select("1") + assertThat(viewModel.getSelected().getOrAwaitValue(), equalTo("1")) + viewModel.clear() + assertThat(viewModel.getSelected().getOrAwaitValue(), equalTo(null)) + } +} diff --git a/lists/src/test/java/org/odk/collect/lists/selects/support/TextAndCheckboxViewHolder.kt b/lists/src/test/java/org/odk/collect/lists/selects/support/TextAndCheckboxViewHolder.kt new file mode 100644 index 00000000000..35395438b83 --- /dev/null +++ b/lists/src/test/java/org/odk/collect/lists/selects/support/TextAndCheckboxViewHolder.kt @@ -0,0 +1,39 @@ +package org.odk.collect.lists.selects.support + +import android.content.Context +import android.widget.CheckBox +import android.widget.FrameLayout +import android.widget.TextView +import org.odk.collect.lists.selects.MultiSelectAdapter + +class TextAndCheckBoxView(context: Context) : FrameLayout(context) { + + val textView = TextView(context).also { + it.id = TEXT_VIEW_ID + addView(it) + } + + val checkBox = CheckBox(context).also { + it.id = CHECK_BOX_ID + addView(it) + } + + companion object { + const val TEXT_VIEW_ID = 101 + const val CHECK_BOX_ID = 102 + } +} + +class TextAndCheckBoxViewHolder(context: Context) : + MultiSelectAdapter.ViewHolder(TextAndCheckBoxView(context)) { + + val view = itemView as TextAndCheckBoxView + + override fun setItem(item: T) { + view.textView.text = item.toString() + } + + override fun getCheckbox(): CheckBox { + return view.checkBox + } +} diff --git a/lists/src/test/resources/robolectric.properties b/lists/src/test/resources/robolectric.properties new file mode 100644 index 00000000000..d20a6a9e3c2 --- /dev/null +++ b/lists/src/test/resources/robolectric.properties @@ -0,0 +1,2 @@ +application=org.odk.collect.lists.RobolectricApplication +sdk=33 \ No newline at end of file diff --git a/mapbox/build.gradle.kts b/mapbox/build.gradle.kts index a64c44b9374..ee99e953e4e 100644 --- a/mapbox/build.gradle.kts +++ b/mapbox/build.gradle.kts @@ -44,9 +44,9 @@ dependencies { implementation(project(":settings")) implementation(project(":shared")) implementation(project(":strings")) + implementation(project(":async")) implementation(Dependencies.play_services_location) implementation(Dependencies.androidx_preference_ktx) - implementation(Dependencies.guava) implementation(Dependencies.mapbox_android_sdk) implementation(Dependencies.timber) implementation(Dependencies.androidx_startup) diff --git a/mapbox/src/main/java/org/odk/collect/mapbox/DynamicPolyLineFeature.kt b/mapbox/src/main/java/org/odk/collect/mapbox/DynamicPolyLineFeature.kt index 1416c320db7..cef92b955ee 100644 --- a/mapbox/src/main/java/org/odk/collect/mapbox/DynamicPolyLineFeature.kt +++ b/mapbox/src/main/java/org/odk/collect/mapbox/DynamicPolyLineFeature.kt @@ -2,7 +2,6 @@ package org.odk.collect.mapbox import android.content.Context import com.mapbox.geojson.Point -import com.mapbox.maps.extension.style.utils.ColorUtils import com.mapbox.maps.plugin.annotation.generated.OnPointAnnotationClickListener import com.mapbox.maps.plugin.annotation.generated.OnPointAnnotationDragListener import com.mapbox.maps.plugin.annotation.generated.PointAnnotation @@ -10,7 +9,7 @@ import com.mapbox.maps.plugin.annotation.generated.PointAnnotationManager import com.mapbox.maps.plugin.annotation.generated.PolylineAnnotation import com.mapbox.maps.plugin.annotation.generated.PolylineAnnotationManager import com.mapbox.maps.plugin.annotation.generated.PolylineAnnotationOptions -import org.odk.collect.maps.MapConsts.MAPBOX_POLYLINE_STROKE_WIDTH +import org.odk.collect.maps.LineDescription import org.odk.collect.maps.MapFragment import org.odk.collect.maps.MapPoint @@ -22,18 +21,17 @@ internal class DynamicPolyLineFeature( private val featureId: Int, private val featureClickListener: MapFragment.FeatureListener?, private val featureDragEndListener: MapFragment.FeatureListener?, - private val closedPolygon: Boolean, - initMapPoints: Iterable -) : MapFeature { - val mapPoints = mutableListOf() + private val lineDescription: LineDescription +) : LineFeature { + override val points = mutableListOf() private val pointAnnotations = mutableListOf() private val pointAnnotationClickListener = ClickListener() private val pointAnnotationDragListener = DragListener() private var polylineAnnotation: PolylineAnnotation? = null init { - initMapPoints.forEach { - mapPoints.add(it) + lineDescription.points.forEach { + points.add(it) pointAnnotations.add( MapUtils.createPointAnnotation( pointAnnotationManager, @@ -74,11 +72,11 @@ internal class DynamicPolyLineFeature( } pointAnnotations.clear() - mapPoints.clear() + points.clear() } fun appendPoint(point: MapPoint) { - mapPoints.add(point) + points.add(point) pointAnnotations.add( MapUtils.createPointAnnotation( pointAnnotationManager, @@ -96,19 +94,19 @@ internal class DynamicPolyLineFeature( if (pointAnnotations.isNotEmpty()) { pointAnnotationManager.delete(pointAnnotations.last()) pointAnnotations.removeLast() - mapPoints.removeLast() + points.removeLast() updateLine() } } private fun updateLine() { - val points = mapPoints + val points = points .map { Point.fromLngLat(it.longitude, it.latitude, it.altitude) } .toMutableList() .also { - if (closedPolygon && it.isNotEmpty()) { + if (lineDescription.closed && it.isNotEmpty()) { it.add(it.first()) } } @@ -121,8 +119,8 @@ internal class DynamicPolyLineFeature( polylineAnnotation = polylineAnnotationManager.create( PolylineAnnotationOptions() .withPoints(points) - .withLineColor(ColorUtils.colorToRgbaString(context.resources.getColor(org.odk.collect.icons.R.color.mapLineColor))) - .withLineWidth(MAPBOX_POLYLINE_STROKE_WIDTH.toDouble()) + .withLineColor(lineDescription.getStrokeColor()) + .withLineWidth(MapUtils.convertStrokeWidth(lineDescription)) ).also { polylineAnnotationManager.update(it) } @@ -147,7 +145,7 @@ internal class DynamicPolyLineFeature( override fun onAnnotationDrag(annotation: com.mapbox.maps.plugin.annotation.Annotation<*>) { pointAnnotations.forEachIndexed { index, pointAnnotation -> if (annotation.id == pointAnnotation.id) { - mapPoints[index] = MapUtils.mapPointFromPointAnnotation(pointAnnotation) + points[index] = MapUtils.mapPointFromPointAnnotation(pointAnnotation) } } updateLine() diff --git a/mapbox/src/main/java/org/odk/collect/mapbox/LineFeature.kt b/mapbox/src/main/java/org/odk/collect/mapbox/LineFeature.kt new file mode 100644 index 00000000000..130325eb39b --- /dev/null +++ b/mapbox/src/main/java/org/odk/collect/mapbox/LineFeature.kt @@ -0,0 +1,7 @@ +package org.odk.collect.mapbox + +import org.odk.collect.maps.MapPoint + +interface LineFeature : MapFeature { + val points: List +} diff --git a/mapbox/src/main/java/org/odk/collect/mapbox/MapBoxInitializationFragment.kt b/mapbox/src/main/java/org/odk/collect/mapbox/MapBoxInitializationFragment.kt index 780258d433b..9895300e904 100644 --- a/mapbox/src/main/java/org/odk/collect/mapbox/MapBoxInitializationFragment.kt +++ b/mapbox/src/main/java/org/odk/collect/mapbox/MapBoxInitializationFragment.kt @@ -12,7 +12,7 @@ import com.mapbox.maps.MapView import com.mapbox.maps.Style import com.mapbox.maps.loader.MapboxMapsInitializer import org.odk.collect.androidshared.data.getState -import org.odk.collect.androidshared.network.NetworkStateProvider +import org.odk.collect.async.network.NetworkStateProvider import org.odk.collect.settings.SettingsProvider import org.odk.collect.settings.keys.MetaKeys import org.odk.collect.shared.injection.ObjectProviderHost diff --git a/mapbox/src/main/java/org/odk/collect/mapbox/MapUtils.kt b/mapbox/src/main/java/org/odk/collect/mapbox/MapUtils.kt index 06bf7b4b296..b2576797512 100644 --- a/mapbox/src/main/java/org/odk/collect/mapbox/MapUtils.kt +++ b/mapbox/src/main/java/org/odk/collect/mapbox/MapUtils.kt @@ -6,6 +6,7 @@ import com.mapbox.maps.extension.style.layers.properties.generated.IconAnchor import com.mapbox.maps.plugin.annotation.generated.PointAnnotation import com.mapbox.maps.plugin.annotation.generated.PointAnnotationManager import com.mapbox.maps.plugin.annotation.generated.PointAnnotationOptions +import org.odk.collect.maps.LineDescription import org.odk.collect.maps.MapFragment import org.odk.collect.maps.MapPoint import org.odk.collect.maps.markers.MarkerDescription @@ -65,4 +66,10 @@ object MapUtils { // deviation fields are no longer meaningful; reset them to zero. return MapPoint(pointAnnotation.point.latitude(), pointAnnotation.point.longitude(), 0.0, 0.0) } + + // To ensure consistent stroke width across map platforms like Mapbox, Google, and OSM, + // the value for Mapbox needs to be divided by 3. + fun convertStrokeWidth(lineDescription: LineDescription): Double { + return (lineDescription.getStrokeWidth() / 3).toDouble() + } } diff --git a/mapbox/src/main/java/org/odk/collect/mapbox/MapboxMapConfigurator.java b/mapbox/src/main/java/org/odk/collect/mapbox/MapboxMapConfigurator.java index 76ea423f24c..2a173ec51e7 100644 --- a/mapbox/src/main/java/org/odk/collect/mapbox/MapboxMapConfigurator.java +++ b/mapbox/src/main/java/org/odk/collect/mapbox/MapboxMapConfigurator.java @@ -1,13 +1,13 @@ package org.odk.collect.mapbox; import static org.odk.collect.settings.keys.ProjectKeys.KEY_MAPBOX_MAP_STYLE; +import static kotlin.collections.SetsKt.setOf; import android.content.Context; import android.os.Bundle; import androidx.preference.Preference; -import com.google.common.collect.ImmutableSet; import com.mapbox.maps.Style; import org.odk.collect.androidshared.ui.PrefUtils; @@ -66,8 +66,8 @@ public MapboxMapConfigurator() { } @Override public Set getPrefKeys() { - return prefKey.isEmpty() ? ImmutableSet.of(ProjectKeys.KEY_REFERENCE_LAYER) : - ImmutableSet.of(prefKey, ProjectKeys.KEY_REFERENCE_LAYER); + return prefKey.isEmpty() ? setOf(ProjectKeys.KEY_REFERENCE_LAYER) : + setOf(prefKey, ProjectKeys.KEY_REFERENCE_LAYER); } @Override public Bundle buildConfig(Settings prefs) { diff --git a/mapbox/src/main/java/org/odk/collect/mapbox/MapboxMapFragment.kt b/mapbox/src/main/java/org/odk/collect/mapbox/MapboxMapFragment.kt index 9e4ab2c8582..834918bec40 100644 --- a/mapbox/src/main/java/org/odk/collect/mapbox/MapboxMapFragment.kt +++ b/mapbox/src/main/java/org/odk/collect/mapbox/MapboxMapFragment.kt @@ -50,6 +50,7 @@ import kotlinx.coroutines.launch import org.odk.collect.androidshared.utils.ScreenUtils import org.odk.collect.location.LocationClient import org.odk.collect.location.LocationClient.LocationClientListener +import org.odk.collect.maps.LineDescription import org.odk.collect.maps.MapFragment import org.odk.collect.maps.MapFragment.ErrorListener import org.odk.collect.maps.MapFragment.FeatureListener @@ -57,6 +58,7 @@ import org.odk.collect.maps.MapFragment.PointListener import org.odk.collect.maps.MapFragment.ReadyListener import org.odk.collect.maps.MapFragmentDelegate import org.odk.collect.maps.MapPoint +import org.odk.collect.maps.PolygonDescription import org.odk.collect.maps.layers.MapFragmentReferenceLayerUtils.getReferenceLayerFile import org.odk.collect.maps.layers.MbtilesFile import org.odk.collect.maps.layers.ReferenceLayerRepository @@ -336,9 +338,9 @@ class MapboxMapFragment : } } - override fun addPolyLine(points: MutableIterable, closed: Boolean, draggable: Boolean): Int { + override fun addPolyLine(lineDescription: LineDescription): Int { val featureId = nextFeatureId++ - if (draggable) { + if (lineDescription.draggable) { features[featureId] = DynamicPolyLineFeature( requireContext(), pointAnnotationManager, @@ -346,28 +348,24 @@ class MapboxMapFragment : featureId, featureClickListener, featureDragEndListener, - closed, - points + lineDescription ) } else { features[featureId] = StaticPolyLineFeature( - requireContext(), polylineAnnotationManager, featureId, featureClickListener, - closed, - points + lineDescription ) } return featureId } - override fun addPolygon(points: MutableIterable): Int { + override fun addPolygon(polygonDescription: PolygonDescription): Int { val featureId = nextFeatureId++ features[featureId] = StaticPolygonFeature( - requireContext(), mapView.annotations.createPolygonAnnotationManager(), - points, + polygonDescription, featureClickListener, featureId ) @@ -391,8 +389,8 @@ class MapboxMapFragment : override fun getPolyLinePoints(featureId: Int): List { val feature = features[featureId] - return if (feature is DynamicPolyLineFeature) { - feature.mapPoints + return if (feature is LineFeature) { + feature.points } else { emptyList() } @@ -583,7 +581,7 @@ class MapboxMapFragment : tileSet.minZoom(mbtiles.getMetadata("minzoom").toInt()) tileSet.maxZoom(mbtiles.getMetadata("maxzoom").toInt()) } catch (e: NumberFormatException) { - /* ignore */ + // ignore } var parts = mbtiles.getMetadata("center").split(",").toTypedArray() if (parts.size == 3) { // latitude, longitude, zoom @@ -596,7 +594,7 @@ class MapboxMapFragment : ) ) } catch (e: NumberFormatException) { - /* ignore */ + // ignore } } parts = mbtiles.getMetadata("bounds").split(",").toTypedArray() @@ -611,7 +609,7 @@ class MapboxMapFragment : ) ) } catch (e: NumberFormatException) { - /* ignore */ + // ignore } } } catch (e: MbtilesFile.MbtilesException) { diff --git a/mapbox/src/main/java/org/odk/collect/mapbox/StaticPolyLineFeature.kt b/mapbox/src/main/java/org/odk/collect/mapbox/StaticPolyLineFeature.kt index e55fec08808..88ac51e69f8 100644 --- a/mapbox/src/main/java/org/odk/collect/mapbox/StaticPolyLineFeature.kt +++ b/mapbox/src/main/java/org/odk/collect/mapbox/StaticPolyLineFeature.kt @@ -1,39 +1,35 @@ package org.odk.collect.mapbox -import android.content.Context import com.mapbox.geojson.Point -import com.mapbox.maps.extension.style.utils.ColorUtils import com.mapbox.maps.plugin.annotation.generated.PolylineAnnotation import com.mapbox.maps.plugin.annotation.generated.PolylineAnnotationManager import com.mapbox.maps.plugin.annotation.generated.PolylineAnnotationOptions -import org.odk.collect.maps.MapConsts.MAPBOX_POLYLINE_STROKE_WIDTH +import org.odk.collect.maps.LineDescription import org.odk.collect.maps.MapFragment import org.odk.collect.maps.MapPoint /** A polyline that can not be manipulated by dragging Symbols at its vertices. */ internal class StaticPolyLineFeature( - context: Context, private val polylineAnnotationManager: PolylineAnnotationManager, private val featureId: Int, private val featureClickListener: MapFragment.FeatureListener?, - private val closedPolygon: Boolean, - initMapPoints: Iterable -) : MapFeature { - private val mapPoints = mutableListOf() + private val lineDescription: LineDescription +) : LineFeature { + override val points = mutableListOf() private var polylineAnnotation: PolylineAnnotation? = null init { - initMapPoints.forEach { - mapPoints.add(it) + lineDescription.points.forEach { + points.add(it) } - val points = mapPoints + val points = points .map { Point.fromLngLat(it.longitude, it.latitude, it.altitude) } .toMutableList() .also { - if (closedPolygon && it.isNotEmpty()) { + if (lineDescription.closed && it.isNotEmpty()) { it.add(it.first()) } } @@ -46,8 +42,8 @@ internal class StaticPolyLineFeature( polylineAnnotation = polylineAnnotationManager.create( PolylineAnnotationOptions() .withPoints(points) - .withLineColor(ColorUtils.colorToRgbaString(context.resources.getColor(org.odk.collect.icons.R.color.mapLineColor))) - .withLineWidth(MAPBOX_POLYLINE_STROKE_WIDTH.toDouble()) + .withLineColor(lineDescription.getStrokeColor()) + .withLineWidth(MapUtils.convertStrokeWidth(lineDescription)) ).also { polylineAnnotationManager.update(it) } @@ -69,6 +65,6 @@ internal class StaticPolyLineFeature( polylineAnnotation?.let { polylineAnnotationManager.delete(it) } - mapPoints.clear() + points.clear() } } diff --git a/mapbox/src/main/java/org/odk/collect/mapbox/StaticPolygonFeature.kt b/mapbox/src/main/java/org/odk/collect/mapbox/StaticPolygonFeature.kt index f9eae49aa72..d70846baf16 100644 --- a/mapbox/src/main/java/org/odk/collect/mapbox/StaticPolygonFeature.kt +++ b/mapbox/src/main/java/org/odk/collect/mapbox/StaticPolygonFeature.kt @@ -1,34 +1,25 @@ package org.odk.collect.mapbox -import android.content.Context -import androidx.core.graphics.ColorUtils import com.mapbox.geojson.Point import com.mapbox.maps.plugin.annotation.generated.OnPolygonAnnotationClickListener import com.mapbox.maps.plugin.annotation.generated.PolygonAnnotation import com.mapbox.maps.plugin.annotation.generated.PolygonAnnotationManager import com.mapbox.maps.plugin.annotation.generated.PolygonAnnotationOptions -import org.odk.collect.maps.MapConsts.POLYGON_FILL_COLOR_OPACITY import org.odk.collect.maps.MapFragment -import org.odk.collect.maps.MapPoint +import org.odk.collect.maps.PolygonDescription class StaticPolygonFeature( - context: Context, private val polygonAnnotationManager: PolygonAnnotationManager, - points: Iterable, + polygonDescription: PolygonDescription, featureClickListener: MapFragment.FeatureListener?, featureId: Int ) : MapFeature { private val polygonAnnotation: PolygonAnnotation = polygonAnnotationManager.create( PolygonAnnotationOptions() - .withPoints(listOf(points.map { Point.fromLngLat(it.longitude, it.latitude) })) - .withFillOutlineColor(context.resources.getColor(org.odk.collect.icons.R.color.mapLineColor)) - .withFillColor( - ColorUtils.setAlphaComponent( - context.resources.getColor(org.odk.collect.icons.R.color.mapLineColor), - POLYGON_FILL_COLOR_OPACITY - ) - ) + .withPoints(listOf(polygonDescription.points.map { Point.fromLngLat(it.longitude, it.latitude) })) + .withFillOutlineColor(polygonDescription.getStrokeColor()) + .withFillColor(polygonDescription.getFillColor()) ) private val polygonClickListener = diff --git a/maps/build.gradle.kts b/maps/build.gradle.kts index cd272ca60b2..cf2d40c9326 100644 --- a/maps/build.gradle.kts +++ b/maps/build.gradle.kts @@ -48,14 +48,31 @@ android { dependencies { coreLibraryDesugaring(Dependencies.desugar) + implementation(project(":async")) implementation(project(":shared")) + implementation(project(":androidshared")) + implementation(project(":icons")) + implementation(project(":material")) + implementation(project(":settings")) + implementation(project(":strings")) + implementation(project(":web-page")) + implementation(project(":analytics")) + implementation(project(":lists")) + implementation(Dependencies.android_material) implementation(Dependencies.kotlin_stdlib) implementation(Dependencies.androidx_fragment_ktx) implementation(Dependencies.androidx_preference_ktx) implementation(Dependencies.timber) + debugImplementation(project(":fragments-test")) + + testImplementation(project(":androidtest")) + testImplementation(project(":test-shared")) testImplementation(Dependencies.junit) testImplementation(Dependencies.androidx_test_ext_junit) testImplementation(Dependencies.hamcrest) testImplementation(Dependencies.robolectric) + testImplementation(Dependencies.mockito_kotlin) + testImplementation(Dependencies.androidx_test_espresso_contrib) + testImplementation(Dependencies.androidx_test_espresso_core) } diff --git a/maps/src/main/java/org/odk/collect/maps/AnalyticsEvents.kt b/maps/src/main/java/org/odk/collect/maps/AnalyticsEvents.kt new file mode 100644 index 00000000000..cfcf7bfed56 --- /dev/null +++ b/maps/src/main/java/org/odk/collect/maps/AnalyticsEvents.kt @@ -0,0 +1,11 @@ +package org.odk.collect.maps + +object AnalyticsEvents { + + /** + * Tracks how many offline layers people are importing at once + */ + const val IMPORT_LAYER_SINGLE = "ImportLayerSingle" // One + const val IMPORT_LAYER_FEW = "ImportLayerFew" // <= 5 + const val IMPORT_LAYER_MANY = "ImportLayerMany" // > 5 +} diff --git a/maps/src/main/java/org/odk/collect/maps/LineDescription.kt b/maps/src/main/java/org/odk/collect/maps/LineDescription.kt new file mode 100644 index 00000000000..b422648db32 --- /dev/null +++ b/maps/src/main/java/org/odk/collect/maps/LineDescription.kt @@ -0,0 +1,30 @@ +package org.odk.collect.maps + +import org.odk.collect.androidshared.utils.toColorInt + +data class LineDescription( + val points: List = emptyList(), + private val strokeWidth: String? = null, + private val strokeColor: String? = null, + val draggable: Boolean = false, + val closed: Boolean = false +) { + fun getStrokeWidth(): Float { + return try { + strokeWidth?.toFloat()?.let { + if (it >= 0) { + it + } else { + MapConsts.DEFAULT_STROKE_WIDTH + } + } ?: MapConsts.DEFAULT_STROKE_WIDTH + } catch (e: NumberFormatException) { + MapConsts.DEFAULT_STROKE_WIDTH + } + } + + fun getStrokeColor(): Int { + val customColor = strokeColor?.toColorInt() + return customColor ?: MapConsts.DEFAULT_STROKE_COLOR + } +} diff --git a/maps/src/main/java/org/odk/collect/maps/MapConfigurator.java b/maps/src/main/java/org/odk/collect/maps/MapConfigurator.kt similarity index 67% rename from maps/src/main/java/org/odk/collect/maps/MapConfigurator.java rename to maps/src/main/java/org/odk/collect/maps/MapConfigurator.kt index 4f7da5e86bf..6c8af62df40 100644 --- a/maps/src/main/java/org/odk/collect/maps/MapConfigurator.java +++ b/maps/src/main/java/org/odk/collect/maps/MapConfigurator.kt @@ -1,15 +1,10 @@ -package org.odk.collect.maps; +package org.odk.collect.maps -import android.content.Context; -import android.os.Bundle; - -import androidx.preference.Preference; - -import org.odk.collect.shared.settings.Settings; - -import java.io.File; -import java.util.Collection; -import java.util.List; +import android.content.Context +import android.os.Bundle +import androidx.preference.Preference +import org.odk.collect.shared.settings.Settings +import java.io.File /** * For each MapFragment implementation class, there is one instance of this @@ -22,33 +17,33 @@ * For example, the GoogleMapConfigurator can define a "Google map style" * preference with choices such as Terrain or Satellite. */ -public interface MapConfigurator { - /** Returns true if this MapFragment implementation is available on this device. */ - boolean isAvailable(Context context); +interface MapConfigurator { + /** Returns true if this MapFragment implementation is available on this device. */ + fun isAvailable(context: Context): Boolean /** * Displays a warning to the user that this MapFragment implementation is * unavailable. This will be invoked when isSupported() is false or * createMapFragment(context) returns null. */ - void showUnavailableMessage(Context context); + fun showUnavailableMessage(context: Context) - /** Constructs any preference widgets that are specific to this map implementation. */ - List createPrefs(Context context, Settings settings); + /** Constructs any preference widgets that are specific to this map implementation. */ + fun createPrefs(context: Context, settings: Settings): List - /** Gets the set of keys for preferences that should be watched for changes. */ - Collection getPrefKeys(); + /** Gets the set of keys for preferences that should be watched for changes. */ + val prefKeys: Collection - /** Packs map-related preferences into a Bundle for MapFragment.applyConfig(). */ - Bundle buildConfig(Settings prefs); + /** Packs map-related preferences into a Bundle for MapFragment.applyConfig(). */ + fun buildConfig(prefs: Settings): Bundle /** * Returns true if map fragments obtained from this MapConfigurator are * expected to be able to render the given file as an overlay. This * check determines which files appear as available Reference Layers. */ - boolean supportsLayer(File file); + fun supportsLayer(file: File): Boolean - /** Returns a String name for a given overlay file, or null if unsupported. */ - String getDisplayName(File file); + /** Returns a String name for a given overlay file, or null if unsupported. */ + fun getDisplayName(file: File): String } diff --git a/maps/src/main/java/org/odk/collect/maps/MapConsts.kt b/maps/src/main/java/org/odk/collect/maps/MapConsts.kt index 0fdd2855bcb..a95e71b0aad 100644 --- a/maps/src/main/java/org/odk/collect/maps/MapConsts.kt +++ b/maps/src/main/java/org/odk/collect/maps/MapConsts.kt @@ -1,7 +1,7 @@ package org.odk.collect.maps object MapConsts { - const val POLYLINE_STROKE_WIDTH = 8 - const val MAPBOX_POLYLINE_STROKE_WIDTH = 4 - const val POLYGON_FILL_COLOR_OPACITY = 68 + const val DEFAULT_STROKE_COLOR = -65536 // color-int representation of #ffff0000 + const val DEFAULT_STROKE_WIDTH = 8f + const val DEFAULT_FILL_COLOR_OPACITY = 68 } diff --git a/maps/src/main/java/org/odk/collect/maps/MapFragment.java b/maps/src/main/java/org/odk/collect/maps/MapFragment.java index ebe00240167..5c8cacb6f21 100644 --- a/maps/src/main/java/org/odk/collect/maps/MapFragment.java +++ b/maps/src/main/java/org/odk/collect/maps/MapFragment.java @@ -118,13 +118,13 @@ public interface MapFragment { * The vertices will have handles that can be dragged by the user. * Returns a positive integer, the featureId for the newly added shape. */ - int addPolyLine(@NonNull Iterable points, boolean closed, boolean draggable); + int addPolyLine(LineDescription lineDescription); /** * Adds a polygon to the map with given sequence of vertices. * Returns a positive integer, * the featureId for the newly added shape. */ - int addPolygon(@NonNull Iterable points); + int addPolygon(PolygonDescription polygonDescription); /** Appends a vertex to the polyline or polygon specified by featureId. */ void appendPointToPolyLine(int featureId, @NonNull MapPoint point); diff --git a/maps/src/main/java/org/odk/collect/maps/PolygonDescription.kt b/maps/src/main/java/org/odk/collect/maps/PolygonDescription.kt new file mode 100644 index 00000000000..f97442bb661 --- /dev/null +++ b/maps/src/main/java/org/odk/collect/maps/PolygonDescription.kt @@ -0,0 +1,45 @@ +package org.odk.collect.maps + +import androidx.core.graphics.ColorUtils +import org.odk.collect.androidshared.utils.toColorInt + +data class PolygonDescription( + val points: List = emptyList(), + private val strokeWidth: String? = null, + private val strokeColor: String? = null, + private val fillColor: String? = null +) { + fun getStrokeWidth(): Float { + return try { + strokeWidth?.toFloat()?.let { + if (it >= 0) { + it + } else { + MapConsts.DEFAULT_STROKE_WIDTH + } + } ?: MapConsts.DEFAULT_STROKE_WIDTH + } catch (e: NumberFormatException) { + MapConsts.DEFAULT_STROKE_WIDTH + } + } + + fun getStrokeColor(): Int { + val customColor = strokeColor?.toColorInt() + return customColor ?: MapConsts.DEFAULT_STROKE_COLOR + } + + fun getFillColor(): Int { + val customColor = fillColor?.toColorInt()?.let { + ColorUtils.setAlphaComponent( + it, + MapConsts.DEFAULT_FILL_COLOR_OPACITY + ) + } + + return customColor + ?: ColorUtils.setAlphaComponent( + MapConsts.DEFAULT_STROKE_COLOR, + MapConsts.DEFAULT_FILL_COLOR_OPACITY + ) + } +} diff --git a/maps/src/main/java/org/odk/collect/maps/layers/DirectoryReferenceLayerRepository.kt b/maps/src/main/java/org/odk/collect/maps/layers/DirectoryReferenceLayerRepository.kt index eb02521c270..0c848ed4210 100644 --- a/maps/src/main/java/org/odk/collect/maps/layers/DirectoryReferenceLayerRepository.kt +++ b/maps/src/main/java/org/odk/collect/maps/layers/DirectoryReferenceLayerRepository.kt @@ -1,40 +1,55 @@ package org.odk.collect.maps.layers +import org.odk.collect.maps.MapConfigurator import org.odk.collect.shared.PathUtils import org.odk.collect.shared.files.DirectoryUtils.listFilesRecursively import java.io.File -class DirectoryReferenceLayerRepository(private val directoryPaths: List) : - ReferenceLayerRepository { - - /** - * Convenience constructors - */ - constructor(vararg directoryPaths: String) : this(directoryPaths.toList()) - constructor(directoryPath: String) : this(listOf(directoryPath)) +class DirectoryReferenceLayerRepository( + private val sharedLayersDirPath: String, + private val projectLayersDirPath: String, + private val getMapConfigurator: () -> MapConfigurator +) : ReferenceLayerRepository { override fun getAll(): List { - return getAllFilesWithDirectory().map { - ReferenceLayer(getIdForFile(it.second, it.first), it.first) - }.distinctBy { it.id } + return getAllFilesWithDirectory() + .map { ReferenceLayer(getIdForFile(it.second, it.first), it.first, getName(it.first)) } + .distinctBy { it.id } + .filter { getMapConfigurator().supportsLayer(it.file) } } override fun get(id: String): ReferenceLayer? { val file = getAllFilesWithDirectory().firstOrNull { getIdForFile(it.second, it.first) == id } - return if (file != null) { - ReferenceLayer(getIdForFile(file.second, file.first), file.first) + return if (file != null && getMapConfigurator().supportsLayer(file.first)) { + ReferenceLayer(getIdForFile(file.second, file.first), file.first, getName(file.first)) } else { null } } - private fun getAllFilesWithDirectory() = directoryPaths.flatMap { dir -> + override fun addLayer(file: File, shared: Boolean) { + if (shared) { + file.copyTo(File(sharedLayersDirPath, file.name), true) + } else { + file.copyTo(File(projectLayersDirPath, file.name), true) + } + } + + override fun delete(id: String) { + get(id)?.file?.delete() + } + + private fun getAllFilesWithDirectory() = listOf(sharedLayersDirPath, projectLayersDirPath).flatMap { dir -> listFilesRecursively(File(dir)).map { file -> Pair(file, dir) } } - private fun getIdForFile(directoryPath: String, file: File) = + fun getIdForFile(directoryPath: String, file: File) = PathUtils.getRelativeFilePath(directoryPath, file.absolutePath) + + private fun getName(file: File): String { + return getMapConfigurator().getDisplayName(file) + } } diff --git a/maps/src/main/java/org/odk/collect/maps/layers/MbtilesFile.java b/maps/src/main/java/org/odk/collect/maps/layers/MbtilesFile.java index 0e15ba40ba1..8dfbb202e8a 100644 --- a/maps/src/main/java/org/odk/collect/maps/layers/MbtilesFile.java +++ b/maps/src/main/java/org/odk/collect/maps/layers/MbtilesFile.java @@ -28,6 +28,8 @@ * See https://github.com/mapbox/mbtiles-spec for the detailed specification. */ public class MbtilesFile implements Closeable, TileSource { + public static final String FILE_EXTENSION = ".mbtiles"; + public enum LayerType { RASTER, VECTOR } private final File file; @@ -166,7 +168,7 @@ private static String detectContentType(File file) throws MbtilesException { if (!file.exists() || !file.isFile()) { throw new NotFileException(file); } - if (!file.getName().toLowerCase(Locale.US).endsWith(".mbtiles")) { + if (!file.getName().toLowerCase(Locale.US).endsWith(FILE_EXTENSION)) { throw new UnsupportedFilenameException(file); } try (SQLiteDatabase db = openSqliteReadOnly(file)) { diff --git a/maps/src/main/java/org/odk/collect/maps/layers/OfflineMapLayersImporter.kt b/maps/src/main/java/org/odk/collect/maps/layers/OfflineMapLayersImporter.kt new file mode 100644 index 00000000000..92767850aa2 --- /dev/null +++ b/maps/src/main/java/org/odk/collect/maps/layers/OfflineMapLayersImporter.kt @@ -0,0 +1,127 @@ +package org.odk.collect.maps.layers + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.odk.collect.async.Scheduler +import org.odk.collect.maps.databinding.OfflineMapLayersImporterBinding +import org.odk.collect.material.MaterialFullScreenDialogFragment +import org.odk.collect.settings.SettingsProvider +import org.odk.collect.strings.R +import org.odk.collect.strings.localization.getLocalizedQuantityString +import org.odk.collect.strings.localization.getLocalizedString + +class OfflineMapLayersImporter( + private val referenceLayerRepository: ReferenceLayerRepository, + private val scheduler: Scheduler, + private val settingsProvider: SettingsProvider +) : MaterialFullScreenDialogFragment() { + val viewModel: OfflineMapLayersViewModel by activityViewModels { + object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return OfflineMapLayersViewModel( + referenceLayerRepository, + scheduler, + settingsProvider + ) as T + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val binding = OfflineMapLayersImporterBinding.inflate(inflater) + + binding.cancelButton.setOnClickListener { + dismiss() + } + + binding.addLayerButton.setOnClickListener { + viewModel.importNewLayers(binding.allProjectsOption.isChecked) + dismiss() + } + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val binding = OfflineMapLayersImporterBinding.bind(view) + + viewModel.trackableWorker.isWorking.observe(this) { isLoading -> + if (isLoading) { + binding.addLayerButton.isEnabled = false + binding.layers.visibility = View.GONE + binding.progressIndicator.visibility = View.VISIBLE + } else { + binding.addLayerButton.isEnabled = true + binding.layers.visibility = View.VISIBLE + binding.progressIndicator.visibility = View.GONE + } + } + + viewModel.layersToImport.observe(this) { layersToImport -> + val adapter = OfflineMapLayersImporterAdapter(layersToImport.value.layers) + binding.layers.setAdapter(adapter) + + if (!layersToImport.isConsumed()) { + layersToImport.consume() + + if (layersToImport.value.numberOfSelectedLayers == layersToImport.value.numberOfUnsupportedLayers) { + dismiss() + showNoSupportedLayersWarning(layersToImport.value.numberOfUnsupportedLayers) + } else if (layersToImport.value.numberOfUnsupportedLayers > 0) { + showSomeUnsupportedLayersWarning(layersToImport.value.numberOfUnsupportedLayers) + } + } + } + } + + override fun onCloseClicked() = Unit + + override fun onBackPressed() { + dismiss() + } + + override fun getToolbar(): Toolbar { + return OfflineMapLayersImporterBinding.bind(requireView()).toolbar + } + + private fun showNoSupportedLayersWarning(numberOfLayers: Int) { + MaterialAlertDialogBuilder(requireActivity()) + .setTitle( + requireActivity().getLocalizedQuantityString( + R.plurals.non_mbtiles_files_selected_title, + numberOfLayers, + numberOfLayers + ) + ) + .setMessage(requireActivity().getLocalizedString(R.string.all_non_mbtiles_files_selected_message)) + .setPositiveButton(R.string.ok, null) + .create() + .show() + } + + private fun showSomeUnsupportedLayersWarning(numberOfLayers: Int) { + MaterialAlertDialogBuilder(requireActivity()) + .setTitle( + requireActivity().getLocalizedQuantityString( + R.plurals.non_mbtiles_files_selected_title, + numberOfLayers, + numberOfLayers + ) + ) + .setMessage(requireActivity().getLocalizedString(R.string.some_non_mbtiles_files_selected_message)) + .setPositiveButton(R.string.ok, null) + .create() + .show() + } +} diff --git a/maps/src/main/java/org/odk/collect/maps/layers/OfflineMapLayersImporterAdapter.kt b/maps/src/main/java/org/odk/collect/maps/layers/OfflineMapLayersImporterAdapter.kt new file mode 100644 index 00000000000..d011f7e8d61 --- /dev/null +++ b/maps/src/main/java/org/odk/collect/maps/layers/OfflineMapLayersImporterAdapter.kt @@ -0,0 +1,24 @@ +package org.odk.collect.maps.layers + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import org.odk.collect.maps.databinding.OfflineMapLayersImporterItemBinding + +class OfflineMapLayersImporterAdapter( + private val layers: List, +) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = OfflineMapLayersImporterItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.binding.layerName.text = layers[position].name + } + + override fun getItemCount() = layers.size + + class ViewHolder(val binding: OfflineMapLayersImporterItemBinding) : RecyclerView.ViewHolder(binding.root) +} diff --git a/maps/src/main/java/org/odk/collect/maps/layers/OfflineMapLayersPicker.kt b/maps/src/main/java/org/odk/collect/maps/layers/OfflineMapLayersPicker.kt new file mode 100644 index 00000000000..9e5534ecba6 --- /dev/null +++ b/maps/src/main/java/org/odk/collect/maps/layers/OfflineMapLayersPicker.kt @@ -0,0 +1,233 @@ +package org.odk.collect.maps.layers + +import android.content.Context +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.map +import androidx.lifecycle.viewmodel.viewModelFactory +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.odk.collect.androidshared.livedata.LiveDataUtils +import org.odk.collect.androidshared.ui.DialogFragmentUtils +import org.odk.collect.androidshared.ui.FragmentFactoryBuilder +import org.odk.collect.androidshared.ui.addOnClickListener +import org.odk.collect.async.Scheduler +import org.odk.collect.lists.selects.MultiSelectViewModel +import org.odk.collect.lists.selects.SelectItem +import org.odk.collect.lists.selects.SingleSelectViewModel +import org.odk.collect.maps.databinding.OfflineMapLayersPickerBinding +import org.odk.collect.settings.SettingsProvider +import org.odk.collect.settings.keys.ProjectKeys +import org.odk.collect.strings.localization.getLocalizedString +import org.odk.collect.webpage.ExternalWebPageHelper + +class OfflineMapLayersPicker( + registry: ActivityResultRegistry, + private val referenceLayerRepository: ReferenceLayerRepository, + private val scheduler: Scheduler, + private val settingsProvider: SettingsProvider, + private val externalWebPageHelper: ExternalWebPageHelper +) : BottomSheetDialogFragment(), + OfflineMapLayersPickerAdapter.OfflineMapLayersPickerAdapterInterface { + + private val sharedViewModel: OfflineMapLayersViewModel by activityViewModels { + object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return OfflineMapLayersViewModel( + referenceLayerRepository, + scheduler, + settingsProvider + ) as T + } + } + } + private val expandedStateViewModel: MultiSelectViewModel<*> by viewModels { + MultiSelectViewModel.Factory() + } + + private val checkedStateViewModel: SingleSelectViewModel by viewModels { + viewModelFactory { + addInitializer(SingleSelectViewModel::class) { + SingleSelectViewModel( + settingsProvider.getUnprotectedSettings().getString(ProjectKeys.KEY_REFERENCE_LAYER), + sharedViewModel.existingLayers.map { + it.map { layer -> + SelectItem(layer.id, layer) + } + } + ) + } + } + } + + private val getLayers = registerForActivityResult(ActivityResultContracts.GetMultipleContents(), registry) { uris -> + if (uris.isNotEmpty()) { + sharedViewModel.loadLayersToImport(uris, requireContext()) + DialogFragmentUtils.showIfNotShowing( + OfflineMapLayersImporter::class.java, + childFragmentManager + ) + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + sharedViewModel.loadExistingLayers() + } + + override fun onCreate(savedInstanceState: Bundle?) { + childFragmentManager.fragmentFactory = FragmentFactoryBuilder() + .forClass(OfflineMapLayersImporter::class) { + OfflineMapLayersImporter(referenceLayerRepository, scheduler, settingsProvider) + } + .build() + + super.onCreate(savedInstanceState) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val binding = OfflineMapLayersPickerBinding.inflate(inflater) + + binding.mbtilesInfoGroup.addOnClickListener { + externalWebPageHelper.openWebPageInCustomTab( + requireActivity(), + Uri.parse("https://docs.getodk.org/collect-offline-maps/#transferring-offline-tilesets-to-devices") + ) + } + + binding.addLayer.setOnClickListener { + getLayers.launch("*/*") + } + + binding.cancel.setOnClickListener { + dismiss() + } + + binding.save.setOnClickListener { + sharedViewModel.saveCheckedLayer(checkedStateViewModel.getSelected().value) + dismiss() + } + + if (sharedViewModel.layersToImport.value?.value == null) { + DialogFragmentUtils.dismissDialog( + OfflineMapLayersImporter::class.java, + childFragmentManager + ) + } + + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = OfflineMapLayersPickerBinding.bind(view) + + sharedViewModel.trackableWorker.isWorking.observe(this) { isLoading -> + if (isLoading) { + binding.progressIndicator.visibility = View.VISIBLE + binding.layers.visibility = View.GONE + binding.save.isEnabled = false + } else { + binding.progressIndicator.visibility = View.GONE + binding.layers.visibility = View.VISIBLE + binding.save.isEnabled = true + } + } + + val adapter = OfflineMapLayersPickerAdapter(this) + binding.layers.setAdapter(adapter) + LiveDataUtils.zip3( + sharedViewModel.existingLayers, + checkedStateViewModel.getSelected(), + expandedStateViewModel.getSelected() + ).observe(this) { (layers, checkedLayerId, expandedLayerIds) -> + updateAdapter(layers, checkedLayerId, expandedLayerIds.toList(), adapter) + } + } + + override fun onStart() { + super.onStart() + try { + BottomSheetBehavior.from(requireView().parent as View).apply { + maxWidth = ViewGroup.LayoutParams.MATCH_PARENT + } + } catch (e: Exception) { + // ignore + } + } + + override fun onLayerChecked(layerId: String?) { + if (layerId != null) { + checkedStateViewModel.select(layerId) + } else { + checkedStateViewModel.clear() + } + } + + override fun onLayerToggled(layerId: String) { + expandedStateViewModel.toggle(layerId) + } + + override fun onDeleteLayer(layerItem: CheckableReferenceLayer) { + MaterialAlertDialogBuilder(requireActivity()) + .setMessage( + requireActivity().getLocalizedString( + org.odk.collect.strings.R.string.delete_layer_confirmation_message, + layerItem.name + ) + ) + .setPositiveButton(org.odk.collect.strings.R.string.delete_layer) { _, _ -> + sharedViewModel.deleteLayer(layerItem.id!!) + } + .setNegativeButton(org.odk.collect.strings.R.string.cancel, null) + .create() + .show() + } + + private fun updateAdapter( + layers: List?, + checkedLayerId: String?, + expandedLayerIds: List, + adapter: OfflineMapLayersPickerAdapter + ) { + if (layers == null) { + return + } + + val newData = mutableListOf( + CheckableReferenceLayer( + null, + null, + requireContext().getLocalizedString(org.odk.collect.strings.R.string.none), + checkedLayerId == null, + false + ) + ) + + newData.addAll( + layers.map { + CheckableReferenceLayer( + it.id, + it.file, + it.name, + checkedLayerId == it.id, + expandedLayerIds.contains(it.id) + ) + } + ) + adapter.setData(newData) + } +} diff --git a/maps/src/main/java/org/odk/collect/maps/layers/OfflineMapLayersPickerAdapter.kt b/maps/src/main/java/org/odk/collect/maps/layers/OfflineMapLayersPickerAdapter.kt new file mode 100644 index 00000000000..6f5936f70cf --- /dev/null +++ b/maps/src/main/java/org/odk/collect/maps/layers/OfflineMapLayersPickerAdapter.kt @@ -0,0 +1,88 @@ +package org.odk.collect.maps.layers + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.core.view.isInvisible +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import org.odk.collect.androidshared.ui.addOnClickListener +import org.odk.collect.maps.databinding.OfflineMapLayersPickerItemBinding +import java.io.File + +class OfflineMapLayersPickerAdapter( + private val listener: OfflineMapLayersPickerAdapterInterface +) : RecyclerView.Adapter() { + interface OfflineMapLayersPickerAdapterInterface { + fun onLayerChecked(layerId: String?) + fun onLayerToggled(layerId: String) + fun onDeleteLayer(layerItem: CheckableReferenceLayer) + } + + private val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: CheckableReferenceLayer, newItem: CheckableReferenceLayer): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: CheckableReferenceLayer, newItem: CheckableReferenceLayer): Boolean { + return oldItem == newItem + } + } + private val asyncListDiffer = AsyncListDiffer(this, diffUtil) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = OfflineMapLayersPickerItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val layer = asyncListDiffer.currentList[position] + + holder.binding.radioButton.setChecked(layer.isChecked) + holder.binding.title.text = layer.name + holder.binding.path.text = layer.file?.absolutePath + holder.binding.arrow.isInvisible = layer.id == null + + if (layer.isExpanded) { + holder.binding.arrow.setImageDrawable(ContextCompat.getDrawable(holder.binding.root.context, org.odk.collect.icons.R.drawable.ic_baseline_collapse_24)) + holder.binding.path.visibility = View.VISIBLE + holder.binding.deleteLayer.visibility = View.VISIBLE + } else { + holder.binding.arrow.setImageDrawable(ContextCompat.getDrawable(holder.binding.root.context, org.odk.collect.icons.R.drawable.ic_baseline_expand_24)) + holder.binding.path.visibility = View.GONE + holder.binding.deleteLayer.visibility = View.GONE + } + + listOf(holder.binding.radioButton, holder.binding.title, holder.binding.path).addOnClickListener { + listener.onLayerChecked(layer.id) + } + + holder.binding.arrow.setOnClickListener { + if (layer.id != null) { + listener.onLayerToggled(layer.id) + } + } + + holder.binding.deleteLayer.setOnClickListener { + listener.onDeleteLayer(layer) + } + } + + override fun getItemCount() = asyncListDiffer.currentList.size + + fun setData(layers: List) { + asyncListDiffer.submitList(layers) + } + + class ViewHolder(val binding: OfflineMapLayersPickerItemBinding) : RecyclerView.ViewHolder(binding.root) +} + +data class CheckableReferenceLayer( + val id: String?, + val file: File?, + val name: String, + val isChecked: Boolean, + val isExpanded: Boolean +) diff --git a/maps/src/main/java/org/odk/collect/maps/layers/OfflineMapLayersViewModel.kt b/maps/src/main/java/org/odk/collect/maps/layers/OfflineMapLayersViewModel.kt new file mode 100644 index 00000000000..70e71523501 --- /dev/null +++ b/maps/src/main/java/org/odk/collect/maps/layers/OfflineMapLayersViewModel.kt @@ -0,0 +1,123 @@ +package org.odk.collect.maps.layers + +import android.content.Context +import android.net.Uri +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import org.odk.collect.analytics.Analytics +import org.odk.collect.androidshared.async.TrackableWorker +import org.odk.collect.androidshared.data.Consumable +import org.odk.collect.androidshared.system.copyToFile +import org.odk.collect.androidshared.system.getFileExtension +import org.odk.collect.androidshared.system.getFileName +import org.odk.collect.async.Scheduler +import org.odk.collect.maps.AnalyticsEvents +import org.odk.collect.settings.SettingsProvider +import org.odk.collect.settings.keys.ProjectKeys +import org.odk.collect.shared.TempFiles +import java.io.File + +class OfflineMapLayersViewModel( + private val referenceLayerRepository: ReferenceLayerRepository, + scheduler: Scheduler, + private val settingsProvider: SettingsProvider +) : ViewModel() { + val trackableWorker = TrackableWorker(scheduler) + + private val _existingLayers = MutableLiveData>() + val existingLayers: LiveData> = _existingLayers + + private val _layersToImport = MutableLiveData>() + val layersToImport: LiveData> = _layersToImport + + private lateinit var tempLayersDir: File + + fun loadExistingLayers() { + trackableWorker.immediate( + background = { + val layers = referenceLayerRepository.getAll().sortedBy { it.name } + _existingLayers.postValue(layers) + } + ) + } + + fun loadLayersToImport(uris: List, context: Context) { + trackableWorker.immediate( + background = { + tempLayersDir = TempFiles.createTempDir().also { + it.deleteOnExit() + } + val layers = mutableListOf() + uris.forEach { uri -> + if (uri.getFileExtension(context) == MbtilesFile.FILE_EXTENSION) { + uri.getFileName(context)?.let { fileName -> + val layerFile = File(tempLayersDir, fileName).also { file -> + uri.copyToFile(context, file) + } + layers.add(ReferenceLayer(layerFile.absolutePath, layerFile, MbtilesFile.readName(layerFile) ?: layerFile.name)) + } + } + } + _layersToImport.postValue( + Consumable( + LayersToImport( + uris.size, + uris.size - layers.size, + layers.sortedBy { it.name } + ) + ) + ) + } + ) + } + + fun importNewLayers(shared: Boolean) { + trackableWorker.immediate( + background = { + val layers = tempLayersDir.listFiles() + logImport(layers) + + layers?.forEach { + referenceLayerRepository.addLayer(it, shared) + } + tempLayersDir.delete() + }, + foreground = { + loadExistingLayers() + } + ) + } + + fun saveCheckedLayer(layerId: String?) { + settingsProvider.getUnprotectedSettings().save(ProjectKeys.KEY_REFERENCE_LAYER, layerId) + } + + fun deleteLayer(layerId: String) { + trackableWorker.immediate { + if (settingsProvider.getUnprotectedSettings().getString(ProjectKeys.KEY_REFERENCE_LAYER) == layerId) { + settingsProvider.getUnprotectedSettings().save(ProjectKeys.KEY_REFERENCE_LAYER, null) + } + + referenceLayerRepository.delete(layerId) + _existingLayers.postValue(_existingLayers.value?.filter { it.id != layerId }) + } + } + + private fun logImport(layers: Array?) { + val count = layers?.size ?: return + val event = when { + count == 1 -> AnalyticsEvents.IMPORT_LAYER_SINGLE + count <= 5 -> AnalyticsEvents.IMPORT_LAYER_FEW + else -> AnalyticsEvents.IMPORT_LAYER_MANY + } + + Analytics.log(event) + } + + data class LayersToImport( + val numberOfSelectedLayers: Int, + val numberOfUnsupportedLayers: Int, + val layers: List + ) +} diff --git a/maps/src/main/java/org/odk/collect/maps/layers/ReferenceLayerRepository.kt b/maps/src/main/java/org/odk/collect/maps/layers/ReferenceLayerRepository.kt index 926e925b6f2..a7520708d7e 100644 --- a/maps/src/main/java/org/odk/collect/maps/layers/ReferenceLayerRepository.kt +++ b/maps/src/main/java/org/odk/collect/maps/layers/ReferenceLayerRepository.kt @@ -6,6 +6,8 @@ interface ReferenceLayerRepository { fun getAll(): List fun get(id: String): ReferenceLayer? + fun addLayer(file: File, shared: Boolean) + fun delete(id: String) } -data class ReferenceLayer(val id: String, val file: File) +data class ReferenceLayer(val id: String, val file: File, val name: String) diff --git a/maps/src/main/java/org/odk/collect/maps/markers/MarkerIconDescription.kt b/maps/src/main/java/org/odk/collect/maps/markers/MarkerIconDescription.kt index 85e693d3394..3424367b8ea 100644 --- a/maps/src/main/java/org/odk/collect/maps/markers/MarkerIconDescription.kt +++ b/maps/src/main/java/org/odk/collect/maps/markers/MarkerIconDescription.kt @@ -1,6 +1,6 @@ package org.odk.collect.maps.markers -import android.graphics.Color +import org.odk.collect.androidshared.utils.toColorInt import org.odk.collect.shared.strings.StringUtils import java.util.Locale @@ -9,23 +9,7 @@ class MarkerIconDescription @JvmOverloads constructor( private val color: String? = null, private val symbol: String? = null ) { - fun getColor(): Int? = try { - color?.let { - var sanitizedColor = if (color.startsWith("#")) { - color - } else { - "#$color" - } - - if (sanitizedColor.length == 4) { - sanitizedColor = shorthandToLonghandHexColor(sanitizedColor) - } - - Color.parseColor(sanitizedColor) - } - } catch (e: Throwable) { - null - } + fun getColor(): Int? = color?.toColorInt() fun getSymbol(): String? = symbol?.let { if (it.isBlank()) { @@ -34,13 +18,4 @@ class MarkerIconDescription @JvmOverloads constructor( StringUtils.firstCharacterOrEmoji(it).uppercase(Locale.US) } } - - private fun shorthandToLonghandHexColor(shorthandColor: String): String { - var longHandColor = "" - shorthandColor.substring(1).map { - longHandColor += it.toString() + it.toString() - } - - return "#$longHandColor" - } } diff --git a/maps/src/main/res/layout/offline_map_layers_importer.xml b/maps/src/main/res/layout/offline_map_layers_importer.xml new file mode 100644 index 00000000000..e6a4bdfedf5 --- /dev/null +++ b/maps/src/main/res/layout/offline_map_layers_importer.xml @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/maps/src/main/res/layout/offline_map_layers_importer_item.xml b/maps/src/main/res/layout/offline_map_layers_importer_item.xml new file mode 100644 index 00000000000..c564a94f6a4 --- /dev/null +++ b/maps/src/main/res/layout/offline_map_layers_importer_item.xml @@ -0,0 +1,26 @@ + + + + + + + + \ No newline at end of file diff --git a/maps/src/main/res/layout/offline_map_layers_picker.xml b/maps/src/main/res/layout/offline_map_layers_picker.xml new file mode 100644 index 00000000000..27413f894d7 --- /dev/null +++ b/maps/src/main/res/layout/offline_map_layers_picker.xml @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/maps/src/main/res/layout/offline_map_layers_picker_item.xml b/maps/src/main/res/layout/offline_map_layers_picker_item.xml new file mode 100644 index 00000000000..266f0bcd04f --- /dev/null +++ b/maps/src/main/res/layout/offline_map_layers_picker_item.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + diff --git a/maps/src/test/java/org/odk/collect/maps/LineDescriptionTest.kt b/maps/src/test/java/org/odk/collect/maps/LineDescriptionTest.kt new file mode 100644 index 00000000000..3ce21facdf6 --- /dev/null +++ b/maps/src/test/java/org/odk/collect/maps/LineDescriptionTest.kt @@ -0,0 +1,58 @@ +package org.odk.collect.maps + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LineDescriptionTest { + @Test + fun `getStrokeWidth returns the default value when the passed one is null`() { + val lineDescription = LineDescription(strokeWidth = null) + assertThat(lineDescription.getStrokeWidth(), equalTo(MapConsts.DEFAULT_STROKE_WIDTH)) + } + + @Test + fun `getStrokeWidth returns the default value when the passed one is invalid`() { + val lineDescription = LineDescription(strokeWidth = "blah") + assertThat(lineDescription.getStrokeWidth(), equalTo(MapConsts.DEFAULT_STROKE_WIDTH)) + } + + @Test + fun `getStrokeWidth returns the default value when the passed one is not greater than or equal to zero`() { + val lineDescription = LineDescription(strokeWidth = "-1") + assertThat(lineDescription.getStrokeWidth(), equalTo(MapConsts.DEFAULT_STROKE_WIDTH)) + } + + @Test + fun `getStrokeWidth returns custom value when the passed one is a valid int number`() { + val lineDescription = LineDescription(strokeWidth = "10") + assertThat(lineDescription.getStrokeWidth(), equalTo(10f)) + } + + @Test + fun `getStrokeWidth returns custom value when the passed one is a valid float number`() { + val lineDescription = LineDescription(strokeWidth = "10.5") + assertThat(lineDescription.getStrokeWidth(), equalTo(10.5f)) + } + + @Test + fun `getStrokeColor returns the default color when the passed one is null`() { + val lineDescription = LineDescription(strokeColor = null) + assertThat(lineDescription.getStrokeColor(), equalTo(MapConsts.DEFAULT_STROKE_COLOR)) + } + + @Test + fun `getStrokeColor returns the default color when the passed one is invalid`() { + val lineDescription = LineDescription(strokeColor = "blah") + assertThat(lineDescription.getStrokeColor(), equalTo(MapConsts.DEFAULT_STROKE_COLOR)) + } + + @Test + fun `getStrokeColor returns custom color when it is valid`() { + val lineDescription = LineDescription(strokeColor = "#aaccee") + assertThat(lineDescription.getStrokeColor(), equalTo(-5583634)) + } +} diff --git a/maps/src/test/java/org/odk/collect/maps/MarkerIconDescriptionTest.kt b/maps/src/test/java/org/odk/collect/maps/MarkerIconDescriptionTest.kt index f70b64cf154..94ba04b17a2 100644 --- a/maps/src/test/java/org/odk/collect/maps/MarkerIconDescriptionTest.kt +++ b/maps/src/test/java/org/odk/collect/maps/MarkerIconDescriptionTest.kt @@ -10,49 +10,12 @@ import org.odk.collect.maps.markers.MarkerIconDescription @RunWith(AndroidJUnit4::class) class MarkerIconDescriptionTest { - @Test fun `return null when color is null`() { val markerIconDescription = MarkerIconDescription(0, null) assertThat(markerIconDescription.getColor(), `is`(nullValue())) } - @Test - fun `return null when color is empty`() { - val markerIconDescription = MarkerIconDescription(0, "") - assertThat(markerIconDescription.getColor(), `is`(nullValue())) - } - - @Test - fun `return null when color is invalid`() { - val markerIconDescription = MarkerIconDescription(0, "qwerty") - assertThat(markerIconDescription.getColor(), `is`(nullValue())) - } - - @Test - fun `return color int for valid hex color with # prefix`() { - val markerIconDescription = MarkerIconDescription(0, "#aaccee") - assertThat(markerIconDescription.getColor(), `is`(-5583634)) - } - - @Test - fun `return color int for valid hex color without # prefix`() { - val markerIconDescription = MarkerIconDescription(0, "aaccee") - assertThat(markerIconDescription.getColor(), `is`(-5583634)) - } - - @Test - fun `return color int for valid shorthand hex color with # prefix`() { - val markerIconDescription = MarkerIconDescription(0, "#ace") - assertThat(markerIconDescription.getColor(), `is`(-5583634)) - } - - @Test - fun `return color int for valid shorthand hex color without # prefix`() { - val markerIconDescription = MarkerIconDescription(0, "ace") - assertThat(markerIconDescription.getColor(), `is`(-5583634)) - } - @Test fun `return null when symbol is null`() { val markerIconDescription = MarkerIconDescription(0, symbol = null) diff --git a/maps/src/test/java/org/odk/collect/maps/PolygonDescriptionTest.kt b/maps/src/test/java/org/odk/collect/maps/PolygonDescriptionTest.kt new file mode 100644 index 00000000000..52ea8cd1f86 --- /dev/null +++ b/maps/src/test/java/org/odk/collect/maps/PolygonDescriptionTest.kt @@ -0,0 +1,76 @@ +package org.odk.collect.maps + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PolygonDescriptionTest { + @Test + fun `getStrokeWidth returns the default value when the passed one is null`() { + val polygonDescription = PolygonDescription(strokeWidth = null) + assertThat(polygonDescription.getStrokeWidth(), equalTo(MapConsts.DEFAULT_STROKE_WIDTH)) + } + + @Test + fun `getStrokeWidth returns the default value when the passed one is invalid`() { + val polygonDescription = PolygonDescription(strokeWidth = "blah") + assertThat(polygonDescription.getStrokeWidth(), equalTo(MapConsts.DEFAULT_STROKE_WIDTH)) + } + + @Test + fun `getStrokeWidth returns the default value when the passed one is not greater than or equal to zero`() { + val polygonDescription = PolygonDescription(strokeWidth = "-1") + assertThat(polygonDescription.getStrokeWidth(), equalTo(MapConsts.DEFAULT_STROKE_WIDTH)) + } + + @Test + fun `getStrokeWidth returns custom value when the passed one is a valid int number`() { + val polygonDescription = PolygonDescription(strokeWidth = "10") + assertThat(polygonDescription.getStrokeWidth(), equalTo(10f)) + } + + @Test + fun `getStrokeWidth returns custom value when the passed one is a valid float number`() { + val polygonDescription = PolygonDescription(strokeWidth = "10.5") + assertThat(polygonDescription.getStrokeWidth(), equalTo(10.5f)) + } + + @Test + fun `getStrokeColor returns the default color when the passed one is null`() { + val polygonDescription = PolygonDescription(strokeColor = null) + assertThat(polygonDescription.getStrokeColor(), equalTo(MapConsts.DEFAULT_STROKE_COLOR)) + } + + @Test + fun `getStrokeColor returns the default color when the passed one is invalid`() { + val polygonDescription = PolygonDescription(strokeColor = "blah") + assertThat(polygonDescription.getStrokeColor(), equalTo(MapConsts.DEFAULT_STROKE_COLOR)) + } + + @Test + fun `getStrokeColor returns custom color when it is valid`() { + val polygonDescription = PolygonDescription(strokeColor = "#aaccee") + assertThat(polygonDescription.getStrokeColor(), equalTo(-5583634)) + } + + @Test + fun `getFillColor returns the default color when the passed one is null`() { + val polygonDescription = PolygonDescription(fillColor = null) + assertThat(polygonDescription.getFillColor(), equalTo(1157562368)) + } + + @Test + fun `getFillColor returns the default color when the passed one is invalid`() { + val polygonDescription = PolygonDescription(fillColor = "blah") + assertThat(polygonDescription.getFillColor(), equalTo(1157562368)) + } + + @Test + fun `getFillColor returns custom color when it is valid`() { + val polygonDescription = PolygonDescription(fillColor = "#aaccee") + assertThat(polygonDescription.getFillColor(), equalTo(1152044270)) + } +} diff --git a/maps/src/test/java/org/odk/collect/maps/RobolectricApplication.kt b/maps/src/test/java/org/odk/collect/maps/RobolectricApplication.kt new file mode 100644 index 00000000000..4b73f7b2339 --- /dev/null +++ b/maps/src/test/java/org/odk/collect/maps/RobolectricApplication.kt @@ -0,0 +1,13 @@ +package org.odk.collect.maps + +import android.app.Application +import org.odk.collect.androidshared.ui.multiclicksafe.MultiClickGuard + +class RobolectricApplication : Application() { + override fun onCreate() { + super.onCreate() + + // We don't want any clicks to be blocked + MultiClickGuard.test = true + } +} diff --git a/maps/src/test/java/org/odk/collect/maps/layers/DirectoryReferenceLayerRepositoryTest.kt b/maps/src/test/java/org/odk/collect/maps/layers/DirectoryReferenceLayerRepositoryTest.kt index b5249c96580..0a7a3049213 100644 --- a/maps/src/test/java/org/odk/collect/maps/layers/DirectoryReferenceLayerRepositoryTest.kt +++ b/maps/src/test/java/org/odk/collect/maps/layers/DirectoryReferenceLayerRepositoryTest.kt @@ -1,43 +1,70 @@ package org.odk.collect.maps.layers +import android.content.Context +import android.os.Bundle +import androidx.preference.Preference import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.contains import org.hamcrest.Matchers.containsInAnyOrder import org.hamcrest.Matchers.equalTo import org.junit.Test +import org.odk.collect.maps.MapConfigurator import org.odk.collect.shared.TempFiles +import org.odk.collect.shared.settings.Settings +import java.io.File class DirectoryReferenceLayerRepositoryTest { + private val sharedLayersDir = TempFiles.createTempDir() + private val projectLayersDir = TempFiles.createTempDir() + private var mapConfigurator = StubMapConfigurator() + private val repository = DirectoryReferenceLayerRepository( + sharedLayersDir.absolutePath, + projectLayersDir.absolutePath + ) { mapConfigurator } @Test - fun getAll_returnsAllLayersInTheDirectory() { - val dir = TempFiles.createTempDir() - val file1 = TempFiles.createTempFile(dir) - val file2 = TempFiles.createTempFile(dir) + fun getAll_returnsAllSupportedLayersInTheDirectory() { + val file1 = TempFiles.createTempFile(sharedLayersDir) + val file2 = TempFiles.createTempFile(sharedLayersDir) + val file3 = TempFiles.createTempFile(sharedLayersDir) + mapConfigurator.apply { + addFile(file1, true, file1.name) + addFile(file2, false, file2.name) + addFile(file3, true, file3.name) + } - val repository = DirectoryReferenceLayerRepository(dir.absolutePath) - assertThat(repository.getAll().map { it.file }, containsInAnyOrder(file1, file2)) + assertThat(repository.getAll().map { it.file }, containsInAnyOrder(file1, file3)) } @Test - fun getAll_returnsAllLayersInSubDirectories() { - val dir1 = TempFiles.createTempDir() - val dir2 = TempFiles.createTempDir(dir1) - val file1 = TempFiles.createTempFile(dir2) - val file2 = TempFiles.createTempFile(dir2) + fun getAll_returnsAllSupportedLayersInSubDirectories() { + val subDir = TempFiles.createTempDir(sharedLayersDir) + val file1 = TempFiles.createTempFile(subDir) + val file2 = TempFiles.createTempFile(subDir) + val file3 = TempFiles.createTempFile(subDir) + mapConfigurator.apply { + addFile(file1, true, file1.name) + addFile(file2, false, file2.name) + addFile(file3, true, file3.name) + } - val repository = DirectoryReferenceLayerRepository(dir1.absolutePath) - assertThat(repository.getAll().map { it.file }, containsInAnyOrder(file1, file2)) + assertThat(repository.getAll().map { it.file }, containsInAnyOrder(file1, file3)) } @Test - fun getAll_withMultipleDirectories_returnsAllLayersInAllDirectories() { - val dir1 = TempFiles.createTempDir() - val dir2 = TempFiles.createTempDir() - val file1 = TempFiles.createTempFile(dir1) - val file2 = TempFiles.createTempFile(dir2) + fun getAll_withMultipleDirectories_returnsAllSupportedLayersInAllDirectories() { + val file1 = TempFiles.createTempFile(sharedLayersDir) + val file2 = TempFiles.createTempFile(sharedLayersDir) + val file3 = TempFiles.createTempFile(projectLayersDir) + val file4 = TempFiles.createTempFile(projectLayersDir) + mapConfigurator.apply { + addFile(file1, true, file1.name) + addFile(file2, false, file2.name) + addFile(file3, true, file3.name) + addFile(file4, false, file4.name) + } - val repository = DirectoryReferenceLayerRepository(dir1.absolutePath, dir2.absolutePath) - assertThat(repository.getAll().map { it.file }, containsInAnyOrder(file1, file2)) + assertThat(repository.getAll().map { it.file }, containsInAnyOrder(file1, file3)) } /** @@ -46,60 +73,205 @@ class DirectoryReferenceLayerRepositoryTest { * layer directories) will be returned. */ @Test - fun getAll_withMultipleDirectoriesWithFilesWithTheSameRelativePath_onlyReturnsTheFileFromTheFirstDirectory() { - val dir1 = TempFiles.createTempDir() - val dir2 = TempFiles.createTempDir() - val file1 = TempFiles.createTempFile(dir1, "blah", ".temp") - TempFiles.createTempFile(dir2, "blah", ".temp") + fun getAll_withMultipleDirectoriesWithFilesWithTheSameRelativePath_onlyReturnsTheSupportedFileFromTheFirstDirectory() { + val file1 = TempFiles.createTempFile(sharedLayersDir, "blah", ".temp") + val file2 = TempFiles.createTempFile(projectLayersDir, "blah", ".temp") + mapConfigurator.apply { + addFile(file1, true, file1.name) + addFile(file2, true, file2.name) + } - val repository = DirectoryReferenceLayerRepository(dir1.absolutePath, dir2.absolutePath) assertThat(repository.getAll().map { it.file }, containsInAnyOrder(file1)) } @Test - fun get_returnsLayer() { - val dir = TempFiles.createTempDir() - TempFiles.createTempFile(dir) - val file2 = TempFiles.createTempFile(dir) + fun getAllAlwaysUsesMapConfiguratorThatRepresentsTheCurrentConfiguration() { + val file = TempFiles.createTempFile(sharedLayersDir) + + mapConfigurator.apply { + addFile(file, false, file.name) + } + + assertThat(repository.getAll().isEmpty(), equalTo(true)) + + mapConfigurator = StubMapConfigurator().apply { + addFile(file, true, file.name) + } + + assertThat(repository.getAll().isEmpty(), equalTo(false)) + } + + @Test + fun get_returnsProperLayer() { + val file1 = TempFiles.createTempFile(sharedLayersDir) + val file2 = TempFiles.createTempFile(sharedLayersDir) + mapConfigurator.apply { + addFile(file1, true, file1.name) + addFile(file2, true, file2.name) + } - val repository = DirectoryReferenceLayerRepository(dir.absolutePath) val file2Layer = repository.getAll().first { it.file == file2 } assertThat(repository.get(file2Layer.id)!!.file, equalTo(file2)) } + @Test + fun get_returnsNullIfLayerIsNotSupported() { + val file = TempFiles.createTempFile(sharedLayersDir) + mapConfigurator.apply { + addFile(file, false, file.name) + } + + val fileId = repository.getIdForFile(sharedLayersDir.absolutePath, file) + assertThat(repository.get(fileId), equalTo(null)) + } + @Test fun get_withMultipleDirectories_returnsLayer() { - val dir1 = TempFiles.createTempDir() - val dir2 = TempFiles.createTempDir() - TempFiles.createTempFile(dir1) - val file2 = TempFiles.createTempFile(dir2) + val file1 = TempFiles.createTempFile(sharedLayersDir) + val file2 = TempFiles.createTempFile(projectLayersDir) + mapConfigurator.apply { + addFile(file1, true, file1.name) + addFile(file2, true, file2.name) + } - val repository = DirectoryReferenceLayerRepository(dir1.absolutePath, dir2.absolutePath) val file2Layer = repository.getAll().first { it.file == file2 } assertThat(repository.get(file2Layer.id)!!.file, equalTo(file2)) } @Test fun get_withMultipleDirectoriesWithFilesWithTheSameRelativePath_returnsLayerFromFirstDirectory() { - val dir1 = TempFiles.createTempDir() - val dir2 = TempFiles.createTempDir() - val file1 = TempFiles.createTempFile(dir1, "blah", ".temp") - TempFiles.createTempFile(dir2, "blah", ".temp") + val file1 = TempFiles.createTempFile(sharedLayersDir, "blah", ".temp") + val file2 = TempFiles.createTempFile(projectLayersDir, "blah", ".temp") + mapConfigurator.apply { + addFile(file1, true, file1.name) + addFile(file2, true, file2.name) + } - val repository = DirectoryReferenceLayerRepository(dir1.absolutePath, dir2.absolutePath) val layerId = repository.getAll().first().id assertThat(repository.get(layerId)!!.file, equalTo(file1)) } @Test fun get_whenFileDoesNotExist_returnsNull() { - val dir = TempFiles.createTempDir() - val file = TempFiles.createTempFile(dir) + val file = TempFiles.createTempFile(sharedLayersDir) + mapConfigurator.apply { + addFile(file, true, file.name) + } - val repository = DirectoryReferenceLayerRepository(dir.absolutePath) val fileLayer = repository.getAll().first { it.file == file } file.delete() assertThat(repository.get(fileLayer.id), equalTo(null)) } + + @Test + fun get_returnsLayerWithCorrectName() { + val file = TempFiles.createTempFile(sharedLayersDir) + + mapConfigurator.apply { + addFile(file, true, file.name) + } + + val fileLayer = repository.getAll().first { it.file == file } + + assertThat(repository.get(fileLayer.id)!!.name, equalTo(file.name)) + } + + @Test + fun getAlwaysUsesMapConfiguratorThatRepresentsTheCurrentConfiguration() { + val file = TempFiles.createTempFile(sharedLayersDir) + + mapConfigurator.apply { + addFile(file, false, file.name) + } + + val fileId = repository.getIdForFile(sharedLayersDir.absolutePath, file) + assertThat(repository.get(fileId), equalTo(null)) + + mapConfigurator = StubMapConfigurator().apply { + addFile(file, true, file.name) + } + + assertThat(repository.get(fileId)!!.file, equalTo(file)) + } + + @Test + fun addLayer_movesFileToTheSharedLayersDir_whenSharedIsTrue() { + val file = TempFiles.createTempFile().also { + it.writeText("blah") + } + + repository.addLayer(file, true) + + assertThat(sharedLayersDir.listFiles().size, equalTo(1)) + assertThat(sharedLayersDir.listFiles()[0].name, equalTo(file.name)) + assertThat(sharedLayersDir.listFiles()[0].readText(), equalTo("blah")) + assertThat(projectLayersDir.listFiles().size, equalTo(0)) + } + + @Test + fun addLayer_movesFileToTheProjectLayersDir_whenSharedIsFalse() { + val file = TempFiles.createTempFile().also { + it.writeText("blah") + } + + repository.addLayer(file, false) + + assertThat(sharedLayersDir.listFiles().size, equalTo(0)) + assertThat(projectLayersDir.listFiles().size, equalTo(1)) + assertThat(projectLayersDir.listFiles()[0].name, equalTo(file.name)) + assertThat(projectLayersDir.listFiles()[0].readText(), equalTo("blah")) + } + + @Test + fun delete_deletesLayerWithId() { + val file1 = TempFiles.createTempFile(sharedLayersDir) + val file2 = TempFiles.createTempFile(sharedLayersDir) + + mapConfigurator.apply { + addFile(file1, true, file2.name) + addFile(file2, true, file2.name) + } + + val fileLayer1 = repository.getAll().first { it.file == file1 } + val fileLayer2 = repository.getAll().first { it.file == file2 } + repository.delete(fileLayer1.id) + + assertThat(repository.getAll(), contains(fileLayer2)) + } + + private class StubMapConfigurator : MapConfigurator { + private val files = mutableMapOf>() + + override fun supportsLayer(file: File): Boolean { + return files[file]!!.first + } + + override fun getDisplayName(file: File): String { + return files[file]!!.second + } + + fun addFile(file: File, isSupported: Boolean, displayName: String) { + files[file] = Pair(isSupported, displayName) + } + + override fun isAvailable(context: Context): Boolean { + TODO("Not yet implemented") + } + + override fun showUnavailableMessage(context: Context) { + TODO("Not yet implemented") + } + + override fun createPrefs(context: Context, settings: Settings): MutableList { + TODO("Not yet implemented") + } + + override val prefKeys: Collection + get() = TODO("Not yet implemented") + + override fun buildConfig(prefs: Settings): Bundle { + TODO("Not yet implemented") + } + } } diff --git a/maps/src/test/java/org/odk/collect/maps/layers/MapFragmentReferenceLayerUtilsTest.kt b/maps/src/test/java/org/odk/collect/maps/layers/MapFragmentReferenceLayerUtilsTest.kt index ae035b558da..45dcb88dc59 100644 --- a/maps/src/test/java/org/odk/collect/maps/layers/MapFragmentReferenceLayerUtilsTest.kt +++ b/maps/src/test/java/org/odk/collect/maps/layers/MapFragmentReferenceLayerUtilsTest.kt @@ -5,6 +5,9 @@ import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.odk.collect.maps.MapConfigurator import org.odk.collect.maps.MapFragment import org.odk.collect.shared.TempFiles.createTempDir import org.robolectric.RobolectricTestRunner @@ -21,7 +24,7 @@ class MapFragmentReferenceLayerUtilsTest { assertNull( MapFragmentReferenceLayerUtils.getReferenceLayerFile( config, - DirectoryReferenceLayerRepository(layersPath) + DirectoryReferenceLayerRepository(layersPath, "", mock()) ) ) } @@ -34,7 +37,7 @@ class MapFragmentReferenceLayerUtilsTest { assertNull( MapFragmentReferenceLayerUtils.getReferenceLayerFile( config, - DirectoryReferenceLayerRepository(layersPath) + DirectoryReferenceLayerRepository(layersPath, "", mock()) ) ) } @@ -42,15 +45,22 @@ class MapFragmentReferenceLayerUtilsTest { @Test fun whenOfflineLayerFileExist_should_getReferenceLayerFileReturnThatFile() { val layersPath = createTempDir().absolutePath - File(layersPath, "blah").writeBytes(byteArrayOf()) + val file = File(layersPath, "blah").also { + it.writeBytes(byteArrayOf()) + } val config = Bundle() config.putString(MapFragment.KEY_REFERENCE_LAYER, "blah") + val mapConfigurator = mock().also { + whenever(it.supportsLayer(file)).thenReturn(true) + whenever(it.getDisplayName(File(layersPath, "blah"))).thenReturn("blah") + } + assertNotNull( MapFragmentReferenceLayerUtils.getReferenceLayerFile( config, - DirectoryReferenceLayerRepository(layersPath) + DirectoryReferenceLayerRepository(layersPath, "") { mapConfigurator } ) ) } diff --git a/maps/src/test/java/org/odk/collect/maps/layers/OfflineMapLayersImporterTest.kt b/maps/src/test/java/org/odk/collect/maps/layers/OfflineMapLayersImporterTest.kt new file mode 100644 index 00000000000..87f99aaa03a --- /dev/null +++ b/maps/src/test/java/org/odk/collect/maps/layers/OfflineMapLayersImporterTest.kt @@ -0,0 +1,338 @@ +package org.odk.collect.maps.layers + +import android.app.Application +import androidx.core.net.toUri +import androidx.fragment.app.testing.FragmentScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isChecked +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isEnabled +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.CoreMatchers.not +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.odk.collect.androidshared.ui.FragmentFactoryBuilder +import org.odk.collect.fragmentstest.FragmentScenarioLauncherRule +import org.odk.collect.settings.InMemSettingsProvider +import org.odk.collect.shared.TempFiles +import org.odk.collect.strings.R +import org.odk.collect.strings.localization.getLocalizedQuantityString +import org.odk.collect.testshared.FakeScheduler +import org.odk.collect.testshared.Interactions +import org.odk.collect.testshared.RecyclerViewMatcher +import org.odk.collect.testshared.RecyclerViewMatcher.Companion.withRecyclerView +import org.odk.collect.testshared.RobolectricHelpers +import java.io.File + +@RunWith(AndroidJUnit4::class) +class OfflineMapLayersImporterTest { + private val scheduler = FakeScheduler() + private val referenceLayerRepository = mock() + private val settingsProvider = InMemSettingsProvider() + + @get:Rule + val fragmentScenarioLauncherRule = FragmentScenarioLauncherRule( + FragmentFactoryBuilder() + .forClass(OfflineMapLayersImporter::class) { + OfflineMapLayersImporter(referenceLayerRepository, scheduler, settingsProvider) + }.build() + ) + + @Test + fun `clicking the 'cancel' button dismisses the dialog`() { + launchFragment().onFragment { + scheduler.flush() + assertThat(it.isVisible, equalTo(true)) + Interactions.clickOn(withText(R.string.cancel)) + assertThat(it.isVisible, equalTo(false)) + } + } + + @Test + fun `clicking the 'add layer' button dismisses the dialog`() { + launchFragment().onFragment { + scheduler.flush() + assertThat(it.isVisible, equalTo(true)) + it.viewModel.loadLayersToImport(emptyList(), it.requireContext()) + Interactions.clickOn(withId(org.odk.collect.maps.R.id.add_layer_button)) + scheduler.flush() + RobolectricHelpers.runLooper() + assertThat(it.isVisible, equalTo(false)) + } + } + + @Test + fun `progress indicator is displayed during loading layers`() { + val file1 = TempFiles.createTempFile("layer1", MbtilesFile.FILE_EXTENSION) + val file2 = TempFiles.createTempFile("layer2", MbtilesFile.FILE_EXTENSION) + + launchFragment().onFragment { + it.viewModel.loadLayersToImport(listOf(file1.toUri(), file2.toUri()), it.requireContext()) + } + + onView(withId(org.odk.collect.maps.R.id.progress_indicator)).check(matches(isDisplayed())) + onView(withId(org.odk.collect.maps.R.id.layers)).check(matches(not(isDisplayed()))) + + scheduler.flush() + + onView(withId(org.odk.collect.maps.R.id.progress_indicator)).check(matches(not(isDisplayed()))) + onView(withId(org.odk.collect.maps.R.id.layers)).check(matches(isDisplayed())) + } + + @Test + fun `the 'cancel' button is enabled during loading layers`() { + launchFragment() + + onView(withId(org.odk.collect.maps.R.id.cancel_button)).check(matches(isEnabled())) + scheduler.flush() + onView(withId(org.odk.collect.maps.R.id.cancel_button)).check(matches(isEnabled())) + } + + @Test + fun `the 'add layer' button is disabled during loading layers`() { + val file = TempFiles.createTempFile("layer", MbtilesFile.FILE_EXTENSION) + + launchFragment().onFragment { + it.viewModel.loadLayersToImport(listOf(file.toUri()), it.requireContext()) + } + + onView(withId(org.odk.collect.maps.R.id.add_layer_button)).check(matches(not(isEnabled()))) + scheduler.flush() + onView(withId(org.odk.collect.maps.R.id.add_layer_button)).check(matches(isEnabled())) + } + + @Test + fun `'All projects' location should be selected by default`() { + launchFragment() + + onView(withId(org.odk.collect.maps.R.id.all_projects_option)).check(matches(isChecked())) + onView(withId(org.odk.collect.maps.R.id.current_project_option)).check(matches(not(isChecked()))) + } + + @Test + fun `checking location sets selection correctly`() { + launchFragment() + scheduler.flush() + + Interactions.clickOn(withId(org.odk.collect.maps.R.id.current_project_option)) + + onView(withId(org.odk.collect.maps.R.id.all_projects_option)).check(matches(not(isChecked()))) + onView(withId(org.odk.collect.maps.R.id.current_project_option)).check(matches(isChecked())) + + Interactions.clickOn(withId(org.odk.collect.maps.R.id.all_projects_option)) + + onView(withId(org.odk.collect.maps.R.id.all_projects_option)).check(matches(isChecked())) + onView(withId(org.odk.collect.maps.R.id.current_project_option)).check(matches(not(isChecked()))) + } + + @Test + fun `recreating maintains the selected layers location`() { + val scenario = launchFragment() + scheduler.flush() + + Interactions.clickOn(withId(org.odk.collect.maps.R.id.current_project_option)) + + scenario.recreate() + + onView(withId(org.odk.collect.maps.R.id.all_projects_option)).check(matches(not(isChecked()))) + onView(withId(org.odk.collect.maps.R.id.current_project_option)).check(matches(isChecked())) + } + + @Test + fun `the list of selected layers should be displayed in A-Z order`() { + val file1 = TempFiles.createTempFile("layerB", MbtilesFile.FILE_EXTENSION) + val file2 = TempFiles.createTempFile("layerA", MbtilesFile.FILE_EXTENSION) + + launchFragment().onFragment { + it.viewModel.loadLayersToImport(listOf(file1.toUri(), file2.toUri()), it.requireContext()) + } + + scheduler.flush() + + onView(withId(org.odk.collect.maps.R.id.layers)).check(matches(RecyclerViewMatcher.withListSize(2))) + onView(withRecyclerView(org.odk.collect.maps.R.id.layers).atPositionOnView(0, org.odk.collect.maps.R.id.layer_name)).check(matches(withText(file2.name))) + onView(withRecyclerView(org.odk.collect.maps.R.id.layers).atPositionOnView(1, org.odk.collect.maps.R.id.layer_name)).check(matches(withText(file1.name))) + } + + @Test + fun `recreating maintains the list of selected layers`() { + val file1 = TempFiles.createTempFile("layer1", MbtilesFile.FILE_EXTENSION) + val file2 = TempFiles.createTempFile("layer2", MbtilesFile.FILE_EXTENSION) + + val scenario = launchFragment().onFragment { + it.viewModel.loadLayersToImport(listOf(file1.toUri(), file2.toUri()), it.requireContext()) + } + + scheduler.flush() + + scenario.recreate() + + onView(withId(org.odk.collect.maps.R.id.layers)).check(matches(RecyclerViewMatcher.withListSize(2))) + onView(withRecyclerView(org.odk.collect.maps.R.id.layers).atPositionOnView(0, org.odk.collect.maps.R.id.layer_name)).check(matches(withText(file1.name))) + onView(withRecyclerView(org.odk.collect.maps.R.id.layers).atPositionOnView(1, org.odk.collect.maps.R.id.layer_name)).check(matches(withText(file2.name))) + } + + @Test + fun `only mbtiles files are taken into account`() { + val file1 = TempFiles.createTempFile("layer1", MbtilesFile.FILE_EXTENSION) + val file2 = TempFiles.createTempFile("layer2", ".txt") + + launchFragment().onFragment { + it.viewModel.loadLayersToImport(listOf(file1.toUri(), file2.toUri()), it.requireContext()) + } + + scheduler.flush() + + onView(withId(org.odk.collect.maps.R.id.layers)).check(matches(RecyclerViewMatcher.withListSize(1))) + onView(withRecyclerView(org.odk.collect.maps.R.id.layers).atPositionOnView(0, org.odk.collect.maps.R.id.layer_name)).check(matches(withText(file1.name))) + } + + @Test + fun `clicking the 'add layer' button moves the files to the shared layers dir if it is selected`() { + val file1 = TempFiles.createTempFile("layer1", MbtilesFile.FILE_EXTENSION) + val file2 = TempFiles.createTempFile("layer2", MbtilesFile.FILE_EXTENSION) + + launchFragment().onFragment { + it.viewModel.loadLayersToImport(listOf(file1.toUri(), file2.toUri()), it.requireContext()) + } + + scheduler.flush() + + Interactions.clickOn(withId(org.odk.collect.maps.R.id.add_layer_button)) + scheduler.flush() + + val fileCaptor = argumentCaptor() + val booleanCaptor = argumentCaptor() + + verify(referenceLayerRepository, times(2)).addLayer(fileCaptor.capture(), booleanCaptor.capture()) + assertThat(fileCaptor.allValues.any { file -> file.name == file1.name }, equalTo(true)) + assertThat(fileCaptor.allValues.any { file -> file.name == file2.name }, equalTo(true)) + assertThat(booleanCaptor.firstValue, equalTo(true)) + assertThat(booleanCaptor.secondValue, equalTo(true)) + } + + @Test + fun `clicking the 'add layer' button moves the files to the project layers dir if it is selected`() { + val file1 = TempFiles.createTempFile("layer1", MbtilesFile.FILE_EXTENSION) + val file2 = TempFiles.createTempFile("layer2", MbtilesFile.FILE_EXTENSION) + + launchFragment().onFragment { + it.viewModel.loadLayersToImport(listOf(file1.toUri(), file2.toUri()), it.requireContext()) + } + + scheduler.flush() + + Interactions.clickOn(withId(org.odk.collect.maps.R.id.current_project_option)) + Interactions.clickOn(withId(org.odk.collect.maps.R.id.add_layer_button)) + scheduler.flush() + + val fileCaptor = argumentCaptor() + val booleanCaptor = argumentCaptor() + + verify(referenceLayerRepository, times(2)).addLayer(fileCaptor.capture(), booleanCaptor.capture()) + assertThat(fileCaptor.allValues.any { file -> file.name == file1.name }, equalTo(true)) + assertThat(fileCaptor.allValues.any { file -> file.name == file2.name }, equalTo(true)) + assertThat(booleanCaptor.firstValue, equalTo(false)) + assertThat(booleanCaptor.secondValue, equalTo(false)) + } + + @Test + fun `the warning dialog is displayed if some selected files are not supported and the importer dialog is kept displayed`() { + val file1 = TempFiles.createTempFile("layerA", ".txt") + val file2 = TempFiles.createTempFile("layerB", MbtilesFile.FILE_EXTENSION) + + launchFragment().onFragment { + it.viewModel.loadLayersToImport(listOf(file1.toUri(), file2.toUri()), it.requireContext()) + + scheduler.flush() + + val context = ApplicationProvider.getApplicationContext() + onView(withText(context.getLocalizedQuantityString(R.plurals.non_mbtiles_files_selected_title, 1, 1))).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText(R.string.some_non_mbtiles_files_selected_message)).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText(R.string.ok)).inRoot(isDialog()).check(matches(isDisplayed())) + + assertThat(it.isVisible, equalTo(true)) + } + } + + @Test + fun `the warning dialog is displayed if all selected files are not supported and the importer dialog is dismissed`() { + val file = TempFiles.createTempFile("layerA", ".txt") + + launchFragment().onFragment { + it.viewModel.loadLayersToImport(listOf(file.toUri()), it.requireContext()) + + scheduler.flush() + + val context = ApplicationProvider.getApplicationContext() + onView(withText(context.getLocalizedQuantityString(R.plurals.non_mbtiles_files_selected_title, 1, 1))).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText(R.string.all_non_mbtiles_files_selected_message)).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText(R.string.ok)).inRoot(isDialog()).check(matches(isDisplayed())) + + assertThat(it.isVisible, equalTo(false)) + } + } + + @Test + fun `the warning dialog shows correct number of unsupported layers`() { + val context = ApplicationProvider.getApplicationContext() + + launchFragment().onFragment { + // Three unsupported layers + it.viewModel.loadLayersToImport( + listOf( + TempFiles.createTempFile("layerA", ".txt").toUri(), + TempFiles.createTempFile("layerB", ".txt").toUri(), + TempFiles.createTempFile("layerC", ".txt").toUri() + ), + context + ) + scheduler.flush() + onView(withText(context.getLocalizedQuantityString(R.plurals.non_mbtiles_files_selected_title, 3, 3))).inRoot(isDialog()).check(matches(isDisplayed())) + } + + launchFragment().onFragment { + // Two unsupported layers + it.viewModel.loadLayersToImport( + listOf( + TempFiles.createTempFile("layerA", ".txt").toUri(), + TempFiles.createTempFile("layerB", ".txt").toUri(), + TempFiles.createTempFile("layerC", MbtilesFile.FILE_EXTENSION).toUri() + ), + context + ) + scheduler.flush() + onView(withText(context.getLocalizedQuantityString(R.plurals.non_mbtiles_files_selected_title, 2, 2))).inRoot(isDialog()).check(matches(isDisplayed())) + } + + launchFragment().onFragment { + // One unsupported layer + it.viewModel.loadLayersToImport( + listOf( + TempFiles.createTempFile("layerA", ".txt").toUri(), + TempFiles.createTempFile("layerB", MbtilesFile.FILE_EXTENSION).toUri(), + TempFiles.createTempFile("layerC", MbtilesFile.FILE_EXTENSION).toUri() + ), + context + ) + scheduler.flush() + onView(withText(context.getLocalizedQuantityString(R.plurals.non_mbtiles_files_selected_title, 1, 1))).inRoot(isDialog()).check(matches(isDisplayed())) + } + } + + private fun launchFragment(): FragmentScenario { + return fragmentScenarioLauncherRule.launchInContainer(OfflineMapLayersImporter::class.java) + } +} diff --git a/maps/src/test/java/org/odk/collect/maps/layers/OfflineMapLayersPickerTest.kt b/maps/src/test/java/org/odk/collect/maps/layers/OfflineMapLayersPickerTest.kt new file mode 100644 index 00000000000..e0e6f4e00f3 --- /dev/null +++ b/maps/src/test/java/org/odk/collect/maps/layers/OfflineMapLayersPickerTest.kt @@ -0,0 +1,855 @@ +package org.odk.collect.maps.layers + +import android.net.Uri +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.app.ActivityOptionsCompat +import androidx.core.net.toUri +import androidx.fragment.app.testing.FragmentScenario +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.scrollTo +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions.scrollToPosition +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.assertThat +import androidx.test.espresso.matcher.ViewMatchers.isChecked +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isEnabled +import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.CoreMatchers.instanceOf +import org.hamcrest.CoreMatchers.not +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.odk.collect.androidshared.ui.FragmentFactoryBuilder +import org.odk.collect.androidtest.DrawableMatcher.withImageDrawable +import org.odk.collect.fragmentstest.FragmentScenarioLauncherRule +import org.odk.collect.maps.R +import org.odk.collect.settings.InMemSettingsProvider +import org.odk.collect.settings.keys.ProjectKeys +import org.odk.collect.shared.TempFiles +import org.odk.collect.strings.R.string +import org.odk.collect.testshared.FakeScheduler +import org.odk.collect.testshared.Interactions +import org.odk.collect.testshared.RecyclerViewMatcher +import org.odk.collect.testshared.RecyclerViewMatcher.Companion.withRecyclerView +import org.odk.collect.webpage.ExternalWebPageHelper + +@RunWith(AndroidJUnit4::class) +class OfflineMapLayersPickerTest { + private val referenceLayerRepository = mock() + private val scheduler = FakeScheduler() + private val settingsProvider = InMemSettingsProvider() + private val externalWebPageHelper = mock() + + private val uris = mutableListOf() + private val testRegistry = object : ActivityResultRegistry() { + override fun onLaunch( + requestCode: Int, + contract: ActivityResultContract, + input: I, + options: ActivityOptionsCompat? + ) { + assertThat( + contract, + instanceOf(ActivityResultContracts.GetMultipleContents()::class.java) + ) + assertThat(input, equalTo("*/*")) + dispatchResult(requestCode, uris) + } + } + + @get:Rule + val fragmentScenarioLauncherRule = FragmentScenarioLauncherRule( + FragmentFactoryBuilder() + .forClass(OfflineMapLayersPicker::class) { + OfflineMapLayersPicker( + testRegistry, + referenceLayerRepository, + scheduler, + settingsProvider, + externalWebPageHelper + ) + }.build() + ) + + @Test + fun `clicking the 'cancel' button dismisses the layers picker`() { + val scenario = launchFragment() + + scenario.onFragment { + assertThat(it.isVisible, equalTo(true)) + Interactions.clickOn(withText(string.cancel)) + assertThat(it.isVisible, equalTo(false)) + } + } + + @Test + fun `clicking the 'cancel' button does not save the layer`() { + whenever(referenceLayerRepository.getAll()).thenReturn( + listOf(ReferenceLayer("1", TempFiles.createTempFile(), "layer1")) + ) + + launchFragment() + + scheduler.flush() + + Interactions.clickOn(withText(string.cancel)) + assertThat( + settingsProvider.getUnprotectedSettings().contains(ProjectKeys.KEY_REFERENCE_LAYER), + equalTo(false) + ) + } + + @Test + fun `the 'cancel' button should be enabled during loading layers`() { + launchFragment() + + onView(withText(string.cancel)).check(matches(isEnabled())) + } + + @Test + fun `clicking the 'save' button dismisses the layers picker`() { + val scenario = launchFragment() + + scheduler.flush() + + scenario.onFragment { + assertThat(it.isVisible, equalTo(true)) + Interactions.clickOn(withText(string.save)) + assertThat(it.isVisible, equalTo(false)) + } + } + + @Test + fun `the 'save' button should be disabled during loading layers`() { + launchFragment() + + onView(withText(string.save)).check(matches(not(isEnabled()))) + scheduler.flush() + onView(withText(string.save)).check(matches(isEnabled())) + } + + @Test + fun `clicking the 'save' button saves null when 'None' option is checked`() { + whenever(referenceLayerRepository.getAll()).thenReturn( + listOf(ReferenceLayer("1", TempFiles.createTempFile(), "layer1")) + ) + + launchFragment() + + scheduler.flush() + + Interactions.clickOn(withText(string.save)) + assertThat( + settingsProvider.getUnprotectedSettings().getString(ProjectKeys.KEY_REFERENCE_LAYER), + equalTo(null) + ) + } + + @Test + fun `clicking the 'save' button saves the layer id if any is checked`() { + whenever(referenceLayerRepository.getAll()).thenReturn( + listOf(ReferenceLayer("1", TempFiles.createTempFile(), "layer1")) + ) + + launchFragment() + + scheduler.flush() + + Interactions.clickOn(withText("layer1")) + Interactions.clickOn(withText(string.save)) + assertThat( + settingsProvider.getUnprotectedSettings().getString(ProjectKeys.KEY_REFERENCE_LAYER), + equalTo("1") + ) + } + + @Test + fun `when no layer id is saved in settings the 'None' option should be checked`() { + whenever(referenceLayerRepository.getAll()).thenReturn( + listOf(ReferenceLayer("1", TempFiles.createTempFile(), "layer1")) + ) + + launchFragment() + + scheduler.flush() + + onView(withRecyclerView(R.id.layers).atPositionOnView(0, R.id.radio_button)).check( + matches( + isChecked() + ) + ) + onView(withRecyclerView(R.id.layers).atPositionOnView(1, R.id.radio_button)).check( + matches( + not(isChecked()) + ) + ) + } + + @Test + fun `when layer id is saved in settings the layer it belongs to should be checked`() { + whenever(referenceLayerRepository.getAll()).thenReturn( + listOf( + ReferenceLayer("1", TempFiles.createTempFile(), "layer1"), + ReferenceLayer("2", TempFiles.createTempFile(), "layer2") + ) + ) + + settingsProvider.getUnprotectedSettings().save(ProjectKeys.KEY_REFERENCE_LAYER, "2") + + launchFragment() + + scheduler.flush() + + onView(withRecyclerView(R.id.layers).atPositionOnView(0, R.id.radio_button)).check( + matches( + not(isChecked()) + ) + ) + onView(withRecyclerView(R.id.layers).atPositionOnView(1, R.id.radio_button)).check( + matches( + not(isChecked()) + ) + ) + onView(withRecyclerView(R.id.layers).atPositionOnView(2, R.id.radio_button)).check( + matches( + isChecked() + ) + ) + } + + @Test + fun `when layer id is saved in settings but the layer it belongs to does not exist the 'None' option should be checked`() { + whenever(referenceLayerRepository.getAll()).thenReturn( + listOf(ReferenceLayer("1", TempFiles.createTempFile(), "layer1")) + ) + + settingsProvider.getUnprotectedSettings().save(ProjectKeys.KEY_REFERENCE_LAYER, "2") + + launchFragment() + + scheduler.flush() + + onView(withId(R.id.layers)).check(matches(RecyclerViewMatcher.withListSize(2))) + onView( + withRecyclerView(R.id.layers).atPositionOnView( + 0, + R.id.title + ) + ).check(matches(withText(string.none))) + onView(withRecyclerView(R.id.layers).atPositionOnView(0, R.id.radio_button)).check( + matches( + isChecked() + ) + ) + } + + @Test + fun `progress indicator is displayed during loading layers`() { + launchFragment() + + onView(withId(R.id.progress_indicator)).check(matches(isDisplayed())) + onView(withId(R.id.layers)).check(matches(not(isDisplayed()))) + + scheduler.flush() + + onView(withId(R.id.progress_indicator)).check(matches(not(isDisplayed()))) + onView(withId(R.id.layers)).check(matches(isDisplayed())) + } + + @Test + fun `the 'learn more' button should be enabled during loading layers`() { + launchFragment() + + onView(withText(string.get_help_with_offline_layers)).check(matches(isEnabled())) + } + + @Test + fun `clicking the 'learn more' button opens the forum thread`() { + launchFragment() + + scheduler.flush() + + Interactions.clickOn(withText(string.get_help_with_offline_layers)) + + verify(externalWebPageHelper).openWebPageInCustomTab( + any(), + eq(Uri.parse("https://docs.getodk.org/collect-offline-maps/#transferring-offline-tilesets-to-devices")) + ) + } + + @Test + fun `if there are no layers the 'none' option is displayed`() { + launchFragment() + + scheduler.flush() + + onView(withId(R.id.layers)).check(matches(RecyclerViewMatcher.withListSize(1))) + onView( + withRecyclerView(R.id.layers).atPositionOnView( + 0, + R.id.title + ) + ).check(matches(withText(string.none))) + } + + @Test + fun `if there are multiple layers all of them are displayed along with the 'None' and sorted in A-Z order`() { + whenever(referenceLayerRepository.getAll()).thenReturn( + listOf( + ReferenceLayer("1", TempFiles.createTempFile(), "layerB"), + ReferenceLayer("2", TempFiles.createTempFile(), "layerA") + ) + ) + + launchFragment() + + scheduler.flush() + + onView(withId(R.id.layers)).check(matches(RecyclerViewMatcher.withListSize(3))) + onView( + withRecyclerView(R.id.layers).atPositionOnView( + 0, + R.id.title + ) + ).check(matches(withText(string.none))) + onView( + withRecyclerView(R.id.layers).atPositionOnView( + 1, + R.id.title + ) + ).check(matches(withText("layerA"))) + onView( + withRecyclerView(R.id.layers).atPositionOnView( + 2, + R.id.title + ) + ).check(matches(withText("layerB"))) + } + + @Test + fun `checking layers sets selection correctly`() { + whenever(referenceLayerRepository.getAll()).thenReturn( + listOf(ReferenceLayer("1", TempFiles.createTempFile(), "layer1")) + ) + + launchFragment() + + scheduler.flush() + + Interactions.clickOn(withText("layer1")) + onView(withRecyclerView(R.id.layers).atPositionOnView(0, R.id.radio_button)).check( + matches( + not(isChecked()) + ) + ) + onView(withRecyclerView(R.id.layers).atPositionOnView(1, R.id.radio_button)).check( + matches( + isChecked() + ) + ) + + Interactions.clickOn(withText(string.none)) + onView(withRecyclerView(R.id.layers).atPositionOnView(0, R.id.radio_button)).check( + matches( + isChecked() + ) + ) + onView(withRecyclerView(R.id.layers).atPositionOnView(1, R.id.radio_button)).check( + matches( + not(isChecked()) + ) + ) + } + + @Test + fun `recreating maintains selection`() { + whenever(referenceLayerRepository.getAll()).thenReturn( + listOf(ReferenceLayer("1", TempFiles.createTempFile(), "layer1")) + ) + + val scenario = launchFragment() + + scheduler.flush() + + Interactions.clickOn(withText("layer1")) + scenario.recreate() + scheduler.flush() + onView(withRecyclerView(R.id.layers).atPositionOnView(0, R.id.radio_button)).check( + matches( + not(isChecked()) + ) + ) + onView(withRecyclerView(R.id.layers).atPositionOnView(1, R.id.radio_button)).check( + matches( + isChecked() + ) + ) + } + + @Test + fun `clicking the 'add layer' and selecting layers displays the confirmation dialog`() { + val scenario = launchFragment() + + uris.add(Uri.parse("blah")) + Interactions.clickOn(withText(string.add_layer)) + + scenario.onFragment { + assertThat( + it.childFragmentManager.findFragmentByTag(OfflineMapLayersImporter::class.java.name), + instanceOf(OfflineMapLayersImporter::class.java) + ) + } + } + + @Test + fun `clicking the 'add layer' and selecting nothing does not display the confirmation dialog`() { + val scenario = launchFragment() + + Interactions.clickOn(withText(string.add_layer)) + + scenario.onFragment { + assertThat( + it.childFragmentManager.findFragmentByTag(OfflineMapLayersImporter::class.java.name), + equalTo(null) + ) + } + } + + @Test + fun `progress indicator is displayed during loading layers after receiving new ones`() { + val file1 = TempFiles.createTempFile("layer1", MbtilesFile.FILE_EXTENSION) + val file2 = TempFiles.createTempFile("layer2", MbtilesFile.FILE_EXTENSION) + + launchFragment() + + scheduler.flush() + + uris.add(file1.toUri()) + uris.add(file2.toUri()) + + Interactions.clickOn(withText(string.add_layer)) + scheduler.flush() + onView(withId(R.id.add_layer_button)).inRoot(isDialog()).perform(scrollTo(), click()) + + onView(withId(R.id.progress_indicator)).check(matches(isDisplayed())) + onView(withId(R.id.layers)).check(matches(not(isDisplayed()))) + + scheduler.flush() + + onView(withId(R.id.progress_indicator)).check(matches(not(isDisplayed()))) + onView(withId(R.id.layers)).check(matches(isDisplayed())) + } + + @Test + fun `when new layers added the list should be updated`() { + val file1 = TempFiles.createTempFile("layer1", MbtilesFile.FILE_EXTENSION) + val file2 = TempFiles.createTempFile("layer2", MbtilesFile.FILE_EXTENSION) + + launchFragment() + + scheduler.flush() + + uris.add(file1.toUri()) + uris.add(file2.toUri()) + + Interactions.clickOn(withText(string.add_layer)) + scheduler.flush() + onView(withId(R.id.add_layer_button)).inRoot(isDialog()).perform(scrollTo(), click()) + whenever(referenceLayerRepository.getAll()).thenReturn( + listOf( + ReferenceLayer("1", TempFiles.createTempFile(), file1.name), + ReferenceLayer("2", TempFiles.createTempFile(), file2.name) + ) + ) + scheduler.flush() + + onView(withId(R.id.layers)).check(matches(RecyclerViewMatcher.withListSize(3))) + onView( + withRecyclerView(R.id.layers).atPositionOnView( + 0, + R.id.title + ) + ).check(matches(withText(string.none))) + onView( + withRecyclerView(R.id.layers).atPositionOnView( + 1, + R.id.title + ) + ).check(matches(withText(file1.name))) + onView( + withRecyclerView(R.id.layers).atPositionOnView( + 2, + R.id.title + ) + ).check(matches(withText(file2.name))) + } + + @Test + fun `layers are collapsed by default`() { + whenever(referenceLayerRepository.getAll()).thenReturn( + listOf( + ReferenceLayer("1", TempFiles.createTempFile(), "layer1"), + ReferenceLayer("2", TempFiles.createTempFile(), "layer2") + ) + ) + + launchFragment() + + scheduler.flush() + + assertLayerCollapsed(1) + assertLayerCollapsed(2) + } + + @Test + fun `recreating maintains expanded layers`() { + whenever(referenceLayerRepository.getAll()).thenReturn( + listOf( + ReferenceLayer("1", TempFiles.createTempFile(), "layer1"), + ReferenceLayer("2", TempFiles.createTempFile(), "layer2"), + ReferenceLayer("3", TempFiles.createTempFile(), "layer3") + ) + ) + + val scenario = launchFragment() + + scheduler.flush() + + onView(withRecyclerView(R.id.layers).atPositionOnView(1, R.id.arrow)).perform(click()) + onView(withId(R.id.layers)).perform(scrollToPosition(3)) + onView(withRecyclerView(R.id.layers).atPositionOnView(3, R.id.arrow)).perform(click()) + + scenario.recreate() + scheduler.flush() + + assertLayerExpanded(1) + assertLayerCollapsed(2) + assertLayerExpanded(3) + } + + @Test + fun `correct path is displayed after expanding layers`() { + val file1 = TempFiles.createTempFile() + val file2 = TempFiles.createTempFile() + whenever(referenceLayerRepository.getAll()).thenReturn( + listOf( + ReferenceLayer("1", file1, "layer1"), + ReferenceLayer("2", file2, "layer2") + ) + ) + + launchFragment() + + scheduler.flush() + + onView(withRecyclerView(R.id.layers).atPositionOnView(1, R.id.arrow)).perform(click()) + onView(withId(R.id.layers)).perform(scrollToPosition(2)) + onView(withRecyclerView(R.id.layers).atPositionOnView(2, R.id.arrow)).perform(click()) + + onView(withRecyclerView(R.id.layers).atPositionOnView(1, R.id.path)).check( + matches( + withText( + file1.absolutePath + ) + ) + ) + onView(withRecyclerView(R.id.layers).atPositionOnView(2, R.id.path)).check( + matches( + withText( + file2.absolutePath + ) + ) + ) + } + + @Test + fun `clicking delete shows the confirmation dialog`() { + whenever(referenceLayerRepository.getAll()).thenReturn( + listOf( + ReferenceLayer("1", TempFiles.createTempFile(), "layer1") + ) + ) + + launchFragment() + + scheduler.flush() + + onView(withRecyclerView(R.id.layers).atPositionOnView(1, R.id.arrow)).perform(click()) + onView(withRecyclerView(R.id.layers).atPositionOnView(1, R.id.delete_layer)).perform( + scrollTo(), + click() + ) + + onView(withText(string.cancel)).inRoot(isDialog()).check(matches(isDisplayed())) + onView(withText(string.delete_layer)).inRoot(isDialog()).check(matches(isDisplayed())) + } + + @Test + fun `clicking delete and canceling does not remove the layer`() { + val layerFile1 = TempFiles.createTempFile() + whenever(referenceLayerRepository.getAll()).thenReturn( + listOf( + ReferenceLayer("1", layerFile1, "layer1") + ) + ) + + launchFragment() + + scheduler.flush() + + onView(withRecyclerView(R.id.layers).atPositionOnView(1, R.id.arrow)).perform(click()) + onView(withRecyclerView(R.id.layers).atPositionOnView(1, R.id.delete_layer)).perform( + scrollTo(), + click() + ) + + onView(withText(string.cancel)).inRoot(isDialog()).perform(click()) + + onView(withId(R.id.layers)).check(matches(RecyclerViewMatcher.withListSize(2))) + onView(withId(R.id.layers)).perform(scrollToPosition(0)) + onView( + withRecyclerView(R.id.layers).atPositionOnView( + 0, + R.id.title + ) + ).check(matches(withText(string.none))) + onView( + withRecyclerView(R.id.layers).atPositionOnView( + 1, + R.id.title + ) + ).check(matches(withText("layer1"))) + verify(referenceLayerRepository, never()).delete("1") + } + + @Test + fun `clicking delete and confirming removes the layer`() { + val layerFile1 = TempFiles.createTempFile() + val layerFile2 = TempFiles.createTempFile() + whenever(referenceLayerRepository.getAll()).thenReturn( + listOf( + ReferenceLayer("1", layerFile1, "layer1"), + ReferenceLayer("2", layerFile2, "layer2") + ) + ) + + launchFragment() + + scheduler.flush() + + onView(withRecyclerView(R.id.layers).atPositionOnView(1, R.id.arrow)).perform(click()) + onView(withRecyclerView(R.id.layers).atPositionOnView(1, R.id.delete_layer)).perform( + scrollTo(), + click() + ) + + onView(withText(string.delete_layer)).inRoot(isDialog()).perform(click()) + scheduler.flush() + + onView(withId(R.id.layers)).check(matches(RecyclerViewMatcher.withListSize(2))) + onView( + withRecyclerView(R.id.layers).atPositionOnView( + 0, + R.id.title + ) + ).check(matches(withText(string.none))) + onView( + withRecyclerView(R.id.layers).atPositionOnView( + 1, + R.id.title + ) + ).check(matches(withText("layer2"))) + verify(referenceLayerRepository).delete("1") + verify(referenceLayerRepository, never()).delete("2") + } + + @Test + fun `deleting the selected layer changes selection to 'none' and saves it`() { + val layerFile1 = TempFiles.createTempFile() + whenever(referenceLayerRepository.getAll()).thenReturn( + listOf( + ReferenceLayer("1", layerFile1, "layer1") + ) + ) + settingsProvider.getUnprotectedSettings().save(ProjectKeys.KEY_REFERENCE_LAYER, "1") + + launchFragment() + + scheduler.flush() + + onView(withRecyclerView(R.id.layers).atPositionOnView(1, R.id.arrow)).perform(click()) + onView(withRecyclerView(R.id.layers).atPositionOnView(1, R.id.delete_layer)).perform( + scrollTo(), + click() + ) + + onView(withText(string.delete_layer)).inRoot(isDialog()).perform(click()) + scheduler.flush() + + onView( + withRecyclerView(R.id.layers).atPositionOnView( + 0, + R.id.title + ) + ).check(matches(withText(string.none))) + onView(withRecyclerView(R.id.layers).atPositionOnView(0, R.id.radio_button)).check( + matches( + isChecked() + ) + ) + assertThat( + settingsProvider.getUnprotectedSettings().getString(ProjectKeys.KEY_REFERENCE_LAYER), + equalTo(null) + ) + } + + @Test + fun `deleting one of the layers keeps the list sorted in A-Z order`() { + whenever(referenceLayerRepository.getAll()).thenReturn( + listOf( + ReferenceLayer("1", TempFiles.createTempFile(), "layerC"), + ReferenceLayer("2", TempFiles.createTempFile(), "layerB"), + ReferenceLayer("3", TempFiles.createTempFile(), "layerA") + ) + ) + + launchFragment() + + scheduler.flush() + + onView(withId(R.id.layers)).perform(scrollToPosition(2)) + onView(withRecyclerView(R.id.layers).atPositionOnView(2, R.id.arrow)).perform(click()) + onView(withRecyclerView(R.id.layers).atPositionOnView(2, R.id.delete_layer)).perform( + scrollTo(), + click() + ) + + onView(withText(string.delete_layer)).inRoot(isDialog()).perform(click()) + scheduler.flush() + + onView(withId(R.id.layers)).check(matches(RecyclerViewMatcher.withListSize(3))) + onView( + withRecyclerView(R.id.layers).atPositionOnView( + 0, + R.id.title + ) + ).check(matches(withText(string.none))) + onView( + withRecyclerView(R.id.layers).atPositionOnView( + 1, + R.id.title + ) + ).check(matches(withText("layerA"))) + onView( + withRecyclerView(R.id.layers).atPositionOnView( + 2, + R.id.title + ) + ).check(matches(withText("layerC"))) + } + + @Test + fun `progress indicator is displayed during deleting layers`() { + val layerFile1 = TempFiles.createTempFile("layer1", MbtilesFile.FILE_EXTENSION) + whenever(referenceLayerRepository.getAll()).thenReturn( + listOf( + ReferenceLayer("1", layerFile1, "layer1"), + ) + ) + + launchFragment() + + scheduler.flush() + + onView(withRecyclerView(R.id.layers).atPositionOnView(1, R.id.arrow)).perform(click()) + onView(withRecyclerView(R.id.layers).atPositionOnView(1, R.id.delete_layer)).perform( + scrollTo(), + click() + ) + onView(withText(string.delete_layer)).inRoot(isDialog()).perform(click()) + + onView(withId(R.id.progress_indicator)).check(matches(isDisplayed())) + onView(withId(R.id.layers)).check(matches(not(isDisplayed()))) + + scheduler.flush() + + onView(withId(R.id.progress_indicator)).check(matches(not(isDisplayed()))) + onView(withId(R.id.layers)).check(matches(isDisplayed())) + } + + private fun assertLayerCollapsed(position: Int) { + onView(withRecyclerView(R.id.layers).atPositionOnView(position, R.id.arrow)).check( + matches( + withImageDrawable(org.odk.collect.icons.R.drawable.ic_baseline_expand_24) + ) + ) + onView(withRecyclerView(R.id.layers).atPositionOnView(position, R.id.path)).check( + matches( + withEffectiveVisibility(ViewMatchers.Visibility.GONE) + ) + ) + onView(withRecyclerView(R.id.layers).atPositionOnView(position, R.id.delete_layer)).check( + matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)) + ) + } + + private fun assertLayerExpanded(position: Int) { + onView(withRecyclerView(R.id.layers).atPositionOnView(position, R.id.arrow)).check( + matches( + withImageDrawable(org.odk.collect.icons.R.drawable.ic_baseline_collapse_24) + ) + ) + onView(withRecyclerView(R.id.layers).atPositionOnView(position, R.id.path)).check( + matches( + withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE) + ) + ) + onView(withRecyclerView(R.id.layers).atPositionOnView(position, R.id.delete_layer)).check( + matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)) + ) + } + + @Test + fun `the confirmation dialog is dismissed o activity recreation`() { + val scenario = launchFragment() + + uris.add(Uri.parse("blah")) + Interactions.clickOn(withText(string.add_layer)) + + scenario.onFragment { + assertThat( + it.childFragmentManager.findFragmentByTag(OfflineMapLayersImporter::class.java.name), + instanceOf(OfflineMapLayersImporter::class.java) + ) + } + + scenario.recreate() + + scenario.onFragment { + assertThat( + it.childFragmentManager.findFragmentByTag(OfflineMapLayersImporter::class.java.name), + equalTo(null) + ) + } + } + + private fun launchFragment(): FragmentScenario { + return fragmentScenarioLauncherRule.launchInContainer(OfflineMapLayersPicker::class.java) + } +} diff --git a/maps/src/test/resources/robolectric.properties b/maps/src/test/resources/robolectric.properties index 4f3945f61dc..ea84398ea49 100644 --- a/maps/src/test/resources/robolectric.properties +++ b/maps/src/test/resources/robolectric.properties @@ -1 +1,2 @@ +application=org.odk.collect.maps.RobolectricApplication sdk=33 diff --git a/material/build.gradle.kts b/material/build.gradle.kts index 8201dac58a6..d0024c6d0e3 100644 --- a/material/build.gradle.kts +++ b/material/build.gradle.kts @@ -48,8 +48,9 @@ dependencies { implementation(project(":androidshared")) implementation(project(":strings")) + implementation(project(":icons")) implementation(Dependencies.androidx_appcompat) - implementation(Dependencies.android_material) + api(Dependencies.android_material) implementation(Dependencies.androidx_fragment_ktx) implementation(Dependencies.kotlin_stdlib) diff --git a/material/src/main/java/org/odk/collect/material/ErrorsPill.kt b/material/src/main/java/org/odk/collect/material/ErrorsPill.kt new file mode 100644 index 00000000000..3fd56270d1a --- /dev/null +++ b/material/src/main/java/org/odk/collect/material/ErrorsPill.kt @@ -0,0 +1,33 @@ +package org.odk.collect.material + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import com.google.android.material.color.MaterialColors +import org.odk.collect.androidshared.system.ContextUtils + +class ErrorsPill(context: Context, attrs: AttributeSet?) : MaterialPill(context, attrs) { + var errors: Boolean = false + set(value) { + setup(value) + field = value + } + + private fun setup(errors: Boolean) { + if (errors) { + visibility = View.VISIBLE + setIcon(org.odk.collect.icons.R.drawable.ic_baseline_rule_24) + setText(org.odk.collect.strings.R.string.draft_errors) + setPillBackgroundColor(MaterialColors.getColor(this, com.google.android.material.R.attr.colorErrorContainer)) + setTextColor(ContextUtils.getThemeAttributeValue(context, com.google.android.material.R.attr.colorOnErrorContainer)) + setIconTint(ContextUtils.getThemeAttributeValue(context, com.google.android.material.R.attr.colorOnErrorContainer)) + } else { + visibility = View.VISIBLE + setIcon(org.odk.collect.icons.R.drawable.ic_baseline_check_24) + setText(org.odk.collect.strings.R.string.draft_no_errors) + setPillBackgroundColor(MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimaryContainer)) + setTextColor(ContextUtils.getThemeAttributeValue(context, com.google.android.material.R.attr.colorOnPrimaryContainer)) + setIconTint(ContextUtils.getThemeAttributeValue(context, com.google.android.material.R.attr.colorOnPrimaryContainer)) + } + } +} diff --git a/material/src/main/java/org/odk/collect/material/MaterialPill.kt b/material/src/main/java/org/odk/collect/material/MaterialPill.kt index 3649d3eb5a7..bb5bb4de4cc 100644 --- a/material/src/main/java/org/odk/collect/material/MaterialPill.kt +++ b/material/src/main/java/org/odk/collect/material/MaterialPill.kt @@ -2,6 +2,7 @@ package org.odk.collect.material import android.content.Context import android.content.res.ColorStateList +import android.graphics.drawable.ColorDrawable import android.util.AttributeSet import android.view.LayoutInflater import android.widget.FrameLayout @@ -9,6 +10,7 @@ import androidx.annotation.ColorInt import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.content.res.ResourcesCompat +import androidx.core.content.withStyledAttributes import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.ShapeAppearanceModel import org.odk.collect.androidshared.system.ContextUtils.getThemeAttributeValue @@ -19,7 +21,7 @@ import org.odk.collect.material.databinding.PillBinding * included in the spec or in Android's MaterialComponents. The pill will use the * `?shapeAppearanceCornerSmall` shape appearance for the current theme. */ -class MaterialPill(context: Context, attrs: AttributeSet?) : +open class MaterialPill(context: Context, attrs: AttributeSet?) : FrameLayout(context, attrs) { var text: String? = null @@ -28,13 +30,23 @@ class MaterialPill(context: Context, attrs: AttributeSet?) : binding.text.text = text } - private val shapeAppearanceModel = - ShapeAppearanceModel.builder(context, getShapeAppearance(context), -1).build() - val binding = PillBinding.inflate(LayoutInflater.from(context), this, true) init { - background = createMaterialShapeDrawable(getDefaultBackgroundColor(context)) + context.withStyledAttributes(attrs, R.styleable.MaterialPill) { + text = getString(R.styleable.MaterialPill_text) + + val iconId = getResourceId(R.styleable.MaterialPill_icon, -1) + if (iconId != -1) { + setIcon(iconId) + } + + val backgroundColor = getColor( + R.styleable.MaterialPill_pillBackgroundColor, + getDefaultBackgroundColor(context) + ) + setPillBackgroundColor(backgroundColor) + } } fun setText(@StringRes id: Int) { @@ -51,26 +63,37 @@ class MaterialPill(context: Context, attrs: AttributeSet?) : } fun setPillBackgroundColor(@ColorInt color: Int) { - background = createMaterialShapeDrawable(color) + if (isInEditMode) { + /** + * For some reason `ShapeAppearanceModel` can't be built in Android Studio's design + * preview (even when using a Material 3 theme). It could be that some of the + * attibutes used here are not available in the basic themes, but are set in the real + * ones we use. For now, just setting a "unshaped" background is an easier option than + * deep diving. + */ + background = ColorDrawable(color) + return + } + + val shapeAppearance = getThemeAttributeValue( + context, + com.google.android.material.R.attr.shapeAppearanceCornerSmall + ) + + val shapeAppearanceModel = + ShapeAppearanceModel.builder(context, shapeAppearance, -1).build() + + background = MaterialShapeDrawable(shapeAppearanceModel).also { + it.fillColor = ColorStateList.valueOf(color) + } } fun setTextColor(@ColorInt color: Int) { binding.text.setTextColor(color) } - private fun getShapeAppearance(context: Context) = getThemeAttributeValue( - context, - com.google.android.material.R.attr.shapeAppearanceCornerSmall - ) - private fun getDefaultBackgroundColor(context: Context) = getThemeAttributeValue( context, - com.google.android.material.R.attr.colorPrimary + com.google.android.material.R.attr.colorPrimaryContainer ) - - private fun createMaterialShapeDrawable(@ColorInt color: Int): MaterialShapeDrawable { - return MaterialShapeDrawable(shapeAppearanceModel).also { - it.fillColor = ColorStateList.valueOf(color) - } - } } diff --git a/material/src/main/res/layout/pill.xml b/material/src/main/res/layout/pill.xml index bd13d41afba..ab947ebd85a 100644 --- a/material/src/main/res/layout/pill.xml +++ b/material/src/main/res/layout/pill.xml @@ -4,7 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:paddingHorizontal="@dimen/margin_extra_small" + android:paddingHorizontal="@dimen/margin_small" android:paddingVertical="@dimen/margin_extra_extra_small"> - - + + + + + + + + + diff --git a/material/src/test/java/org/odk/collect/material/ErrorsPillTest.kt b/material/src/test/java/org/odk/collect/material/ErrorsPillTest.kt new file mode 100644 index 00000000000..a27cef826be --- /dev/null +++ b/material/src/test/java/org/odk/collect/material/ErrorsPillTest.kt @@ -0,0 +1,52 @@ +package org.odk.collect.material + +import android.app.Application +import android.view.View +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows + +@RunWith(AndroidJUnit4::class) +class ErrorsPillTest { + private val context = ApplicationProvider.getApplicationContext().also { + it.setTheme(com.google.android.material.R.style.Theme_Material3_Light) + } + private val errorsPill: ErrorsPill = ErrorsPill(context, null) + + @Test + fun `setup with State ERRORS should set appropriate properties`() { + errorsPill.errors = true + assertErrorsPill() + } + + @Test + fun `setup with State NO_ERRORS should set appropriate properties`() { + errorsPill.errors = false + assertNoErrorsPill() + } + + @Test + fun `pill can be recycled`() { + errorsPill.errors = true + assertErrorsPill() + + errorsPill.errors = false + assertNoErrorsPill() + } + + private fun assertErrorsPill() { + assertThat(errorsPill.visibility, equalTo(View.VISIBLE)) + assertThat(Shadows.shadowOf(errorsPill.binding.icon.drawable).createdFromResId, equalTo(org.odk.collect.icons.R.drawable.ic_baseline_rule_24)) + assertThat(errorsPill.binding.text.text, equalTo(context.getString(org.odk.collect.strings.R.string.draft_errors))) + } + + private fun assertNoErrorsPill() { + assertThat(errorsPill.visibility, equalTo(View.VISIBLE)) + assertThat(Shadows.shadowOf(errorsPill.binding.icon.drawable).createdFromResId, equalTo(org.odk.collect.icons.R.drawable.ic_baseline_check_24)) + assertThat(errorsPill.binding.text.text, equalTo(context.getString(org.odk.collect.strings.R.string.draft_no_errors))) + } +} diff --git a/osmdroid/src/main/java/org/odk/collect/osmdroid/OsmDroidMapFragment.java b/osmdroid/src/main/java/org/odk/collect/osmdroid/OsmDroidMapFragment.java index 2cc434885b6..eb081b9cc70 100644 --- a/osmdroid/src/main/java/org/odk/collect/osmdroid/OsmDroidMapFragment.java +++ b/osmdroid/src/main/java/org/odk/collect/osmdroid/OsmDroidMapFragment.java @@ -16,9 +16,6 @@ import static androidx.core.graphics.drawable.DrawableKt.toBitmap; -import static org.odk.collect.maps.MapConsts.POLYGON_FILL_COLOR_OPACITY; -import static org.odk.collect.maps.MapConsts.POLYLINE_STROKE_WIDTH; - import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; @@ -38,17 +35,18 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; -import androidx.core.graphics.ColorUtils; import androidx.fragment.app.Fragment; import com.google.android.gms.location.LocationListener; import org.odk.collect.androidshared.system.ContextUtils; import org.odk.collect.location.LocationClient; +import org.odk.collect.maps.LineDescription; import org.odk.collect.maps.MapConfigurator; import org.odk.collect.maps.MapFragment; import org.odk.collect.maps.MapFragmentDelegate; import org.odk.collect.maps.MapPoint; +import org.odk.collect.maps.PolygonDescription; import org.odk.collect.maps.layers.MapFragmentReferenceLayerUtils; import org.odk.collect.maps.layers.ReferenceLayerRepository; import org.odk.collect.maps.markers.MarkerDescription; @@ -322,6 +320,7 @@ public void setMarkerIcon(int featureId, MarkerIconDescription markerIconDescrip MapFeature feature = features.get(featureId); if (feature instanceof MarkerFeature) { ((MarkerFeature) feature).setIcon(markerIconDescription); + map.invalidate(); } } @@ -333,20 +332,20 @@ MapPoint getMarkerPoint(int featureId) { } @Override - public int addPolyLine(@NonNull Iterable points, boolean closed, boolean draggable) { + public int addPolyLine(LineDescription lineDescription) { int featureId = nextFeatureId++; - if (draggable) { - features.put(featureId, new DynamicPolyLineFeature(map, points, closed)); + if (lineDescription.getDraggable()) { + features.put(featureId, new DynamicPolyLineFeature(map, lineDescription)); } else { - features.put(featureId, new StaticPolyLineFeature(map, points, closed)); + features.put(featureId, new StaticPolyLineFeature(map, lineDescription)); } return featureId; } @Override - public int addPolygon(@NonNull Iterable points) { + public int addPolygon(PolygonDescription polygonDescription) { int featureId = nextFeatureId++; - features.put(featureId, new StaticPolygonFeature(map, points)); + features.put(featureId, new StaticPolygonFeature(map, polygonDescription)); return featureId; } @@ -362,8 +361,8 @@ public void appendPointToPolyLine(int featureId, @NonNull MapPoint point) { public @NonNull List getPolyLinePoints(int featureId) { MapFeature feature = features.get(featureId); - if (feature instanceof DynamicPolyLineFeature) { - return ((DynamicPolyLineFeature) feature).getPoints(); + if (feature instanceof LineFeature) { + return ((LineFeature) feature).getPoints(); } return new ArrayList<>(); } @@ -793,19 +792,25 @@ public void dispose() { } } + private interface LineFeature extends MapFeature { + + List getPoints(); + } + /** * A polyline or polygon that can be manipulated by dragging markers at its vertices. */ - private class StaticPolyLineFeature implements MapFeature { + private class StaticPolyLineFeature implements LineFeature { final MapView map; final Polyline polyline; final boolean closedPolygon; + private final List points; - StaticPolyLineFeature(MapView map, Iterable points, boolean closedPolygon) { + StaticPolyLineFeature(MapView map, LineDescription lineDescription) { this.map = map; - this.closedPolygon = closedPolygon; + this.closedPolygon = lineDescription.getClosed(); polyline = new Polyline(); - polyline.setColor(map.getContext().getResources().getColor(org.odk.collect.icons.R.color.mapLineColor)); + polyline.setColor(lineDescription.getStrokeColor()); polyline.setOnClickListener((clickedPolyline, mapView, eventPos) -> { int featureId = findFeature(clickedPolyline); if (featureClickListener != null && featureId != -1) { @@ -815,9 +820,10 @@ private class StaticPolyLineFeature implements MapFeature { return false; }); Paint paint = polyline.getPaint(); - paint.setStrokeWidth(POLYLINE_STROKE_WIDTH); + paint.setStrokeWidth(lineDescription.getStrokeWidth()); map.getOverlays().add(polyline); + points = lineDescription.getPoints(); List geoPoints = StreamSupport.stream(points.spliterator(), false).map(mapPoint -> new GeoPoint(mapPoint.latitude, mapPoint.longitude, mapPoint.altitude)).collect(Collectors.toList()); if (closedPolygon && !geoPoints.isEmpty()) { geoPoints.add(geoPoints.get(0)); @@ -849,22 +855,27 @@ public void update() { public void dispose() { map.getOverlays().remove(polyline); } + + @Override + public List getPoints() { + return points; + } } /** * A polyline or polygon that can be manipulated by dragging markers at its vertices. */ - private class DynamicPolyLineFeature implements MapFeature { + private class DynamicPolyLineFeature implements LineFeature { final MapView map; final List markers = new ArrayList<>(); final Polyline polyline; final boolean closedPolygon; - DynamicPolyLineFeature(MapView map, Iterable points, boolean closedPolygon) { + DynamicPolyLineFeature(MapView map, LineDescription lineDescription) { this.map = map; - this.closedPolygon = closedPolygon; + this.closedPolygon = lineDescription.getClosed(); polyline = new Polyline(); - polyline.setColor(map.getContext().getResources().getColor(org.odk.collect.icons.R.color.mapLineColor)); + polyline.setColor(lineDescription.getStrokeColor()); polyline.setOnClickListener((clickedPolyline, mapView, eventPos) -> { int featureId = findFeature(clickedPolyline); if (featureClickListener != null && featureId != -1) { @@ -874,9 +885,9 @@ private class DynamicPolyLineFeature implements MapFeature { return false; }); Paint paint = polyline.getPaint(); - paint.setStrokeWidth(POLYLINE_STROKE_WIDTH); + paint.setStrokeWidth(lineDescription.getStrokeWidth()); map.getOverlays().add(polyline); - for (MapPoint point : points) { + for (MapPoint point : lineDescription.getPoints()) { markers.add(createMarker(map, new MarkerDescription(point, true, CENTER, new MarkerIconDescription(org.odk.collect.icons.R.drawable.ic_map_point)))); } update(); @@ -919,6 +930,7 @@ public void dispose() { map.getOverlays().remove(polyline); } + @Override public List getPoints() { List points = new ArrayList<>(); for (Marker marker : markers) { @@ -946,15 +958,14 @@ private class StaticPolygonFeature implements MapFeature { private final MapView map; private final Polygon polygon = new Polygon(); - StaticPolygonFeature(MapView map, Iterable points) { + StaticPolygonFeature(MapView map, PolygonDescription polygonDescription) { this.map = map; map.getOverlays().add(polygon); - int strokeColor = map.getContext().getResources().getColor(org.odk.collect.icons.R.color.mapLineColor); - polygon.getOutlinePaint().setColor(strokeColor); - polygon.setStrokeWidth(POLYLINE_STROKE_WIDTH); - polygon.getFillPaint().setColor(ColorUtils.setAlphaComponent(strokeColor, POLYGON_FILL_COLOR_OPACITY)); - polygon.setPoints(StreamSupport.stream(points.spliterator(), false).map(point -> new GeoPoint(point.latitude, point.longitude)).collect(Collectors.toList())); + polygon.getOutlinePaint().setColor(polygonDescription.getStrokeColor()); + polygon.setStrokeWidth(polygonDescription.getStrokeWidth()); + polygon.getFillPaint().setColor(polygonDescription.getFillColor()); + polygon.setPoints(StreamSupport.stream(polygonDescription.getPoints().spliterator(), false).map(point -> new GeoPoint(point.latitude, point.longitude)).collect(Collectors.toList())); polygon.setOnClickListener((polygon, mapView, eventPos) -> { int featureId = findFeature(polygon); if (featureClickListener != null && featureId != -1) { diff --git a/package.json b/package.json new file mode 100644 index 00000000000..2e06e3a3786 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "replace": "^1.2.2" + } +} diff --git a/permissions/src/test/resources/robolectric.properties b/permissions/src/test/resources/robolectric.properties index a333e53ef88..4f3945f61dc 100644 --- a/permissions/src/test/resources/robolectric.properties +++ b/permissions/src/test/resources/robolectric.properties @@ -1,3 +1 @@ -# Workaround for https://github.com/robolectric/robolectric/issues/6593 -instrumentedPackages=androidx.loader.content sdk=33 diff --git a/projects/build.gradle.kts b/projects/build.gradle.kts index d1b2aa16f79..cf96886d940 100644 --- a/projects/build.gradle.kts +++ b/projects/build.gradle.kts @@ -55,7 +55,6 @@ dependencies { implementation(Dependencies.androidx_core_ktx) implementation(Dependencies.androidx_fragment_ktx) implementation(Dependencies.gson) - implementation(Dependencies.android_material) implementation(Dependencies.dagger) kapt(Dependencies.dagger_compiler) diff --git a/robolectric-deps.properties b/robolectric-deps.properties index 697edf57bbf..ac8e15b3dd4 100644 --- a/robolectric-deps.properties +++ b/robolectric-deps.properties @@ -7,3 +7,4 @@ org.robolectric\:android-all-instrumented\:12-robolectric-7732740-i3=../../../.. org.robolectric\:android-all-instrumented\:12-robolectric-7732740-i4=../../../../../../robolectric-deps/android-all-instrumented-12-robolectric-7732740-i4.jar org.robolectric\:android-all-instrumented\:12.1-robolectric-8229987-i4=../../../../../../robolectric-deps/android-all-instrumented-12.1-robolectric-8229987-i4.jar org.robolectric\:android-all-instrumented\:13-robolectric-9030017-i4=../../../../../../robolectric-deps/android-all-instrumented-13-robolectric-9030017-i4.jar +org.robolectric\:android-all-instrumented\:14-robolectric-10818077-i4=../../../../../../robolectric-deps/android-all-instrumented-14-robolectric-10818077-i4.jar diff --git a/selfie-camera/build.gradle.kts b/selfie-camera/build.gradle.kts index aac7155555d..16e86670666 100644 --- a/selfie-camera/build.gradle.kts +++ b/selfie-camera/build.gradle.kts @@ -54,6 +54,7 @@ dependencies { implementation(Dependencies.camerax_lifecycle) implementation(Dependencies.camerax_video) implementation(Dependencies.camerax_camera2) + implementation("com.google.guava:guava:33.0.0-android") // Guava is a dependency required by CameraX. It shouldn't be used in any other context and should be removed when no longer necessary. implementation(Dependencies.dagger) kapt(Dependencies.dagger_compiler) diff --git a/settings.gradle b/settings.gradle index 3d340523d42..2fab48ad648 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,12 +1,12 @@ include ':projects' -include ':formstest' +include ':forms-test' include ':shared' include ':forms' include ':analytics' include ':androidshared' -include ':audiorecorder' +include ':audio-recorder' include ':test-shared' -include ':audioclips' +include ':audio-clips' include ':strings' include ':async' include ':collect_app' @@ -36,9 +36,11 @@ include ':metadata' include ':google-maps' include ':test-forms' include ':printer' +include ':draw' +include ':lists' apply from: 'secrets.gradle' if (getSecrets().getProperty('MAPBOX_DOWNLOADS_TOKEN', '') != '') { include ':mapbox' } -include ':draw' +include ':web-page' diff --git a/settings/src/main/java/org/odk/collect/settings/enums/AutoSend.kt b/settings/src/main/java/org/odk/collect/settings/enums/AutoSend.kt new file mode 100644 index 00000000000..046b9f57ba6 --- /dev/null +++ b/settings/src/main/java/org/odk/collect/settings/enums/AutoSend.kt @@ -0,0 +1,11 @@ +package org.odk.collect.settings.enums + +import androidx.annotation.StringRes +import org.odk.collect.settings.R + +enum class AutoSend(@StringRes override val stringId: Int) : StringIdEnum { + OFF(R.string.auto_send_off), + WIFI_ONLY(R.string.auto_send_wifi_only), + CELLULAR_ONLY(R.string.auto_send_cellular_only), + WIFI_AND_CELLULAR(R.string.auto_send_wifi_and_cellular) +} diff --git a/settings/src/main/java/org/odk/collect/settings/enums/FormUpdateMode.java b/settings/src/main/java/org/odk/collect/settings/enums/FormUpdateMode.java new file mode 100644 index 00000000000..0196af39564 --- /dev/null +++ b/settings/src/main/java/org/odk/collect/settings/enums/FormUpdateMode.java @@ -0,0 +1,21 @@ +package org.odk.collect.settings.enums; + +import org.odk.collect.settings.R; + +public enum FormUpdateMode implements StringIdEnum { + + MANUAL(R.string.form_update_mode_manual), + PREVIOUSLY_DOWNLOADED_ONLY(R.string.form_update_mode_previously_downloaded), + MATCH_EXACTLY(R.string.form_update_mode_match_exactly); + + private final int string; + + FormUpdateMode(int string) { + this.string = string; + } + + @Override + public int getStringId() { + return string; + } +} diff --git a/settings/src/main/java/org/odk/collect/settings/enums/StringIdEnum.kt b/settings/src/main/java/org/odk/collect/settings/enums/StringIdEnum.kt new file mode 100644 index 00000000000..461aa9466e9 --- /dev/null +++ b/settings/src/main/java/org/odk/collect/settings/enums/StringIdEnum.kt @@ -0,0 +1,11 @@ +package org.odk.collect.settings.enums + +import android.content.Context + +internal interface StringIdEnum { + val stringId: Int + + fun getValue(context: Context): String { + return context.getString(stringId) + } +} diff --git a/settings/src/main/java/org/odk/collect/settings/enums/StringIdEnumUtils.kt b/settings/src/main/java/org/odk/collect/settings/enums/StringIdEnumUtils.kt new file mode 100644 index 00000000000..fc835bb4ed2 --- /dev/null +++ b/settings/src/main/java/org/odk/collect/settings/enums/StringIdEnumUtils.kt @@ -0,0 +1,29 @@ +package org.odk.collect.settings.enums + +import android.content.Context +import org.odk.collect.settings.keys.ProjectKeys +import org.odk.collect.shared.settings.Settings + +object StringIdEnumUtils { + + @JvmStatic + fun Settings.getFormUpdateMode(context: Context): FormUpdateMode { + val setting = this.getString(ProjectKeys.KEY_FORM_UPDATE_MODE) + return parse(context, setting) + } + + @JvmStatic + fun Settings.getAutoSend(context: Context): AutoSend { + val setting = this.getString(ProjectKeys.KEY_AUTOSEND) + return parse(context, setting) + } + + private inline fun parse( + context: Context, + value: String? + ): T where T : Enum, T : StringIdEnum { + return enumValues().find { + context.getString(it.stringId) == value + } ?: throw IllegalArgumentException() + } +} diff --git a/settings/src/main/java/org/odk/collect/settings/keys/MetaKeys.kt b/settings/src/main/java/org/odk/collect/settings/keys/MetaKeys.kt index db4cd7055e7..12dc4f2bc40 100644 --- a/settings/src/main/java/org/odk/collect/settings/keys/MetaKeys.kt +++ b/settings/src/main/java/org/odk/collect/settings/keys/MetaKeys.kt @@ -8,6 +8,7 @@ object MetaKeys { const val CURRENT_PROJECT_ID = "current_project_id" const val KEY_PROJECTS = "projects" const val EXISTING_PROJECT_IMPORTED = "existing_project_imported" + const val OLD_SAVEPOINTS_IMPORTED = "old_savepoints_imported" const val LAST_LAUNCHED = "last_launched" const val LAST_USED_PEN_COLOR = "last_used_pen_color" const val PERMISSIONS_REQUESTED = "permissions_requested" diff --git a/settings/src/main/java/org/odk/collect/settings/keys/ProjectKeys.kt b/settings/src/main/java/org/odk/collect/settings/keys/ProjectKeys.kt index 3a1a3c5d672..2b99b5ae96b 100644 --- a/settings/src/main/java/org/odk/collect/settings/keys/ProjectKeys.kt +++ b/settings/src/main/java/org/odk/collect/settings/keys/ProjectKeys.kt @@ -50,6 +50,9 @@ object ProjectKeys { const val KEY_BACKGROUND_LOCATION = "background_location" const val KEY_BACKGROUND_RECORDING = "background_recording" + // experimental_preferences.xml + const val KEY_LOCAL_ENTITIES = "experimental_local_entities" + // values const val PROTOCOL_SERVER = "odk_default" const val PROTOCOL_GOOGLE_SHEETS = "google_sheets" @@ -67,4 +70,8 @@ object ProjectKeys { const val BASEMAP_SOURCE_OSM = "osm" const val BASEMAP_SOURCE_USGS = "usgs" const val BASEMAP_SOURCE_CARTO = "carto" + + // remembered defaults + const val KEY_SAVED_FORM_SORT_ORDER = "instanceUploaderListSortingOrder" + const val KEY_BLANK_FORM_SORT_ORDER = "formChooserListSortingOrder" } diff --git a/settings/src/main/java/org/odk/collect/settings/keys/ProtectedProjectKeys.kt b/settings/src/main/java/org/odk/collect/settings/keys/ProtectedProjectKeys.kt index ae373d02f17..a6e201ccfbb 100644 --- a/settings/src/main/java/org/odk/collect/settings/keys/ProtectedProjectKeys.kt +++ b/settings/src/main/java/org/odk/collect/settings/keys/ProtectedProjectKeys.kt @@ -47,13 +47,11 @@ object ProtectedProjectKeys { fun allKeys() = listOf( KEY_ADMIN_PW, - KEY_EDIT_SAVED, KEY_SEND_FINALIZED, KEY_VIEW_SENT, KEY_GET_BLANK, KEY_DELETE_SAVED, - KEY_CHANGE_SERVER, KEY_CHANGE_PROJECT_DISPLAY, KEY_APP_THEME, @@ -75,7 +73,6 @@ object ProtectedProjectKeys { KEY_INSTANCE_FORM_SYNC, KEY_CHANGE_FORM_METADATA, KEY_ANALYTICS, - KEY_MOVING_BACKWARDS, KEY_ACCESS_SETTINGS, KEY_CHANGE_LANGUAGE, diff --git a/settings/src/main/res/values/strings.xml b/settings/src/main/res/values/strings.xml new file mode 100644 index 00000000000..77e626e5abc --- /dev/null +++ b/settings/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + + off + wifi_only + cellular_only + wifi_and_cellular + + manual + previously_downloaded + match_exactly + diff --git a/shared/src/main/java/org/odk/collect/shared/PathUtils.kt b/shared/src/main/java/org/odk/collect/shared/PathUtils.kt index 71e04c51e7b..1f4ba227cc8 100644 --- a/shared/src/main/java/org/odk/collect/shared/PathUtils.kt +++ b/shared/src/main/java/org/odk/collect/shared/PathUtils.kt @@ -1,7 +1,5 @@ package org.odk.collect.shared -import java.io.File - object PathUtils { @JvmStatic @@ -9,11 +7,6 @@ object PathUtils { return if (filePath.startsWith(dirPath)) filePath.substring(dirPath.length + 1) else filePath } - @JvmStatic - fun getAbsoluteFilePath(dirPath: String, filePath: String): String { - return if (filePath.startsWith(dirPath)) filePath else dirPath + File.separator + filePath - } - // https://stackoverflow.com/questions/2679699/what-characters-allowed-in-file-names-on-android @JvmStatic fun getPathSafeFileName(fileName: String) = fileName.replace("[\"*/:<>?\\\\|]".toRegex(), "_") diff --git a/shared/src/main/java/org/odk/collect/shared/TempFiles.kt b/shared/src/main/java/org/odk/collect/shared/TempFiles.kt index f5b8fb422e1..f58c52b628f 100644 --- a/shared/src/main/java/org/odk/collect/shared/TempFiles.kt +++ b/shared/src/main/java/org/odk/collect/shared/TempFiles.kt @@ -84,7 +84,7 @@ object TempFiles { } private fun getTempDir(): File { - val tmpDir = File(System.getProperty("java.io.tmpdir", "."), " org.odk.collect.shared.TempFiles") + val tmpDir = File(System.getProperty("java.io.tmpdir", "."), "org.odk.collect.shared.TempFiles") if (!tmpDir.exists()) { tmpDir.mkdir() } diff --git a/shared/src/main/java/org/odk/collect/shared/files/FileUtils.kt b/shared/src/main/java/org/odk/collect/shared/files/FileUtils.kt new file mode 100644 index 00000000000..bf1dc990e19 --- /dev/null +++ b/shared/src/main/java/org/odk/collect/shared/files/FileUtils.kt @@ -0,0 +1,23 @@ +package org.odk.collect.shared.files + +import java.io.File +import java.io.IOException +import java.io.InputStream +import kotlin.jvm.Throws + +object FileUtils { + @Throws(IOException::class) + @JvmStatic + fun saveToFile(inputStream: InputStream, filePath: String) { + val file = File(filePath) + if (file.exists() && !file.delete()) { + throw IOException("Cannot overwrite $filePath. Perhaps the file is locked?") + } + + inputStream.use { input -> + file.outputStream().use { output -> + input.copyTo(output) + } + } + } +} diff --git a/shared/src/test/java/org/odk/collect/shared/PathUtilsTest.kt b/shared/src/test/java/org/odk/collect/shared/PathUtilsTest.kt index 8e33688dc75..b7c2dede84b 100644 --- a/shared/src/test/java/org/odk/collect/shared/PathUtilsTest.kt +++ b/shared/src/test/java/org/odk/collect/shared/PathUtilsTest.kt @@ -5,25 +5,6 @@ import org.hamcrest.Matchers.equalTo import org.junit.Test class PathUtilsTest { - - @Test - fun `getAbsoluteFilePath() returns filePath prepended with dirPath`() { - val path = PathUtils.getAbsoluteFilePath("/anotherRoot/anotherDir", "root/dir/file") - assertThat(path, equalTo("/anotherRoot/anotherDir/root/dir/file")) - } - - @Test - fun `getAbsoluteFilePath() returns valid path when filePath does not start with seperator`() { - val path = PathUtils.getAbsoluteFilePath("/root/dir", "file") - assertThat(path, equalTo("/root/dir/file")) - } - - @Test - fun `getAbsoluteFilePath() returns filePath when it starts with dirPath`() { - val path = PathUtils.getAbsoluteFilePath("/root/dir", "/root/dir/file") - assertThat(path, equalTo("/root/dir/file")) - } - @Test fun `getRelativeFilePath() returns filePath with dirPath removed`() { val path = PathUtils.getRelativeFilePath("/root/dir", "/root/dir/file") diff --git a/strings/src/main/res/values-af/strings.xml b/strings/src/main/res/values-af/strings.xml index b962d46bb63..f6ebdc1f576 100644 --- a/strings/src/main/res/values-af/strings.xml +++ b/strings/src/main/res/values-af/strings.xml @@ -180,8 +180,21 @@ - Vertoon of Verander Posisie - Stoor Posisie + + + + + + + + + + + + + + + Jammer, Posisie-verskaffers is afgeskakel! @@ -201,7 +214,6 @@ - @@ -253,9 +265,7 @@ Verwyder Geselekteerde Moenie Verwyder Nie Verwyder Vorms - Jammer, verwydering van %1$s van %2$s geselekteerde vorm(s) het misluk! %s vorm(s) suksesvol verwyder! - Jammer, daar is klaar \'n aksie aan die gang om \'n vorm te verwyder! @@ -335,6 +345,7 @@ Voer Admin wagwoord in Deselekteer om van Hoofkieslys af te haal Reset + Reset + + + + + @@ -433,4 +449,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-am/strings.xml b/strings/src/main/res/values-am/strings.xml index 7e1df967230..7fe71745daf 100644 --- a/strings/src/main/res/values-am/strings.xml +++ b/strings/src/main/res/values-am/strings.xml @@ -190,8 +190,21 @@ መንገዶች መልከአ ምድር ሳተላይት - አካባቢን ይመልከቱ ወይም ይቀይሩ - ቦታን መዝግብ + + + + + + + + + + + + + + + ይቅርታ፣ የአካባቢ አቅራቢዎች አይሰሩም፡፡ @@ -213,12 +226,9 @@ አስወግድ - GeoShapeን ይመልከቱ ወይም ይቀይሩ - GeoTraceን ይመልከቱ ወይም ይቀይሩ - @@ -269,9 +279,7 @@ የተመረጠውን ሰርዝ አትሰርዝ ቅጾችን ሰርዝ - እናዝናለን፣ የተመረጠው 1 %1$s የ 2 %2$s ቅጽ (ዎች) መሰረዝ አልተሳካም! 1 %s ቅጽ (ዎች) በተሳካ ሁኔታ ተሰርዘዋል! - ይቅርታ፣ ቅጽ የመሰረዝ እርምጃ አስቀድሞ በሂደት ላይ ነው! @@ -437,6 +445,11 @@ # Permissions ##############################################--> + + + + + @@ -481,4 +494,22 @@ + + + + + + አስወግድ + + + + + + + + + + diff --git a/strings/src/main/res/values-ar/strings.xml b/strings/src/main/res/values-ar/strings.xml index fd4678417f0..116ff6669f5 100644 --- a/strings/src/main/res/values-ar/strings.xml +++ b/strings/src/main/res/values-ar/strings.xml @@ -380,14 +380,24 @@ غير قادر على الوصول إلى خرائط الجوجل. هل خدمات Google Play مثبتة؟ بوزيترون المادة المظلمة - الطبقة المرجعية - ملف بيانات الطبقة - الاطلاع على الموقع أو تغيير الموقع - تغيير الموقع + + + + + + + + + + + + + + + سجل نقطة الدقة: %1$s متر مزود خدمة تحديد الموقع: %s - تسجيل الموقع عذراً، مزود خدمة الموقع معطّل. خط العرض: %1$s\n خط الطول : %2$s\nالارتفاع : %3$s\nالدقة : %4$s متر تريد هذه الاستمارة تتبع موقعك ولكن خدمات Google Play غير متوفرة. @@ -402,12 +412,6 @@ هذه الاستمارة تقوم بتتبع موقعك. يمكنك تعطيل التتبع في القائمة %1$s أعلاه. تحقق من الأخطاء - البدء بتحديد النقطة - عرض نقاط الخريطة - عرض GeoShape - عرض GeoTrace - البدء بتحديد مضلع - البدء بتتبع المسار @@ -481,13 +485,10 @@ انقر مطولاً لإنشاء علامة او انقر على زر إضافة علامة انقر على زر مؤشر الإضافة تجاهل - عرض أو تغيير المضلع - عرض أو تغيير المسار عرض الإستمارات المحفوظة - @@ -552,11 +553,8 @@ إحذف الخيارات المحددة لا تحذف حذف الاستمارات - عذرا, فشل حذف %1$s من %2$s من الاستمارة/ات المحددة! جاري حذف الاستمارات المحددة - حذف الاستمارة : %1$d من أصل %2$d %s تم حذف الاستمارة /ت! - عذراً، يوجد حاليا استمارة يتم حذفها! @@ -821,6 +819,11 @@ # Permissions ##############################################--> + + + + + @@ -865,4 +868,22 @@ + + + + + + تجاهل + + + + + + + + + + diff --git a/strings/src/main/res/values-bg/strings.xml b/strings/src/main/res/values-bg/strings.xml index cd4dc608730..b2ab549634d 100644 --- a/strings/src/main/res/values-bg/strings.xml +++ b/strings/src/main/res/values-bg/strings.xml @@ -118,6 +118,21 @@ + + + + + + + + + + + + + + + @@ -136,7 +151,6 @@ - @@ -251,6 +265,11 @@ # Permissions ##############################################--> + + + + + @@ -295,4 +314,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-bn/strings.xml b/strings/src/main/res/values-bn/strings.xml index d2e50660185..bcf5f46451b 100644 --- a/strings/src/main/res/values-bn/strings.xml +++ b/strings/src/main/res/values-bn/strings.xml @@ -151,7 +151,21 @@ ভূখণ্ড হাইব্রিড উপগ্রহ - জিপিএস স্থান চিন্হিত করুন + + + + + + + + + + + + + + + দুঃখিত, অবস্থান প্রদানকারী সেবা বন্ধ আছে! @@ -174,7 +188,6 @@ - @@ -224,9 +237,7 @@ ফাইল ডিলিট মুছবেন না ফরম মুছে দিন - দুঃখিত, %1$s এর %2$s নির্বাচিত/ (নির্বাচিতগুলো) মুছে ফেলতে ব্যার্থ হয়েছে! %s ফরম/(ফরমগুলি) সফলভাবে মুছে গেছে! - দুঃখিত, একটি ফরম মুছে ফেলার প্রক্রিয়া ইতিমধ্যে চলমান! @@ -300,6 +311,7 @@ এডমিন পাসওয়ার্ড দিন। প্রধান মেনু্তে পাবেন রিসেট + রিসেট + + + + + @@ -394,4 +411,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-ca/strings.xml b/strings/src/main/res/values-ca/strings.xml index 857b523db61..2b5c45829fc 100644 --- a/strings/src/main/res/values-ca/strings.xml +++ b/strings/src/main/res/values-ca/strings.xml @@ -205,18 +205,27 @@ Híbrid Satel·lit No pot accedir a Google Maps. Teniu el servei instal·lat ? - Veure o modificar posició - Enregistrar Localització + + + + + + + + + + + + + + + Atenció, la Locatització està desactivada! Latitud: %1$s\nLongitud: %2$s\nAltitud: %3$sm\nPrecisió: %4$sm - Comença localització gps - Veure localització gps - Comença GeoForma - Comença GeoTraça @@ -239,12 +248,9 @@ Prem una estona o toca el botó d\'afegir fita. Toca el botó afegir fita Descarta - Visualitza o modifica GeoShape - Visualitza o modifica GeoShape - @@ -297,13 +303,12 @@ Eliminar seleccionats No eliminar Esborrar formularis - Ho sentim, %1$s de %2$s formulari(s) seleccionat(s) no s\'han pogut eliminar! %s formularis(s) eliminats correctament! - Ho sentim, s\'está eliminant el formulari! + Servidor @@ -463,6 +468,11 @@ # Permissions ##############################################--> + + + + + @@ -507,4 +517,22 @@ + + + + + + Descarta + + + + + + + + + + diff --git a/strings/src/main/res/values-cs/strings.xml b/strings/src/main/res/values-cs/strings.xml index b9c692509cc..1d2add675f9 100644 --- a/strings/src/main/res/values-cs/strings.xml +++ b/strings/src/main/res/values-cs/strings.xml @@ -385,14 +385,24 @@ Nelze přistupovat k Mapám Google. Je služba Google Play nainstalována? Positron Temná hmota - Referenční vrstva - Soubor datové vrstvy - Prohlédnout nebo změnit polohu - Změnit lokaci + + + + + + + + + + + + + + + Zaznamenat bod Přesnost: %1$s m Poskytovatel polohy: %s - Uložit pozici Nástroje pro získání polohy jsou zakázané! Zeměpisná šířka:%1$s \n Zeměpisná délka: %2$s \n Výška: %3$s m \n Přesnost %4$sm Tento formulář chce sledovat vaši polohu, ale služby Google Play nejsou k dispozici. @@ -405,12 +415,6 @@ Tento formulář sleduje vaši polohu. Sledování můžete zakázat v nabídce %1$s výše. Kontrola chyb - Začít GeoPoint - Zobrazit GeoPoint - Zobrazit GeoShape - Zobrazit GeoTrace - Začít GeoShape - Začít GeoTrace Uplynulý čas: %1$s @@ -484,8 +488,6 @@ Dlouhým stisknutím označit značku nebo klepnutím na tlačítko přidat značku. Klepnutím na tlačítko přidat značku. Vyřadit - Zobrazit nebo změnit GeoShape - Zobrazit nebo změnit GeoTrace %s: %d (%d zobrazeno na mapě) Vybrat Nová položka @@ -496,8 +498,6 @@ Sledování polohy Sledování polohy… - - Získejte nápovědu k referenčním vrstvám Zobrazit mou polohu @@ -578,11 +578,8 @@ Smazat označené Nemazat Smazat dotazníky - %1$s z %2$s vybraných dotazníků se nepodařilo smazat! Smazání vybraných formulářů - Vymazání formuláře: %1$d z %2$d %s dotazníků úspěšně smazáno! - Mazání dotazníku už probíhá! @@ -915,6 +912,11 @@ Otevřít nastavení Subjekty + + + + + @@ -998,4 +1000,22 @@ Žádné formuláře k odstranění. + + + + + + Vyřadit + + + + + + + + + + diff --git a/strings/src/main/res/values-da/strings.xml b/strings/src/main/res/values-da/strings.xml index 976012fc55d..418058ae224 100644 --- a/strings/src/main/res/values-da/strings.xml +++ b/strings/src/main/res/values-da/strings.xml @@ -222,23 +222,28 @@ Udendørs Topografisk Positron - Referencelag - Se eller skift lokalitet - Skift position + + + + + + + + + + + + + + + Præcision: 1%1$s m Lokalitetsudbyder: 1%s - Gem lokation Geolokation er slået fra - Start GeoPoint - Vis GeoPoint - Vis GeoShape - Vis GeoTrace - Start GeoShape - Start GeoTrace @@ -292,12 +297,10 @@ Punkter indtastet: %d(manuel optagelse) Punkter indtastet: %1$d(optager hver %2$dsekund) Kassér - Gennemse eller ændr´ GeoShape Inpicér gemt formular - @@ -351,9 +354,7 @@ Slet valgte Undlad at slette Fjern formularer - Desværre, 1%1$s af 2%2$s valgte formularer fejlede ved sletning 1%s formular slettet - Der er allerede en sletning af formular i gang @@ -528,6 +529,11 @@ # Permissions ##############################################--> + + + + + @@ -572,4 +578,22 @@ + + + + + + Kassér + + + + + + + + + + diff --git a/strings/src/main/res/values-de/strings.xml b/strings/src/main/res/values-de/strings.xml index 116c19e6b64..5ed1bce4ff0 100644 --- a/strings/src/main/res/values-de/strings.xml +++ b/strings/src/main/res/values-de/strings.xml @@ -391,14 +391,37 @@ Zugriff auf Google Maps nicht möglich. Sind die Google Play Services installiert? Positron Dunkle Materie - Referenzebene - Ebenendaten-Datei - Standort anzeigen oder ändern - Standort ändern + + Offline-Ebenen + + Ebene + + Offline-Ebene auswählen + + Wählen Sie die Offline-Ebene aus, die für alle Karten in diesem Projekt verwendet werden soll. Sie können der Liste Optionen hinzufügen, indem Sie eine MBTiles-Datei von Ihrem Gerät auswählen, und vorhandene Ebenen aus der Liste löschen.\" + + Mehr über das Hinzufügen von MBTiles erfahren? + + Ebenen hinzufügen + + Ebene löschen + + + + + Ebenen + + Ebenenzugriff auswählen + Sollen die Ebenen in allen Projekten oder nur im aktuellen verfügbar sein? + + Alle Projekte + + Nur aktuelles Projekt + + Möchten sie wirklich die %1$s Offline-Ebene löschen? Punkt erfassen Genauigkeit: %1$s m Standortdienst: %s - Standort erfassen Standortdienste sind nicht aktiviert! Breite (Lat.): %1$s\nLänge (Long.): %2$s\nHöhe (Alt.): %3$sm\nGenauigkeit: %4$sm Dieses Formular möchte Ihren Standort aufzeichnen, aber die Google Play-Dienste sind nicht verfügbar. @@ -413,12 +436,6 @@ Dieses Formular zeichnet Ihren Standort auf. Sie können die Standortaufzeichnung oben im %1$s Menü deaktivieren. Auf Fehler überprüfen - Standort erfassen - Standort anzeigen - GeoShape anzeigen - GeoTrace anzeigen - GeoShape starten - GeoTrace starten Vergangene Zeit: %1$s @@ -486,8 +503,6 @@ Lange Drücken um eine Markierung zu platzieren oder Button Markierung hinzufügen antippen. Button Markierung hinzufügen antippen. Verwerfen - GeoShape anzeigen oder ändern - GeoTrace anzeigen oder ändern %s: %d (%d auf der Karte angezeigt) Auswählen Neues Element @@ -498,8 +513,6 @@ Standortverlauf Standort wird aufgezeichnet… - - Hilfe zu Referenzebenen Meinen Standort anzeigen @@ -580,11 +593,8 @@ Auswahl löschen Nicht löschen Formulare löschen - %1$s von %2$s Formular(en) konnte(n) nicht gelöscht werden! Ausgewählte Formulare löschen - Lösche Formular: %1$d von %2$d %s Formular(e) erfolgreich gelöscht! - Ein Formularlöschvorgang ist bereits aktiv! @@ -917,6 +927,17 @@ Einstellungen öffnen Objekte + + Lokale Entitäten aktivieren + Follow-Up-Formulare werden konsistente Objektlisten haben und lokal erzeugte oder aktualisierte Objekte beinhalten. + + Objektliste anzeigen + + Alle löschen + + Objektliste hinzufügen + + Offline @@ -1035,4 +1056,35 @@ Wenn sie Leerformulare heruntergeladen haben, erscheinen sie hier. Wenn Sie gespeicherte Formulare haben, erscheinen sie hier. + + + Ihre Arbeit Wiederherstellen? + + \'Collect wurde am \'EEE, MMM dd, yyyy \'um\' HH:mm\' unerwartet beendet und hat ihre Arbeit gespeichert.\n\nWollen Sie die gespeicherte Arbeit wiederherstellen oder verwerfen?\' + + Wiederherstellen + + Verwerfen + + Punkt erhalten + + Punkt anzeigen oder ändern + + Punkt anzeigen + + Punkt ändern + + Linie erhalten + + Linie anzeigen oder ändern + + Linie anzeigen + + Polygon erhalten + + Polygon anzeigen oder ändern + + Polygon anzeigen diff --git a/strings/src/main/res/values-es/strings.xml b/strings/src/main/res/values-es/strings.xml index 3de54afe610..ba728429bb7 100644 --- a/strings/src/main/res/values-es/strings.xml +++ b/strings/src/main/res/values-es/strings.xml @@ -175,6 +175,7 @@ Esta aplicación no está. Por favor ingrese el dato manualmente. La aplicación externa no suministra la información esperada + Imprimir Iniciar Impresión La impresora solicitado no está instalada. Por favor, instale la impresora. Abrir URL @@ -388,14 +389,24 @@ No se puede acceder a Google Maps. ¿Están los servicios de Google Play instalados? Positrón Materia oscura - Capa de referencia - Archivo de datos de capa - Ver o cambiar de localización - Cambiar Ubicación + + + + + + + + + + + + + + + Registrando un punto Precisión: %1$s m Proveedor de Ubicación: %s - Obtener localización ¡Los proveedores de ubicación están deshabilitados! Latitud: %1$s\nLongitud: %2$s\nAltitud: %3$sm\nPrecisión: %4$sm Este formulario quiere rastrear su ubicación, pero los servicios de Google Play no están disponibles. @@ -408,12 +419,6 @@ Este formulario registra su localización. Puede deshabilitar el rastreo en el menú de arriba %1$s. Comprobar si hay errores - Buscar Ubicación - Ver Ubicación - Ver GeoArea - Ver GeoLínea - Iniciar GeoArea - Iniciar GeoLínea Tiempo transcurrido: %1$s @@ -484,8 +489,6 @@ Pulsar prolongadamente para colocar la marca o tocar el botón marcador. Tap botón de agregar marker Descartar - Ver o cambiar GeoArea - Ver o cambiar GeoLínea %s: %d (%d mostrar en el mapa) Seleccionar Nuevo ítem @@ -496,8 +499,6 @@ Seguimiento de ubicación Monitoreando ubicación… - - Obtenga ayuda con las capas de referencia Mostrar mi ubicación @@ -578,11 +579,8 @@ Borrar los Seleccionados No Borrar Borrar los Formularios - ¡%1$s de %2$s formulario(s) seleccionados no se borraron! Eliminando formularios seleccionados - Eliminando formularios %1$d de %2$d  ¡%s formulario(s) se borraron correctamente! - ¡Disculpe, una acción de borrar esta todavía en progreso! @@ -916,6 +914,11 @@ Abrir Configuración Entidades + + + + + @@ -924,6 +927,7 @@ + Este proyecto estaba previamente conectado a una cuenta de Google Drive. Se ha eliminado la compatibilidad con Google Drive. Puede configurar un servidor o conectar a una computadora. Aprende más + + + + + + Descartar + + + + + + + + + + diff --git a/strings/src/main/res/values-et/strings.xml b/strings/src/main/res/values-et/strings.xml index 7db1d63fcd6..62ca42d6ad3 100644 --- a/strings/src/main/res/values-et/strings.xml +++ b/strings/src/main/res/values-et/strings.xml @@ -210,18 +210,27 @@ Hübriid Satelliit Puudub ligipääs Google Maps-ile. Veenduge, et Google Play teenused on paigaldatud. - Vaata või muuda asukohta - Salvesta asukoht + + + + + + + + + + + + + + + Kahjuks on asukoha küsimine maha keeratud! Laiuskraad: %1$s\nPikkuskraad: %2$s\nKõrgus: %3$sm\nTäpsusaste: %4$sm - Loo asukohapunkt - Vaata asukohapunkti - Loo polügoon - Alusta rada @@ -248,12 +257,9 @@ Markeri paigutamiseks tuleb teha pikk hoidmine või valida marker nupp. Puuduta lisa marke nuppu. Loobu - Vaata või muuda polügooni - Vaata või muuda rada - @@ -306,10 +312,7 @@ Kustuta valitu Ei kustuta Kustuta vormid - Kahjuks ei õnnestunud kustutada %1$s / %2$s valitud vormidest. - %s vorm(id) edukalt kustutatud! - Kahjuks kustutamise tegevus on juba töös! @@ -468,6 +471,11 @@ # Permissions ##############################################--> + + + + + @@ -512,4 +520,22 @@ + + + + + + Loobu + + + + + + + + + + diff --git a/strings/src/main/res/values-fa-rAF/strings.xml b/strings/src/main/res/values-fa-rAF/strings.xml index 0b1abe3d63d..c7398c376ee 100644 --- a/strings/src/main/res/values-fa-rAF/strings.xml +++ b/strings/src/main/res/values-fa-rAF/strings.xml @@ -357,14 +357,24 @@ دسترسی به نقشه های گوگل امکان پذیر نیست. آیا خدمات Google Play نصب شده است؟ پوزیترون ماده تاریک - مرجع به لایه - لایه data فایل - مشاهده یا تغییر مکان - تغییر موقعیت + + + + + + + + + + + + + + + یک نقطه را ثبت کنید دقت: %1$s متر ارائه دهنده مکان: %s - ثبت لوکیشن با معذرت، نشان دهنده های مکان غیرفعال هستند! عرض جغرافیایی: %1$s\n طول: %2$s\lارتفاع: %3$smg دقت: %4$sm این فورم می خواهد موقعیت مکانی شما را ردیابی کند اما خدمات Google Play در دسترس نیست. @@ -377,12 +387,6 @@ این فورم موقعیت مکانی شما را ردیابی می کند. می‌توانید ردیابی را در منوی %1$s بالا غیرفعال کنید. بررسی خطا - آغاز GeoPoint - نمایش GeoPoint - نمایش GeoShape - نمایش GeoTrace - آغاز GeoShape - آغاز GeoTrace زمان سپری شده: %1$s @@ -446,8 +450,6 @@ برای قرار دادن علامت طولانتر فشار دهید یا روی دکمه افزودن نشانگر انگیشت بکشید روی دکمه افزودن نشانگر انگشیتان را بکشید دور انداختن - نمایش و یا تغییر GeoShape - نمایش و یا تغییر GeoShape %s: %d (%d روی نقشه نشان داده شده است) برگزیدن گزینه جدید @@ -458,8 +460,6 @@ ردیابی موقعیت مکانی ردیابی مکان… - - از لایه های مرجع کمک بگیرید نمایش لوکیشن من @@ -540,11 +540,8 @@ حذف مورد انتخاب شده حذف نکنید حذف فورم ها - متأسفیم، %1$s از %2$s فورم(های) انتخابی حذف نشد! حذف کردن فورم های انتخاب شده - در حال حذف فورم: %1$d از %2$d %sفورم(ها) موفقانه حذف گردید! - با معذرت، پروسه حذف فورم از قبل در جریان است! @@ -875,6 +872,11 @@ تنظیمات را باز کنید موجودیت ها + + + + + @@ -984,4 +986,22 @@ + + + + + + دور انداختن + + + + + + + + + + diff --git a/strings/src/main/res/values-fa/strings.xml b/strings/src/main/res/values-fa/strings.xml index 0b1abe3d63d..c7398c376ee 100644 --- a/strings/src/main/res/values-fa/strings.xml +++ b/strings/src/main/res/values-fa/strings.xml @@ -357,14 +357,24 @@ دسترسی به نقشه های گوگل امکان پذیر نیست. آیا خدمات Google Play نصب شده است؟ پوزیترون ماده تاریک - مرجع به لایه - لایه data فایل - مشاهده یا تغییر مکان - تغییر موقعیت + + + + + + + + + + + + + + + یک نقطه را ثبت کنید دقت: %1$s متر ارائه دهنده مکان: %s - ثبت لوکیشن با معذرت، نشان دهنده های مکان غیرفعال هستند! عرض جغرافیایی: %1$s\n طول: %2$s\lارتفاع: %3$smg دقت: %4$sm این فورم می خواهد موقعیت مکانی شما را ردیابی کند اما خدمات Google Play در دسترس نیست. @@ -377,12 +387,6 @@ این فورم موقعیت مکانی شما را ردیابی می کند. می‌توانید ردیابی را در منوی %1$s بالا غیرفعال کنید. بررسی خطا - آغاز GeoPoint - نمایش GeoPoint - نمایش GeoShape - نمایش GeoTrace - آغاز GeoShape - آغاز GeoTrace زمان سپری شده: %1$s @@ -446,8 +450,6 @@ برای قرار دادن علامت طولانتر فشار دهید یا روی دکمه افزودن نشانگر انگیشت بکشید روی دکمه افزودن نشانگر انگشیتان را بکشید دور انداختن - نمایش و یا تغییر GeoShape - نمایش و یا تغییر GeoShape %s: %d (%d روی نقشه نشان داده شده است) برگزیدن گزینه جدید @@ -458,8 +460,6 @@ ردیابی موقعیت مکانی ردیابی مکان… - - از لایه های مرجع کمک بگیرید نمایش لوکیشن من @@ -540,11 +540,8 @@ حذف مورد انتخاب شده حذف نکنید حذف فورم ها - متأسفیم، %1$s از %2$s فورم(های) انتخابی حذف نشد! حذف کردن فورم های انتخاب شده - در حال حذف فورم: %1$d از %2$d %sفورم(ها) موفقانه حذف گردید! - با معذرت، پروسه حذف فورم از قبل در جریان است! @@ -875,6 +872,11 @@ تنظیمات را باز کنید موجودیت ها + + + + + @@ -984,4 +986,22 @@ + + + + + + دور انداختن + + + + + + + + + + diff --git a/strings/src/main/res/values-fi/strings.xml b/strings/src/main/res/values-fi/strings.xml index c22f1670295..a25683006a9 100644 --- a/strings/src/main/res/values-fi/strings.xml +++ b/strings/src/main/res/values-fi/strings.xml @@ -16,22 +16,22 @@ Versio: %s ID: %s - \'Lisätty\' EEE, dd MMM yyyy \'kello\' HH:mm + \'Lisätty\' EEE dd.MMM.yyyy \'kello\' HH:mm \'Päivitetty\' EEE dd.MM.yyyy \'kello\' HH:mm - \'Tallennettu\' EEE, dd MMM yyyy \'kello\' HH:mm + \'Tallennettu\' EEE dd.MM.yyyy \'kello\' HH:mm - \'Viimeistelty\' EEE, dd MMM yyyy \'kello\' HH:mm + \'Viimeistelty\' EEE dd.MM.yyyy \'kello\' HH:mm - \'Lähetetty\' EEE, dd MMM yyyy \'kello\' HH:mm + \'Lähetetty\' EEE dd.MM.yyyy \'kello\' HH:mm - \'Lähettäminen epäonnistunut\' EEE, dd MMM yyyy \'kello\' HH:mm + \'Lähettäminen epäonnistunut\' EEE dd.MM.yyyy \'kello\' HH:mm - \'Poistettu\' EEE, dd MMM yyyy \'kello\' HH:mm + \'Poistettu\' EEE dd.MM.yyyy \'kello\' HH:mm Lähetys poistettu - \'Muokattu\' EEE, dd MMM yyyy \'kello\' HH:mm + \'Muokattu\' EEE dd.MM.yyyy \'kello\' HH:mm Salattu lomake Poistettu lomake Lajittele lista @@ -134,10 +134,10 @@ Jatka muokkausta Tallennetaanko lomake? Voit tallentaa tämän lomakkeen ja löytää sen milloin tahansa luonnostesi joukosta. - \'Tämä lomake tallennettiin viimeksi\' EEE, MMM dd, yyyy \'kello\' HH:mm\'. Tallenna luonnos säilyttääksesi muutokset.\' + \'Tämä lomake tallennettiin viimeksi\' EEE dd.MM.yyyy \'kello\' HH:mm\'. Tallenna luonnos säilyttääksesi muutokset.\' Jatketaanko muokkausta? Jos hylkäät lomakkeen, tulet menettämään kaikki tähän mennessä tekemäsi muutokset. - \'Tämä lomake tallennettiin viimeksi\' EEE, MMM dd, yyyy \'kello\' HH:mm\'. Jos hylkäät muutokset, tulet menettämään kaikki tähän mennessä tekemäsi muutokset.\' + \'Tämä lomake tallennettiin viimeksi\' EEE dd.MM.yyyy \'kello\' HH:mm\'. Jos hylkäät muutokset, tulet menettämään kaikki tähän mennessä tekemäsi muutokset.\' Tallentamassa lomaketta Tarkistetaan vastauksia… Kerätää dataa… @@ -391,14 +391,43 @@ Google Maps -palveluun ei saada yhteyttä. Onko Google Play Services asennettu? Positroni Pimeä aine - Viitekerros - Kerrosdatatiedosto - Tarkastele tai vaihda sijaintia - Vaihda sijaintia + + Offlinetasot + + Kerros + + Valitse offlinetaso + + Valitse offline-taso kaikkiin karttoihin tässä projektissa. Voit lisätä vaihtoehtoja listaan valitsemalla MBTile-tiedoston laitteellasi tai poistaa aikaisemmin lisättyjä tasoja listalta. + + Opi lisää MBTile-tiedostojen lisäämisestä. + + Lisää tasoja + + Poista taso + + + %d tasoa ei voi lisätä + %d tasoa ei voi lisätä + + + Valitsemasi tiedostot eivät ole MBTiles-tiedostoja. Voit ainoastaan lisätä MBTiles-tiedostoja. + + Jotkut valitsemistasi tiedostoista eivät ole MBTiles-tiedostoja. Voit ainoastaan lisätä MBTiles-tiedostoja. + + Tasot + + Valitse tasojen saatavuus + Haluatko tasojen olevan käytettävissä kaikissa projekteissa vai ainoastaan nykyisessä? + + Kaikissa projekteissa + + Vain tässä projektissa + + Haluatko varmasti poistaa offline-tason %1$s? Tallenna piste Tarkkuus: %1$s m Sijaintilähde: %s - Tallenna sijainti Pahoittelut, sijaintitiedot eivät ole käytössä! Leveysasteet: %1$s\nPituusasteet: %2$s\nKorkeus: %3$sm\nTarkkuus: %4$sm Tämä lomake haluaa seurata sijaintiasi mutta Google Play Services ei ole käytettävissä. @@ -413,12 +442,6 @@ Tämä lomake seuraa sijaintiasi. Voit laittaa seurannan pois päältä valikossa %1$s yllä. Tarkista virheiden varalta - Käynnistä GeoPoint - Tarkastele GeoPoint - Katso GeoShape - Katso GeoTrace - Aloita GeoShape - Aloita GeoTrace Kulunut aika: %1$s @@ -486,8 +509,6 @@ Paina pitkään asettaaksesi merkin tai näpäytä merkkinäppäintä. Paina lisää merkki -näppäintä. Hylkää - Tarkastele tai Muuta GeoShape - Tarkastele tai Muuta GeoTrace %s: %d (%d näytetty kartalla) Valitse Uusi kohde @@ -498,8 +519,6 @@ Sijainnin seuranta Sijaintia seurataan… - - Viitekerrosten ohje Näytä sijaintini @@ -580,11 +599,8 @@ Poista valittu Älä poista Poista lomakkeita - Pahoittelut, poistaminen epäonnistui %1$s / %2$s valitun lomakkeen kohdalla! Valittuja lomakkeita poistetaan - Poistetaan lomake %1$d / %2$d %s lomakkeen poistaminen onnistui! - Pahoittelut, lomakkeiden poistaminen jo käynnissä! @@ -917,6 +933,17 @@ Avaa asetukset Entiteettejä + + Aktivoi paikalliset entiteetit + Seurantalomakkeilla tulee olemaan johdonmukaiset entiteettilistat ja tulevat sisällyttämään paikallisesti luodut tai päivitetyt entiteetit. + + Tarkastele entiteettilistoja + + Poista kaikki + + Lisää entiteettilista + + Offline @@ -1035,4 +1062,35 @@ Kun olet ladannut tyhjiä lomakkeita ne tulevat ilmestymään tänne Kun olet tallentanut lomakkeita ne tulevat ilmestymään tänne + + + Haluatko palauttaa työsi? + + \'Collect sulkeutui odottamattomasti\' EEE dd.MM.yyyy \'kello\' HH:mm\' ja tallensi työsi.\n\nHaluatko palauttaa työsi?\' + + Palauta + + Hylkää + + Hae piste + + Tarkastele tai muuta pistettä + + Tarkastele pistettä + + Muuta pistettä + + Hae viiva + + Tarkastele tai muuta viivaa + + Tarkastele viivaa + + Hae monikulmio + + Tarkastele tai muuta monikulmiota + + Tarkastele monikulmiota diff --git a/strings/src/main/res/values-fr/strings.xml b/strings/src/main/res/values-fr/strings.xml index e0e0644e801..c5eadc2ecfa 100644 --- a/strings/src/main/res/values-fr/strings.xml +++ b/strings/src/main/res/values-fr/strings.xml @@ -391,14 +391,44 @@ Impossible d’accéder à Google Maps. Google Play Services est-il installé ? Positron Matière Sombre - Couche de référence - Dossier de calques - Voir ou changer la position - Modifier la localisation + + Couches de carte hors ligne + + Couche + + Choisir couche hors ligne + + Choisissez la couche hors ligne à utiliser pour toutes les cartes de ce projet. Vous pouvez ajouter des options à la liste en choisissant un fichier MBTiles de votre appareil. Vous pouvez également supprimer des couches de la liste. + + En apprendre plus à propos de l\'ajout de MBTiles. + + Ajouter chouches + + Supprimer une couche + + + %d couche n\'a pas pu être ajoutée. + %d couches n\'ont pas pu être ajoutées. + %d couches n\'ont pas pu être ajoutées. + + + Les fichiers que vous avez sélectionnés ne sont pas des fichiers MBTiles. Vous pouvez seulement ajouter des fichiers MBTiles. + + Certains des fichiers que vous avez sélectionnés ne sont pas des fichiers MBTiles. Vous pouvez seulement ajouter des fichiers MBTiles. + + Couches + + Choisir l\'accès aux couches + Voulez vous que les couches soient disponibles dans tous les projets ou seulement dans le projet courant ? + + Tous les projets + + Projet courant seulement + + Voulez-vous vraiment supprimer la couche hors ligne %1$s? Enregistrer un point Précision: %1$s m Fournisseur de la localisation: 1%s - Enregistrement du lieu Désolé, le fournisseur de localisation est désactivé. Latitude: %1$s\nLongitude: %2$s\nAltitude: %3$sm\nPrécision: %4$sm Ce formulaire souhaite suivre votre localisation, mais les Services de Google Play ne sont pas disponible @@ -413,12 +443,6 @@ Ce formulaire suit votre position. Vous pouvez désactiver le suivi dans le %1$s menu ci-dessus. Vérifier s\'il y a des erreurs - Démarrer le point - Voir le point - Voir le polygone - Voir la ligne - Démarrer le polygone - Démarrer la ligne Temps écoulé : %1$s @@ -489,8 +513,6 @@ Appuyez longuement sur la carte pour placer un marqueur ou appuyez sur le bouton \"ajouter un marqueur\". Appuyez sur le bouton \"ajouter un marqueur\". Abandoner changements - Voir ou modifier le polygone - Voir ou modifier la ligne %s: %d (%d affichés sur la carte) Sélectionner Nouvel item @@ -501,8 +523,6 @@ Suivi de la localisation Enregistrement de la localisation… - - Obtenir de l\'aide au sujet des couches de référence Montrer ma position @@ -583,11 +603,8 @@ Supprimer la sélection Ne pas supprimer Supprimer ces formulaires - Désolé, %1$s de %2$s formulaires sélectionnés ont échoué la suppression. Supprimer les formulaires sélectionnés - Suppression du formulaire: %1$d sur %2$d %s formulaire(s) supprimé(s) avec succès. - Désolé, une action de suppression d\'un formulaire est en cours. @@ -920,10 +937,21 @@ Ouvrir les paramètres Entités + + Activer les entités locales + Les formulaires de suivi auront accès aux listes d\'entités partagées incluant les entités créées ou mises à jour localement. + + Voir liste d\'entités + + Tout supprimer + + Ajouter liste d\'entités + + Hors ligne - L\'application a rencontré un problème durant l\'usage précédant! + L\'application a rencontré un problème durant l\'usage précédent! Impossible de démarrer l\'application! Quand vous aurez enregistré des formulaires, ils apparaîtront ici + + + Récupérer votre travail ? + + \'Collect s\'\'est arrété de manière inattendue le \'EEE dd MMM yyyy\' à \'HH:mm\' et a sauvegardé votre travail.\n\nVoulez vous le récupérer ou l\'\'ignorer ?\' + + Récupérer ? + + Abandoner changements + + Créer le point + + Voir ou modifier le point + + Voir le point + + Modifier le point + + Créer la ligne + + Voir ou modifier la ligne + + Voir la ligne + + Créer le polygone + + Voir ou modifier le polygone + + Voir le polygone diff --git a/strings/src/main/res/values-hi/strings.xml b/strings/src/main/res/values-hi/strings.xml index a7fb4eee5b6..3833bcada21 100644 --- a/strings/src/main/res/values-hi/strings.xml +++ b/strings/src/main/res/values-hi/strings.xml @@ -226,18 +226,27 @@ हाइब्रिड उपग्रह गूगल मानचित्र तक पहुंचने में असमर्थ क्या गूगल प्ले सर्विस इनस्टॉल है? - लोकेशन देखें या बदलें - लोकेशन रिकार्ड करें + + + + + + + + + + + + + + + क्षमा करें, लोकेशन प्रदाता सक्षम नही हैं! अक्षांश: %1$s \n देशांतर: %2$s \n ऊंचाई: %3$sm \n शुद्धता: %4$sm - जिओपॉइंट शुरू करे - जिओपॉइंट देखे - जिओशेप शुरू करे - जीओट्रेस प्रारंभ करें @@ -264,12 +273,9 @@ प्लेस मार्क के लिए लंबे समय तक दबाएं या मार्कर बटन जोड़ने के लिए टैप करें। मार्कर बटन जोड़ें पर टैप करें छोड़ दें - जिओ शेप देखें या बदलें  - जिओ ट्रेस देखें या बदलें  - @@ -323,9 +329,7 @@ चयन मिटाएं न मिटाएं फॉर्म मिटाएं - क्षमा करें, चयनित %2$sफॉर्म() का %1$sमिटाने में असफल! %s फॉर्म(s) सफलतापूर्वक मिटाये! - क्षमा करें, फॉर्म मिटाने की कार्रवाई पहले से ही प्रगति में है! @@ -531,6 +535,11 @@ # Permissions ##############################################--> + + + + + @@ -575,4 +584,22 @@ + + + + + + छोड़ दें + + + + + + + + + + diff --git a/strings/src/main/res/values-ht/strings.xml b/strings/src/main/res/values-ht/strings.xml index 206c1e5b324..7e793597f1f 100644 --- a/strings/src/main/res/values-ht/strings.xml +++ b/strings/src/main/res/values-ht/strings.xml @@ -204,6 +204,21 @@ + + + + + + + + + + + + + + + Ale nan paramèt @@ -223,7 +238,6 @@ - @@ -350,6 +364,11 @@ ##############################################--> Ouvri paramèt yo + + + + + @@ -395,4 +414,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-in/strings.xml b/strings/src/main/res/values-in/strings.xml index c0030f440f5..3fbcd692649 100644 --- a/strings/src/main/res/values-in/strings.xml +++ b/strings/src/main/res/values-in/strings.xml @@ -384,14 +384,24 @@ Tidak dapat mengakses Google Maps. Apakah Google Play Services telah terinstal? Positron Dark Matter - Lapisan referensi - Berkas data lapisan - Lihat atau Ubah Lokasi - Ubah Lokasi + + + + + + + + + + + + + + + Rekam titik Tingkat akurasi adalah %1$s meter Lokasi penyedia jasa: %s - Rekam Lokasi Maaf, penyedia lokasi belum diaktifkan Lintang: %1$s\nBujur: %2$s\nKetinggian: %3$s m\nAkurasi: %4$s m Formulir ini hendak merekam pergerakan lokasi Anda, tetapi Google Play Service tidak tersedia. @@ -404,12 +414,6 @@ Formulir ini melacak lokasi Anda. Anda dapat menonaktifkan pelacakan melalui menu %1$s di atas. Periksa kesalahan - Mulai GeoPoint - Lihat GeoPoint - Lihat GeoShape - Lihat GeoTrace - Mulai GeoShape - Mulai GeoTrace Waktu berlalu: %1$s @@ -474,8 +478,6 @@ Sentuh lama untuk menandai atau sentuh tombol tambah penanda. Sentuh tombol tambah penanda. Buang - Lihat atau Ubah GeoShape - Lihat atau Ubah GeoTrace %s: %d (%d ditampilkan di peta) Pilih Barang baru @@ -486,8 +488,6 @@ Melacak lokasi Lokasi pelacakan - - Minta bantuan untuk Lapisan Referensi Tunjukkan lokasi saya @@ -568,11 +568,8 @@ Hapus Terpilih Batal Hapus Hapus Formulir - Maaf, %1$s dari %2$s formulir terpilih, tidak berhasil dihapus. Menghapus form terpilih - Menghapus form: %1$d dari %2$d %s formulir telah berhasil dihapus. - Maaf, proses penghapusan formulir sedang berlangsung. @@ -905,6 +902,11 @@ Buka Pengaturan Entitas + + + + + @@ -966,4 +968,22 @@ + + + + + + Buang + + + + + + + + + + diff --git a/strings/src/main/res/values-it/strings.xml b/strings/src/main/res/values-it/strings.xml index 6ac556673e0..0e365f4406e 100644 --- a/strings/src/main/res/values-it/strings.xml +++ b/strings/src/main/res/values-it/strings.xml @@ -175,6 +175,7 @@ L\'applicazione richiesta è mancante. Per piacere inserisci manualmente la lettura. L\'applicativo esterno non ha fornito l\'informazione attesa. + Stampa Inizio Stampa La stampante richiesta non è installata. Per piacere installa la stampante. Apri URL @@ -388,14 +389,25 @@ Impossibile accedere a Google Maps. Il Servizio di Google Play è installato? Posizione Scuro è importante - Livello di riferimento - File di dati del livello - Vedi o Cambia Posizione - Cambiare Localizzazione + + + + + + + + + + + + + + Tutti i progetti + + Memorizza un punto Precisione: %1$s m Fornitore di posizione: %s - Registra Posizione Attenzione, i Sistemi di Posizionamento sono disabilitati! Latitudine: %1$s\nLongitudine: %2$s\nAltitudine: %3$sm\nPrecisione: %4$sm Questo formulario vuole monitorare la tua posizione ma Google Play Services non è disponibile. @@ -408,12 +420,6 @@ Questo modulo tiene traccia della tua posizione. Puoi disabilitare il monitoraggio nel menu %1$s in alto. Controlla gli errori - Inizia Geo-punto - Guarda Geo-punto - Visualizza Geo-contorno - Visualizza Geo-tracciatura - Inizia Geo-contorno - Inizia Geo-tracciatura Tempo trascorso: %1$s @@ -484,8 +490,6 @@ Premi a lungo per posizionare il segno altrimenti tocca su Aggiungi Posizione. Tocca Aggiungi Posizione. Annulla - Guarda o Cambia Geo-contorno - Guarda o Cambia Geo-tracciatura %s: %d (%d mostrato sulla mappa) Selezionare Nuovo oggetto @@ -496,8 +500,6 @@ Tracciamento della posizione Monitoraggio della posizione… - - Ottieni aiuto con i livelli di riferimento Mostra la mia posizione @@ -578,11 +580,8 @@ Elimina Selezionato Non Eliminare Elimina moduli - Spiacenti, eliminazione di %1$s su %2$s modulo(i) fallita! Elimina i moduli selezionati - Eliminazione moduli: %1$d di %2$d %s modulo(i) cancellato(i) con successo! - Spiacenti, eliminazione modulo già in corso! @@ -915,6 +914,12 @@ Apri impostazioni Entità + + + + Deseleziona tutto + + @@ -1033,4 +1038,26 @@ + + + Recuperare il tuo lavoro? + + + + Annulla + + + + + + + + + Ottieni il poligono + + Vedi o Cambia il poligono + + Visualizza poligono diff --git a/strings/src/main/res/values-ja/strings.xml b/strings/src/main/res/values-ja/strings.xml index bfaf0617972..0260bffa7df 100644 --- a/strings/src/main/res/values-ja/strings.xml +++ b/strings/src/main/res/values-ja/strings.xml @@ -350,14 +350,24 @@ Google マップにアクセスできません。 Google Play サービスはインストールされていますか? 位置 ダークマター - 参照レイヤー - レイヤーデータファイル - 場所の表示または変更 - 場所の変更 + + + + + + + + + + + + + + + 位置を記録 精度: %1$s m ロケーションプロバイダー: %s - 記録場所 申し訳ありません、ロケーション プロバイダーが無効です! 緯度: %1$s\n経度: %2$s\n高度: %3$sm\n精度: %4$sm このフォームは位置を追跡しようとしますが、Google Play サービスが利用できません。 @@ -369,12 +379,6 @@ このフォームは位置を追跡しようとしますが、追跡が無効になっています。 上の %1$s メニューで有効にしてください。 このフォームは位置を追跡します。 上の %1$s メニューで追跡を無効にすることができます。 - 地理位置を開始 - 地理位置を表示 - GeoShape を表示 - GeoTrace を表示 - GeoShape を開始 - GeoTrace を開始 @@ -430,13 +434,10 @@ 長押ししてマークするか、マーカーを追加ボタンをタップしてください。 マーカーを追加ボタンをタップします。 破棄 - GeoShape を表示または変更 - GeoTrace を表示または変更 保存済のフォームを表示 - @@ -501,11 +502,8 @@ 選択を削除 削除しない フォームを削除 - 申し訳ありません、%1$s / %2$s の選択されたフォームが削除できませんでした! 選択したフォームを削除中 - フォーム削除中: %1$d / %2$d %s フォームを削除しました! - 申し訳ありません、フォームの削除を実行中です! @@ -769,6 +767,11 @@ # Permissions ##############################################--> + + + + + @@ -813,4 +816,22 @@ + + + + + + 破棄 + + + + + + + + + + diff --git a/strings/src/main/res/values-ka/strings.xml b/strings/src/main/res/values-ka/strings.xml index 00ff6ac6774..321b82339df 100644 --- a/strings/src/main/res/values-ka/strings.xml +++ b/strings/src/main/res/values-ka/strings.xml @@ -210,9 +210,21 @@ tile caches, and are not shown in the UI.--> წყარო ქუჩები - მდებარეობის ნახვა ან შეცვლა - ადგილმდებარეობის შეცვლა - ადგილმდებარეობის ჩანიშვნა + + + + + + + + + + + + + + + უკაცრავად, მდებარეობის მიმღები არ არის გააქტიურებული! @@ -236,7 +248,6 @@ - @@ -289,13 +300,12 @@ არჩეულის წაშლა არ წაშალოთ ფორმის წაშლა - უკაცრავად, არჩეული %2$s ფორმიდან %1$s ვერ წაიშალა %s ფორმა წარმატებით წაიშალა - უკაცრავად, ფორმის წაშლის პროცესი ამჟამად მიმდინარეობს! + სერვერი @@ -377,6 +387,7 @@ მაჩვენე პაროლი გამორთეთ მთავარი მენიუდან დასამალად გადატვირთვა + გადატვირთვა ფორმის შენახვა @@ -435,6 +446,11 @@ # Permissions ##############################################--> + + + + + @@ -484,4 +500,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-km/strings.xml b/strings/src/main/res/values-km/strings.xml index 0299c3a83ee..7afb481632e 100644 --- a/strings/src/main/res/values-km/strings.xml +++ b/strings/src/main/res/values-km/strings.xml @@ -330,9 +330,21 @@ ភ្លឺ ងងឹត មិនអាចចូលដំណើរការផែនទី Google ។ តើសេវា Google Play បានដំឡើងហើយឬនៅ? - មើល ឬផ្លាស់ប្តូរទីតាំង - ផ្លាស់ប្តូរ​ទីតាំង - ចាប់​យក​ទីតាំង + + + + + + + + + + + + + + + សុំទោស, សេវាចាប់យកទីតាំងត្រូវបានបិទ រយៈទទឹង: %1$s\nរយៈបណ្ដោយ: %2$s\nរយៈកំពស់: %3$sm\nភាពត្រឹមត្រូវ: %4$sm @@ -340,10 +352,6 @@ - ចាប់ផ្តើម GeoPoint - មើល GeoPoint - ចាប់ផ្តើម GeoShape - ចាប់ផ្តើម GeoTrace @@ -380,12 +388,9 @@ ចុចយូរដើម្បីដាក់ស្លាកកន្លែង ឬប៉ះប៊ូតុងបន្ថែមស្លាក ប៉ះដើម្បីបន្ថែមប៊ូតុងសម្គាល់ បោះបង់ - មើល ឬផ្លាស់ប្តូរ GeoShape - មើល ឬផ្លាស់ប្តូរ GeoTrace - @@ -442,11 +447,8 @@ លុប​អ្វី​ដែល​បាន​ជ្រើស កុំ​លុប លុប​សំណុំបែបបទ - សំុទោស សំណុំ​បែប​បទ​ %1$s នៃ %2$s ដែលបានជ្រើសរើស​មិន​អាច​លុប​បាន​ទេ! កំពុងលុបទម្រង់ដែលបានជ្រើសរើសរួច - កំពុងលុបទម្រង់: %1$d នៃ %2$d %s សំណុំបែបបទបានលុបដោយជោគជ័យ ! - សុំទោស, សកម្មភាពនៃការលុបសំណុំបែបបទគឺបានដំណើរការ! @@ -692,6 +694,11 @@ # Permissions ##############################################--> + + + + + @@ -736,4 +743,22 @@ + + + + + + បោះបង់ + + + + + + + + + + diff --git a/strings/src/main/res/values-ln/strings.xml b/strings/src/main/res/values-ln/strings.xml index a49d5ce46e6..cb2be2125f6 100644 --- a/strings/src/main/res/values-ln/strings.xml +++ b/strings/src/main/res/values-ln/strings.xml @@ -94,6 +94,21 @@ + + + + + + + + + + + + + + + @@ -112,7 +127,6 @@ - @@ -237,6 +251,11 @@ # Permissions ##############################################--> + + + + + @@ -281,4 +300,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-lo-rLA/strings.xml b/strings/src/main/res/values-lo-rLA/strings.xml index 97028ae9620..b08cc7c253f 100644 --- a/strings/src/main/res/values-lo-rLA/strings.xml +++ b/strings/src/main/res/values-lo-rLA/strings.xml @@ -177,8 +177,21 @@ - ເບີ່ງ ຫຼື ປ່ຽນຈຸດ​ທີ່​ຕັ້ງ - ບັນທືກຈຸດ GPS ຂອງທີ່ຕັ້ງ + + + + + + + + + + + + + + + ຄຳສັ່ງບັນທຶກຈຸດທີ່ຕັ້ງ GPS ຖືກປິດໃຫ້ໃຊ້ງານ @@ -198,7 +211,6 @@ - @@ -251,9 +263,7 @@ ລຶບສະເພາະແບບຟອມທີ່ໄດ້ເລືອກ ບໍ່ຕ້ອງການລຶບ ລຶບແບບຟອມ - ຄຳສັ່ງລຶບແບບຟອມ %1$s %2$s ກຳລັງດຳເນີນງານ! ລຶບແບບຟອມ%s ສຳເລັດແລ້ວ! - ຄຳສັ່ງລຶບແບບຟອມກຳລັງດຳເນີນງານ @@ -333,6 +343,7 @@ ປ້ອນລະຫັດຜ່ານຜູ້ຄວບຄຸມລະບົບ ຍົກເລີກການ​ເລືອກ​ ເພື່ອເຊື່ອງຈາກເມນູຫລັກ ຕັ້ງຄ່າຄືນໃໝ່ + ຕັ້ງຄ່າຄືນໃໝ່ + + + + + @@ -431,4 +447,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-lt/strings.xml b/strings/src/main/res/values-lt/strings.xml index 93d27f023cb..3f42c93c290 100644 --- a/strings/src/main/res/values-lt/strings.xml +++ b/strings/src/main/res/values-lt/strings.xml @@ -145,7 +145,21 @@ - Įrašyti vietovę + + + + + + + + + + + + + + + Deja, padėties valdikliai yra išjungti! @@ -165,7 +179,6 @@ - @@ -216,13 +229,12 @@ Ištrinti pažymėtas Neištrinti Ištrinti formas - Deja, nepavyko ištrinti %1$s iš %2$s parinktų formų! %s forma(-os) sėkmingai ištrintos! - Deja, šiuo metu vyksta formos trynimo procesas! + Serveris @@ -292,6 +304,7 @@ Įveskite administratoriaus slaptažodį Uncheck to hide from Main Menu Atstatyti + Atstatyti + + + + + @@ -386,4 +404,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-mg/strings.xml b/strings/src/main/res/values-mg/strings.xml index 425f6a074ad..f48bd77025f 100644 --- a/strings/src/main/res/values-mg/strings.xml +++ b/strings/src/main/res/values-mg/strings.xml @@ -88,6 +88,21 @@ + + + + + + + + + + + + + + + @@ -106,7 +121,6 @@ - @@ -222,6 +236,11 @@ # Permissions ##############################################--> + + + + + @@ -266,4 +285,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-ml/strings.xml b/strings/src/main/res/values-ml/strings.xml index cf91cd66384..b606fcc15e3 100644 --- a/strings/src/main/res/values-ml/strings.xml +++ b/strings/src/main/res/values-ml/strings.xml @@ -156,6 +156,21 @@ + + + + + + + + + + + + + + + @@ -174,7 +189,6 @@ - @@ -303,6 +317,11 @@ # Permissions ##############################################--> + + + + + @@ -347,4 +366,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-mr/strings.xml b/strings/src/main/res/values-mr/strings.xml index c94ab46fd18..8de3be7d603 100644 --- a/strings/src/main/res/values-mr/strings.xml +++ b/strings/src/main/res/values-mr/strings.xml @@ -220,18 +220,27 @@ संकरित उपग्रह गूगल नकाशे मध्ये प्रवेश करण्यात अक्षम. गूगल प्ले सेवा स्थापित केली आहे का? - दृश्य किंवा स्थान बदला - स्थान रेकॉर्ड + + + + + + + + + + + + + + + क्षमस्व, स्थान प्रदाता अक्षम केले आहेत! अक्षांश: %1$s\nरेखांश: %2$s\nअल्टिट्यूड: %3$sm\nअॅक्चुसीसी: %4$sm - जिओ पॉईंट प्रारंभ करा - जिओ पॉईंट पहा - जिओ पॉईंट प्रारंभ करा - जिओ ट्रेस प्रारंभ करा @@ -258,12 +267,9 @@ चिन्ह ठेवण्यासाठी जास्त वेळ दाबा किंवा जोडा मार्कर बटण टॅप करा जोडा मार्कर बटण टॅप करा टाकून द्या - जिओ शेप पहा किंवा बदला - जिओ ट्रेस पहा किंवा बदला - @@ -317,9 +323,7 @@ निवडलेले हटवा ? हटवू नका फॉर्म हटवा - क्षमस्व, %1$s पैकी फॉर्म(स) %2$s नी हटविण्यात अयशस्वी! %s फॉर्म(स) यशस्वीरित्या हटविले! - क्षमस्व, एक फॉर्म हटविणे क्रिया आधीच प्रगतीपथावर आहे! @@ -520,6 +524,11 @@ # Permissions ##############################################--> + + + + + @@ -564,4 +573,22 @@ + + + + + + टाकून द्या + + + + + + + + + + diff --git a/strings/src/main/res/values-ms/strings.xml b/strings/src/main/res/values-ms/strings.xml index ecacc8546f5..e03ede7c4f9 100644 --- a/strings/src/main/res/values-ms/strings.xml +++ b/strings/src/main/res/values-ms/strings.xml @@ -91,6 +91,21 @@ + + + + + + + + + + + + + + + @@ -109,7 +124,6 @@ - @@ -232,6 +246,11 @@ # Permissions ##############################################--> + + + + + @@ -276,4 +295,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-my/strings.xml b/strings/src/main/res/values-my/strings.xml index 8a1510d9685..f1d92d5a7c9 100644 --- a/strings/src/main/res/values-my/strings.xml +++ b/strings/src/main/res/values-my/strings.xml @@ -167,7 +167,21 @@ - တည္ေနရာအားမွတ္သားရန္ + + + + + + + + + + + + + + + တည္ေနရာဖမ္းယူမွု ခ်ိဳ႔ယြင္းေနပါသည္ @@ -187,7 +201,6 @@ - @@ -238,9 +251,7 @@ ေရြးခ်ယ္ထားသည္မ်ားကို ဖ်က္ရန္ မဖ်က္ရန္ ပုံစံကို ဖယ်ရှားရန် - %1$s ၊ %2$s ေရြးခ်ယ္ထားေသာပံုစံမ်ားအားဖ်က္၍ မရပါ %s ပံုစံမ်ားဖ်က္ၿပီးျဖစ္ပါသည္ - ပုံစံမ်ား ဖ်က္ေနဆဲ ျဖစ္ပါသည္ @@ -314,6 +325,7 @@ စကားဝှက်ပြပါ Uncheck to hide from Main Menu မူလအတုိင္းျပန္ထားရန္ + မူလအတုိင္းျပန္ထားရန္ + + + + + @@ -409,4 +426,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-ne-rNP/strings.xml b/strings/src/main/res/values-ne-rNP/strings.xml index 0292d1858b1..e73240b71d0 100644 --- a/strings/src/main/res/values-ne-rNP/strings.xml +++ b/strings/src/main/res/values-ne-rNP/strings.xml @@ -212,18 +212,26 @@ हाइव्रिड उपग्रह गुगल म्याप्स् मा जान सकिएन । के गुगल प्ले सर्भिसेस इन्स्टल गरिएको छ ? - स्थान हेर्नुहोस् वा परिवर्तन गर्नुहोस् - स्थान परिवर्तन गर्नुहोस् - स्थान रेकर्ड गर्नुहोस् + + + + + + + + + + + + + + + माफ गर्नुहोस्, स्थान प्रदायकहरु निष्क्रिय गरिएका छन् ! - जियो पोइन्ट शुरू गर्नुहोस् - जियो पोइन्ट हेर्नुहोस् - जियो सेप शुरू गर्नुहोस् - जियो ट्रेस शुरू गर्नुहोस् @@ -247,7 +255,6 @@ - @@ -301,9 +308,7 @@ छानिएका डिलिट गर्नुहोस डिलिट नगर्नुहोस फारमहरू डिलिट गर्नुहोस् - माफ गर्नुहोस्, छानिएका %2$s मध्येको %1$s फारम(हरु) डिलिट गर्न सकिएन ! %s फारम (हरु) डिलिट भयो ! - माफ गर्नुहोला, फारम डिलिट गर्ने प्रक्रिया सुरु भैसक्यो! @@ -411,6 +416,7 @@ पहिलाकै ठाउँमा ल्याउनुहोस् सबै छान्नुहोस् सबै मेटाउनु होस् + पहिलाकै ठाउँमा ल्याउनुहोस् सबै सेटिङहरू::%s बचत गरिएको फारमहरू:: %s खाली फारमहरू :: %s @@ -488,6 +494,11 @@ # Permissions ##############################################--> + + + + + @@ -532,4 +543,22 @@ + + + + + + त्याग्नुहोस् + + + + + + + + + + diff --git a/strings/src/main/res/values-nl/strings.xml b/strings/src/main/res/values-nl/strings.xml index 54342d7b06d..8c8112ca884 100644 --- a/strings/src/main/res/values-nl/strings.xml +++ b/strings/src/main/res/values-nl/strings.xml @@ -275,9 +275,21 @@ Donker Buiten Topografisch - Bekijk of wijzig positie - Locatie wijzigen - Positie vastleggen + + + + + + + + + + + + + + + Sorry, GPS is uitgeschakeld! Ga naar instellingen @@ -301,7 +313,6 @@ - Toon mijn locatie @@ -358,10 +369,8 @@ Verwijder selectie Niet verwijderen Verwijderen - Sorry, %1$s van de %2$s geselecteerde formulier(en) kon niet worden verwijderd! Verwijderen van geselecteerde formulieren %s formulier(en) verwijderd! - Sorry, er is nog een verwijder opdracht actief! @@ -560,6 +569,11 @@ # Permissions ##############################################--> + + + + + @@ -604,4 +618,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-no/strings.xml b/strings/src/main/res/values-no/strings.xml index 8b05f6c0998..e269d0f32ce 100644 --- a/strings/src/main/res/values-no/strings.xml +++ b/strings/src/main/res/values-no/strings.xml @@ -180,8 +180,21 @@ These strings are only being used to preserve compatibility with pre-existing tile caches, and are not shown in the UI.--> Ikke mulig å åpne Google Maps. Er Google Play Services installert? - Vis eller endre plassering - Registrer posisjon + + + + + + + + + + + + + + + Beklager, stedstjenesten er deaktivert! @@ -201,7 +214,6 @@ - @@ -254,13 +266,12 @@ Slett utvalgte Ikke slett Slett skjemaer - Beklager, %1$s av %2$s utvalgte skjema(er) sletting mislyktes! %s skjema(er) er slettet! - Beklager, sletting pågår! + Server @@ -338,6 +349,7 @@ Enter Admin Password Uncheck to hide from Main Menu Reset + Reset + + + + + @@ -436,4 +453,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-pl/strings.xml b/strings/src/main/res/values-pl/strings.xml index acb8c869d43..87defc35aee 100644 --- a/strings/src/main/res/values-pl/strings.xml +++ b/strings/src/main/res/values-pl/strings.xml @@ -330,12 +330,22 @@ Nie można uzyskać dostępu do Google Maps. Czy Google Play Services są zainstalowane? Pozyton Ciemna Materia - Warstwa referencyjna - Plik danych warstwy - Obejrzyj lub Zmień Lokalizację - Zmień Lokalizację + + + + + + + + + + + + + + + Zapisz punkt - Zapisz Lokalizację Przykro mi, dostawcy usługi Lokalizacji są niedostępni. Szerokość: %1$s\nDługość: %2$s\nWzniesienie: %3$sm\nDokładność: %4$sm Ten formularz chce śledzić twoją lokalizację, ale usługi Google Play nie są dostępne. @@ -347,10 +357,6 @@ Ten formularz chce śledzić twoją lokalizację, jednak funkcja śledzenia jest wyłączona. Włącz ją wybierając %1$s w menu powyżej. Ten formularz śledzi twoją lokalizację. Możesz wyłączyć funkcję śledzenia poprzez %1$s w menu powyżej. - Zacznij GeoPoint - Pokaż GeoPoint - Zacznij GeoShape - Zacznij GeoShape @@ -411,12 +417,9 @@ Długo naciśnij aby umieścić znacznik lub dotknij przycisk Dodaj znacznik. Dotknij przycisk Dodaj znacznik. Usuń - Pokaż lub Zmień GeoShape - Pokaż lub Zmień GeoTrace - @@ -472,11 +475,8 @@ Skasuj Wybrane Nie Kasuj Skasuj Formularze - Przykro mi, %1$s z %2$s wybranego/ych formularza/y nie udało się skasować! Kasowanie wybranych formularzy - Kasowanie formularzy: %1$d z %2$d %s formularz/y zostało skasowanych! - Przykro mi, trwa kasowanie formularzy! @@ -723,6 +723,11 @@ # Permissions ##############################################--> + + + + + @@ -767,4 +772,22 @@ + + + + + + Usuń + + + + + + + + + + diff --git a/strings/src/main/res/values-ps/strings.xml b/strings/src/main/res/values-ps/strings.xml index 0cf281a153e..b1008d486a3 100644 --- a/strings/src/main/res/values-ps/strings.xml +++ b/strings/src/main/res/values-ps/strings.xml @@ -135,7 +135,21 @@ - موقعیت ثبت کړی + + + + + + + + + + + + + + + اوبخښه ، د موقعیت وړاندي کوونکي غیر فعاله دی! @@ -155,7 +169,6 @@ - @@ -321,6 +334,11 @@ # Permissions ##############################################--> + + + + + @@ -365,4 +383,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-pt/strings.xml b/strings/src/main/res/values-pt/strings.xml index 1f0fa8d28c6..ad9ebd99d48 100644 --- a/strings/src/main/res/values-pt/strings.xml +++ b/strings/src/main/res/values-pt/strings.xml @@ -175,6 +175,7 @@ Aplicativo faltando. Por favor, inserir manualmente a informação A aplicação externa não enviou as informações esperadas. + Imprimir Impressão Inicializada A impressora solicitada não está instalada. Por favor, instale a impressora Abrir URL @@ -225,7 +226,9 @@ Continuar + Gravação de áudio: ligado + Gravação de áudio: desligado Esse formulário grava áudio em segundo plano. Você precisa dar permissão de acesso ao microfone para o KoboCollect. Se não permitir isso, você não poderá abrir esse formulário. Esse formulário solicita gravação de áudio em segundo plano. Desabilitar isso irá interromper a gravação e descartar o áudio já gravado. Você tem certeza que deseja continuar? @@ -388,14 +391,30 @@ Não foi capaz de acessar o Google Maps. O serviço do Google Play está instalado? Positron Matéria escura - Camada de referência - Arquivo de dados da camada - Ver ou modificar localização - Mudar Localização + + + Camada + + + + Saber mais sobre adicionar MBTiles. + + + Apagar camada + + + + + Camadas + + + Todos os projetos + + Apenas o projeto atual + Registre um ponto Precisão: %1$s m Provedor de localização: %s - Gravar Localização Desculpe, provedor de localização desabilitado! Latitude: %1$s\nLongitude: %2$s\nAltitude: %3$sm\nPrecisão:%4$s m Este formulário necessita rastrear sua localização, mas o Google Play Services não está disponível. @@ -403,17 +422,13 @@ Ir para as configurações + Rastrear localização: ligado + Rastrear localização: desligado Este formulário necessita rastrear sua localização, mas a localização está desabilitada no seu celular. Por favor, habilite no menu %1$s acima. Este formulário rastreia a sua localização. Você pode desabilitar o rastreamento de localização no menu %1$s acima. Verificar erros - Inicia GeoPoint - Visualiza GeoPoint - Visualizar GeoShape - Visualizar GeoTrace - Inicia GeoShape - Inicia GeoTrace Tempo decorrido: %1$s @@ -484,8 +499,6 @@ Pressione por mais tempo para colocar marcador ou selecione o botão Adicionar Marcador. Selecione o botão Adicionar Marcador. Descartar - Visualizar ou Modificar GeoShape - Visualizar ou Modificar GeoTrace 1 %s: 2 %d (2 %d mostrado no mapa) Selecionar Novo item @@ -496,8 +509,6 @@ Registro de localização Obtendo localização… - - Obter ajuda com Camadas de Referência Mostrar minha localização @@ -578,11 +589,8 @@ Apagar Selecionados Não Apagar Apagar Formulários - Desculpe, %1$s de %2$s formulário(s) selecionado falhou ao apagar! Apagando formulários selecionados - Apagando formulário: %1$d de %2$d %s formulário(s) apagados com sucesso! - Desculpe, uma ação de apagar está em andamento! @@ -915,6 +923,15 @@ Abrir configurações Entidades + + Habilitar entidades locais + + Ver listas de entidades + + Limpar tudo + + Adicionar lista de entidades + @@ -1016,7 +1033,9 @@ Não há nada para exibir + Não há formulários em branco + Baixe o formulário para começar Não há nada em rascunhos @@ -1030,7 +1049,42 @@ Quando você enviar formulários finalizados eles aparecerão aqui + Não há formulários para baixar + Baixe o formulário para começar + Não há formulários para apagar + Quando você tiver baixado formulários em branco, eles aparecerão aqui + Quando você tiver formulários salvos, eles aparecerão aqui + + + Recuperar o seu trabalho? + + + Recuperar + + Descartar + + Obter ponto + + Ver ou modificar o ponto + + Visualizar ponto + + Alterar ponto + + Obter linha + + Ver ou modificar linha + + Visualizar linha + + Obter polígono + + Ver ou modificar polígono + + Visualizar polígono diff --git a/strings/src/main/res/values-ro/strings.xml b/strings/src/main/res/values-ro/strings.xml index ce4b6e7a89f..532041e0dbe 100644 --- a/strings/src/main/res/values-ro/strings.xml +++ b/strings/src/main/res/values-ro/strings.xml @@ -189,16 +189,26 @@ Hibrid Satelit Nu am putut accesa Google Maps. Aveți instalat Google Play Services? - Vizualizați sau modificați locația - Înregistrați coordonatele + + + + + + + + + + + + + + + Ne pare rău, localizarea este dezactivată! - Start GeoPoint - Start GeoShape - Start GeoTrace @@ -221,7 +231,6 @@ - @@ -274,9 +283,7 @@ Ștergeți cele selectate Nu ștergeți Ștergeți formulare - Ne pare rău, %1$s din %2$s formular(e) selectat(e) nu au putut fi șterse! %s formular(e) șters(e) cu succes! - Ne pare rău, formularul este deja în proces de ștergere! @@ -361,6 +368,7 @@ Schimbați parola administratorului Deselectați pentru a ascunde din Meniul principal Resetați + Resetați Da Nu @@ -420,6 +428,11 @@ # Permissions ##############################################--> + + + + + @@ -464,4 +477,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-ru/strings.xml b/strings/src/main/res/values-ru/strings.xml index b99862ef23a..a957fd4b08d 100644 --- a/strings/src/main/res/values-ru/strings.xml +++ b/strings/src/main/res/values-ru/strings.xml @@ -369,14 +369,24 @@ Снаружи Топографический Не удалось получить доступ к Google картам. Установлен ли Google Play Services? - Базовый слой - Файл данных слоя - Показать или изменить местоположение - Изменить местоположение + + + + + + + + + + + + + + + Зафиксировать точку Точность: %1$s м Провайдер геолокации: %s - Записать местоположение Включите функцию определения местоположения на вашем устройстве! Широта: %1$s\nДолгота: %2$s\nВысота: %3$sм\nТочность:%4$sм Эта форма хочет отслеживать ваше местоположение, но сервисы Google Play недоступны. @@ -389,12 +399,6 @@ Эта форма отслеживает ваше местоположение. Вы можете отключить отслеживание в меню %1$s выше. Проверить на ошибки - Определить местоположение - Просмотреть геоточку - Просмотр GeoShape - Просмотр GeoTrace - Начать геофигуру - Начать геослед Истекшее время: %1$s @@ -450,8 +454,6 @@ Используйте продолжительное нажатие, чтобы расставлять отметки или нажмите кнопку \"добавить отметку\". Нажмите кнопку \"добавить отметку\". Отменить - Показать или изменить геофигуру - Показать или изменить геослед %s: %d (%d показано на карте) Выбрать Новый элемент @@ -462,8 +464,6 @@ Отслеживание местоположения Отслеживание местоположения… - - Получить помощь по слоям Показать мое местоположение @@ -544,11 +544,8 @@ Удалить выбранные Не удалять Удалить бланки - Не удалось удалить %1$s бланк(а/ов) из %2$s Удаляю выбранные опросники - Удаляю опросник: %1$d из %2$d Бланк опросника успешно удалён: %s шт. - Удаление бланка опросника уже выполняется @@ -866,6 +863,11 @@ ##############################################--> Открыть настройки + + + + + @@ -922,4 +924,22 @@ + + + + + + Отменить + + + + + + + + + + diff --git a/strings/src/main/res/values-rw/strings.xml b/strings/src/main/res/values-rw/strings.xml index 876fe53b84e..fa115228c16 100644 --- a/strings/src/main/res/values-rw/strings.xml +++ b/strings/src/main/res/values-rw/strings.xml @@ -353,14 +353,24 @@ N\'ukomea guhura n\'iki kibazo, wakigeza kuwa gusabye gukusanya amakuru.Ntibishoka ko ukoresha ikarita iranga ahantu.Reba ko waba ufite google play muri telefone yawe Utunyangingo. Ibibazo bidasobanutse cg byijimye. - Indangakitegererezo. - Ububiko bw\'amakuru shingiro. - Kora igenzura cyangwa uhindure ujye ahandi - Hindura ujye ahandi + + + + + + + + + + + + + + + Irabika amakuru Birizewe: %1$s m Igitanga aho uri ubu: %s - Fata amakuru y\'ahantu Ihangane, ibituma amakuru y\'ahantu afatwa birafunze Ubujy\'ejuru: %1$s \nUmurambararo: %2$s \nUbutumburuke: %3$s m\n Byizewe: %4$s m Iyi form irashaka kugenzura aho uherereye, ariko Google Play Services ntabwo iboneka @@ -372,12 +382,6 @@ N\'ukomea guhura n\'iki kibazo, wakigeza kuwa gusabye gukusanya amakuru.Iyi form ishaka kwerekana aho uherereye ariko ntiwabifunguye. Yishyireho 1%1$s murutonde ruri hejuru. Iyi form iri kwerekana aho uherereye.Ushobobora kubihagarika muri 1%1$s unyuze ku rutonde ruri hejuru aha - Reba amakuru y\'ahantu - Reba amakuru y\'ahantu - Garagaza imiterere yahantu. - Garagaza ikimenyetso kiranga ahantu. - Tangira cg Shushanya imiterere yahantu. - Tangira cg Shakisha ibimenyetso biranga ahantu. @@ -436,13 +440,10 @@ N\'ukomea guhura n\'iki kibazo, wakigeza kuwa gusabye gukusanya amakuru.Kanda umare umwanya kugirango ushyiremo mark cg ukore kuri buto ya marker Kanda hano wemeze. Kuraho cg Jugunya. - Garagaza cg Uhindure imiterere yahantu. - Garagaza cg Uhindure ikimenyetso ndangahantu. Reba Form Wabitse - @@ -507,11 +508,8 @@ N\'ukomea guhura n\'iki kibazo, wakigeza kuwa gusabye gukusanya amakuru.Siba icyo utoranije Ntubisibe Siba ifishi - Ihangane, %1$s muri %2$s z\' Ifishi wahisemo ntizabashije gusibwa. Ifishi wahisemo ziri gusibwa - Ifishi zimaze gusibwa ni %1$d muri %2$d Ifishi %s zamaze gusibwa - Ihangane, Igikorwa cyo gusiba ifishi kiracyari gukorwa @@ -804,6 +802,11 @@ Birashoboka ko ari zimwe zashyizwemo mubihe bitandukanye cgw ziturutse ahatanduk # Permissions ##############################################--> + + + + + @@ -848,4 +851,22 @@ Birashoboka ko ari zimwe zashyizwemo mubihe bitandukanye cgw ziturutse ahatanduk + + + + + + Kuraho cg Jugunya. + + + + + + + + + + diff --git a/strings/src/main/res/values-si/strings.xml b/strings/src/main/res/values-si/strings.xml index 6580372665c..ad95cacd917 100644 --- a/strings/src/main/res/values-si/strings.xml +++ b/strings/src/main/res/values-si/strings.xml @@ -87,6 +87,21 @@ + + + + + + + + + + + + + + + @@ -117,7 +132,6 @@ - @@ -235,6 +249,11 @@ # Permissions ##############################################--> + + + + + @@ -279,4 +298,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-sl/strings.xml b/strings/src/main/res/values-sl/strings.xml index b207f8fe7aa..d7309610a68 100644 --- a/strings/src/main/res/values-sl/strings.xml +++ b/strings/src/main/res/values-sl/strings.xml @@ -1,40 +1,65 @@ + Izpolni prazen obrazec + Osnutki + Pripravljeni za oddajo + Oddani + Prenesi obrazec + Izbriši obrazec + Shranjeni obrazci + Verzija: %s + ID: %s - \'Dodan na\' EEE,MMM dd, yyyy \'ob\' HH:mm + \'Dodan\' EEE,MMM dd, yyyy \'ob\' HH:mm + \'Posodobljen\' EEE, MMM dd, yyyy \'at\' HH:mm - \'Shranjen na\' EEE,MMM dd, yyyy \'ob\' HH:mm + \'Shranjen \' EEE,MMM dd, yyyy \'ob\' HH:mm - \'Zaključen na\' EEE,MMM dd, yyyy \'ob\' HH:mm + \'Zaključen\' EEE,MMM dd, yyyy \'ob\' HH:mm - \'Poslan na\' EEE,MMM dd, yyyy \'ob\' HH:mm + \'Poslan \' EEE,MMM dd, yyyy \'ob\' HH:mm - \'Pošiljanje ni uspelo na\' EEE,MMM dd, yyyy \'ob\' HH:mm + \'Pošiljanje ni uspelo \' EEE,MMM dd, yyyy \'ob\' HH:mm - \'Brsian na\' EEE,MMM dd, yyyy \'ob\' HH:mm + \'Odstranjen na\' EEE,MMM dd, yyyy \'ob\' HH:mm + Poslan obrazec izbrisan - \'Spremenjeno\' EEE, MMM dd, yyyy \'ob\' HH:mm + \'Spremenjen\' EEE, MMM dd, yyyy \'ob\' HH:mm Šifriran obrazec + Izbrisan obrazec + Sortiraj seznam + Sortiraj po + Ime, A-Z + Ime, Z-A Datum, najprej najnovejši Datum, najprej najstarejši + Po datumu shranjevanja + Filtiraj seznam + Poišči + Željena aktivnost nima potrebnih dovoljenj za dostop. + Dovoljenja za shranjevanje Brez teh dovoljenj, Collect ne more dostopati do vaših obrazcev ali shraniti odgovorov. Aplikacijo znova odprite, ko ste jih pripravljeni odobriti. + Dovoljenja za dostop do lokacije - Dovljenje za fotoaparat + Brez dovoljenja za lokacijo Collect ne more zabeležiti vaše lokacije. Prosimo, poskusite znova, ko boste pripravljeni, da ga odobrite. + Dovoljenje za fotoaparat Brez tega dovoljenja Collect ne more dostopati do fotoaparata. Prosimo, poskusite znova, ko ste pripravljeni odobriti. + Dovoljenje za snemanje zvoka Brez tega dovoljenja Collect ne more dostopati do mikrofona. Prosimo, poskusite znova, ko ste pripravljeni odobriti. + Odpri zemljevid z obrazci %1$d obrazcev dodanih. Končano skeniranje. Vsi obrazci so naloženi. Xform napaka obravnave za %1$s: %2$s manjka ali je napačen. @@ -43,6 +68,8 @@ Berem podatke obrazcev… Berem CSV datoteke… Napaka, ne morem opredeliti obrazca. + Pri nalaganju obrazca je prišlo do napake. Prosimo, poizkusite ponovno. + Osnutek ni na voljo za urejanje, saj ni prisotnega osnovnega praznega obrazca ali pa je bil izbrisan.\n\nForm ID: %1$s Obrazca ni moč popravljati ko je bil zaključen. Lahko pa ga šifrirate. Neshranjene spremembe vrnjene na shranjeno točko. @@ -51,6 +78,7 @@ Pojdi na vprašanje + Shrani kot osnutek Pojdi na začetek @@ -58,8 +86,15 @@ Pojdi gor + Skupina + Ponavljajoča skupina + Izhod + Dodaj \"%s\"? + Dodaj + Ne dodaj + Dodaj ponovno Uredi Odstranim odgovor za \"%s\"? @@ -73,54 +108,101 @@ Odstranim skupino %s in vse njene podskupine? Prekini Ta odgovor je napačen! - Ta odgovor je obvezen + Ta odgovor je obvezen! + Brez najdenih napak v obrazcu! Opozorilo: vrednost %s vsebuje presledke Opozorila: vrednosti %s vsebujejo presledke - Interna napaka. Korak k vprašanju ni uspel. - Si pri koncu %s + Obrazec je preveč kompleksen za vašo napravo. Poskusite poenostaviti vprašanja ali vprašati za pomoč na forumu. + Interna napaka: prehod ni uspel. + Ste pri koncu %s. + Zaključeni obrazci niso na voljo za urejanje. + Poslani obrazi niso na voljo za urejanje. + Če želite spreminjati shranjene odgovore, shranite kot osnutek dokler niste pripravljeni za oddajo. + Preberi več + Shrani kot osnutek + Zaključi + Pošlji Shranjevanje obrazca ni uspelo! Obrazec uspešno shranjen! - Napaka pri shranjevanju obnovitvene točke %s + Napaka pri shranjevanju obnovitvene točke %s Izhod %s + Zavrzi obrazec + Zavrzi spremembe Shrani spremembe + Urejaj še naprej + Shrani obrazec? + Obrazec lahko shranite kot osnutek in ga nato urejate iz osnutkov. + \'Obrazec zadnjič shranjen \' EEE, MMM dd, yyyy \'at\' HH:mm\'. Shranite kot osnutek, če želite shraniti spremembe.\' + Nadaljuj z urejanjem? + Če boste zavrgli obrazec, boste izgubili vse vnešene podatke do zdaj. + \'Obrazec zadnjič shranjen\' EEE, MMM dd, yyyy \'at\' HH:mm\'. Če boste zavrgli spremembe, jih boste izgubili.\' Shrani obrazec Vrednotim odgovore… Zbiram podatke… Shranjujem na SD kartico… Zaključujem na SD kartico… Šifriram podatke… + Ni samo enega zapisa za to instanco! + Ni samo enega praznega obrazca s tem jr_form_id. + ID obrazca ni bil specificiran??? Telefon ne podpira RSA šifriranja. + Neveljaven RSA javni ključ. + Obrazec %s ni bil shranjen kot dokončan. + Nedelujoč URI: %s + Neprepoznan URI: %s + Začetek snemanja zvoka neuspešen. + Mikrofon že v uporabi. + Medijske datoteke ni mogoče priložiti obrazcu, vendar je na voljo na %1$s. + Snemanje zvoka onemogočeno. Omogoči v %s + Izpolnjen obrazec je bil izbrisan! + Napaka pri posodabljanju vrednosti. To je običajno posledica napačne uporabe izračunov v oblikovanju obrazca.\n\nČe se ta težava še naprej pojavlja, jo sporočite osebi, ki vas je prosila, da zbirate podatke. Zaženi - Ne najdem aktivnosti za obravnavo %s + Ne najdem aktivnosti za obravnavo: %s Zahtevane aplikacije ne najdem ali ni na voljo. Ročno vnesi vrednost. + Zunanja aplikacija ni zagotovila pričakovanih informacij. + Tisk Zaženi tiskanje Želen tiskalnik ni nameščen. Namesti tiskalnik. Odpri URL Zaženi OpenMapKit Ponovi OSM označevanje + Urejena OSM XML datoteka: + Nekaj je šlo narobe. Nismo prejeli veljavnih OSM podatkov. + Opozorilo Prosim, namestite OpenMapKit! Pridobi črtno kodo Zamenjaj črtno kodo + Postavite črtno kodo znotraj pravokotnika. + Odpri datoteko + Razvrsti + Izbirnik številk + Ta widget ima neveljavne parametre! + SVG datoteka ne obstaja! Fotografiraj Izberi sliko + Izbrana datoteka ni veljavna slika. Klikni na ekran za fotografiranje - Prednja kamera ni na voljo v tej napravi. + Prednja kamera ni na voljo v tej napravi + Prišlo je do napake pri fotografiranju. + Kamera ni na voljo! Označi na sliki + Gif datoteke niso podprte + Aplikacija je vrnila neveljavno vrsto datoteke Shrani in zapri Zajem podpisa @@ -134,41 +216,61 @@ Predvajaj video Izberi datoteko + Izbriši Posnemi zvok Izberi zvočno datoteko + Premor + Nadaljuj + Snemanje zvoka: vklopljeno + Snemanje zvoka: izklopljeno + Ta obrazec snema zvok v ozadju. Za uporabo morate dovoliti uporabo mikrofona. Če tega ne storite, ne boste mogli odpreti tega obrazca. + Ta obrazec želi snemati zvok v ozadju. Onemogočanje le-tega bo ustavilo snemanje zvoka in zavrglo obstoječi posnetek. Ali želite nadaljevati? + Onemogoči snemanje. + Snemanje zvoka se ne bo začelo takoj. Obrazec morate ponovno odpreti, da pričnete s snemanjem. + Izbriši datoteko? + Ne boste mogli obnoviti te datoteke, ko jo izbrišete. Ste prepričani, da želite nadaljevati? + Ustavi + Snemanje.. + Zvočni posnetek + Predvajaj posnetek + Shranjevanje datoteke + Preden nadaljujte morate ustaviti snemanje zvoka. Napaka pri dodajanju medijske vsebine: %s + Izberi vrednost Izberi odgovor + Uredi vrednost + Ni izbrana nobena vrednost. Izbrano: OK. Nadaljuj - Datoteka ne vsebuje podatkov + Datoteka ne vsebuje podatkov! Stolpci %s se ujemajo! - Razvrsti stolpec po vrednostih naj bi vseboval le številčne vrednosti. Težava je pri vrednost \' %s\'. - Sintaktična napaka v search() funkciji. Funkcija potrebuje 1,4 od 6 argumentov. + Stolpec za razvrstitev naj bi vseboval le številčne vrednosti. Težava je pri vrednosti \' %s\'. + Sintaktična napaka v search() funkciji. Funkcija potrebuje 1,4 ali 6 argumentov. Sintaktična napaka v search() funkciji: neprepoznana funkcija \'%s\'. Sintaktična napaka v search() funkciji. \'%s\' ne morem ovrednotiti kot funkcijo. Iskalnik je vrnil objekt tipa \'%s\'. - Ne morem uvoziti podatkov iz %1$s. Razlog %2$s - Nalagam podatke iz \'%1$s\'. Počakaj prosim %2$s. + Ne morem uvoziti podatkov iz %1$s. Razlog: %2$s + Nalagam podatke iz \'%1$s\', prosimo počakajte… %2$s. Branje podatkov prekinjeno! Zaključujem prednaložene podatke … Branje podatkov končano! @@ -176,43 +278,164 @@ Zunanji podatki za %1$s niso bili uvoženi. Morda si pozabil vključiti %2$s csv datoteko v obrazec? Sintaktična napaka v search() funkciji: %s Ne morem pripisati vrednosti k \'%s\'. - Zabeleži smer - Zamenjaj smer - Nalagam smer - Zabeleži smer + XPathParser Izjema: \"%s\" + Zabeleži azimut + Zamenjaj azimut + Nalagam azimut + Zabeleži azimut + Azimut: %.3f + Smer: %s + Napaka: azimuta ni mogoče zabeležiti, ker naprava nima pospeškomera, senzorja magnetnega polja ali obojega. + Izberite datum + Izberite čas + Ni izbran noben datum + Ni izbranega časa + Meskerem + Tikimt + Hidar + Tahsas + Tir + Yekatit + Megabit + Miazia + Ginbot + Senie + Hamlie + Nehasie + Pagumien + Thout + Paopi + Hathor + Koiak + Tobi + Meshir + Paremhat + Parmouti + Pashons + Paoni + Epip + Mesori + Pi Kogi Enavot + Muharram + Safar + Rabi\' al-awwal + Rabi\' al-thani + Jumada al-awwal + Jumada al-thani + Rajab + Sha\'ban + Ramadan + Shawwal + Dhu al-Qidah + Dhu al-Hijjah + Farvardin + Ordibehesht + Khordad + Tir + Mordad + Shahrivar + Mehr + Aban + Azar + Dey + Bahman + Esfand + %1$s (%2$s) - Datoteka %s je napačna. + Datoteka %s je neveljavna. Ne najdem datoteke %s. + Vnesite identiteto + Identiteta + Ta obrazec zahteva, da se identificirate. + Zapri + Razlog za spremembe + Razlog + Morate pojasniti razlog za spremembe tega obrazca. + Shrani + Izberite lokacijo + Možnosti + USGS National Map Topo + USGS National Map Hybrid + USGS National Map Imagery + CartoDB Positron + CartoDB Dark Matter + Basemap + Vir + Oprostite, %s temeljne karte niso na voljo na tem napravi. Prosimo, izberite drugo temeljno karto v Nastavitve > Zemljevidi. + %s slog zemljevida + Ulice + Teren + Hibrid Satelit - Poglej ali spremeni lokacijo - Spremeni lokacijo - Zapiši lokacijo + Svetlost + Temnost + Na prostem + Topografsko + Ne morem dostopati do Google Maps. Je nameščen Google Play Services? + Positron + Dark Matter + + + + + + + + + + + + + + + + Posnemi točko + Natančnost je %1$s m + Ponudnik lokacije: %s Ponudniki lociranja so onemogočeni! + Zemljepisna širina: %1$s\nZemljepisna dolžina: %2$s\nNadmorska višina: %3$sm\nNatančnost: %4$sm + Ta obrazec želi uporabiti vašo lokacijo, vendar storitve Google Play niso na voljo. + Ta obrazec želi uporabiti vašo lokacijo, vendar so ponudniki lokacije onemogočeni. Prosimo, omogočite jih v nastavitvah Androida. + Pojdite v nastavitve + Sledenje lokaciji: vklopljeno + Sledenje lokaciji: izklopljeno + Ta obrazec želi slediti vaši lokaciji, vendar je sledenje onemogočeno. Prosimo, omogočite ga v meniju %1$s zgoraj. + Ta obrazec sledi vaši lokaciji. Sledenje lahko onemogočite v meniju %1$s zgoraj. + Preveri napake + Pretečen čas: %1$s + Točka bo shranjena pri %1$s + Sateliti: %1$s + Pridobivam lokacijo. Prosim, počakajte. + Slaba natančnost. Prosim, počakajte. + Nesprejemljiva natančnost. Prosim, počakajte. + Izboljšujem natančnost. Počakajte, prosim. + Pridobivam lokacijo + %1$sm Z %1$s%2$s%3$s @@ -221,23 +444,76 @@ J %1$s%2$s%3$s S %1$s%2$s%3$s + GeoShape + Vhodna metoda Začni Omogoči GPS + GeoTrace + GPS v napravi je onemogočen. Želite omogočiti? Funkcija je že narejena. Želite izbrisati funkcijo? Želite zavreči spremembe in se vrniti v KoboToolbox? + Potrebne so vsaj 3 točke za ustvarjenje poligona + Potrebne so vsaj 2 točki za polilinijo Počisti + Določitev z dotikom + Ročno beleženje lokacije + Samodejno beleženje lokacije + Interval beleženja: + + %d sekunda + %d sekundi + %d sekund + %d sekund + + + %d minuta + %d minuti + %d minut + %d minut + + Zahtevana natančnost: + Brez + + %d meter + %d metra + %d metrov + %d metrov + + Pridobivanje lokacije, prosimo počakajte … + Natančnost lokacije: %.2f m + Natančnost lokacije: %.2f m (sprejemljivo) + Natančnost lokacije: %.2f m (nesprejemljivo) + Vnesene točke: %d + Vnesene točke: %d (tapnite za postavitev točk) + Vnesene točke: %d (ročno beleženje) + Vnesene točke: %1$d (snemanje vsakih %2$d sekund) + Vnesene točke: %1$d (snemanje vsakih %2$d min) + Vnesene točke: %1$d (snemanje vsakih %2$d sekund, natančnost %3$d m) + Vnesene točke: %1$d (snemanje vsakih %2$d min, natančnost %3$d m) + Dolgi pritisk za postavitev oznake ali tapnite gumb za dodajanje oznake. + Pritisni gumb za označevanje. Zavrzi + %s: %d (%d prikazano na zemljevidu) + Izberi + Nov element + Uredi osnutek + Ogled shranjenega obrazca + Sledenje lokaciji - + Beležim lokacijo… + Pokaži mojo lokacijo + Prikaži vse + Premor + Odstrani zadnjo točko @@ -255,6 +531,10 @@ Pokaži poslane in neposlane obrazce Pokaži neposlane obrazce Pošiljanja se izvaja v ozadju, prosim poskusite znova cez malo časa + Obrazci so bili uspešno poslani. + Obrazci niso bili poslani. + Vsi prenosi so uspeli! + %1$s od %2$s pošiljanj ni uspelo! @@ -264,18 +544,37 @@ Pridobivam %1$s.\n\nObrazec %2$s od %3$sobrazcev. %1$s. Pridobivam medijske datoteke: %2$s od %3$s Verzija: + Na voljo so posodobitve obrazca + To je novejša verzija že prenešenega obrazca + Posodobitev obrazca ni uspela. + Obrazec je bil uspešno posodobljen. + Seznam obrazcev ni bilo mogoče prenesti. + Obrazci so bili uspešno preneseni. + Obrazci niso bili preneseni. + Vsi prenosi so uspeli! + %1$s od %2$s prenosov ni uspelo! + ID: %1$s Različica: %2$s + Če se vam težave večkrat ponavljajo, jih sporočite osebi, ki vas je prosila, da zbirate podatke. + Collect ne more doseči strežnika na %s. Ste pravilno vnesli URL? + Collect se ne more varno povezati s strežnikom na %s. Ste pravilno vnesli URL? + Strežnik %1$s je vrnil kodo stanja %2$s. + Strežnik %s je posredoval neveljaven odgovor. + Strežnik ni zagotovil hasha za ta obrazec. + Ta obrazec ne more biti sprocesiran. + Pri prenosu tega obrazca je prišlo do napake na napravi. + Ta obrazec ima neveljaven URL za oddajo. @@ -287,37 +586,57 @@ Izbriši izbrano Ne briši Izbriši obrazce - Napaka. %1$s od %2$s izbranih obrazcev nisem uspel zbrisati. Izbriši izbrane obrazce %s obrazcev uspešno zbrisanih! - Brisanje obrazca že poteka! + Nastavitve projekta + Strežnik + URL, uporabniško ime, geslo + Prikaz projektov + Ime, ikone, barve Uporabniški vmesnik + Jezik aplikacije, tema, velikost pisave + Zemljevidi + Temeljne karte, stili, plasti + Upravljanje obrazcev + Samodejno posodabljanje, pošiljanje in brisanje + Identiteta uporabnika in naprave + Uporabniško ime, telefonska številka, ID naprave + Eksperimentalno + Zaščiteno + Odkleni zaščitene nastavitve + Spremenite geslo, nastavite nadzor dostopa + Nastavi skrbniško geslo + Upravljanje projektov + Ponovno nastavi, ponastavi, izbriši + Nadzor dostopa + Omejitve uporabniškega vmesnika Nastavitve strežnika URL + Strežnik URL Napaka, neveljaven URL! Uporabniško ime KoboToolbox uporabniško ime @@ -328,7 +647,9 @@ Tema Svetla tema Temna tema + Uporabi temo naprave Jezik + Uporabi jezik naprave Velikost črk Velikost črk Extra veliko @@ -343,12 +664,19 @@ Naprej + Nazaj + Posodobitev obrazcev + Posodobitve praznih obrazcev + Ročno + Samo obstoječi obrazci + Strogo usklajevanje obrazcev s stanjem na strežniku + Frekvenca samodejnih posodobitev Vsakih petnajst minut @@ -361,25 +689,59 @@ Samodejni prenos Samodejno prenesi posodobljene različice obrazcev Skrij stare različice obrazca + Prikazana bo samo najnovejša različica v seznamu praznih obrazcev. Predložitev obrazca Samodejno pošlji Samodejno pošlji + Izključeno Samo WiFi Samo mobilno omrežje WiFi ali mobilno omrežje + Izbriši po pošiljanju + Izbriše zaključene obrazce in medijske datoteke iz naprave po pošiljanju na strežnik. + Izpolnjevanje obrazca + Omejitve procesiranja Način obravnave omejitev Ovrednoti ob potegu naprej Zadrži vrednotenje do zaključka + Visoko-resolucijski video Omogoči video zapis visoke resolucije + Velikost slike + Največje število pikslov na daljši stranici slike + Velika (3072px) + Srednja (2048px) + Majhna (1024px) + Zelo majhna (640px) + Izvirna velikost iz kamere (privzeto) + Pokaži navodila za vprašanja + Ne + Da - vedno prikazano + Da - skrito + Uporabite zunanjo aplikacijo za snemanje zvoka + Uvoz obrazcev + Zaključi obrazec ob uvozu + Vpliva na obrazce, dodane neposredno v mapo. + Podatki o uporabi Zberi anonimne podatke o uporabi + Anonimni podatki o uporabi pomagajo KoboToolbox ekipi določiti prednost popravkov in funkcij. + Metapodatki obrazca + Metapodatki obrazca Vrednosti bodo dodane obrazcem, ki imajo uporabnisko ime, email ali telefonsko stevilko … + Določeno s strani uporabnika Telefonska številka + E-poštni naslov + Neveljaven e-poštni naslov! + Določeno z napravo + ID naprave + Ni na voljo + Odkleni nastavitve + Nastavitve odklenjene @@ -392,28 +754,85 @@ Spremeni Admin geslo Pokaži geslo Ne izberi da ne prikaže v Glavnem meniju + Ne odkljukaj, da ne prikažeš pri Splošnih nastavitvah + Ne odkljukaj, da ne prikažeš pri izpopolnjevanju obrazca + Ne odkljukaj, da ne prikažeš pri zaključevanju obrazca Resetiraj + Izberite med nastavitvami, obrazci, podatki. + Izbriši + Izberi vse Počisti vse. + Vse nastavitve (notranje in določene s strani uporabnika) Shranjeni obrazci + Prazni obrazci (mapa obrazcev, baza podatkov, baza vprašanj) + Sloji zemljevida (mapa slojev) + Predpomnilnik obrazcev (mapa .predpomnilnika) + Izberite, kaj želite ponastaviti. Resetiraj + Ponastavljanje … + Ponastavi rezultate Vsi izbrani podatki bodo trajno izbrisani. Razveljavitev ni mogoča. + Vse nastavitve :: %s Shranjeni obrazci :: %s Prazni obrazci :: %s + Sloji zemljevida :: %s + Predpomnilnik obrazcev :: %s + Glavni meni nastavitev + Pokaži ali skrij gumbe + Uporabniške nastavitve + Pokaži ali skrij nastavitve Nastavitve za vnos obrazca + Pokaži ali skrij dejanja + Gibanje nazaj + Gibanje nazaj onemogočeno + Želite preprečiti uporabnikom, da bi zaobšli to nastavitev?\n\nSpremembe so: \n\u2022\u2022 Onemogoči urejanje osnutka \n\u2022 Onemogoči shranjevanje kot osnutek \n\u2022 Onemogoči prehod na poziv \n\u2022 Ovrednosti vprašanja ob premiku naprej + Da + Ne + Gibanje nazaj omogočeno + Morda želite pregledati naslednje nastavitve:\n\n\u2022 Uredi osnutek\n\u2022 Shrani kot osnutek\n\u2022 Pojdi na poziv\n\u2022 Vrednotenje vprašanj + Shrani kot osnutek + Shrani ikona v zgornji vrstici in gumb \"Shrani kot osnutek\" ob izhodu iz obrazca + O nas + Delite KoboCollect. Ali vaši kolegi še vedno zbirajo podatke na papirju? Delite KoboCollect z njimi. + Oceni aplikacijo v Play Store. + Vaša (upajmo pozitivna) ocena poveča vidnost aplikacije v Play Store. + Obiščite spletno stran KoboToolbox. + KoboCollect is part of KoboToolbox and based on ODK Collect. + Pridružite se KoboToolbox forumu + Join the forum to get support and connect with other users! + Odprtokodne knjižnice/licence Stojimo na ramenih velikanov! + Ponovno konfigurirajte s QR kodo + Zamenjajte vse obstoječe nastavitve. + QR koda konfiguracija + Skeniraj + QR koda + Uvozi QR kodo Nastavitve uspešno uvožene + Doseženo največje število znakov: QR koda ne more biti ustvarjena za vse nastavitve + Deli Vklopi svetilko Izklopi svetilko + Vsebuje občutljive informacije: admin in gesla za strežnik + QR koda ne vsebuje gesel za admin ali server. Tapnite za konfiguracijo. + Vsebuje občutljive informacije: admin geslo + Vsebuje občutljive informacije: server geslo Geslo strežnika + Gesla vključena v kodo + Generiraj + QR koda ne vsebuje veljavnih nastavitev + QR koda ni bila najdena na izbrani sliki. + Trenutne nastavitve so poškodovane. Iz nastavitev upravljanja projekta ponastavite nastavitve ali uvozite delujoče. + Google Drive/Sheets projekti niso več na voljo @@ -425,6 +844,7 @@ Prekini Prekini Prekinjam + Splošna obvestila Uspešno Nastala je napaka Malo počakaj, prosim. @@ -433,76 +853,228 @@ Ne morem ustvariti medijsko mapo \'%s\'. Ne morem kopirat \'%1$s\' preko \'%2$s\'. Razlog: %3$s. Naložili ste dva različna obrazca z istim ID obrazca in verzijo. Izbrišite enega od obeh. + Izberite bližnjico za dostop do KoboToolbox + Ta obrazec snema zvok preko mikrofona vaše naprave.\n\nZa zagotovitev, da je mikrofon dovolj blizu zvoku, ki ga želite posneti, lahko uporabite kazalnik glasnosti.\n\nZa ustavitev snemanja zapustite ta obrazec.\n\nZa več informacij se obrnite na osebo, ki vas je prosila za zbiranje podatkov. + Nalaganje … + Projekti + Nastavitve + Dodaj projekt + Dodaj + Podvoji projekt + Projekt s temi nastavitvami povezave že obstaja? Ali želite preklopiti na obstoječi projekt ali dodati novega? + Dodaj podvojen projekt + Preklopi na obstoječi projekt + Ime projekta + Ikona projekta + Barva projekta + Preklopil na %s + Uporablja se %s + Preklopi na %s + Vsi prazni in poslani zaključeni obrazci ter nastavitve bodo trajno izbrisane. + Da + Ne + Projekt se ne da izbrisati + Imate nesposlane zaključene obrazce. Za izbris projekta morate najprej poslati ali izbrisati te obrazce. + Procesiram naloge v ozadju. Prosimo, poskusite znova kasneje. + ALI + Hex barva + Neveljaven hex koda + Zbiraj podatke\nkjer koli + Nastavi z QR kodo + Ročno vnesi projekt + Po dodajanju projekta ga lahko konfigurirate v Nastavitvah. + Še nimate projekta? + Poskusite demo. + Skenirajte konfiguracijsko QR kodo + Za odprtje tega obrazca morate najprej odpreti KoboCollect in preklopiti na projekt, ki ga vsebuje. + KoboCollect ni bil pravilno konfiguriran. Poskusite odpreti KoboCollect in ga konfigurirati.\n\nČe ste tapnili na bližnjico, jo boste morda morali ponovno ustvariti po konfiguriranju KoboCollect. + Pokaži podrobnosti + Napake + Odpri nastavitve + Entitete + + + + + + Ob zadnjem delovanju aplikacije je prišlo do kritične napake in zaustavitve aplikacije! + Ne morem zagnati aplikacije! + Ta projekt je bil v preteklosti povezan z računom Google Drive. Podpora za Google Drive je bila odstranjena. Lahko ponastavite strežnik ali prenesete izpolnjene obrazce na računalnik. + Preberi več + Razvijalska orodja + Prisilno zaustavi aplikacijo + Neujeta napaka zaustavila aplikacijo + O dovoljenjih + Če želite dovoliti dostop KoboCollect do spodaj navedenih funkcij, izberite \"dovoli\", če jih želite uporabljati. + Obvestila + Potrebno za prikaz posodobitev, ko se obrazci prenesejo, posodobijo in pošljejo. + Vaš obrazec je bil shranjen kot osnutek. + Vaš obrazec je bil shranjen. + Pošiljam obrazec… + Uredi + Poglej + Zapri snackbar + + Zadnji obrazec poslan: %d sekundo nazaj + Zadnji obrazec poslan: %d sekundi nazaj + Zadnji obrazec poslan: %d sekund nazaj + Zadnji obrazec poslan: %d sekund nazaj + + + Zadnji obrazec poslan: %d minuto nazaj + Zadnji obrazec poslan: %d minuti nazaj + Zadnji obrazec poslan: %d minut nazaj + Zadnji obrazec poslan: %d minut nazaj + + + Zadnji obrazec poslan: %d uro nazaj + Zadnji obrazec poslan: %d uri nazaj + Zadnji obrazec poslan: %d ur nazaj + Zadnji obrazec poslan: %d ur nazaj + + + Zadnji obrazec poslan: %d dan nazaj + Zadnji obrazec poslan: %d dni nazaj + Zadnji obrazec poslan: %d dni nazaj + Zadnji obrazec poslan: %d dni nazaj + + + %d obrazec pripravljen za pošiljanje + %d obrazca pripravljena za pošiljanje + %d obrazcev pripravljenih za pošiljanje + %d obrazcev pripravljenih za pošiljanje + + V kasnejših različicah zaključenih obrazcev ne bo več mogoče urejati. Obrazce shranite kot osnutke, če jih želite kasneje še spreminjati. \n\nLahko ovrednotiti morebitne napake pri izpolnjevanju obrazca tako, da tapnete tri pike (⋮) in nato Preveri napake. + V kasnejših različicah zaključenih obrazcev ne bo več mogoče urejati. + Zaključite vse osnutke + + Ali želite dokončati %d osnutek? + Želite dokončati %d osnutka? + Želite dokončati %d osnutkov? + Želite dokončati %d osnutkov? + + Ko zaključite osnutke, bodo v stanju \"Pripravljeni za pošiljanje\" in jih ne boste mogli več urejati. Vsi osnutki z napakami ne bodo zaključeni.\n\nTega dejanja ne boste mogli razveljaviti. + + Uspeh! %d osnutek je dokončan. + Uspeh! %d osnutka sta dokončana. + Uspeh! %d osnutkov je dokončanih. + Uspeh! %d osnutkov je dokončanih. + + + %d osnutek ima napake, ki jih je treba odpraviti pred zaključitvijo. + %d osnutka imata napake, ki jih je treba odpraviti pred zaključitvijo. + %d osnutkov ima napake, ki jih je treba odpraviti pred zaključitvijo. + %d osnutkov ima napake, ki jih je treba odpraviti pred zaključitvijo. + + %d osnutkov je dokončanih. %d osnutkov ima napake, ki jih je treba odpraviti pred zaključitivijo. + %d osnutkov zaključenih. Preostale osnutke bo potrebno zaključiti ročno. + Napake + Brez napak + Odkljukajte, da ne prikazujete v osnutkih + Nova funkcionalnost + Seznam osnutekov zdaj prikazuje zaznane napake. Vsakič, ko obrazec shranite kot osnutek, se izpolnjeni odgovori ponovno preverijo. \n\nOsnutki, označeni z \"Napake\", imajo manjkajoča zahtevana vprašanja ali imajo vrednosti, ki niso dovoljene. + Ničesar ni na voljo za prikaz + Brez praznih obrazcev + Prenesite obrazec, da začnete. + Ničesar ni v osnutkih + Ko shranite obrazec kot osnutek, se bodo pojavili tukaj + Nič ni pripravljenega za pošiljanje + Ko zaključite osnutke, se bodo pojavili tukaj + Nič ni bilo poslano + Ko pošljete zaključene obrazce, se bodo pojavili tukaj + Brez obrazcev za prenos. + Prenesite obrazec, da začnete + Ni obrazcev za izbris + Ko boste prenesli prazne obrazce, se bodo pojavili tukaj + Ko boste imeli shranjene obrazce, se bodo pojavili tukaj + + + + + + Zavrzi + + + + + + + + + + diff --git a/strings/src/main/res/values-so/strings.xml b/strings/src/main/res/values-so/strings.xml index 92c6dd04a72..de7cff7dbf8 100644 --- a/strings/src/main/res/values-so/strings.xml +++ b/strings/src/main/res/values-so/strings.xml @@ -99,7 +99,21 @@ - Diiwaan geli Goobta + + + + + + + + + + + + + + + @@ -118,7 +132,6 @@ - @@ -242,6 +255,11 @@ # Permissions ##############################################--> + + + + + @@ -286,4 +304,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-sq/strings.xml b/strings/src/main/res/values-sq/strings.xml index f3385f45671..d0aaa37ddba 100644 --- a/strings/src/main/res/values-sq/strings.xml +++ b/strings/src/main/res/values-sq/strings.xml @@ -234,9 +234,21 @@ Hibride Satelit E pamundur të aksesohen Google Maps. A është i instaluar Google Play Services? - Shih ose ndrysho vendndodhjen - Ndrysho vendndodhjen - Regjistro vendndodhjen. + + + + + + + + + + + + + + + Ofruesit e vendndodhjes janë të ç\'aktivizuar! Gjerësi: %1$s\nGjatësi: %2$s\nLartësi: %3$sm\nSaktësi: %4$sm @@ -244,10 +256,6 @@ - Regjistro vendndodhjen gjeografike - Shih vendndodhjen gjeografike - Nis GeoShape - Nis GeoTrace @@ -273,12 +281,9 @@ Shtyp për pak sekonda për të vendosur një shenjë ose shtuar buton shenjues Elimino - Shih ose ndrysho GeoShape - Shih ose ndrysho GeoTrace - @@ -334,9 +339,7 @@ Mos fshi Fshi format Duke fshirë formularët e përzgjedhur - Duke fshirë formularin:%1$dnga%2$d %s formë(a) u fshinë me sukses! - Një komandë për të fshirë formularin është duke u ekzekutuar! @@ -567,6 +570,11 @@ # Permissions ##############################################--> + + + + + @@ -611,4 +619,22 @@ + + + + + + Elimino + + + + + + + + + + diff --git a/strings/src/main/res/values-sr/strings.xml b/strings/src/main/res/values-sr/strings.xml index 80f77c5e39e..6db1d5ed67e 100644 --- a/strings/src/main/res/values-sr/strings.xml +++ b/strings/src/main/res/values-sr/strings.xml @@ -300,10 +300,22 @@ Hibrid Satelit Ne mogu da pristupim Google Mapama. Je li Google Play Service instaliran? - Vidi ili promijeni lokaciju - Promijeni lokaciju + + + + + + + + + + + + + + + Zabilježi lokaciju - Zabilježi lokaciju Pronalaženje lokacije je isključeno! Geografska širina:%1$s\nGeografska dužina: %2$s\nVisina: %3$sm\nPreciznost: %4$sm Ovaj formular želi da prati vašu poziciju ali Google Play Services nisu dostupne. @@ -315,10 +327,6 @@ Ovaj formular želi da prati vašu lokaciju ali je ona isključena. Molim uključite lokaciju u %1$s meniju iznad. Ovaj formular bilježi tvoju lokaciju. Možeš onemogućiti praćenje u %1$s meniju iznad. - Započni GeoPoint - Vidi GeoPoint - Započni GeoShape - Započni GeoTrace @@ -375,12 +383,9 @@ Dugi pritisak da ostavite znak ili dodir za dodavanje dugmeta za marker. Dodirni da dodaš dugme za marker Odbaci - Vidi ili promijeni GeoShape - Vidi ili promijeni GeoTrace - @@ -436,11 +441,8 @@ Obriši označeno Ne briši! Obriši formulare - %1$s od %2$s odabranog(ih) formulara ne može biti obrisan. Brišem odabrane formulare - Brišem formular: %1$d od %2$d %s formular(i) su uspješno obrisani! - Brisanje formulara je već u toku! @@ -685,6 +687,11 @@ # Permissions ##############################################--> + + + + + @@ -729,4 +736,22 @@ + + + + + + Odbaci + + + + + + + + + + diff --git a/strings/src/main/res/values-sv-rSE/strings.xml b/strings/src/main/res/values-sv-rSE/strings.xml index 6e63642f0e1..e5bce447d9e 100644 --- a/strings/src/main/res/values-sv-rSE/strings.xml +++ b/strings/src/main/res/values-sv-rSE/strings.xml @@ -309,20 +309,27 @@ Åtkomst till Google Maps ej möjlig. Är Google Play Services installerad? Positron Mörk materia - Referenslager - Visa eller ändra position - Ändra plats - Spara position + + + + + + + + + + + + + + + Tyvärr, platstjänster är inaktiverade! Latitud: %1$s\nLongitud: %2$s\nAltitud: %3$sm\nNoggrannhet: %4$sm - Starta GeoPoint - Visa GeoPoint - Starta GeoShape - Starta GeoTrace @@ -354,12 +361,9 @@ Lång tryckning för att placera markör eller knacka på knappen \'lägg till markör\' Knacka på knappen \'lägg till markör\'. Ignorera - Visa eller redigera GeoShape - Visa eller redigera GeoTrace - @@ -415,11 +419,8 @@ Radera vald Radera inte Radera formulär - Tyvärr, %1$s av %2$s valda formulär kunde inte raderas! Raderar valda formulär - Raderar formulär %1$d av %2$d %s formulär har raderats! - Tyvärr, radering av formulär pågår redan! @@ -666,6 +667,11 @@ Dela KoboCollect med dem. # Permissions ##############################################--> + + + + + @@ -710,4 +716,22 @@ Dela KoboCollect med dem. + + + + + + Ignorera + + + + + + + + + + diff --git a/strings/src/main/res/values-sw-rKE/strings.xml b/strings/src/main/res/values-sw-rKE/strings.xml index 4c5c62e358a..0e9ff96dcd1 100644 --- a/strings/src/main/res/values-sw-rKE/strings.xml +++ b/strings/src/main/res/values-sw-rKE/strings.xml @@ -178,8 +178,21 @@ These strings are only being used to preserve compatibility with pre-existing tile caches, and are not shown in the UI.--> Barabara - Tazama au badilisha Eneo/Mahali - Rekodi Eneo/Mahali + + + + + + + + + + + + + + + Samahani, viwekaji mahali/eneo vimezimwa! @@ -201,7 +214,6 @@ - @@ -252,9 +264,7 @@ Futa/Ondoa zilizochaguliwa USIFUTE Futa/Ondoa Fomu - Samahani, %1$s kati ya fomu() %2$s zilizochaguliwa hazikufutwa! Fomu() %s zimefutwa vilivyo! - samahani, kuna fomu tayari inafutwa! @@ -332,6 +342,7 @@ Samahani, password si sawa Weka password ya msimamizi fanya upya + fanya upya + + + + + @@ -428,4 +444,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-sw/strings.xml b/strings/src/main/res/values-sw/strings.xml index 2c02e590abb..b7815982bb5 100644 --- a/strings/src/main/res/values-sw/strings.xml +++ b/strings/src/main/res/values-sw/strings.xml @@ -343,22 +343,28 @@ Upatikanaji wa ramani za google umeshindikana. Je, huduma ya Google Play imesakinishwa kwenye simu? Positron Jambo la Giza - Safu ya Kumbukumbu - Safu ya faili la data - Tazama au badilisha eneo - Badilisha eneo + + + + + + + + + + + + + + + Rekodi pointi - Rekodi mahali Samahani, kionesha mahali hakijawezeshwa! Latitude: %1$s \nLongitude: %2$s \nUrefumwambao:%3$s m\nUsahihi: %4$s m - Anza geopoint - Tazama geopoint - Anza umbo la kijografia - Anza geotrace @@ -395,12 +401,9 @@ Bonyeza kwa mda kuweka alama au bofya kitufe ongeza alama. Bofya kitufe cha kuongeza alama. Sitisha - Onesha au badilisha umbo la Kijografia - Onesha au badili Mfuatilio wa Kijografia - @@ -457,11 +460,8 @@ Futa iliyochaguliwa Usifute Futa fomu - Samahani, %1$s kati ya %2$s fomu(s) ulizochagua hazikufutika! Inafuta fomu zilizochaguliwa - Inafuta fomu: %1$d kati ya %2$d %s fomu(s) zimefanikiwa kufutwa! - Samahani, kitendo cha kufuta fomu kinaendelea! @@ -714,6 +714,11 @@ # Permissions ##############################################--> + + + + + @@ -758,4 +763,22 @@ + + + + + + Sitisha + + + + + + + + + + diff --git a/strings/src/main/res/values-te/strings.xml b/strings/src/main/res/values-te/strings.xml index ae9e0583672..f433931e9af 100644 --- a/strings/src/main/res/values-te/strings.xml +++ b/strings/src/main/res/values-te/strings.xml @@ -330,14 +330,24 @@ Google మ్యాప్స్‌ను యాక్సెస్ చేయడం సాధ్యం కాట్లేదు. Google Play సేవలు ఇన్‌స్టాల్ చేయబడిందా? Positron Dark Matter - రిఫరెన్స్ లేయర్ - లేయర్ డేటా ఫైల్ - లొకేషన్ చూడండి లేదా మార్చండి - లొకేషన్ మార్చండి + + + + + + + + + + + + + + + ఒక పాయింట్ రికార్డ్ చేయండి %1$s ఖచ్చితత్వం %s ప్రదేశ అనుమతులు - లొకేషన్ రికార్డు చెయ్యండి క్షమించండి, లొకేషన్ ప్రొవైడర్లు డిసేబుల్ చెయ్యబడ్డాయి! అక్షాంశం: %1$s\n రేఖాంశం:%2$s \n ఎత్తు: %3$sm\n ఖచ్చితత్వం: %4$sm ఈ ఫారం మీ స్థానాన్ని ట్రాక్ చేయాలనుకుంటుంది, కానీ గూగుల్ ప్లే సేవలు అందుబాటులో లేవు. @@ -349,10 +359,6 @@ ఈ ఫారం మీ స్థానాన్ని ట్రాక్ చేయాలనుకుంటుంది కాని ట్రాకింగ్ నిలిపివేయబడింది. దయచేసి పై మెను %1$sలో ప్రారంభించండి. ఈ ఫారం మీ స్థానాన్ని ట్రాక్ చేస్తుంది. పై మెను%1$sలో మీరు ట్రాకింగ్‌ను నిలిపివేయవచ్చు. - జియోపాయింట్ ప్రారంభించండి - జియోపాయింట్ చూడండి - జియోషాప్ ప్రారంభించండి - జియోట్రేస్ ప్రారంభించండి @@ -407,13 +413,10 @@ గుర్తు ఉంచడానికి ఎక్కువసేపు నొక్కండి లేదా మార్కర్ బటన్‌ను నొక్కండి. మార్కర్ బటన్ యాడ్ చెయ్యడానికి ఇక్కడ నొక్కండి. విస్మరించండి - జియో షేప్ చూడండి లేదా మార్చండి - జియోట్రేస్‌ను చూడండి లేదా మార్చండి సేవ్ చెయ్యబడిన ఫారంలు చూడండి - @@ -472,11 +475,8 @@ ఎంచుకున్నదాన్ని తొలగించండి తొలగించవద్దు ఫారంలను తొలగించు - క్షమించండి, ఎంచుకున్న ఫారం(లు) %1$s of %2$sడిలిట్ అవ్వలేదు! ఎంచుకున్న ఫారమ్‌లను తొలగిస్తోంది - %2$dలో %1$dవ ఫారమ్ ని తొలగిస్తుంది %sఫారం(లు) విజయవంతంగా తొలగించబడ్డాయి! - క్షమించండి, ఫారం తొలగింపు చర్య ఇప్పటికే పురోగతిలో ఉంది! @@ -731,6 +731,11 @@ # Permissions ##############################################--> + + + + + @@ -775,4 +780,22 @@ + + + + + + విస్మరించండి + + + + + + + + + + diff --git a/strings/src/main/res/values-th-rTH/strings.xml b/strings/src/main/res/values-th-rTH/strings.xml index d25648ab522..60ed8e71632 100644 --- a/strings/src/main/res/values-th-rTH/strings.xml +++ b/strings/src/main/res/values-th-rTH/strings.xml @@ -198,18 +198,26 @@ Satellite เปิด Google Maps ไม่ได้ ตรวจสอบว่ามี Google Play Services ติดตั้งหรือไม่ - แสดงหรือเปลี่ยนตำแหน่ง - เปลี่ยนตำแหน่งพิกัด - บันทึกตำแหน่งที่อยู่ + + + + + + + + + + + + + + + ขออภัย GPS ถูกปิดการใช้งานไว้ - เริ่มต้นจุดพิกัด - แสดงจุดพิกัด - เริ่มต้น GeoShape - เริ่มต้น GeoTrace @@ -235,12 +243,9 @@ กดแช่ไว้เพื่อวางเครื่องหมายหรือแตะที่ปุ่มเพิ่มเครื่องหมาย แตะที่ปุ่มเพิ่มเครื่องหมาย ยกเลิก - แสดงหรือแก้ไข GeoShape - แสดงหรือแก้ไข GeoTrace - @@ -293,9 +298,7 @@ ลบแบบสำรวจที่เลือกไว้ ไม่ลบ ลบแบบสำรวจ - ขออภัย ไม่สามารถลบแบบสำรวจ %1$s จาก %2$s ที่เลือกไว้ได้ ลบแบบสำรวจ %s เรียบร้อยแล้ว - ขออภัย กำลังทำการลบแบบสำรวจ @@ -467,6 +470,11 @@ # Permissions ##############################################--> + + + + + @@ -511,4 +519,22 @@ + + + + + + ยกเลิก + + + + + + + + + + diff --git a/strings/src/main/res/values-ti/strings.xml b/strings/src/main/res/values-ti/strings.xml index 226e09f5489..b20c841a6e0 100644 --- a/strings/src/main/res/values-ti/strings.xml +++ b/strings/src/main/res/values-ti/strings.xml @@ -101,6 +101,21 @@ + + + + + + + + + + + + + + + @@ -119,7 +134,6 @@ - @@ -234,6 +248,11 @@ # Permissions ##############################################--> + + + + + @@ -278,4 +297,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-tl-rPH/strings.xml b/strings/src/main/res/values-tl-rPH/strings.xml index 3e62f2ac040..ae9622dc9e2 100644 --- a/strings/src/main/res/values-tl-rPH/strings.xml +++ b/strings/src/main/res/values-tl-rPH/strings.xml @@ -35,16 +35,13 @@ Balewalain ang mga binago Punan ang Blangkong Form May Maling Nangyari - Paumanhin, %1$s of %2$s ang mga piniling form(s) ay nabigong ma-i-karga! - %s na (mga) form ay matagumpay na binura! - Paumanhin, ang form ay kasalukuyang binubura! + %s na (mga) form ay matagumpay na binura! Talaksa: %s ay walang kabuluhan. Talaksa: %s ay nawawala. Tapos na ang pag-scan. Naka-karga na ang lahat ng forms. Mga Blangkong Form Kunin ang Barcode Kumuha ng Blangkong Form - Itala ang Kinalalagyan Paumanhin, ang mga provider ng Kinalalagyan ay hindi pinagana! Paumanhin, ang tugon na into ay hindi wasto! Punta sa Simula diff --git a/strings/src/main/res/values-tl/strings.xml b/strings/src/main/res/values-tl/strings.xml index 7b7b0eb5397..66266812fc7 100644 --- a/strings/src/main/res/values-tl/strings.xml +++ b/strings/src/main/res/values-tl/strings.xml @@ -146,8 +146,21 @@ - Palitan ang Lokasyon - Itala ang Kinalalagyan + + + + + + + + + + + + + + + Paumanhin, ang mga provider ng Kinalalagyan ay hindi pinagana! @@ -167,7 +180,6 @@ - @@ -218,13 +230,12 @@ Burahin ang mga napili Huwag burahin Oo, Burahin - Paumanhin, %1$s of %2$s ang mga piniling form(s) ay nabigong ma-i-karga! %s na (mga) form ay matagumpay na binura! - Paumanhin, ang form ay kasalukuyang binubura! + Server @@ -294,6 +305,7 @@ Enter Admin Password Uncheck to hide from Main Menu I-reset + I-reset + + + + + @@ -388,4 +405,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-tr/strings.xml b/strings/src/main/res/values-tr/strings.xml index edf6371d201..fc8fb8227f1 100644 --- a/strings/src/main/res/values-tr/strings.xml +++ b/strings/src/main/res/values-tr/strings.xml @@ -195,8 +195,21 @@ - Lokasyonu Göster ya da Değiştir - Yeri kaydet + + + + + + + + + + + + + + + @@ -215,7 +228,6 @@ - @@ -412,6 +424,11 @@ ##############################################--> Ayarları aç + + + + + @@ -457,4 +474,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-uk/strings.xml b/strings/src/main/res/values-uk/strings.xml index 31388b0b0d9..fcc9dab8f25 100644 --- a/strings/src/main/res/values-uk/strings.xml +++ b/strings/src/main/res/values-uk/strings.xml @@ -297,10 +297,22 @@ Змішана Супутник Нема доступу до Google Карт. Чи встановлено Google Play Services? - Подивитись або змінити місцезнаходження - Змінити Місцезнаходження + + + + + + + + + + + + + + + Записати точку - Записати місцезнаходження Вибачте! Джерела місцезнаходження не доступні! Широта: %1$s\nДовгота: %2$s\nВисота: %3$sm\nТочність: %4$sm Ця анкета хоче відслідковувати ваше розташування але Сервіси Google Play не доступні. @@ -312,10 +324,6 @@ Ця анкета хоче відслідковувати ваше розташування але відслідковування вимкнене. Будь ласка, увімкніть його в %1$s меню. Ця анкета відслідковує ваше розташування. Ви можете вимкнути відслідковування в %1$s меню. - Записати ГеоТочку - Переглянути ГеоТочку - Почати ГеоФігуру - Почати ГеоМаршрут @@ -375,12 +383,9 @@ Довго натисніть для розміщення позначки або натисніть кнопку Додати позначку. Натисніть кнопку Додати позначку. Скинути - Переглянути чи змінити ГеоФігуру - Переглянути чи змінити ГеоМаршрут - @@ -436,11 +441,8 @@ Видалити обране Не видаляти Видалити анкети - Вибачте, %1$s з %2$s обраних(ої) анкет(и) не вдалось видалити! Видалення обраних анкет - Видалення анкет: %1$d з %2$d %s анкет(у) успішно видалено! - Вибачте! Видалення анкети наразі виконується! @@ -684,6 +686,11 @@ # Permissions ##############################################--> + + + + + @@ -728,4 +735,22 @@ + + + + + + Скинути + + + + + + + + + + diff --git a/strings/src/main/res/values-ur-rPK/strings.xml b/strings/src/main/res/values-ur-rPK/strings.xml index 171e6df29fd..dfd2ec10618 100644 --- a/strings/src/main/res/values-ur-rPK/strings.xml +++ b/strings/src/main/res/values-ur-rPK/strings.xml @@ -151,8 +151,6 @@ - مقام کو ملاحظہ کریں یا تبدیلکریں۔ - مقام درج کریں معافی، مقام فراہم کرنے والے نااہل کئے گئے ہیں @@ -223,9 +221,7 @@ منتخب شدہ کو مٹادیں نہ مٹا ئیں فارم کو مٹا دیں - منتخب شدہ ‎%1$s‏ فارم(ز) کا ‎%2$s‏ نہ مٹ سکا - فارم ‎%s‏ کامیابی سے مٹادئے گئے - معافی، فارم مٹانے کا عمل پہلے سے جاری ہے + فارم ‎%s‏ کامیابی سے مٹادئے گئے diff --git a/strings/src/main/res/values-ur/strings.xml b/strings/src/main/res/values-ur/strings.xml index 3b582392135..d315f937dc2 100644 --- a/strings/src/main/res/values-ur/strings.xml +++ b/strings/src/main/res/values-ur/strings.xml @@ -389,14 +389,24 @@ گوگل میپس تک رسائی حاصل کرنے میں ناکام۔ کیا گوگل پلے سروسز انسٹالڈہے؟ Positron Dark Matter - ریفرنس Layer - Layer کی ڈیٹا فائل - مقام کو دیکھیں یا تبدیل کریں۔ - جگہ تبدیل کریں + + + + + + + + + + + + + + + پوائنٹ کو ریکارڈ کریں درستگی: %1$s میٹر لوکیشن مہیا کرنے والا: %s - لوکیشن ریکارڈ کریں معذرت، لوکیشن فراہم کرنے والے نااہل کئے گئے ہیں طول عرض: %1$s \nطول بلد: %2$s \n اونچائی: %3$s میٹر\n ایکیوریسی: %4$s میٹر یہ فارم آپ کی لوکیشن کو ریکارڈ کرنا چاہتا ہے، مگر Google Play Services دستیاب نہیں ہیں۔ @@ -409,12 +419,6 @@ یہ فارم آپ کی لوکیشن ریکارڈ کرتا ہے۔ لوکیشن ریکارڈنگ کو روکنے کے لئے %1$s مینیو دبایئں۔ غلطیوں کو چیک کریں - جیو پوائنٹ شروع کریں - جیوپوائنٹ دیکھیں - Geoshape کو دیکھیں - Geotrace کو دیکھیں - جیوشیپ شروع کریں - جیو ٹریس شروع کریں کتنا ٹائم گزرا: %1$s @@ -482,8 +486,6 @@ پلیس مارک کرنے کے لیے طویل دبائیں یا ایڈ مارکر بٹن ٹیپ کریں مارکر بٹن پر کلک کریں. ختم کر دینا - جیو شیپ دیکھیں یا تبدیل کریں - جیو ٹریس دیکھیں یا تبدیل کریں %s: %d (%d میپ پہ موجود) منتخب کریں نیا آئٹم @@ -494,8 +496,6 @@ لوکیشن کو ٹریک کریں لوکیشن کا پتہ لگایا جا رہا ہے۔۔۔ - - ریفرنس layers کے متعلق مدد حاصل کریں میری لوکیشن دکھایئں @@ -576,11 +576,8 @@ منتخب شدہ کو مٹادیں نہ مٹا ئیں فارم کو مٹا دیں - معذرت، منتخب شدہ ‎‏%1$s آف%2$s فارم(ز) آف ‎‏ ڈیلیٹ نہیں ہو سکے منتخب کردہ فارم کو ڈیلیٹ کیا جارہا ہے - فارم ڈیلیٹ کیے جا رہے ہیں:%1$d آوؑٹ آف%2$d %s‏فارم (ز )‎‏ کامیابی سےڈیلیٹ کر دیے گئے - معافی، فارم مٹانے کا عمل پہلے سے جاری ہے @@ -799,7 +796,7 @@ Constraint processing - کے بارے میں + مزید معلومات او ڈی کے کوللیکٹ شیئرکریں کیا آپ کے ساتھی ابھی بھی کاغذ پر ڈیٹا جمع کرتے ہیں؟ ان کے ساتھ او ڈی کے کوللیکٹ شیئرکریں پلے اسٹور پر رائے دیجیے @@ -930,6 +927,11 @@ Constraint processing سیٹنگز کھولیں Entities + + + + + @@ -1040,4 +1042,22 @@ Constraint processing + + + + + + ختم کر دینا + + + + + + + + + + diff --git a/strings/src/main/res/values-vi/strings.xml b/strings/src/main/res/values-vi/strings.xml index fb0673841e8..e0d8f33eba2 100644 --- a/strings/src/main/res/values-vi/strings.xml +++ b/strings/src/main/res/values-vi/strings.xml @@ -149,7 +149,21 @@ - Ghi lại vị trí + + + + + + + + + + + + + + + Xin lỗi, Khả năng xác định vị trí không được kích hoạt! @@ -169,7 +183,6 @@ - @@ -221,13 +234,12 @@ Xoá lựa chọn Không xoá Xoá biểu mẫu - Xin lỗi, %1$s của %2$s các biểu mẫu được chọn bị lỗi khi xoá! Biểu mẫu %s đã bị xoá! - Xin lỗi, file này đang được xoá! + Máy chủ @@ -297,6 +309,7 @@ Enter Admin Password Uncheck to hide from Main Menu Reset + Reset + + + + + @@ -391,4 +409,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values-zh-rTW/strings.xml b/strings/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 00000000000..e20b8620dff --- /dev/null +++ b/strings/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,1086 @@ + + + + 新建表格 + + 草稿 + + 準備傳送 + + 已傳送 + + 下載表格 + + 刪除表格 + 已保存的表格 + 版本: %s + ID: %s + + \'添加於\' EEE, MMM dd, yyyy \' \' HH:mm + + \'更新於\' EEE, MMM dd, yyyy \' \' HH:mm + + \'保存於\' EEE, MMM dd, yyyy \' \' HH:mm + + \'完成於\' EEE, MMM dd, yyyy \' \' HH:mm + + \'傳送於\' EEE, MMM dd, yyyy \' \' HH:mm + + \'傳送失敗於\' EEE, MMM dd, yyyy \' \' HH:mm + + \'於\' EEE, MMM dd, yyyy \' \' HH:mm刪除 + 提交已刪除 + + \'修改日期\' EEE, MMM dd, yyyy \' \' HH:mm + 加密的表單 + 刪除的表單 + 排序列表 + 排序 + 依名稱:A->Z + 依名稱:Z->A + 依日期:由新至舊 + 依日期:由舊至新 + + 依保存時間:由新至舊 + 篩選列表 + + 搜索 + 您嘗試啟動的活動需要未授予的權限。 + 存儲權限 + 沒有這些權限,程式無法存取您的表單或填寫的資料。請授與權限後,再重新開啟此程式。 + 位置權限 + + 沒有 精確位置 權限,Collect 無法記錄您的位置。請授與權限後再重試。 + 相機權限 + 沒有此權限,Collect 無法存取相機。請授與權限後再重試。 + 錄製音頻權限 + 沒有此權限,Collect 無法存取麥克風。請在準備好授予時重試。 + + + 打開表單地圖 + 添加了 %1$d 個表單. + 文件掃瞄結束,所有表格已經被載入。 + %1$s解析出錯: %2$s 缺少或無效。 + 正在載入表格 + 正在讀取表單定義… + 正在讀取調查資料… + 正在讀取CSV文件… + 無法讀取表格。 + 載入表單時出錯。請重試。 + 無法編輯此草稿,因為相應的空白表格不存在或已被刪除。\n\n表格ID: %1$s + 一旦此表格被標記為「最終版」,它便不能再次編輯,因為它可能已被加密。 + + 已從自動儲存點恢復未儲存的修改! + + 更改語言 + + 前往提示 + + 儲存為草稿 + + 開始 + + 結束 + + 返回 + + 群組 + + 可重複群組 + 退出 + + 於 \"%s\"增加項目? + 增加 + 不增加 + 增加其他項目 + + 修改提示 + 刪除針對 \"%s\" 的回答? + 刪除回答 + 刪除這個回答? + 取消 + 刪除回答 + 刪除群組 + 刪除群組 + 刪除這個群組? + 刪除群組 \"%s\" 及其子群組? + 取消 + 回答無效! + 此問題為必答題! + 成功!表格中未發現錯誤。 + 警告:內在值 %s 有空白符號 + 警告:多個內在值 %s 有空白符號 + 此表單對於此設備太複雜。嘗試簡化表達或在論壇上尋求幫助。 + 內部錯誤:提示步驟失敗。 + 您已在 %s 的結尾。 + 最終的表格不可編輯。 + 請注意,傳送後表格不可編輯。 + 如果您需要對表格進行編輯,請點選「儲存為草稿」,直到您準備好傳送為止。 + 瞭解更多 + 儲存為草稿 + 最終確定 + 傳送 + 表格儲存失敗! + 表格已成功儲存! + 錯誤!儲存還原點文件:%s + + 退出 %s + + 放棄表格 + 放棄變更 + 儲存修改 + 繼續編輯 + 儲存表格? + 您可以儲存此表單,並隨時從草稿中存取它。 + \'此表格最後儲存於\' EEE, MMM dd, yyyy\' \'HH:mm\'。另存為草稿以保留變更。\' + 繼續編輯? + 如果您放棄儲存表單,您將失去所有已做的修改。 + \'此表格最後儲存於\' EEE, MMM dd, yyyy HH:mm \',如果您放棄變更,將失去此後所做的更改。\' + 正在儲存表格 + 正在驗證答案… + 正在收集資料… + 正在儲存至SD卡… + 正儲存到SD卡… + 加密資料 + 此實例中沒有一條記錄! + 並非只有一個空白表單與此 jr_form_id 匹配。 + 未指定表單ID??? + 手機不支持RSA加密。 + RSA公鑰無效。 + %s 表單尚未儲存為完成狀態。 + + 錯誤的URI: %s + + 無法識別的URI: %s + + 無法開始錄製。 + + 麥克風已在使用中。 + + 無法將媒體附加到表單,但可以在 %1$s 取得 + + 錄製已禁用。在 %s 中啟用 + + 填寫的表單已被刪除! + + 嘗試更新數值失敗,這通常是因為表單設計的 calculation 欄位使用不正確所引起。\n\n如果您一直存在此問題,請向要求您收集資料的人報告。 + + + 執行 + 沒找到與 %s 對應的活動 + 所請求的應用程式無法使用,請手動填入對應值。 + 外部應用程式未提供預期訊息。 + + 列印 + 初始化列印 + 請求的印表機未被安裝. 請安裝印表機. + 打開Url + 啟動 OpenMapKit + 重做OSM標記 + 已編輯的OSM XML文件: + 出了問題,我們沒有獲得有效的OSM資料。 + 警告 + 請安裝OpenMapKit! + 取得條碼 + 替換條碼 + 請將條碼放於矩形內 + 打開文件 + 排序項目 + 數字選擇器 + 此部件的參數無效! + SVG文件不存在! + 拍照 + 選擇圖片 + 所選文件不是有效影像檔案 + 點擊螢幕拍照 + 前置鏡頭在此設備上不可用 + 拍照時出錯 + 無法啟動相機! + 註釋該圖片 + Gif 檔案格式不支援 + 應用程式返回了無效的文件類型 + + 儲存並關閉 + 取得簽名 + 收集簽名 + 修改影像 + 繪製圖像 + 重置 + 選擇顏色 + 拍攝影片 + 選擇影片 + 播放影片 + 選擇文件 + + 刪除 + + 錄音 + + 選擇錄音檔 + + 暫停 + + 恢復 + + 錄音:開啟 + + 錄音:關閉 + 此表單在背景錄音。您必須授予使用麥克風的權限,否則無法開啟此表單。 + + 此表單要求背景錄音,停用它將停止錄製,並放棄現有錄音,請您確定是否繼續? + + 停用錄音 + + 錄音不會立即開始,您必須重新開啟表單進行記錄。 + + 刪除文件? + + 刪除此文件後,您將無法恢復該文件,請您確定是否繼續? + + 停止 + + 正在錄製… + + 錄音 + + 播放聲音 + + 儲存文件 + + 離開此螢幕前必須停止錄音。 + 在存取媒體文件: %s 時發生了錯誤 + + 選擇數值 + 選擇回答 + + 編輯數值 + + 沒有選定的數值 + 選定: + 好的,請繼續。 + 此文件不包含資料! + 列 %s 相符! + sortby列應該只包含數值。衝突值為 \'%s\'. + search() 函數語法錯誤:該函數需要1、4或6個參數。 + search() 函數中的語法錯誤:未識別的函數 \'%s\'. + search()函數中的語法錯誤: \'%s\' 未作為函數計算. + 搜索處理程式返回了類型為 \'%s\'的對象. + 無法從 %1$s 中導入資料,原因:%2$s + 從中 \'%1$s\'預載入資料, 請稍候… %2$s + 讀取資料已取消! + 正在完成預載入的資料… + 讀取資料已完成! + ExternalDataManager尚未初始化。 + 外部資料 %1$s 尚未導入. 也許您忘記在表單中包含 %2$s.csv 文件? + search() 函數中的語法錯誤: %s + \'%s\' 無法賦予值。 + XPathParser 異常: \"%s\" + 記錄方位 + 替代方位 + 載入方位 + 記錄方位 + 方位: %.3f + 方向: %s + 無法收集方位資訊:缺少加速計或磁場傳感器設備,或兩者都缺。 + 選擇日期 + 選擇時間 + 未選擇日期 + 未選擇時間 + Meskerem + Tikimt + Hidar + Tahsas + Tir + Yekatit + Megabit + Miazia + Ginbot + Senie + Hamlie + Nehasie + Pagumien + Thout + Paopi + Hathor + Koiak + Tobi + Meshir + Paremhat + Parmouti + Pashons + Paoni + Epip + Mesori + Pi Kogi Enavot + Muharram + Safar + Rabi\' al-awwal + Rabi\' al-thani + Jumada al-awwal + Jumada al-thani + Rajab + Sha\'ban + Ramadan + Shawwal + Dhu al-Qidah + Dhu al-Hijjah + Farvardin + Ordibehesht + Khordad + Tir + Mordad + Shahrivar + Mehr + Aban + Azar + Dey + Bahman + Esfand + + %1$s (%2$s) + + 文件: %s 不是有效文件。 + + 文件: %s 不能被找到。 + + 輸入身份 + + 身份 + + 此表單要求您確認身份 + + 關閉 + + 變更原因 + + 理由 + + 您需要解釋更改此表單的原因 + + 儲存 + + 選擇地點 + 選擇 + + + USGS地形圖 + USGS混合地圖 + USGS影像地圖 + CartoDB Positron + CartoDB Dark Matter + 底圖 + 來源 + 抱歉, %s 此設備上沒有底圖。請在「設置」中選擇另一個底圖 > 地圖. + %s 地圖樣式 + 街道 + 地形 + 混合 + 衛星 + 淺色 + 深色 + 戶外 + 地形圖 + 無法存取谷歌地圖。是否安裝了Google Play服務? + Positron + Dark Matter + + 離線圖層 + + 圖層 + + 選擇離線圖層 + + 選擇要用於該項目中所有地圖的離線圖層。您可從設備中選取 MBTiles 文件,添加選項到列表中,也可以從列表刪除現有圖層。 + + 瞭解有關添加 MBTiles 的更多訊息。 + + 新增圖層 + + 刪除圖層 + + + %d 圖層無法新增 + + + 您選擇的文件不是 MBTiles。您只能添加 MBTiles 文件。 + + 某些您選擇的文件不是 MBTiles 格式,您只能添加 MBTiles 文件。 + + 圖層 + + 選擇存取圖層 + 您希望圖層在所有項目中使用,還是僅在當前項目中使用? + + 所有項目 + + 目前項目 + + 您確定要刪除 %1$s 離線圖層嗎? + 記錄點 + 精確度: %1$s m + 位置提供程式: %s + 位置服務被關閉! + 緯度: %1$s\n經度: %2$s\n高程: %3$sm\n精度: %4$sm + 此表單希望跟蹤您的位置,但Google Play服務不可用。 + 此表單希望跟蹤您的位置,但位置提供程式被禁用,請在Android設置中啟用。 + + 轉到設置 + + 追蹤位置:開啟 + + 追蹤位置:關閉 + 此表單希望跟蹤您的位置,但已禁用跟蹤,請在選單 %1$s 上啟用 + 此表單跟蹤您的位置,您可以選單上 %1$s 中禁用跟蹤。 + + 檢查錯誤 + + 已用時間:%1$s + + 點將儲存在 %1$s + + 衛星:%1$s + 正在嘗試取得位置,請稍候.. + 精度差,請稍候。 + 精度不可接受,請稍候。 + 提高準確性,請稍候。 + 正在取得位置 + + %1$sm + + W %1$s%2$s%3$s + + E %1$s%2$s%3$s + + S %1$s%2$s%3$s + + N %1$s%2$s%3$s + GeoShape + 輸入方法 + 開始 + 啟用GPS + GeoTrace + 設備上的GPS已禁用,是否要啟用它? + 對像已建立,是否要清除該對像? + 放棄更改並返回 KoboToolbox? + 必須至少有3個點才能建立多邊形 + 必須至少有2個點才能建立多段線 + 清除 + 輕敲放置 + 手動位置記錄 + 自動位置記錄 + 記錄間隔: + + + %d 秒 + + + + %d 分鐘 + + 精度要求: + 沒有 + + + %d 米 + + 正在搜索位置,請稍候… + 位置精度: %.2f m + 位置精度: %.2f m (可接受) + 位置精度: %.2f m (不可接受) + 輸入的點數: %d + 輸入的點數: %d (點擊以放置點) + 輸入的點數: %d (手動記錄) + 輸入的點數: %1$d (每隔%2$d 秒記錄) + 輸入的點數: %1$d (每隔 %2$d 分鐘記錄) + 輸入的點數: %1$d (每隔 %2$d 秒記錄, 精度%3$d m) + 輸入的點數: %1$d (每隔 %2$d 分鐘記錄, 精度 %3$d m) + 長按放置標記或點擊添加標記按鈕。 + 點擊添加標記按鈕。 + 取消 + %s: %d (%d 在圖上) + 選擇 + 新項目 + + 編輯草稿 + 查看已儲存的表格 + + 位置跟蹤 + + 跟蹤位置… + + 顯示位置 + + 縮放至適合所有 + + 暫停 + + 移除最後一點 + + 正在傳送表格 + 沒有選定表格! + 沒有可用網路 + 上傳 %2$s 中的 %1$s 個表格 + 上傳選定項 + 伺服器:%s無效的使用者名稱或密碼 + 伺服器要求驗證身份 + 沒有表格被上傳。 + 上傳結果 + + 修改視圖 + 顯示已傳送和未傳送的表格 + 只顯示未傳送的表格 + 後台傳送正在運行,請稍後重試 + 表單上載成功 + 表單上載失敗 + 所有上傳成功! + %1$s / %2$s 上載失敗! + + 更新 + 下載選取項目 + 連接到伺服器 + 正在下載 %1$s.\n\n表單 %2$s / %3$s 表單.. + %1$s. 載入媒體文件: %3$s 中的 %2$s + 版本: + 表單更新可用 + 這是對您的表單的更新 + 表單更新失敗 + 表單更新成功 + 表單列表下載失敗 + 表單下載成功 + 表單下載失敗 + 所有下載成功! + %1$s / %2$s 下載失敗! + + ID: %1$s 版本: %2$s + + 如果你一直有這個問題,請向要求你收集資料的人報告。 + + 無法在存取伺服器 %s,您輸入的URL是否正確? + + 無法安全連接到位於 %s 的伺服器. 您輸入的URL是否正確? + + 伺服器 %1$s 返回的狀態 %2$s。 + + 伺服器 %s 提供了無效的響應。 + + 伺服器未為此表單提供哈希值。 + + 無法處理此表單。 + + 下載此表單時,設備上出現磁碟錯誤。 + + 此表單的提交URL無效。 + + + 已儲存的表格 + + 空表格 + 刪除表格 %s ? + 刪除選中 + 不刪除 + 刪除表格 + 刪除所選表單 + %s 表格被成功刪除! + + 項目設置 + + 伺服器 + + URL、使用者名稱、密碼 + + 項目顯示 + + 名稱、圖示、顏色 + + 使用者介面 + + 應用程式語言、主題、字體大小 + + 地圖 + + 底圖、樣式、圖層 + + 表單管理 + + 自動更新、自動傳送、自動刪除 + + 使用者和設備標識 + + 使用者名稱、電話號碼、設備ID + + 實驗項目 + 受保護的設置 + + 解鎖受保護的設置 + + 更改密碼,設置存取控制 + 設置管理員密碼 + + 項目管理 + + 重新配置、重置、刪除 + + 存取控制 + + 有限使用者界面 + 伺服器設置 + + URL + + 伺服器 URL + 不正確的URL! + 使用者名稱 + 使用者名稱 + 密碼 + 密碼 + 密碼字串前後不能有空格 + 使用者名稱字串前後不能有空格 + 主題 + 淺色主題 + 深色主題 + 使用設備主題 + 語言 + 使用設備語言 + 字體大小 + 字體大小 + 極大 + + + + 極小 + 操作方式 + 使用螢幕左右滑動 + 使用前進/後退按鈕 + 使用滑動和按鈕 + + 下一個 + + 上一個 + + 表單更新 + + 空白表單更新模式 + + 手動 + + 僅限以前下載的表單 + + 與伺服器完全相符 + + 自動更新頻率 + + 每十五分鐘 + + 每小時 + + 每6小時 + + 每24小時 + + 自動下載 + 自動下載表單的更新版本 + 隱藏舊版本表單 + 只有最新版本才會出現在「填寫空白表單」中 + 表單提交 + 自動傳送 + 自動傳送 + 關閉 + 僅Wi-Fi + 僅行動網路 + Wi-Fi或行動網路 + 傳送後自動刪除 + 傳送到伺服器後刪除已完成的表單和媒體 + 表單填寫 + 約束處理 + 約束檢查 + 向前滑動時驗證 + 推遲檢驗直到完成 + 高解析度影片 + 啟用高解析度影片錄製 + 相片大小 + 相片長的最高像素值 + 大(3072px) + 中(2048px) + 小(1024px) + 非常小(640px) + 相機的原始大小(默認) + + 顯示指南 + + 是-始終顯示 + + 是-已折疊 + + 使用外部應用程式進行音頻錄製 + 表單導入 + 導入時完成表單 + 影響直接添加到實例文件夾的表單。 + 使用情況資料 + 收集匿名使用資料 + 匿名使用資料幫助團隊確定修復和功能的優先級。 + 表單元資料 + 表單元資料 + 這些值將添加到預先載入了使用者名稱、電子郵件和/或電話字段的表單中,以標識提交資料的人。 + 使用者定義的 + 電話號碼 + 電子郵件地址 + 電子郵件地址無效! + 設備已定義 + 設備ID + + 不可用 + + 解鎖設置 + + 設置已解鎖 + + 輸入新密碼 + 管理員密碼 + 管理員密碼已成功修改 + 管理員密碼已禁用 + 對不起, 密碼不正確! + 輸入管理員密碼 + 更改管理員密碼 + 顯示密碼 + 不選中以從主菜單中隱藏 + 取消選中以隱藏不受保護的設置 + 取消選中以隱藏表單條目 + 取消選中以隱藏 Form End + 重置 + 從設置、表單和資料中進行選擇 + 刪除 + 全選 + 清空 + 所有設置(內部設置、儲存的設置) + 儲存的表單(實例文件夾、實例資料庫) + 空白表單(表單文件夾、表單資料庫、元素集資料庫) + 地圖圖層(圖層文件夾) + 表單載入緩存(.cache文件夾) + 選擇要重置的內容 + 重置 + 正在重置… + 重置結果 + 所有選定的資料將被永久刪除。無法撤消。 + 所有設置 :: %s + 儲存的表單 :: %s + 空白表單 :: %s + 地圖圖層 :: %s + 表單載入緩存 :: %s + 主菜單設置 + 顯示或隱藏按鈕 + 使用者設置 + 顯示或隱藏設置 + 表單條目設置 + 顯示或隱藏操作 + + 向後移動 + 向後移動已禁用 + 配置設備以防止使用者繞過此設置?\n\n更改如下:\n\u2022 禁用「編輯草稿」\n\u2022 禁用「另存為草稿」\n\u2022 禁用「轉到提示」\n\u2022 將約束處理設置為向前滑動時驗證 + + + 已啟用向後移動 + 您可能希望檢查以下設置:\n\n\u2022 編輯草稿\n\u2022 另存為草稿\n\u2022 前往提示\n\u2022 約束處理 + + 儲存為草稿 + 退出表單時,頂部欄中的儲存圖標和「另存為草稿」按鈕 + + 關於 + 分享KoboCollect + 您的同事還在用紙本收集資料嗎?與他們分享 KoboCollect 應用程式。 + 留下 Play Store 評論 + 你的評論(希望是正面的)提高了應用程式在應用商店中的知名度。 + 造訪 KoboToolbox 網站 + KoboCollect is part of KoboToolbox and based on ODK Collect. + 加入論壇 + Join the forum to get support and connect with other users! + 開源庫/許可證 + 我們站在巨人的肩膀上! + + 使用 QR code 重新配置 + 替換所有設置 + QR code 配置 + 掃瞄 + QR code + 導入 QR code + 成功導入設置 + 已達到最大字符數限制:無法為所有設置生成 QR code + 分享 + 打開閃光燈 + 關閉閃光燈 + 包含敏感資訊:管理員伺服器密碼 + QR code 不包含 管理員伺服器 密碼。點擊以配置 + 包含敏感資訊:管理員 密碼 + 包含敏感資訊:伺服器 密碼 + 伺服器密碼 + 代碼中包含的密碼 + 生成 + QR code 不包含有效設置 + 在所選圖像中找不到 QR code + 當前設置已損壞。從項目管理設置中,重置設置或導入工作設置。 + 無法再建立 Google Drive/Sheets 項目 + + 確定 + + 取消 + 取消 + 取消 + 取消 + 取消 + 正在取消 + 一般通知 + 成功 + 錯誤 + 請稍候。 + 請稍候。這可能需要幾分鐘。 + 無法刪除 \'%s\'. 請手動刪除文件並重新下載表單。 + 無法建立媒體文件夾 \'%s\'. + 不能複製 \'%1$s\' 到 \'%2$s\',原因: %3$s + 您下載了兩個具有相同表單ID和版本的不同表單。也許它們是在不同的時間或不同的伺服器上傳的相同表單。無論如何,您都應該刪除一個。 + 選擇 KoboToolbox 快捷方式 + 此表單記錄設備麥克風的音頻。\n\n您可以使用音量指示器來確保麥克風足夠接近您需要錄製的聲音。\n\n若要停止錄製,請退出此表單。\n\n有關詳細資訊,請與要求您收集資料的人員聯繫。 + 正在載入… + + 項目 + 設置 + 添加項目 + 添加 + + 重複項目 + + 您已經有此連結設置的項目。要切換到現有項目還是添加新項目? + + 添加重複項目 + + 切換到現有項目 + 項目名稱 + 項目圖示 + 項目顏色 + + 已切換到 %s + + 使用 %s + + 切換到 %s + 所有空白表單、提交內容和設置將被永久刪除。 + + + 無法刪除項目 + 您有未傳送的提交內容,若要刪除項目,必須先傳送或刪除這些提交內容。 + 後台作業正在運行。請稍後再試。 + + 十六進位顏色 + 無效的十六進位代碼 + + + 收集資料\n任何位置 + + 使用 QR code 配置 + + 手動輸入項目詳細訊息 + 在新增項目後,可以於「設置」對項目進行設定 + + 還沒有項目? + + 嘗試示範項目 + 掃瞄配置 QR code + + 要打開此表單,必須首先開啟 KoboCollect 並切換到包含它的項目。 + + 尚未配置 KoboCollect 。嘗試開啟 KoboCollect 進行設定。\n\n如果您點選了快捷方式,您可能需要在配置 KoboCollect 後重新建立它。 + + 顯示詳細訊息 + 錯誤 + + 打開設置 + + 實體 + + 啟用本地實體 + 後續表格將具有一致的實體列表,並將包括本地創建或更新的實體。 + + 查看實體列表 + + 全部清除 + + 新增實體列表 + + 離線 + + 應用程式上次運行時發生了崩潰! + 無法啟動應用程式! + + 該項目之前已連接到 Google Drive 帳戶。Google Drive 支持已被刪除。您可以配置伺服器或將提交內容傳至電腦。 + + + 瞭解更多 + + + 開發人員工具 + + 崩潰應用程式 + + 強制執行導致應用程式崩潰的未捕獲異常 + 關於權限 + 您將被要求允許 KoboCollect 存取以下功能,如果您想使用它們,請選擇「允許」。 + 提醒事項 + 在下載、更新和傳送表單時需要顯示更新。 + + 您的表單已儲存為草稿。 + 你的表格已被儲存。 + 傳送表格中… + 編輯 + 檢視 + 關閉通知 + + + 最後傳送的表格:%d 秒前 + + + 最後傳送的表格:%d 分鐘前 + + + 最後傳送的表格:%d 小時前 + + + 最後傳送的表格:%d 天前 + + + %d 表格已準備傳送 + + 在後續版本中,最終確定的表格將不再可編輯。將表單儲存為草稿以便稍後編輯。\n\n您可以通過點擊三個點 (⋮) 然後檢查錯誤來檢查草稿表單中的錯誤。 + + 在後續版本中,最終確定的表格將不再可編輯。 + + 完成所有草稿 + + + 您要完成 %d 草稿? + + + 一旦您完成所有草稿,它們將處於「準備傳送」狀態,並且您將無法進行編輯。任何有錯誤的草稿都不會被最終確定。\n\n您將無法撤消此操作。 + + + 成功!%d 份草稿已完成。 + + + + %d 個草稿有錯誤,必須在最終確定之前解決。 + + + %d 份草稿已完成。%d 個草稿有錯誤,必須在最終確定之前解決。 + + %d 份草稿已完成。剩下的草稿需要手動完成。 + + 錯誤 + + 無錯誤 + + 取消選中即可在草稿中隱藏 + + 新功能 + + 草稿列表現在已顯示驗證錯誤。每次將表單儲存為草稿時,其驗證狀態都會更新。\n\n標有「錯誤」的草稿要麼缺少必填問題,要麼包含不允許的值。 + + 沒有可顯示的內容 + + 無空白表格 + + 下載表格以開始使用 + + 無草稿 + + 當您儲存為草稿時,表格將顯示在此處 + + 尚無準備傳送的表單 + + 當你完成草稿時,它們將顯示在這裡 + + 沒有任何已傳送的表單 + + 當您傳送最終表單後,它們將在這裡顯示 + + 無下載表單 + 下載表格以開始使用 + + 沒有要刪除的表單 + + 下載空白表單後,它們將顯示在這裡 + + 當您儲存表單後,它們將顯示在此處 + + + 恢復您的作業? + + \'Collect 於 \'EEE, MMM dd, yyyy HH:mm\' 意外關閉並儲存了您的工作。\n\n您想恢復還是放棄它?\' + + 恢復 + + 放棄 + + 取得定位點 + + 檢視或查看定位點 + + 查看定位點 + + 變更定位點 + + 取得線段 + + 查看或變更線段 + + 檢視線段 + + 獲取多邊形 + + 檢視或變更多邊形 + + 檢視多邊形 + diff --git a/strings/src/main/res/values-zh/strings.xml b/strings/src/main/res/values-zh/strings.xml index b1cbb4fae56..18983b62a28 100644 --- a/strings/src/main/res/values-zh/strings.xml +++ b/strings/src/main/res/values-zh/strings.xml @@ -69,6 +69,7 @@ 正在读取CSV文件… 无法读取表格. 加载表单时出错。请重试。 + 无法编辑此草稿,因为相应的空白表格不存在或已被删除。\n\n表格ID: %1$s 一旦此表格被标记为“最终版”,它便不能再次编辑,因为它可能已被加密。 已从自动保存点恢复未保存修改! @@ -114,7 +115,12 @@ 此表单对于此设备太复杂。尝试简化表达或在论坛上寻求帮助。 内部错误:提示步骤失败。 您已在 %s 的末尾. + 最终的表格不可编辑。 + 发送的表格不可编辑。 + 如果您需要对表格进行编辑,请“另存为草稿”,直到您准备好发送为止。 + 了解更多 保存为草稿 + 最终确定 发送 表格保存失败! 表格已成功保存! @@ -122,7 +128,16 @@ 退出 %s + 放弃表格 + 放弃变更 保存修改 + 继续编辑 + 储存表格? + 您可以保存此表单并随时从草稿中存取它。 + \'此表格最后储存于\' EEE, MMM dd, yyyy\'at\'HH:mm\'。另存为草稿以保留变更。\' + 继续编辑? + 如果您丢弃表单,您将失去所有已做的更改。 + \'此表格最后保存于 \'EEE, MMM dd, yyyy HH:mm\'。如果您放弃更改,您将失去此后所做的所有更改。\' 正在保存表格 正在验证答案… 正在收集数据… @@ -160,6 +175,7 @@ 请求的应用不可用. 请手动填入相应值. 外部应用程序未提供预期信息。 + 列印 初始化打印 请求的打印机未被安装. 请安装打印机. 打开Url @@ -182,7 +198,10 @@ 所选文件不是有效图像 点击屏幕拍照 前置摄像头在此设备上不可用 + 拍照时出错 + 无法启动相机! 注释该图片 + Gif 档案格式不支援 应用程序返回了无效的文件类型 保存并关闭 @@ -207,7 +226,9 @@ 恢复 + 录音:开 + 录音:关闭 此窗体在后台录制音频。您必须授予使用麦克风的权限。否则,您将无法打开此表单。 此表单请求后台音频录制。禁用它将停止录制并丢弃现有音频。是否确实要继续? @@ -370,14 +391,42 @@ 无法访问谷歌地图。是否安装了Google Play服务? Positron Dark Matter - 应用图层 - 图层数据文件 - 查看或修改位置 - 更改地点 + + 离线图层 + + 图层 + + 选择离线图层 + + 选择要用于该项目中所有地图的离线图层。您可以通过从设备中选择 MBTiles 文件来将选项添加到列表中,也可以从列表中删除现有图层。 + + 了解有关添加 MBTiles 的更多信息。 + + 新增图层 + + 删除图层 + + + %d 图层无法新增 + + + 您选择的文件不是 MBTiles。您只能添加 MBTiles 文件。 + + 某些您选择的文件不是 MBTiles 格式。您只能添加 MBTiles 文件。 + + 图层 + + 选择访问图层 + 您希望图层在所有项目中使用,还是仅在当前项目中使用? + + 所有项目 + + 目前项目 + + 您确定要删除 %1$s 离线图层吗? 记录点 精确度: %1$s m 位置提供程序: %s - 获取位置 位置服务被关闭! 纬度: %1$s\n经度: %2$s\n高程: %3$sm\n精度: %4$sm 此表单希望跟踪您的位置,但Google Play服务不可用。 @@ -385,17 +434,13 @@ 转到设置 + 追踪位置:开 + 追踪位置:关 此表单希望跟踪您的位置,但已禁用跟踪. 请在菜单 %1$s 上启用他 此表单跟踪您的位置. 您可以在上面的菜单 %1$s 中禁用跟踪。 检查错误 - 开始GeoPoint - 查看GeoPoint - 查看 GeoShape - 查看 GeoTrace - 开始 GeoShape - 开始 GeoTrace 已用时间: %1$s @@ -460,8 +505,6 @@ 长按放置标记或点击添加标记按钮。 点击添加标记按钮。 丢弃 - 查看或更改GeoShape - 查看或更改GeoTrace %s: %d (%d 在图上) 选择 新元素 @@ -472,8 +515,6 @@ 位置跟踪 跟踪位置… - - 获取引用图层帮助 显示位置 @@ -554,11 +595,8 @@ 删除选中 不删除 删除表格 - %2$s 中的 %1$s 个选定的表格不能被删除! 删除所选表单 - 正在删除表单: %1$d /%2$d %s 表格被成功删除! - 正在删除表格! @@ -727,6 +765,7 @@ 不选中以从主菜单中隐藏 取消选中以隐藏不受保护的设置 取消选中以隐藏表单条目 + 取消选中以隐藏 Form End 重置 从设置、表单和数据中进行选择 删除 @@ -756,11 +795,14 @@ 向后移动 向后移动已禁用 + 配置设备以防止用户绕过此设置?\n\n更改如下:\n\u2022 禁用“编辑草稿”\n\u2022 禁用“另存为草稿”\n\u2022 禁用“转到提示”\n\u2022 将约束处理设置为向前滑动时验证 已启用向后移动 + 您可能希望检查以下设置:\n\n\u2022 编辑草稿\n\u2022 另存为草稿\n\u2022 前往提示\n\u2022 约束处理 保存为草稿 + 退出表单时,顶部栏中的保存图标和“另存为草稿”按钮 @@ -799,6 +841,7 @@ 二维码不包含有效设置 在所选图像中找不到二维码 当前设置已损坏。从项目管理设置中,重置设置或导入工作设置。 + 无法再创建 Google Drive/Sheets 项目 @@ -886,6 +929,17 @@ 打开设置 实体 + + 启用本地实体 + 后续表格将具有一致的实体列表,并将包括本地创建或更新的实体。 + + 查看实体列表 + + 全部清除 + + 新增实体列表 + + 离线 @@ -894,6 +948,10 @@ + 该项目之前已连接到 Google Drive 帐户。Google Drive 支持已被删除。您可以配置服务器或将提交内容传至电脑。 + + + 了解更多 @@ -903,41 +961,126 @@ 崩溃应用程序 强制执行导致应用程序崩溃的未捕获异常 + 关于权限 + 您将被要求允许 KoboCollect 访问以下功能,如果您想使用它们,请选择“允许”。 提醒事项 + 在下载、更新和发送表单时需要显示更新。 + 您的表单已保存为草稿。 你的表格已被保存。 发送表格中… 编辑 + 检视 + 关闭通知 + + 最后发送的表格:%d 秒前 + + + 最后发送的表格:%d 分钟前 + + + 最后发送的表格:%d 小时前 + + + 最后发送的表格:%d 天前 + + + %d 表格已准备传送 + + 在后续版本中,最终确定的表格将不再可编辑。将表单保存为草稿以便稍后编辑。\n\n您可以通过点击三个点 (⋮) 然后检查错误来检查草稿表单中的错误。 + 在后续版本中,最终确定的表格将不再可编辑。 + 完成所有草稿 + + 您要完成 %d 草稿? + + 一旦您完成所有草稿,它们将处于“准备发送”状态,并且您将无法进行编辑。任何有错误的草稿都不会被最终确定。\n\n您将无法撤消此操作。 + + 成功!%d 份草稿已完成。 + + + %d 个草稿有错误,必须在最终确定之前解决。 + + %d 份草稿已完成。%d 个草稿有错误,必须在最终确定之前解决。 + %d 份草稿已完成。剩下的草稿需要手动完成。 错误 + 无错误 + 取消选中即可在草稿中隐藏 + 新功能 + 草稿列表现在已显示验证错误。每次将表单保存为草稿时,其验证状态都会更新。\n\n标有“错误”的草稿要么缺少必填问题,要么包含不允许的值。 + 没有可显示的内容 + 無空白表格 + 下载表格以开始使用 + 無草稿 + 当您保存为草稿时,表格将显示在此处 + 尚无准备发送的表單 + 当你完成草稿时,它们将显示在这里 + 沒有任何已發送的表單 + 当您发送最终表單後,它们将在这里显示 + 无下载表單 + 下载表格以开始使用 + 没有要删除的表單 + 下载空白表单后,它们将显示在这里 + 当您保存表单后,它们将显示在此处 + + + 恢复您的作业? + + \'Collect 于 \'EEE, MMM dd, yyyy HH:mm\' 意外关闭并保存了您的工作。\n\n您想恢复还是放弃它?\' + + 恢复 + + 放弃 + + 取得定位点 + + 检视或查看定位点 + + 查看定位点 + + 变更定位点 + + 取得线段 + + 查看或变更线段 + + 检视线段 + + 获取多边形 + + 检视或变更多边形 + + 检视多边形 diff --git a/strings/src/main/res/values-zu/strings.xml b/strings/src/main/res/values-zu/strings.xml index bd8f723bb31..9093588bea8 100644 --- a/strings/src/main/res/values-zu/strings.xml +++ b/strings/src/main/res/values-zu/strings.xml @@ -160,8 +160,21 @@ - Bheka noma ushintshe isizinda/indawo - Bhala Indawo + + + + + + + + + + + + + + + Uxolo, indawo enikeziwe ayisebenzi @@ -181,7 +194,6 @@ - @@ -232,9 +244,7 @@ Susa okukhethiwe Ungasusi Susa amafomu - Uxoli, %1$s of %2$s ifomu elikhethiwe lihlulekile ukususwa %s ifomu lisuswe ngempumelelo - Uxolo ukususwa kwefomu kusaqhubeka @@ -309,6 +319,7 @@ Faka inombolomfihlo yokusingathwa komsebenzi Susa uphawu ukufihla ohlwini oluwumongo Hlela kabusha + Hlela kabusha + + + + + @@ -404,4 +420,21 @@ + + + + + + + + + + + + + + + diff --git a/strings/src/main/res/values/strings.xml b/strings/src/main/res/values/strings.xml index 3972ceb2372..88dc3306c21 100644 --- a/strings/src/main/res/values/strings.xml +++ b/strings/src/main/res/values/strings.xml @@ -479,15 +479,58 @@ Positron Dark Matter - Reference layer - Layer data file + + Offline layers + + + Layer + + + Select offline layer + + + Select the offline layer to use for all maps in this project. You can add options to the list by selecting an MBTiles file from your device and also delete existing layers from the list. + + + Learn more about adding MBTiles. + + + Add layers + + + Delete layer + + + + %d layer can\'t be added + %d layers can\'t be added + + + + The files you selected are not MBTiles. You can only add MBTiles files. + + + Some of the files you selected are not MBTiles. You can only add MBTiles files. + + + Layers + + + Select layers access + Do you want the layers available in all projects or your current project only? + + + All projects + + + Current project only + + + Are you sure you want to delete the %1$s offline layer? - View or Change Location - Change Location Record a point Accuracy: %1$s m Location provider: %s - Record Location Sorry, Location providers are disabled! Latitude: %1$s\nLongitude: %2$s\nAltitude: %3$sm\nAccuracy: %4$sm This form wants to track your location but Google Play Services are not available. @@ -504,13 +547,6 @@ Check for errors - Start GeoPoint - View GeoPoint - View GeoShape - View GeoTrace - Start GeoShape - Start GeoTrace - Time elapsed: %1$s @@ -584,8 +620,6 @@ Long press to place mark or tap add marker button. Tap add marker button. Discard - View or Change GeoShape - View or Change GeoTrace %s: %d (%d shown on map) Select New item @@ -599,9 +633,6 @@ Tracking location… - - Get help with Reference Layers - Show my location @@ -720,11 +751,8 @@ Delete Selected Do Not Delete Delete Forms - Sorry, %1$s of %2$s selected form(s) failed to delete! Deleting selected forms - Deleting form: %1$d out of %2$d %s form(s) successfully deleted! - Sorry, a form delete action is already in progress! Entities + + Enable local entities + + Follow up forms will have consistent entity lists and will include locally created or updated entities. + + + View entity lists + + + Clear all + + + Add entity list + + + Offline + When you have saved forms, they will appear here + + + + Recover your work? + + \'Collect closed unexpectedly on \'EEE, MMM dd, yyyy \'at\' HH:mm\' and saved your work.\n\nWould you like to recover or discard it?\' + + Recover + + Discard + + + Get point + + View or change point + + View point + + Change point + + Get line + + View or change line + + View line + + Get polygon + + View or change polygon + + View polygon diff --git a/strings/src/main/res/values/untranslated.xml b/strings/src/main/res/values/untranslated.xml index a47bb26f7b8..764952ecc6e 100644 --- a/strings/src/main/res/values/untranslated.xml +++ b/strings/src/main/res/values/untranslated.xml @@ -18,9 +18,6 @@ the specific language governing permissions and limitations under the License. - light_theme dark_theme system - manual - previously_downloaded - match_exactly Google diff --git a/strings/src/test/java/org/odk/collect/strings/format/DateFormatsTest.kt b/strings/src/test/java/org/odk/collect/strings/format/DateFormatsTest.kt index 92fbb386d09..7fa8c99e9dc 100644 --- a/strings/src/test/java/org/odk/collect/strings/format/DateFormatsTest.kt +++ b/strings/src/test/java/org/odk/collect/strings/format/DateFormatsTest.kt @@ -42,5 +42,6 @@ private val formats = setOf( R.string.sent_on_date_at_time, R.string.sending_failed_on_date_at_time, R.string.deleted_on_date_at_time, - R.string.modified_on_date_at_time + R.string.modified_on_date_at_time, + R.string.savepoint_recovery_dialog_message ) diff --git a/test-forms/src/main/resources/forms/fieldlist-updates.xml b/test-forms/src/main/resources/forms/fieldlist-updates.xml index f812e44bf28..cd52632c820 100644 --- a/test-forms/src/main/resources/forms/fieldlist-updates.xml +++ b/test-forms/src/main/resources/forms/fieldlist-updates.xml @@ -186,6 +186,10 @@ + + + + @@ -372,6 +376,8 @@ + + @@ -729,5 +735,14 @@ + + + + + + + + + diff --git a/test-forms/src/main/resources/forms/one-question-autosend-disabled.xml b/test-forms/src/main/resources/forms/one-question-autosend-disabled.xml new file mode 100644 index 00000000000..30ef523a4c5 --- /dev/null +++ b/test-forms/src/main/resources/forms/one-question-autosend-disabled.xml @@ -0,0 +1,20 @@ + + + + One Question Autosend Disabled + + + + + + + + + + + + + + + + diff --git a/test-forms/src/main/resources/forms/one-question-entity-follow-up.xml b/test-forms/src/main/resources/forms/one-question-entity-follow-up.xml new file mode 100644 index 00000000000..02c683f453c --- /dev/null +++ b/test-forms/src/main/resources/forms/one-question-entity-follow-up.xml @@ -0,0 +1,30 @@ + + + + One Question Entity Follow Up + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test-forms/src/main/resources/forms/one-question-entity.xml b/test-forms/src/main/resources/forms/one-question-entity-registration.xml similarity index 95% rename from test-forms/src/main/resources/forms/one-question-entity.xml rename to test-forms/src/main/resources/forms/one-question-entity-registration.xml index e393931bf73..71eda71fa3f 100644 --- a/test-forms/src/main/resources/forms/one-question-entity.xml +++ b/test-forms/src/main/resources/forms/one-question-entity-registration.xml @@ -1,7 +1,7 @@ - One Question Entity + One Question Entity Registration diff --git a/test-forms/src/main/resources/forms/one-question-entity-update.xml b/test-forms/src/main/resources/forms/one-question-entity-update.xml new file mode 100644 index 00000000000..7d09de3c653 --- /dev/null +++ b/test-forms/src/main/resources/forms/one-question-entity-update.xml @@ -0,0 +1,44 @@ + + + + One Question Entity Update + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test-forms/src/main/resources/forms/repeat_group_wrapped_with_a_regular_group.xml b/test-forms/src/main/resources/forms/repeat_group_wrapped_with_a_regular_group.xml new file mode 100644 index 00000000000..58c10696645 --- /dev/null +++ b/test-forms/src/main/resources/forms/repeat_group_wrapped_with_a_regular_group.xml @@ -0,0 +1,45 @@ + + + + Repeat group wrapped with a regular group + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test-forms/src/main/resources/media/external_data_broken.csv b/test-forms/src/main/resources/media/external_data_broken.csv new file mode 100644 index 00000000000..9548d45efdc --- /dev/null +++ b/test-forms/src/main/resources/media/external_data_broken.csv @@ -0,0 +1,4 @@ +name,label +one,"One +two,Two +three,Three diff --git a/test-forms/src/main/resources/media/people.csv b/test-forms/src/main/resources/media/people.csv new file mode 100644 index 00000000000..6637680bc39 --- /dev/null +++ b/test-forms/src/main/resources/media/people.csv @@ -0,0 +1,2 @@ +name,label,full_name,__version +123abc,Roman Roy,Roman Roy,1 diff --git a/test-forms/src/main/resources/media/updated-people.csv b/test-forms/src/main/resources/media/updated-people.csv new file mode 100644 index 00000000000..06119278ee5 --- /dev/null +++ b/test-forms/src/main/resources/media/updated-people.csv @@ -0,0 +1,2 @@ +name,label,full_name,__version +123abc,Ro-Ro Roy,Ro-Ro Roy,3 diff --git a/test-shared/src/main/java/org/odk/collect/testshared/AssertIntentsHelper.kt b/test-shared/src/main/java/org/odk/collect/testshared/AssertIntentsHelper.kt index 69b2043c02d..5fc7a3b88df 100644 --- a/test-shared/src/main/java/org/odk/collect/testshared/AssertIntentsHelper.kt +++ b/test-shared/src/main/java/org/odk/collect/testshared/AssertIntentsHelper.kt @@ -3,7 +3,7 @@ package org.odk.collect.testshared import android.content.Intent import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent import org.hamcrest.Matcher -import org.odk.collect.testshared.EspressoHelpers.assertIntents +import org.odk.collect.testshared.Assertions.assertIntents import kotlin.reflect.KClass class AssertIntentsHelper { diff --git a/test-shared/src/main/java/org/odk/collect/testshared/Assertions.kt b/test-shared/src/main/java/org/odk/collect/testshared/Assertions.kt new file mode 100644 index 00000000000..6730512d7ab --- /dev/null +++ b/test-shared/src/main/java/org/odk/collect/testshared/Assertions.kt @@ -0,0 +1,38 @@ +package org.odk.collect.testshared + +import android.content.Intent +import android.view.View +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.Root +import androidx.test.espresso.assertion.ViewAssertions.doesNotExist +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.matcher.ViewMatchers.Visibility.VISIBLE +import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility +import org.hamcrest.CoreMatchers.not +import org.hamcrest.Matcher +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.allOf +import org.hamcrest.Matchers.equalTo + +object Assertions { + + fun assertText(view: Matcher, root: Matcher? = null) { + val onView = if (root != null) { + onView(allOf(view, withEffectiveVisibility(VISIBLE))).inRoot(root) + } else { + onView(allOf(view, withEffectiveVisibility(VISIBLE))) + } + + onView.check(matches(not(doesNotExist()))) + } + + fun assertIntents(vararg intentMatchers: Matcher) { + val intents = Intents.getIntents() + assertThat(intentMatchers.size, equalTo(intents.size)) + + intentMatchers.forEachIndexed { index, matcher -> + assertThat(intents[index], matcher) + } + } +} diff --git a/test-shared/src/main/java/org/odk/collect/testshared/EspressoHelpers.kt b/test-shared/src/main/java/org/odk/collect/testshared/EspressoHelpers.kt deleted file mode 100644 index 7cbf1d16c2b..00000000000 --- a/test-shared/src/main/java/org/odk/collect/testshared/EspressoHelpers.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.odk.collect.testshared - -import android.content.Intent -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.assertion.ViewAssertions.doesNotExist -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.intent.Intents -import androidx.test.espresso.matcher.RootMatchers.isDialog -import androidx.test.espresso.matcher.ViewMatchers.Visibility.VISIBLE -import androidx.test.espresso.matcher.ViewMatchers.withContentDescription -import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility -import androidx.test.espresso.matcher.ViewMatchers.withText -import org.hamcrest.CoreMatchers.not -import org.hamcrest.Matcher -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.allOf -import org.hamcrest.Matchers.equalTo - -object EspressoHelpers { - - fun assertText(text: String) { - onView(allOf(withText(text), withEffectiveVisibility(VISIBLE))) - .check(matches(not(doesNotExist()))) - } - - fun assertTextInDialog(text: String) { - onView(allOf(withText(text), withEffectiveVisibility(VISIBLE))) - .inRoot(isDialog()) - .check(matches(not(doesNotExist()))) - } - - fun clickOnContentDescription(string: Int) { - onView(withContentDescription(string)).perform(click()) - } - - fun clickOnText(string: Int) { - onView(withText(string)).perform(click()) - } - - fun clickOnTextInDialog(string: Int) { - onView(withText(string)).inRoot(isDialog()).perform(click()) - } - - fun assertIntents(vararg matchers: Matcher) { - val intents = Intents.getIntents() - assertThat(matchers.size, equalTo(intents.size)) - - matchers.forEachIndexed { index, matcher -> - assertThat(intents[index], matcher) - } - } -} diff --git a/test-shared/src/main/java/org/odk/collect/testshared/FakeScheduler.kt b/test-shared/src/main/java/org/odk/collect/testshared/FakeScheduler.kt index 53a9ed19a13..3646e5f2223 100644 --- a/test-shared/src/main/java/org/odk/collect/testshared/FakeScheduler.kt +++ b/test-shared/src/main/java/org/odk/collect/testshared/FakeScheduler.kt @@ -35,17 +35,23 @@ class FakeScheduler : Scheduler { ) } - override fun immediate(background: Boolean, runnable: Runnable) { - if (background) { + override fun immediate(foreground: Boolean, runnable: Runnable) { + if (!foreground) { backgroundTasks.push(runnable) } else { foregroundTasks.push(runnable) } } - override fun networkDeferred(tag: String, spec: TaskSpec, inputData: Map) {} - override fun networkDeferred( + tag: String, + spec: TaskSpec, + inputData: Map, + networkConstraint: Scheduler.NetworkType? + ) { + } + + override fun networkDeferredRepeat( tag: String, taskSpec: TaskSpec, repeatPeriod: Long, @@ -71,6 +77,12 @@ class FakeScheduler : Scheduler { return flow.flowOn(backgroundDispatcher) } + fun runFirstForeground() { + if (foregroundTasks.isNotEmpty()) { + foregroundTasks.removeFirst().run() + } + } + fun runForeground() { while (foregroundTasks.isNotEmpty()) { foregroundTasks.remove().run() @@ -97,6 +109,12 @@ class FakeScheduler : Scheduler { } } + fun runFirstBackground() { + if (backgroundTasks.isNotEmpty()) { + backgroundTasks.removeFirst().run() + } + } + fun runBackground() { while (backgroundTasks.isNotEmpty()) { backgroundTasks.remove().run() @@ -122,9 +140,9 @@ class FakeScheduler : Scheduler { } fun LiveData.getOrAwaitValue( - scheduler: FakeScheduler + scheduler: FakeScheduler? = null ): T { - return this.getOrAwaitValue { scheduler.flush() } + return this.getOrAwaitValue { scheduler?.flush() } } private data class RepeatTask(val interval: Long, val runnable: Runnable, var lastRun: Long?) diff --git a/test-shared/src/main/java/org/odk/collect/testshared/Interactions.kt b/test-shared/src/main/java/org/odk/collect/testshared/Interactions.kt new file mode 100644 index 00000000000..0332079eac2 --- /dev/null +++ b/test-shared/src/main/java/org/odk/collect/testshared/Interactions.kt @@ -0,0 +1,60 @@ +package org.odk.collect.testshared + +import android.view.View +import androidx.test.espresso.Espresso.closeSoftKeyboard +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.Root +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.scrollTo +import org.hamcrest.Matcher +import org.odk.collect.testshared.WaitFor.tryAgainOnFail + +object Interactions { + + /** + * Click on the view matched by [view]. The root to use can optionally be specified with + * [root] (otherwise Espresso will use heuristics to determine the most likely root). If + * initially clicking on the view fails, this will then attempt to scroll to the view and + * retry the click. + */ + @JvmStatic + @JvmOverloads + fun clickOn(view: Matcher, root: Matcher? = null) { + val onView = if (root != null) { + onView(view).inRoot(root) + } else { + onView(view) + } + + try { + onView.perform(click()) + } catch (e: Exception) { + onView.perform(scrollTo(), click()) + } + } + + /** + * Like [clickOn], but an [assertion] can be made after the click. If this fails, the click + * action will be reattempted. + * + * This can be useful in cases where [clickOn] itself appears to succeed, but the test fails + * because the click never actually occurs (most likely due to some flakiness in + * [androidx.test.espresso.action.ViewActions.click]). + */ + fun clickOn(view: Matcher, root: Matcher? = null, assertion: () -> Unit) { + tryAgainOnFail { + clickOn(view, root) + assertion() + } + } + + /** + * Replaces text in the view matched by [view] and then closes the keyboard. + */ + @JvmStatic + fun replaceText(view: Matcher, text: String) { + onView(view).perform(ViewActions.replaceText(text)) + closeSoftKeyboard() + } +} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/support/WaitFor.kt b/test-shared/src/main/java/org/odk/collect/testshared/WaitFor.kt similarity index 65% rename from collect_app/src/androidTest/java/org/odk/collect/android/support/WaitFor.kt rename to test-shared/src/main/java/org/odk/collect/testshared/WaitFor.kt index eab326903de..96c9650ace4 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/support/WaitFor.kt +++ b/test-shared/src/main/java/org/odk/collect/testshared/WaitFor.kt @@ -1,4 +1,4 @@ -package org.odk.collect.android.support +package org.odk.collect.testshared import junit.framework.AssertionFailedError import java.util.concurrent.Callable @@ -37,4 +37,21 @@ object WaitFor { // ignored } } + + @JvmStatic + @JvmOverloads + fun tryAgainOnFail(maxTimes: Int = 2, action: Runnable) { + var failure: Exception? = null + for (i in 0 until maxTimes) { + try { + action.run() + return + } catch (e: Exception) { + failure = e + wait250ms() + } + } + + throw RuntimeException("tryAgainOnFail failed", failure) + } } diff --git a/web-page/.gitignore b/web-page/.gitignore new file mode 100644 index 00000000000..42afabfd2ab --- /dev/null +++ b/web-page/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/web-page/build.gradle.kts b/web-page/build.gradle.kts new file mode 100644 index 00000000000..9f9e433d027 --- /dev/null +++ b/web-page/build.gradle.kts @@ -0,0 +1,56 @@ +import dependencies.Dependencies +import dependencies.Versions + +plugins { + id("com.android.library") + id("kotlin-android") +} + +apply(from = "../config/quality.gradle") + +android { + namespace = "org.odk.collect.webpage" + + compileSdk = Versions.android_compile_sdk + + defaultConfig { + minSdk = Versions.android_min_sdk + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + + buildFeatures { + viewBinding = true + } +} + +dependencies { + coreLibraryDesugaring(Dependencies.desugar) + + implementation(project(":icons")) + implementation(project(":strings")) + implementation(Dependencies.androidx_browser) + + testImplementation(Dependencies.androidx_test_ext_junit) + testImplementation(Dependencies.hamcrest) + testImplementation(Dependencies.robolectric) +} diff --git a/web-page/src/main/AndroidManifest.xml b/web-page/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..a5918e68abc --- /dev/null +++ b/web-page/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/ExternalWebPageHelper.java b/web-page/src/main/java/org/odk/collect/webpage/ExternalWebPageHelper.java similarity index 96% rename from collect_app/src/main/java/org/odk/collect/android/utilities/ExternalWebPageHelper.java rename to web-page/src/main/java/org/odk/collect/webpage/ExternalWebPageHelper.java index db23045aad1..1e19f122ed4 100644 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/ExternalWebPageHelper.java +++ b/web-page/src/main/java/org/odk/collect/webpage/ExternalWebPageHelper.java @@ -1,4 +1,4 @@ -package org.odk.collect.android.utilities; +package org.odk.collect.webpage; import android.app.Activity; import android.content.ComponentName; @@ -11,8 +11,6 @@ import androidx.browser.customtabs.CustomTabsServiceConnection; import androidx.browser.customtabs.CustomTabsSession; -import org.odk.collect.android.activities.WebViewActivity; - /** * Created by sanjeev on 17/3/17. */ diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/WebViewActivity.java b/web-page/src/main/java/org/odk/collect/webpage/WebViewActivity.java similarity index 91% rename from collect_app/src/main/java/org/odk/collect/android/activities/WebViewActivity.java rename to web-page/src/main/java/org/odk/collect/webpage/WebViewActivity.java index 904994cae6f..3fd1c803310 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/WebViewActivity.java +++ b/web-page/src/main/java/org/odk/collect/webpage/WebViewActivity.java @@ -12,7 +12,7 @@ * the License. */ -package org.odk.collect.android.activities; +package org.odk.collect.webpage; import android.graphics.Bitmap; import android.os.Bundle; @@ -26,8 +26,6 @@ import android.webkit.WebViewClient; import android.widget.ProgressBar; -import org.odk.collect.android.R; -import org.odk.collect.android.utilities.ExternalWebPageHelper; import org.odk.collect.strings.localization.LocalizedActivity; public class WebViewActivity extends LocalizedActivity { @@ -51,14 +49,14 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_web_view); - Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); + Toolbar toolbar = findViewById(R.id.toolbar); setSupportActionBar(toolbar); getSupportActionBar().setDisplayHomeAsUpEnabled(true); getSupportActionBar().setHomeAsUpIndicator(org.odk.collect.icons.R.drawable.ic_close); String url = getIntent().getStringExtra(ExternalWebPageHelper.OPEN_URL); - webView = (WebView) findViewById(R.id.webView); - progressBar = (ProgressBar) findViewById(R.id.progressBar); + webView = findViewById(R.id.webView); + progressBar = findViewById(R.id.progressBar); webView.setWebViewClient(new WebViewClient() { @Override diff --git a/collect_app/src/main/res/layout/activity_web_view.xml b/web-page/src/main/res/layout/activity_web_view.xml similarity index 100% rename from collect_app/src/main/res/layout/activity_web_view.xml rename to web-page/src/main/res/layout/activity_web_view.xml diff --git a/collect_app/src/test/java/org/odk/collect/android/utilities/ExternalWebPageHelperTest.java b/web-page/src/test/java/org/odk/collect/webpage/ExternalWebPageHelperTest.java similarity index 95% rename from collect_app/src/test/java/org/odk/collect/android/utilities/ExternalWebPageHelperTest.java rename to web-page/src/test/java/org/odk/collect/webpage/ExternalWebPageHelperTest.java index 4d104e2a159..a733067f9b4 100644 --- a/collect_app/src/test/java/org/odk/collect/android/utilities/ExternalWebPageHelperTest.java +++ b/web-page/src/test/java/org/odk/collect/webpage/ExternalWebPageHelperTest.java @@ -1,4 +1,4 @@ -package org.odk.collect.android.utilities; +package org.odk.collect.webpage; import android.app.Activity; import android.net.Uri;