Skip to content

fix: Move Android SharedPreferences IO off the main thread to prevent ANR - #432

Open
vlaushkin wants to merge 1 commit into
ABausG:mainfrom
vlaushkin:fix/android-prefs-io-off-main-thread
Open

fix: Move Android SharedPreferences IO off the main thread to prevent ANR#432
vlaushkin wants to merge 1 commit into
ABausG:mainfrom
vlaushkin:fix/android-prefs-io-off-main-thread

Conversation

@vlaushkin

Copy link
Copy Markdown

Description

On Android, HomeWidget.saveWidgetData persisted data by calling SharedPreferences.Editor.commit() directly inside onMethodCall, which runs on the platform (main) thread. commit() performs a synchronous fsync, so on slow storage it blocks the UI thread and triggers ANRs. This is exactly the stack reported in #401:

at android.app.SharedPreferencesImpl$EditorImpl.commit(SharedPreferencesImpl.java:604)
at es.antonborri.home_widget.HomeWidgetPlugin.onMethodCall(HomeWidgetPlugin.kt)

This PR moves the blocking SharedPreferences disk IO off the platform thread:

  • saveWidgetData now runs commit() off the platform thread and replies once the write finishes. The reply is still sent only after the write completes, so an awaited saveWidgetData Future continues to guarantee the data has been persisted — the existing save-then-read ordering (and the integration tests that rely on it) keep working.
  • getWidgetData resolves its value off the platform thread as well, since the first read loads the backing file from disk.
  • The disk IO runs on Dispatchers.IO.limitedParallelism(1), i.e. a single-threaded view of the IO pool. This keeps the previous behaviour where operations were serialized in call order (so concurrent/non-awaited reads and writes can't reorder), while no longer blocking the platform thread.
  • Work is launched from a plugin-scoped CoroutineScope(SupervisorJob() + Dispatchers.Main) that is cancelled in onDetachedFromEngine. kotlinx-coroutines-android is already a dependency of the plugin.

While restructuring the saveWidgetData branch I also fixed a latent double-reply: for an unsupported data type the handler called result.error(...) and then still fell through to result.success(prefs.commit()), submitting two replies for one call. The unsupported-type branch now returns after result.error(...). This removes one cause of the "Reply already submitted" crash tracked in #265.

Checklist

  • [-] I have updated/added tests for ALL new/updated/fixed functionality.
  • I have updated/added relevant documentation and added code (documentation) comments where necessary.
  • [-] I have updated/added relevant examples in example or documentation.

Breaking Change?

  • Yes, this PR is a breaking change.
  • No, this PR is not a breaking change.

Related Issues

Closes #401

@docs-page

docs-page Bot commented Jun 22, 2026

Copy link
Copy Markdown

To preview the documentation for this pull request, visit the following URL:

docs.page/abausg/home_widget~432

Documentation is deployed and generated using docs.page

@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

HomeWidgetPlugin.kt adds a plugin-scoped coroutineScope (SupervisorJob + Main dispatcher) and a limited-parallelism ioDispatcher. The saveWidgetData and getWidgetData methods are refactored to perform SharedPreferences disk I/O inside withContext(ioDispatcher) coroutines, returning results asynchronously. The scope is cancelled on onDetachedFromEngine.

Changes

Async SharedPreferences via Kotlin coroutines

Layer / File(s) Summary
Coroutine scope and dispatcher setup
packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetPlugin.kt
Adds coroutine imports, declares a plugin-wide coroutineScope (SupervisorJob + Main dispatcher) and a limited-parallelism ioDispatcher as private fields, and cancels the scope in onDetachedFromEngine.
Async saveWidgetData and getWidgetData
packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetPlugin.kt
Replaces the synchronous prefs.commit() call in saveWidgetData and the direct preference read in getWidgetData with coroutineScope.launch blocks that execute on ioDispatcher and return via result.success asynchronously.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately summarizes the main change: moving Android SharedPreferences IO off the main thread to prevent ANR crashes.
Description check ✅ Passed The PR description comprehensively explains the problem, solution, implementation details, and checklist items, following the template structure.
Linked Issues check ✅ Passed All coding requirements from issue #401 are addressed: SharedPreferences IO moved off main thread using coroutines, single-threaded IO dispatcher preserves serialization, and the latent double-reply bug is fixed.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing ANR issues and improving thread safety; there are no unrelated modifications to the codebase.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetPlugin.kt (3)

75-75: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider passing message to exception constructor.

Static analysis suggests passing the error message to the IllegalArgumentException constructor rather than using the default constructor. While the message is provided to result.error(), passing it to the exception constructor improves stack trace readability.

📝 Suggested improvement
                result.error(
                    "-10",
-                    "Invalid Type ${data!!::class.java.simpleName}. Supported types are Boolean, Float, String, Double, Long",
-                    IllegalArgumentException(),
+                    "Invalid Type ${data!!::class.java.simpleName}. Supported types are Boolean, Float, String, Double, Long, Int",
+                    IllegalArgumentException("Unsupported data type: ${data!!::class.java.simpleName}"),
                )

Note: Also added "Int" to the supported types list in the message.

🤖 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
`@packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetPlugin.kt`
at line 75, The IllegalArgumentException() constructor call is missing an error
message argument. Modify the IllegalArgumentException() instantiation to pass
the error message to the constructor instead of using the default constructor.
The error message should be the same descriptive message that is being passed to
the result.error() call, which will improve stack trace readability and provide
better context for debugging.

Source: Linters/SAST tools


101-113: 🧹 Nitpick | 🔵 Trivial | ⚖️ Poor tradeoff

Consider exception handling for read failures.

The pattern correctly moves SharedPreferences reads to the IO dispatcher (first access may load the backing file). However, similar to saveWidgetData, exceptions during the read operation could prevent result.success() from being called.

🛡️ Optional: Add exception handling
          coroutineScope.launch {
-            val value =
-                withContext(ioDispatcher) {
-                  val prefs = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
-                  val stored = prefs.all[id] ?: defaultValue
-                  if (stored is Long && prefs.getBoolean("$doubleLongPrefix$id", false)) {
-                    java.lang.Double.longBitsToDouble(stored)
-                  } else {
-                    stored
-                  }
-                }
-            result.success(value)
+            try {
+              val value =
+                  withContext(ioDispatcher) {
+                    val prefs = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
+                    val stored = prefs.all[id] ?: defaultValue
+                    if (stored is Long && prefs.getBoolean("$doubleLongPrefix$id", false)) {
+                      java.lang.Double.longBitsToDouble(stored)
+                    } else {
+                      stored
+                    }
+                  }
+              result.success(value)
+            } catch (e: Exception) {
+              result.error("-12", "Failed to read preferences: ${e.message}", e)
+            }
          }
🤖 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
`@packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetPlugin.kt`
around lines 101 - 113, The coroutineScope.launch block that reads from
SharedPreferences using withContext(ioDispatcher) lacks exception handling,
which means any exceptions during the read operation will prevent
result.success() from being called and leave the request hanging. Wrap the
withContext(ioDispatcher) block in a try-catch statement, and in the catch
block, call result.error() with an appropriate error code and error message to
properly handle read failures, similar to the error handling pattern used in
saveWidgetData.

84-87: 🧹 Nitpick | 🔵 Trivial | ⚖️ Poor tradeoff

Consider exception handling for commit failures.

While commit() typically returns false on failure (which is correctly propagated to the caller), it could theoretically throw exceptions in severe conditions (e.g., out of memory). If an exception is thrown within withContext, the coroutine will fail and result.success() will never be called, leading to "reply never submitted" on the Dart side.

🛡️ Optional: Add exception handling
          coroutineScope.launch {
-            val committed = withContext(ioDispatcher) { prefs.commit() }
-            result.success(committed)
+            try {
+              val committed = withContext(ioDispatcher) { prefs.commit() }
+              result.success(committed)
+            } catch (e: Exception) {
+              result.error("-11", "Failed to commit preferences: ${e.message}", e)
+            }
          }
🤖 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
`@packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetPlugin.kt`
around lines 84 - 87, The coroutineScope.launch block that calls prefs.commit()
within withContext lacks exception handling. If an exception is thrown during
the commit operation, the coroutine will fail silently and result.success() will
never be invoked, causing "reply never submitted" errors on the Dart side. Wrap
the withContext block in a try-catch statement to handle any exceptions that
might be thrown by prefs.commit() or the context switching operation, and call
result.error() in the catch block with appropriate error details to ensure the
Dart side always receives a response.
🤖 Prompt for all review comments with 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.

Nitpick comments:
In
`@packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetPlugin.kt`:
- Line 75: The IllegalArgumentException() constructor call is missing an error
message argument. Modify the IllegalArgumentException() instantiation to pass
the error message to the constructor instead of using the default constructor.
The error message should be the same descriptive message that is being passed to
the result.error() call, which will improve stack trace readability and provide
better context for debugging.
- Around line 101-113: The coroutineScope.launch block that reads from
SharedPreferences using withContext(ioDispatcher) lacks exception handling,
which means any exceptions during the read operation will prevent
result.success() from being called and leave the request hanging. Wrap the
withContext(ioDispatcher) block in a try-catch statement, and in the catch
block, call result.error() with an appropriate error code and error message to
properly handle read failures, similar to the error handling pattern used in
saveWidgetData.
- Around line 84-87: The coroutineScope.launch block that calls prefs.commit()
within withContext lacks exception handling. If an exception is thrown during
the commit operation, the coroutine will fail silently and result.success() will
never be invoked, causing "reply never submitted" errors on the Dart side. Wrap
the withContext block in a try-catch statement to handle any exceptions that
might be thrown by prefs.commit() or the context switching operation, and call
result.error() in the catch block with appropriate error details to ensure the
Dart side always receives a response.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 64b17785-b8dd-4d0e-863c-da54ead80265

📥 Commits

Reviewing files that changed from the base of the PR and between 0b42c89 and 317f3d5.

📒 Files selected for processing (1)
  • packages/home_widget/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetPlugin.kt

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.

es.antonborri.home_widget.HomeWidgetPlugin.onMethodCall ANR triggered by slow IO operations

1 participant