Skip to content
Draft
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
1 change: 1 addition & 0 deletions RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ This file is meant as an easy way for us to collate notes and change logs across
- Confirm that entering an incorrect verification code still displays the "incorrect code" error and does not silently trigger a new OTP.
- After signing out of PersonalID, the nav drawer should offer sign-in/register rather than a "Logged out of PersonalID" error asking you to configure the account again. Worth repeating a few times, and while push notifications are actively arriving, since the original failure depended on timing.
- Confirm signing back in still works and that notifications and messaging behave normally afterward.
- Signing out while a Connect request is landing should no longer be able to wipe the account and restart the app. Signing out repeatedly with notifications arriving, then signing back in, should behave normally every time.

## CommCare 2.63.4

Expand Down
6 changes: 5 additions & 1 deletion app/src/org/commcare/CommCareApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
import org.commcare.models.database.connect.DatabaseConnectOpenHelper;
import org.commcare.models.database.global.DatabaseGlobalOpenHelper;
import org.commcare.models.database.user.UserSandboxUtils;
import org.commcare.connect.database.ConnectDatabaseUnavailableException;
import org.commcare.connect.database.ConnectDatabaseUtils;
import org.commcare.models.database.user.DatabaseUserOpenHelper;
import org.commcare.models.database.user.models.CommCareEntityStorageCache;
Expand Down Expand Up @@ -1251,7 +1252,10 @@ public void onStateChanged(@NonNull LifecycleOwner source, @NonNull Lifecycle.Ev
public IDatabase getConnectDbOpenHelper(Context context) {
byte[] passphrase = ConnectDatabaseUtils.getConnectDbPassphrase(context);
if (passphrase == null || passphrase.length == 0) {
throw new IllegalStateException("Attempting to access Connect DB without a passphrase");
//Typed so callers can tell "no account to open a DB for" apart from a DB that won't
//open, which is a genuinely broken DB
throw new ConnectDatabaseUnavailableException(
"Attempting to access Connect DB without a passphrase");
}
return new EncryptedDatabaseAdapter(new DatabaseConnectOpenHelper(
context, UserSandboxUtils.getSqlCipherEncodedKey(passphrase)));
Expand Down
7 changes: 6 additions & 1 deletion app/src/org/commcare/connect/PersonalIdManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import org.commcare.connect.network.ConnectSsoHelper;
import org.commcare.connect.network.ConnectSsoSyncHelper;
import org.commcare.connect.network.TokenExceptionHandler;
import org.commcare.connect.repository.ConnectRequestManager;
import org.commcare.connect.workers.ConnectHeartbeatWorker;
import org.commcare.connect.workers.ConnectReleaseTogglesWorker;
import org.commcare.core.network.AuthInfo;
Expand Down Expand Up @@ -182,12 +183,16 @@ public void forgetUser(String reason) {
// Cancel periodic push notification retrieval when user logs out
NotificationsSyncWorkerManager.cancelPeriodicPushNotificationRetrieval(CommCareApplication.instance());

ConnectReleaseTogglesWorker.Companion.cancelPeriodicFetch(CommCareApplication.instance());

//Drop in-flight API requests
ConnectRequestManager.INSTANCE.cancelAll();

ConnectUserDatabaseUtil.forgetUser();

// remove notification read / unread preferences
NotificationPrefs.INSTANCE.removeNotificationReadPref(CommCareApplication.instance());

ConnectReleaseTogglesWorker.Companion.cancelPeriodicFetch(CommCareApplication.instance());
PersonalIdUnlocker.INSTANCE.resetSession();
PersonalIdUserPreferences.clear();
}
Expand Down
30 changes: 29 additions & 1 deletion app/src/org/commcare/connect/database/ConnectDatabaseHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import org.commcare.android.database.connect.models.ConnectLinkedAppRecord;
import org.commcare.android.database.connect.models.ConnectUserRecord;
import org.commcare.android.database.global.models.ConnectKeyRecord;
import org.commcare.android.database.global.models.GlobalErrorRecord;
import org.commcare.connect.PersonalIdManager;
import org.commcare.connect.network.SsoToken;
Expand All @@ -14,6 +15,7 @@
import org.commcare.models.database.SqlStorage;
import org.commcare.models.database.connect.DatabaseConnectOpenHelper;
import org.commcare.modern.database.Table;
import org.commcare.util.LogTypes;
import org.commcare.utils.GlobalErrorUtil;
import org.commcare.utils.GlobalErrors;
import org.javarosa.core.services.Logger;
Expand All @@ -30,7 +32,8 @@
public class ConnectDatabaseHelper {
private static final Object connectDbHandleLock = new Object();
public static IDatabase connectDatabase;
static boolean dbBroken = false;
//Written under connectDbHandleLock, but isDbBroken() reads it from other threads
private static volatile boolean dbBroken = false;

public static void handleReceivedDbPassphrase(Context context, String passphrase) {
ConnectDatabaseUtils.storeConnectDbPassphrase(context, passphrase);
Expand All @@ -52,6 +55,14 @@ public IDatabase getHandle() {
if (connectDatabase == null || !connectDatabase.isOpen()) {
try {
connectDatabase = CommCareApplication.instance().getConnectDbOpenHelper(context);
} catch (ConnectDatabaseUnavailableException e) {
//There's no account to open a DB for, which is the expected state once
//the user signs out. Don't flag the DB or raise the global error: that
//would wipe the account and restart the process over work that simply
//raced with sign-out
Logger.log(LogTypes.TYPE_MAINTENANCE,
"Skipping Connect DB access, there is no passphrase to open it");
throw e;
} catch (Exception e) {
//Flag the DB as broken if we hit an error opening it (usually means corrupted or bad encryption)
dbBroken = true;
Expand All @@ -65,6 +76,23 @@ public IDatabase getHandle() {
});
}

/**
* Deletes the Connect DB along with the passphrase used to open it.
* <p>
* Held under the same lock as {@link #getConnectStorage} so that a storage operation racing
* with sign-out either completes against the live DB or sees it already gone. Without the
* lock a reader can open a handle partway through, and re-flag the DB as broken after this
* has cleared the flag.
*/
static void clearConnectData() {
synchronized (connectDbHandleLock) {
teardown();
DatabaseConnectOpenHelper.deleteDb();
CommCareApplication.instance().getGlobalStorage(ConnectKeyRecord.class).removeAll();
dbBroken = false;
}
}

public static void teardown() {
synchronized (connectDbHandleLock) {
if (connectDatabase != null && connectDatabase.isOpen()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.commcare.connect.database

/**
* Thrown when Connect storage is accessed with no passphrase to open it, which is the expected state
* once the user has signed out of PersonalId.
*
* Deliberately not a [org.commcare.connect.network.LoginInvalidatedException]: that one is
* reserved for a DB that can't be opened despite having a passphrase (corruption or bad
* encryption), and reaching the uncaught handler with it wipes the account and restarts the
* process. A missing passphrase just means the caller raced with sign-out and its work is no
* longer wanted.
*
* @author dviggiano
*/
class ConnectDatabaseUnavailableException(
message: String,
) : RuntimeException(message)
Comment on lines +15 to +17

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline app/src/org/commcare/connect --items all --type function

printf '%s\n' '== Connect storage access sites =='
rg -n -C 3 'getConnectStorage\s*\(|getConnectDbOpenHelper\s*\(' app/src

printf '%s\n' '== Explicit unavailable-database handling =='
rg -n -C 3 'ConnectDatabaseUnavailableException' app/src

Repository: dimagi/commcare-android

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Database helper behavior =='
sed -n '45,90p' app/src/org/commcare/connect/database/ConnectDatabaseHelper.java

printf '%s\n' '== All storage call sites with enclosing declarations =='
python3 - <<'PY'
from pathlib import Path
import re

root = Path("app/src")
call = re.compile(r'\b(?:ConnectDatabaseHelper\.)?getConnectStorage\s*\(')
decl = re.compile(
    r'\b(?:public|private|protected|internal|override|static|suspend|fun|'
    r'final|abstract|open|class|object|void|[A-Za-z_][\w<>,.? ]*)\s+'
    r'([A-Za-z_]\w*)\s*\([^;{}]*\)\s*(?::\s*[^{=]+)?\s*(?:\{|=)'
)
for path in sorted(root.rglob("*")):
    if path.suffix not in {".java", ".kt"}:
        continue
    text = path.read_text(errors="replace")
    lines = text.splitlines()
    starts = []
    for i, line in enumerate(lines):
        m = decl.search(line)
        if m:
            starts.append((i, m.group(1)))
    for i, line in enumerate(lines):
        if call.search(line):
            candidates = [x for x in starts if x[0] <= i]
            name = candidates[-1][1] if candidates else "<unknown>"
            print(f"{path}:{i+1}: {name}: {line.strip()}")
PY

printf '%s\n' '== Unavailable exception references and catches =='
rg -n -C 2 'ConnectDatabaseUnavailableException' app/src --glob '*.{java,kt}'

printf '%s\n' '== Connect worker implementations =='
rg -n -C 8 'class .*Worker|override fun doWork|Result\.(success|retry|failure)|ConnectJobUtils|ConnectAppDatabaseUtil|ConnectTaskUtils|ConnectMessagingDatabaseHelper' app/src/org/commcare/connect/workers app/src/org/commcare/connect --glob '*Worker*.{java,kt}' --glob '*.kt' --glob '*.java' | head -n 500

Repository: dimagi/commcare-android

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Parser implementations and storage-related calls =='
for f in $(fd -t f -e kt -e java app/src/org/commcare/connect/network); do
    if rg -q 'class .*Parser|object .*Parser|fun parse|ConnectDatabaseHelper|getConnectStorage|ConnectJobUtils|ConnectAppDatabaseUtil|ConnectTaskUtils|ConnectMessagingDatabaseHelper' "$f"; then
        echo "---- $f"
        rg -n -C 6 'class .*Parser|object .*Parser|fun parse|ConnectDatabaseHelper|getConnectStorage|ConnectJobUtils|ConnectAppDatabaseUtil|ConnectTaskUtils|ConnectMessagingDatabaseHelper' "$f"
    fi
done

printf '%s\n' '== Worker source files =='
for f in $(fd -t f -e kt -e java app/src/org/commcare/connect/workers); do
    echo "---- $f"
    cat -n "$f"
done

printf '%s\n' '== Callers of parser classes and database utility entry points =='
rg -n -C 5 'ConnectOpportunitiesParser|LearningAppProgressResponseParser|DeliveryAppProgressResponseParser|ConnectReleaseTogglesParser|RetrieveHqTokenResponseParser|ConnectTokenResponseParser|LinkHqWorkerResponseParser|RetrieveNotificationsResponseParser|RetrieveWorkHistoryResponseParser|RetrieveChannelEncryptionKeyResponseParser|ConnectJobUtils\.|ConnectAppDatabaseUtil\.|ConnectTaskUtils\.|ConnectMessagingDatabaseHelper\.' app/src --glob '*.{java,kt}' | head -n 1000

Repository: dimagi/commcare-android

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Focused parser source excerpts =='
for f in \
  app/src/org/commcare/connect/network/connect/parser/ConnectOpportunitiesParser.kt \
  app/src/org/commcare/connect/network/connect/parser/LearningAppProgressResponseParser.kt \
  app/src/org/commcare/connect/network/connect/parser/DeliveryAppProgressResponseParser.kt \
  app/src/org/commcare/connect/network/connect/parser/ConnectReleaseTogglesParser.kt \
  app/src/org/commcare/connect/network/connectId/parser/RetrieveHqTokenResponseParser.kt \
  app/src/org/commcare/connect/network/connectId/parser/ConnectTokenResponseParser.kt \
  app/src/org/commcare/connect/network/connectId/parser/LinkHqWorkerResponseParser.kt \
  app/src/org/commcare/connect/network/connectId/parser/RetrieveNotificationsResponseParser.kt \
  app/src/org/commcare/connect/network/connectId/parser/RetrieveWorkHistoryResponseParser.kt \
  app/src/org/commcare/connect/network/connectId/parser/RetrieveChannelEncryptionKeyResponseParser.kt; do
  if [ -f "$f" ]; then
    echo "---- $f"
    cat -n "$f"
  fi
done

printf '%s\n' '== Focused worker source excerpts =='
for f in $(fd -t f -e kt -e java app/src/org/commcare/connect/workers); do
  echo "---- $f"
  cat -n "$f"
done

Repository: dimagi/commcare-android

Length of output: 26957


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Direct storage callers grouped by file =='
python3 - <<'PY'
from pathlib import Path
import re

pat = re.compile(r'\bgetConnectStorage\s*\(')
for path in sorted(Path("app/src").rglob("*")):
    if path.suffix not in {".java", ".kt"}:
        continue
    lines = path.read_text(errors="replace").splitlines()
    hits = [i + 1 for i, line in enumerate(lines) if pat.search(line)]
    if hits:
        print(f"{path}: {','.join(map(str, hits))}")

print("== Exception catch blocks by file ==")
catch = re.compile(r'\bcatch\s*\([^)]*ConnectDatabaseUnavailableException[^)]*\)')
for path in sorted(Path("app/src").rglob("*")):
    if path.suffix not in {".java", ".kt"}:
        continue
    lines = path.read_text(errors="replace").splitlines()
    for i, line in enumerate(lines):
        if catch.search(line):
            print(f"{path}:{i+1}: {line.strip()}")
PY

printf '%s\n' '== Worker and parser names referenced by production code =='
rg -n 'class [A-Za-z0-9_]*(Worker|Parser)|object [A-Za-z0-9_]*(Worker|Parser)|[A-Za-z0-9_]*(Worker|Parser)\(' app/src --glob '*.{java,kt}' | head -n 500

Repository: dimagi/commcare-android

Length of output: 24805


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

root = Path("app/src")
call = re.compile(r'\bConnectDatabaseHelper\s*\.\s*getConnectStorage\s*\(')
decl = re.compile(
    r'(?:(?:public|private|protected|internal|override|static|final|suspend|open)\s+)*'
    r'(?:[\w.<>,?\[\]]+\s+)+([A-Za-z_]\w*)\s*\([^;{}]*\)\s*(?:[:][^{=]+)?(?:\{|=)'
)

def enclosing(lines, line_no):
    found = []
    for i, line in enumerate(lines[:line_no + 1]):
        m = decl.search(line)
        if m:
            found.append((i + 1, m.group(1)))
    return found[-1] if found else (None, "<unknown>")

for path in sorted(root.rglob("*")):
    if path.suffix not in {".java", ".kt"}:
        continue
    lines = path.read_text(errors="replace").splitlines()
    for i, line in enumerate(lines):
        if call.search(line):
            line_no, method = enclosing(lines, i)
            print(f"{path}:{i+1}: {method} (declared at {line_no}): {line.strip()}")

print("== Catch sites ==")
catch = re.compile(r'\bcatch\s*\([^)]*ConnectDatabaseUnavailableException[^)]*\)')
for path in sorted(root.rglob("*")):
    if path.suffix not in {".java", ".kt"}:
        continue
    for i, line in enumerate(path.read_text(errors="replace").splitlines()):
        if catch.search(line):
            print(f"{path}:{i+1}: {line.strip()}")
PY

Repository: dimagi/commcare-android

Length of output: 10157


Handle the sign-out race in Connect parsers and workers.

ConnectDatabaseHelper is the only catch site, but parsers and MessagingChannelsKeySyncWorker can still propagate ConnectDatabaseUnavailableException. The isloggedIn() check in ConnectReleaseTogglesParser does not prevent a concurrent sign-out. Return the expected no-op result when this exception occurs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/org/commcare/connect/database/ConnectDatabaseUnavailableException.kt`
around lines 15 - 17, Update the Connect parser and worker flows, especially
ConnectReleaseTogglesParser and MessagingChannelsKeySyncWorker, to catch
ConnectDatabaseUnavailableException around database-dependent operations and
return their existing expected no-op result. Do not rely solely on isloggedIn(),
since sign-out can occur concurrently; preserve normal behavior for successful
operations and other failures.

Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@

import android.content.Context;

import org.commcare.CommCareApplication;
import org.commcare.android.database.connect.models.ConnectUserRecord;
import org.commcare.android.database.global.models.ConnectKeyRecord;
import org.commcare.models.database.connect.DatabaseConnectOpenHelper;

public class ConnectUserDatabaseUtil {

Expand Down Expand Up @@ -35,10 +32,7 @@ public static void storeUser(Context context, ConnectUserRecord user) {
}

public static void forgetUser() {
DatabaseConnectOpenHelper.deleteDb();
CommCareApplication.instance().getGlobalStorage(ConnectKeyRecord.class).removeAll();
ConnectDatabaseHelper.dbBroken = false;
ConnectDatabaseHelper.teardown();
ConnectDatabaseHelper.clearConnectData();
}

public static boolean hasConnectAccess(Context context) {
Expand Down
7 changes: 6 additions & 1 deletion app/src/org/commcare/pn/workers/NotificationsSyncWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import org.commcare.connect.ConnectConstants.OPPORTUNITY_STATUS_DELIVERY
import org.commcare.connect.ConnectConstants.OPPORTUNITY_STATUS_LEARN
import org.commcare.connect.ConnectConstants.OPPORTUNITY_UUID
import org.commcare.connect.ConnectJobHelper
import org.commcare.connect.PersonalIdManager
import org.commcare.connect.database.ConnectJobUtils
import org.commcare.connect.database.NotificationRecordDatabaseHelper.getNotificationById
import org.commcare.dalvik.R
Expand Down Expand Up @@ -227,7 +228,11 @@ class NotificationsSyncWorker(
}

private fun raiseFCMPushNotificationIfApplicable() {
if (showNotification && !isNotificationRead() && checkForOpportunityStatus()) {
if (PersonalIdManager.getInstance().isloggedIn() &&
showNotification &&
!isNotificationRead() &&
checkForOpportunityStatus()
) {
FirebaseMessagingUtil.handleNotification(appContext, notificationPayload, null, true)
}
}
Expand Down
48 changes: 36 additions & 12 deletions app/src/org/commcare/utils/PushNotificationApiHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ import org.commcare.connect.network.connectId.parser.NotificationParseResult
import org.commcare.pn.helper.NotificationBroadcastHelper
import org.commcare.pn.workers.MessagingChannelsKeySyncWorker
import org.commcare.preferences.NotificationPrefs
import org.commcare.util.LogTypes
import org.commcare.utils.coroutines.DispatcherProvider
import org.javarosa.core.services.Logger
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
Expand Down Expand Up @@ -85,20 +87,42 @@ object PushNotificationApiHelper {

scheduleMessagingChannelsKeySync(context)
CoroutineScope(DispatcherProvider.io()).launch {
val (savedNotifications, savedNotificationIds) = processParsedDataIntoDB(context, parseResult)
// runCatching keeps a storage failure from escaping to the uncaught
// exception handler, and guarantees the continuation is resumed exactly once
val result =
runCatching {
val (savedNotifications, savedNotificationIds) =
processParsedDataIntoDB(context, parseResult)

// Update notification preferences and send broadcasts
if (savedNotificationIds.isNotEmpty()) {
NotificationPrefs.setNotificationAsUnread(context)
}
if (savedNotificationIds.isNotEmpty() || parseResult.messagingNotificationIds.isNotEmpty()) {
NotificationBroadcastHelper.sendNewNotificationBroadcast(context)
}
// Update notification preferences and send broadcasts
if (savedNotificationIds.isNotEmpty()) {
NotificationPrefs.setNotificationAsUnread(context)
}
if (savedNotificationIds.isNotEmpty() || parseResult.messagingNotificationIds.isNotEmpty()) {
NotificationBroadcastHelper.sendNewNotificationBroadcast(context)
}

// Acknowledge all notifications (both stored and messaging)
acknowledgeNotificationsReceipt(context, savedNotificationIds + parseResult.messagingNotificationIds)
// Acknowledge all notifications (both stored and messaging)
val acknowledged =
acknowledgeNotificationsReceipt(
context,
savedNotificationIds + parseResult.messagingNotificationIds,
)
if (!acknowledged) {
// Not treated as a failure: the notifications are stored, and the
// server will resend the unacknowledged ones on the next sync
Logger.log(
LogTypes.TYPE_MAINTENANCE,
"Failed to acknowledge receipt of retrieved notifications",
)
}

continuation.resume(Result.success(savedNotifications))
savedNotifications
}.onFailure {
Logger.exception("Error storing retrieved notifications", it)
}

continuation.resume(result)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -166,7 +190,7 @@ object PushNotificationApiHelper {
if (savedNotificationIds.isEmpty()) {
return true
}
val user = ConnectUserDatabaseUtil.getUser(context)
val user = ConnectUserDatabaseUtil.getUser(context) ?: return false
return suspendCoroutine { continuation ->
object : PersonalIdApiHandler<Boolean>() {
override fun onSuccess(result: Boolean) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package org.commcare.connect.database

import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.commcare.CommCareTestApplication
import org.commcare.android.database.connect.models.ConnectUserRecord
import org.commcare.connect.network.LoginInvalidatedException
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference

/**
* Covers Connect storage being reached around sign-out, which an in-flight request can still do
* because the sign-in check and the storage access aren't atomic.
*
* The invariant under test is that losing that race stays survivable: it must never flag the DB as
* broken or raise [LoginInvalidatedException], because reaching the uncaught handler with that wipes
* the account and restarts the process.
*/
@Config(application = CommCareTestApplication::class)
@RunWith(AndroidJUnit4::class)
class ConnectDatabaseHelperTest {
private val context: Context = CommCareTestApplication.instance()

@After
fun tearDown() {
ConnectUserDatabaseUtil.forgetUser()
}

private fun readUserStorage() =
ConnectDatabaseHelper
.getConnectStorage(context, ConnectUserRecord::class.java)
.getRecordsForValues(emptyArray(), emptyArray())

@Test
fun testForgetUserLeavesNoPassphraseAndNoBrokenFlag() {
ConnectUserDatabaseUtil.forgetUser()

// the passphrase going away is what tells the storage layer there's no account to open a
// DB for, and it has to land together with the deletion rather than partway through it
assertNull(ConnectDatabaseUtils.getKeyRecord())
assertFalse(ConnectDatabaseHelper.dbExists())
assertFalse(ConnectDatabaseHelper.isDbBroken())
}

@Test
fun testGetUserAfterForgetUserReturnsNullWithoutFlaggingDb() {
ConnectUserDatabaseUtil.forgetUser()

// the ordinary "do I have an account" check treats an absent DB as no account, so it stays
// cheap and side-effect free
assertNull(ConnectUserDatabaseUtil.getUser(context))
assertFalse(ConnectDatabaseHelper.isDbBroken())
}

@Test
fun testStorageReadRacingSignOutNeverInvalidatesLogin() {
val readerReady = CountDownLatch(1)
val signOutDone = CountDownLatch(1)
val fatal = AtomicReference<Throwable?>(null)

val reader =
Thread {
readerReady.countDown()
// keep hammering storage across the sign-out so at least one read lands on either
// side of it, and ideally one lands mid-teardown
repeat(200) {
try {
readUserStorage()
} catch (expected: ConnectDatabaseUnavailableException) {
// the read can't succeed once the account is gone, it just has to fail
// survivably
} catch (t: Throwable) {
if (t is LoginInvalidatedException) {
fatal.compareAndSet(null, t)
}
}
}
signOutDone.countDown()
}

reader.start()
assertTrue(readerReady.await(5, TimeUnit.SECONDS))
ConnectUserDatabaseUtil.forgetUser()
reader.join(TimeUnit.SECONDS.toMillis(30))

assertNull("A read racing sign-out invalidated the login", fatal.get())
assertFalse("A read racing sign-out flagged the Connect DB as broken", ConnectDatabaseHelper.isDbBroken())
}
}
Loading