diff --git a/androidshared/build.gradle b/androidshared/build.gradle index c6cadd0ad4e..7bc860650c5 100644 --- a/androidshared/build.gradle +++ b/androidshared/build.gradle @@ -61,6 +61,7 @@ dependencies { testImplementation Dependencies.androidx_test_ext_junit testImplementation Dependencies.androidx_test_espresso_core testImplementation Dependencies.robolectric + testImplementation Dependencies.mockito_kotlin debugImplementation project(':fragmentstest') } diff --git a/androidshared/src/main/java/org/odk/collect/androidshared/system/IntentLauncher.kt b/androidshared/src/main/java/org/odk/collect/androidshared/system/IntentLauncher.kt new file mode 100644 index 00000000000..6b602c5011b --- /dev/null +++ b/androidshared/src/main/java/org/odk/collect/androidshared/system/IntentLauncher.kt @@ -0,0 +1,59 @@ +package org.odk.collect.androidshared.system + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.activity.result.ActivityResultLauncher + +object IntentLauncherImpl : IntentLauncher { + override fun launch(context: Context, intent: Intent?, onError: () -> Unit) { + try { + context.startActivity(intent) + } catch (e: Exception) { + onError() + } catch (e: Error) { + onError() + } + } + + override fun launchForResult( + activity: Activity, + intent: Intent?, + requestCode: Int, + onError: () -> Unit + ) { + try { + activity.startActivityForResult(intent, requestCode) + } catch (e: Exception) { + onError() + } catch (e: Error) { + onError() + } + } + + override fun launchForResult( + resultLauncher: ActivityResultLauncher, + intent: Intent?, + onError: () -> Unit + ) { + try { + resultLauncher.launch(intent) + } catch (e: Exception) { + onError() + } catch (e: Error) { + onError() + } + } +} + +interface IntentLauncher { + fun launch(context: Context, intent: Intent?, onError: () -> Unit) + + fun launchForResult(activity: Activity, intent: Intent?, requestCode: Int, onError: () -> Unit) + + fun launchForResult( + resultLauncher: ActivityResultLauncher, + intent: Intent?, + onError: () -> Unit + ) +} diff --git a/androidshared/src/test/java/org/odk/collect/androidshared/utils/IntentLauncherImplTest.kt b/androidshared/src/test/java/org/odk/collect/androidshared/utils/IntentLauncherImplTest.kt new file mode 100644 index 00000000000..7b11039b410 --- /dev/null +++ b/androidshared/src/test/java/org/odk/collect/androidshared/utils/IntentLauncherImplTest.kt @@ -0,0 +1,103 @@ +package org.odk.collect.androidshared.utils + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.activity.result.ActivityResultLauncher +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever +import org.odk.collect.androidshared.system.IntentLauncherImpl +import java.lang.Exception + +class IntentLauncherImplTest { + private val context = mock() + private val activity = mock() + private val activityResultLauncher = mock>() + private val intent = mock() + private val onError = mock<() -> Unit>() + private val intentLauncher = IntentLauncherImpl + + @Test + fun `startActivity with given intent should be called on the context when calling IntentLauncher#launch(context, intent, onError)`() { + intentLauncher.launch(context, intent, onError) + verify(context).startActivity(intent) + verifyNoMoreInteractions(onError) + } + + @Test + fun `onError should be called if any exception occurs when calling IntentLauncher#launch(context, intent, onError)`() { + whenever(context.startActivity(intent)).then { + throw Exception() + } + intentLauncher.launch(context, intent, onError) + verify(onError).invoke() + } + + @Test + fun `onError should be called if any error occurs when calling IntentLauncher#launch(context, intent, onError)`() { + whenever(context.startActivity(intent)).then { + throw Error() + } + intentLauncher.launch(context, intent, onError) + verify(onError).invoke() + } + + @Test + fun `startActivityForResult with given intent should be called on the context when calling IntentLauncher#launchForResult(context, intent, requestCode, onError)`() { + intentLauncher.launchForResult(activity, intent, 1, onError) + verify(activity).startActivityForResult(intent, 1) + verifyNoMoreInteractions(onError) + } + + @Test + fun `onError should be called if any exception occurs when calling IntentLauncher#launchForResult(context, intent, requestCode, onError)`() { + whenever(activity.startActivityForResult(intent, 1)).then { + throw Exception() + } + intentLauncher.launchForResult(activity, intent, 1, onError) + verify(onError).invoke() + } + + @Test + fun `onError should be called if any error occurs when calling IntentLauncher#launchForResult(context, intent, requestCode, onError)`() { + whenever(activity.startActivityForResult(intent, 1)).then { + throw Error() + } + intentLauncher.launchForResult(activity, intent, 1, onError) + verify(onError).invoke() + } + + @Test + fun `startActivityForResult with given intent should be called on the context when calling IntentLauncher#launchForResult(resultLauncher, intent, onError)`() { + intentLauncher.launchForResult(activityResultLauncher, intent, onError) + verify(activityResultLauncher).launch(intent) + verifyNoMoreInteractions(onError) + } + + @Test + fun `onError should not be called if no exception occurs when calling IntentLauncher#launchForResult(resultLauncher, intent, onError)`() { + intentLauncher.launchForResult(activityResultLauncher, intent, onError) + verifyNoMoreInteractions(onError) + } + + @Test + fun `onError should be called if any exception occurs when calling IntentLauncher#launchForResult(resultLauncher, intent, onError)`() { + whenever(activityResultLauncher.launch(intent)).then { + throw Exception() + } + intentLauncher.launchForResult(activityResultLauncher, intent, onError) + verify(onError).invoke() + } + + @Test + fun `onError should be called if any error occurs when calling IntentLauncher#launchForResult(resultLauncher, intent, onError)`() { + whenever(activityResultLauncher.launch(intent)).then { + throw Error() + } + intentLauncher.launchForResult(activityResultLauncher, intent, onError) + verify(onError).invoke() + } +} diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/ExternalAudioRecordingTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/ExternalAudioRecordingTest.java index 0badd34b32a..9876ea11949 100644 --- a/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/ExternalAudioRecordingTest.java +++ b/collect_app/src/androidTest/java/org/odk/collect/android/feature/formentry/ExternalAudioRecordingTest.java @@ -3,7 +3,6 @@ import android.Manifest; import android.app.Activity; import android.app.Instrumentation; -import android.content.Context; import android.content.Intent; import android.net.Uri; import android.provider.MediaStore; @@ -19,10 +18,8 @@ import org.odk.collect.android.RecordedIntentsRule; import org.odk.collect.android.support.CollectTestRule; import org.odk.collect.android.support.RunnableRule; -import org.odk.collect.android.support.TestDependencies; import org.odk.collect.android.support.TestRuleChain; import org.odk.collect.android.support.pages.MainMenuPage; -import org.odk.collect.android.utilities.ActivityAvailability; import java.io.File; import java.io.IOException; @@ -34,22 +31,10 @@ @RunWith(AndroidJUnit4.class) public class ExternalAudioRecordingTest { - public final TestDependencies testDependencies = new TestDependencies() { - @Override - public ActivityAvailability providesActivityAvailability(Context context) { - return new ActivityAvailability(context) { - @Override - public boolean isActivityAvailable(Intent intent) { - return true; - } - }; - } - }; - public final CollectTestRule rule = new CollectTestRule(); @Rule - public final RuleChain chain = TestRuleChain.chain(testDependencies) + public final RuleChain chain = TestRuleChain.chain() .around(GrantPermissionRule.grant(Manifest.permission.RECORD_AUDIO)) .around(new RecordedIntentsRule()) .around(new RunnableRule(() -> { 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 index ae2928f29af..e675ffb1d05 100644 --- 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 @@ -1,17 +1,12 @@ package org.odk.collect.android.feature.smoke; -import android.content.Context; - import org.junit.BeforeClass; import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; import org.junit.rules.RuleChain; -import org.odk.collect.android.injection.config.AppDependencyModule; import org.odk.collect.android.support.CopyFormRule; import org.odk.collect.android.support.FormActivityTestRule; -import org.odk.collect.android.support.ResetStateRule; -import org.odk.collect.android.utilities.ActivityAvailability; import tools.fastlane.screengrab.Screengrab; import tools.fastlane.screengrab.UiAutomatorScreenshotStrategy; @@ -25,7 +20,6 @@ import static androidx.test.espresso.matcher.ViewMatchers.withText; import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.startsWith; -import static org.mockito.Mockito.mock; /** * Integration test that runs through a form with all question types. @@ -36,8 +30,6 @@ */ public class AllWidgetsFormTest { - private final ActivityAvailability activityAvailability = mock(ActivityAvailability.class); - @ClassRule public static final LocaleTestRule LOCALE_TEST_RULE = new LocaleTestRule(); @@ -45,13 +37,7 @@ public class AllWidgetsFormTest { @Rule public RuleChain copyFormChain = RuleChain - .outerRule(new ResetStateRule(new AppDependencyModule() { - @Override - public ActivityAvailability providesActivityAvailability(Context context) { - return activityAvailability; - } - })) - .around(new CopyFormRule("all-widgets.xml", true)) + .outerRule(new CopyFormRule("all-widgets.xml", true)) .around(activityTestRule); @BeforeClass diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/AboutActivity.java b/collect_app/src/main/java/org/odk/collect/android/activities/AboutActivity.java index 4ae2fd8fc68..b992c00a61d 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/AboutActivity.java +++ b/collect_app/src/main/java/org/odk/collect/android/activities/AboutActivity.java @@ -16,13 +16,9 @@ package org.odk.collect.android.activities; -import android.content.ComponentName; import android.content.Intent; -import android.content.pm.ActivityInfo; -import android.content.pm.ResolveInfo; import android.net.Uri; import android.os.Bundle; -import android.widget.Toast; import androidx.appcompat.widget.Toolbar; import androidx.recyclerview.widget.DefaultItemAnimator; @@ -31,13 +27,12 @@ import org.odk.collect.android.R; import org.odk.collect.android.adapters.AboutListAdapter; -import org.odk.collect.android.application.Collect; +import org.odk.collect.android.injection.DaggerUtils; import org.odk.collect.android.utilities.ExternalWebPageHelper; import org.odk.collect.android.utilities.MultiClickGuard; +import org.odk.collect.androidshared.system.IntentLauncher; -import java.util.List; - -import timber.log.Timber; +import javax.inject.Inject; public class AboutActivity extends CollectAbstractActivity implements AboutListAdapter.AboutItemClickListener { @@ -50,10 +45,15 @@ public class AboutActivity extends CollectAbstractActivity implements private Uri websiteUri; private Uri forumUri; + @Inject + IntentLauncher intentLauncher; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.about_layout); + DaggerUtils.getComponent(this).inject(this); + initToolbar(); int[][] items = { @@ -103,37 +103,16 @@ public void onClick(int position) { getString(R.string.tell_your_friends))); break; case 3: - boolean intentStarted = false; - try { - // Open the google play store app if present - Intent intent = new Intent(Intent.ACTION_VIEW, - Uri.parse("market://details?id=" + getPackageName())); - List list = getPackageManager().queryIntentActivities(intent, 0); - for (ResolveInfo info : list) { - ActivityInfo activity = info.activityInfo; - if (activity.name.contains("com.google.android")) { - ComponentName name = new ComponentName( - activity.applicationInfo.packageName, - activity.name); - intent.setComponent(name); - startActivity(intent); - intentStarted = true; - } - } - } catch (android.content.ActivityNotFoundException anfe) { - Toast.makeText(Collect.getInstance(), - getString(R.string.activity_not_found, "market view"), - Toast.LENGTH_SHORT).show(); - Timber.d(anfe); - } - if (!intentStarted) { + Intent intent = new Intent(Intent.ACTION_VIEW, + Uri.parse("market://details?id=" + getPackageName())); + intentLauncher.launch(this, intent, () -> { // Show a list of all available browsers if user doesn't have a default browser - startActivity(new Intent(Intent.ACTION_VIEW, - Uri.parse(GOOGLE_PLAY_URL + getPackageName()))); - } + startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(GOOGLE_PLAY_URL + getPackageName()))); + return null; + }); break; case 4: - Intent intent = new Intent(this, WebViewActivity.class); + intent = new Intent(this, WebViewActivity.class); intent.putExtra(ExternalWebPageHelper.OPEN_URL, LICENSES_HTML_PATH); startActivity(intent); break; diff --git a/collect_app/src/main/java/org/odk/collect/android/activities/FormEntryActivity.java b/collect_app/src/main/java/org/odk/collect/android/activities/FormEntryActivity.java index 7bb0448d794..0b44ce95a33 100644 --- a/collect_app/src/main/java/org/odk/collect/android/activities/FormEntryActivity.java +++ b/collect_app/src/main/java/org/odk/collect/android/activities/FormEntryActivity.java @@ -152,7 +152,6 @@ 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.ActivityAvailability; import org.odk.collect.android.utilities.ApplicationConstants; import org.odk.collect.android.utilities.ContentUriHelper; import org.odk.collect.android.utilities.DestroyableLifecyleOwner; @@ -176,6 +175,7 @@ import org.odk.collect.android.widgets.utilities.WaitingForDataRegistry; import org.odk.collect.androidshared.ui.DialogFragmentUtils; import org.odk.collect.androidshared.ui.ToastUtils; +import org.odk.collect.androidshared.system.IntentLauncher; import org.odk.collect.async.Scheduler; import org.odk.collect.audioclips.AudioClipViewModel; import org.odk.collect.audiorecorder.recording.AudioRecorder; @@ -342,9 +342,6 @@ enum AnimationType { @Inject PermissionsChecker permissionsChecker; - @Inject - ActivityAvailability activityAvailability; - @Inject ExternalAppIntentProvider externalAppIntentProvider; @@ -354,6 +351,9 @@ enum AnimationType { @Inject CurrentProjectProvider currentProjectProvider; + @Inject + IntentLauncher intentLauncher; + private final LocationProvidersReceiver locationProvidersReceiver = new LocationProvidersReceiver(); private SwipeHandler swipeHandler; @@ -496,7 +496,7 @@ private void setupViewModels() { internalRecordingRequester = new InternalRecordingRequester(this, audioRecorder, permissionsProvider, formEntryViewModel); waitingForDataRegistry = new FormControllerWaitingForDataRegistry(); - externalAppRecordingRequester = new ExternalAppRecordingRequester(this, activityAvailability, waitingForDataRegistry, permissionsProvider, formEntryViewModel); + externalAppRecordingRequester = new ExternalAppRecordingRequester(this, intentLauncher, waitingForDataRegistry, permissionsProvider, formEntryViewModel); RecordingHandler recordingHandler = new RecordingHandler(formSaveViewModel, this, audioRecorder, new AMRAppender(), new M4AAppender()); audioRecorder.getCurrentSession().observe(this, session -> { diff --git a/collect_app/src/main/java/org/odk/collect/android/configure/qr/QRCodeMenuDelegate.java b/collect_app/src/main/java/org/odk/collect/android/configure/qr/QRCodeMenuDelegate.java index a15d7c076f5..ce575516eef 100644 --- a/collect_app/src/main/java/org/odk/collect/android/configure/qr/QRCodeMenuDelegate.java +++ b/collect_app/src/main/java/org/odk/collect/android/configure/qr/QRCodeMenuDelegate.java @@ -10,10 +10,10 @@ import org.odk.collect.android.R; import org.odk.collect.android.preferences.source.SettingsProvider; -import org.odk.collect.android.utilities.ActivityAvailability; import org.odk.collect.android.utilities.FileProvider; import org.odk.collect.android.utilities.MenuDelegate; import org.odk.collect.androidshared.ui.ToastUtils; +import org.odk.collect.androidshared.system.IntentLauncher; import org.odk.collect.async.Scheduler; import timber.log.Timber; @@ -23,16 +23,16 @@ public class QRCodeMenuDelegate implements MenuDelegate { public static final int SELECT_PHOTO = 111; private final FragmentActivity activity; - private final ActivityAvailability activityAvailability; + private final IntentLauncher intentLauncher; private final FileProvider fileProvider; private String qrFilePath; - QRCodeMenuDelegate(FragmentActivity activity, ActivityAvailability activityAvailability, QRCodeGenerator qrCodeGenerator, + QRCodeMenuDelegate(FragmentActivity activity, IntentLauncher intentLauncher, QRCodeGenerator qrCodeGenerator, AppConfigurationGenerator appConfigurationGenerator, FileProvider fileProvider, SettingsProvider settingsProvider, Scheduler scheduler) { this.activity = activity; - this.activityAvailability = activityAvailability; + this.intentLauncher = intentLauncher; this.fileProvider = fileProvider; QRCodeViewModel qrCodeViewModel = new ViewModelProvider( @@ -57,13 +57,11 @@ public boolean onOptionsItemSelected(MenuItem item) { case R.id.menu_item_scan_sd_card: Intent photoPickerIntent = new Intent(Intent.ACTION_GET_CONTENT); photoPickerIntent.setType("image/*"); - if (activityAvailability.isActivityAvailable(photoPickerIntent)) { - activity.startActivityForResult(photoPickerIntent, SELECT_PHOTO); - } else { + intentLauncher.launchForResult(activity, photoPickerIntent, SELECT_PHOTO, () -> { ToastUtils.showShortToast(activity, activity.getString(R.string.activity_not_found, activity.getString(R.string.choose_image))); Timber.w(activity.getString(R.string.activity_not_found, activity.getString(R.string.choose_image))); - } - + return null; + }); return true; case R.id.menu_item_share: diff --git a/collect_app/src/main/java/org/odk/collect/android/configure/qr/QRCodeTabsActivity.java b/collect_app/src/main/java/org/odk/collect/android/configure/qr/QRCodeTabsActivity.java index 119e7aaf5a8..3ddf7712db9 100644 --- a/collect_app/src/main/java/org/odk/collect/android/configure/qr/QRCodeTabsActivity.java +++ b/collect_app/src/main/java/org/odk/collect/android/configure/qr/QRCodeTabsActivity.java @@ -17,9 +17,9 @@ import org.odk.collect.android.injection.DaggerUtils; import org.odk.collect.android.listeners.PermissionListener; import org.odk.collect.android.projects.CurrentProjectProvider; -import org.odk.collect.android.utilities.ActivityAvailability; import org.odk.collect.android.utilities.FileProvider; import org.odk.collect.android.utilities.MultiClickGuard; +import org.odk.collect.androidshared.system.IntentLauncher; import org.odk.collect.async.Scheduler; import javax.inject.Inject; @@ -32,7 +32,7 @@ public class QRCodeTabsActivity extends CollectAbstractActivity { QRCodeGenerator qrCodeGenerator; @Inject - ActivityAvailability activityAvailability; + IntentLauncher intentLauncher; @Inject FileProvider fileProvider; @@ -63,12 +63,12 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); DaggerUtils.getComponent(this).inject(this); - menuDelegate = new QRCodeMenuDelegate(this, activityAvailability, qrCodeGenerator, appConfigurationGenerator, fileProvider, settingsProvider, scheduler); + menuDelegate = new QRCodeMenuDelegate(this, intentLauncher, qrCodeGenerator, appConfigurationGenerator, fileProvider, settingsProvider, scheduler); activityResultDelegate = new QRCodeActivityResultDelegate(this, settingsImporter, qrCodeDecoder, currentProjectProvider.getCurrentProject()); setContentView(R.layout.tabs_layout); initToolbar(getString(R.string.reconfigure_with_qr_code_settings_title)); - menuDelegate = new QRCodeMenuDelegate(this, activityAvailability, qrCodeGenerator, appConfigurationGenerator, fileProvider, settingsProvider, scheduler); + menuDelegate = new QRCodeMenuDelegate(this, intentLauncher, qrCodeGenerator, appConfigurationGenerator, fileProvider, settingsProvider, scheduler); permissionsProvider.requestCameraPermission(this, new PermissionListener() { @Override 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 bbe1f87c820..24ea540ed5a 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 @@ -70,7 +70,6 @@ import org.odk.collect.android.listeners.WidgetValueChangedListener; import org.odk.collect.android.permissions.PermissionsProvider; import org.odk.collect.android.preferences.source.SettingsProvider; -import org.odk.collect.android.utilities.ActivityAvailability; import org.odk.collect.android.utilities.ContentUriHelper; import org.odk.collect.android.utilities.FileUtils; import org.odk.collect.android.utilities.QuestionFontSizeUtils; @@ -83,8 +82,10 @@ import org.odk.collect.android.widgets.interfaces.WidgetDataReceiver; import org.odk.collect.android.widgets.utilities.AudioPlayer; import org.odk.collect.android.widgets.utilities.ExternalAppRecordingRequester; +import org.odk.collect.android.widgets.utilities.FileRequester; import org.odk.collect.android.widgets.utilities.InternalRecordingRequester; import org.odk.collect.android.widgets.utilities.RecordingRequesterProvider; +import org.odk.collect.android.widgets.utilities.StringRequester; import org.odk.collect.android.widgets.utilities.WaitingForDataRegistry; import org.odk.collect.androidshared.ui.ToastUtils; import org.odk.collect.audioclips.PlaybackFailedException; @@ -121,15 +122,18 @@ public class ODKView extends FrameLayout implements OnLongClickListener, WidgetV @Inject public AudioHelperFactory audioHelperFactory; - @Inject - ActivityAvailability activityAvailability; - @Inject PermissionsProvider permissionsProvider; @Inject SettingsProvider settingsProvider; + @Inject + FileRequester fileRequester; + + @Inject + StringRequester stringRequester; + private final WidgetFactory widgetFactory; private final LifecycleOwner viewLifecycle; private final AudioRecorder audioRecorder; @@ -174,14 +178,15 @@ public ODKView(ComponentActivity context, final FormEntryPrompt[] questionPrompt waitingForDataRegistry, questionMediaManager, audioPlayer, - activityAvailability, new RecordingRequesterProvider( internalRecordingRequester, externalAppRecordingRequester ), formEntryViewModel, audioRecorder, - viewLifecycle + viewLifecycle, + fileRequester, + stringRequester ); widgets = new ArrayList<>(); 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 5e6bf98d799..51872f135cd 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 @@ -4,6 +4,7 @@ import org.javarosa.core.reference.ReferenceManager; import org.odk.collect.analytics.Analytics; +import org.odk.collect.android.activities.AboutActivity; import org.odk.collect.android.activities.CollectAbstractActivity; import org.odk.collect.android.activities.DeleteSavedFormActivity; import org.odk.collect.android.activities.FillBlankFormActivity; @@ -137,6 +138,8 @@ interface Builder { void inject(Collect collect); + void inject(AboutActivity aboutActivity); + void inject(InstanceUploaderAdapter instanceUploaderAdapter); void inject(SavedFormListFragment savedFormListFragment); 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 544fa0221ea..f02e2a0cbbe 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 @@ -86,6 +86,11 @@ import org.odk.collect.android.openrosa.OpenRosaHttpInterface; import org.odk.collect.android.openrosa.okhttp.OkHttpConnection; import org.odk.collect.android.openrosa.okhttp.OkHttpOpenRosaServerClientProvider; +import org.odk.collect.android.utilities.MediaUtils; +import org.odk.collect.android.widgets.utilities.FileRequester; +import org.odk.collect.android.widgets.utilities.FileRequesterImpl; +import org.odk.collect.android.widgets.utilities.StringRequester; +import org.odk.collect.android.widgets.utilities.StringRequesterImpl; import org.odk.collect.androidshared.system.PermissionsChecker; import org.odk.collect.android.permissions.PermissionsProvider; import org.odk.collect.android.preferences.PreferenceVisibilityHandler; @@ -103,7 +108,6 @@ import org.odk.collect.android.projects.ProjectImporter; import org.odk.collect.android.storage.StoragePathProvider; import org.odk.collect.android.storage.StorageSubdirectory; -import org.odk.collect.android.utilities.ActivityAvailability; import org.odk.collect.android.utilities.AdminPasswordProvider; import org.odk.collect.android.utilities.AndroidUserAgent; import org.odk.collect.android.utilities.ChangeLockProvider; @@ -116,7 +120,6 @@ import org.odk.collect.android.utilities.FormsRepositoryProvider; import org.odk.collect.android.utilities.InstancesRepositoryProvider; import org.odk.collect.android.utilities.LaunchState; -import org.odk.collect.android.utilities.MediaUtils; import org.odk.collect.android.utilities.ProjectResetter; import org.odk.collect.android.utilities.ScreenUtils; import org.odk.collect.android.utilities.SoftKeyboardController; @@ -124,6 +127,8 @@ 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.system.IntentLauncher; +import org.odk.collect.androidshared.system.IntentLauncherImpl; import org.odk.collect.async.CoroutineAndWorkManagerScheduler; import org.odk.collect.async.Scheduler; import org.odk.collect.audiorecorder.recording.AudioRecorder; @@ -220,17 +225,13 @@ public AudioHelperFactory providesAudioHelperFactory(Scheduler scheduler) { return new ScreenContextAudioHelperFactory(scheduler, MediaPlayer::new); } - @Provides - public ActivityAvailability providesActivityAvailability(Context context) { - return new ActivityAvailability(context); - } - @Provides @Singleton public SettingsProvider providesSettingsProvider(Context context) { return new SharedPreferencesSettingsProvider(context); } + @Provides InstallIDProvider providesInstallIDProvider(SettingsProvider settingsProvider) { return new SharedPreferencesInstallIDProvider(settingsProvider.getMetaSettings(), KEY_INSTALL_ID); @@ -610,4 +611,19 @@ public ReferenceLayerRepository providesReferenceLayerRepository(StoragePathProv storagePathProvider.getOdkDirPath(StorageSubdirectory.SHARED_LAYERS) ); } + + @Provides + public IntentLauncher providesIntentLauncher() { + return IntentLauncherImpl.INSTANCE; + } + + @Provides + public FileRequester providesFileRequester(IntentLauncher intentLauncher, ExternalAppIntentProvider externalAppIntentProvider) { + return new FileRequesterImpl(intentLauncher, externalAppIntentProvider); + } + + @Provides + public StringRequester providesStringRequester(IntentLauncher intentLauncher, ExternalAppIntentProvider externalAppIntentProvider) { + return new StringRequesterImpl(intentLauncher, externalAppIntentProvider); + } } diff --git a/collect_app/src/main/java/org/odk/collect/android/projects/ManualProjectCreatorDialog.kt b/collect_app/src/main/java/org/odk/collect/android/projects/ManualProjectCreatorDialog.kt index 3576f128ab4..5ec326b4542 100644 --- a/collect_app/src/main/java/org/odk/collect/android/projects/ManualProjectCreatorDialog.kt +++ b/collect_app/src/main/java/org/odk/collect/android/projects/ManualProjectCreatorDialog.kt @@ -26,8 +26,8 @@ import org.odk.collect.android.permissions.PermissionsProvider import org.odk.collect.android.preferences.source.SettingsProvider import org.odk.collect.android.projects.DuplicateProjectConfirmationKeys.MATCHING_PROJECT import org.odk.collect.android.projects.DuplicateProjectConfirmationKeys.SETTINGS_JSON -import org.odk.collect.android.utilities.ActivityAvailability import org.odk.collect.android.utilities.SoftKeyboardController +import org.odk.collect.androidshared.system.IntentLauncher import org.odk.collect.androidshared.ui.DialogFragmentUtils import org.odk.collect.androidshared.ui.ToastUtils import org.odk.collect.material.MaterialFullScreenDialogFragment @@ -64,7 +64,7 @@ class ManualProjectCreatorDialog : lateinit var settingsProvider: SettingsProvider @Inject - lateinit var activityAvailability: ActivityAvailability + lateinit var intentLauncher: IntentLauncher lateinit var settingsConnectionMatcher: SettingsConnectionMatcher @@ -188,9 +188,7 @@ class ManualProjectCreatorDialog : object : PermissionListener { override fun granted() { val intent: Intent = googleAccountsManager.accountChooserIntent - if (activityAvailability.isActivityAvailable(intent)) { - googleAccountResultLauncher.launch(intent) - } else { + intentLauncher.launchForResult(googleAccountResultLauncher, intent) { ToastUtils.showShortToast( requireContext(), getString( diff --git a/collect_app/src/main/java/org/odk/collect/android/projects/QrCodeProjectCreatorDialog.kt b/collect_app/src/main/java/org/odk/collect/android/projects/QrCodeProjectCreatorDialog.kt index 936c81dbd70..2c85c9f7c62 100644 --- a/collect_app/src/main/java/org/odk/collect/android/projects/QrCodeProjectCreatorDialog.kt +++ b/collect_app/src/main/java/org/odk/collect/android/projects/QrCodeProjectCreatorDialog.kt @@ -26,10 +26,10 @@ import org.odk.collect.android.injection.DaggerUtils import org.odk.collect.android.listeners.PermissionListener import org.odk.collect.android.permissions.PermissionsProvider import org.odk.collect.android.preferences.source.SettingsProvider -import org.odk.collect.android.utilities.ActivityAvailability import org.odk.collect.android.utilities.CodeCaptureManagerFactory import org.odk.collect.android.utilities.CompressionUtils import org.odk.collect.android.views.BarcodeViewDecoder +import org.odk.collect.androidshared.system.IntentLauncher import org.odk.collect.androidshared.ui.DialogFragmentUtils import org.odk.collect.androidshared.ui.ToastUtils import org.odk.collect.androidshared.ui.ToastUtils.showShortToast @@ -70,15 +70,15 @@ class QrCodeProjectCreatorDialog : private lateinit var beepManager: BeepManager lateinit var binding: QrCodeProjectCreatorDialogLayoutBinding - @Inject - lateinit var activityAvailability: ActivityAvailability - @Inject lateinit var qrCodeDecoder: QRCodeDecoder @Inject lateinit var settingsImporter: SettingsImporter + @Inject + lateinit var intentLauncher: IntentLauncher + private val imageQrCodeImportResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> val imageUri: Uri? = result.data?.data @@ -167,9 +167,7 @@ class QrCodeProjectCreatorDialog : R.id.menu_item_scan_sd_card -> { val photoPickerIntent = Intent(Intent.ACTION_GET_CONTENT) photoPickerIntent.type = "image/*" - if (activityAvailability.isActivityAvailable(photoPickerIntent)) { - imageQrCodeImportResultLauncher.launch(photoPickerIntent) - } else { + intentLauncher.launchForResult(imageQrCodeImportResultLauncher, photoPickerIntent) { showShortToast( requireContext(), getString( diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/ActivityAvailability.java b/collect_app/src/main/java/org/odk/collect/android/utilities/ActivityAvailability.java deleted file mode 100644 index d7e74459563..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/ActivityAvailability.java +++ /dev/null @@ -1,23 +0,0 @@ -package org.odk.collect.android.utilities; - -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import androidx.annotation.NonNull; - -public class ActivityAvailability { - - @NonNull - private final Context context; - - public ActivityAvailability(@NonNull Context context) { - this.context = context; - } - - public boolean isActivityAvailable(Intent intent) { - return context - .getPackageManager() - .queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) - .size() > 0; - } -} 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 f9dbce80bfe..def15ea353a 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 @@ -1,13 +1,11 @@ package org.odk.collect.android.utilities; -import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; import org.javarosa.form.api.FormEntryPrompt; import org.javarosa.xpath.parser.XPathSyntaxException; -import org.odk.collect.android.R; import org.odk.collect.android.exception.ExternalParamsException; import org.odk.collect.android.externaldata.ExternalAppsUtils; @@ -17,13 +15,10 @@ public class ExternalAppIntentProvider { // If an extra with this key is specified, it will be parsed as a URI and used as intent data private static final String URI_KEY = "uri_data"; - public Intent getIntentToRunExternalApp(Context context, FormEntryPrompt formEntryPrompt, ActivityAvailability activityAvailability, PackageManager packageManager) throws ExternalParamsException, XPathSyntaxException { + public Intent getIntentToRunExternalApp(FormEntryPrompt formEntryPrompt) throws ExternalParamsException, XPathSyntaxException { String exSpec = formEntryPrompt.getAppearanceHint().replaceFirst("^ex[:]", ""); final String intentName = ExternalAppsUtils.extractIntentName(exSpec); final Map exParams = ExternalAppsUtils.extractParameters(exSpec); - final String errorString; - String v = formEntryPrompt.getSpecialFormQuestionText("noAppErrorString"); - errorString = (v != null) ? v : context.getString(R.string.no_app); Intent intent = new Intent(intentName); @@ -36,21 +31,23 @@ public Intent getIntentToRunExternalApp(Context context, FormEntryPrompt formEnt exParams.remove(URI_KEY); } - if (!activityAvailability.isActivityAvailable(intent)) { - Intent launchIntent = packageManager.getLaunchIntentForPackage(intentName); + ExternalAppsUtils.populateParameters(intent, exParams, formEntryPrompt.getIndex().getReference()); + return intent; + } - if (launchIntent != null) { - // Make sure FLAG_ACTIVITY_NEW_TASK is not set because it doesn't work with startActivityForResult - launchIntent.setFlags(0); - intent = launchIntent; - } - } + // https://github.com/getodk/collect/issues/4194 + public Intent getIntentToRunExternalAppWithoutDefaultCategory(FormEntryPrompt formEntryPrompt, PackageManager packageManager) throws ExternalParamsException { + String exSpec = formEntryPrompt.getAppearanceHint().replaceFirst("^ex[:]", ""); + final String intentName = ExternalAppsUtils.extractIntentName(exSpec); + final Map exParams = ExternalAppsUtils.extractParameters(exSpec); - if (activityAvailability.isActivityAvailable(intent)) { + Intent intent = packageManager.getLaunchIntentForPackage(intentName); + if (intent != null) { + // Make sure FLAG_ACTIVITY_NEW_TASK is not set because it doesn't work with startActivityForResult + intent.setFlags(0); ExternalAppsUtils.populateParameters(intent, exParams, formEntryPrompt.getIndex().getReference()); - return intent; - } else { - throw new RuntimeException(errorString); } + + return intent; } } diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/MediaUtils.java b/collect_app/src/main/java/org/odk/collect/android/utilities/MediaUtils.java deleted file mode 100644 index 7d8e6bc847a..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/MediaUtils.java +++ /dev/null @@ -1,85 +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.utilities; - -import static org.odk.collect.android.utilities.FileUtils.deleteAndReport; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; - -import org.odk.collect.android.BuildConfig; -import org.odk.collect.android.R; -import org.odk.collect.androidshared.ui.ToastUtils; - -import java.io.File; - -import timber.log.Timber; - -/** - * Consolidate all interactions with media providers here. - *

- * The functionality of getPath() was provided by paulburke as described here: - * See - * http://stackoverflow.com/questions/20067508/get-real-path-from-uri-android - * -kitkat-new-storage-access-framework for details - * - * @author mitchellsundt@gmail.com - * @author paulburke - */ -public class MediaUtils { - public void deleteMediaFile(String imageFile) { - deleteAndReport(new File(imageFile)); - } - - public void openFile(Context context, File file, String mimeType) { - Uri contentUri = ContentUriProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", file); - Intent intent = new Intent(); - intent.setAction(Intent.ACTION_VIEW); - if (mimeType == null || mimeType.isEmpty()) { - mimeType = FileUtils.getMimeType(file); - } - intent.setDataAndType(contentUri, mimeType); - FileUtils.grantFileReadPermissions(intent, contentUri, context); - - if (new ActivityAvailability(context).isActivityAvailable(intent)) { - context.startActivity(intent); - } else { - String message = context.getString(R.string.activity_not_found, context.getString(R.string.open_file)); - ToastUtils.showLongToast(context, message); - Timber.w(message); - } - } - - public void pickFile(Activity activity, String mimeType, int requestCode) { - Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); - intent.addCategory(Intent.CATEGORY_OPENABLE); - intent.setType(mimeType); - activity.startActivityForResult(intent, requestCode); - } - - public boolean isVideoFile(File file) { - return FileUtils.getMimeType(file).startsWith("video"); - } - - public boolean isImageFile(File file) { - return FileUtils.getMimeType(file).startsWith("image"); - } - - public boolean isAudioFile(File file) { - return FileUtils.getMimeType(file).startsWith("audio"); - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/MediaUtils.kt b/collect_app/src/main/java/org/odk/collect/android/utilities/MediaUtils.kt new file mode 100644 index 00000000000..a1098dd4400 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/MediaUtils.kt @@ -0,0 +1,87 @@ +/* + * 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.utilities + +import android.app.Activity +import android.content.Context +import android.content.Intent +import org.odk.collect.android.BuildConfig +import org.odk.collect.android.R +import org.odk.collect.androidshared.system.IntentLauncherImpl +import org.odk.collect.androidshared.ui.ToastUtils.showLongToast +import timber.log.Timber +import java.io.File + +/** + * Consolidate all interactions with media providers here. + * + * + * The functionality of getPath() was provided by paulburke as described here: + * See + * http://stackoverflow.com/questions/20067508/get-real-path-from-uri-android + * -kitkat-new-storage-access-framework for details + * + * @author mitchellsundt@gmail.com + * @author paulburke + */ +class MediaUtils { + fun deleteMediaFile(imageFile: String) { + FileUtils.deleteAndReport(File(imageFile)) + } + + fun openFile(context: Context, file: File, expectedMimeType: String?) { + val contentUri = ContentUriProvider.getUriForFile( + context, + BuildConfig.APPLICATION_ID + ".provider", + file + ) + val intent = Intent() + intent.action = Intent.ACTION_VIEW + + val mimeType = + if (expectedMimeType == null || expectedMimeType.isEmpty()) FileUtils.getMimeType(file) + else expectedMimeType + + intent.setDataAndType(contentUri, mimeType) + FileUtils.grantFileReadPermissions(intent, contentUri, context) + + IntentLauncherImpl.launch(context, intent) { + val message = context.getString( + R.string.activity_not_found, + context.getString(R.string.open_file) + ) + showLongToast(context, message) + Timber.w(message) + } + } + + fun pickFile(activity: Activity, mimeType: String, requestCode: Int) { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = mimeType + activity.startActivityForResult(intent, requestCode) + } + + fun isVideoFile(file: File): Boolean { + return FileUtils.getMimeType(file).startsWith("video") + } + + fun isImageFile(file: File): Boolean { + return FileUtils.getMimeType(file).startsWith("image") + } + + fun isAudioFile(file: File): Boolean { + return FileUtils.getMimeType(file).startsWith("audio") + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/ExArbitraryFileWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/ExArbitraryFileWidget.java index a7119c27443..444667971bf 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/ExArbitraryFileWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/ExArbitraryFileWidget.java @@ -3,40 +3,31 @@ import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; -import android.content.Intent; import android.util.TypedValue; import android.view.View; import androidx.annotation.NonNull; import org.javarosa.form.api.FormEntryPrompt; -import org.odk.collect.android.R; -import org.odk.collect.android.application.Collect; import org.odk.collect.android.databinding.ExArbitraryFileWidgetAnswerBinding; import org.odk.collect.android.formentry.questions.QuestionDetails; -import org.odk.collect.android.utilities.ActivityAvailability; import org.odk.collect.android.utilities.ApplicationConstants; -import org.odk.collect.android.utilities.ExternalAppIntentProvider; import org.odk.collect.android.utilities.MediaUtils; import org.odk.collect.android.utilities.QuestionMediaManager; +import org.odk.collect.android.widgets.utilities.FileRequester; import org.odk.collect.android.widgets.utilities.WaitingForDataRegistry; -import org.odk.collect.androidshared.ui.ToastUtils; - -import timber.log.Timber; @SuppressLint("ViewConstructor") public class ExArbitraryFileWidget extends BaseArbitraryFileWidget { ExArbitraryFileWidgetAnswerBinding binding; - private final ExternalAppIntentProvider externalAppIntentProvider; - private final ActivityAvailability activityAvailability; + private final FileRequester fileRequester; public ExArbitraryFileWidget(Context context, QuestionDetails questionDetails, @NonNull MediaUtils mediaUtils, QuestionMediaManager questionMediaManager, WaitingForDataRegistry waitingForDataRegistry, - ExternalAppIntentProvider externalAppIntentProvider, ActivityAvailability activityAvailability) { + FileRequester fileRequester) { super(context, questionDetails, mediaUtils, questionMediaManager, waitingForDataRegistry); - this.externalAppIntentProvider = externalAppIntentProvider; - this.activityAvailability = activityAvailability; + this.fileRequester = fileRequester; } @Override @@ -88,20 +79,6 @@ protected void hideAnswerText() { private void onButtonClick() { waitingForDataRegistry.waitForData(getFormEntryPrompt().getIndex()); - try { - Intent intent = externalAppIntentProvider.getIntentToRunExternalApp(getContext(), getFormEntryPrompt(), activityAvailability, Collect.getInstance().getPackageManager()); - fireActivityForResult(intent); - } catch (Exception | Error e) { - ToastUtils.showLongToast(getContext(), e.getMessage()); - } - } - - private void fireActivityForResult(Intent intent) { - try { - ((Activity) getContext()).startActivityForResult(intent, ApplicationConstants.RequestCodes.EX_ARBITRARY_FILE_CHOOSER); - } catch (SecurityException e) { - Timber.i(e); - ToastUtils.showLongToast(getContext(), R.string.not_granted_permission); - } + fileRequester.launch((Activity) getContext(), ApplicationConstants.RequestCodes.EX_ARBITRARY_FILE_CHOOSER, getFormEntryPrompt()); } } diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/ExAudioWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/ExAudioWidget.java index c9b3f9d4a29..501ac300674 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/ExAudioWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/ExAudioWidget.java @@ -3,7 +3,6 @@ import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; -import android.content.Intent; import android.media.MediaMetadataRetriever; import android.util.TypedValue; import android.view.View; @@ -16,19 +15,17 @@ 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.application.Collect; import org.odk.collect.android.audio.AudioControllerView; import org.odk.collect.android.databinding.ExAudioWidgetAnswerBinding; import org.odk.collect.android.formentry.questions.QuestionDetails; -import org.odk.collect.android.utilities.ActivityAvailability; import org.odk.collect.android.utilities.ApplicationConstants; -import org.odk.collect.android.utilities.ExternalAppIntentProvider; import org.odk.collect.android.utilities.FileUtils; import org.odk.collect.android.utilities.MediaUtils; import org.odk.collect.android.utilities.QuestionMediaManager; import org.odk.collect.android.widgets.interfaces.FileWidget; import org.odk.collect.android.widgets.interfaces.WidgetDataReceiver; import org.odk.collect.android.widgets.utilities.AudioPlayer; +import org.odk.collect.android.widgets.utilities.FileRequester; import org.odk.collect.android.widgets.utilities.WaitingForDataRegistry; import org.odk.collect.androidshared.ui.ToastUtils; import org.odk.collect.audioclips.Clip; @@ -45,22 +42,20 @@ public class ExAudioWidget extends QuestionWidget implements FileWidget, WidgetD private final WaitingForDataRegistry waitingForDataRegistry; private final QuestionMediaManager questionMediaManager; private final MediaUtils mediaUtils; - private final ExternalAppIntentProvider externalAppIntentProvider; - private final ActivityAvailability activityAvailability; + private final FileRequester fileRequester; File answerFile; public ExAudioWidget(Context context, QuestionDetails questionDetails, QuestionMediaManager questionMediaManager, AudioPlayer audioPlayer, WaitingForDataRegistry waitingForDataRegistry, MediaUtils mediaUtils, - ExternalAppIntentProvider externalAppIntentProvider, ActivityAvailability activityAvailability) { + FileRequester fileRequester) { super(context, questionDetails); this.audioPlayer = audioPlayer; this.waitingForDataRegistry = waitingForDataRegistry; this.questionMediaManager = questionMediaManager; this.mediaUtils = mediaUtils; - this.externalAppIntentProvider = externalAppIntentProvider; - this.activityAvailability = activityAvailability; + this.fileRequester = fileRequester; updateVisibilities(); updatePlayerMedia(); @@ -200,21 +195,7 @@ private Integer getDurationOfFile(String uri) { private void launchExternalApp() { waitingForDataRegistry.waitForData(getFormEntryPrompt().getIndex()); - try { - Intent intent = externalAppIntentProvider.getIntentToRunExternalApp(getContext(), getFormEntryPrompt(), activityAvailability, Collect.getInstance().getPackageManager()); - fireActivityForResult(intent); - } catch (Exception | Error e) { - ToastUtils.showLongToast(getContext(), e.getMessage()); - } - } - - private void fireActivityForResult(Intent intent) { - try { - ((Activity) getContext()).startActivityForResult(intent, ApplicationConstants.RequestCodes.EX_AUDIO_CHOOSER); - } catch (SecurityException e) { - Timber.i(e); - ToastUtils.showLongToast(getContext(), R.string.not_granted_permission); - } + fileRequester.launch((Activity) getContext(), ApplicationConstants.RequestCodes.EX_AUDIO_CHOOSER, getFormEntryPrompt()); } private void setupAnswerFile(String fileName) { 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 d0aecf9764d..2be836c584c 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 @@ -15,24 +15,20 @@ package org.odk.collect.android.widgets; import android.annotation.SuppressLint; -import android.app.Activity; -import android.content.ActivityNotFoundException; import android.content.Context; -import android.content.Intent; import org.javarosa.core.model.data.DecimalData; import org.javarosa.core.model.data.IAnswerData; -import org.odk.collect.android.R; import org.odk.collect.android.externaldata.ExternalAppsUtils; import org.odk.collect.android.formentry.questions.QuestionDetails; +import org.odk.collect.android.widgets.utilities.StringRequester; import org.odk.collect.android.widgets.utilities.StringWidgetUtils; -import org.odk.collect.androidshared.ui.ToastUtils; import org.odk.collect.android.widgets.utilities.WaitingForDataRegistry; -import timber.log.Timber; - import static org.odk.collect.android.utilities.ApplicationConstants.RequestCodes; +import java.io.Serializable; + /** * Launch an external app to supply a decimal value. If the app * does not launch, enable the text area for regular data entry. @@ -42,20 +38,19 @@ @SuppressLint("ViewConstructor") public class ExDecimalWidget extends ExStringWidget { - public ExDecimalWidget(Context context, QuestionDetails questionDetails, WaitingForDataRegistry waitingForDataRegistry) { - super(context, questionDetails, waitingForDataRegistry); + public ExDecimalWidget(Context context, QuestionDetails questionDetails, WaitingForDataRegistry waitingForDataRegistry, StringRequester stringRequester) { + super(context, questionDetails, waitingForDataRegistry, stringRequester); StringWidgetUtils.adjustEditTextAnswerToDecimalWidget(answerText, questionDetails.getPrompt()); } @Override - protected void fireActivity(Intent i) throws ActivityNotFoundException { - i.putExtra(DATA_NAME, StringWidgetUtils.getDoubleAnswerValueFromIAnswerData(getFormEntryPrompt().getAnswerValue())); - try { - ((Activity) getContext()).startActivityForResult(i, RequestCodes.EX_DECIMAL_CAPTURE); - } catch (SecurityException e) { - Timber.i(e); - ToastUtils.showLongToast(getContext(), R.string.not_granted_permission); - } + protected Serializable getAnswerForIntent() { + return StringWidgetUtils.getDoubleAnswerValueFromIAnswerData(getFormEntryPrompt().getAnswerValue()); + } + + @Override + protected int getRequestCode() { + return RequestCodes.EX_DECIMAL_CAPTURE; } @Override diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/ExImageWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/ExImageWidget.java index 500e745a265..7ada0c07ebd 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/ExImageWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/ExImageWidget.java @@ -3,7 +3,6 @@ import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; -import android.content.Intent; import android.util.TypedValue; import android.view.View; @@ -13,17 +12,15 @@ import org.javarosa.core.model.data.StringData; import org.javarosa.form.api.FormEntryPrompt; import org.odk.collect.android.R; -import org.odk.collect.android.application.Collect; import org.odk.collect.android.databinding.ExImageWidgetAnswerBinding; import org.odk.collect.android.formentry.questions.QuestionDetails; -import org.odk.collect.android.utilities.ActivityAvailability; import org.odk.collect.android.utilities.ApplicationConstants; -import org.odk.collect.android.utilities.ExternalAppIntentProvider; import org.odk.collect.android.utilities.FileUtils; import org.odk.collect.android.utilities.MediaUtils; import org.odk.collect.android.utilities.QuestionMediaManager; import org.odk.collect.android.widgets.interfaces.FileWidget; import org.odk.collect.android.widgets.interfaces.WidgetDataReceiver; +import org.odk.collect.android.widgets.utilities.FileRequester; import org.odk.collect.android.widgets.utilities.WaitingForDataRegistry; import org.odk.collect.androidshared.ui.ToastUtils; @@ -38,21 +35,19 @@ public class ExImageWidget extends QuestionWidget implements FileWidget, WidgetD private final WaitingForDataRegistry waitingForDataRegistry; private final QuestionMediaManager questionMediaManager; private final MediaUtils mediaUtils; - private final ExternalAppIntentProvider externalAppIntentProvider; - private final ActivityAvailability activityAvailability; + private final FileRequester fileRequester; File answerFile; public ExImageWidget(Context context, QuestionDetails questionDetails, QuestionMediaManager questionMediaManager, WaitingForDataRegistry waitingForDataRegistry, MediaUtils mediaUtils, - ExternalAppIntentProvider externalAppIntentProvider, ActivityAvailability activityAvailability) { + FileRequester fileRequester) { super(context, questionDetails); this.waitingForDataRegistry = waitingForDataRegistry; this.questionMediaManager = questionMediaManager; this.mediaUtils = mediaUtils; - this.externalAppIntentProvider = externalAppIntentProvider; - this.activityAvailability = activityAvailability; + this.fileRequester = fileRequester; } @Override @@ -133,21 +128,7 @@ public void cancelLongPress() { private void launchExternalApp() { waitingForDataRegistry.waitForData(getFormEntryPrompt().getIndex()); - try { - Intent intent = externalAppIntentProvider.getIntentToRunExternalApp(getContext(), getFormEntryPrompt(), activityAvailability, Collect.getInstance().getPackageManager()); - fireActivityForResult(intent); - } catch (Exception | Error e) { - ToastUtils.showLongToast(getContext(), e.getMessage()); - } - } - - private void fireActivityForResult(Intent intent) { - try { - ((Activity) getContext()).startActivityForResult(intent, ApplicationConstants.RequestCodes.EX_IMAGE_CHOOSER); - } catch (SecurityException e) { - Timber.i(e); - ToastUtils.showLongToast(getContext(), R.string.not_granted_permission); - } + fileRequester.launch((Activity) getContext(), ApplicationConstants.RequestCodes.EX_IMAGE_CHOOSER, getFormEntryPrompt()); } private void setupAnswerFile(String fileName) { 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 e9e5a72b49d..25dfb23bc08 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 @@ -17,21 +17,17 @@ import static org.odk.collect.android.utilities.ApplicationConstants.RequestCodes; import android.annotation.SuppressLint; -import android.app.Activity; -import android.content.ActivityNotFoundException; import android.content.Context; -import android.content.Intent; import org.javarosa.core.model.data.IAnswerData; import org.javarosa.core.model.data.IntegerData; -import org.odk.collect.android.R; import org.odk.collect.android.externaldata.ExternalAppsUtils; import org.odk.collect.android.formentry.questions.QuestionDetails; -import org.odk.collect.androidshared.ui.ToastUtils; +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 timber.log.Timber; +import java.io.Serializable; /** * Launch an external app to supply an integer value. If the app @@ -42,20 +38,19 @@ @SuppressLint("ViewConstructor") public class ExIntegerWidget extends ExStringWidget { - public ExIntegerWidget(Context context, QuestionDetails questionDetails, WaitingForDataRegistry waitingForDataRegistry) { - super(context, questionDetails, waitingForDataRegistry); + public ExIntegerWidget(Context context, QuestionDetails questionDetails, WaitingForDataRegistry waitingForDataRegistry, StringRequester stringRequester) { + super(context, questionDetails, waitingForDataRegistry, stringRequester); StringWidgetUtils.adjustEditTextAnswerToIntegerWidget(answerText, questionDetails.getPrompt()); } @Override - protected void fireActivity(Intent i) throws ActivityNotFoundException { - i.putExtra(DATA_NAME, StringWidgetUtils.getIntegerAnswerValueFromIAnswerData(getFormEntryPrompt().getAnswerValue())); - try { - ((Activity) getContext()).startActivityForResult(i, RequestCodes.EX_INT_CAPTURE); - } catch (SecurityException e) { - Timber.i(e); - ToastUtils.showLongToast(getContext(), R.string.not_granted_permission); - } + protected Serializable getAnswerForIntent() { + return StringWidgetUtils.getIntegerAnswerValueFromIAnswerData(getFormEntryPrompt().getAnswerValue()); + } + + @Override + protected int getRequestCode() { + return RequestCodes.EX_INT_CAPTURE; } @Override 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 22a4ea1981f..a28f8006401 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,16 +14,13 @@ package org.odk.collect.android.widgets; -import static android.content.Intent.ACTION_SENDTO; 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.ActivityNotFoundException; import android.content.Context; -import android.content.Intent; import android.text.Editable; import android.text.Selection; import android.text.TextWatcher; @@ -34,18 +31,15 @@ import org.javarosa.core.model.data.StringData; import org.odk.collect.android.R; -import org.odk.collect.android.application.Collect; import org.odk.collect.android.externaldata.ExternalAppsUtils; import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.android.formentry.questions.WidgetViewUtils; -import org.odk.collect.android.utilities.ActivityAvailability; -import org.odk.collect.android.utilities.ExternalAppIntentProvider; import org.odk.collect.android.widgets.interfaces.ButtonClickListener; import org.odk.collect.android.widgets.interfaces.WidgetDataReceiver; +import org.odk.collect.android.widgets.utilities.StringRequester; import org.odk.collect.android.widgets.utilities.WaitingForDataRegistry; -import org.odk.collect.androidshared.ui.ToastUtils; -import javax.inject.Inject; +import java.io.Serializable; import timber.log.Timber; @@ -90,18 +84,16 @@ */ @SuppressLint("ViewConstructor") public class ExStringWidget extends StringWidget implements WidgetDataReceiver, ButtonClickListener { - protected static final String DATA_NAME = "value"; private final WaitingForDataRegistry waitingForDataRegistry; private boolean hasExApp = true; public Button launchIntentButton; + private final StringRequester stringRequester; - @Inject - public ActivityAvailability activityAvailability; - - public ExStringWidget(Context context, QuestionDetails questionDetails, WaitingForDataRegistry waitingForDataRegistry) { + public ExStringWidget(Context context, QuestionDetails questionDetails, WaitingForDataRegistry waitingForDataRegistry, StringRequester stringRequester) { super(context, questionDetails); this.waitingForDataRegistry = waitingForDataRegistry; + this.stringRequester = stringRequester; getComponent(context).inject(this); } @@ -122,14 +114,12 @@ private String getButtonText() { return v != null ? v : getContext().getString(R.string.launch_app); } - protected void fireActivity(Intent i) throws ActivityNotFoundException { - i.putExtra(DATA_NAME, getFormEntryPrompt().getAnswerText()); - try { - ((Activity) getContext()).startActivityForResult(i, RequestCodes.EX_STRING_CAPTURE); - } catch (SecurityException e) { - Timber.i(e); - ToastUtils.showLongToast(getContext(), R.string.not_granted_permission); - } + protected Serializable getAnswerForIntent() { + return getFormEntryPrompt().getAnswerText(); + } + + protected int getRequestCode() { + return RequestCodes.EX_STRING_CAPTURE; } @Override @@ -180,17 +170,10 @@ public void cancelLongPress() { @Override public void onButtonClick(int buttonId) { waitingForDataRegistry.waitForData(getFormEntryPrompt().getIndex()); - try { - Intent intent = new ExternalAppIntentProvider().getIntentToRunExternalApp(getContext(), getFormEntryPrompt(), activityAvailability, Collect.getInstance().getPackageManager()); - // ACTION_SENDTO used for sending text messages or emails doesn't require any results - if (ACTION_SENDTO.equals(intent.getAction())) { - getContext().startActivity(intent); - } else { - fireActivity(intent); - } - } catch (Exception | Error e) { - onException(e.getMessage()); - } + stringRequester.launch((Activity) getContext(), getRequestCode(), getFormEntryPrompt(), getAnswerForIntent(), (String errorMsg) -> { + onException(errorMsg); + return null; + }); } private void focusAnswer() { diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/ExVideoWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/ExVideoWidget.java index 3290eff41d7..8531c6d272b 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/ExVideoWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/ExVideoWidget.java @@ -3,7 +3,6 @@ import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; -import android.content.Intent; import android.util.TypedValue; import android.view.View; @@ -11,17 +10,15 @@ import org.javarosa.core.model.data.StringData; import org.javarosa.form.api.FormEntryPrompt; import org.odk.collect.android.R; -import org.odk.collect.android.application.Collect; import org.odk.collect.android.databinding.ExVideoWidgetAnswerBinding; import org.odk.collect.android.formentry.questions.QuestionDetails; -import org.odk.collect.android.utilities.ActivityAvailability; import org.odk.collect.android.utilities.ApplicationConstants; -import org.odk.collect.android.utilities.ExternalAppIntentProvider; import org.odk.collect.android.utilities.FileUtils; import org.odk.collect.android.utilities.MediaUtils; import org.odk.collect.android.utilities.QuestionMediaManager; import org.odk.collect.android.widgets.interfaces.FileWidget; import org.odk.collect.android.widgets.interfaces.WidgetDataReceiver; +import org.odk.collect.android.widgets.utilities.FileRequester; import org.odk.collect.android.widgets.utilities.WaitingForDataRegistry; import org.odk.collect.androidshared.ui.ToastUtils; @@ -36,21 +33,19 @@ public class ExVideoWidget extends QuestionWidget implements FileWidget, WidgetD private final WaitingForDataRegistry waitingForDataRegistry; private final QuestionMediaManager questionMediaManager; private final MediaUtils mediaUtils; - private final ExternalAppIntentProvider externalAppIntentProvider; - private final ActivityAvailability activityAvailability; + private final FileRequester fileRequester; File answerFile; public ExVideoWidget(Context context, QuestionDetails questionDetails, QuestionMediaManager questionMediaManager, WaitingForDataRegistry waitingForDataRegistry, MediaUtils mediaUtils, - ExternalAppIntentProvider externalAppIntentProvider, ActivityAvailability activityAvailability) { + FileRequester fileRequester) { super(context, questionDetails); this.waitingForDataRegistry = waitingForDataRegistry; this.questionMediaManager = questionMediaManager; this.mediaUtils = mediaUtils; - this.externalAppIntentProvider = externalAppIntentProvider; - this.activityAvailability = activityAvailability; + this.fileRequester = fileRequester; } @Override @@ -128,21 +123,7 @@ public void cancelLongPress() { private void launchExternalApp() { waitingForDataRegistry.waitForData(getFormEntryPrompt().getIndex()); - try { - Intent intent = externalAppIntentProvider.getIntentToRunExternalApp(getContext(), getFormEntryPrompt(), activityAvailability, Collect.getInstance().getPackageManager()); - fireActivityForResult(intent); - } catch (Exception | Error e) { - ToastUtils.showLongToast(getContext(), e.getMessage()); - } - } - - private void fireActivityForResult(Intent intent) { - try { - ((Activity) getContext()).startActivityForResult(intent, ApplicationConstants.RequestCodes.EX_VIDEO_CHOOSER); - } catch (SecurityException e) { - Timber.i(e); - ToastUtils.showLongToast(getContext(), R.string.not_granted_permission); - } + fileRequester.launch((Activity) getContext(), ApplicationConstants.RequestCodes.EX_VIDEO_CHOOSER, getFormEntryPrompt()); } private void setupAnswerFile(String fileName) { diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/OSMWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/OSMWidget.java index 67d57100ce3..084d4622865 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/OSMWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/OSMWidget.java @@ -21,10 +21,10 @@ import org.odk.collect.android.databinding.OsmWidgetAnswerBinding; import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.android.javarosawrapper.FormController; -import org.odk.collect.android.utilities.ActivityAvailability; import org.odk.collect.android.utilities.FileUtils; import org.odk.collect.android.widgets.interfaces.WidgetDataReceiver; import org.odk.collect.android.widgets.utilities.WaitingForDataRegistry; +import org.odk.collect.androidshared.system.IntentLauncher; import java.util.ArrayList; import java.util.List; @@ -46,7 +46,7 @@ public class OSMWidget extends QuestionWidget implements WidgetDataReceiver { public static final String OSM_EDIT_FILE_NAME = "OSM_EDIT_FILE_NAME"; private final WaitingForDataRegistry waitingForDataRegistry; - private final ActivityAvailability activityAvailability; + private final IntentLauncher intentLauncher; private final List osmRequiredTags; private final String instanceId; @@ -55,10 +55,10 @@ public class OSMWidget extends QuestionWidget implements WidgetDataReceiver { private final int formId; public OSMWidget(Context context, QuestionDetails questionDetails, WaitingForDataRegistry waitingForDataRegistry, - ActivityAvailability activityAvailability, FormController formController) { + IntentLauncher intentLauncher, FormController formController) { super(context, questionDetails); this.waitingForDataRegistry = waitingForDataRegistry; - this.activityAvailability = activityAvailability; + this.intentLauncher = intentLauncher; formFileName = FileUtils.getFormBasenameFromMediaFolder(formController.getMediaFolder()); @@ -121,14 +121,12 @@ private void launchOpenMapKit() { //send encode tag data structure to intent writeOsmRequiredTagsToExtras(launchIntent); - if (activityAvailability.isActivityAvailable(launchIntent)) { - waitingForDataRegistry.waitForData(getFormEntryPrompt().getIndex()); - ((Activity) getContext()).startActivityForResult(launchIntent, RequestCodes.OSM_CAPTURE); - } else { + waitingForDataRegistry.waitForData(getFormEntryPrompt().getIndex()); + intentLauncher.launchForResult((Activity) getContext(), launchIntent, RequestCodes.OSM_CAPTURE, () -> { waitingForDataRegistry.cancelWaitingForData(); binding.errorText.setVisibility(View.VISIBLE); - } - + return null; + }); } catch (Exception ex) { AlertDialog.Builder builder = new AlertDialog.Builder(getContext()); builder.setTitle(R.string.alert); 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 80a0b11431e..5c8e1ee8487 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 @@ -32,10 +32,8 @@ import org.odk.collect.android.geo.MapProvider; import org.odk.collect.android.permissions.PermissionsProvider; import org.odk.collect.android.storage.StoragePathProvider; -import org.odk.collect.android.utilities.ActivityAvailability; import org.odk.collect.android.utilities.Appearances; import org.odk.collect.android.utilities.CameraUtils; -import org.odk.collect.android.utilities.ExternalAppIntentProvider; import org.odk.collect.android.utilities.ExternalWebPageHelper; import org.odk.collect.android.utilities.MediaUtils; import org.odk.collect.android.utilities.QuestionMediaManager; @@ -54,10 +52,13 @@ import org.odk.collect.android.widgets.utilities.AudioPlayer; import org.odk.collect.android.widgets.utilities.AudioRecorderRecordingStatusHandler; import org.odk.collect.android.widgets.utilities.DateTimeWidgetUtils; +import org.odk.collect.android.widgets.utilities.FileRequester; +import org.odk.collect.android.widgets.utilities.StringRequester; import org.odk.collect.android.widgets.utilities.GetContentAudioFileRequester; import org.odk.collect.android.widgets.utilities.RecordingRequester; import org.odk.collect.android.widgets.utilities.RecordingRequesterProvider; import org.odk.collect.android.widgets.utilities.WaitingForDataRegistry; +import org.odk.collect.androidshared.system.IntentLauncherImpl; import org.odk.collect.audiorecorder.recording.AudioRecorder; /** @@ -75,11 +76,12 @@ public class WidgetFactory { private final WaitingForDataRegistry waitingForDataRegistry; private final QuestionMediaManager questionMediaManager; private final AudioPlayer audioPlayer; - private final ActivityAvailability activityAvailability; private final RecordingRequesterProvider recordingRequesterProvider; private final FormEntryViewModel formEntryViewModel; private final AudioRecorder audioRecorder; private final LifecycleOwner viewLifecycle; + private final FileRequester fileRequester; + private final StringRequester stringRequester; public WidgetFactory(Activity activity, boolean readOnlyOverride, @@ -87,22 +89,24 @@ public WidgetFactory(Activity activity, WaitingForDataRegistry waitingForDataRegistry, QuestionMediaManager questionMediaManager, AudioPlayer audioPlayer, - ActivityAvailability activityAvailability, RecordingRequesterProvider recordingRequesterProvider, FormEntryViewModel formEntryViewModel, AudioRecorder audioRecorder, - LifecycleOwner viewLifecycle) { + LifecycleOwner viewLifecycle, + FileRequester fileRequester, + StringRequester stringRequester) { this.context = activity; this.readOnlyOverride = readOnlyOverride; this.useExternalRecorder = useExternalRecorder; this.waitingForDataRegistry = waitingForDataRegistry; this.questionMediaManager = questionMediaManager; this.audioPlayer = audioPlayer; - this.activityAvailability = activityAvailability; this.recordingRequesterProvider = recordingRequesterProvider; this.formEntryViewModel = formEntryViewModel; this.audioRecorder = audioRecorder; this.viewLifecycle = viewLifecycle; + this.fileRequester = fileRequester; + this.stringRequester = stringRequester; } public QuestionWidget createWidgetFromPrompt(FormEntryPrompt prompt, PermissionsProvider permissionsProvider) { @@ -124,7 +128,7 @@ public QuestionWidget createWidgetFromPrompt(FormEntryPrompt prompt, Permissions break; case Constants.DATATYPE_DECIMAL: if (appearance.startsWith(Appearances.EX)) { - questionWidget = new ExDecimalWidget(context, questionDetails, waitingForDataRegistry); + questionWidget = new ExDecimalWidget(context, questionDetails, waitingForDataRegistry, stringRequester); } else if (appearance.equals(Appearances.BEARING)) { questionWidget = new BearingWidget(context, questionDetails, waitingForDataRegistry, (SensorManager) context.getSystemService(Context.SENSOR_SERVICE)); @@ -134,7 +138,7 @@ public QuestionWidget createWidgetFromPrompt(FormEntryPrompt prompt, Permissions break; case Constants.DATATYPE_INTEGER: if (appearance.startsWith(Appearances.EX)) { - questionWidget = new ExIntegerWidget(context, questionDetails, waitingForDataRegistry); + questionWidget = new ExIntegerWidget(context, questionDetails, waitingForDataRegistry, stringRequester); } else { questionWidget = new IntegerWidget(context, questionDetails); } @@ -166,7 +170,7 @@ public QuestionWidget createWidgetFromPrompt(FormEntryPrompt prompt, Permissions } else if (appearance.startsWith(Appearances.PRINTER)) { questionWidget = new ExPrinterWidget(context, questionDetails, waitingForDataRegistry); } else if (appearance.startsWith(Appearances.EX)) { - questionWidget = new ExStringWidget(context, questionDetails, waitingForDataRegistry); + questionWidget = new ExStringWidget(context, questionDetails, waitingForDataRegistry, stringRequester); } else if (appearance.contains(Appearances.NUMBERS)) { questionWidget = new StringNumberWidget(context, questionDetails); } else if (appearance.equals(Appearances.URL)) { @@ -182,7 +186,7 @@ public QuestionWidget createWidgetFromPrompt(FormEntryPrompt prompt, Permissions break; case Constants.CONTROL_FILE_CAPTURE: if (appearance.startsWith(Appearances.EX)) { - questionWidget = new ExArbitraryFileWidget(context, questionDetails, new MediaUtils(), questionMediaManager, waitingForDataRegistry, new ExternalAppIntentProvider(), activityAvailability); + questionWidget = new ExArbitraryFileWidget(context, questionDetails, new MediaUtils(), questionMediaManager, waitingForDataRegistry, fileRequester); } else { questionWidget = new ArbitraryFileWidget(context, questionDetails, new MediaUtils(), questionMediaManager, waitingForDataRegistry); } @@ -195,28 +199,28 @@ public QuestionWidget createWidgetFromPrompt(FormEntryPrompt prompt, Permissions } else if (appearance.equals(Appearances.DRAW)) { questionWidget = new DrawWidget(context, questionDetails, questionMediaManager, waitingForDataRegistry, new StoragePathProvider().getTmpImageFilePath()); } else if (appearance.startsWith(Appearances.EX)) { - questionWidget = new ExImageWidget(context, questionDetails, questionMediaManager, waitingForDataRegistry, new MediaUtils(), new ExternalAppIntentProvider(), activityAvailability); + questionWidget = new ExImageWidget(context, questionDetails, questionMediaManager, waitingForDataRegistry, new MediaUtils(), fileRequester); } else { questionWidget = new ImageWidget(context, questionDetails, questionMediaManager, waitingForDataRegistry, new StoragePathProvider().getTmpImageFilePath()); } break; case Constants.CONTROL_OSM_CAPTURE: questionWidget = new OSMWidget(context, questionDetails, waitingForDataRegistry, - new ActivityAvailability(context), Collect.getInstance().getFormController()); + IntentLauncherImpl.INSTANCE, Collect.getInstance().getFormController()); break; case Constants.CONTROL_AUDIO_CAPTURE: RecordingRequester recordingRequester = recordingRequesterProvider.create(prompt, useExternalRecorder); - GetContentAudioFileRequester audioFileRequester = new GetContentAudioFileRequester(context, activityAvailability, waitingForDataRegistry, formEntryViewModel); + GetContentAudioFileRequester audioFileRequester = new GetContentAudioFileRequester(context, IntentLauncherImpl.INSTANCE, waitingForDataRegistry, formEntryViewModel); if (appearance.startsWith(Appearances.EX)) { - questionWidget = new ExAudioWidget(context, questionDetails, questionMediaManager, audioPlayer, waitingForDataRegistry, new MediaUtils(), new ExternalAppIntentProvider(), activityAvailability); + questionWidget = new ExAudioWidget(context, questionDetails, questionMediaManager, audioPlayer, waitingForDataRegistry, new MediaUtils(), fileRequester); } else { questionWidget = new AudioWidget(context, questionDetails, questionMediaManager, audioPlayer, recordingRequester, audioFileRequester, new AudioRecorderRecordingStatusHandler(audioRecorder, formEntryViewModel, viewLifecycle)); } break; case Constants.CONTROL_VIDEO_CAPTURE: if (appearance.startsWith(Appearances.EX)) { - questionWidget = new ExVideoWidget(context, questionDetails, questionMediaManager, waitingForDataRegistry, new MediaUtils(), new ExternalAppIntentProvider(), activityAvailability); + questionWidget = new ExVideoWidget(context, questionDetails, questionMediaManager, waitingForDataRegistry, new MediaUtils(), fileRequester); } else { questionWidget = new VideoWidget(context, questionDetails, questionMediaManager, waitingForDataRegistry); } diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/ExternalAppRecordingRequester.java b/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/ExternalAppRecordingRequester.java deleted file mode 100644 index 6d91191a89d..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/ExternalAppRecordingRequester.java +++ /dev/null @@ -1,59 +0,0 @@ -package org.odk.collect.android.widgets.utilities; - -import android.app.Activity; -import android.content.Intent; -import android.provider.MediaStore; -import android.widget.Toast; - -import org.javarosa.form.api.FormEntryPrompt; -import org.odk.collect.android.R; -import org.odk.collect.android.analytics.AnalyticsEvents; -import org.odk.collect.android.formentry.FormEntryViewModel; -import org.odk.collect.android.listeners.PermissionListener; -import org.odk.collect.android.permissions.PermissionsProvider; -import org.odk.collect.android.utilities.ActivityAvailability; -import org.odk.collect.android.utilities.ApplicationConstants; - -public class ExternalAppRecordingRequester implements RecordingRequester { - - private final Activity activity; - private final PermissionsProvider permissionsProvider; - private final ActivityAvailability activityAvailability; - private final WaitingForDataRegistry waitingForDataRegistry; - private final FormEntryViewModel formEntryViewModel; - - public ExternalAppRecordingRequester(Activity activity, ActivityAvailability activityAvailability, WaitingForDataRegistry waitingForDataRegistry, PermissionsProvider permissionsProvider, FormEntryViewModel formEntryViewModel) { - this.activity = activity; - this.permissionsProvider = permissionsProvider; - this.activityAvailability = activityAvailability; - this.waitingForDataRegistry = waitingForDataRegistry; - this.formEntryViewModel = formEntryViewModel; - } - - @Override - public void requestRecording(FormEntryPrompt prompt) { - permissionsProvider.requestRecordAudioPermission(activity, new PermissionListener() { - @Override - public void granted() { - Intent intent = new Intent(MediaStore.Audio.Media.RECORD_SOUND_ACTION); - intent.putExtra(MediaStore.EXTRA_OUTPUT, - MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.toString()); - - if (activityAvailability.isActivityAvailable(intent)) { - waitingForDataRegistry.waitForData(prompt.getIndex()); - activity.startActivityForResult(intent, ApplicationConstants.RequestCodes.AUDIO_CAPTURE); - } else { - Toast.makeText(activity, activity.getString(R.string.activity_not_found, - activity.getString(R.string.capture_audio)), Toast.LENGTH_SHORT).show(); - waitingForDataRegistry.cancelWaitingForData(); - } - } - - @Override - public void denied() { - } - }); - - formEntryViewModel.logFormEvent(AnalyticsEvents.AUDIO_RECORDING_EXTERNAL); - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/ExternalAppRecordingRequester.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/ExternalAppRecordingRequester.kt new file mode 100644 index 00000000000..925582bc009 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/ExternalAppRecordingRequester.kt @@ -0,0 +1,57 @@ +package org.odk.collect.android.widgets.utilities + +import android.app.Activity +import android.content.Intent +import android.provider.MediaStore +import android.widget.Toast +import org.javarosa.form.api.FormEntryPrompt +import org.odk.collect.android.R +import org.odk.collect.android.analytics.AnalyticsEvents +import org.odk.collect.android.formentry.FormEntryViewModel +import org.odk.collect.android.listeners.PermissionListener +import org.odk.collect.android.permissions.PermissionsProvider +import org.odk.collect.android.utilities.ApplicationConstants +import org.odk.collect.androidshared.system.IntentLauncher + +class ExternalAppRecordingRequester( + private val activity: Activity, + private val intentLauncher: IntentLauncher, + private val waitingForDataRegistry: WaitingForDataRegistry, + private val permissionsProvider: PermissionsProvider, + private val formEntryViewModel: FormEntryViewModel +) : RecordingRequester { + + override fun requestRecording(prompt: FormEntryPrompt) { + permissionsProvider.requestRecordAudioPermission( + activity, + object : PermissionListener { + override fun granted() { + val intent = Intent(MediaStore.Audio.Media.RECORD_SOUND_ACTION) + intent.putExtra( + MediaStore.EXTRA_OUTPUT, + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.toString() + ) + waitingForDataRegistry.waitForData(prompt.index) + intentLauncher.launchForResult( + activity, + intent, + ApplicationConstants.RequestCodes.AUDIO_CAPTURE + ) { + Toast.makeText( + activity, + activity.getString( + R.string.activity_not_found, + activity.getString(R.string.capture_audio) + ), + Toast.LENGTH_SHORT + ).show() + waitingForDataRegistry.cancelWaitingForData() + } + } + + override fun denied() {} + } + ) + formEntryViewModel.logFormEvent(AnalyticsEvents.AUDIO_RECORDING_EXTERNAL) + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/FileRequester.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/FileRequester.kt new file mode 100644 index 00000000000..f388cfc22cb --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/FileRequester.kt @@ -0,0 +1,58 @@ +package org.odk.collect.android.widgets.utilities + +import android.app.Activity +import org.javarosa.form.api.FormEntryPrompt +import org.odk.collect.android.R +import org.odk.collect.android.utilities.ExternalAppIntentProvider +import org.odk.collect.androidshared.system.IntentLauncher +import org.odk.collect.androidshared.ui.ToastUtils.showLongToast +import java.lang.Error +import java.lang.Exception + +class FileRequesterImpl( + val intentLauncher: IntentLauncher, + val externalAppIntentProvider: ExternalAppIntentProvider, +) : FileRequester { + + override fun launch( + activity: Activity, + requestCode: Int, + formEntryPrompt: FormEntryPrompt + ) { + try { + val intent = externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt) + val intentWithoutDefaultCategory = + externalAppIntentProvider.getIntentToRunExternalAppWithoutDefaultCategory( + formEntryPrompt, + activity.packageManager + ) + + intentLauncher.launchForResult( + activity, intent, requestCode + ) { + intentLauncher.launchForResult( + activity, intentWithoutDefaultCategory, requestCode + ) { + showLongToast(activity, getErrorMessage(formEntryPrompt, activity)) + } + } + } catch (e: Exception) { + showLongToast(activity, e.message!!) + } catch (e: Error) { + showLongToast(activity, e.message!!) + } + } + + private fun getErrorMessage(formEntryPrompt: FormEntryPrompt, activity: Activity): String { + val customErrorMessage = formEntryPrompt.getSpecialFormQuestionText("noAppErrorString") + return customErrorMessage ?: activity.getString(R.string.no_app) + } +} + +interface FileRequester { + fun launch( + activity: Activity, + requestCode: Int, + formEntryPrompt: FormEntryPrompt + ) +} diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/GetContentAudioFileRequester.java b/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/GetContentAudioFileRequester.java deleted file mode 100644 index e0c032209f6..00000000000 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/GetContentAudioFileRequester.java +++ /dev/null @@ -1,43 +0,0 @@ -package org.odk.collect.android.widgets.utilities; - -import android.app.Activity; -import android.content.Intent; -import android.widget.Toast; - -import org.javarosa.form.api.FormEntryPrompt; -import org.odk.collect.android.R; -import org.odk.collect.android.analytics.AnalyticsEvents; -import org.odk.collect.android.formentry.FormEntryViewModel; -import org.odk.collect.android.utilities.ActivityAvailability; -import org.odk.collect.android.utilities.ApplicationConstants; - -public class GetContentAudioFileRequester implements AudioFileRequester { - - private final Activity activity; - private final ActivityAvailability activityAvailability; - private final WaitingForDataRegistry waitingForDataRegistry; - private final FormEntryViewModel formEntryViewModel; - - public GetContentAudioFileRequester(Activity activity, ActivityAvailability activityAvailability, WaitingForDataRegistry waitingForDataRegistry, FormEntryViewModel formEntryViewModel) { - this.activity = activity; - this.activityAvailability = activityAvailability; - this.waitingForDataRegistry = waitingForDataRegistry; - this.formEntryViewModel = formEntryViewModel; - } - - @Override - public void requestFile(FormEntryPrompt prompt) { - Intent intent = new Intent(Intent.ACTION_GET_CONTENT); - intent.setType("audio/*"); - - if (activityAvailability.isActivityAvailable(intent)) { - waitingForDataRegistry.waitForData(prompt.getIndex()); - activity.startActivityForResult(intent, ApplicationConstants.RequestCodes.AUDIO_CHOOSER); - } else { - Toast.makeText(activity, activity.getString(R.string.activity_not_found, activity.getString(R.string.choose_sound)), Toast.LENGTH_SHORT).show(); - waitingForDataRegistry.cancelWaitingForData(); - } - - formEntryViewModel.logFormEvent(AnalyticsEvents.AUDIO_RECORDING_CHOOSE); - } -} diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/GetContentAudioFileRequester.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/GetContentAudioFileRequester.kt new file mode 100644 index 00000000000..56c33624614 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/GetContentAudioFileRequester.kt @@ -0,0 +1,41 @@ +package org.odk.collect.android.widgets.utilities + +import android.app.Activity +import android.content.Intent +import android.widget.Toast +import org.javarosa.form.api.FormEntryPrompt +import org.odk.collect.android.R +import org.odk.collect.android.analytics.AnalyticsEvents +import org.odk.collect.android.formentry.FormEntryViewModel +import org.odk.collect.android.utilities.ApplicationConstants +import org.odk.collect.androidshared.system.IntentLauncher + +class GetContentAudioFileRequester( + private val activity: Activity, + private val intentLauncher: IntentLauncher, + private val waitingForDataRegistry: WaitingForDataRegistry, + private val formEntryViewModel: FormEntryViewModel +) : AudioFileRequester { + + override fun requestFile(prompt: FormEntryPrompt) { + val intent = Intent(Intent.ACTION_GET_CONTENT) + intent.type = "audio/*" + waitingForDataRegistry.waitForData(prompt.index) + intentLauncher.launchForResult( + activity, + intent, + ApplicationConstants.RequestCodes.AUDIO_CHOOSER + ) { + Toast.makeText( + activity, + activity.getString( + R.string.activity_not_found, + activity.getString(R.string.choose_sound) + ), + Toast.LENGTH_SHORT + ).show() + waitingForDataRegistry.cancelWaitingForData() + } + formEntryViewModel.logFormEvent(AnalyticsEvents.AUDIO_RECORDING_CHOOSE) + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/StringRequester.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/StringRequester.kt new file mode 100644 index 00000000000..37526209470 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/StringRequester.kt @@ -0,0 +1,76 @@ +package org.odk.collect.android.widgets.utilities + +import android.app.Activity +import android.content.Intent +import org.javarosa.form.api.FormEntryPrompt +import org.odk.collect.android.R +import org.odk.collect.android.utilities.ExternalAppIntentProvider +import org.odk.collect.androidshared.system.IntentLauncher +import java.io.Serializable +import java.lang.Error +import java.lang.Exception + +class StringRequesterImpl( + val intentLauncher: IntentLauncher, + val externalAppIntentProvider: ExternalAppIntentProvider, +) : StringRequester { + + override fun launch( + activity: Activity, + requestCode: Int, + formEntryPrompt: FormEntryPrompt, + value: Serializable?, + onError: (String) -> Unit + ) { + try { + val intent = externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt)?.apply { + putExtra("value", value) + } + val intentWithoutDefaultCategory = + externalAppIntentProvider.getIntentToRunExternalAppWithoutDefaultCategory( + formEntryPrompt, + activity.packageManager + )?.apply { + putExtra("value", value) + } + + // ACTION_SENDTO used for sending text messages or emails doesn't require any results + if (intent != null && Intent.ACTION_SENDTO == intent.action) { + intentLauncher.launch(activity, intent) { + intentLauncher.launch( + activity, intentWithoutDefaultCategory + ) { + onError(getErrorMessage(formEntryPrompt, activity)) + } + } + } else { + intentLauncher.launchForResult(activity, intent, requestCode) { + intentLauncher.launchForResult( + activity, intentWithoutDefaultCategory, requestCode + ) { + onError(getErrorMessage(formEntryPrompt, activity)) + } + } + } + } catch (e: Exception) { + onError(e.message!!) + } catch (e: Error) { + onError(e.message!!) + } + } + + private fun getErrorMessage(formEntryPrompt: FormEntryPrompt, activity: Activity): String { + val customErrorMessage = formEntryPrompt.getSpecialFormQuestionText("noAppErrorString") + return customErrorMessage ?: activity.getString(R.string.no_app) + } +} + +interface StringRequester { + fun launch( + activity: Activity, + requestCode: Int, + formEntryPrompt: FormEntryPrompt, + value: Serializable?, + onError: (String) -> Unit + ) +} diff --git a/collect_app/src/test/java/org/odk/collect/android/configure/qr/QRCodeMenuDelegateTest.java b/collect_app/src/test/java/org/odk/collect/android/configure/qr/QRCodeMenuDelegateTest.java index 0de9024e3e1..7782ee1c421 100644 --- a/collect_app/src/test/java/org/odk/collect/android/configure/qr/QRCodeMenuDelegateTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/configure/qr/QRCodeMenuDelegateTest.java @@ -12,8 +12,10 @@ import org.odk.collect.android.R; import org.odk.collect.android.TestSettingsProvider; -import org.odk.collect.android.utilities.ActivityAvailability; import org.odk.collect.android.utilities.FileProvider; +import org.odk.collect.androidshared.system.IntentLauncher; +import org.odk.collect.androidshared.system.IntentLauncherImpl; +import org.odk.collect.testshared.ErrorIntentLauncher; import org.odk.collect.testshared.FakeScheduler; import org.robolectric.Robolectric; import org.robolectric.fakes.RoboMenuItem; @@ -33,7 +35,7 @@ @RunWith(AndroidJUnit4.class) public class QRCodeMenuDelegateTest { - private final ActivityAvailability activityAvailability = mock(ActivityAvailability.class); + private IntentLauncher intentLauncher; private final QRCodeGenerator qrCodeGenerator = mock(QRCodeGenerator.class); private final AppConfigurationGenerator appConfigurationGenerator = mock(AppConfigurationGenerator.class); private final FileProvider fileProvider = mock(FileProvider.class); @@ -45,13 +47,17 @@ public class QRCodeMenuDelegateTest { @Before public void setup() { activity = Robolectric.setupActivity(FragmentActivity.class); - menuDelegate = new QRCodeMenuDelegate(activity, activityAvailability, qrCodeGenerator, + intentLauncher = IntentLauncherImpl.INSTANCE; + setupMenuDelegate(); + } + + private void setupMenuDelegate() { + menuDelegate = new QRCodeMenuDelegate(activity, intentLauncher, qrCodeGenerator, appConfigurationGenerator, fileProvider, TestSettingsProvider.getSettingsProvider(), fakeScheduler); } @Test public void clickingOnImportQRCode_startsExternalImagePickerIntent() { - when(activityAvailability.isActivityAvailable(any())).thenReturn(true); menuDelegate.onOptionsItemSelected(new RoboMenuItem(R.id.menu_item_scan_sd_card)); ShadowActivity.IntentForResult intentForResult = shadowOf(activity).getNextStartedActivityForResult(); @@ -63,7 +69,8 @@ public void clickingOnImportQRCode_startsExternalImagePickerIntent() { @Test public void clickingOnImportQRCode_whenPickerActivityNotAvailable_showsToast() { - when(activityAvailability.isActivityAvailable(any())).thenReturn(false); + intentLauncher = new ErrorIntentLauncher(); + setupMenuDelegate(); menuDelegate.onOptionsItemSelected(new RoboMenuItem(R.id.menu_item_scan_sd_card)); assertThat(shadowOf(activity).getNextStartedActivityForResult(), is(nullValue())); diff --git a/collect_app/src/test/java/org/odk/collect/android/projects/ManualProjectCreatorDialogTest.kt b/collect_app/src/test/java/org/odk/collect/android/projects/ManualProjectCreatorDialogTest.kt index daa2f73e36d..1053496cf15 100644 --- a/collect_app/src/test/java/org/odk/collect/android/projects/ManualProjectCreatorDialogTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/projects/ManualProjectCreatorDialogTest.kt @@ -19,7 +19,6 @@ import org.hamcrest.Matchers.`is` import org.hamcrest.Matchers.not 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.verify @@ -30,11 +29,12 @@ import org.odk.collect.android.injection.config.AppDependencyModule import org.odk.collect.android.preferences.source.SettingsProvider import org.odk.collect.android.support.CollectHelpers import org.odk.collect.android.support.Matchers.isPasswordHidden -import org.odk.collect.android.utilities.ActivityAvailability +import org.odk.collect.androidshared.system.IntentLauncher import org.odk.collect.fragmentstest.DialogFragmentTest import org.odk.collect.fragmentstest.DialogFragmentTest.onViewInDialog import org.odk.collect.projects.Project import org.odk.collect.projects.ProjectsRepository +import org.odk.collect.testshared.ErrorIntentLauncher import org.robolectric.shadows.ShadowToast @RunWith(AndroidJUnit4::class) @@ -155,12 +155,9 @@ class ManualProjectCreatorDialogTest { @Test fun `If activity to choose google account is not found the app should not crash`() { - val activityAvailability = mock { - on { isActivityAvailable(any()) } doReturn false - } CollectHelpers.overrideAppDependencyModule(object : AppDependencyModule() { - override fun providesActivityAvailability(context: Context?): ActivityAvailability { - return activityAvailability + override fun providesIntentLauncher(): IntentLauncher { + return ErrorIntentLauncher() } }) diff --git a/collect_app/src/test/java/org/odk/collect/android/utilities/ExternalAppIntentProviderTest.java b/collect_app/src/test/java/org/odk/collect/android/utilities/ExternalAppIntentProviderTest.java deleted file mode 100644 index 9aa22d7d4ef..00000000000 --- a/collect_app/src/test/java/org/odk/collect/android/utilities/ExternalAppIntentProviderTest.java +++ /dev/null @@ -1,122 +0,0 @@ -package org.odk.collect.android.utilities; - -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; - -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.javarosa.core.model.FormIndex; -import org.javarosa.form.api.FormEntryPrompt; -import org.javarosa.xpath.parser.XPathSyntaxException; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.odk.collect.android.R; -import org.odk.collect.android.exception.ExternalParamsException; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.core.Is.is; -import static org.hamcrest.core.IsNull.nullValue; -import static org.junit.Assert.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -@RunWith(AndroidJUnit4.class) -public class ExternalAppIntentProviderTest { - private Context context; - private ActivityAvailability activityAvailability; - private FormEntryPrompt formEntryPrompt; - private PackageManager packageManager; - private ExternalAppIntentProvider externalAppIntentProvider; - - @Before - public void setup() { - context = mock(Context.class); - activityAvailability = mock(ActivityAvailability.class); - formEntryPrompt = mock(FormEntryPrompt.class); - packageManager = mock(PackageManager.class); - externalAppIntentProvider = new ExternalAppIntentProvider(); - - when(context.getString(R.string.no_app)).thenReturn("The requested application is missing. Please manually enter the reading."); - when(formEntryPrompt.getIndex()).thenReturn(mock(FormIndex.class)); - } - - @Test - public void whenExternalActivityNotAvailable_shouldExceptionBeThrown() { - when(formEntryPrompt.getAppearanceHint()).thenReturn("ex:com.example.collectanswersprovider()"); - - assertThrows(RuntimeException.class, () -> externalAppIntentProvider.getIntentToRunExternalApp(context, formEntryPrompt, activityAvailability, packageManager)); - } - - @Test - public void whenNoCustomErrorMessageSpecified_shouldDefaultOneBeReturned() { - when(formEntryPrompt.getAppearanceHint()).thenReturn("ex:com.example.collectanswersprovider()"); - - Exception exception = assertThrows(RuntimeException.class, () -> externalAppIntentProvider.getIntentToRunExternalApp(context, formEntryPrompt, activityAvailability, packageManager)); - assertThat(exception.getMessage(), is("The requested application is missing. Please manually enter the reading.")); - } - - @Test - public void whenCustomErrorMessageSpecified_shouldThatMessageReturned() { - when(formEntryPrompt.getAppearanceHint()).thenReturn("ex:com.example.collectanswersprovider()"); - when(formEntryPrompt.getSpecialFormQuestionText("noAppErrorString")).thenReturn("Custom error message"); - - Exception exception = assertThrows(RuntimeException.class, () -> externalAppIntentProvider.getIntentToRunExternalApp(context, formEntryPrompt, activityAvailability, packageManager)); - assertThat(exception.getMessage(), is("Custom error message")); - } - - @Test - public void intentAction_shouldBeSetProperly() throws ExternalParamsException, XPathSyntaxException { - when(activityAvailability.isActivityAvailable(any())).thenReturn(true); - when(formEntryPrompt.getAppearanceHint()).thenReturn("ex:com.example.collectanswersprovider()"); - - Intent resultIntent = externalAppIntentProvider.getIntentToRunExternalApp(context, formEntryPrompt, activityAvailability, packageManager); - assertThat(resultIntent.getAction(), is("com.example.collectanswersprovider")); - } - - @Test - public void whenNoParamsSpecified_shouldIntentHaveNoExtras() throws ExternalParamsException, XPathSyntaxException { - when(activityAvailability.isActivityAvailable(any())).thenReturn(true); - when(formEntryPrompt.getAppearanceHint()).thenReturn("ex:com.example.collectanswersprovider()"); - - Intent resultIntent = externalAppIntentProvider.getIntentToRunExternalApp(context, formEntryPrompt, activityAvailability, packageManager); - assertThat(resultIntent.getExtras(), nullValue()); - } - - @Test - public void whenParamsSpecified_shouldIntentHaveExtras() throws ExternalParamsException, XPathSyntaxException { - when(activityAvailability.isActivityAvailable(any())).thenReturn(true); - when(formEntryPrompt.getAppearanceHint()).thenReturn("ex:com.example.collectanswersprovider(param1='value1', param2='value2')"); - - Intent resultIntent = externalAppIntentProvider.getIntentToRunExternalApp(context, formEntryPrompt, activityAvailability, packageManager); - assertThat(resultIntent.getExtras().keySet().size(), is(2)); - assertThat(resultIntent.getExtras().getString("param1"), is("value1")); - assertThat(resultIntent.getExtras().getString("param2"), is("value2")); - } - - @Test - public void whenParamsContainUri_shouldThatUriBeAddedAsIntentData() throws ExternalParamsException, XPathSyntaxException { - when(activityAvailability.isActivityAvailable(any())).thenReturn(true); - when(formEntryPrompt.getAppearanceHint()).thenReturn("ex:com.example.collectanswersprovider(param1='value1', uri_data='file:///tmp/android.txt')"); - - Intent resultIntent = externalAppIntentProvider.getIntentToRunExternalApp(context, formEntryPrompt, activityAvailability, packageManager); - assertThat(resultIntent.getData().toString(), is("file:///tmp/android.txt")); - assertThat(resultIntent.getExtras().keySet().size(), is(1)); - assertThat(resultIntent.getExtras().getString("param1"), is("value1")); - } - - @Test - public void whenSpecifiedIntentCanNotBeLaunched_shouldTryToLaunchTheMainExternalAppActivity() throws ExternalParamsException, XPathSyntaxException { - when(formEntryPrompt.getAppearanceHint()).thenReturn("ex:com.example.collectanswersprovider()"); - Intent intent = mock(Intent.class); - when(packageManager.getLaunchIntentForPackage("com.example.collectanswersprovider")).thenReturn(intent); - when(activityAvailability.isActivityAvailable(intent)).thenReturn(true); - - Intent resultIntent = externalAppIntentProvider.getIntentToRunExternalApp(context, formEntryPrompt, activityAvailability, packageManager); - assertThat(resultIntent, is(intent)); - assertThat(resultIntent.getFlags(), is(0)); - - } -} \ No newline at end of file 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 new file mode 100644 index 00000000000..fa9158e19b3 --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/utilities/ExternalAppIntentProviderTest.kt @@ -0,0 +1,59 @@ +package org.odk.collect.android.utilities + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.CoreMatchers.nullValue +import org.hamcrest.MatcherAssert.assertThat +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 + +@RunWith(AndroidJUnit4::class) +class ExternalAppIntentProviderTest { + private lateinit var formEntryPrompt: FormEntryPrompt + private lateinit var externalAppIntentProvider: ExternalAppIntentProvider + + @Before + fun setup() { + formEntryPrompt = mock() + externalAppIntentProvider = ExternalAppIntentProvider() + `when`(formEntryPrompt.index).thenReturn(mock()) + } + + @Test + fun intentAction_shouldBeSetProperly() { + `when`(formEntryPrompt.appearanceHint).thenReturn("ex:com.example.collectanswersprovider()") + val resultIntent = externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt) + assertThat(resultIntent.action, `is`("com.example.collectanswersprovider")) + } + + @Test + fun whenNoParamsSpecified_shouldIntentHaveNoExtras() { + `when`(formEntryPrompt.appearanceHint).thenReturn("ex:com.example.collectanswersprovider()") + val resultIntent = externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt) + assertThat(resultIntent.extras, nullValue()) + } + + @Test + fun whenParamsSpecified_shouldIntentHaveExtras() { + `when`(formEntryPrompt.appearanceHint) + .thenReturn("ex:com.example.collectanswersprovider(param1='value1', param2='value2')") + val resultIntent = externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt) + assertThat(resultIntent.extras!!.keySet().size, `is`(2)) + assertThat(resultIntent.extras!!.getString("param1"), `is`("value1")) + assertThat(resultIntent.extras!!.getString("param2"), `is`("value2")) + } + + @Test + fun whenParamsContainUri_shouldThatUriBeAddedAsIntentData() { + `when`(formEntryPrompt.appearanceHint) + .thenReturn("ex:com.example.collectanswersprovider(param1='value1', uri_data='file:///tmp/android.txt')") + val resultIntent = externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt) + assertThat(resultIntent.data.toString(), `is`("file:///tmp/android.txt")) + assertThat(resultIntent.extras!!.keySet().size, `is`(1)) + assertThat(resultIntent.extras!!.getString("param1"), `is`("value1")) + } +} diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/ExArbitraryFileWidgetTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/ExArbitraryFileWidgetTest.java index bc9cc54459b..5f1228c1f9e 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/ExArbitraryFileWidgetTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/ExArbitraryFileWidgetTest.java @@ -1,40 +1,34 @@ package org.odk.collect.android.widgets; -import android.content.Intent; import android.view.View; import androidx.annotation.NonNull; import org.javarosa.core.model.data.StringData; -import org.javarosa.xpath.parser.XPathSyntaxException; import org.junit.Test; import org.mockito.Mock; -import org.odk.collect.android.exception.ExternalParamsException; import org.odk.collect.android.formentry.questions.QuestionDetails; -import org.odk.collect.android.utilities.ActivityAvailability; -import org.odk.collect.android.utilities.ExternalAppIntentProvider; +import org.odk.collect.android.utilities.ApplicationConstants; import org.odk.collect.android.utilities.MediaUtils; import org.odk.collect.android.widgets.base.FileWidgetTest; import org.odk.collect.android.widgets.support.FakeQuestionMediaManager; import org.odk.collect.android.widgets.support.FakeWaitingForDataRegistry; +import org.odk.collect.android.widgets.utilities.FileRequester; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.odk.collect.android.preferences.keys.ProjectKeys.KEY_FONT_SIZE; import static org.odk.collect.android.utilities.QuestionFontSizeUtils.DEFAULT_FONT_SIZE; -import static org.robolectric.Shadows.shadowOf; public class ExArbitraryFileWidgetTest extends FileWidgetTest { @Mock MediaUtils mediaUtils; @Mock - ExternalAppIntentProvider externalAppIntentProvider; + FileRequester fileRequester; @Override public StringData getInitialAnswer() { @@ -51,7 +45,7 @@ public StringData getNextAnswer() { @Override public ExArbitraryFileWidget createWidget() { return new ExArbitraryFileWidget(activity, new QuestionDetails(formEntryPrompt, readOnlyOverride), - mediaUtils, new FakeQuestionMediaManager(), new FakeWaitingForDataRegistry(), externalAppIntentProvider, new ActivityAvailability(activity)); + mediaUtils, new FakeQuestionMediaManager(), new FakeWaitingForDataRegistry(), fileRequester); } @Test @@ -83,11 +77,9 @@ public void whenThereIsAnswer_shouldAnswerTextBeDisplayed() { } @Test - public void whenClickingOnButton_externalAppShouldBeLaunchedByIntent() throws ExternalParamsException, XPathSyntaxException { - Intent intent = mock(Intent.class); - when(externalAppIntentProvider.getIntentToRunExternalApp(any(), any(), any(), any())).thenReturn(intent); + public void whenClickingOnButton_exWidgetIntentLauncherShouldBeStarted() { getWidget().binding.exArbitraryFileButton.performClick(); - assertThat(shadowOf(activity).getNextStartedActivity(), is(intent)); + verify(fileRequester).launch(activity, ApplicationConstants.RequestCodes.EX_ARBITRARY_FILE_CHOOSER, formEntryPrompt); } @Test diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/ExAudioWidgetTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/ExAudioWidgetTest.java index bb6f6100703..c765a037982 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/ExAudioWidgetTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/ExAudioWidgetTest.java @@ -1,24 +1,21 @@ package org.odk.collect.android.widgets; -import android.content.Intent; import android.view.View; import androidx.annotation.NonNull; import org.javarosa.core.model.data.StringData; -import org.javarosa.xpath.parser.XPathSyntaxException; import org.junit.Before; import org.junit.Test; import org.mockito.Mock; -import org.odk.collect.android.exception.ExternalParamsException; import org.odk.collect.android.formentry.questions.QuestionDetails; -import org.odk.collect.android.utilities.ActivityAvailability; -import org.odk.collect.android.utilities.ExternalAppIntentProvider; +import org.odk.collect.android.utilities.ApplicationConstants; import org.odk.collect.android.utilities.MediaUtils; import org.odk.collect.android.widgets.base.FileWidgetTest; import org.odk.collect.android.widgets.support.FakeQuestionMediaManager; import org.odk.collect.android.widgets.support.FakeWaitingForDataRegistry; import org.odk.collect.android.widgets.utilities.AudioPlayer; +import org.odk.collect.android.widgets.utilities.FileRequester; import org.robolectric.shadows.ShadowToast; import java.io.File; @@ -28,22 +25,20 @@ import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.odk.collect.android.preferences.keys.ProjectKeys.KEY_FONT_SIZE; import static org.odk.collect.android.utilities.QuestionFontSizeUtils.DEFAULT_FONT_SIZE; -import static org.robolectric.Shadows.shadowOf; public class ExAudioWidgetTest extends FileWidgetTest { @Mock MediaUtils mediaUtils; @Mock - ExternalAppIntentProvider externalAppIntentProvider; + AudioPlayer audioPlayer; @Mock - AudioPlayer audioPlayer; + FileRequester fileRequester; @Before public void setup() { @@ -65,7 +60,7 @@ public StringData getNextAnswer() { @Override public ExAudioWidget createWidget() { return new ExAudioWidget(activity, new QuestionDetails(formEntryPrompt, readOnlyOverride), - new FakeQuestionMediaManager(), audioPlayer, new FakeWaitingForDataRegistry(), mediaUtils, externalAppIntentProvider, new ActivityAvailability(activity)); + new FakeQuestionMediaManager(), audioPlayer, new FakeWaitingForDataRegistry(), mediaUtils, fileRequester); } @Test @@ -121,11 +116,9 @@ public void whenAnswerCleared_shouldAudioPlayerBeHidden() { } @Test - public void whenLaunchButtonClicked_externalAppShouldBeLaunchedByIntent() throws ExternalParamsException, XPathSyntaxException { - Intent intent = mock(Intent.class); - when(externalAppIntentProvider.getIntentToRunExternalApp(any(), any(), any(), any())).thenReturn(intent); + public void whenLaunchButtonClicked_exWidgetIntentLauncherShouldBeStarted() { getWidget().binding.launchExternalAppButton.performClick(); - assertThat(shadowOf(activity).getNextStartedActivity(), is(intent)); + verify(fileRequester).launch(activity, ApplicationConstants.RequestCodes.EX_AUDIO_CHOOSER, formEntryPrompt); } @Test diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/ExDecimalWidgetTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/ExDecimalWidgetTest.java index e294521bb8d..d231f5ceb65 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/ExDecimalWidgetTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/ExDecimalWidgetTest.java @@ -9,6 +9,7 @@ import org.mockito.Mock; import org.odk.collect.android.widgets.base.GeneralExStringWidgetTest; import org.odk.collect.android.widgets.support.FakeWaitingForDataRegistry; +import org.odk.collect.android.widgets.utilities.StringRequester; import java.text.NumberFormat; import java.util.Locale; @@ -30,10 +31,13 @@ public class ExDecimalWidgetTest extends GeneralExStringWidgetTest { @Mock MediaUtils mediaUtils; @Mock - ExternalAppIntentProvider externalAppIntentProvider; + FileRequester fileRequester; @Before public void setup() { @@ -61,7 +56,7 @@ public StringData getNextAnswer() { @Override public ExImageWidget createWidget() { return new ExImageWidget(activity, new QuestionDetails(formEntryPrompt, readOnlyOverride), - new FakeQuestionMediaManager(), new FakeWaitingForDataRegistry(), mediaUtils, externalAppIntentProvider, new ActivityAvailability(activity)); + new FakeQuestionMediaManager(), new FakeWaitingForDataRegistry(), mediaUtils, fileRequester); } @Test @@ -108,11 +103,9 @@ public void whenAnswerCleared_shouldImageViewBeHidden() { } @Test - public void whenLaunchButtonClicked_externalAppShouldBeLaunchedByIntent() throws ExternalParamsException, XPathSyntaxException { - Intent intent = mock(Intent.class); - when(externalAppIntentProvider.getIntentToRunExternalApp(any(), any(), any(), any())).thenReturn(intent); + public void whenLaunchButtonClicked_exWidgetIntentLauncherShouldBeStarted() { getWidget().binding.launchExternalAppButton.performClick(); - assertThat(shadowOf(activity).getNextStartedActivity(), is(intent)); + verify(fileRequester).launch(activity, ApplicationConstants.RequestCodes.EX_IMAGE_CHOOSER, formEntryPrompt); } @Test diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/ExIntegerWidgetTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/ExIntegerWidgetTest.java index 37843bcb6d9..280ab67b18d 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/ExIntegerWidgetTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/ExIntegerWidgetTest.java @@ -3,10 +3,12 @@ import androidx.annotation.NonNull; import org.javarosa.core.model.data.IntegerData; +import org.mockito.Mock; import org.odk.collect.android.formentry.questions.QuestionDetails; import org.junit.Test; import org.odk.collect.android.widgets.base.GeneralExStringWidgetTest; import org.odk.collect.android.widgets.support.FakeWaitingForDataRegistry; +import org.odk.collect.android.widgets.utilities.StringRequester; import static junit.framework.TestCase.assertEquals; import static org.mockito.Mockito.when; @@ -18,10 +20,13 @@ public class ExIntegerWidgetTest extends GeneralExStringWidgetTest { + @Mock + StringRequester stringRequester; + @NonNull @Override public ExIntegerWidget createWidget() { - return new ExIntegerWidget(activity, new QuestionDetails(formEntryPrompt), new FakeWaitingForDataRegistry()); + return new ExIntegerWidget(activity, new QuestionDetails(formEntryPrompt), new FakeWaitingForDataRegistry(), stringRequester); } @NonNull diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/ExStringWidgetTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/ExStringWidgetTest.java index 902462286ff..e022cd9c3c2 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/ExStringWidgetTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/ExStringWidgetTest.java @@ -5,9 +5,11 @@ import net.bytebuddy.utility.RandomString; import org.javarosa.core.model.data.StringData; +import org.mockito.Mock; import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.android.widgets.base.GeneralExStringWidgetTest; import org.odk.collect.android.widgets.support.FakeWaitingForDataRegistry; +import org.odk.collect.android.widgets.utilities.StringRequester; import static org.mockito.Mockito.when; @@ -17,10 +19,13 @@ public class ExStringWidgetTest extends GeneralExStringWidgetTest { + @Mock + StringRequester stringRequester; + @NonNull @Override public ExStringWidget createWidget() { - return new ExStringWidget(activity, new QuestionDetails(formEntryPrompt), new FakeWaitingForDataRegistry()); + return new ExStringWidget(activity, new QuestionDetails(formEntryPrompt), new FakeWaitingForDataRegistry(), stringRequester); } @NonNull diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/ExVideoWidgetTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/ExVideoWidgetTest.java index ac971e68f2d..b4ea8fbbb9b 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/ExVideoWidgetTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/ExVideoWidgetTest.java @@ -1,23 +1,20 @@ package org.odk.collect.android.widgets; -import android.content.Intent; import android.view.View; import androidx.annotation.NonNull; import org.javarosa.core.model.data.StringData; -import org.javarosa.xpath.parser.XPathSyntaxException; import org.junit.Before; import org.junit.Test; import org.mockito.Mock; -import org.odk.collect.android.exception.ExternalParamsException; import org.odk.collect.android.formentry.questions.QuestionDetails; -import org.odk.collect.android.utilities.ActivityAvailability; -import org.odk.collect.android.utilities.ExternalAppIntentProvider; +import org.odk.collect.android.utilities.ApplicationConstants; import org.odk.collect.android.utilities.MediaUtils; import org.odk.collect.android.widgets.base.FileWidgetTest; import org.odk.collect.android.widgets.support.FakeQuestionMediaManager; import org.odk.collect.android.widgets.support.FakeWaitingForDataRegistry; +import org.odk.collect.android.widgets.utilities.FileRequester; import org.robolectric.shadows.ShadowToast; import java.io.File; @@ -27,19 +24,17 @@ import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.odk.collect.android.preferences.keys.ProjectKeys.KEY_FONT_SIZE; import static org.odk.collect.android.utilities.QuestionFontSizeUtils.DEFAULT_FONT_SIZE; -import static org.robolectric.Shadows.shadowOf; public class ExVideoWidgetTest extends FileWidgetTest { @Mock MediaUtils mediaUtils; @Mock - ExternalAppIntentProvider externalAppIntentProvider; + FileRequester fileRequester; @Before public void setup() { @@ -61,7 +56,7 @@ public StringData getNextAnswer() { @Override public ExVideoWidget createWidget() { return new ExVideoWidget(activity, new QuestionDetails(formEntryPrompt, readOnlyOverride), - new FakeQuestionMediaManager(), new FakeWaitingForDataRegistry(), mediaUtils, externalAppIntentProvider, new ActivityAvailability(activity)); + new FakeQuestionMediaManager(), new FakeWaitingForDataRegistry(), mediaUtils, fileRequester); } @Test @@ -115,11 +110,9 @@ public void whenClearAnswerCall_shouldPlayButtonBecomeDisabled() { } @Test - public void whenClickingOnChooseButton_externalAppShouldBeLaunchedByIntent() throws ExternalParamsException, XPathSyntaxException { - Intent intent = mock(Intent.class); - when(externalAppIntentProvider.getIntentToRunExternalApp(any(), any(), any(), any())).thenReturn(intent); + public void whenCaptureVideoButtonClicked_exWidgetIntentLauncherShouldBeStarted() { getWidget().binding.captureVideoButton.performClick(); - assertThat(shadowOf(activity).getNextStartedActivity(), is(intent)); + verify(fileRequester).launch(activity, ApplicationConstants.RequestCodes.EX_VIDEO_CHOOSER, formEntryPrompt); } @Test diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/OSMWidgetTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/OSMWidgetTest.java index 2ce98c19d5b..87dbb2d7ea4 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/OSMWidgetTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/OSMWidgetTest.java @@ -16,15 +16,16 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.ArgumentMatchers; import org.odk.collect.android.R; import org.odk.collect.android.formentry.questions.QuestionDetails; import org.odk.collect.android.javarosawrapper.FormController; import org.odk.collect.android.listeners.WidgetValueChangedListener; import org.odk.collect.android.support.WidgetTestActivity; -import org.odk.collect.android.utilities.ActivityAvailability; import org.odk.collect.android.utilities.ApplicationConstants; import org.odk.collect.android.widgets.support.FakeWaitingForDataRegistry; +import org.odk.collect.androidshared.system.IntentLauncherImpl; +import org.odk.collect.testshared.ErrorIntentLauncher; +import org.odk.collect.androidshared.system.IntentLauncher; import org.robolectric.shadows.ShadowActivity; import java.io.File; @@ -55,7 +56,7 @@ public class OSMWidgetTest { private WidgetTestActivity widgetActivity; private ShadowActivity shadowActivity; - private ActivityAvailability activityAvailability; + private IntentLauncher intentLauncher; private FormController formController; private QuestionDef questionDef; @@ -64,12 +65,11 @@ public void setUp() { widgetActivity = widgetTestActivity(); shadowActivity = shadowOf(widgetActivity); - activityAvailability = mock(ActivityAvailability.class); + intentLauncher = IntentLauncherImpl.INSTANCE; formController = mock(FormController.class); FormDef formDef = mock(FormDef.class); questionDef = mock(QuestionDef.class); - when(activityAvailability.isActivityAvailable(ArgumentMatchers.any())).thenReturn(true); when(formController.getInstanceFile()).thenReturn(instancePath); when(formController.getMediaFolder()).thenReturn(mediaFolder); when(formController.getSubmissionMetadata()).thenReturn( @@ -189,7 +189,8 @@ public void setData_callsValueChangeListeners() { @Test public void clickingButton_whenActivityIsNotAvailable_showsErrorTextView() { - when(activityAvailability.isActivityAvailable(ArgumentMatchers.any())).thenReturn(false); + intentLauncher = new ErrorIntentLauncher(); + OSMWidget widget = createWidget(promptWithAnswer(null)); widget.binding.launchOpenMapKitButton.performClick(); @@ -197,12 +198,12 @@ public void clickingButton_whenActivityIsNotAvailable_showsErrorTextView() { } @Test - public void clickingButton_whenActivityIsNotAvailable_DoesNotLAunchAnyIntentAndCancelsWaitingForData() { - when(activityAvailability.isActivityAvailable(ArgumentMatchers.any())).thenReturn(false); + public void clickingButton_whenActivityIsNotAvailable_CancelsWaitingForData() { + intentLauncher = new ErrorIntentLauncher(); + OSMWidget widget = createWidget(promptWithAnswer(null)); widget.binding.launchOpenMapKitButton.performClick(); - assertThat(shadowActivity.getNextStartedActivity(), nullValue()); assertThat(fakeWaitingForDataRegistry.waiting.isEmpty(), is(true)); } @@ -237,7 +238,7 @@ private OSMWidget createWidget(FormEntryPrompt prompt) { when(questionDef.getOsmTags()).thenReturn(ImmutableList.of()); return new OSMWidget(widgetActivity, new QuestionDetails(prompt), - fakeWaitingForDataRegistry, activityAvailability, formController); + fakeWaitingForDataRegistry, intentLauncher, formController); } private void assertIntentExtrasEquals(String fileName) { diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/WidgetFactoryTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/WidgetFactoryTest.java index 1efb243ad45..ec26db208c8 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/WidgetFactoryTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/WidgetFactoryTest.java @@ -35,7 +35,7 @@ public class WidgetFactoryTest { public void setup() { Activity activity = CollectHelpers.buildThemedActivity(WidgetTestActivity.class).get(); - widgetFactory = new WidgetFactory(activity, false, false, null, null, null, null, null, null, null, null); + widgetFactory = new WidgetFactory(activity, false, false, null, null, null, null, null, null, null, null, null); } @Test diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/ExternalAppRecordingRequesterTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/ExternalAppRecordingRequesterTest.java deleted file mode 100644 index b6772b085bc..00000000000 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/ExternalAppRecordingRequesterTest.java +++ /dev/null @@ -1,93 +0,0 @@ -package org.odk.collect.android.widgets.utilities; - -import android.app.Activity; -import android.content.Intent; -import android.provider.MediaStore; - -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.javarosa.form.api.FormEntryPrompt; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.odk.collect.android.R; -import org.odk.collect.android.fakes.FakePermissionsProvider; -import org.odk.collect.android.formentry.FormEntryViewModel; -import org.odk.collect.android.utilities.ActivityAvailability; -import org.odk.collect.android.utilities.ApplicationConstants; -import org.odk.collect.android.widgets.support.FakeWaitingForDataRegistry; -import org.robolectric.Robolectric; -import org.robolectric.shadows.ShadowActivity; -import org.robolectric.shadows.ShadowToast; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.nullValue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; -import static org.odk.collect.android.widgets.support.QuestionWidgetHelpers.promptWithAnswer; -import static org.robolectric.Shadows.shadowOf; - -@RunWith(AndroidJUnit4.class) -public class ExternalAppRecordingRequesterTest { - - private final ActivityAvailability activityAvailability = mock(ActivityAvailability.class); - private final FakePermissionsProvider permissionsProvider = new FakePermissionsProvider(); - private final FakeWaitingForDataRegistry waitingForDataRegistry = new FakeWaitingForDataRegistry(); - - private Activity activity; - private ExternalAppRecordingRequester requester; - - @Before - public void setup() { - activity = Robolectric.buildActivity(Activity.class).get(); - requester = new ExternalAppRecordingRequester(activity, activityAvailability, waitingForDataRegistry, permissionsProvider, mock(FormEntryViewModel.class)); - } - - @Test - public void requestRecording_whenIntentIsNotAvailable_doesNotStartAnyIntentAndCancelsWaitingForData() { - when(activityAvailability.isActivityAvailable(any())).thenReturn(false); - permissionsProvider.setPermissionGranted(true); - - requester.requestRecording(promptWithAnswer(null)); - - Intent startedActivity = shadowOf(activity).getNextStartedActivity(); - String toastMessage = ShadowToast.getTextOfLatestToast(); - assertThat(startedActivity, nullValue()); - assertThat(waitingForDataRegistry.waiting.isEmpty(), is(true)); - assertThat(toastMessage, equalTo(activity.getString(R.string.activity_not_found, activity.getString(R.string.capture_audio)))); - } - - @Test - public void requestRecording_whenPermissionIsNotGranted_doesNotStartAnyIntentAndCancelsWaitingForData() { - when(activityAvailability.isActivityAvailable(any())).thenReturn(true); - permissionsProvider.setPermissionGranted(false); - - requester.requestRecording(promptWithAnswer(null)); - - Intent startedActivity = shadowOf(activity).getNextStartedActivity(); - assertThat(startedActivity, nullValue()); - assertThat(waitingForDataRegistry.waiting.isEmpty(), equalTo(true)); - } - - @Test - public void requestRecording_whenPermissionIsGranted_startsRecordSoundIntentAndSetsWidgetWaitingForData() { - when(activityAvailability.isActivityAvailable(any())).thenReturn(true); - permissionsProvider.setPermissionGranted(true); - - FormEntryPrompt prompt = promptWithAnswer(null); - requester.requestRecording(prompt); - - Intent startedActivity = shadowOf(activity).getNextStartedActivity(); - assertThat(startedActivity.getAction(), equalTo(MediaStore.Audio.Media.RECORD_SOUND_ACTION)); - assertThat(startedActivity.getStringExtra(MediaStore.EXTRA_OUTPUT), equalTo(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI - .toString())); - - ShadowActivity.IntentForResult intentForResult = shadowOf(activity).getNextStartedActivityForResult(); - assertThat(intentForResult.requestCode, equalTo(ApplicationConstants.RequestCodes.AUDIO_CAPTURE)); - - assertThat(waitingForDataRegistry.waiting.contains(prompt.getIndex()), equalTo(true)); - } -} diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/ExternalAppRecordingRequesterTest.kt b/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/ExternalAppRecordingRequesterTest.kt new file mode 100644 index 00000000000..b507ea5ff53 --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/ExternalAppRecordingRequesterTest.kt @@ -0,0 +1,110 @@ +package org.odk.collect.android.widgets.utilities + +import android.app.Activity +import android.provider.MediaStore +import androidx.test.espresso.matcher.ViewMatchers.assertThat +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.CoreMatchers.nullValue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.odk.collect.android.R +import org.odk.collect.android.fakes.FakePermissionsProvider +import org.odk.collect.android.formentry.FormEntryViewModel +import org.odk.collect.android.utilities.ApplicationConstants +import org.odk.collect.android.widgets.support.FakeWaitingForDataRegistry +import org.odk.collect.android.widgets.support.QuestionWidgetHelpers +import org.odk.collect.androidshared.system.IntentLauncher +import org.odk.collect.androidshared.system.IntentLauncherImpl +import org.odk.collect.testshared.ErrorIntentLauncher +import org.robolectric.Robolectric +import org.robolectric.Shadows +import org.robolectric.shadows.ShadowToast + +@RunWith(AndroidJUnit4::class) +class ExternalAppRecordingRequesterTest { + private lateinit var intentLauncher: IntentLauncher + private val permissionsProvider = FakePermissionsProvider() + private val waitingForDataRegistry = FakeWaitingForDataRegistry() + private lateinit var activity: Activity + private lateinit var requester: ExternalAppRecordingRequester + + @Before + fun setup() { + activity = Robolectric.buildActivity(Activity::class.java).get() + } + + private fun setupRequester() { + requester = ExternalAppRecordingRequester( + activity, intentLauncher, waitingForDataRegistry, permissionsProvider, + mock( + FormEntryViewModel::class.java + ) + ) + } + + @Test + fun requestRecording_whenIntentIsNotAvailable_doesNotStartAnyIntentAndCancelsWaitingForData() { + intentLauncher = ErrorIntentLauncher() + setupRequester() + permissionsProvider.setPermissionGranted(true) + requester.requestRecording(QuestionWidgetHelpers.promptWithAnswer(null)) + val startedActivity = Shadows.shadowOf(activity).nextStartedActivity + val toastMessage = ShadowToast.getTextOfLatestToast() + assertThat(startedActivity, nullValue()) + assertThat(waitingForDataRegistry.waiting.isEmpty(), `is`(true)) + assertThat( + toastMessage, + equalTo( + activity.getString( + R.string.activity_not_found, + activity.getString(R.string.capture_audio) + ) + ) + ) + } + + @Test + fun requestRecording_whenPermissionIsNotGranted_doesNotStartAnyIntentAndCancelsWaitingForData() { + intentLauncher = IntentLauncherImpl + setupRequester() + permissionsProvider.setPermissionGranted(false) + requester.requestRecording(QuestionWidgetHelpers.promptWithAnswer(null)) + val startedActivity = Shadows.shadowOf(activity).nextStartedActivity + assertThat(startedActivity, nullValue()) + assertThat(waitingForDataRegistry.waiting.isEmpty(), equalTo(true)) + } + + @Test + fun requestRecording_whenPermissionIsGranted_startsRecordSoundIntentAndSetsWidgetWaitingForData() { + intentLauncher = IntentLauncherImpl + setupRequester() + permissionsProvider.setPermissionGranted(true) + val prompt = QuestionWidgetHelpers.promptWithAnswer(null) + requester.requestRecording(prompt) + val startedActivity = Shadows.shadowOf(activity).nextStartedActivity + assertThat( + startedActivity.action, + equalTo(MediaStore.Audio.Media.RECORD_SOUND_ACTION) + ) + assertThat( + startedActivity.getStringExtra(MediaStore.EXTRA_OUTPUT), + equalTo( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + .toString() + ) + ) + val intentForResult = Shadows.shadowOf(activity).nextStartedActivityForResult + assertThat( + intentForResult.requestCode, + equalTo(ApplicationConstants.RequestCodes.AUDIO_CAPTURE) + ) + assertThat( + waitingForDataRegistry.waiting.contains(prompt.index), + equalTo(true) + ) + } +} diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/FileRequesterImplTest.kt b/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/FileRequesterImplTest.kt new file mode 100644 index 00000000000..e17baea9293 --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/FileRequesterImplTest.kt @@ -0,0 +1,222 @@ +package org.odk.collect.android.widgets.utilities + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.activity.result.ActivityResultLauncher +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.MatcherAssert.assertThat +import org.javarosa.form.api.FormEntryPrompt +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.whenever +import org.odk.collect.android.R +import org.odk.collect.android.utilities.ExternalAppIntentProvider +import org.odk.collect.androidshared.system.IntentLauncher +import org.robolectric.Robolectric +import org.robolectric.shadows.ShadowToast +import java.lang.Exception + +@RunWith(AndroidJUnit4::class) +class FileRequesterImplTest { + private val intentLauncher = spy(FakeIntentLauncher()) + private val requestCode = 99 + private val externalAppIntentProvider = mock() + private val formEntryPrompt = mock() + private val availableIntent = Intent() + private val unAvailableIntent = Intent().also { + it.putExtra("fail", "fail") + } + + private lateinit var activity: Activity + private lateinit var fileRequester: FileRequester + + @Before + fun setup() { + activity = Robolectric.buildActivity(Activity::class.java).get() + fileRequester = FileRequesterImpl(intentLauncher, externalAppIntentProvider) + } + + @Test + fun `When exception is thrown by ExternalAppIntentProvider#getIntentToRunExternalApp a toast should be displayed`() { + whenever(externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt)).then { + throw Exception("exception") + } + fileRequester.launch( + activity, + requestCode, + formEntryPrompt + ) + val toastText = ShadowToast.getTextOfLatestToast() + assertThat(toastText, `is`("exception")) + } + + @Test + fun `When exception is thrown by ExternalAppIntentProvider#getIntentToRunExternalAppWithoutDefaultCategory a toast should be displayed`() { + whenever( + externalAppIntentProvider.getIntentToRunExternalAppWithoutDefaultCategory( + formEntryPrompt, + activity.packageManager + ) + ).then { + throw Exception("exception") + } + fileRequester.launch( + activity, + requestCode, + formEntryPrompt + ) + val toastText = ShadowToast.getTextOfLatestToast() + assertThat(toastText, `is`("exception")) + } + + @Test + fun `When error is thrown by ExternalAppIntentProvider#getIntentToRunExternalApp a toast should be displayed`() { + whenever(externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt)).then { + throw Exception("error") + } + fileRequester.launch( + activity, + requestCode, + formEntryPrompt + ) + val toastText = ShadowToast.getTextOfLatestToast() + assertThat(toastText, `is`("error")) + } + + @Test + fun `When error is thrown by ExternalAppIntentProvider#getIntentToRunExternalAppWithoutDefaultCategory a toast should be displayed`() { + whenever( + externalAppIntentProvider.getIntentToRunExternalAppWithoutDefaultCategory( + formEntryPrompt, + activity.packageManager + ) + ).then { + throw Exception("error") + } + fileRequester.launch( + activity, + requestCode, + formEntryPrompt + ) + val toastText = ShadowToast.getTextOfLatestToast() + assertThat(toastText, `is`("error")) + } + + @Test + fun `If the first attempt to start activity succeeded nothing else should happen`() { + whenever(externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt)).thenReturn( + availableIntent + ) + + fileRequester.launch( + activity, + requestCode, + formEntryPrompt + ) + assertThat(intentLauncher.callCounter, `is`(1)) + assertThat(intentLauncher.errorCounter, `is`(0)) + } + + @Test + fun `If the first attempt to start activity failed there should be another one`() { + whenever(externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt)).thenReturn( + unAvailableIntent + ) + whenever( + externalAppIntentProvider.getIntentToRunExternalAppWithoutDefaultCategory( + formEntryPrompt, + activity.packageManager + ) + ).thenReturn(availableIntent) + + fileRequester.launch( + activity, + requestCode, + formEntryPrompt + ) + + assertThat(intentLauncher.callCounter, `is`(2)) + assertThat(intentLauncher.errorCounter, `is`(1)) + } + + @Test + fun `If both attempts to start activity failed a toast should be displayed`() { + whenever(externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt)).thenReturn( + unAvailableIntent + ) + whenever( + externalAppIntentProvider.getIntentToRunExternalAppWithoutDefaultCategory( + formEntryPrompt, + activity.packageManager + ) + ).thenReturn(unAvailableIntent) + + fileRequester.launch( + activity, + requestCode, + formEntryPrompt + ) + + assertThat(intentLauncher.callCounter, `is`(2)) + assertThat(intentLauncher.errorCounter, `is`(2)) + + val toastText = ShadowToast.getTextOfLatestToast() + assertThat(toastText, `is`(activity.getString(R.string.no_app))) + } + + @Test + fun `If both attempts to start activity failed a toast with custom message should be displayed if it is set`() { + whenever(formEntryPrompt.getSpecialFormQuestionText("noAppErrorString")).thenReturn("Custom message") + whenever(externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt)).thenReturn( + unAvailableIntent + ) + whenever( + externalAppIntentProvider.getIntentToRunExternalAppWithoutDefaultCategory( + formEntryPrompt, + activity.packageManager + ) + ).thenReturn(unAvailableIntent) + + fileRequester.launch( + activity, + requestCode, + formEntryPrompt + ) + + val toastText = ShadowToast.getTextOfLatestToast() + assertThat(toastText, `is`("Custom message")) + } + + class FakeIntentLauncher : IntentLauncher { + var callCounter = 0 + var errorCounter = 0 + + override fun launch(context: Context, intent: Intent?, onError: () -> Unit) { + } + + override fun launchForResult( + activity: Activity, + intent: Intent?, + requestCode: Int, + onError: () -> Unit + ) { + callCounter++ + if (intent!!.hasExtra("fail")) { + errorCounter++ + onError() + } + } + + override fun launchForResult( + resultLauncher: ActivityResultLauncher, + intent: Intent?, + onError: () -> Unit + ) { + } + } +} diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/GetContentAudioFileRequesterTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/GetContentAudioFileRequesterTest.java deleted file mode 100644 index 1b06f89f1fd..00000000000 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/GetContentAudioFileRequesterTest.java +++ /dev/null @@ -1,74 +0,0 @@ -package org.odk.collect.android.widgets.utilities; - -import android.app.Activity; -import android.content.Intent; - -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.javarosa.form.api.FormEntryPrompt; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.odk.collect.android.R; -import org.odk.collect.android.formentry.FormEntryViewModel; -import org.odk.collect.android.utilities.ActivityAvailability; -import org.odk.collect.android.utilities.ApplicationConstants; -import org.odk.collect.android.widgets.support.FakeWaitingForDataRegistry; -import org.robolectric.Robolectric; -import org.robolectric.shadows.ShadowActivity; -import org.robolectric.shadows.ShadowToast; - -import static android.content.Intent.ACTION_GET_CONTENT; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.nullValue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; -import static org.odk.collect.android.widgets.support.QuestionWidgetHelpers.promptWithAnswer; -import static org.robolectric.Shadows.shadowOf; - -@RunWith(AndroidJUnit4.class) -public class GetContentAudioFileRequesterTest { - - private final ActivityAvailability activityAvailability = mock(ActivityAvailability.class); - private final FakeWaitingForDataRegistry waitingForDataRegistry = new FakeWaitingForDataRegistry(); - - private Activity activity; - private GetContentAudioFileRequester requester; - - @Before - public void setup() { - activity = Robolectric.buildActivity(Activity.class).get(); - requester = new GetContentAudioFileRequester(activity, activityAvailability, waitingForDataRegistry, mock(FormEntryViewModel.class)); - } - - @Test - public void requestFile_whenIntentIsNotAvailable_doesNotStartAnyIntentAndCancelsWaitingForData() { - when(activityAvailability.isActivityAvailable(any())).thenReturn(false); - - requester.requestFile(promptWithAnswer(null)); - Intent startedActivity = shadowOf(activity).getNextStartedActivity(); - String toastMessage = ShadowToast.getTextOfLatestToast(); - - assertThat(startedActivity, nullValue()); - assertThat(waitingForDataRegistry.waiting.isEmpty(), equalTo(true)); - assertThat(toastMessage, equalTo(activity.getString(R.string.activity_not_found, activity.getString(R.string.choose_sound)))); - } - - @Test - public void requestFile_startsChooseAudioFileActivityAndSetsWidgetWaitingForData() { - when(activityAvailability.isActivityAvailable(any())).thenReturn(true); - - FormEntryPrompt prompt = promptWithAnswer(null); - requester.requestFile(prompt); - Intent startedActivity = shadowOf(activity).getNextStartedActivity(); - assertThat(startedActivity.getAction(), equalTo(ACTION_GET_CONTENT)); - assertThat(startedActivity.getType(), equalTo("audio/*")); - - ShadowActivity.IntentForResult intentForResult = shadowOf(activity).getNextStartedActivityForResult(); - assertThat(intentForResult.requestCode, equalTo(ApplicationConstants.RequestCodes.AUDIO_CHOOSER)); - - assertThat(waitingForDataRegistry.waiting.contains(prompt.getIndex()), equalTo(true)); - } -} diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/GetContentAudioFileRequesterTest.kt b/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/GetContentAudioFileRequesterTest.kt new file mode 100644 index 00000000000..b1ef03625de --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/GetContentAudioFileRequesterTest.kt @@ -0,0 +1,88 @@ +package org.odk.collect.android.widgets.utilities + +import android.app.Activity +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.CoreMatchers.equalTo +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.Mockito.mock +import org.odk.collect.android.R +import org.odk.collect.android.formentry.FormEntryViewModel +import org.odk.collect.android.utilities.ApplicationConstants +import org.odk.collect.android.widgets.support.FakeWaitingForDataRegistry +import org.odk.collect.android.widgets.support.QuestionWidgetHelpers +import org.odk.collect.androidshared.system.IntentLauncher +import org.odk.collect.androidshared.system.IntentLauncherImpl +import org.odk.collect.testshared.ErrorIntentLauncher +import org.robolectric.Robolectric +import org.robolectric.Shadows +import org.robolectric.shadows.ShadowToast + +@RunWith(AndroidJUnit4::class) +class GetContentAudioFileRequesterTest { + private lateinit var intentLauncher: IntentLauncher + private val waitingForDataRegistry = FakeWaitingForDataRegistry() + private lateinit var activity: Activity + private lateinit var requester: GetContentAudioFileRequester + + @Before + fun setup() { + activity = Robolectric.buildActivity(Activity::class.java).get() + } + + private fun setupRequester() { + requester = GetContentAudioFileRequester( + activity, intentLauncher, waitingForDataRegistry, + mock( + FormEntryViewModel::class.java + ) + ) + } + + @Test + fun requestFile_whenIntentIsNotAvailable_doesNotStartAnyIntentAndCancelsWaitingForData() { + intentLauncher = ErrorIntentLauncher() + setupRequester() + requester.requestFile(QuestionWidgetHelpers.promptWithAnswer(null)) + val startedActivity = Shadows.shadowOf(activity).nextStartedActivity + val toastMessage = ShadowToast.getTextOfLatestToast() + assertThat(startedActivity, nullValue()) + assertThat(waitingForDataRegistry.waiting.isEmpty(), equalTo(true)) + assertThat( + toastMessage, + equalTo( + activity.getString( + R.string.activity_not_found, + activity.getString(R.string.choose_sound) + ) + ) + ) + } + + @Test + fun requestFile_startsChooseAudioFileActivityAndSetsWidgetWaitingForData() { + intentLauncher = IntentLauncherImpl + setupRequester() + val prompt = QuestionWidgetHelpers.promptWithAnswer(null) + requester.requestFile(prompt) + val startedActivity = Shadows.shadowOf(activity).nextStartedActivity + assertThat( + startedActivity.action, + equalTo(Intent.ACTION_GET_CONTENT) + ) + assertThat(startedActivity.type, equalTo("audio/*")) + val intentForResult = Shadows.shadowOf(activity).nextStartedActivityForResult + assertThat( + intentForResult.requestCode, + equalTo(ApplicationConstants.RequestCodes.AUDIO_CHOOSER) + ) + assertThat( + waitingForDataRegistry.waiting.contains(prompt.index), + equalTo(true) + ) + } +} diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/StringRequesterImplTest.kt b/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/StringRequesterImplTest.kt new file mode 100644 index 00000000000..b907db5272d --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/StringRequesterImplTest.kt @@ -0,0 +1,371 @@ +package org.odk.collect.android.widgets.utilities + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.activity.result.ActivityResultLauncher +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.MatcherAssert.assertThat +import org.javarosa.form.api.FormEntryPrompt +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.odk.collect.android.R +import org.odk.collect.android.utilities.ExternalAppIntentProvider +import org.odk.collect.androidshared.system.IntentLauncher +import org.robolectric.Robolectric +import java.lang.Exception + +@RunWith(AndroidJUnit4::class) +class StringRequesterImplTest { + private val intentLauncher = spy(FakeIntentLauncher()) + private val requestCode = 99 + private val externalAppIntentProvider = mock() + private val formEntryPrompt = mock() + private val onError: (String) -> Unit = mock() + private val availableActionSendToIntent = Intent(Intent.ACTION_SENDTO) + private val unAvailableActionSendToIntent = Intent(Intent.ACTION_SENDTO).also { + it.putExtra("fail", "fail") + } + private val availableIntent = Intent() + private val unAvailableIntent = Intent().also { + it.putExtra("fail", "fail") + } + + private lateinit var activity: Activity + private lateinit var stringRequester: StringRequester + + @Before + fun setup() { + activity = Robolectric.buildActivity(Activity::class.java).get() + stringRequester = StringRequesterImpl(intentLauncher, externalAppIntentProvider) + } + + @Test + fun `When exception is thrown by ExternalAppIntentProvider#getIntentToRunExternalApp onError should be called`() { + whenever(externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt)).then { + throw Exception("exception") + } + stringRequester.launch( + activity, + requestCode, + formEntryPrompt, + null, + onError + ) + + verify(onError).invoke("exception") + } + + @Test + fun `When exception is thrown by ExternalAppIntentProvider#getIntentToRunExternalAppWithoutDefaultCategory onError should be called`() { + whenever( + externalAppIntentProvider.getIntentToRunExternalAppWithoutDefaultCategory( + formEntryPrompt, + activity.packageManager + ) + ).then { + throw Exception("exception") + } + stringRequester.launch( + activity, + requestCode, + formEntryPrompt, + null, + onError + ) + + verify(onError).invoke("exception") + } + + @Test + fun `When error is thrown by ExternalAppIntentProvider#getIntentToRunExternalApp onError should be called`() { + whenever(externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt)).then { + throw Exception("error") + } + stringRequester.launch( + activity, + requestCode, + formEntryPrompt, + null, + onError + ) + + verify(onError).invoke("error") + } + + @Test + fun `When error is thrown by ExternalAppIntentProvider#getIntentToRunExternalAppWithoutDefaultCategory onError should be called`() { + whenever( + externalAppIntentProvider.getIntentToRunExternalAppWithoutDefaultCategory( + formEntryPrompt, + activity.packageManager + ) + ).then { + throw Exception("error") + } + stringRequester.launch( + activity, + requestCode, + formEntryPrompt, + null, + onError + ) + + verify(onError).invoke("error") + } + + @Test + fun `If the first attempt to start activity succeeded nothing else should happen`() { + whenever(externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt)).thenReturn( + availableIntent + ) + + stringRequester.launch( + activity, + requestCode, + formEntryPrompt, + null, + onError + ) + + assertThat(intentLauncher.launchCallCounter, `is`(0)) + assertThat(intentLauncher.launchForResultCallCounter, `is`(1)) + assertThat(intentLauncher.errorCounter, `is`(0)) + } + + @Test + fun `If the first attempt to start activity succeeded for intent with ACTION_SENDTO nothing else should happen`() { + whenever(externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt)).thenReturn( + availableActionSendToIntent + ) + + stringRequester.launch( + activity, + requestCode, + formEntryPrompt, + null, + onError + ) + + assertThat(intentLauncher.launchCallCounter, `is`(1)) + assertThat(intentLauncher.launchForResultCallCounter, `is`(0)) + assertThat(intentLauncher.errorCounter, `is`(0)) + } + + @Test + fun `If the first attempt to start activity failed there should be another one`() { + whenever(externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt)).thenReturn( + unAvailableIntent + ) + whenever( + externalAppIntentProvider.getIntentToRunExternalAppWithoutDefaultCategory( + formEntryPrompt, + activity.packageManager + ) + ).thenReturn(availableIntent) + + stringRequester.launch( + activity, + requestCode, + formEntryPrompt, + null, + onError + ) + + assertThat(intentLauncher.launchCallCounter, `is`(0)) + assertThat(intentLauncher.launchForResultCallCounter, `is`(2)) + assertThat(intentLauncher.errorCounter, `is`(1)) + } + + @Test + fun `If the first attempt to start activity failed for intent with ACTION_SENDTO there should be another one`() { + whenever(externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt)).thenReturn( + unAvailableActionSendToIntent + ) + whenever( + externalAppIntentProvider.getIntentToRunExternalAppWithoutDefaultCategory( + formEntryPrompt, + activity.packageManager + ) + ).thenReturn(availableIntent) + + stringRequester.launch( + activity, + requestCode, + formEntryPrompt, + null, + onError + ) + + assertThat(intentLauncher.launchCallCounter, `is`(2)) + assertThat(intentLauncher.launchForResultCallCounter, `is`(0)) + assertThat(intentLauncher.errorCounter, `is`(1)) + } + + @Test + fun `If both attempts to start activity failed onError should be called`() { + whenever(externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt)).thenReturn( + unAvailableIntent + ) + whenever( + externalAppIntentProvider.getIntentToRunExternalAppWithoutDefaultCategory( + formEntryPrompt, + activity.packageManager + ) + ).thenReturn(unAvailableIntent) + + stringRequester.launch( + activity, + requestCode, + formEntryPrompt, + null, + onError + ) + + assertThat(intentLauncher.launchCallCounter, `is`(0)) + assertThat(intentLauncher.launchForResultCallCounter, `is`(2)) + assertThat(intentLauncher.errorCounter, `is`(2)) + + verify(onError).invoke(activity.getString(R.string.no_app)) + } + + @Test + fun `If both attempts to start activity failed for intent with ACTION_SENDTO onError should be called`() { + whenever(externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt)).thenReturn( + unAvailableActionSendToIntent + ) + whenever( + externalAppIntentProvider.getIntentToRunExternalAppWithoutDefaultCategory( + formEntryPrompt, + activity.packageManager + ) + ).thenReturn(unAvailableActionSendToIntent) + + stringRequester.launch( + activity, + requestCode, + formEntryPrompt, + null, + onError + ) + + assertThat(intentLauncher.launchCallCounter, `is`(2)) + assertThat(intentLauncher.launchForResultCallCounter, `is`(0)) + assertThat(intentLauncher.errorCounter, `is`(2)) + + verify(onError).invoke(activity.getString(R.string.no_app)) + } + + @Test + fun `If both attempts to start activity failed onError should be called with custom message if it is set`() { + whenever(formEntryPrompt.getSpecialFormQuestionText("noAppErrorString")).thenReturn("Custom message") + whenever(externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt)).thenReturn( + unAvailableIntent + ) + whenever( + externalAppIntentProvider.getIntentToRunExternalAppWithoutDefaultCategory( + formEntryPrompt, + activity.packageManager + ) + ).thenReturn(unAvailableIntent) + + stringRequester.launch( + activity, + requestCode, + formEntryPrompt, + null, + onError + ) + + verify(onError).invoke("Custom message") + } + + @Test + fun `If both attempts to start activity failed for intent with ACTION_SENDTO onError should be called with custom message if it is set`() { + whenever(formEntryPrompt.getSpecialFormQuestionText("noAppErrorString")).thenReturn("Custom message") + whenever(externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt)).thenReturn( + unAvailableActionSendToIntent + ) + whenever( + externalAppIntentProvider.getIntentToRunExternalAppWithoutDefaultCategory( + formEntryPrompt, + activity.packageManager + ) + ).thenReturn(unAvailableActionSendToIntent) + + stringRequester.launch( + activity, + requestCode, + formEntryPrompt, + null, + onError + ) + + verify(onError).invoke("Custom message") + } + + @Test + fun `Value should be added to intent`() { + whenever(formEntryPrompt.getSpecialFormQuestionText("noAppErrorString")).thenReturn("Custom message") + whenever(externalAppIntentProvider.getIntentToRunExternalApp(formEntryPrompt)).thenReturn( + unAvailableIntent + ) + whenever( + externalAppIntentProvider.getIntentToRunExternalAppWithoutDefaultCategory( + formEntryPrompt, + activity.packageManager + ) + ).thenReturn(availableIntent) + + stringRequester.launch( + activity, + requestCode, + formEntryPrompt, + "123", + onError + ) + + assertThat(unAvailableIntent.getSerializableExtra("value"), `is`("123")) + assertThat(availableIntent.getSerializableExtra("value"), `is`("123")) + } + + class FakeIntentLauncher : IntentLauncher { + var launchCallCounter = 0 + var launchForResultCallCounter = 0 + var errorCounter = 0 + + override fun launch(context: Context, intent: Intent?, onError: () -> Unit) { + launchCallCounter++ + if (intent!!.hasExtra("fail")) { + errorCounter++ + onError() + } + } + + override fun launchForResult( + activity: Activity, + intent: Intent?, + requestCode: Int, + onError: () -> Unit + ) { + launchForResultCallCounter++ + if (intent!!.hasExtra("fail")) { + errorCounter++ + onError() + } + } + + override fun launchForResult( + resultLauncher: ActivityResultLauncher, + intent: Intent?, + onError: () -> Unit + ) { + } + } +} diff --git a/testshared/build.gradle b/testshared/build.gradle index 5045cd6c0d8..ffe3e801ddc 100644 --- a/testshared/build.gradle +++ b/testshared/build.gradle @@ -36,6 +36,7 @@ dependencies { implementation fileTree(dir: "libs", include: ["*.jar"]) implementation project(path: ':async') implementation project(path: ':shared') + implementation project(path: ':androidshared') implementation Dependencies.androidx_recyclerview implementation Dependencies.kotlin_stdlib implementation Dependencies.androidx_core_ktx diff --git a/testshared/src/main/java/org/odk/collect/testshared/ErrorIntentLauncher.kt b/testshared/src/main/java/org/odk/collect/testshared/ErrorIntentLauncher.kt new file mode 100644 index 00000000000..d2b976421e6 --- /dev/null +++ b/testshared/src/main/java/org/odk/collect/testshared/ErrorIntentLauncher.kt @@ -0,0 +1,30 @@ +package org.odk.collect.testshared + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.activity.result.ActivityResultLauncher +import org.odk.collect.androidshared.system.IntentLauncher + +class ErrorIntentLauncher : IntentLauncher { + override fun launch(context: Context, intent: Intent?, onError: () -> Unit) { + onError() + } + + override fun launchForResult( + activity: Activity, + intent: Intent?, + requestCode: Int, + onError: () -> Unit + ) { + onError() + } + + override fun launchForResult( + resultLauncher: ActivityResultLauncher, + intent: Intent?, + onError: () -> Unit + ) { + onError() + } +}