diff --git a/.github/actions/project-setup/action.yml b/.github/actions/project-setup/action.yml index 18bceda33..5b78d1d00 100644 --- a/.github/actions/project-setup/action.yml +++ b/.github/actions/project-setup/action.yml @@ -5,6 +5,7 @@ runs: uses: actions/checkout@v4 with: fetch-depth: 0 + submodules: recursive - name: Setup Java uses: actions/setup-java@v4 diff --git a/.github/workflows/bump-kmp-submodule.yml b/.github/workflows/bump-kmp-submodule.yml new file mode 100644 index 000000000..e24be5746 --- /dev/null +++ b/.github/workflows/bump-kmp-submodule.yml @@ -0,0 +1,126 @@ +name: Bump KMP Submodule + +# Reusable workflow: re-points the OneSignal-KMP-SDK git submodule to a released tag +# and opens a PR. Called by the KMP repo's Release workflow (the fan-out pattern used +# by OneSignal/sdk-shared), or run manually via workflow_dispatch. +# +# Cross-repo callers authenticate by passing the shared org push token as +# GH_PUSH_TOKEN: a workflow triggered from the KMP repo runs with the KMP repo's +# GITHUB_TOKEN, which cannot write here, so GH_PUSH_TOKEN (used across the OneSignal +# SDK repos) is required for the checkout/push/PR to target this repo. + +on: + workflow_call: + inputs: + kmp_version: + description: "OneSignal-KMP-SDK tag to pin the submodule to (e.g. v0.2.0)" + required: true + type: string + kmp_sha: + description: "Commit SHA the tag points at (the gitlink target)" + required: true + type: string + secrets: + GH_PUSH_TOKEN: + required: false + description: Cross-repo push token (contents + pull-requests write) + workflow_dispatch: + inputs: + kmp_version: + description: "OneSignal-KMP-SDK tag to pin the submodule to (e.g. v0.2.0)" + required: true + type: string + kmp_sha: + description: "Commit SHA the tag points at (the gitlink target)" + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + bump: + runs-on: ubuntu-latest + env: + SUBMODULE_PATH: OneSignal-KMP-SDK + VERSION: ${{ inputs.kmp_version }} + NEW_SHA: ${{ inputs.kmp_sha }} + steps: + - name: "[Checkout] Android SDK" + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GH_PUSH_TOKEN || github.token }} + # Only the mode-160000 gitlink SHA is rewritten, so the submodule contents + # are not needed (no recursive checkout). + + - name: "[Setup] Git user" + uses: OneSignal/sdk-shared/.github/actions/setup-git-user@main + + - name: "[Bump] Re-point submodule gitlink" + id: bump + run: | + set -euo pipefail + branch="chore/bump-kmp-${VERSION}" + echo "branch=$branch" >> "$GITHUB_OUTPUT" + + git checkout -b "$branch" + # Update the mode-160000 gitlink entry to the commit the tag points at. + git update-index --add --cacheinfo "160000,${NEW_SHA},${SUBMODULE_PATH}" + + # No-op guard: if the pointer already matches (e.g. the very first release, + # where main already points at the tagged commit), skip the commit/PR + # instead of failing on an empty commit. + if git diff --cached --quiet; then + echo "Submodule already at ${NEW_SHA}; nothing to bump." + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "changed=true" >> "$GITHUB_OUTPUT" + git commit -m "chore(logger): bump ${SUBMODULE_PATH} submodule to ${VERSION}" + git push origin "$branch" --force-with-lease + + - name: "[PR] Open or update bump PR" + if: ${{ steps.bump.outputs.changed == 'true' }} + env: + GH_TOKEN: ${{ secrets.GH_PUSH_TOKEN || github.token }} + run: | + set -euo pipefail + branch="${{ steps.bump.outputs.branch }}" + notes_url="https://github.com/OneSignal/OneSignal-KMP-SDK/releases/tag/${VERSION}" + + # Best-effort: pull the published release notes for this tag so the reviewer can + # read exactly what the new version contains before merging the bump, without + # leaving the PR. Reading another repo's release needs cross-repo access, which + # GH_PUSH_TOKEN has; on any failure (missing token, release not yet visible) we + # fall back to just linking the release page. + notes="$(gh release view "$VERSION" --repo OneSignal/OneSignal-KMP-SDK --json body --jq .body 2>/dev/null || true)" + + body_file="$RUNNER_TEMP/bump_pr_body.md" + { + printf 'Automated bump of the `%s` submodule to **%s** (`%s`).\n\n' "$SUBMODULE_PATH" "$VERSION" "$NEW_SHA" + printf '## Release notes — %s\n\n' "$VERSION" + if [ -n "$notes" ]; then + printf '%s\n\n' "$notes" + printf '_Source: [%s](%s)_\n\n' "$VERSION" "$notes_url" + else + printf 'See [%s](%s).\n\n' "$VERSION" "$notes_url" + fi + printf 'After merge, run `git submodule update --init --recursive` locally to sync.\n' + } > "$body_file" + + # Reuse the PR if one is already open for this branch (re-runs refresh title/body). + if gh pr view "$branch" >/dev/null 2>&1; then + echo "PR already exists for $branch; refreshing title and release notes." + gh pr edit "$branch" \ + --title "chore(logger): bump shared KMP logger to ${VERSION}" \ + --body-file "$body_file" + else + gh pr create \ + --base main \ + --head "$branch" \ + --title "chore(logger): bump shared KMP logger to ${VERSION}" \ + --body-file "$body_file" + fi diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 48a1ff99d..868220850 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,6 +41,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 + with: + submodules: recursive # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index c05cada92..e7a3de63e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -24,6 +24,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + submodules: recursive # Fail fast on unset var. Otherwise `demoOverride()` collapses "" to # null and the build silently uses the hardcoded default app ID. diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index c4dbeb4a8..ead127d6f 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -41,6 +41,15 @@ jobs: uses: actions/checkout@v5 with: ref: ${{ github.event.inputs.ref || github.sha }} + submodules: recursive + + # actions/checkout does not fetch tag refs into submodules (fetch-depth: 1). + # The shared logger stamps LoggerBuildInfo.KMP_VERSION via `git describe --tags` + # and refuses to publish without a vX.Y.* provenance — so tags must be present. + - name: Fetch OneSignal-KMP-SDK tags (logger provenance) + run: | + git -C OneSignal-KMP-SDK fetch --tags --force + echo "KMP provenance: $(git -C OneSignal-KMP-SDK describe --tags --always --dirty)" - name: Detect current branch id: detect_branch diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..e46bacde6 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "OneSignal-KMP-SDK"] + path = OneSignal-KMP-SDK + url = git@github.com:OneSignal/OneSignal-KMP-SDK.git diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c25e37c3..b777d61c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,65 @@ If your proposed contribution is a small bug fix, please feel free to create you If your contribution would _break_ or _change_ the functionality of the SDK, please reach out to us on (contact) before you put in a lot of effort into a change we may not be able to use. We try our best to make sure that the SDK remains stable so that developers do not have to continually change their code, however some breaking changes _are_ desirable, so please get in touch to discuss your idea before you put in a lot of effort. +### Building the SDK from source + +The shared Kotlin Multiplatform `:logger` module lives in the standalone [OneSignal-KMP-SDK](https://github.com/OneSignal/OneSignal-KMP-SDK) repository and is consumed here as a git submodule pinned to a specific commit. Its sources are checked out under `OneSignal-KMP-SDK/`, so you must initialize the submodule before building or the Gradle build will fail (the remapped `:OneSignal:logger` project directory won't exist). + +```bash +# Fresh clone (recommended) +git clone --recurse-submodules git@github.com:OneSignal/OneSignal-Android-SDK.git + +# If you already cloned without --recurse-submodules +git submodule update --init --recursive +``` + +Whenever you pull changes that move the submodule pointer, re-run `git submodule update --init --recursive` to sync your local checkout to the pinned commit. + +### Working on the shared `:logger` module + +The `:logger` sources live in the KMP repo and are checked out here, nested, at `OneSignal-KMP-SDK/`. This means **two gits are in play**: + +- Commands run from the **Android repo root** act on Android and only record the submodule *pointer* (a `160000` gitlink SHA) — not the logger source. +- Commands run from **inside `OneSignal-KMP-SDK/`** act on the KMP repo — that's where the actual `.kt` changes are committed. + +Because Gradle reads the module from the submodule folder on disk, your **uncommitted** KMP edits compile straight into the Android app — no push or version bump is needed while iterating. + +There are two distinct flows: everyday development (Flow A) and releasing (Flow B). + +#### Flow A — Local development (edit → commit → push → merge) + +Ends when your change is on KMP `main`. **No tagging and nothing ships to Android here.** + +```mermaid +flowchart TD + A["Clone with --recurse-submodules
(or: git submodule update --init --recursive)"] --> B["cd OneSignal-KMP-SDK
git checkout main && git pull
git checkout -b feat/my-logger-change"] + B --> C["Edit :logger sources"] + C --> D["Build/verify locally (no push)
Android: ./gradlew :OneSignal:core:compileDebugKotlin
KMP tests: ./gradlew :logger:testDebugUnitTest :logger:iosSimulatorArm64Test spotlessCheck"] + D -->|iterate| C + D --> E["Inside the submodule: commit + push
cd OneSignal-KMP-SDK
git add -A && git commit -m 'feat(logger): ...'
git push -u origin feat/my-logger-change"] + E --> F["Open KMP PR — CI runs (Spotless + unit tests)"] + F --> G["Merge to KMP main"] + G --> H(["Change is on KMP main — still NO tag"]) +``` + +> The submodule starts in **detached HEAD** at the pinned commit. Run `git checkout main` (or `-b `) inside `OneSignal-KMP-SDK/` before committing, or your commit will be orphaned. + +#### Flow B — Release & pin Android to a tag + +Run this when a merged KMP `main` should actually ship into the Android (and later iOS) SDK. **Manual trigger**; Android itself is never tagged — only its submodule *pointer* moves to the KMP tag. + +```mermaid +flowchart TD + A["KMP main has the change(s) to release"] --> B["KMP repo → Actions → 'Release' → Run workflow
pick bump: patch / minor / major"] + B --> C["Release workflow (automatic):
verify → compute next vX.Y.Z
→ create tag + GitHub Release (auto notes)
→ call Android bump workflow (GH_PUSH_TOKEN)"] + C --> D["Android PR opens: 'bump submodule → vX.Y.Z'
moves the pointer to the tag's commit
body includes the KMP release notes · NOT auto-merged"] + D --> E["YOU: review the notes + merge the Android PR"] + E --> F["Sync local Android checkout:
git pull && git submodule update --init --recursive"] + F --> G(["Android pinned to KMP tag vX.Y.Z"]) +``` + +The only manual actions in Flow B are **Run workflow** and **merge the bump PR**; everything in between is automated. + #### Before Submitting A Bug Report Before creating bug reports, please check this list of steps to follow. diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK new file mode 160000 index 000000000..9cf1a33db --- /dev/null +++ b/OneSignal-KMP-SDK @@ -0,0 +1 @@ +Subproject commit 9cf1a33dbc3bb95926fc4accd5af2faf48696dc4 diff --git a/OneSignalSDK/gradle.properties b/OneSignalSDK/gradle.properties index 3ce4922ae..b13a8dc8a 100644 --- a/OneSignalSDK/gradle.properties +++ b/OneSignalSDK/gradle.properties @@ -37,6 +37,18 @@ android.enableD8 = true # Android X settings android.useAndroidX = true +# The `logger` KMP module uses a newer AGP than the Kotlin MPP plugin has been +# tested against; the combination works for our targets, so silence the warning. +kotlin.mpp.androidGradlePluginCompatibility.nowarn = true + +# Opt in to publishing the shared KMP modules (e.g. :kmp, sourced from the +# OneSignal-KMP-SDK submodule) to Maven Central as part of the Android SDK +# release. The shared module's build.gradle only wires up Maven publishing when +# this flag is set, so its coordinate (com.onesignal:kmp, referenced by :core's +# POM) resolves for consumers. The standalone KMP repo build and the iOS host +# (which ships an XCFramework, not a Maven artifact) never set this. +onesignalPublishSharedKmp = true + # This is the name of the SDK to use when building your project. # This will be fed from the GitHub Actions workflow. SDK_VERSION=5.9.7 diff --git a/OneSignalSDK/onesignal/core/build.gradle b/OneSignalSDK/onesignal/core/build.gradle index 6632ebc22..c9d526674 100644 --- a/OneSignalSDK/onesignal/core/build.gradle +++ b/OneSignalSDK/onesignal/core/build.gradle @@ -95,6 +95,8 @@ dependencies { // Otel module dependency implementation(project(':OneSignal:otel')) + // Logger module (KMP, OpenTelemetry-free) — the eventual replacement for :otel. + implementation(project(':OneSignal:logger')) testImplementation(project(':OneSignal:testhelpers')) testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") diff --git a/OneSignalSDK/onesignal/core/src/main/AndroidManifest.xml b/OneSignalSDK/onesignal/core/src/main/AndroidManifest.xml index 445d04058..9a891ea8e 100644 --- a/OneSignalSDK/onesignal/core/src/main/AndroidManifest.xml +++ b/OneSignalSDK/onesignal/core/src/main/AndroidManifest.xml @@ -1,9 +1,9 @@ - - - + + + diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt index b2b2d6903..4151d4b9b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt @@ -29,6 +29,18 @@ enum class FeatureFlag( "sdk_identity_verification", FeatureActivationMode.IMMEDIATE ), + + /** + * Routes SDK observability (remote logging, crash capture/upload, ANR detection) through the + * multiplatform `logger` module instead of the legacy OpenTelemetry `otel` module. + * + * APP_STARTUP: the module choice is latched for the whole process and a remote change only + * takes effect on the next app start — switching observability pipelines mid-session is unsafe. + */ + SDK_CUSTOM_LOGGING( + "sdk_custom_logging", + FeatureActivationMode.APP_STARTUP + ), ; /** diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt index c67e563b9..476835cc1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt @@ -170,6 +170,10 @@ internal class FeatureManager( // SDK_IDENTITY_VERIFICATION has no side effect: IdentityVerificationService // reads featureStates directly via isEnabled() at gate-check time. FeatureFlag.SDK_IDENTITY_VERIFICATION -> {} + // SDK_CUSTOM_LOGGING has no side effect here: the observability module choice is + // made during early init (before this manager exists) by reading the cached flag + // straight from prefs via LoggerModuleSwitch/OtelIdResolver. + FeatureFlag.SDK_CUSTOM_LOGGING -> {} } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt index aeac7c991..fb3ed3a0b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt @@ -4,8 +4,14 @@ import com.onesignal.common.threading.OneSignalDispatchers import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.features.IFeatureManager import com.onesignal.core.internal.startup.IStartableService +import com.onesignal.debug.internal.logging.logger.LoggerModuleSwitch +import com.onesignal.debug.internal.logging.logger.android.AndroidLogger +import com.onesignal.debug.internal.logging.logger.android.FileLogStore +import com.onesignal.debug.internal.logging.logger.android.OneSignalLogHttpSender +import com.onesignal.debug.internal.logging.logger.android.createAndroidLoggerPlatformProvider import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider +import com.onesignal.logger.LoggerFactory import com.onesignal.otel.OtelFactory import com.onesignal.otel.crash.OtelCrashUploader @@ -34,7 +40,7 @@ internal class OneSignalCrashUploaderWrapper( private val applicationService: IApplicationService, private val featureManager: IFeatureManager, ) : IStartableService { - private val uploader: OtelCrashUploader by lazy { + private val otelUploader: OtelCrashUploader by lazy { // Create Android-specific platform provider (injects Android values + a FeatureManager // supplier that resolves to the constructor-injected manager on each access). val platformProvider = createAndroidOtelPlatformProvider( @@ -46,12 +52,25 @@ internal class OneSignalCrashUploaderWrapper( OtelFactory.createCrashUploader(platformProvider, logger) } + private val loggerUploader by lazy { + val platformProvider = createAndroidLoggerPlatformProvider(applicationService.appContext) { featureManager } + val logger = AndroidLogger() + val httpSender = OneSignalLogHttpSender(logger) { platformProvider.isExporterLoggingEnabled } + val remote = LoggerFactory.createRemoteTelemetry(platformProvider, httpSender) + val fileStore = FileLogStore(platformProvider.crashStoragePath) + LoggerFactory.createCrashUploader(platformProvider, remote, fileStore, logger) + } + @Suppress("TooGenericExceptionCaught") override fun start() { if (!OtelSdkSupport.isSupported) return OneSignalDispatchers.launchOnIO { try { - uploader.start() + if (LoggerModuleSwitch.useLoggerModule(applicationService.appContext)) { + loggerUploader.start() + } else { + otelUploader.start() + } } catch (t: Throwable) { com.onesignal.debug.internal.logging.Logging.warn( "OneSignal: Crash uploader failed to start: ${t.message}", diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/Logging.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/Logging.kt index 673db1b8d..7c27cd4f9 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/Logging.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/Logging.kt @@ -6,6 +6,8 @@ import com.onesignal.core.internal.application.IApplicationService import com.onesignal.debug.ILogListener import com.onesignal.debug.LogLevel import com.onesignal.debug.OneSignalLogEvent +import com.onesignal.logger.ILogTelemetryRemote +import com.onesignal.logger.LogLoggingHelper import com.onesignal.otel.IOtelOpenTelemetryRemote import com.onesignal.otel.OtelLoggingHelper import kotlinx.coroutines.CoroutineScope @@ -52,6 +54,29 @@ object Logging { shouldSendLogLevel = shouldSend } + /** + * Optional `logger` module remote telemetry. This is the OpenTelemetry-free + * replacement for [otelRemoteTelemetry]; only one of the two is wired at a time + * (see LoggerModuleSwitch). Set this when remote logging is enabled via the + * logger module. + */ + @Volatile + private var loggerRemoteTelemetry: ILogTelemetryRemote? = null + + @Volatile + private var shouldSendLoggerLogLevel: (LogLevel) -> Boolean = { false } + + /** + * Sets the `logger` module remote telemetry instance and log level check function. + */ + fun setLoggerTelemetry( + telemetry: ILogTelemetryRemote?, + shouldSend: (LogLevel) -> Boolean = { false }, + ) { + loggerRemoteTelemetry = telemetry + shouldSendLoggerLogLevel = shouldSend + } + // Coroutine scope for async Otel logging (non-blocking) private val otelLoggingScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -132,6 +157,7 @@ object Logging { showVisualLogging(level, fullMessage, throwable) callLogListeners(level, fullMessage, throwable) logToOtel(level, fullMessage, throwable) + logToLogger(level, fullMessage, throwable) } private fun logToLogcat( @@ -235,6 +261,37 @@ object Logging { } } + /** + * Logs to the `logger` module remote telemetry if enabled. Non-blocking, mirrors + * [logToOtel]. Only one of the otel/logger sinks is active at a time. + */ + @Suppress("TooGenericExceptionCaught", "ReturnCount") + private fun logToLogger( + level: LogLevel, + message: String, + throwable: Throwable?, + ) { + val telemetry = loggerRemoteTelemetry ?: return + + if (level == LogLevel.NONE) return + if (!shouldSendLoggerLogLevel(level)) return + + otelLoggingScope.launch { + try { + LogLoggingHelper.log( + telemetry = telemetry, + level = level.name, + message = message, + exceptionType = throwable?.javaClass?.name, + exceptionMessage = throwable?.message, + exceptionStacktrace = throwable?.stackTraceToString(), + ) + } catch (t: Throwable) { + android.util.Log.e(TAG, "Failed to log to logger module: ${t.message}", t) + } + } + } + fun addListener(listener: ILogListener) { logListeners.add(listener) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitch.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitch.kt new file mode 100644 index 000000000..e65a7e297 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitch.kt @@ -0,0 +1,28 @@ +package com.onesignal.debug.internal.logging.logger + +import android.content.Context +import com.onesignal.debug.internal.logging.otel.android.OtelIdResolver + +/** + * Routes the SDK's observability (remote logging, crash capture, crash upload, ANR detection) + * through either: + * - the legacy OpenTelemetry-based `otel` module (default), or + * - the new, multiplatform, OpenTelemetry-free `logger` module. + * + * The choice is driven by the [com.onesignal.core.internal.features.FeatureFlag.SDK_CUSTOM_LOGGING] + * remote feature flag, read from the cached config in SharedPreferences via [OtelIdResolver]. Because + * the value comes from the config the *previous* session persisted, enabling/disabling the flag takes + * effect on the next app start — never mid-session (the flag is [FeatureActivationMode.APP_STARTUP]). + * + * The flag is read directly from prefs (not through [com.onesignal.core.internal.features.FeatureManager]) + * because the module decision is made during early init, before service bootstrap. It is read fresh on + * each call — consumers ([com.onesignal.internal.LoggerLifecycleManager] and the crash uploader) all run + * early in the same init pass, before the first remote config fetch can change the persisted value, so + * they resolve to the same module for the session. + * + * Once the `logger` module has been validated in production, the otel path (and this switch) can be + * removed along with the `:otel` module. + */ +internal object LoggerModuleSwitch { + fun useLoggerModule(context: Context): Boolean = OtelIdResolver(context).resolveCustomLoggingEnabled() +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogAnrDetector.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogAnrDetector.kt new file mode 100644 index 000000000..e409959db --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogAnrDetector.kt @@ -0,0 +1,209 @@ +package com.onesignal.debug.internal.logging.logger.android + +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import com.onesignal.debug.internal.crash.AnrCheckEvaluator +import com.onesignal.debug.internal.crash.AnrCheckResult +import com.onesignal.debug.internal.crash.AnrConstants +import com.onesignal.debug.internal.crash.buildBlockFingerprint +import com.onesignal.logger.CrashData +import com.onesignal.logger.ILogAnrDetector +import com.onesignal.logger.ILogCrashReporter +import com.onesignal.logger.ILogger +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Android [ILogAnrDetector] — watchdog that monitors main-thread responsiveness and, if + * OneSignal is at fault, persists a report via the `logger` module's [ILogCrashReporter]. + * Transport-agnostic analogue of `OtelAnrDetector`. + * + * Detection is app-state aware, because a blocked main thread does not mean the same thing + * in the foreground as in the background: + * - Foreground block > [anrThresholdMs]: a real, user-visible ANR — reported via + * [ILogCrashReporter.saveCrash] (fatal). + * - Background block > [backgroundThresholdMs]: not an ANR (Android raises none for a + * backgrounded app) — recorded via [ILogCrashReporter.saveNonFatal] under a distinct + * exception type so it stays out of the ANR metric while remaining queryable. + * - If the watchdog's own sleep overran far beyond [checkIntervalMs], the whole process was + * descheduled (Doze / cached-process freeze), so the measured block is a freeze artifact + * and is suppressed. + * + * Every timing/classification/dedup decision is delegated to the shared [AnrCheckEvaluator] + * (pure, JVM-tested), the same core the `otel` watchdog uses, so the two stay in lockstep. + */ +internal class AndroidLogAnrDetector( + private val crashReporter: ILogCrashReporter, + private val logger: ILogger, + private val anrThresholdMs: Long = AnrConstants.DEFAULT_ANR_THRESHOLD_MS, + private val checkIntervalMs: Long = AnrConstants.DEFAULT_CHECK_INTERVAL_MS, + backgroundThresholdMs: Long = AnrConstants.DEFAULT_BACKGROUND_BLOCK_THRESHOLD_MS, + // Only "background" downgrades to a warning; "unknown" is treated as foreground so a + // genuine ANR is never silently dropped. + private val isAppInForeground: () -> Boolean = { true }, +) : ILogAnrDetector { + private val mainHandler = Handler(Looper.getMainLooper()) + private val isMonitoring = AtomicBoolean(false) + + private val evaluator = + AnrCheckEvaluator( + anrThresholdMs = anrThresholdMs, + checkIntervalMs = checkIntervalMs, + backgroundThresholdMs = backgroundThresholdMs, + frozenSlackMs = FROZEN_PROCESS_SLACK_MS, + dedupWindowMs = MIN_TIME_BETWEEN_ANR_REPORTS_MS, + // Monotonic clock: SystemClock.uptimeMillis matches the main Looper's scheduling + // clock and can't be skewed by NTP/time changes. + now = { SystemClock.uptimeMillis() }, + ) + + private var watchdogThread: Thread? = null + private var watchdogRunnable: Runnable? = null + private var mainThreadRunnable: Runnable? = null + + private companion object { + const val TAG = "AndroidLogAnrDetector" + const val MIN_TIME_BETWEEN_ANR_REPORTS_MS = 30_000L + const val FROZEN_PROCESS_SLACK_MS = 2_000L + } + + override fun start() { + if (isMonitoring.getAndSet(true)) { + logger.warn("$TAG: already monitoring, skipping start") + return + } + // Reset the baseline so a gap between construction and start() can't be read as a block. + evaluator.resetBaseline() + setupRunnables() + watchdogThread = + Thread(watchdogRunnable, "OneSignal-Log-ANR-Watchdog").apply { + isDaemon = true + start() + } + logger.info("$TAG: ANR detection started") + } + + @Suppress("TooGenericExceptionCaught") + private fun setupRunnables() { + mainThreadRunnable = Runnable { evaluator.recordHeartbeat() } + watchdogRunnable = + Runnable { + while (isMonitoring.get()) { + try { + checkForAnr() + } catch (e: InterruptedException) { + break + } catch (t: Throwable) { + logger.error("$TAG: error in watchdog: ${t.message}") + } + } + } + } + + private fun checkForAnr() { + val runnable = mainThreadRunnable ?: return + mainHandler.post(runnable) + // Time the sleep itself: if our own thread oversleeps, the process was frozen, not blocked. + val sleepStart = SystemClock.uptimeMillis() + Thread.sleep(checkIntervalMs) + evaluateCheck(actualSleepMs = SystemClock.uptimeMillis() - sleepStart) + } + + private fun evaluateCheck(actualSleepMs: Long) { + when (val result = evaluator.evaluate(actualSleepMs = actualSleepMs, inForeground = resolveForeground())) { + is AnrCheckResult.Responsive -> Unit + is AnrCheckResult.FrozenProcess -> + logger.debug( + "$TAG: watchdog overslept ${result.actualSleepMs}ms (expected ${result.expectedSleepMs}ms); " + + "process was frozen, not blocked", + ) + is AnrCheckResult.Deduped -> + logger.debug( + "$TAG: block still ongoing (${result.durationMs}ms), already reported ${result.sinceLastReportMs}ms ago", + ) + is AnrCheckResult.ForegroundAnr -> { + logger.info("$TAG: ANR detected! Main thread unresponsive for ${result.durationMs}ms (foreground)") + reportAnr(result.durationMs) + } + is AnrCheckResult.BackgroundWarning -> { + logger.info("$TAG: main thread blocked ${result.durationMs}ms while backgrounded — warning, not ANR") + reportBackgroundBlock(result.durationMs) + } + } + } + + @Suppress("TooGenericExceptionCaught") + private fun resolveForeground(): Boolean = + try { + isAppInForeground() + } catch (t: Throwable) { + // Unknown state: treat as foreground so a genuine ANR is never silently downgraded. + logger.debug("$TAG: could not resolve app state (${t.message}), assuming foreground") + true + } + + override fun stop() { + if (!isMonitoring.getAndSet(false)) { + logger.warn("$TAG: not monitoring, skipping stop") + return + } + watchdogThread?.interrupt() + watchdogThread = null + watchdogRunnable = null + mainThreadRunnable?.let { mainHandler.removeCallbacks(it) } + mainThreadRunnable = null + logger.info("$TAG: ANR detection stopped") + } + + @Suppress("TooGenericExceptionCaught") + private fun reportAnr(unresponsiveDurationMs: Long) { + try { + val mainThread = Looper.getMainLooper().thread + val stackTrace = mainThread.stackTrace + if (!isOneSignalAtFault(stackTrace)) { + logger.debug("$TAG: ANR not OneSignal-related, skipping report") + return + } + val crash = + CrashData( + threadName = mainThread.name, + exceptionType = "ApplicationNotRespondingException", + exceptionMessage = "Application Not Responding: Main thread blocked for ${unresponsiveDurationMs}ms", + stacktrace = stackTrace.joinToString("\n") { it.toString() }, + ) + crashReporter.saveCrash(crash) + logger.info("$TAG: ANR report saved") + } catch (t: Throwable) { + logger.error("$TAG: failed to report ANR: ${t.message}") + } + } + + /** + * Records a backgrounded main-thread block as a retained non-fatal warning rather than an + * ANR. Uses a distinct exception type so it can be segmented into its own stream, and the + * message carries a compact stack fingerprint (top frame + first OneSignal frame) for triage. + */ + @Suppress("TooGenericExceptionCaught") + private fun reportBackgroundBlock(unresponsiveDurationMs: Long) { + try { + val mainThread = Looper.getMainLooper().thread + val stackTrace = mainThread.stackTrace + if (!isOneSignalAtFault(stackTrace)) { + logger.debug("$TAG: background block not OneSignal-related, skipping") + return + } + val crash = + CrashData( + threadName = mainThread.name, + exceptionType = "BackgroundMainThreadBlockException", + exceptionMessage = + "Background main-thread block for ${unresponsiveDurationMs}ms | ${buildBlockFingerprint(stackTrace)}", + stacktrace = stackTrace.joinToString("\n") { it.toString() }, + ) + crashReporter.saveNonFatal(crash) + logger.info("$TAG: background block warning recorded") + } catch (t: Throwable) { + logger.error("$TAG: failed to record background block: ${t.message}") + } + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogCrashHandler.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogCrashHandler.kt new file mode 100644 index 000000000..8a494a4b9 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogCrashHandler.kt @@ -0,0 +1,113 @@ +package com.onesignal.debug.internal.logging.logger.android + +import com.onesignal.logger.CrashData +import com.onesignal.logger.ILogCrashHandler +import com.onesignal.logger.ILogCrashReporter +import com.onesignal.logger.ILogger + +/** + * Android [ILogCrashHandler] — installs a [Thread.UncaughtExceptionHandler] that + * captures OneSignal-related crashes and hands a platform-neutral [CrashData] to the + * `logger` module's [ILogCrashReporter]. + * + * Crash *capture* is platform-specific (hence this lives in core), but everything + * downstream — persisting and shipping — is shared multiplatform code. Direct + * analogue of `OtelCrashHandler`. + */ +internal class AndroidLogCrashHandler( + private val crashReporter: ILogCrashReporter, + private val logger: ILogger, +) : Thread.UncaughtExceptionHandler, ILogCrashHandler { + private var existingHandler: Thread.UncaughtExceptionHandler? = null + private val seenThrowables: MutableList = mutableListOf() + + @Volatile + private var initialized = false + + override fun initialize() { + if (initialized) { + logger.warn("AndroidLogCrashHandler already initialized, skipping") + return + } + existingHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler(this) + initialized = true + logger.info("AndroidLogCrashHandler: registered as default uncaught exception handler") + } + + override fun unregister() { + if (!initialized) { + logger.debug("AndroidLogCrashHandler: not initialized, nothing to unregister") + return + } + Thread.setDefaultUncaughtExceptionHandler(existingHandler) + existingHandler = null + initialized = false + } + + @Suppress("TooGenericExceptionCaught") + override fun uncaughtException(thread: Thread, throwable: Throwable) { + synchronized(seenThrowables) { + if (seenThrowables.contains(throwable)) { + logger.warn("AndroidLogCrashHandler: ignoring duplicate throwable instance") + return + } + seenThrowables.add(throwable) + } + + val isAnr = + throwable.javaClass.simpleName.contains("ApplicationNotResponding", ignoreCase = true) || + throwable.message?.contains("Application Not Responding", ignoreCase = true) == true + + if (!isAnr && !isOneSignalAtFault(throwable)) { + logger.debug("AndroidLogCrashHandler: crash not OneSignal-related, delegating") + existingHandler?.uncaughtException(thread, throwable) + return + } + + logger.info("AndroidLogCrashHandler: OneSignal-related crash detected, saving report") + try { + // Synchronous by contract — process is about to die; write must finish first. + crashReporter.saveCrash(throwable.toCrashData(thread)) + } catch (t: Throwable) { + logger.error("AndroidLogCrashHandler: failed to save crash report: ${t.message}") + } + existingHandler?.uncaughtException(thread, throwable) + } +} + +internal fun Throwable.toCrashData(thread: Thread): CrashData = + CrashData( + threadName = thread.name, + exceptionType = this::class.java.name, + exceptionMessage = message ?: "", + stacktrace = stackTraceToString(), + ) + +/** True when any frame originates from OneSignal SDK code. */ +internal fun isOneSignalAtFault(stackTrace: Array): Boolean = + stackTrace.any { it.className.startsWith("com.onesignal") } + +/** + * True when OneSignal appears anywhere in the throwable graph — the throwable + * itself, its [Throwable.cause] chain, or any [Throwable.getSuppressed] entries. + * + * This is broader than inspecting only the top-level frames: framework wrappers + * (e.g. `RuntimeException: Unable to create application`) and SDK init failures + * that re-throw a generic exception bury the real OneSignal frames one or more + * levels down as a cause or suppressed exception. A BFS with an identity-based + * visited set keeps us safe against cyclic cause chains. + */ +internal fun isOneSignalAtFault(throwable: Throwable): Boolean { + val visited = java.util.Collections.newSetFromMap(java.util.IdentityHashMap()) + val queue = ArrayDeque() + queue.add(throwable) + while (queue.isNotEmpty()) { + val current = queue.removeFirst() + if (!visited.add(current)) continue + if (isOneSignalAtFault(current.stackTrace)) return true + current.cause?.let { queue.add(it) } + current.suppressed.forEach { queue.add(it) } + } + return false +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogger.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogger.kt new file mode 100644 index 000000000..0838ed5e5 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogger.kt @@ -0,0 +1,26 @@ +package com.onesignal.debug.internal.logging.logger.android + +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.logger.ILogger + +/** + * Android implementation of [ILogger] for the `logger` module. Delegates to the + * existing [Logging] object. Direct analogue of `AndroidOtelLogger`. + */ +internal class AndroidLogger : ILogger { + override fun error(message: String) { + Logging.error(message) + } + + override fun warn(message: String) { + Logging.warn(message) + } + + override fun info(message: String) { + Logging.info(message) + } + + override fun debug(message: String) { + Logging.debug(message) + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/FileLogStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/FileLogStore.kt new file mode 100644 index 000000000..a65d87be9 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/FileLogStore.kt @@ -0,0 +1,79 @@ +package com.onesignal.debug.internal.logging.logger.android + +import com.onesignal.logger.ILogFileStore +import com.onesignal.logger.StoredLogFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.util.UUID + +/** + * Android [ILogFileStore] backed by the local filesystem. Replaces OpenTelemetry's + * `disk-buffering` contrib library with a trivial one-file-per-record format we own. + * + * Each crash record is written to its own file under [rootPath]. The file's last + * modified time is used as the record age for [listReadable], mirroring the old + * `minFileAgeForReadMillis` behavior (never read a file the crashing process may + * still have been writing). + */ +internal class FileLogStore( + private val rootPath: String, +) : ILogFileStore { + private val rootDir: File get() = File(rootPath) + + private companion object { + const val FILE_SUFFIX = ".otlp" + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + override fun save(bytes: ByteArray): Boolean { + return try { + val dir = rootDir + if (!dir.exists()) dir.mkdirs() + // Write to a temp file then rename so a half-written file is never readable. + val target = File(dir, "${System.currentTimeMillis()}-${UUID.randomUUID()}$FILE_SUFFIX") + val temp = File(dir, target.name + ".tmp") + temp.writeBytes(bytes) + if (!temp.renameTo(target)) { + // Fallback: write directly if rename is unsupported on this fs. + target.writeBytes(bytes) + temp.delete() + } + true + } catch (t: Throwable) { + // Crash-path safety: never throw from persistence; signal failure to caller. + false + } + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + override suspend fun listReadable(minAgeMillis: Long): List = + withContext(Dispatchers.IO) { + try { + val now = System.currentTimeMillis() + rootDir.listFiles { file -> file.isFile && file.name.endsWith(FILE_SUFFIX) } + ?.filter { now - it.lastModified() >= minAgeMillis } + ?.mapNotNull { file -> + try { + StoredLogFile(id = file.name, bytes = file.readBytes()) + } catch (t: Throwable) { + null + } + } + ?: emptyList() + } catch (t: Throwable) { + emptyList() + } + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + override suspend fun delete(id: String) { + withContext(Dispatchers.IO) { + try { + File(rootDir, id).delete() + } catch (t: Throwable) { + // best-effort + } + } + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformFactory.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformFactory.kt new file mode 100644 index 000000000..34bfff504 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformFactory.kt @@ -0,0 +1,19 @@ +package com.onesignal.debug.internal.logging.logger.android + +import android.content.Context +import com.onesignal.core.internal.features.IFeatureManager +import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider +import com.onesignal.logger.ILoggerPlatformProvider + +/** + * Builds an [ILoggerPlatformProvider] for Android by adapting the existing Android + * platform provider. Centralizes the wiring so the lifecycle manager and crash + * uploader wrapper construct it identically. + */ +internal fun createAndroidLoggerPlatformProvider( + context: Context, + featureManagerProvider: () -> IFeatureManager, +): ILoggerPlatformProvider = + LoggerPlatformProviderAdapter( + createAndroidOtelPlatformProvider(context, featureManagerProvider), + ) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformProviderAdapter.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformProviderAdapter.kt new file mode 100644 index 000000000..f96a9e344 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformProviderAdapter.kt @@ -0,0 +1,48 @@ +package com.onesignal.debug.internal.logging.logger.android + +import com.onesignal.logger.ILoggerPlatformProvider +import com.onesignal.otel.IOtelPlatformProvider + +/** + * Adapts the existing Android [IOtelPlatformProvider] to the `logger` module's + * [ILoggerPlatformProvider]. This reuses all of the battle-tested Android value + * resolution (IDs, config, device metadata) so the logger pipeline reads exactly the + * same values as the otel pipeline — only the consuming interface differs. + * + * When `otel` is eventually removed, the underlying provider's logic can move into a + * native [ILoggerPlatformProvider] implementation and this adapter deleted. + */ +internal class LoggerPlatformProviderAdapter( + private val delegate: IOtelPlatformProvider, +) : ILoggerPlatformProvider { + override suspend fun getInstallId(): String = delegate.getInstallId() + + override val sdkBase: String get() = delegate.sdkBase + override val sdkBaseVersion: String get() = delegate.sdkBaseVersion + override val appPackageId: String get() = delegate.appPackageId + override val appVersion: String get() = delegate.appVersion + override val deviceManufacturer: String get() = delegate.deviceManufacturer + override val deviceModel: String get() = delegate.deviceModel + override val osName: String get() = delegate.osName + override val osVersion: String get() = delegate.osVersion + override val osBuildId: String get() = delegate.osBuildId + override val sdkWrapper: String? get() = delegate.sdkWrapper + override val sdkWrapperVersion: String? get() = delegate.sdkWrapperVersion + override val enabledFeatureFlags: List get() = delegate.enabledFeatureFlags + + override val appId: String? get() = delegate.appId + override val onesignalId: String? get() = delegate.onesignalId + override val pushSubscriptionId: String? get() = delegate.pushSubscriptionId + override val appState: String get() = delegate.appState + override val processUptime: Long get() = delegate.processUptime + override val currentThreadName: String get() = delegate.currentThreadName + + override val crashStoragePath: String get() = delegate.crashStoragePath + override val minFileAgeForReadMillis: Long get() = delegate.minFileAgeForReadMillis + + override val isRemoteLoggingEnabled: Boolean get() = delegate.isRemoteLoggingEnabled + override val remoteLogLevel: String? get() = delegate.remoteLogLevel + override val isExporterLoggingEnabled: Boolean get() = delegate.isOtelExporterLoggingEnabled + override val appIdForHeaders: String get() = delegate.appIdForHeaders + override val apiBaseUrl: String get() = delegate.apiBaseUrl +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/OneSignalLogHttpSender.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/OneSignalLogHttpSender.kt new file mode 100644 index 000000000..bd0e8b7a6 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/OneSignalLogHttpSender.kt @@ -0,0 +1,78 @@ +package com.onesignal.debug.internal.logging.logger.android + +import com.onesignal.logger.ILogHttpSender +import com.onesignal.logger.ILogger +import com.onesignal.logger.LogHttpRequest +import com.onesignal.logger.LogHttpResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL + +/** + * Android [ILogHttpSender] backed by [HttpURLConnection]. + * + * The `logger` module deliberately has no networking dependency, so the platform + * supplies the transport. This keeps the module pure-Kotlin/multiplatform while + * routing all SDK log traffic through a standard, well-understood HTTP client. + * + * Request/response diagnostics are emitted through [logger] only when + * [isDiagnosticsEnabled] returns true (driven by the remote-config exporter-logging + * toggle), mirroring the old otel exporter's opt-in logging — never unconditional + * logcat noise in production. + */ +internal class OneSignalLogHttpSender( + private val logger: ILogger, + private val isDiagnosticsEnabled: () -> Boolean = { false }, +) : ILogHttpSender { + companion object { + private const val CONNECT_TIMEOUT_MS = 10_000 + private const val READ_TIMEOUT_MS = 10_000 + private const val HTTP_OK_MIN = 200 + private const val HTTP_OK_MAX = 299 + private const val MAX_LOGGED_BODY_CHARS = 500 + } + + @Suppress("TooGenericExceptionCaught") + override suspend fun send(request: LogHttpRequest): LogHttpResponse = + withContext(Dispatchers.IO) { + var connection: HttpURLConnection? = null + try { + connection = (URL(request.url).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = CONNECT_TIMEOUT_MS + readTimeout = READ_TIMEOUT_MS + doOutput = true + setRequestProperty("Content-Type", request.contentType) + request.headers.forEach { (key, value) -> setRequestProperty(key, value) } + } + + connection.outputStream.use { it.write(request.body) } + + val code = connection.responseCode + val success = code in HTTP_OK_MIN..HTTP_OK_MAX + if (!success) { + // Always drain the error stream so the connection can be reused/closed cleanly; + // only surface it when diagnostics are enabled. + val errorBody = connection.errorStream?.use { String(it.readBytes()) } + if (isDiagnosticsEnabled()) { + logger.warn( + "OneSignalLogHttpSender: POST ${request.url} -> $code " + + "(ct=${request.contentType}, ${request.body.size}B) " + + "body=${errorBody?.take(MAX_LOGGED_BODY_CHARS)}", + ) + } + } else if (isDiagnosticsEnabled()) { + logger.debug("OneSignalLogHttpSender: POST ${request.url} -> $code OK (${request.body.size}B)") + } + LogHttpResponse(success = success, statusCode = code) + } catch (t: Throwable) { + if (isDiagnosticsEnabled()) { + logger.warn("OneSignalLogHttpSender: POST ${request.url} failed: ${t::class.simpleName}: ${t.message}") + } + LogHttpResponse(success = false, statusCode = -1, message = t.message) + } finally { + connection?.disconnect() + } + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt index b205fffd9..ef400f2ab 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt @@ -2,7 +2,9 @@ package com.onesignal.debug.internal.logging.otel.android import android.content.Context import com.onesignal.common.IDManager +import com.onesignal.common.toList import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.features.FeatureFlag import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.debug.internal.logging.Logging @@ -231,6 +233,21 @@ internal class OtelIdResolver( if (remoteLoggingParams.has("logLevel")) remoteLoggingParams.getString("logLevel") else null ) + /** + * Resolves whether the multiplatform `logger` module should be used instead of `otel`, from + * the [FeatureFlag.SDK_CUSTOM_LOGGING] flag in the cached config's remote feature flags. + * + * Read directly from SharedPreferences (not via ConfigModelStore / FeatureManager) because + * this is consulted during early init, before those services are ready. The cached value is + * whatever the previous session persisted, so toggling the flag remotely takes effect on the + * next app start. Matching is case-insensitive to mirror FeatureManager's canonical keys. + */ + fun resolveCustomLoggingEnabled(): Boolean { + val flags = readConfigModel()?.optJSONArray(ConfigModel::sdkRemoteFeatureFlags.name)?.toList() ?: return false + val target = FeatureFlag.SDK_CUSTOM_LOGGING.key + return flags.any { (it as? String)?.equals(target, ignoreCase = true) == true } + } + /** * Resolves install ID from SharedPreferences. * Returns "InstallId-Null" if not found, "InstallId-NotFound" if there's an error. diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/IObservabilityLifecycleManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/IObservabilityLifecycleManager.kt new file mode 100644 index 000000000..1704e41ef --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/IObservabilityLifecycleManager.kt @@ -0,0 +1,19 @@ +package com.onesignal.internal + +import com.onesignal.core.internal.config.ConfigModelStore + +/** + * Owns the lifecycle of the SDK's observability features (remote logging, crash + * handling, ANR detection) and reacts to remote config changes. + * + * Implemented by both [OtelLifecycleManager] (OpenTelemetry path) and + * [LoggerLifecycleManager] (multiplatform `logger` path) so [OneSignalImp] can switch + * between them via a single toggle without caring which backend is active. + */ +internal interface IObservabilityLifecycleManager { + /** Boots whichever features are already enabled from cached config at cold start. */ + fun initializeFromCachedConfig() + + /** Subscribes to config store change events so features react to fresh remote config. */ + fun subscribeToConfigStore(configModelStore: ConfigModelStore) +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/LoggerLifecycleManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/LoggerLifecycleManager.kt new file mode 100644 index 000000000..435811043 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/LoggerLifecycleManager.kt @@ -0,0 +1,211 @@ +package com.onesignal.internal + +import android.content.Context +import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler +import com.onesignal.common.modeling.ModelChangeTags +import com.onesignal.common.modeling.ModelChangedArgs +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.features.IFeatureManager +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.crash.AnrConstants +import com.onesignal.debug.internal.crash.OtelSdkSupport +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.debug.internal.logging.logger.android.AndroidLogAnrDetector +import com.onesignal.debug.internal.logging.logger.android.AndroidLogCrashHandler +import com.onesignal.debug.internal.logging.logger.android.AndroidLogger +import com.onesignal.debug.internal.logging.logger.android.FileLogStore +import com.onesignal.debug.internal.logging.logger.android.OneSignalLogHttpSender +import com.onesignal.debug.internal.logging.logger.android.createAndroidLoggerPlatformProvider +import com.onesignal.logger.ILogAnrDetector +import com.onesignal.logger.ILogCrashHandler +import com.onesignal.logger.ILogTelemetryRemote +import com.onesignal.logger.ILoggerPlatformProvider +import com.onesignal.logger.LoggerFactory + +/** + * The `logger` module counterpart to [OtelLifecycleManager]. Owns the lifecycle of the + * multiplatform, OpenTelemetry-free observability pipeline and reacts to remote config + * changes the same way (using the shared [OtelConfig]/[OtelConfigEvaluator]). + * + * Only active when [com.onesignal.debug.internal.logging.logger.LoggerModuleSwitch.useLoggerModule] + * resolves true (i.e. the SDK_CUSTOM_LOGGING feature flag is enabled in cached config); + * otherwise [OtelLifecycleManager] is used instead. + */ +@Suppress("TooManyFunctions") +internal class LoggerLifecycleManager( + private val context: Context, + private val featureManagerProvider: () -> IFeatureManager, +) : ISingletonModelStoreChangeHandler, IObservabilityLifecycleManager { + private val lock = Any() + + private val platformProvider: ILoggerPlatformProvider by lazy { + createAndroidLoggerPlatformProvider(context, featureManagerProvider) + } + + private val logger = AndroidLogger() + private val httpSender = OneSignalLogHttpSender(logger) { platformProvider.isExporterLoggingEnabled } + + private val fileStore: FileLogStore by lazy { FileLogStore(platformProvider.crashStoragePath) } + + private var crashHandler: ILogCrashHandler? = null + private var anrDetector: ILogAnrDetector? = null + private var remoteTelemetry: ILogTelemetryRemote? = null + private var currentConfig: OtelConfig? = null + + @Suppress("TooGenericExceptionCaught") + override fun initializeFromCachedConfig() { + if (!OtelSdkSupport.isSupported) { + Logging.info("OneSignal: Device SDK < ${OtelSdkSupport.MIN_SDK_VERSION}, logger module not supported — skipping") + return + } + try { + val cachedConfig = readCurrentCachedConfig() + synchronized(lock) { + val action = OtelConfigEvaluator.evaluate(old = currentConfig, new = cachedConfig) + applyAction(action, cachedConfig) + } + } catch (t: Throwable) { + Logging.warn("OneSignal: Failed to initialize logger module from cached config: ${t.message}", t) + } + } + + override fun subscribeToConfigStore(configModelStore: ConfigModelStore) { + configModelStore.subscribe(this) + } + + @Suppress("TooGenericExceptionCaught") + override fun onModelReplaced(model: ConfigModel, tag: String) { + if (tag != ModelChangeTags.HYDRATE) return + if (!OtelSdkSupport.isSupported) return + try { + val newConfig = + OtelConfig( + isEnabled = model.remoteLoggingParams.isEnabled, + logLevel = model.remoteLoggingParams.logLevel, + ) + synchronized(lock) { + val action = OtelConfigEvaluator.evaluate(old = currentConfig, new = newConfig) + applyAction(action, newConfig) + } + } catch (t: Throwable) { + Logging.warn("OneSignal: Failed to refresh logger module from remote config: ${t.message}", t) + } + } + + override fun onModelUpdated(args: ModelChangedArgs, tag: String) { + // Only full model replacements (HYDRATE) matter here. + } + + private fun readCurrentCachedConfig(): OtelConfig { + val enabled = platformProvider.isRemoteLoggingEnabled + val level = LogLevel.fromString(platformProvider.remoteLogLevel) + return OtelConfig(isEnabled = enabled, logLevel = level) + } + + /** Must be called while holding [lock]. */ + private fun applyAction(action: OtelConfigAction, newConfig: OtelConfig) { + when (action) { + is OtelConfigAction.Enable -> enableFeatures(newConfig.logLevel ?: LogLevel.ERROR) + is OtelConfigAction.Disable -> disableFeatures() + is OtelConfigAction.UpdateLogLevel -> updateLogLevel(action.newLevel) + is OtelConfigAction.NoChange -> Logging.debug("OneSignal: logger config unchanged") + } + currentConfig = newConfig + } + + @Suppress("TooGenericExceptionCaught") + private fun enableFeatures(logLevel: LogLevel) { + Logging.info("OneSignal: Enabling logger module features at level $logLevel") + try { + startCrashHandler() + } catch (t: Throwable) { + Logging.warn("OneSignal: Failed to start logger crash handler: ${t.message}", t) + } + try { + startAnrDetector() + } catch (t: Throwable) { + Logging.warn("OneSignal: Failed to start logger ANR detector: ${t.message}", t) + } + try { + startLogging(logLevel) + } catch (t: Throwable) { + Logging.warn("OneSignal: Failed to start logger logging: ${t.message}", t) + } + } + + @Suppress("TooGenericExceptionCaught") + private fun disableFeatures() { + Logging.info("OneSignal: Disabling logger module features") + try { + anrDetector?.stop() + anrDetector = null + } catch (t: Throwable) { + Logging.warn("OneSignal: Error stopping logger ANR detector: ${t.message}", t) + } + try { + crashHandler?.unregister() + crashHandler = null + } catch (t: Throwable) { + Logging.warn("OneSignal: Error unregistering logger crash handler: ${t.message}", t) + } + try { + Logging.setLoggerTelemetry(null) { false } + remoteTelemetry?.shutdown() + remoteTelemetry = null + } catch (t: Throwable) { + Logging.warn("OneSignal: Error disabling logger logging: ${t.message}", t) + } + } + + @Suppress("TooGenericExceptionCaught") + private fun updateLogLevel(newLevel: LogLevel) { + Logging.info("OneSignal: Updating logger module log level to $newLevel") + try { + startLogging(newLevel) + } catch (t: Throwable) { + Logging.warn("OneSignal: Failed to update logger log level: ${t.message}", t) + } + } + + private fun startCrashHandler() { + if (crashHandler != null) return + val crashTelemetry = LoggerFactory.createCrashLocalTelemetry(platformProvider, fileStore) + val reporter = LoggerFactory.createCrashReporter(crashTelemetry, logger) + val handler = AndroidLogCrashHandler(reporter, logger) + handler.initialize() + crashHandler = handler + Logging.info("OneSignal: logger crash handler initialized — logs at: ${platformProvider.crashStoragePath}") + } + + private fun startAnrDetector() { + if (anrDetector != null) return + val crashTelemetry = LoggerFactory.createCrashLocalTelemetry(platformProvider, fileStore) + val reporter = LoggerFactory.createCrashReporter(crashTelemetry, logger) + val detector = + AndroidLogAnrDetector( + reporter, + logger, + AnrConstants.DEFAULT_ANR_THRESHOLD_MS, + AnrConstants.DEFAULT_CHECK_INTERVAL_MS, + AnrConstants.DEFAULT_BACKGROUND_BLOCK_THRESHOLD_MS, + // Only "background" downgrades a block to a non-fatal warning; "unknown" is + // treated as foreground so a genuine ANR is never silently dropped. + isAppInForeground = { platformProvider.appState != "background" }, + ) + detector.start() + anrDetector = detector + Logging.info("OneSignal: logger ANR detector started") + } + + private fun startLogging(logLevel: LogLevel) { + remoteTelemetry?.shutdown() + val telemetry = LoggerFactory.createRemoteTelemetry(platformProvider, httpSender) + remoteTelemetry = telemetry + val shouldSend: (LogLevel) -> Boolean = { level -> + logLevel != LogLevel.NONE && level <= logLevel + } + Logging.setLoggerTelemetry(telemetry, shouldSend) + Logging.info("OneSignal: logger module logging active at level $logLevel") + } +} 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 a12f4a438..306dc57b2 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 @@ -61,7 +61,7 @@ internal class OneSignalImp : IOneSignal, // Save the exception pointing to the caller that triggered init, not the async worker thread. private var initFailureException: Exception? = null - private var otelManager: OtelLifecycleManager? = null + private var observabilityManager: IObservabilityLifecycleManager? = null override val sdkVersion: String = OneSignalUtils.sdkVersion @@ -239,11 +239,13 @@ internal class OneSignalImp : IOneSignal, // anything that happens during the rest of init. FeatureManager is wired in via a // lazy supplier — `enabledFeatureFlags` is read per-event, so resolving the manager // can be deferred until services have bootstrapped. - otelManager = - OtelLifecycleManager( - context = context, - featureManagerProvider = { services.getService() }, - ).also { it.initializeFromCachedConfig() } + val featureManagerProvider = { services.getService() } + observabilityManager = + if (com.onesignal.debug.internal.logging.logger.LoggerModuleSwitch.useLoggerModule(context)) { + LoggerLifecycleManager(context = context, featureManagerProvider = featureManagerProvider) + } else { + OtelLifecycleManager(context = context, featureManagerProvider = featureManagerProvider) + }.also { it.initializeFromCachedConfig() } PreferenceStoreFix.ensureNoObfuscatedPrefStore(context) @@ -387,7 +389,7 @@ internal class OneSignalImp : IOneSignal, // Now that the IoC container is ready, subscribe the Otel lifecycle // manager to config store events so it reacts to fresh remote config. - otelManager?.subscribeToConfigStore(services.getService()) + observabilityManager?.subscribeToConfigStore(services.getService()) val result = resolveAppId(appId, configModel, preferencesService) if (result.failed) { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt index 62263470a..a9de086d0 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt @@ -54,7 +54,7 @@ internal class OtelLifecycleManager( private val platformProviderFactory: (Context, () -> IFeatureManager) -> OtelPlatformProvider = { ctx, fm -> createAndroidOtelPlatformProvider(ctx, fm) }, private val loggerFactory: () -> IOtelLogger = { AndroidOtelLogger() }, -) : ISingletonModelStoreChangeHandler { +) : ISingletonModelStoreChangeHandler, IObservabilityLifecycleManager { private val lock = Any() private val platformProvider: OtelPlatformProvider by lazy { @@ -74,7 +74,7 @@ internal class OtelLifecycleManager( * whichever features are already enabled. */ @Suppress("TooGenericExceptionCaught") - fun initializeFromCachedConfig() { + override fun initializeFromCachedConfig() { if (!OtelSdkSupport.isSupported) { Logging.info("OneSignal: Device SDK < ${OtelSdkSupport.MIN_SDK_VERSION}, Otel not supported — skipping all Otel features") return @@ -95,7 +95,7 @@ internal class OtelLifecycleManager( * Subscribes this manager to config store change events. * Call after the IoC container is bootstrapped (i.e. after [bootstrapServices]). */ - fun subscribeToConfigStore(configModelStore: ConfigModelStore) { + override fun subscribeToConfigStore(configModelStore: ConfigModelStore) { configModelStore.subscribe(this) } diff --git a/OneSignalSDK/onesignal/core/src/test/AndroidManifest.xml b/OneSignalSDK/onesignal/core/src/test/AndroidManifest.xml index 8768713b9..04ba2503d 100644 --- a/OneSignalSDK/onesignal/core/src/test/AndroidManifest.xml +++ b/OneSignalSDK/onesignal/core/src/test/AndroidManifest.xml @@ -3,5 +3,5 @@ xmlns:tools="http://schemas.android.com/tools"> + tools:overrideLibrary="com.onesignal.otel, com.onesignal.logger" /> diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt index e2417a3fa..e187f4cf5 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt @@ -16,4 +16,11 @@ class FeatureFlagTests : FunSpec({ FeatureFlag.SDK_IDENTITY_VERIFICATION.key shouldBe "sdk_identity_verification" FeatureFlag.SDK_IDENTITY_VERIFICATION.activationMode shouldBe FeatureActivationMode.IMMEDIATE } + + test("SDK_CUSTOM_LOGGING uses the expected remote key and APP_STARTUP activation") { + // APP_STARTUP so the observability module choice is latched per process and a remote + // change only takes effect on the next app start. + FeatureFlag.SDK_CUSTOM_LOGGING.key shouldBe "sdk_custom_logging" + FeatureFlag.SDK_CUSTOM_LOGGING.activationMode shouldBe FeatureActivationMode.APP_STARTUP + } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitchTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitchTest.kt new file mode 100644 index 000000000..a03168eaf --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitchTest.kt @@ -0,0 +1,63 @@ +package com.onesignal.debug.internal.logging.logger + +import android.content.Context +import android.content.SharedPreferences +import androidx.test.core.app.ApplicationProvider +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.features.FeatureFlag +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import org.json.JSONArray +import org.json.JSONObject +import com.onesignal.core.internal.config.CONFIG_NAME_SPACE as configNameSpace + +/** + * End-to-end coverage for the otel-vs-logger routing switch. Asserts [LoggerModuleSwitch.useLoggerModule] + * reflects the SDK_CUSTOM_LOGGING flag as persisted in the cached config (the same prefs the previous + * session wrote), which is how the choice is made during early init before service bootstrap. + */ +@RobolectricTest +class LoggerModuleSwitchTest : FunSpec({ + + lateinit var appContext: Context + lateinit var sharedPreferences: SharedPreferences + + fun writeCachedFeatureFlags(vararg flags: String) { + val configModel = JSONObject().apply { + put(ConfigModel::sdkRemoteFeatureFlags.name, JSONArray().apply { flags.forEach { put(it) } }) + } + val configArray = JSONArray().apply { put(configModel) } + sharedPreferences.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + .commit() + } + + beforeEach { + appContext = ApplicationProvider.getApplicationContext() + sharedPreferences = appContext.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + sharedPreferences.edit().clear().commit() + } + + afterEach { + sharedPreferences.edit().clear().commit() + } + + test("useLoggerModule returns true when SDK_CUSTOM_LOGGING is cached") { + writeCachedFeatureFlags(FeatureFlag.SDK_CUSTOM_LOGGING.key) + + LoggerModuleSwitch.useLoggerModule(appContext) shouldBe true + } + + test("useLoggerModule returns false when SDK_CUSTOM_LOGGING is not cached") { + writeCachedFeatureFlags("sdk_identity_verification") + + LoggerModuleSwitch.useLoggerModule(appContext) shouldBe false + } + + test("useLoggerModule returns false when no config is cached") { + LoggerModuleSwitch.useLoggerModule(appContext) shouldBe false + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt index 86be0f189..d43e92b69 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt @@ -6,6 +6,7 @@ import androidx.test.core.app.ApplicationProvider import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest import com.onesignal.common.IDManager.LOCAL_PREFIX import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.features.FeatureFlag import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.debug.LogLevel @@ -1048,4 +1049,68 @@ class OtelIdResolverTest : FunSpec({ appId2 shouldBe "test-app-id" pushId2 shouldBe "test-push-id" } + + // ===== resolveCustomLoggingEnabled Tests ===== + // Reads the SDK_CUSTOM_LOGGING flag from the cached config's sdkRemoteFeatureFlags array. + // Drives the otel-vs-logger observability module choice (via LoggerModuleSwitch) on next launch. + + fun writeConfigWithFeatureFlags(vararg flags: String) { + val configModel = JSONObject().apply { + put(ConfigModel::sdkRemoteFeatureFlags.name, JSONArray().apply { flags.forEach { put(it) } }) + } + writeAndVerifyConfigData(JSONArray().apply { put(configModel) }) + } + + test("resolveCustomLoggingEnabled returns true when sdk_custom_logging flag is present") { + writeConfigWithFeatureFlags(FeatureFlag.SDK_CUSTOM_LOGGING.key) + + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe true + } + + test("resolveCustomLoggingEnabled matches the flag case-insensitively") { + writeConfigWithFeatureFlags("SDK_Custom_Logging") + + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe true + } + + test("resolveCustomLoggingEnabled returns true when flag is present among other flags") { + writeConfigWithFeatureFlags("sdk_identity_verification", "sdk_custom_logging", "some_other_flag") + + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe true + } + + test("resolveCustomLoggingEnabled returns false when flag is absent but others present") { + writeConfigWithFeatureFlags("sdk_identity_verification") + + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe false + } + + test("resolveCustomLoggingEnabled returns false when the feature flags array is empty") { + writeConfigWithFeatureFlags() + + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe false + } + + test("resolveCustomLoggingEnabled returns false when sdkRemoteFeatureFlags field is missing") { + val configModel = JSONObject().apply { put(ConfigModel::appId.name, "test-app-id") } + writeAndVerifyConfigData(JSONArray().apply { put(configModel) }) + + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe false + } + + test("resolveCustomLoggingEnabled returns false when no config exists") { + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe false + } + + test("resolveCustomLoggingEnabled returns false when context is null") { + OtelIdResolver(null).resolveCustomLoggingEnabled() shouldBe false + } + + test("resolveCustomLoggingEnabled handles invalid JSON gracefully") { + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "invalid-json") + .commit() + + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe false + } }) diff --git a/OneSignalSDK/settings.gradle b/OneSignalSDK/settings.gradle index e5a1a34ad..291fc2940 100644 --- a/OneSignalSDK/settings.gradle +++ b/OneSignalSDK/settings.gradle @@ -16,6 +16,7 @@ gradle.rootProject { substitute(module('com.onesignal:location')).using(project(':OneSignal:location')) substitute(module('com.onesignal:in-app-messages')).using(project(':OneSignal:in-app-messages')) substitute(module('com.onesignal:otel')).using(project(':OneSignal:otel')) + substitute(module('com.onesignal:logger')).using(project(':OneSignal:logger')) } } } @@ -32,3 +33,10 @@ include ':OneSignal:location' include ':OneSignal:notifications' include ':OneSignal:testhelpers' include ':OneSignal:otel' + +// The :logger module is a Kotlin Multiplatform module maintained in the standalone +// OneSignal-KMP-SDK repo and consumed here as a git submodule (pinned commit). Its +// sources live outside this Gradle tree, so we remap the project directory. Run +// `git submodule update --init --recursive` after cloning, or the path won't exist. +include ':OneSignal:logger' +project(':OneSignal:logger').projectDir = new File(settingsDir, '../OneSignal-KMP-SDK/logger') diff --git a/README.md b/README.md index f1621c78b..33bb722b4 100644 --- a/README.md +++ b/README.md @@ -38,5 +38,7 @@ For account issues and support please contact OneSignal support from the [OneSig #### Demo Project To make things easier, we have published demo projects in the `/Examples` folder of this repository. +> **Building from source?** This repository uses a git submodule for the shared Kotlin Multiplatform `:logger` module. Clone with `git clone --recurse-submodules ...`, or run `git submodule update --init --recursive` after cloning, before building. See [CONTRIBUTING.md](CONTRIBUTING.md#building-the-sdk-from-source) for details. + #### Supports: * Tested from Android 5.0 (API level 21) to Android 14 (34) \ No newline at end of file