diff --git a/examples/build.md b/examples/build.md index 8cf3617dd..e70dd8ace 100644 --- a/examples/build.md +++ b/examples/build.md @@ -1,846 +1,149 @@ -# OneSignal Sample App - Build Guide +# OneSignal Android Sample App - Build Guide -This document contains all the prompts and requirements needed to build the OneSignal Sample App V2 from scratch. Give these prompts to an AI assistant or follow them manually to recreate the app. +This document extends the shared build guide with Android-specific details. ---- - -## Phase 1: Initial Setup - -### Prompt 1.1 - Project Foundation - -``` -Build a sample Android app with: -- MVVM architecture with Jetpack Compose UI -- Kotlin Coroutines for background threading (Dispatchers.IO, Dispatchers.Main) -- Gradle Kotlin DSL with inline dependency versions (no buildSrc, so it works when included from the SDK project) -- Support for Google FCM and Huawei HMS product flavors (matching existing OneSignalDemo setup) -- Package name: com.onesignal.sdktest (must match google-services.json and agconnect-services.json) -- All dialogs should have EMPTY input fields (for Appium testing - test framework enters values) -- Material3 theming with OneSignal brand colors -- App name (in strings.xml): "OneSignal Demo" -- Top app bar: use CenterAlignedTopAppBar (Material3) with the OneSignal logo + "Sample App" text, centered horizontally -``` - -### Prompt 1.2 - OneSignal Code Organization - -``` -Centralize all OneSignal SDK calls in a single OneSignalRepository.kt class: - -User operations: -- loginUser(externalUserId: String) -- logoutUser() - -Alias operations: -- addAlias(label: String, id: String) -- addAliases(aliases: Map) // Batch add - -Email operations: -- addEmail(email: String) -- removeEmail(email: String) - -SMS operations: -- addSms(smsNumber: String) -- removeSms(smsNumber: String) - -Tag operations: -- addTag(key: String, value: String) -- addTags(tags: Map) // Batch add -- removeTag(key: String) -- removeTags(keys: Collection) // Batch remove -- getTags(): Map - -Trigger operations: -- addTrigger(key: String, value: String) -- addTriggers(triggers: Map) // Batch add -- removeTrigger(key: String) -- clearTriggers(keys: Collection) - -Outcome operations: -- sendOutcome(name: String) -- sendUniqueOutcome(name: String) -- sendOutcomeWithValue(name: String, value: Float) - -Track Event: -- trackEvent(name: String, properties: Map?) // Properties as parsed JSON map - -Push subscription: -- getPushSubscriptionId(): String? -- isPushEnabled(): Boolean -- setPushEnabled(enabled: Boolean) - -In-App Messages: -- setInAppMessagesPaused(paused: Boolean) -- isInAppMessagesPaused(): Boolean - -Location: -- setLocationShared(shared: Boolean) -- isLocationShared(): Boolean -- promptLocation() - -Privacy consent: -- setConsentRequired(required: Boolean) -- getConsentRequired(): Boolean -- setPrivacyConsent(granted: Boolean) -- getPrivacyConsent(): Boolean - -Notification sending (via REST API, delegated to OneSignalService): -- sendNotification(type: NotificationType): Boolean -- sendCustomNotification(title: String, body: String): Boolean -- fetchUser(onesignalId: String): UserData? -``` +**Read the shared guide first:** +https://raw.githubusercontent.com/OneSignal/sdk-shared/refs/heads/main/demo/build.md -### Prompt 1.3 - OneSignalService (REST API Client) - -``` -Create OneSignalService.kt object for REST API calls: - -Properties: -- appId: String (set from MainApplication) - -Methods: -- setAppId(appId: String) -- getAppId(): String -- sendNotification(type: NotificationType): Boolean -- sendCustomNotification(title: String, body: String): Boolean -- fetchUser(onesignalId: String): UserData? - -sendNotification endpoint: -- POST https://onesignal.com/api/v1/notifications -- Accept header: "application/vnd.onesignal.v1+json" -- Uses include_subscription_ids (not include_player_ids) -- Includes big_picture for image notifications - -fetchUser endpoint: -- GET https://api.onesignal.com/apps/{app_id}/users/by/onesignal_id/{onesignal_id} -- NO Authorization header needed (public endpoint) -- Returns UserData with aliases, tags, emails, smsNumbers, externalId -``` - -### Prompt 1.4 - SDK Observers - -``` -In MainApplication.kt, set up OneSignal listeners: -- IInAppMessageLifecycleListener (onWillDisplay, onDidDisplay, onWillDismiss, onDidDismiss) -- IInAppMessageClickListener -- INotificationClickListener -- INotificationLifecycleListener (with preventDefault() for async display testing) -- IUserStateObserver (log when user state changes) -- After registering listeners, restore cached SDK states from SharedPreferences: - - OneSignal.InAppMessages.paused = cached paused status - - OneSignal.Location.isShared = cached location shared status - -In MainViewModel.kt, implement observers: -- IPushSubscriptionObserver - react to push subscription changes -- IPermissionObserver - react to notification permission changes -- IUserStateObserver - call fetchUserDataFromApi() when user changes (login/logout) -``` +Replace `{{PLATFORM}}` with `Android` everywhere in that guide. Everything below either overrides or supplements sections from the shared guide. --- -## Phase 2: UI Sections - -### Section Order (top to bottom) - FINAL - -1. **App Section** (App ID, Guidance Banner, Consent Toggle, Logged-in-as display, Login/Logout) -2. **Push Section** (Push ID, Enabled Toggle, Auto-prompts permission on load) -3. **Send Push Notification Section** (Simple, With Image, Custom buttons) -4. **In-App Messaging Section** (Pause toggle) -5. **Send In-App Message Section** (Top Banner, Bottom Banner, Center Modal, Full Screen - with icons) -6. **Aliases Section** (Add/Add Multiple, read-only list) -7. **Emails Section** (Collapsible list >5 items) -8. **SMS Section** (Collapsible list >5 items) -9. **Tags Section** (Add/Add Multiple/Remove Selected) -10. **Outcome Events Section** (Send Outcome dialog with type selection) -11. **Triggers Section** (Add/Add Multiple/Remove Selected/Clear All - IN MEMORY ONLY) -12. **Track Event Section** (Track Event with JSON validation) -13. **Location Section** (Location Shared toggle, Prompt Location button) -14. **Next Activity Button** +## Project Foundation -### Prompt 2.1 - App Section - -``` -App Section layout: - -1. App ID display (readonly Text showing the OneSignal App ID) - -2. Sticky guidance banner below App ID: - - Text: "Add your own App ID, then rebuild to fully test all functionality." - - Link text: "Get your keys at onesignal.com" (clickable, opens browser) - - Light background color to stand out - -3. Consent card with up to two toggles: - a. "Consent Required" toggle (always visible): - - Label: "Consent Required" - - Description: "Require consent before SDK processes data" - - Sets OneSignal.consentRequired - b. "Privacy Consent" toggle (only visible when Consent Required is ON): - - Label: "Privacy Consent" - - Description: "Consent given for data collection" - - Sets OneSignal.consentGiven - - Separated from the above toggle by a horizontal divider - - NOT a blocking overlay - user can interact with app regardless of state - -4. User status card (always visible, ABOVE the login/logout buttons): - - Card with two rows separated by a divider - - Row 1: "Status" label on the left, value on the right - - Row 2: "External ID" label on the left, value on the right - - When logged out: - - Status shows "Anonymous" - - External ID shows "–" (dash) - - When logged in: - - Status shows "Logged In" with green styling (Color(0xFF2E7D32)) - - External ID shows the actual external user ID - -5. LOGIN USER button: - - Shows "LOGIN USER" when no user is logged in - - Shows "SWITCH USER" when a user is logged in - - Opens dialog with empty "External User Id" field - -6. LOGOUT USER button (only visible when a user is logged in) -``` - -### Prompt 2.2 - Push Section - -``` -Push Section: -- Section title: "Push" with info icon for tooltip -- Push Subscription ID display (readonly) -- Enabled toggle switch (controls optIn/optOut) - - Disabled when notification permission is NOT granted -- Notification permission is automatically requested when MainActivity loads -- PROMPT PUSH button: - - Only visible when notification permission is NOT granted (fallback if user denied) - - Requests notification permission when clicked - - Hidden once permission is granted -``` - -### Prompt 2.3 - Send Push Notification Section - -``` -Send Push Notification Section (placed right after Push Section): -- Section title: "Send Push Notification" with info icon for tooltip -- Three buttons: - 1. SIMPLE - sends basic notification with title/body - 2. WITH IMAGE - sends notification with big picture - (use https://media.onesignal.com/automated_push_templates/ratings_template.png) - 3. CUSTOM - opens dialog for custom title and body - -Tooltip should explain each button type. -``` - -### Prompt 2.4 - In-App Messaging Section - -``` -In-App Messaging Section (placed right after Send Push): -- Section title: "In-App Messaging" with info icon for tooltip -- Pause In-App Messages toggle switch: - - Label: "Pause In-App Messages" - - Description: "Toggle in-app message display" -``` - -### Prompt 2.5 - Send In-App Message Section - -``` -Send In-App Message Section (placed right after In-App Messaging): -- Section title: "Send In-App Message" with info icon for tooltip -- Four FULL-WIDTH buttons (not a grid): - 1. TOP BANNER - VerticalAlignTop icon, trigger: "iam_type" = "top_banner" - 2. BOTTOM BANNER - VerticalAlignBottom icon, trigger: "iam_type" = "bottom_banner" - 3. CENTER MODAL - CropSquare icon, trigger: "iam_type" = "center_modal" - 4. FULL SCREEN - Fullscreen icon, trigger: "iam_type" = "full_screen" -- Button styling: - - RED background color (#E9444E) - - WHITE text - - Type-specific icon on LEFT side only (no right side icon) - - Full width of the card - - Left-aligned text and icon content (not centered) - - UPPERCASE button text -- On click: adds trigger "iam_type" with the type's value and shows toast "Sent In-App Message: {type}" - -Tooltip should explain each IAM type. -``` - -### Prompt 2.6 - Aliases Section - -``` -Aliases Section (placed after Send In-App Message): -- Section title: "Aliases" with info icon for tooltip -- Compose list showing key-value pairs (read-only, no delete icons) -- Each item shows: Label | ID -- Filter out "external_id" and "onesignal_id" from display (these are special) -- "No Aliases Added" text when empty -- ADD button -> PairInputDialog with empty Label and ID fields (single add) -- ADD MULTIPLE button -> MultiPairInputDialog (dynamic rows, add/remove) -- No remove/delete functionality (aliases are add-only from the UI) -``` - -### Prompt 2.7 - Emails Section - -``` -Emails Section: -- Section title: "Emails" with info icon for tooltip -- Compose list showing email addresses -- Each item shows email with delete icon -- "No Emails Added" text when empty -- ADD EMAIL button -> dialog with empty email field -- Collapse behavior when >5 items: - - Show first 5 items - - Show "X more" text (clickable) - - Expand to show all when clicked -``` - -### Prompt 2.8 - SMS Section - -``` -SMS Section: -- Section title: "SMS" with info icon for tooltip -- Compose list showing phone numbers -- Each item shows phone number with delete icon -- "No SMS Added" text when empty -- ADD SMS button -> dialog with empty SMS field -- Collapse behavior when >5 items (same as Emails) -``` - -### Prompt 2.9 - Tags Section - -``` -Tags Section: -- Section title: "Tags" with info icon for tooltip -- Compose list showing key-value pairs -- Each item shows: Key | Value with delete icon -- "No Tags Added" text when empty -- ADD button -> PairInputDialog with empty Key and Value fields (single add) -- ADD MULTIPLE button -> MultiPairInputDialog (dynamic rows) -- REMOVE SELECTED button: - - Only visible when at least one tag exists - - Opens MultiSelectRemoveDialog with checkboxes -``` - -### Prompt 2.10 - Outcome Events Section - -``` -Outcome Events Section: -- Section title: "Outcome Events" with info icon for tooltip -- SEND OUTCOME button -> opens dialog with 3 radio options: - 1. Normal Outcome -> shows name input field - 2. Unique Outcome -> shows name input field - 3. Outcome with Value -> shows name and value (float) input fields -``` - -### Prompt 2.11 - Triggers Section (IN MEMORY ONLY) - -``` -Triggers Section: -- Section title: "Triggers" with info icon for tooltip -- Compose list showing key-value pairs -- Each item shows: Key | Value with delete icon -- "No Triggers Added" text when empty -- ADD button -> PairInputDialog with empty Key and Value fields (single add) -- ADD MULTIPLE button -> MultiPairInputDialog (dynamic rows) -- Two action buttons (only visible when triggers exist): - - REMOVE SELECTED -> MultiSelectRemoveDialog with checkboxes - - CLEAR ALL -> Removes all triggers at once - -IMPORTANT: Triggers are stored IN MEMORY ONLY during the app session. -- triggersList is a mutableListOf>() in MainViewModel -- Triggers are NOT persisted to SharedPreferences -- Triggers are cleared when the app is killed/restarted -- This is intentional - triggers are transient test data for IAM testing -``` - -### Prompt 2.12 - Track Event Section - -``` -Track Event Section: -- Section title: "Track Event" with info icon for tooltip -- TRACK EVENT button -> opens TrackEventDialog with: - - "Event Name" label + empty input field (required, shows error if empty on submit) - - "Properties (optional, JSON)" label + input field with placeholder hint {"key": "value"} - - If non-empty and not valid JSON, shows "Invalid JSON format" error on the field - - If valid JSON, parsed via JSONObject and converted to Map for the SDK call - - If empty, passes null - - TRACK button disabled until name is filled AND JSON is valid (or empty) -- Calls OneSignal.User.trackEvent(name, properties) -``` - -### Prompt 2.13 - Location Section - -``` -Location Section: -- Section title: "Location" with info icon for tooltip -- Location Shared toggle switch: - - Label: "Location Shared" - - Description: "Share device location with OneSignal" -- PROMPT LOCATION button -``` - -### Prompt 2.14 - Secondary Activity - -``` -Secondary Activity (launched by "Next Activity" button at bottom of main screen): -- Activity title: "Secondary Activity" -- Page content: centered text "Secondary Activity" using headlineMedium style -- Simple screen, no additional functionality needed -``` +- **Language**: Kotlin with Coroutines (`Dispatchers.IO`, `Dispatchers.Main`) +- **UI**: Jetpack Compose with Material3 +- **Architecture**: MVVM (`MainViewModel` with `LiveData`) +- **Build system**: Gradle Kotlin DSL with inline dependency versions (no `buildSrc`, so it works when included from the SDK project) +- **Product flavors**: Google FCM and Huawei HMS (matching existing OneSignalDemo setup) +- **Package name**: `com.onesignal.sdktest` (must match `google-services.json` and `agconnect-services.json`) +- **App bar**: `CenterAlignedTopAppBar` (Material3) --- -## Phase 3: View User API Integration +## Jetpack Compose Setup -### Prompt 3.1 - Data Loading Flow +`build.gradle.kts` (app): -``` -Loading indicator overlay: -- Full-screen semi-transparent overlay with centered spinner -- isLoading LiveData in MainViewModel -- Show/hide based on isLoading state -- IMPORTANT: Add 100ms delay after populating data before dismissing loading indicator - - This ensures UI has time to render - - Use kotlinx.coroutines.delay(100) after setting all LiveData values - -On cold start: -- Check if OneSignal.User.onesignalId is not null -- If exists: show loading -> call fetchUserDataFromApi() -> populate UI -> delay 100ms -> hide loading -- If null: just show empty state (no loading indicator) - -On login (LOGIN USER / SWITCH USER): -- Show loading indicator immediately -- Call OneSignal.login(externalUserId) -- Clear old user data (aliases, emails, sms, triggers) -- Wait for onUserStateChange callback -- onUserStateChange calls fetchUserDataFromApi() -- fetchUserDataFromApi() populates UI, delays 100ms, then hides loading - -On logout: -- Show loading indicator -- Call OneSignal.logout() -- Clear local lists (aliases, emails, sms, triggers) -- Hide loading indicator - -On onUserStateChange callback: -- Call fetchUserDataFromApi() to sync with server state -- Update UI with new data (aliases, tags, emails, sms) - -Note: REST API key is NOT required for fetchUser endpoint. +```kotlin +plugins { + id("org.jetbrains.kotlin.plugin.compose") version "2.2.0" +} + +buildFeatures { compose = true } ``` -### Prompt 3.2 - UserData Model +Dependencies (via BOM `2024.02.00`): -``` -data class UserData( - val aliases: Map, // From identity object (filter out external_id, onesignal_id) - val tags: Map, // From properties.tags object - val emails: List, // From subscriptions where type="Email" -> token - val smsNumbers: List, // From subscriptions where type="SMS" -> token - val externalId: String? // From identity.external_id -) -``` +- `composeUi`, `composeUiGraphics`, `composeUiToolingPreview` +- `composeMaterial3` +- `composeMaterialIconsExtended` (for IAM type icons) +- `composeRuntime`, `composeRuntimeLivedata` +- `activityCompose` +- `lifecycleViewModelCompose`, `lifecycleRuntimeCompose` --- -## Phase 4: Info Tooltips +## SDK Initialization (MainApplication.kt) -### Prompt 4.1 - Tooltip Content (Remote) +Register Android-specific listener interfaces: -``` -Tooltip content is fetched at runtime from the sdk-shared repo. Do NOT bundle a local copy. +- `IInAppMessageLifecycleListener` (`onWillDisplay`, `onDidDisplay`, `onWillDismiss`, `onDidDismiss`) +- `IInAppMessageClickListener` +- `INotificationClickListener` +- `INotificationLifecycleListener` (with `preventDefault()` for async display testing) +- `IUserStateObserver` (log when user state changes) -URL: -https://raw.githubusercontent.com/OneSignal/sdk-shared/main/demo/tooltip_content.json +Initialization order: -This file is maintained in the sdk-shared repo and shared across all platform demo apps. ``` - -### Prompt 4.2 - Tooltip Helper - +OneSignal.consentRequired = SharedPreferenceUtil.getCachedConsentRequired(context) +OneSignal.consentGiven = SharedPreferenceUtil.getUserPrivacyConsent(context) +OneSignal.initWithContext(this, appId) +OneSignal.InAppMessages.paused = SharedPreferenceUtil.getCachedInAppMessagingPausedStatus(context) +OneSignal.Location.isShared = SharedPreferenceUtil.getCachedLocationSharedStatus(context) ``` -Create TooltipHelper.kt: - -object TooltipHelper { - private var tooltips: Map = emptyMap() - private var initialized = false - - private const val TOOLTIP_URL = - "https://raw.githubusercontent.com/OneSignal/sdk-shared/main/demo/tooltip_content.json" - - fun init(context: Context) { - if (initialized) return - // IMPORTANT: Fetch on background thread to avoid blocking app startup - CoroutineScope(Dispatchers.IO).launch { - // Fetch tooltip_content.json from TOOLTIP_URL using HttpURLConnection - // Parse JSON into tooltips map - // On failure (no network, etc.), leave tooltips empty — tooltips are non-critical +SDK log capture: - withContext(Dispatchers.Main) { - // Update tooltips map on main thread - initialized = true - } - } - } - - fun getTooltip(key: String): TooltipData? +```kotlin +OneSignal.Debug.addLogListener { event -> + LogManager.log("SDK", event.entry, level) } +``` -data class TooltipData( - val title: String, - val description: String, - val options: List? = null -) +## ViewModel Observers (MainViewModel.kt) -data class TooltipOption( - val name: String, - val description: String -) -``` +- `IPushSubscriptionObserver` — react to push subscription changes +- `IPermissionObserver` — react to notification permission changes +- `IUserStateObserver` — call `fetchUserDataFromApi()` when user changes -### Prompt 4.3 - Tooltip UI Integration (Compose) +`loadInitialState()` reads from the SDK (not SharedPreferences): -``` -For each section, pass an onInfoClick callback to SectionCard: -- SectionCard has an optional info icon that calls onInfoClick when tapped -- In MainScreen, wire onInfoClick to show a TooltipDialog composable -- TooltipDialog displays title, description, and options (if present) - -Example in MainScreen.kt: -AliasesSection( - ..., - onInfoClick = { showTooltipDialog = "aliases" } -) - -showTooltipDialog?.let { key -> - val tooltip = TooltipHelper.getTooltip(key) - if (tooltip != null) { - TooltipDialog( - title = tooltip.title, - description = tooltip.description, - options = tooltip.options?.map { it.name to it.description }, - onDismiss = { showTooltipDialog = null } - ) - } -} -``` +- `_consentRequired` from `repository.getConsentRequired()` (reads `OneSignal.consentRequired`) +- `_privacyConsentGiven` from `repository.getPrivacyConsent()` (reads `OneSignal.consentGiven`) +- `_inAppMessagesPaused` from `repository.isInAppMessagesPaused()` (reads `OneSignal.InAppMessages.paused`) +- `_locationShared` from `repository.isLocationShared()` (reads `OneSignal.Location.isShared`) +- `_externalUserId` from `OneSignal.User.externalId` (empty string = no user logged in) +- `_appId` from `SharedPreferenceUtil` (app-level config, no SDK getter) --- -## Phase 5: Data Persistence & Initialization +## Data Persistence (SharedPreferenceUtil.kt) -### What IS Persisted (SharedPreferences) +Stored in `SharedPreferences`: -``` -SharedPreferenceUtil.kt stores: - OneSignal App ID - Consent required status - Privacy consent status - External user ID (for login state restoration) - Location shared status - In-app messaging paused status -``` - -### Initialization Flow - -``` -On app startup, state is restored in two layers: - -1. MainApplication.kt restores SDK state from SharedPreferences cache BEFORE init: - - OneSignal.consentRequired = SharedPreferenceUtil.getCachedConsentRequired(context) - - OneSignal.consentGiven = SharedPreferenceUtil.getUserPrivacyConsent(context) - - OneSignal.initWithContext(this, appId) - Then AFTER init, restores remaining SDK state: - - OneSignal.InAppMessages.paused = SharedPreferenceUtil.getCachedInAppMessagingPausedStatus(context) - - OneSignal.Location.isShared = SharedPreferenceUtil.getCachedLocationSharedStatus(context) - This ensures consent settings are in place before the SDK initializes. - -2. MainViewModel.loadInitialState() reads UI state from the SDK (not SharedPreferences): - - _consentRequired from repository.getConsentRequired() (reads OneSignal.consentRequired) - - _privacyConsentGiven from repository.getPrivacyConsent() (reads OneSignal.consentGiven) - - _inAppMessagesPaused from repository.isInAppMessagesPaused() (reads OneSignal.InAppMessages.paused) - - _locationShared from repository.isLocationShared() (reads OneSignal.Location.isShared) - - _externalUserId from OneSignal.User.externalId (empty string means no user logged in) - - _appId from SharedPreferenceUtil (app-level config, no SDK getter) - -This two-layer approach ensures: -- The SDK is configured with the user's last preferences before anything else runs -- The ViewModel reads the SDK's actual state as the source of truth for the UI -- The UI always reflects what the SDK reports, not stale cache values -``` - -### What is NOT Persisted (In-Memory Only) - -``` -MainViewModel holds in memory: -- triggersList: MutableList> - - Triggers are session-only - - Cleared on app restart - - Used for testing IAM trigger conditions - -- aliasesList: - - Populated from REST API on each session start - - When user adds alias locally, added to list immediately (SDK syncs async) - - Fetched fresh via fetchUserDataFromApi() on login/app start - -- emailsList, smsNumbersList: - - Populated from REST API on each session - - Not cached locally - - Fetched fresh via fetchUserDataFromApi() - -- tagsList: - - Can be read from SDK via getTags() - - Also fetched from API for consistency -``` --- -## Phase 6: Testing Values (Appium Compatibility) - -``` -All dialog input fields should be EMPTY by default. -The test automation framework (Appium) will enter these values: - -- Login Dialog: External User Id = "test" -- Add Alias Dialog: Key = "Test", Value = "Value" -- Add Multiple Aliases Dialog: Key = "Test", Value = "Value" (first row; supports multiple rows) -- Add Email Dialog: Email = "test@onesignal.com" -- Add SMS Dialog: SMS = "123-456-5678" -- Add Tag Dialog: Key = "Test", Value = "Value" -- Add Multiple Tags Dialog: Key = "Test", Value = "Value" (first row; supports multiple rows) -- Add Trigger Dialog: Key = "trigger_key", Value = "trigger_value" -- Add Multiple Triggers Dialog: Key = "trigger_key", Value = "trigger_value" (first row; supports multiple rows) -- Outcome Dialog: Name = "test_outcome", Value = "1.5" -- Track Event Dialog: Name = "test_event", Properties = "{\"key\": \"value\"}" -- Custom Notification Dialog: Title = "Test Title", Body = "Test Body" -``` - ---- - -## Phase 7: Important Implementation Details - -### Alias Management - -``` -Aliases are managed with a hybrid approach: - -1. On app start/login: Fetched from REST API via fetchUserDataFromApi() -2. When user adds alias locally: - - Call OneSignal.User.addAlias(label, id) - syncs to server async - - Immediately add to local aliasesList (don't wait for API) - - This ensures instant UI feedback while SDK syncs in background -3. On next app launch: Fresh data from API includes the synced alias -``` +## Android-Specific Implementation Notes ### Notification Permission -``` -Notification permission is automatically requested when MainActivity loads: -- Call viewModel.promptPush() at end of onCreate() -- This ensures prompt appears after user sees the app UI -- PROMPT PUSH button remains as fallback if user initially denied -- Button hidden once permission is granted -- Keep Push "Enabled" toggle disabled until permission is granted -``` - ---- +Auto-request via `LaunchedEffect(Unit) { viewModel.promptPush() }` in `MainScreen` composable. -## Phase 8: Jetpack Compose Architecture +### Loading Indicator -### Prompt 8.1 - Compose Setup +Use `kotlinx.coroutines.delay(100)` after setting all `LiveData` values before dismissing the loader. -``` -Enable Jetpack Compose in the project: - -build.gradle.kts (app): -- buildFeatures { compose = true } -- composeOptions { kotlinCompilerExtensionVersion = "1.5.10" } - -Dependencies (via BOM): -- composeBom = "2024.02.00" -- composeUi, composeUiGraphics, composeUiToolingPreview -- composeMaterial3 -- composeMaterialIconsExtended (for IAM type icons) -- composeRuntime, composeRuntimeLivedata -- activityCompose -- lifecycleViewModelCompose, lifecycleRuntimeCompose -``` +### JSON Parsing -### Prompt 8.2 - Reusable Components +Use `JSONObject` to parse Track Event properties and convert to `Map`. -``` -Create reusable Compose components in ui/components/: - -SectionCard.kt: -- Card with title text and optional info icon -- Column content slot -- OnInfoClick callback for tooltips - -ToggleRow.kt: -- Label, optional description, Switch -- Horizontal layout with space between - -ActionButton.kt: -- PrimaryButton (filled, primary color background) -- DestructiveButton (outlined, red accent) -- Full-width buttons for consistent styling - -ListComponents.kt: -- PairItem (key-value with delete icon) -- SingleItem (single value with delete icon) -- EmptyState (centered "No items" text) -- CollapsibleSingleList (shows 5, expandable) -- PairList (simple list of pairs) - -LoadingOverlay.kt: -- Semi-transparent full-screen overlay -- Centered CircularProgressIndicator -- Shown via isLoading state - -Dialogs.kt: -- SingleInputDialog (one text field) -- PairInputDialog (key-value fields, single pair) -- MultiPairInputDialog (dynamic rows, add/remove, batch submit) -- MultiSelectRemoveDialog (checkboxes for batch remove) -- LoginDialog, OutcomeDialog, TrackEventDialog -- CustomNotificationDialog, TooltipDialog -``` +### Toast Messages -### Prompt 8.3 - Reusable Multi-Pair Dialog (Compose) +- `MainViewModel` exposes `toastMessage: LiveData` +- `MainActivity` observes and shows Android `Toast` +- `LaunchedEffect` triggers on `toastMessage` change -``` -Tags, Aliases, and Triggers all share a reusable MultiPairInputDialog composable -for adding multiple key-value pairs at once. - -Behavior: -- Dialog opens with one empty key-value row -- "Add Row" button below the rows adds another empty row -- Each row has a remove button (hidden when only one row exists) -- "Add All" button is disabled until ALL key and value fields in every row are filled -- Validation runs on every text change and after row add/remove -- On "Add All" press, all rows are collected and submitted as a batch -- Batch operations use SDK bulk APIs (addAliases, addTags, addTriggers) - -Used by: -- ADD MULTIPLE button (Aliases section) -> calls viewModel.addAliases(pairs) -- ADD MULTIPLE button (Tags section) -> calls viewModel.addTags(pairs) -- ADD MULTIPLE button (Triggers section) -> calls viewModel.addTriggers(pairs) -``` - -### Prompt 8.4 - Reusable Remove Multi Dialog (Compose) - -``` -Aliases, Tags, and Triggers share a reusable MultiSelectRemoveDialog composable -for selectively removing items from the current list. - -Behavior: -- Accepts the current list of items as List> -- Renders one Checkbox per item on the left with just the key as the label (not "key: value") -- User can check 0, 1, or more items -- "Remove (N)" button shows count of selected items, disabled when none selected -- On confirm, checked items' keys are collected as Collection and passed to the callback - -Used by: -- REMOVE SELECTED button (Tags section) -> calls viewModel.removeSelectedTags(keys) -- REMOVE SELECTED button (Triggers section) -> calls viewModel.removeSelectedTriggers(keys) -``` - -### Prompt 8.5 - Theme - -``` -Create OneSignal theme in ui/theme/Theme.kt: - -Colors: -- OneSignalRed = #E54B4D (primary) -- OneSignalGreen = #34A853 (success) -- OneSignalGreenLight = #E6F4EA (success background) -- LightBackground = #F8F9FA -- CardBackground = White -- DividerColor = #E8EAED -- WarningBackground = #FFF8E1 - -OneSignalTheme composable: -- MaterialTheme with LightColorScheme -- Custom Typography with SemiBold weights -- Custom Shapes with rounded corners (8/12/16/24dp) -- Primary = OneSignalRed -- Surface variants for cards -``` +### LogManager -### Prompt 8.6 - Log View (Appium-Ready) - -``` -Add collapsible log view at top of screen for debugging and Appium testing. - -Files: -- util/LogManager.kt - Thread-safe pass-through logger -- ui/components/LogView.kt - Compose UI with test tags - -LogManager Features: -- Pass-through to Android logcat AND UI display +- Pass-through to Android `logcat` AND UI display - Thread-safe (posts to main thread for Compose state) -- Captures SDK logs via OneSignal.Debug.addLogListener -- API: LogManager.d/i/w/e(tag, message) mimics android.util.Log - -LogView Features: -- Collapsible header (default expanded) -- 5-line height (~100dp) -- Color-coded by level (Debug=blue, Info=green, Warn=amber, Error=red) -- Clear button -- Auto-scroll to newest - -Appium Test Tags: -| Tag | Description | -|-----|-------------| -| log_view_container | Main container | -| log_view_header | Clickable expand/collapse | -| log_view_count | Shows "(N)" log count | -| log_view_clear_button | Clear all logs | -| log_view_list | Scrollable LazyColumn | -| log_view_empty | "No logs yet" state | -| log_entry_N | Each log row (N=index) | -| log_entry_N_timestamp | Timestamp text | -| log_entry_N_level | D/I/W/E indicator | -| log_entry_N_message | Log message content | - -SDK Log Integration (MainApplication): -OneSignal.Debug.addLogListener { event -> - LogManager.log("SDK", event.entry, level) -} +- API: `LogManager.d/i/w/e(tag, message)` mimics `android.util.Log` -Appium Example: -# Verify a log message exists -log_msg = driver.find_element(By.XPATH, "//*[@resource-id='log_entry_0_message']") -assert "Notification sent" in log_msg.text +### Tooltip Helper -# Scroll logs -log_list = driver.find_element(By.XPATH, "//*[@resource-id='log_view_list']") -driver.execute_script("mobile: scroll", {"element": log_list, "direction": "down"}) -``` +- `TooltipHelper` is a Kotlin `object` (singleton) +- Fetches on `CoroutineScope(Dispatchers.IO)` to avoid blocking startup +- Uses `HttpURLConnection` for the network request +- `init(context: Context)` takes Android `Context` -### Prompt 8.7 - Toast Messages +### WITH SOUND Notification -``` -All user actions should display toast messages: - -- Login: "Logged in as: {userId}" -- Logout: "Logged out" -- Add alias: "Alias added: {label}" -- Add multiple aliases: "{count} alias(es) added" -- Similar patterns for tags, triggers, emails, SMS -- Notifications: "Notification sent: {type}" or "Failed to send notification" -- In-App Messages: "Sent In-App Message: {type}" -- Outcomes: "Outcome sent: {name}" -- Events: "Event tracked: {name}" -- Location: "Location sharing enabled/disabled" -- Push: "Push enabled/disabled" - -Implementation: -- MainViewModel has toastMessage: LiveData -- MainActivity observes and shows Android Toast -- LaunchedEffect triggers on toastMessage change -- All toast messages are also logged via LogManager.info() -``` +- `vine_boom.wav` placed in `res/raw/` +- `NotificationType.WITH_SOUND` sends with `android_channel_id` in the REST API payload +- The OneSignal SDK handles channel creation when the notification is received --- -## Key Files Structure +## File Structure ``` examples/demo/ @@ -848,38 +151,40 @@ examples/demo/ │ ├── src/main/ │ │ ├── java/com/onesignal/sdktest/ │ │ │ ├── application/ -│ │ │ │ └── MainApplication.kt # SDK init, log listener, observers +│ │ │ │ └── MainApplication.kt │ │ │ ├── data/ │ │ │ │ ├── model/ -│ │ │ │ │ ├── NotificationType.kt # With bigPicture URL -│ │ │ │ │ └── InAppMessageType.kt # With Material icons +│ │ │ │ │ ├── NotificationType.kt +│ │ │ │ │ └── InAppMessageType.kt │ │ │ │ ├── network/ -│ │ │ │ │ └── OneSignalService.kt # REST API client +│ │ │ │ │ └── OneSignalService.kt │ │ │ │ └── repository/ │ │ │ │ └── OneSignalRepository.kt │ │ │ ├── ui/ -│ │ │ │ ├── components/ # Reusable Compose components -│ │ │ │ │ ├── SectionCard.kt # Card with title and info icon -│ │ │ │ │ ├── ToggleRow.kt # Label + Switch -│ │ │ │ │ ├── ActionButton.kt # Primary/Destructive buttons -│ │ │ │ │ ├── ListComponents.kt # PairList, SingleList, EmptyState -│ │ │ │ │ ├── LoadingOverlay.kt # Full-screen loading spinner -│ │ │ │ │ ├── LogView.kt # Collapsible log viewer (Appium-ready) -│ │ │ │ │ └── Dialogs.kt # All dialog composables +│ │ │ │ ├── components/ +│ │ │ │ │ ├── SectionCard.kt +│ │ │ │ │ ├── ToggleRow.kt +│ │ │ │ │ ├── ActionButton.kt +│ │ │ │ │ ├── ListComponents.kt +│ │ │ │ │ ├── LoadingOverlay.kt +│ │ │ │ │ ├── LogView.kt +│ │ │ │ │ └── Dialogs.kt │ │ │ │ ├── main/ -│ │ │ │ │ ├── MainActivity.kt # ComponentActivity with setContent -│ │ │ │ │ ├── MainScreen.kt # Main Compose screen (includes LogView) -│ │ │ │ │ ├── Sections.kt # Individual section composables -│ │ │ │ │ └── MainViewModel.kt # With batch operations +│ │ │ │ │ ├── MainActivity.kt +│ │ │ │ │ ├── MainScreen.kt +│ │ │ │ │ ├── Sections.kt +│ │ │ │ │ └── MainViewModel.kt │ │ │ │ ├── secondary/ -│ │ │ │ │ └── SecondaryActivity.kt # Simple Compose screen +│ │ │ │ │ └── SecondaryActivity.kt │ │ │ │ └── theme/ -│ │ │ │ └── Theme.kt # OneSignal Material3 theme +│ │ │ │ └── Theme.kt │ │ │ └── util/ │ │ │ ├── SharedPreferenceUtil.kt -│ │ │ ├── LogManager.kt # Thread-safe pass-through logger -│ │ │ └── TooltipHelper.kt # Fetches tooltips from remote URL +│ │ │ ├── LogManager.kt +│ │ │ └── TooltipHelper.kt │ │ └── res/ +│ │ ├── raw/ +│ │ │ └── vine_boom.wav │ │ └── values/ │ │ ├── strings.xml │ │ ├── colors.xml @@ -889,62 +194,19 @@ examples/demo/ │ └── HmsMessageServiceAppLevel.kt ├── google-services.json ├── agconnect-services.json -└── build_app_prompt.md (this file) +└── build.md (this file) ``` -Note: - -- All UI is Jetpack Compose (no XML layouts) -- Tooltip content is fetched from remote URL (not bundled locally) -- LogView at top of screen displays SDK and app logs for debugging/Appium testing - --- ## Configuration -### strings.xml Placeholders +### strings.xml ```xml - -YOUR_APP_ID_HERE +77e32082-ea27-42e3-a898-c72e141824ef ``` -Note: REST API key is NOT required for the fetchUser endpoint. - ### Package Name -The package name MUST be `com.onesignal.sdktest` to work with the existing: - -- `google-services.json` (Firebase configuration) -- `agconnect-services.json` (Huawei configuration) - -If you change the package name, you must also update these files with your own Firebase/Huawei project configuration. - ---- - -## Summary - -This app demonstrates all OneSignal Android SDK features: - -- User management (login/logout, aliases with batch add) -- Push notifications (subscription, sending with images, auto-permission prompt) -- Email and SMS subscriptions -- Tags for segmentation (batch add/remove support) -- Triggers for in-app message targeting (in-memory only, batch operations) -- Outcomes for conversion tracking -- Event tracking with JSON properties validation -- In-app messages (display testing with type-specific icons) -- Location sharing -- Privacy consent management - -The app is designed to be: - -1. **Testable** - Empty dialogs for Appium automation -2. **Comprehensive** - All SDK features demonstrated -3. **Clean** - MVVM architecture with Jetpack Compose UI -4. **Cross-platform ready** - Tooltip content in JSON for sharing across wrappers -5. **Session-based triggers** - Triggers stored in memory only, cleared on restart -6. **Responsive UI** - Loading indicator with delay to ensure UI populates before dismissing -7. **Performant** - Tooltip JSON loaded on background thread -8. **Modern UI** - Material3 theming with reusable Compose components -9. **Batch Operations** - Add multiple items at once, select and remove multiple items +The package name MUST be `com.onesignal.sdktest` to work with the existing `google-services.json` (Firebase) and `agconnect-services.json` (Huawei). Changing it requires updating those files with your own project configuration. diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/application/MainApplication.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/application/MainApplication.kt index 7df8fec38..cf12bafca 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/application/MainApplication.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/application/MainApplication.kt @@ -13,12 +13,10 @@ import com.onesignal.inAppMessages.IInAppMessageDidDisplayEvent import com.onesignal.inAppMessages.IInAppMessageLifecycleListener import com.onesignal.inAppMessages.IInAppMessageWillDismissEvent import com.onesignal.inAppMessages.IInAppMessageWillDisplayEvent -import com.onesignal.notifications.IDisplayableNotification import com.onesignal.notifications.INotificationClickEvent import com.onesignal.notifications.INotificationClickListener import com.onesignal.notifications.INotificationLifecycleListener import com.onesignal.notifications.INotificationWillDisplayEvent -import com.onesignal.sdktest.R import com.onesignal.sdktest.data.network.OneSignalService import com.onesignal.sdktest.util.SharedPreferenceUtil import com.onesignal.sdktest.util.TooltipHelper @@ -42,7 +40,6 @@ class MainApplication : MultiDexApplication() { OneSignal.Debug.logLevel = LogLevel.VERBOSE - // Add SDK log listener BEFORE init to capture all SDK logs in UI OneSignal.Debug.addLogListener { event -> val level = when (event.level) { LogLevel.VERBOSE, LogLevel.DEBUG -> AppLogLevel.DEBUG @@ -54,33 +51,19 @@ class MainApplication : MultiDexApplication() { LogManager.log("SDK", event.entry, level) } - // Get or set the OneSignal App ID - var appId = SharedPreferenceUtil.getOneSignalAppId(this) - if (appId == null) { - appId = getString(R.string.onesignal_app_id) - SharedPreferenceUtil.cacheOneSignalAppId(this, appId) - } + val appId = SharedPreferenceUtil.getOneSignalAppId(this) - // Initialize OneSignal Service with app ID and REST API key OneSignalService.setAppId(appId) - - // Initialize tooltip helper TooltipHelper.init(this) - // Set consent required before init (must be set before initWithContext) + // Consent must be set before initWithContext OneSignal.consentRequired = SharedPreferenceUtil.getCachedConsentRequired(this) OneSignal.consentGiven = SharedPreferenceUtil.getUserPrivacyConsent(this) - // Initialize OneSignal on main thread (required) - // Crash handler + ANR detector are initialized early inside initWithContext OneSignal.initWithContext(this, appId) - LogManager.i(TAG, "OneSignal init completed (crash handler, ANR detector, and logging active)") + LogManager.i(TAG, "OneSignal init completed") - // Set up all OneSignal listeners setupOneSignalListeners() - - // Note: Notification permission is automatically requested when MainActivity loads. - // This ensures the prompt appears after the user sees the app UI. } private fun setupOneSignalListeners() { @@ -118,11 +101,9 @@ class MainApplication : MultiDexApplication() { override fun onWillDisplay(event: INotificationWillDisplayEvent) { LogManager.d(TAG, "INotificationLifecycleListener.onWillDisplay fired with event: $event") - val notification: IDisplayableNotification = event.notification - - // Prevent OneSignal from displaying the notification immediately on return. - // Spin up a new thread to mimic some asynchronous behavior. + // Demonstrate async notification display: prevent default, delay, then show event.preventDefault() + val notification = event.notification Thread { try { Thread.sleep(SLEEP_TIME_TO_MIMIC_ASYNC_OPERATION) @@ -143,4 +124,5 @@ class MainApplication : MultiDexApplication() { OneSignal.InAppMessages.paused = SharedPreferenceUtil.getCachedInAppMessagingPausedStatus(this) OneSignal.Location.isShared = SharedPreferenceUtil.getCachedLocationSharedStatus(this) } + } diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/data/model/NotificationType.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/data/model/NotificationType.kt index b6ee86bf2..6eaeaa74c 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/data/model/NotificationType.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/data/model/NotificationType.kt @@ -1,14 +1,12 @@ package com.onesignal.sdktest.data.model -/** - * Enum representing different types of push notifications that can be sent. - */ enum class NotificationType( val title: String, val notificationTitle: String, val notificationBody: String, val bigPicture: String? = null, - val largeIcon: String? = null + val largeIcon: String? = null, + val androidChannelId: String? = null ) { SIMPLE( title = "Simple", @@ -20,5 +18,11 @@ enum class NotificationType( notificationTitle = "Image Notification", notificationBody = "This notification includes an image", bigPicture = "https://media.onesignal.com/automated_push_templates/ratings_template.png" + ), + WITH_SOUND( + title = "With Sound", + notificationTitle = "Sound Notification", + notificationBody = "This notification plays a custom sound", + androidChannelId = "b3b015d9-c050-4042-8548-dcc34aa44aa4" ) } diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/data/network/OneSignalService.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/data/network/OneSignalService.kt index 8982aefc8..a9285e66b 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/data/network/OneSignalService.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/data/network/OneSignalService.kt @@ -9,13 +9,6 @@ import org.json.JSONObject import java.net.HttpURLConnection import java.net.URL -/** - * OneSignal API service for testing purposes. - * Provides methods to send notifications and fetch user data via the REST API. - * - * Note: This approach is for testing purposes only. In production, notifications - * should be sent from your backend server. - */ object OneSignalService { private const val TAG = "OneSignalService" @@ -29,147 +22,91 @@ object OneSignalService { } fun getAppId(): String = appId - - /** - * Send a notification to this device. - */ - suspend fun sendNotification(type: NotificationType): Boolean = withContext(Dispatchers.IO) { + + private fun getSubscriptionIdIfOptedIn(): String? { val subscription = OneSignal.User.pushSubscription - if (!subscription.optedIn) { LogManager.w(TAG, "Cannot send notification - user not opted in") - return@withContext false + return null } - - val subscriptionId = subscription.id - if (subscriptionId.isNullOrEmpty()) { + val id = subscription.id + if (id.isNullOrEmpty()) { LogManager.w(TAG, "Cannot send notification - no subscription ID") - return@withContext false + return null } + return id + } - try { - val notificationJson = JSONObject().apply { - put("app_id", appId) - put("include_subscription_ids", org.json.JSONArray().put(subscriptionId)) - put("headings", JSONObject().put("en", type.notificationTitle)) - put("contents", JSONObject().put("en", type.notificationBody)) - put("android_group", type.title) - put("android_led_color", "FF595CF2") - put("android_accent_color", "FF595CF2") - // Add large icon if available - type.largeIcon?.let { - put("large_icon", it) - LogManager.d(TAG, "Adding large_icon: $it") - } - // Add big picture if available - type.bigPicture?.let { - put("big_picture", it) - LogManager.d(TAG, "Adding big_picture: $it") - } - } - - LogManager.d(TAG, "Sending notification: ${notificationJson.toString(2)}") - LogManager.d(TAG, "Request URL: $ONESIGNAL_API_URL") + private fun postJson(url: String, json: JSONObject): Pair { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + useCaches = false + connectTimeout = 30000 + readTimeout = 30000 + setRequestProperty("Accept", "application/vnd.onesignal.v1+json") + setRequestProperty("Content-Type", "application/json; charset=UTF-8") + requestMethod = "POST" + doOutput = true + doInput = true + } + val outputBytes = json.toString().toByteArray(Charsets.UTF_8) + connection.setFixedLengthStreamingMode(outputBytes.size) + connection.outputStream.write(outputBytes) + val code = connection.responseCode + val body = if (code in 200..299) { + connection.inputStream.bufferedReader().use { it.readText() } + } else { + connection.errorStream?.bufferedReader()?.use { it.readText() } ?: "Unknown error" + } + return code to body + } - val connection = (URL(ONESIGNAL_API_URL).openConnection() as HttpURLConnection).apply { - useCaches = false - connectTimeout = 30000 - readTimeout = 30000 - setRequestProperty("Accept", "application/vnd.onesignal.v1+json") - setRequestProperty("Content-Type", "application/json; charset=UTF-8") - requestMethod = "POST" - doOutput = true - doInput = true - } - - val outputBytes = notificationJson.toString().toByteArray(Charsets.UTF_8) - connection.setFixedLengthStreamingMode(outputBytes.size) - connection.outputStream.write(outputBytes) - - val responseCode = connection.responseCode - - if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_ACCEPTED || responseCode == HttpURLConnection.HTTP_CREATED) { - val response = connection.inputStream.bufferedReader().use { it.readText() } - LogManager.d(TAG, "Notification sent successfully: $response") - return@withContext true + private suspend fun sendNotificationPayload(payload: JSONObject, label: String): Boolean = withContext(Dispatchers.IO) { + try { + LogManager.d(TAG, "Sending $label: ${payload.toString(2)}") + val (code, body) = postJson(ONESIGNAL_API_URL, payload) + if (code in 200..299) { + LogManager.d(TAG, "$label sent successfully: $body") + true } else { - val errorResponse = connection.errorStream?.bufferedReader()?.use { it.readText() } ?: "Unknown error" - LogManager.e(TAG, "Failed to send notification (HTTP $responseCode): $errorResponse") - LogManager.e(TAG, "Request body was: ${notificationJson.toString()}") - return@withContext false + LogManager.e(TAG, "Failed to send $label (HTTP $code): $body") + false } } catch (e: Exception) { - LogManager.e(TAG, "Error sending notification", e) - return@withContext false + LogManager.e(TAG, "Error sending $label", e) + false } } - /** - * Send a custom notification with title and body. - */ - suspend fun sendCustomNotification(title: String, body: String): Boolean = withContext(Dispatchers.IO) { - val subscription = OneSignal.User.pushSubscription - - if (!subscription.optedIn) { - LogManager.w(TAG, "Cannot send notification - user not opted in") - return@withContext false + suspend fun sendNotification(type: NotificationType): Boolean { + val subscriptionId = getSubscriptionIdIfOptedIn() ?: return false + val payload = JSONObject().apply { + put("app_id", appId) + put("include_subscription_ids", org.json.JSONArray().put(subscriptionId)) + put("headings", JSONObject().put("en", type.notificationTitle)) + put("contents", JSONObject().put("en", type.notificationBody)) + put("android_group", type.title) + put("android_led_color", "FF595CF2") + put("android_accent_color", "FF595CF2") + type.largeIcon?.let { put("large_icon", it) } + type.bigPicture?.let { put("big_picture", it) } + type.androidChannelId?.let { put("android_channel_id", it) } } - - val subscriptionId = subscription.id - if (subscriptionId.isNullOrEmpty()) { - LogManager.w(TAG, "Cannot send notification - no subscription ID") - return@withContext false - } - - try { - val notificationJson = JSONObject().apply { - put("app_id", appId) - put("include_subscription_ids", org.json.JSONArray().put(subscriptionId)) - put("headings", JSONObject().put("en", title)) - put("contents", JSONObject().put("en", body)) - put("android_led_color", "FF595CF2") - put("android_accent_color", "FF595CF2") - } - - val connection = (URL(ONESIGNAL_API_URL).openConnection() as HttpURLConnection).apply { - useCaches = false - connectTimeout = 30000 - readTimeout = 30000 - setRequestProperty("Accept", "application/vnd.onesignal.v1+json") - setRequestProperty("Content-Type", "application/json; charset=UTF-8") - requestMethod = "POST" - doOutput = true - doInput = true - } - - val outputBytes = notificationJson.toString().toByteArray(Charsets.UTF_8) - connection.setFixedLengthStreamingMode(outputBytes.size) - connection.outputStream.write(outputBytes) - - val responseCode = connection.responseCode - - if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_ACCEPTED || responseCode == HttpURLConnection.HTTP_CREATED) { - val response = connection.inputStream.bufferedReader().use { it.readText() } - LogManager.d(TAG, "Custom notification sent successfully: $response") - return@withContext true - } else { - val errorResponse = connection.errorStream?.bufferedReader()?.use { it.readText() } ?: "Unknown error" - LogManager.e(TAG, "Failed to send custom notification (HTTP $responseCode): $errorResponse") - return@withContext false - } - } catch (e: Exception) { - LogManager.e(TAG, "Error sending custom notification", e) - return@withContext false + return sendNotificationPayload(payload, "notification") + } + + suspend fun sendCustomNotification(title: String, body: String): Boolean { + val subscriptionId = getSubscriptionIdIfOptedIn() ?: return false + val payload = JSONObject().apply { + put("app_id", appId) + put("include_subscription_ids", org.json.JSONArray().put(subscriptionId)) + put("headings", JSONObject().put("en", title)) + put("contents", JSONObject().put("en", body)) + put("android_led_color", "FF595CF2") + put("android_accent_color", "FF595CF2") } + return sendNotificationPayload(payload, "custom notification") } - /** - * Fetch user data from OneSignal API. - * Note: This endpoint does not require authentication. - * - * @param onesignalId The OneSignal user ID - * @return UserData object containing aliases, tags, emails, and SMS numbers, or null on error - */ suspend fun fetchUser(onesignalId: String): UserData? = withContext(Dispatchers.IO) { if (onesignalId.isEmpty()) { LogManager.w(TAG, "Cannot fetch user - onesignalId is empty") @@ -197,31 +134,25 @@ object OneSignalService { if (responseCode == HttpURLConnection.HTTP_OK) { val response = connection.inputStream.bufferedReader().use { it.readText() } - LogManager.d(TAG, "User data fetched successfully, parsing response...") - try { - val userData = parseUserResponse(response) - LogManager.d(TAG, "Parsed user data: aliases=${userData.aliases.size}, tags=${userData.tags.size}, emails=${userData.emails.size}, sms=${userData.smsNumbers.size}") - return@withContext userData - } catch (e: Exception) { - LogManager.e(TAG, "Error parsing user response", e) - return@withContext null - } + LogManager.d(TAG, "User data fetched successfully") + parseUserResponse(response) } else { val errorResponse = connection.errorStream?.bufferedReader()?.use { it.readText() } ?: "Unknown error" LogManager.e(TAG, "Failed to fetch user (HTTP $responseCode): $errorResponse") - return@withContext null + null } } catch (e: Exception) { LogManager.e(TAG, "Error fetching user", e) - return@withContext null + null } } private fun parseUserResponse(json: String): UserData { val jsonObject = JSONObject(json) - // Parse aliases from identity object (filter out external_id and onesignal_id) val aliases = mutableMapOf() + val externalId: String? + if (jsonObject.has("identity")) { val identity = jsonObject.getJSONObject("identity") identity.keys().forEach { key -> @@ -229,15 +160,11 @@ object OneSignalService { aliases[key] = identity.getString(key) } } + externalId = if (identity.has("external_id")) identity.getString("external_id") else null + } else { + externalId = null } - // Parse external_id separately - val externalId = if (jsonObject.has("identity")) { - val identity = jsonObject.getJSONObject("identity") - if (identity.has("external_id")) identity.getString("external_id") else null - } else null - - // Parse tags from properties object val tags = mutableMapOf() if (jsonObject.has("properties")) { val properties = jsonObject.getJSONObject("properties") @@ -249,7 +176,6 @@ object OneSignalService { } } - // Parse subscriptions for emails and SMS val emails = mutableListOf() val smsNumbers = mutableListOf() if (jsonObject.has("subscriptions")) { @@ -276,9 +202,6 @@ object OneSignalService { } } -/** - * Data class representing user data fetched from the OneSignal API. - */ data class UserData( val aliases: Map, val tags: Map, diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt index 70696e54f..d1de4f128 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt @@ -1,243 +1,205 @@ package com.onesignal.sdktest.data.repository -import android.util.Log import com.onesignal.OneSignal import com.onesignal.sdktest.data.model.NotificationType import com.onesignal.sdktest.data.network.OneSignalService import com.onesignal.sdktest.data.network.UserData +import com.onesignal.sdktest.util.LogManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -/** - * Repository for all OneSignal SDK operations. - * All methods are suspend functions to be called from coroutines on background threads. - */ class OneSignalRepository { companion object { private const val TAG = "OneSignalRepository" } - // User operations suspend fun loginUser(externalUserId: String) = withContext(Dispatchers.IO) { - Log.d(TAG, "Logging in user with externalUserId: $externalUserId") + LogManager.d(TAG, "Logging in user: $externalUserId") OneSignal.login(externalUserId) - Log.d(TAG, "Logged in user with onesignalId: ${OneSignal.User.onesignalId}") } suspend fun logoutUser() = withContext(Dispatchers.IO) { - Log.d(TAG, "Logging out user") + LogManager.d(TAG, "Logging out user") OneSignal.logout() } - // Alias operations fun addAlias(label: String, id: String) { - Log.d(TAG, "Adding alias: $label -> $id") + LogManager.d(TAG, "Adding alias: $label -> $id") OneSignal.User.addAlias(label, id) } fun addAliases(aliases: Map) { - Log.d(TAG, "Adding aliases: $aliases") + LogManager.d(TAG, "Adding aliases: $aliases") OneSignal.User.addAliases(aliases) } fun removeAlias(label: String) { - Log.d(TAG, "Removing alias: $label") + LogManager.d(TAG, "Removing alias: $label") OneSignal.User.removeAlias(label) } fun removeAliases(labels: Collection) { - Log.d(TAG, "Removing aliases: $labels") if (labels.isNotEmpty()) { + LogManager.d(TAG, "Removing aliases: $labels") OneSignal.User.removeAliases(labels) } } - // Email operations fun addEmail(email: String) { - Log.d(TAG, "Adding email: $email") + LogManager.d(TAG, "Adding email: $email") OneSignal.User.addEmail(email) } fun removeEmail(email: String) { - Log.d(TAG, "Removing email: $email") + LogManager.d(TAG, "Removing email: $email") OneSignal.User.removeEmail(email) } - // SMS operations fun addSms(smsNumber: String) { - Log.d(TAG, "Adding SMS: $smsNumber") + LogManager.d(TAG, "Adding SMS: $smsNumber") OneSignal.User.addSms(smsNumber) } fun removeSms(smsNumber: String) { - Log.d(TAG, "Removing SMS: $smsNumber") + LogManager.d(TAG, "Removing SMS: $smsNumber") OneSignal.User.removeSms(smsNumber) } - // Tag operations fun addTag(key: String, value: String) { - Log.d(TAG, "Adding tag: $key -> $value") + LogManager.d(TAG, "Adding tag: $key -> $value") OneSignal.User.addTag(key, value) } fun addTags(tags: Map) { - Log.d(TAG, "Adding tags: $tags") + LogManager.d(TAG, "Adding tags: $tags") OneSignal.User.addTags(tags) } fun removeTag(key: String) { - Log.d(TAG, "Removing tag: $key") + LogManager.d(TAG, "Removing tag: $key") OneSignal.User.removeTag(key) } fun removeTags(keys: Collection) { - Log.d(TAG, "Removing tags: $keys") if (keys.isNotEmpty()) { + LogManager.d(TAG, "Removing tags: $keys") OneSignal.User.removeTags(keys) } } - fun getTags(): Map { - return OneSignal.User.getTags() - } + fun getTags(): Map = OneSignal.User.getTags() - // Trigger operations fun addTrigger(key: String, value: String) { - Log.d(TAG, "Adding trigger: $key -> $value") + LogManager.d(TAG, "Adding trigger: $key -> $value") OneSignal.InAppMessages.addTrigger(key, value) } fun addTriggers(triggers: Map) { - Log.d(TAG, "Adding triggers: $triggers") + LogManager.d(TAG, "Adding triggers: $triggers") OneSignal.InAppMessages.addTriggers(triggers) } fun removeTrigger(key: String) { - Log.d(TAG, "Removing trigger: $key") + LogManager.d(TAG, "Removing trigger: $key") OneSignal.InAppMessages.removeTrigger(key) } fun clearTriggers(keys: Collection) { - Log.d(TAG, "Clearing triggers: $keys") if (keys.isNotEmpty()) { + LogManager.d(TAG, "Clearing triggers: $keys") OneSignal.InAppMessages.removeTriggers(keys) } } - // Outcome operations fun sendOutcome(name: String) { - Log.d(TAG, "Sending outcome: $name") + LogManager.d(TAG, "Sending outcome: $name") OneSignal.Session.addOutcome(name) } fun sendUniqueOutcome(name: String) { - Log.d(TAG, "Sending unique outcome: $name") + LogManager.d(TAG, "Sending unique outcome: $name") OneSignal.Session.addUniqueOutcome(name) } fun sendOutcomeWithValue(name: String, value: Float) { - Log.d(TAG, "Sending outcome with value: $name -> $value") + LogManager.d(TAG, "Sending outcome with value: $name -> $value") OneSignal.Session.addOutcomeWithValue(name, value) } - // Track Event fun trackEvent(name: String, properties: Map?) { - Log.d(TAG, "Tracking event: $name with properties: $properties") + LogManager.d(TAG, "Tracking event: $name with properties: $properties") OneSignal.User.trackEvent(name, properties) } - // Push subscription - fun getPushSubscriptionId(): String? { - return OneSignal.User.pushSubscription.id - } + fun getPushSubscriptionId(): String? = OneSignal.User.pushSubscription.id + fun isPushEnabled(): Boolean = OneSignal.User.pushSubscription.optedIn - fun isPushEnabled(): Boolean { - return OneSignal.User.pushSubscription.optedIn + fun setPushEnabled(enabled: Boolean) { + LogManager.d(TAG, "Setting push enabled: $enabled") + if (enabled) OneSignal.User.pushSubscription.optIn() + else OneSignal.User.pushSubscription.optOut() } - fun setPushEnabled(enabled: Boolean) { - Log.d(TAG, "Setting push enabled: $enabled") - if (enabled) { - OneSignal.User.pushSubscription.optIn() - } else { - OneSignal.User.pushSubscription.optOut() - } + suspend fun promptPushPermission() { + LogManager.d(TAG, "Prompting for push permission") + OneSignal.Notifications.requestPermission(true) } - // In-App Messaging - fun isInAppMessagesPaused(): Boolean { - return OneSignal.InAppMessages.paused + fun hasNotificationPermission(): Boolean = OneSignal.Notifications.permission + + fun clearAllNotifications() { + LogManager.d(TAG, "Clearing all notifications") + OneSignal.Notifications.clearAllNotifications() } + fun isInAppMessagesPaused(): Boolean = OneSignal.InAppMessages.paused + fun setInAppMessagesPaused(paused: Boolean) { - Log.d(TAG, "Setting in-app messages paused: $paused") + LogManager.d(TAG, "Setting in-app messages paused: $paused") OneSignal.InAppMessages.paused = paused } - // Location - fun isLocationShared(): Boolean { - return OneSignal.Location.isShared - } + fun isLocationShared(): Boolean = OneSignal.Location.isShared fun setLocationShared(shared: Boolean) { - Log.d(TAG, "Setting location shared: $shared") + LogManager.d(TAG, "Setting location shared: $shared") OneSignal.Location.isShared = shared } suspend fun promptLocation() = withContext(Dispatchers.IO) { - Log.d(TAG, "Prompting for location permission") + LogManager.d(TAG, "Prompting for location permission") OneSignal.Location.requestPermission() } - // Notifications - suspend fun promptPushPermission() = withContext(Dispatchers.IO) { - Log.d(TAG, "Prompting for push permission") - OneSignal.Notifications.requestPermission(true) - } - - fun hasNotificationPermission(): Boolean { - return OneSignal.Notifications.permission - } - - // Send notifications - suspend fun sendNotification(type: NotificationType): Boolean { - Log.d(TAG, "Sending notification: ${type.title}") - return OneSignalService.sendNotification(type) - } - - suspend fun sendCustomNotification(title: String, body: String): Boolean { - Log.d(TAG, "Sending custom notification: $title") - return OneSignalService.sendCustomNotification(title, body) - } - - // Privacy consent fun setConsentRequired(required: Boolean) { - Log.d(TAG, "Setting consent required: $required") + LogManager.d(TAG, "Setting consent required: $required") OneSignal.consentRequired = required } - fun getConsentRequired(): Boolean { - return OneSignal.consentRequired - } + fun getConsentRequired(): Boolean = OneSignal.consentRequired fun setPrivacyConsent(granted: Boolean) { - Log.d(TAG, "Setting privacy consent: $granted") + LogManager.d(TAG, "Setting privacy consent: $granted") OneSignal.consentGiven = granted } - fun getPrivacyConsent(): Boolean { - return OneSignal.consentGiven + fun getPrivacyConsent(): Boolean = OneSignal.consentGiven + + fun getOneSignalId(): String? = OneSignal.User.onesignalId + + suspend fun sendNotification(type: NotificationType): Boolean { + LogManager.d(TAG, "Sending notification: ${type.title}") + return OneSignalService.sendNotification(type) } - // OneSignal ID - fun getOneSignalId(): String? { - return OneSignal.User.onesignalId + suspend fun sendCustomNotification(title: String, body: String): Boolean { + LogManager.d(TAG, "Sending custom notification: $title") + return OneSignalService.sendCustomNotification(title, body) } - // Fetch user data from API - suspend fun fetchUser(onesignalId: String): UserData? = withContext(Dispatchers.IO) { - Log.d(TAG, "Fetching user data for: $onesignalId") - OneSignalService.fetchUser(onesignalId) + suspend fun fetchUser(onesignalId: String): UserData? { + LogManager.d(TAG, "Fetching user data for: $onesignalId") + return OneSignalService.fetchUser(onesignalId) } } diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt index a9c02609b..af2ddb534 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt @@ -114,7 +114,7 @@ fun MainScreen(viewModel: MainViewModel) { ) Spacer(modifier = Modifier.width(10.dp)) Text( - "Sample App", + "Android", color = Color.White.copy(alpha = 0.7f), fontSize = 14.sp, fontWeight = FontWeight.Normal @@ -177,7 +177,9 @@ fun MainScreen(viewModel: MainViewModel) { SendPushSection( onSimpleClick = { viewModel.sendNotification(NotificationType.SIMPLE) }, onImageClick = { viewModel.sendNotification(NotificationType.WITH_IMAGE) }, + onSoundClick = { viewModel.sendNotification(NotificationType.WITH_SOUND) }, onCustomClick = { showCustomNotificationDialog = true }, + onClearAllClick = { viewModel.clearAllNotifications() }, onInfoClick = { showTooltipDialog = "sendPushNotification" } ) diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt index e65af736d..484721daa 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt @@ -99,7 +99,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I OneSignal.User.pushSubscription.addObserver(this) OneSignal.Notifications.addPermissionObserver(this) OneSignal.User.addObserver(this) - android.util.Log.d("MainViewModel", "init: observers registered, current onesignalId=${OneSignal.User.onesignalId}") LogManager.debug("OneSignal ID: ${OneSignal.User.onesignalId ?: "not set"}") } @@ -110,7 +109,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I // IUserStateObserver - called when user changes (login/logout) override fun onUserStateChange(state: UserChangedState) { - android.util.Log.d("MainViewModel", "onUserStateChange fired: ${state.current.onesignalId}") + LogManager.debug("onUserStateChange: ${state.current.onesignalId}") viewModelScope.launch(Dispatchers.Main) { loadExistingAliases() loadExistingTags() @@ -122,7 +121,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I private fun loadInitialState() { val context = getApplication() - _appId.value = SharedPreferenceUtil.getOneSignalAppId(context) ?: "" + _appId.value = SharedPreferenceUtil.getOneSignalAppId(context) _consentRequired.value = repository.getConsentRequired() _privacyConsentGiven.value = repository.getPrivacyConsent() _inAppMessagesPaused.value = repository.isInAppMessagesPaused() @@ -177,13 +176,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I _externalUserId.value = userData.externalId SharedPreferenceUtil.cacheUserExternalUserId(getApplication(), userData.externalId) } - - kotlinx.coroutines.delay(100) } _isLoading.value = false } } catch (e: Exception) { - android.util.Log.e("MainViewModel", "Error fetching user data", e) withContext(Dispatchers.Main) { logError("Failed to fetch user data: ${e.message}") _isLoading.value = false @@ -235,7 +231,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I refreshTriggers() loadExistingTags() refreshPushSubscription() - // Loading stays on; onUserStateChange will call fetchUserDataFromApi() to dismiss it + fetchUserDataFromApi() } } } @@ -531,12 +527,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I } fun promptPush() { - viewModelScope.launch(Dispatchers.Main) { - OneSignal.Notifications.requestPermission(true) + viewModelScope.launch { + repository.promptPushPermission() refreshPushSubscription() } } + fun clearAllNotifications() { + repository.clearAllNotifications() + showToast("All notifications cleared") + } + // In-App Messages fun setInAppMessagesPaused(paused: Boolean) { repository.setInAppMessagesPaused(paused) @@ -622,5 +623,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I override fun onCleared() { super.onCleared() OneSignal.User.pushSubscription.removeObserver(this) + OneSignal.Notifications.removePermissionObserver(this) + OneSignal.User.removeObserver(this) } } diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt index f672d322c..438aa37fc 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt @@ -267,13 +267,17 @@ fun PushSection( fun SendPushSection( onSimpleClick: () -> Unit, onImageClick: () -> Unit, + onSoundClick: () -> Unit, onCustomClick: () -> Unit, + onClearAllClick: () -> Unit, onInfoClick: () -> Unit ) { SectionCard(title = "Send Push Notification", showCard = false, onInfoClick = onInfoClick) { PrimaryButton(text = "SIMPLE", onClick = onSimpleClick) PrimaryButton(text = "WITH IMAGE", onClick = onImageClick) + PrimaryButton(text = "WITH SOUND", onClick = onSoundClick) PrimaryButton(text = "CUSTOM", onClick = onCustomClick) + DestructiveButton(text = "CLEAR ALL", onClick = onClearAllClick) } } diff --git a/examples/demo/app/src/main/java/com/onesignal/sdktest/util/SharedPreferenceUtil.kt b/examples/demo/app/src/main/java/com/onesignal/sdktest/util/SharedPreferenceUtil.kt index f3b93dfb0..b465f7d15 100644 --- a/examples/demo/app/src/main/java/com/onesignal/sdktest/util/SharedPreferenceUtil.kt +++ b/examples/demo/app/src/main/java/com/onesignal/sdktest/util/SharedPreferenceUtil.kt @@ -17,13 +17,9 @@ object SharedPreferenceUtil { return context.getSharedPreferences(APP_SHARED_PREFS, Context.MODE_PRIVATE) } - fun exists(context: Context, key: String): Boolean { - return getSharedPreference(context).contains(key) - } - - fun getOneSignalAppId(context: Context): String? { + fun getOneSignalAppId(context: Context): String { val defaultAppId = "77e32082-ea27-42e3-a898-c72e141824ef" - return getSharedPreference(context).getString(OS_APP_ID_SHARED_PREF, defaultAppId) + return getSharedPreference(context).getString(OS_APP_ID_SHARED_PREF, defaultAppId) ?: defaultAppId } fun getUserPrivacyConsent(context: Context): Boolean { diff --git a/examples/demo/app/src/main/res/raw/vine_boom.wav b/examples/demo/app/src/main/res/raw/vine_boom.wav new file mode 100644 index 000000000..626bd5cc5 Binary files /dev/null and b/examples/demo/app/src/main/res/raw/vine_boom.wav differ diff --git a/examples/demo/app/src/main/res/values/strings.xml b/examples/demo/app/src/main/res/values/strings.xml index f230d4b29..2c90b13b6 100644 --- a/examples/demo/app/src/main/res/values/strings.xml +++ b/examples/demo/app/src/main/res/values/strings.xml @@ -98,7 +98,9 @@ Send Push Notification SIMPLE NOTIFICATION NOTIFICATION WITH IMAGE + NOTIFICATION WITH SOUND CUSTOM NOTIFICATION + CLEAR ALL Notification Title Notification Body diff --git a/examples/demo/app/src/main/res/values/styles.xml b/examples/demo/app/src/main/res/values/styles.xml index bfda9fbac..432ab60af 100644 --- a/examples/demo/app/src/main/res/values/styles.xml +++ b/examples/demo/app/src/main/res/values/styles.xml @@ -3,6 +3,6 @@