Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f34655d
CCCT-2439 Connect Login Silent Launch Path
conroy-ricketts Jun 4, 2026
2d3bd52
CCCT-2439 Connect Login Silent Launch Path
conroy-ricketts Jun 5, 2026
ad7fdc2
CCCT-2439 Connect Login Silent Launch Path
conroy-ricketts Jun 5, 2026
20f959f
CCCT-2439 Connect Login Silent Launch Path
conroy-ricketts Jun 5, 2026
9e3aa2c
CCCT-2439 Connect Login Silent Launch Path
conroy-ricketts Jun 5, 2026
7cafc14
CCCT-2439 Connect Login Silent Launch Path
conroy-ricketts Jun 5, 2026
6a9a96f
CCCT-2439 Connect Login Silent Launch Path
conroy-ricketts Jun 5, 2026
5393194
CCCT-2439 Connect Login Silent Launch Path
conroy-ricketts Jun 5, 2026
bc7ddc6
CCCT-2439 Connect Login Silent Launch Path
conroy-ricketts Jun 5, 2026
2e373a6
CCCT-2439 Connect Login Silent Launch Path
conroy-ricketts Jun 5, 2026
5af435e
CCCT-2439 Connect Login Silent Launch Path
conroy-ricketts Jun 5, 2026
4f3657a
Add release notes for CCCT-2439
conroy-ricketts Jun 5, 2026
b847f7f
Add QA notes for CCCT-2439
conroy-ricketts Jun 5, 2026
10082ee
CCCT-2439 Connect Login Silent Launch Path
conroy-ricketts Jun 8, 2026
ff83fa1
Fixed merge conflict with CCCT-2438-login-routing-extraction-and-inli…
conroy-ricketts Jun 8, 2026
1568ebf
CCCT-2439 Connect Login Silent Launch Path
conroy-ricketts Jun 8, 2026
18c9126
Merge branch 'CCCT-2438-login-routing-extraction-and-inline-app-seati…
conroy-ricketts Jun 8, 2026
aa9285c
Fixed merge conflict with CCCT-2438-login-routing-extraction-and-inli…
conroy-ricketts Jun 8, 2026
502c326
Merge branch 'CCCT-2438-login-routing-extraction-and-inline-app-seati…
conroy-ricketts Jun 10, 2026
b331085
Merge remote-tracking branch 'origin/CCCT-2438-login-routing-extracti…
conroy-ricketts Jun 11, 2026
8153e70
CCCT-2439 Connect Login Silent Launch Path
conroy-ricketts Jun 11, 2026
c663ef9
Merge branch 'master' into CCCT-2439-connect-login-silent-launch-path
conroy-ricketts Jun 11, 2026
126daaf
CCCT-2439 Connect Login Silent Launch Path
conroy-ricketts Jun 11, 2026
72aa6df
Merge branch 'master' of github.com:dimagi/commcare-android into CCCT…
conroy-ricketts Jun 11, 2026
cc723d3
Merge branch 'CCCT-2439-connect-login-silent-launch-path' of github.c…
conroy-ricketts Jun 11, 2026
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
4 changes: 4 additions & 0 deletions RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ These are published publicly on Playstore, Github Releases and CommCare Forums
- Reduced frequency of required biometric or pin unlocks for PersonalID and Connect
- [Back Online Indicator] Refreshable Connect pages now show a green "Back Online" indicator at the top of the page when a sync succeeds after a previous offline failure
- [Delivery Progress Offline-First] The Connect Delivery Progress page now displays cached delivery data immediately on open, even with no network, and shows inline sync status (success / failure / offline) instead of a blocking loading dialog
- Launching an app from a Connect opportunity now opens it directly with a single loading dialog, instead of briefly flashing the login and app-setup screens

#### Important Bug Fixes

Expand Down Expand Up @@ -125,6 +126,9 @@ we would like to communicate to QA as part of the release testing
- Open the Connect Delivery Progress page while online with a working network and let it sync. Verify the progress, payment list, payment-confirmation tile, and "Last updated" timestamp all populate as before, and that the green "Sync successful" bar flashes briefly at the top.
- Background the app, turn on airplane mode, and reopen the Delivery Progress page. Verify cached delivery data (progress, deliveries, payments, "Last updated" timestamp) appears immediately without waiting for a network call, and that the orange "Offline" indicator with the previous sync time is shown at the top.
- Confirm the full-screen blocking loading dialog that used to appear on refresh no longer appears — the inline small progress spinner is the only loading indicator.
- **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.



Expand Down
65 changes: 14 additions & 51 deletions app/src/org/commcare/activities/DispatchActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,6 @@
import org.commcare.dalvik.R;
import org.commcare.google.services.analytics.AnalyticsParamValue;
import org.commcare.google.services.analytics.FirebaseAnalyticsUtil;
import org.commcare.login.LaunchContext;
import org.commcare.login.LoginResult;
import org.commcare.login.PostLoginDestination;
import org.commcare.login.PostLoginOutcome;
import org.commcare.login.PostLoginRouter;
import org.commcare.preferences.DeveloperPreferences;
import org.commcare.recovery.measures.ExecuteRecoveryMeasuresActivity;
import org.commcare.recovery.measures.RecoveryMeasuresHelper;
Expand Down Expand Up @@ -359,22 +354,16 @@ private boolean getLaunchedFromConnect() {
}

private void launchHomeScreen() {
Intent i;
if (useRootMenuHomeActivity()) {
i = new Intent(this, RootMenuHomeActivity.class);
// Since we are entering a menu list, the session state will expect this later
HomeScreenBaseActivity.addPendingDataExtra(i,
CommCareApplication.instance().getCurrentSessionWrapper().getSession());
} else {
i = new Intent(this, StandardHomeActivity.class);
}
i.putExtra(START_FROM_LOGIN, startFromLogin);
i.putExtra(LoginActivity.LOGIN_MODE, lastLoginMode);
i.putExtra(LoginActivity.MANUAL_SWITCH_TO_PW_MODE, userManuallyEnteredPasswordMode);
i.putExtra(PERSONALID_MANAGED_LOGIN, personalIdManagedLogin);
Intent intent = HomeScreenBaseActivity.buildHomeIntent(
this,
lastLoginMode,
startFromLogin,
userManuallyEnteredPasswordMode,
personalIdManagedLogin
);
startFromLogin = false;
clearSessionEndpointIntentExtras();
startActivityForResult(i, HOME_SCREEN);
startActivityForResult(intent, HOME_SCREEN);
}

public static boolean useRootMenuHomeActivity() {
Expand Down Expand Up @@ -519,7 +508,12 @@ protected void onActivityResult(int requestCode, int resultCode, Intent intent)
if (resultCode == RESULT_CANCELED) {
shouldFinish = true;
} else if (intent != null) {
applyPostLoginDestination(routeLoginResult(intent));
lastLoginMode = (LoginMode)intent.getSerializableExtra(LoginActivity.LOGIN_MODE);
userManuallyEnteredPasswordMode =
intent.getBooleanExtra(LoginActivity.MANUAL_SWITCH_TO_PW_MODE, false);
personalIdManagedLogin = intent.getBooleanExtra(PERSONALID_MANAGED_LOGIN, false);
connectManagedLogin = intent.getBooleanExtra(CONNECT_MANAGED_LOGIN, false);
startFromLogin = true;
Comment thread
conroy-ricketts marked this conversation as resolved.
}
return;
case HOME_SCREEN:
Expand All @@ -539,35 +533,4 @@ protected void onActivityResult(int requestCode, int resultCode, Intent intent)
}
super.onActivityResult(requestCode, resultCode, intent);
}

private PostLoginDestination routeLoginResult(Intent intent) {
LoginMode loginMode = (LoginMode) intent.getSerializableExtra(LoginActivity.LOGIN_MODE);
boolean manualSwitchToPwMode =
intent.getBooleanExtra(LoginActivity.MANUAL_SWITCH_TO_PW_MODE, false);
boolean personalIdManaged = intent.getBooleanExtra(PERSONALID_MANAGED_LOGIN, false);
connectManagedLogin = intent.getBooleanExtra(CONNECT_MANAGED_LOGIN, false);
boolean restoreSession = false;
LoginResult.Success success = new LoginResult.Success(
"",
"",
loginMode,
restoreSession,
personalIdManaged,
"",
new PostLoginOutcome(redirectToConnectOpportunityInfo, false)
);

return PostLoginRouter.route(success, new LaunchContext(true, manualSwitchToPwMode));
}

private void applyPostLoginDestination(PostLoginDestination destination) {
if (destination instanceof PostLoginDestination.Home home) {
lastLoginMode = home.getLoginMode();
userManuallyEnteredPasswordMode = home.getManualSwitchToPwMode();
personalIdManagedLogin = home.getPersonalIdManagedLogin();
startFromLogin = home.getStartFromLogin();
} else {
shouldFinish = true;
}
}
}
40 changes: 38 additions & 2 deletions app/src/org/commcare/activities/HomeScreenBaseActivity.java
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
package org.commcare.activities;

import static org.commcare.activities.DispatchActivity.EXIT_AFTER_FORM_SUBMISSION;
import static org.commcare.connect.ConnectAppUtils.IS_LAUNCH_FROM_CONNECT;
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;
import static org.commcare.connect.ConnectConstants.PERSONALID_MANAGED_LOGIN;
import static org.commcare.activities.DriftHelper.getCurrentDrift;
import static org.commcare.activities.DriftHelper.getDriftDialog;
import static org.commcare.activities.DriftHelper.shouldShowDriftWarning;
import static org.commcare.activities.DriftHelper.updateLastDriftWarningTime;
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;
import android.content.SharedPreferences;
Expand Down Expand Up @@ -80,7 +82,6 @@
import org.commcare.util.LogTypes;
import org.commcare.utils.AndroidCommCarePlatform;
import org.commcare.utils.AndroidInstanceInitializer;
import org.commcare.utils.AndroidUtil;
import org.commcare.utils.ChangeLocaleUtil;
import org.commcare.utils.CommCareUtil;
import org.commcare.utils.ConnectivityStatus;
Expand Down Expand Up @@ -1215,6 +1216,41 @@ && getResources().getConfiguration().orientation
}
}

static Intent buildHomeIntent(
Context context,
LoginMode loginMode,
boolean startFromLogin,
boolean manualSwitchToPwMode,
boolean personalIdManagedLogin
) {
Intent intent;
if (DispatchActivity.useRootMenuHomeActivity()) {
intent = new Intent(context, RootMenuHomeActivity.class);
addPendingDataExtra(
intent,
CommCareApplication.instance().getCurrentSessionWrapper().getSession()
);
} else {
intent = new Intent(context, StandardHomeActivity.class);
}
intent.putExtra(DispatchActivity.START_FROM_LOGIN, startFromLogin);
intent.putExtra(LoginActivity.LOGIN_MODE, loginMode);
intent.putExtra(LoginActivity.MANUAL_SWITCH_TO_PW_MODE, manualSwitchToPwMode);
intent.putExtra(PERSONALID_MANAGED_LOGIN, personalIdManagedLogin);
return intent;
}

public static void launchHome(Activity activity) {
Intent intent = buildHomeIntent(
activity,
LoginMode.PASSWORD,
true,
false,
true
);
activity.startActivity(intent);
}

protected static void addPendingDataExtra(Intent i, CommCareSession session) {
EvaluationContext evalContext =
CommCareApplication.instance().getCurrentSessionWrapper().getEvaluationContext();
Expand Down
128 changes: 128 additions & 0 deletions app/src/org/commcare/connect/ConnectAppLauncher.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package org.commcare.connect

import android.content.Context
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.commcare.CommCareApplication
import org.commcare.activities.DataPullController.DataPullMode
import org.commcare.activities.LoginMode
import org.commcare.android.database.app.models.UserKeyRecord
import org.commcare.connect.database.ConnectUserDatabaseUtil
import org.commcare.google.services.analytics.FirebaseAnalyticsUtil
import org.commcare.login.AppSeater
import org.commcare.login.AuthSource
import org.commcare.login.LoginController
import org.commcare.login.LoginError
import org.commcare.login.LoginProgressSink
import org.commcare.login.LoginRequest
import org.commcare.login.LoginResult
import org.commcare.login.SeatResult
import java.util.Locale

/**
* Terminal result of a Connect launch. All non-token, non-seat errors fold into [Retryable];
* since this path only does PersonalID password logins, there is no SyncFailed or Demo outcome.
*/
sealed class LaunchOutcome {
object Launched : LaunchOutcome()

object TokenDenied : LaunchOutcome()

object AppSeatFailed : LaunchOutcome()

object CredentialResolutionFailed : LaunchOutcome()

data class Retryable(
val error: LoginError,
) : LaunchOutcome()
}

/**
* Seats and signs into a Connect app with the worker's PersonalID credentials without showing
* [org.commcare.activities.LoginActivity].
*/
class ConnectAppLauncher internal constructor(
private val seatApp: suspend (String, LoginProgressSink) -> SeatResult,
private val performLogin: suspend (Context, LoginRequest, LoginProgressSink) -> LoginResult,
private val connectUsername: (Context) -> String?,
) {
constructor() : this(
seatApp = { appId, sink -> AppSeater().seatIfNeeded(appId, sink) },
performLogin = { context, request, sink -> LoginController(context).performLogin(request, sink) },
connectUsername = { context -> ConnectUserDatabaseUtil.getUser(context)?.userId },
)

fun interface OutcomeCallback {
fun onOutcome(outcome: LaunchOutcome)
}

/** Fire-and-forget [awaitOutcome], scoped to [lifecycleOwner] so it cancels with the view. */
fun start(
lifecycleOwner: LifecycleOwner,
context: Context,
appId: String,
isLearning: Boolean,
sink: LoginProgressSink,
callback: OutcomeCallback,
): Job =
lifecycleOwner.lifecycleScope.launch {
callback.onOutcome(awaitOutcome(context, appId, isLearning, sink))
}

suspend fun awaitOutcome(
context: Context,
appId: String,
isLearning: Boolean,
sink: LoginProgressSink,
): LaunchOutcome {
CommCareApplication.instance().closeUserSession()
FirebaseAnalyticsUtil.reportCccAppLaunch(
if (isLearning) "Learn" else "Deliver",
appId,
)

if (seatApp(appId, sink) is SeatResult.Failed) {
return LaunchOutcome.AppSeatFailed
}

val username =
connectUsername(context)?.trim()?.lowercase(Locale.ROOT)
if (username.isNullOrEmpty()) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return LaunchOutcome.CredentialResolutionFailed
}

val request =
LoginRequest(
appId = appId,
username = username,
passwordOrPin = "",
credentialType = LoginMode.PASSWORD,
authSource = AuthSource.PersonalId,
restoreSession = false,
triggerMultipleUsersWarning = hasMultipleMatchingUsers(username),
blockRemoteKeyManagement = false,
dataPullMode = DataPullMode.NORMAL,
)

return when (val result = performLogin(context, request, sink)) {
is LoginResult.Success -> LaunchOutcome.Launched
is LoginResult.Failed ->
when (result.error) {
is LoginError.TokenDenied -> LaunchOutcome.TokenDenied
else -> LaunchOutcome.Retryable(result.error)
}
}
}

private fun hasMultipleMatchingUsers(username: String): Boolean {
var count = 0
for (record in CommCareApplication.instance().getAppStorage(UserKeyRecord::class.java)) {
if (record.username == username && ++count > 1) {
return true
}
}
return false
}
}
39 changes: 39 additions & 0 deletions app/src/org/commcare/connect/LaunchOutcomeRouter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package org.commcare.connect

/** Actions a caller runs for a [LaunchOutcome]; a seam so the mapping is testable without a fragment. */
interface LaunchActions {
fun dismissProgress()

fun launchHome()

fun handleTokenDenied()

fun recoverFromSeatFailure()

fun fallBackToLegacyLaunch()

fun reportFailure(reason: String)
}

/** Maps each [LaunchOutcome] to its [LaunchActions] calls, always dismissing progress first. */
object LaunchOutcomeRouter {
fun dispatch(
outcome: LaunchOutcome,
actions: LaunchActions,
) {
actions.dismissProgress()
when (outcome) {
LaunchOutcome.Launched -> actions.launchHome()
LaunchOutcome.TokenDenied -> actions.handleTokenDenied()
LaunchOutcome.AppSeatFailed -> actions.recoverFromSeatFailure()
LaunchOutcome.CredentialResolutionFailed -> {
actions.reportFailure("CredentialResolutionFailed")
actions.fallBackToLegacyLaunch()
}
is LaunchOutcome.Retryable -> {
actions.reportFailure(outcome.error::class.java.simpleName)
actions.fallBackToLegacyLaunch()
}
}
}
}
Loading