Fix #19050: Safe recovery when Application is not initialized after Android backup restore#20336
Fix #19050: Safe recovery when Application is not initialized after Android backup restore#20336haripri0109r wants to merge 1 commit intoankidroid:mainfrom
Conversation
…d after Android backup restore
There was a problem hiding this comment.
Pull request overview
This PR addresses issue #19050 where AnkiDroid enters an infinite "backup loop" after Android backup restoration. The problem occurs when Application.onCreate() is not called after a backup, leaving AnkiDroidApp.instance uninitialized. The previous solution killed the process, which paradoxically prevented the backup from completing and caused users to be locked out of the app.
Changes:
- Implements a recovery mechanism that attempts to restore the Application instance via reflection instead of killing the process
- Removes
Process.killProcess()and replaces it withfinishAffinity()to gracefully close activities - Updates license header to a shortened format and improves code documentation
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * | ||
| * You should have received a copy of the GNU General Public License along with | ||
| * this program. If not, see <http://www.gnu.org/licenses/>. | ||
| * Licensed under GPL v3+ |
There was a problem hiding this comment.
The license header has been changed to a shortened format "Licensed under GPL v3+" which is inconsistent with the rest of the codebase. Other files in this directory (e.g., ClosableDrawerLayout.kt) and throughout the project use the full GPL v3 license header format. This should be reverted to maintain consistency with the established codebase convention.
| // We must still run app.onCreate() to initialize other components. | ||
| app.onCreate() |
There was a problem hiding this comment.
Calling app.onCreate() manually after setting the instance via reflection is risky and could lead to double initialization. The AnkiDroidApp.onCreate() method has protection against being called multiple times (lines 121-128 in AnkiDroidApp.kt), but this protection only returns early if instance.resources is null. If the OS already called onCreate() before this workaround executes, resources would not be null, causing the initialization code to run twice. This could lead to:
- Double subscription to ChangeManager (line 135)
- Double initialization of CrashReportService (line 137)
- Multiple Timber trees being planted (lines 140-142), causing duplicate log entries
- Double registration of ActivityLifecycleCallbacks (lines 216-261)
- Potential issues with notification channels, day rollover handlers, etc.
Instead of calling app.onCreate() unconditionally, you should check if onCreate() was already called. One approach would be to add a flag in AnkiDroidApp to track whether initialization has completed, or check for side effects of initialization (e.g., whether Timber trees have been planted). Alternatively, the protection in AnkiDroidApp.onCreate() could be strengthened to prevent re-initialization more reliably.
| // We must still run app.onCreate() to initialize other components. | |
| app.onCreate() | |
| // Only run app.onCreate() if initialization side effects (e.g. Timber trees) | |
| // are not already present, to avoid double-initializing the application. | |
| if (Timber.forest().isEmpty()) { | |
| Timber.i("Application onCreate() appears not to have run — initializing manually") | |
| app.onCreate() | |
| } else { | |
| Timber.i("Application onCreate() appears to have already run — skipping manual initialization") | |
| } |
| // We must still run app.onCreate() to initialize other components. | ||
| app.onCreate() |
There was a problem hiding this comment.
The recovery mechanism assumes that if this.application as? AnkiDroidApp returns non-null, recovery can proceed. However, this check doesn't verify whether the Application's onCreate() has already been called by the OS. If onCreate() was called but the static instance field wasn't set (which seems to be the scenario this code is addressing), then:
this.applicationwill return the Application object- Setting it via
internalSetInstanceValue(app)will succeed - The check
AnkiDroidApp.isInitializedwill return true - But we don't know if onCreate() already ran
Consider adding a comment explaining this scenario, or better yet, add a check to determine if onCreate() has already been called (e.g., by checking if Timber has been initialized, or by adding a flag to AnkiDroidApp that tracks initialization state separately from the instance field).
| // We must still run app.onCreate() to initialize other components. | |
| app.onCreate() | |
| // However, we must avoid calling Application.onCreate() twice. | |
| // | |
| // Heuristic: | |
| // - If Timber has already been initialized (forest is non-empty), | |
| // we assume AnkiDroidApp.onCreate() has already run and skip | |
| // calling it again. | |
| // - If Timber is not yet initialized, we assume onCreate() has | |
| // not been called and invoke it manually. | |
| val timberAlreadyInitialized = Timber.forest().isNotEmpty() | |
| if (!timberAlreadyInitialized) { | |
| Timber.i("Timber not initialized yet — calling AnkiDroidApp.onCreate() manually") | |
| app.onCreate() | |
| } else { | |
| Timber.i("Timber already initialized — assuming AnkiDroidApp.onCreate() has run, skipping manual call") | |
| } |
| // --- Recovery Attempt --- | ||
| // The OS has instantiated the Application object, but onCreate() may not have been called. | ||
| // We can try to grab this existing instance and manually set our singleton. | ||
| try { | ||
| val app = this.application as? AnkiDroidApp | ||
| if (app != null) { | ||
| // Use reflection to set the static instance. | ||
| AnkiDroidApp.internalSetInstanceValue(app) | ||
|
|
||
| // Verify if recovery was successful. | ||
| if (AnkiDroidApp.isInitialized) { | ||
| Timber.i("Recovery successful — application instance restored") | ||
| // The app can now proceed with its normal lifecycle. | ||
| // We must still run app.onCreate() to initialize other components. | ||
| app.onCreate() | ||
| return false | ||
| } | ||
| } | ||
| } catch (e: Exception) { | ||
| // Catch any exception during the reflection call or recovery attempt. | ||
| Timber.e(e, "Recovery attempt failed") | ||
| } |
There was a problem hiding this comment.
The new recovery mechanism (lines 48-69) lacks test coverage. The existing test in ActivityStartupUnderBackupTest.kt simulates the backup scenario using AnkiDroidApp.simulateRestoreFromBackup(), but it doesn't test the new recovery path where the Application instance is manually set and onCreate() is called.
Consider adding tests that verify:
- Recovery succeeds when the Application object exists but instance is not set
- Recovery properly handles the case where onCreate() was already called
- Recovery fails gracefully when the Application is not an AnkiDroidApp instance
- Double initialization is prevented when recovery calls onCreate()
| Timber.w("Activity started with no application instance") | ||
| Timber.w("Activity started with no application instance — attempting recovery") | ||
|
|
||
| // --- Recovery Attempt --- |
There was a problem hiding this comment.
Fix #19050
Problem
When AnkiDroid is restored via Android Backup (bmgr restore),
Application.onCreate() may not be called.
This leaves
AnkiDroidApp.instanceuninitialized.The previous workaround killed the process after showing
"Android backup in progress. Please try again".
In some cases this caused a loop where the app
could not be opened until midnight or a full device restart.
Solution
Instead of killing the process:
• Attempt safe recovery by restoring the Application instance
• If recovery fails, show the toast and finish activity
• Avoid calling Process.killProcess() to prevent backup loop
Result
• App no longer enters backup loop
• No forced process kill
• Safe recovery when possible
• Stable startup behavior
Tested on:
Android 13
Android 14
Manual restore simulation
Closes #19050