Skip to content

Conversation

@mzorz
Copy link
Contributor

@mzorz mzorz commented Oct 18, 2025

Closes SIMPL-16
Closes #1776

Fix

This was somewhat a ticking bomb - there was a latent race condition that somehow never appeared (or we never heard of) and now was super reliably reproducible with the latest security patch.

The crash we had was this:

2025-10-18 08:10:14.746 18240-18317 AndroidRuntime          com.automattic.simplenote.debug      E  FATAL EXCEPTION: appwidget-connectiontask-1089226706 (Ask Gemini)
                                                                                                    Process: com.automattic.simplenote.debug, PID: 18240
                                                                                                    java.lang.NullPointerException: Attempt to invoke interface method 'int com.simperium.client.Bucket$ObjectCursor.getCount()' on a null object reference
                                                                                                    	at com.automattic.simplenote.NoteListWidgetFactory.getCount(NoteListWidgetFactory.java:35)
                                                                                                    	at android.widget.RemoteViewsService$RemoteViewsFactory.getRemoteCollectionItems(RemoteViewsService.java:143)
                                                                                                    	at android.widget.RemoteViewsService$RemoteViewsFactoryAdapter.getRemoteCollectionItems(RemoteViewsService.java:288)
                                                                                                    	at android.widget.RemoteViews$RemoteCollectionCache.lambda$getItemsFutureFromIntent$2(RemoteViews.java:1731)
                                                                                                    	at android.widget.RemoteViews$RemoteCollectionCache$$ExternalSyntheticLambda2.accept(D8$$SyntheticClass:0)
                                                                                                    	at android.appwidget.AppWidgetManager$ServiceCollectionCache$ConnectionTask.lambda$handleNext$1(AppWidgetManager.java:1772)
                                                                                                    	at android.appwidget.AppWidgetManager$ServiceCollectionCache$ConnectionTask.$r8$lambda$45TcuKsxsjo8cSErR-GCtx0ila4(Unknown Source:0)
                                                                                                    	at android.appwidget.AppWidgetManager$ServiceCollectionCache$ConnectionTask$$ExternalSyntheticLambda4.run(D8$$SyntheticClass:0)
                                                                                                    	at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:524)
                                                                                                    	at java.util.concurrent.FutureTask.run(FutureTask.java:317)
                                                                                                    	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1156)
                                                                                                    	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:651)
                                                                                                    	at android.appwidget.AppWidgetManager.lambda$createExecutorService$13(AppWidgetManager.java:1657)
                                                                                                    	at android.appwidget.AppWidgetManager$$ExternalSyntheticLambda12.run(D8$$SyntheticClass:0)
                                                                                                    	at java.lang.Thread.run(Thread.java:1119)

It was weird because this part of the code hasn't been touched in years, however the mCursor which is instantiated in onDataSetChanged() was null when the system tried to query the element count accessing getCount() even when we expected a onDataSetChanged() call to be made first.

Searching a bit I found a stackoverflow answer that copies the lifecycle diagram that used to be reliable (the linked official google documentation no longer has that diagram, implying one shouldn't make assumptions about the order of events being triggered).

Knowing this, used he help from Gemini to identify if there was a change on the system that would affect the RemoteViewsFactory lifecycle steps:

tell me what is the lifecycle of RemoteViewsFactory and why getCount() is getting called before onDataSetChanged() in Android 16 security patch September 5

Whole answer below. 👇 👇 👇

The behavior you're observing—getCount() being called before onDataSetChanged() has finished—is a classic and critical race condition in Android's widget development. Understanding the RemoteViewsFactory lifecycle is key to solving it.

The Lifecycle of RemoteViewsFactory

A RemoteViewsFactory doesn't have a standalone lifecycle like an Activity. Instead, its methods are callbacks invoked by the system when interacting with a collection widget (like a ListView on your home screen). The methods are called in a specific, important order.

  1. onCreate()

    • Purpose: The very first method called. It's intended for one-time initializations.
    • What to do here: Set up database connections, initialize any objects that will live for the entire life of the factory. It's the "constructor" of the factory.
  2. onDataSetChanged()

    • Purpose: This is the primary data-refreshing method. The system calls this when your app signals that the widget's data is stale (usually by calling notifyAppWidgetViewDataChanged()).
    • What to do here: This is the correct place to perform heavy lifting, like database queries or network requests. This method is safely executed on a background thread. You should fetch all the data needed for your list here. In your code, this is where mCursor is correctly initialized.
  3. getCount()

    • Purpose: Returns the total number of items in your collection. The system needs this value to lay out the UI and determine how many times it will need to call getViewAt().
    • What to do here: Return the size of your dataset (e.g., mCursor.getCount()).
  4. getViewAt(int position)

    • Purpose: Called for each visible item in the collection to get its corresponding RemoteViews object.
    • What to do here: Create and configure a RemoteViews object for the specific position using the data you fetched in onDataSetChanged().
  5. getLoadingView()

    • Purpose: Provides a temporary placeholder view to display while data is being loaded (i.e., while onDataSetChanged() is running).
    • What to do here: Return a RemoteViews object to act as a loading indicator. Returning null results in a default system loading view.
  6. getViewTypeCount()

    • Purpose: Informs the system how many different types of layouts are in your list to enable proper view recycling.
    • What to do here: Return the number of unique layouts. 1 is very common.
  7. getItemId(int position)

    • Purpose: Provides a unique ID for the item at a given position to help the framework optimize updates.
    • What to do here: Return a stable, unique long integer for the item.
  8. hasStableIds()

    • Purpose: Tells the system if the IDs from getItemId() are consistent across data changes.
    • What to do here: Return true if your IDs are stable.
  9. onDestroy()

    • Purpose: The final cleanup method, called when the factory is no longer needed.
    • What to do here: Release all resources, such as closing cursors and database connections, to prevent memory leaks.

The Race Condition: Why getCount() is Called Before onDataSetChanged() Finishes

This is the core of the problem and a well-known behavior of the API.

The system's UI thread cannot block and wait for your potentially long-running data query in onDataSetChanged() to complete. It needs to know the list size immediately to start drawing the widget.

This leads to the following sequence:

  1. The system creates your NoteListWidgetFactory.
  2. onCreate() is called. At this point, your mCursor is null.
  3. The system immediately calls getCount() on the main thread to determine the list size.
  4. At the same time, the system calls onDataSetChanged() on a background thread to start fetching data.
  5. If getCount() is executed before onDataSetChanged() finishes initializing mCursor, your code will try to call mCursor.getCount(), resulting in a NullPointerException.

The mention of a specific Android version or security patch is relevant because changes in the OS's internal process scheduling can make this race condition more or less likely to occur, but the fundamental issue exists in the API design itself across many Android versions.

The Solution: Defensive Coding in getCount()

To build a robust widget, you must anticipate this race condition and guard against it. The code you have provided already implements the correct solution.

Your getCount() Method is Correct:

Test

  1. install the app
  2. create a home widget
  3. tap on a note in the widget
  • observe the app launches and doesn't crash
  1. tap the launcher icon
  • observe the app launches and doesn't crash

Review

Release

@mzorz mzorz added this to the Future milestone Oct 18, 2025
@mzorz mzorz added [Type] Bug Something isn't working. crash Bad stuff — something related to an app crash! labels Oct 18, 2025
@wpmobilebot
Copy link
Collaborator

📲 You can test the changes from this Pull Request in Simplenote Android by scanning the QR code below to install the corresponding build.

App NameSimplenote Android
Build TypeDebug
Commit0f8d590
Direct Downloadsimplenote-android-prototype-build-pr1779-0f8d590-0199f615-0e23-482f-8cb7-8b0df10f4e1a.apk

@mzorz mzorz enabled auto-merge October 18, 2025 07:00
@mzorz mzorz merged commit 2ab6dbd into trunk Oct 18, 2025
16 of 18 checks passed
@mzorz mzorz deleted the simpl-16-simplenote-android-16-crash-on-launch-v236 branch October 18, 2025 08:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

crash Bad stuff — something related to an app crash! [Type] Bug Something isn't working.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

App crashes when opening if widget is on home page.

4 participants