From f1603cf70c7768b635bbb983eae9384a3964806f Mon Sep 17 00:00:00 2001 From: Sherwin Heydarbeygi Date: Fri, 23 Jan 2026 15:27:02 -0800 Subject: [PATCH 1/8] ci: remove "Auto-Generated Release Notes" header from create-release-pr workflow (#2526) --- .github/workflows/create-release-pr.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml index b1badcf80..ce11e837b 100644 --- a/.github/workflows/create-release-pr.yml +++ b/.github/workflows/create-release-pr.yml @@ -110,9 +110,6 @@ jobs: - name: Generate Release Notes from PR Titles id: generate_notes run: | - echo "## 🔖 Auto-Generated Release Notes" > pr_body.md - echo "" >> pr_body.md - if [[ "$VERSION" == *"alpha"* ]]; then CHANNEL="alpha" elif [[ "$VERSION" == *"beta"* ]]; then From 446d4dd84f63dc215b3bd7a11e5cce511c40f2c4 Mon Sep 17 00:00:00 2001 From: abdulraqeeb33 Date: Mon, 26 Jan 2026 10:44:01 -0500 Subject: [PATCH 2/8] chore: Restrict test coverage just for the lines of code changed (#2527) Co-authored-by: AR Abdul Azeez --- OneSignalSDK/coverage/checkCoverage.sh | 168 ++++++++++++++++++++++--- 1 file changed, 149 insertions(+), 19 deletions(-) diff --git a/OneSignalSDK/coverage/checkCoverage.sh b/OneSignalSDK/coverage/checkCoverage.sh index 1f0814c1f..26708f0af 100755 --- a/OneSignalSDK/coverage/checkCoverage.sh +++ b/OneSignalSDK/coverage/checkCoverage.sh @@ -2,6 +2,7 @@ # Diff Coverage Check Script # This script generates coverage reports and checks diff coverage against the base branch +# Only checks coverage for newly added/modified lines (not entire files) # Uses a manual coverage check that reliably matches JaCoCo paths to git diff paths # # Usage: @@ -25,7 +26,10 @@ SKIP_COVERAGE_CHECK=${SKIP_COVERAGE_CHECK:-false} # Set to 'true' to bypass cov # Get script directory and project root SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# PROJECT_ROOT is the OneSignalSDK directory (where build reports are) PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +# REPO_ROOT is the git repository root (parent of OneSignalSDK) +REPO_ROOT="$(cd "$PROJECT_ROOT/.." && pwd)" # Paths relative to project root COVERAGE_REPORT="$PROJECT_ROOT/build/reports/jacoco/merged/jacocoMergedReport.xml" @@ -42,7 +46,7 @@ if [ "$SKIP_COVERAGE_CHECK" = "true" ]; then BYPASS_REASON="SKIP_COVERAGE_CHECK environment variable set" elif [ -n "$GITHUB_EVENT_NAME" ] && [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then # Check commit messages for bypass keyword - cd "$PROJECT_ROOT" + cd "$REPO_ROOT" COMMIT_MESSAGES=$(git log --format=%B origin/main..HEAD 2>/dev/null || git log --format=%B "$BASE_BRANCH"..HEAD 2>/dev/null || echo "") if echo "$COMMIT_MESSAGES" | grep -qiE "\[skip coverage\]|\[bypass coverage\]|\[no coverage\]"; then BYPASS_REASON="Commit message contains [skip coverage] keyword" @@ -71,8 +75,13 @@ echo -e "${YELLOW}[2/3] Checking diff coverage against $BASE_BRANCH...${NC}" echo -e "${YELLOW}Threshold: ${COVERAGE_THRESHOLD}%${NC}\n" # Get changed files (run from project root) -cd "$PROJECT_ROOT" -CHANGED_FILES=$(git diff --name-only "$BASE_BRANCH"...HEAD 2>/dev/null | grep -E '\.(kt|java)$' || true) +# Include committed changes, staged changes, and unstaged changes +cd "$REPO_ROOT" +COMMITTED_FILES=$(git diff --name-only "$BASE_BRANCH"...HEAD 2>/dev/null | grep -E '\.(kt|java)$' || true) +STAGED_FILES=$(git diff --cached --name-only 2>/dev/null | grep -E '\.(kt|java)$' || true) +UNSTAGED_FILES=$(git diff --name-only 2>/dev/null | grep -E '\.(kt|java)$' || true) +# Combine all, remove duplicates, and filter to OneSignalSDK files +CHANGED_FILES=$(echo -e "$COMMITTED_FILES\n$STAGED_FILES\n$UNSTAGED_FILES" | grep -E '^OneSignalSDK/' | sort -u || true) if [ -z "$CHANGED_FILES" ]; then echo -e "${BLUE}No Kotlin/Java files changed${NC}\n" @@ -91,17 +100,93 @@ else export COVERAGE_REPORT export GENERATE_MARKDOWN export MARKDOWN_REPORT + export BASE_BRANCH + export REPO_ROOT python3 << PYEOF import xml.etree.ElementTree as ET import re import sys import os +import subprocess coverage_report = os.environ.get('COVERAGE_REPORT') threshold = int(os.environ.get('COVERAGE_THRESHOLD', '80')) changed_files_str = """$CHANGED_FILES""" generate_markdown = os.environ.get('GENERATE_MARKDOWN', 'false').lower() == 'true' markdown_report = os.environ.get('MARKDOWN_REPORT', 'diff_coverage.md') +base_branch = os.environ.get('BASE_BRANCH', 'origin/main') +repo_root_env = os.environ.get('REPO_ROOT') + +def get_changed_lines(file_path, project_root): + """Get line numbers of added/modified lines from git diff""" + try: + # First try to get diff from committed changes + result = subprocess.run( + ['git', 'diff', '--unified=0', base_branch + '...HEAD', '--', file_path], + capture_output=True, + text=True, + cwd=project_root + ) + + # If no committed changes, check staged changes + if result.returncode != 0 or not result.stdout.strip(): + result = subprocess.run( + ['git', 'diff', '--cached', '--unified=0', '--', file_path], + capture_output=True, + text=True, + cwd=project_root + ) + + # If no staged changes, check unstaged changes + if result.returncode != 0 or not result.stdout.strip(): + result = subprocess.run( + ['git', 'diff', '--unified=0', '--', file_path], + capture_output=True, + text=True, + cwd=project_root + ) + + # If still nothing, try alternative base branch format + if result.returncode != 0 or not result.stdout.strip(): + result = subprocess.run( + ['git', 'diff', '--unified=0', base_branch, 'HEAD', '--', file_path], + capture_output=True, + text=True, + cwd=project_root + ) + + if result.returncode != 0 or not result.stdout.strip(): + return None + + changed_lines = set() + current_new_line = None + + for line in result.stdout.split('\n'): + # Parse unified diff format + # @@ -old_start,old_count +new_start,new_count @@ + match = re.match(r'@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@', line) + if match: + current_new_line = int(match.group(1)) + count = int(match.group(2)) if match.group(2) else 1 + # The count tells us how many lines are in this hunk + # We'll track them as we see + lines + elif line.startswith('+') and not line.startswith('+++'): + # Added/modified line (starts with +) + if current_new_line is not None: + changed_lines.add(current_new_line) + current_new_line += 1 + elif line.startswith('-') and not line.startswith('---'): + # Deleted line - don't add to changed_lines, don't increment current_new_line + pass + elif line.startswith(' '): + # Context line (unchanged, starts with space) - increment current_new_line + if current_new_line is not None: + current_new_line += 1 + + return changed_lines if changed_lines else None + except Exception as e: + # Silently fail and return None - we'll fall back to checking all lines + return None try: tree = ET.parse(coverage_report) @@ -110,6 +195,22 @@ except Exception as e: print(f"Error parsing coverage report: {e}") sys.exit(1) +# Get repository root - prefer environment variable, then try to detect from coverage report path +if repo_root_env: + project_root = repo_root_env +else: + # Fallback: try to detect from coverage report path + # Coverage report is in OneSignalSDK/build/..., so go up two levels to get repo root + detected_root = os.path.dirname(os.path.dirname(coverage_report)) if '/build/' in coverage_report else os.path.dirname(coverage_report) + # Look for OneSignalSDK in the path and go one level up + parts = coverage_report.split('/') + if 'OneSignalSDK' in parts: + idx = parts.index('OneSignalSDK') + project_root = '/'.join(parts[:idx]) + else: + # Fallback: assume we're in repo root + project_root = os.getcwd() + changed_files = [f.strip() for f in changed_files_str.split('\n') if f.strip()] total_uncovered = 0 @@ -119,7 +220,7 @@ files_checked = [] markdown_output = [] if generate_markdown: - markdown_output.append("## Diff Coverage Report\n") + markdown_output.append("## Diff Coverage Report (Changed Lines Only)\n") markdown_output.append(f"**Threshold:** {threshold}%\n\n") markdown_output.append("### Changed Files Coverage\n\n") @@ -141,6 +242,9 @@ for changed_file in changed_files: filename = match.group(3) package_name = package_path.replace('/', '/') + # Get changed line numbers for this file + changed_lines = get_changed_lines(changed_file, project_root) + # Find in coverage report found = False for package in root.findall(f'.//package[@name="{package_name}"]'): @@ -149,26 +253,38 @@ for changed_file in changed_files: files_checked.append(filename) lines = sourcefile.findall('line') - file_total = len([l for l in lines if int(l.get('mi', 0)) > 0 or int(l.get('ci', 0)) > 0]) - file_covered = len([l for l in lines if int(l.get('ci', 0)) > 0]) - file_uncovered = len([l for l in lines if l.get('ci') == '0' and int(l.get('mi', 0)) > 0]) + + # Filter to only changed lines if we have that info + if changed_lines is not None and len(changed_lines) > 0: + # Only check lines that were added/modified + relevant_lines = [l for l in lines if int(l.get('nr', 0)) in changed_lines] + else: + # Fallback: check all lines if we can't get changed lines + relevant_lines = lines + + # Count only executable lines (mi > 0 means instructions exist) + file_total = len([l for l in relevant_lines if int(l.get('mi', 0)) > 0 or int(l.get('ci', 0)) > 0]) + file_covered = len([l for l in relevant_lines if int(l.get('ci', 0)) > 0]) + file_uncovered = len([l for l in relevant_lines if l.get('ci') == '0' and int(l.get('mi', 0)) > 0]) if file_total > 0: total_lines += file_total total_uncovered += file_uncovered - coverage_pct = (file_covered / file_total * 100) + coverage_pct = (file_covered / file_total * 100) if file_total > 0 else 100 if generate_markdown: status = "✅" if coverage_pct >= threshold else "❌" - markdown_output.append(f"- {status} **{filename}**: {file_covered}/{file_total} lines ({coverage_pct:.1f}%)") + changed_info = f" ({len(changed_lines)} changed lines)" if changed_lines else " (all lines - could not determine changed lines)" + markdown_output.append(f"- {status} **{filename}**: {file_covered}/{file_total} changed lines ({coverage_pct:.1f}%){changed_info}") if coverage_pct < threshold: files_below_threshold.append((filename, coverage_pct, file_uncovered)) - markdown_output.append(f" - ⚠️ Below threshold: {file_uncovered} uncovered lines") + markdown_output.append(f" - ⚠️ Below threshold: {file_uncovered} uncovered changed lines") else: status = "✓" if coverage_pct >= threshold else "✗" color = "" if coverage_pct >= threshold else "\033[0;31m" reset = "\033[0m" if color else "" - print(f" {color}{status}{reset} {filename}: {file_covered}/{file_total} lines ({coverage_pct:.1f}%)") + changed_info = f" ({len(changed_lines)} changed lines)" if changed_lines else " (all lines - could not determine changed lines)" + print(f" {color}{status}{reset} {filename}: {file_covered}/{file_total} changed lines ({coverage_pct:.1f}%){changed_info}") if coverage_pct < threshold: files_below_threshold.append((filename, coverage_pct, file_uncovered)) break @@ -185,14 +301,14 @@ if total_lines > 0: overall_coverage = ((total_lines - total_uncovered) / total_lines * 100) if generate_markdown: - markdown_output.append(f"\n### Overall Coverage\n") - markdown_output.append(f"**{total_lines - total_uncovered}/{total_lines}** lines covered ({overall_coverage:.1f}%)\n") + markdown_output.append(f"\n### Overall Coverage (Changed Lines Only)\n") + markdown_output.append(f"**{total_lines - total_uncovered}/{total_lines}** changed lines covered ({overall_coverage:.1f}%)\n") if files_below_threshold: markdown_output.append(f"\n### ❌ Coverage Check Failed\n") markdown_output.append(f"Files below {threshold}% threshold:\n") for filename, pct, uncovered in files_below_threshold: - markdown_output.append(f"- **{filename}**: {pct:.1f}% ({uncovered} uncovered lines)\n") + markdown_output.append(f"- **{filename}**: {pct:.1f}% ({uncovered} uncovered changed lines)\n") # Write markdown file with open(markdown_report, 'w') as f: @@ -206,15 +322,15 @@ if total_lines > 0: else: sys.exit(0) else: - print(f"\n Overall: {(total_lines - total_uncovered)}/{total_lines} lines covered ({overall_coverage:.1f}%)") + print(f"\n Overall: {(total_lines - total_uncovered)}/{total_lines} changed lines covered ({overall_coverage:.1f}%)") if files_below_threshold: print(f"\n Files below {threshold}% threshold:") for filename, pct, uncovered in files_below_threshold: - print(f" • {filename}: {pct:.1f}% ({uncovered} uncovered lines)") + print(f" • {filename}: {pct:.1f}% ({uncovered} uncovered changed lines)") sys.exit(1) else: - print(f"\n ✓ All files meet {threshold}% threshold") + print(f"\n ✓ All files meet {threshold}% threshold for changed lines") sys.exit(0) elif files_checked: # Files were found but had no executable lines @@ -279,13 +395,27 @@ echo -e "${YELLOW}[3/3] Generating HTML coverage report...${NC}" # Try to generate HTML report using diff-cover if available, otherwise skip if python3 -m diff_cover.diff_cover_tool --version &>/dev/null 2>&1; then # Try diff-cover for HTML report (may not work due to path issues, but worth trying) - cd "$PROJECT_ROOT" - python3 -m diff_cover.diff_cover_tool "build/reports/jacoco/merged/jacocoMergedReport.xml" \ + cd "$REPO_ROOT" + # Check if there are uncommitted changes - if so, we need to handle them differently + STAGED_COUNT=$(git diff --cached --name-only 2>/dev/null | grep -E '\.(kt|java)$' | wc -l | tr -d ' ') + UNSTAGED_COUNT=$(git diff --name-only 2>/dev/null | grep -E '\.(kt|java)$' | wc -l | tr -d ' ') + + if [ "$STAGED_COUNT" -gt 0 ] || [ "$UNSTAGED_COUNT" -gt 0 ]; then + # There are uncommitted changes - diff-cover won't see them with --compare-branch + # So we'll note this in the output + echo -e "${YELLOW} Note: HTML report shows committed changes only${NC}" + echo -e "${YELLOW} Uncommitted changes are checked in the console output above${NC}" + fi + + python3 -m diff_cover.diff_cover_tool "$PROJECT_ROOT/build/reports/jacoco/merged/jacocoMergedReport.xml" \ --compare-branch="$BASE_BRANCH" \ --format html:"$HTML_REPORT" 2>&1 | grep -v "No lines with coverage" || true if [ -f "$HTML_REPORT" ]; then echo -e "${GREEN}✓ HTML report generated: $HTML_REPORT${NC}" + if [ "$STAGED_COUNT" -gt 0 ] || [ "$UNSTAGED_COUNT" -gt 0 ]; then + echo -e "${YELLOW} Note: Report shows committed changes only (uncommitted changes shown in console)${NC}" + fi echo -e "${BLUE} Open it in your browser to see detailed coverage${NC}\n" else echo -e "${YELLOW} HTML report generation had issues (non-fatal)${NC}\n" From db7a30e3f98d5312545bc5d6a827987a6d22ca0d Mon Sep 17 00:00:00 2001 From: jinliu Date: Mon, 26 Jan 2026 11:45:04 -0500 Subject: [PATCH 3/8] chore: merge 5.3.0 beta into main and resolve conflict (#2525) --- .../java/com/onesignal/common/JSONUtils.kt | 55 + .../operations/impl/OperationModelStore.kt | 3 + .../java/com/onesignal/user/IUserManager.kt | 11 + .../java/com/onesignal/user/UserModule.kt | 8 + .../onesignal/user/internal/UserManager.kt | 15 + .../ICustomEventBackendService.kt | 24 + .../customEvents/ICustomEventController.kt | 8 + .../impl/CustomEventBackendService.kt | 53 + .../impl/CustomEventController.kt | 32 + .../customEvents/impl/CustomEventMetadata.kt | 39 + .../operations/TrackCustomEventOperation.kt | 85 ++ .../executors/CustomEventOperationExecutor.kt | 71 ++ .../com/onesignal/common/JSONUtilsTests.kt | 945 ++++++++++++++++++ .../user/internal/UserManagerTests.kt | 53 +- .../backend/CustomEventBackendServiceTests.kt | 86 ++ .../CustomEventOperationExecutorTests.kt | 66 ++ .../java/com/onesignal/mocks/MockHelper.kt | 7 + 17 files changed, 1556 insertions(+), 5 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventBackendService.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventController.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventBackendService.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventController.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventMetadata.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackCustomEventOperation.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/CustomEventOperationExecutor.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/JSONUtilsTests.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/CustomEventBackendServiceTests.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/CustomEventOperationExecutorTests.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/JSONUtils.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/JSONUtils.kt index 75ba75db7..3169fd3d1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/JSONUtils.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/JSONUtils.kt @@ -163,4 +163,59 @@ object JSONUtils { `object` } } + + /** + * Check if an object is JSON-serializable. + * Recursively check each item if object is a map or a list. + */ + fun isValidJsonObject(value: Any?): Boolean { + return when (value) { + null, + is Boolean, + is Number, + is String, + is JSONObject, + is JSONArray, + -> true + is Map<*, *> -> value.keys.all { it is String } && value.values.all { isValidJsonObject(it) } + is List<*> -> value.all { isValidJsonObject(it) } + else -> false + } + } + + /** + * Recursively convert a JSON-serializable map into a JSON-compatible format, handling + * nested Maps and Lists appropriately. + */ + fun mapToJson(map: Map): JSONObject { + val json = JSONObject() + for ((key, value) in map) { + json.put(key, convertToJson(value)) + } + return json + } + + /** + * Recursively converts maps and lists into JSON-compatible objects, transforming maps with + * String keys into JSON objects, lists into JSON arrays, and leaving primitive values unchanged to support safe JSON serialization. + */ + fun convertToJson(value: Any): Any { + return when (value) { + is Map<*, *> -> { + val subMap = + value.entries + .filter { it.key is String } + .associate { + it.key as String to convertToJson(it.value!!) + } + mapToJson(subMap) + } + is List<*> -> { + val array = JSONArray() + value.forEach { array.put(convertToJson(it!!)) } + array + } + else -> value + } + } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationModelStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationModelStore.kt index 1fa25fe63..c3d6e4591 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationModelStore.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationModelStore.kt @@ -14,11 +14,13 @@ import com.onesignal.user.internal.operations.RefreshUserOperation import com.onesignal.user.internal.operations.SetAliasOperation import com.onesignal.user.internal.operations.SetPropertyOperation import com.onesignal.user.internal.operations.SetTagOperation +import com.onesignal.user.internal.operations.TrackCustomEventOperation import com.onesignal.user.internal.operations.TrackPurchaseOperation import com.onesignal.user.internal.operations.TrackSessionEndOperation import com.onesignal.user.internal.operations.TrackSessionStartOperation import com.onesignal.user.internal.operations.TransferSubscriptionOperation import com.onesignal.user.internal.operations.UpdateSubscriptionOperation +import com.onesignal.user.internal.operations.impl.executors.CustomEventOperationExecutor import com.onesignal.user.internal.operations.impl.executors.IdentityOperationExecutor import com.onesignal.user.internal.operations.impl.executors.LoginUserFromSubscriptionOperationExecutor import com.onesignal.user.internal.operations.impl.executors.LoginUserOperationExecutor @@ -60,6 +62,7 @@ internal class OperationModelStore(prefs: IPreferencesService) : ModelStore TrackSessionStartOperation() UpdateUserOperationExecutor.TRACK_SESSION_END -> TrackSessionEndOperation() UpdateUserOperationExecutor.TRACK_PURCHASE -> TrackPurchaseOperation() + CustomEventOperationExecutor.CUSTOM_EVENT -> TrackCustomEventOperation() else -> throw Exception("Unrecognized operation: $operationName") } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/IUserManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/IUserManager.kt index 2b71ca11d..7ebf37f14 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/IUserManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/IUserManager.kt @@ -166,4 +166,15 @@ interface IUserManager { * Remove an observer from the user state. */ fun removeObserver(observer: IUserStateObserver) + + /** + * Tracks a custom event performed by the current user + * + * @param name for the custom event + * @param properties an optional property dictionary, must be serializable into a JSON Object + */ + fun trackEvent( + name: String, + properties: Map? = null, + ) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/UserModule.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/UserModule.kt index f8dc3c495..be5522875 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/UserModule.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/UserModule.kt @@ -16,9 +16,14 @@ import com.onesignal.user.internal.backend.impl.SubscriptionBackendService import com.onesignal.user.internal.backend.impl.UserBackendService import com.onesignal.user.internal.builduser.IRebuildUserService import com.onesignal.user.internal.builduser.impl.RebuildUserService +import com.onesignal.user.internal.customEvents.ICustomEventBackendService +import com.onesignal.user.internal.customEvents.ICustomEventController +import com.onesignal.user.internal.customEvents.impl.CustomEventBackendService +import com.onesignal.user.internal.customEvents.impl.CustomEventController import com.onesignal.user.internal.identity.IdentityModelStore import com.onesignal.user.internal.migrations.RecoverConfigPushSubscription import com.onesignal.user.internal.migrations.RecoverFromDroppedLoginBug +import com.onesignal.user.internal.operations.impl.executors.CustomEventOperationExecutor import com.onesignal.user.internal.operations.impl.executors.IdentityOperationExecutor import com.onesignal.user.internal.operations.impl.executors.LoginUserFromSubscriptionOperationExecutor import com.onesignal.user.internal.operations.impl.executors.LoginUserOperationExecutor @@ -71,6 +76,9 @@ internal class UserModule : IModule { builder.register().provides() builder.register().provides() builder.register().provides() + builder.register().provides() + builder.register().provides() + builder.register().provides() builder.register().provides() diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt index 934d23318..60f322b80 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt @@ -1,6 +1,7 @@ package com.onesignal.user.internal import com.onesignal.common.IDManager +import com.onesignal.common.JSONUtils import com.onesignal.common.OneSignalUtils import com.onesignal.common.events.EventProducer import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler @@ -10,6 +11,7 @@ import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.user.IUserManager import com.onesignal.user.internal.backend.IdentityConstants +import com.onesignal.user.internal.customEvents.ICustomEventController import com.onesignal.user.internal.identity.IdentityModel import com.onesignal.user.internal.identity.IdentityModelStore import com.onesignal.user.internal.properties.PropertiesModel @@ -25,6 +27,7 @@ internal open class UserManager( private val _subscriptionManager: ISubscriptionManager, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, + private val _customEventController: ICustomEventController, private val _languageContext: ILanguageContext, ) : IUserManager, ISingletonModelStoreChangeHandler { override val onesignalId: String @@ -244,6 +247,18 @@ internal open class UserManager( changeHandlersNotifier.unsubscribe(observer) } + override fun trackEvent( + name: String, + properties: Map?, + ) { + if (!JSONUtils.isValidJsonObject(properties)) { + Logging.log(LogLevel.ERROR, "Custom event properties are not JSON-serializable") + return + } + + _customEventController.sendCustomEvent(name, properties) + } + override fun onModelReplaced( model: IdentityModel, tag: String, diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventBackendService.kt new file mode 100644 index 000000000..92474635a --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventBackendService.kt @@ -0,0 +1,24 @@ +package com.onesignal.user.internal.customEvents + +import com.onesignal.core.internal.operations.ExecutionResponse +import com.onesignal.user.internal.customEvents.impl.CustomEventMetadata + +/** + * The backend service for custom events. + */ +interface ICustomEventBackendService { + /** + * Send an custom event to the backend and return the response. + * + * @param customEvent The custom event to send up. + */ + suspend fun sendCustomEvent( + appId: String, + onesignalId: String, + externalId: String?, + timestamp: Long, + eventName: String, + eventProperties: String?, + metadata: CustomEventMetadata, + ): ExecutionResponse +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventController.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventController.kt new file mode 100644 index 000000000..35c307539 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventController.kt @@ -0,0 +1,8 @@ +package com.onesignal.user.internal.customEvents + +interface ICustomEventController { + fun sendCustomEvent( + name: String, + properties: Map?, + ) +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventBackendService.kt new file mode 100644 index 000000000..096fa6745 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventBackendService.kt @@ -0,0 +1,53 @@ +package com.onesignal.user.internal.customEvents.impl + +import com.onesignal.common.DateUtils +import com.onesignal.common.exceptions.BackendException +import com.onesignal.core.internal.http.IHttpClient +import com.onesignal.core.internal.operations.ExecutionResponse +import com.onesignal.core.internal.operations.ExecutionResult +import com.onesignal.user.internal.customEvents.ICustomEventBackendService +import org.json.JSONArray +import org.json.JSONObject +import java.util.TimeZone + +internal class CustomEventBackendService( + private val httpClient: IHttpClient, +) : ICustomEventBackendService { + override suspend fun sendCustomEvent( + appId: String, + onesignalId: String, + externalId: String?, + timestamp: Long, + eventName: String, + eventProperties: String?, + metadata: CustomEventMetadata, + ): ExecutionResponse { + val body = JSONObject() + body.put("name", eventName) + body.put("onesignal_id", onesignalId) + externalId?.let { body.put("external_id", it) } + body.put( + "timestamp", + DateUtils.iso8601Format().apply { + timeZone = TimeZone.getTimeZone("UTC") + }.format( + timestamp, + ), + ) + + val payload = eventProperties?.let { JSONObject(it) } ?: JSONObject() + + payload.put("os_sdk", metadata.toJSONObject()) + + body.put("payload", payload) + val jsonObject = JSONObject().put("events", JSONArray().put(body)) + + val response = httpClient.post("apps/$appId/custom_events", jsonObject) + + if (!response.isSuccess) { + throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) + } + + return ExecutionResponse(ExecutionResult.SUCCESS) + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventController.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventController.kt new file mode 100644 index 000000000..05142fe78 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventController.kt @@ -0,0 +1,32 @@ +package com.onesignal.user.internal.customEvents.impl + +import com.onesignal.common.JSONUtils +import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.operations.IOperationRepo +import com.onesignal.core.internal.time.ITime +import com.onesignal.user.internal.customEvents.ICustomEventController +import com.onesignal.user.internal.identity.IdentityModelStore +import com.onesignal.user.internal.operations.TrackCustomEventOperation + +class CustomEventController( + private val identityModelStore: IdentityModelStore, + private val configModelStore: ConfigModelStore, + private val time: ITime, + private val opRepo: IOperationRepo, +) : ICustomEventController { + override fun sendCustomEvent( + name: String, + properties: Map?, + ) { + val op = + TrackCustomEventOperation( + configModelStore.model.appId, + identityModelStore.model.onesignalId, + identityModelStore.model.externalId, + time.currentTimeMillis, + name, + properties?.let { JSONUtils.mapToJson(it).toString() }, + ) + opRepo.enqueue(op) + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventMetadata.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventMetadata.kt new file mode 100644 index 000000000..cd14d6a90 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventMetadata.kt @@ -0,0 +1,39 @@ +package com.onesignal.user.internal.customEvents.impl + +import com.onesignal.common.putSafe +import org.json.JSONException +import org.json.JSONObject + +class CustomEventMetadata( + val deviceType: String?, + val sdk: String?, + val appVersion: String?, + val type: String?, + val deviceModel: String?, + val deviceOS: String?, +) { + @Throws(JSONException::class) + fun toJSONObject(): JSONObject { + val json = JSONObject() + json.putSafe(SDK, sdk) + json.putSafe(APP_VERSION, appVersion) + json.putSafe(TYPE, type) + json.putSafe(DEVICE_TYPE, deviceType) + json.putSafe(DEVICE_MODEL, deviceModel) + json.putSafe(DEVICE_OS, deviceOS) + return json + } + + override fun toString(): String { + return toJSONObject().toString() + } + + companion object { + private const val DEVICE_TYPE = "device_type" + private const val SDK = "sdk" + private const val APP_VERSION = "app_version" + private const val TYPE = "type" + private const val DEVICE_MODEL = "device_model" + private const val DEVICE_OS = "device_os" + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackCustomEventOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackCustomEventOperation.kt new file mode 100644 index 000000000..73313f97e --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackCustomEventOperation.kt @@ -0,0 +1,85 @@ +package com.onesignal.user.internal.operations + +import com.onesignal.common.IDManager +import com.onesignal.core.internal.operations.GroupComparisonType +import com.onesignal.core.internal.operations.Operation +import com.onesignal.user.internal.operations.impl.executors.CustomEventOperationExecutor + +class TrackCustomEventOperation() : Operation(CustomEventOperationExecutor.CUSTOM_EVENT) { + /** + * The OneSignal appId the custom event was created. + */ + var appId: String + get() = getStringProperty(::appId.name) + private set(value) { + setStringProperty(::appId.name, value) + } + + /** + * The OneSignal ID the custom event was created under. This ID *may* be locally generated + * and can be checked via [IDManager.isLocalId] to ensure correct processing. + */ + var onesignalId: String + get() = getStringProperty(::onesignalId.name) + private set(value) { + setStringProperty(::onesignalId.name, value) + } + + /** + * The optional external ID of current logged-in user. Must be unique for the [appId]. + */ + var externalId: String? + get() = getOptStringProperty(::externalId.name) + private set(value) { + setOptStringProperty(::externalId.name, value) + } + + /** + * The timestamp when the custom event was created. + */ + var timeStamp: Long + get() = getLongProperty(::timeStamp.name) + private set(value) { + setLongProperty(::timeStamp.name, value) + } + + /** + * The name for the custom event. + */ + var eventName: String + get() = getStringProperty(::eventName.name) + set(value) { + setAnyProperty(::eventName.name, value) + } + + /** + * The nullable properties for the custom event. + */ + var eventProperties: String? + get() = getOptStringProperty(::eventProperties.name) + set(value) { + setOptStringProperty(::eventProperties.name, value) + } + + override val createComparisonKey: String get() = "$appId.User.$onesignalId.CustomEvent.$eventName" + override val modifyComparisonKey: String get() = "$appId.User.$onesignalId.CustomEvent.$eventName" + + override val groupComparisonType: GroupComparisonType = GroupComparisonType.NONE + override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId) + override val applyToRecordId: String get() = onesignalId + + constructor(appId: String, onesignalId: String, externalId: String?, timeStamp: Long, eventName: String, eventProperties: String?) : this() { + this.appId = appId + this.onesignalId = onesignalId + this.externalId = externalId + this.timeStamp = timeStamp + this.eventName = eventName + this.eventProperties = eventProperties + } + + override fun translateIds(map: Map) { + if (map.containsKey(onesignalId)) { + onesignalId = map[onesignalId]!! + } + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/CustomEventOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/CustomEventOperationExecutor.kt new file mode 100644 index 000000000..2e1046e6c --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/CustomEventOperationExecutor.kt @@ -0,0 +1,71 @@ +package com.onesignal.user.internal.operations.impl.executors + +import android.os.Build +import com.onesignal.common.AndroidUtils +import com.onesignal.common.NetworkUtils +import com.onesignal.common.OneSignalUtils +import com.onesignal.common.exceptions.BackendException +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.device.IDeviceService +import com.onesignal.core.internal.operations.ExecutionResponse +import com.onesignal.core.internal.operations.ExecutionResult +import com.onesignal.core.internal.operations.IOperationExecutor +import com.onesignal.core.internal.operations.Operation +import com.onesignal.user.internal.customEvents.ICustomEventBackendService +import com.onesignal.user.internal.customEvents.impl.CustomEventMetadata +import com.onesignal.user.internal.operations.TrackCustomEventOperation + +internal class CustomEventOperationExecutor( + private val customEventBackendService: ICustomEventBackendService, + private val applicationService: IApplicationService, + private val deviceService: IDeviceService, +) : IOperationExecutor { + override val operations: List + get() = listOf(CUSTOM_EVENT) + + private val eventMetadataJson: CustomEventMetadata by lazy { + CustomEventMetadata( + deviceType = deviceService.deviceType.name, + sdk = OneSignalUtils.sdkVersion, + appVersion = AndroidUtils.getAppVersion(applicationService.appContext), + type = "AndroidPush", + deviceModel = Build.MODEL, + deviceOS = Build.VERSION.RELEASE, + ) + } + + override suspend fun execute(operations: List): ExecutionResponse { + val operation = operations.first() + + try { + when (operation) { + is TrackCustomEventOperation -> { + customEventBackendService.sendCustomEvent( + operation.appId, + operation.onesignalId, + operation.externalId, + operation.timeStamp, + operation.eventName, + operation.eventProperties, + eventMetadataJson, + ) + } + } + } catch (ex: BackendException) { + val responseType = NetworkUtils.getResponseStatusType(ex.statusCode) + + return when (responseType) { + NetworkUtils.ResponseStatusType.RETRYABLE -> + ExecutionResponse(ExecutionResult.FAIL_RETRY, retryAfterSeconds = ex.retryAfterSeconds) + else -> + ExecutionResponse(ExecutionResult.FAIL_NORETRY) + } + } + + return ExecutionResponse(ExecutionResult.SUCCESS) + } + + companion object { + const val CUSTOM_EVENT = "custom-event" + } +} diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/JSONUtilsTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/JSONUtilsTests.kt new file mode 100644 index 000000000..1320369ca --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/JSONUtilsTests.kt @@ -0,0 +1,945 @@ +package com.onesignal.common + +import android.os.Bundle +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import io.mockk.every +import io.mockk.mockk +import org.json.JSONArray +import org.json.JSONObject + +class JSONUtilsTests : FunSpec({ + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + context("wrapInJsonArray") { + test("should wrap a JSONObject in a JSONArray") { + // Given + val jsonObject = JSONObject().apply { + put("key", "value") + } + + // When + val result = JSONUtils.wrapInJsonArray(jsonObject) + + // Then + result.length() shouldBe 1 + result.getJSONObject(0).getString("key") shouldBe "value" + } + + test("should handle null JSONObject") { + // Given + val jsonObject: JSONObject? = null + + // When + val result = JSONUtils.wrapInJsonArray(jsonObject) + + // Then + result.length() shouldBe 1 + result.isNull(0) shouldBe true + } + + test("should wrap empty JSONObject") { + // Given + val jsonObject = JSONObject() + + // When + val result = JSONUtils.wrapInJsonArray(jsonObject) + + // Then + result.length() shouldBe 1 + result.getJSONObject(0).length() shouldBe 0 + } + } + + context("bundleAsJSONObject") { + test("should convert Bundle to JSONObject") { + // Given + val bundle = mockk(relaxed = true) + val keySet = setOf("stringKey", "intKey", "boolKey") + every { bundle.keySet() } returns keySet + every { bundle["stringKey"] } returns "stringValue" + every { bundle["intKey"] } returns 42 + every { bundle["boolKey"] } returns true + + // When + val result = JSONUtils.bundleAsJSONObject(bundle) + + // Then + result.getString("stringKey") shouldBe "stringValue" + result.getInt("intKey") shouldBe 42 + result.getBoolean("boolKey") shouldBe true + } + + test("should handle empty Bundle") { + // Given + val bundle = mockk(relaxed = true) + every { bundle.keySet() } returns emptySet() + + // When + val result = JSONUtils.bundleAsJSONObject(bundle) + + // Then + result.length() shouldBe 0 + } + + test("should handle Bundle with null values") { + // Given + val bundle = mockk(relaxed = true) + val keySet = setOf("key1", "key2") + every { bundle.keySet() } returns keySet + every { bundle["key1"] } returns "value1" + every { bundle["key2"] } returns null + + // When + val result = JSONUtils.bundleAsJSONObject(bundle) + + // Then + result.getString("key1") shouldBe "value1" + result.isNull("key2") shouldBe true + } + } + + context("jsonStringToBundle") { + test("should return null for invalid JSON string") { + // Given + val invalidJson = "{invalid json}" + + // When + val result = JSONUtils.jsonStringToBundle(invalidJson) + + // Then + result shouldBe null + } + + test("should return null for empty string") { + // Given + val emptyString = "" + + // When + val result = JSONUtils.jsonStringToBundle(emptyString) + + // Then + result shouldBe null + } + } + + context("newStringMapFromJSONObject") { + test("should convert JSONObject to Map") { + // Given + val jsonObject = JSONObject().apply { + put("key1", "value1") + put("key2", "value2") + put("key3", 123) + } + + // When + val result = JSONUtils.newStringMapFromJSONObject(jsonObject) + + // Then + result.size shouldBe 3 + result["key1"] shouldBe "value1" + result["key2"] shouldBe "value2" + result["key3"] shouldBe "123" + } + + test("should handle null values as empty string") { + // Given + val jsonObject = JSONObject().apply { + put("key1", "value1") + put("key2", JSONObject.NULL) + } + + // When + val result = JSONUtils.newStringMapFromJSONObject(jsonObject) + + // Then + result["key1"] shouldBe "value1" + result["key2"] shouldBe "" + } + + test("should omit nested JSONObjects") { + // Given + val jsonObject = JSONObject().apply { + put("key1", "value1") + put("nested", JSONObject().apply { put("inner", "value") }) + } + + // When + val result = JSONUtils.newStringMapFromJSONObject(jsonObject) + + // Then + result.size shouldBe 1 + result["key1"] shouldBe "value1" + result.containsKey("nested") shouldBe false + } + + test("should omit JSONArrays") { + // Given + val jsonObject = JSONObject().apply { + put("key1", "value1") + put("array", JSONArray().put("item1").put("item2")) + } + + // When + val result = JSONUtils.newStringMapFromJSONObject(jsonObject) + + // Then + result.size shouldBe 1 + result["key1"] shouldBe "value1" + result.containsKey("array") shouldBe false + } + + test("should handle empty JSONObject") { + // Given + val jsonObject = JSONObject() + + // When + val result = JSONUtils.newStringMapFromJSONObject(jsonObject) + + // Then + result.size shouldBe 0 + } + } + + context("newStringSetFromJSONArray") { + test("should convert JSONArray to Set") { + // Given + val jsonArray = JSONArray().apply { + put("item1") + put("item2") + put("item3") + } + + // When + val result = JSONUtils.newStringSetFromJSONArray(jsonArray) + + // Then + result.size shouldBe 3 + result shouldBe setOf("item1", "item2", "item3") + } + + test("should handle empty JSONArray") { + // Given + val jsonArray = JSONArray() + + // When + val result = JSONUtils.newStringSetFromJSONArray(jsonArray) + + // Then + result.size shouldBe 0 + } + + test("should handle JSONArray with duplicate values") { + // Given + val jsonArray = JSONArray().apply { + put("item1") + put("item2") + put("item1") + } + + // When + val result = JSONUtils.newStringSetFromJSONArray(jsonArray) + + // Then + result.size shouldBe 2 + result shouldBe setOf("item1", "item2") + } + } + + context("toUnescapedEUIDString") { + test("should unescape forward slashes in external_user_id") { + // Given + val jsonObject = JSONObject().apply { + put("external_user_id", "user/123") + put("other_key", "value") + } + + // When + val result = JSONUtils.toUnescapedEUIDString(jsonObject) + + // Then + result shouldContain "\"external_user_id\":\"user/123\"" + result shouldNotContain "user\\/123" + } + + test("should handle JSON without external_user_id") { + // Given + val jsonObject = JSONObject().apply { + put("key1", "value1") + put("key2", "value2") + } + + // When + val result = JSONUtils.toUnescapedEUIDString(jsonObject) + + // Then + result shouldContain "key1" + result shouldContain "value1" + } + + test("should handle external_user_id without slashes") { + // Given + val jsonObject = JSONObject().apply { + put("external_user_id", "user123") + } + + // When + val result = JSONUtils.toUnescapedEUIDString(jsonObject) + + // Then + result shouldContain "\"external_user_id\":\"user123\"" + } + + test("should handle multiple escaped slashes") { + // Given + val jsonObject = JSONObject().apply { + put("external_user_id", "user/123/456") + } + + // When + val result = JSONUtils.toUnescapedEUIDString(jsonObject) + + // Then + result shouldContain "\"external_user_id\":\"user/123/456\"" + result shouldNotContain "\\/" + } + + test("should handle empty external_user_id") { + // Given + val jsonObject = JSONObject().apply { + put("external_user_id", "") + } + + // When + val result = JSONUtils.toUnescapedEUIDString(jsonObject) + + // Then + result shouldContain "\"external_user_id\":\"\"" + } + } + + context("compareJSONArrays") { + test("should return true for equal JSONArrays") { + // Given + val array1 = JSONArray().apply { + put("item1") + put("item2") + } + val array2 = JSONArray().apply { + put("item1") + put("item2") + } + + // When + val result = JSONUtils.compareJSONArrays(array1, array2) + + // Then + result shouldBe true + } + + test("should return true for both null arrays") { + // When + val result = JSONUtils.compareJSONArrays(null, null) + + // Then + result shouldBe true + } + + test("should return false when one array is null") { + // Given + val array1 = JSONArray().put("item1") + + // When + val result1 = JSONUtils.compareJSONArrays(array1, null) + val result2 = JSONUtils.compareJSONArrays(null, array1) + + // Then + result1 shouldBe false + result2 shouldBe false + } + + test("should return false for arrays of different sizes") { + // Given + val array1 = JSONArray().apply { + put("item1") + put("item2") + } + val array2 = JSONArray().put("item1") + + // When + val result = JSONUtils.compareJSONArrays(array1, array2) + + // Then + result shouldBe false + } + + test("should return false for arrays with different items") { + // Given + val array1 = JSONArray().apply { + put("item1") + put("item2") + } + val array2 = JSONArray().apply { + put("item1") + put("item3") + } + + // When + val result = JSONUtils.compareJSONArrays(array1, array2) + + // Then + result shouldBe false + } + + test("should handle arrays with different order but same items") { + // Given + val array1 = JSONArray().apply { + put("item1") + put("item2") + } + val array2 = JSONArray().apply { + put("item2") + put("item1") + } + + // When + val result = JSONUtils.compareJSONArrays(array1, array2) + + // Then + result shouldBe true + } + + test("should handle arrays with numbers") { + // Given + val array1 = JSONArray().apply { + put(1) + put(2) + } + val array2 = JSONArray().apply { + put(1) + put(2) + } + + // When + val result = JSONUtils.compareJSONArrays(array1, array2) + + // Then + result shouldBe true + } + } + + context("normalizeType") { + test("should convert Int to Long") { + // Given + val intValue = 42 + + // When + val result = JSONUtils.normalizeType(intValue) + + // Then + result shouldBe 42L + } + + test("should convert Float to Double") { + // Given + val floatValue = 3.14f + + // When + val result = JSONUtils.normalizeType(floatValue) + + // Then + // Float to Double conversion has precision differences, so use approximate comparison + result shouldNotBe null + val doubleValue = result as? Number + doubleValue shouldNotBe null + val difference = kotlin.math.abs(doubleValue!!.toDouble() - 3.14) + (difference < 0.0001) shouldBe true + } + + test("should return other types unchanged") { + // Given + val stringValue = "test" + val boolValue = true + val longValue = 100L + + // When + val stringResult = JSONUtils.normalizeType(stringValue) + val boolResult = JSONUtils.normalizeType(boolValue) + val longResult = JSONUtils.normalizeType(longValue) + + // Then + stringResult shouldBe "test" + boolResult shouldBe true + longResult shouldBe 100L + } + } + + context("isValidJsonObject") { + test("should return true for primitive types") { + // Then + JSONUtils.isValidJsonObject(null) shouldBe true + JSONUtils.isValidJsonObject(true) shouldBe true + JSONUtils.isValidJsonObject(false) shouldBe true + JSONUtils.isValidJsonObject(42) shouldBe true + JSONUtils.isValidJsonObject(3.14) shouldBe true + JSONUtils.isValidJsonObject("string") shouldBe true + } + + test("should return true for JSONObject and JSONArray") { + // Given + val jsonObject = JSONObject() + val jsonArray = JSONArray() + + // Then + JSONUtils.isValidJsonObject(jsonObject) shouldBe true + JSONUtils.isValidJsonObject(jsonArray) shouldBe true + } + + test("should return true for valid Map with String keys") { + // Given + val map = mapOf( + "key1" to "value1", + "key2" to 42, + "key3" to true, + ) + + // When + val result = JSONUtils.isValidJsonObject(map) + + // Then + result shouldBe true + } + + test("should return false for Map with non-String keys") { + // Given + val map = mapOf( + 1 to "value1", + 2 to "value2", + ) + + // When + val result = JSONUtils.isValidJsonObject(map) + + // Then + result shouldBe false + } + + test("should return true for valid List") { + // Given + val list = listOf("item1", "item2", 42, true) + + // When + val result = JSONUtils.isValidJsonObject(list) + + // Then + result shouldBe true + } + + test("should return true for nested valid structures") { + // Given + val nestedMap = mapOf( + "key1" to "value1", + "key2" to mapOf( + "nestedKey" to "nestedValue", + ), + "key3" to listOf("item1", "item2"), + ) + + // When + val result = JSONUtils.isValidJsonObject(nestedMap) + + // Then + result shouldBe true + } + + test("should return false for nested invalid structures") { + // Given + val invalidMap = mapOf( + "key1" to "value1", + "key2" to mapOf( + 1 to "invalid", // non-String key + ), + ) + + // When + val result = JSONUtils.isValidJsonObject(invalidMap) + + // Then + result shouldBe false + } + + test("should return false for non-JSON types") { + // Then + JSONUtils.isValidJsonObject(Any()) shouldBe false + JSONUtils.isValidJsonObject(Exception()) shouldBe false + JSONUtils.isValidJsonObject(Thread.currentThread()) shouldBe false + } + + test("should return true for List containing valid nested structures") { + // Given + val list = listOf( + "string", + 42, + mapOf("key" to "value"), + listOf("nested", "items"), + ) + + // When + val result = JSONUtils.isValidJsonObject(list) + + // Then + result shouldBe true + } + + test("should return false for List containing invalid types") { + // Given + val list = listOf( + "string", + Any(), // invalid type + ) + + // When + val result = JSONUtils.isValidJsonObject(list) + + // Then + result shouldBe false + } + } + + context("mapToJson") { + test("should convert simple map to JSONObject") { + // Given + val map = mapOf( + "key1" to "value1", + "key2" to 42, + "key3" to true, + ) + + // When + val result = JSONUtils.mapToJson(map) + + // Then + result.getString("key1") shouldBe "value1" + result.getInt("key2") shouldBe 42 + result.getBoolean("key3") shouldBe true + } + + test("should convert empty map to empty JSONObject") { + // Given + val map = emptyMap() + + // When + val result = JSONUtils.mapToJson(map) + + // Then + result.length() shouldBe 0 + } + + test("should convert map with nested map") { + // Given + val map = mapOf( + "key1" to "value1", + "nested" to mapOf( + "nestedKey1" to "nestedValue1", + "nestedKey2" to 100, + ), + ) + + // When + val result = JSONUtils.mapToJson(map) + + // Then + result.getString("key1") shouldBe "value1" + val nested = result.getJSONObject("nested") + nested.getString("nestedKey1") shouldBe "nestedValue1" + nested.getInt("nestedKey2") shouldBe 100 + } + + test("should convert map with list values") { + // Given + val map = mapOf( + "key1" to listOf("item1", "item2", "item3"), + "key2" to listOf(1, 2, 3), + ) + + // When + val result = JSONUtils.mapToJson(map) + + // Then + val array1 = result.getJSONArray("key1") + array1.length() shouldBe 3 + array1.getString(0) shouldBe "item1" + array1.getString(1) shouldBe "item2" + array1.getString(2) shouldBe "item3" + + val array2 = result.getJSONArray("key2") + array2.length() shouldBe 3 + array2.getInt(0) shouldBe 1 + array2.getInt(1) shouldBe 2 + array2.getInt(2) shouldBe 3 + } + + test("should convert map with deeply nested structures") { + // Given + val map = mapOf( + "level1" to mapOf( + "level2" to mapOf( + "level3" to "deepValue", + ), + ), + ) + + // When + val result = JSONUtils.mapToJson(map) + + // Then + val level1 = result.getJSONObject("level1") + val level2 = level1.getJSONObject("level2") + level2.getString("level3") shouldBe "deepValue" + } + + test("should convert map with list containing maps") { + // Given + val map = mapOf( + "items" to listOf( + mapOf("name" to "item1", "value" to 10), + mapOf("name" to "item2", "value" to 20), + ), + ) + + // When + val result = JSONUtils.mapToJson(map) + + // Then + val array = result.getJSONArray("items") + array.length() shouldBe 2 + val item1 = array.getJSONObject(0) + item1.getString("name") shouldBe "item1" + item1.getInt("value") shouldBe 10 + val item2 = array.getJSONObject(1) + item2.getString("name") shouldBe "item2" + item2.getInt("value") shouldBe 20 + } + + test("should handle null values") { + // Given + val map = mapOf( + "key1" to "value1", + "key2" to JSONObject.NULL, + ) + + // When + val result = JSONUtils.mapToJson(map) + + // Then + result.getString("key1") shouldBe "value1" + result.isNull("key2") shouldBe true + } + + test("should handle different number types") { + // Given + val map = mapOf( + "int" to 42, + "long" to 100L, + "double" to 3.14, + "float" to 2.5f, + ) + + // When + val result = JSONUtils.mapToJson(map) + + // Then + result.getInt("int") shouldBe 42 + result.getLong("long") shouldBe 100L + result.getDouble("double") shouldBe 3.14 + // Float precision may differ, so check approximately + (kotlin.math.abs(result.getDouble("float") - 2.5) < 0.0001) shouldBe true + } + } + + context("convertToJson") { + test("should return primitive values unchanged") { + // Then + JSONUtils.convertToJson("string") shouldBe "string" + JSONUtils.convertToJson(42) shouldBe 42 + JSONUtils.convertToJson(true) shouldBe true + JSONUtils.convertToJson(false) shouldBe false + JSONUtils.convertToJson(3.14) shouldBe 3.14 + } + + test("should convert Map to JSONObject") { + // Given + val map = mapOf( + "key1" to "value1", + "key2" to 42, + ) + + // When + val result = JSONUtils.convertToJson(map) + + // Then + (result is JSONObject) shouldBe true + val jsonObject = result as JSONObject + jsonObject.getString("key1") shouldBe "value1" + jsonObject.getInt("key2") shouldBe 42 + } + + test("should convert List to JSONArray") { + // Given + val list = listOf("item1", "item2", "item3") + + // When + val result = JSONUtils.convertToJson(list) + + // Then + (result is JSONArray) shouldBe true + val jsonArray = result as JSONArray + jsonArray.length() shouldBe 3 + jsonArray.getString(0) shouldBe "item1" + jsonArray.getString(1) shouldBe "item2" + jsonArray.getString(2) shouldBe "item3" + } + + test("should convert nested Map recursively") { + // Given + val map = mapOf( + "outer" to mapOf( + "inner" to "value", + ), + ) + + // When + val result = JSONUtils.convertToJson(map) + + // Then + val jsonObject = result as JSONObject + val inner = jsonObject.getJSONObject("outer") + inner.getString("inner") shouldBe "value" + } + + test("should convert List containing Maps") { + // Given + val list = listOf( + mapOf("key1" to "value1"), + mapOf("key2" to "value2"), + ) + + // When + val result = JSONUtils.convertToJson(list) + + // Then + val jsonArray = result as JSONArray + jsonArray.length() shouldBe 2 + val item1 = jsonArray.getJSONObject(0) + item1.getString("key1") shouldBe "value1" + val item2 = jsonArray.getJSONObject(1) + item2.getString("key2") shouldBe "value2" + } + + test("should convert Map containing List") { + // Given + val map = mapOf( + "items" to listOf("a", "b", "c"), + ) + + // When + val result = JSONUtils.convertToJson(map) + + // Then + val jsonObject = result as JSONObject + val array = jsonObject.getJSONArray("items") + array.length() shouldBe 3 + array.getString(0) shouldBe "a" + array.getString(1) shouldBe "b" + array.getString(2) shouldBe "c" + } + + test("should convert empty List to empty JSONArray") { + // Given + val list = emptyList() + + // When + val result = JSONUtils.convertToJson(list) + + // Then + val jsonArray = result as JSONArray + jsonArray.length() shouldBe 0 + } + + test("should convert empty Map to empty JSONObject") { + // Given + val map = emptyMap() + + // When + val result = JSONUtils.convertToJson(map) + + // Then + val jsonObject = result as JSONObject + jsonObject.length() shouldBe 0 + } + + test("should handle List with mixed types") { + // Given + val list = listOf("string", 42, true, 3.14) + + // When + val result = JSONUtils.convertToJson(list) + + // Then + val jsonArray = result as JSONArray + jsonArray.length() shouldBe 4 + jsonArray.getString(0) shouldBe "string" + jsonArray.getInt(1) shouldBe 42 + jsonArray.getBoolean(2) shouldBe true + jsonArray.getDouble(3) shouldBe 3.14 + } + + test("should filter out non-String keys from Map") { + // Given + val map = mapOf( + "validKey" to "value1", + 123 to "value2", // non-String key should be filtered + ) + + // When + val result = JSONUtils.convertToJson(map) + + // Then + val jsonObject = result as JSONObject + jsonObject.length() shouldBe 1 + jsonObject.getString("validKey") shouldBe "value1" + jsonObject.has("123") shouldBe false + } + + test("should handle deeply nested structures") { + // Given + val structure = mapOf( + "level1" to listOf( + mapOf( + "level2" to listOf( + mapOf("level3" to "deepValue"), + ), + ), + ), + ) + + // When + val result = JSONUtils.convertToJson(structure) + + // Then + val jsonObject = result as JSONObject + val level1Array = jsonObject.getJSONArray("level1") + val level1Item = level1Array.getJSONObject(0) + val level2Array = level1Item.getJSONArray("level2") + val level2Item = level2Array.getJSONObject(0) + level2Item.getString("level3") shouldBe "deepValue" + } + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserManagerTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserManagerTests.kt index 41e836eb9..ada9f00f6 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserManagerTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserManagerTests.kt @@ -4,7 +4,9 @@ import com.onesignal.core.internal.language.ILanguageContext import com.onesignal.mocks.MockHelper import com.onesignal.user.internal.subscriptions.ISubscriptionManager import com.onesignal.user.internal.subscriptions.SubscriptionList +import io.kotest.assertions.throwables.shouldNotThrow import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.equals.shouldBeEqual import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.mockk.every @@ -26,7 +28,7 @@ class UserManagerTests : FunSpec({ every { languageContext.language = capture(languageSlot) } answers { } val userManager = - UserManager(mockSubscriptionManager, MockHelper.identityModelStore(), MockHelper.propertiesModelStore(), languageContext) + UserManager(mockSubscriptionManager, MockHelper.identityModelStore(), MockHelper.propertiesModelStore(), MockHelper.customEventController(), languageContext) // When userManager.setLanguage("new-language") @@ -44,7 +46,7 @@ class UserManagerTests : FunSpec({ } val userManager = - UserManager(mockSubscriptionManager, identityModelStore, MockHelper.propertiesModelStore(), MockHelper.languageContext()) + UserManager(mockSubscriptionManager, identityModelStore, MockHelper.propertiesModelStore(), MockHelper.customEventController(), MockHelper.languageContext()) // When val externalId = userManager.externalId @@ -63,7 +65,7 @@ class UserManagerTests : FunSpec({ } val userManager = - UserManager(mockSubscriptionManager, identityModelStore, MockHelper.propertiesModelStore(), MockHelper.languageContext()) + UserManager(mockSubscriptionManager, identityModelStore, MockHelper.propertiesModelStore(), MockHelper.customEventController(), MockHelper.languageContext()) // When val alias1 = userManager.aliases["my-alias-key1"] @@ -102,7 +104,7 @@ class UserManagerTests : FunSpec({ } val userManager = - UserManager(mockSubscriptionManager, MockHelper.identityModelStore(), propertiesModelStore, MockHelper.languageContext()) + UserManager(mockSubscriptionManager, MockHelper.identityModelStore(), propertiesModelStore, MockHelper.customEventController(), MockHelper.languageContext()) // When val tag1 = propertiesModelStore.model.tags["my-tag-key1"] @@ -141,7 +143,7 @@ class UserManagerTests : FunSpec({ it.tags["my-tag-key1"] = "my-tag-value1" } - val userManager = UserManager(mockSubscriptionManager, MockHelper.identityModelStore(), propertiesModelStore, MockHelper.languageContext()) + val userManager = UserManager(mockSubscriptionManager, MockHelper.identityModelStore(), propertiesModelStore, MockHelper.customEventController(), MockHelper.languageContext()) // When val tagSnapshot1 = userManager.getTags() @@ -173,6 +175,7 @@ class UserManagerTests : FunSpec({ mockSubscriptionManager, MockHelper.identityModelStore(), MockHelper.propertiesModelStore(), + MockHelper.customEventController(), MockHelper.languageContext(), ) @@ -191,4 +194,44 @@ class UserManagerTests : FunSpec({ verify(exactly = 1) { mockSubscriptionManager.addSmsSubscription("+15558675309") } verify(exactly = 1) { mockSubscriptionManager.removeSmsSubscription("+15558675309") } } + + test("custom event controller sends various types of properties") { + // Given + val customEventController = MockHelper.customEventController() + + val userManager = + UserManager(mockk(), MockHelper.identityModelStore(), MockHelper.propertiesModelStore(), customEventController, MockHelper.languageContext()) + + val eventName = "eventName" + val properties = + mapOf( + "key1" to "value1", + "key2" to 2, + "key3" to 5.123, + "key4" to mapOf("key4-1" to "value4-1"), + "key5" to mapOf("key5-1" to mapOf("key5-1-1" to 0)), + ) + + // When + // should be able to handle any of the map structures above + shouldNotThrow { + userManager.trackEvent( + eventName, + properties, + ) + } + + // Then + // ensure the controller call sendCustomEvent() with the correct name and properties + verify(exactly = 1) { + customEventController.sendCustomEvent( + withArg { + it.shouldBeEqual(eventName) + }, + withArg { + it.shouldBeEqual(properties) + }, + ) + } + } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/CustomEventBackendServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/CustomEventBackendServiceTests.kt new file mode 100644 index 000000000..924d7f9f3 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/backend/CustomEventBackendServiceTests.kt @@ -0,0 +1,86 @@ +package com.onesignal.user.internal.backend + +import com.onesignal.common.DateUtils +import com.onesignal.core.internal.http.HttpResponse +import com.onesignal.core.internal.http.IHttpClient +import com.onesignal.core.internal.operations.ExecutionResult +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.user.internal.customEvents.impl.CustomEventBackendService +import com.onesignal.user.internal.customEvents.impl.CustomEventMetadata +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.equals.shouldBeEqual +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import org.json.JSONObject +import java.util.TimeZone + +class CustomEventBackendServiceTests : FunSpec({ + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + val metadata = + CustomEventMetadata( + "Android", + "sdk", + "1.0", + "type", + "deviceModel", + "deviceOS", + ) + + test("track event") { + // Given + val spyHttpClient = mockk() + coEvery { spyHttpClient.post(any(), any()) } returns HttpResponse(202, "") + val customEventBackendService = CustomEventBackendService(spyHttpClient) + + // When + val properties = JSONObject().put("proKey1", "proVal1").toString() + + val response = + customEventBackendService.sendCustomEvent( + appId = "appId", + onesignalId = "onesignalId", + externalId = null, + timestamp = 1, + eventName = "event-name", + eventProperties = properties, + metadata = metadata, + ) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify { + spyHttpClient.post( + "apps/appId/custom_events", + withArg { + val eventsObject = it.getJSONArray("events").getJSONObject(0) + val eventMap = mutableMapOf() + for (key in eventsObject.keys()) { + eventMap[key] = eventsObject.get(key) + } + + eventMap["name"] shouldBe "event-name" + eventMap["onesignal_id"] shouldBe "onesignalId" + eventMap["external_id"] shouldBe null + eventMap["timestamp"] shouldBe + DateUtils + .iso8601Format() + .apply { + timeZone = TimeZone.getTimeZone("UTC") + }.format( + 1, + ) + + val payload = eventMap["payload"] as JSONObject + payload.getJSONObject("os_sdk").toString() shouldBeEqual metadata.toJSONObject().toString() + payload.getString("proKey1") shouldBeEqual "proVal1" + }, + ) + } + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/CustomEventOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/CustomEventOperationExecutorTests.kt new file mode 100644 index 000000000..044d4c372 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/CustomEventOperationExecutorTests.kt @@ -0,0 +1,66 @@ +package com.onesignal.user.internal.operations + +import android.content.Context +import android.os.Build +import com.onesignal.common.OneSignalUtils +import com.onesignal.core.internal.device.IDeviceService +import com.onesignal.core.internal.operations.ExecutionResponse +import com.onesignal.core.internal.operations.ExecutionResult +import com.onesignal.core.internal.operations.Operation +import com.onesignal.mocks.MockHelper +import com.onesignal.user.internal.customEvents.ICustomEventBackendService +import com.onesignal.user.internal.operations.impl.executors.CustomEventOperationExecutor +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.equals.shouldBeEqual +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import org.json.JSONObject + +class CustomEventOperationExecutorTests : FunSpec({ + test("execution of track event operation") { + // Given + val mockCustomEventBackendService = mockk() + coEvery { mockCustomEventBackendService.sendCustomEvent(any(), any(), any(), any(), any(), any(), any()) } returns ExecutionResponse(ExecutionResult.SUCCESS) + + val mockApplicationService = MockHelper.applicationService() + val mockContext = mockk(relaxed = true) + every { mockApplicationService.appContext } returns mockContext + val mockDeviceService = MockHelper.deviceService() + every { mockDeviceService.deviceType } returns IDeviceService.DeviceType.Android + + val deviceMode = Build.MODEL + val deviceOS = Build.VERSION.RELEASE + val properties = JSONObject().put("key", "value").toString() + + val customEventOperationExecutor = + CustomEventOperationExecutor(mockCustomEventBackendService, mockApplicationService, mockDeviceService) + val operations = listOf(TrackCustomEventOperation("appId", "onesignalId", null, 1, "event-name", properties)) + + // When + val response = customEventOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockCustomEventBackendService.sendCustomEvent( + "appId", + "onesignalId", + null, + 1, + "event-name", + properties, + withArg { + it.sdk shouldBe OneSignalUtils.sdkVersion + it.appVersion?.shouldBeEqual("0") + it.type?.shouldBeEqual(("AndroidPush")) + it.deviceType?.shouldBeEqual(("Android")) + it.deviceModel shouldBe deviceMode + it.deviceOS shouldBe deviceOS + }, + ) + } + } +}) diff --git a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/MockHelper.kt b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/MockHelper.kt index d8fa8ed86..500b736e7 100644 --- a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/MockHelper.kt +++ b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/MockHelper.kt @@ -8,6 +8,7 @@ import com.onesignal.core.internal.language.ILanguageContext import com.onesignal.core.internal.time.ITime import com.onesignal.session.internal.session.SessionModel import com.onesignal.session.internal.session.SessionModelStore +import com.onesignal.user.internal.customEvents.ICustomEventController import com.onesignal.user.internal.identity.IdentityModel import com.onesignal.user.internal.identity.IdentityModelStore import com.onesignal.user.internal.properties.PropertiesModel @@ -126,4 +127,10 @@ object MockHelper { every { deviceService.deviceType } returns IDeviceService.DeviceType.Android return deviceService } + + fun customEventController(): ICustomEventController { + val controller = mockk() + every { controller.sendCustomEvent(any(), any()) } just runs + return controller + } } From 24cfb646efefe3817ab3f0f29e626f7d531c0685 Mon Sep 17 00:00:00 2001 From: jinliu Date: Mon, 26 Jan 2026 11:57:05 -0500 Subject: [PATCH 4/8] chore: improve testing with injected coroutine dispatchers (#2524) Co-authored-by: AR Abdul Azeez --- .../threading/CoroutineDispatcherProvider.kt | 23 + .../threading/DefaultDispatcherProvider.kt | 26 + .../outcomes/impl/OutcomeEventsRepository.kt | 16 +- .../outcomes/OutcomeEventsRepositoryTests.kt | 873 +++++++++--------- .../data/impl/NotificationRepository.kt | 32 +- .../onesignal/mocks/TestDispatcherProvider.kt | 65 ++ 6 files changed, 594 insertions(+), 441 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CoroutineDispatcherProvider.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/DefaultDispatcherProvider.kt create mode 100644 OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/TestDispatcherProvider.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CoroutineDispatcherProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CoroutineDispatcherProvider.kt new file mode 100644 index 000000000..8b57c5659 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CoroutineDispatcherProvider.kt @@ -0,0 +1,23 @@ +package com.onesignal.common.threading + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job + +/** + * Provider interface for coroutine dispatchers. + * This allows for proper dependency injection and easier testing. + */ +interface CoroutineDispatcherProvider { + val io: CoroutineDispatcher + val default: CoroutineDispatcher + + /** + * Launch a coroutine on the IO dispatcher. + */ + fun launchOnIO(block: suspend () -> Unit): Job + + /** + * Launch a coroutine on the Default dispatcher. + */ + fun launchOnDefault(block: suspend () -> Unit): Job +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/DefaultDispatcherProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/DefaultDispatcherProvider.kt new file mode 100644 index 000000000..8ca50d5b5 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/DefaultDispatcherProvider.kt @@ -0,0 +1,26 @@ +package com.onesignal.common.threading + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job + +/** + * Production implementation of [CoroutineDispatcherProvider] that uses OneSignalDispatchers. + * + * This delegates to the existing scopes in OneSignalDispatchers to avoid creating duplicate scopes. + * The OneSignalDispatchers already maintains IOScope and DefaultScope with SupervisorJob, + * so we reuse those instead of creating new ones. + */ +class DefaultDispatcherProvider : CoroutineDispatcherProvider { + override val io: CoroutineDispatcher = OneSignalDispatchers.IO + override val default: CoroutineDispatcher = OneSignalDispatchers.Default + + override fun launchOnIO(block: suspend () -> Unit): Job { + // Delegate to OneSignalDispatchers which already has IOScope with SupervisorJob + return OneSignalDispatchers.launchOnIO(block) + } + + override fun launchOnDefault(block: suspend () -> Unit): Job { + // Delegate to OneSignalDispatchers which already has DefaultScope with SupervisorJob + return OneSignalDispatchers.launchOnDefault(block) + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/outcomes/impl/OutcomeEventsRepository.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/outcomes/impl/OutcomeEventsRepository.kt index 8a9b7ecb6..16c4f2d32 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/outcomes/impl/OutcomeEventsRepository.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/outcomes/impl/OutcomeEventsRepository.kt @@ -1,6 +1,7 @@ package com.onesignal.session.internal.outcomes.impl import android.content.ContentValues +import com.onesignal.common.threading.OneSignalDispatchers import com.onesignal.core.internal.database.IDatabaseProvider import com.onesignal.core.internal.database.impl.OneSignalDbContract import com.onesignal.debug.internal.logging.Logging @@ -9,7 +10,7 @@ import com.onesignal.session.internal.influence.InfluenceChannel import com.onesignal.session.internal.influence.InfluenceType import com.onesignal.session.internal.influence.InfluenceType.Companion.fromString import com.onesignal.session.internal.outcomes.migrations.RemoveInvalidSessionTimeRecords -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONException @@ -17,12 +18,13 @@ import java.util.Locale internal class OutcomeEventsRepository( private val _databaseProvider: IDatabaseProvider, + private val ioDispatcher: CoroutineDispatcher = OneSignalDispatchers.IO, ) : IOutcomeEventsRepository { /** * Delete event from the DB */ override suspend fun deleteOldOutcomeEvent(event: OutcomeEventParams) { - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { _databaseProvider.os.delete( OutcomeEventsTable.TABLE_NAME, OutcomeEventsTable.COLUMN_NAME_TIMESTAMP + " = ?", @@ -36,7 +38,7 @@ internal class OutcomeEventsRepository( * For offline mode and contingency of errors */ override suspend fun saveOutcomeEvent(eventParams: OutcomeEventParams) { - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { var notificationIds = JSONArray() var iamIds = JSONArray() var notificationInfluenceType = InfluenceType.UNATTRIBUTED @@ -101,7 +103,7 @@ internal class OutcomeEventsRepository( */ override suspend fun getAllEventsToSend(): List { val events: MutableList = ArrayList() - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { RemoveInvalidSessionTimeRecords.run(_databaseProvider) _databaseProvider.os.query(OutcomeEventsTable.TABLE_NAME) { cursor -> if (cursor.moveToFirst()) { @@ -248,7 +250,7 @@ internal class OutcomeEventsRepository( override suspend fun saveUniqueOutcomeEventParams(eventParams: OutcomeEventParams) { Logging.debug("OutcomeEventsCache.saveUniqueOutcomeEventParams(eventParams: $eventParams)") - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { val outcomeName = eventParams.outcomeId val cachedUniqueOutcomes: MutableList = ArrayList() val directBody = eventParams.outcomeSource?.directBody @@ -283,7 +285,7 @@ internal class OutcomeEventsRepository( ): List { val uniqueInfluences: MutableList = ArrayList() - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { try { for (influence in influences) { val availableInfluenceIds = JSONArray() @@ -333,7 +335,7 @@ internal class OutcomeEventsRepository( val notificationTableName = OneSignalDbContract.NotificationTable.TABLE_NAME val notificationIdColumnName = OneSignalDbContract.NotificationTable.COLUMN_NAME_NOTIFICATION_ID - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { val whereStr = "NOT EXISTS(" + "SELECT NULL FROM " + notificationTableName + " n " + diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/outcomes/OutcomeEventsRepositoryTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/outcomes/OutcomeEventsRepositoryTests.kt index e7fad98ac..f1cd47491 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/outcomes/OutcomeEventsRepositoryTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/outcomes/OutcomeEventsRepositoryTests.kt @@ -4,6 +4,7 @@ import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.mocks.DatabaseMockHelper +import com.onesignal.mocks.TestDispatcherProvider import com.onesignal.session.internal.influence.Influence import com.onesignal.session.internal.influence.InfluenceChannel import com.onesignal.session.internal.influence.InfluenceType @@ -19,6 +20,9 @@ import io.kotest.matchers.shouldNotBe import io.mockk.verify import io.mockk.verifyAll import io.mockk.verifySequence +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.runTest import org.json.JSONArray @RobolectricTest @@ -27,481 +31,512 @@ class OutcomeEventsRepositoryTests : FunSpec({ Logging.logLevel = LogLevel.NONE } + // avoids initialization happening too early (before Robolectric’s environment exists). + lateinit var testDispatcher: TestDispatcher + lateinit var dispatcherProvider: TestDispatcherProvider + + beforeTest { + testDispatcher = StandardTestDispatcher() + dispatcherProvider = TestDispatcherProvider(testDispatcher) + } + test("delete outcome event should use the timestamp to delete row from database") { - // Given - val mockDatabasePair = DatabaseMockHelper.databaseProvider(OutcomeEventsTable.TABLE_NAME) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) - - // When - outcomeEventsRepository.deleteOldOutcomeEvent(OutcomeEventParams("outcomeId", null, 0f, 0, 1111)) - - // Then - verify(exactly = 1) { - mockDatabasePair.second.delete( - OutcomeEventsTable.TABLE_NAME, - withArg { - it.contains(OutcomeEventsTable.COLUMN_NAME_TIMESTAMP) - }, - withArg { it.contains("1111") }, - ) + runTest(dispatcherProvider.io) { + val mockDatabasePair = DatabaseMockHelper.databaseProvider(OutcomeEventsTable.TABLE_NAME) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) + + // When + outcomeEventsRepository.deleteOldOutcomeEvent(OutcomeEventParams("outcomeId", null, 0f, 0, 1111)) + + // Then + verify(exactly = 1) { + mockDatabasePair.second.delete( + OutcomeEventsTable.TABLE_NAME, + withArg { + it.contains(OutcomeEventsTable.COLUMN_NAME_TIMESTAMP) + }, + withArg { it.contains("1111") }, + ) + } } } test("save outcome event should insert row into database") { - // Given - val mockDatabasePair = DatabaseMockHelper.databaseProvider(OutcomeEventsTable.TABLE_NAME) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) - - // When - outcomeEventsRepository.saveOutcomeEvent(OutcomeEventParams("outcomeId1", null, 0f, 0, 1111)) - outcomeEventsRepository.saveOutcomeEvent( - OutcomeEventParams( - "outcomeId2", - OutcomeSource( - OutcomeSourceBody(JSONArray().put("notificationId1")), - OutcomeSourceBody(null, JSONArray().put("iamId1").put("iamId2")), - ), - .2f, - 0, - 2222, - ), - ) - outcomeEventsRepository.saveOutcomeEvent( - OutcomeEventParams( - "outcomeId3", - OutcomeSource( - OutcomeSourceBody(JSONArray().put("notificationId1"), JSONArray().put("iamId1")), - null, - ), - .4f, - 0, - 3333, - ), - ) - outcomeEventsRepository.saveOutcomeEvent( - OutcomeEventParams( - "outcomeId4", - OutcomeSource( - null, - OutcomeSourceBody(JSONArray().put("notificationId1"), JSONArray().put("iamId1").put("iamId2")), + runTest(dispatcherProvider.io) { + // Given + val mockDatabasePair = DatabaseMockHelper.databaseProvider(OutcomeEventsTable.TABLE_NAME) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) + + // When + outcomeEventsRepository.saveOutcomeEvent(OutcomeEventParams("outcomeId1", null, 0f, 0, 1111)) + outcomeEventsRepository.saveOutcomeEvent( + OutcomeEventParams( + "outcomeId2", + OutcomeSource( + OutcomeSourceBody(JSONArray().put("notificationId1")), + OutcomeSourceBody(null, JSONArray().put("iamId1").put("iamId2")), + ), + .2f, + 0, + 2222, ), - .6f, - 0, - 4444, - ), - ) - - // Then - verifySequence { - mockDatabasePair.second.insert( - OutcomeEventsTable.TABLE_NAME, - null, - withArg { - it[OutcomeEventsTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[OutcomeEventsTable.COLUMN_NAME_WEIGHT] shouldBe 0f - it[OutcomeEventsTable.COLUMN_NAME_TIMESTAMP] shouldBe 1111L - it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE] shouldBe "unattributed" - it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS] shouldBe JSONArray().toString() - it[OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE] shouldBe "unattributed" - it[OutcomeEventsTable.COLUMN_NAME_IAM_IDS] shouldBe JSONArray().toString() - }, ) - mockDatabasePair.second.insert( - OutcomeEventsTable.TABLE_NAME, - null, - withArg { - it[OutcomeEventsTable.COLUMN_NAME_NAME] shouldBe "outcomeId2" - it[OutcomeEventsTable.COLUMN_NAME_WEIGHT] shouldBe .2f - it[OutcomeEventsTable.COLUMN_NAME_TIMESTAMP] shouldBe 2222L - it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE] shouldBe "direct" - it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS] shouldBe JSONArray("[\"notificationId1\"]").toString() - it[OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE] shouldBe "indirect" - it[OutcomeEventsTable.COLUMN_NAME_IAM_IDS] shouldBe JSONArray("[\"iamId1\", \"iamId2\"]").toString() - }, - ) - mockDatabasePair.second.insert( - OutcomeEventsTable.TABLE_NAME, - null, - withArg { - it[OutcomeEventsTable.COLUMN_NAME_NAME] shouldBe "outcomeId3" - it[OutcomeEventsTable.COLUMN_NAME_WEIGHT] shouldBe .4f - it[OutcomeEventsTable.COLUMN_NAME_TIMESTAMP] shouldBe 3333L - it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE] shouldBe "direct" - it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS] shouldBe JSONArray("[\"notificationId1\"]").toString() - it[OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE] shouldBe "direct" - it[OutcomeEventsTable.COLUMN_NAME_IAM_IDS] shouldBe JSONArray("[\"iamId1\"]").toString() - }, + outcomeEventsRepository.saveOutcomeEvent( + OutcomeEventParams( + "outcomeId3", + OutcomeSource( + OutcomeSourceBody(JSONArray().put("notificationId1"), JSONArray().put("iamId1")), + null, + ), + .4f, + 0, + 3333, + ), ) - mockDatabasePair.second.insert( - OutcomeEventsTable.TABLE_NAME, - null, - withArg { - it[OutcomeEventsTable.COLUMN_NAME_NAME] shouldBe "outcomeId4" - it[OutcomeEventsTable.COLUMN_NAME_WEIGHT] shouldBe .6f - it[OutcomeEventsTable.COLUMN_NAME_TIMESTAMP] shouldBe 4444L - it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE] shouldBe "indirect" - it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS] shouldBe JSONArray("[\"notificationId1\"]").toString() - it[OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE] shouldBe "indirect" - it[OutcomeEventsTable.COLUMN_NAME_IAM_IDS] shouldBe JSONArray("[\"iamId1\", \"iamId2\"]").toString() - }, + outcomeEventsRepository.saveOutcomeEvent( + OutcomeEventParams( + "outcomeId4", + OutcomeSource( + null, + OutcomeSourceBody(JSONArray().put("notificationId1"), JSONArray().put("iamId1").put("iamId2")), + ), + .6f, + 0, + 4444, + ), ) + + // Then + verifySequence { + mockDatabasePair.second.insert( + OutcomeEventsTable.TABLE_NAME, + null, + withArg { + it[OutcomeEventsTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[OutcomeEventsTable.COLUMN_NAME_WEIGHT] shouldBe 0f + it[OutcomeEventsTable.COLUMN_NAME_TIMESTAMP] shouldBe 1111L + it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE] shouldBe "unattributed" + it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS] shouldBe JSONArray().toString() + it[OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE] shouldBe "unattributed" + it[OutcomeEventsTable.COLUMN_NAME_IAM_IDS] shouldBe JSONArray().toString() + }, + ) + mockDatabasePair.second.insert( + OutcomeEventsTable.TABLE_NAME, + null, + withArg { + it[OutcomeEventsTable.COLUMN_NAME_NAME] shouldBe "outcomeId2" + it[OutcomeEventsTable.COLUMN_NAME_WEIGHT] shouldBe .2f + it[OutcomeEventsTable.COLUMN_NAME_TIMESTAMP] shouldBe 2222L + it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE] shouldBe "direct" + it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS] shouldBe JSONArray("[\"notificationId1\"]").toString() + it[OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE] shouldBe "indirect" + it[OutcomeEventsTable.COLUMN_NAME_IAM_IDS] shouldBe JSONArray("[\"iamId1\", \"iamId2\"]").toString() + }, + ) + mockDatabasePair.second.insert( + OutcomeEventsTable.TABLE_NAME, + null, + withArg { + it[OutcomeEventsTable.COLUMN_NAME_NAME] shouldBe "outcomeId3" + it[OutcomeEventsTable.COLUMN_NAME_WEIGHT] shouldBe .4f + it[OutcomeEventsTable.COLUMN_NAME_TIMESTAMP] shouldBe 3333L + it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE] shouldBe "direct" + it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS] shouldBe JSONArray("[\"notificationId1\"]").toString() + it[OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE] shouldBe "direct" + it[OutcomeEventsTable.COLUMN_NAME_IAM_IDS] shouldBe JSONArray("[\"iamId1\"]").toString() + }, + ) + mockDatabasePair.second.insert( + OutcomeEventsTable.TABLE_NAME, + null, + withArg { + it[OutcomeEventsTable.COLUMN_NAME_NAME] shouldBe "outcomeId4" + it[OutcomeEventsTable.COLUMN_NAME_WEIGHT] shouldBe .6f + it[OutcomeEventsTable.COLUMN_NAME_TIMESTAMP] shouldBe 4444L + it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE] shouldBe "indirect" + it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS] shouldBe JSONArray("[\"notificationId1\"]").toString() + it[OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE] shouldBe "indirect" + it[OutcomeEventsTable.COLUMN_NAME_IAM_IDS] shouldBe JSONArray("[\"iamId1\", \"iamId2\"]").toString() + }, + ) + } } } test("get events should retrieve return empty list when database is empty") { - // Given - val mockDatabasePair = DatabaseMockHelper.databaseProvider(OutcomeEventsTable.TABLE_NAME) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) + runTest(dispatcherProvider.io) { + // Given + val mockDatabasePair = DatabaseMockHelper.databaseProvider(OutcomeEventsTable.TABLE_NAME) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) - // When - val events = outcomeEventsRepository.getAllEventsToSend() + // When + val events = outcomeEventsRepository.getAllEventsToSend() - // Then - events.count() shouldBe 0 + // Then + events.count() shouldBe 0 + } } test("get events should retrieve return an item per row in database") { - // Given - val records = - listOf( - mapOf( - OutcomeEventsTable.COLUMN_NAME_NAME to "outcomeId1", - OutcomeEventsTable.COLUMN_NAME_WEIGHT to 0.2f, - OutcomeEventsTable.COLUMN_NAME_TIMESTAMP to 1111L, - OutcomeEventsTable.COLUMN_NAME_SESSION_TIME to 1L, - OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE to "unattributed", - OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE to "unattributed", - ), - mapOf( - OutcomeEventsTable.COLUMN_NAME_NAME to "outcomeId2", - OutcomeEventsTable.COLUMN_NAME_WEIGHT to 0.4f, - OutcomeEventsTable.COLUMN_NAME_TIMESTAMP to 2222L, - OutcomeEventsTable.COLUMN_NAME_SESSION_TIME to 2L, - OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE to "indirect", - OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS to "[\"notificationId1\",\"notificationId2\"]", - OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE to "indirect", - OutcomeEventsTable.COLUMN_NAME_IAM_IDS to "[\"iamId1\",\"iamId2\"]", - ), - mapOf( - OutcomeEventsTable.COLUMN_NAME_NAME to "outcomeId3", - OutcomeEventsTable.COLUMN_NAME_WEIGHT to 0.6f, - OutcomeEventsTable.COLUMN_NAME_TIMESTAMP to 3333L, - OutcomeEventsTable.COLUMN_NAME_SESSION_TIME to 3L, - OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE to "direct", - OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS to "[\"notificationId3\"]", - OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE to "direct", - OutcomeEventsTable.COLUMN_NAME_IAM_IDS to "[\"iamId3\"]", - ), - ) - val mockDatabasePair = DatabaseMockHelper.databaseProvider(OutcomeEventsTable.TABLE_NAME, records) - - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) - - // When - val events = outcomeEventsRepository.getAllEventsToSend() - - // Then - events.count() shouldBe 3 - events[0].outcomeId shouldBe "outcomeId1" - events[0].weight shouldBe 0.2f - events[0].timestamp shouldBe 1111L - events[0].sessionTime shouldBe 1L - events[0].outcomeSource shouldNotBe null - events[0].outcomeSource!!.directBody shouldBe null - events[0].outcomeSource!!.indirectBody shouldBe null - events[1].outcomeId shouldBe "outcomeId2" - events[1].weight shouldBe 0.4f - events[1].timestamp shouldBe 2222L - events[1].sessionTime shouldBe 2L - events[1].outcomeSource shouldNotBe null - events[1].outcomeSource!!.directBody shouldBe null - events[1].outcomeSource!!.indirectBody shouldNotBe null - events[1].outcomeSource!!.indirectBody!!.notificationIds!!.length() shouldBe 2 - events[1].outcomeSource!!.indirectBody!!.notificationIds!!.getString(0) shouldBe "notificationId1" - events[1].outcomeSource!!.indirectBody!!.notificationIds!!.getString(1) shouldBe "notificationId2" - events[1].outcomeSource!!.indirectBody!!.inAppMessagesIds!!.length() shouldBe 2 - events[1].outcomeSource!!.indirectBody!!.inAppMessagesIds!!.getString(0) shouldBe "iamId1" - events[1].outcomeSource!!.indirectBody!!.inAppMessagesIds!!.getString(1) shouldBe "iamId2" - events[2].outcomeId shouldBe "outcomeId3" - events[2].weight shouldBe 0.6f - events[2].timestamp shouldBe 3333L - events[2].sessionTime shouldBe 3L - events[2].outcomeSource shouldNotBe null - events[2].outcomeSource!!.indirectBody shouldBe null - events[2].outcomeSource!!.directBody shouldNotBe null - events[2].outcomeSource!!.directBody!!.notificationIds!!.length() shouldBe 1 - events[2].outcomeSource!!.directBody!!.notificationIds!!.getString(0) shouldBe "notificationId3" - events[2].outcomeSource!!.directBody!!.inAppMessagesIds!!.length() shouldBe 1 - events[2].outcomeSource!!.directBody!!.inAppMessagesIds!!.getString(0) shouldBe "iamId3" + runTest(dispatcherProvider.io) { + // Given + val records = + listOf( + mapOf( + OutcomeEventsTable.COLUMN_NAME_NAME to "outcomeId1", + OutcomeEventsTable.COLUMN_NAME_WEIGHT to 0.2f, + OutcomeEventsTable.COLUMN_NAME_TIMESTAMP to 1111L, + OutcomeEventsTable.COLUMN_NAME_SESSION_TIME to 1L, + OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE to "unattributed", + OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE to "unattributed", + ), + mapOf( + OutcomeEventsTable.COLUMN_NAME_NAME to "outcomeId2", + OutcomeEventsTable.COLUMN_NAME_WEIGHT to 0.4f, + OutcomeEventsTable.COLUMN_NAME_TIMESTAMP to 2222L, + OutcomeEventsTable.COLUMN_NAME_SESSION_TIME to 2L, + OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE to "indirect", + OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS to "[\"notificationId1\",\"notificationId2\"]", + OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE to "indirect", + OutcomeEventsTable.COLUMN_NAME_IAM_IDS to "[\"iamId1\",\"iamId2\"]", + ), + mapOf( + OutcomeEventsTable.COLUMN_NAME_NAME to "outcomeId3", + OutcomeEventsTable.COLUMN_NAME_WEIGHT to 0.6f, + OutcomeEventsTable.COLUMN_NAME_TIMESTAMP to 3333L, + OutcomeEventsTable.COLUMN_NAME_SESSION_TIME to 3L, + OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE to "direct", + OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS to "[\"notificationId3\"]", + OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE to "direct", + OutcomeEventsTable.COLUMN_NAME_IAM_IDS to "[\"iamId3\"]", + ), + ) + val mockDatabasePair = DatabaseMockHelper.databaseProvider(OutcomeEventsTable.TABLE_NAME, records) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) + + // When + val events = outcomeEventsRepository.getAllEventsToSend() + + // Then + events.count() shouldBe 3 + events[0].outcomeId shouldBe "outcomeId1" + events[0].weight shouldBe 0.2f + events[0].timestamp shouldBe 1111L + events[0].sessionTime shouldBe 1L + events[0].outcomeSource shouldNotBe null + events[0].outcomeSource!!.directBody shouldBe null + events[0].outcomeSource!!.indirectBody shouldBe null + events[1].outcomeId shouldBe "outcomeId2" + events[1].weight shouldBe 0.4f + events[1].timestamp shouldBe 2222L + events[1].sessionTime shouldBe 2L + events[1].outcomeSource shouldNotBe null + events[1].outcomeSource!!.directBody shouldBe null + events[1].outcomeSource!!.indirectBody shouldNotBe null + events[1].outcomeSource!!.indirectBody!!.notificationIds!!.length() shouldBe 2 + events[1].outcomeSource!!.indirectBody!!.notificationIds!!.getString(0) shouldBe "notificationId1" + events[1].outcomeSource!!.indirectBody!!.notificationIds!!.getString(1) shouldBe "notificationId2" + events[1].outcomeSource!!.indirectBody!!.inAppMessagesIds!!.length() shouldBe 2 + events[1].outcomeSource!!.indirectBody!!.inAppMessagesIds!!.getString(0) shouldBe "iamId1" + events[1].outcomeSource!!.indirectBody!!.inAppMessagesIds!!.getString(1) shouldBe "iamId2" + events[2].outcomeId shouldBe "outcomeId3" + events[2].weight shouldBe 0.6f + events[2].timestamp shouldBe 3333L + events[2].sessionTime shouldBe 3L + events[2].outcomeSource shouldNotBe null + events[2].outcomeSource!!.indirectBody shouldBe null + events[2].outcomeSource!!.directBody shouldNotBe null + events[2].outcomeSource!!.directBody!!.notificationIds!!.length() shouldBe 1 + events[2].outcomeSource!!.directBody!!.notificationIds!!.getString(0) shouldBe "notificationId3" + events[2].outcomeSource!!.directBody!!.inAppMessagesIds!!.length() shouldBe 1 + events[2].outcomeSource!!.directBody!!.inAppMessagesIds!!.getString(0) shouldBe "iamId3" + } } test("save unique outcome should insert no rows when no influences") { - // Given - val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.COLUMN_NAME_NAME) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) + runTest(dispatcherProvider.io) { + // Given + val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.COLUMN_NAME_NAME) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) - // When - outcomeEventsRepository.saveUniqueOutcomeEventParams(OutcomeEventParams("outcomeId1", null, 0f, 0, 1111)) + // When + outcomeEventsRepository.saveUniqueOutcomeEventParams(OutcomeEventParams("outcomeId1", null, 0f, 0, 1111)) - // Then - verify(exactly = 0) { mockDatabasePair.second.insert(CachedUniqueOutcomeTable.COLUMN_NAME_NAME, null, any()) } + // Then + verify(exactly = 0) { mockDatabasePair.second.insert(CachedUniqueOutcomeTable.COLUMN_NAME_NAME, null, any()) } + } } test("save unique outcome should insert 1 row for each unique influence when direct notification and indiract iam") { - // Given - val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) - - // When - outcomeEventsRepository.saveUniqueOutcomeEventParams( - OutcomeEventParams( - "outcomeId1", - OutcomeSource( - OutcomeSourceBody(JSONArray().put("notificationId1")), - OutcomeSourceBody(null, JSONArray().put("iamId1").put("iamId2")), + runTest(dispatcherProvider.io) { + // Given + val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) + + // When + outcomeEventsRepository.saveUniqueOutcomeEventParams( + OutcomeEventParams( + "outcomeId1", + OutcomeSource( + OutcomeSourceBody(JSONArray().put("notificationId1")), + OutcomeSourceBody(null, JSONArray().put("iamId1").put("iamId2")), + ), + .2f, + 0, + 2222, ), - .2f, - 0, - 2222, - ), - ) - - // Then - verifyAll { - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId1" - }, - ) - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId1" - }, - ) - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId2" - }, ) + + // Then + verifyAll { + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId1" + }, + ) + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId1" + }, + ) + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId2" + }, + ) + } } } test("save unique outcome should insert 1 row for each unique influence when direct iam and indiract notifications") { - // Given - val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) - - // When - outcomeEventsRepository.saveUniqueOutcomeEventParams( - OutcomeEventParams( - "outcomeId1", - OutcomeSource( - OutcomeSourceBody(null, JSONArray().put("iamId1")), - OutcomeSourceBody(JSONArray().put("notificationId1").put("notificationId2")), + runTest(dispatcherProvider.io) { + // Given + val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) + + // When + outcomeEventsRepository.saveUniqueOutcomeEventParams( + OutcomeEventParams( + "outcomeId1", + OutcomeSource( + OutcomeSourceBody(null, JSONArray().put("iamId1")), + OutcomeSourceBody(JSONArray().put("notificationId1").put("notificationId2")), + ), + .2f, + 0, + 2222, ), - .2f, - 0, - 2222, - ), - ) - - // Then - verifyAll { - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId1" - }, - ) - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId2" - }, - ) - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId1" - }, ) + + // Then + verifyAll { + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId1" + }, + ) + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId2" + }, + ) + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId1" + }, + ) + } } } test("save unique outcome should insert 1 row for each unique influence when direct notification and iam") { - // Given - val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) - - // When - outcomeEventsRepository.saveUniqueOutcomeEventParams( - OutcomeEventParams( - "outcomeId1", - OutcomeSource( - OutcomeSourceBody(JSONArray().put("notificationId1"), JSONArray().put("iamId1")), - null, + runTest(dispatcherProvider.io) { + // Given + val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) + + // When + outcomeEventsRepository.saveUniqueOutcomeEventParams( + OutcomeEventParams( + "outcomeId1", + OutcomeSource( + OutcomeSourceBody(JSONArray().put("notificationId1"), JSONArray().put("iamId1")), + null, + ), + .2f, + 0, + 2222, ), - .2f, - 0, - 2222, - ), - ) - - // Then - verifyAll { - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId1" - }, - ) - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId1" - }, ) + + // Then + verifyAll { + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId1" + }, + ) + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId1" + }, + ) + } } } test("save unique outcome should insert 1 row for each unique influence when indirect notification and iam") { - // Given - val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) - - // When - outcomeEventsRepository.saveUniqueOutcomeEventParams( - OutcomeEventParams( - "outcomeId1", - OutcomeSource( - null, - OutcomeSourceBody(JSONArray().put("notificationId1").put("notificationId2"), JSONArray().put("iamId1").put("iamId2")), + runTest(dispatcherProvider.io) { + // Given + val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) + + // When + outcomeEventsRepository.saveUniqueOutcomeEventParams( + OutcomeEventParams( + "outcomeId1", + OutcomeSource( + null, + OutcomeSourceBody(JSONArray().put("notificationId1").put("notificationId2"), JSONArray().put("iamId1").put("iamId2")), + ), + .2f, + 0, + 2222, ), - .2f, - 0, - 2222, - ), - ) - - // Then - verifyAll { - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId1" - }, - ) - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId2" - }, - ) - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId1" - }, - ) - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId2" - }, ) + + // Then + verifyAll { + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId1" + }, + ) + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId2" + }, + ) + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId1" + }, + ) + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId2" + }, + ) + } } } test("retrieve non-cached influence should return full list when there are no cached unique influences") { - // Given - val records = listOf>() - val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME, records) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) - - // When - val influences = - outcomeEventsRepository.getNotCachedUniqueInfluencesForOutcome( - "outcomeId1", - listOf( - Influence(InfluenceChannel.NOTIFICATION, InfluenceType.DIRECT, JSONArray().put("notificationId1")), - Influence(InfluenceChannel.NOTIFICATION, InfluenceType.DIRECT, JSONArray().put("notificationId2")), - ), - ) - - // Then - influences.count() shouldBe 2 + runTest(dispatcherProvider.io) { + // Given + val records = listOf>() + val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME, records) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) + + // When + val influences = + outcomeEventsRepository.getNotCachedUniqueInfluencesForOutcome( + "outcomeId1", + listOf( + Influence(InfluenceChannel.NOTIFICATION, InfluenceType.DIRECT, JSONArray().put("notificationId1")), + Influence(InfluenceChannel.NOTIFICATION, InfluenceType.DIRECT, JSONArray().put("notificationId2")), + ), + ) + + // Then + influences.count() shouldBe 2 + } } test("retrieve non-cached influence should filter out an influence when there are is a matching influence") { - // Given - val records = listOf(mapOf(CachedUniqueOutcomeTable.COLUMN_NAME_NAME to "outcomeId1")) - val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME, records) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) - - // When - val influences = - outcomeEventsRepository.getNotCachedUniqueInfluencesForOutcome( - "outcomeId1", - listOf( - Influence(InfluenceChannel.NOTIFICATION, InfluenceType.DIRECT, JSONArray().put("notificationId1")), - Influence(InfluenceChannel.NOTIFICATION, InfluenceType.DIRECT, JSONArray().put("notificationId2")), - ), - ) - - // Then - influences.count() shouldBe 0 + runTest(dispatcherProvider.io) { + // Given + val records = listOf(mapOf(CachedUniqueOutcomeTable.COLUMN_NAME_NAME to "outcomeId1")) + val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME, records) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) + + // When + val influences = + outcomeEventsRepository.getNotCachedUniqueInfluencesForOutcome( + "outcomeId1", + listOf( + Influence(InfluenceChannel.NOTIFICATION, InfluenceType.DIRECT, JSONArray().put("notificationId1")), + Influence(InfluenceChannel.NOTIFICATION, InfluenceType.DIRECT, JSONArray().put("notificationId2")), + ), + ) + + // Then + influences.count() shouldBe 0 + } } test("clear unique influence should delete out an influence when there are is a matching influence") { - // Given - val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) - - // When - outcomeEventsRepository.cleanCachedUniqueOutcomeEventNotifications() - - // Then - verifyAll { - mockDatabasePair.second.delete(CachedUniqueOutcomeTable.TABLE_NAME, any(), any()) + runTest(dispatcherProvider.io) { + // Given + val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) + + // When + outcomeEventsRepository.cleanCachedUniqueOutcomeEventNotifications() + + // Then + verifyAll { + mockDatabasePair.second.delete(CachedUniqueOutcomeTable.TABLE_NAME, any(), any()) + } } } }) diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/data/impl/NotificationRepository.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/data/impl/NotificationRepository.kt index 66c750e3c..d1ad883e1 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/data/impl/NotificationRepository.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/data/impl/NotificationRepository.kt @@ -4,6 +4,8 @@ import android.app.NotificationManager import android.content.ContentValues import android.provider.BaseColumns import android.text.TextUtils +import com.onesignal.common.threading.CoroutineDispatcherProvider +import com.onesignal.common.threading.DefaultDispatcherProvider import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.database.IDatabaseProvider import com.onesignal.core.internal.database.impl.OneSignalDbContract @@ -14,7 +16,6 @@ import com.onesignal.notifications.internal.common.NotificationHelper import com.onesignal.notifications.internal.data.INotificationQueryHelper import com.onesignal.notifications.internal.data.INotificationRepository import com.onesignal.notifications.internal.limiting.INotificationLimitManager -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONException @@ -24,6 +25,7 @@ internal class NotificationRepository( private val _databaseProvider: IDatabaseProvider, private val _time: ITime, private val _badgeCountUpdater: IBadgeCountUpdater, + private val dispatchers: CoroutineDispatcherProvider = DefaultDispatcherProvider(), ) : INotificationRepository { /** * Deletes notifications with created timestamps older than 7 days @@ -31,7 +33,7 @@ internal class NotificationRepository( * 1. NotificationTable.TABLE_NAME */ override suspend fun deleteExpiredNotifications() { - withContext(Dispatchers.IO) { + withContext(dispatchers.io) { val whereStr: String = OneSignalDbContract.NotificationTable.COLUMN_NAME_CREATED_TIME.toString() + " < ?" val sevenDaysAgoInSeconds: String = java.lang.String.valueOf( @@ -48,7 +50,7 @@ internal class NotificationRepository( } override suspend fun markAsDismissedForOutstanding() { - withContext(Dispatchers.IO) { + withContext(dispatchers.io) { val appContext = _applicationService.appContext val notificationManager: NotificationManager = NotificationHelper.getNotificationManager(appContext) val retColumn = arrayOf(OneSignalDbContract.NotificationTable.COLUMN_NAME_ANDROID_NOTIFICATION_ID) @@ -79,7 +81,7 @@ internal class NotificationRepository( } override suspend fun markAsDismissedForGroup(group: String) { - withContext(Dispatchers.IO) { + withContext(dispatchers.io) { val appContext = _applicationService.appContext val notificationManager: NotificationManager = NotificationHelper.getNotificationManager(appContext) @@ -124,7 +126,7 @@ internal class NotificationRepository( override suspend fun markAsDismissed(androidId: Int): Boolean { var didDismiss: Boolean = false - withContext(Dispatchers.IO) { + withContext(dispatchers.io) { didDismiss = internalMarkAsDismissed(androidId) } @@ -159,7 +161,7 @@ internal class NotificationRepository( var result = false - withContext(Dispatchers.IO) { + withContext(dispatchers.io) { val retColumn = arrayOf(OneSignalDbContract.NotificationTable.COLUMN_NAME_NOTIFICATION_ID) val whereArgs = arrayOf(id!!) @@ -188,7 +190,7 @@ internal class NotificationRepository( androidId: Int, groupId: String, ) { - withContext(Dispatchers.IO) { + withContext(dispatchers.io) { // There currently isn't a visible notification from for this group_id. // Save the group summary notification id so it can be updated later. val values = ContentValues() @@ -218,7 +220,7 @@ internal class NotificationRepository( expireTime: Long, jsonPayload: String, ) { - withContext(Dispatchers.IO) { + withContext(dispatchers.io) { Logging.debug("Saving Notification id=$id") try { @@ -302,7 +304,7 @@ internal class NotificationRepository( summaryGroup: String?, clearGroupOnSummaryClick: Boolean, ) { - withContext(Dispatchers.IO) { + withContext(dispatchers.io) { var whereStr: String var whereArgs: Array? = null if (summaryGroup != null) { @@ -358,7 +360,7 @@ internal class NotificationRepository( override suspend fun getGroupId(androidId: Int): String? { var groupId: String? = null - withContext(Dispatchers.IO) { + withContext(dispatchers.io) { _databaseProvider.os.query( OneSignalDbContract.NotificationTable.TABLE_NAME, // retColumn @@ -378,7 +380,7 @@ internal class NotificationRepository( override suspend fun getAndroidIdFromCollapseKey(collapseKey: String): Int? { var androidId: Int? = null - withContext(Dispatchers.IO) { + withContext(dispatchers.io) { _databaseProvider.os.query( OneSignalDbContract.NotificationTable.TABLE_NAME, // retColumn @@ -402,7 +404,7 @@ internal class NotificationRepository( notificationsToMakeRoomFor: Int, maxNumberOfNotificationsInt: Int, ) { - withContext(Dispatchers.IO) { + withContext(dispatchers.io) { val maxNumberOfNotificationsString = maxNumberOfNotificationsInt.toString() try { @@ -437,7 +439,7 @@ internal class NotificationRepository( override suspend fun listNotificationsForGroup(summaryGroup: String): List { val listOfNotifications = mutableListOf() - withContext(Dispatchers.IO) { + withContext(dispatchers.io) { val whereArgs = arrayOf(summaryGroup) _databaseProvider.os.query( @@ -512,7 +514,7 @@ internal class NotificationRepository( val whereArgs = if (isGroupless) null else arrayOf(group) - withContext(Dispatchers.IO) { + withContext(dispatchers.io) { // Order by timestamp in descending and limit to 1 _databaseProvider.os.query( OneSignalDbContract.NotificationTable.TABLE_NAME, @@ -538,7 +540,7 @@ internal class NotificationRepository( override suspend fun listNotificationsForOutstanding(excludeAndroidIds: List?): List { val listOfNotifications = mutableListOf() - withContext(Dispatchers.IO) { + withContext(dispatchers.io) { val dbQuerySelection = _queryHelper.recentUninteractedWithNotificationsWhere() if (excludeAndroidIds != null) { diff --git a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/TestDispatcherProvider.kt b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/TestDispatcherProvider.kt new file mode 100644 index 000000000..2288659dc --- /dev/null +++ b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/TestDispatcherProvider.kt @@ -0,0 +1,65 @@ +package com.onesignal.mocks + +import com.onesignal.common.threading.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher + +/** + * Test implementation of [CoroutineDispatcherProvider] for unit tests. + * Uses a [TestDispatcher] for deterministic testing. + * + * Usage in tests: + * ``` + * test("my test") { + * val testDispatcher = StandardTestDispatcher() + * val dispatcherProvider = TestDispatcherProvider(testDispatcher) + * + * runTest(testDispatcher.scheduler) { + * val service = MyService(dispatcherProvider) + * service.doWork() + * + * // Option 1: Advance until all pending coroutines complete + * advanceUntilIdle() + * + * // Option 2: Advance virtual time by a specific amount (e.g., 100ms) + * // advanceTimeBy(100) + * + * // Option 3: Run only coroutines scheduled at current time + * // runCurrent() + * + * // Make assertions + * } + * } + * ``` + * + * Methods to control execution: + * - [advanceUntilIdle()] - Runs all pending coroutines until there's nothing left to execute + * - [advanceTimeBy(delayTime)] - Advances virtual time by the specified amount and runs + * coroutines scheduled for that time period + * - [runCurrent()] - Runs only the coroutines that are scheduled to run at the current + * virtual time (doesn't advance time) + * - [currentTime] - Property to check the current virtual time + */ +class TestDispatcherProvider( + private val testDispatcher: TestDispatcher = StandardTestDispatcher() +) : CoroutineDispatcherProvider { + override val io: CoroutineDispatcher = testDispatcher + override val default: CoroutineDispatcher = testDispatcher + + private val scope: CoroutineScope by lazy { + CoroutineScope(SupervisorJob() + testDispatcher) + } + + override fun launchOnIO(block: suspend () -> Unit): Job { + return scope.launch { block() } + } + + override fun launchOnDefault(block: suspend () -> Unit): Job { + return scope.launch { block() } + } +} From a251ec8ef5647f91f407edeb15194d7d03b1dea1 Mon Sep 17 00:00:00 2001 From: Sherwin Heydarbeygi Date: Mon, 26 Jan 2026 12:52:50 -0800 Subject: [PATCH 5/8] ci: remove extra "Channels" line that gets prepended to the release notes (#2529) --- .github/workflows/publish-release.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index cb58cbc99..b8fa1f288 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -163,8 +163,6 @@ jobs: CHANNEL="current" fi - echo -e "Channels: $CHANNEL\n\n$(cat release_notes.md)" > release_notes.md - git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" From 300b4c168b95ff80da57b4d3631b6ca3845d8f87 Mon Sep 17 00:00:00 2001 From: jinliu Date: Mon, 26 Jan 2026 17:31:24 -0500 Subject: [PATCH 6/8] Revert "chore: improve testing with injected coroutine dispatchers" (#2530) --- .../threading/CoroutineDispatcherProvider.kt | 23 - .../threading/DefaultDispatcherProvider.kt | 26 - .../outcomes/impl/OutcomeEventsRepository.kt | 16 +- .../outcomes/OutcomeEventsRepositoryTests.kt | 873 +++++++++--------- .../data/impl/NotificationRepository.kt | 32 +- .../onesignal/mocks/TestDispatcherProvider.kt | 65 -- 6 files changed, 441 insertions(+), 594 deletions(-) delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CoroutineDispatcherProvider.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/DefaultDispatcherProvider.kt delete mode 100644 OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/TestDispatcherProvider.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CoroutineDispatcherProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CoroutineDispatcherProvider.kt deleted file mode 100644 index 8b57c5659..000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CoroutineDispatcherProvider.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.onesignal.common.threading - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Job - -/** - * Provider interface for coroutine dispatchers. - * This allows for proper dependency injection and easier testing. - */ -interface CoroutineDispatcherProvider { - val io: CoroutineDispatcher - val default: CoroutineDispatcher - - /** - * Launch a coroutine on the IO dispatcher. - */ - fun launchOnIO(block: suspend () -> Unit): Job - - /** - * Launch a coroutine on the Default dispatcher. - */ - fun launchOnDefault(block: suspend () -> Unit): Job -} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/DefaultDispatcherProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/DefaultDispatcherProvider.kt deleted file mode 100644 index 8ca50d5b5..000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/DefaultDispatcherProvider.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.onesignal.common.threading - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Job - -/** - * Production implementation of [CoroutineDispatcherProvider] that uses OneSignalDispatchers. - * - * This delegates to the existing scopes in OneSignalDispatchers to avoid creating duplicate scopes. - * The OneSignalDispatchers already maintains IOScope and DefaultScope with SupervisorJob, - * so we reuse those instead of creating new ones. - */ -class DefaultDispatcherProvider : CoroutineDispatcherProvider { - override val io: CoroutineDispatcher = OneSignalDispatchers.IO - override val default: CoroutineDispatcher = OneSignalDispatchers.Default - - override fun launchOnIO(block: suspend () -> Unit): Job { - // Delegate to OneSignalDispatchers which already has IOScope with SupervisorJob - return OneSignalDispatchers.launchOnIO(block) - } - - override fun launchOnDefault(block: suspend () -> Unit): Job { - // Delegate to OneSignalDispatchers which already has DefaultScope with SupervisorJob - return OneSignalDispatchers.launchOnDefault(block) - } -} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/outcomes/impl/OutcomeEventsRepository.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/outcomes/impl/OutcomeEventsRepository.kt index 16c4f2d32..8a9b7ecb6 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/outcomes/impl/OutcomeEventsRepository.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/outcomes/impl/OutcomeEventsRepository.kt @@ -1,7 +1,6 @@ package com.onesignal.session.internal.outcomes.impl import android.content.ContentValues -import com.onesignal.common.threading.OneSignalDispatchers import com.onesignal.core.internal.database.IDatabaseProvider import com.onesignal.core.internal.database.impl.OneSignalDbContract import com.onesignal.debug.internal.logging.Logging @@ -10,7 +9,7 @@ import com.onesignal.session.internal.influence.InfluenceChannel import com.onesignal.session.internal.influence.InfluenceType import com.onesignal.session.internal.influence.InfluenceType.Companion.fromString import com.onesignal.session.internal.outcomes.migrations.RemoveInvalidSessionTimeRecords -import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONException @@ -18,13 +17,12 @@ import java.util.Locale internal class OutcomeEventsRepository( private val _databaseProvider: IDatabaseProvider, - private val ioDispatcher: CoroutineDispatcher = OneSignalDispatchers.IO, ) : IOutcomeEventsRepository { /** * Delete event from the DB */ override suspend fun deleteOldOutcomeEvent(event: OutcomeEventParams) { - withContext(ioDispatcher) { + withContext(Dispatchers.IO) { _databaseProvider.os.delete( OutcomeEventsTable.TABLE_NAME, OutcomeEventsTable.COLUMN_NAME_TIMESTAMP + " = ?", @@ -38,7 +36,7 @@ internal class OutcomeEventsRepository( * For offline mode and contingency of errors */ override suspend fun saveOutcomeEvent(eventParams: OutcomeEventParams) { - withContext(ioDispatcher) { + withContext(Dispatchers.IO) { var notificationIds = JSONArray() var iamIds = JSONArray() var notificationInfluenceType = InfluenceType.UNATTRIBUTED @@ -103,7 +101,7 @@ internal class OutcomeEventsRepository( */ override suspend fun getAllEventsToSend(): List { val events: MutableList = ArrayList() - withContext(ioDispatcher) { + withContext(Dispatchers.IO) { RemoveInvalidSessionTimeRecords.run(_databaseProvider) _databaseProvider.os.query(OutcomeEventsTable.TABLE_NAME) { cursor -> if (cursor.moveToFirst()) { @@ -250,7 +248,7 @@ internal class OutcomeEventsRepository( override suspend fun saveUniqueOutcomeEventParams(eventParams: OutcomeEventParams) { Logging.debug("OutcomeEventsCache.saveUniqueOutcomeEventParams(eventParams: $eventParams)") - withContext(ioDispatcher) { + withContext(Dispatchers.IO) { val outcomeName = eventParams.outcomeId val cachedUniqueOutcomes: MutableList = ArrayList() val directBody = eventParams.outcomeSource?.directBody @@ -285,7 +283,7 @@ internal class OutcomeEventsRepository( ): List { val uniqueInfluences: MutableList = ArrayList() - withContext(ioDispatcher) { + withContext(Dispatchers.IO) { try { for (influence in influences) { val availableInfluenceIds = JSONArray() @@ -335,7 +333,7 @@ internal class OutcomeEventsRepository( val notificationTableName = OneSignalDbContract.NotificationTable.TABLE_NAME val notificationIdColumnName = OneSignalDbContract.NotificationTable.COLUMN_NAME_NOTIFICATION_ID - withContext(ioDispatcher) { + withContext(Dispatchers.IO) { val whereStr = "NOT EXISTS(" + "SELECT NULL FROM " + notificationTableName + " n " + diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/outcomes/OutcomeEventsRepositoryTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/outcomes/OutcomeEventsRepositoryTests.kt index f1cd47491..e7fad98ac 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/outcomes/OutcomeEventsRepositoryTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/outcomes/OutcomeEventsRepositoryTests.kt @@ -4,7 +4,6 @@ import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.mocks.DatabaseMockHelper -import com.onesignal.mocks.TestDispatcherProvider import com.onesignal.session.internal.influence.Influence import com.onesignal.session.internal.influence.InfluenceChannel import com.onesignal.session.internal.influence.InfluenceType @@ -20,9 +19,6 @@ import io.kotest.matchers.shouldNotBe import io.mockk.verify import io.mockk.verifyAll import io.mockk.verifySequence -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestDispatcher -import kotlinx.coroutines.test.runTest import org.json.JSONArray @RobolectricTest @@ -31,512 +27,481 @@ class OutcomeEventsRepositoryTests : FunSpec({ Logging.logLevel = LogLevel.NONE } - // avoids initialization happening too early (before Robolectric’s environment exists). - lateinit var testDispatcher: TestDispatcher - lateinit var dispatcherProvider: TestDispatcherProvider - - beforeTest { - testDispatcher = StandardTestDispatcher() - dispatcherProvider = TestDispatcherProvider(testDispatcher) - } - test("delete outcome event should use the timestamp to delete row from database") { - runTest(dispatcherProvider.io) { - val mockDatabasePair = DatabaseMockHelper.databaseProvider(OutcomeEventsTable.TABLE_NAME) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) - - // When - outcomeEventsRepository.deleteOldOutcomeEvent(OutcomeEventParams("outcomeId", null, 0f, 0, 1111)) - - // Then - verify(exactly = 1) { - mockDatabasePair.second.delete( - OutcomeEventsTable.TABLE_NAME, - withArg { - it.contains(OutcomeEventsTable.COLUMN_NAME_TIMESTAMP) - }, - withArg { it.contains("1111") }, - ) - } + // Given + val mockDatabasePair = DatabaseMockHelper.databaseProvider(OutcomeEventsTable.TABLE_NAME) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) + + // When + outcomeEventsRepository.deleteOldOutcomeEvent(OutcomeEventParams("outcomeId", null, 0f, 0, 1111)) + + // Then + verify(exactly = 1) { + mockDatabasePair.second.delete( + OutcomeEventsTable.TABLE_NAME, + withArg { + it.contains(OutcomeEventsTable.COLUMN_NAME_TIMESTAMP) + }, + withArg { it.contains("1111") }, + ) } } test("save outcome event should insert row into database") { - runTest(dispatcherProvider.io) { - // Given - val mockDatabasePair = DatabaseMockHelper.databaseProvider(OutcomeEventsTable.TABLE_NAME) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) - - // When - outcomeEventsRepository.saveOutcomeEvent(OutcomeEventParams("outcomeId1", null, 0f, 0, 1111)) - outcomeEventsRepository.saveOutcomeEvent( - OutcomeEventParams( - "outcomeId2", - OutcomeSource( - OutcomeSourceBody(JSONArray().put("notificationId1")), - OutcomeSourceBody(null, JSONArray().put("iamId1").put("iamId2")), - ), - .2f, - 0, - 2222, + // Given + val mockDatabasePair = DatabaseMockHelper.databaseProvider(OutcomeEventsTable.TABLE_NAME) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) + + // When + outcomeEventsRepository.saveOutcomeEvent(OutcomeEventParams("outcomeId1", null, 0f, 0, 1111)) + outcomeEventsRepository.saveOutcomeEvent( + OutcomeEventParams( + "outcomeId2", + OutcomeSource( + OutcomeSourceBody(JSONArray().put("notificationId1")), + OutcomeSourceBody(null, JSONArray().put("iamId1").put("iamId2")), ), - ) - outcomeEventsRepository.saveOutcomeEvent( - OutcomeEventParams( - "outcomeId3", - OutcomeSource( - OutcomeSourceBody(JSONArray().put("notificationId1"), JSONArray().put("iamId1")), - null, - ), - .4f, - 0, - 3333, + .2f, + 0, + 2222, + ), + ) + outcomeEventsRepository.saveOutcomeEvent( + OutcomeEventParams( + "outcomeId3", + OutcomeSource( + OutcomeSourceBody(JSONArray().put("notificationId1"), JSONArray().put("iamId1")), + null, ), - ) - outcomeEventsRepository.saveOutcomeEvent( - OutcomeEventParams( - "outcomeId4", - OutcomeSource( - null, - OutcomeSourceBody(JSONArray().put("notificationId1"), JSONArray().put("iamId1").put("iamId2")), - ), - .6f, - 0, - 4444, + .4f, + 0, + 3333, + ), + ) + outcomeEventsRepository.saveOutcomeEvent( + OutcomeEventParams( + "outcomeId4", + OutcomeSource( + null, + OutcomeSourceBody(JSONArray().put("notificationId1"), JSONArray().put("iamId1").put("iamId2")), ), + .6f, + 0, + 4444, + ), + ) + + // Then + verifySequence { + mockDatabasePair.second.insert( + OutcomeEventsTable.TABLE_NAME, + null, + withArg { + it[OutcomeEventsTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[OutcomeEventsTable.COLUMN_NAME_WEIGHT] shouldBe 0f + it[OutcomeEventsTable.COLUMN_NAME_TIMESTAMP] shouldBe 1111L + it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE] shouldBe "unattributed" + it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS] shouldBe JSONArray().toString() + it[OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE] shouldBe "unattributed" + it[OutcomeEventsTable.COLUMN_NAME_IAM_IDS] shouldBe JSONArray().toString() + }, + ) + mockDatabasePair.second.insert( + OutcomeEventsTable.TABLE_NAME, + null, + withArg { + it[OutcomeEventsTable.COLUMN_NAME_NAME] shouldBe "outcomeId2" + it[OutcomeEventsTable.COLUMN_NAME_WEIGHT] shouldBe .2f + it[OutcomeEventsTable.COLUMN_NAME_TIMESTAMP] shouldBe 2222L + it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE] shouldBe "direct" + it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS] shouldBe JSONArray("[\"notificationId1\"]").toString() + it[OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE] shouldBe "indirect" + it[OutcomeEventsTable.COLUMN_NAME_IAM_IDS] shouldBe JSONArray("[\"iamId1\", \"iamId2\"]").toString() + }, + ) + mockDatabasePair.second.insert( + OutcomeEventsTable.TABLE_NAME, + null, + withArg { + it[OutcomeEventsTable.COLUMN_NAME_NAME] shouldBe "outcomeId3" + it[OutcomeEventsTable.COLUMN_NAME_WEIGHT] shouldBe .4f + it[OutcomeEventsTable.COLUMN_NAME_TIMESTAMP] shouldBe 3333L + it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE] shouldBe "direct" + it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS] shouldBe JSONArray("[\"notificationId1\"]").toString() + it[OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE] shouldBe "direct" + it[OutcomeEventsTable.COLUMN_NAME_IAM_IDS] shouldBe JSONArray("[\"iamId1\"]").toString() + }, + ) + mockDatabasePair.second.insert( + OutcomeEventsTable.TABLE_NAME, + null, + withArg { + it[OutcomeEventsTable.COLUMN_NAME_NAME] shouldBe "outcomeId4" + it[OutcomeEventsTable.COLUMN_NAME_WEIGHT] shouldBe .6f + it[OutcomeEventsTable.COLUMN_NAME_TIMESTAMP] shouldBe 4444L + it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE] shouldBe "indirect" + it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS] shouldBe JSONArray("[\"notificationId1\"]").toString() + it[OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE] shouldBe "indirect" + it[OutcomeEventsTable.COLUMN_NAME_IAM_IDS] shouldBe JSONArray("[\"iamId1\", \"iamId2\"]").toString() + }, ) - - // Then - verifySequence { - mockDatabasePair.second.insert( - OutcomeEventsTable.TABLE_NAME, - null, - withArg { - it[OutcomeEventsTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[OutcomeEventsTable.COLUMN_NAME_WEIGHT] shouldBe 0f - it[OutcomeEventsTable.COLUMN_NAME_TIMESTAMP] shouldBe 1111L - it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE] shouldBe "unattributed" - it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS] shouldBe JSONArray().toString() - it[OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE] shouldBe "unattributed" - it[OutcomeEventsTable.COLUMN_NAME_IAM_IDS] shouldBe JSONArray().toString() - }, - ) - mockDatabasePair.second.insert( - OutcomeEventsTable.TABLE_NAME, - null, - withArg { - it[OutcomeEventsTable.COLUMN_NAME_NAME] shouldBe "outcomeId2" - it[OutcomeEventsTable.COLUMN_NAME_WEIGHT] shouldBe .2f - it[OutcomeEventsTable.COLUMN_NAME_TIMESTAMP] shouldBe 2222L - it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE] shouldBe "direct" - it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS] shouldBe JSONArray("[\"notificationId1\"]").toString() - it[OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE] shouldBe "indirect" - it[OutcomeEventsTable.COLUMN_NAME_IAM_IDS] shouldBe JSONArray("[\"iamId1\", \"iamId2\"]").toString() - }, - ) - mockDatabasePair.second.insert( - OutcomeEventsTable.TABLE_NAME, - null, - withArg { - it[OutcomeEventsTable.COLUMN_NAME_NAME] shouldBe "outcomeId3" - it[OutcomeEventsTable.COLUMN_NAME_WEIGHT] shouldBe .4f - it[OutcomeEventsTable.COLUMN_NAME_TIMESTAMP] shouldBe 3333L - it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE] shouldBe "direct" - it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS] shouldBe JSONArray("[\"notificationId1\"]").toString() - it[OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE] shouldBe "direct" - it[OutcomeEventsTable.COLUMN_NAME_IAM_IDS] shouldBe JSONArray("[\"iamId1\"]").toString() - }, - ) - mockDatabasePair.second.insert( - OutcomeEventsTable.TABLE_NAME, - null, - withArg { - it[OutcomeEventsTable.COLUMN_NAME_NAME] shouldBe "outcomeId4" - it[OutcomeEventsTable.COLUMN_NAME_WEIGHT] shouldBe .6f - it[OutcomeEventsTable.COLUMN_NAME_TIMESTAMP] shouldBe 4444L - it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE] shouldBe "indirect" - it[OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS] shouldBe JSONArray("[\"notificationId1\"]").toString() - it[OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE] shouldBe "indirect" - it[OutcomeEventsTable.COLUMN_NAME_IAM_IDS] shouldBe JSONArray("[\"iamId1\", \"iamId2\"]").toString() - }, - ) - } } } test("get events should retrieve return empty list when database is empty") { - runTest(dispatcherProvider.io) { - // Given - val mockDatabasePair = DatabaseMockHelper.databaseProvider(OutcomeEventsTable.TABLE_NAME) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) + // Given + val mockDatabasePair = DatabaseMockHelper.databaseProvider(OutcomeEventsTable.TABLE_NAME) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) - // When - val events = outcomeEventsRepository.getAllEventsToSend() + // When + val events = outcomeEventsRepository.getAllEventsToSend() - // Then - events.count() shouldBe 0 - } + // Then + events.count() shouldBe 0 } test("get events should retrieve return an item per row in database") { - runTest(dispatcherProvider.io) { - // Given - val records = - listOf( - mapOf( - OutcomeEventsTable.COLUMN_NAME_NAME to "outcomeId1", - OutcomeEventsTable.COLUMN_NAME_WEIGHT to 0.2f, - OutcomeEventsTable.COLUMN_NAME_TIMESTAMP to 1111L, - OutcomeEventsTable.COLUMN_NAME_SESSION_TIME to 1L, - OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE to "unattributed", - OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE to "unattributed", - ), - mapOf( - OutcomeEventsTable.COLUMN_NAME_NAME to "outcomeId2", - OutcomeEventsTable.COLUMN_NAME_WEIGHT to 0.4f, - OutcomeEventsTable.COLUMN_NAME_TIMESTAMP to 2222L, - OutcomeEventsTable.COLUMN_NAME_SESSION_TIME to 2L, - OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE to "indirect", - OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS to "[\"notificationId1\",\"notificationId2\"]", - OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE to "indirect", - OutcomeEventsTable.COLUMN_NAME_IAM_IDS to "[\"iamId1\",\"iamId2\"]", - ), - mapOf( - OutcomeEventsTable.COLUMN_NAME_NAME to "outcomeId3", - OutcomeEventsTable.COLUMN_NAME_WEIGHT to 0.6f, - OutcomeEventsTable.COLUMN_NAME_TIMESTAMP to 3333L, - OutcomeEventsTable.COLUMN_NAME_SESSION_TIME to 3L, - OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE to "direct", - OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS to "[\"notificationId3\"]", - OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE to "direct", - OutcomeEventsTable.COLUMN_NAME_IAM_IDS to "[\"iamId3\"]", - ), - ) - val mockDatabasePair = DatabaseMockHelper.databaseProvider(OutcomeEventsTable.TABLE_NAME, records) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) - - // When - val events = outcomeEventsRepository.getAllEventsToSend() - - // Then - events.count() shouldBe 3 - events[0].outcomeId shouldBe "outcomeId1" - events[0].weight shouldBe 0.2f - events[0].timestamp shouldBe 1111L - events[0].sessionTime shouldBe 1L - events[0].outcomeSource shouldNotBe null - events[0].outcomeSource!!.directBody shouldBe null - events[0].outcomeSource!!.indirectBody shouldBe null - events[1].outcomeId shouldBe "outcomeId2" - events[1].weight shouldBe 0.4f - events[1].timestamp shouldBe 2222L - events[1].sessionTime shouldBe 2L - events[1].outcomeSource shouldNotBe null - events[1].outcomeSource!!.directBody shouldBe null - events[1].outcomeSource!!.indirectBody shouldNotBe null - events[1].outcomeSource!!.indirectBody!!.notificationIds!!.length() shouldBe 2 - events[1].outcomeSource!!.indirectBody!!.notificationIds!!.getString(0) shouldBe "notificationId1" - events[1].outcomeSource!!.indirectBody!!.notificationIds!!.getString(1) shouldBe "notificationId2" - events[1].outcomeSource!!.indirectBody!!.inAppMessagesIds!!.length() shouldBe 2 - events[1].outcomeSource!!.indirectBody!!.inAppMessagesIds!!.getString(0) shouldBe "iamId1" - events[1].outcomeSource!!.indirectBody!!.inAppMessagesIds!!.getString(1) shouldBe "iamId2" - events[2].outcomeId shouldBe "outcomeId3" - events[2].weight shouldBe 0.6f - events[2].timestamp shouldBe 3333L - events[2].sessionTime shouldBe 3L - events[2].outcomeSource shouldNotBe null - events[2].outcomeSource!!.indirectBody shouldBe null - events[2].outcomeSource!!.directBody shouldNotBe null - events[2].outcomeSource!!.directBody!!.notificationIds!!.length() shouldBe 1 - events[2].outcomeSource!!.directBody!!.notificationIds!!.getString(0) shouldBe "notificationId3" - events[2].outcomeSource!!.directBody!!.inAppMessagesIds!!.length() shouldBe 1 - events[2].outcomeSource!!.directBody!!.inAppMessagesIds!!.getString(0) shouldBe "iamId3" - } + // Given + val records = + listOf( + mapOf( + OutcomeEventsTable.COLUMN_NAME_NAME to "outcomeId1", + OutcomeEventsTable.COLUMN_NAME_WEIGHT to 0.2f, + OutcomeEventsTable.COLUMN_NAME_TIMESTAMP to 1111L, + OutcomeEventsTable.COLUMN_NAME_SESSION_TIME to 1L, + OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE to "unattributed", + OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE to "unattributed", + ), + mapOf( + OutcomeEventsTable.COLUMN_NAME_NAME to "outcomeId2", + OutcomeEventsTable.COLUMN_NAME_WEIGHT to 0.4f, + OutcomeEventsTable.COLUMN_NAME_TIMESTAMP to 2222L, + OutcomeEventsTable.COLUMN_NAME_SESSION_TIME to 2L, + OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE to "indirect", + OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS to "[\"notificationId1\",\"notificationId2\"]", + OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE to "indirect", + OutcomeEventsTable.COLUMN_NAME_IAM_IDS to "[\"iamId1\",\"iamId2\"]", + ), + mapOf( + OutcomeEventsTable.COLUMN_NAME_NAME to "outcomeId3", + OutcomeEventsTable.COLUMN_NAME_WEIGHT to 0.6f, + OutcomeEventsTable.COLUMN_NAME_TIMESTAMP to 3333L, + OutcomeEventsTable.COLUMN_NAME_SESSION_TIME to 3L, + OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_INFLUENCE_TYPE to "direct", + OutcomeEventsTable.COLUMN_NAME_NOTIFICATION_IDS to "[\"notificationId3\"]", + OutcomeEventsTable.COLUMN_NAME_IAM_INFLUENCE_TYPE to "direct", + OutcomeEventsTable.COLUMN_NAME_IAM_IDS to "[\"iamId3\"]", + ), + ) + val mockDatabasePair = DatabaseMockHelper.databaseProvider(OutcomeEventsTable.TABLE_NAME, records) + + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) + + // When + val events = outcomeEventsRepository.getAllEventsToSend() + + // Then + events.count() shouldBe 3 + events[0].outcomeId shouldBe "outcomeId1" + events[0].weight shouldBe 0.2f + events[0].timestamp shouldBe 1111L + events[0].sessionTime shouldBe 1L + events[0].outcomeSource shouldNotBe null + events[0].outcomeSource!!.directBody shouldBe null + events[0].outcomeSource!!.indirectBody shouldBe null + events[1].outcomeId shouldBe "outcomeId2" + events[1].weight shouldBe 0.4f + events[1].timestamp shouldBe 2222L + events[1].sessionTime shouldBe 2L + events[1].outcomeSource shouldNotBe null + events[1].outcomeSource!!.directBody shouldBe null + events[1].outcomeSource!!.indirectBody shouldNotBe null + events[1].outcomeSource!!.indirectBody!!.notificationIds!!.length() shouldBe 2 + events[1].outcomeSource!!.indirectBody!!.notificationIds!!.getString(0) shouldBe "notificationId1" + events[1].outcomeSource!!.indirectBody!!.notificationIds!!.getString(1) shouldBe "notificationId2" + events[1].outcomeSource!!.indirectBody!!.inAppMessagesIds!!.length() shouldBe 2 + events[1].outcomeSource!!.indirectBody!!.inAppMessagesIds!!.getString(0) shouldBe "iamId1" + events[1].outcomeSource!!.indirectBody!!.inAppMessagesIds!!.getString(1) shouldBe "iamId2" + events[2].outcomeId shouldBe "outcomeId3" + events[2].weight shouldBe 0.6f + events[2].timestamp shouldBe 3333L + events[2].sessionTime shouldBe 3L + events[2].outcomeSource shouldNotBe null + events[2].outcomeSource!!.indirectBody shouldBe null + events[2].outcomeSource!!.directBody shouldNotBe null + events[2].outcomeSource!!.directBody!!.notificationIds!!.length() shouldBe 1 + events[2].outcomeSource!!.directBody!!.notificationIds!!.getString(0) shouldBe "notificationId3" + events[2].outcomeSource!!.directBody!!.inAppMessagesIds!!.length() shouldBe 1 + events[2].outcomeSource!!.directBody!!.inAppMessagesIds!!.getString(0) shouldBe "iamId3" } test("save unique outcome should insert no rows when no influences") { - runTest(dispatcherProvider.io) { - // Given - val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.COLUMN_NAME_NAME) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) + // Given + val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.COLUMN_NAME_NAME) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) - // When - outcomeEventsRepository.saveUniqueOutcomeEventParams(OutcomeEventParams("outcomeId1", null, 0f, 0, 1111)) + // When + outcomeEventsRepository.saveUniqueOutcomeEventParams(OutcomeEventParams("outcomeId1", null, 0f, 0, 1111)) - // Then - verify(exactly = 0) { mockDatabasePair.second.insert(CachedUniqueOutcomeTable.COLUMN_NAME_NAME, null, any()) } - } + // Then + verify(exactly = 0) { mockDatabasePair.second.insert(CachedUniqueOutcomeTable.COLUMN_NAME_NAME, null, any()) } } test("save unique outcome should insert 1 row for each unique influence when direct notification and indiract iam") { - runTest(dispatcherProvider.io) { - // Given - val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) - - // When - outcomeEventsRepository.saveUniqueOutcomeEventParams( - OutcomeEventParams( - "outcomeId1", - OutcomeSource( - OutcomeSourceBody(JSONArray().put("notificationId1")), - OutcomeSourceBody(null, JSONArray().put("iamId1").put("iamId2")), - ), - .2f, - 0, - 2222, + // Given + val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) + + // When + outcomeEventsRepository.saveUniqueOutcomeEventParams( + OutcomeEventParams( + "outcomeId1", + OutcomeSource( + OutcomeSourceBody(JSONArray().put("notificationId1")), + OutcomeSourceBody(null, JSONArray().put("iamId1").put("iamId2")), ), + .2f, + 0, + 2222, + ), + ) + + // Then + verifyAll { + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId1" + }, + ) + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId1" + }, + ) + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId2" + }, ) - - // Then - verifyAll { - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId1" - }, - ) - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId1" - }, - ) - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId2" - }, - ) - } } } test("save unique outcome should insert 1 row for each unique influence when direct iam and indiract notifications") { - runTest(dispatcherProvider.io) { - // Given - val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) - - // When - outcomeEventsRepository.saveUniqueOutcomeEventParams( - OutcomeEventParams( - "outcomeId1", - OutcomeSource( - OutcomeSourceBody(null, JSONArray().put("iamId1")), - OutcomeSourceBody(JSONArray().put("notificationId1").put("notificationId2")), - ), - .2f, - 0, - 2222, + // Given + val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) + + // When + outcomeEventsRepository.saveUniqueOutcomeEventParams( + OutcomeEventParams( + "outcomeId1", + OutcomeSource( + OutcomeSourceBody(null, JSONArray().put("iamId1")), + OutcomeSourceBody(JSONArray().put("notificationId1").put("notificationId2")), ), + .2f, + 0, + 2222, + ), + ) + + // Then + verifyAll { + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId1" + }, + ) + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId2" + }, + ) + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId1" + }, ) - - // Then - verifyAll { - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId1" - }, - ) - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId2" - }, - ) - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId1" - }, - ) - } } } test("save unique outcome should insert 1 row for each unique influence when direct notification and iam") { - runTest(dispatcherProvider.io) { - // Given - val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) - - // When - outcomeEventsRepository.saveUniqueOutcomeEventParams( - OutcomeEventParams( - "outcomeId1", - OutcomeSource( - OutcomeSourceBody(JSONArray().put("notificationId1"), JSONArray().put("iamId1")), - null, - ), - .2f, - 0, - 2222, + // Given + val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) + + // When + outcomeEventsRepository.saveUniqueOutcomeEventParams( + OutcomeEventParams( + "outcomeId1", + OutcomeSource( + OutcomeSourceBody(JSONArray().put("notificationId1"), JSONArray().put("iamId1")), + null, ), + .2f, + 0, + 2222, + ), + ) + + // Then + verifyAll { + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId1" + }, + ) + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId1" + }, ) - - // Then - verifyAll { - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId1" - }, - ) - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId1" - }, - ) - } } } test("save unique outcome should insert 1 row for each unique influence when indirect notification and iam") { - runTest(dispatcherProvider.io) { - // Given - val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) - - // When - outcomeEventsRepository.saveUniqueOutcomeEventParams( - OutcomeEventParams( - "outcomeId1", - OutcomeSource( - null, - OutcomeSourceBody(JSONArray().put("notificationId1").put("notificationId2"), JSONArray().put("iamId1").put("iamId2")), - ), - .2f, - 0, - 2222, + // Given + val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) + + // When + outcomeEventsRepository.saveUniqueOutcomeEventParams( + OutcomeEventParams( + "outcomeId1", + OutcomeSource( + null, + OutcomeSourceBody(JSONArray().put("notificationId1").put("notificationId2"), JSONArray().put("iamId1").put("iamId2")), ), + .2f, + 0, + 2222, + ), + ) + + // Then + verifyAll { + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId1" + }, + ) + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId2" + }, + ) + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId1" + }, + ) + mockDatabasePair.second.insert( + CachedUniqueOutcomeTable.TABLE_NAME, + null, + withArg { + it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" + it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId2" + }, ) - - // Then - verifyAll { - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId1" - }, - ) - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "notification" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "notificationId2" - }, - ) - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId1" - }, - ) - mockDatabasePair.second.insert( - CachedUniqueOutcomeTable.TABLE_NAME, - null, - withArg { - it[CachedUniqueOutcomeTable.COLUMN_NAME_NAME] shouldBe "outcomeId1" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_TYPE] shouldBe "iam" - it[CachedUniqueOutcomeTable.COLUMN_CHANNEL_INFLUENCE_ID] shouldBe "iamId2" - }, - ) - } } } test("retrieve non-cached influence should return full list when there are no cached unique influences") { - runTest(dispatcherProvider.io) { - // Given - val records = listOf>() - val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME, records) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) - - // When - val influences = - outcomeEventsRepository.getNotCachedUniqueInfluencesForOutcome( - "outcomeId1", - listOf( - Influence(InfluenceChannel.NOTIFICATION, InfluenceType.DIRECT, JSONArray().put("notificationId1")), - Influence(InfluenceChannel.NOTIFICATION, InfluenceType.DIRECT, JSONArray().put("notificationId2")), - ), - ) - - // Then - influences.count() shouldBe 2 - } + // Given + val records = listOf>() + val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME, records) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) + + // When + val influences = + outcomeEventsRepository.getNotCachedUniqueInfluencesForOutcome( + "outcomeId1", + listOf( + Influence(InfluenceChannel.NOTIFICATION, InfluenceType.DIRECT, JSONArray().put("notificationId1")), + Influence(InfluenceChannel.NOTIFICATION, InfluenceType.DIRECT, JSONArray().put("notificationId2")), + ), + ) + + // Then + influences.count() shouldBe 2 } test("retrieve non-cached influence should filter out an influence when there are is a matching influence") { - runTest(dispatcherProvider.io) { - // Given - val records = listOf(mapOf(CachedUniqueOutcomeTable.COLUMN_NAME_NAME to "outcomeId1")) - val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME, records) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) - - // When - val influences = - outcomeEventsRepository.getNotCachedUniqueInfluencesForOutcome( - "outcomeId1", - listOf( - Influence(InfluenceChannel.NOTIFICATION, InfluenceType.DIRECT, JSONArray().put("notificationId1")), - Influence(InfluenceChannel.NOTIFICATION, InfluenceType.DIRECT, JSONArray().put("notificationId2")), - ), - ) - - // Then - influences.count() shouldBe 0 - } + // Given + val records = listOf(mapOf(CachedUniqueOutcomeTable.COLUMN_NAME_NAME to "outcomeId1")) + val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME, records) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) + + // When + val influences = + outcomeEventsRepository.getNotCachedUniqueInfluencesForOutcome( + "outcomeId1", + listOf( + Influence(InfluenceChannel.NOTIFICATION, InfluenceType.DIRECT, JSONArray().put("notificationId1")), + Influence(InfluenceChannel.NOTIFICATION, InfluenceType.DIRECT, JSONArray().put("notificationId2")), + ), + ) + + // Then + influences.count() shouldBe 0 } test("clear unique influence should delete out an influence when there are is a matching influence") { - runTest(dispatcherProvider.io) { - // Given - val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME) - val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first, testDispatcher) - - // When - outcomeEventsRepository.cleanCachedUniqueOutcomeEventNotifications() - - // Then - verifyAll { - mockDatabasePair.second.delete(CachedUniqueOutcomeTable.TABLE_NAME, any(), any()) - } + // Given + val mockDatabasePair = DatabaseMockHelper.databaseProvider(CachedUniqueOutcomeTable.TABLE_NAME) + val outcomeEventsRepository = OutcomeEventsRepository(mockDatabasePair.first) + + // When + outcomeEventsRepository.cleanCachedUniqueOutcomeEventNotifications() + + // Then + verifyAll { + mockDatabasePair.second.delete(CachedUniqueOutcomeTable.TABLE_NAME, any(), any()) } } }) diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/data/impl/NotificationRepository.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/data/impl/NotificationRepository.kt index d1ad883e1..66c750e3c 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/data/impl/NotificationRepository.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/data/impl/NotificationRepository.kt @@ -4,8 +4,6 @@ import android.app.NotificationManager import android.content.ContentValues import android.provider.BaseColumns import android.text.TextUtils -import com.onesignal.common.threading.CoroutineDispatcherProvider -import com.onesignal.common.threading.DefaultDispatcherProvider import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.database.IDatabaseProvider import com.onesignal.core.internal.database.impl.OneSignalDbContract @@ -16,6 +14,7 @@ import com.onesignal.notifications.internal.common.NotificationHelper import com.onesignal.notifications.internal.data.INotificationQueryHelper import com.onesignal.notifications.internal.data.INotificationRepository import com.onesignal.notifications.internal.limiting.INotificationLimitManager +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONException @@ -25,7 +24,6 @@ internal class NotificationRepository( private val _databaseProvider: IDatabaseProvider, private val _time: ITime, private val _badgeCountUpdater: IBadgeCountUpdater, - private val dispatchers: CoroutineDispatcherProvider = DefaultDispatcherProvider(), ) : INotificationRepository { /** * Deletes notifications with created timestamps older than 7 days @@ -33,7 +31,7 @@ internal class NotificationRepository( * 1. NotificationTable.TABLE_NAME */ override suspend fun deleteExpiredNotifications() { - withContext(dispatchers.io) { + withContext(Dispatchers.IO) { val whereStr: String = OneSignalDbContract.NotificationTable.COLUMN_NAME_CREATED_TIME.toString() + " < ?" val sevenDaysAgoInSeconds: String = java.lang.String.valueOf( @@ -50,7 +48,7 @@ internal class NotificationRepository( } override suspend fun markAsDismissedForOutstanding() { - withContext(dispatchers.io) { + withContext(Dispatchers.IO) { val appContext = _applicationService.appContext val notificationManager: NotificationManager = NotificationHelper.getNotificationManager(appContext) val retColumn = arrayOf(OneSignalDbContract.NotificationTable.COLUMN_NAME_ANDROID_NOTIFICATION_ID) @@ -81,7 +79,7 @@ internal class NotificationRepository( } override suspend fun markAsDismissedForGroup(group: String) { - withContext(dispatchers.io) { + withContext(Dispatchers.IO) { val appContext = _applicationService.appContext val notificationManager: NotificationManager = NotificationHelper.getNotificationManager(appContext) @@ -126,7 +124,7 @@ internal class NotificationRepository( override suspend fun markAsDismissed(androidId: Int): Boolean { var didDismiss: Boolean = false - withContext(dispatchers.io) { + withContext(Dispatchers.IO) { didDismiss = internalMarkAsDismissed(androidId) } @@ -161,7 +159,7 @@ internal class NotificationRepository( var result = false - withContext(dispatchers.io) { + withContext(Dispatchers.IO) { val retColumn = arrayOf(OneSignalDbContract.NotificationTable.COLUMN_NAME_NOTIFICATION_ID) val whereArgs = arrayOf(id!!) @@ -190,7 +188,7 @@ internal class NotificationRepository( androidId: Int, groupId: String, ) { - withContext(dispatchers.io) { + withContext(Dispatchers.IO) { // There currently isn't a visible notification from for this group_id. // Save the group summary notification id so it can be updated later. val values = ContentValues() @@ -220,7 +218,7 @@ internal class NotificationRepository( expireTime: Long, jsonPayload: String, ) { - withContext(dispatchers.io) { + withContext(Dispatchers.IO) { Logging.debug("Saving Notification id=$id") try { @@ -304,7 +302,7 @@ internal class NotificationRepository( summaryGroup: String?, clearGroupOnSummaryClick: Boolean, ) { - withContext(dispatchers.io) { + withContext(Dispatchers.IO) { var whereStr: String var whereArgs: Array? = null if (summaryGroup != null) { @@ -360,7 +358,7 @@ internal class NotificationRepository( override suspend fun getGroupId(androidId: Int): String? { var groupId: String? = null - withContext(dispatchers.io) { + withContext(Dispatchers.IO) { _databaseProvider.os.query( OneSignalDbContract.NotificationTable.TABLE_NAME, // retColumn @@ -380,7 +378,7 @@ internal class NotificationRepository( override suspend fun getAndroidIdFromCollapseKey(collapseKey: String): Int? { var androidId: Int? = null - withContext(dispatchers.io) { + withContext(Dispatchers.IO) { _databaseProvider.os.query( OneSignalDbContract.NotificationTable.TABLE_NAME, // retColumn @@ -404,7 +402,7 @@ internal class NotificationRepository( notificationsToMakeRoomFor: Int, maxNumberOfNotificationsInt: Int, ) { - withContext(dispatchers.io) { + withContext(Dispatchers.IO) { val maxNumberOfNotificationsString = maxNumberOfNotificationsInt.toString() try { @@ -439,7 +437,7 @@ internal class NotificationRepository( override suspend fun listNotificationsForGroup(summaryGroup: String): List { val listOfNotifications = mutableListOf() - withContext(dispatchers.io) { + withContext(Dispatchers.IO) { val whereArgs = arrayOf(summaryGroup) _databaseProvider.os.query( @@ -514,7 +512,7 @@ internal class NotificationRepository( val whereArgs = if (isGroupless) null else arrayOf(group) - withContext(dispatchers.io) { + withContext(Dispatchers.IO) { // Order by timestamp in descending and limit to 1 _databaseProvider.os.query( OneSignalDbContract.NotificationTable.TABLE_NAME, @@ -540,7 +538,7 @@ internal class NotificationRepository( override suspend fun listNotificationsForOutstanding(excludeAndroidIds: List?): List { val listOfNotifications = mutableListOf() - withContext(dispatchers.io) { + withContext(Dispatchers.IO) { val dbQuerySelection = _queryHelper.recentUninteractedWithNotificationsWhere() if (excludeAndroidIds != null) { diff --git a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/TestDispatcherProvider.kt b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/TestDispatcherProvider.kt deleted file mode 100644 index 2288659dc..000000000 --- a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/TestDispatcherProvider.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.onesignal.mocks - -import com.onesignal.common.threading.CoroutineDispatcherProvider -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestDispatcher - -/** - * Test implementation of [CoroutineDispatcherProvider] for unit tests. - * Uses a [TestDispatcher] for deterministic testing. - * - * Usage in tests: - * ``` - * test("my test") { - * val testDispatcher = StandardTestDispatcher() - * val dispatcherProvider = TestDispatcherProvider(testDispatcher) - * - * runTest(testDispatcher.scheduler) { - * val service = MyService(dispatcherProvider) - * service.doWork() - * - * // Option 1: Advance until all pending coroutines complete - * advanceUntilIdle() - * - * // Option 2: Advance virtual time by a specific amount (e.g., 100ms) - * // advanceTimeBy(100) - * - * // Option 3: Run only coroutines scheduled at current time - * // runCurrent() - * - * // Make assertions - * } - * } - * ``` - * - * Methods to control execution: - * - [advanceUntilIdle()] - Runs all pending coroutines until there's nothing left to execute - * - [advanceTimeBy(delayTime)] - Advances virtual time by the specified amount and runs - * coroutines scheduled for that time period - * - [runCurrent()] - Runs only the coroutines that are scheduled to run at the current - * virtual time (doesn't advance time) - * - [currentTime] - Property to check the current virtual time - */ -class TestDispatcherProvider( - private val testDispatcher: TestDispatcher = StandardTestDispatcher() -) : CoroutineDispatcherProvider { - override val io: CoroutineDispatcher = testDispatcher - override val default: CoroutineDispatcher = testDispatcher - - private val scope: CoroutineScope by lazy { - CoroutineScope(SupervisorJob() + testDispatcher) - } - - override fun launchOnIO(block: suspend () -> Unit): Job { - return scope.launch { block() } - } - - override fun launchOnDefault(block: suspend () -> Unit): Job { - return scope.launch { block() } - } -} From e62629e8be97ac9763ca7a94fa272ebdb7f16b60 Mon Sep 17 00:00:00 2001 From: jinliu Date: Tue, 27 Jan 2026 11:51:20 -0500 Subject: [PATCH 7/8] fix: end initialization early if device storage is locked (#2520) --- .../java/com/onesignal/common/AndroidUtils.kt | 22 +++++++++++++++++++ .../com/onesignal/internal/OneSignalImp.kt | 8 +++++++ 2 files changed, 30 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 b8a49e955..1e9114adb 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 eaa00225e..1ccf96809 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 @@ -192,6 +192,14 @@ internal class OneSignalImp : IOneSignal, IServiceProvider { return true } + // 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!") + return false + } + Logging.log(LogLevel.DEBUG, "initWithContext: SDK initializing") PreferenceStoreFix.ensureNoObfuscatedPrefStore(context) From 273a0f362169cce72bae0902c46b3228df4f902b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 27 Jan 2026 20:40:49 +0000 Subject: [PATCH 8/8] chore: bump SDK_VERSION to 5.6.0 --- Examples/OneSignalDemo/gradle.properties | 2 +- OneSignalSDK/gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Examples/OneSignalDemo/gradle.properties b/Examples/OneSignalDemo/gradle.properties index 65a697421..6e6a36fd1 100644 --- a/Examples/OneSignalDemo/gradle.properties +++ b/Examples/OneSignalDemo/gradle.properties @@ -17,4 +17,4 @@ android.enableJetifier=false # 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.4.2 +SDK_VERSION=5.6.0 diff --git a/OneSignalSDK/gradle.properties b/OneSignalSDK/gradle.properties index 489dd5552..a04008abe 100644 --- a/OneSignalSDK/gradle.properties +++ b/OneSignalSDK/gradle.properties @@ -33,4 +33,4 @@ android.useAndroidX = 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.4.2 +SDK_VERSION=5.6.0