-
-
Notifications
You must be signed in to change notification settings - Fork 48
CCCT-2440 Connect App Launch For Remaining Pages And Back Navigation #3756
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 10 commits
e355bf0
76662d5
11fa6cd
741ae47
de6ab62
ee1338f
8bfa45e
2b23993
983d87d
2281db8
f4ed51c
03af44e
8c05294
2caa26f
dab2abb
4a1414e
a82888a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,218 @@ | ||
| 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.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<Intent> = | ||
| fragment.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> | ||
| onHomeResult(result) | ||
| } | ||
|
|
||
| fun launchLearningApp(appId: String) = launch(LaunchTarget(appId, isLearning = true)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Referenced in another comment but adding more detail here: Is there a reason for the class to construct the launch controller before it's needed and hold a reference to it in the fragment? Otherwise I think a static launch method would be simpler and cleaner for callers.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moving convo to earlier thread here |
||
|
|
||
| fun launchDeliveryApp(appId: String) = launch(LaunchTarget(appId, isLearning = false)) | ||
|
|
||
| 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) { | ||
| // A backed-out app Home (RESULT_CANCELED) ends the app session and returns to the | ||
| // opportunities list; RESULT_OK (logout / app switch) keeps its existing handling. | ||
| if (!fragment.isAdded || result.resultCode == Activity.RESULT_OK) { | ||
| return | ||
| } | ||
| CommCareApplication.instance().closeUserSession() | ||
| ConnectNavHelper.goToConnectJobsList(fragment.requireActivity(), 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 | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Fragment | |
| private int initialTabPosition = 0; | ||
| private boolean isProgrammaticTabChange = false; | ||
| private ConnectDeliveryProgressViewModel viewModel; | ||
| private final ConnectAppLaunchController launchController = new ConnectAppLaunchController(this); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems like this could be a local variable in navigateToDeliverAppHome, or maybe use a static function instead?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The reason for this not being a local variable or static is because So keeping it global here avoids an |
||
|
|
||
| public static ConnectDeliveryProgressFragment newInstance() { | ||
| return new ConnectDeliveryProgressFragment(); | ||
|
|
@@ -340,8 +340,7 @@ private void navigateToDeliverAppHome() { | |
| String appId = job.getDeliveryAppInfo().getAppId(); | ||
|
|
||
| if (AppUtils.isAppInstalled(appId)) { | ||
| CommCareApplication.instance().closeUserSession(); | ||
| ConnectAppUtils.INSTANCE.launchApp(requireActivity(), false, appId); | ||
| launchController.launchDeliveryApp(appId); | ||
| } else { | ||
| NavDirections navDirections = ConnectDeliveryProgressFragmentDirections | ||
| .actionConnectJobDeliveryProgressFragmentToConnectDownloadingFragment( | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Blocker: This seems crash-worthy to me, we should never show the Job Status button if not in a job context so the user clicking that button would indicate something is quite wrong.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great point
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
dab2abb