From 7ec86870dbc7dc05e25c33bf05175c04abc71ecf Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Tue, 13 Jan 2026 11:54:31 -0500 Subject: [PATCH] fix: end initialization early if device storage is locked --- .../java/com/onesignal/common/AndroidUtils.kt | 22 ++++++++++ .../com/onesignal/internal/OneSignalImp.kt | 10 +++++ .../core/internal/application/SDKInitTests.kt | 40 +++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/AndroidUtils.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/AndroidUtils.kt index fe846f503..c1d09cba7 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/AndroidUtils.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/AndroidUtils.kt @@ -10,6 +10,7 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Looper +import android.os.UserManager import android.text.TextUtils import androidx.annotation.Keep import androidx.core.app.NotificationManagerCompat @@ -41,6 +42,27 @@ object AndroidUtils { return hasToken && insetsAttached } + /** + * Retrieve whether the device user is accessible. + * + * On Android 7.0+ (API 24+), encrypted user data is inaccessible until the user unlocks + * the device for the first time after boot. This includes: + * * getSharedPreferences() + * * Any file-based storage in the default credential-encrypted context + * + * Apps that auto-run on boot or background services triggered early may hit this issue. + */ + fun isAndroidUserUnlocked(appContext: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + // Prior to API 24, the device booted into an unlocked state by default + return true + } + + val userManager = appContext.getSystemService(Context.USER_SERVICE) as? UserManager + // assume user is unlocked if the Android UserManager is null + return userManager?.isUserUnlocked ?: true + } + fun hasConfigChangeFlag( activity: Activity, configChangeFlag: Int, diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt index 57495235a..664cfd65c 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt @@ -277,6 +277,16 @@ internal class OneSignalImp( context: Context, appId: String?, ): Boolean { + // Check whether current Android user is accessible. + // Return early if it is inaccessible, as we are unable to complete initialization without access + // to device storage like SharedPreferences. + if (!AndroidUtils.isAndroidUserUnlocked(context)) { + Logging.warn("initWithContext called when device storage is locked, no user data is accessible!") + initState = InitState.FAILED + notifyInitComplete() + return false + } + initEssentials(context) val startupService = bootstrapServices() diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt index 9418aa1f5..724784856 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt @@ -5,6 +5,7 @@ import android.content.ContextWrapper import android.content.SharedPreferences import androidx.test.core.app.ApplicationProvider.getApplicationContext import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.common.AndroidUtils import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging @@ -14,6 +15,9 @@ import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.maps.shouldContain import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject import kotlinx.coroutines.runBlocking import java.util.concurrent.CountDownLatch @@ -98,6 +102,42 @@ class SDKInitTests : FunSpec({ } } + test("initWithContext returns gracefully when Android user is locked") { + // Given + val context = getApplicationContext() + val os = OneSignalImp() + + mockkObject(AndroidUtils) + every { AndroidUtils.isAndroidUserUnlocked(any()) } returns false + + // When + os.initWithContext(context, "appId") + + // Then + // returns gracefully but isInitialized should be false + os.isInitialized shouldBe false + + unmockkObject(AndroidUtils) + } + + test("initWithContext is successful when Android user is unlocked") { + // Given + val context = getApplicationContext() + val os = OneSignalImp() + + mockkObject(AndroidUtils) + every { AndroidUtils.isAndroidUserUnlocked(any()) } returns true + + // When + os.initWithContext(context, "appId") + + // Then + waitForInitialization(os) + os.isInitialized shouldBe true + + unmockkObject(AndroidUtils) + } + test("initWithContext with no appId succeeds when configModel has appId") { // Given // block SharedPreference before calling init