Skip to content

Migrate device reports to Android Keystore-backed key#3567

Draft
avazirna wants to merge 3 commits intovaz/encrypt-device-reports-with-android-keystorefrom
vaz/migrate-device-reports-to-android-keystore
Draft

Migrate device reports to Android Keystore-backed key#3567
avazirna wants to merge 3 commits intovaz/encrypt-device-reports-with-android-keystorefrom
vaz/migrate-device-reports-to-android-keystore

Conversation

@avazirna
Copy link
Contributor

Product Description

No user-facing changes.

Technical Summary

Device report files (log submissions) were previously encrypted with a randomly generated AES key stored as a @Persisting field on each DeviceReportRecord. This PR replaces that per-record key with a single Android Keystore-backed AES key managed by a new SessionManager class, improving key management and removing the need to persist encryption keys in the database.

Key changes:

  • SessionManager (new): lazily initialises and caches a Keystore-backed AES-CBC key via AesKeyStoreHandler
  • DeviceReportRecord: removed aesKey field; openOutputStream() now delegates to SessionManager
  • EncryptionIO: createFileOutputStream and getFileInputStream updated to support IV-aware encryption (IV length + IV bytes prepended to ciphertext) when using a Keystore key
  • EncryptedFileBody: stores Key/transformation/isKeyFromAndroidKeyStore instead of a pre-built Cipher; reads IV from stream on writeTo()
  • FormUploadUtil.getDecryptCipher: updated signature to accept Key, transformation, and iv
  • DB migration v29→v30: re-serialises DeviceReportRecord blobs to strip the stale aesKey bytes, and re-keys all existing device report files from the old per-record AES key to the Keystore key using a write-to-temp-then-rename strategy to avoid data loss on failure
  • DeviceReportRecordPreV30 (new): legacy model used only during migration to deserialise the old blob format

Feature Flag

None.

Safety Assurance

Safety story

  • Existing call sites for EncryptionIO, FormUploadUtil, and HybridFileBackedSqlStorage that use per-record AES keys pass transformation=null and isKeyFromAndroidKeyStore=false, preserving existing behaviour unchanged.
  • The migration re-keys files using a temp file + rename pattern — the original file is only deleted after the new encrypted copy is written successfully.
  • Tested locally: existing device report files survive the migration and are correctly decrypted during log submission.

Automated test coverage

No existing unit tests cover DeviceReportRecord directly. The migration path and EncryptionIO changes are covered by manual testing.

QA Plan

  • Install an older build with pending unsent device reports, upgrade to this build, verify reports are submitted successfully.
  • Verify new device reports are created and submitted after migration.

Labels and Review

  • Do we need to enhance the manual QA test coverage ? If yes, the "QA Note" label is set correctly
  • Does the PR introduce any major changes worth communicating ? If yes, the "Release Note" label is set and a "Release Note" is specified in PR description.
  • Risk label is set correctly
  • The set of people pinged as reviewers is appropriate for the level of risk of the change

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 25, 2026

📝 Walkthrough

Walkthrough

This pull request migrates the CommCare encryption key provisioning strategy from per-record symmetric keys to session-level keystore-backed encryption. The core API changes refactor EncryptionIO methods to accept generic Key objects, transformation strings, and an Android Keystore flag instead of SecretKeySpec. A new SessionManager provides singleton-like access to session-level encryption keys. A database schema upgrade (v29→v30) includes migration logic that converts existing DeviceReportRecord entries by rekeying device report files from per-record AES keys to the session-based keystore key. Multiple call sites across the codebase are updated to supply the new parameters when invoking encryption/decryption methods.

Sequence Diagram(s)

sequenceDiagram
    participant UserDatabaseUpgrader
    participant DeviceReportRecordPreV30
    participant SessionManager
    participant EncryptionIO
    participant FileSystem

    UserDatabaseUpgrader->>DeviceReportRecordPreV30: Read old records from storage
    DeviceReportRecordPreV30-->>UserDatabaseUpgrader: Return fileName + aesKey
    
    loop For each old record
        UserDatabaseUpgrader->>FileSystem: Read encrypted device report file
        FileSystem-->>UserDatabaseUpgrader: Encrypted bytes (with old AES key)
        
        UserDatabaseUpgrader->>EncryptionIO: Decrypt with old aesKey
        EncryptionIO-->>UserDatabaseUpgrader: Decrypted bytes
        
        UserDatabaseUpgrader->>SessionManager: Get session encryption key
        SessionManager-->>UserDatabaseUpgrader: Key + transformation from keystore
        
        UserDatabaseUpgrader->>EncryptionIO: Re-encrypt with session key
        EncryptionIO->>FileSystem: Write re-encrypted file
        FileSystem-->>EncryptionIO: File written
        
        UserDatabaseUpgrader->>UserDatabaseUpgrader: Create new DeviceReportRecord
    end
    
    UserDatabaseUpgrader->>FileSystem: Persist updated DeviceReportRecord entries
    FileSystem-->>UserDatabaseUpgrader: Schema v30 committed
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.64% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description check ✅ Passed The PR description includes all required template sections: Product Description, Technical Summary, Feature Flag, Safety Assurance (with safety story, test coverage, and QA plan), and Labels and Review checklist.
Title check ✅ Passed The PR title 'Migrate device reports to Android Keystore-backed key' accurately summarizes the main objective of the changeset: migrating device report encryption from per-record AES keys to a shared Android Keystore-backed key via SessionManager.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch vaz/migrate-device-reports-to-android-keystore

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/org/commcare/utils/FormUploadUtil.java (1)

73-90: ⚠️ Potential issue | 🟠 Major

getDecryptCipher should fail fast, not return null.

Returning null here pushes crypto initialization failures into downstream NullPointerExceptions. Throw a runtime exception at the source instead.

💡 Proposed fix
 public static Cipher getDecryptCipher(Key key, String transformation, byte[] iv) {
     Cipher cipher;
     try {
         cipher = Cipher.getInstance(Objects.requireNonNullElse(transformation, "AES"));
         if (iv != null) {
             cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv));
         } else {
             cipher.init(Cipher.DECRYPT_MODE, key);
         }

         return cipher;
-        //TODO: Something smart here;
     } catch (NoSuchAlgorithmException |
             NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException e) {
-        e.printStackTrace();
+        throw new IllegalStateException("Failed to initialize decrypt cipher", e);
     }
-    return null;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/utils/FormUploadUtil.java` around lines 73 - 90, The
getDecryptCipher method currently catches crypto exceptions and returns null;
change it to fail fast by rethrowing a runtime exception instead of returning
null: inside getDecryptCipher (the method handling Cipher.getInstance and
cipher.init) catch the NoSuchAlgorithmException, NoSuchPaddingException,
InvalidKeyException, and InvalidAlgorithmParameterException and throw a new
RuntimeException (or IllegalStateException) that wraps the caught exception and
includes a clear message (e.g., "Failed to initialize decrypt cipher"), so
callers never receive null and the original exception is preserved for
debugging.
🧹 Nitpick comments (1)
app/src/org/commcare/services/SessionManager.kt (1)

12-25: Prefer a Kotlin object declaration over a class with only a companion object.

SessionManager has no instance state and no instance methods — the entire public API lives in the companion object. Idiomatic Kotlin uses a top-level object for this pattern:

♻️ Proposed refactor
-@RequiresApi(Build.VERSION_CODES.M)
-class SessionManager {
-    companion object {
-        private const val SESSION_KEY_ALIAS = "session_encryption_key"
-
-        private val sessionKeyAndTransformation by lazy {
-            AesKeyStoreHandler(SESSION_KEY_ALIAS, needsUserAuth = false).getKeyOrGenerate()
-        }
-
-        `@JvmStatic`
-        fun getEncryptionKey(): Key = sessionKeyAndTransformation.key
-
-        `@JvmStatic`
-        fun getKeyTransformation(): String = sessionKeyAndTransformation.transformation
-    }
-}
+@RequiresApi(Build.VERSION_CODES.M)
+object SessionManager {
+    private const val SESSION_KEY_ALIAS = "session_encryption_key"
+
+    private val sessionKeyAndTransformation by lazy {
+        AesKeyStoreHandler(SESSION_KEY_ALIAS, needsUserAuth = false).getKeyOrGenerate()
+    }
+
+    `@JvmStatic`
+    fun getEncryptionKey(): Key = sessionKeyAndTransformation.key
+
+    `@JvmStatic`
+    fun getKeyTransformation(): String = sessionKeyAndTransformation.transformation
+}

@JvmStatic continues to work on object members, so Java call sites are unaffected.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/services/SessionManager.kt` around lines 12 - 25,
SessionManager is implemented as a class with only a companion object; refactor
it to a Kotlin object to be idiomatic and remove the unused outer class: replace
the class declaration with an object declaration named SessionManager and move
the existing companion contents (SESSION_KEY_ALIAS, sessionKeyAndTransformation,
getEncryptionKey, getKeyTransformation) into the object body, preserving the
`@JvmStatic` annotations and existing symbols (AesKeyStoreHandler,
SESSION_KEY_ALIAS, sessionKeyAndTransformation, getEncryptionKey,
getKeyTransformation) so Java callers keep the same API.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/org/commcare/activities/components/ImageCaptureProcessing.java`:
- Line 70: The call to EncryptionIO.encryptFile in ImageCaptureProcessing
(currently: EncryptionIO.encryptFile(sourcePath, finalFilePath,
formEntryActivity.getSymetricKey(), null, false);) exceeds the 115-char
checkstyle limit — split the argument list across multiple lines or assign long
expressions to temporaries (e.g., extract formEntryActivity.getSymetricKey() to
a variable) so the call is wrapped to conform to checkstyle; keep the same
arguments (sourcePath, finalFilePath, the symmetric key, null, false) and
preserve the legacy encryption behavior.

In `@app/src/org/commcare/models/database/user/UserDatabaseUpgrader.java`:
- Around line 844-849: In UserDatabaseUpgrader, the loop that migrates
DeviceReportRecordPreV30 currently calls
rekeyDeviceReportFileToKeystoreKey(old.getFilePath(), old.getAesKey()) but
always writes the new DeviceReportRecord (newStorage.write(updated)) even if
rekeying failed; change this so that a failed rekey prevents writing: make
rekeyDeviceReportFileToKeystoreKey either throw on failure (or return a boolean)
and in the migration loop around DeviceReportRecordPreV30 catch that failure and
do NOT call newStorage.write(updated) — instead propagate the error (rethrow) or
otherwise abort the upgrade so the DB row is not written for an unreadable file;
apply the same pattern to the other migration block(s) around the same logic
(lines referenced in the review: 857-881).
- Around line 874-879: In UserDatabaseUpgrader (around the block handling
tempPath/filename), check the boolean return values of fileToDelete.delete() and
new File(tempPath).renameTo(new File(filename)); if delete() or renameTo()
returns false, throw an appropriate IOException (or
StreamsUtil.OutputIOException) with a clear message including the
filename/tempPath so callers can handle the failure; ensure you only consider
the operation successful after renameTo() succeeds and mirror the error-handling
style used in UserSandboxUtils/GlobalDatabaseUpgrader.

In `@app/src/org/commcare/models/encryption/EncryptionIO.java`:
- Around line 109-113: When reading the IV in the keystore decrypt path (the
isKeyFromAndroidKeyStore branch), preserve the unsigned byte interpretation
(keep the "& 0xFF" mask when assigning ivLength) and replace the single
is.read(iv, 0, ivLength) call with a loop that repeatedly reads into iv until
ivLength bytes have been filled or EOF occurs; if EOF is reached before the
buffer is full, throw/propagate an IOException so the partially-initialized iv
is never used. Ensure the variables referenced are ivLength, iv, is, and the
isKeyFromAndroidKeyStore branch so the change is localized to that parsing
logic.

In `@app/src/org/commcare/models/FormRecordProcessor.java`:
- Around line 73-75: The call to FormUploadUtil.getDecryptCipher(...) can return
null and feeding it directly into new CipherInputStream(...) leads to a silent
NPE; update each call site in FormRecordProcessor (the process(...) method
around the decrypter creation, performLinearFileScan, and attemptXmlScan) to
check the returned Cipher for null after calling
FormUploadUtil.getDecryptCipher(new SecretKeySpec(record.getAesKey(), "AES"),
...) and handle it explicitly—either throw a clear
IOException/IllegalStateException with context (including record identifier and
the underlying exception message if available) or log an error and abort
processing—so you never pass a null Cipher into CipherInputStream and the
failure surface includes useful diagnostic info.

In `@app/src/org/commcare/network/EncryptedFileBody.java`:
- Around line 55-58: The IV-read code in EncryptedFileBody is not validating
reads and can produce a corrupted IV; update the block that reads from fis (the
read of ivLength and the fis.read(iv, 0, ivLength) into iv) to: check the
initial fis.read() return for -1 and throw an IOException on EOF, validate
ivLength is within expected bounds (e.g., >0 and <= a reasonable max like 256),
allocate iv accordingly, then loop calling fis.read(...) until you have received
exactly ivLength bytes (accumulating bytesRead) and throw an IOException if a
read returns -1 before filling the buffer—this ensures the IV is fully and
safely read before decryption.

In `@app/src/org/commcare/services/SessionManager.kt`:
- Around line 16-18: sessionKeyAndTransformation currently uses Kotlin's default
by lazy which caches thrown exceptions from
AesKeyStoreHandler(SESSION_KEY_ALIAS, needsUserAuth = false).getKeyOrGenerate(),
making keystore-init failures permanent; change to avoid caching exceptions by
wrapping initialization in a safe accessor: replace the lazy usage with a
non-caching approach (e.g., lazy(LazyThreadSafetyMode.NONE) combined with
explicit retry/timeout or use runCatching inside a short-lived initialization
function) and ensure callers getEncryptionKey() and getKeyTransformation()
handle retryable failures and surface clear errors or fallbacks instead of
letting a cached Throwable be rethrown for the process lifetime.
- Around line 11-12: SessionManager is annotated `@RequiresApi`(M) only for
Kotlin, so Java callers can still call getEncryptionKey()/getKeyTransformation()
on older SDKs and crash; update each Java call site
(LogSubmissionTask.doInBackground,
UserDatabaseUpgrader.rekeyDeviceReportFileToKeystoreKey, and
DeviceReportRecord.openOutputStream) to either add
`@RequiresApi`(Build.VERSION_CODES.M) to the method declaration or wrap the calls
in an explicit runtime guard if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
before invoking SessionManager.getEncryptionKey() or
SessionManager.getKeyTransformation(); pick the approach consistent with each
method's API contract (annotation if the whole method requires M, runtime check
if only a code path needs guarding).

---

Outside diff comments:
In `@app/src/org/commcare/utils/FormUploadUtil.java`:
- Around line 73-90: The getDecryptCipher method currently catches crypto
exceptions and returns null; change it to fail fast by rethrowing a runtime
exception instead of returning null: inside getDecryptCipher (the method
handling Cipher.getInstance and cipher.init) catch the NoSuchAlgorithmException,
NoSuchPaddingException, InvalidKeyException, and
InvalidAlgorithmParameterException and throw a new RuntimeException (or
IllegalStateException) that wraps the caught exception and includes a clear
message (e.g., "Failed to initialize decrypt cipher"), so callers never receive
null and the original exception is preserved for debugging.

---

Nitpick comments:
In `@app/src/org/commcare/services/SessionManager.kt`:
- Around line 12-25: SessionManager is implemented as a class with only a
companion object; refactor it to a Kotlin object to be idiomatic and remove the
unused outer class: replace the class declaration with an object declaration
named SessionManager and move the existing companion contents
(SESSION_KEY_ALIAS, sessionKeyAndTransformation, getEncryptionKey,
getKeyTransformation) into the object body, preserving the `@JvmStatic`
annotations and existing symbols (AesKeyStoreHandler, SESSION_KEY_ALIAS,
sessionKeyAndTransformation, getEncryptionKey, getKeyTransformation) so Java
callers keep the same API.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 56c2347 and 1cbbc8c.

📒 Files selected for processing (17)
  • app/src/org/commcare/activities/components/ImageCaptureProcessing.java
  • app/src/org/commcare/android/javarosa/DeviceReportRecord.java
  • app/src/org/commcare/android/javarosa/DeviceReportRecordPreV30.java
  • app/src/org/commcare/models/FormRecordProcessor.java
  • app/src/org/commcare/models/database/HybridFileBackedSqlStorage.java
  • app/src/org/commcare/models/database/user/UserDatabaseSchemaManager.java
  • app/src/org/commcare/models/database/user/UserDatabaseUpgrader.java
  • app/src/org/commcare/models/encryption/EncryptionIO.java
  • app/src/org/commcare/network/EncryptedFileBody.java
  • app/src/org/commcare/services/SessionManager.kt
  • app/src/org/commcare/tasks/DumpTask.java
  • app/src/org/commcare/tasks/FormLoaderTask.java
  • app/src/org/commcare/tasks/FormRecordToFileTask.java
  • app/src/org/commcare/tasks/LogSubmissionTask.java
  • app/src/org/commcare/tasks/SaveToDiskTask.java
  • app/src/org/commcare/utils/FormUploadUtil.java
  • app/src/org/commcare/views/widgets/MediaWidget.java

finalFilePath = finalFilePath + MediaWidget.AES_EXTENSION;
try {
EncryptionIO.encryptFile(sourcePath, finalFilePath, formEntryActivity.getSymetricKey());
EncryptionIO.encryptFile(sourcePath, finalFilePath, formEntryActivity.getSymetricKey(), null, false);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, locate the EncryptionIO class
find . -name "EncryptionIO.java" -type f

Repository: dimagi/commcare-android

Length of output: 125


🏁 Script executed:

# Also check the ImageCaptureProcessing.java file structure around line 70
find . -name "ImageCaptureProcessing.java" -type f

Repository: dimagi/commcare-android

Length of output: 139


🏁 Script executed:

# Search for checkstyle.xml in the repository
find . -name "checkstyle.xml" -type f

Repository: dimagi/commcare-android

Length of output: 99


🏁 Script executed:

# Read EncryptionIO.java to find the encryptFile method signature
wc -l ./app/src/org/commcare/models/encryption/EncryptionIO.java

Repository: dimagi/commcare-android

Length of output: 129


🏁 Script executed:

# Read ImageCaptureProcessing.java context around line 70
wc -l ./app/src/org/commcare/activities/components/ImageCaptureProcessing.java

Repository: dimagi/commcare-android

Length of output: 143


🏁 Script executed:

# Get EncryptionIO.java encryptFile method
cat -n ./app/src/org/commcare/models/encryption/EncryptionIO.java | head -150 | tail -100

Repository: dimagi/commcare-android

Length of output: 5584


🏁 Script executed:

# Read complete EncryptionIO.java to see the full encryptFile signature
cat -n ./app/src/org/commcare/models/encryption/EncryptionIO.java | head -60

Repository: dimagi/commcare-android

Length of output: 2611


🏁 Script executed:

# Read ImageCaptureProcessing.java around line 70
cat -n ./app/src/org/commcare/activities/components/ImageCaptureProcessing.java | sed -n '60,85p'

Repository: dimagi/commcare-android

Length of output: 1294


🏁 Script executed:

# Read checkstyle.xml to verify applicable rules for Java style
cat ./.github/linters/checkstyle.xml | head -60

Repository: dimagi/commcare-android

Length of output: 2844


🏁 Script executed:

# Get the exact line 70 and measure its length
sed -n '70p' ./app/src/org/commcare/activities/components/ImageCaptureProcessing.java | wc -c

Repository: dimagi/commcare-android

Length of output: 70


🏁 Script executed:

# Search for getSymetricKey() method definition to verify null-safety
rg -n "getSymetricKey" --type java -B 2 -A 5

Repository: dimagi/commcare-android

Length of output: 3909


🏁 Script executed:

# Check line 70 context and verify it doesn't violate other style rules
sed -n '70p' ./app/src/org/commcare/activities/components/ImageCaptureProcessing.java

Repository: dimagi/commcare-android

Length of output: 184


🏁 Script executed:

# Verify the exact parameter name in createFileOutputStream call at line 49
sed -n '49p' ./app/src/org/commcare/models/encryption/EncryptionIO.java

Repository: dimagi/commcare-android

Length of output: 162


🏁 Script executed:

# Check if symetricKey field can be null and if there are guards at call site
rg -n "symetricKey\s*=" --type java -B 2 -A 2 | head -40

Repository: dimagi/commcare-android

Length of output: 2247


Line length exceeds checkstyle limit — requires wrapping.

This call is 118 characters and violates the checkstyle.xml limit of 115. Split the line to conform. For example:

Suggested fix
-                EncryptionIO.encryptFile(sourcePath, finalFilePath, formEntryActivity.getSymetricKey(), null, false);
+                EncryptionIO.encryptFile(sourcePath, finalFilePath,
+                        formEntryActivity.getSymetricKey(), null, false);

The API adaptation itself is correct: passing null for transformation and false for isKeyFromKeystore safely uses the legacy encryption path (defaults to AES cipher, no keystore IV handling), consistent with the PR's stated backward-compatibility contract. Null handling is safe—createFileOutputStream checks for null keys and falls back to unencrypted output stream.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
EncryptionIO.encryptFile(sourcePath, finalFilePath, formEntryActivity.getSymetricKey(), null, false);
EncryptionIO.encryptFile(sourcePath, finalFilePath,
formEntryActivity.getSymetricKey(), null, false);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/activities/components/ImageCaptureProcessing.java` at
line 70, The call to EncryptionIO.encryptFile in ImageCaptureProcessing
(currently: EncryptionIO.encryptFile(sourcePath, finalFilePath,
formEntryActivity.getSymetricKey(), null, false);) exceeds the 115-char
checkstyle limit — split the argument list across multiple lines or assign long
expressions to temporaries (e.g., extract formEntryActivity.getSymetricKey() to
a variable) so the call is wrapped to conform to checkstyle; keep the same
arguments (sourcePath, finalFilePath, the symmetric key, null, false) and
preserve the legacy encryption behavior.

Comment on lines +844 to +849
for (DeviceReportRecordPreV30 old : oldStorage) {
DeviceReportRecord updated = new DeviceReportRecord(old.getFilePath());
rekeyDeviceReportFileToKeystoreKey(old.getFilePath(), old.getAesKey());
updated.setID(old.getID());
newStorage.write(updated);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Do not write migrated records when file rekeying fails.

Right now rekey failures are logged but not propagated, and the upgraded record is still written. That can leave DB rows on the new schema while the file remains in an unreadable/old state.

💡 Proposed fix
 for (DeviceReportRecordPreV30 old : oldStorage) {
     DeviceReportRecord updated = new DeviceReportRecord(old.getFilePath());
-    rekeyDeviceReportFileToKeystoreKey(old.getFilePath(), old.getAesKey());
+    if (!rekeyDeviceReportFileToKeystoreKey(old.getFilePath(), old.getAesKey())) {
+        throw new IllegalStateException("Failed to rekey device report file: " + old.getFilePath());
+    }
     updated.setID(old.getID());
     newStorage.write(updated);
 }
 ...
-private void rekeyDeviceReportFileToKeystoreKey(String filename, byte[] oldAesKey) {
+private boolean rekeyDeviceReportFileToKeystoreKey(String filename, byte[] oldAesKey) {
     try {
         InputStream is = EncryptionIO.getFileInputStream(
                 filename,
                 new SecretKeySpec(oldAesKey, "AES"),
                 null,
                 false
         );
         String tempPath = filename + ".tmp";
         StreamsUtil.writeFromInputToOutputNew(is,
                 createFileOutputStream(
                         tempPath,
                         SessionManager.getEncryptionKey(),
                         SessionManager.getKeyTransformation(),
                         true
                 )
         );
-        File fileToDelete = new File(filename);
-        if (fileToDelete.exists()) {
-            fileToDelete.delete();
-        }
-        new File(tempPath).renameTo(new File(filename));
+        File fileToDelete = new File(filename);
+        File destination = new File(filename);
+        if (fileToDelete.exists() && !fileToDelete.delete()) {
+            throw new java.io.IOException("Unable to delete original file: " + filename);
+        }
+        if (!new File(tempPath).renameTo(destination)) {
+            throw new java.io.IOException("Unable to promote temp rekeyed file: " + filename);
+        }
+        return true;
     } catch (FileNotFoundException | StreamsUtil.InputIOException | StreamsUtil.OutputIOException e) {
         Logger.exception("Error while rekeying device report file during user db migration", e);
+        return false;
+    } catch (java.io.IOException e) {
+        Logger.exception("Error while replacing rekeyed device report file during user db migration", e);
+        return false;
     }
 }

Also applies to: 857-881

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/models/database/user/UserDatabaseUpgrader.java` around
lines 844 - 849, In UserDatabaseUpgrader, the loop that migrates
DeviceReportRecordPreV30 currently calls
rekeyDeviceReportFileToKeystoreKey(old.getFilePath(), old.getAesKey()) but
always writes the new DeviceReportRecord (newStorage.write(updated)) even if
rekeying failed; change this so that a failed rekey prevents writing: make
rekeyDeviceReportFileToKeystoreKey either throw on failure (or return a boolean)
and in the migration loop around DeviceReportRecordPreV30 catch that failure and
do NOT call newStorage.write(updated) — instead propagate the error (rethrow) or
otherwise abort the upgrade so the DB row is not written for an unreadable file;
apply the same pattern to the other migration block(s) around the same logic
(lines referenced in the review: 857-881).

Comment on lines +874 to +879
File fileToDelete = new File(filename);
if (fileToDelete.exists()) {
fileToDelete.delete();
}
new File(tempPath).renameTo(new File(filename));
} catch (FileNotFoundException | StreamsUtil.InputIOException | StreamsUtil.OutputIOException e) {
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, locate and read the file around lines 874-879
wc -l app/src/org/commcare/models/database/user/UserDatabaseUpgrader.java

Repository: dimagi/commcare-android

Length of output: 138


🏁 Script executed:

# Read the code with surrounding context (lines 860-890)
sed -n '860,890p' app/src/org/commcare/models/database/user/UserDatabaseUpgrader.java | cat -n

Repository: dimagi/commcare-android

Length of output: 1723


🏁 Script executed:

# Check for checkstyle.xml rules
find . -name "checkstyle.xml" -type f

Repository: dimagi/commcare-android

Length of output: 99


🏁 Script executed:

# Read checkstyle.xml to find relevant rules
cat ./.github/linters/checkstyle.xml

Repository: dimagi/commcare-android

Length of output: 19447


🏁 Script executed:

# Search for other patterns of file operations in the codebase to see best practices
rg "\.delete\(\)" app/src --A 2 -B 2 | head -80

Repository: dimagi/commcare-android

Length of output: 472


🏁 Script executed:

# Search for renameTo patterns
rg "\.renameTo\(" app/src --A 2 -B 2 | head -60

Repository: dimagi/commcare-android

Length of output: 472


🏁 Script executed:

# Search for proper File.delete() patterns
rg 'delete\(\)' app/src -B 2 -A 2 | head -100

Repository: dimagi/commcare-android

Length of output: 7767


🏁 Script executed:

# Search for proper File.renameTo() patterns
rg 'renameTo\(' app/src -B 2 -A 2 | head -100

Repository: dimagi/commcare-android

Length of output: 3137


🏁 Script executed:

# Get more context around the problematic lines - read lines 860-895
sed -n '860,895p' app/src/org/commcare/models/database/user/UserDatabaseUpgrader.java

Repository: dimagi/commcare-android

Length of output: 1741


Check return values of delete() and renameTo() to prevent data loss.

The return values from delete() (line 874-876) and renameTo() (line 879) are ignored. If the delete succeeds but the rename fails, the original file is lost. Verify both operations succeed before proceeding, or throw an exception on failure—as done elsewhere in the codebase (e.g., UserSandboxUtils.java, GlobalDatabaseUpgrader.java).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/models/database/user/UserDatabaseUpgrader.java` around
lines 874 - 879, In UserDatabaseUpgrader (around the block handling
tempPath/filename), check the boolean return values of fileToDelete.delete() and
new File(tempPath).renameTo(new File(filename)); if delete() or renameTo()
returns false, throw an appropriate IOException (or
StreamsUtil.OutputIOException) with a clear message including the
filename/tempPath so callers can handle the failure; ensure you only consider
the operation successful after renameTo() succeeds and mirror the error-handling
style used in UserSandboxUtils/GlobalDatabaseUpgrader.

Comment on lines +109 to +113
if (isKeyFromAndroidKeyStore) {
int ivLength = is.read() & 0xFF;
iv = new byte[ivLength];
is.read(iv, 0, ivLength);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "EncryptionIO.java" | head -5

Repository: dimagi/commcare-android

Length of output: 125


🏁 Script executed:

find . -type f -name "checkstyle.xml" | head -5

Repository: dimagi/commcare-android

Length of output: 99


🏁 Script executed:

if [ -f "app/src/org/commcare/models/encryption/EncryptionIO.java" ]; then
  wc -l "app/src/org/commcare/models/encryption/EncryptionIO.java"
fi

Repository: dimagi/commcare-android

Length of output: 127


🏁 Script executed:

cat -n app/src/org/commcare/models/encryption/EncryptionIO.java | sed -n '100,125p'

Repository: dimagi/commcare-android

Length of output: 1559


🏁 Script executed:

cat -n ./.github/linters/checkstyle.xml

Repository: dimagi/commcare-android

Length of output: 22009


Add robust short-read handling to IV parsing in keystore decrypt path.

The current implementation assumes is.read(iv, 0, ivLength) reads all requested bytes in a single call. InputStream.read() may return fewer bytes without throwing an exception, leaving the IV partially initialized. Add a loop to read the full IV buffer.

However, the proposed fix removes the & 0xFF mask from the length byte, which changes EOF handling: a legitimate 0x00 byte length (though cryptographically invalid) would now be rejected. Consider either preserving & 0xFF or explicitly documenting that 0-length IVs are intentionally rejected.

💡 Revised approach

Preserve the & 0xFF mask and add the partial-read loop:

 if (isKeyFromAndroidKeyStore) {
     int ivLength = is.read() & 0xFF;
     iv = new byte[ivLength];
+    int offset = 0;
+    while (offset < ivLength) {
+        int read = is.read(iv, offset, ivLength - offset);
+        if (read < 0) {
+            throw new IOException("Unexpected EOF while reading IV");
+        }
+        offset += read;
+    }
-    is.read(iv, 0, ivLength);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (isKeyFromAndroidKeyStore) {
int ivLength = is.read() & 0xFF;
iv = new byte[ivLength];
is.read(iv, 0, ivLength);
}
if (isKeyFromAndroidKeyStore) {
int ivLength = is.read() & 0xFF;
iv = new byte[ivLength];
int offset = 0;
while (offset < ivLength) {
int read = is.read(iv, offset, ivLength - offset);
if (read < 0) {
throw new IOException("Unexpected EOF while reading IV");
}
offset += read;
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/models/encryption/EncryptionIO.java` around lines 109 -
113, When reading the IV in the keystore decrypt path (the
isKeyFromAndroidKeyStore branch), preserve the unsigned byte interpretation
(keep the "& 0xFF" mask when assigning ivLength) and replace the single
is.read(iv, 0, ivLength) call with a loop that repeatedly reads into iv until
ivLength bytes have been filled or EOF occurs; if EOF is reached before the
buffer is full, throw/propagate an IOException so the partially-initialized iv
is never used. Ensure the variables referenced are ivLength, iv, is, and the
isKeyFromAndroidKeyStore branch so the change is localized to that parsing
logic.

Comment on lines 73 to 75
final Cipher decrypter =
FormUploadUtil.getDecryptCipher((new SecretKeySpec(record.getAesKey(), "AES")));
FormUploadUtil.getDecryptCipher((new SecretKeySpec(record.getAesKey(), "AES")), null, null);
InputStream is = new CipherInputStream(new FileInputStream(f), decrypter);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

getDecryptCipher can return null, causing a silent NPE in CipherInputStream.

FormUploadUtil.getDecryptCipher swallows NoSuchAlgorithmException, InvalidKeyException, and related crypto exceptions and returns null. All three call sites pass the returned value directly into new CipherInputStream(...) without a null check. A null cipher causes CipherInputStream to throw NPE on first read, with no contextual error message.

While this null-return pattern predates this PR, these lines are being touched. Consider at least adding a null guard:

🛡️ Proposed null guard (example for process() at line 73)
-        final Cipher decrypter =
-                FormUploadUtil.getDecryptCipher((new SecretKeySpec(record.getAesKey(), "AES")), null, null);
+        final Cipher decrypter =
+                FormUploadUtil.getDecryptCipher((new SecretKeySpec(record.getAesKey(), "AES")), null, null);
+        if (decrypter == null) {
+            throw new IOException("Failed to initialize decryption cipher for form record " + record.getInstanceID());
+        }

The same guard is needed in performLinearFileScan (Line 227) and attemptXmlScan (Line 262).

Also applies to: 227-232, 262-263

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/models/FormRecordProcessor.java` around lines 73 - 75,
The call to FormUploadUtil.getDecryptCipher(...) can return null and feeding it
directly into new CipherInputStream(...) leads to a silent NPE; update each call
site in FormRecordProcessor (the process(...) method around the decrypter
creation, performLinearFileScan, and attemptXmlScan) to check the returned
Cipher for null after calling FormUploadUtil.getDecryptCipher(new
SecretKeySpec(record.getAesKey(), "AES"), ...) and handle it explicitly—either
throw a clear IOException/IllegalStateException with context (including record
identifier and the underlying exception message if available) or log an error
and abort processing—so you never pass a null Cipher into CipherInputStream and
the failure surface includes useful diagnostic info.

Comment on lines +55 to +58
int ivLength = fis.read() & 0xFF;
iv = new byte[ivLength];
fis.read(iv, 0, ivLength);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "EncryptedFileBody.java" -type f

Repository: dimagi/commcare-android

Length of output: 120


🏁 Script executed:

cat -n ./app/src/org/commcare/network/EncryptedFileBody.java | head -70

Repository: dimagi/commcare-android

Length of output: 3059


🏁 Script executed:

find . -name "checkstyle.xml" -type f

Repository: dimagi/commcare-android

Length of output: 99


🏁 Script executed:

cat ./.github/linters/checkstyle.xml

Repository: dimagi/commcare-android

Length of output: 19447


Validate and fully read the IV header before decrypting.

The IV read path does not handle incomplete reads or EOF. FileInputStream.read() can return -1 on EOF (which becomes 255 after & 0xFF), and read(byte[], int, int) may return fewer bytes than requested. Either case leaves the IV corrupt or partially uninitialized, breaking decryption.

Proposed fix
 if (isKeyFromAndroidKeyStore) {
-    int ivLength = fis.read() & 0xFF;
-    iv = new byte[ivLength];
-    fis.read(iv, 0, ivLength);
+    int ivLength = fis.read();
+    if (ivLength <= 0) {
+        throw new IOException("Invalid IV length while reading encrypted file: " + file.getAbsolutePath());
+    }
+    iv = new byte[ivLength];
+    int offset = 0;
+    while (offset < ivLength) {
+        int count = fis.read(iv, offset, ivLength - offset);
+        if (count < 0) {
+            throw new IOException("Unexpected EOF while reading IV");
+        }
+        offset += count;
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/network/EncryptedFileBody.java` around lines 55 - 58,
The IV-read code in EncryptedFileBody is not validating reads and can produce a
corrupted IV; update the block that reads from fis (the read of ivLength and the
fis.read(iv, 0, ivLength) into iv) to: check the initial fis.read() return for
-1 and throw an IOException on EOF, validate ivLength is within expected bounds
(e.g., >0 and <= a reasonable max like 256), allocate iv accordingly, then loop
calling fis.read(...) until you have received exactly ivLength bytes
(accumulating bytesRead) and throw an IOException if a read returns -1 before
filling the buffer—this ensures the IV is fully and safely read before
decryption.

Comment on lines 11 to 12
@RequiresApi(Build.VERSION_CODES.M)
class SessionManager {
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find all Java call sites invoking SessionManager
rg -n "SessionManager\." --type java -A 2 -B 5

Repository: dimagi/commcare-android

Length of output: 10909


🏁 Script executed:

# Also check if there are any Kotlin files using SessionManager
rg -n "SessionManager\." --type kotlin -A 2 -B 5

Repository: dimagi/commcare-android

Length of output: 12202


🏁 Script executed:

# First, let me find the SessionManager.kt file to verify it exists and check its content
fd "SessionManager.kt" -t f

Repository: dimagi/commcare-android

Length of output: 114


🏁 Script executed:

# Check for ktlint tool availability and run it on SessionManager.kt if found
if command -v ktlint &> /dev/null; then
  ktlint "app/src/org/commcare/services/SessionManager.kt" 2>&1
else
  echo "ktlint not found in environment"
fi

Repository: dimagi/commcare-android

Length of output: 98


🏁 Script executed:

# Check LogSubmissionTask.java around line 300
sed -n '280,310p' app/src/org/commcare/tasks/LogSubmissionTask.java

Repository: dimagi/commcare-android

Length of output: 1089


🏁 Script executed:

# Check if LogSubmissionTask.java class has `@RequiresApi`
head -n 30 app/src/org/commcare/tasks/LogSubmissionTask.java | grep -A 5 -B 5 "@RequiresApi\|class LogSubmissionTask"

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Check UserDatabaseUpgrader.java around line 869
sed -n '850,885p' app/src/org/commcare/models/database/user/UserDatabaseUpgrader.java

Repository: dimagi/commcare-android

Length of output: 1491


🏁 Script executed:

# Check if UserDatabaseUpgrader.java method has guards
sed -n '800,880p' app/src/org/commcare/models/database/user/UserDatabaseUpgrader.java | grep -B 80 "SessionManager" | tail -n 40

Repository: dimagi/commcare-android

Length of output: 1782


🏁 Script executed:

# Check DeviceReportRecord.java around line 71
sed -n '1,100p' app/src/org/commcare/android/javarosa/DeviceReportRecord.java

Repository: dimagi/commcare-android

Length of output: 2490


🏁 Script executed:

# Check LogSubmissionTask.java class declaration and method signature
grep -n "class LogSubmissionTask\|@RequiresApi\|public.*doInBackground\|protected.*doInBackground" app/src/org/commcare/tasks/LogSubmissionTask.java | head -20

Repository: dimagi/commcare-android

Length of output: 255


🏁 Script executed:

# Check UserDatabaseUpgrader.java class and method context
grep -n "class UserDatabaseUpgrader\|@RequiresApi\|rekeyDeviceReportFileToKeystoreKey\|SDK_INT" app/src/org/commcare/models/database/user/UserDatabaseUpgrader.java | head -20

Repository: dimagi/commcare-android

Length of output: 283


🏁 Script executed:

# Check DeviceReportRecord.java for `@RequiresApi` and SDK_INT
grep -n "@RequiresApi\|class DeviceReportRecord\|SDK_INT\|Build.VERSION" app/src/org/commcare/android/javarosa/DeviceReportRecord.java

Repository: dimagi/commcare-android

Length of output: 147


🏁 Script executed:

# Look at the full method containing SessionManager calls in UserDatabaseUpgrader
sed -n '840,895p' app/src/org/commcare/models/database/user/UserDatabaseUpgrader.java | cat -n

Repository: dimagi/commcare-android

Length of output: 2931


🏁 Script executed:

# Check around the doInBackground method in LogSubmissionTask to see if there's any SDK_INT guard
sed -n '100,330p' app/src/org/commcare/tasks/LogSubmissionTask.java | grep -A 25 "protected LogSubmitOutcomes doInBackground"

Repository: dimagi/commcare-android

Length of output: 1349


🏁 Script executed:

# Check if LogSubmissionTask has any `@RequiresApi` at all
head -n 60 app/src/org/commcare/tasks/LogSubmissionTask.java | grep "@RequiresApi"

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Check the full method signature and context of openOutputStream in DeviceReportRecord
sed -n '65,80p' app/src/org/commcare/android/javarosa/DeviceReportRecord.java

Repository: dimagi/commcare-android

Length of output: 418


🏁 Script executed:

# Check if there's any API level check in the parent class Persisted or EncryptedModel
fd "Persisted.java" -t f

Repository: dimagi/commcare-android

Length of output: 128


🏁 Script executed:

# Check for any Build.VERSION or SDK_INT checks in UserDatabaseUpgrader
grep -n "Build.VERSION\|SDK_INT" app/src/org/commcare/models/database/user/UserDatabaseUpgrader.java

Repository: dimagi/commcare-android

Length of output: 49


🏁 Script executed:

# Check if Persisted.java or any parent provides API guards
head -n 50 app/src/org/commcare/android/storage/framework/Persisted.java

Repository: dimagi/commcare-android

Length of output: 1914


🏁 Script executed:

# Check if DeviceReportRecord.openOutputStream is only called in safe contexts
grep -rn "openOutputStream\|DeviceReportRecord" app/src/org/commcare/tasks/LogSubmissionTask.java

Repository: dimagi/commcare-android

Length of output: 1649


🏁 Script executed:

# Let's see the full content of SessionManager.kt to understand the `@RequiresApi` scope
cat app/src/org/commcare/services/SessionManager.kt

Repository: dimagi/commcare-android

Length of output: 850


🏁 Script executed:

# Check if there are any callers that wrap the openOutputStream call
rg -n "openOutputStream" --type java -B 3 -A 3

Repository: dimagi/commcare-android

Length of output: 1452


Java call sites invoking SessionManager methods must add @RequiresApi(Build.VERSION_CODES.M) annotations or SDK_INT runtime guards.

The class-level @RequiresApi(M) annotation on SessionManager.kt provides lint enforcement in Kotlin but does not guard Java callers. The following Java methods invoke SessionManager.getEncryptionKey() or SessionManager.getKeyTransformation() without protection and will crash on API < 23:

  • LogSubmissionTask.doInBackground() (lines 300–301)
  • UserDatabaseUpgrader.rekeyDeviceReportFileToKeystoreKey() (lines 869–870)
  • DeviceReportRecord.openOutputStream() (lines 71–72)

Each must either be annotated with @RequiresApi(Build.VERSION_CODES.M) or guarded with an explicit if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) check.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/services/SessionManager.kt` around lines 11 - 12,
SessionManager is annotated `@RequiresApi`(M) only for Kotlin, so Java callers can
still call getEncryptionKey()/getKeyTransformation() on older SDKs and crash;
update each Java call site (LogSubmissionTask.doInBackground,
UserDatabaseUpgrader.rekeyDeviceReportFileToKeystoreKey, and
DeviceReportRecord.openOutputStream) to either add
`@RequiresApi`(Build.VERSION_CODES.M) to the method declaration or wrap the calls
in an explicit runtime guard if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
before invoking SessionManager.getEncryptionKey() or
SessionManager.getKeyTransformation(); pick the approach consistent with each
method's API contract (annotation if the whole method requires M, runtime check
if only a code path needs guarding).

Comment on lines 16 to 18
private val sessionKeyAndTransformation by lazy {
AesKeyStoreHandler(SESSION_KEY_ALIAS, needsUserAuth = false).getKeyOrGenerate()
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Kotlin by lazy caches the first exception — keystore initialization failure is unrecoverable for the process lifetime.

If AesKeyStoreHandler(...).getKeyOrGenerate() throws (e.g., keystore unavailable, hardware missing, first-boot race), Kotlin's lazy caches the Throwable and rethrows it on every subsequent call to getEncryptionKey() or getKeyTransformation() — with no opportunity for retry or fallback. This makes any transient hardware/keystore issue a permanent failure for the app session.

Consider wrapping with a runCatching or using lazy(LazyThreadSafetyMode.NONE) combined with explicit retry logic, or at minimum document the permanent-failure behavior clearly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/org/commcare/services/SessionManager.kt` around lines 16 - 18,
sessionKeyAndTransformation currently uses Kotlin's default by lazy which caches
thrown exceptions from AesKeyStoreHandler(SESSION_KEY_ALIAS, needsUserAuth =
false).getKeyOrGenerate(), making keystore-init failures permanent; change to
avoid caching exceptions by wrapping initialization in a safe accessor: replace
the lazy usage with a non-caching approach (e.g.,
lazy(LazyThreadSafetyMode.NONE) combined with explicit retry/timeout or use
runCatching inside a short-lived initialization function) and ensure callers
getEncryptionKey() and getKeyTransformation() handle retryable failures and
surface clear errors or fallbacks instead of letting a cached Throwable be
rethrown for the process lifetime.

@avazirna avazirna changed the title Encrypt device reports with Android Keystore-backed key Migrate device reports to Android Keystore-backed key Feb 26, 2026
@avazirna avazirna changed the base branch from master to vaz/encrypt-device-reports-with-android-keystore February 26, 2026 09:22
@avazirna avazirna force-pushed the vaz/migrate-device-reports-to-android-keystore branch 2 times, most recently from 625f2e6 to 6d37530 Compare February 27, 2026 09:14
@avazirna avazirna force-pushed the vaz/migrate-device-reports-to-android-keystore branch from 6d37530 to f9ace7a Compare February 27, 2026 13:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant