diff --git a/app/src/org/commcare/activities/DispatchActivity.java b/app/src/org/commcare/activities/DispatchActivity.java index e70acbd198..6d61b5ea29 100644 --- a/app/src/org/commcare/activities/DispatchActivity.java +++ b/app/src/org/commcare/activities/DispatchActivity.java @@ -1,20 +1,12 @@ package org.commcare.activities; -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; -import static org.commcare.connect.ConnectAppUtils.IS_LAUNCH_FROM_CONNECT; -import static org.commcare.connect.ConnectConstants.CONNECT_MANAGED_LOGIN; -import static org.commcare.connect.ConnectConstants.NOTIFICATION_ID; -import static org.commcare.connect.ConnectConstants.PERSONALID_MANAGED_LOGIN; -import static org.commcare.utils.FirebaseMessagingUtil.getNotificationActionFromIntent; - import android.content.Intent; import android.os.Bundle; - import android.util.Log; import android.widget.Toast; +import androidx.appcompat.app.AppCompatActivity; + import org.commcare.AppUtils; import org.commcare.CommCareApp; import org.commcare.CommCareApplication; @@ -22,8 +14,8 @@ import org.commcare.android.database.global.models.ApplicationRecord; import org.commcare.android.database.user.models.SessionStateDescriptor; import org.commcare.connect.ConnectJobHelper; -import org.commcare.connect.utils.DeepLinkHelper; import org.commcare.connect.ConnectNavHelper; +import org.commcare.connect.utils.DeepLinkHelper; import org.commcare.dalvik.R; import org.commcare.google.services.analytics.AnalyticsParamValue; import org.commcare.google.services.analytics.FirebaseAnalyticsUtil; @@ -39,10 +31,17 @@ import java.util.ArrayList; -import androidx.appcompat.app.AppCompatActivity; - import javax.annotation.Nullable; +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; +import static org.commcare.connect.ConnectAppUtils.IS_LAUNCH_FROM_CONNECT; +import static org.commcare.connect.ConnectConstants.CONNECT_MANAGED_LOGIN; +import static org.commcare.connect.ConnectConstants.NOTIFICATION_ID; +import static org.commcare.connect.ConnectConstants.PERSONALID_MANAGED_LOGIN; +import static org.commcare.utils.FirebaseMessagingUtil.getNotificationActionFromIntent; + /** * Dispatches install, login, and home screen activities. * @@ -70,7 +69,6 @@ public class DispatchActivity extends AppCompatActivity { public static final int INIT_APP = 2; public static final int RECOVERY_MEASURES = 3; - /** * Request code for automatically validating media. * Should signal a return from CommCareVerificationActivity. @@ -109,14 +107,17 @@ protected void onCreate(Bundle savedInstanceState) { if (savedInstanceState != null) { shortcutExtraWasConsumed = savedInstanceState.getBoolean(EXTRA_CONSUMED_KEY); - alreadyCheckedForAppFilesChange = savedInstanceState.getBoolean(KEY_APP_FILES_CHECK_OCCURRED); - waitingForActivityResultFromLogin = savedInstanceState.getBoolean(KEY_WAITING_FOR_ACTIVITY_RESULT); + alreadyCheckedForAppFilesChange = savedInstanceState.getBoolean( + KEY_APP_FILES_CHECK_OCCURRED + ); + waitingForActivityResultFromLogin = savedInstanceState.getBoolean( + KEY_WAITING_FOR_ACTIVITY_RESULT + ); } } - - private Intent checkIfAnyPNIntentPresent(){ - return FirebaseMessagingUtil.getIntentForPNIfAny(this,getIntent()); + private Intent checkIfAnyPNIntentPresent() { + return FirebaseMessagingUtil.getIntentForPNIfAny(this, getIntent()); } /** @@ -130,7 +131,8 @@ private boolean finishIfNotRoot() { if (!isTaskRoot()) { Intent intent = getIntent(); String action = intent.getAction(); - if (intent.hasCategory(Intent.CATEGORY_LAUNCHER) && action != null && action.equals(Intent.ACTION_MAIN)) { + if (intent.hasCategory(Intent.CATEGORY_LAUNCHER) + && action != null && action.equals(Intent.ACTION_MAIN)) { finish(); return true; } @@ -182,9 +184,9 @@ private void dispatch() { return; } - Intent connectOppInviteIntent = DeepLinkHelper.INSTANCE.retrieveConnectOppInviteIntentIfPresent( - this, getIntent()); - if(connectOppInviteIntent != null) { + Intent connectOppInviteIntent = DeepLinkHelper.INSTANCE + .retrieveConnectOppInviteIntentIfPresent(this, getIntent()); + if (connectOppInviteIntent != null) { startActivity(connectOppInviteIntent); return; } @@ -209,7 +211,6 @@ private void dispatch() { // Result will be stored for later use RecoveryMeasuresHelper.requestRecoveryMeasures(); - // Note that the order in which these conditions are checked matters!! if (CommCareApplication.instance().isConsumerApp() && !alreadyCheckedForAppFilesChange) { checkForChangedCCZ(); @@ -241,11 +242,11 @@ private void dispatch() { !shortcutExtraWasConsumed) { // CommCare was launched from a shortcut handleShortcutLaunch(); - } else if(redirectToConnectHome) { + } else if (redirectToConnectHome) { redirectToConnectHome = false; CommCareApplication.instance().closeUserSession(); ConnectNavHelper.INSTANCE.goToConnectJobsList(this); - } else if(redirectToConnectOpportunityInfo) { + } else if (redirectToConnectOpportunityInfo) { redirectToConnectOpportunityInfo = false; ConnectJobRecord job = ConnectJobHelper.INSTANCE.getJobForSeatedApp(this); ConnectNavHelper.INSTANCE.goToActiveInfoForJob(this, job, true); @@ -278,19 +279,31 @@ private boolean isDbInBadState() { int dbState = CommCareApplication.instance().getDatabaseState(); if (dbState == CommCareApplication.STATE_LEGACY_DETECTED) { // Starting from CommCare 2.44, we don't supoort upgrading from Legacy DB - CommCareLifecycleUtils.triggerHandledAppExit(this, + CommCareLifecycleUtils.triggerHandledAppExit( + this, getString(R.string.legacy_failure), - getString(R.string.legacy_failure_title), false, false); + getString(R.string.legacy_failure_title), + false, + false + ); return true; } else if (dbState == CommCareApplication.STATE_MIGRATION_FAILED) { - CommCareLifecycleUtils.triggerHandledAppExit(this, + CommCareLifecycleUtils.triggerHandledAppExit( + this, getString(R.string.migration_definite_failure), - getString(R.string.migration_failure_title), false, false); + getString(R.string.migration_failure_title), + false, + false + ); return true; } else if (dbState == CommCareApplication.STATE_MIGRATION_QUESTIONABLE) { - CommCareLifecycleUtils.triggerHandledAppExit(this, + CommCareLifecycleUtils.triggerHandledAppExit( + this, getString(R.string.migration_possible_failure), - getString(R.string.migration_failure_title), false, true); + getString(R.string.migration_failure_title), + false, + true + ); return true; } else if (dbState == CommCareApplication.STATE_CORRUPTED) { handleDamagedApp(); @@ -319,13 +332,16 @@ private void handleDamagedApp() { private void startRecoveryExecutionActivity() { startActivityForResult( new Intent(this, ExecuteRecoveryMeasuresActivity.class), - RECOVERY_MEASURES); + RECOVERY_MEASURES + ); } private void createNoStorageDialog() { - CommCareLifecycleUtils.triggerHandledAppExit(this, + CommCareLifecycleUtils.triggerHandledAppExit( + this, Localization.get("app.storage.missing.message"), - Localization.get("app.storage.missing.title")); + Localization.get("app.storage.missing.title") + ); } private void launchLoginScreen() { @@ -336,7 +352,7 @@ private void launchLoginScreen() { i.putExtra(IS_LAUNCH_FROM_CONNECT, getLaunchedFromConnect()); String sessionEndpointAppID = getSessionEndpointAppId(); - if(sessionEndpointAppID == null && redirectToLoginAppId != null) { + if (sessionEndpointAppID == null && redirectToLoginAppId != null) { sessionEndpointAppID = redirectToLoginAppId; redirectToLoginAppId = null; } @@ -348,10 +364,12 @@ private void launchLoginScreen() { startActivityForResult(i, LOGIN_USER); waitingForActivityResultFromLogin = true; } else { - Log.w(TAG, + Log.w( + TAG, "Login redirection bug occurred; DispatchActivity is attempting to launch " + "a new LoginActivity while it is still waiting for a result from " + - "another one."); + "another one." + ); } } @@ -371,8 +389,10 @@ private void launchHomeScreen() { 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()); + HomeScreenBaseActivity.addPendingDataExtra( + i, + CommCareApplication.instance().getCurrentSessionWrapper().getSession() + ); } else { i = new Intent(this, StandardHomeActivity.class); } @@ -431,9 +451,11 @@ private void handleUnvalidatedApp() { } else { // Means that there are no usable apps, but there are multiple apps who all don't have // MM verified -- show an error message and shut down - CommCareLifecycleUtils.triggerHandledAppExit(this, + CommCareLifecycleUtils.triggerHandledAppExit( + this, Localization.get("multiple.apps.unverified.message"), - Localization.get("multiple.apps.unverified.title")); + Localization.get("multiple.apps.unverified.title") + ); } } @@ -445,24 +467,35 @@ private void handleExternalLaunch() { String sessionRequest = this.getIntent().getStringExtra(SESSION_REQUEST); SessionStateDescriptor ssd = new SessionStateDescriptor(); ssd.fromBundle(sessionRequest); - CommCareApplication.instance().getCurrentSessionWrapper().loadFromStateDescription(ssd); + CommCareApplication.instance() + .getCurrentSessionWrapper() + .loadFromStateDescription(ssd); i = new Intent(this, StandardHomeActivity.class); } else if (getIntent().hasExtra(SESSION_ENDPOINT_ID)) { String sessionEndpointId = this.getIntent().getStringExtra(SESSION_ENDPOINT_ID); Bundle args = this.getIntent().getBundleExtra(SESSION_ENDPOINT_ARGUMENTS_BUNDLE); - ArrayList argsList = this.getIntent().getStringArrayListExtra(SESSION_ENDPOINT_ARGUMENTS_LIST); + ArrayList argsList = this.getIntent().getStringArrayListExtra( + SESSION_ENDPOINT_ARGUMENTS_LIST + ); i = new Intent(this, StandardHomeActivity.class); i.putExtra(SESSION_ENDPOINT_ID, sessionEndpointId); i.putExtra(SESSION_ENDPOINT_ARGUMENTS_BUNDLE, args); i.putStringArrayListExtra(SESSION_ENDPOINT_ARGUMENTS_LIST, argsList); - i.putExtra(CC_LAUNCH_REQUIRE_SYNC, - getIntent().getBooleanExtra(CC_LAUNCH_REQUIRE_SYNC, false)); + i.putExtra( + CC_LAUNCH_REQUIRE_SYNC, + getIntent().getBooleanExtra(CC_LAUNCH_REQUIRE_SYNC, false) + ); } clearSessionEndpointIntentExtras(); if (i != null) { i.putExtra(WAS_EXTERNAL, true); - i.putExtra(EXIT_AFTER_FORM_SUBMISSION, - getIntent().getBooleanExtra(EXIT_AFTER_FORM_SUBMISSION, EXIT_AFTER_FORM_SUBMISSION_DEFAULT)); + i.putExtra( + EXIT_AFTER_FORM_SUBMISSION, + getIntent().getBooleanExtra( + EXIT_AFTER_FORM_SUBMISSION, + EXIT_AFTER_FORM_SUBMISSION_DEFAULT + ) + ); startActivityForResult(i, HOME_SCREEN); } } @@ -499,8 +532,14 @@ private boolean triggerLoginIfNeeded() { @Override protected void onActivityResult(int requestCode, int resultCode, Intent intent) { if (intent != null) { - needToExecuteRecoveryMeasures = intent.getBooleanExtra(EXECUTE_RECOVERY_MEASURES, false); - redirectToConnectOpportunityInfo = intent.getBooleanExtra(REDIRECT_TO_CONNECT_OPPORTUNITY_INFO, false); + needToExecuteRecoveryMeasures = intent.getBooleanExtra( + EXECUTE_RECOVERY_MEASURES, + false + ); + redirectToConnectOpportunityInfo = intent.getBooleanExtra( + REDIRECT_TO_CONNECT_OPPORTUNITY_INFO, + false + ); redirectToLoginAppId = intent.getStringExtra(EXTRA_APP_ID); forceSingleAppMode = intent.getBooleanExtra(EXTRA_FORCE_SINGLE_APP_MODE, true); } @@ -538,7 +577,7 @@ protected void onActivityResult(int requestCode, int resultCode, Intent intent) case HOME_SCREEN: if (resultCode == RESULT_CANCELED) { shouldFinish = !connectManagedLogin; - if(connectManagedLogin) { + if (connectManagedLogin) { redirectToConnectHome = true; } return; diff --git a/app/src/org/commcare/activities/SeatAppActivity.java b/app/src/org/commcare/activities/SeatAppActivity.java index c1a943e441..b9337bee7d 100644 --- a/app/src/org/commcare/activities/SeatAppActivity.java +++ b/app/src/org/commcare/activities/SeatAppActivity.java @@ -2,15 +2,11 @@ import android.content.Intent; import android.os.Bundle; -import android.os.Handler; -import android.os.Message; import android.widget.TextView; -import org.commcare.CommCareApp; -import org.commcare.CommCareApplication; import org.commcare.dalvik.R; -import org.commcare.android.database.global.models.ApplicationRecord; -import org.commcare.utils.MultipleAppsUtil; +import org.commcare.login.AppSeater; +import org.commcare.login.SeatResult; import org.javarosa.core.services.locale.Localization; /** @@ -20,11 +16,8 @@ */ public class SeatAppActivity extends CommonBaseActivity { - private static final String KEY_IN_PROGRESS = "initialization_in_progress"; public final static String KEY_APP_TO_SEAT = "app_to_seat"; - private boolean inProgress; - @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -32,74 +25,23 @@ protected void onCreate(Bundle savedInstanceState) { TextView tv = findViewById(R.id.text); tv.setText(Localization.get("seating.app")); - inProgress = savedInstanceState != null && - savedInstanceState.getBoolean(KEY_IN_PROGRESS, false); - - if (!inProgress) { - - String idOfAppToSeat = getIntent().getStringExtra(KEY_APP_TO_SEAT); - ApplicationRecord record = MultipleAppsUtil.getAppById(idOfAppToSeat); - - if (record == null) { - // No record was found for the given id - Intent i = new Intent(getIntent()); - setResult(RESULT_CANCELED, i); - finish(); - } - - ThreadHandler handler = new ThreadHandler(this); - Thread t = new Thread(new SeatAppProcess(record, handler)); - setInProgress(true); - t.start(); - } + String appId = getIntent().getStringExtra(KEY_APP_TO_SEAT); + new AppSeater().start( + this, + appId, + progress -> { + }, + this::finishWithResult + ); } - @Override - protected void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - outState.putBoolean(KEY_IN_PROGRESS, inProgress); + private void finishWithResult(SeatResult result) { + setResult(RESULT_OK, new Intent(getIntent())); + finish(); } @Override public void onBackPressed() { // Make it impossible to quit in the middle of this activity } - - private void setInProgress(boolean b) { - this.inProgress = b; - } - - private static class ThreadHandler extends Handler { - - private final SeatAppActivity activity; - - public ThreadHandler(SeatAppActivity a) { - this.activity = a; - } - - @Override - public void handleMessage(Message msg) { - activity.setInProgress(false); - Intent i = new Intent(activity.getIntent()); - activity.setResult(RESULT_OK, i); - activity.finish(); - } - } - - private static class SeatAppProcess implements Runnable { - - private final ApplicationRecord record; - private final ThreadHandler handler; - - public SeatAppProcess(ApplicationRecord record, ThreadHandler handler) { - this.record = record; - this.handler = handler; - } - - @Override - public void run() { - CommCareApplication.instance().initializeAppResources(new CommCareApp(this.record)); - handler.sendEmptyMessage(0); - } - } } diff --git a/app/src/org/commcare/login/AppSeater.kt b/app/src/org/commcare/login/AppSeater.kt new file mode 100644 index 0000000000..78ff3df735 --- /dev/null +++ b/app/src/org/commcare/login/AppSeater.kt @@ -0,0 +1,67 @@ +package org.commcare.login + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.commcare.CommCareApp +import org.commcare.CommCareApplication +import org.commcare.android.database.global.models.ApplicationRecord +import org.commcare.utils.MultipleAppsUtil + +sealed class SeatResult { + object Success : SeatResult() + + data class Failed( + val reason: SeatFailure, + ) : SeatResult() +} + +enum class SeatFailure { + APP_NOT_FOUND, + CORRUPTED, +} + +class AppSeater + @JvmOverloads + constructor( + private val recordLookup: (String) -> ApplicationRecord? = { MultipleAppsUtil.getAppById(it) }, + private val seatApp: (ApplicationRecord) -> Int = { record -> + val app = CommCareApp(record) + CommCareApplication.instance().initializeAppResources(app) + app.appResourceState + }, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + ) { + fun interface SeatResultCallback { + fun onResult(result: SeatResult) + } + + fun start( + lifecycleOwner: LifecycleOwner, + appId: String, + sink: LoginProgressListener, + callback: SeatResultCallback, + ): Job = + lifecycleOwner.lifecycleScope.launch { + callback.onResult(seatIfNeeded(appId, sink)) + } + + suspend fun seatIfNeeded( + appId: String, + sink: LoginProgressListener, + ): SeatResult { + sink.onProgress(LoginProgress(LoginPhase.Seating)) + val record = recordLookup(appId) ?: return SeatResult.Failed(SeatFailure.APP_NOT_FOUND) + val resourceState = withContext(ioDispatcher) { seatApp(record) } + + return if (resourceState == CommCareApplication.STATE_CORRUPTED) { + SeatResult.Failed(SeatFailure.CORRUPTED) + } else { + SeatResult.Success + } + } + } diff --git a/app/unit-tests/src/org/commcare/login/AppSeaterTest.kt b/app/unit-tests/src/org/commcare/login/AppSeaterTest.kt new file mode 100644 index 0000000000..3de3f1b63c --- /dev/null +++ b/app/unit-tests/src/org/commcare/login/AppSeaterTest.kt @@ -0,0 +1,113 @@ +package org.commcare.login + +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import org.commcare.CommCareApplication +import org.commcare.android.database.global.models.ApplicationRecord +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +private const val STATE_READY = 2 + +class AppSeaterTest { + private val sink = LoginProgressListener { } + + @Test + fun `missing record returns Failed APP_NOT_FOUND without seating`() = + runTest { + var seatCalled = false + val seater = + AppSeater( + recordLookup = { null }, + seatApp = { + seatCalled = true + STATE_READY + }, + ioDispatcher = Dispatchers.Unconfined, + ) + + val result = seater.seatIfNeeded("missing-app", sink) + + assertEquals(SeatResult.Failed(SeatFailure.APP_NOT_FOUND), result) + assertFalse(seatCalled) + } + + @Test + fun `ready resource state returns Success`() = + runTest { + val record = mockk(relaxed = true) + val seater = + AppSeater( + recordLookup = { record }, + seatApp = { STATE_READY }, + ioDispatcher = Dispatchers.Unconfined, + ) + + val result = seater.seatIfNeeded("app-1", sink) + + assertEquals(SeatResult.Success, result) + } + + @Test + fun `corrupted resource state returns Failed CORRUPTED`() = + runTest { + val record = mockk(relaxed = true) + val seater = + AppSeater( + recordLookup = { record }, + seatApp = { CommCareApplication.STATE_CORRUPTED }, + ioDispatcher = Dispatchers.Unconfined, + ) + + val result = seater.seatIfNeeded("app-1", sink) + + assertEquals(SeatResult.Failed(SeatFailure.CORRUPTED), result) + } + + @Test(expected = IllegalStateException::class) + fun `seatApp throwing propagates instead of being swallowed`() = + runTest { + val record = mockk(relaxed = true) + val seater = + AppSeater( + recordLookup = { record }, + seatApp = { throw IllegalStateException("seat blew up") }, + ioDispatcher = Dispatchers.Unconfined, + ) + + seater.seatIfNeeded("app-1", sink) + } + + @Test(expected = RuntimeException::class) + fun `recordLookup throwing propagates instead of being swallowed`() = + runTest { + val seater = + AppSeater( + recordLookup = { throw RuntimeException("db unavailable") }, + seatApp = { STATE_READY }, + ioDispatcher = Dispatchers.Unconfined, + ) + + seater.seatIfNeeded("app-1", sink) + } + + @Test + fun `emits Seating progress`() = + runTest { + val record = mockk(relaxed = true) + val phases = mutableListOf() + val seater = + AppSeater( + recordLookup = { record }, + seatApp = { STATE_READY }, + ioDispatcher = Dispatchers.Unconfined, + ) + + seater.seatIfNeeded("app-1") { phases += it.phase } + + assertTrue(phases.contains(LoginPhase.Seating)) + } +}