Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
12 changes: 12 additions & 0 deletions app/src/org/commcare/activities/DispatchActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}
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
231 changes: 231 additions & 0 deletions app/src/org/commcare/connect/ConnectAppLaunchController.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
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<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) {
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
}
}
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
Loading
Loading