Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<uuid>` (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)
Expand Down
33 changes: 18 additions & 15 deletions app/src/org/commcare/activities/HomeScreenBaseActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -586,10 +587,15 @@ 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 = ConnectJobHelper.INSTANCE.getJobForSeatedApp(this);
if (job == null) {
Logger.log(LogTypes.TYPE_ERROR_WORKFLOW,
"View Job Status pressed but no Connect job was found for the seated app");
return;
}
ConnectNavHelper.INSTANCE.goToActiveInfoForJob(this, job, true);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@Override
Expand Down Expand Up @@ -1240,15 +1246,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) {
Expand Down
218 changes: 218 additions & 0 deletions app/src/org/commcare/connect/ConnectAppLaunchController.kt
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))

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
}
}
13 changes: 0 additions & 13 deletions app/src/org/commcare/connect/ConnectAppUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion app/src/org/commcare/connect/ConnectNavHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

public static ConnectDeliveryProgressFragment newInstance() {
return new ConnectDeliveryProgressFragment();
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading