diff --git a/RELEASES.md b/RELEASES.md index 2a0587eeeb..8b8b55a4e7 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -133,6 +133,9 @@ we would like to communicate to QA as part of the release testing - **Connect app launch:** - From the Connect opportunities list, launch an installed learn or delivery app: confirm it opens to the app home behind a single progress dialog (no login/app-setup screens flashing through), and that pressing back from the app home returns to the opportunities list. - With networking off, launching an app from a Connect opportunity should fall back to the normal login screen showing the usual error — not hang on the dialog or crash. + - Launching from the Delivery Progress or Learning Progress page (not just the opportunities list) also opens the app directly with the single progress dialog (no login/app-setup flash) and lands on the app home. + - On the app home, "View Job Status" opens the job's progress page; backing out of it returns to the app home, and backing out of the home returns to the opportunities list. + - Opening a job's progress page directly from the opportunities list (tapping an in-progress job) returns to the opportunities list on back. - **SMS opportunity-invite app link (Connect):** Tap an invite link of the form `https://connect.dimagi.com/users/invite_redirect/` (and the `connect-staging.dimagi.com` equivalent) from an SMS app and verify each corner case: - CommCare not installed: navigates to a webpage on Connect (that should redirect user to Play Store) diff --git a/app/src/org/commcare/activities/DispatchActivity.java b/app/src/org/commcare/activities/DispatchActivity.java index 137011e594..4b60c1bc4d 100644 --- a/app/src/org/commcare/activities/DispatchActivity.java +++ b/app/src/org/commcare/activities/DispatchActivity.java @@ -33,6 +33,8 @@ import javax.annotation.Nullable; +import androidx.annotation.NonNull; + import static org.commcare.activities.LoginActivity.EXTRA_APP_ID; import static org.commcare.activities.LoginActivity.EXTRA_FORCE_SINGLE_APP_MODE; import static org.commcare.commcaresupportlibrary.CommCareLauncher.SESSION_ENDPOINT_APP_ID; @@ -116,6 +118,16 @@ protected void onCreate(Bundle savedInstanceState) { } } + @Override + protected void onNewIntent(@NonNull Intent intent) { + super.onNewIntent(intent); + // Adopt the new intent so dispatch() routes a reused (CLEAR_TOP | SINGLE_TOP) launch. + setIntent(intent); + if (intent.getBooleanExtra(LoginActivity.USER_TRIGGERED_LOGOUT, false)) { + userTriggeredLogout = true; + } + } + private Intent checkIfAnyPNIntentPresent() { return FirebaseMessagingUtil.getIntentForPNIfAny(this, getIntent()); } diff --git a/app/src/org/commcare/activities/HomeScreenBaseActivity.java b/app/src/org/commcare/activities/HomeScreenBaseActivity.java index be992db84f..96ef609e83 100644 --- a/app/src/org/commcare/activities/HomeScreenBaseActivity.java +++ b/app/src/org/commcare/activities/HomeScreenBaseActivity.java @@ -2,7 +2,6 @@ import static org.commcare.activities.DispatchActivity.EXIT_AFTER_FORM_SUBMISSION; import static org.commcare.activities.DispatchActivity.EXIT_AFTER_FORM_SUBMISSION_DEFAULT; -import static org.commcare.activities.DispatchActivity.REDIRECT_TO_CONNECT_OPPORTUNITY_INFO; import static org.commcare.activities.DispatchActivity.SESSION_ENDPOINT_ARGUMENTS_BUNDLE; import static org.commcare.activities.DispatchActivity.SESSION_ENDPOINT_ARGUMENTS_LIST; import static org.commcare.activities.DispatchActivity.SESSION_ENDPOINT_ID; @@ -14,7 +13,6 @@ import static org.commcare.activities.EntitySelectActivity.EXTRA_ENTITY_KEY; import static org.commcare.appupdate.AppUpdateController.IN_APP_UPDATE_REQUEST_CODE; -import android.app.Activity; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; @@ -37,12 +35,15 @@ import org.commcare.activities.components.FormEntryInstanceState; import org.commcare.activities.components.FormEntrySessionWrapper; import org.commcare.android.database.app.models.UserKeyRecord; +import org.commcare.android.database.connect.models.ConnectJobRecord; import org.commcare.android.database.user.models.FormRecord; import org.commcare.android.database.user.models.SessionStateDescriptor; import org.commcare.android.logging.ReportingUtils; import org.commcare.appupdate.AppUpdateControllerFactory; import org.commcare.appupdate.AppUpdateState; import org.commcare.appupdate.FlexibleAppUpdateController; +import org.commcare.connect.ConnectJobHelper; +import org.commcare.connect.ConnectNavHelper; import org.commcare.core.process.CommCareInstanceInitializer; import org.commcare.dalvik.BuildConfig; import org.commcare.dalvik.R; @@ -110,6 +111,7 @@ import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Hashtable; +import java.util.Objects; import java.util.Vector; import java.util.concurrent.locks.ReentrantLock; @@ -586,10 +588,13 @@ protected void userTriggeredLogout() { } protected void userPressedOpportunityStatus() { - Intent i = new Intent(); - i.putExtra(REDIRECT_TO_CONNECT_OPPORTUNITY_INFO, true); - setResult(RESULT_OK, i); - finish(); + // Launch the seated app's job status page on top of this (still-live) Home so the app + // session is preserved and backing out of the status page returns here. + ConnectJobRecord job = Objects.requireNonNull( + ConnectJobHelper.INSTANCE.getJobForSeatedApp(this), + "View Job Status pressed but no Connect job was found for the seated app" + ); + ConnectNavHelper.INSTANCE.goToActiveInfoForJob(this, job, true); } @Override @@ -1240,15 +1245,12 @@ static Intent buildHomeIntent( return intent; } - public static void launchHome(Activity activity) { - Intent intent = buildHomeIntent( - activity, - LoginMode.PASSWORD, - true, - false, - true - ); - activity.startActivity(intent); + /** + * The Home intent used by Connect app launches that bypass LoginActivity. Exposed so callers can + * start it via {@code startActivityForResult} and react to the Home result (e.g. a back-out). + */ + public static Intent buildHomeLaunchIntent(Context context) { + return buildHomeIntent(context, LoginMode.PASSWORD, true, false, true); } protected static void addPendingDataExtra(Intent i, CommCareSession session) { diff --git a/app/src/org/commcare/connect/ConnectAppLaunchController.kt b/app/src/org/commcare/connect/ConnectAppLaunchController.kt new file mode 100644 index 0000000000..5f5f0dd45a --- /dev/null +++ b/app/src/org/commcare/connect/ConnectAppLaunchController.kt @@ -0,0 +1,232 @@ +package org.commcare.connect + +import android.app.Activity +import android.content.Intent +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.Fragment +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import org.commcare.CommCareApplication +import org.commcare.activities.DispatchActivity +import org.commcare.activities.HomeScreenBaseActivity +import org.commcare.activities.LoginActivity +import org.commcare.connect.network.TokenExceptionHandler +import org.commcare.google.services.analytics.FirebaseAnalyticsUtil +import org.commcare.login.LoginPhase +import org.commcare.login.LoginProgress +import org.commcare.util.LogTypes +import org.commcare.views.dialogs.CustomProgressDialog +import org.javarosa.core.services.Logger +import org.javarosa.core.services.locale.Localization + +/** The app a Connect launch is targeting; [isLearning] selects the learn vs delivery app type. */ +internal data class LaunchTarget( + val appId: String, + val isLearning: Boolean, +) + +/** + * How the launch dialog should render a [LoginProgress]: [titleKey]/[messageKey] are localization + * keys; [overrideMessage] is an already-localized runtime message that supersedes [messageKey]. + */ +internal data class LaunchDialogState( + val showSyncDialog: Boolean, + val titleKey: String, + val messageKey: String, + val overrideMessage: String?, + val percent: Int?, +) + +/** Maps each [LoginProgress] phase to the [LaunchDialogState] the launch dialog should show for it. */ +internal object LaunchProgressMapper { + fun map(progress: LoginProgress): LaunchDialogState { + val syncing = progress.phase == LoginPhase.Syncing + val (titleKey, messageKey) = + when (progress.phase) { + LoginPhase.Seating -> "seating.app" to "seating.app" + LoginPhase.SigningIn -> "key.manage.title" to "key.manage.start" + LoginPhase.Syncing -> "sync.communicating.title" to "sync.progress.starting" + } + return LaunchDialogState( + showSyncDialog = syncing, + titleKey = titleKey, + messageKey = messageKey, + overrideMessage = progress.message, + percent = if (syncing) progress.percent else null, + ) + } +} + +/** + * Drives a Connect app launch from a fragment without showing LoginActivity: shows a + * [CustomProgressDialog], runs [ConnectAppLauncher], and routes the [LaunchOutcome] through + * [LaunchOutcomeRouter]. + * + * The dialog is dismissed automatically when the fragment's view is destroyed, so callers don't + * have to manage cleanup themselves. + */ +class ConnectAppLaunchController + @JvmOverloads + constructor( + private val fragment: Fragment, + private val launcher: ConnectAppLauncher = ConnectAppLauncher(), + ) { + private var launchDialog: CustomProgressDialog? = null + private var showingSyncDialog = false + private var observedLifecycle: Lifecycle? = null + + // Registered at construction (fragment init, the only valid time) so a back-out of the launched + // app's Home is delivered back here and can return the worker to the opportunities list. + private val homeResultLauncher: ActivityResultLauncher = + fragment.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + onHomeResult(result) + } + + fun launchApp( + appId: String, + isLearning: Boolean, + ) = launch(LaunchTarget(appId, isLearning)) + + private fun launch(target: LaunchTarget) { + val activity = fragment.requireActivity() + val owner = fragment.viewLifecycleOwner + registerDialogCleanup(owner) + showOrUpdateDialog(LaunchProgressMapper.map(LoginProgress(LoginPhase.Seating))) + launcher.start( + owner, + activity, + target.appId, + target.isLearning, + { progress -> activity.runOnUiThread { updateLaunchProgress(progress) } }, + { outcome -> handleLaunchOutcome(outcome, activity, target) }, + ) + } + + private fun updateLaunchProgress(progress: LoginProgress) { + if (!fragment.isAdded) { + return + } + showOrUpdateDialog(LaunchProgressMapper.map(progress)) + } + + private fun handleLaunchOutcome( + outcome: LaunchOutcome, + activity: Activity, + target: LaunchTarget, + ) { + if (!fragment.isAdded) { + return + } + LaunchOutcomeRouter.dispatch( + outcome, + object : LaunchActions { + override fun dismissProgress() = dismissLaunchDialog() + + override fun launchHome() = homeResultLauncher.launch(HomeScreenBaseActivity.buildHomeLaunchIntent(activity)) + + override fun handleTokenDenied() = TokenExceptionHandler.handleTokenDeniedException() + + override fun recoverFromSeatFailure() { + val intent = + Intent(activity, DispatchActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + fragment.startActivity(intent) + activity.finish() + } + + override fun fallBackToLegacyLaunch() { + ConnectAppUtils.launchApp(activity, target.isLearning, target.appId) + } + + override fun reportFailure(reason: String) { + Logger.log( + LogTypes.TYPE_ERROR_WORKFLOW, + "Connect launch failed for app ${target.appId}: $reason", + ) + FirebaseAnalyticsUtil.reportCccAppFailedAutoLogin(target.appId) + } + }, + ) + } + + private fun showOrUpdateDialog(state: LaunchDialogState) { + val title = Localization.get(state.titleKey) + val message = state.overrideMessage ?: Localization.get(state.messageKey) + + if (launchDialog == null || state.showSyncDialog != showingSyncDialog) { + if (fragment.childFragmentManager.isStateSaved) { + return + } + + dismissLaunchDialog() + launchDialog = + CustomProgressDialog + .newInstance(title, message, LAUNCH_DIALOG_TASK_ID) + .apply { if (state.showSyncDialog) addProgressBar() } + showingSyncDialog = state.showSyncDialog + launchDialog?.showNow(fragment.childFragmentManager, LAUNCH_DIALOG_TAG) + } else { + launchDialog?.updateTitle(title) + launchDialog?.updateMessage(message) + } + + state.percent?.let { launchDialog?.updateProgressBar(it, PROGRESS_BAR_MAX) } + } + + private fun dismissLaunchDialog() { + launchDialog?.let { + if (it.isAdded) { + it.dismissAllowingStateLoss() + } + } + launchDialog = null + } + + private fun onHomeResult(result: ActivityResult) { + if (!fragment.isAdded) { + return + } + + val activity = fragment.requireActivity() + if (result.resultCode == Activity.RESULT_OK) { + // Route to the login screen via DispatchActivity on the user logging-out. + val intent = + Intent(activity, DispatchActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + .putExtra(LoginActivity.USER_TRIGGERED_LOGOUT, true) + .putExtra(ConnectAppUtils.IS_LAUNCH_FROM_CONNECT, true) + activity.startActivity(intent) + return + } + + // Back-out (RESULT_CANCELED): end the app session, return to the opportunities list. + CommCareApplication.instance().closeUserSession() + ConnectNavHelper.goToConnectJobsList(activity, clearTop = true) + } + + private fun registerDialogCleanup(owner: LifecycleOwner) { + if (observedLifecycle === owner.lifecycle) { + return + } + observedLifecycle = owner.lifecycle + owner.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + dismissLaunchDialog() + observedLifecycle = null + } + }, + ) + } + + companion object { + private const val LAUNCH_DIALOG_TAG = "connect_launch_progress" + + // Negative so it can't collide with the positive task ids CommCareActivity assigns to real tasks. + private const val LAUNCH_DIALOG_TASK_ID = -10 + private const val PROGRESS_BAR_MAX = 100 + } + } diff --git a/app/src/org/commcare/connect/ConnectAppUtils.kt b/app/src/org/commcare/connect/ConnectAppUtils.kt index 5126ad4f11..b0d67cd2dd 100644 --- a/app/src/org/commcare/connect/ConnectAppUtils.kt +++ b/app/src/org/commcare/connect/ConnectAppUtils.kt @@ -7,7 +7,6 @@ import org.commcare.commcaresupportlibrary.CommCareLauncher import org.commcare.connect.database.ConnectAppDatabaseUtil import org.commcare.engine.resource.ResourceInstallUtils import org.commcare.google.services.analytics.FirebaseAnalyticsUtil -import org.commcare.login.ConnectCredentialResolver import org.commcare.tasks.ResourceEngineListener import org.commcare.tasks.templates.CommCareTask import org.commcare.tasks.templates.CommCareTaskConnector @@ -62,18 +61,6 @@ object ConnectAppUtils { } } - fun shouldOverridePassword(personalIdManagedLogin: Boolean): Boolean = - PersonalIdManager.getInstance().isloggedIn() && personalIdManagedLogin - - fun getPasswordOverride( - context: Context, - username: String, - createIfNeeded: Boolean, - ): String { - val seatedAppId = CommCareApplication.instance().currentApp.uniqueId - return ConnectCredentialResolver(context).resolve(seatedAppId, username, createIfNeeded).password - } - fun updateLastAccessed( context: Context, appId: String, diff --git a/app/src/org/commcare/connect/ConnectNavHelper.kt b/app/src/org/commcare/connect/ConnectNavHelper.kt index fbe29fa6aa..80bd7154d7 100644 --- a/app/src/org/commcare/connect/ConnectNavHelper.kt +++ b/app/src/org/commcare/connect/ConnectNavHelper.kt @@ -70,9 +70,17 @@ object ConnectNavHelper { unlockAndGoTo(activity, policy, listener, ::goToConnectJobsList) } - fun goToConnectJobsList(context: Context) { + @JvmOverloads + fun goToConnectJobsList( + context: Context, + clearTop: Boolean = false, + ) { checkConnectAccess(context) val i = Intent(context, ConnectActivity::class.java) + if (clearTop) { + // Drop any Connect/app screens stacked above the opportunities list so back lands there cleanly. + i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } context.startActivity(i) } diff --git a/app/src/org/commcare/fragments/connect/ConnectDeliveryProgressFragment.java b/app/src/org/commcare/fragments/connect/ConnectDeliveryProgressFragment.java index 119577f34f..c1b17ab41d 100644 --- a/app/src/org/commcare/fragments/connect/ConnectDeliveryProgressFragment.java +++ b/app/src/org/commcare/fragments/connect/ConnectDeliveryProgressFragment.java @@ -20,9 +20,8 @@ import com.google.android.material.tabs.TabLayout; import org.commcare.AppUtils; -import org.commcare.CommCareApplication; import org.commcare.android.database.connect.models.ConnectJobPaymentRecord; -import org.commcare.connect.ConnectAppUtils; +import org.commcare.connect.ConnectAppLaunchController; import org.commcare.connect.ConnectDateUtils; import org.commcare.connect.ConnectJobHelper; import org.commcare.connect.PersonalIdManager; @@ -56,6 +55,7 @@ public class ConnectDeliveryProgressFragment extends ConnectJobFragment newJobs; ArrayList finishedJobs; private ConnectJobsListViewModel viewModel; - - private static final String LAUNCH_DIALOG_TAG = "connect_launch_progress"; - // Negative so it can't collide with the positive task ids CommCareActivity assigns to real tasks. - private static final int LAUNCH_DIALOG_TASK_ID = -10; - private static final int PROGRESS_BAR_MAX = 100; - private CustomProgressDialog launchDialog; - private boolean showingSyncDialog; + private final ConnectAppLaunchController launchController = new ConnectAppLaunchController(this); public ConnectJobsListsFragment() { // Required empty public constructor @@ -188,7 +166,7 @@ private void launchAppForJob(ConnectJobRecord job, boolean isLearning) { } else if (isLearning && job.passedAssessment()) { navigateToDeliveryDetails(); } else if (AppUtils.isAppInstalled(appId)) { - launchApp(isLearning, appId); + launchController.launchApp(appId, isLearning); } else { int textId = isLearning ? R.string.connect_downloading_learn @@ -203,137 +181,6 @@ private void launchAppForJob(ConnectJobRecord job, boolean isLearning) { } } - private void launchApp(boolean isLearning, String appId) { - FragmentActivity activity = requireActivity(); - showLaunchDialog(false); - new ConnectAppLauncher().start( - getViewLifecycleOwner(), - activity, - appId, - isLearning, - progress -> activity.runOnUiThread(() -> updateLaunchProgress(progress)), - outcome -> handleLaunchOutcome(outcome, activity, isLearning, appId) - ); - } - - private void updateLaunchProgress(LoginProgress progress) { - if (!isAdded()) { - return; - } - - LoginPhase phase = progress.getPhase(); - boolean syncing = phase == LoginPhase.Syncing; - if (launchDialog == null || syncing != showingSyncDialog) { - showLaunchDialog(syncing); - } - if (phase == LoginPhase.Seating) { - launchDialog.updateTitle(Localization.get("seating.app")); - launchDialog.updateMessage(Localization.get("seating.app")); - } else if (phase == LoginPhase.SigningIn) { - launchDialog.updateTitle(Localization.get("key.manage.title")); - launchDialog.updateMessage(Localization.get("key.manage.start")); - } - if (progress.getMessage() != null) { - launchDialog.updateMessage(progress.getMessage()); - } - Integer percent = progress.getPercent(); - if (syncing && percent != null) { - launchDialog.updateProgressBar(percent, PROGRESS_BAR_MAX); - } - } - - private void handleLaunchOutcome( - LaunchOutcome outcome, - FragmentActivity activity, - boolean isLearning, - String appId - ) { - LaunchOutcomeRouter.INSTANCE.dispatch(outcome, new LaunchActions() { - @Override - public void dismissProgress() { - dismissLaunchDialog(); - } - - @Override - public void launchHome() { - HomeScreenBaseActivity.launchHome(activity); - } - - @Override - public void handleTokenDenied() { - TokenExceptionHandler.INSTANCE.handleTokenDeniedException(); - } - - @Override - public void recoverFromSeatFailure() { - startDispatchAfterSeatFailure(activity); - } - - @Override - public void fallBackToLegacyLaunch() { - ConnectAppUtils.INSTANCE.launchApp(activity, isLearning, appId); - } - - @Override - public void reportFailure(String reason) { - reportLaunchFailure(appId, reason); - } - }); - } - - private void startDispatchAfterSeatFailure(FragmentActivity activity) { - Intent intent = new Intent(activity, DispatchActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - startActivity(intent); - activity.finish(); - } - - private void reportLaunchFailure(String appId, String reason) { - Logger.log( - LogTypes.TYPE_ERROR_WORKFLOW, - "Connect launch failed for app " + appId + ": " + reason - ); - FirebaseAnalyticsUtil.reportCccAppFailedAutoLogin(appId); - } - - private void showLaunchDialog(boolean syncing) { - if (getChildFragmentManager().isStateSaved()) { - return; - } - dismissLaunchDialog(); - if (syncing) { - launchDialog = CustomProgressDialog.newInstance( - Localization.get("sync.communicating.title"), - Localization.get("sync.progress.starting"), - LAUNCH_DIALOG_TASK_ID - ); - launchDialog.addProgressBar(); - } else { - launchDialog = CustomProgressDialog.newInstance( - Localization.get("seating.app"), - Localization.get("seating.app"), - LAUNCH_DIALOG_TASK_ID - ); - } - showingSyncDialog = syncing; - launchDialog.showNow(getChildFragmentManager(), LAUNCH_DIALOG_TAG); - } - - private void dismissLaunchDialog() { - if (launchDialog != null) { - if (launchDialog.isAdded()) { - launchDialog.dismissAllowingStateLoss(); - } - launchDialog = null; - } - } - - @Override - public void onDestroyView() { - dismissLaunchDialog(); - super.onDestroyView(); - } - private void navigateToDeliveryProgress() { Navigation.findNavController(getBinding().getRoot()) .navigate( diff --git a/app/src/org/commcare/fragments/connect/ConnectLearningProgressFragment.java b/app/src/org/commcare/fragments/connect/ConnectLearningProgressFragment.java index 1ad0c8cea4..6947dae5ee 100644 --- a/app/src/org/commcare/fragments/connect/ConnectLearningProgressFragment.java +++ b/app/src/org/commcare/fragments/connect/ConnectLearningProgressFragment.java @@ -12,10 +12,9 @@ import androidx.navigation.Navigation; import org.commcare.AppUtils; -import org.commcare.CommCareApplication; import org.commcare.android.database.connect.models.ConnectJobAssessmentRecord; import org.commcare.android.database.connect.models.ConnectJobLearningRecord; -import org.commcare.connect.ConnectAppUtils; +import org.commcare.connect.ConnectAppLaunchController; import org.commcare.connect.ConnectDateUtils; import org.commcare.connect.repository.ConnectRepository; import org.commcare.connect.PersonalIdManager; @@ -39,6 +38,7 @@ public class ConnectLearningProgressFragment extends ConnectJobFragment